diff --git a/.changeset/one-id-drivers.md b/.changeset/one-id-drivers.md new file mode 100644 index 000000000..334726c0b --- /dev/null +++ b/.changeset/one-id-drivers.md @@ -0,0 +1,11 @@ +--- +"ftw": patch +--- + +Every bundled driver now declares the same id and version as the signed driver +channel, for example `sungrow` instead of `sungrow-shx` and `easee_cloud` instead +of `easee-cloud`. Choosing a version under Settings → Devices now works for these +drivers, "Use bundled" finds the release's copy, and installing a channel version +reaches the running device without a restart. The driver inventory sent to +Sourceful reports these ids. The bundled ESPHome DSMR driver is its real source +again; its published file used to report a wrong id. diff --git a/AGENTS.md b/AGENTS.md index 30b63dc84..06f850cbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,9 +174,9 @@ drivers pinned in [`drivers/BUNDLED_SOURCE.json`](drivers/BUNDLED_SOURCE.json), and `ftw update` and `ftw rollback` move them with Core. A driver fix is a pin bump in the next beta. The signed driver channel serves installs that cannot take a new Core and lets an owner pick another signed version of one driver. -Early access ends when a release catches up; an older version chosen on -purpose stays until the owner changes it. Proposed: `drivers-stable` carries the drivers of the -latest stable Core and is promoted with it. +A newer driver installed early runs until a release brings a newer one; an +older version chosen on purpose stays until the owner changes it, and an +update or rollback never loses the owner's selection. Native 0.x path: @@ -188,6 +188,11 @@ Native 0.x path: 4. Dispatch the same workflow for `v0.X.Y` stable, naming the tested beta. The workflow checks the source commit and release assets, and keeps GitHub `releases/latest` on the old 2.x line. +5. Promote the signed driver channel for installs that read `drivers-stable`: + `gh workflow run ftw-drivers-release.yml -R srcfl/device-drivers --ref main -f channel=stable`. + It promotes the driver commit already published as `drivers-beta`. If it + refuses because `main` has moved past `drivers-beta`, publish beta first + (`-f channel=beta`), then promote. Do not publish routine Docker releases. Existing 1.x, 2.x and 3.x installs remain on their current version until their owner uses the guided installer diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 0c2067dfb..7daa8bcba 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -16,7 +16,7 @@ "from the signed channel. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "489c9373be1fb391d885b948bf736399b3e40f2e", + "commit": "43710c841f4715bdbc6070bea38c89fc60fd830a", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/go/internal/api/api_device_repository.go b/go/internal/api/api_device_repository.go index 3b3f7f330..c23d8b9cc 100644 --- a/go/internal/api/api_device_repository.go +++ b/go/internal/api/api_device_repository.go @@ -11,7 +11,6 @@ import ( "time" "github.com/srcfl/ftw/go/internal/config" - "github.com/srcfl/ftw/go/internal/driverrepo" "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/state" ) @@ -410,31 +409,21 @@ func (r managedDriverRestartState) response(status string, artifact any) map[str } // sameDriverFile reports whether a driver file, declaring id, holds the -// driver of a signed artifact. An equal id is enough. The bundled sources -// spell ids their own way (easee-cloud for the channel's easee_cloud), so a -// file at the path the signed manifest names also counts when its id passes -// the publisher's identity rule. The rule alone is too loose: ctek-chargestorm- -// hybrid contains "ctek". -func sameDriverFile(path, id string, artifact state.DriverRepoInstall) bool { - if artifact.DriverID == "" || id == "" { - return false - } - if id == artifact.DriverID { - return true - } - return filepath.Base(path) == filepath.Base(artifact.LogicalPath) && - driverrepo.IdentifiesSameDriver(id, artifact.DriverID) +// driver of a signed artifact. A driver has one id everywhere: its source, +// the signed channel and the copy bundled with this release. +func sameDriverFile(id string, artifact state.DriverRepoInstall) bool { + return artifact.DriverID != "" && id == artifact.DriverID } // bundledDriverFor finds this release's copy of a managed driver: first the // file at the logical path the signed manifest names, then any bundled file -// declaring exactly the same id. +// declaring the same id. func (s *Server) bundledDriverFor(id, logicalPath string) (string, error) { rel := strings.TrimPrefix(filepath.ToSlash(filepath.Clean(logicalPath)), "drivers/") if logicalPath != "" && rel != "" && !strings.Contains(rel, "..") && !filepath.IsAbs(rel) { candidate := filepath.Join(s.deps.DriverDir, filepath.FromSlash(rel)) if entry, err := drivers.ParseCatalogFile(candidate); err == nil && - sameDriverFile(candidate, entry.ID, state.DriverRepoInstall{DriverID: id, LogicalPath: logicalPath}) { + sameDriverFile(entry.ID, state.DriverRepoInstall{DriverID: id}) { return candidate, nil } } @@ -507,7 +496,7 @@ func (s *Server) restartManagedDriversExpected(ctx context.Context, artifact sta // record still binds the path to the ID; all other files need metadata. if filepath.Clean(current.Lua) != filepath.Clean(activePath) { entry, err := drivers.ParseCatalogFile(current.Lua) - if err != nil || !sameDriverFile(current.Lua, entry.ID, artifact) { + if err != nil || !sameDriverFile(entry.ID, artifact) { continue } } diff --git a/go/internal/api/api_device_repository_test.go b/go/internal/api/api_device_repository_test.go index 88232aed3..c20a7f8cd 100644 --- a/go/internal/api/api_device_repository_test.go +++ b/go/internal/api/api_device_repository_test.go @@ -492,9 +492,8 @@ func TestDriverCatalogNamesTheDriversThatRunEachFile(t *testing.T) { } } -// publishAs publishes the fixture driver under the channel's own spelling, -// as device-drivers does: the bundled source declares id "esphome-dsmr", -// the signed channel calls the same file "esphome_dsmr". +// publishAs publishes the fixture driver on the channel under id, at +// drivers/. func (f *driverUpdateFixture) publishAs(id, filename, version string) { f.t.Helper() source := []byte(strings.Replace(string(updateDriverLua(version, "P1-123", `host.emit("meter", {w=103})`)), @@ -528,13 +527,17 @@ func (f *driverUpdateFixture) requestFor(id, action, body string, want int) map[ return response } -// On the home box "Use bundled" answered "no bundled file declares driver -// easee_cloud": the release's easee_cloud.lua declares "easee-cloud". The -// same spelling kept an install from reaching the running instance until -// Core restarted. The signed manifest names the file, and the publisher's -// identity rule ties the two spellings together. -func TestChannelAndBundledSpellingsOfOneDriverMeet(t *testing.T) { +// A driver has one id: the release's copy and the signed channel's file at +// the same path declare it alike. An install reaches the running instance, +// and "Use bundled" finds the release's copy again. On the home box the old +// spellings (easee-cloud, easee_cloud) broke both. +func TestReleaseCopyAndChannelMeetByTheirOneID(t *testing.T) { f := newDriverUpdateFixture(t, "running") + release := strings.Replace(string(updateDriverLua("1.0.2", "P1-123", `host.emit("meter", {w=102})`)), + `id = "esphome-dsmr"`, `id = "esphome_dsmr"`, 1) + if err := os.WriteFile(f.bundled, []byte(release), 0o644); err != nil { + t.Fatal(err) + } f.publishAs("esphome_dsmr", "esphome_dsmr.lua", "1.0.3") response := f.requestFor("esphome_dsmr", "install", `{"repository_id":"test"}`, 200) diff --git a/go/internal/driverrepo/identity.go b/go/internal/driverrepo/identity.go deleted file mode 100644 index 0021ace17..000000000 --- a/go/internal/driverrepo/identity.go +++ /dev/null @@ -1,34 +0,0 @@ -package driverrepo - -import "strings" - -// IdentifiesSameDriver reports whether a DRIVER table's declared id names the -// signed catalog driver catalogID. It is the publisher's own rule, the one the -// channel build enforces (srcfl/device-drivers tools/ftw_repository.py, -// _identifies_same_driver): source ids use hyphens and are often more -// specific, so the catalog id's words must appear in the declared id in -// order. "easee-cloud" names easee_cloud and "sungrow-shx" names sungrow; a -// growatt table in deye.lua does not. -// -// The rule is loose on purpose ("ctek-chargestorm-hybrid" also contains -// "ctek"), so callers pair it with the file the signed manifest names. -func IdentifiesSameDriver(declared, catalogID string) bool { - if declared == "" || catalogID == "" { - return false - } - words := func(value string) []string { - return strings.Split(strings.ReplaceAll(value, "-", "_"), "_") - } - remaining := words(declared) - next := 0 - for _, word := range words(catalogID) { - for next < len(remaining) && remaining[next] != word { - next++ - } - if next == len(remaining) { - return false - } - next++ - } - return true -} diff --git a/go/internal/driverrepo/identity_test.go b/go/internal/driverrepo/identity_test.go deleted file mode 100644 index 2b224134e..000000000 --- a/go/internal/driverrepo/identity_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package driverrepo - -import "testing" - -// The cases are the publisher's (device-drivers tools/ftw_repository.py). -func TestIdentifiesSameDriverFollowsThePublishersRule(t *testing.T) { - for _, tc := range []struct { - declared, catalog string - want bool - }{ - {"easee-cloud", "easee_cloud", true}, - {"esphome-dsmr", "esphome-dsmr", true}, - {"ctek-chargestorm", "ctek", true}, - {"ctek-chargestorm-hybrid", "ctek_hybrid", true}, - {"sungrow-shx", "sungrow", true}, - {"huawei-sun2000", "huawei", true}, - {"sourceful-zap", "zap", true}, - {"growatt", "deye", false}, - {"different-driver", "esphome-dsmr", false}, - {"cloud-easee", "easee_cloud", false}, - {"", "easee_cloud", false}, - {"easee-cloud", "", false}, - } { - if got := IdentifiesSameDriver(tc.declared, tc.catalog); got != tc.want { - t.Errorf("IdentifiesSameDriver(%q, %q) = %v, want %v", tc.declared, tc.catalog, got, tc.want) - } - } -} diff --git a/go/internal/drivers/catalog_verification_test.go b/go/internal/drivers/catalog_verification_test.go index 302148bf3..694bfd2ca 100644 --- a/go/internal/drivers/catalog_verification_test.go +++ b/go/internal/drivers/catalog_verification_test.go @@ -25,13 +25,13 @@ func TestCatalogVerificationStatus(t *testing.T) { status string }{ {"ferroamp", "production"}, - {"sungrow-shx", "production"}, - {"easee-cloud", "production"}, - {"ferroamp-modbus", "experimental"}, - {"sourceful-zap", "production"}, + {"sungrow", "production"}, + {"easee_cloud", "production"}, + {"ferroamp_modbus", "experimental"}, + {"zap", "production"}, {"deye", "experimental"}, {"solis", "experimental"}, - {"solis-string", "experimental"}, + {"solis_string", "experimental"}, {"tibber", "experimental"}, } for _, tc := range cases { @@ -53,7 +53,7 @@ func TestCatalogSourcefulZapIsReadOnly(t *testing.T) { t.Fatal(err) } for _, e := range entries { - if e.ID == "sourceful-zap" { + if e.ID == "zap" { if !e.ReadOnly { t.Fatal("Sourceful Zap must remain explicitly read-only until the local API has a semantic control endpoint") } @@ -68,7 +68,7 @@ func TestCatalogSourcefulZapIsReadOnly(t *testing.T) { return } } - t.Fatal("sourceful-zap missing from catalog") + t.Fatal("zap missing from catalog") } // Drivers at production status must also have a non-empty VerifiedBy diff --git a/go/internal/drivers/esphome_dsmr_test.go b/go/internal/drivers/esphome_dsmr_test.go index 6e9dedce4..f4ab1f12a 100644 --- a/go/internal/drivers/esphome_dsmr_test.go +++ b/go/internal/drivers/esphome_dsmr_test.go @@ -2,12 +2,10 @@ package drivers import ( "context" - "crypto/sha256" "encoding/json" "fmt" "net/http" "net/http/httptest" - "os" "strings" "testing" @@ -505,22 +503,6 @@ func TestESPHomeDSMR_BackoffResetsOnRecovery(t *testing.T) { } } -func TestESPHomeDSMRRecoverySnapshotProvenance(t *testing.T) { - const ( - sourceCommit = "2939543a2041a29566e6cf27a4eb5e4c69924de8" - assetURL = "https://github.com/srcfl/device-drivers/releases/download/drivers-beta/driver-esphome-dsmr-v1.0.2-c415e507f4371c85.lua" - wantSHA256 = "c415e507f4371c859fbf60827cc0704c5e24ad122dca1a9e5e1f190d67e852d1" - ) - raw, err := os.ReadFile("../../../drivers/esphome_dsmr.lua") - if err != nil { - t.Fatalf("read recovery snapshot: %v", err) - } - got := fmt.Sprintf("%x", sha256.Sum256(raw)) - if got != wantSHA256 { - t.Fatalf("recovery snapshot SHA-256 = %s, want %s from %s at %s", got, wantSHA256, assetURL, sourceCommit) - } -} - func TestESPHomeDSMR_NameDerivedPhaseObjectIDs(t *testing.T) { // Default ESPHome DSMR YAML derives object_ids from entity names // ("Current Phase 1" → current_phase_1) rather than the DSMR @@ -640,7 +622,7 @@ func TestESPHomeDSMRCatalogEntry(t *testing.T) { } var found *CatalogEntry for i, e := range entries { - if e.ID == "esphome-dsmr" { + if e.ID == "esphome_dsmr" { found = &entries[i] break } diff --git a/go/internal/drivers/ferroamp_modbus_test.go b/go/internal/drivers/ferroamp_modbus_test.go index e97d19b2a..1244b20dc 100644 --- a/go/internal/drivers/ferroamp_modbus_test.go +++ b/go/internal/drivers/ferroamp_modbus_test.go @@ -54,7 +54,7 @@ func TestFerroampModbusCatalogEntry(t *testing.T) { } var found *CatalogEntry for i, e := range entries { - if e.ID == "ferroamp-modbus" { + if e.ID == "ferroamp_modbus" { found = &entries[i] break } diff --git a/go/internal/drivers/solaredge_curtail_test.go b/go/internal/drivers/solaredge_curtail_test.go index f5b1ad696..de1a5eaa2 100644 --- a/go/internal/drivers/solaredge_curtail_test.go +++ b/go/internal/drivers/solaredge_curtail_test.go @@ -193,8 +193,8 @@ func TestSolarEdgeCatalogAdvertisesCurtail(t *testing.T) { } wantIDs := map[string]bool{ "solaredge": false, - "solaredge-pv": false, - "solaredge-legacy": false, + "solaredge_pv": false, + "solaredge_legacy": false, } for _, e := range entries { if _, ok := wantIDs[e.ID]; !ok { diff --git a/go/internal/drivers/solis_string_test.go b/go/internal/drivers/solis_string_test.go index c7ba3f315..aed8b9ae1 100644 --- a/go/internal/drivers/solis_string_test.go +++ b/go/internal/drivers/solis_string_test.go @@ -108,7 +108,7 @@ func TestSolisStringCatalogEntry(t *testing.T) { } var found *CatalogEntry for i, e := range entries { - if e.ID == "solis-string" { + if e.ID == "solis_string" { found = &entries[i] break } diff --git a/web/setup.js b/web/setup.js index 28f75388d..dec90c857 100644 --- a/web/setup.js +++ b/web/setup.js @@ -272,7 +272,7 @@ btn.disabled = false; var lines = []; - if (selectedCatalog.filename === 'zap.lua' || selectedCatalog.id === 'sourceful-zap') { + if (selectedCatalog.filename === 'zap.lua' || selectedCatalog.id === 'zap') { lines.push('This driver is the P1/HAN site meter by default. Add inverters, batteries and chargers as their own devices in FTW when you can. If Zap is the only reader, turn on PV or battery ingest later under Settings → Devices.'); } if (selectedCatalog.description) lines.push(selectedCatalog.description);