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..e8d57ea 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,19 +50,21 @@ 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 +### One id and one version -The 37 drivers promoted in #27 are exempt from this suite's catalog -conventions for as long as they stay byte-identical to `baselines/ftw/drivers` -— `drivers/tests/conftest.py` decides that by content, not by a list. The -moment you edit one, every check in the suite starts applying to it, and -`make check` may fail on rules that driver never had to meet. That is working -as intended. Read the failure before assuming your change caused it: the -rule may be right and the driver wrong, or the check itself may be wrong. +A driver's id is its file name and catalog name, and its `DRIVER` table says +exactly that id and the manifest's version. FTW compares its bundled copy with +the signed channel by that id and version, so they must be the same text. The +channel build and `tests/test_driver_truth.py` refuse anything else. Change a +version with `make bump-driver`, which moves both. + +`baselines/ftw` records what was promoted from FTW in #27. Every promoted +driver has since changed, so no check treats them differently. ## Source rules @@ -82,9 +80,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 +87,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..19f1953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,125 @@ # Changelog +## ctek 0.3.3 + +The `DRIVER` table's id is now `ctek`, the catalog id, instead of `ctek-chargestorm`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## ctek_hybrid 0.3.2 + +The `DRIVER` table's id is now `ctek_hybrid`, the catalog id, instead of `ctek-chargestorm-hybrid`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## ctek_v2 0.3.3 + +The `DRIVER` table's id is now `ctek_v2`, the catalog id, instead of `ctek-chargestorm-v2`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## easee_cloud 1.3.4 + +The `DRIVER` table's id is now `easee_cloud`, the catalog id, instead of `easee-cloud`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## ferroamp_modbus 2.1.3 + +The `DRIVER` table's id is now `ferroamp_modbus`, the catalog id, instead of `ferroamp-modbus`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## fronius_smart_meter 2.1.3 + +The `DRIVER` table's id is now `fronius_smart_meter`, the catalog id, instead of `fronius-smart-meter`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## huawei 2.1.3 + +The `DRIVER` table's id is now `huawei`, the catalog id, instead of `huawei-sun2000`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## nibe_local 1.2.1 + +The `DRIVER` table's id is now `nibe_local`, the catalog id, instead of `nibe-local`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## pixii_pv 0.3.2 + +The `DRIVER` table's id is now `pixii_pv`, the catalog id, instead of `pixii-pv`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## solaredge_legacy 0.3.3 + +The `DRIVER` table's id is now `solaredge_legacy`, the catalog id, instead of `solaredge-legacy`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## solaredge_pv 1.2.2 + +The `DRIVER` table's id is now `solaredge_pv`, the catalog id, instead of `solaredge-pv`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## solis_string 1.1.3 + +The `DRIVER` table's id is now `solis_string`, the catalog id, instead of `solis-string`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## sungrow 1.5.9 + +The `DRIVER` table's id is now `sungrow`, the catalog id, instead of `sungrow-shx`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## tesla_vehicle 0.2.3 + +The `DRIVER` table's id is now `tesla_vehicle`, the catalog id, instead of `tesla-vehicle`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## tesla_wall_connector 0.1.1 + +The `DRIVER` table's id is now `tesla_wall_connector`, the catalog id, instead of `tesla-wall-connector`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## zap 3.1.1 + +The `DRIVER` table's id is now `zap`, the catalog id, instead of `sourceful-zap`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## zaptec_cloud 0.1.1 + +The `DRIVER` table's id is now `zaptec_cloud`, the catalog id, instead of `zaptec-cloud`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## zuidwijk_p1 1.1.2 + +The `DRIVER` table's id is now `zuidwijk_p1`, the catalog id, instead of `zuidwijk-p1`. FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## heishamon 0.5.1 + +The `DRIVER` table's version now matches the catalog. It said 0.4.0 while the manifest said 0.5.0, because the file was kept byte-identical to the copy promoted from FTW. No behaviour change. + +## sdm630 2.0.2 + +The `DRIVER` table's version now matches the catalog; it said 1.1.1 while the manifest said 2.0.1. Drop the Blixt L1 `DRIVER_MANIFEST` table. FTW never read it, and this repository no longer builds packages for other products. No behaviour change. + +## esphome_dsmr 1.0.4 + +The source is now the ESPHome DSMR driver itself. `drivers/lua/esphome_dsmr.lua` held a generated channel artifact committed as source, so the channel wrapped its header twice and the published file reported id `esphome-dsmr` 1.0.2. The tested hardware now matches the `esphome-dsmr` entry, and the manifest says `control: false`, since the driver is read-only. The separate catalog entry `esphome-dsmr` stays for installs that use it. + +## ambibox_v2x 1.1.1 + +The `DRIVER` table's id is now `ambibox_v2x` instead of `ambibox-v2x`, and its version matches the catalog (it said 1.0.0 while the manifest said 1.1.0). FTW compares the id of its bundled copy with the signed channel's to decide which file runs; the old spelling kept them from matching. No behaviour change. + +## ferroamp_dc2_v2x 2.1.1 + +The `DRIVER` table's id is now `ferroamp_dc2_v2x` instead of `ferroamp-dc2-v2x`, and its version matches the catalog (it said 1.1.0 while the manifest said 2.1.0). JSON payloads are decoded through `pcall`, like every other MQTT driver; a bad payload is still logged and ignored. Control logic is unchanged. Hardware-in-the-loop testing is still pending before stable. + +## 50-125k-svk 0.2.4 + +Drop the Blixt L1 `DRIVER_MANIFEST` table. FTW never read it, and this repository no longer builds packages for other products. No behaviour change. + +## 50-125k-svk-ac-slew 0.2.5 + +Drop the Blixt L1 `DRIVER_MANIFEST` table. FTW never read it, and this repository no longer builds packages for other products. No behaviour change. + +## 50-125k-svk-slew 0.1.13 + +Drop the Blixt L1 `DRIVER_MANIFEST` table. FTW never read it, and this repository no longer builds packages for other products. No behaviour change. + +## acuvim 0.4.3 + +Drop the Blixt L1 `DRIVER_MANIFEST` table. FTW never read it, and this repository no longer builds packages for other products. No behaviour change. + +## deye-svk 0.2.2 + +Drop the Blixt L1 `DRIVER_MANIFEST` table. FTW never read it, and this repository no longer builds packages for other products. No behaviour change. + +## foxess_h3_smart 0.9.6 + +Drop the Blixt L1 `DRIVER_MANIFEST` table. FTW never read it, and this repository no longer builds packages for other products. No behaviour change. + +## konja-261-svk 0.3.2 + +Drop the Blixt L1 `DRIVER_MANIFEST` table. FTW never read it, and this repository no longer builds packages for other products. No behaviour change. + ## easee_cloud 1.3.3 Omit `power_observed_at` when the power is unchanged since the last poll, so the host stamps the reading on arrival. Easee records TotalPower only when it changes, so a steady charge kept an old timestamp; FTW took it as a stale charger after three minutes and stopped the car every few minutes all night ([srcfl/ftw#1417](https://github.com/srcfl/ftw/pull/1417)). A new value still carries Easee's source time, and an offline charger (op_mode 0) still emits no sample. @@ -17,6 +137,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..c6ab9b1 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.5 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| 50-125k-svk-ac-slew | 0.2.5 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| 50-125k-svk-slew | 0.1.13 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| 50-125k-svk-slew | 0.1.13 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| 50-125k-svk | 0.2.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| 50-125k-svk | 0.2.4 | 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.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| acuvim | 0.4.3 | 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.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ambibox_v2x | 1.1.1 | 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.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ctek | 0.3.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| ctek_hybrid | 0.3.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ctek_hybrid | 0.3.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| ctek_v2 | 0.3.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ctek_v2 | 0.3.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| deye-svk | 0.2.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| deye-svk | 0.2.2 | 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.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| easee_cloud | 1.3.4 | 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.4 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| esphome_dsmr | 1.0.4 | 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.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ferroamp_dc2_v2x | 2.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| ferroamp_modbus | 2.1.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| ferroamp_modbus | 2.1.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| foxess_h3_smart | 0.9.6 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| foxess_h3_smart | 0.9.6 | 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.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| fronius_smart_meter | 2.1.3 | 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.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| heishamon | 0.5.1 | 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.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| huawei | 2.1.3 | 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.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| konja-261-svk | 0.3.2 | 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.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| nibe_local | 1.2.1 | 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.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| pixii_pv | 0.3.2 | 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.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sdm630 | 2.0.2 | 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.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| solaredge_legacy | 0.3.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| solaredge_pv | 1.2.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| solaredge_pv | 1.2.2 | 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.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| solis_string | 1.1.3 | 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.9 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| sungrow | 1.5.9 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| tesla_vehicle | 0.2.3 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| tesla_vehicle | 0.2.3 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| tesla_wall_connector | 0.1.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| tesla_wall_connector | 0.1.1 | 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.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| zap | 3.1.1 | 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.1 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| zaptec_cloud | 0.1.1 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | +| zuidwijk_p1 | 1.1.2 | ftw-core | not_assessed | — | not_recorded | not_assessed | +| zuidwijk_p1 | 1.1.2 | blixt-l1 | not_assessed | — | not_recorded | not_assessed | diff --git a/devices.yaml b/devices.yaml index c379581..624f96d 100644 --- a/devices.yaml +++ b/devices.yaml @@ -67,7 +67,7 @@ manufacturers: protocols: - protocol: modbus driver: "acuvim" - version: "0.4.2" + version: "0.4.3" ders: [meter] control: false firmware_versions: "" @@ -159,7 +159,7 @@ manufacturers: protocols: - protocol: mqtt driver: "ambibox_v2x" - version: "1.1.0" + version: "1.1.1" ders: [v2x_charger] control: true firmware_versions: "" @@ -183,7 +183,7 @@ manufacturers: protocols: - protocol: modbus driver: "ctek" - version: "0.3.2" + version: "0.3.3" ders: [ev] control: true firmware_versions: "" @@ -194,7 +194,7 @@ manufacturers: protocols: - protocol: modbus driver: "ctek_v2" - version: "0.3.2" + version: "0.3.3" ders: [ev] control: true firmware_versions: "" @@ -205,7 +205,7 @@ manufacturers: protocols: - protocol: modbus driver: "ctek_hybrid" - version: "0.3.1" + version: "0.3.2" ders: [ev] control: true firmware_versions: "" @@ -301,7 +301,7 @@ manufacturers: protocols: - protocol: modbus driver: "deye-svk" - version: "0.2.1" + version: "0.2.2" ders: [battery] control: true firmware_versions: "" @@ -317,19 +317,13 @@ manufacturers: version: "1.0.3" ders: [meter] control: false - firmware_versions: "DSMR 2022.10.1 on SlimmeLezer+; Sourceful Zap open firmware" - notes: "Name-derived phase object_ids live-validated on SlimmeLezer+; explicit Sourceful aliases retained." - - name: "ESPHome DSMR (P1)" - variants: [ESPHome DSMR P1 readers using name-derived object_ids (SlimmeLezer+), Sourceful Zap P1 (sourceful-zap-esphome firmware)] - regions: [] - protocols: - protocol: http driver: "esphome_dsmr" - version: "1.0.3" + version: "1.0.4" ders: [meter] - control: true - firmware_versions: "" - notes: "Smart meter via ESPHome web_server v3 + dsmr component (Sourceful Zap on open firmware, DIY ESP32+P1, etc.)." + control: false + firmware_versions: "DSMR 2022.10.1 on SlimmeLezer+; Sourceful Zap open firmware" + notes: "Name-derived phase object_ids live-validated on SlimmeLezer+; explicit Sourceful aliases retained." - name: "Easee" model_families: - name: "Easee" @@ -349,7 +343,7 @@ manufacturers: protocols: - protocol: http driver: "easee_cloud" - version: "1.3.3" + version: "1.3.4" ders: [ev] control: true firmware_versions: "" @@ -362,7 +356,7 @@ manufacturers: protocols: - protocol: modbus driver: "sdm630" - version: "2.0.1" + version: "2.0.2" ders: [meter] control: false firmware_versions: "" @@ -388,7 +382,7 @@ manufacturers: protocols: - protocol: mqtt driver: "ferroamp_dc2_v2x" - version: "2.1.0" + version: "2.1.1" ders: [v2x_charger] control: true firmware_versions: "" @@ -404,7 +398,7 @@ manufacturers: control: true - protocol: modbus driver: "ferroamp_modbus" - version: "2.1.2" + version: "2.1.3" ders: [battery, meter, pv] control: false firmware_versions: "" @@ -417,7 +411,7 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.9.5" + version: "0.9.6" ders: [pv, battery, meter] control: true firmware_versions: "" @@ -428,7 +422,7 @@ manufacturers: protocols: - protocol: modbus driver: "foxess_h3_smart" - version: "0.9.5" + version: "0.9.6" ders: [pv, battery, meter] control: true firmware_versions: "" @@ -496,7 +490,7 @@ manufacturers: protocols: - protocol: modbus driver: "fronius_smart_meter" - version: "2.1.2" + version: "2.1.3" ders: [meter] control: false firmware_versions: "" @@ -531,7 +525,7 @@ manufacturers: protocols: - protocol: modbus driver: "solis_string" - version: "1.1.2" + version: "1.1.3" ders: [pv] control: false firmware_versions: "" @@ -687,7 +681,7 @@ manufacturers: protocols: - protocol: modbus driver: "huawei" - version: "2.1.2" + version: "2.1.3" ders: [battery, meter, pv] control: false firmware_versions: "" @@ -698,7 +692,7 @@ manufacturers: protocols: - protocol: modbus driver: "huawei" - version: "2.1.2" + version: "2.1.3" ders: [battery, meter, pv] control: false firmware_versions: "" @@ -709,7 +703,7 @@ manufacturers: protocols: - protocol: modbus driver: "huawei" - version: "2.1.2" + version: "2.1.3" ders: [battery, meter, pv] control: false firmware_versions: "" @@ -720,7 +714,7 @@ manufacturers: protocols: - protocol: modbus driver: "huawei" - version: "2.1.2" + version: "2.1.3" ders: [battery, meter, pv] control: false firmware_versions: "" @@ -731,7 +725,7 @@ manufacturers: protocols: - protocol: modbus driver: "huawei" - version: "2.1.2" + version: "2.1.3" ders: [battery, meter, pv] control: false firmware_versions: "" @@ -860,7 +854,7 @@ manufacturers: protocols: - protocol: modbus driver: "konja-261-svk" - version: "0.3.1" + version: "0.3.2" ders: [battery, meter] control: true firmware_versions: "" @@ -993,7 +987,7 @@ manufacturers: protocols: - protocol: http driver: "nibe_local" - version: "1.2.0" + version: "1.2.1" ders: [heatpump] control: true firmware_versions: "" @@ -1019,7 +1013,7 @@ manufacturers: protocols: - protocol: mqtt driver: "heishamon" - version: "0.5.0" + version: "0.5.1" ders: [heatpump] control: true firmware_versions: "" @@ -1032,7 +1026,7 @@ manufacturers: protocols: - protocol: mqtt driver: "pixii_pv" - version: "0.3.1" + version: "0.3.2" ders: [pv, meter] control: false firmware_versions: "" @@ -1564,7 +1558,7 @@ manufacturers: protocols: - protocol: modbus driver: "solaredge_pv" - version: "1.2.1" + version: "1.2.2" ders: [pv] control: true firmware_versions: "" @@ -1575,7 +1569,7 @@ manufacturers: protocols: - protocol: modbus driver: "solaredge_legacy" - version: "0.3.2" + version: "0.3.3" ders: [pv] control: true firmware_versions: "" @@ -1689,17 +1683,17 @@ manufacturers: protocols: - protocol: modbus driver: "50-125k-svk-ac-slew" - version: "0.2.4" + version: "0.2.5" ders: [battery] control: true - protocol: modbus driver: "50-125k-svk-slew" - version: "0.1.12" + version: "0.1.13" ders: [battery] control: true - protocol: modbus driver: "50-125k-svk" - version: "0.2.3" + version: "0.2.4" ders: [battery] control: true firmware_versions: "" @@ -1734,7 +1728,7 @@ manufacturers: protocols: - protocol: http driver: "zap" - version: "3.1.0" + version: "3.1.1" ders: [meter, pv, battery] control: false firmware_versions: "" @@ -1747,7 +1741,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.8" + version: "1.5.9" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1758,7 +1752,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.8" + version: "1.5.9" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1769,7 +1763,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.8" + version: "1.5.9" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1780,7 +1774,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.8" + version: "1.5.9" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1791,7 +1785,7 @@ manufacturers: protocols: - protocol: modbus driver: "sungrow" - version: "1.5.8" + version: "1.5.9" ders: [battery, meter, pv] control: true firmware_versions: "" @@ -1804,7 +1798,7 @@ manufacturers: protocols: - protocol: http driver: "tesla_vehicle" - version: "0.2.2" + version: "0.2.3" ders: [vehicle] control: true firmware_versions: "" @@ -1815,7 +1809,7 @@ manufacturers: protocols: - protocol: http driver: "tesla_wall_connector" - version: "0.1.0" + version: "0.1.1" ders: [ev] control: false firmware_versions: "" @@ -2064,7 +2058,7 @@ manufacturers: protocols: - protocol: http driver: "zaptec_cloud" - version: "0.1.0" + version: "0.1.1" ders: [ev] control: false firmware_versions: "" @@ -2077,7 +2071,7 @@ manufacturers: protocols: - protocol: modbus driver: "zuidwijk_p1" - version: "1.1.1" + version: "1.1.2" ders: [meter] control: false firmware_versions: "" diff --git a/docs/WRITING-A-DRIVER.md b/docs/WRITING-A-DRIVER.md index 95d05fc..07db7e3 100644 --- a/docs/WRITING-A-DRIVER.md +++ b/docs/WRITING-A-DRIVER.md @@ -365,19 +365,13 @@ hidden a real bug — it once implemented decode helpers no host provided, which is how thirty-five broken drivers passed CI for months. If the host does not have it, the mock must not either. -## Drivers promoted from FTW +## One id and one version -37 drivers here came from FTW, where they have run on customer sites for -months. They are kept **byte-identical** to `baselines/ftw/drivers/`, which is -what makes their provenance checkable. +A driver has one id, its file name and catalog name, and one version. Its +`DRIVER` table states both exactly as the manifest does; FTW compares its +bundled copy with the signed channel by them. `make bump-driver ID=` +moves the manifest and the `DRIVER` table together. -Two consequences: - -- The test suite skips catalog conventions for them, because FTW tests them in - Go. Identity is by content: **edit one and every check applies to it again.** -- Their `DRIVER` table states FTW's id and version; the file name and manifest - state this repository's. Two lineages, deliberately, because making them - agree would mean editing a file we keep unmodified on purpose. - -If you need to change one, just change it. The tooling will notice and start -holding it to this repository's rules, which is the correct outcome. +37 drivers came from FTW in #27; `baselines/ftw/drivers/` records what they +were. All of them have changed since, and the same rules apply to every +driver. 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/50-125k-svk-ac-slew.lua b/drivers/lua/50-125k-svk-ac-slew.lua index d8a8a44..42de5ef 100644 --- a/drivers/lua/50-125k-svk-ac-slew.lua +++ b/drivers/lua/50-125k-svk-ac-slew.lua @@ -101,7 +101,7 @@ DRIVER = { id = "50-125k-svk-ac-slew", name = "Solis S6 50-125 kW C&I hybrid (SvK, AC-stage slew-limited)", manufacturer = "Solis", - version = "0.2.4", + version = "0.2.5", protocols = { "modbus" }, capabilities = { "battery" }, read_only = false, @@ -115,75 +115,6 @@ DRIVER = { }, } -DRIVER_MANIFEST = { - name = "50-125k-svk-ac-slew", - version = "0.2.4", - role = "battery", - - requires = { - { name = "battery_capacity_wh", - purpose = "control", - type = "integer", min = 1000, max = 1000000, - help = "Total usable LFP capacity wired to this Solis (Wh). " .. - "Not on the bus; configured per install. Used to derive " .. - "charge / discharge energy headroom (Wh)." }, - { name = "battery_soc_min_pct", - purpose = "control", - type = "integer", min = 0, max = 100, - help = "Floor for discharge — arbitrator vetoes setpoints that " .. - "would drive SoC below this percentage." }, - { name = "battery_soc_max_pct", - purpose = "control", - type = "integer", min = 0, max = 100, - help = "Ceiling for charge — arbitrator vetoes setpoints that " .. - "would drive SoC above this percentage." }, - }, - - options = { - { name = "battery_rated_w", - purpose = "control", - type = "integer", min = 1000, max = 200000, - help = "Battery max continuous charge / discharge power (W, " .. - "magnitude). Cap on |setpoint| regardless of the " .. - "inverter's nameplate. The driver clamps |W| to this " .. - "before writing the 44284-85 AC power value." }, - { name = "battery_max_c_rate", - purpose = "control", - type = "double", default = 1.0, min = 0.1, max = 5.0, - help = "Battery max C-rate. Fallback magnitude cap when " .. - "battery_rated_w is unset (capacity_wh × c_rate)." }, - { name = "pv_shares_ac_stage", - purpose = "control", - type = "boolean", default = true, - help = "Hybrid topology: PV + battery contend for the AC " .. - "inverter stage. Carried for config-compatibility with " .. - "solis_50_125k; this fast driver does not emit PV." }, - { name = "ffr_slew_rate_pct_per_s", - purpose = "control", - type = "double", default = 100000.0, min = 1.0, max = 100000.0, - help = "Max deactivation slew rate as % of |setpoint_cap_w| " .. - "per second. Applied ONLY when |new_setpoint| < " .. - "|current_setpoint| (deactivation). Activation, hold, " .. - "and direction-up-in-magnitude bypass the slew and " .. - "write immediately. init() / deinit() also bypass — " .. - "safe-revert must reach 0 W instantly. Default is " .. - "**no slew enforcement** (100000 %/s, effectively " .. - "unbounded) so the driver behaves transparently for " .. - "FCR-D/N, aFRR, mFRR, and operator setpoints. SvK FFR " .. - "§1.4 Tabell 3 kort uthållighet caps deact at 20 %/s — " .. - "the runner constrains via driver_command('set_slew_" .. - "rate', 15) only for that specific test profile." }, - }, - - provides = { - live = { "battery.W", "battery.SoC_nom_fract", "battery.available_charge_Wh", - "battery.available_discharge_Wh", "battery.available_charge_W", - "battery.available_discharge_W", "inverter.W", - "inverter.available_import_W", "inverter.available_export_W" }, - static = { "rated_W", "make", "model", "sn" }, - }, -} - PROTOCOL = "modbus" local rated_w = 0 diff --git a/drivers/lua/50-125k-svk-slew.lua b/drivers/lua/50-125k-svk-slew.lua index 074d815..7e5368d 100644 --- a/drivers/lua/50-125k-svk-slew.lua +++ b/drivers/lua/50-125k-svk-slew.lua @@ -75,7 +75,7 @@ DRIVER = { id = "50-125k-svk-slew", name = "Solis S6 50-125 kW C&I hybrid (SvK, slew-limited)", manufacturer = "Solis", - version = "0.1.12", + version = "0.1.13", protocols = { "modbus" }, capabilities = { "battery" }, read_only = false, @@ -89,75 +89,6 @@ DRIVER = { }, } -DRIVER_MANIFEST = { - name = "50-125k-svk-slew", - version = "0.1.12", - role = "battery", - - requires = { - { name = "battery_capacity_wh", - purpose = "control", - type = "integer", min = 1000, max = 1000000, - help = "Total usable LFP capacity wired to this Solis (Wh). " .. - "Not on the bus; configured per install. Used to derive " .. - "charge / discharge energy headroom (Wh)." }, - { name = "battery_soc_min_pct", - purpose = "control", - type = "integer", min = 0, max = 100, - help = "Floor for discharge — arbitrator vetoes setpoints that " .. - "would drive SoC below this percentage." }, - { name = "battery_soc_max_pct", - purpose = "control", - type = "integer", min = 0, max = 100, - help = "Ceiling for charge — arbitrator vetoes setpoints that " .. - "would drive SoC above this percentage." }, - }, - - options = { - { name = "battery_rated_w", - purpose = "control", - type = "integer", min = 1000, max = 200000, - help = "Battery max continuous charge / discharge power (W, " .. - "magnitude). Cap on |setpoint| regardless of the " .. - "inverter's nameplate. The driver clamps |W| to this " .. - "before writing the 44282-83 battery power value." }, - { name = "battery_max_c_rate", - purpose = "control", - type = "double", default = 1.0, min = 0.1, max = 5.0, - help = "Battery max C-rate. Fallback magnitude cap when " .. - "battery_rated_w is unset (capacity_wh × c_rate)." }, - { name = "pv_shares_ac_stage", - purpose = "control", - type = "boolean", default = true, - help = "Hybrid topology: PV + battery contend for the AC " .. - "inverter stage. Carried for config-compatibility with " .. - "solis_50_125k; this fast driver does not emit PV." }, - { name = "ffr_slew_rate_pct_per_s", - purpose = "control", - type = "double", default = 100000.0, min = 1.0, max = 100000.0, - help = "Max deactivation slew rate as % of |setpoint_cap_w| " .. - "per second. Applied ONLY when |new_setpoint| < " .. - "|current_setpoint| (deactivation). Activation, hold, " .. - "and direction-up-in-magnitude bypass the slew and " .. - "write immediately. init() / deinit() also bypass — " .. - "safe-revert must reach 0 W instantly. Default is " .. - "**no slew enforcement** (100000 %/s, effectively " .. - "unbounded) so the driver behaves transparently for " .. - "FCR-D/N, aFRR, mFRR, and operator setpoints. SvK FFR " .. - "§1.4 Tabell 3 kort uthållighet caps deact at 20 %/s — " .. - "the runner constrains via driver_command('set_slew_" .. - "rate', 15) only for that specific test profile." }, - }, - - provides = { - live = { "battery.W", "battery.SoC_nom_fract", "battery.available_charge_Wh", - "battery.available_discharge_Wh", "battery.available_charge_W", - "battery.available_discharge_W", "inverter.W", - "inverter.available_import_W", "inverter.available_export_W" }, - static = { "rated_W", "make", "model", "sn" }, - }, -} - PROTOCOL = "modbus" local rated_w = 0 diff --git a/drivers/lua/50-125k-svk.lua b/drivers/lua/50-125k-svk.lua index 353b4ea..5d8004e 100644 --- a/drivers/lua/50-125k-svk.lua +++ b/drivers/lua/50-125k-svk.lua @@ -64,7 +64,7 @@ DRIVER = { id = "50-125k-svk", name = "Solis S6 50-125 kW C&I hybrid (SvK)", manufacturer = "Solis", - version = "0.2.3", + version = "0.2.4", protocols = { "modbus" }, capabilities = { "battery" }, read_only = false, @@ -78,60 +78,6 @@ DRIVER = { }, } -DRIVER_MANIFEST = { - name = "50-125k-svk", - version = "0.2.3", - role = "battery", - - requires = { - { name = "battery_capacity_wh", - purpose = "control", - type = "integer", min = 1000, max = 1000000, - help = "Total usable LFP capacity wired to this Solis (Wh). " .. - "Not on the bus; configured per install. Used to derive " .. - "charge / discharge energy headroom (Wh)." }, - { name = "battery_soc_min_pct", - purpose = "control", - type = "integer", min = 0, max = 100, - help = "Floor for discharge — arbitrator vetoes setpoints that " .. - "would drive SoC below this percentage." }, - { name = "battery_soc_max_pct", - purpose = "control", - type = "integer", min = 0, max = 100, - help = "Ceiling for charge — arbitrator vetoes setpoints that " .. - "would drive SoC above this percentage." }, - }, - - options = { - { name = "battery_rated_w", - purpose = "control", - type = "integer", min = 1000, max = 200000, - help = "Battery max continuous charge / discharge power (W, " .. - "magnitude). Cap on |setpoint| regardless of the " .. - "inverter's nameplate. The driver clamps |W| to this " .. - "before writing the 44282-83 battery power value." }, - { name = "battery_max_c_rate", - purpose = "control", - type = "double", default = 1.0, min = 0.1, max = 5.0, - help = "Battery max C-rate. Fallback magnitude cap when " .. - "battery_rated_w is unset (capacity_wh × c_rate)." }, - { name = "pv_shares_ac_stage", - purpose = "control", - type = "boolean", default = true, - help = "Hybrid topology: PV + battery contend for the AC " .. - "inverter stage. Carried for config-compatibility with " .. - "solis_50_125k; this fast driver does not emit PV." }, - }, - - provides = { - live = { "battery.W", "battery.SoC_nom_fract", "battery.available_charge_Wh", - "battery.available_discharge_Wh", "battery.available_charge_W", - "battery.available_discharge_W", "inverter.W", - "inverter.available_import_W", "inverter.available_export_W" }, - static = { "rated_W", "make", "model", "sn" }, - }, -} - PROTOCOL = "modbus" local rated_w = 0 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/lua/acuvim.lua b/drivers/lua/acuvim.lua index 8240946..0f8897b 100644 --- a/drivers/lua/acuvim.lua +++ b/drivers/lua/acuvim.lua @@ -106,7 +106,7 @@ DRIVER = { id = "acuvim", name = "Accuenergy Acuvim II meter", manufacturer = "Accuenergy", - version = "0.4.2", + version = "0.4.3", protocols = { "modbus" }, capabilities = { "meter" }, read_only = true, @@ -120,29 +120,6 @@ DRIVER = { }, } -DRIVER_MANIFEST = { - name = "acuvim", - version = "0.4.2", - role = "meter", - - requires = {}, - options = {}, - - -- Field names match @srcful/data-models MeterTelemetry verbatim and - -- the SDM630 reference driver. Top-level W/Hz are totals; per-phase - -- rows are flat (L1_V/L1_A/L1_W ...) — the shape l1's MeterEmit / - -- arbitrator per-phase fuse clamp reads (lua_driver.rs reads - -- tbl.get("L1_V") etc.; the Rust side converts A→mA and V→dV). - provides = { - live = { "meter.W", "meter.Hz", - "meter.L1_V", "meter.L2_V", "meter.L3_V", - "meter.L1_A", "meter.L2_A", "meter.L3_A", - "meter.L1_W", "meter.L2_W", "meter.L3_W", - "meter.total_import_Wh", "meter.total_export_Wh" }, - static = { "make", "model", "sn" }, - }, -} - -- ── Module state ──────────────────────────────────────────────────── -- The Acuvim II has no per-cycle state we need to remember; everything -- comes from the bus. We cache nothing across polls. diff --git a/drivers/lua/ambibox_v2x.lua b/drivers/lua/ambibox_v2x.lua index 02a12d8..9d25995 100644 --- a/drivers/lua/ambibox_v2x.lua +++ b/drivers/lua/ambibox_v2x.lua @@ -9,10 +9,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "ambibox-v2x", + id = "ambibox_v2x", name = "Ambibox V2X", manufacturer = "Ambibox", - version = "1.0.0", + version = "1.1.1", protocols = { "mqtt" }, capabilities = { "v2x_charger" }, description = "Ambibox bidirectional V2X charger via MQTT.", diff --git a/drivers/lua/ctek.lua b/drivers/lua/ctek.lua index 8e0d0e9..b55716c 100644 --- a/drivers/lua/ctek.lua +++ b/drivers/lua/ctek.lua @@ -68,10 +68,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "ctek-chargestorm", + id = "ctek", name = "CTEK Chargestorm (API v1)", manufacturer = "CTEK", - version = "0.3.2", + version = "0.3.3", protocols = { "modbus" }, capabilities = { "ev" }, description = "CTEK Chargestorm Connected 2/3 via Modbus/TCP Automation API v1 (CSOS ≥ 4.9.3). Full telemetry + current-limit control.", diff --git a/drivers/lua/ctek_hybrid.lua b/drivers/lua/ctek_hybrid.lua index f4a6d1e..821d752 100644 --- a/drivers/lua/ctek_hybrid.lua +++ b/drivers/lua/ctek_hybrid.lua @@ -67,10 +67,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "ctek-chargestorm-hybrid", + id = "ctek_hybrid", name = "CTEK Chargestorm (Modbus + MQTT)", manufacturer = "CTEK", - version = "0.3.1", + version = "0.3.2", protocols = { "modbus", "mqtt" }, capabilities = { "ev" }, description = "CTEK Chargestorm Connected 2/3 — MQTT for state + live telemetry (preferred), Modbus/TCP for control + telemetry fallback.", diff --git a/drivers/lua/ctek_v2.lua b/drivers/lua/ctek_v2.lua index 58527f0..a96da49 100644 --- a/drivers/lua/ctek_v2.lua +++ b/drivers/lua/ctek_v2.lua @@ -68,10 +68,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "ctek-chargestorm-v2", + id = "ctek_v2", name = "CTEK Chargestorm (API v2)", manufacturer = "CTEK", - version = "0.3.2", + version = "0.3.3", protocols = { "modbus" }, capabilities = { "ev" }, description = "CTEK Chargestorm Connected 2/3 via Modbus/TCP Automation API v2 (CSOS ≥ 4.9.3). Full telemetry + current-limit control.", diff --git a/drivers/lua/deye-svk.lua b/drivers/lua/deye-svk.lua index 4537718..d524d2b 100644 --- a/drivers/lua/deye-svk.lua +++ b/drivers/lua/deye-svk.lua @@ -64,7 +64,7 @@ DRIVER = { id = "deye-svk", name = "Deye SUN hybrid — lean SvK fast-poll variant", manufacturer = "Deye", - version = "0.2.1", + version = "0.2.2", protocols = { "modbus" }, capabilities = { "battery" }, read_only = false, @@ -78,65 +78,6 @@ DRIVER = { }, } -DRIVER_MANIFEST = { - name = "deye-svk", - version = "0.2.1", - role = "battery", -- hybrid inverter is treated as a battery asset - - -- Same requires/options as deye@2.4.4 so an existing Deye device - -- entry (e.g. Sveavägen 19) can be repinned to this driver WITHOUT - -- re-entering config. - requires = { - { name = "battery_capacity_wh", - purpose = "control", - type = "integer", min = 1000, max = 1000000, - help = "Total usable LFP capacity wired to this Deye (Wh). " .. - "The Deye doesn't know how much battery is on its DC bus, " .. - "so this must be configured per install." }, - { name = "battery_rated_w", - purpose = "control", - type = "integer", min = 1000, max = 200000, - help = "Battery max continuous charge / discharge power (W, " .. - "magnitude). Cap on |setpoint| regardless of inverter " .. - "rated_w. Use the manufacturer's continuous rating; " .. - "the inverter is hardware-limited but the battery may " .. - "be the actual bottleneck (e.g. a 30 kWh / 15 kW pack " .. - "behind a 30 kW inverter). The driver will silently " .. - "clamp setpoints over this magnitude before writing " .. - "reg 1109." }, - { name = "battery_soc_min_pct", - purpose = "control", - type = "integer", min = 0, max = 100, - help = "Floor for discharge — arbitrator vetoes setpoints that " .. - "would drive SoC below this percentage." }, - { name = "battery_soc_max_pct", - purpose = "control", - type = "integer", min = 0, max = 100, - help = "Ceiling for charge — arbitrator vetoes setpoints that " .. - "would drive SoC above this percentage." }, - }, - - options = { - { name = "battery_max_c_rate", - purpose = "control", - type = "double", default = 1.0, min = 0.1, max = 5.0, - help = "Battery max C-rate. 1.0 means rated power == capacity " .. - "per hour. Most LFP packs paired with hybrid inverters " .. - "can sustain 1.0 C at moderate temperatures." }, - { name = "pv_shares_ac_stage", - purpose = "control", - type = "boolean", default = true, - help = "Hybrid topology: PV + battery contend for the AC " .. - "inverter stage. Carried for config-compatibility with " .. - "deye; this fast driver does not emit PV." }, - }, - - provides = { - live = { "battery.W", "battery.SoC_nom_fract" }, - static = { "rated_W", "make", "model", "sn" }, - }, -} - PROTOCOL = "modbus" local is_hv = false diff --git a/drivers/lua/easee_cloud.lua b/drivers/lua/easee_cloud.lua index 72b3429..38aa5df 100644 --- a/drivers/lua/easee_cloud.lua +++ b/drivers/lua/easee_cloud.lua @@ -22,10 +22,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "easee-cloud", + id = "easee_cloud", name = "Easee Cloud", manufacturer = "Easee", - version = "1.3.3", + version = "1.3.4", protocols = { "http" }, capabilities = { "ev" }, description = "Easee Home/Charge via Cloud REST API. No local protocol needed.", diff --git a/drivers/lua/esphome_dsmr.lua b/drivers/lua/esphome_dsmr.lua index 9caf608..e0f4aea 100644 --- a/drivers/lua/esphome_dsmr.lua +++ b/drivers/lua/esphome_dsmr.lua @@ -1,57 +1,3 @@ --- Generated by tools/ftw_repository.py from the public source and catalog. --- This FTW v1 artifact is read-only even when another target has control. -local __sourceful_ftw_metadata = { - id = "esphome-dsmr", - name = "ESPHome DSMR (P1)", - version = "1.0.2", - host_api_min = 1, - host_api_max = 1, - protocols = { "http" }, - capabilities = { "meter" }, - read_only = true, - manufacturer = "ESPHome", - description = "Smart meter via ESPHome web_server v3 + dsmr component (Sourceful Zap on open firmware, DIY ESP32+P1, etc.).", - homepage = "https://github.com/erikarenhill/sourceful-zap-esphome", - verification_status = "experimental", - verification_notes = "Signed community source; check the catalog for current hardware evidence.", - tested_models = { "DSMR web_server v3 P1 reader" }, -} -DRIVER = __sourceful_ftw_metadata -local function __sourceful_ftw_write_denied() - error("signed FTW community drivers are read-only") -end -host.http_post = __sourceful_ftw_write_denied -host.modbus_write = __sourceful_ftw_write_denied -host.modbus_write_multi = __sourceful_ftw_write_denied -host.modbus_write_multiple = __sourceful_ftw_write_denied -host.mqtt_pub = __sourceful_ftw_write_denied -host.mqtt_publish = __sourceful_ftw_write_denied -host.serial_write = __sourceful_ftw_write_denied -local __sourceful_ftw_log = host.log -host.log = function(level, message) - if message == nil then return __sourceful_ftw_log("info", tostring(level)) end - return __sourceful_ftw_log(level, message) -end -host.decode_u32 = host.decode_u32 or host.decode_u32_be -host.decode_i32 = host.decode_i32 or host.decode_i32_be -host.modbus_write_multiple = __sourceful_ftw_write_denied -host.scale = host.scale or function(value, scale_factor) - return value * (10 ^ scale_factor) -end -host.decode_f32 = host.decode_f32 or function(hi, lo) - local bits = hi * 65536 + lo - if bits == 0 then return 0 end - local sign = bits >= 2147483648 and -1 or 1 - local exponent = math.floor(bits / 8388608) % 256 - local mantissa = bits % 8388608 - if exponent == 0 then return sign * mantissa * (2 ^ -149) end - if exponent == 255 then return 0 end - return sign * (1 + mantissa / 8388608) * (2 ^ (exponent - 127)) -end -host.decode_u64 = host.decode_u64 or function(w1, w2, w3, w4) - return w1 * 281474976710656 + w2 * 4294967296 + w3 * 65536 + w4 -end - -- esphome_dsmr.lua -- ESPHome-flashed P1 / DSMR smart-meter reader (e.g. Sourceful Zap on -- the open-source firmware at github.com/erikarenhill/sourceful-zap-esphome). @@ -113,10 +59,10 @@ end -- without hammering the ESP32's tiny TCP stack. DRIVER = { host_api_min = 1, host_api_max = 1, - id = "esphome-dsmr", + id = "esphome_dsmr", name = "ESPHome DSMR (P1)", manufacturer = "ESPHome", - version = "1.0.2", + version = "1.0.4", protocols = { "http" }, capabilities = { "meter" }, description = "Smart meter via ESPHome web_server v3 + dsmr component (Sourceful Zap on open firmware, DIY ESP32+P1, etc.).", @@ -437,8 +383,3 @@ function driver_cleanup() has_reactive = false consecutive_failures = 0 end - --- Enforce the signed FTW metadata and read-only lifecycle after source load. -DRIVER = __sourceful_ftw_metadata -function driver_command(action, value, context) return false end -function driver_default_mode() end diff --git a/drivers/lua/ferroamp_dc2_v2x.lua b/drivers/lua/ferroamp_dc2_v2x.lua index 6b10b25..05b133e 100644 --- a/drivers/lua/ferroamp_dc2_v2x.lua +++ b/drivers/lua/ferroamp_dc2_v2x.lua @@ -13,10 +13,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "ferroamp-dc2-v2x", + id = "ferroamp_dc2_v2x", name = "Ferroamp DC2 V2X", manufacturer = "Ferroamp", - version = "1.1.0", + version = "2.1.1", protocols = { "mqtt" }, capabilities = { "v2x_charger" }, description = "Ferroamp DC2 V2X 20 kW bidirectional CCS2 charger via local MQTT.", @@ -98,9 +98,9 @@ end local function parse_json(payload) if payload == nil or payload == "" then return nil end - local value, err = host.json_decode(payload) - if err then - host.log("warn", "dc2 json decode failed: " .. tostring(err)) + local ok, value, err = pcall(host.json_decode, payload) + if not ok or err then + host.log("warn", "dc2 json decode failed: " .. tostring(ok and err or value)) return nil end return value diff --git a/drivers/lua/ferroamp_modbus.lua b/drivers/lua/ferroamp_modbus.lua index 4d2e1f4..04987e0 100644 --- a/drivers/lua/ferroamp_modbus.lua +++ b/drivers/lua/ferroamp_modbus.lua @@ -20,10 +20,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "ferroamp-modbus", + id = "ferroamp_modbus", name = "Ferroamp EnergyHub (Modbus)", manufacturer = "Ferroamp", - version = "2.1.2", + version = "2.1.3", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, read_only = true, diff --git a/drivers/lua/foxess_h3_smart.lua b/drivers/lua/foxess_h3_smart.lua index a8d0114..d09f8e1 100644 --- a/drivers/lua/foxess_h3_smart.lua +++ b/drivers/lua/foxess_h3_smart.lua @@ -148,7 +148,7 @@ DRIVER = { id = "foxess_h3_smart", name = "FoxESS H3-Smart / 1K5", manufacturer = "Fox ESS", - version = "0.9.5", + version = "0.9.6", host_api_min = 1, host_api_max = 2, protocols = { "modbus" }, @@ -171,28 +171,6 @@ PROTOCOL = "modbus" -- what driver_poll emits; SoC, temperature and the energy counters are -- omitted from an emit when their block has given up, same as every -- other field here. -DRIVER_MANIFEST = { - name = "foxess_h3_smart", - version = "0.9.5", - role = "inverter", - requires = {}, - options = {}, - provides = { - live = { - "pv.W", "pv.mppts", "pv.total_generation_Wh", - "battery.W", "battery.V", "battery.A", - "battery.SoC_nom_fract", "battery.temperature_C", - "battery.total_charge_Wh", "battery.total_discharge_Wh", - "meter.W", "meter.Hz", - "meter.L1_V", "meter.L2_V", "meter.L3_V", - "meter.L1_W", "meter.L2_W", "meter.L3_W", - "meter.L1_A", "meter.L2_A", "meter.L3_A", - "meter.total_import_Wh", "meter.total_export_Wh", - }, - static = { "make" }, - }, -} - -- Model regs 30000..30015, serial regs 30016..30031, both ASCII. local MODEL_ADDR = 30000 -- One block covers inverter state through temperature: diff --git a/drivers/lua/fronius_smart_meter.lua b/drivers/lua/fronius_smart_meter.lua index 285a771..90ae7e0 100644 --- a/drivers/lua/fronius_smart_meter.lua +++ b/drivers/lua/fronius_smart_meter.lua @@ -11,10 +11,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "fronius-smart-meter", + id = "fronius_smart_meter", name = "Fronius Smart Meter", manufacturer = "Fronius", - version = "2.1.2", + version = "2.1.3", protocols = { "modbus" }, capabilities = { "meter" }, read_only = true, diff --git a/drivers/lua/heishamon.lua b/drivers/lua/heishamon.lua index 2318aa5..046d323 100644 --- a/drivers/lua/heishamon.lua +++ b/drivers/lua/heishamon.lua @@ -31,7 +31,7 @@ DRIVER = { id = "heishamon", name = "Panasonic Aquarea (Heishamon)", manufacturer = "Panasonic", - version = "0.4.0", + version = "0.5.1", protocols = { "mqtt" }, capabilities = { "heatpump" }, description = "Panasonic Aquarea H/J/K/L/M-series heat pump via Heishamon MQTT bridge. Controls Zone 1 heat curve offset (Z1_Heat_Request_Temp) in range -3..+3 °C.", diff --git a/drivers/lua/huawei.lua b/drivers/lua/huawei.lua index 2308ebe..d379499 100644 --- a/drivers/lua/huawei.lua +++ b/drivers/lua/huawei.lua @@ -20,10 +20,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "huawei-sun2000", + id = "huawei", name = "Huawei SUN2000 Hybrid Inverter", manufacturer = "Huawei", - version = "2.1.2", + version = "2.1.3", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery" }, read_only = true, diff --git a/drivers/lua/konja-261-svk.lua b/drivers/lua/konja-261-svk.lua index be93580..7c1d00d 100644 --- a/drivers/lua/konja-261-svk.lua +++ b/drivers/lua/konja-261-svk.lua @@ -148,7 +148,7 @@ DRIVER = { id = "konja-261-svk", name = "Konja MG 261 kWh / 125 kW C&I BESS (SvK)", manufacturer = "Konja Power", - version = "0.3.1", + version = "0.3.2", protocols = { "modbus" }, capabilities = { "battery", "meter" }, read_only = false, @@ -162,97 +162,6 @@ DRIVER = { }, } -DRIVER_MANIFEST = { - name = "konja-261-svk", - version = "0.3.1", - role = "battery", - - -- 20 Hz. SvK FFR §2 Tabell 5 wants 0.1 s logging resolution and - -- the l1 snapshot feeds the SOC / UtsignalStyrenhet_W columns of - -- the session CSV — at exactly 100 ms any jitter drops 0.1 s bins, - -- so leave headroom. Each poll is 3 × FC04 over TCP (PCS, BMS, - -- meter — the meter block was added in 0.3.0). Command - -- latency does NOT depend on this: l1's mid-poll preempt - -- dispatches a pending SetpointWrite between register bundles. - -- Raise it per-device if the EMS objects to the rate - -- (see OPEN QUESTION 1). - poll_interval_ms = 50, - - requires = { - { name = "battery_capacity_wh", - purpose = "control", - type = "integer", min = 1000, max = 1000000, - help = "Total usable LFP capacity of the cabinet (Wh). " .. - "Nameplate for the 261 kWh unit is 261000. Not " .. - "reliably on the bus; configured per install. Used " .. - "to derive charge / discharge energy headroom (Wh)." }, - { name = "battery_soc_min_pct", - purpose = "control", - type = "integer", min = 0, max = 100, - help = "Floor for discharge — arbitrator vetoes setpoints " .. - "that would drive SoC below this percentage." }, - { name = "battery_soc_max_pct", - purpose = "control", - type = "integer", min = 0, max = 100, - help = "Ceiling for charge — arbitrator vetoes setpoints " .. - "that would drive SoC above this percentage." }, - }, - - options = { - { name = "battery_rated_w", - purpose = "control", - type = "integer", min = 1000, max = 200000, - help = "Max continuous charge / discharge power (W, " .. - "magnitude). Nameplate for this cabinet is 125000. " .. - "Cap on |setpoint| regardless of what the PCS " .. - "reports. When unset the driver falls back to PCS " .. - "reg 8256 (Maximum Discharging Power), then to " .. - "capacity_wh × battery_max_c_rate." }, - { name = "battery_max_c_rate", - purpose = "control", - type = "double", default = 1.0, min = 0.1, max = 5.0, - help = "Battery max C-rate. Fallback magnitude cap when " .. - "battery_rated_w is unset and the PCS rating is " .. - "unreadable (capacity_wh × c_rate)." }, - { name = "pv_shares_ac_stage", - purpose = "control", - type = "boolean", default = false, - help = "Hybrid topology flag. FALSE for this cabinet — " .. - "the 261 kWh unit has no PV on the PCS AC stage, " .. - "so battery power never contends with PV." }, - { name = "ffr_slew_rate_pct_per_s", - purpose = "control", - type = "double", default = 100000.0, min = 1.0, max = 100000.0, - help = "Max DEACTIVATION slew rate as % of " .. - "|setpoint_cap_w| per second. Applied only when " .. - "|new_setpoint| < |current_setpoint|. Activation, " .. - "hold and direction-up-in-magnitude bypass it and " .. - "write immediately; init() / deinit() also bypass " .. - "— safe-revert must reach 0 W instantly. Default " .. - "is no enforcement (100000 %/s) so the driver is " .. - "market-agnostic; SvK FFR §1.4 Tabell 3 kort " .. - "uthållighet caps deact at 20 %/s and the runner " .. - "sets it via driver_command('set_slew_rate', 15)." }, - }, - - provides = { - live = { "battery.W", "battery.SoC_nom_fract", - "battery.available_charge_Wh", "battery.available_discharge_Wh", - "battery.available_charge_W", "battery.available_discharge_W", - "battery.V", "battery.A", "battery.temperature_C", - "inverter.W", "inverter.Hz", "inverter.heatsink_C", - -- Cabinet ADL400 meter (0.3.0). Flat per-phase keys, - -- not `meter.lines[]` — the l1 MeterEmit struct is - -- flat and silently drops anything else. - "meter.W", "meter.Hz", - "meter.L1_V", "meter.L2_V", "meter.L3_V", - "meter.L1_A", "meter.L2_A", "meter.L3_A", - "meter.L1_W", "meter.L2_W", "meter.L3_W", - "meter.total_import_Wh", "meter.total_export_Wh" }, - static = { "rated_W", "make", "model", "sn" }, - }, -} - PROTOCOL = "modbus" -- ── Register map (all literal, from the V1.16 workbook) ────────────── diff --git a/drivers/lua/nibe_local.lua b/drivers/lua/nibe_local.lua index a59c8c2..a0ccdf4 100644 --- a/drivers/lua/nibe_local.lua +++ b/drivers/lua/nibe_local.lua @@ -105,10 +105,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "nibe-local", + id = "nibe_local", name = "NIBE REST API S-series", manufacturer = "NIBE", - version = "1.2.0", + version = "1.2.1", protocols = { "http" }, capabilities = { "apicreds" }, description = "NIBE S-series heat-pump telemetry over the on-prem Local REST API (HTTPS + Basic auth, self-signed cert pinned via tls_pin_sha256). Emits compressor/used power, lifetime energy meters, and the full ~980-point register map. Read-only by default; one opt-in write path feeds the pump's native Solar PV surplus input (registers 2107/2109) so the pump soaks up excess solar.", diff --git a/drivers/lua/pixii_pv.lua b/drivers/lua/pixii_pv.lua index 3bb6466..2b50c6b 100644 --- a/drivers/lua/pixii_pv.lua +++ b/drivers/lua/pixii_pv.lua @@ -11,10 +11,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "pixii-pv", + id = "pixii_pv", name = "Pixii PowerShaper (PV + meter)", manufacturer = "Pixii", - version = "0.3.1", + version = "0.3.2", protocols = { "mqtt" }, capabilities = { "pv", "meter" }, read_only = true, diff --git a/drivers/lua/sdm630.lua b/drivers/lua/sdm630.lua index 917794a..d5911f9 100644 --- a/drivers/lua/sdm630.lua +++ b/drivers/lua/sdm630.lua @@ -14,7 +14,7 @@ DRIVER = { id = "sdm630", name = "Eastron SDM630 meter", manufacturer = "Eastron", - version = "1.1.1", + version = "2.0.2", protocols = { "modbus" }, capabilities = { "meter" }, read_only = true, @@ -30,24 +30,6 @@ DRIVER = { }, } -DRIVER_MANIFEST = { - name = "sdm630", - version = "1.1.1", - role = "meter", - requires = {}, - options = {}, - provides = { - live = { - "meter.W", "meter.Hz", - "meter.L1_V", "meter.L2_V", "meter.L3_V", - "meter.L1_A", "meter.L2_A", "meter.L3_A", - "meter.L1_W", "meter.L2_W", "meter.L3_W", - "meter.total_import_Wh", "meter.total_export_Wh", - }, - static = { "make" }, - }, -} - -- Decode IEEE-754 float32 from two big-endian 16-bit registers. Keeping this -- in Lua avoids depending on a helper that currently exists only in Blixt. local function decode_f32_be(hi, lo) diff --git a/drivers/lua/solaredge_legacy.lua b/drivers/lua/solaredge_legacy.lua index 6baa297..b7d23bf 100644 --- a/drivers/lua/solaredge_legacy.lua +++ b/drivers/lua/solaredge_legacy.lua @@ -30,10 +30,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "solaredge-legacy", + id = "solaredge_legacy", name = "SolarEdge legacy (K-series with display)", manufacturer = "SolarEdge", - version = "0.3.2", + version = "0.3.3", protocols = { "modbus" }, capabilities = { "pv", "pv-curtail" }, description = "SolarEdge K-series (SE7K / SE10K / SE17K / SE25K) PV inverter via Modbus TCP — reads use FC 0x03 holding; curtail writes use FC 0x10 multi-holding on the same Advanced Power Control registers (0xF000/0xF001) as HD-Wave.", diff --git a/drivers/lua/solaredge_pv.lua b/drivers/lua/solaredge_pv.lua index a9bbd67..41e651a 100644 --- a/drivers/lua/solaredge_pv.lua +++ b/drivers/lua/solaredge_pv.lua @@ -10,10 +10,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "solaredge-pv", + id = "solaredge_pv", name = "SolarEdge inverter (PV only)", manufacturer = "SolarEdge", - version = "1.2.1", + version = "1.2.2", protocols = { "modbus" }, capabilities = { "pv", "pv-curtail" }, description = "SolarEdge HD-Wave / StorEdge PV-only via Modbus TCP (SunSpec) with PV active-power-limit curtail.", diff --git a/drivers/lua/solis_string.lua b/drivers/lua/solis_string.lua index eb573b0..c2e93b4 100644 --- a/drivers/lua/solis_string.lua +++ b/drivers/lua/solis_string.lua @@ -8,10 +8,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "solis-string", + id = "solis_string", name = "Solis string inverter", manufacturer = "Ginlong Solis", - version = "1.1.2", + version = "1.1.3", protocols = { "modbus" }, capabilities = { "pv" }, read_only = true, diff --git a/drivers/lua/sungrow.lua b/drivers/lua/sungrow.lua index 314ee25..56aa82d 100644 --- a/drivers/lua/sungrow.lua +++ b/drivers/lua/sungrow.lua @@ -6,10 +6,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "sungrow-shx", + id = "sungrow", name = "Sungrow SH Hybrid Inverter", manufacturer = "Sungrow", - version = "1.5.8", + version = "1.5.9", protocols = { "modbus" }, capabilities = { "meter", "pv", "battery", "pv-curtail" }, description = "Sungrow SH-series hybrid inverters with LFP battery, via Modbus TCP.", diff --git a/drivers/lua/tesla_vehicle.lua b/drivers/lua/tesla_vehicle.lua index ffe9ef7..8252c5e 100644 --- a/drivers/lua/tesla_vehicle.lua +++ b/drivers/lua/tesla_vehicle.lua @@ -26,10 +26,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "tesla-vehicle", + id = "tesla_vehicle", name = "Tesla Vehicle (BLE Proxy)", manufacturer = "Tesla", - version = "0.2.2", + version = "0.2.3", protocols = { "http" }, capabilities = { "vehicle" }, description = "Read-only vehicle SoC + charge limit via Tesla API-compatible HTTP endpoint (e.g. TeslaBLEProxy).", diff --git a/drivers/lua/tesla_wall_connector.lua b/drivers/lua/tesla_wall_connector.lua index 28c1a3e..985895e 100644 --- a/drivers/lua/tesla_wall_connector.lua +++ b/drivers/lua/tesla_wall_connector.lua @@ -23,10 +23,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "tesla-wall-connector", + id = "tesla_wall_connector", name = "Tesla Wall Connector", manufacturer = "Tesla", - version = "0.1.0", + version = "0.1.1", protocols = { "http" }, capabilities = { "ev" }, read_only = true, diff --git a/drivers/lua/zap.lua b/drivers/lua/zap.lua index 0a81535..721c010 100644 --- a/drivers/lua/zap.lua +++ b/drivers/lua/zap.lua @@ -35,10 +35,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "sourceful-zap", + id = "zap", name = "Sourceful Zap (P1/HAN meter)", manufacturer = "Sourceful", - version = "3.1.0", + version = "3.1.1", protocols = { "http" }, capabilities = { "meter", "pv", "battery" }, read_only = true, diff --git a/drivers/lua/zaptec_cloud.lua b/drivers/lua/zaptec_cloud.lua index 12f1e27..8e6ce24 100644 --- a/drivers/lua/zaptec_cloud.lua +++ b/drivers/lua/zaptec_cloud.lua @@ -32,10 +32,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "zaptec-cloud", + id = "zaptec_cloud", name = "Zaptec Cloud", manufacturer = "Zaptec", - version = "0.1.0", + version = "0.1.1", protocols = { "http" }, capabilities = { "ev" }, read_only = true, diff --git a/drivers/lua/zuidwijk_p1.lua b/drivers/lua/zuidwijk_p1.lua index c9252e0..f2d8327 100644 --- a/drivers/lua/zuidwijk_p1.lua +++ b/drivers/lua/zuidwijk_p1.lua @@ -11,10 +11,10 @@ DRIVER = { host_api_min = 1, host_api_max = 1, - id = "zuidwijk-p1", + id = "zuidwijk_p1", name = "Zuidwijk P1 Reader Ethernet", manufacturer = "Zuidwijk", - version = "1.1.1", + version = "1.1.2", protocols = { "tcp" }, capabilities = { "meter" }, read_only = true, diff --git a/drivers/tests/conftest.py b/drivers/tests/conftest.py index 5b63eb9..ca9b199 100644 --- a/drivers/tests/conftest.py +++ b/drivers/tests/conftest.py @@ -2,42 +2,10 @@ import os import glob -import hashlib import pytest DRIVERS_DIR = os.path.join(os.path.dirname(__file__), "..", "lua") MANIFESTS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "manifests") -BASELINE_DIR = os.path.join(os.path.dirname(__file__), "..", "..", - "baselines", "ftw", "drivers") - - -def _digest(path): - with open(path, "rb") as handle: - return hashlib.sha256(handle.read()).hexdigest() - - -def ftw_sourced_drivers(): - """Drivers that are byte-identical to FTW's field-proven version. - - These have run on customer sites for months and FTW tests them in Go, so - this suite's conventions -- which were written for the catalog and describe - a dialect FTW never spoke -- do not apply to them. - - Identity is by content, not by a list. Edit a promoted driver here and it - stops matching, and every check in this suite starts applying to it again. - """ - if not os.path.isdir(BASELINE_DIR): - return frozenset() - sourced = set() - for baseline in glob.glob(os.path.join(BASELINE_DIR, "*.lua")): - name = os.path.splitext(os.path.basename(baseline))[0] - promoted = os.path.join(DRIVERS_DIR, f"{name}.lua") - if os.path.exists(promoted) and _digest(promoted) == _digest(baseline): - sourced.add(name) - return frozenset(sourced) - - -FTW_SOURCED = ftw_sourced_drivers() def valid_ders(): @@ -60,50 +28,6 @@ def valid_ders(): if name.strip()} -def skip_if_ftw_sourced(name): - """Skip a catalog-convention check for a driver FTW owns and tests.""" - if name in FTW_SOURCED: - pytest.skip(f"{name} is FTW's driver verbatim; FTW tests it in Go") - - -def pytest_configure(config): - config.addinivalue_line( - "markers", - "holds_for_ftw_drivers: this check describes runtime behaviour rather " - "than a catalog convention, so a driver promoted from FTW is not " - "exempt from it") - - -def pytest_collection_modifyitems(items): - """Skip catalog-convention checks for the drivers FTW owns. - - Applied here rather than in each test so that a driver promoted from FTW - is exempt everywhere at once, and stops being exempt the moment someone - edits it and it no longer matches the baseline. - - The exemption covers conventions this repository grew and FTW never spoke: - spelling, key names, structure. It must not cover a rule about what the - driver does on hardware. A check marked `holds_for_ftw_drivers` applies to - every driver, promoted or not — the flap that took Pixii and SolarEdge - legacy offline lived in promoted drivers, and a blanket exemption is what - kept anyone from seeing it. - """ - if not FTW_SOURCED: - return - reason = pytest.mark.skip( - reason="FTW's driver verbatim; FTW tests it in Go") - for item in items: - if item.get_closest_marker("holds_for_ftw_drivers"): - continue - params = getattr(getattr(item, "callspec", None), "params", {}) - for value in params.values(): - name = getattr(value, "stem", None) or ( - value if isinstance(value, str) else None) - if name in FTW_SOURCED: - item.add_marker(reason) - break - - @pytest.fixture def all_driver_files(): """Return list of all .lua driver file paths.""" 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/lua_harness/test_esphome_dsmr_aliases.lua b/drivers/tests/lua_harness/test_esphome_dsmr_aliases.lua index c1bcd63..bf7e4d2 100644 --- a/drivers/tests/lua_harness/test_esphome_dsmr_aliases.lua +++ b/drivers/tests/lua_harness/test_esphome_dsmr_aliases.lua @@ -26,9 +26,9 @@ local function text_sensor(path, value) string.format('{"value":"%s"}', value) end -dofile("drivers/lua/esphome-dsmr.lua") +dofile("drivers/lua/esphome_dsmr.lua") -if type(DRIVER) ~= "table" or DRIVER.id ~= "esphome-dsmr" or DRIVER.version ~= "1.0.3" then +if type(DRIVER) ~= "table" or DRIVER.id ~= "esphome_dsmr" or DRIVER.version ~= "1.0.4" then error("ESPHome DSMR identity metadata is wrong") end if DRIVER.host_api_min ~= 1 or DRIVER.host_api_max ~= 1 or DRIVER.read_only ~= true then diff --git a/drivers/tests/lua_harness/test_nibe_local_ftw.lua b/drivers/tests/lua_harness/test_nibe_local_ftw.lua index c57c114..6710430 100644 --- a/drivers/tests/lua_harness/test_nibe_local_ftw.lua +++ b/drivers/tests/lua_harness/test_nibe_local_ftw.lua @@ -58,7 +58,7 @@ end boot(nil, 1, 0) -if type(DRIVER) ~= "table" or DRIVER.id ~= "nibe-local" or DRIVER.version ~= "1.2.0" then +if type(DRIVER) ~= "table" or DRIVER.id ~= "nibe_local" or DRIVER.version ~= "1.2.1" then error("NIBE identity metadata is wrong") end if DRIVER.host_api_min ~= 1 or DRIVER.host_api_max ~= 1 then 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_absent_register_settles.py b/drivers/tests/test_absent_register_settles.py index bf6dcb3..d447a05 100644 --- a/drivers/tests/test_absent_register_settles.py +++ b/drivers/tests/test_absent_register_settles.py @@ -80,7 +80,6 @@ def driver_names() -> list[str]: return sorted(p.stem for p in DRIVERS.glob("*.lua")) -@pytest.mark.holds_for_ftw_drivers @pytest.mark.parametrize("driver", driver_names()) def test_absent_register_debt_does_not_grow(driver: str) -> None: baseline = load_baseline() diff --git a/drivers/tests/test_driver_contract.py b/drivers/tests/test_driver_contract.py index f58b65e..0100250 100644 --- a/drivers/tests/test_driver_contract.py +++ b/drivers/tests/test_driver_contract.py @@ -120,13 +120,17 @@ def test_no_forbidden_globals(self, driver_name): def test_no_io_os_debug(self, driver_name): """Driver must not access io, os, or debug modules.""" code = read_driver(driver_name) - clean = strip_lua_comments(code) + # Text inside a string is not a module call ("sid-os.local" is a host + # name), so strings are blanked before looking. + clean = re.sub(r'"(?:\\.|[^"\\\n])*"|\'(?:\\.|[^\'\\\n])*\'', '""', strip_lua_comments(code)) # Check for io.xxx usage (word boundary to avoid false positives like 'radio.xxx') assert not re.search(r'\bio\.\w+', clean), \ f"{driver_name}: uses forbidden io module" - # Check for os.xxx usage (word boundary to avoid 'Ferroamp EnergyHub OS' etc.) - assert not re.search(r'\bos\.\w+', clean), \ + # Check for os.xxx usage (word boundary to avoid 'Ferroamp EnergyHub OS' etc.). + # FTW's host keeps os.time and os.date for MQTT timestamps and removes + # the process and filesystem entry points (go/internal/drivers/lua.go). + assert not re.search(r'\bos\.(?!(?:time|date)\b)\w+', clean), \ f"{driver_name}: uses forbidden os module" # Check for debug.xxx usage assert not re.search(r'\bdebug\.\w+', clean), \ diff --git a/drivers/tests/test_driver_structure.py b/drivers/tests/test_driver_structure.py index 063e28b..0946445 100644 --- a/drivers/tests/test_driver_structure.py +++ b/drivers/tests/test_driver_structure.py @@ -111,8 +111,9 @@ def test_handles_nil_messages(self, driver_name): """MQTT drivers should handle nil/empty message list.""" code = read_driver(driver_name) clean = strip_lua_comments(code) - # Should check for nil messages (e.g., "if not messages then") - assert re.search(r'if\s+not\s+messages\b', clean), \ + # Should check for nil messages ("if not messages then" or a loop + # guarded by "if messages then") + assert re.search(r'if\s+not\s+messages\b|if\s+messages\s+then\b', clean), \ f"{driver_name}: should check for nil messages from mqtt_messages()" diff --git a/drivers/tests/test_emit_fields.py b/drivers/tests/test_emit_fields.py index f9eb2a2..d0b96fc 100644 --- a/drivers/tests/test_emit_fields.py +++ b/drivers/tests/test_emit_fields.py @@ -100,6 +100,10 @@ "charge_power_min_w", "charge_power_max_w", "discharge_power_min_w", "discharge_power_max_w", "plug_connected", + # Fields FTW reads for a V2X charger: its host maps hz to freq_hz + # and vehicle_soc to the charger's SoC, and its API shows connected, + # status, control_mode and protocol (FTW go/internal/api/api.go). + "freq_hz", "vehicle_soc", "connected", "status", "control_mode", "protocol", }, } diff --git a/drivers/tests/test_esphome_dsmr_aliases.py b/drivers/tests/test_esphome_dsmr_aliases.py index 42538b4..779c012 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_mqtt_drivers.py b/drivers/tests/test_mqtt_drivers.py index 59cd45d..f28a7b7 100644 --- a/drivers/tests/test_mqtt_drivers.py +++ b/drivers/tests/test_mqtt_drivers.py @@ -146,6 +146,7 @@ def test_handles_empty_message_list(self, driver_name): re.search(r'if\s+not\s+messages\b', clean) or re.search(r'if\s+messages\s*==\s*nil', clean) or re.search(r'if\s+not\s+msgs\b', clean) + or re.search(r'if\s+(?:messages|msgs)\s+then\b', clean) ) assert has_nil_check, ( diff --git a/drivers/tests/test_no_undefined_locals.py b/drivers/tests/test_no_undefined_locals.py index 77cf179..b8f3ede 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,9 @@ 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_refused_write_settles.py b/drivers/tests/test_refused_write_settles.py index 5a97074..45e4104 100644 --- a/drivers/tests/test_refused_write_settles.py +++ b/drivers/tests/test_refused_write_settles.py @@ -94,7 +94,6 @@ def driver_names() -> list[str]: return sorted(p.stem for p in DRIVERS.glob("*.lua")) -@pytest.mark.holds_for_ftw_drivers @pytest.mark.parametrize("driver", driver_names()) def test_refused_write_debt_does_not_grow(driver: str) -> None: baseline = load_baseline() 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/index.yaml b/index.yaml index bd94a91..43341ad 100644 --- a/index.yaml +++ b/index.yaml @@ -1,32 +1,32 @@ version: 1 drivers: - name: "50-125k-svk-ac-slew" - version: "0.2.4" + version: "0.2.5" tier: community protocol: modbus connectivity: local ders: [battery] control: true - size_bytes: 41128 - sha256: "db00b11c9bb22e94dbed47f4fd1699e011b4fbd8cd389278365f843b07b5081c" + size_bytes: 37524 + sha256: "82ff5376ce2f698b3488cd4b4ded011d869d528b20c66b62f91ddeb2ca116838" - name: "50-125k-svk-slew" - version: "0.1.12" + version: "0.1.13" tier: community protocol: modbus connectivity: local ders: [battery] control: true - size_bytes: 38227 - sha256: "43da75072cd54b0e0165b7070e51f6ea5ff6c0ec3572c27ea8871c590755caf2" + size_bytes: 34620 + sha256: "d30ac2b50d39cc402b9226ce2022fac2a33dd5b3cd41f9b44191c99c338ce027" - name: "50-125k-svk" - version: "0.2.3" + version: "0.2.4" tier: community protocol: modbus connectivity: local ders: [battery] control: true - size_bytes: 25341 - sha256: "052e51a0d4da51f9a48bddc0ab6684474bbe7988a9cefdf768b4d669dad0e40f" + size_bytes: 22821 + sha256: "0c0061094c43fcd46a386c6787fd984459f4510644ecc2260cf29ebbf3147433" - name: "abb_meter" version: "1.0.6" tier: community @@ -55,14 +55,14 @@ drivers: size_bytes: 3412 sha256: "e3607864140b8d92fcaa175d066b8f3315c49065755f8cb1bb5b8a3f075cfaa9" - name: "acuvim" - version: "0.4.2" + version: "0.4.3" tier: community protocol: modbus connectivity: local ders: [meter] control: false - size_bytes: 18291 - sha256: "221f3733028fa0c9e82f8c83622bf8fe778454985fa06243007238a41ea0ddd0" + size_bytes: 17436 + sha256: "3c82c1286a0a21c5ed644aef39a28dac523135f8729ecf84e9e8ec2400a8438f" - name: "alfen" version: "1.0.4" tier: community @@ -91,14 +91,14 @@ drivers: size_bytes: 7203 sha256: "12de1c99cfed144f35c2e2a7f372186ef09e2a43120918a54e3622c572e5cd2e" - name: "ambibox_v2x" - version: "1.1.0" + version: "1.1.1" tier: core protocol: mqtt connectivity: local ders: [v2x_charger] control: true size_bytes: 7506 - sha256: "90a572b0f74511d42e4b55047a6e1e52f266af5f595ce31dbcf2ffd74ce4253f" + sha256: "9ad0e7989fb17b82531b47885baa94fa3c130ef25d23d647fad472cea9b8bbcb" - name: "atmoce" version: "1.1.1" tier: community @@ -136,44 +136,44 @@ drivers: size_bytes: 4749 sha256: "7ebfe010a41f380885b59976d1a4a6d27db4abec08844cd3c553d4cf754e6aa3" - name: "ctek" - version: "0.3.2" + version: "0.3.3" tier: core protocol: modbus connectivity: local setup: [device_ui] ders: [ev] control: true - size_bytes: 14921 - sha256: "a1c2cf0c7c543952214175ace3e957e2e8ecb9de760c1f73b2981d6c5bbf6c1a" + size_bytes: 14909 + sha256: "3f10cceed4de5d88ef1a8b199fbc6a30560b165d320398a3712f12133c23fbc5" - name: "ctek_hybrid" - version: "0.3.1" + version: "0.3.2" tier: core protocol: modbus connectivity: local setup: [device_ui] ders: [ev] control: true - size_bytes: 27291 - sha256: "1104d42fe705d43220b2ae282bc2a00d2b713a03b82e6d18fd1c6db17c747d02" + size_bytes: 27279 + sha256: "9cd72f29d7ecd06e5ed10ba487cd038cce1c5b9a032ca64a3a7fd6b19e03d78e" - name: "ctek_v2" - version: "0.3.2" + version: "0.3.3" tier: core protocol: modbus connectivity: local setup: [device_ui] ders: [ev] control: true - size_bytes: 12072 - sha256: "186d1e7b2009cd34c2b58a1e29fea5fbdd3b5a896722d41108b77ed6ec7fd3dd" + size_bytes: 12060 + sha256: "71674f2f9eae0d74fcc6f1d804a22b5044b45fbba33165374f51d48548efae30" - name: "deye-svk" - version: "0.2.1" + version: "0.2.2" tier: community protocol: modbus connectivity: local ders: [battery] control: true - size_bytes: 24774 - sha256: "e4b0637ddb91a4e4ee375125d166e93a86fac97a0ded64e062a7bd39f026e2a6" + size_bytes: 21949 + sha256: "ce957acebd6b98060e2736148af5fe53784109b243678e9d216becaa6e116088" - name: "deye" version: "2.1.1" tier: core @@ -193,7 +193,7 @@ drivers: size_bytes: 4054 sha256: "4a2cd1efb4a5583468ce897ebd88a000e348917295c40210e86ecf834c0ba543" - name: "easee_cloud" - version: "1.3.3" + version: "1.3.4" tier: core protocol: http connectivity: cloud @@ -201,7 +201,7 @@ drivers: ders: [ev] control: true size_bytes: 39308 - sha256: "1ae0b702197553df0075a649a161ecce57b2a3afe69322afa78edd585f112232" + sha256: "46581eb77e9d7e5fc4b0937ea2c5fa560afef749b50a42dd0353682fada87624" - name: "esphome-dsmr" version: "1.0.3" tier: community @@ -213,15 +213,15 @@ drivers: size_bytes: 16772 sha256: "f91e7c1769c239020b94aa75555dcaf830449d60d2bbbe54d09a969d2a423382" - name: "esphome_dsmr" - version: "1.0.3" + version: "1.0.4" tier: core protocol: http connectivity: local setup: [bridge] ders: [meter] - control: true - size_bytes: 19353 - sha256: "c415e507f4371c859fbf60827cc0704c5e24ad122dca1a9e5e1f190d67e852d1" + control: false + size_bytes: 16772 + sha256: "4c51414c0eaa02d5d6e1319337e0355e4ea8af5ada3390604544ea152156b400" - name: "etrel" version: "1.0.3" tier: community @@ -241,32 +241,32 @@ drivers: size_bytes: 55329 sha256: "81de3c2f78618f4a30994b0397f4b6b4b56273a33946ae2d23f75a27698bc399" - name: "ferroamp_dc2_v2x" - version: "2.1.0" + version: "2.1.1" tier: core protocol: mqtt connectivity: local ders: [v2x_charger] control: true - size_bytes: 13321 - sha256: "c9f4fa0fd78c0c28fbd730a66178d993f91d6d1c6557c1b8ecb8612ef4129262" + size_bytes: 13358 + sha256: "997fc712c5ef0336620fd5d628175c4df831e26b7826b9fdc36f56b5ca3f69d7" - name: "ferroamp_modbus" - version: "2.1.2" + version: "2.1.3" tier: core protocol: modbus connectivity: local ders: [battery, meter, pv] control: false size_bytes: 11190 - sha256: "c5b0ed1bb867b93861406740832a329454195f90f580d8f032b7777b54959996" + sha256: "bfa346c7532ab5071b08496c9c17828853834320648db71c325904e54318afcf" - name: "foxess_h3_smart" - version: "0.9.5" + version: "0.9.6" tier: community protocol: modbus connectivity: local ders: [pv, battery, meter] control: true - size_bytes: 41341 - sha256: "8d2775354a1716dee9b03aedef0b8415bafa94e60271e2eced24cecde03964aa" + size_bytes: 40705 + sha256: "c7dd1abf8069785667f5a7e419a304a0550594f89620ef1c173ff3952a9e7675" - name: "fronius" version: "2.1.2" tier: core @@ -286,14 +286,14 @@ drivers: size_bytes: 2080 sha256: "3094603577fc51aa45bb4c757a471d24e8cb77ee4f0c596bb2fd4112b22a6578" - name: "fronius_smart_meter" - version: "2.1.2" + version: "2.1.3" tier: core protocol: modbus connectivity: local ders: [meter] control: false size_bytes: 8075 - sha256: "b5bfc8258d5f030f9121392397e3247d4131241c6cff03b957bf4fcc91383d13" + sha256: "65ec75d903be602bb705b1d750f628d3e448ac0a99ddb61e5a5268f01997d072" - name: "goe" version: "1.0.3" tier: community @@ -341,7 +341,7 @@ drivers: size_bytes: 2193 sha256: "517e086af899dc511c86b00460cd8ce052a8d4973edee8b018e5824d4b550d6e" - name: "heishamon" - version: "0.5.0" + version: "0.5.1" tier: core protocol: mqtt connectivity: local @@ -349,7 +349,7 @@ drivers: ders: [heatpump] control: true size_bytes: 6973 - sha256: "01a8bc73689715531cfc0f21963c045b2144fa54715690949b44a578ee821a88" + sha256: "a20d17ccc147772df3ad6827f0243436dcbb28b307e1dfc034cd6ff658a9ba69" - name: "hello" version: "1.1.2" tier: community @@ -360,14 +360,14 @@ drivers: size_bytes: 1362 sha256: "4f273fa6aaff3bb462c3ac141f1773a2b6985421633d002578ce647726aa0ba9" - name: "huawei" - version: "2.1.2" + version: "2.1.3" tier: core protocol: modbus connectivity: local ders: [battery, meter, pv] control: false - size_bytes: 13084 - sha256: "050ae3f17acd59e1d3b39aeb0d0246ebe55e75e9e0207f4e7029edfc821152f9" + size_bytes: 13076 + sha256: "b3fe86dfa0cc68f1655ddd4845f2c7f9a12a50de4c9b3b2aa0f606ab07e5548b" - name: "janitza" version: "1.0.5" tier: community @@ -387,14 +387,14 @@ drivers: size_bytes: 4620 sha256: "8c8f2685dbd9a67b422c134c9d1afc53d073233f0b2c10cca75c8b30fefaad91" - name: "konja-261-svk" - version: "0.3.1" + version: "0.3.2" tier: community protocol: modbus connectivity: local ders: [battery, meter] control: true - size_bytes: 93318 - sha256: "dcd2eccbccc9e97f24c63f6a65a3498b8441f249ae9a17b5268a97bd8b34bdb5" + size_bytes: 88536 + sha256: "be06598829fa88048c4d0ede0703ca7e5e53376341cdcdbdb47062604ce470db" - name: "kostal" version: "2.1.2" tier: community @@ -433,7 +433,7 @@ drivers: size_bytes: 17549 sha256: "0af1e129899c4c014b2e825d3efe13887dbc7f041cde6dbcb5496cc712e67bf9" - name: "nibe_local" - version: "1.2.0" + version: "1.2.1" tier: core protocol: http connectivity: local @@ -441,7 +441,7 @@ drivers: ders: [heatpump] control: true size_bytes: 35431 - sha256: "f4bc18ddbf187cdbd84202d81b1417b141e4cc071643c2362f87e03bf1fb60c9" + sha256: "d5a2acc14db177f8d94f3b4f03ea40e47af9be726933bc9356f375a8e1baaa11" - name: "opendtu" version: "1.0.2" tier: community @@ -517,14 +517,14 @@ drivers: size_bytes: 27846 sha256: "2b9c691f7171794e39dd28e927a9d09ac706e507cd646b8f1ca2bf76c7d892ea" - name: "pixii_pv" - version: "0.3.1" + version: "0.3.2" tier: core protocol: mqtt connectivity: local ders: [pv, meter] control: false size_bytes: 6699 - sha256: "4f86b5c75ad1af8ed7bb48d7fa0f50db8ade8397b9a04c9ea1a8347f0bafff3d" + sha256: "7a27b937bdc845dff8d4032cb96ccca16242cc8378d1cbf5cfc8030660b871db" - name: "saj" version: "1.2.0" tier: community @@ -554,14 +554,14 @@ drivers: size_bytes: 3554 sha256: "652db1569e74d8a451ddfcefb4f55d897e144b9a955608d20b4278ed6b9a2fdd" - name: "sdm630" - version: "2.0.1" + version: "2.0.2" tier: core protocol: modbus connectivity: local ders: [meter] control: false - size_bytes: 5488 - sha256: "27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54" + size_bytes: 5024 + sha256: "d879dd48e0ab6bf42547745659798473267e52bdaa84c93b94b5a6dc9969ac4c" - name: "shelly" version: "1.1.2" tier: community @@ -636,23 +636,23 @@ drivers: size_bytes: 21555 sha256: "94b5f18fb42c1413540d7321bca47ed755c8a0a9b069f8d51dbac4384910c496" - name: "solaredge_legacy" - version: "0.3.2" + version: "0.3.3" tier: core protocol: modbus connectivity: local ders: [pv] control: true size_bytes: 18246 - sha256: "7a590f5f35efc395754f002f0d48aef8b2d46cab525bf2ae863f4fd73b01781b" + sha256: "c3a06fad328d6135abd6fdaf7c08fed0ba7e4905f18c0f2f2a624fda12750c06" - name: "solaredge_pv" - version: "1.2.1" + version: "1.2.2" tier: core protocol: modbus connectivity: local ders: [pv] control: true size_bytes: 12121 - sha256: "8ea6381cdc608939919b8ff55c22c1febe8a8da5a0b81f93cbd341a7d1733467" + sha256: "50d0d07dd92d14f93d3a4ba123fa7e036762437eee450e2caea6556d3b770582" - name: "solax" version: "1.1.1" tier: community @@ -681,14 +681,14 @@ drivers: size_bytes: 17463 sha256: "ed089b7b41a5d15bf6144aea9f625039f3a14b82e44b41a8db40d2795dc69eb8" - name: "solis_string" - version: "1.1.2" + version: "1.1.3" tier: core protocol: modbus connectivity: local ders: [pv] control: false size_bytes: 5807 - sha256: "be7c491e93dcbdf6271428c6c9268f99da6efba7903ae0356c585b0e3c5c9992" + sha256: "5669996a562d10fc94ed89748664542ce14e5d965ff0df85d1e7610ff4dda568" - name: "sonnen" version: "2.0.3" tier: community @@ -700,16 +700,16 @@ drivers: size_bytes: 6631 sha256: "94ae5e0065f5793051acb89b7ef04a679b2898908dfac6f4ffd6e8deb77fa2c0" - name: "sungrow" - version: "1.5.8" + version: "1.5.9" tier: core protocol: modbus connectivity: local ders: [battery, meter, pv] control: true - size_bytes: 48889 - sha256: "a11ddc8de433c527a3d63cd014d243b09f3393ee3afa9b8e1594d4de8de37562" + size_bytes: 48885 + sha256: "9847c4281d4387b5d4c6a2e1d91c592ca2cbf32c06b872be2d68d46fe307f823" - name: "tesla_vehicle" - version: "0.2.2" + version: "0.2.3" tier: core protocol: http connectivity: local @@ -717,16 +717,16 @@ drivers: ders: [vehicle] control: true size_bytes: 23888 - sha256: "b05cbd358a51409225b7043d22336b01cd85c2c005da6d64cc57368a34713aec" + sha256: "480c23e8ec13e075254dded23b4a9e3b93bb91b02ac63d29bd126b672a569bdd" - name: "tesla_wall_connector" - version: "0.1.0" + version: "0.1.1" tier: community protocol: http connectivity: local ders: [ev] control: false size_bytes: 8162 - sha256: "7715ce1ec3cf707fbcfc5ae58430430ffa79f7f82b38b242df9631ea1593af79" + sha256: "66b6313eda4f68a92a7cc55931baa2408cb6cbb249145f7e6005456fb821d284" - name: "tibber" version: "1.1.2" tier: core @@ -774,14 +774,14 @@ drivers: size_bytes: 2842 sha256: "1c0362e0a25f1fe297de838a85089eb7676b25e78a7d6d1d03ccea1258b94403" - name: "zap" - version: "3.1.0" + version: "3.1.1" tier: core protocol: http connectivity: local ders: [meter, pv, battery] control: false - size_bytes: 26921 - sha256: "d89a99205f99aa04bc838f89e15a6aa979a7ab106f76c06cef9094a6abee9f17" + size_bytes: 26911 + sha256: "dbbef0e614162219c3774292c647f944d0210c752688165afb9a491244700c19" - name: "zaptec" version: "1.0.1" tier: community @@ -792,7 +792,7 @@ drivers: size_bytes: 2281 sha256: "78d66d1af8f8169a7fa3adaa914f27475907930dc733ea58757d7b394e5a9802" - name: "zaptec_cloud" - version: "0.1.0" + version: "0.1.1" tier: community protocol: http connectivity: cloud @@ -800,9 +800,9 @@ drivers: ders: [ev] control: false size_bytes: 11275 - sha256: "b6fcdd70b15749caa13a77c4993568effcb199e05f913be0fd667ee9ad2ef134" + sha256: "5ef87b886b0cfd9952e00f357e81b9cd9196f041ad7b17f321f7219e3f292998" - name: "zuidwijk_p1" - version: "1.1.1" + version: "1.1.2" tier: core protocol: modbus connectivity: local @@ -810,4 +810,4 @@ drivers: ders: [meter] control: false size_bytes: 16578 - sha256: "a08af3da055a64f082293389d6a1d8a13ca784b4a497bab3ca20c2f70361ec4e" + sha256: "811b2efc31aee3a3e5b5bdffef9048c2eed7bdf9746672147a8b2535667610ee" diff --git a/manifests/50-125k-svk-ac-slew.yaml b/manifests/50-125k-svk-ac-slew.yaml index b0ff0b9..fb93b7d 100644 --- a/manifests/50-125k-svk-ac-slew.yaml +++ b/manifests/50-125k-svk-ac-slew.yaml @@ -1,5 +1,5 @@ name: "50-125k-svk-ac-slew" -version: "0.2.4" +version: "0.2.5" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -15,9 +15,9 @@ tested_devices: notes: "AC-stage variant (44284/85) of 50-125k-svk-slew; PV shares the AC stage." min_driver_version: "0.2.3" min_host_version: "2.0.0" -size_bytes: 41128 +size_bytes: 37524 dkb_id: "50-125k-svk-ac-slew" -sha256: "db00b11c9bb22e94dbed47f4fd1699e011b4fbd8cd389278365f843b07b5081c" +sha256: "82ff5376ce2f698b3488cd4b4ded011d869d528b20c66b62f91ddeb2ca116838" signature: "" bytecode_sha256: "" diff --git a/manifests/50-125k-svk-slew.yaml b/manifests/50-125k-svk-slew.yaml index 60524f4..239cb75 100644 --- a/manifests/50-125k-svk-slew.yaml +++ b/manifests/50-125k-svk-slew.yaml @@ -1,5 +1,5 @@ name: "50-125k-svk-slew" -version: "0.1.12" +version: "0.1.13" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -15,9 +15,9 @@ tested_devices: notes: "Solis 10 kW / 50 kWh BESS test site — FFR + FCR-D validated under Blixt L1." min_driver_version: "0.1.11" min_host_version: "2.0.0" -size_bytes: 38227 +size_bytes: 34620 dkb_id: "50-125k-svk-slew" -sha256: "43da75072cd54b0e0165b7070e51f6ea5ff6c0ec3572c27ea8871c590755caf2" +sha256: "d30ac2b50d39cc402b9226ce2022fac2a33dd5b3cd41f9b44191c99c338ce027" signature: "" bytecode_sha256: "" diff --git a/manifests/50-125k-svk.yaml b/manifests/50-125k-svk.yaml index 741eef5..58b7fdc 100644 --- a/manifests/50-125k-svk.yaml +++ b/manifests/50-125k-svk.yaml @@ -1,5 +1,5 @@ name: "50-125k-svk" -version: "0.2.3" +version: "0.2.4" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -15,9 +15,9 @@ tested_devices: notes: "Base SvK driver; slew variants derive from it." min_driver_version: "0.2.2" min_host_version: "2.0.0" -size_bytes: 25341 +size_bytes: 22821 dkb_id: "50-125k-svk" -sha256: "052e51a0d4da51f9a48bddc0ab6684474bbe7988a9cefdf768b4d669dad0e40f" +sha256: "0c0061094c43fcd46a386c6787fd984459f4510644ecc2260cf29ebbf3147433" signature: "" bytecode_sha256: "" diff --git a/manifests/acuvim.yaml b/manifests/acuvim.yaml index ca54344..a4a3a11 100644 --- a/manifests/acuvim.yaml +++ b/manifests/acuvim.yaml @@ -1,5 +1,5 @@ name: "acuvim" -version: "0.4.2" +version: "0.4.3" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -24,9 +24,9 @@ upstream_docs: kind: register_map url_stability: volatile min_host_version: "2.0.0" -size_bytes: 18291 +size_bytes: 17436 dkb_id: "acuvim" -sha256: "221f3733028fa0c9e82f8c83622bf8fe778454985fa06243007238a41ea0ddd0" +sha256: "3c82c1286a0a21c5ed644aef39a28dac523135f8729ecf84e9e8ec2400a8438f" signature: "" bytecode_sha256: "" diff --git a/manifests/ambibox_v2x.yaml b/manifests/ambibox_v2x.yaml index a426ab3..4dec6a4 100644 --- a/manifests/ambibox_v2x.yaml +++ b/manifests/ambibox_v2x.yaml @@ -1,5 +1,5 @@ name: "ambibox_v2x" -version: "1.1.0" +version: "1.1.1" tier: core author: "Sourceful Labs AB" protocol: mqtt @@ -17,7 +17,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 7506 dkb_id: "ambibox_v2x" -sha256: "90a572b0f74511d42e4b55047a6e1e52f266af5f595ce31dbcf2ffd74ce4253f" +sha256: "9ad0e7989fb17b82531b47885baa94fa3c130ef25d23d647fad472cea9b8bbcb" signature: "" bytecode_sha256: "" diff --git a/manifests/ctek.yaml b/manifests/ctek.yaml index cd42827..944aa63 100644 --- a/manifests/ctek.yaml +++ b/manifests/ctek.yaml @@ -1,5 +1,5 @@ name: "ctek" -version: "0.3.2" +version: "0.3.3" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -16,9 +16,9 @@ tested_devices: notes: "CTEK Chargestorm Connected 2/3 via Modbus/TCP Automation API v1 (CSOS ≥ 4.9.3). Full telemetry + current-limit control." min_driver_version: "0.2.0" min_host_version: "2.0.0" -size_bytes: 14921 +size_bytes: 14909 dkb_id: "ctek" -sha256: "a1c2cf0c7c543952214175ace3e957e2e8ecb9de760c1f73b2981d6c5bbf6c1a" +sha256: "3f10cceed4de5d88ef1a8b199fbc6a30560b165d320398a3712f12133c23fbc5" signature: "" bytecode_sha256: "" diff --git a/manifests/ctek_hybrid.yaml b/manifests/ctek_hybrid.yaml index f5c97e9..761784c 100644 --- a/manifests/ctek_hybrid.yaml +++ b/manifests/ctek_hybrid.yaml @@ -1,5 +1,5 @@ name: "ctek_hybrid" -version: "0.3.1" +version: "0.3.2" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -16,9 +16,9 @@ tested_devices: notes: "CTEK Chargestorm Connected 2/3 — MQTT for state + live telemetry (preferred), Modbus/TCP for control + telemetry fallback." min_driver_version: "0.2.0" min_host_version: "2.0.0" -size_bytes: 27291 +size_bytes: 27279 dkb_id: "ctek_hybrid" -sha256: "1104d42fe705d43220b2ae282bc2a00d2b713a03b82e6d18fd1c6db17c747d02" +sha256: "9cd72f29d7ecd06e5ed10ba487cd038cce1c5b9a032ca64a3a7fd6b19e03d78e" signature: "" bytecode_sha256: "" diff --git a/manifests/ctek_v2.yaml b/manifests/ctek_v2.yaml index 13fe3e6..74ef7e1 100644 --- a/manifests/ctek_v2.yaml +++ b/manifests/ctek_v2.yaml @@ -1,5 +1,5 @@ name: "ctek_v2" -version: "0.3.2" +version: "0.3.3" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -16,9 +16,9 @@ tested_devices: notes: "CTEK Chargestorm Connected 2/3 via Modbus/TCP Automation API v2 (CSOS ≥ 4.9.3). Full telemetry + current-limit control." min_driver_version: "0.2.0" min_host_version: "2.0.0" -size_bytes: 12072 +size_bytes: 12060 dkb_id: "ctek_v2" -sha256: "186d1e7b2009cd34c2b58a1e29fea5fbdd3b5a896722d41108b77ed6ec7fd3dd" +sha256: "71674f2f9eae0d74fcc6f1d804a22b5044b45fbba33165374f51d48548efae30" signature: "" bytecode_sha256: "" diff --git a/manifests/deye-svk.yaml b/manifests/deye-svk.yaml index 494149b..ff8c7d6 100644 --- a/manifests/deye-svk.yaml +++ b/manifests/deye-svk.yaml @@ -1,5 +1,5 @@ name: "deye-svk" -version: "0.2.1" +version: "0.2.2" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -15,9 +15,9 @@ tested_devices: notes: "Lean SvK sibling of deye; validated on Deye SvK bench under Blixt L1." min_driver_version: "0.2.0" min_host_version: "2.0.0" -size_bytes: 24774 +size_bytes: 21949 dkb_id: "deye-svk" -sha256: "e4b0637ddb91a4e4ee375125d166e93a86fac97a0ded64e062a7bd39f026e2a6" +sha256: "ce957acebd6b98060e2736148af5fe53784109b243678e9d216becaa6e116088" signature: "" bytecode_sha256: "" diff --git a/manifests/easee_cloud.yaml b/manifests/easee_cloud.yaml index 7a8ab9f..6d6a0ba 100644 --- a/manifests/easee_cloud.yaml +++ b/manifests/easee_cloud.yaml @@ -1,5 +1,5 @@ name: "easee_cloud" -version: "1.3.3" +version: "1.3.4" tier: core author: "Sourceful Labs AB" protocol: http @@ -18,7 +18,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 39308 dkb_id: "easee_cloud" -sha256: "1ae0b702197553df0075a649a161ecce57b2a3afe69322afa78edd585f112232" +sha256: "46581eb77e9d7e5fc4b0937ea2c5fa560afef749b50a42dd0353682fada87624" signature: "" bytecode_sha256: "" diff --git a/manifests/esphome_dsmr.yaml b/manifests/esphome_dsmr.yaml index a6c0ade..6e537e2 100644 --- a/manifests/esphome_dsmr.yaml +++ b/manifests/esphome_dsmr.yaml @@ -1,24 +1,24 @@ name: "esphome_dsmr" -version: "1.0.3" +version: "1.0.4" tier: core author: "Sourceful Labs AB" protocol: http connectivity: local setup: [bridge] ders: [meter] -control: true +control: false tested_devices: - manufacturer: "ESPHome" - model_family: "ESPHome DSMR (P1)" - variants: [Sourceful Zap P1 (sourceful-zap-esphome firmware), ESPHome DSMR P1 readers using name-derived object_ids (SlimmeLezer+)] - regions: [] - firmware_versions: "" - notes: "Smart meter via ESPHome web_server v3 + dsmr component (Sourceful Zap on open firmware, DIY ESP32+P1, etc.)." - min_driver_version: "1.0.2" + model_family: "DSMR web_server v3 P1 reader" + variants: [SlimmeLezer+, Sourceful Zap P1] + regions: [NL, BE, SE, DK] + firmware_versions: "DSMR 2022.10.1 on SlimmeLezer+; Sourceful Zap open firmware" + notes: "Name-derived phase object_ids live-validated on SlimmeLezer+; explicit Sourceful aliases retained." + min_driver_version: "1.0.1" min_host_version: "2.0.0" -size_bytes: 19353 +size_bytes: 16772 dkb_id: "esphome_dsmr" -sha256: "c415e507f4371c859fbf60827cc0704c5e24ad122dca1a9e5e1f190d67e852d1" +sha256: "4c51414c0eaa02d5d6e1319337e0355e4ea8af5ada3390604544ea152156b400" signature: "" bytecode_sha256: "" diff --git a/manifests/ferroamp_dc2_v2x.yaml b/manifests/ferroamp_dc2_v2x.yaml index 9161c1b..79cd597 100644 --- a/manifests/ferroamp_dc2_v2x.yaml +++ b/manifests/ferroamp_dc2_v2x.yaml @@ -1,5 +1,5 @@ name: "ferroamp_dc2_v2x" -version: "2.1.0" +version: "2.1.1" tier: core author: "Sourceful Labs AB" protocol: mqtt @@ -18,9 +18,9 @@ connection_defaults: mqtt_username: "dc2" mqtt_password: "dc2mqtt" min_host_version: "2.0.0" -size_bytes: 13321 +size_bytes: 13358 dkb_id: "ferroamp_dc2_v2x" -sha256: "c9f4fa0fd78c0c28fbd730a66178d993f91d6d1c6557c1b8ecb8612ef4129262" +sha256: "997fc712c5ef0336620fd5d628175c4df831e26b7826b9fdc36f56b5ca3f69d7" signature: "" bytecode_sha256: "" diff --git a/manifests/ferroamp_modbus.yaml b/manifests/ferroamp_modbus.yaml index 27c30d4..26652e3 100644 --- a/manifests/ferroamp_modbus.yaml +++ b/manifests/ferroamp_modbus.yaml @@ -1,5 +1,5 @@ name: "ferroamp_modbus" -version: "2.1.2" +version: "2.1.3" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -17,7 +17,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 11190 dkb_id: "ferroamp_modbus" -sha256: "c5b0ed1bb867b93861406740832a329454195f90f580d8f032b7777b54959996" +sha256: "bfa346c7532ab5071b08496c9c17828853834320648db71c325904e54318afcf" signature: "" bytecode_sha256: "" diff --git a/manifests/foxess_h3_smart.yaml b/manifests/foxess_h3_smart.yaml index 5bb6dd9..d7189b0 100644 --- a/manifests/foxess_h3_smart.yaml +++ b/manifests/foxess_h3_smart.yaml @@ -1,5 +1,5 @@ name: "foxess_h3_smart" -version: "0.9.5" +version: "0.9.6" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -31,9 +31,9 @@ upstream_docs: kind: register_map url_stability: stable min_host_version: "1.5.0" -size_bytes: 41341 +size_bytes: 40705 dkb_id: "" -sha256: "8d2775354a1716dee9b03aedef0b8415bafa94e60271e2eced24cecde03964aa" +sha256: "c7dd1abf8069785667f5a7e419a304a0550594f89620ef1c173ff3952a9e7675" signature: "" bytecode_sha256: "" bytecode_signature: "" diff --git a/manifests/fronius_smart_meter.yaml b/manifests/fronius_smart_meter.yaml index 9e5dc76..52f5390 100644 --- a/manifests/fronius_smart_meter.yaml +++ b/manifests/fronius_smart_meter.yaml @@ -1,5 +1,5 @@ name: "fronius_smart_meter" -version: "2.1.2" +version: "2.1.3" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -17,7 +17,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 8075 dkb_id: "fronius_smart_meter" -sha256: "b5bfc8258d5f030f9121392397e3247d4131241c6cff03b957bf4fcc91383d13" +sha256: "65ec75d903be602bb705b1d750f628d3e448ac0a99ddb61e5a5268f01997d072" signature: "" bytecode_sha256: "" diff --git a/manifests/heishamon.yaml b/manifests/heishamon.yaml index 3363c4f..b1ac703 100644 --- a/manifests/heishamon.yaml +++ b/manifests/heishamon.yaml @@ -1,5 +1,5 @@ name: "heishamon" -version: "0.5.0" +version: "0.5.1" tier: core author: "Sourceful Labs AB" protocol: mqtt @@ -18,7 +18,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 6973 dkb_id: "heishamon" -sha256: "01a8bc73689715531cfc0f21963c045b2144fa54715690949b44a578ee821a88" +sha256: "a20d17ccc147772df3ad6827f0243436dcbb28b307e1dfc034cd6ff658a9ba69" signature: "" bytecode_sha256: "" diff --git a/manifests/huawei.yaml b/manifests/huawei.yaml index d7b8395..f3b0f11 100644 --- a/manifests/huawei.yaml +++ b/manifests/huawei.yaml @@ -1,5 +1,5 @@ name: "huawei" -version: "2.1.2" +version: "2.1.3" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -43,9 +43,9 @@ tested_devices: notes: "C&I aggregator gateway, Modbus TCP server" min_driver_version: "1.3.0" min_host_version: "2.0.0" -size_bytes: 13084 +size_bytes: 13076 dkb_id: "huawei_sun2000" -sha256: "050ae3f17acd59e1d3b39aeb0d0246ebe55e75e9e0207f4e7029edfc821152f9" +sha256: "b3fe86dfa0cc68f1655ddd4845f2c7f9a12a50de4c9b3b2aa0f606ab07e5548b" signature: "" bytecode_sha256: "" diff --git a/manifests/konja-261-svk.yaml b/manifests/konja-261-svk.yaml index 7063e3e..95aa6a5 100644 --- a/manifests/konja-261-svk.yaml +++ b/manifests/konja-261-svk.yaml @@ -1,5 +1,5 @@ name: "konja-261-svk" -version: "0.3.1" +version: "0.3.2" tier: community author: "Sourceful Labs AB" protocol: modbus @@ -15,9 +15,9 @@ tested_devices: notes: "C&I BESS via MG500 EMS Modbus TCP :1502; SvK FCR/FFR test driver on Blixt L1." min_driver_version: "0.3.0" min_host_version: "2.0.0" -size_bytes: 93318 +size_bytes: 88536 dkb_id: "konja-261-svk" -sha256: "dcd2eccbccc9e97f24c63f6a65a3498b8441f249ae9a17b5268a97bd8b34bdb5" +sha256: "be06598829fa88048c4d0ede0703ca7e5e53376341cdcdbdb47062604ce470db" signature: "" bytecode_sha256: "" diff --git a/manifests/nibe_local.yaml b/manifests/nibe_local.yaml index b08d481..9b3a6d1 100644 --- a/manifests/nibe_local.yaml +++ b/manifests/nibe_local.yaml @@ -1,5 +1,5 @@ name: "nibe_local" -version: "1.2.0" +version: "1.2.1" tier: core author: "HuggeK with the help of Claude Code" protocol: http @@ -23,7 +23,7 @@ upstream_docs: min_host_version: "2.0.0" size_bytes: 35431 dkb_id: "nibe_local" -sha256: "f4bc18ddbf187cdbd84202d81b1417b141e4cc071643c2362f87e03bf1fb60c9" +sha256: "d5a2acc14db177f8d94f3b4f03ea40e47af9be726933bc9356f375a8e1baaa11" signature: "" bytecode_sha256: "" diff --git a/manifests/pixii_pv.yaml b/manifests/pixii_pv.yaml index 04b8232..2c26a15 100644 --- a/manifests/pixii_pv.yaml +++ b/manifests/pixii_pv.yaml @@ -1,5 +1,5 @@ name: "pixii_pv" -version: "0.3.1" +version: "0.3.2" tier: core author: "Sourceful Labs AB" protocol: mqtt @@ -17,7 +17,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 6699 dkb_id: "pixii_pv" -sha256: "4f86b5c75ad1af8ed7bb48d7fa0f50db8ade8397b9a04c9ea1a8347f0bafff3d" +sha256: "7a27b937bdc845dff8d4032cb96ccca16242cc8378d1cbf5cfc8030660b871db" signature: "" bytecode_sha256: "" diff --git a/manifests/sdm630.yaml b/manifests/sdm630.yaml index 2cf04a4..1463ed3 100644 --- a/manifests/sdm630.yaml +++ b/manifests/sdm630.yaml @@ -1,5 +1,5 @@ name: "sdm630" -version: "2.0.1" +version: "2.0.2" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -15,9 +15,9 @@ tested_devices: notes: "" min_driver_version: "1.1.1" min_host_version: "2.0.0" -size_bytes: 5488 +size_bytes: 5024 dkb_id: "sdm630" -sha256: "27d5612b891b01c98b054304a16295bd3e78982d166f731fd99372e3871eea54" +sha256: "d879dd48e0ab6bf42547745659798473267e52bdaa84c93b94b5a6dc9969ac4c" signature: "" bytecode_sha256: "" diff --git a/manifests/solaredge_legacy.yaml b/manifests/solaredge_legacy.yaml index b9b04a7..1e37421 100644 --- a/manifests/solaredge_legacy.yaml +++ b/manifests/solaredge_legacy.yaml @@ -1,5 +1,5 @@ name: "solaredge_legacy" -version: "0.3.2" +version: "0.3.3" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -17,7 +17,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 18246 dkb_id: "solaredge_legacy" -sha256: "7a590f5f35efc395754f002f0d48aef8b2d46cab525bf2ae863f4fd73b01781b" +sha256: "c3a06fad328d6135abd6fdaf7c08fed0ba7e4905f18c0f2f2a624fda12750c06" signature: "" bytecode_sha256: "" diff --git a/manifests/solaredge_pv.yaml b/manifests/solaredge_pv.yaml index 2f0f2c1..5832ae1 100644 --- a/manifests/solaredge_pv.yaml +++ b/manifests/solaredge_pv.yaml @@ -1,5 +1,5 @@ name: "solaredge_pv" -version: "1.2.1" +version: "1.2.2" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -17,7 +17,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 12121 dkb_id: "solaredge_pv" -sha256: "8ea6381cdc608939919b8ff55c22c1febe8a8da5a0b81f93cbd341a7d1733467" +sha256: "50d0d07dd92d14f93d3a4ba123fa7e036762437eee450e2caea6556d3b770582" signature: "" bytecode_sha256: "" diff --git a/manifests/solis_string.yaml b/manifests/solis_string.yaml index b3be3f3..34a426c 100644 --- a/manifests/solis_string.yaml +++ b/manifests/solis_string.yaml @@ -1,5 +1,5 @@ name: "solis_string" -version: "1.1.2" +version: "1.1.3" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -17,7 +17,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 5807 dkb_id: "solis_string" -sha256: "be7c491e93dcbdf6271428c6c9268f99da6efba7903ae0356c585b0e3c5c9992" +sha256: "5669996a562d10fc94ed89748664542ce14e5d965ff0df85d1e7610ff4dda568" signature: "" bytecode_sha256: "" diff --git a/manifests/sungrow.yaml b/manifests/sungrow.yaml index 84ac237..b68639c 100644 --- a/manifests/sungrow.yaml +++ b/manifests/sungrow.yaml @@ -1,5 +1,5 @@ name: "sungrow" -version: "1.5.8" +version: "1.5.9" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -43,9 +43,9 @@ tested_devices: notes: "Commercial/industrial three-phase string inverter. Needs 1.5.3: 1.2.1 stopped polling the SH battery registers this family does not answer, and 1.5.3 refuses a battery dispatch instead of writing them. A zero-watt command is still written on every family from 1.5.4 -- it is the release, and on a model without the registers it fails harmlessly." min_driver_version: "1.5.3" min_host_version: "2.0.0" -size_bytes: 48889 +size_bytes: 48885 dkb_id: "sungrow_sh_rt" -sha256: "a11ddc8de433c527a3d63cd014d243b09f3393ee3afa9b8e1594d4de8de37562" +sha256: "9847c4281d4387b5d4c6a2e1d91c592ca2cbf32c06b872be2d68d46fe307f823" signature: "" bytecode_sha256: "" diff --git a/manifests/tesla_vehicle.yaml b/manifests/tesla_vehicle.yaml index 3e9b5eb..128dcd1 100644 --- a/manifests/tesla_vehicle.yaml +++ b/manifests/tesla_vehicle.yaml @@ -1,5 +1,5 @@ name: "tesla_vehicle" -version: "0.2.2" +version: "0.2.3" tier: core author: "Sourceful Labs AB" protocol: http @@ -18,7 +18,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 23888 dkb_id: "tesla_vehicle" -sha256: "b05cbd358a51409225b7043d22336b01cd85c2c005da6d64cc57368a34713aec" +sha256: "480c23e8ec13e075254dded23b4a9e3b93bb91b02ac63d29bd126b672a569bdd" signature: "" bytecode_sha256: "" diff --git a/manifests/tesla_wall_connector.yaml b/manifests/tesla_wall_connector.yaml index d46e222..f037d4f 100644 --- a/manifests/tesla_wall_connector.yaml +++ b/manifests/tesla_wall_connector.yaml @@ -1,5 +1,5 @@ name: "tesla_wall_connector" -version: "0.1.0" +version: "0.1.1" tier: community author: "Sourceful Labs AB" protocol: http @@ -17,7 +17,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 8162 dkb_id: "tesla_wall_connector" -sha256: "7715ce1ec3cf707fbcfc5ae58430430ffa79f7f82b38b242df9631ea1593af79" +sha256: "66b6313eda4f68a92a7cc55931baa2408cb6cbb249145f7e6005456fb821d284" signature: "" bytecode_sha256: "" diff --git a/manifests/zap.yaml b/manifests/zap.yaml index 89ccd52..77aeb9d 100644 --- a/manifests/zap.yaml +++ b/manifests/zap.yaml @@ -1,5 +1,5 @@ name: "zap" -version: "3.1.0" +version: "3.1.1" tier: core author: "Sourceful Labs AB" protocol: http @@ -15,9 +15,9 @@ tested_devices: notes: "P1/HAN site meter from a Sourceful Zap. PV and battery telemetry from devices on Zap are opt-in (read_pv / read_battery) and read-only. Prefer a native FTW driver; use Zap as the reader only when it already owns the bus." min_driver_version: "3.1.0" min_host_version: "2.0.0" -size_bytes: 26921 +size_bytes: 26911 dkb_id: "zap" -sha256: "d89a99205f99aa04bc838f89e15a6aa979a7ab106f76c06cef9094a6abee9f17" +sha256: "dbbef0e614162219c3774292c647f944d0210c752688165afb9a491244700c19" signature: "" bytecode_sha256: "" diff --git a/manifests/zaptec_cloud.yaml b/manifests/zaptec_cloud.yaml index 2149c31..4f214d1 100644 --- a/manifests/zaptec_cloud.yaml +++ b/manifests/zaptec_cloud.yaml @@ -1,5 +1,5 @@ name: "zaptec_cloud" -version: "0.1.0" +version: "0.1.1" tier: community author: "Sourceful Labs AB" protocol: http @@ -18,7 +18,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 11275 dkb_id: "zaptec_cloud" -sha256: "b6fcdd70b15749caa13a77c4993568effcb199e05f913be0fd667ee9ad2ef134" +sha256: "5ef87b886b0cfd9952e00f357e81b9cd9196f041ad7b17f321f7219e3f292998" signature: "" bytecode_sha256: "" diff --git a/manifests/zuidwijk_p1.yaml b/manifests/zuidwijk_p1.yaml index 744aa59..5112a41 100644 --- a/manifests/zuidwijk_p1.yaml +++ b/manifests/zuidwijk_p1.yaml @@ -1,5 +1,5 @@ name: "zuidwijk_p1" -version: "1.1.1" +version: "1.1.2" tier: core author: "Sourceful Labs AB" protocol: modbus @@ -18,7 +18,7 @@ tested_devices: min_host_version: "2.0.0" size_bytes: 16578 dkb_id: "zuidwijk_p1" -sha256: "a08af3da055a64f082293389d6a1d8a13ca784b4a497bab3ca20c2f70361ec4e" +sha256: "811b2efc31aee3a3e5b5bdffef9048c2eed7bdf9746672147a8b2535667610ee" signature: "" bytecode_sha256: "" 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..09fa830 100644 --- a/support-status.json +++ b/support-status.json @@ -2,84 +2,63 @@ "drivers": [ { "catalog_source": true, - "catalog_version": "0.2.4", + "catalog_version": "0.2.5", "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" } } }, { "catalog_source": true, - "catalog_version": "0.1.12", + "catalog_version": "0.1.13", "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" } } }, { "catalog_source": true, - "catalog_version": "0.2.3", + "catalog_version": "0.2.4", "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,54 +109,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "0.4.2", + "catalog_version": "0.4.3", "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,54 +193,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "1.1.0", + "catalog_version": "1.1.1", "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,138 +298,103 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "0.3.2", + "catalog_version": "0.3.3", "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" } } }, { "catalog_source": true, - "catalog_version": "0.3.1", + "catalog_version": "0.3.2", "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" } } }, { "catalog_source": true, - "catalog_version": "0.3.2", + "catalog_version": "0.3.3", "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" } } }, { "catalog_source": true, - "catalog_version": "0.2.1", + "catalog_version": "0.2.2", "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,54 +424,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "1.3.3", + "catalog_version": "1.3.4", "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,54 +466,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "1.0.3", + "catalog_version": "1.0.4", "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,110 +529,82 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "2.1.0", + "catalog_version": "2.1.1", "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" } } }, { "catalog_source": true, - "catalog_version": "2.1.2", + "catalog_version": "2.1.3", "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" } } }, { "catalog_source": true, - "catalog_version": "0.9.5", + "catalog_version": "0.9.6", "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,54 +634,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "2.1.2", + "catalog_version": "2.1.3", "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,54 +760,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "0.5.0", + "catalog_version": "0.5.1", "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,54 +802,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "2.1.2", + "catalog_version": "2.1.3", "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,54 +865,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "0.3.1", + "catalog_version": "0.3.2", "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,54 +970,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "1.2.0", + "catalog_version": "1.2.1", "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,54 +1159,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "0.3.1", + "catalog_version": "0.3.2", "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,55 +1243,41 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "2.0.1", + "catalog_version": "2.0.2", "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,82 +1432,61 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "0.3.2", + "catalog_version": "0.3.3", "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" } } }, { "catalog_source": true, - "catalog_version": "1.2.1", + "catalog_version": "1.2.2", "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,54 +1537,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "1.1.2", + "catalog_version": "1.1.3", "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,110 +1579,82 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "1.5.8", + "catalog_version": "1.5.9", "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" } } }, { "catalog_source": true, - "catalog_version": "0.2.2", + "catalog_version": "0.2.3", "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" } } }, { "catalog_source": true, - "catalog_version": "0.1.0", + "catalog_version": "0.1.1", "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,54 +1747,40 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "3.1.0", + "catalog_version": "3.1.1", "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,82 +1789,61 @@ "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" } } }, { "catalog_source": true, - "catalog_version": "0.1.0", + "catalog_version": "0.1.1", "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" } } }, { "catalog_source": true, - "catalog_version": "1.1.1", + "catalog_version": "1.1.2", "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_driver_truth.py b/tests/test_driver_truth.py index 4523388..64753f5 100644 --- a/tests/test_driver_truth.py +++ b/tests/test_driver_truth.py @@ -64,6 +64,28 @@ def test_manifest_hash_and_size_match_the_source(driver_id: str) -> None: f"drivers/lua/{driver_id}.lua. Run: make sync-manifests") +def lua_driver_id(text: str) -> str | None: + start = text.find("DRIVER") + if start == -1: + return None + end = text.find("\n}", start) + if end == -1: + return None + match = re.search(r'\bid\s*=\s*"([^"]+)"', text[start:end]) + return match.group(1) if match else None + + +@pytest.mark.parametrize("driver_id", DRIVER_IDS) +def test_manifest_and_driver_table_agree_on_the_id(driver_id: str) -> None: + # FTW compares the id its bundled copy declares with the signed channel's + # to decide which file runs, so one driver has one id, spelled once. + lua_id = lua_driver_id((LUA_DIR / f"{driver_id}.lua").read_text(encoding="utf-8")) + if lua_id is None: + pytest.skip(f"{driver_id} has no id in its DRIVER table") + assert lua_id == driver_id, ( + f"{driver_id}: the DRIVER table says id {lua_id!r}; it must be {driver_id!r}") + + @pytest.mark.parametrize("driver_id", DRIVER_IDS) def test_manifest_and_driver_table_agree_on_the_version(driver_id: str) -> None: manifest_version = manifest_field( @@ -74,14 +96,6 @@ def test_manifest_and_driver_table_agree_on_the_version(driver_id: str) -> None: if lua_version is None: pytest.skip(f"{driver_id} has no version in its DRIVER table") - baseline = ROOT / "baselines" / "ftw" / "drivers" / f"{driver_id}.lua" - if baseline.exists() and baseline.read_bytes() == (LUA_DIR / f"{driver_id}.lua").read_bytes(): - pytest.skip( - f"{driver_id} is FTW's driver verbatim. Its DRIVER table counts " - f"FTW's releases; the manifest counts this repository's. Making " - f"them agree would mean editing a file kept byte-identical to its " - f"baseline, which is what makes provenance checkable.") - assert manifest_version == lua_version, ( f"{driver_id}: manifest says {manifest_version}, the DRIVER table says " f"{lua_version}. Run: make bump-driver ID={driver_id}") 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_ftw_repository.py b/tests/test_ftw_repository.py index 1ddaeaa..2ddf42b 100644 --- a/tests/test_ftw_repository.py +++ b/tests/test_ftw_repository.py @@ -206,7 +206,7 @@ def test_esphome_dsmr_artifact_declares_one_ftw_host_api_range( tmp_path: Path, keypair: tuple[str, str] ) -> None: manifest, output = build(tmp_path, keypair) - driver = next(driver for driver in manifest["drivers"] if driver["id"] == "esphome-dsmr") + driver = next(driver for driver in manifest["drivers"] if driver["id"] == "esphome_dsmr") artifact = output / Path(driver["url"]).name source = artifact.read_text(encoding="utf-8") @@ -503,12 +503,17 @@ def test_check_versions_accepts_a_changed_driver_that_took_a_version( ).group(1) previous_path = publish_once(tmp_path, keypair, repo, config_path) - source_path.write_text(source_path.read_text() + "\n-- source changed\n") major, minor, patch = (int(part) for part in published_version.split(".")) + bumped = f"{major}.{minor}.{patch + 1}" + # One version per driver: the DRIVER table moves with the manifest. + source_path.write_text( + source_path.read_text().replace(f'"{published_version}"', f'"{bumped}"', 1) + + "\n-- source changed\n" + ) manifest_path.write_text( re.sub( r'^version:\s*"[^"]+"', - f'version: "{major}.{minor}.{patch + 1}"', + f'version: "{bumped}"', manifest_path.read_text(), count=1, flags=re.M, 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..4385fd5 100644 --- a/tools/bump_driver.py +++ b/tools/bump_driver.py @@ -58,21 +58,6 @@ def write_manifest_version(path: Path, version: str) -> None: path.write_text(text, encoding="utf-8") -def is_ftw_baseline(path: Path) -> bool: - """Whether this driver is FTW's, byte for byte. - - A promoted driver is kept identical to baselines/ftw so its provenance - stays checkable and any real edit is visible. Rewriting a version inside - it would spend that guarantee on a field the tooling already accepts as - differing: the manifest counts this repository's releases, the DRIVER table - counts FTW's. - """ - baseline = ROOT / "baselines" / "ftw" / "drivers" / path.name - if not baseline.exists(): - return False - return baseline.read_bytes() == path.read_bytes() - - def write_lua_version(path: Path, version: str) -> bool: """Rewrite the version inside the DRIVER table, not anywhere else. @@ -80,15 +65,11 @@ def write_lua_version(path: Path, version: str) -> bool: manifest alone. That is fine; there is simply nothing to keep in step. Returns whether the file was changed. """ - if is_ftw_baseline(path): - return False - text = path.read_text(encoding="utf-8") 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 +88,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 +118,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/ftw_repository.py b/tools/ftw_repository.py index 75bdc24..f9dbe9b 100644 --- a/tools/ftw_repository.py +++ b/tools/ftw_repository.py @@ -63,29 +63,6 @@ -def _identifies_same_driver(metadata_id: str, driver_id: str) -> bool: - """Whether a DRIVER table's id names the catalog driver holding it. - - FTW spells ids with hyphens and is usually more specific than the catalog - name, sometimes by inserting a word rather than appending one: - - ctek <- ctek-chargestorm - ctek_hybrid <- ctek-chargestorm-hybrid - sungrow <- sungrow-shx - huawei <- huawei-sun2000 - zap <- sourceful-zap - - So require the catalog name's words to appear in the declared id in order, - which all of those satisfy while a driver dropped into the wrong file -- - "deye" holding a growatt table -- still does not. - """ - def words(value: str) -> list[str]: - return value.replace("-", "_").split("_") - - remaining = iter(words(metadata_id)) - return all(word in remaining for word in words(driver_id)) - - class RepositoryError(ValueError): """A driver channel build or verification error.""" @@ -635,22 +612,18 @@ def _load_channel(config_path: Path, repo_root: Path) -> list[dict[str, Any]]: if body is not None: metadata_id = _string_field(body, "id") metadata_version = _string_field(body, "version") - # A driver promoted from FTW keeps its own DRIVER table verbatim, so - # that it stays byte-identical to the baseline it came from and any - # later edit shows up. That table states FTW's identity, which need - # not be this catalog's: - # - # id FTW is more specific -- "sungrow-shx" for what this - # catalog publishes as "sungrow". The catalog name is the - # one operators use, so it wins; a name that is neither - # the catalog id nor a prefix of the declared one is still - # an error. - # version two lineages. The manifest counts releases from this - # repository; DRIVER.version counts FTW's. Forcing them - # equal would mean rewriting a field inside a file we - # deliberately keep unmodified. - if metadata_id and not _identifies_same_driver(metadata_id, driver_id): - raise RepositoryError(f"{driver_id}: DRIVER id is {metadata_id!r}") + # One driver has one id and one version. The DRIVER table, the + # catalog, the signed channel and FTW's bundled copy all state + # them, and FTW compares them to decide which file runs, so they + # must be the same text, not the same driver by some rule. + if metadata_id and metadata_id != driver_id: + raise RepositoryError( + f"{driver_id}: DRIVER id is {metadata_id!r}; it must be {driver_id!r}" + ) + if metadata_version and metadata_version != version: + raise RepositoryError( + f"{driver_id}: DRIVER version is {metadata_version!r}; the catalog says {version!r}" + ) filename = source_path.name logical_path = f"drivers/{filename}" 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" }, -]