From c0ebc7cf2cdec4592edeeb10158e6fe30115c397 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 25 Sep 2026 09:05:32 +0200 Subject: [PATCH] chore: remove the Device Support package pilot and its format This is FTW's driver repository. The package pilot gave five drivers a third version line beside the catalog DRIVER version and the manifest version, under package ids that cannot contain "_" where catalog ids do. Nothing published or consumed it: the release workflow never called the packager, CI built unsigned candidates only, and no release carries a package or index asset. Removed: - packages/v1 (recipes for esphome-dsmr, foxess_h3_smart, pixii, sdm630 and sungrow, the separate pixii and sungrow target files, and the sungrow PILOT.md); - tools/driver_package.py, tools/build_candidate.py, tools/derive_sungrow_ftw_observe.py, make package-driver and the packages CI job; - the package-v1 and migration specs, the driver inventory and Blixt reference specs, and every sourceful.driver-* JSON schema, with the tests that validated them; - package fields and the pilot's own override entries from the support status, the always-empty "Stable package" column from the catalog page, and the now unused jsonschema dependency; - package and Device Support text from AGENTS.md, README.md, CONTRIBUTING.md, SECURITY.md, SOURCE_IMPORT.md and the specs. spec/control-runtime-v2.md keeps only FTW Core's v2 command interface and spec/signing.md only the FTW channel. Tests that held a rule against both a catalog driver and its package target now hold it against the catalog driver. No driver Lua, manifest or version changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 Signed-off-by: Fredrik Ahlgren --- .github/CODEOWNERS | 3 - .github/PULL_REQUEST_TEMPLATE.md | 12 +- .github/workflows/validate.yml | 19 - AGENTS.md | 31 +- CHANGELOG.md | 4 + CONTRIBUTING.md | 11 +- Makefile | 8 +- README.md | 38 +- SECURITY.md | 5 +- SOURCE_IMPORT.md | 5 +- SUPPORT_STATUS.md | 356 +++--- drivers/INTEGRATION_OVERVIEW.md | 2 +- drivers/lua/GUIDELINES.md | 4 +- drivers/lua/README.md | 6 +- .../tests/lua_harness/test_all_drivers.lua | 374 +----- .../tests/test_absent_optional_registers.py | 22 +- drivers/tests/test_esphome_dsmr_aliases.py | 22 +- drivers/tests/test_no_undefined_locals.py | 19 +- drivers/tests/test_sungrow_model_family.py | 149 +-- packages/v1/esphome-dsmr/package-source.json | 106 -- .../v1/foxess_h3_smart/package-source.json | 172 --- packages/v1/pixii/package-source.json | 109 -- packages/v1/pixii/targets/ftw.lua | 376 ------ packages/v1/sdm630/package-source.json | 120 -- packages/v1/sungrow/PILOT.md | 51 - packages/v1/sungrow/package-source.json | 101 -- packages/v1/sungrow/targets/ftw-observe.lua | 424 ------- packages/v1/sungrow/targets/ftw.lua | 470 ------- pyproject.toml | 1 - spec/blixt-lua-reference-architecture.md | 52 - spec/control-runtime-v2.md | 83 +- spec/driver-inventory-v1.md | 43 - spec/driver-package-v1-migration.md | 42 - spec/driver-package-v1.md | 176 --- ...ceful.driver-command-result.v1.schema.json | 42 - .../sourceful.driver-command.v1.schema.json | 36 - ...ceful.driver-index-envelope.v1.schema.json | 35 - .../sourceful.driver-index.v1.schema.json | 53 - .../sourceful.driver-inventory.v1.schema.json | 105 -- ...ful.driver-package-envelope.v1.schema.json | 43 - ...ceful.driver-package-source.v1.schema.json | 84 -- .../sourceful.driver-package.v1.schema.json | 536 -------- spec/signing.md | 53 +- spec/support-status.md | 19 +- support-status-overrides.json | 42 +- support-status.json | 654 +--------- tests/test_driver_package.py | 440 ------- tests/test_driver_runtime_contracts.py | 142 --- tests/test_ftw_legacy_parity.py | 54 +- tests/test_host_api_profile.py | 5 +- tools/build_candidate.py | 60 - tools/bump_driver.py | 30 +- tools/check_public_boundary.py | 9 - tools/derive_sungrow_ftw_observe.py | 130 -- tools/driver_package.py | 1084 ----------------- tools/generate_site.py | 5 +- tools/generate_support_status.py | 22 +- tools/import_ftw_baseline.py | 5 +- tools/new_driver.py | 90 +- uv.lock | 184 --- 60 files changed, 360 insertions(+), 7018 deletions(-) delete mode 100644 packages/v1/esphome-dsmr/package-source.json delete mode 100644 packages/v1/foxess_h3_smart/package-source.json delete mode 100644 packages/v1/pixii/package-source.json delete mode 100644 packages/v1/pixii/targets/ftw.lua delete mode 100644 packages/v1/sdm630/package-source.json delete mode 100644 packages/v1/sungrow/PILOT.md delete mode 100644 packages/v1/sungrow/package-source.json delete mode 100644 packages/v1/sungrow/targets/ftw-observe.lua delete mode 100644 packages/v1/sungrow/targets/ftw.lua delete mode 100644 spec/blixt-lua-reference-architecture.md delete mode 100644 spec/driver-inventory-v1.md delete mode 100644 spec/driver-package-v1-migration.md delete mode 100644 spec/driver-package-v1.md delete mode 100644 spec/schemas/sourceful.driver-command-result.v1.schema.json delete mode 100644 spec/schemas/sourceful.driver-command.v1.schema.json delete mode 100644 spec/schemas/sourceful.driver-index-envelope.v1.schema.json delete mode 100644 spec/schemas/sourceful.driver-index.v1.schema.json delete mode 100644 spec/schemas/sourceful.driver-inventory.v1.schema.json delete mode 100644 spec/schemas/sourceful.driver-package-envelope.v1.schema.json delete mode 100644 spec/schemas/sourceful.driver-package-source.v1.schema.json delete mode 100644 spec/schemas/sourceful.driver-package.v1.schema.json delete mode 100644 tests/test_driver_package.py delete mode 100644 tests/test_driver_runtime_contracts.py delete mode 100644 tools/build_candidate.py delete mode 100644 tools/derive_sungrow_ftw_observe.py delete mode 100644 tools/driver_package.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6a671e3..d460a55 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,8 +1,5 @@ * @srcfl/core -/packages/ @srcfl/core /spec/ @srcfl/core /spec/control-runtime-v2.md @srcfl/core -/spec/schemas/sourceful.driver-command.v1.schema.json @srcfl/core -/spec/schemas/sourceful.driver-command-result.v1.schema.json @srcfl/core /drivers/lua/ @srcfl/core /.github/ @srcfl/core diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index cf391ed..02b771b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -23,22 +23,16 @@ See CONTRIBUTING.md. --> - [ ] Cached telemetry becomes stale instead of being re-emitted as fresh. - [ ] No credentials, serial numbers, private addresses or site data are included. -## Package or promotion evidence +## Control evidence -Complete this part when a package version, target, control contract or release -candidate changes. Use `not applicable` for a driver-only read-only change. +Complete this part when a control path changes. Use `not applicable` for a +read-only change. -- Public source SHA: -- Package ID and new version: -- Target matrix: -- Material and candidate artifact SHA-256: - HIL evidence or `required`: -- Control enabled: `false` unless all control gates have passed - [ ] Control changes include default-mode, lease-expiry and HIL evidence. ## Checks - [ ] Commits include `Signed-off-by`. - [ ] `make test-driver ID=` -- [ ] `make package-driver ID= TARGET=` - [ ] `make check` diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 5a9792f..8730469 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -85,25 +85,6 @@ jobs: - run: git diff --exit-code -- support-status.json SUPPORT_STATUS.md - run: uv run --no-sync pytest -q drivers/tests tests - packages: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - with: - fetch-depth: 1 - - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 - with: - python-version: "3.12" - - run: uv sync --frozen --extra package --extra dev - - name: Build each unsigned package candidate - run: | - for source in packages/v1/*/package-source.json; do - id="$(basename "$(dirname "$source")")" - uv run --no-sync python tools/build_candidate.py \ - --id "$id" \ - --output-dir "/tmp/device-driver-candidates/$id" - done - # The sign-off certifies authored content, so this asks it of the commits that # carry some, and skips merge commits. # diff --git a/AGENTS.md b/AGENTS.md index 587f53b..90ad98e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,8 @@ # Device driver repository guide -This public repository is the only editable source for shared Sourceful device -drivers and the main driver source for FTW. It publishes FTW's signed driver -channel from reviewed commits. Device Support may later consume a locked commit -for other products or support levels, but it does not own a second source tree. +This public repository is FTW's driver repository and the only editable source +for its device drivers. It publishes FTW's signed driver channel from reviewed +commits. ## FTW product direction @@ -30,23 +29,20 @@ and control acceptance gates below. - Keep signing keys out of source, logs and build output. - Public pull-request builds stay unsigned. The release workflow signs the FTW channel only after a reviewed change reaches `main`. -- A catalog or package build never grants activation or control authority. +- A catalog build never grants activation or control authority. - New drivers start read-only. - Control needs a safe default mode, bounded leases, structured results and HIL acceptance for every target host. ## Where a driver change has to land -A driver can exist in three places. Fixing one and leaving the others is how a +A driver can exist in two places. Fixing one and leaving the other is how a fixed bug comes back. 1. **`drivers/lua/.lua`** — the catalog driver. This is what the signed - channel publishes and what FTW bundles. Every fix starts here. -2. **`packages/v1//targets/*.lua`** — a separate file, not generated from - the catalog driver. Only some drivers have one. A package target carries its - own version line and can drift from the catalog copy without any check - noticing. If the driver you are fixing has one, fix both. -3. **FTW's `drivers/`** — a recovery snapshot, generated from this repository + channel publishes and what FTW bundles. Every fix starts here, and it is + the only copy of a driver in this repository. +2. **FTW's `drivers/`** — a recovery snapshot, generated from this repository at the commit pinned in FTW's `drivers/BUNDLED_SOURCE.json`. Never edit a driver there; FTW's own CI rejects the drift. But note the reverse: merging here does **not** reach that snapshot. Someone has to move the pin and run @@ -54,9 +50,10 @@ fixed bug comes back. offline still runs the old driver. Pixii's flap on register 40288 is the worked example. It was fixed in #16, -survived in the package target, was reverted in the catalog driver by #27, and -reached customer hardware a second time. See the entries for **pixii** 2.1.1 -and **solaredge_legacy** 0.3.1 in `CHANGELOG.md`. +survived in a separate package-target copy, was reverted in the catalog driver +by #27, and reached customer hardware a second time. See the entries for +**pixii** 2.1.1 and **solaredge_legacy** 0.3.1 in `CHANGELOG.md`. Those copies +have since been removed. ### Editing a driver that came from FTW @@ -82,9 +79,6 @@ rule may be right and the driver wrong, or the check itself may be wrong. caught there rather than by a wrong value on a customer's site. A document behind a login cannot be watched; reference it in a driver comment instead. See `docs/WRITING-A-DRIVER.md`. -- Keep Lua compatible with every runtime declared in the package recipe. -- Package id, version, read-only state and target metadata must match the Lua - `DRIVER` block. ## Checks @@ -92,7 +86,6 @@ Run the narrow driver command while editing, then the full check: ```bash make test-driver ID=example -make package-driver ID=example TARGET=ftw-core make check ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c5306..9f552bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ Driver versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html ## [Unreleased] +### Removed + +- **The Device Support package pilot and its format.** This is FTW's driver repository, and the pilot gave each packaged driver a third version line beside the catalog `DRIVER` version and the manifest version, under a package id that could not contain `_` where the catalog id does. Nothing published or consumed it: the release workflow never called the packager, CI built unsigned candidates only, and no release carries a package or index asset. Removed: `packages/v1/` (recipes for esphome-dsmr, foxess_h3_smart, pixii, sdm630 and sungrow, and the separate `pixii` and `sungrow` target files), `tools/driver_package.py`, `tools/build_candidate.py`, `tools/derive_sungrow_ftw_observe.py`, `make package-driver`, the `packages` CI job, the package-v1 specs and every `sourceful.driver-*` JSON schema, the driver inventory and Blixt reference specs, `package_id`, `candidate_package_version`, `stable_package_version` and `control_enabled` from `support-status.json`, and the four `support-status-overrides.json` entries (pixii, goodwe, sdm630, sungrow) that described the pilot's own package targets and candidate versions. The catalog page loses its always-empty "Stable package" column and those notes. `spec/control-runtime-v2.md` keeps only FTW Core's v2 command interface, and `spec/signing.md` only the FTW channel. Tests that ran a rule against both a catalog driver and its package target now run it against the catalog driver only. No driver source or version changed. A package can be rebuilt from the catalog driver the day one is needed; git history keeps the pilot at 489c937 + ### Added - **nibe_local** 1.2.0 — the driver's first write path, and deliberately its only one: the pump's native **Solar PV surplus feed** ([srcfl/ftw#537](https://github.com/srcfl/ftw/issues/537)). The S-series was built to take a live "available solar power" number from NIBE's Modbus accessory (registers 2107/2109) and soak the surplus into heating and hot water using owner-tuned offsets; FTW now acts as that accessory. Control-by-hint: the pump's firmware decides what to do with the number, so a wrong value degrades to wasted comfort, never to unsafe operation. Off by default and triple-gated — host `capabilities.http.allow_write`, driver `write.solar_pv: true` with a mandatory `write.max_w` clamp ceiling, and the owner-side enable on the pump itself. The pump's timeout for a silently stopped feed is undocumented, so the driver does not lean on it: a dead-man's switch clears the feed when commands stop, `driver_default_mode` clears it on watchdog/stale-meter/stop, and a startup sweep clears a feed a crashed run left behind — and the sweep stays armed even when a config mistake (missing `max_w`) refuses new writes. All of that runs only while FTW runs, so the driver header documents the decommission step (turn 2107 off, or set the API read-only in menu 7.5.15) for the day it does not. The write API's most dangerous habit is covered too: the Local REST API rejects writes *inside an HTTP 200* ("error: read only value"), which the driver surfaces as an actionable error naming the installer menu (7.5.15) instead of reporting success. Requires `host.http_patch` from the FTW core; on older cores the driver states so and stays read-only. The `DRIVER` block declares the path as `write_capabilities = { "solar_pv" }`, which is what lets a host offer a switch for it: FTW's Settings screen renders the feed's controls only for a driver that states it has a write path, so the alternative was an owner hand-editing two keys in `config.yaml` to use the feature at all diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c05ec87..d98e488 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,13 +42,12 @@ git checkout -b add-example-meter make new-driver ID=example PROTOCOL=modbus KIND=meter ``` -The generated package is read-only and targets FTW and Blixt through the shared -Lua 5.1 source profile. Edit the generated files rather than creating a second -manifest elsewhere. +The generated driver is read-only and comes with its manifest. Edit the +generated files rather than creating a second manifest elsewhere. Every driver must: -- declare `DRIVER` metadata whose id and version match its package; +- declare `DRIVER` metadata whose id and version match its manifest; - implement `driver_init`, `driver_poll`, `driver_cleanup` and a safe `driver_default_mode` when control is later added; - translate vendor signs at the driver boundary; @@ -140,13 +139,9 @@ make history ```bash make bootstrap make test-driver ID=example -make package-driver ID=example TARGET=ftw-core make check ``` -The package command creates an unsigned candidate under `.artifacts/`. It does -not grant release or signing rights. - The FTW release build turns each source file into a signed, read-only Lua artifact. The channel includes the full catalog and checks every generated artifact against the FTW v1 lifecycle and host API. A merged source change diff --git a/Makefile b/Makefile index 30e1890..6e3feff 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,10 @@ ID ?= PROTOCOL ?= modbus KIND ?= meter -TARGET ?= ftw-core -ARTIFACT_DIR ?= .artifacts/$(ID) LEVEL ?= patch -.PHONY: bootstrap new-driver test-driver package-driver check boundary \ +.PHONY: bootstrap new-driver test-driver check boundary \ refused-write-report absent-register-report \ sync-manifests bump-driver history ftw-baseline ftw-baseline-report \ host-api site watch-upstream-docs @@ -56,10 +54,6 @@ test-driver: uv run --frozen --extra package --extra dev python tools/validate_manifest.py "manifests/$(ID).yaml" uv run --frozen --extra package --extra dev pytest -q drivers/tests -k "$(ID)" -package-driver: - test -n "$(ID)" - uv run --frozen --extra package --extra dev python tools/build_candidate.py --id "$(ID)" --target "$(TARGET)" --output-dir "$(ARTIFACT_DIR)" - boundary: uv run --frozen --extra package --extra dev python tools/check_public_boundary.py diff --git a/README.md b/README.md index 818c306..18cf5f2 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,9 @@ # Sourceful Device Drivers -This public repository is the source of truth for Sourceful device driver -code, package metadata, compatibility contracts and tests. It is also FTW's -main driver source. FTW installs signed, content-addressed release assets from -this repository. It never runs raw code from `main`. - -Device Support may later consume an exact public commit to build packages for -other products or a higher support level. It does not own a second editable -copy of the source and is not FTW's normal driver source. +This public repository is FTW's driver repository: the source of truth for its +device driver code, catalog metadata and tests. FTW installs signed, +content-addressed release assets from this repository. It never runs raw code +from `main`. ## Browse the catalog @@ -59,9 +55,7 @@ New drivers start with telemetry only. Control support needs a later, separate review with a safe default mode, a bounded command lease, structured results and supervised hardware-in-the-loop evidence. -Read [CONTRIBUTING.md](CONTRIBUTING.md) and -[spec/driver-package-v1.md](spec/driver-package-v1.md) before changing a -package contract. +Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. ## Scope @@ -84,10 +78,6 @@ host provides, which `tools/host_api_check.py` enforces. Converting drivers to a single spelling was tried and abandoned: it changed 196 lines across 36 field-proven drivers without changing what any of them does. -Zap is built on a separate track that compiles from this source. Its -constraints do not shape the drivers here, and it is not a target in these -package recipes. - What a driver may call is defined in [spec/host-api-profile.json](spec/host-api-profile.json) and enforced by `make check`. A function outside the profile is not available, whichever host it was tested against. @@ -100,13 +90,12 @@ integrity, never hardware coverage. This repository contains only public source and validation code: -- `drivers/lua` — shared Lua driver source; +- `drivers/lua` — Lua driver source; - `manifests` — public catalog metadata and tested models; -- `packages/v1` — signed-package build recipes and host adapters; -- `spec` — package, inventory and command contracts; -- `drivers/tests` and `tests` — driver and package tests; +- `spec` — driver, host API, manifest and signing contracts; +- `drivers/tests` and `tests` — driver and tooling tests; - `ftw-channel.json` — the rules for FTW's signed, read-only channel; -- `tools` — local validation, FTW release builds and unsigned package builds. +- `tools` — local validation and FTW release builds. Private keys, credentials, cloud roles and service code stay outside this repository. A pull request can produce unsigned test output only. GitHub @@ -138,14 +127,9 @@ uv run python tools/ftw_download_stats.py GitHub counts asset downloads, not unique users or active installs. -The separate package-v1 work remains available for Blixt and later Device -Support use. It can consume the same public commit without changing FTW's -default source. - The catalog is not an install claim. See [SUPPORT_STATUS.md](SUPPORT_STATUS.md) -for source, target conformance, signed beta, HIL, stable and legacy parity per -driver and target. Nova and fleet inventory use the same driver, package and -target identities from [support-status.json](support-status.json). +for target conformance, signed beta, HIL and legacy parity per driver and +target. ## License diff --git a/SECURITY.md b/SECURITY.md index cc3bcdb..f3d61b8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,6 +6,5 @@ advisory flow for this repository. Never include credentials, signing material, private addresses, device serial numbers or site telemetry in an issue, pull request, fixture or log. -The public build creates unsigned candidates. Only the private Device Support -release service holds release credentials and signs packages from reviewed, -locked commits. +Pull-request builds are unsigned. Only the release workflow on `main` signs +FTW's driver channel, with credentials held as GitHub Actions secrets. diff --git a/SOURCE_IMPORT.md b/SOURCE_IMPORT.md index aa32233..26a1989 100644 --- a/SOURCE_IMPORT.md +++ b/SOURCE_IMPORT.md @@ -14,8 +14,7 @@ for Ferroamp DC2 V2X field names. A focused host-contract test pins that change; no Ferroamp package recipe or release is part of this cutover, and physical HIL remains required before any signed Ferroamp artifact. -From this import onward, this public repository is the editable source. The -private service consumes a locked commit and must reject local source drift. +From this import onward, this public repository is the editable source. ## FTW bundled drivers @@ -25,7 +24,7 @@ hash, FTW driver id and version. `make check` verifies those hashes, so a baseline cannot be edited by hand. A baseline is a record, not a driver. Nothing under `baselines` reaches the -catalog, a package recipe or the signed channel, and every entry stays +catalog or the signed channel, and every entry stays `live_activation: blocked`. Re-import after a change in FTW: diff --git a/SUPPORT_STATUS.md b/SUPPORT_STATUS.md index 7a239ed..66c82cc 100644 --- a/SUPPORT_STATUS.md +++ b/SUPPORT_STATUS.md @@ -2,181 +2,181 @@ Catalog source is not proof that a target can install or run a driver. -| Driver | Catalog | Target | Conformance | Candidate | Signed beta | HIL | Stable | Legacy parity | Control | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 50-125k-svk-ac-slew | 0.2.4 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| 50-125k-svk-ac-slew | 0.2.4 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| 50-125k-svk-slew | 0.1.12 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| 50-125k-svk-slew | 0.1.12 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| 50-125k-svk | 0.2.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| 50-125k-svk | 0.2.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| abb_meter | 1.0.6 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| abb_meter | 1.0.6 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| abb_terra | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| abb_terra | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| acrel | 1.0.4 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| acrel | 1.0.4 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| acuvim | 0.4.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| acuvim | 0.4.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| alfen | 1.0.4 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| alfen | 1.0.4 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| alphaess | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| alphaess | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ambibox | 1.2.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ambibox | 1.2.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ambibox_v2x | 1.1.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ambibox_v2x | 1.1.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| atmoce | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| atmoce | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| carlo_gavazzi | 1.0.5 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| carlo_gavazzi | 1.0.5 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| chint | 1.0.5 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| chint | 1.0.5 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| circutor | 1.0.5 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| circutor | 1.0.5 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ctek | 0.3.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ctek | 0.3.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ctek_hybrid | 0.3.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ctek_hybrid | 0.3.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ctek_v2 | 0.3.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ctek_v2 | 0.3.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| deye-svk | 0.2.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| deye-svk | 0.2.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| deye | 2.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| deye | 2.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| easee | 1.0.4 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| easee | 1.0.4 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| easee_cloud | 1.3.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| easee_cloud | 1.3.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| esphome-dsmr | 1.0.3 | ftw-core | not_assessed | 1.0.2 | — | not_recorded | — | not_assessed | no | -| esphome-dsmr | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| esphome_dsmr | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| esphome_dsmr | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| etrel | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| etrel | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ferroamp | 2.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ferroamp | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ferroamp_dc2_v2x | 2.1.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ferroamp_dc2_v2x | 2.1.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ferroamp_modbus | 2.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| ferroamp_modbus | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| foxess_h3_smart | 0.9.5 | ftw-core | not_assessed | 0.9.5 | — | not_recorded | — | not_assessed | yes | -| foxess_h3_smart | 0.9.5 | blixt-l1 | not_assessed | 0.9.5 | — | not_recorded | — | not_assessed | yes | -| fronius | 2.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| fronius | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| fronius_api | 1.0.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| fronius_api | 1.0.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| fronius_smart_meter | 2.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| fronius_smart_meter | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| goe | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| goe | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| goe_http | 1.0.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| goe_http | 1.0.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| goodwe | 2.1.2 | ftw-core | contract_passed | — | — | gw8kn_et_hk3000_required | — | profile_selected | no | -| goodwe | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| growatt | 2.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| growatt | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| hardybarth | 1.0.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| hardybarth | 1.0.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| heishamon | 0.5.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| heishamon | 0.5.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| hello | 1.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| hello | 1.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| huawei | 2.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| huawei | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| janitza | 1.0.5 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| janitza | 1.0.5 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| keba | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| keba | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| konja-261-svk | 0.3.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| konja-261-svk | 0.3.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| kostal | 2.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| kostal | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| kstar | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| kstar | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| mennekes | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| mennekes | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| myuplink | 1.2.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| myuplink | 1.2.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| nibe_local | 1.2.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| nibe_local | 1.2.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| opendtu | 1.0.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| opendtu | 1.0.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| opendtu_mqtt | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| opendtu_mqtt | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| openevse | 1.0.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| openevse | 1.0.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| p1_dsmr | 2.0.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| p1_dsmr | 2.0.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| p1_encrypted | 2.0.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| p1_encrypted | 2.0.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| p1_hdlc | 2.0.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| p1_hdlc | 2.0.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| p1_meter | 2.0.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| p1_meter | 2.0.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| pixii | 2.1.4 | ftw-core | staged | 1.2.5 | — | required | — | blocked | no | -| pixii | 2.1.4 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| pixii_pv | 0.3.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| pixii_pv | 0.3.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| saj | 1.2.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| saj | 1.2.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| schneider_meter | 1.0.5 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| schneider_meter | 1.0.5 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| schrack_ev | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| schrack_ev | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sdm630 | 2.0.1 | ftw-core | contract_passed | 2.0.0 | 1.1.1 | required_for_public_source_candidate | — | not_assessed | no | -| sdm630 | 2.0.1 | blixt-l1 | contract_passed | 2.0.0 | 1.1.1 | required_for_public_source_candidate | — | not_applicable_new_pilot | no | -| shelly | 1.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| shelly | 1.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| siemens_pac | 1.0.5 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| siemens_pac | 1.0.5 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sigenergy | 1.1.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sigenergy | 1.1.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sma | 2.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sma | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sma_pv | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sma_pv | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| socomec | 1.0.4 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| socomec | 1.0.4 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sofar | 2.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sofar | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solaredge | 2.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solaredge | 2.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solaredge_legacy | 0.3.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solaredge_legacy | 0.3.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solaredge_pv | 1.2.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solaredge_pv | 1.2.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solax | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solax | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solinteg | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solinteg | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solis | 2.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solis | 2.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solis_string | 1.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| solis_string | 1.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sonnen | 2.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sonnen | 2.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| sungrow | 1.5.8 | ftw-core | observe_only_profile_blocked | 1.3.3 | 1.3.0 | exact_model_firmware_required | — | not_assessed | no | -| sungrow | 1.5.8 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| tesla_vehicle | 0.2.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| tesla_vehicle | 0.2.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| tesla_wall_connector | 0.1.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| tesla_wall_connector | 0.1.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| tibber | 1.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| tibber | 1.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| varta | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| varta | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| victron | 2.1.2 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| victron | 2.1.2 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| victron_mqtt | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| victron_mqtt | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| wallbox | 1.0.3 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| wallbox | 1.0.3 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| zap | 3.1.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| zap | 3.1.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| zaptec | 1.0.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| zaptec | 1.0.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| zaptec_cloud | 0.1.0 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| zaptec_cloud | 0.1.0 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | -| zuidwijk_p1 | 1.1.1 | ftw-core | not_assessed | — | — | not_recorded | — | not_assessed | no | -| zuidwijk_p1 | 1.1.1 | blixt-l1 | not_assessed | — | — | not_recorded | — | not_assessed | no | +| Driver | Catalog | Target | Conformance | Signed beta | HIL | Legacy parity | +| --- | --- | --- | --- | --- | --- | --- | +| 50-125k-svk-ac-slew | 0.2.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| 50-125k-svk-ac-slew | 0.2.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| 50-125k-svk-slew | 0.1.12 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| 50-125k-svk-slew | 0.1.12 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| 50-125k-svk | 0.2.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| 50-125k-svk | 0.2.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| abb_meter | 1.0.6 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| abb_meter | 1.0.6 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| abb_terra | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| abb_terra | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| acrel | 1.0.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| acrel | 1.0.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| acuvim | 0.4.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| acuvim | 0.4.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| alfen | 1.0.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| alfen | 1.0.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| alphaess | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| alphaess | 1.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| ambibox | 1.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ambibox | 1.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| ambibox_v2x | 1.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ambibox_v2x | 1.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| atmoce | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| atmoce | 1.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| carlo_gavazzi | 1.0.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| carlo_gavazzi | 1.0.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| chint | 1.0.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| chint | 1.0.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| circutor | 1.0.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| circutor | 1.0.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| ctek | 0.3.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ctek | 0.3.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| ctek_hybrid | 0.3.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ctek_hybrid | 0.3.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| ctek_v2 | 0.3.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ctek_v2 | 0.3.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| deye-svk | 0.2.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| deye-svk | 0.2.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| deye | 2.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| deye | 2.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| easee | 1.0.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| easee | 1.0.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| esphome-dsmr | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| esphome-dsmr | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| esphome_dsmr | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| esphome_dsmr | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| etrel | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| etrel | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| ferroamp | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ferroamp | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| ferroamp_dc2_v2x | 2.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ferroamp_dc2_v2x | 2.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| ferroamp_modbus | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ferroamp_modbus | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| foxess_h3_smart | 0.9.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| foxess_h3_smart | 0.9.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| fronius | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| fronius | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| fronius_api | 1.0.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| fronius_api | 1.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| fronius_smart_meter | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| fronius_smart_meter | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| goe | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| goe | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| goe_http | 1.0.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| goe_http | 1.0.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| goodwe | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| goodwe | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| growatt | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| growatt | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| hardybarth | 1.0.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| hardybarth | 1.0.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| heishamon | 0.5.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| heishamon | 0.5.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| hello | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| hello | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| huawei | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| huawei | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| janitza | 1.0.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| janitza | 1.0.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| keba | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| keba | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| konja-261-svk | 0.3.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| konja-261-svk | 0.3.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| kostal | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| kostal | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| kstar | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| kstar | 1.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| mennekes | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| mennekes | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| myuplink | 1.2.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| myuplink | 1.2.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| nibe_local | 1.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| nibe_local | 1.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| opendtu | 1.0.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| opendtu | 1.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| opendtu_mqtt | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| opendtu_mqtt | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| openevse | 1.0.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| openevse | 1.0.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| p1_dsmr | 2.0.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| p1_dsmr | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| p1_encrypted | 2.0.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| p1_encrypted | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| p1_hdlc | 2.0.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| p1_hdlc | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| p1_meter | 2.0.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| p1_meter | 2.0.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| pixii | 2.1.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| pixii | 2.1.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| pixii_pv | 0.3.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| pixii_pv | 0.3.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| saj | 1.2.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| saj | 1.2.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| schneider_meter | 1.0.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| schneider_meter | 1.0.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| schrack_ev | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| schrack_ev | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sdm630 | 2.0.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sdm630 | 2.0.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| shelly | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| shelly | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| siemens_pac | 1.0.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| siemens_pac | 1.0.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sigenergy | 1.1.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sigenergy | 1.1.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sma | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sma | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sma_pv | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sma_pv | 1.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| socomec | 1.0.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| socomec | 1.0.4 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sofar | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sofar | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| solaredge | 2.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| solaredge | 2.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| solaredge_legacy | 0.3.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| solaredge_legacy | 0.3.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| solaredge_pv | 1.2.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| solaredge_pv | 1.2.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| solax | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| solax | 1.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| solinteg | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| solinteg | 1.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| solis | 2.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| solis | 2.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| solis_string | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| solis_string | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sonnen | 2.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sonnen | 2.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| sungrow | 1.5.8 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sungrow | 1.5.8 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| tesla_vehicle | 0.2.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| tesla_vehicle | 0.2.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| tesla_wall_connector | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| tesla_wall_connector | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| tibber | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| tibber | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| varta | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| varta | 1.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| victron | 2.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| victron | 2.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| victron_mqtt | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| victron_mqtt | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| wallbox | 1.0.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| wallbox | 1.0.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| zap | 3.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| zap | 3.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| zaptec | 1.0.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| zaptec | 1.0.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| zaptec_cloud | 0.1.0 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| zaptec_cloud | 0.1.0 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| zuidwijk_p1 | 1.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| zuidwijk_p1 | 1.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | diff --git a/drivers/INTEGRATION_OVERVIEW.md b/drivers/INTEGRATION_OVERVIEW.md index 685bac8..444dcf2 100644 --- a/drivers/INTEGRATION_OVERVIEW.md +++ b/drivers/INTEGRATION_OVERVIEW.md @@ -1,7 +1,7 @@ # Sourceful Device Integration Overview > **53 drivers, 46 manufacturers, 551+ device variants** -> One Lua driver framework, two runtimes (Blaxt + Zap), zero firmware updates needed for new devices. +> One Lua driver framework, zero firmware updates needed for new devices. --- diff --git a/drivers/lua/GUIDELINES.md b/drivers/lua/GUIDELINES.md index 51469cc..2b11e61 100644 --- a/drivers/lua/GUIDELINES.md +++ b/drivers/lua/GUIDELINES.md @@ -5,8 +5,7 @@ on the linux-edge hosts this repository targets — FTW (gopher-lua) and Blixt L (luajit). Both run on Linux-class hardware, so a driver here is not written to a memory -budget. Write the driver the device needs. Zap builds are a separate track that -compiles from this source; its constraints do not shape the code here. +budget. Write the driver the device needs. What a driver may call is set by `spec/host-api-profile.json` and checked by `tests/test_host_api_profile.py`. A function that is not in the profile is not @@ -274,7 +273,6 @@ end ```bash make test-driver ID= # syntax, sandbox, manifest and driver tests -make package-driver ID= TARGET=ftw-core make check # the whole suite, including the host API profile ``` diff --git a/drivers/lua/README.md b/drivers/lua/README.md index 0be5652..2225827 100644 --- a/drivers/lua/README.md +++ b/drivers/lua/README.md @@ -1,9 +1,7 @@ # Lua device drivers -This catalog supplies drivers to FTW core and the Blixt gateway. Zap is a -future target, not current production support. Each driver is a standalone Lua -script. Its package recipe lists the exact runtime targets and versions it -supports. +This catalog supplies drivers to FTW core. Each driver is a standalone Lua +script with a manifest under `manifests/`. ## Driver Types diff --git a/drivers/tests/lua_harness/test_all_drivers.lua b/drivers/tests/lua_harness/test_all_drivers.lua index 3fe1edb..a032fc8 100644 --- a/drivers/tests/lua_harness/test_all_drivers.lua +++ b/drivers/tests/lua_harness/test_all_drivers.lua @@ -652,245 +652,6 @@ local function test_driver(name, spec) return passed, errors, warnings end -local function test_sungrow_ftw_v2() - host.reset() - clear_driver_globals() - setup_modbus_data("sungrow") - - local errors = {} - local driver_path = script_dir .. "../../../packages/v1/sungrow/targets/ftw.lua" - local load_ok, load_err = pcall(dofile, driver_path) - if not load_ok then - return false, {"Failed to load: " .. tostring(load_err)} - end - if not DRIVER or DRIVER.version ~= "1.3.3" or - DRIVER.host_api_min ~= 2 or DRIVER.host_api_max ~= 2 then - table.insert(errors, "Sungrow FTW v2 metadata is wrong") - end - if type(driver_command_v2) ~= "function" or - type(driver_default_mode_v2) ~= "function" then - table.insert(errors, "Sungrow FTW v2 entrypoints are missing") - return false, errors - end - - driver_init({host = "127.0.0.1", port = 502, unit_id = 1}) - local poll_ok, poll_err = pcall(driver_poll) - if not poll_ok then - table.insert(errors, "Sungrow FTW v2 poll failed: " .. tostring(poll_err)) - end - - local applied = driver_command_v2({ - command = "battery.set_power", - runtime_action = "battery", - inputs = {power_w = 1250}, - }) - if applied.status ~= "applied" or applied.device_state ~= "controlled" or - host._modbus_registers.holding[13050] ~= 1250 or - host._modbus_registers.holding[13049] ~= 1 then - table.insert(errors, "Sungrow FTW v2 charge did not apply with readback") - end - - local rejected = driver_command_v2({ - command = "battery.set_power", - runtime_action = "battery", - inputs = {power_w = 70000}, - }) - if rejected.status ~= "rejected" or rejected.device_state ~= "unchanged" then - table.insert(errors, "Sungrow FTW v2 accepted an unsafe register value") - end - - host._modbus_write_attempts = 0 - host._modbus_write_fail_at = 2 - local failed = driver_command_v2({ - command = "battery.set_power", - runtime_action = "battery", - inputs = {power_w = -900}, - }) - host._modbus_write_fail_at = nil - if failed.status ~= "failed" or host._modbus_registers.holding[13051] ~= 900 then - table.insert(errors, "Sungrow FTW v2 did not report a partial write failure") - end - - local defaulted = driver_default_mode_v2({reason = "test"}) - if defaulted.status ~= "defaulted" or defaulted.device_state ~= "default" or - host._modbus_registers.holding[13049] ~= 0 then - table.insert(errors, "Sungrow FTW v2 did not restore vendor auto mode") - end - - return #errors == 0, errors -end - -local function test_sungrow_ftw_observe() - host.reset() - clear_driver_globals() - - local errors = {} - local driver_path = script_dir .. "../../../packages/v1/sungrow/targets/ftw-observe.lua" - local load_ok, load_err = pcall(dofile, driver_path) - if not load_ok then - return false, {"Failed to load: " .. tostring(load_err)} - end - if not DRIVER or DRIVER.version ~= "1.3.2" or - DRIVER.host_api_min ~= 1 or DRIVER.host_api_max ~= 1 or - not DRIVER.legacy_ids or DRIVER.legacy_ids[1] ~= "sungrow-shx" then - table.insert(errors, "Sungrow observe-only metadata is wrong") - end - if type(driver_command) ~= "function" or - type(driver_default_mode) ~= "function" or - type(driver_command_v2) == "function" or - type(driver_default_mode_v2) == "function" then - table.insert(errors, "Sungrow observe-only entrypoints are wrong") - return false, errors - end - - local init_ok, init_err = pcall(driver_init, { - host = "127.0.0.1", - port = 502, - unit_id = 1, - model = "SH10RT", - firmware = "unknown", - }) - if init_ok or not string.find(tostring(init_err), "not approved", 1, true) then - table.insert(errors, "Sungrow observe-only target did not block an unapproved profile") - end - - driver_command("battery", 1000, {}) - driver_default_mode() - driver_cleanup() - if host._modbus_write_attempts ~= 0 then - table.insert(errors, "Sungrow observe-only lifecycle attempted a write") - end - - return #errors == 0, errors -end - -local function test_pixii_ftw_v2() - host.reset() - clear_driver_globals() - setup_modbus_data("pixii") - - local errors = {} - local driver_path = script_dir .. "../../../packages/v1/pixii/targets/ftw.lua" - local load_ok, load_err = pcall(dofile, driver_path) - if not load_ok then - return false, {"Failed to load: " .. tostring(load_err)} - end - if not DRIVER or DRIVER.version ~= "1.2.5" or - DRIVER.host_api_min ~= 2 or DRIVER.host_api_max ~= 2 then - table.insert(errors, "Pixii FTW v2 metadata is wrong") - end - if type(driver_command_v2) ~= "function" or - type(driver_default_mode_v2) ~= "function" then - table.insert(errors, "Pixii FTW v2 entrypoints are missing") - return false, errors - end - - driver_init({host = "127.0.0.1", port = 502, unit_id = 1}) - host._modbus_registers.holding[40137] = 7 - local writes_before_poll = host._modbus_write_attempts - local poll_ok, poll_err = pcall(driver_poll) - if not poll_ok then - table.insert(errors, "Pixii FTW v2 poll failed: " .. tostring(poll_err)) - end - if host._modbus_write_attempts ~= writes_before_poll then - table.insert(errors, "Pixii FTW v2 poll wrote outside the control scope") - end - if not host._faulted or host._fault_reason == "" then - table.insert(errors, "Pixii FTW v2 did not enter a fault during calibration") - end - if not host._emitted.battery or not host._emitted.meter then - table.insert(errors, "Pixii FTW v2 stopped telemetry during calibration") - end - - local blocked = driver_command_v2({ - command = "battery.set_power", - runtime_action = "battery", - inputs = {power_w = 1250}, - }) - if blocked.status ~= "rejected" or blocked.code ~= "device_calibrating" then - table.insert(errors, "Pixii FTW v2 accepted control during calibration") - end - - host._modbus_registers.holding[40137] = 6 - local recovery_ok, recovery_err = pcall(driver_poll) - if not recovery_ok then - table.insert(errors, "Pixii FTW v2 recovery poll failed: " .. tostring(recovery_err)) - end - if host._faulted or host._fault_reason ~= "" then - table.insert(errors, "Pixii FTW v2 did not clear the fault after calibration") - end - - local applied = driver_command_v2({ - command = "battery.set_power", - runtime_action = "battery", - inputs = {power_w = 1250}, - }) - local native_power = host.decode_i32_be( - host._modbus_registers.holding[39905], - host._modbus_registers.holding[39906] - ) - if applied.status ~= "applied" or applied.device_state ~= "controlled" or - native_power ~= -1250 or host._modbus_registers.holding[39903] == nil then - table.insert(errors, "Pixii FTW v2 charge did not apply with readback and heartbeat") - end - - local rejected = driver_command_v2({ - command = "battery.set_power", - runtime_action = "battery", - inputs = {power_w = 0 / 0}, - }) - if rejected.status ~= "rejected" or rejected.device_state ~= "unchanged" then - table.insert(errors, "Pixii FTW v2 accepted an invalid power value") - end - - local defaulted = driver_default_mode_v2({reason = "test"}) - local default_power = host.decode_i32_be( - host._modbus_registers.holding[39905], - host._modbus_registers.holding[39906] - ) - if defaulted.status ~= "defaulted" or defaulted.device_state ~= "default" or - default_power ~= 0 then - table.insert(errors, "Pixii FTW v2 did not restore its safe idle setpoint") - end - - return #errors == 0, errors -end - -local function test_pixii_ftw_v2_missing_meter_energy_sf() - host.reset() - clear_driver_globals() - setup_modbus_data("pixii") - host._modbus_read_fail_addresses[40288] = "Illegal Data Address" - - local errors = {} - local driver_path = script_dir .. "../../../packages/v1/pixii/targets/ftw.lua" - local load_ok, load_err = pcall(dofile, driver_path) - if not load_ok then - return false, {"Failed to load: " .. tostring(load_err)} - end - - driver_init({host = "127.0.0.1", port = 502, unit_id = 1}) - local poll_ok, poll_err = pcall(driver_poll) - if not poll_ok then - table.insert(errors, "Pixii FTW v2 poll failed without 40288: " .. tostring(poll_err)) - end - if not host._emitted.battery or not host._emitted.meter then - table.insert(errors, "Pixii FTW v2 stopped telemetry when 40288 is absent") - end - - local poll_reads_40288 = 0 - for _, call in ipairs(host._calls) do - if call.func == "modbus_read" and call.args[1] == 40288 then - poll_reads_40288 = poll_reads_40288 + 1 - end - end - if poll_reads_40288 > 1 then - table.insert(errors, "Pixii FTW v2 polled optional 40288 more than once") - end - - return #errors == 0, errors -end - -- An SG string inverter answers the 5xxx PV registers but none of the SH -- hybrid block, so a driver that polls the hybrid map unconditionally fails -- every poll on the host and takes the whole device offline. @@ -966,47 +727,6 @@ local function test_sungrow_sg_string() return #errors == 0, errors end -local function test_sungrow_ftw_v2_sg_string() - host.reset() - clear_driver_globals() - setup_modbus_data("sungrow") - host._modbus_registers.input[4999] = SG_DEVICE_TYPE_CODE - fail_sg_string_registers() - - local errors = {} - local driver_path = script_dir .. "../../../packages/v1/sungrow/targets/ftw.lua" - local load_ok, load_err = pcall(dofile, driver_path) - if not load_ok then - return false, {"Failed to load: " .. tostring(load_err)} - end - - driver_init({host = "127.0.0.1", port = 502, unit_id = 1}) - for poll = 1, 3 do - local poll_ok, poll_err = pcall(driver_poll) - if not poll_ok then - table.insert(errors, "Sungrow FTW v2 poll " .. poll - .. " failed on a string inverter: " .. tostring(poll_err)) - end - end - check_sg_string_poll("Sungrow FTW v2", errors) - - host._modbus_write_attempts = 0 - local rejected = driver_command_v2({ - command = "battery.set_power", - runtime_action = "battery", - inputs = {power_w = 1250}, - }) - if rejected.status ~= "rejected" or rejected.code ~= "no_battery" or - rejected.device_state ~= "unchanged" then - table.insert(errors, "Sungrow FTW v2 did not reject a battery command without a battery") - end - if host._modbus_write_attempts ~= 0 then - table.insert(errors, "Sungrow FTW v2 wrote EMS registers on a string inverter") - end - - return #errors == 0, errors -end - -- When the device type code cannot be read the driver knows nothing about the -- model, so it must fall back on probing rather than assume a hybrid. local function test_sungrow_unknown_model_probes() @@ -1085,10 +805,7 @@ end -- asking sits at "1 of N reads failed" until the watchdog marks it offline -- and telemetry stops. -- --- These two cover the catalog drivers under drivers/lua. The equivalent --- Pixii test above runs the package target, which is why it stayed green --- through the promotion in #27 while the catalog driver lost the fix from --- #15 and the flap came back on customer hardware. +-- These two cover the catalog drivers under drivers/lua. local function test_pixii_catalog_absent_scale_factor() host.reset() @@ -1219,13 +936,9 @@ for _, name in ipairs(driver_files) do if #name > max_name_len then max_name_len = #name end end max_name_len = max_name_len + 2 -max_name_len = math.max(max_name_len, #"sungrow-ftw-v2" + 2) -max_name_len = math.max(max_name_len, #"sungrow-ftw-observe" + 2) max_name_len = math.max(max_name_len, #"sungrow-sg-string" + 2) -max_name_len = math.max(max_name_len, #"sungrow-ftw-v2-sg-string" + 2) max_name_len = math.max(max_name_len, #"sungrow-unknown-model" + 2) max_name_len = math.max(max_name_len, #"sungrow-transient-read" + 2) -max_name_len = math.max(max_name_len, #"pixii-ftw-v2" + 2) max_name_len = math.max(max_name_len, #"pixii-catalog-missing-40288" + 2) max_name_len = math.max(max_name_len, #"solaredge-legacy-missing-40123" + 2) @@ -1287,74 +1000,6 @@ for _, name in ipairs(driver_files) do end end -total = total + 1 -local ftw_name = "sungrow-ftw-v2" -local ftw_ok, ftw_errors = test_sungrow_ftw_v2() -local ftw_padding = string.rep(" ", max_name_len - #ftw_name) -if ftw_ok then - passed = passed + 1 - io.write(string.format(" %s%s%s[PASS]%s staged control adapter\n", - ftw_name, ftw_padding, GREEN, RESET)) -else - failed = failed + 1 - io.write(string.format(" %s%s%s[FAIL]%s staged control adapter\n", - ftw_name, ftw_padding, RED, RESET)) - for _, err in ipairs(ftw_errors) do - io.write(string.format(" %s- %s%s\n", RED, err, RESET)) - end -end - -total = total + 1 -local observe_name = "sungrow-ftw-observe" -local observe_ok, observe_errors = test_sungrow_ftw_observe() -local observe_padding = string.rep(" ", max_name_len - #observe_name) -if observe_ok then - passed = passed + 1 - io.write(string.format(" %s%s%s[PASS]%s read-only and profile-blocked\n", - observe_name, observe_padding, GREEN, RESET)) -else - failed = failed + 1 - io.write(string.format(" %s%s%s[FAIL]%s read-only and profile-blocked\n", - observe_name, observe_padding, RED, RESET)) - for _, err in ipairs(observe_errors) do - io.write(string.format(" %s- %s%s\n", RED, err, RESET)) - end -end - -total = total + 1 -local pixii_ftw_name = "pixii-ftw-v2" -local pixii_ftw_ok, pixii_ftw_errors = test_pixii_ftw_v2() -local pixii_ftw_padding = string.rep(" ", max_name_len - #pixii_ftw_name) -if pixii_ftw_ok then - passed = passed + 1 - io.write(string.format(" %s%s%s[PASS]%s calibration fault and staged control adapter\n", - pixii_ftw_name, pixii_ftw_padding, GREEN, RESET)) -else - failed = failed + 1 - io.write(string.format(" %s%s%s[FAIL]%s calibration fault and staged control adapter\n", - pixii_ftw_name, pixii_ftw_padding, RED, RESET)) - for _, err in ipairs(pixii_ftw_errors) do - io.write(string.format(" %s- %s%s\n", RED, err, RESET)) - end -end - -total = total + 1 -local pixii_missing_sf_name = "pixii-ftw-v2-missing-40288" -local pixii_missing_sf_ok, pixii_missing_sf_errors = test_pixii_ftw_v2_missing_meter_energy_sf() -local pixii_missing_sf_padding = string.rep(" ", max_name_len - #pixii_missing_sf_name) -if pixii_missing_sf_ok then - passed = passed + 1 - io.write(string.format(" %s%s%s[PASS]%s optional meter energy scale factor\n", - pixii_missing_sf_name, pixii_missing_sf_padding, GREEN, RESET)) -else - failed = failed + 1 - io.write(string.format(" %s%s%s[FAIL]%s optional meter energy scale factor\n", - pixii_missing_sf_name, pixii_missing_sf_padding, RED, RESET)) - for _, err in ipairs(pixii_missing_sf_errors) do - io.write(string.format(" %s- %s%s\n", RED, err, RESET)) - end -end - total = total + 1 local pixii_catalog_sf_name = "pixii-catalog-missing-40288" local pixii_catalog_sf_ok, pixii_catalog_sf_errors = test_pixii_catalog_absent_scale_factor() @@ -1406,23 +1051,6 @@ else end end -total = total + 1 -local sg_ftw_name = "sungrow-ftw-v2-sg-string" -local sg_ftw_ok, sg_ftw_errors = test_sungrow_ftw_v2_sg_string() -local sg_ftw_padding = string.rep(" ", max_name_len - #sg_ftw_name) -if sg_ftw_ok then - passed = passed + 1 - io.write(string.format(" %s%s%s[PASS]%s string inverter skips the hybrid block\n", - sg_ftw_name, sg_ftw_padding, GREEN, RESET)) -else - failed = failed + 1 - io.write(string.format(" %s%s%s[FAIL]%s string inverter skips the hybrid block\n", - sg_ftw_name, sg_ftw_padding, RED, RESET)) - for _, err in ipairs(sg_ftw_errors) do - io.write(string.format(" %s- %s%s\n", RED, err, RESET)) - end -end - total = total + 1 local sg_unknown_name = "sungrow-unknown-model" local sg_unknown_ok, sg_unknown_errors = test_sungrow_unknown_model_probes() diff --git a/drivers/tests/test_absent_optional_registers.py b/drivers/tests/test_absent_optional_registers.py index 4033fd1..dbf918e 100644 --- a/drivers/tests/test_absent_optional_registers.py +++ b/drivers/tests/test_absent_optional_registers.py @@ -89,33 +89,23 @@ def poll_with_absent(path: Path, absent: int, present: int, ''') -# The catalog driver and the package target are separate files that both ship. -# Fixing one and not the other is what let this bug come back: #16 patched both, -# #27 overwrote only the catalog copy, and the package target's own test stayed -# green while the flap reached customer hardware. Hold both to the same rule. -PIXII_SOURCES = { - "catalog": ROOT / "drivers" / "lua" / "pixii.lua", - "package-target": ROOT / "packages" / "v1" / "pixii" / "targets" / "ftw.lua", -} - - -@pytest.mark.parametrize("source", sorted(PIXII_SOURCES), ids=sorted(PIXII_SOURCES)) -def test_pixii_gives_up_on_an_absent_scale_factor(source): +def test_pixii_gives_up_on_an_absent_scale_factor(): """40288 meter_energy_sf is absent on PowerShaper firmware below CPU 2.0.23.""" - out = poll_with_absent(PIXII_SOURCES[source], absent=40288, present=40256, + out = poll_with_absent(ROOT / "drivers" / "lua" / "pixii.lua", + absent=40288, present=40256, config="{host = '127.0.0.1', port = 502, unit_id = 1}") assert int(out["ABSENT_READS"]) <= GIVE_UP_AFTER, ( - f"pixii {source} read absent 40288 {out['ABSENT_READS']} times over " + f"pixii read absent 40288 {out['ABSENT_READS']} times over " f"{POLLS} polls, so it never stopped. Every failed read counts against " f"the poll and takes the driver offline; the budget is {GIVE_UP_AFTER}." ) # Caching absence must not freeze a scale factor the firmware does carry. assert int(out["PRESENT_READS"]) >= POLLS, ( - f"pixii {source} stopped re-reading meter power SF 40256, which is present" + "pixii stopped re-reading meter power SF 40256, which is present" ) assert int(out["STREAMS"]) >= 2, ( - f"pixii {source} stopped telemetry when 40288 is absent" + "pixii stopped telemetry when 40288 is absent" ) diff --git a/drivers/tests/test_esphome_dsmr_aliases.py b/drivers/tests/test_esphome_dsmr_aliases.py index 42538b4..8a76f8a 100644 --- a/drivers/tests/test_esphome_dsmr_aliases.py +++ b/drivers/tests/test_esphome_dsmr_aliases.py @@ -1,27 +1,27 @@ """Exercise ESPHome DSMR aliases against the canonical public driver.""" from pathlib import Path +import re import subprocess -import sys import pytest ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "tools")) - -from driver_package import _validate_lua_source_for_target # noqa: E402 def test_esphome_dsmr_source_has_ftw_read_only_contract(): - source = (ROOT / "drivers/lua/esphome-dsmr.lua").read_bytes() - _validate_lua_source_for_target( + source = (ROOT / "drivers/lua/esphome-dsmr.lua").read_text(encoding="utf-8") + for entrypoint in ("driver_init", "driver_poll", "driver_command", + "driver_default_mode"): + assert re.search(rf"\bfunction\s+{entrypoint}\s*\(", source), entrypoint + assert re.search(r"\bhost_api_min\s*=", source) + assert re.search(r"\bhost_api_max\s*=", source) + assert re.search(r"\bread_only\s*=\s*true\b", source) + assert not re.search( + r"\bhost\.(?:http_post|modbus_write(?:_multi)?|mqtt_publish|serial_write)\s*\(", source, - target="ftw-core", - read_only=True, - package_version="1.0.2", - runtime_abi="gopher-lua-source-v1", - ) + ), "a read-only driver calls a write-capable host function" @pytest.mark.parametrize("scenario", ["name-derived", "delivered-returned"]) diff --git a/drivers/tests/test_no_undefined_locals.py b/drivers/tests/test_no_undefined_locals.py index 77cf179..7b0cd02 100644 --- a/drivers/tests/test_no_undefined_locals.py +++ b/drivers/tests/test_no_undefined_locals.py @@ -22,13 +22,10 @@ """ import re -from pathlib import Path import pytest from conftest import get_driver_names, read_driver, strip_lua_comments -ROOT = Path(__file__).resolve().parents[2] - # Names shaped like a local this repository would declare: a read guard or a # register block. Anything else is out of scope for this check. # @@ -58,20 +55,10 @@ def declared_locals(code: str) -> set[str]: return names -def driver_sources(): - sources = [(name, ROOT / "drivers" / "lua" / f"{name}.lua") - for name in get_driver_names()] - sources += [(f"package:{path.parent.parent.name}", path) - for path in sorted((ROOT / "packages" / "v1").glob("*/targets/*.lua"))] - return sources - - @pytest.mark.holds_for_ftw_drivers -@pytest.mark.parametrize("name,path", driver_sources(), ids=lambda v: v if isinstance(v, str) else "") -def test_driver_declares_every_guard_it_reads(name, path): - code = strip_lua_comments( - read_driver(name) if not str(path).startswith(str(ROOT / "packages")) - else path.read_text()) +@pytest.mark.parametrize("name", get_driver_names()) +def test_driver_declares_every_guard_it_reads(name): + code = strip_lua_comments(read_driver(name)) declared = declared_locals(code) used = {} diff --git a/drivers/tests/test_sungrow_model_family.py b/drivers/tests/test_sungrow_model_family.py index 009c7d5..175ad61 100644 --- a/drivers/tests/test_sungrow_model_family.py +++ b/drivers/tests/test_sungrow_model_family.py @@ -26,7 +26,6 @@ LUA = ROOT / "lua55" HARNESS = ROOT / "drivers" / "tests" / "lua_harness" DRIVER = ROOT / "drivers" / "lua" / "sungrow.lua" -FTW_V2 = ROOT / "packages" / "v1" / "sungrow" / "targets" / "ftw.lua" # Every address in the hybrid block this driver touches. HYBRID_BLOCK = [12999, 13000, 13002, 13019, 13026, 13036, 13040, 13045, 13049] @@ -273,8 +272,7 @@ def test_battery_refusal_says_why() -> None: assert out["CODE"] == "no_battery", ( f"the refusal carried code {out['CODE']!r}. It must be 'no_battery', " - f"the same code packages/v1/sungrow/targets/ftw.lua already returns, " - f"so both control paths refuse in one vocabulary.") + f"which says the command never could succeed, not merely that it failed.") assert out["STATE"] == "unchanged", ( "a refused command touched nothing, and the host needs to be told so " "before it decides whether the device still needs default mode") @@ -378,49 +376,27 @@ def test_string_inverter_still_takes_a_curtail_command() -> None: # -------------------------------------------------------------------------- -# Both copies of this driver, held to one rule +# One rule, however the battery is missing # -# The Sungrow driver exists twice: the catalog driver the signed channel -# publishes, and the FTW v2 control target, which is a separate file and not -# generated from it. They speak different command ABIs -- v1 returns a boolean, -# v2 returns a result table -- but the rule underneath is the same, so one test -# states it once. Pixii's 40288 came back because the package target had its -# own green test; the v2 target here had no pytest at all, only a Lua harness -# CI does not run. +# A model that names itself a string inverter and a model that names nothing +# and never answers a battery register both have no battery to command. The +# guard refuses both, and still accepts a battery it can confirm either way. # -------------------------------------------------------------------------- -BOTH_DRIVERS = [pytest.param(DRIVER, id="catalog"), pytest.param(FTW_V2, id="ftw-v2")] - - -def battery_command_on(driver: Path) -> str: - """Send one battery setpoint through whichever ABI this file speaks.""" - if driver == FTW_V2: - call = ''' -local result = driver_command_v2({ - command = "battery.set_power", - runtime_action = "battery", - inputs = {power_w = 1000}, -}) -print("ACCEPTED " .. tostring(result.status ~= "rejected")) -print("CODE " .. tostring(result.code or "none")) -''' - else: - call = ''' -local accepted, refusal = driver_command("battery", 1000, {}) -print("ACCEPTED " .. tostring(accepted == true)) -print("CODE " .. tostring(type(refusal) == "table" and refusal.code or "none")) -''' +def battery_command() -> str: + """Send one battery setpoint once the driver has settled.""" return f''' -dofile("{driver}") +dofile("{DRIVER}") driver_init({{}}) for poll = 1, 4 do pcall(driver_poll) end host._modbus_write_attempts = 0 -{call} +local accepted, refusal = driver_command("battery", 1000, {{}}) +print("ACCEPTED " .. tostring(accepted == true)) +print("CODE " .. tostring(type(refusal) == "table" and refusal.code or "none")) print("WRITES " .. tostring(host._modbus_write_attempts)) ''' -@pytest.mark.parametrize("driver", BOTH_DRIVERS) @pytest.mark.parametrize("device,label", [ pytest.param(STRING_INVERTER, "a model that names itself a string inverter", @@ -429,23 +405,20 @@ def battery_command_on(driver: Path) -> str: "a model that names nothing and answers no battery register", id="unidentified-string"), ]) -def test_neither_copy_writes_a_battery_it_cannot_confirm( - driver: Path, device: str, label: str) -> None: - out = run_lua(device + battery_command_on(driver)) +def test_no_battery_write_it_cannot_confirm(device: str, label: str) -> None: + out = run_lua(device + battery_command()) assert out["ACCEPTED"] == "false", ( - f"{driver.name} accepted a battery setpoint on {label}. Writing " + f"the driver accepted a battery setpoint on {label}. Writing " f"13049/13050/13051 to an inverter that does not implement them is " f"unexpected traffic to live hardware, and answering success renews " f"the lease on a battery that is not there.") assert out["WRITES"] == "0", ( - f"{driver.name} refused and still wrote {out['WRITES']} registers") + f"the driver refused and still wrote {out['WRITES']} registers") assert out["CODE"] == "no_battery", ( - f"{driver.name} refused with code {out['CODE']!r}. Both control paths " - f"refuse in one vocabulary or they drift apart again.") + f"the driver refused with code {out['CODE']!r}, not 'no_battery'") -@pytest.mark.parametrize("driver", BOTH_DRIVERS) @pytest.mark.parametrize("device,label", [ pytest.param(HEALTHY_HYBRID, "an inverter that names itself a hybrid", @@ -454,13 +427,12 @@ def test_neither_copy_writes_a_battery_it_cannot_confirm( "an inverter whose battery answers but whose type does not", id="unidentified-hybrid"), ]) -def test_neither_copy_refuses_a_battery_it_can_confirm( - driver: Path, device: str, label: str) -> None: +def test_no_refusal_of_a_battery_it_can_confirm(device: str, label: str) -> None: """The other half. Refusing too much is also an outage.""" - out = run_lua(device + battery_command_on(driver)) + out = run_lua(device + battery_command()) assert out["ACCEPTED"] == "true", ( - f"{driver.name} refused a battery setpoint on {label}. A driver that " + f"the driver refused a battery setpoint on {label}. A driver that " f"reads a battery every poll and will not command it has taken a " f"control off the fleet.") @@ -489,9 +461,7 @@ def test_neither_copy_refuses_a_battery_it_can_confirm( # watchdog and the stale-site-meter standdown, never through # driver_command. driver_init writes self-consumption too, before the run # loop can accept any command -- so the window where a battery command is -# refused is a window where the inverter is already back on its own. On -# the control v2 path FTW does not even rely on that: it calls -# driver_default_mode_v2 itself once driver_init returns. +# refused is a window where the inverter is already back on its own. # # Refusing therefore withholds nothing that is not reachable by a route which # cannot refuse. Accepting would put mode 2 and a setpoint back on an SG @@ -502,18 +472,6 @@ def test_neither_copy_refuses_a_battery_it_can_confirm( # The residue is honest and small: if the init write fails on a real hybrid, # battery commands are refused until the first poll confirms the battery -- # at most one poll interval, with driver_default_mode available throughout. -# -# The two copies agree on the guard, and the tests below hold both to it. They -# do NOT agree on what zero writes: the catalog driver forces idle (mode 2), -# the v2 target hands the inverter back (mode 0, device_state "default", which -# clears FTW's lease). The v2 target uses a different register map throughout -# -- 13050 and 13051 as per-direction limits rather than force command and -# setpoint -- so this is one question inside a larger one. It belongs to the -# register-map review packages/v1/sungrow/PILOT.md already requires before -# that target ships; its verification_status is "experimental" and the signed -# package builds from targets/ftw-observe.lua, so nothing here reaches -# hardware today. Written down because untested drift between these two files -# is how Pixii's 40288 came back. # -------------------------------------------------------------------------- NOTHING_ANSWERED_YET = ''' @@ -549,7 +507,7 @@ def ems_mode_written() -> str: ''' -def zero_command_before_any_poll(driver: Path) -> str: +def zero_command_before_any_poll() -> str: """Init, then one zero-watt battery command, with no poll in between. The earliest a command can reach a driver on FTW. Registry.Add runs @@ -557,33 +515,18 @@ def zero_command_before_any_poll(driver: Path) -> str: channel before the first poll timer fires -- and that timer is a full poll interval out, longer with host.set_warmup_s. """ - if driver == FTW_V2: - call = ''' -local result = driver_command_v2({ - command = "battery.set_power", - runtime_action = "battery", - inputs = {power_w = 0}, -}) -print("ACCEPTED " .. tostring(result.status ~= "rejected")) -print("CODE " .. tostring(result.code or "none")) -''' - else: - call = ''' -local accepted, refusal = driver_command("battery", 0, {}) -print("ACCEPTED " .. tostring(accepted == true)) -print("CODE " .. tostring(type(refusal) == "table" and refusal.code or "none")) -''' return f''' -dofile("{driver}") +dofile("{DRIVER}") driver_init({{}}) host._modbus_write_attempts = 0 -{call} +local accepted, refusal = driver_command("battery", 0, {{}}) +print("ACCEPTED " .. tostring(accepted == true)) +print("CODE " .. tostring(type(refusal) == "table" and refusal.code or "none")) print("WRITES " .. tostring(host._modbus_write_attempts)) ''' -@pytest.mark.parametrize("driver", BOTH_DRIVERS) -def test_zero_watts_is_still_a_battery_command(driver: Path) -> None: +def test_zero_watts_is_still_a_battery_command() -> None: """A setpoint of zero is a setpoint. It proves nothing about the battery. Nothing here says this device has one: no family named, no battery @@ -591,16 +534,16 @@ def test_zero_watts_is_still_a_battery_command(driver: Path) -> None: is not a guard -- an SG string inverter takes a zero as readily as it takes a thousand, and answers success to both. """ - out = run_lua(NOTHING_ANSWERED_YET + zero_command_before_any_poll(driver)) + out = run_lua(NOTHING_ANSWERED_YET + zero_command_before_any_poll()) assert out["ACCEPTED"] == "false", ( - f"{driver.name} accepted a zero-watt battery command on a device that " - f"has confirmed nothing. Zero is not exempt: it writes the same three " - f"registers to the same models, and reports the same false success.") + "the driver accepted a zero-watt battery command on a device that " + "has confirmed nothing. Zero is not exempt: it writes the same three " + "registers to the same models, and reports the same false success.") assert out["WRITES"] == "0", ( - f"{driver.name} refused and still wrote {out['WRITES']} registers") + f"the driver refused and still wrote {out['WRITES']} registers") assert out["CODE"] == "no_battery", ( - f"{driver.name} refused with code {out['CODE']!r}, not 'no_battery'") + f"the driver refused with code {out['CODE']!r}, not 'no_battery'") def test_zero_watts_takes_the_inverter_over_rather_than_handing_it_back() -> None: @@ -631,27 +574,17 @@ def test_zero_watts_takes_the_inverter_over_rather_than_handing_it_back() -> Non f"reads it backwards.") -def release_before_any_poll(driver: Path) -> str: +def release_before_any_poll() -> str: """Init, then the release, with no poll in between.""" - if driver == FTW_V2: - call = ''' -local result = driver_default_mode_v2({reason = "host_shutdown"}) -print("RELEASED " .. tostring(result.status ~= "rejected")) -''' - else: - call = ''' -print("RELEASED " .. tostring(driver_default_mode() == true)) -''' return f''' -dofile("{driver}") +dofile("{DRIVER}") driver_init({{}}) host._calls = {{}} -{call} +print("RELEASED " .. tostring(driver_default_mode() == true)) ''' + ems_mode_written() -@pytest.mark.parametrize("driver", BOTH_DRIVERS) -def test_the_release_is_never_gated(driver: Path) -> None: +def test_the_release_is_never_gated() -> None: """Why refusing a battery command is safe, and the thing that must not move. Refusing costs nothing only while handing the inverter back stays @@ -659,13 +592,13 @@ def test_the_release_is_never_gated(driver: Path) -> None: driver_command is gated and the whole argument collapses: a device would then be able to hold a forced state that nothing is allowed to clear. """ - out = run_lua(NOTHING_ANSWERED_YET + release_before_any_poll(driver)) + out = run_lua(NOTHING_ANSWERED_YET + release_before_any_poll()) assert out["RELEASED"] == "true", ( - f"{driver.name} refused to hand the inverter back on a device that " - f"has confirmed nothing. Shutdown, lease expiry, the watchdog and the " - f"stale-meter standdown all arrive here, and none of them can wait " - f"for a poll.") + "the driver refused to hand the inverter back on a device that " + "has confirmed nothing. Shutdown, lease expiry, the watchdog and the " + "stale-meter standdown all arrive here, and none of them can wait " + "for a poll.") assert out["EMS_MODE_WRITTEN"] == "0", ( f"the release left EMS mode at {out['EMS_MODE_WRITTEN']}, not 0. " f"Anything else keeps the inverter under FTW's control.") diff --git a/packages/v1/esphome-dsmr/package-source.json b/packages/v1/esphome-dsmr/package-source.json deleted file mode 100644 index 242110a..0000000 --- a/packages/v1/esphome-dsmr/package-source.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "schema_version": "sourceful.driver-package-source/v1", - "package_id": "com.sourceful.driver.esphome-dsmr", - "version": "1.0.2", - "channel": "beta", - "display_name": "ESPHome DSMR (P1)", - "identity": { - "schema": "sourceful.hardware-identity/v1", - "make": "driver_reported", - "serial": "driver_reported_when_available", - "host_fallbacks": [ - "mac", - "endpoint" - ], - "persistent_state_owner": "host" - }, - "source": { - "repository": "https://github.com/srcfl/device-drivers", - "path": "drivers/lua/esphome-dsmr.lua" - }, - "builder_id": "https://github.com/srcfl/device-drivers/blob/main/tools/driver_package.py", - "device_matches": [ - { - "manufacturer": "ESPHome", - "model_family": "DSMR web_server v3 P1 reader", - "variants": [ - "SlimmeLezer+", - "Sourceful Zap P1" - ], - "regions": [ - "NL", - "BE", - "SE", - "DK" - ] - } - ], - "capabilities": { - "telemetry": [ - "meter" - ], - "control": [] - }, - "permissions": [ - "http.get" - ], - "telemetry": { - "schema": "sourceful.telemetry/v2", - "sign_convention": "sourceful.site-import-positive/v1", - "streams": [ - { - "kind": "meter", - "power_field": "w", - "meaning": "Positive is grid import and negative is grid export at the site boundary." - } - ] - }, - "commands": [], - "read_only": true, - "default_mode": { - "strategy": "not_applicable", - "description": "The ESPHome meter endpoint is read-only and has no control state." - }, - "lease_policy": { - "required_for_control": false, - "expiry_action": "not_applicable" - }, - "rollback": { - "strategy": "install_previous_verified_package", - "state_owner": "host", - "automatic": false - }, - "compatibility": [ - { - "target": "ftw-core", - "artifact_id": "ftw.lua51.source", - "host": { - "product": "ftw", - "min_version": "1.10.0", - "max_version_exclusive": "2.0.0" - }, - "runtime": { - "name": "gopher-lua", - "semantics": "lua-5.1", - "version": "1.1.2", - "abi": "gopher-lua-source-v1", - "host_api": { - "profile": "sourceful.host/ftw-core/v1", - "min": 1, - "max": 1 - } - }, - "control_enabled": false - } - ], - "artifact_inputs": [ - { - "artifact_id": "ftw.lua51.source", - "target": "ftw-core", - "media_type": "application/vnd.sourceful.lua.source", - "input_path": "drivers/lua/esphome-dsmr.lua", - "transform": "copy", - "extension": "lua" - } - ] -} diff --git a/packages/v1/foxess_h3_smart/package-source.json b/packages/v1/foxess_h3_smart/package-source.json deleted file mode 100644 index 848354e..0000000 --- a/packages/v1/foxess_h3_smart/package-source.json +++ /dev/null @@ -1,172 +0,0 @@ -{ - "schema_version": "sourceful.driver-package-source/v1", - "package_id": "com.sourceful.driver.foxess-h3-smart", - "version": "0.9.5", - "channel": "beta", - "display_name": "FoxESS H3-Smart / 1K5", - "identity": { - "schema": "sourceful.hardware-identity/v1", - "make": "driver_reported", - "serial": "driver_reported_when_available", - "host_fallbacks": [ - "mac", - "endpoint" - ], - "persistent_state_owner": "host" - }, - "source": { - "repository": "https://github.com/srcfl/device-drivers", - "path": "drivers/lua/foxess_h3_smart.lua" - }, - "builder_id": "https://github.com/srcfl/device-drivers/blob/main/tools/driver_package.py", - "device_matches": [ - { - "manufacturer": "Fox ESS", - "model_family": "1K5 (Three-Phase Hybrid)", - "variants": [ - "1K5-HI-5-V1", - "1K5-HI-8-V1", - "1K5-HI-10-V1", - "1K5-HI-12-V1", - "1K5-HI-15-V1" - ], - "regions": [] - }, - { - "manufacturer": "Fox ESS", - "model_family": "H3-Smart", - "variants": [], - "regions": [] - } - ], - "capabilities": { - "telemetry": [ - "pv", - "battery", - "meter" - ], - "control": [ - "battery" - ] - }, - "permissions": [ - "modbus.read", - "modbus.write" - ], - "telemetry": { - "schema": "sourceful.telemetry/v2", - "sign_convention": "sourceful.site-import-positive/v1", - "streams": [ - { - "kind": "pv", - "power_field": "w", - "meaning": "PV generation is negative because it reduces site import." - }, - { - "kind": "battery", - "power_field": "w", - "meaning": "Positive is charging and negative is discharging." - }, - { - "kind": "meter", - "power_field": "w", - "meaning": "Positive is grid import and negative is grid export at the site boundary." - } - ] - }, - "commands": [ - { - "id": "battery", - "capability": "battery", - "runtime_action": "battery", - "inputs": [ - { - "name": "power_w", - "type": "number", - "unit": "W", - "required": true - } - ], - "description": "Battery power setpoint, translated to the vendor AC active-power register (AC = PV - target)." - } - ], - "read_only": false, - "default_mode": { - "strategy": "vendor_autonomous", - "description": "Write the remote-control disable and read it back; the inverter runs native self-use, and the vendor-side 60 s timeout reverts it even if the write cannot land.", - "entrypoint": "driver_default_mode_v2" - }, - "lease_policy": { - "required_for_control": true, - "expiry_action": "return_to_default", - "heartbeat_interval_seconds": 5, - "max_duration_seconds": 60 - }, - "rollback": { - "strategy": "install_previous_verified_package", - "state_owner": "host", - "automatic": false - }, - "compatibility": [ - { - "target": "ftw-core", - "artifact_id": "ftw.lua51.source", - "host": { - "product": "ftw", - "min_version": "1.5.0", - "max_version_exclusive": "2.0.0" - }, - "runtime": { - "name": "gopher-lua", - "semantics": "lua-5.1", - "version": "1.1.2", - "abi": "gopher-lua-source-v2", - "host_api": { - "profile": "sourceful.host/ftw-core/v2", - "min": 2, - "max": 2 - } - }, - "control_enabled": true - }, - { - "target": "blixt-l1", - "artifact_id": "blixt.lua51.source", - "host": { - "product": "blixt-gateway", - "min_version": "0.1.0", - "max_version_exclusive": "1.0.0" - }, - "runtime": { - "name": "luajit", - "semantics": "lua-5.1", - "version": "2.1", - "abi": "mlua-0.10-luajit21-source-v2", - "host_api": { - "profile": "sourceful.host/blixt-l1/v2", - "min": 2, - "max": 2 - } - }, - "control_enabled": true - } - ], - "artifact_inputs": [ - { - "artifact_id": "ftw.lua51.source", - "target": "ftw-core", - "media_type": "application/vnd.sourceful.lua.source", - "input_path": "drivers/lua/foxess_h3_smart.lua", - "transform": "copy", - "extension": "lua" - }, - { - "artifact_id": "blixt.lua51.source", - "target": "blixt-l1", - "media_type": "application/vnd.sourceful.lua.source", - "input_path": "drivers/lua/foxess_h3_smart.lua", - "transform": "copy", - "extension": "lua" - } - ] -} diff --git a/packages/v1/pixii/package-source.json b/packages/v1/pixii/package-source.json deleted file mode 100644 index 06e1644..0000000 --- a/packages/v1/pixii/package-source.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "schema_version": "sourceful.driver-package-source/v1", - "package_id": "com.sourceful.driver.pixii", - "version": "1.2.5", - "channel": "beta", - "display_name": "Pixii PowerShaper", - "identity": { - "schema": "sourceful.hardware-identity/v1", - "make": "driver_reported", - "serial": "driver_reported_when_available", - "host_fallbacks": ["mac", "endpoint"], - "persistent_state_owner": "host" - }, - "source": { - "repository": "https://github.com/srcfl/device-drivers", - "path": "packages/v1/pixii/targets/ftw.lua" - }, - "builder_id": "https://github.com/srcfl/device-drivers/blob/main/tools/driver_package.py", - "device_matches": [ - { - "manufacturer": "Pixii", - "model_family": "PowerShaper", - "variants": ["PowerShaper 2.0"], - "regions": [] - } - ], - "capabilities": { - "telemetry": ["battery", "meter"], - "control": ["battery.dispatch"] - }, - "permissions": ["modbus.read", "modbus.write"], - "telemetry": { - "schema": "sourceful.telemetry/v2", - "sign_convention": "sourceful.site-import-positive/v1", - "streams": [ - { - "kind": "battery", - "power_field": "w", - "meaning": "Positive is charging and negative is discharging." - }, - { - "kind": "meter", - "power_field": "w", - "meaning": "Positive is grid import and negative is grid export at the site boundary." - } - ] - }, - "commands": [ - { - "id": "battery.set_power", - "capability": "battery.dispatch", - "runtime_action": "battery", - "description": "Set signed battery power in the site convention; zero returns the Pixii to idle.", - "inputs": [ - {"name": "power_w", "type": "number", "unit": "W", "required": true} - ] - } - ], - "read_only": false, - "default_mode": { - "strategy": "vendor_autonomous", - "entrypoint": "driver_default_mode_v2", - "description": "Write a zero active-power setpoint and let the Pixii handshake expire to its safe idle mode." - }, - "lease_policy": { - "required_for_control": true, - "max_duration_seconds": 30, - "heartbeat_interval_seconds": 10, - "expiry_action": "return_to_default" - }, - "rollback": { - "strategy": "install_previous_verified_package", - "state_owner": "host", - "automatic": false - }, - "compatibility": [ - { - "target": "ftw-core", - "artifact_id": "ftw.lua51.source", - "host": { - "product": "ftw", - "min_version": "1.8.0-beta.1", - "max_version_exclusive": "2.0.0" - }, - "runtime": { - "name": "gopher-lua", - "semantics": "lua-5.1", - "version": "1.1.2", - "abi": "gopher-lua-source-v2", - "host_api": { - "profile": "sourceful.host/ftw-core/v2", - "min": 2, - "max": 2 - } - }, - "control_enabled": false - } - ], - "artifact_inputs": [ - { - "artifact_id": "ftw.lua51.source", - "target": "ftw-core", - "media_type": "application/vnd.sourceful.lua.source", - "input_path": "packages/v1/pixii/targets/ftw.lua", - "transform": "copy", - "extension": "lua" - } - ] -} diff --git a/packages/v1/pixii/targets/ftw.lua b/packages/v1/pixii/targets/ftw.lua deleted file mode 100644 index 081eac3..0000000 --- a/packages/v1/pixii/targets/ftw.lua +++ /dev/null @@ -1,376 +0,0 @@ --- Pixii PowerShaper FTW target --- Emits: Battery, Meter --- Protocol: Modbus TCP, SunSpec holding registers --- --- The telemetry and control paths come from the FTW community driver. The --- calibration fault handling was contributed by Tommy Lindgren in srcfl/ftw#600. - -DRIVER = { - host_api_min = 2, - host_api_max = 2, - id = "pixii", - name = "Pixii PowerShaper", - manufacturer = "Pixii", - version = "1.2.5", - protocols = {"modbus"}, - capabilities = {"battery", "meter"}, - description = "Pixii PowerShaper commercial battery storage via Modbus TCP.", - authors = {"Tommy Lindgren", "Sourceful contributors"}, - verification_status = "experimental", - verification_notes = "FTW v2 control stays disabled until runtime isolation and physical HIL pass.", - connection_defaults = { - port = 502, - unit_id = 1, - }, -} - -PROTOCOL = "modbus" - -local REG_HEARTBEAT = 39903 -local REG_SETPOINT_HI = 39905 -local REG_BATTERY_CHARGE_STATUS = 40137 -local REG_METER_ENERGY_SF = 40288 -- SunSpec model 213 offset 53; absent on some firmware - -local heartbeat = 0 -local serial_read = false -local last_calibrating = nil - -local function scale(value, factor) - if factor == 0 then return value end - return value * (10 ^ factor) -end - --- Registers this device has stopped answering. --- --- The host counts every failed host.modbus_read against the poll whether or --- not this driver caught the error — "driver_poll: N of M modbus reads --- failed". So a register retried on every poll costs a failed poll on every --- poll, and the stale-telemetry watchdog takes the driver offline. The site --- then reports nothing at all, which is worse than reporting one field less. --- PowerShaper firmware below CPU 2.0.23 answers Modbus exception 2 on 40288 --- (meter_energy_sf), which is how this was found in the field. --- --- Three attempts absorb a transient blip; after that we stop asking. A --- restart re-probes, so firmware that gains the register is picked up. -local GIVE_UP_AFTER = 3 -local read_failures = {} - -local function probe_read(address, count, kind) - if (read_failures[address] or 0) >= GIVE_UP_AFTER then return nil end - local ok, registers = pcall(host.modbus_read, address, count, kind) - if ok and registers and registers[1] ~= nil then - read_failures[address] = nil - return registers - end - local failures = (read_failures[address] or 0) + 1 - read_failures[address] = failures - if failures == GIVE_UP_AFTER then - host.log("info", string.format( - "Pixii: register %d did not answer %d times; leaving it alone " .. - "until restart", address, GIVE_UP_AFTER)) - end - return nil -end - --- SF=0 (x1 scaling) is the right fallback when the register is absent, and --- matches units where it exists but reads zero. -local function read_scale_factor(address) - local registers = probe_read(address, 1, "holding") - if registers then - return host.decode_i16(registers[1]) - end - return 0 -end - -local function decode_ascii(registers, count) - local value = "" - for i = 1, count do - local hi = math.floor(registers[i] / 256) - local lo = registers[i] % 256 - if hi == 0 and lo == 0 then break end - if hi > 32 and hi < 127 then value = value .. string.char(hi) end - if lo > 32 and lo < 127 then value = value .. string.char(lo) end - end - return value -end - -local function read_charge_status() - local registers = probe_read(REG_BATTERY_CHARGE_STATUS, 1, "holding") - if not registers then - return nil - end - return registers[1] -end - -local function update_calibration_fault(charge_status) - if charge_status == nil then - return - end - - local calibrating = charge_status == 7 - host.set_device_fault( - calibrating, - calibrating and "Pixii battery calibrating/testing (SunSpec ChaSt=testing)" or "" - ) - - if calibrating ~= last_calibrating then - if calibrating then - host.log("warn", "Pixii: charge status is TESTING; excluding from dispatch until calibration finishes") - elseif last_calibrating == true then - host.log("info", "Pixii: calibration finished; dispatch may resume") - end - last_calibrating = calibrating - end -end - -function driver_init(config) - host.set_make("Pixii") -end - -function driver_poll() - if not serial_read then - local registers = probe_read(40052, 16, "holding") - if registers then - local serial = decode_ascii(registers, 16) - if string.len(serial) > 0 then - host.set_sn(serial) - serial_read = true - end - end - end - - local ac_w_factor = read_scale_factor(40084) - local hz_factor = read_scale_factor(40086) - local temp_factor = read_scale_factor(40106) - local soc_factor = read_scale_factor(40177) - local battery_v_factor = read_scale_factor(40180) - local battery_a_factor = read_scale_factor(40182) - local battery_w_factor = read_scale_factor(40184) - local meter_a_factor = read_scale_factor(40240) - local meter_v_factor = read_scale_factor(40249) - local meter_hz_factor = read_scale_factor(40251) - local meter_w_factor = read_scale_factor(40256) - local meter_energy_factor = read_scale_factor(REG_METER_ENERGY_SF) - - local ac_w_registers = probe_read(40083, 1, "holding") - local ac_w = 0 - if ac_w_registers then - ac_w = scale(host.decode_i16(ac_w_registers[1]), ac_w_factor) - end - - local hz_registers = probe_read(40085, 1, "holding") - local inverter_hz = 0 - if hz_registers then - inverter_hz = scale(hz_registers[1], hz_factor) - end - - local temp_registers = probe_read(40102, 1, "holding") - local temp_c = 0 - if temp_registers then - temp_c = scale(host.decode_i16(temp_registers[1]), temp_factor) - end - - local soc_registers = probe_read(40132, 1, "holding") - local battery_soc = nil - if soc_registers then - local candidate = scale(soc_registers[1], soc_factor) / 100 - if candidate >= 0 and candidate <= 1 then - battery_soc = candidate - end - end - - local battery_v_registers = probe_read(40155, 1, "holding") - local battery_v = 0 - if battery_v_registers then - battery_v = scale(host.decode_i16(battery_v_registers[1]), battery_v_factor) - end - - local battery_a_registers = probe_read(40165, 1, "holding") - local battery_a = 0 - if battery_a_registers then - battery_a = scale(host.decode_i16(battery_a_registers[1]), battery_a_factor) - end - - local battery_w_registers = probe_read(40168, 1, "holding") - local battery_w = 0 - if battery_w_registers then - battery_w = scale(host.decode_i16(battery_w_registers[1]), battery_w_factor) - end - - local energy_registers = probe_read(39958, 4, "holding") - local charge_wh, discharge_wh = 0, 0 - if energy_registers then - charge_wh = host.decode_i32_be(energy_registers[1], energy_registers[2]) * 1000 - discharge_wh = host.decode_i32_be(energy_registers[3], energy_registers[4]) * 1000 - end - - local charge_status = read_charge_status() - update_calibration_fault(charge_status) - - local battery = { - w = battery_w, - v = battery_v, - a = battery_a, - temp_c = temp_c, - charge_wh = charge_wh, - discharge_wh = discharge_wh, - charge_status = charge_status, - } - if battery_soc ~= nil then battery.soc = battery_soc end - host.emit("battery", battery) - - local meter_a_registers = probe_read(40237, 3, "holding") - local l1_a, l2_a, l3_a = 0, 0, 0 - if meter_a_registers then - l1_a = scale(host.decode_i16(meter_a_registers[1]), meter_a_factor) - l2_a = scale(host.decode_i16(meter_a_registers[2]), meter_a_factor) - l3_a = scale(host.decode_i16(meter_a_registers[3]), meter_a_factor) - end - - local meter_v_registers = probe_read(40242, 3, "holding") - local l1_v, l2_v, l3_v = 0, 0, 0 - if meter_v_registers then - l1_v = scale(host.decode_i16(meter_v_registers[1]), meter_v_factor) - l2_v = scale(host.decode_i16(meter_v_registers[2]), meter_v_factor) - l3_v = scale(host.decode_i16(meter_v_registers[3]), meter_v_factor) - end - - local meter_hz_registers = probe_read(40250, 1, "holding") - local meter_hz = 0 - if meter_hz_registers then - meter_hz = scale(meter_hz_registers[1], meter_hz_factor) - end - - local meter_w_registers = probe_read(40252, 1, "holding") - local meter_w = 0 - if meter_w_registers then - meter_w = scale(host.decode_i16(meter_w_registers[1]), meter_w_factor) - end - - local phase_w_registers = probe_read(40253, 3, "holding") - local l1_w, l2_w, l3_w = 0, 0, 0 - if phase_w_registers then - l1_w = scale(host.decode_i16(phase_w_registers[1]), meter_w_factor) - l2_w = scale(host.decode_i16(phase_w_registers[2]), meter_w_factor) - l3_w = scale(host.decode_i16(phase_w_registers[3]), meter_w_factor) - end - - local export_registers = probe_read(40272, 4, "holding") - local export_wh = 0 - if export_registers then - export_wh = scale(host.decode_u32_be(export_registers[1], export_registers[2]), meter_energy_factor) - end - - local import_registers = probe_read(40280, 4, "holding") - local import_wh = 0 - if import_registers then - import_wh = scale(host.decode_u32_be(import_registers[1], import_registers[2]), meter_energy_factor) - end - - host.emit("meter", { - W = meter_w, - L1_W = l1_w, - L2_W = l2_w, - L3_W = l3_w, - L1_V = l1_v, - L2_V = l2_v, - L3_V = l3_v, - L1_A = l1_a, - L2_A = l2_a, - L3_A = l3_a, - Hz = meter_hz, - total_import_Wh = import_wh, - total_export_Wh = export_wh, - }) - - return 5000 -end - -local function encode_i32_be(value) - local rounded - if value < 0 then - rounded = math.ceil(value - 0.5) - else - rounded = math.floor(value + 0.5) - end - if rounded < 0 then rounded = rounded + 0x100000000 end - return math.floor(rounded / 0x10000) % 0x10000, rounded % 0x10000 -end - -local function read_i32_be(address) - local registers, err = host.modbus_read(address, 2, "holding") - if err then return nil, tostring(err) end - if type(registers) ~= "table" or type(registers[1]) ~= "number" or type(registers[2]) ~= "number" then - return nil, "missing setpoint registers" - end - return host.decode_i32_be(registers[1], registers[2]), nil -end - -local function write_setpoint(power_w) - local pixii_w = -power_w - local hi, lo = encode_i32_be(pixii_w) - local write_error = host.write_registers(REG_SETPOINT_HI, {hi, lo}) - if write_error then - return false, "setpoint write failed: " .. tostring(write_error) - end - - heartbeat = (heartbeat + 1) % 100 - local heartbeat_error = host.write(REG_HEARTBEAT, heartbeat) - if heartbeat_error then - return false, "heartbeat write failed: " .. tostring(heartbeat_error) - end - - local actual, read_error = read_i32_be(REG_SETPOINT_HI) - if read_error then return false, "setpoint readback failed: " .. read_error end - if actual ~= pixii_w then return false, "setpoint readback mismatch" end - return true, nil -end - -local function failed(code, message, state) - return { - status = "failed", - code = code, - message = message, - device_state = state or "unknown", - } -end - -function driver_command_v2(command) - if command.runtime_action ~= "battery" then - return {status = "rejected", code = "unknown_action", device_state = "unchanged"} - end - - local inputs = command.inputs or {} - local power_w = inputs.power_w - if type(power_w) ~= "number" or power_w ~= power_w or - power_w < -2147483647 or power_w > 2147483647 then - return {status = "rejected", code = "invalid_power", device_state = "unchanged"} - end - if last_calibrating == true then - return {status = "rejected", code = "device_calibrating", device_state = "unchanged"} - end - - local ok, err = write_setpoint(power_w) - if not ok then return failed("setpoint_failed", err) end - return { - status = "applied", - code = power_w == 0 and "default_restored" or "ok", - device_state = power_w == 0 and "default" or "controlled", - evidence = {"write_ack", "readback", "heartbeat_ack"}, - applied = {power_w = power_w}, - } -end - -function driver_default_mode_v2(context) - local ok, err = write_setpoint(0) - if not ok then return failed("default_write_failed", err) end - return { - status = "defaulted", - code = "default_restored", - device_state = "default", - evidence = {"write_ack", "readback", "heartbeat_ack"}, - } -end - -function driver_cleanup() -end diff --git a/packages/v1/sdm630/package-source.json b/packages/v1/sdm630/package-source.json deleted file mode 100644 index cb589e1..0000000 --- a/packages/v1/sdm630/package-source.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "schema_version": "sourceful.driver-package-source/v1", - "package_id": "com.sourceful.driver.sdm630", - "version": "2.0.0", - "channel": "beta", - "display_name": "Eastron SDM630", - "identity": { - "schema": "sourceful.hardware-identity/v1", - "make": "driver_reported", - "serial": "driver_reported_when_available", - "host_fallbacks": ["mac", "endpoint"], - "persistent_state_owner": "host" - }, - "source": { - "repository": "https://github.com/srcfl/device-drivers", - "path": "drivers/lua/sdm630.lua" - }, - "builder_id": "https://github.com/srcfl/device-drivers/blob/main/tools/driver_package.py", - "device_matches": [ - { - "manufacturer": "Eastron", - "model_family": "SDM630", - "variants": ["SDM630-Modbus"], - "regions": [] - } - ], - "capabilities": { - "telemetry": ["meter"], - "control": [] - }, - "permissions": ["modbus.read"], - "telemetry": { - "schema": "sourceful.telemetry/v2", - "sign_convention": "sourceful.site-import-positive/v1", - "streams": [ - { - "kind": "meter", - "power_field": "w", - "meaning": "Positive is grid import and negative is grid export at the site boundary." - } - ] - }, - "commands": [], - "read_only": true, - "default_mode": { - "strategy": "not_applicable", - "description": "The meter exposes telemetry only and has no control state." - }, - "lease_policy": { - "required_for_control": false, - "expiry_action": "not_applicable" - }, - "rollback": { - "strategy": "install_previous_verified_package", - "state_owner": "host", - "automatic": false - }, - "compatibility": [ - { - "target": "ftw-core", - "artifact_id": "ftw.lua51.source", - "host": { - "product": "ftw", - "min_version": "1.4.0", - "max_version_exclusive": "2.0.0" - }, - "runtime": { - "name": "gopher-lua", - "semantics": "lua-5.1", - "version": "1.1.2", - "abi": "gopher-lua-source-v1", - "host_api": { - "profile": "sourceful.host/ftw-core/v1", - "min": 1, - "max": 1 - } - }, - "control_enabled": false - }, - { - "target": "blixt-l1", - "artifact_id": "blixt.lua51.source", - "host": { - "product": "blixt-gateway", - "min_version": "0.1.0", - "max_version_exclusive": "1.0.0" - }, - "runtime": { - "name": "luajit", - "semantics": "lua-5.1", - "version": "2.1", - "abi": "mlua-0.10-luajit21-source-v1", - "host_api": { - "profile": "sourceful.host/blixt-l1/v1", - "min": 1, - "max": 1 - } - }, - "control_enabled": false - } - ], - "artifact_inputs": [ - { - "artifact_id": "ftw.lua51.source", - "target": "ftw-core", - "media_type": "application/vnd.sourceful.lua.source", - "input_path": "drivers/lua/sdm630.lua", - "transform": "copy", - "extension": "lua" - }, - { - "artifact_id": "blixt.lua51.source", - "target": "blixt-l1", - "media_type": "application/vnd.sourceful.lua.source", - "input_path": "drivers/lua/sdm630.lua", - "transform": "copy", - "extension": "lua" - } - ] -} diff --git a/packages/v1/sungrow/PILOT.md b/packages/v1/sungrow/PILOT.md deleted file mode 100644 index b5b0d6a..0000000 --- a/packages/v1/sungrow/PILOT.md +++ /dev/null @@ -1,51 +0,0 @@ -# Sungrow FTW observe-only pilot - -Do not install Sungrow 1.3.1 on a live site. It contains write code even -though package control is off. - -Version 1.3.2 is read-only. It has no approved model and firmware profile yet, -so `driver_init` stops before the first device read. Add a profile only after -review of the exact register map. - -The telemetry code comes from FTW `drivers/sungrow.lua` at commit -`699873db3e7abe81f76e8110d1cefa4a38ba6efb`, SHA-256 -`466a5f8637e6756fc2e1af4197d4edc1845474231413c0016f0e5900acb7b7ac`. -The import keeps the old ID `sungrow-shx` and version 1.1.0 in the source map. -The package uses the canonical alias `sungrow`. - -## Site facts required before profile review - -- Exact inverter family and model. The known families are SG-CX, SG-RT, SH-RS, - SH-RT and SH-T. This target lists only FTW's tested SH-RT models. Any other - family needs its own reviewed protocol profile. -- Exact firmware version. -- Connection type and port. Modbus TCP defaults to port 502. -- Modbus device or unit ID. The target defaults to 1. -- Register-map title, revision and a public link or document ID. - -Keep the inverter address, serial number, credentials, site ID and raw config -out of issues and inventory. - -## Telemetry comparison - -Compare FTW with the inverter screen or vendor app at the same time: - -- PV power, lifetime energy, rated power and inverter temperature. -- Battery power and direction, voltage, current, state of charge, charged - energy and discharged energy. -- Grid power and direction, phase power, phase voltage, phase current, - frequency, imported energy and exported energy. - -Record units, sign, scale, sample time and any value that the inverter or app -does not expose. Stop if the model, firmware or register map differs from the -approved profile. - -## Release evidence - -Before signed activation, record the exact public commit and tree, private -source lock, built artifact hash and signed beta index entry. In FTW, verify -install, observe-only activation, rollback to the prior driver and the Nova -inventory identity. - -All write and control work stays blocked until FTW process and heap isolation, -leases, default mode, command results and physical HIL have passed review. diff --git a/packages/v1/sungrow/package-source.json b/packages/v1/sungrow/package-source.json deleted file mode 100644 index 6352184..0000000 --- a/packages/v1/sungrow/package-source.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "schema_version": "sourceful.driver-package-source/v1", - "package_id": "com.sourceful.driver.sungrow", - "version": "1.3.3", - "channel": "beta", - "display_name": "Sungrow observe-only pilot", - "identity": { - "schema": "sourceful.hardware-identity/v1", - "make": "driver_reported", - "serial": "driver_reported_when_available", - "host_fallbacks": ["mac", "endpoint"], - "persistent_state_owner": "host" - }, - "source": { - "repository": "https://github.com/srcfl/device-drivers", - "path": "packages/v1/sungrow/targets/ftw-observe.lua" - }, - "builder_id": "https://github.com/srcfl/device-drivers/blob/main/tools/driver_package.py", - "device_matches": [ - { - "manufacturer": "Sungrow", - "model_family": "SH-RT (Three-Phase Hybrid)", - "variants": ["SH5.0RT", "SH6.0RT", "SH8.0RT", "SH10RT"], - "regions": ["EU"] - } - ], - "capabilities": { - "telemetry": ["battery", "meter", "pv"], - "control": [] - }, - "permissions": ["modbus.read"], - "telemetry": { - "schema": "sourceful.telemetry/v2", - "sign_convention": "sourceful.site-import-positive/v1", - "streams": [ - { - "kind": "battery", - "power_field": "w", - "meaning": "Positive is charging and negative is discharging." - }, - { - "kind": "meter", - "power_field": "w", - "meaning": "Positive is grid import and negative is grid export at the site boundary." - }, - { - "kind": "pv", - "power_field": "w", - "meaning": "PV generation is negative because it reduces site import." - } - ] - }, - "commands": [], - "read_only": true, - "default_mode": { - "strategy": "not_applicable", - "description": "The observe-only target never changes inverter state." - }, - "lease_policy": { - "required_for_control": false, - "expiry_action": "not_applicable" - }, - "rollback": { - "strategy": "install_previous_verified_package", - "state_owner": "host", - "automatic": false - }, - "compatibility": [ - { - "target": "ftw-core", - "artifact_id": "ftw.lua51.source", - "host": { - "product": "ftw", - "min_version": "1.7.0-beta.1", - "max_version_exclusive": "2.0.0" - }, - "runtime": { - "name": "gopher-lua", - "semantics": "lua-5.1", - "version": "1.1.2", - "abi": "gopher-lua-source-v1", - "host_api": { - "profile": "sourceful.host/ftw-core/v1", - "min": 1, - "max": 1 - } - }, - "control_enabled": false - } - ], - "artifact_inputs": [ - { - "artifact_id": "ftw.lua51.source", - "target": "ftw-core", - "media_type": "application/vnd.sourceful.lua.source", - "input_path": "packages/v1/sungrow/targets/ftw-observe.lua", - "transform": "copy", - "extension": "lua" - } - ] -} diff --git a/packages/v1/sungrow/targets/ftw-observe.lua b/packages/v1/sungrow/targets/ftw-observe.lua deleted file mode 100644 index 32d3404..0000000 --- a/packages/v1/sungrow/targets/ftw-observe.lua +++ /dev/null @@ -1,424 +0,0 @@ --- Generated by tools/derive_sungrow_ftw_observe.py. --- Source: srcfl/ftw@699873db3e7abe81f76e8110d1cefa4a38ba6efb --- Path: drivers/sungrow.lua --- SHA-256: 466a5f8637e6756fc2e1af4197d4edc1845474231413c0016f0e5900acb7b7ac --- The fingerprint and poll functions stay byte-for-byte equal to the FTW --- legacy source. Init and lifecycle code remove every write path. - -DRIVER = { - host_api_min = 1, - host_api_max = 1, - id = "sungrow", - name = "Sungrow observe-only pilot", - manufacturer = "Sungrow", - version = "1.3.2", - read_only = true, - legacy_ids = { "sungrow-shx" }, - protocols = { "modbus" }, - capabilities = { "meter", "pv", "battery" }, - description = "Sungrow SH-series telemetry via Modbus TCP.", - homepage = "https://en.sungrowpower.com", - authors = { "FTW contributors" }, - tested_models = { "SH5.0RT", "SH6.0RT", "SH8.0RT", "SH10RT" }, - verification_status = "experimental", - verification_notes = "Observe only. Exact pilot model and firmware approval is required.", - connection_defaults = { - port = 502, - unit_id = 1, - }, -} - -PROTOCOL = "modbus" - -local APPROVED_MODEL_FIRMWARE_PROFILES = {} -local sn_read = false -local rated_ac_w = 0 -local pv_curtail_method = "active_power" -local latest_non_export_w = 0 - -local function bit_is_set(value, bit) - return math.floor(value / (2 ^ bit)) % 2 == 1 -end - -function driver_fingerprint() - local ok, regs = pcall(host.modbus_read, 4999, 1, "input") - if not ok or regs == nil or regs[1] == nil then - return nil - end - local code = regs[1] - if code == 0 or code == 0xFFFF then - return nil -- empty / sentinel read — inconclusive - end - local hi = math.floor(code / 256) - if hi == 0x0E or hi == 0x0D then - return true, { - make = "Sungrow", - model = string.format("0x%04X", code), - confidence = 0.9, - } - end - return false -- responded, but not a Sungrow SH hybrid signature -end - -function driver_init(config) - config = config or {} - local model = tostring(config.model or "") - local firmware = tostring(config.firmware or "") - if not APPROVED_MODEL_FIRMWARE_PROFILES[model .. "|" .. firmware] then - error( - "Sungrow observe-only profile is not approved for model '" .. - model .. "' and firmware '" .. firmware .. "'" - ) - end - - host.set_make("Sungrow") - local ok, dev = pcall(host.modbus_read, 4999, 1, "input") - if ok and dev then - host.log("info", "Device type code: " .. tostring(dev[1])) - end - local ok_rated, rated = pcall(host.modbus_read, 5000, 1, "input") - if ok_rated and rated and rated[1] and rated[1] > 0 then - rated_ac_w = rated[1] * 0.1 * 1000 - end - local ok_ems, ems = pcall(host.modbus_read, 13049, 3, "holding") - if ok_ems and ems then - host.log("info", "EMS state: mode=" .. tostring(ems[1]) - .. " cmd=0x" .. string.format("%04x", ems[2]) - .. " power=" .. tostring(ems[3]) .. "W") - end -end - -function driver_poll() - -- Read serial number once - if not sn_read then - local ok, sn_regs = pcall(host.modbus_read, 4990, 10, "input") - if ok and sn_regs then - local sn = "" - for i = 1, 10 do - local hi = math.floor(sn_regs[i] / 256) - local lo = sn_regs[i] % 256 - if hi > 32 and hi < 127 then sn = sn .. string.char(hi) end - if lo > 32 and lo < 127 then sn = sn .. string.char(lo) end - end - if string.len(sn) > 0 then - host.set_sn(sn) - sn_read = true - end - end - end - - -- Sungrow running state is documented register 13000 (host address - -- 12999). Power-flow status is documented 13001 (host address 13000): - -- bit 2 = discharging, bit 1 = charging. Keep both as diagnostics so a - -- reachable-but-stopped inverter is no longer misreported as merely 0 W. - local ok_running, running_regs = pcall(host.modbus_read, 12999, 1, "input") - local running_state = 0 - if ok_running and running_regs then running_state = running_regs[1] end - - local ok_flow, flow_regs = pcall(host.modbus_read, 13000, 1, "input") - local power_flow_status = 0 - if ok_flow and flow_regs then power_flow_status = flow_regs[1] end - local is_discharging = (math.floor(power_flow_status / 4) % 2) == 1 - - -- PV power: 5016-5017, U32 LE, watts - -- Observed on at least one SH-series firmware: this register stays - -- at 0 even while MPPT1/MPPT2 V×I clearly indicate generation. So we - -- also read the MPPT voltage+current pairs separately and use their - -- product as a fallback when the top-level register doesn't match. - local ok_pv, pv_regs = pcall(host.modbus_read, 5016, 2, "input") - local pv_w_primary = 0 - local pv_raw_u32 = 0 - if ok_pv and pv_regs then - pv_w_primary = host.decode_u32_le(pv_regs[1], pv_regs[2]) - pv_raw_u32 = pv_w_primary - end - - -- PV MPPT: 5010-5013 (V×0.1, A×0.1 per string) - local ok_mppt, mppt_regs = pcall(host.modbus_read, 5010, 4, "input") - local mppt1_v, mppt1_a, mppt2_v, mppt2_a = 0, 0, 0, 0 - if ok_mppt and mppt_regs then - mppt1_v = mppt_regs[1] * 0.1 - mppt1_a = mppt_regs[2] * 0.1 - mppt2_v = mppt_regs[3] * 0.1 - mppt2_a = mppt_regs[4] * 0.1 - end - local mppt1_w = mppt1_v * mppt1_a - local mppt2_w = mppt2_v * mppt2_a - local pv_w_mppt = mppt1_w + mppt2_w - - -- Resolve PV power with a fallback ladder: - -- 1. Trust 5016-5017 if it reports > 50 W (clearly live). - -- 2. Otherwise use MPPT sum if that reports > 50 W (primary - -- register stuck / firmware quirk). - -- 3. Zero — panels aren't generating right now. - -- 50 W threshold filters out noise-floor readings without swallowing - -- genuine low-light output. - local pv_w = 0 - local pv_source = "zero" - if pv_w_primary > 50 then - pv_w = pv_w_primary - pv_source = "primary_reg" - elseif pv_w_mppt > 50 then - pv_w = pv_w_mppt - pv_source = "mppt_sum" - end - - -- PV lifetime energy: 13002-13003, U32 LE × 0.1 kWh - local ok_pvgen, pvgen_regs = pcall(host.modbus_read, 13002, 2, "input") - local pv_gen_wh = 0 - if ok_pvgen and pvgen_regs then - pv_gen_wh = host.decode_u32_le(pvgen_regs[1], pvgen_regs[2]) * 0.1 * 1000 - end - - -- Rated power: 5000, U16 × 0.1 kW - local ok_rated, rated_regs = pcall(host.modbus_read, 5000, 1, "input") - local rated_w = rated_ac_w - if ok_rated and rated_regs then - rated_w = rated_regs[1] * 0.1 * 1000 - if rated_w > 0 then rated_ac_w = rated_w end - end - - -- Heatsink temp: 5007, I16 × 0.1 C - local ok_temp, temp_regs = pcall(host.modbus_read, 5007, 1, "input") - local heatsink_c = 0 - if ok_temp and temp_regs then - heatsink_c = host.decode_i16(temp_regs[1]) * 0.1 - end - - -- Grid frequency: 5241, U16 × 0.01 Hz - local ok_hz, hz_regs = pcall(host.modbus_read, 5241, 1, "input") - local hz = 0 - if ok_hz and hz_regs then - hz = hz_regs[1] * 0.01 - end - - host.emit("pv", { - w = -pv_w, -- negative = generation (EMS convention) - mppt1_v = mppt1_v, - mppt1_a = mppt1_a, - mppt2_v = mppt2_v, - mppt2_a = mppt2_a, - mppt1_w = mppt1_w, - mppt2_w = mppt2_w, - pv_source = pv_source, -- "primary_reg" | "mppt_sum" | "zero" - lifetime_wh = pv_gen_wh, - rated_w = rated_w, - temp_c = heatsink_c, - }) - -- Diagnostics: long-format TS DB. Surface BOTH the primary-register - -- reading and the MPPT-derived fallback so operators can see when - -- the two disagree (= firmware register quirk) directly in the - -- metric browser. - host.emit_metric("pv_w_primary", pv_w_primary) - host.emit_metric("pv_w_mppt_sum", pv_w_mppt) - host.emit_metric("pv_raw_u32", pv_raw_u32) - host.emit_metric("pv_mppt1_v", mppt1_v) - host.emit_metric("pv_mppt1_a", mppt1_a) - host.emit_metric("pv_mppt1_w", mppt1_w) - host.emit_metric("pv_mppt2_v", mppt2_v) - host.emit_metric("pv_mppt2_a", mppt2_a) - host.emit_metric("pv_mppt2_w", mppt2_w) - host.emit_metric("inverter_temp_c", heatsink_c) - host.emit_metric("grid_hz", hz) - host.emit_metric("sungrow_running_state", running_state) - host.emit_metric("sungrow_power_flow_status", power_flow_status) - host.emit_metric("sungrow_pv_curtail_method", pv_curtail_method == "feed_in" and 2 or 1) - - -- Contiguous bitfields from documented input registers 13050-13079 - -- (zero-based host addresses 13049-13078). One read gives enough evidence - -- to distinguish thermal, grid, PV/DC, battery and BMS shutdowns. - local ok_faults, fault_regs = pcall(host.modbus_read, 13049, 30, "input") - local fault_values = {} - if ok_faults and fault_regs and #fault_regs >= 30 then - local fault_names = { - "sungrow_inverter_alarm_bits", - "sungrow_grid_fault_bits", - "sungrow_system_fault1_bits", - "sungrow_system_fault2_bits", - "sungrow_dc_fault_bits", - "sungrow_permanent_fault_bits", - "sungrow_bdc_fault_bits", - "sungrow_bdc_permanent_fault_bits", - "sungrow_battery_fault_bits", - "sungrow_battery_alarm_bits", - "sungrow_bms_alarm_bits", - "sungrow_bms_protection_bits", - "sungrow_bms_fault1_bits", - "sungrow_bms_fault2_bits", - "sungrow_bms_alarm2_bits", - } - for i, name in ipairs(fault_names) do - local j = (i - 1) * 2 + 1 - local value = host.decode_u32_le(fault_regs[j], fault_regs[j + 1]) - fault_values[i] = value - host.emit_metric(name, value) - end - end - - -- A faulted inverter can keep returning perfectly fresh Modbus telemetry, - -- which previously made /api/status and Diagnose say "ok" while PV and - -- battery actuation were physically unavailable. Surface Sungrow's actual - -- running state through the host's device-fault channel. RecordSuccess does - -- not clear this flag; only a later non-fault running-state poll does. - if running_state == 0x5500 or running_state == 0x0100 then - local reason = string.format("Sungrow fault (running state 0x%04X)", running_state) - local system_fault2 = fault_values[4] or 0 - if bit_is_set(system_fault2, 1) then - reason = string.format( - "Sungrow fault: excessively high ambient temperature (%.1f C, state 0x%04X)", - heatsink_c, running_state) - end - host.set_device_fault(true, reason) - elseif ok_running and running_regs and running_regs[1] ~= nil then - host.set_device_fault(false, "") - end - - -- Battery: 13019-13022 (voltage, current, power, SoC) - local ok_bat, bat_regs = pcall(host.modbus_read, 13019, 4, "input") - local bat_v, bat_a, bat_w, bat_soc = 0, 0, 0, 0 - if ok_bat and bat_regs then - bat_v = bat_regs[1] * 0.1 - bat_a = bat_regs[2] * 0.1 - bat_w = bat_regs[3] - bat_soc = bat_regs[4] * 0.1 / 100 -- 0-1 fraction - end - - -- Apply sign: Sungrow reports power as unsigned, direction from status register - -- EMS convention: positive = charging, negative = discharging - if is_discharging then - bat_w = -bat_w - end - - -- Battery charge energy: 13040-13041, U32 LE × 0.1 kWh - local ok_bchg, bchg_regs = pcall(host.modbus_read, 13040, 2, "input") - local bat_charge_wh = 0 - if ok_bchg and bchg_regs then - bat_charge_wh = host.decode_u32_le(bchg_regs[1], bchg_regs[2]) * 0.1 * 1000 - end - - -- Battery discharge energy: 13026-13027, U32 LE × 0.1 kWh - local ok_bdis, bdis_regs = pcall(host.modbus_read, 13026, 2, "input") - local bat_discharge_wh = 0 - if ok_bdis and bdis_regs then - bat_discharge_wh = host.decode_u32_le(bdis_regs[1], bdis_regs[2]) * 0.1 * 1000 - end - - host.emit("battery", { - w = bat_w, - v = bat_v, - a = bat_a, - soc = bat_soc, - charge_wh = bat_charge_wh, - discharge_wh = bat_discharge_wh, - }) - host.emit_metric("battery_dc_v", bat_v) - host.emit_metric("battery_dc_a", bat_a) - - -- EMS state diagnostics — what the inverter *actually* has latched - -- in its control registers right now. With the #164 write-order fix - -- these should track whatever the dispatcher sent last tick; any - -- drift between target and ems_force_w points at external writers - -- (iSolarCloud, HA integration, another EMS) racing the driver. - local ok_emsd, emsd = pcall(host.modbus_read, 13049, 3, "holding") - if ok_emsd and emsd then - host.emit_metric("sungrow_ems_mode", emsd[1]) -- 0=self, 2=forced, 3=ext - host.emit_metric("sungrow_force_cmd", emsd[2]) -- 0xAA=170 chg, 0xBB=187 dis, 0xCC=204 stop - host.emit_metric("sungrow_force_w", emsd[3]) - end - - -- Grid meter power: 5600-5601, I32 LE, watts (positive=import, negative=export) - local ok_mw, mw_regs = pcall(host.modbus_read, 5600, 2, "input") - local meter_w = 0 - if ok_mw and mw_regs then - meter_w = host.decode_i32_le(mw_regs[1], mw_regs[2]) - end - - -- Per-phase power: 5602-5607, I32 LE pairs - local ok_mp, mp_regs = pcall(host.modbus_read, 5602, 6, "input") - local l1_w, l2_w, l3_w = 0, 0, 0 - if ok_mp and mp_regs then - l1_w = host.decode_i32_le(mp_regs[1], mp_regs[2]) - l2_w = host.decode_i32_le(mp_regs[3], mp_regs[4]) - l3_w = host.decode_i32_le(mp_regs[5], mp_regs[6]) - end - - -- Per-phase voltage: 5740-5742, U16 × 0.1 V - local ok_mv, mv_regs = pcall(host.modbus_read, 5740, 3, "input") - local l1_v, l2_v, l3_v = 0, 0, 0 - if ok_mv and mv_regs then - l1_v = mv_regs[1] * 0.1 - l2_v = mv_regs[2] * 0.1 - l3_v = mv_regs[3] * 0.1 - end - - -- Per-phase current: 5743-5745, U16 × 0.01 A - local ok_ma, ma_regs = pcall(host.modbus_read, 5743, 3, "input") - local l1_a, l2_a, l3_a = 0, 0, 0 - if ok_ma and ma_regs then - l1_a = ma_regs[1] * 0.01 - l2_a = ma_regs[2] * 0.01 - l3_a = ma_regs[3] * 0.01 - end - - -- Import energy: 13036-13037, U32 LE × 0.1 kWh - local ok_imp, imp_regs = pcall(host.modbus_read, 13036, 2, "input") - local import_wh = 0 - if ok_imp and imp_regs then - import_wh = host.decode_u32_le(imp_regs[1], imp_regs[2]) * 0.1 * 1000 - end - - -- Export energy: 13045-13046, U32 LE × 0.1 kWh - local ok_exp, exp_regs = pcall(host.modbus_read, 13045, 2, "input") - local export_wh = 0 - if ok_exp and exp_regs then - export_wh = host.decode_u32_le(exp_regs[1], exp_regs[2]) * 0.1 * 1000 - end - - host.emit("meter", { - w = meter_w, - l1_w = l1_w, - l2_w = l2_w, - l3_w = l3_w, - l1_v = l1_v, - l2_v = l2_v, - l3_v = l3_v, - l1_a = l1_a, - l2_a = l2_a, - l3_a = l3_a, - hz = hz, - import_wh = import_wh, - export_wh = export_wh, - }) - host.emit_metric("meter_l1_w", l1_w) - host.emit_metric("meter_l2_w", l2_w) - host.emit_metric("meter_l3_w", l3_w) - host.emit_metric("meter_l1_v", l1_v) - host.emit_metric("meter_l2_v", l2_v) - host.emit_metric("meter_l3_v", l3_v) - host.emit_metric("meter_l1_a", l1_a) - host.emit_metric("meter_l2_a", l2_a) - host.emit_metric("meter_l3_a", l3_a) - - -- Feed-in fallback translation: a PV allowance is the power that may - -- serve local load, charge the battery, or leave the site. Sungrow's - -- legacy SHxRT register controls only the last term, so subtract current - -- non-export absorption. This preserves useful self-consumption while - -- preventing paid/negative-price export. A manual cap below live local - -- absorption cannot force DC PV lower on this firmware; it safely floors - -- the export allowance at zero instead. - local load_w = meter_w - bat_w + pv_w - latest_non_export_w = math.max(0, load_w + math.max(0, bat_w)) - - return 5000 -end - -function driver_command(action, value, context) - return nil, "Sungrow observe-only target has no commands" -end - -function driver_default_mode() - return nil -end - -function driver_cleanup() -end diff --git a/packages/v1/sungrow/targets/ftw.lua b/packages/v1/sungrow/targets/ftw.lua deleted file mode 100644 index e2cca02..0000000 --- a/packages/v1/sungrow/targets/ftw.lua +++ /dev/null @@ -1,470 +0,0 @@ --- Sungrow SH Hybrid Inverter Driver --- FTW control-v2 target adapter, based on the read and telemetry logic in --- drivers/lua/sungrow.lua 1.2.0. Keep target changes here until all hosts use --- the canonical v2 lifecycle and result contract. --- Emits: PV, Battery, Meter --- Register type: INPUT --- Byte order: Little-Endian for multi-register values - -DRIVER = { - host_api_min = 2, - host_api_max = 2, - id = "sungrow", - name = "Sungrow hybrid and string inverter", - manufacturer = "Sungrow", - version = "1.3.5", - protocols = { "modbus" }, - capabilities = { "pv", "battery", "meter" }, - description = "Sungrow hybrid and string inverters via Modbus.", - authors = { "Sourceful contributors" }, - verification_status = "experimental", - verification_notes = "FTW v2 control adapter is staged off until runtime isolation and physical HIL pass.", - connection_defaults = { - port = 502, - unit_id = 1, - }, -} - -PROTOCOL = "modbus" - -local sn_read = false - --- Sungrow model families do not share one register map. SH hybrids answer the --- 13xxx block; SG string inverters have no battery and no such block, and a --- site without a Sungrow meter answers none of the 56xx/57xx meter registers. --- The host counts every failed Modbus read against the poll, so an SG inverter --- read with the hybrid map failed the whole poll and went offline instead of --- reporting the PV it does have. --- --- The device type code says which family this is, so ask the inverter rather --- than discovering it through failed reads. Only when that register is --- unreadable do we fall back on probing. -local REG_DEVICE_TYPE = 4999 -local model_family = nil -- "hybrid", "string", or nil while still unknown - --- Set once the battery registers have actually answered. The device type code --- is the fast answer to "does this have a battery"; this is the slow one, and --- the only one available when register 4999 never answers. -local battery_confirmed = false - --- A register that answers on one model and not on another. Absence has to be --- proved, not guessed: a single timeout or a busy bus must not silence a --- register for the rest of the session, so only a run of failures counts. -local MISSES_BEFORE_SKIP = 3 -local miss_counts = {} - -local function optional_read(address, count, kind) - if (miss_counts[address] or 0) >= MISSES_BEFORE_SKIP then - return nil - end - local ok, registers = pcall(host.modbus_read, address, count, kind) - if ok and registers ~= nil and registers[1] ~= nil then - miss_counts[address] = nil - return registers - end - local misses = (miss_counts[address] or 0) + 1 - miss_counts[address] = misses - if misses >= MISSES_BEFORE_SKIP then - host.log("info", "Sungrow: register " .. address - .. " failed " .. misses .. " reads in a row; treating it as absent") - end - return nil -end - --- 0x0D and 0x0E are the SH hybrid device-type families. Any other clean code --- is a string inverter: PV, an optional meter, and no battery. -local function detect_model_family() - local ok, regs = pcall(host.modbus_read, REG_DEVICE_TYPE, 1, "input") - if not ok or regs == nil or regs[1] == nil then - return nil - end - local code = regs[1] - if code == 0 or code == 0xFFFF then - return nil -- empty / sentinel read, inconclusive - end - local hi = math.floor(code / 256) - if hi == 0x0D or hi == 0x0E then - return "hybrid" - end - return "string" -end - --- True while the hybrid block is worth reading: on a known string inverter it --- never is, and on an unknown model we let optional_read decide. -local function hybrid_block_worth_reading() - return model_family ~= "string" -end - -function driver_init(config) - host.set_make("Sungrow") -end - -function driver_poll() - -- Read serial number once on first successful poll (Modbus connected by now) - if not sn_read then - local ok, sn_regs = pcall(host.modbus_read, 4990, 10, "input") - if ok and sn_regs then - local sn = "" - for i = 1, 10 do - local hi = math.floor(sn_regs[i] / 256) - local lo = sn_regs[i] % 256 - if hi > 32 and hi < 127 then sn = sn .. string.char(hi) end - if lo > 32 and lo < 127 then sn = sn .. string.char(lo) end - end - if string.len(sn) > 0 then - host.set_sn(sn) - sn_read = true - end - end - end - - -- Ask the inverter which family it belongs to before reading a map that - -- may not apply. Retried while unknown; the code register is cheap. - if model_family == nil then - model_family = detect_model_family() - if model_family then - host.log("info", "Sungrow: detected a " .. model_family .. " inverter") - end - end - - -- Read status flags to determine battery direction. Hybrid only. - local status = 0 - if hybrid_block_worth_reading() then - local status_regs = optional_read(13000, 1, "input") - if status_regs then - status = status_regs[1] - end - end - -- Lua 5.1 compatible bit check: bit 2 (0x0004) set means discharging - local is_discharging = (math.floor(status / 4) % 2) == 1 - - -- PV power: 5016-5017, U32 LE, watts - local ok_pv, pv_regs = pcall(host.modbus_read, 5016, 2, "input") - local pv_w = 0 - if ok_pv and pv_regs then - pv_w = host.decode_u32_le(pv_regs[1], pv_regs[2]) - end - - -- PV MPPT voltages and currents: 5010-5013 - local ok_mppt, mppt_regs = pcall(host.modbus_read, 5010, 4, "input") - local mppt1_v, mppt1_a, mppt2_v, mppt2_a = 0, 0, 0, 0 - if ok_mppt and mppt_regs then - mppt1_v = mppt_regs[1] * 0.1 - mppt1_a = mppt_regs[2] * 0.1 - mppt2_v = mppt_regs[3] * 0.1 - mppt2_a = mppt_regs[4] * 0.1 - end - - -- PV generation energy: 13002-13003, U32 LE × 0.1 kWh. Hybrid only. - local pv_gen_wh = 0 - if hybrid_block_worth_reading() then - local pvgen_regs = optional_read(13002, 2, "input") - if pvgen_regs then - pv_gen_wh = host.decode_u32_le(pvgen_regs[1], pvgen_regs[2]) * 0.1 * 1000 - end - end - - -- Rated power: 5000, U16 × 0.1 kW - local ok_rated, rated_regs = pcall(host.modbus_read, 5000, 1, "input") - local rated_w = 0 - if ok_rated and rated_regs then - rated_W = rated_regs[1] * 0.1 * 1000 - end - - -- Heatsink temp: 5007, I16 × 0.1 C - local ok_temp, temp_regs = pcall(host.modbus_read, 5007, 1, "input") - local heatsink_c = 0 - if ok_temp and temp_regs then - heatsink_c = host.decode_i16(temp_regs[1]) * 0.1 - end - - -- Frequency: 5241, U16 × 0.01 Hz - local ok_hz, hz_regs = pcall(host.modbus_read, 5241, 1, "input") - local hz = 0 - if ok_hz and hz_regs then - hz = hz_regs[1] * 0.01 - end - - -- Emit PV telemetry (W always negative for generation) - host.emit("pv", { - W = -pv_w, - mppt1_v = mppt1_v, - mppt1_a = mppt1_a, - mppt2_v = mppt2_v, - mppt2_a = mppt2_a, - total_generation_Wh = pv_gen_wh, - rated_W = rated_w, - temperature_C = heatsink_c, - }) - - -- Battery registers: 13019-13022. An SG string inverter has no battery and - -- answers none of these, so probe here and emit the stream only when the - -- inverter really has one. - local bat_regs = nil - if hybrid_block_worth_reading() then - bat_regs = optional_read(13019, 4, "input") - end - if bat_regs then - battery_confirmed = true - local bat_v = bat_regs[1] * 0.1 - local bat_a = bat_regs[2] * 0.1 - local bat_w = bat_regs[3] - local bat_soc = bat_regs[4] * 0.1 / 100 -- percent to fraction - - -- Negate battery W if discharging - if is_discharging then - bat_w = -bat_w - end - - -- Battery charge energy: 13040-13041, U32 LE × 0.1 kWh - local bchg_regs = optional_read(13040, 2, "input") - local bat_charge_wh = 0 - if bchg_regs then - bat_charge_wh = host.decode_u32_le(bchg_regs[1], bchg_regs[2]) * 0.1 * 1000 - end - - -- Battery discharge energy: 13026-13027, U32 LE × 0.1 kWh - local bdis_regs = optional_read(13026, 2, "input") - local bat_discharge_wh = 0 - if bdis_regs then - bat_discharge_wh = host.decode_u32_le(bdis_regs[1], bdis_regs[2]) * 0.1 * 1000 - end - - -- Emit Battery telemetry - host.emit("battery", { - W = bat_w, - V = bat_v, - A = bat_a, - SoC_nom_fract = bat_soc, - total_charge_Wh = bat_charge_wh, - total_discharge_Wh = bat_discharge_wh, - }) - end - - -- Meter registers. They exist only when a Sungrow meter is wired to the - -- inverter, so every read below is optional and the stream is emitted only - -- when at least one of them answered. - local meter_present = false - - -- Meter power: 5600-5601, I32 LE, watts - local mw_regs = optional_read(5600, 2, "input") - local meter_w = 0 - if mw_regs then - meter_w = host.decode_i32_le(mw_regs[1], mw_regs[2]) - meter_present = true - end - - -- Per-phase meter power: 5602-5607, I32 LE each pair - local mp_regs = optional_read(5602, 6, "input") - local l1_w, l2_w, l3_w = 0, 0, 0 - if mp_regs then - l1_w = host.decode_i32_le(mp_regs[1], mp_regs[2]) - l2_w = host.decode_i32_le(mp_regs[3], mp_regs[4]) - l3_w = host.decode_i32_le(mp_regs[5], mp_regs[6]) - meter_present = true - end - - -- Per-phase voltage: 5740-5742, U16 × 0.1 each - local mv_regs = optional_read(5740, 3, "input") - local l1_v, l2_v, l3_v = 0, 0, 0 - if mv_regs then - l1_v = mv_regs[1] * 0.1 - l2_v = mv_regs[2] * 0.1 - l3_v = mv_regs[3] * 0.1 - meter_present = true - end - - -- Per-phase current: 5743-5745, U16 × 0.01 each - local ma_regs = optional_read(5743, 3, "input") - local l1_a, l2_a, l3_a = 0, 0, 0 - if ma_regs then - l1_a = ma_regs[1] * 0.01 - l2_a = ma_regs[2] * 0.01 - l3_a = ma_regs[3] * 0.01 - meter_present = true - end - - -- Import and export energy: 13036-13037 and 13045-13046, U32 LE × 0.1 kWh. - -- Hybrid only. - local import_wh, export_wh = 0, 0 - if hybrid_block_worth_reading() then - local imp_regs = optional_read(13036, 2, "input") - if imp_regs then - import_wh = host.decode_u32_le(imp_regs[1], imp_regs[2]) * 0.1 * 1000 - end - - local exp_regs = optional_read(13045, 2, "input") - if exp_regs then - export_wh = host.decode_u32_le(exp_regs[1], exp_regs[2]) * 0.1 * 1000 - end - end - - -- Emit Meter telemetry - if meter_present then - host.emit("meter", { - W = meter_w, - L1_W = l1_w, - L2_W = l2_w, - L3_W = l3_w, - L1_V = l1_v, - L2_V = l2_v, - L3_V = l3_v, - L1_A = l1_a, - L2_A = l2_a, - L3_A = l3_a, - Hz = hz, - total_import_Wh = import_wh, - total_export_Wh = export_wh, - }) - end - - return 5000 -end - -local function v2_failure(code, message, device_state) - return { - status = "failed", - code = code, - message = message, - device_state = device_state or "unknown", - } -end - -local function v2_write_register(address, value) - local err = host.write(address, value) - if err then - return false, tostring(err) - end - return true, nil -end - -local function v2_read_register(address) - local registers, err = host.modbus_read(address, 1, "holding") - if err then - return nil, tostring(err) - end - if type(registers) ~= "table" or type(registers[1]) ~= "number" then - return nil, "missing register value" - end - return registers[1], nil -end - -local function v2_power_limit(power_w) - if type(power_w) ~= "number" then - return nil, "power_w must be a number" - end - local limit = math.floor(math.abs(power_w) + 0.5) - if limit > 65535 then - return nil, "power_w exceeds the Sungrow register range" - end - return limit, nil -end - -local function v2_apply_mode(mode, limit_register, limit, applied_power_w) - if limit_register then - local ok, err = v2_write_register(limit_register, limit) - if not ok then - return v2_failure("limit_write_failed", err, "unchanged") - end - end - - local ok, err = v2_write_register(13049, mode) - if not ok then - return v2_failure("mode_write_failed", err) - end - - local actual_mode, mode_err = v2_read_register(13049) - if mode_err then - return v2_failure("mode_readback_failed", mode_err) - end - if actual_mode ~= mode then - return v2_failure("mode_readback_mismatch", "operating mode did not match the request") - end - - if limit_register then - local actual_limit, limit_err = v2_read_register(limit_register) - if limit_err then - return v2_failure("limit_readback_failed", limit_err) - end - if actual_limit ~= limit then - return v2_failure("limit_readback_mismatch", "power limit did not match the request") - end - end - - return { - status = "applied", - code = mode == 0 and "default_restored" or "ok", - device_state = mode == 0 and "default" or "controlled", - evidence = {"write_ack", "readback"}, - applied = {power_w = applied_power_w}, - } -end - --- FTW control v2 entrypoint. The host validates the command envelope and --- opens the short write scope before this function runs. -function driver_command_v2(command) - local action = command.runtime_action - local inputs = command.inputs or {} - - if action == "battery" then - -- Two ways to know this device takes a battery command: it named - -- itself a hybrid, or its battery registers have answered. Refuse - -- everything else rather than write EMS registers the model may not - -- implement. Checking only for "string" left the gap: an SG inverter - -- whose device-type register never answered fell through and got the - -- writes. Matches drivers/lua/sungrow.lua, deliberately. - if model_family ~= "hybrid" and not battery_confirmed then - return { - status = "rejected", - code = "no_battery", - message = model_family == "string" - and "this Sungrow model has no battery registers" - or "no battery register has answered on this device", - device_state = "unchanged", - } - end - local limit, err = v2_power_limit(inputs.power_w) - if not limit then - return {status = "rejected", code = "invalid_power", message = err, device_state = "unchanged"} - end - if inputs.power_w > 0 then - return v2_apply_mode(1, 13050, limit, limit) - elseif inputs.power_w < 0 then - return v2_apply_mode(2, 13051, limit, -limit) - end - return v2_apply_mode(0, nil, nil, 0) - elseif action == "curtail" then - local limit, err = v2_power_limit(inputs.power_w) - if not limit then - return {status = "rejected", code = "invalid_power", message = err, device_state = "unchanged"} - end - return v2_apply_mode(1, 13050, limit, limit) - elseif action == "curtail_disable" then - return v2_apply_mode(0, nil, nil, 0) - end - - return {status = "rejected", code = "unknown_action", device_state = "unchanged"} -end - -function driver_default_mode_v2(context) - local ok, err = v2_write_register(13049, 0) - if not ok then - return v2_failure("default_write_failed", err) - end - local actual_mode, read_err = v2_read_register(13049) - if read_err then - return v2_failure("default_readback_failed", read_err) - end - if actual_mode ~= 0 then - return v2_failure("default_readback_mismatch", "operating mode did not return to auto") - end - return { - status = "defaulted", - code = "default_restored", - device_state = "default", - evidence = {"write_ack", "readback"}, - } -end - -function driver_cleanup() -end diff --git a/pyproject.toml b/pyproject.toml index 21cd0eb..5f3170d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [] [project.optional-dependencies] package = [ "cryptography>=50.0.0,<51.0.0", - "jsonschema>=4.23.0,<5.0.0", ] dev = [ "pytest>=9.0.3,<10.0.0", diff --git a/spec/blixt-lua-reference-architecture.md b/spec/blixt-lua-reference-architecture.md deleted file mode 100644 index e48b1ff..0000000 --- a/spec/blixt-lua-reference-architecture.md +++ /dev/null @@ -1,52 +0,0 @@ -# Blixt L1 as the Lua runtime reference - -This repository owns Sourceful driver source, package metadata and versions. -The private Device Support publisher owns official signed artifacts. Those -roles do not replace the runtime architecture David built in Blixt L1. Blixt -is the main reference for hardware-near Lua execution. - -## Preserve from Blixt - -- LuaJIT through `mlua`, with a target-specific ABI rather than pretending it - is the same runtime as FTW's GopherLua; -- one driver manager per hardware path and explicit device arbitration; -- queued work with control preemption, poll coalescing and bounded ordering; -- batched Modbus reads and typed emission adapters; -- `DRIVER_MANIFEST`, lifecycle probing and static/live provides validation; -- separate telemetry and control modes. - -These are target runtime duties and remain in Blixt. Device Support does not -become a generic remote Lua executor. - -## Add around the runtime - -Blixt must resolve only a `blixt-l1` artifact whose compatibility record names: - -- product `blixt-gateway` and a bounded host SemVer range; -- runtime `luajit`, semantics `lua-5.1`, version `2.1`; -- ABI `mlua-0.10-luajit21-source-v1`; -- host profile `sourceful.host/blixt-l1/v1` and a bounded API version. - -Before load, the host verifies the Ed25519 package envelope, compatibility, -artifact URL binding, byte length and SHA-256. Verified bytes are staged in a -content-addressed cache and activated atomically; rollback selects an earlier -verified package. Package provenance records the exact source commit and input -hash. Runtime/compiler build materials belong to provenance, not compatibility. - -The runtime still needs explicit instruction/time/memory budgets, permission -enforcement for every host call, and structured command results. Controllable -drivers also need host-owned leases whose expiry, driver failure and shutdown -all invoke `driver_default_mode` before control can be considered safe. None of -those requirements is granted merely by loading package metadata. - -## Portable source and target adapters - -A single reviewed Lua source may produce artifacts for more than one target -when its target contract tests prove that it uses the common Lua 5.1 subset and -only shared host calls. SDM630 is that minimal case. The package still contains -separate FTW and Blixt artifact records so either target can diverge later. - -When runtime helpers, concurrency assumptions, memory limits or lifecycle -semantics differ, this repository keeps one canonical driver definition with -target-specific source adapters or generated artifacts. Byte-identical Lua is -never a goal. Zap follows the same rule when it becomes an active target. diff --git a/spec/control-runtime-v2.md b/spec/control-runtime-v2.md index a3c0606..649698f 100644 --- a/spec/control-runtime-v2.md +++ b/spec/control-runtime-v2.md @@ -1,21 +1,13 @@ -# Sourceful control runtime v2 +# Control runtime v2 -Control packages use the same signed `sourceful.driver-package/v1` envelope as -read-only packages. A control target must use a v2 runtime ABI and host API. -Hosts must not grant control to a v1 target. - -The first profiles are: - -- FTW: `gopher-lua-source-v2` with `sourceful.host/ftw-core/v2`; -- Blixt L1: `mlua-0.10-luajit21-source-v2` with - `sourceful.host/blixt-l1/v2`. - -This version split keeps existing read-only artifacts valid and stops an old -host from loading a control artifact that it cannot contain. +Control runtime v2 is FTW Core's structured command interface for Lua +drivers. FTW's profile is `gopher-lua-source-v2` with the host API profile +`sourceful.host/ftw-core/v2`. A driver that implements it keeps its v1 +entrypoints as well; FTW never grants v2 control to a v1 driver. ## Calls and results -A v2 adapter exposes: +A v2 driver exposes: ```lua function driver_command_v2(command) @@ -35,64 +27,47 @@ function driver_default_mode_v2(context) end ``` -The host passes `sourceful.driver-command/v1` to `driver_command_v2`. The host -owns the command ID, times, attempt and lease. It rejects expired commands, -unknown command IDs, undeclared inputs and values outside the package limits -before Lua runs. +The host passes a `sourceful.driver-command/v1` command to +`driver_command_v2`. The host owns the command ID, times, attempt and lease. It +rejects expired commands, unknown command IDs, undeclared inputs and values +outside the declared limits before Lua runs. -The Lua adapter returns only `status`, `code`, optional `message`, -`device_state`, applied values and evidence names. The host creates -`sourceful.driver-command-result/v1`, adds the command and lease IDs, package -identity, completion time and the number of allowed host writes, and records -the result. A nil, boolean or string Lua result fails the v2 call. `accepted` -is not proof that the device applied a value. Only `applied` with the evidence -required by that driver may renew a lease. +The driver returns only `status`, `code`, optional `message`, `device_state`, +applied values and evidence names. The host creates the +`sourceful.driver-command-result/v1` record, adds the command and lease IDs, +driver identity, completion time and the number of allowed host writes, and +records the result. A nil, boolean or string Lua result fails the v2 call. +`accepted` is not proof that the device applied a value. Only `applied` with +the evidence required by that driver may renew a lease. ## Write scope -Package permissions narrow the host capabilities. Host config may narrow them -again but may never add a permission that the signed package did not request. - -For a managed v2 control package, write calls work only while the host runs -`driver_command_v2` under a valid lease or `driver_default_mode_v2` during -expiry and shutdown. Write calls from init, poll or cleanup fail. A driver that -needs writes in init or poll needs a target adapter that moves those writes to -an explicit command or default-mode call. +Write calls work only while the host runs `driver_command_v2` under a valid +lease or `driver_default_mode_v2` during expiry and shutdown. Write calls from +init, poll or cleanup fail. A driver that needs writes in init or poll must +move them to an explicit command or default-mode call. The host applies call deadlines, instruction and memory budgets, URL and topic allowlists, and write-count limits. A timed-out Lua call loses its write scope. -Managed control Lua gets a small library allowlist; `os`, `io`, `debug`, -`package`, `load`, `loadfile`, `dofile` and native modules are absent. +Control Lua gets a small library allowlist; `os`, `io`, `debug`, `package`, +`load`, `loadfile`, `dofile` and native modules are absent. ## Lease and default mode The host starts or renews a lease only after an `applied` result. It runs default mode when the lease expires, the driver goes stale, the control loop -stops, the package changes or the host shuts down. It blocks later control if -default mode fails. +stops, the driver changes or the host shuts down. It blocks later control if +default mode fails. Downloading, installing or activating a driver never +starts a lease. -The package lease is an upper bound. Site policy may use a shorter lease. A -channel index, download, install or activation never starts a lease. - -A controllable source package may keep every target at `control_enabled: false` -while its v2 adapter and host are still in development. A target may change the -flag to `true` only when it uses its approved v2 ABI and host API. A control -package may reach a beta channel index only after HIL proves: +A v2 control path may reach a beta only after HIL proves: 1. normal command and result; 2. expiry returns the device to its stated default; 3. host restart and process kill do not leave an unsafe target; 4. network loss has a bounded safe outcome; -5. rollback restores the last verified package; +5. rollback restores the last verified driver; 6. init, poll and cleanup cannot write outside the allowed phase. If the device keeps a setpoint after host loss and has no device-side timeout, -the driver does not pass this gate until its adapter provides an equivalent -safe path. - -## Beta activation - -`control_enabled: true` says that an artifact supports control. It does not -turn control on. A host also requires an explicit beta channel, exact package -version and hash pin, a per-site opt-in and a passed HIL record for that driver -and model. Beta control packages never auto-activate. +the driver does not pass this gate until it provides an equivalent safe path. diff --git a/spec/driver-inventory-v1.md b/spec/driver-inventory-v1.md deleted file mode 100644 index 4cede84..0000000 --- a/spec/driver-inventory-v1.md +++ /dev/null @@ -1,43 +0,0 @@ -# Sourceful driver inventory v1 - -`sourceful.driver-inventory/v1` reports which drivers an authenticated host is -using. It supports fleet counts, beta rollout and rollback checks. It is not a -package index and grants no install or control rights. - -The host sends one bounded snapshot on start, on a driver change and at least -every 15 minutes. FTW publishes it on: - -```text -gateways.{gateway_id}.inventory.drivers.json.v1 -``` - -Nova gets gateway and organization identity from the authenticated subject. -The payload does not repeat them. Nova checks that the bridged organization -owns the gateway before it replaces the last snapshot. - -Each row groups equal driver ID, version and source. It reports configured and -running counts plus health counts. Managed rows bind the Sourceful package ID, -repository, channel and artifact SHA-256. `legacy_repository` rows mark an old -FTW repository artifact by repository and artifact SHA-256 without claiming a -canonical package ID or channel. Bundled and local override rows must not claim -package provenance. They report the loaded Lua source SHA-256 so Nova can tell -two local builds apart even when their driver version is equal. - -`control_class` is `read_only` or `control` only when the loaded metadata or -canonical package states that fact. Old drivers with no explicit declaration -report `unknown`; the inventory must not guess from a missing boolean. - -The payload must not contain instance names, site names, config, IP or host -names, URLs, serial numbers, MAC addresses, device IDs, tokens, credentials, -logs, command inputs or vendor responses. Nova may keep the latest snapshot -and aggregate by driver, version, source, channel and health. Access to one -gateway's row stays under the normal organization and admin checks. - -The first FTW rollout will therefore have two baselines: - -- the existing Nova network count, which cannot identify drivers; -- exact driver use from FTW versions that emit this contract. - -Coverage must be shown with every fleet report. Counts from reporting FTW -hosts must not be presented as the full Sourceful fleet until coverage reaches -the chosen release gate. diff --git a/spec/driver-package-v1-migration.md b/spec/driver-package-v1-migration.md deleted file mode 100644 index 27c9cdf..0000000 --- a/spec/driver-package-v1-migration.md +++ /dev/null @@ -1,42 +0,0 @@ -# Driver package v1 migration map - -This bridges the current representations to `sourceful.driver-package/v1`. It -is a per-driver migration plan, not an instruction to mass-convert catalogs. - -| Concern | FTW | Blixt L1 | Public source action | -|---|---|---|---| -| Identity | `DRIVER.id` and signed repository ID | `DRIVER_MANIFEST.name` and registry name | Allocate the stable reverse-DNS `package_id`; retain target IDs as aliases. | -| Version/channel | Driver version plus signed beta/stable history | Lua manifest version; registry response is not a release channel | Own one SemVer and signed channel. Never mutate a published version. | -| Runtime | GopherLua 1.1.2, Lua 5.1 semantics | LuaJIT 2.1 through mlua 0.10 | Bind separate runtime names, ABI values and host profiles. Do not claim runtime identity. | -| Hardware identity | `DRIVER`, `set_make`/`set_sn`; host owns state | `DRIVER_MANIFEST.provides` and host setters | Declare reporting promises and stable host fallbacks; state remains host-owned. | -| Device match | manufacturer, tested models and verification status | runner configuration and local selection | Curate reviewed families/variants/regions; catalog presence is not HIL proof. | -| Permissions | protocol/capability-scoped host functions | broad host functions around an arbitrated device path | Sign minimum operations and enforce them at every host call. | -| Telemetry | lowercase Sourceful fields; import-positive convention | typed adapter currently consumes mixed-case meter fields | Bind telemetry v2 and sign convention. Adapters normalize target field names. | -| Lifecycle | init, poll, command, default mode and cleanup | same core entrypoints plus manifest probing | Require target lifecycle conformance during build and runtime tests. | -| Scheduling | host/Core owns stale data and safety | queued work, preemption, coalescing and device arbitration | Preserve Blixt's hardware-near scheduling; it is not package-registry logic. | -| Control safety | Core-owned leases, stale cutoff and default mode | command path exists; lease expiry/default result is incomplete | Metadata never enables control. Close lifecycle, lease and HIL gates before a control pilot. | -| Artifacts | signed content-addressed Lua source | source selected by embedded/local/remote registry | Produce one immutable artifact record per target. Shared bytes are allowed, not required. | -| Trust | Ed25519 signed repository artifacts | remote source fetch has no complete package trust chain | Sign canonical envelope; host verifies signature, compatibility, length and SHA-256 before staging. | -| Provenance | repository and commit in update center | embedded/local source or mutable registry response | Bind source commit, input hashes, builder and source epoch. Toolchain is provenance, not compatibility. | -| Update/rollback | verified history and atomic activation | driver registry resolution and runtime load | The private publisher signs once; targets stage and activate on their own and retain a prior verified package. | -| Consumers | Core executes drivers and publishes telemetry | L1 executes drivers and publishes telemetry | Nova Core and apps discover metadata/telemetry; they are not Lua targets. | - -## Pilot interpretation - -SDM630 is the read-only pilot: telemetry only, one bundled Modbus read, no -commands, no lease and FTW/Blixt artifacts with control disabled. Its canonical -source is based on David's Blixt implementation. The common source emits both -canonical lowercase fields and temporary Blixt mixed-case aliases. - -Sungrow is only the later control-contract pilot. It declares Modbus write -permission, default mode and a 30-second lease with a 10-second heartbeat, but -must not progress to activation until command results, failure/expiry default -mode and physical HIL gates are closed. - -## Explicitly deferred - -- no mass migration of either Lua catalog; -- no automatic publication, update, install or activation in the first pilot; -- no active Zap artifact yet; the schema only reserves the future target; -- no Hugin runtime or registry work; Hugin may later become a workbench; -- no claim of production control before physical HIL validation. diff --git a/spec/driver-package-v1.md b/spec/driver-package-v1.md deleted file mode 100644 index 6552ed0..0000000 --- a/spec/driver-package-v1.md +++ /dev/null @@ -1,176 +0,0 @@ -# Sourceful driver package v1 - -`sourceful.driver-package/v1` is the canonical Sourceful release contract for a -driver. It describes one immutable version and binds executable artifacts to -runtime targets without requiring those targets to share an implementation. -The initial targets are FTW Core and Blixt L1. Zap is reserved as a future -target; Nova Core and mobile applications are consumers of package metadata -and telemetry, not executable Lua targets. - -The normative schemas are: - -- `schemas/sourceful.driver-package.v1.schema.json` — signed payload; -- `schemas/sourceful.driver-package-envelope.v1.schema.json` — signature envelope; -- `schemas/sourceful.driver-package-source.v1.schema.json` — reviewable build input. -- `schemas/sourceful.driver-index.v1.schema.json` — channel discovery index; -- `schemas/sourceful.driver-index-envelope.v1.schema.json` — signed index envelope. - -Runtime and fleet contracts are separate from the signed package: - -- `schemas/sourceful.driver-command.v1.schema.json` — host-owned command and lease; -- `schemas/sourceful.driver-command-result.v1.schema.json` — bounded control result; -- `schemas/sourceful.driver-inventory.v1.schema.json` — secret-free fleet snapshot. - -See `control-runtime-v2.md` and `driver-inventory-v1.md`. Package v1 remains -valid for read-only targets. Managed control requires a v2 runtime ABI and host -API, so an old host cannot gain control by accepting new package metadata. - -The source schema is not published or installed. It contains repository paths -and build transforms, never generated hashes, credentials, or runtime -configuration. The packager turns it into the immutable payload. - -## Contract boundaries - -The payload binds all information a host must decide before loading code: - -- stable `package_id`, SemVer and `beta` or `stable` channel; -- hardware identity reporting and host-owned persistent state; -- source commit and build provenance; -- device matching, telemetry capabilities, permissions and sign convention; -- canonical commands, their runtime actions, default mode and lease expiry; -- an explicit compatibility record for each target, Lua semantics, runtime ABI, - named host API profile and host API range; -- content-addressed artifacts with HTTPS URLs, SHA-256 and size constraints; -- rollback to a previously verified package without mutating the old release. - -Compatibility is fail-closed. A host must provide a known target, product, -SemVer, runtime name/semantics/version/ABI, host API profile and host API. A -missing value, an unknown target or any out-of-range value is incompatible. A target-specific -`control_enabled` flag prevents a controllable package from acquiring control -rights merely because it can be loaded by another runtime. A controllable -source package may keep this flag false for every target while its v2 adapter -is staged. Setting it to true requires the target's approved v2 runtime ABI and -host API. - -`read_only: true` requires no commands, no write permissions, no control leases -and no target with control enabled. A controllable package requires a vendor -autonomous default and a bounded lease whose heartbeat is shorter than its -expiry. Hosts still own runtime safety and must validate every command; package -metadata never authorizes direct hardware dispatch. - -## Signing and immutable releases - -The envelope uses the existing Sourceful Ed25519 PKCS8/SPKI key format. It does -not reuse the legacy operation of signing a hexadecimal artifact hash. Instead, -it signs the complete canonical payload, which already binds every artifact -hash and compatibility rule. - -`sourceful.canonical-json/v1` is UTF-8 JSON with object keys sorted, compact -separators, no NaN/Infinity and no trailing newline in the signed bytes. The -serialized `.json` file ends with one newline; verification parses it and -reconstructs the canonical signed bytes. - -The immutable trust root is the verified envelope plus the pinned public key. -A GitHub release or immutable object store may hold the envelope and artifacts. -The Device Support database and APIs may index verified releases for discovery, -but must not rewrite payloads, substitute URLs, or become the authority for an -immutable release. Channel promotion publishes a newly signed channel payload -over the exact already-reviewed version, commit and artifact hashes. - -This public repository owns canonical sources, package metadata and release -versions. The private Device Support publisher owns official signed target -artifacts. Host repositories own runtime adapters, pinned trust roots and -install and activation policy. A driver changes once here; each target checks, -stages and activates the matching artifact on its own. -There is intentionally no automatic update or activation path in Phase 1. -Building, publishing, selecting, installing and activating are separate actions. - -## Discovery index - -`sourceful.driver-index/v1` is the single signed discovery/control-plane view. -It lists immutable package-envelope URLs, exact envelope hashes and available -executable targets. It does not duplicate driver metadata, artifact URLs or -permissions from the package and cannot authorize activation. A consumer first -verifies the index, then downloads and independently verifies each selected -package envelope and artifact. - -Device Support signs and publishes this index. Nova Core and apps may use it for -catalog discovery; FTW adapts `ftw-core` packages into its Update Center; Blixt -can resolve an explicitly pinned package directly. Hugin is not on this trust -or distribution path. - -## Deterministic tooling - -Install tooling dependencies and validate the two pilots: - -```bash -uv run --extra package tools/driver_package.py validate packages/v1/sdm630/package-source.json -uv run --extra package tools/driver_package.py validate packages/v1/sungrow/package-source.json -``` - -Build an unsigned FTW or Blixt candidate. The tool reads the current Git commit -and commit epoch so the same inputs produce the same bytes: - -```bash -make package-driver ID=sdm630 TARGET=ftw-core -``` - -Official package and index signing runs only in the private publisher. Runtime -device passwords, tokens, certificates and serial or Modbus settings live in -each host's secret or config store and sit outside this package contract. - -The private publisher verifies the signature and every local artifact before -publication. Hosts repeat the same checks before install: - -```bash -uv run --extra package tools/driver_package.py verify \ - --envelope dist/packages/sdm630/1.1.1/manifest.envelope.json \ - --public-key /path/to/pinned-public.pem \ - --artifact-dir dist/packages/sdm630/1.1.1 -``` - -The private publisher builds a signed beta index from signed package envelopes: - -```bash -uv run --extra package tools/driver_package.py index \ - --package-envelope dist/packages/sdm630/1.1.1/manifest.envelope.json \ - --package-url https://packages.example/sdm630/1.1.1/manifest.envelope.json \ - --channel beta \ - --source-date-epoch 1700000000 \ - --public-key /path/to/pinned-public.pem \ - --key /path/to/release-private.pem \ - --key-id RELEASE_KEY_ID \ - --output dist/index/beta/manifest.envelope.json -``` - -The index builder first verifies every package envelope with the supplied -public key and binds the exact downloaded envelope bytes by SHA-256. - -Published artifacts use the immutable version root. Beta and stable package -envelopes use separate immutable paths because the signed payload contains its -channel: - -```text -/v1/packages/sdm630/1.1.1/.lua -/v1/packages/sdm630/1.1.1/beta/manifest.envelope.json -/v1/packages/sdm630/1.1.1/stable/manifest.envelope.json -``` - -Promotion verifies the beta envelope, changes only `channel`, and signs a new -stable envelope. It keeps the exact source commit, provenance, artifact URLs, -hashes, and sizes: - -```bash -uv run --extra package tools/driver_package.py promote \ - --envelope dist/packages/sdm630/1.1.1/beta/manifest.envelope.json \ - --public-key /path/to/pinned-public.pem \ - --key /path/to/release-private.pem \ - --key-id RELEASE_KEY_ID \ - --output dist/packages/sdm630/1.1.1/stable/manifest.envelope.json -``` - -SDM630 is the first executable read-only pilot for FTW and Blixt. Its canonical -source is based on David's Blixt L1 implementation and preserves the one-read -hardware path. Sungrow is only a control-contract fixture until lease expiry, -default-mode invocation, command results and physical HIL gates are closed. -Zap remains intentionally outside the active pilot set. diff --git a/spec/schemas/sourceful.driver-command-result.v1.schema.json b/spec/schemas/sourceful.driver-command-result.v1.schema.json deleted file mode 100644 index 5c7d28b..0000000 --- a/spec/schemas/sourceful.driver-command-result.v1.schema.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://schemas.sourceful.energy/sourceful.driver-command-result/v1", - "title": "Sourceful Driver Command Result v1", - "description": "A bounded host-owned result for a control or default-mode call.", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "id", "command", "status", "code", "completed_at", "device_state", "writes", "driver"], - "properties": { - "schema_version": {"const": "sourceful.driver-command-result/v1"}, - "id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$"}, - "command": {"type": "string", "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", "maxLength": 128}, - "lease_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$"}, - "status": {"enum": ["accepted", "applied", "rejected", "failed", "expired", "defaulted"]}, - "code": {"type": "string", "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", "maxLength": 128}, - "message": {"type": "string", "maxLength": 512}, - "completed_at": {"type": "string", "format": "date-time"}, - "device_state": {"enum": ["controlled", "default", "unchanged", "unknown"]}, - "writes": {"type": "integer", "minimum": 0, "maximum": 128}, - "evidence": { - "type": "array", - "uniqueItems": true, - "maxItems": 3, - "items": {"enum": ["write_ack", "vendor_ack", "readback"]} - }, - "applied": { - "type": "object", - "maxProperties": 32, - "additionalProperties": {"type": ["boolean", "number", "string"], "maxLength": 512} - }, - "driver": { - "type": "object", - "additionalProperties": false, - "required": ["package_id", "version", "artifact_sha256"], - "properties": { - "package_id": {"type": "string", "pattern": "^com\\.sourceful\\.driver\\.[a-z0-9]+(?:[.-][a-z0-9]+)*$"}, - "version": {"type": "string", "minLength": 1, "maxLength": 64}, - "artifact_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"} - } - } - } -} diff --git a/spec/schemas/sourceful.driver-command.v1.schema.json b/spec/schemas/sourceful.driver-command.v1.schema.json deleted file mode 100644 index ab6a7cf..0000000 --- a/spec/schemas/sourceful.driver-command.v1.schema.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://schemas.sourceful.energy/sourceful.driver-command/v1", - "title": "Sourceful Driver Command v1", - "description": "A bounded host-owned command and lease passed to a control driver.", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "id", "command", "source", "issued_at", "expires_at", "attempt", "lease", "inputs"], - "properties": { - "schema_version": {"const": "sourceful.driver-command/v1"}, - "id": {"$ref": "#/$defs/id"}, - "command": {"type": "string", "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", "maxLength": 128}, - "source": {"type": "string", "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", "maxLength": 128}, - "issued_at": {"type": "string", "format": "date-time"}, - "expires_at": {"type": "string", "format": "date-time"}, - "attempt": {"type": "integer", "minimum": 1, "maximum": 16}, - "lease": { - "type": "object", - "additionalProperties": false, - "required": ["id", "expires_at", "heartbeat_interval_ms"], - "properties": { - "id": {"$ref": "#/$defs/id"}, - "expires_at": {"type": "string", "format": "date-time"}, - "heartbeat_interval_ms": {"type": "integer", "minimum": 100, "maximum": 60000} - } - }, - "inputs": { - "type": "object", - "maxProperties": 32, - "additionalProperties": {"type": ["boolean", "number", "string"], "maxLength": 512} - } - }, - "$defs": { - "id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$"} - } -} diff --git a/spec/schemas/sourceful.driver-index-envelope.v1.schema.json b/spec/schemas/sourceful.driver-index-envelope.v1.schema.json deleted file mode 100644 index 40b61d0..0000000 --- a/spec/schemas/sourceful.driver-index-envelope.v1.schema.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://schemas.sourceful.energy/sourceful.driver-index-envelope/v1", - "title": "Sourceful Driver Index Signed Envelope v1", - "description": "Ed25519 signature over Sourceful Canonical JSON v1 bytes of a driver discovery index.", - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", - "payload_type", - "canonicalization", - "key_id", - "algorithm", - "payload", - "signature" - ], - "properties": { - "schema_version": {"const": "sourceful.driver-index-envelope/v1"}, - "payload_type": {"const": "application/vnd.sourceful.driver-index.v1+json"}, - "canonicalization": {"const": "sourceful.canonical-json/v1"}, - "key_id": { - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$" - }, - "algorithm": {"const": "Ed25519"}, - "payload": { - "$ref": "https://schemas.sourceful.energy/sourceful.driver-index/v1" - }, - "signature": { - "type": "string", - "contentEncoding": "base64", - "pattern": "^[A-Za-z0-9+/]{86}==$" - } - } -} diff --git a/spec/schemas/sourceful.driver-index.v1.schema.json b/spec/schemas/sourceful.driver-index.v1.schema.json deleted file mode 100644 index c5c9732..0000000 --- a/spec/schemas/sourceful.driver-index.v1.schema.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://schemas.sourceful.energy/sourceful.driver-index/v1", - "title": "Sourceful Driver Index v1", - "description": "Signed discovery index of immutable driver package envelopes.", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "channel", "source_date_epoch", "packages"], - "properties": { - "schema_version": {"const": "sourceful.driver-index/v1"}, - "channel": {"enum": ["beta", "stable"]}, - "source_date_epoch": {"type": "integer", "minimum": 0}, - "packages": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "package_id", - "version", - "envelope_url", - "envelope_sha256", - "targets" - ], - "properties": { - "package_id": { - "type": "string", - "pattern": "^com\\.sourceful\\.driver\\.[a-z0-9]+(?:[.-][a-z0-9]+)*$" - }, - "version": { - "$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/$defs/semver" - }, - "envelope_url": { - "type": "string", - "format": "uri", - "pattern": "^https://" - }, - "envelope_sha256": { - "$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/$defs/sha256" - }, - "targets": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { - "$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/$defs/target" - } - } - } - } - } - } -} diff --git a/spec/schemas/sourceful.driver-inventory.v1.schema.json b/spec/schemas/sourceful.driver-inventory.v1.schema.json deleted file mode 100644 index 523343d..0000000 --- a/spec/schemas/sourceful.driver-inventory.v1.schema.json +++ /dev/null @@ -1,105 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://schemas.sourceful.energy/sourceful.driver-inventory/v1", - "title": "Sourceful Driver Inventory v1", - "description": "A bounded, secret-free snapshot of drivers used by one authenticated host.", - "type": "object", - "additionalProperties": false, - "required": ["schema_version", "generated_at", "host", "drivers"], - "properties": { - "schema_version": {"const": "sourceful.driver-inventory/v1"}, - "generated_at": {"type": "string", "format": "date-time"}, - "host": { - "type": "object", - "additionalProperties": false, - "required": ["product", "version", "update_channel", "target", "runtime_abi", "host_api"], - "properties": { - "product": {"enum": ["ftw", "blixt-gateway"]}, - "version": {"type": "string", "minLength": 1, "maxLength": 64}, - "update_channel": {"enum": ["beta", "stable", "unknown"]}, - "target": {"enum": ["ftw-core", "blixt-l1"]}, - "runtime_abi": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{2,127}$"}, - "host_api": {"type": "integer", "minimum": 1, "maximum": 2147483647} - }, - "allOf": [ - { - "if": {"properties": {"product": {"const": "ftw"}}}, - "then": {"properties": {"target": {"const": "ftw-core"}}} - }, - { - "if": {"properties": {"product": {"const": "blixt-gateway"}}}, - "then": {"properties": {"target": {"const": "blixt-l1"}}} - } - ] - }, - "drivers": { - "type": "array", - "maxItems": 128, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["driver_id", "version", "source", "control_class", "configured_instances", "running_instances", "health"], - "properties": { - "driver_id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,127}$"}, - "version": {"type": "string", "minLength": 1, "maxLength": 64}, - "source": {"enum": ["bundled", "managed", "legacy_repository", "local_override"]}, - "package_id": {"type": "string", "pattern": "^com\\.sourceful\\.driver\\.[a-z0-9]+(?:[.-][a-z0-9]+)*$"}, - "repository_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}, - "package_channel": {"enum": ["beta", "stable"]}, - "artifact_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "source_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, - "control_class": {"enum": ["read_only", "control", "unknown"]}, - "configured_instances": {"type": "integer", "minimum": 1, "maximum": 128}, - "running_instances": {"type": "integer", "minimum": 0, "maximum": 128}, - "health": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "degraded", "offline", "unknown"], - "properties": { - "ok": {"type": "integer", "minimum": 0, "maximum": 128}, - "degraded": {"type": "integer", "minimum": 0, "maximum": 128}, - "offline": {"type": "integer", "minimum": 0, "maximum": 128}, - "unknown": {"type": "integer", "minimum": 0, "maximum": 128} - } - } - }, - "allOf": [ - { - "if": {"properties": {"source": {"const": "managed"}}}, - "then": { - "required": ["package_id", "repository_id", "package_channel", "artifact_sha256"], - "not": {"required": ["source_sha256"]} - } - }, - { - "if": {"properties": {"source": {"const": "legacy_repository"}}}, - "then": { - "required": ["repository_id", "artifact_sha256"], - "not": { - "anyOf": [ - {"required": ["package_id"]}, - {"required": ["package_channel"]}, - {"required": ["source_sha256"]} - ] - } - } - }, - { - "if": {"properties": {"source": {"enum": ["bundled", "local_override"]}}}, - "then": { - "required": ["source_sha256"], - "not": { - "anyOf": [ - {"required": ["package_id"]}, - {"required": ["repository_id"]}, - {"required": ["package_channel"]}, - {"required": ["artifact_sha256"]} - ] - } - } - } - ] - } - } - } -} diff --git a/spec/schemas/sourceful.driver-package-envelope.v1.schema.json b/spec/schemas/sourceful.driver-package-envelope.v1.schema.json deleted file mode 100644 index bca3022..0000000 --- a/spec/schemas/sourceful.driver-package-envelope.v1.schema.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://schemas.sourceful.energy/sourceful.driver-package-envelope/v1", - "title": "Sourceful Driver Package Signed Envelope v1", - "description": "Ed25519 signature over Sourceful Canonical JSON v1 bytes of a driver package payload.", - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", - "payload_type", - "canonicalization", - "key_id", - "algorithm", - "payload", - "signature" - ], - "properties": { - "schema_version": { - "const": "sourceful.driver-package-envelope/v1" - }, - "payload_type": { - "const": "application/vnd.sourceful.driver-package.v1+json" - }, - "canonicalization": { - "const": "sourceful.canonical-json/v1" - }, - "key_id": { - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$" - }, - "algorithm": { - "const": "Ed25519" - }, - "payload": { - "$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1" - }, - "signature": { - "type": "string", - "contentEncoding": "base64", - "pattern": "^[A-Za-z0-9+/]{86}==$" - } - } -} diff --git a/spec/schemas/sourceful.driver-package-source.v1.schema.json b/spec/schemas/sourceful.driver-package-source.v1.schema.json deleted file mode 100644 index 018eca2..0000000 --- a/spec/schemas/sourceful.driver-package-source.v1.schema.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://schemas.sourceful.energy/sourceful.driver-package-source/v1", - "title": "Sourceful Driver Package Build Source v1", - "description": "Reviewable source metadata consumed by the deterministic v1 packager. It never contains credentials or runtime configuration.", - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", - "package_id", - "version", - "channel", - "display_name", - "identity", - "source", - "builder_id", - "device_matches", - "capabilities", - "permissions", - "telemetry", - "commands", - "read_only", - "default_mode", - "lease_policy", - "rollback", - "compatibility", - "artifact_inputs" - ], - "properties": { - "schema_version": {"const": "sourceful.driver-package-source/v1"}, - "package_id": {"type": "string", "pattern": "^com\\.sourceful\\.driver\\.[a-z0-9]+(?:[.-][a-z0-9]+)*$"}, - "version": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/$defs/semver"}, - "channel": {"enum": ["beta", "stable"]}, - "display_name": {"type": "string", "minLength": 1, "maxLength": 120}, - "identity": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/properties/identity"}, - "source": { - "type": "object", - "additionalProperties": false, - "required": ["repository", "path"], - "properties": { - "repository": {"type": "string", "format": "uri", "pattern": "^https://"}, - "path": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"} - } - }, - "builder_id": {"type": "string", "format": "uri"}, - "device_matches": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/properties/device_matches"}, - "capabilities": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/properties/capabilities"}, - "permissions": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/properties/permissions"}, - "telemetry": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/properties/telemetry"}, - "commands": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/properties/commands"}, - "read_only": {"type": "boolean"}, - "default_mode": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/properties/default_mode"}, - "lease_policy": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/properties/lease_policy"}, - "rollback": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/properties/rollback"}, - "compatibility": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/properties/compatibility"}, - "artifact_inputs": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["artifact_id", "target", "media_type", "input_path", "transform", "extension"], - "properties": { - "artifact_id": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/$defs/identifier"}, - "target": {"$ref": "https://schemas.sourceful.energy/sourceful.driver-package/v1#/$defs/target"}, - "media_type": {"enum": ["application/vnd.sourceful.lua.source", "application/vnd.sourceful.lua.bytecode"]}, - "input_path": {"type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$"}, - "transform": {"enum": ["copy", "lua55-strip"]}, - "extension": {"enum": ["lua", "luac"]} - }, - "allOf": [ - { - "if": {"properties": {"transform": {"const": "copy"}}}, - "then": {"properties": {"media_type": {"const": "application/vnd.sourceful.lua.source"}, "extension": {"const": "lua"}}} - }, - { - "if": {"properties": {"transform": {"const": "lua55-strip"}}}, - "then": {"properties": {"media_type": {"const": "application/vnd.sourceful.lua.bytecode"}, "extension": {"const": "luac"}}} - } - ] - } - } - } -} diff --git a/spec/schemas/sourceful.driver-package.v1.schema.json b/spec/schemas/sourceful.driver-package.v1.schema.json deleted file mode 100644 index 7d65a93..0000000 --- a/spec/schemas/sourceful.driver-package.v1.schema.json +++ /dev/null @@ -1,536 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://schemas.sourceful.energy/sourceful.driver-package/v1", - "title": "Sourceful Driver Package v1", - "description": "Immutable, target-neutral metadata for one signed Sourceful driver release.", - "type": "object", - "additionalProperties": false, - "required": [ - "schema_version", - "package_id", - "version", - "channel", - "display_name", - "identity", - "source", - "provenance", - "device_matches", - "capabilities", - "permissions", - "telemetry", - "commands", - "read_only", - "default_mode", - "lease_policy", - "rollback", - "compatibility", - "artifacts" - ], - "properties": { - "schema_version": { - "const": "sourceful.driver-package/v1" - }, - "package_id": { - "type": "string", - "pattern": "^com\\.sourceful\\.driver\\.[a-z0-9]+(?:[.-][a-z0-9]+)*$" - }, - "version": { - "$ref": "#/$defs/semver" - }, - "channel": { - "enum": ["beta", "stable"] - }, - "display_name": { - "type": "string", - "minLength": 1, - "maxLength": 120 - }, - "identity": { - "type": "object", - "additionalProperties": false, - "required": ["schema", "make", "serial", "host_fallbacks", "persistent_state_owner"], - "properties": { - "schema": {"const": "sourceful.hardware-identity/v1"}, - "make": {"const": "driver_reported"}, - "serial": {"enum": ["driver_reported_when_available", "unavailable"]}, - "host_fallbacks": { - "type": "array", - "items": {"enum": ["mac", "endpoint"]}, - "minItems": 1, - "uniqueItems": true - }, - "persistent_state_owner": {"const": "host"} - } - }, - "source": { - "type": "object", - "additionalProperties": false, - "required": ["repository", "commit", "path"], - "properties": { - "repository": { - "type": "string", - "format": "uri", - "pattern": "^https://" - }, - "commit": { - "type": "string", - "pattern": "^[0-9a-f]{40}$" - }, - "path": { - "type": "string", - "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" - } - } - }, - "provenance": { - "type": "object", - "additionalProperties": false, - "required": ["builder_id", "build_type", "source_date_epoch", "materials"], - "properties": { - "builder_id": { - "type": "string", - "format": "uri" - }, - "build_type": { - "const": "sourceful.driver-package/v1" - }, - "source_date_epoch": { - "type": "integer", - "minimum": 0 - }, - "materials": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["uri", "sha256"], - "properties": { - "uri": { - "type": "string", - "minLength": 1 - }, - "sha256": { - "$ref": "#/$defs/sha256" - } - } - } - } - } - }, - "device_matches": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["manufacturer", "model_family", "variants", "regions"], - "properties": { - "manufacturer": { - "type": "string", - "minLength": 1 - }, - "model_family": { - "type": "string", - "minLength": 1 - }, - "variants": { - "type": "array", - "items": {"type": "string", "minLength": 1}, - "uniqueItems": true - }, - "regions": { - "type": "array", - "items": {"type": "string", "minLength": 1}, - "uniqueItems": true - } - } - } - }, - "capabilities": { - "type": "object", - "additionalProperties": false, - "required": ["telemetry", "control"], - "properties": { - "telemetry": { - "type": "array", - "items": {"$ref": "#/$defs/capability"}, - "uniqueItems": true - }, - "control": { - "type": "array", - "items": {"type": "string", "pattern": "^[a-z][a-z0-9_.-]+$"}, - "uniqueItems": true - } - } - }, - "permissions": { - "type": "array", - "items": { - "enum": [ - "http.get", - "http.post", - "modbus.read", - "modbus.write", - "mqtt.publish", - "mqtt.subscribe", - "serial.read", - "serial.write" - ] - }, - "uniqueItems": true - }, - "telemetry": { - "type": "object", - "additionalProperties": false, - "required": ["schema", "sign_convention", "streams"], - "properties": { - "schema": { - "const": "sourceful.telemetry/v2" - }, - "sign_convention": { - "const": "sourceful.site-import-positive/v1" - }, - "streams": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "power_field", "meaning"], - "properties": { - "kind": {"$ref": "#/$defs/capability"}, - "power_field": { - "type": "string", - "pattern": "^[a-z][a-z0-9_.-]*$" - }, - "meaning": { - "type": "string", - "minLength": 1, - "maxLength": 240 - } - } - } - } - } - }, - "commands": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "capability", "runtime_action", "description", "inputs"], - "properties": { - "id": { - "type": "string", - "pattern": "^[a-z][a-z0-9_.-]+$" - }, - "capability": { - "type": "string", - "pattern": "^[a-z][a-z0-9_.-]+$" - }, - "runtime_action": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]*$" - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 240 - }, - "inputs": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["name", "type", "unit", "required"], - "properties": { - "name": { - "type": "string", - "pattern": "^[a-z][a-z0-9_]*$" - }, - "type": { - "enum": ["boolean", "integer", "number", "string"] - }, - "unit": { - "type": "string" - }, - "required": { - "type": "boolean" - } - } - } - } - } - } - }, - "read_only": { - "type": "boolean" - }, - "default_mode": { - "type": "object", - "additionalProperties": false, - "required": ["strategy", "description"], - "properties": { - "strategy": { - "enum": ["not_applicable", "vendor_autonomous"] - }, - "entrypoint": { - "enum": ["driver_default_mode", "driver_default_mode_v2"] - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 240 - } - } - }, - "lease_policy": { - "type": "object", - "additionalProperties": false, - "required": ["required_for_control", "expiry_action"], - "properties": { - "required_for_control": { - "type": "boolean" - }, - "max_duration_seconds": { - "type": "integer", - "minimum": 1 - }, - "heartbeat_interval_seconds": { - "type": "integer", - "minimum": 1 - }, - "expiry_action": { - "enum": ["not_applicable", "return_to_default"] - } - } - }, - "rollback": { - "type": "object", - "additionalProperties": false, - "required": ["strategy", "state_owner", "automatic"], - "properties": { - "strategy": {"const": "install_previous_verified_package"}, - "state_owner": {"const": "host"}, - "automatic": {"type": "boolean"} - } - }, - "compatibility": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["target", "artifact_id", "host", "runtime", "control_enabled"], - "properties": { - "target": {"$ref": "#/$defs/target"}, - "artifact_id": {"$ref": "#/$defs/identifier"}, - "host": { - "type": "object", - "additionalProperties": false, - "required": ["product", "min_version"], - "properties": { - "product": { - "enum": ["ftw", "blixt-gateway"] - }, - "min_version": {"$ref": "#/$defs/semver"}, - "max_version_exclusive": {"$ref": "#/$defs/semver"} - } - }, - "runtime": { - "type": "object", - "additionalProperties": false, - "required": ["name", "semantics", "version", "abi", "host_api"], - "properties": { - "name": { - "enum": ["gopher-lua", "luajit", "lua", "native", "wasm"] - }, - "semantics": { - "enum": ["lua-5.1", "lua-5.5", "native", "wasm"] - }, - "version": { - "type": "string", - "minLength": 1 - }, - "abi": { - "type": "string", - "minLength": 1 - }, - "host_api": { - "type": "object", - "additionalProperties": false, - "required": ["profile", "min", "max"], - "properties": { - "profile": { - "type": "string", - "pattern": "^sourceful\\.host/[a-z0-9-]+/v[1-9][0-9]*$" - }, - "min": {"type": "integer", "minimum": 1}, - "max": {"type": "integer", "minimum": 1} - } - } - } - }, - "control_enabled": { - "type": "boolean" - }, - "constraints": { - "type": "object", - "additionalProperties": false, - "properties": { - "max_artifact_bytes": { - "type": "integer", - "minimum": 1 - }, - "max_instances": { - "type": "integer", - "minimum": 1 - } - } - } - }, - "allOf": [ - { - "if": {"properties": {"target": {"const": "ftw-core"}}}, - "then": { - "properties": { - "host": {"properties": {"product": {"const": "ftw"}}}, - "runtime": { - "properties": { - "name": {"const": "gopher-lua"}, - "semantics": {"const": "lua-5.1"}, - "host_api": {"properties": {"profile": {"enum": ["sourceful.host/ftw-core/v1", "sourceful.host/ftw-core/v2"]}}} - } - } - } - } - }, - { - "if": {"properties": {"target": {"const": "blixt-l1"}}}, - "then": { - "properties": { - "host": {"properties": {"product": {"const": "blixt-gateway"}}}, - "runtime": { - "properties": { - "name": {"const": "luajit"}, - "semantics": {"const": "lua-5.1"}, - "host_api": {"properties": {"profile": {"enum": ["sourceful.host/blixt-l1/v1", "sourceful.host/blixt-l1/v2"]}}} - } - } - } - } - } - ] - } - }, - "artifacts": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "artifact_id", - "target", - "media_type", - "filename", - "url", - "sha256", - "size_bytes" - ], - "properties": { - "artifact_id": {"$ref": "#/$defs/identifier"}, - "target": {"$ref": "#/$defs/target"}, - "media_type": { - "enum": [ - "application/vnd.sourceful.lua.source", - "application/vnd.sourceful.lua.bytecode" - ] - }, - "filename": { - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]+$" - }, - "url": { - "type": "string", - "format": "uri", - "pattern": "^https://" - }, - "sha256": {"$ref": "#/$defs/sha256"}, - "size_bytes": { - "type": "integer", - "minimum": 1 - } - } - } - } - }, - "allOf": [ - { - "if": {"properties": {"read_only": {"const": true}}}, - "then": { - "properties": { - "capabilities": {"properties": {"control": {"maxItems": 0}}}, - "commands": {"maxItems": 0}, - "default_mode": { - "properties": {"strategy": {"const": "not_applicable"}}, - "not": {"required": ["entrypoint"]} - }, - "lease_policy": { - "properties": { - "required_for_control": {"const": false}, - "expiry_action": {"const": "not_applicable"} - }, - "not": { - "anyOf": [ - {"required": ["max_duration_seconds"]}, - {"required": ["heartbeat_interval_seconds"]} - ] - } - }, - "compatibility": { - "items": {"properties": {"control_enabled": {"const": false}}} - } - } - }, - "else": { - "properties": { - "capabilities": {"properties": {"control": {"minItems": 1}}}, - "commands": {"minItems": 1}, - "default_mode": { - "required": ["entrypoint"], - "properties": {"strategy": {"const": "vendor_autonomous"}} - }, - "lease_policy": { - "required": ["max_duration_seconds", "heartbeat_interval_seconds"], - "properties": { - "required_for_control": {"const": true}, - "expiry_action": {"const": "return_to_default"} - } - } - } - } - } - ], - "$defs": { - "semver": { - "type": "string", - "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" - }, - "sha256": { - "type": "string", - "pattern": "^[0-9a-f]{64}$" - }, - "identifier": { - "type": "string", - "pattern": "^[a-z][a-z0-9_.-]+$" - }, - "capability": { - "enum": ["battery", "evse", "meter", "pv", "v2x_charger"] - }, - "target": { - "enum": ["ftw-core", "blixt-l1"] - } - } -} diff --git a/spec/signing.md b/spec/signing.md index 593e46f..bf01a54 100644 --- a/spec/signing.md +++ b/spec/signing.md @@ -1,20 +1,14 @@ # Driver signing boundary -Sourceful driver releases use Ed25519 envelopes to bind release data to its -content, permissions and source commit. The public repository never contains a -private key or release credential. +FTW's driver channel uses Ed25519 signatures to bind release data to its +content and source commit. The public repository never contains a private key +or release credential. ## Public build -Public CI validates source and builds unsigned candidates. Contributors can -run the same path locally: - -```bash -make package-driver ID=sdm630 TARGET=ftw-core -``` - -An unsigned candidate is test input. A host must not treat it as a release or -activate it through the normal update path. +Public CI validates source and signs nothing. A pull request can produce +unsigned test output only. A host must not treat it as a release or activate +it through the normal update path. ## FTW release @@ -28,35 +22,12 @@ beta. FTW pins the public key and keeps install and activation explicit. The FTW key signs distribution integrity. It does not certify a device, change the driver's tier or grant control rights. -## Device Support packages - -Device Support may later check out an exact commit, repeat validation and sign -package-v1 data for other products or a higher support level. Those packages -remain separate from FTW's default channel. Both paths use this repository as -their only editable source. - -Each publisher supplies keys and release credentials at run time. Those inputs -must not enter source files, package recipes, logs or build output. - -## Host checks - -Before staging a package, a host must: - -1. verify the index and package envelopes against a pinned public key; -2. require a known target and compatible host, runtime, ABI and host API; -3. verify the artifact URL, byte length and SHA-256; -4. keep install separate from activation; -5. retain a prior verified package for rollback. - -Package metadata never grants direct hardware control. The host still owns -leases, stale-data stops, command bounds and the driver default mode. +The release workflow supplies the signing key at run time. It must not enter +source files, logs or build output. ## Canonical bytes -Package v1 signs canonical JSON payload bytes, not a stand-alone artifact hash. -`sourceful.canonical-json/v1` uses UTF-8 JSON, sorted object keys, compact -separators, no NaN or Infinity and no trailing newline in the signed bytes. The -stored JSON file ends with one newline. - -See [driver-package-v1.md](driver-package-v1.md) for the full package and -promotion contract. +The channel signs the manifest payload's canonical JSON bytes, not a +stand-alone artifact hash: UTF-8, sorted object keys, compact separators, no +NaN or Infinity and no trailing newline in the signed bytes. The stored +`manifest.json` ends with one newline. diff --git a/spec/support-status.md b/spec/support-status.md index 1e4301b..1d8d5aa 100644 --- a/spec/support-status.md +++ b/spec/support-status.md @@ -1,27 +1,18 @@ # Driver support status Catalog presence means that public source and a manifest exist. It does not -mean that a driver has a conforming target package, a signed beta, HIL evidence +mean that a driver has passed a target contract, a signed beta, HIL evidence or a stable release. `support-status.json` records each catalog driver against `ftw-core` and `blixt-l1`. Its keys mean: - `target_conformance` — no assessment, contract passed or a staged gate; -- `candidate_package_version` — the next source recipe, not a signed release; - `historical_signed_beta_version` — an older beta that keeps its original provenance and must not be overwritten; - `hil` — recorded physical test state for that driver and target; -- `stable_package_version` — a package-v1 stable channel release; -- `legacy_parity` — whether the package target matches an existing host driver; -- `control_enabled` — the target flag in the candidate recipe. +- `legacy_parity` — whether the driver matches an existing host driver. -Package-v1 stable promotion does not rebuild artifacts. It keeps the source -commit, materials, artifact bytes, hashes and URLs from beta. Because v1 signs -`channel` inside the payload, stable has a new envelope and signature whose -only payload change is `channel`. Moving one byte-identical envelope between -channels would require a versioned package-v2 contract. - -Nova and fleet reports must use the same `driver_id`, `package_id`, version and -target values. Runtime truth still comes from each host's verified inventory, -not this planning file. +Values other than the defaults come from `support-status-overrides.json`. +Runtime truth still comes from each host's own inventory, not this planning +file. diff --git a/support-status-overrides.json b/support-status-overrides.json index d748f16..0967ef4 100644 --- a/support-status-overrides.json +++ b/support-status-overrides.json @@ -1,41 +1 @@ -{ - "pixii": { - "ftw-core": { - "target_conformance": "staged", - "hil": "required", - "legacy_parity": "blocked", - "note": "Target differs materially from the FTW legacy driver. Golden telemetry, identity, fault, default-mode parity and physical HIL are required before activation." - } - }, - "goodwe": { - "ftw-core": { - "target_conformance": "contract_passed", - "hil": "gw8kn_et_hk3000_required", - "legacy_parity": "profile_selected", - "note": "Version 1.0.2 is an observe-only GW8KN-ET/HK3000 candidate. Stable needs the masked fixture, three hard-reboot recoveries and a 24-hour field pass." - } - }, - "sdm630": { - "ftw-core": { - "target_conformance": "contract_passed", - "historical_signed_beta_version": "1.1.1", - "hil": "required_for_public_source_candidate", - "legacy_parity": "not_assessed" - }, - "blixt-l1": { - "target_conformance": "contract_passed", - "historical_signed_beta_version": "1.1.1", - "hil": "required_for_public_source_candidate", - "legacy_parity": "not_applicable_new_pilot" - } - }, - "sungrow": { - "ftw-core": { - "target_conformance": "observe_only_profile_blocked", - "historical_signed_beta_version": "1.3.0", - "hil": "exact_model_firmware_required", - "legacy_parity": "not_assessed", - "note": "Version 1.3.2 is read-only and blocks activation until an exact model and firmware profile passes review. Version 1.3.1 remains staged and must not run on the pilot site." - } - } -} +{} diff --git a/support-status.json b/support-status.json index 7a93e1a..55dacd3 100644 --- a/support-status.json +++ b/support-status.json @@ -4,26 +4,19 @@ "catalog_source": true, "catalog_version": "0.2.4", "driver_id": "50-125k-svk-ac-slew", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -32,26 +25,19 @@ "catalog_source": true, "catalog_version": "0.1.12", "driver_id": "50-125k-svk-slew", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -60,26 +46,19 @@ "catalog_source": true, "catalog_version": "0.2.3", "driver_id": "50-125k-svk", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -88,26 +67,19 @@ "catalog_source": true, "catalog_version": "1.0.6", "driver_id": "abb_meter", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -116,26 +88,19 @@ "catalog_source": true, "catalog_version": "1.0.3", "driver_id": "abb_terra", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -144,26 +109,19 @@ "catalog_source": true, "catalog_version": "1.0.4", "driver_id": "acrel", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -172,26 +130,19 @@ "catalog_source": true, "catalog_version": "0.4.2", "driver_id": "acuvim", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -200,26 +151,19 @@ "catalog_source": true, "catalog_version": "1.0.4", "driver_id": "alfen", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -228,26 +172,19 @@ "catalog_source": true, "catalog_version": "1.1.1", "driver_id": "alphaess", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -256,26 +193,19 @@ "catalog_source": true, "catalog_version": "1.2.0", "driver_id": "ambibox", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -284,26 +214,19 @@ "catalog_source": true, "catalog_version": "1.1.0", "driver_id": "ambibox_v2x", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -312,26 +235,19 @@ "catalog_source": true, "catalog_version": "1.1.1", "driver_id": "atmoce", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -340,26 +256,19 @@ "catalog_source": true, "catalog_version": "1.0.5", "driver_id": "carlo_gavazzi", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -368,26 +277,19 @@ "catalog_source": true, "catalog_version": "1.0.5", "driver_id": "chint", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -396,26 +298,19 @@ "catalog_source": true, "catalog_version": "1.0.5", "driver_id": "circutor", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -424,26 +319,19 @@ "catalog_source": true, "catalog_version": "0.3.2", "driver_id": "ctek", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -452,26 +340,19 @@ "catalog_source": true, "catalog_version": "0.3.1", "driver_id": "ctek_hybrid", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -480,26 +361,19 @@ "catalog_source": true, "catalog_version": "0.3.2", "driver_id": "ctek_v2", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -508,26 +382,19 @@ "catalog_source": true, "catalog_version": "0.2.1", "driver_id": "deye-svk", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -536,26 +403,19 @@ "catalog_source": true, "catalog_version": "2.1.1", "driver_id": "deye", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -564,26 +424,19 @@ "catalog_source": true, "catalog_version": "1.0.4", "driver_id": "easee", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -592,26 +445,19 @@ "catalog_source": true, "catalog_version": "1.3.3", "driver_id": "easee_cloud", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -620,26 +466,19 @@ "catalog_source": true, "catalog_version": "1.0.3", "driver_id": "esphome-dsmr", - "package_id": "com.sourceful.driver.esphome-dsmr", "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "1.0.2", - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -648,26 +487,19 @@ "catalog_source": true, "catalog_version": "1.0.3", "driver_id": "esphome_dsmr", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -676,26 +508,19 @@ "catalog_source": true, "catalog_version": "1.0.3", "driver_id": "etrel", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -704,26 +529,19 @@ "catalog_source": true, "catalog_version": "2.1.2", "driver_id": "ferroamp", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -732,26 +550,19 @@ "catalog_source": true, "catalog_version": "2.1.0", "driver_id": "ferroamp_dc2_v2x", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -760,26 +571,19 @@ "catalog_source": true, "catalog_version": "2.1.2", "driver_id": "ferroamp_modbus", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -788,26 +592,19 @@ "catalog_source": true, "catalog_version": "0.9.5", "driver_id": "foxess_h3_smart", - "package_id": "com.sourceful.driver.foxess-h3-smart", "targets": { "blixt-l1": { - "candidate_package_version": "0.9.5", - "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "0.9.5", - "control_enabled": true, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -816,26 +613,19 @@ "catalog_source": true, "catalog_version": "2.1.2", "driver_id": "fronius", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -844,26 +634,19 @@ "catalog_source": true, "catalog_version": "1.0.2", "driver_id": "fronius_api", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -872,26 +655,19 @@ "catalog_source": true, "catalog_version": "2.1.2", "driver_id": "fronius_smart_meter", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -900,26 +676,19 @@ "catalog_source": true, "catalog_version": "1.0.3", "driver_id": "goe", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -928,26 +697,19 @@ "catalog_source": true, "catalog_version": "1.0.1", "driver_id": "goe_http", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -956,27 +718,20 @@ "catalog_source": true, "catalog_version": "2.1.2", "driver_id": "goodwe", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, - "hil": "gw8kn_et_hk3000_required", + "hil": "not_recorded", "historical_signed_beta_version": null, - "legacy_parity": "profile_selected", - "note": "Version 1.0.2 is an observe-only GW8KN-ET/HK3000 candidate. Stable needs the masked fixture, three hard-reboot recoveries and a 24-hour field pass.", - "stable_package_version": null, - "target_conformance": "contract_passed" + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" } } }, @@ -984,26 +739,19 @@ "catalog_source": true, "catalog_version": "2.1.2", "driver_id": "growatt", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1012,26 +760,19 @@ "catalog_source": true, "catalog_version": "1.0.1", "driver_id": "hardybarth", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1040,26 +781,19 @@ "catalog_source": true, "catalog_version": "0.5.0", "driver_id": "heishamon", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1068,26 +802,19 @@ "catalog_source": true, "catalog_version": "1.1.2", "driver_id": "hello", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1096,26 +823,19 @@ "catalog_source": true, "catalog_version": "2.1.2", "driver_id": "huawei", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1124,26 +844,19 @@ "catalog_source": true, "catalog_version": "1.0.5", "driver_id": "janitza", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1152,26 +865,19 @@ "catalog_source": true, "catalog_version": "1.0.3", "driver_id": "keba", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1180,26 +886,19 @@ "catalog_source": true, "catalog_version": "0.3.1", "driver_id": "konja-261-svk", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1208,26 +907,19 @@ "catalog_source": true, "catalog_version": "2.1.2", "driver_id": "kostal", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1236,26 +928,19 @@ "catalog_source": true, "catalog_version": "1.1.1", "driver_id": "kstar", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1264,26 +949,19 @@ "catalog_source": true, "catalog_version": "1.0.3", "driver_id": "mennekes", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1292,26 +970,19 @@ "catalog_source": true, "catalog_version": "1.2.2", "driver_id": "myuplink", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1320,26 +991,19 @@ "catalog_source": true, "catalog_version": "1.2.0", "driver_id": "nibe_local", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1348,26 +1012,19 @@ "catalog_source": true, "catalog_version": "1.0.2", "driver_id": "opendtu", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1376,26 +1033,19 @@ "catalog_source": true, "catalog_version": "1.0.3", "driver_id": "opendtu_mqtt", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1404,26 +1054,19 @@ "catalog_source": true, "catalog_version": "1.0.1", "driver_id": "openevse", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1432,26 +1075,19 @@ "catalog_source": true, "catalog_version": "2.0.2", "driver_id": "p1_dsmr", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1460,26 +1096,19 @@ "catalog_source": true, "catalog_version": "2.0.2", "driver_id": "p1_encrypted", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1488,26 +1117,19 @@ "catalog_source": true, "catalog_version": "2.0.2", "driver_id": "p1_hdlc", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1516,26 +1138,19 @@ "catalog_source": true, "catalog_version": "2.0.2", "driver_id": "p1_meter", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1544,27 +1159,20 @@ "catalog_source": true, "catalog_version": "2.1.4", "driver_id": "pixii", - "package_id": "com.sourceful.driver.pixii", "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "1.2.5", - "control_enabled": false, - "hil": "required", + "hil": "not_recorded", "historical_signed_beta_version": null, - "legacy_parity": "blocked", - "note": "Target differs materially from the FTW legacy driver. Golden telemetry, identity, fault, default-mode parity and physical HIL are required before activation.", - "stable_package_version": null, - "target_conformance": "staged" + "legacy_parity": "not_assessed", + "note": "", + "target_conformance": "not_assessed" } } }, @@ -1572,26 +1180,19 @@ "catalog_source": true, "catalog_version": "0.3.1", "driver_id": "pixii_pv", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1600,26 +1201,19 @@ "catalog_source": true, "catalog_version": "1.2.0", "driver_id": "saj", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1628,26 +1222,19 @@ "catalog_source": true, "catalog_version": "1.0.5", "driver_id": "schneider_meter", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1656,26 +1243,19 @@ "catalog_source": true, "catalog_version": "1.0.3", "driver_id": "schrack_ev", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1684,27 +1264,20 @@ "catalog_source": true, "catalog_version": "2.0.1", "driver_id": "sdm630", - "package_id": "com.sourceful.driver.sdm630", "targets": { "blixt-l1": { - "candidate_package_version": "2.0.0", - "control_enabled": false, - "hil": "required_for_public_source_candidate", - "historical_signed_beta_version": "1.1.1", - "legacy_parity": "not_applicable_new_pilot", + "hil": "not_recorded", + "historical_signed_beta_version": null, + "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, - "target_conformance": "contract_passed" + "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "2.0.0", - "control_enabled": false, - "hil": "required_for_public_source_candidate", - "historical_signed_beta_version": "1.1.1", + "hil": "not_recorded", + "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, - "target_conformance": "contract_passed" + "target_conformance": "not_assessed" } } }, @@ -1712,26 +1285,19 @@ "catalog_source": true, "catalog_version": "1.1.2", "driver_id": "shelly", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1740,26 +1306,19 @@ "catalog_source": true, "catalog_version": "1.0.5", "driver_id": "siemens_pac", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1768,26 +1327,19 @@ "catalog_source": true, "catalog_version": "1.1.3", "driver_id": "sigenergy", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1796,26 +1348,19 @@ "catalog_source": true, "catalog_version": "2.1.2", "driver_id": "sma", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1824,26 +1369,19 @@ "catalog_source": true, "catalog_version": "1.1.1", "driver_id": "sma_pv", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1852,26 +1390,19 @@ "catalog_source": true, "catalog_version": "1.0.4", "driver_id": "socomec", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1880,26 +1411,19 @@ "catalog_source": true, "catalog_version": "2.1.2", "driver_id": "sofar", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1908,26 +1432,19 @@ "catalog_source": true, "catalog_version": "2.1.1", "driver_id": "solaredge", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1936,26 +1453,19 @@ "catalog_source": true, "catalog_version": "0.3.2", "driver_id": "solaredge_legacy", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1964,26 +1474,19 @@ "catalog_source": true, "catalog_version": "1.2.1", "driver_id": "solaredge_pv", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -1992,26 +1495,19 @@ "catalog_source": true, "catalog_version": "1.1.1", "driver_id": "solax", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2020,26 +1516,19 @@ "catalog_source": true, "catalog_version": "1.1.1", "driver_id": "solinteg", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2048,26 +1537,19 @@ "catalog_source": true, "catalog_version": "2.1.1", "driver_id": "solis", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2076,26 +1558,19 @@ "catalog_source": true, "catalog_version": "1.1.2", "driver_id": "solis_string", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2104,26 +1579,19 @@ "catalog_source": true, "catalog_version": "2.0.3", "driver_id": "sonnen", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2132,27 +1600,20 @@ "catalog_source": true, "catalog_version": "1.5.8", "driver_id": "sungrow", - "package_id": "com.sourceful.driver.sungrow", "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": "1.3.3", - "control_enabled": false, - "hil": "exact_model_firmware_required", - "historical_signed_beta_version": "1.3.0", + "hil": "not_recorded", + "historical_signed_beta_version": null, "legacy_parity": "not_assessed", - "note": "Version 1.3.2 is read-only and blocks activation until an exact model and firmware profile passes review. Version 1.3.1 remains staged and must not run on the pilot site.", - "stable_package_version": null, - "target_conformance": "observe_only_profile_blocked" + "note": "", + "target_conformance": "not_assessed" } } }, @@ -2160,26 +1621,19 @@ "catalog_source": true, "catalog_version": "0.2.2", "driver_id": "tesla_vehicle", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2188,26 +1642,19 @@ "catalog_source": true, "catalog_version": "0.1.0", "driver_id": "tesla_wall_connector", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2216,26 +1663,19 @@ "catalog_source": true, "catalog_version": "1.1.2", "driver_id": "tibber", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2244,26 +1684,19 @@ "catalog_source": true, "catalog_version": "1.1.1", "driver_id": "varta", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2272,26 +1705,19 @@ "catalog_source": true, "catalog_version": "2.1.2", "driver_id": "victron", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2300,26 +1726,19 @@ "catalog_source": true, "catalog_version": "1.0.3", "driver_id": "victron_mqtt", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2328,26 +1747,19 @@ "catalog_source": true, "catalog_version": "1.0.3", "driver_id": "wallbox", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2356,26 +1768,19 @@ "catalog_source": true, "catalog_version": "3.1.0", "driver_id": "zap", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2384,26 +1789,19 @@ "catalog_source": true, "catalog_version": "1.0.1", "driver_id": "zaptec", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2412,26 +1810,19 @@ "catalog_source": true, "catalog_version": "0.1.0", "driver_id": "zaptec_cloud", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } @@ -2440,26 +1831,19 @@ "catalog_source": true, "catalog_version": "1.1.1", "driver_id": "zuidwijk_p1", - "package_id": null, "targets": { "blixt-l1": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" }, "ftw-core": { - "candidate_package_version": null, - "control_enabled": false, "hil": "not_recorded", "historical_signed_beta_version": null, "legacy_parity": "not_assessed", "note": "", - "stable_package_version": null, "target_conformance": "not_assessed" } } diff --git a/tests/test_driver_package.py b/tests/test_driver_package.py deleted file mode 100644 index 2cd14db..0000000 --- a/tests/test_driver_package.py +++ /dev/null @@ -1,440 +0,0 @@ -"""Contract tests for sourceful.driver-package/v1.""" - -from __future__ import annotations - -import copy -import json -import sys -from pathlib import Path - -import pytest -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / "tools")) - -from driver_package import ( # noqa: E402 - PackageError, - _compare_semver, - _validate_lua_source_for_target, - build_index, - build_package, - canonical_json, - compatible_target, - promote_payload, - sign_index_payload, - sign_payload, - validate_document, - verify_envelope, - verify_index_envelope, -) - - -PILOTS = { - "sdm630": ROOT / "packages" / "v1" / "sdm630" / "package-source.json", - "sungrow": ROOT / "packages" / "v1" / "sungrow" / "package-source.json", -} -CONTROLLABLE = ROOT / "packages" / "v1" / "pixii" / "package-source.json" -SOURCE_COMMIT = "a" * 40 -SOURCE_DATE_EPOCH = 1_700_000_000 -BASE_URL = "https://github.com/srcfl/device-drivers/releases/download/test" - - -def load_json(path: Path) -> dict: - return json.loads(path.read_text(encoding="utf-8")) - - -@pytest.fixture -def fake_luac55(tmp_path: Path) -> Path: - compiler = tmp_path / "luac55" - compiler.write_text( - "#!/usr/bin/env python3\n" - "import pathlib, sys\n" - "output = pathlib.Path(sys.argv[sys.argv.index('-o') + 1])\n" - "source = pathlib.Path(sys.argv[-1])\n" - "output.write_bytes(b'\\x1bLua\\x55' + source.read_bytes())\n", - encoding="utf-8", - ) - compiler.chmod(0o755) - return compiler - - -@pytest.fixture -def keypair(tmp_path: Path) -> tuple[Path, Path]: - private_key = Ed25519PrivateKey.generate() - private_path = tmp_path / "private.pem" - public_path = tmp_path / "public.pem" - private_path.write_bytes( - private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - ) - public_path.write_bytes( - private_key.public_key().public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - ) - return private_path, public_path - - -def build_pilot(name: str, output: Path, compiler: Path) -> dict: - return build_package( - PILOTS[name], - ROOT, - output, - BASE_URL, - SOURCE_COMMIT, - SOURCE_DATE_EPOCH, - compiler, - ) - - -@pytest.mark.parametrize("name", sorted(PILOTS)) -def test_pilot_source_is_valid(name: str) -> None: - validated = validate_document(load_json(PILOTS[name])) - assert validated["schema_version"] == "sourceful.driver-package-source/v1" - - -def test_pilot_safety_metadata_is_explicit() -> None: - sdm630 = load_json(PILOTS["sdm630"]) - sdm_targets = {item["target"] for item in sdm630["compatibility"]} - assert sdm630["read_only"] is True - assert sdm630["commands"] == [] - assert sdm_targets == {"ftw-core", "blixt-l1"} - assert all(not target["control_enabled"] for target in sdm630["compatibility"]) - - sungrow = load_json(PILOTS["sungrow"]) - targets = {item["target"]: item for item in sungrow["compatibility"]} - assert sungrow["read_only"] is True - # No version assertion. This test is about what the package promises -- - # read-only, no commands, no control permissions -- and a version is not - # one of those promises. Pinning it here made an unrelated safety test the - # thing that failed on every legitimate bump, and the version is already - # held to the driver's own by the manifest checks and the package build. - assert sungrow["commands"] == [] - assert sungrow["capabilities"]["control"] == [] - assert sungrow["permissions"] == ["modbus.read"] - assert sungrow["default_mode"]["strategy"] == "not_applicable" - assert set(targets) == {"ftw-core"} - assert targets["ftw-core"]["control_enabled"] is False - assert sungrow["lease_policy"]["expiry_action"] == "not_applicable" - assert [item["model_family"] for item in sungrow["device_matches"]] == [ - "SH-RT (Three-Phase Hybrid)" - ] - - -def test_packaging_is_byte_for_byte_deterministic( - tmp_path: Path, fake_luac55: Path -) -> None: - first = tmp_path / "first" - second = tmp_path / "second" - first_payload = build_pilot("sdm630", first, fake_luac55) - second_payload = build_pilot("sdm630", second, fake_luac55) - - assert first_payload == second_payload - assert (first / "manifest.json").read_bytes() == (second / "manifest.json").read_bytes() - assert sorted(path.name for path in first.iterdir()) == sorted( - path.name for path in second.iterdir() - ) - for path in first.iterdir(): - assert path.read_bytes() == (second / path.name).read_bytes() - - -def test_sungrow_package_uses_the_observe_only_ftw_target( - tmp_path: Path, fake_luac55: Path -) -> None: - source = load_json(PILOTS["sungrow"]) - assert source["source"]["path"] == "packages/v1/sungrow/targets/ftw-observe.lua" - assert source["artifact_inputs"][0]["input_path"] == source["source"]["path"] - - payload = build_pilot("sungrow", tmp_path / "package", fake_luac55) - artifact = tmp_path / "package" / payload["artifacts"][0]["filename"] - lua = artifact.read_text(encoding="utf-8") - assert "host.modbus_write" not in lua - assert "host.modbus_write_multi" not in lua - assert "function driver_command_v2(" not in lua - assert "function driver_default_mode_v2(" not in lua - assert "function driver_command(" in lua - assert "function driver_default_mode(" in lua - assert payload["read_only"] is True - assert payload["permissions"] == ["modbus.read"] - assert payload["compatibility"][0]["control_enabled"] is False - - -def test_signed_envelope_and_artifacts_verify( - tmp_path: Path, fake_luac55: Path, keypair: tuple[Path, Path] -) -> None: - private_key, public_key = keypair - output = tmp_path / "package" - payload = build_pilot("sdm630", output, fake_luac55) - envelope = sign_payload(payload, private_key, "sourceful-driver-test-1") - - assert verify_envelope(envelope, public_key, output) == payload - - tampered_envelope = copy.deepcopy(envelope) - tampered_envelope["payload"]["display_name"] = "Tampered" - with pytest.raises(PackageError, match="signature verification failed"): - verify_envelope(tampered_envelope, public_key, output) - - artifact = output / payload["artifacts"][0]["filename"] - artifact.write_bytes(artifact.read_bytes() + b"tampered") - with pytest.raises(PackageError, match="artifact size mismatch"): - verify_envelope(envelope, public_key, output) - - -def test_signed_driver_index_binds_verified_package_envelopes( - tmp_path: Path, fake_luac55: Path, keypair: tuple[Path, Path] -) -> None: - private_key, public_key = keypair - output = tmp_path / "package" - payload = build_pilot("sdm630", output, fake_luac55) - package_envelope = sign_payload(payload, private_key, "sourceful-driver-test-1") - package_envelope_path = output / "manifest.envelope.json" - package_envelope_path.write_bytes(canonical_json(package_envelope) + b"\n") - - index = build_index( - [package_envelope_path], - ["https://packages.example/sdm630/1.1.1/manifest.envelope.json"], - "beta", - SOURCE_DATE_EPOCH, - public_key, - ) - assert index["packages"][0]["targets"] == ["blixt-l1", "ftw-core"] - index_envelope = sign_index_payload(index, private_key, "sourceful-driver-test-1") - assert verify_index_envelope(index_envelope, public_key) == index - - tampered = copy.deepcopy(index_envelope) - tampered["payload"]["packages"][0]["version"] = "9.9.9" - with pytest.raises(PackageError, match="signature verification failed"): - verify_index_envelope(tampered, public_key) - - -def test_promotion_keeps_reviewed_bytes_and_provenance( - tmp_path: Path, fake_luac55: Path, keypair: tuple[Path, Path] -) -> None: - private_key, public_key = keypair - beta = build_pilot("sdm630", tmp_path / "package", fake_luac55) - stable = promote_payload(beta) - - assert stable["channel"] == "stable" - assert stable["artifacts"] == beta["artifacts"] - assert stable["source"] == beta["source"] - assert stable["provenance"] == beta["provenance"] - assert verify_envelope( - sign_payload(stable, private_key, "sourceful-driver-test-1"), public_key - ) == stable - - -def test_promotion_is_only_beta_to_stable( - tmp_path: Path, fake_luac55: Path -) -> None: - beta = build_pilot("sdm630", tmp_path / "package", fake_luac55) - with pytest.raises(PackageError, match="only allows beta-to-stable"): - promote_payload(beta, source_channel="beta", target_channel="beta") - - stable = promote_payload(beta) - with pytest.raises(PackageError, match="requires a beta package"): - promote_payload(stable) - - -def test_compatibility_requires_a_fully_known_host( - tmp_path: Path, fake_luac55: Path -) -> None: - payload = build_pilot("sdm630", tmp_path / "package", fake_luac55) - known_host = { - "target": "ftw-core", - "host_product": "ftw", - "host_version": "1.4.0", - "runtime_name": "gopher-lua", - "runtime_semantics": "lua-5.1", - "runtime_version": "1.1.2", - "runtime_abi": "gopher-lua-source-v1", - "host_api_profile": "sourceful.host/ftw-core/v1", - "host_api": 1, - } - assert compatible_target(payload, **known_host) is not None - - for field in known_host: - unknown = dict(known_host) - unknown[field] = None - assert compatible_target(payload, **unknown) is None - assert compatible_target(payload, **{**known_host, "target": "future-host"}) is None - assert compatible_target(payload, **{**known_host, "host_version": "1.3.9"}) is None - assert compatible_target(payload, **{**known_host, "host_version": "2.0.0"}) is None - assert compatible_target(payload, **{**known_host, "runtime_abi": "unknown"}) is None - - -def test_sdm630_binds_distinct_hosts_to_the_same_reviewed_source( - tmp_path: Path, fake_luac55: Path -) -> None: - payload = build_pilot("sdm630", tmp_path / "package", fake_luac55) - artifacts = {item["target"]: item for item in payload["artifacts"]} - assert set(artifacts) == {"ftw-core", "blixt-l1"} - assert artifacts["ftw-core"]["sha256"] == artifacts["blixt-l1"]["sha256"] - - blixt = compatible_target( - payload, - target="blixt-l1", - host_product="blixt-gateway", - host_version="0.1.0", - runtime_name="luajit", - runtime_semantics="lua-5.1", - runtime_version="2.1", - runtime_abi="mlua-0.10-luajit21-source-v1", - host_api_profile="sourceful.host/blixt-l1/v1", - host_api=1, - ) - assert blixt is not None - assert blixt["control_enabled"] is False - - -def test_target_source_contracts_fail_closed() -> None: - lifecycle = b""" -function driver_init(config) end -function driver_poll() end -function driver_command(action, value, context) end -function driver_default_mode() end -function driver_cleanup() end -""" - with pytest.raises(PackageError, match="DRIVER_MANIFEST"): - _validate_lua_source_for_target( - lifecycle, - target="blixt-l1", - read_only=True, - package_version="1.0.0", - ) - with pytest.raises(PackageError, match="must declare DRIVER"): - _validate_lua_source_for_target( - lifecycle, - target="ftw-core", - read_only=True, - package_version="1.0.0", - ) - missing_read_only = ( - lifecycle - + b'DRIVER = { version = "1.0.0", host_api_min = 1, host_api_max = 1 }\n' - ) - with pytest.raises(PackageError, match="must declare read_only = true"): - _validate_lua_source_for_target( - missing_read_only, - target="ftw-core", - read_only=True, - package_version="1.0.0", - ) - other_metadata_is_not_ftw_metadata = ( - missing_read_only - + b'DRIVER_MANIFEST = { version = "1.0.0", read_only = true }\n' - + b'-- read_only = true\n' - ) - with pytest.raises(PackageError, match="must declare read_only = true"): - _validate_lua_source_for_target( - other_metadata_is_not_ftw_metadata, - target="ftw-core", - read_only=True, - package_version="1.0.0", - ) - wrong_read_only = missing_read_only.replace(b" }", b", read_only = false }") - with pytest.raises(PackageError, match="must match the package"): - _validate_lua_source_for_target( - wrong_read_only, - target="ftw-core", - read_only=True, - package_version="1.0.0", - ) - unsafe = ( - lifecycle - + b'DRIVER_MANIFEST = { version = "1.0.0", read_only = true }\n' - + b"host.modbus_write(1, 2)\n" - ) - with pytest.raises(PackageError, match="read-only"): - _validate_lua_source_for_target( - unsafe, - target="blixt-l1", - read_only=True, - package_version="1.0.0", - ) - - ftw_v1 = lifecycle + b'DRIVER = { version = "1.0.0", host_api_min = 1, host_api_max = 1 }\n' - with pytest.raises(PackageError, match="driver_command_v2"): - _validate_lua_source_for_target( - ftw_v1, - target="ftw-core", - read_only=False, - package_version="1.0.0", - runtime_abi="gopher-lua-source-v2", - default_entrypoint="driver_default_mode_v2", - ) - - -def test_unknown_package_target_is_rejected() -> None: - source = load_json(PILOTS["sdm630"]) - source["compatibility"][0]["target"] = "future-host" - source["artifact_inputs"][0]["target"] = "future-host" - with pytest.raises(PackageError, match="future-host"): - validate_document(source) - - -def test_secrets_are_rejected_from_package_source() -> None: - source = load_json(PILOTS["sdm630"]) - source["password"] = "must-not-ship" - with pytest.raises(PackageError): - validate_document(source) - - -def test_policy_rejects_unsafe_read_only_and_lease_combinations() -> None: - read_only = load_json(PILOTS["sdm630"]) - read_only["permissions"].append("modbus.write") - with pytest.raises(PackageError, match="read-only"): - validate_document(read_only) - - read_only = load_json(PILOTS["sdm630"]) - read_only["default_mode"] = { - "strategy": "vendor_autonomous", - "entrypoint": "driver_default_mode", - "description": "Unsafe for a read-only package.", - } - with pytest.raises(PackageError, match="default mode"): - validate_document(read_only) - - controllable = load_json(CONTROLLABLE) - controllable["lease_policy"]["heartbeat_interval_seconds"] = 30 - with pytest.raises(PackageError, match="heartbeat interval"): - validate_document(controllable) - - controllable = load_json(CONTROLLABLE) - del controllable["lease_policy"]["heartbeat_interval_seconds"] - with pytest.raises(PackageError, match="bounded lease"): - validate_document(controllable) - - -def test_control_target_requires_approved_v2_runtime() -> None: - controllable = load_json(CONTROLLABLE) - controllable["compatibility"][0]["control_enabled"] = True - validate_document(controllable) - - target = controllable["compatibility"][0] - target["runtime"]["abi"] = "gopher-lua-source-v1" - target["runtime"]["host_api"] = { - "profile": "sourceful.host/ftw-core/v1", - "min": 1, - "max": 1, - } - with pytest.raises(PackageError, match="control requires gopher-lua-source-v2"): - validate_document(controllable) - - controllable = load_json(CONTROLLABLE) - controllable["compatibility"][0]["control_enabled"] = True - controllable["default_mode"]["entrypoint"] = "driver_default_mode" - with pytest.raises(PackageError, match="driver_default_mode_v2"): - validate_document(controllable) - - -def test_semver_precedence_supports_prereleases_and_ignores_build_metadata() -> None: - assert _compare_semver("1.2.3-beta.1", "1.2.3-beta.2") < 0 - assert _compare_semver("1.2.3-beta.2", "1.2.3") < 0 - assert _compare_semver("1.2.3+build.1", "1.2.3+build.2") == 0 diff --git a/tests/test_driver_runtime_contracts.py b/tests/test_driver_runtime_contracts.py deleted file mode 100644 index 8282e0c..0000000 --- a/tests/test_driver_runtime_contracts.py +++ /dev/null @@ -1,142 +0,0 @@ -from __future__ import annotations - -from copy import deepcopy - -import pytest - -from tools.driver_package import PackageError, validate_document - - -def inventory() -> dict: - return { - "schema_version": "sourceful.driver-inventory/v1", - "generated_at": "2026-07-20T10:00:00Z", - "host": { - "product": "ftw", - "version": "1.5.0-beta.1", - "update_channel": "beta", - "target": "ftw-core", - "runtime_abi": "gopher-lua-source-v1", - "host_api": 1, - }, - "drivers": [ - { - "driver_id": "sdm630", - "version": "1.1.1", - "source": "managed", - "package_id": "com.sourceful.driver.sdm630", - "repository_id": "sourceful", - "package_channel": "beta", - "artifact_sha256": "a" * 64, - "control_class": "read_only", - "configured_instances": 2, - "running_instances": 2, - "health": {"ok": 1, "degraded": 0, "offline": 1, "unknown": 0}, - } - ], - } - - -def test_inventory_contract_accepts_bounded_aggregate() -> None: - assert validate_document(inventory())["drivers"][0]["driver_id"] == "sdm630" - - -def test_inventory_contract_rejects_host_target_mismatch() -> None: - mismatch = deepcopy(inventory()) - mismatch["host"]["target"] = "blixt-l1" - with pytest.raises(PackageError): - validate_document(mismatch) - - -def test_inventory_contract_rejects_false_counts_and_package_claims() -> None: - bad_counts = deepcopy(inventory()) - bad_counts["drivers"][0]["health"]["ok"] = 2 - with pytest.raises(PackageError, match="counts"): - validate_document(bad_counts) - - false_claim = deepcopy(inventory()) - false_claim["drivers"][0]["source"] = "bundled" - with pytest.raises(PackageError): - validate_document(false_claim) - - -def test_inventory_contract_requires_source_hash_for_bundled_drivers() -> None: - bundled = deepcopy(inventory()) - driver = bundled["drivers"][0] - driver["source"] = "bundled" - for field in ("package_id", "repository_id", "package_channel", "artifact_sha256"): - del driver[field] - - with pytest.raises(PackageError): - validate_document(bundled) - - driver["source_sha256"] = "b" * 64 - assert validate_document(bundled) == bundled - - -def test_inventory_contract_marks_legacy_repository_without_package_claim() -> None: - legacy = deepcopy(inventory()) - driver = legacy["drivers"][0] - driver["source"] = "legacy_repository" - del driver["package_id"] - del driver["package_channel"] - - assert validate_document(legacy) == legacy - - -def test_command_and_result_contracts() -> None: - command = { - "schema_version": "sourceful.driver-command/v1", - "id": "cmd-01K0ABCDEF0123456789", - "command": "battery.set_power", - "source": "ftw.core", - "issued_at": "2026-07-20T10:00:00Z", - "expires_at": "2026-07-20T10:00:30Z", - "attempt": 1, - "lease": { - "id": "lease-01K0ABCDEF01234567", - "expires_at": "2026-07-20T10:00:30Z", - "heartbeat_interval_ms": 10000, - }, - "inputs": {"power_w": -2500}, - } - result = { - "schema_version": "sourceful.driver-command-result/v1", - "id": command["id"], - "command": command["command"], - "lease_id": command["lease"]["id"], - "status": "applied", - "code": "ok", - "completed_at": "2026-07-20T10:00:01Z", - "device_state": "controlled", - "writes": 2, - "evidence": ["write_ack", "readback"], - "applied": {"power_w": -2500}, - "driver": { - "package_id": "com.sourceful.driver.sungrow", - "version": "1.3.0-beta.1", - "artifact_sha256": "b" * 64, - }, - } - assert validate_document(command) == command - assert validate_document(result) == result - - -def test_command_contract_rejects_secret_fields() -> None: - command = { - "schema_version": "sourceful.driver-command/v1", - "id": "cmd-01K0ABCDEF0123456789", - "command": "battery.set_power", - "source": "ftw.core", - "issued_at": "2026-07-20T10:00:00Z", - "expires_at": "2026-07-20T10:00:30Z", - "attempt": 1, - "lease": { - "id": "lease-01K0ABCDEF01234567", - "expires_at": "2026-07-20T10:00:30Z", - "heartbeat_interval_ms": 10000, - }, - "inputs": {"api_key": "no"}, - } - with pytest.raises(PackageError): - validate_document(command) diff --git a/tests/test_ftw_legacy_parity.py b/tests/test_ftw_legacy_parity.py index 6fe3336..162bb67 100644 --- a/tests/test_ftw_legacy_parity.py +++ b/tests/test_ftw_legacy_parity.py @@ -1,4 +1,4 @@ -"""Byte-exact FTW legacy imports and observe-only derivation checks.""" +"""Byte-exact FTW legacy import checks.""" from __future__ import annotations @@ -10,19 +10,10 @@ ROOT = Path(__file__).resolve().parents[1] SOURCE_MAP = ROOT / "baselines" / "ftw" / "source-map.json" BASELINE = ROOT / "baselines" / "ftw" / "drivers" / "sungrow.lua" -OBSERVE = ROOT / "packages" / "v1" / "sungrow" / "targets" / "ftw-observe.lua" -def extract(source: str, start: str, end: str) -> str: - start_at = source.index(start) - end_at = source.index(end, start_at) - return source[start_at:end_at].rstrip() - - -# packages/v1/sungrow/targets/ftw-observe.lua is generated from this exact -# file, and tools/derive_sungrow_ftw_observe.py refuses to run against any -# other bytes. That hash is the contract, not FTW's commit sha: the sha moves -# whenever any other bundled driver changes, while these bytes must not. +# The hash of these exact bytes is the record, not FTW's commit sha: the sha +# moves whenever any other bundled driver changes, while these bytes must not. SUNGROW_BASELINE_SHA256 = ( "466a5f8637e6756fc2e1af4197d4edc1845474231413c0016f0e5900acb7b7ac") @@ -60,42 +51,3 @@ def test_every_ftw_baseline_matches_its_recorded_hash() -> None: assert entry["import_status"] == "byte_exact" # A baseline is a record, never something the channel can activate. assert entry["live_activation"] == "blocked" - - -def test_sungrow_observe_target_keeps_ftw_fingerprint_and_poll() -> None: - baseline = BASELINE.read_text(encoding="utf-8") - observe = OBSERVE.read_text(encoding="utf-8") - - baseline_fingerprint = extract( - baseline, - "function driver_fingerprint()", - "\n----------------------------------------------------------------------------\n-- Initialization", - ) - observe_fingerprint = extract( - observe, - "function driver_fingerprint()", - "\n\nfunction driver_init", - ) - baseline_poll = extract( - baseline, - "function driver_poll()", - "\n----------------------------------------------------------------------------\n-- Battery control", - ) - observe_poll = extract( - observe, - "function driver_poll()", - "\n\nfunction driver_command", - ) - - assert observe_fingerprint == baseline_fingerprint - assert observe_poll == baseline_poll - - -def test_sungrow_observe_target_has_no_write_path() -> None: - observe = OBSERVE.read_text(encoding="utf-8") - - assert "host.modbus_write" not in observe - assert "host.modbus_write_multi" not in observe - assert "function driver_command_v2(" not in observe - assert "function driver_default_mode_v2(" not in observe - assert "local APPROVED_MODEL_FIRMWARE_PROFILES = {}" in observe diff --git a/tests/test_host_api_profile.py b/tests/test_host_api_profile.py index 293933c..ca0ff39 100644 --- a/tests/test_host_api_profile.py +++ b/tests/test_host_api_profile.py @@ -37,14 +37,13 @@ def allowed_functions(profile_name: str) -> set[str]: PENDING = set(PROFILE["pending"]["functions"]) CATALOG = sorted((ROOT / "drivers" / "lua").glob("*.lua")) -TARGETS = sorted((ROOT / "packages" / "v1").glob("*/targets/*.lua")) def called_functions(path: Path) -> set[str]: return set(HOST_CALL.findall(path.read_text(encoding="utf-8", errors="replace"))) -@pytest.mark.parametrize("path", CATALOG + TARGETS, ids=lambda p: p.stem) +@pytest.mark.parametrize("path", CATALOG, ids=lambda p: p.stem) def test_driver_calls_only_functions_a_host_provides(path: Path) -> None: called = called_functions(path) unknown = called - LINUX_EDGE - set(DIALECT) - set(DIALECT.values()) @@ -58,7 +57,7 @@ def test_driver_calls_only_functions_a_host_provides(path: Path) -> None: if unknown & PENDING else "")) -@pytest.mark.parametrize("path", CATALOG + TARGETS, ids=lambda p: p.stem) +@pytest.mark.parametrize("path", CATALOG, ids=lambda p: p.stem) def test_driver_does_not_mix_both_spellings_of_one_function(path: Path) -> None: """One driver, one dialect. Both names of the same function means a mistake.""" called = called_functions(path) diff --git a/tools/build_candidate.py b/tools/build_candidate.py deleted file mode 100644 index 8cb6103..0000000 --- a/tools/build_candidate.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env python3 -"""Build one deterministic unsigned driver-package candidate.""" - -from __future__ import annotations - -import argparse -import json -import subprocess -from pathlib import Path - -from driver_package import PackageError, build_package - - -ROOT = Path(__file__).resolve().parents[1] - - -def git(*args: str) -> str: - return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--id", required=True) - parser.add_argument("--target") - parser.add_argument("--output-dir", required=True, type=Path) - args = parser.parse_args() - - source = ROOT / "packages" / "v1" / args.id / "package-source.json" - if not source.is_file(): - raise SystemExit(f"package source not found: {source.relative_to(ROOT)}") - payload = json.loads(source.read_text(encoding="utf-8")) - targets = {item["target"] for item in payload["compatibility"]} - if args.target and args.target not in targets: - raise SystemExit( - f"{args.id} does not declare target {args.target}; available: {', '.join(sorted(targets))}" - ) - - try: - candidate = build_package( - source, - ROOT, - args.output_dir, - "https://packages.example.invalid/candidate", - git("rev-parse", "HEAD"), - int(git("show", "-s", "--format=%ct", "HEAD")), - ROOT / "luac55" if any( - item["transform"] == "lua55-strip" for item in payload["artifact_inputs"] - ) else None, - ) - except PackageError as exc: - raise SystemExit(str(exc)) from exc - print( - f"unsigned candidate {candidate['package_id']}@{candidate['version']} " - f"for {', '.join(sorted(targets))}" - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/bump_driver.py b/tools/bump_driver.py index d001736..ede97d3 100644 --- a/tools/bump_driver.py +++ b/tools/bump_driver.py @@ -87,8 +87,7 @@ def write_lua_version(path: Path, version: str) -> bool: changed = False # A driver may state its version in DRIVER, in DRIVER_MANIFEST, or in both. - # sdm630 carries both, and leaving one behind fails the package build with - # "Lua metadata version must equal package version". + # sdm630 carries both, so both move together. for table in ("DRIVER_MANIFEST", "DRIVER"): for match in re.finditer(rf'^{table}\s*=\s*\{{', text, re.M): start = match.start() @@ -107,30 +106,6 @@ def write_lua_version(path: Path, version: str) -> bool: return changed -def write_package_version(driver_id: str, previous: str, version: str) -> bool: - """Keep packages/v1//package-source.json in step. - - The package build refuses an artifact whose Lua metadata version differs - from the package version, so a driver with a recipe must move both. - """ - path = ROOT / "packages" / "v1" / driver_id / "package-source.json" - if not path.exists(): - return False - text = path.read_text(encoding="utf-8") - - # A package may run its own version line. sungrow's catalog driver is on - # 1.2.x while its package is on 1.3.x, and forcing them together would - # rewrite a published package version. Only follow along when the package - # was tracking the driver, which is what sdm630 does. - match = re.search(r'^ "version":\s*"([^"]+)"', text, re.M) - if not match or match.group(1) != previous: - return False - - new_text = text[:match.start(1)] + version + text[match.end(1):] - path.write_text(new_text, encoding="utf-8") - return True - - def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--id", required=True, help="driver id, e.g. sungrow") @@ -161,13 +136,10 @@ def main() -> int: write_manifest_version(manifest_path, target) lua_changed = write_lua_version(lua_path, target) - package_changed = write_package_version(args.id, current, target) updated = ["manifest"] if lua_changed: updated.append("Lua metadata") - if package_changed: - updated.append("package recipe") print(f"{args.id}: {current} -> {target}") print(f"updated {', '.join(updated)}. Now run:") diff --git a/tools/check_public_boundary.py b/tools/check_public_boundary.py index ffb2c44..ec517a7 100644 --- a/tools/check_public_boundary.py +++ b/tools/check_public_boundary.py @@ -3,7 +3,6 @@ from __future__ import annotations -import json import re import subprocess from pathlib import Path @@ -41,7 +40,6 @@ PROVENANCE_TREES = (Path("baselines"),) SECRET_PATTERN_EXCEPTIONS = { Path("tools/check_public_boundary.py"), - Path("tools/driver_package.py"), } @@ -89,13 +87,6 @@ def main(root: Path = ROOT) -> int: if value in text: errors.append(f"{relative}: forbidden private reference {value}") - for source in sorted((root / "packages" / "v1").glob("*/package-source.json")): - payload = json.loads(source.read_text(encoding="utf-8")) - if payload["source"]["repository"] != "https://github.com/srcfl/device-drivers": - errors.append(f"{source.relative_to(root)}: public source repository mismatch") - if payload["builder_id"] != "https://github.com/srcfl/device-drivers/blob/main/tools/driver_package.py": - errors.append(f"{source.relative_to(root)}: public builder id mismatch") - if errors: for error in errors: print(f"FAIL {error}") diff --git a/tools/derive_sungrow_ftw_observe.py b/tools/derive_sungrow_ftw_observe.py deleted file mode 100644 index a818583..0000000 --- a/tools/derive_sungrow_ftw_observe.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -"""Derive the Sungrow observe-only target from the exact FTW baseline.""" - -from __future__ import annotations - -import hashlib -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -BASELINE = ROOT / "baselines" / "ftw" / "drivers" / "sungrow.lua" -TARGET = ROOT / "packages" / "v1" / "sungrow" / "targets" / "ftw-observe.lua" -BASELINE_SHA256 = "466a5f8637e6756fc2e1af4197d4edc1845474231413c0016f0e5900acb7b7ac" - - -def extract(source: str, start: str, end: str) -> str: - start_at = source.index(start) - end_at = source.index(end, start_at) - return source[start_at:end_at].rstrip() - - -def main() -> int: - source_bytes = BASELINE.read_bytes() - digest = hashlib.sha256(source_bytes).hexdigest() - if digest != BASELINE_SHA256: - raise SystemExit(f"FTW Sungrow baseline hash differs: {digest}") - - source = source_bytes.decode("utf-8") - fingerprint = extract( - source, - "function driver_fingerprint()", - "\n----------------------------------------------------------------------------\n-- Initialization", - ) - poll = extract( - source, - "function driver_poll()", - "\n----------------------------------------------------------------------------\n-- Battery control", - ) - target = f'''-- Generated by tools/derive_sungrow_ftw_observe.py. --- Source: srcfl/ftw@699873db3e7abe81f76e8110d1cefa4a38ba6efb --- Path: drivers/sungrow.lua --- SHA-256: {BASELINE_SHA256} --- The fingerprint and poll functions stay byte-for-byte equal to the FTW --- legacy source. Init and lifecycle code remove every write path. - -DRIVER = {{ - host_api_min = 1, - host_api_max = 1, - id = "sungrow", - name = "Sungrow observe-only pilot", - manufacturer = "Sungrow", - version = "1.3.2", - read_only = true, - legacy_ids = {{ "sungrow-shx" }}, - protocols = {{ "modbus" }}, - capabilities = {{ "meter", "pv", "battery" }}, - description = "Sungrow SH-series telemetry via Modbus TCP.", - homepage = "https://en.sungrowpower.com", - authors = {{ "FTW contributors" }}, - tested_models = {{ "SH5.0RT", "SH6.0RT", "SH8.0RT", "SH10RT" }}, - verification_status = "experimental", - verification_notes = "Observe only. Exact pilot model and firmware approval is required.", - connection_defaults = {{ - port = 502, - unit_id = 1, - }}, -}} - -PROTOCOL = "modbus" - -local APPROVED_MODEL_FIRMWARE_PROFILES = {{}} -local sn_read = false -local rated_ac_w = 0 -local pv_curtail_method = "active_power" -local latest_non_export_w = 0 - -local function bit_is_set(value, bit) - return math.floor(value / (2 ^ bit)) % 2 == 1 -end - -{fingerprint} - -function driver_init(config) - config = config or {{}} - local model = tostring(config.model or "") - local firmware = tostring(config.firmware or "") - if not APPROVED_MODEL_FIRMWARE_PROFILES[model .. "|" .. firmware] then - error( - "Sungrow observe-only profile is not approved for model '" .. - model .. "' and firmware '" .. firmware .. "'" - ) - end - - host.set_make("Sungrow") - local ok, dev = pcall(host.modbus_read, 4999, 1, "input") - if ok and dev then - host.log("info", "Device type code: " .. tostring(dev[1])) - end - local ok_rated, rated = pcall(host.modbus_read, 5000, 1, "input") - if ok_rated and rated and rated[1] and rated[1] > 0 then - rated_ac_w = rated[1] * 0.1 * 1000 - end - local ok_ems, ems = pcall(host.modbus_read, 13049, 3, "holding") - if ok_ems and ems then - host.log("info", "EMS state: mode=" .. tostring(ems[1]) - .. " cmd=0x" .. string.format("%04x", ems[2]) - .. " power=" .. tostring(ems[3]) .. "W") - end -end - -{poll} - -function driver_command(action, value, context) - return nil, "Sungrow observe-only target has no commands" -end - -function driver_default_mode() - return nil -end - -function driver_cleanup() -end -''' - TARGET.write_text(target, encoding="utf-8") - print(f"wrote {TARGET.relative_to(ROOT)} from FTW baseline {digest}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/driver_package.py b/tools/driver_package.py deleted file mode 100644 index 6834519..0000000 --- a/tools/driver_package.py +++ /dev/null @@ -1,1084 +0,0 @@ -#!/usr/bin/env python3 -"""Build, validate, sign, and verify Sourceful driver packages. - -The published payload is deterministic for the same source recipe, source -commit, source epoch, compiler, and input bytes. Ed25519 signs canonical JSON -payload bytes; neither a database nor an API response participates in trust. -""" - -from __future__ import annotations - -import argparse -import base64 -import copy -import hashlib -import json -import os -import re -import subprocess -import sys -import tempfile -from pathlib import Path -from typing import Any, Iterable -from urllib.parse import urlparse - -try: - from jsonschema import Draft202012Validator, FormatChecker - from referencing import Registry, Resource -except ImportError as exc: # pragma: no cover - exercised by CLI environments - raise SystemExit( - "driver_package.py requires the package extra: uv run --extra package ..." - ) from exc - - -ROOT = Path(__file__).resolve().parents[1] -SCHEMA_DIR = ROOT / "spec" / "schemas" -SCHEMA_FILES = { - "sourceful.driver-package/v1": "sourceful.driver-package.v1.schema.json", - "sourceful.driver-package-envelope/v1": - "sourceful.driver-package-envelope.v1.schema.json", - "sourceful.driver-package-source/v1": - "sourceful.driver-package-source.v1.schema.json", - "sourceful.driver-index/v1": "sourceful.driver-index.v1.schema.json", - "sourceful.driver-index-envelope/v1": - "sourceful.driver-index-envelope.v1.schema.json", - "sourceful.driver-inventory/v1": "sourceful.driver-inventory.v1.schema.json", - "sourceful.driver-command/v1": "sourceful.driver-command.v1.schema.json", - "sourceful.driver-command-result/v1": - "sourceful.driver-command-result.v1.schema.json", -} -KNOWN_TARGETS = { - "ftw-core", - "blixt-l1", - "zap-firmware", -} -SEMVER_RE = re.compile( - r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" - r"(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" - r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?" - r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" -) -SENSITIVE_KEY_RE = re.compile( - r"(?:^|_)(?:api_key|access_token|credential|password|private_key|secret)(?:$|_)", - re.IGNORECASE, -) -WRITE_PERMISSIONS = {"http.post", "modbus.write", "mqtt.publish", "serial.write"} -CONTROL_RUNTIME_V2 = { - "ftw-core": { - "abi": "gopher-lua-source-v2", - "host_api_profile": "sourceful.host/ftw-core/v2", - "host_api_min": 2, - }, - "blixt-l1": { - "abi": "mlua-0.10-luajit21-source-v2", - "host_api_profile": "sourceful.host/blixt-l1/v2", - "host_api_min": 2, - }, -} -LUA_WRITE_CALL_RE = re.compile( - r"\bhost\.(?:http_post|modbus_write(?:_multi)?|mqtt_publish|serial_write)\s*\(" -) -LUA_ENTRYPOINT_RE = re.compile(r"\bfunction\s+(driver_[a-z0-9_]+)\s*\(") - - -class PackageError(ValueError): - """A deterministic package validation or build failure.""" - - -def canonical_json(value: Any) -> bytes: - """Return Sourceful Canonical JSON v1 bytes (UTF-8, sorted, compact).""" - return json.dumps( - value, - ensure_ascii=False, - allow_nan=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - - -def _read_json(path: Path) -> dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise PackageError(f"cannot read JSON from {path}: {exc}") from exc - if not isinstance(value, dict): - raise PackageError(f"{path}: top-level JSON value must be an object") - return value - - -def _write_json(path: Path, value: Any) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - data = canonical_json(value) + b"\n" - temporary = path.with_name(f".{path.name}.tmp") - temporary.write_bytes(data) - temporary.replace(path) - - -def _schemas() -> tuple[dict[str, dict[str, Any]], Registry]: - schemas: dict[str, dict[str, Any]] = {} - resources: list[tuple[str, Resource]] = [] - for schema_version, filename in SCHEMA_FILES.items(): - schema = _read_json(SCHEMA_DIR / filename) - schemas[schema_version] = schema - resources.append((schema["$id"], Resource.from_contents(schema))) - return schemas, Registry().with_resources(resources) - - -def _format_path(parts: Iterable[Any]) -> str: - rendered = "$" - for part in parts: - rendered += f"[{part}]" if isinstance(part, int) else f".{part}" - return rendered - - -def _validate_schema(document: dict[str, Any], schema_version: str) -> None: - schemas, registry = _schemas() - schema = schemas[schema_version] - validator = Draft202012Validator( - schema, - registry=registry, - format_checker=FormatChecker(), - ) - errors = sorted( - validator.iter_errors(document), - key=lambda error: (_format_path(error.absolute_path), error.message), - ) - if errors: - details = "; ".join( - f"{_format_path(error.absolute_path)}: {error.message}" for error in errors - ) - raise PackageError(details) - - -def _scan_for_secrets(value: Any, path: str = "$") -> None: - if isinstance(value, dict): - for key, child in value.items(): - child_path = f"{path}.{key}" - if SENSITIVE_KEY_RE.search(key): - raise PackageError(f"{child_path}: secrets are forbidden in packages") - _scan_for_secrets(child, child_path) - elif isinstance(value, list): - for index, child in enumerate(value): - _scan_for_secrets(child, f"{path}[{index}]") - elif isinstance(value, str) and re.search( - r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----", value - ): - raise PackageError(f"{path}: private key material is forbidden in packages") - - -def _parse_semver(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None]: - match = SEMVER_RE.fullmatch(version) - if not match: - raise PackageError(f"invalid SemVer: {version!r}") - prerelease = tuple(match.group(4).split(".")) if match.group(4) else None - return (int(match.group(1)), int(match.group(2)), int(match.group(3))), prerelease - - -def _compare_semver(left: str, right: str) -> int: - left_core, left_pre = _parse_semver(left) - right_core, right_pre = _parse_semver(right) - if left_core != right_core: - return -1 if left_core < right_core else 1 - if left_pre is None or right_pre is None: - if left_pre is right_pre: - return 0 - return 1 if left_pre is None else -1 - for left_part, right_part in zip(left_pre, right_pre): - if left_part == right_part: - continue - left_numeric = left_part.isdigit() - right_numeric = right_part.isdigit() - if left_numeric and right_numeric: - return -1 if int(left_part) < int(right_part) else 1 - if left_numeric != right_numeric: - return -1 if left_numeric else 1 - return -1 if left_part < right_part else 1 - if len(left_pre) == len(right_pre): - return 0 - return -1 if len(left_pre) < len(right_pre) else 1 - - -def _unique(values: Iterable[str], label: str) -> set[str]: - items = list(values) - if len(items) != len(set(items)): - raise PackageError(f"{label}: values must be unique") - return set(items) - - -def _validate_policy(document: dict[str, Any], *, source: bool) -> None: - _scan_for_secrets(document) - - capabilities = document["capabilities"] - telemetry_capabilities = set(capabilities["telemetry"]) - stream_capabilities = _unique( - (stream["kind"] for stream in document["telemetry"]["streams"]), - "$.telemetry.streams", - ) - if telemetry_capabilities != stream_capabilities: - raise PackageError( - "$.telemetry.streams: stream kinds must exactly match telemetry capabilities" - ) - - command_ids = _unique((item["id"] for item in document["commands"]), "$.commands") - control_capabilities = set(capabilities["control"]) - for command in document["commands"]: - if command["capability"] not in control_capabilities: - raise PackageError( - f"$.commands: {command['id']} references undeclared control capability " - f"{command['capability']}" - ) - if control_capabilities != {item["capability"] for item in document["commands"]}: - raise PackageError("$.capabilities.control: every capability needs a command") - - read_only = document["read_only"] - if read_only and WRITE_PERMISSIONS.intersection(document["permissions"]): - raise PackageError("$.permissions: read-only packages cannot request write permissions") - if read_only and (control_capabilities or command_ids): - raise PackageError("$.commands: read-only packages cannot declare control") - if not read_only and not command_ids: - raise PackageError("$.commands: controllable packages need at least one command") - - lease = document["lease_policy"] - default_mode = document["default_mode"] - if read_only: - if default_mode.get("strategy") != "not_applicable" or "entrypoint" in default_mode: - raise PackageError("$.default_mode: read-only packages have no default mode") - if ( - lease.get("required_for_control") is not False - or lease.get("expiry_action") != "not_applicable" - or "max_duration_seconds" in lease - or "heartbeat_interval_seconds" in lease - ): - raise PackageError("$.lease_policy: read-only packages cannot declare a lease") - else: - if ( - default_mode.get("strategy") != "vendor_autonomous" - or default_mode.get("entrypoint") - not in {"driver_default_mode", "driver_default_mode_v2"} - ): - raise PackageError( - "$.default_mode: controllable packages require a supported default entrypoint" - ) - if ( - lease.get("required_for_control") is not True - or lease.get("expiry_action") != "return_to_default" - or not isinstance(lease.get("heartbeat_interval_seconds"), int) - or not isinstance(lease.get("max_duration_seconds"), int) - ): - raise PackageError("$.lease_policy: controllable packages require a bounded lease") - heartbeat = lease["heartbeat_interval_seconds"] - duration = lease["max_duration_seconds"] - if heartbeat >= duration: - raise PackageError( - "$.lease_policy: heartbeat interval must be shorter than lease duration" - ) - - compatibility = document["compatibility"] - targets = _unique((item["target"] for item in compatibility), "$.compatibility") - for item in compatibility: - runtime = item["runtime"] - if runtime["host_api"]["min"] > runtime["host_api"]["max"]: - raise PackageError( - f"$.compatibility[{item['target']}].runtime.host_api: min exceeds max" - ) - maximum = item["host"].get("max_version_exclusive") - if maximum and _compare_semver(item["host"]["min_version"], maximum) >= 0: - raise PackageError( - f"$.compatibility[{item['target']}].host: empty version range" - ) - if item["control_enabled"] and read_only: - raise PackageError( - f"$.compatibility[{item['target']}]: read-only package enables control" - ) - if item["control_enabled"]: - if default_mode.get("entrypoint") != "driver_default_mode_v2": - raise PackageError( - f"$.compatibility[{item['target']}]: control requires " - "driver_default_mode_v2" - ) - required = CONTROL_RUNTIME_V2.get(item["target"]) - if required is None: - raise PackageError( - f"$.compatibility[{item['target']}]: control has no approved v2 runtime" - ) - host_api = runtime["host_api"] - if ( - runtime["abi"] != required["abi"] - or host_api["profile"] != required["host_api_profile"] - or host_api["min"] < required["host_api_min"] - ): - raise PackageError( - f"$.compatibility[{item['target']}]: control requires " - f"{required['abi']} and {required['host_api_profile']}" - ) - - artifact_key = "artifact_inputs" if source else "artifacts" - artifacts = document[artifact_key] - artifact_ids = _unique((item["artifact_id"] for item in artifacts), f"$.{artifact_key}") - artifact_by_id = {item["artifact_id"]: item for item in artifacts} - referenced_artifact_ids = {item["artifact_id"] for item in compatibility} - if artifact_ids != referenced_artifact_ids: - raise PackageError(f"$.{artifact_key}: every artifact must be referenced exactly once") - for item in compatibility: - artifact_id = item["artifact_id"] - if artifact_id not in artifact_ids: - raise PackageError( - f"$.compatibility[{item['target']}]: unknown artifact_id {artifact_id}" - ) - if artifact_by_id[artifact_id]["target"] != item["target"]: - raise PackageError( - f"$.compatibility[{item['target']}]: artifact target does not match" - ) - if targets != {item["target"] for item in artifacts}: - raise PackageError(f"$.{artifact_key}: every target needs exactly one artifact") - - if source: - for item in artifacts: - if item["input_path"] != document["source"]["path"]: - raise PackageError( - f"$.{artifact_key}: v1 artifacts must derive from the declared source path" - ) - return - - for item in artifacts: - if item["sha256"] not in item["filename"]: - raise PackageError( - f"$.artifacts[{item['artifact_id']}].filename: full content hash required" - ) - if not item["url"].endswith(f"/{item['filename']}"): - raise PackageError( - f"$.artifacts[{item['artifact_id']}].url: must end in filename" - ) - compatibility_item = next( - entry for entry in compatibility if entry["artifact_id"] == item["artifact_id"] - ) - maximum_size = compatibility_item.get("constraints", {}).get("max_artifact_bytes") - if maximum_size is not None and item["size_bytes"] > maximum_size: - raise PackageError( - f"$.artifacts[{item['artifact_id']}]: exceeds target size constraint" - ) - - -def validate_document(document: dict[str, Any]) -> dict[str, Any]: - schema_version = document.get("schema_version") - if schema_version not in SCHEMA_FILES: - raise PackageError(f"$.schema_version: unsupported schema {schema_version!r}") - _validate_schema(document, schema_version) - if schema_version == "sourceful.driver-package-envelope/v1": - _validate_policy(document["payload"], source=False) - elif schema_version in { - "sourceful.driver-package/v1", - "sourceful.driver-package-source/v1", - }: - _validate_policy( - document, - source=schema_version == "sourceful.driver-package-source/v1", - ) - elif schema_version == "sourceful.driver-index-envelope/v1": - _validate_index_policy(document["payload"]) - elif schema_version == "sourceful.driver-inventory/v1": - _validate_inventory_policy(document) - elif schema_version in { - "sourceful.driver-command/v1", - "sourceful.driver-command-result/v1", - }: - _scan_for_secrets(document) - else: - _validate_index_policy(document) - return document - - -def _validate_inventory_policy(document: dict[str, Any]) -> None: - _scan_for_secrets(document) - identities: set[tuple[str, str, str, str, str, str, str, str]] = set() - for driver in document["drivers"]: - health = driver["health"] - if sum(health.values()) != driver["configured_instances"]: - raise PackageError( - "$.drivers.health: counts must equal configured_instances" - ) - if driver["running_instances"] > driver["configured_instances"]: - raise PackageError( - "$.drivers.running_instances: exceeds configured_instances" - ) - identity = ( - driver["driver_id"], - driver["version"], - driver["source"], - driver.get("package_id", ""), - driver.get("repository_id", ""), - driver.get("package_channel", ""), - driver.get("artifact_sha256", ""), - driver.get("source_sha256", ""), - ) - if identity in identities: - raise PackageError("$.drivers: duplicate driver inventory identity") - identities.add(identity) - - -def _validate_index_policy(document: dict[str, Any]) -> None: - _scan_for_secrets(document) - identities: set[tuple[str, str]] = set() - for package in document["packages"]: - identity = (package["package_id"], package["version"]) - if identity in identities: - raise PackageError( - f"$.packages: duplicate package {package['package_id']}@{package['version']}" - ) - identities.add(identity) - parsed = urlparse(package["envelope_url"]) - if parsed.scheme != "https" or not parsed.netloc or parsed.query or parsed.fragment: - raise PackageError("$.packages.envelope_url: HTTPS URL without query required") - - -def _safe_input(repo_root: Path, relative_path: str) -> Path: - root = repo_root.resolve() - candidate = (root / relative_path).resolve() - try: - candidate.relative_to(root) - except ValueError as exc: - raise PackageError(f"input escapes repository root: {relative_path}") from exc - if not candidate.is_file(): - raise PackageError(f"input is not a file: {relative_path}") - return candidate - - -def _compile_lua55(compiler: Path, source: Path, source_date_epoch: int) -> bytes: - if not compiler.is_file(): - raise PackageError(f"Lua 5.5 compiler not found: {compiler}") - with tempfile.TemporaryDirectory(prefix="sourceful-driver-package-") as temp_dir: - output = Path(temp_dir) / "driver.luac" - environment = os.environ.copy() - environment.update( - { - "LC_ALL": "C", - "SOURCE_DATE_EPOCH": str(source_date_epoch), - "TZ": "UTC", - } - ) - result = subprocess.run( - [str(compiler.resolve()), "-s", "-o", str(output), str(source)], - check=False, - capture_output=True, - env=environment, - text=True, - ) - if result.returncode != 0: - detail = result.stderr.strip() or result.stdout.strip() or "unknown compiler error" - raise PackageError(f"Lua 5.5 compilation failed: {detail}") - bytecode = output.read_bytes() - if not bytecode.startswith(b"\x1bLua"): - raise PackageError("Lua 5.5 compiler did not produce Lua bytecode") - return bytecode - - -def _lua_named_table_body(source: str, name: str) -> str | None: - """Return one Lua assignment table body without crossing into another block.""" - assignment = re.search(rf"\b{re.escape(name)}\s*=\s*{{", source) - if not assignment: - return None - start = assignment.end() - 1 - depth = 0 - quote: str | None = None - i = start - while i < len(source): - char = source[i] - if quote is not None: - if char == "\\": - i += 2 - continue - if char == quote: - quote = None - i += 1 - continue - if source.startswith("--", i): - long_comment = re.match(r"--\[(=*)\[", source[i:]) - if long_comment: - close = "]" + long_comment.group(1) + "]" - end = source.find(close, i + long_comment.end()) - if end < 0: - return None - i = end + len(close) - continue - newline = source.find("\n", i + 2) - i = len(source) if newline < 0 else newline + 1 - continue - long_string = re.match(r"\[(=*)\[", source[i:]) - if long_string: - close = "]" + long_string.group(1) + "]" - end = source.find(close, i + long_string.end()) - if end < 0: - return None - i = end + len(close) - continue - if char in {'"', "'"}: - quote = char - elif char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return source[start + 1 : i] - i += 1 - return None - - -def _validate_lua_source_for_target( - source_bytes: bytes, - *, - target: str, - read_only: bool, - package_version: str, - runtime_abi: str | None = None, - default_entrypoint: str | None = None, -) -> None: - """Reject a Lua artifact that cannot satisfy its declared target contract. - - This is deliberately a small source-contract check, not a Lua parser or a - substitute for executing the target's contract tests and HIL suite. - """ - try: - source = source_bytes.decode("utf-8") - except UnicodeDecodeError as exc: - raise PackageError(f"{target}: Lua source must be UTF-8") from exc - - entrypoints = set(LUA_ENTRYPOINT_RE.findall(source)) - required_entrypoints = {"driver_init", "driver_poll"} - if read_only: - required_entrypoints.update({"driver_command", "driver_default_mode"}) - else: - if runtime_abi in {profile["abi"] for profile in CONTROL_RUNTIME_V2.values()}: - required_entrypoints.add("driver_command_v2") - else: - required_entrypoints.add("driver_command") - required_entrypoints.add(default_entrypoint or "driver_default_mode") - if target == "blixt-l1": - required_entrypoints.add("driver_cleanup") - metadata_body = _lua_named_table_body(source, "DRIVER_MANIFEST") - if metadata_body is None: - raise PackageError("blixt-l1: Lua source must declare DRIVER_MANIFEST") - elif target == "ftw-core": - metadata_body = _lua_named_table_body(source, "DRIVER") - if metadata_body is None: - raise PackageError("ftw-core: Lua source must declare DRIVER") - if not re.search(r"\bhost_api_min\s*=", source) or not re.search( - r"\bhost_api_max\s*=", source - ): - raise PackageError("ftw-core: DRIVER must declare host_api_min and host_api_max") - elif target == "zap-firmware": - return - else: - raise PackageError(f"unsupported Lua target: {target}") - - declared_version = re.search( - r"\bversion\s*=\s*[\"'](?P[^\"']+)[\"']", - metadata_body, - ) - # Two lineages, deliberately. The package version counts releases from this - # repository; a driver promoted from FTW keeps FTW's DRIVER table verbatim, - # so its declared version counts FTW's. Rewriting that field would mean - # editing a file we keep byte-identical to its baseline on purpose, which - # is how provenance stays checkable. A missing version is still an error. - if not declared_version: - raise PackageError(f"{target}: Lua metadata must declare a version") - - declared_read_only = re.search( - r"\bread_only\s*=\s*(?Ptrue|false)\b", metadata_body - ) - if target == "ftw-core" and read_only and not declared_read_only: - raise PackageError(f"{target}: read-only Lua source must declare read_only = true") - if declared_read_only and (declared_read_only.group("value") == "true") != read_only: - raise PackageError(f"{target}: Lua read_only metadata must match the package") - - missing = sorted(required_entrypoints - entrypoints) - if missing: - raise PackageError(f"{target}: Lua source is missing {', '.join(missing)}") - if read_only and LUA_WRITE_CALL_RE.search(source): - raise PackageError(f"{target}: read-only Lua source calls a write-capable host API") - - -def _normalise_source(source: dict[str, Any]) -> dict[str, Any]: - value = copy.deepcopy(source) - value["permissions"] = sorted(value["permissions"]) - value["capabilities"]["telemetry"] = sorted(value["capabilities"]["telemetry"]) - value["capabilities"]["control"] = sorted(value["capabilities"]["control"]) - value["device_matches"] = sorted( - value["device_matches"], key=lambda item: (item["manufacturer"], item["model_family"]) - ) - for match in value["device_matches"]: - match["variants"] = sorted(match["variants"]) - match["regions"] = sorted(match["regions"]) - value["telemetry"]["streams"] = sorted( - value["telemetry"]["streams"], key=lambda item: item["kind"] - ) - value["commands"] = sorted(value["commands"], key=lambda item: item["id"]) - value["compatibility"] = sorted(value["compatibility"], key=lambda item: item["target"]) - value["artifact_inputs"] = sorted( - value["artifact_inputs"], key=lambda item: item["artifact_id"] - ) - return value - - -def build_package( - source_path: Path, - repo_root: Path, - output_dir: Path, - base_url: str, - source_commit: str, - source_date_epoch: int, - lua55_compiler: Path | None, -) -> dict[str, Any]: - source = _normalise_source(validate_document(_read_json(source_path))) - if not re.fullmatch(r"[0-9a-f]{40}", source_commit): - raise PackageError("source commit must be a 40-character lowercase Git SHA") - if source_date_epoch < 0: - raise PackageError("source date epoch cannot be negative") - parsed_base = urlparse(base_url) - if parsed_base.scheme != "https" or not parsed_base.netloc or parsed_base.query: - raise PackageError("base URL must be an HTTPS URL without a query string") - - artifacts: list[dict[str, Any]] = [] - materials: dict[str, dict[str, str]] = {} - artifact_bytes: dict[str, bytes] = {} - package_name = source["package_id"].rsplit(".", 1)[-1] - for artifact_input in source["artifact_inputs"]: - relative_path = artifact_input["input_path"] - input_path = _safe_input(repo_root, relative_path) - input_bytes = input_path.read_bytes() - compatibility_item = next( - item - for item in source["compatibility"] - if item["artifact_id"] == artifact_input["artifact_id"] - ) - if artifact_input["media_type"] in { - "application/vnd.sourceful.lua.source", - "application/vnd.sourceful.lua.bytecode", - }: - _validate_lua_source_for_target( - input_bytes, - target=artifact_input["target"], - read_only=source["read_only"], - package_version=source["version"], - runtime_abi=compatibility_item["runtime"]["abi"], - default_entrypoint=source["default_mode"].get("entrypoint"), - ) - input_hash = hashlib.sha256(input_bytes).hexdigest() - material_uri = ( - f"git+{source['source']['repository']}@{source_commit}#{relative_path}" - ) - materials[material_uri] = {"uri": material_uri, "sha256": input_hash} - - if artifact_input["transform"] == "copy": - content = input_bytes - else: - if lua55_compiler is None: - raise PackageError("--lua55-compiler is required for lua55-strip artifacts") - compiler_path = lua55_compiler.resolve() - if not compiler_path.is_file(): - raise PackageError(f"Lua 5.5 compiler not found: {lua55_compiler}") - compiler_bytes = compiler_path.read_bytes() - compiler_uri = "tool:sourceful.lua55-compiler" - materials[compiler_uri] = { - "uri": compiler_uri, - "sha256": hashlib.sha256(compiler_bytes).hexdigest(), - } - content = _compile_lua55(lua55_compiler, input_path, source_date_epoch) - - digest = hashlib.sha256(content).hexdigest() - filename = ( - f"{package_name}-{source['version']}-{artifact_input['target']}-" - f"{artifact_input['artifact_id']}-{digest}.{artifact_input['extension']}" - ) - artifact = { - "artifact_id": artifact_input["artifact_id"], - "target": artifact_input["target"], - "media_type": artifact_input["media_type"], - "filename": filename, - "url": f"{base_url.rstrip('/')}/{filename}", - "sha256": digest, - "size_bytes": len(content), - } - artifacts.append(artifact) - artifact_bytes[filename] = content - - excluded = {"schema_version", "builder_id", "source", "artifact_inputs"} - payload = {key: value for key, value in source.items() if key not in excluded} - payload.update( - { - "schema_version": "sourceful.driver-package/v1", - "source": { - "repository": source["source"]["repository"], - "commit": source_commit, - "path": source["source"]["path"], - }, - "provenance": { - "builder_id": source["builder_id"], - "build_type": "sourceful.driver-package/v1", - "source_date_epoch": source_date_epoch, - "materials": sorted(materials.values(), key=lambda item: item["uri"]), - }, - "artifacts": sorted(artifacts, key=lambda item: item["artifact_id"]), - } - ) - validate_document(payload) - - output_dir.mkdir(parents=True, exist_ok=True) - for filename, content in artifact_bytes.items(): - (output_dir / filename).write_bytes(content) - _write_json(output_dir / "manifest.json", payload) - return payload - - -def _load_private_key(path: Path): - try: - from cryptography.hazmat.primitives import serialization - from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - except ImportError as exc: # pragma: no cover - raise PackageError("cryptography is required for signing") from exc - key = serialization.load_pem_private_key(path.read_bytes(), password=None) - if not isinstance(key, Ed25519PrivateKey): - raise PackageError("private key must be Ed25519 PKCS8 PEM") - return key - - -def _load_public_key(path: Path): - try: - from cryptography.hazmat.primitives import serialization - from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey - except ImportError as exc: # pragma: no cover - raise PackageError("cryptography is required for verification") from exc - key = serialization.load_pem_public_key(path.read_bytes()) - if not isinstance(key, Ed25519PublicKey): - raise PackageError("public key must be Ed25519 SPKI PEM") - return key - - -def sign_payload(payload: dict[str, Any], private_key_path: Path, key_id: str) -> dict[str, Any]: - validate_document(payload) - private_key = _load_private_key(private_key_path) - envelope = { - "schema_version": "sourceful.driver-package-envelope/v1", - "payload_type": "application/vnd.sourceful.driver-package.v1+json", - "canonicalization": "sourceful.canonical-json/v1", - "key_id": key_id, - "algorithm": "Ed25519", - "payload": payload, - "signature": base64.b64encode(private_key.sign(canonical_json(payload))).decode("ascii"), - } - return validate_document(envelope) - - -def promote_payload( - payload: dict[str, Any], - *, - source_channel: str = "beta", - target_channel: str = "stable", -) -> dict[str, Any]: - """Return a stable payload without rebuilding or changing artifacts.""" - validate_document(payload) - if payload["schema_version"] != "sourceful.driver-package/v1": - raise PackageError("promotion requires sourceful.driver-package/v1") - if source_channel != "beta" or target_channel != "stable": - raise PackageError("v1 only allows beta-to-stable promotion") - if payload["channel"] != source_channel: - raise PackageError( - f"promotion requires a {source_channel} package, got {payload['channel']}" - ) - promoted = copy.deepcopy(payload) - promoted["channel"] = target_channel - return validate_document(promoted) - - -def sign_index_payload( - payload: dict[str, Any], - private_key_path: Path, - key_id: str, -) -> dict[str, Any]: - validate_document(payload) - if payload["schema_version"] != "sourceful.driver-index/v1": - raise PackageError("index signer requires sourceful.driver-index/v1") - private_key = _load_private_key(private_key_path) - envelope = { - "schema_version": "sourceful.driver-index-envelope/v1", - "payload_type": "application/vnd.sourceful.driver-index.v1+json", - "canonicalization": "sourceful.canonical-json/v1", - "key_id": key_id, - "algorithm": "Ed25519", - "payload": payload, - "signature": base64.b64encode( - private_key.sign(canonical_json(payload)) - ).decode("ascii"), - } - return validate_document(envelope) - - -def verify_index_envelope( - envelope: dict[str, Any], - public_key_path: Path, -) -> dict[str, Any]: - validate_document(envelope) - if envelope["schema_version"] != "sourceful.driver-index-envelope/v1": - raise PackageError("index verifier requires sourceful.driver-index-envelope/v1") - try: - signature = base64.b64decode(envelope["signature"], validate=True) - _load_public_key(public_key_path).verify( - signature, canonical_json(envelope["payload"]) - ) - except Exception as exc: - raise PackageError("index envelope signature verification failed") from exc - return envelope["payload"] - - -def build_index( - envelope_paths: list[Path], - envelope_urls: list[str], - channel: str, - source_date_epoch: int, - public_key_path: Path, -) -> dict[str, Any]: - if len(envelope_paths) != len(envelope_urls) or not envelope_paths: - raise PackageError("index requires matching non-empty envelope paths and URLs") - packages: list[dict[str, Any]] = [] - for path, url in zip(envelope_paths, envelope_urls, strict=True): - raw = path.read_bytes() - envelope = _read_json(path) - payload = verify_envelope(envelope, public_key_path) - if payload["channel"] != channel: - package_ref = f"{payload['package_id']}@{payload['version']}" - raise PackageError( - f"{package_ref}: package channel does not match index" - ) - packages.append( - { - "package_id": payload["package_id"], - "version": payload["version"], - "envelope_url": url, - "envelope_sha256": hashlib.sha256(raw).hexdigest(), - "targets": sorted(item["target"] for item in payload["compatibility"]), - } - ) - index = { - "schema_version": "sourceful.driver-index/v1", - "channel": channel, - "source_date_epoch": source_date_epoch, - "packages": sorted( - packages, - key=lambda item: (item["package_id"], item["version"]), - ), - } - return validate_document(index) - - -def verify_envelope( - envelope: dict[str, Any], - public_key_path: Path, - artifact_dir: Path | None = None, -) -> dict[str, Any]: - validate_document(envelope) - try: - signature = base64.b64decode(envelope["signature"], validate=True) - _load_public_key(public_key_path).verify( - signature, canonical_json(envelope["payload"]) - ) - except Exception as exc: - raise PackageError("envelope signature verification failed") from exc - - if artifact_dir is not None: - root = artifact_dir.resolve() - for artifact in envelope["payload"]["artifacts"]: - path = (root / artifact["filename"]).resolve() - try: - path.relative_to(root) - except ValueError as exc: - raise PackageError("artifact path escapes artifact directory") from exc - if not path.is_file(): - raise PackageError(f"artifact is missing: {artifact['filename']}") - content = path.read_bytes() - if len(content) != artifact["size_bytes"]: - raise PackageError(f"artifact size mismatch: {artifact['filename']}") - if hashlib.sha256(content).hexdigest() != artifact["sha256"]: - raise PackageError(f"artifact hash mismatch: {artifact['filename']}") - return envelope["payload"] - - -def compatible_target( - payload: dict[str, Any], - *, - target: str | None, - host_product: str | None, - host_version: str | None, - runtime_name: str | None, - runtime_semantics: str | None, - runtime_version: str | None, - runtime_abi: str | None, - host_api_profile: str | None, - host_api: int | None, -) -> dict[str, Any] | None: - """Resolve exact compatibility; every unknown host/runtime input fails closed.""" - if ( - target not in KNOWN_TARGETS - or not host_product - or not host_version - or not runtime_name - or not runtime_semantics - or not runtime_version - or not runtime_abi - or not host_api_profile - or host_api is None - ): - return None - try: - _parse_semver(host_version) - except PackageError: - return None - match = next( - (item for item in payload["compatibility"] if item["target"] == target), - None, - ) - if match is None or match["host"]["product"] != host_product: - return None - if _compare_semver(host_version, match["host"]["min_version"]) < 0: - return None - maximum = match["host"].get("max_version_exclusive") - if maximum and _compare_semver(host_version, maximum) >= 0: - return None - runtime = match["runtime"] - if ( - runtime["name"] != runtime_name - or runtime["semantics"] != runtime_semantics - or runtime["version"] != runtime_version - or runtime["abi"] != runtime_abi - or runtime["host_api"]["profile"] != host_api_profile - or not runtime["host_api"]["min"] <= host_api <= runtime["host_api"]["max"] - ): - return None - return match - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description=__doc__) - subparsers = parser.add_subparsers(dest="command", required=True) - - validate = subparsers.add_parser("validate", help="validate JSON against v1") - validate.add_argument("document", type=Path) - - package = subparsers.add_parser("package", help="build deterministic artifacts") - package.add_argument("--source", required=True, type=Path) - package.add_argument("--repo-root", required=True, type=Path) - package.add_argument("--output-dir", required=True, type=Path) - package.add_argument("--base-url", required=True) - package.add_argument("--source-commit", required=True) - package.add_argument("--source-date-epoch", required=True, type=int) - package.add_argument("--lua55-compiler", type=Path) - package.add_argument("--key", type=Path) - package.add_argument("--key-id") - - verify = subparsers.add_parser("verify", help="verify envelope and local artifacts") - verify.add_argument("--envelope", required=True, type=Path) - verify.add_argument("--public-key", required=True, type=Path) - verify.add_argument("--artifact-dir", type=Path) - - index = subparsers.add_parser("index", help="build and sign a discovery index") - index.add_argument("--package-envelope", action="append", required=True, type=Path) - index.add_argument("--package-url", action="append", required=True) - index.add_argument("--channel", choices=["beta", "stable"], required=True) - index.add_argument("--source-date-epoch", required=True, type=int) - index.add_argument("--public-key", required=True, type=Path) - index.add_argument("--key", required=True, type=Path) - index.add_argument("--key-id", required=True) - index.add_argument("--output", required=True, type=Path) - - promote = subparsers.add_parser( - "promote", help="promote a verified beta package to stable" - ) - promote.add_argument("--envelope", required=True, type=Path) - promote.add_argument("--public-key", required=True, type=Path) - promote.add_argument("--key", required=True, type=Path) - promote.add_argument("--key-id", required=True) - promote.add_argument("--output", required=True, type=Path) - - compatible = subparsers.add_parser("compatible", help="resolve host compatibility") - compatible.add_argument("--manifest", required=True, type=Path) - compatible.add_argument("--target", required=True) - compatible.add_argument("--host-product", required=True) - compatible.add_argument("--host-version", required=True) - compatible.add_argument("--runtime-name", required=True) - compatible.add_argument("--runtime-semantics", required=True) - compatible.add_argument("--runtime-version", required=True) - compatible.add_argument("--runtime-abi", required=True) - compatible.add_argument("--host-api-profile", required=True) - compatible.add_argument("--host-api", required=True, type=int) - return parser - - -def main(argv: list[str] | None = None) -> int: - args = _build_parser().parse_args(argv) - try: - if args.command == "validate": - document = validate_document(_read_json(args.document)) - print(f"OK {args.document}: {document['schema_version']}") - elif args.command == "package": - if bool(args.key) != bool(args.key_id): - raise PackageError("--key and --key-id must be provided together") - payload = build_package( - args.source, - args.repo_root, - args.output_dir, - args.base_url, - args.source_commit, - args.source_date_epoch, - args.lua55_compiler, - ) - if args.key: - envelope = sign_payload(payload, args.key, args.key_id) - _write_json(args.output_dir / "manifest.envelope.json", envelope) - print(f"OK {payload['package_id']}@{payload['version']} -> {args.output_dir}") - elif args.command == "verify": - payload = verify_envelope( - _read_json(args.envelope), args.public_key, args.artifact_dir - ) - print(f"OK {payload['package_id']}@{payload['version']}: signature valid") - elif args.command == "index": - index_payload = build_index( - args.package_envelope, - args.package_url, - args.channel, - args.source_date_epoch, - args.public_key, - ) - index_envelope = sign_index_payload(index_payload, args.key, args.key_id) - _write_json(args.output, index_envelope) - print(f"OK {len(index_payload['packages'])} packages -> {args.output}") - elif args.command == "promote": - beta = verify_envelope(_read_json(args.envelope), args.public_key) - stable = promote_payload(beta) - envelope = sign_payload(stable, args.key, args.key_id) - _write_json(args.output, envelope) - print(f"OK {stable['package_id']}@{stable['version']}: beta -> stable") - else: - document = validate_document(_read_json(args.manifest)) - payload = document.get("payload", document) - match = compatible_target( - payload, - target=args.target, - host_product=args.host_product, - host_version=args.host_version, - runtime_name=args.runtime_name, - runtime_semantics=args.runtime_semantics, - runtime_version=args.runtime_version, - runtime_abi=args.runtime_abi, - host_api_profile=args.host_api_profile, - host_api=args.host_api, - ) - if match is None: - print("INCOMPATIBLE") - return 3 - print(f"COMPATIBLE control_enabled={str(match['control_enabled']).lower()}") - except PackageError as exc: - print(f"FAIL: {exc}", file=sys.stderr) - return 2 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/generate_site.py b/tools/generate_site.py index 19bdc7e..01f6541 100644 --- a/tools/generate_site.py +++ b/tools/generate_site.py @@ -305,8 +305,6 @@ def collect_drivers() -> list[dict]: name: { "conformance": target.get("target_conformance", "not_assessed"), "hil": target.get("hil", "not_recorded"), - "control_enabled": bool(target.get("control_enabled", False)), - "stable": target.get("stable_package_version"), "beta": target.get("historical_signed_beta_version"), "note": target.get("note", ""), } @@ -644,12 +642,11 @@ def embed_json(payload: dict) -> str: return "" + esc(name) + "" + '' + esc(t.conformance) + "" + '' + esc(t.hil) + "" + - '' + esc(t.stable || "—") + "" + '' + esc(t.note) + ""; }).join(""); return '

Per-target status

' + '
' + - "" + rows + "
TargetConformanceHILStable packageNote
"; + "Note" + rows + ""; } // The vendor material the driver decodes. Reading a register map is how diff --git a/tools/generate_support_status.py b/tools/generate_support_status.py index bee7dbf..8666829 100644 --- a/tools/generate_support_status.py +++ b/tools/generate_support_status.py @@ -13,15 +13,6 @@ TARGETS = ("ftw-core", "blixt-l1") -def package_data(driver_id: str) -> tuple[dict | None, dict[str, dict]]: - path = ROOT / "packages" / "v1" / driver_id / "package-source.json" - if not path.exists(): - return None, {} - package = json.loads(path.read_text(encoding="utf-8")) - targets = {item["target"]: item for item in package["compatibility"]} - return package, targets - - def main() -> int: overrides = json.loads( (ROOT / "support-status-overrides.json").read_text(encoding="utf-8") @@ -31,18 +22,13 @@ def main() -> int: for manifest_path in sorted((ROOT / "manifests").glob("*.yaml")): manifest = parse_yaml_simple(manifest_path.read_text(encoding="utf-8")) driver_id = manifest["name"] - package, package_targets = package_data(driver_id) target_status: dict[str, dict] = {} for target in TARGETS: - compatibility = package_targets.get(target) status = { "target_conformance": "not_assessed", - "candidate_package_version": package["version"] if compatibility else None, "historical_signed_beta_version": None, "hil": "not_recorded", - "stable_package_version": None, "legacy_parity": "not_assessed", - "control_enabled": bool(compatibility and compatibility["control_enabled"]), "note": "", } status.update(overrides.get(driver_id, {}).get(target, {})) @@ -53,12 +39,9 @@ def main() -> int: str(manifest["version"]), target, status["target_conformance"], - status["candidate_package_version"] or "—", status["historical_signed_beta_version"] or "—", status["hil"], - status["stable_package_version"] or "—", status["legacy_parity"], - "yes" if status["control_enabled"] else "no", ] ) drivers.append( @@ -66,7 +49,6 @@ def main() -> int: "driver_id": driver_id, "catalog_version": str(manifest["version"]), "catalog_source": True, - "package_id": package["package_id"] if package else None, "targets": target_status, } ) @@ -80,8 +62,8 @@ def main() -> int: json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) headers = [ - "Driver", "Catalog", "Target", "Conformance", "Candidate", "Signed beta", - "HIL", "Stable", "Legacy parity", "Control", + "Driver", "Catalog", "Target", "Conformance", "Signed beta", "HIL", + "Legacy parity", ] lines = [ "# Driver support status", diff --git a/tools/import_ftw_baseline.py b/tools/import_ftw_baseline.py index 10764a7..aa2c808 100644 --- a/tools/import_ftw_baseline.py +++ b/tools/import_ftw_baseline.py @@ -8,9 +8,8 @@ Importing a bundled driver straight into drivers/lua would break things, so this tool does the safe half first. It copies each bundled driver in byte for byte under baselines/ftw and records its hash, id and version in -source-map.json. Nothing here reaches the catalog, the package recipes or the -signed channel. It gives the repository the source, and gives us a hash to -check drift against. +source-map.json. Nothing here reaches the catalog or the signed channel. It +gives the repository the source, and gives us a hash to check drift against. Importing needs the GitHub API. Verifying does not, so CI can run --check offline against the recorded hashes. diff --git a/tools/new_driver.py b/tools/new_driver.py index b72e34b..e3232ec 100644 --- a/tools/new_driver.py +++ b/tools/new_driver.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 -"""Create a read-only community driver and package recipe.""" +"""Create a read-only community driver and its manifest.""" from __future__ import annotations import argparse -import json import re from pathlib import Path @@ -12,12 +11,6 @@ ROOT = Path(__file__).resolve().parents[1] KINDS = {"meter", "pv", "battery", "v2x_charger"} PROTOCOLS = {"http", "modbus", "mqtt", "serial", "standalone"} -READ_PERMISSION = { - "http": "http.get", - "modbus": "modbus.read", - "mqtt": "mqtt.subscribe", - "serial": "serial.read", -} def write_new(path: Path, content: str) -> None: @@ -99,89 +92,8 @@ def main() -> int: bytecode_size: 0 changelog: "" ''' - package = { - "schema_version": "sourceful.driver-package-source/v1", - "package_id": f"com.sourceful.driver.{args.id.replace('_', '-')}", - "version": version, - "channel": "beta", - "display_name": args.id.replace("_", " ").title(), - "identity": { - "schema": "sourceful.hardware-identity/v1", - "make": "driver_reported", - "serial": "driver_reported_when_available", - "host_fallbacks": ["mac", "endpoint"], - "persistent_state_owner": "host", - }, - "source": { - "repository": "https://github.com/srcfl/device-drivers", - "path": f"drivers/lua/{args.id}.lua", - }, - "builder_id": "https://github.com/srcfl/device-drivers/blob/main/tools/driver_package.py", - "device_matches": [ - {"manufacturer": "TODO", "model_family": "TODO", "variants": [], "regions": []} - ], - "capabilities": {"telemetry": [args.kind], "control": []}, - "permissions": [READ_PERMISSION[args.protocol]] if args.protocol in READ_PERMISSION else [], - "telemetry": { - "schema": "sourceful.telemetry/v2", - "sign_convention": "sourceful.site-import-positive/v1", - "streams": [ - {"kind": args.kind, "power_field": "w", "meaning": "TODO"} - ], - }, - "commands": [], - "read_only": True, - "default_mode": {"strategy": "not_applicable", "description": "Read-only driver."}, - "lease_policy": {"required_for_control": False, "expiry_action": "not_applicable"}, - "rollback": { - "strategy": "install_previous_verified_package", - "state_owner": "host", - "automatic": False, - }, - "compatibility": [ - { - "target": "ftw-core", - "artifact_id": "ftw.lua51.source", - "host": {"product": "ftw", "min_version": "1.5.0", "max_version_exclusive": "2.0.0"}, - "runtime": { - "name": "gopher-lua", "semantics": "lua-5.1", "version": "1.1.2", - "abi": "gopher-lua-source-v1", - "host_api": {"profile": "sourceful.host/ftw-core/v1", "min": 1, "max": 1}, - }, - "control_enabled": False, - }, - { - "target": "blixt-l1", - "artifact_id": "blixt.lua51.source", - "host": {"product": "blixt-gateway", "min_version": "0.1.0", "max_version_exclusive": "1.0.0"}, - "runtime": { - "name": "luajit", "semantics": "lua-5.1", "version": "2.1", - "abi": "mlua-0.10-luajit21-source-v1", - "host_api": {"profile": "sourceful.host/blixt-l1/v1", "min": 1, "max": 1}, - }, - "control_enabled": False, - }, - ], - "artifact_inputs": [ - { - "artifact_id": "ftw.lua51.source", "target": "ftw-core", - "media_type": "application/vnd.sourceful.lua.source", - "input_path": f"drivers/lua/{args.id}.lua", "transform": "copy", "extension": "lua", - }, - { - "artifact_id": "blixt.lua51.source", "target": "blixt-l1", - "media_type": "application/vnd.sourceful.lua.source", - "input_path": f"drivers/lua/{args.id}.lua", "transform": "copy", "extension": "lua", - }, - ], - } - write_new(ROOT / "drivers" / "lua" / f"{args.id}.lua", driver) write_new(ROOT / "manifests" / f"{args.id}.yaml", manifest) - write_new( - ROOT / "packages" / "v1" / args.id / "package-source.json", - json.dumps(package, indent=2, sort_keys=False) + "\n", - ) print(f"created read-only community driver {args.id}") return 0 diff --git a/uv.lock b/uv.lock index 2f88ea7..8519ceb 100644 --- a/uv.lock +++ b/uv.lock @@ -2,15 +2,6 @@ version = 1 revision = 3 requires-python = ">=3.11" -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - [[package]] name = "cffi" version = "2.1.0" @@ -183,33 +174,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - [[package]] name = "packaging" version = "26.2" @@ -262,143 +226,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, -] - -[[package]] -name = "rpds-py" -version = "2026.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, - { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, - { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, - { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, - { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, - { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, - { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, - { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, - { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, - { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, - { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, - { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, - { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, - { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, - { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, - { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, - { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, - { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, - { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, - { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, - { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, - { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, - { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, - { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, - { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, - { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, - { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, - { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, - { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, - { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, - { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, - { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, - { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, - { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, - { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, - { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, - { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, - { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, - { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, - { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, - { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, - { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, - { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, - { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, - { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, - { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, - { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, - { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, - { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, - { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, - { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, - { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, - { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, - { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, - { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, - { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, - { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, - { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, - { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, - { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, - { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, - { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, - { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, - { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, - { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, - { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, - { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, - { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, - { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, - { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, - { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, - { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, - { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, - { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, -] - [[package]] name = "sourceful-device-drivers" version = "0.1.0" @@ -410,22 +237,11 @@ dev = [ ] package = [ { name = "cryptography" }, - { name = "jsonschema" }, ] [package.metadata] requires-dist = [ { name = "cryptography", marker = "extra == 'package'", specifier = ">=50.0.0,<51.0.0" }, - { name = "jsonschema", marker = "extra == 'package'", specifier = ">=4.23.0,<5.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3,<10.0.0" }, ] provides-extras = ["package", "dev"] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -]