From 1c376a76463711fac1d4217ab23541a4d9e82bde Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 25 Sep 2026 10:42:59 +0200 Subject: [PATCH] feat(drivers): move devices off a driver the release replaces srcfl/device-drivers#135 folded the catalog entry esphome-dsmr into esphome_dsmr, which declares replaces = { "esphome-dsmr" } in its DRIVER table. At start, a configured driver that runs the owner's selection of a replaced driver now names the release's file, the old selection ends, and the settings are saved. An operator's own file of the old name is left alone. The pin moves to device-drivers 92adaf0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 --- .changeset/driver-replaces.md | 8 +++ drivers/BUNDLED_SOURCE.json | 2 +- go/cmd/ftw/main.go | 7 +++ go/internal/driverrepo/replaces.go | 81 +++++++++++++++++++++++++ go/internal/driverrepo/replaces_test.go | 75 +++++++++++++++++++++++ go/internal/drivers/catalog.go | 5 ++ 6 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 .changeset/driver-replaces.md create mode 100644 go/internal/driverrepo/replaces.go create mode 100644 go/internal/driverrepo/replaces_test.go diff --git a/.changeset/driver-replaces.md b/.changeset/driver-replaces.md new file mode 100644 index 00000000..f09473b2 --- /dev/null +++ b/.changeset/driver-replaces.md @@ -0,0 +1,8 @@ +--- +"ftw": patch +--- + +A device that runs the retired ESPHome DSMR driver entry `esphome-dsmr` moves to +the release's `esphome_dsmr` at start, and its settings are saved; the two were +the same driver under two names. An operator's own driver file of the old name is +left alone. The bundled drivers come from srcfl/device-drivers 92adaf0. diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 7daa8bcb..97b2c4c1 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": "43710c841f4715bdbc6070bea38c89fc60fd830a", + "commit": "92adaf074f54a20f872ab84ae34c2cac45528f04", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index db88bfe6..e331ff2b 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -550,6 +550,13 @@ func main() { driverRepository.SetBundledDir(resolveDriverDir()) driverRepository.ApplyBundled() cfg.UnresolveDriverPaths(filepath.Dir(*configPath)) + // A catalog driver the release's own driver replaces (DRIVER.replaces) + // moves its devices over, and the saved settings follow. + if moved := driverRepository.MigrateReplaced(cfg, *userDriversDirFlag); len(moved) > 0 { + if err := config.SaveStored(st, *configPath, cfg); err != nil { + slog.Warn("could not save devices moved to a replacing driver", "err", err) + } + } config.ManagedDriversDirOverride = driverRepository.EffectiveDir() cfg.ResolveDriverPaths(filepath.Dir(*configPath)) diff --git a/go/internal/driverrepo/replaces.go b/go/internal/driverrepo/replaces.go new file mode 100644 index 00000000..491e7957 --- /dev/null +++ b/go/internal/driverrepo/replaces.go @@ -0,0 +1,81 @@ +package driverrepo + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" +) + +// Replacement is a device moved from a retired catalog driver to the +// release's driver that replaces it. +type Replacement struct { + Driver string `json:"driver"` + From string `json:"from"` + To string `json:"to"` + LogicalPath string `json:"logical_path"` +} + +// MigrateReplaced moves devices off a catalog driver that one of this +// release's drivers declares it replaces (DRIVER.replaces), such as +// esphome-dsmr folded into esphome_dsmr. A configured driver that runs the +// owner's selection of the retired driver now names the release's file, and +// the old selection ends. An operator's own file of the old name in userDir +// is left alone. cfg must hold portable driver paths ("drivers/x.lua"); the +// caller saves the configuration when anything moved. +func (m *Manager) MigrateReplaced(cfg *config.Config, userDir string) []Replacement { + if m.store == nil || m.bundledDir == "" || cfg == nil { + return nil + } + entries, err := drivers.LoadCatalog(m.bundledDir) + if err != nil { + return nil + } + replacing := make(map[string]drivers.CatalogEntry) + for _, entry := range entries { + for _, old := range entry.Replaces { + replacing[old] = entry + } + } + if len(replacing) == 0 { + return nil + } + active, err := m.store.ActiveDriverRepoInstalls() + if err != nil { + slog.Warn("driver repository: read active state", "err", err) + return nil + } + var moved []Replacement + for _, installed := range active { + replacement, ok := replacing[installed.DriverID] + if !ok { + continue + } + rel := strings.TrimPrefix(installed.LogicalPath, "drivers/") + if userDir != "" { + if _, err := os.Stat(filepath.Join(userDir, filepath.FromSlash(rel))); err == nil { + continue + } + } + target := "drivers/" + replacement.Filename + var names []string + for i := range cfg.Drivers { + if filepath.ToSlash(cfg.Drivers[i].Lua) == installed.LogicalPath { + cfg.Drivers[i].Lua = target + names = append(names, cfg.Drivers[i].Name) + } + } + if err := m.Deactivate(installed.LogicalPath); err != nil { + slog.Warn("driver repository: end replaced selection", "path", installed.LogicalPath, "err", err) + } + for _, name := range names { + slog.Info("driver repository: device moved to the release's replacing driver", + "driver", name, "from", installed.DriverID, "to", replacement.ID, "path", target) + moved = append(moved, Replacement{Driver: name, From: installed.DriverID, To: replacement.ID, LogicalPath: target}) + } + } + return moved +} diff --git a/go/internal/driverrepo/replaces_test.go b/go/internal/driverrepo/replaces_test.go new file mode 100644 index 00000000..c46c43a6 --- /dev/null +++ b/go/internal/driverrepo/replaces_test.go @@ -0,0 +1,75 @@ +package driverrepo + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/components" + "github.com/srcfl/ftw/go/internal/config" +) + +// esphome-dsmr was folded into esphome_dsmr: a site that installed the old +// catalog entry moves to the release's driver instead of keeping a copy that +// no longer gets updates. +func TestDeviceOnARetiredDriverMovesToTheReleasesReplacement(t *testing.T) { + for _, tc := range []struct { + name string + ownFile bool + wantMoved bool + }{ + {"moves", false, true}, + {"an operator's own file stays", true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + site := newSupersedeSite(t, "1.0.3", "") + old := []byte(strings.Replace(string(testDriver("1.0.3")), `id = "demo"`, `id = "demo-old"`, 1)) + site.channel.mu.Lock() + site.channel.driver = old + sum := sha256.Sum256(old) + site.channel.manifest.Drivers[0].ID = "demo-old" + site.channel.manifest.Drivers[0].Path = "drivers/demo-old.lua" + site.channel.manifest.Drivers[0].Filename = "demo-old.lua" + site.channel.manifest.Drivers[0].SHA256 = hex.EncodeToString(sum[:]) + site.channel.manifest.Drivers[0].URL = site.channelURL + "/demo.lua" + site.channel.manifest.Drivers[0].HostAPI = components.CompatibleRange{Min: 1, Max: 1} + site.channel.mu.Unlock() + replacement := strings.Replace(string(testDriver("1.0.6")), "capabilities = { \"meter\" },", + "capabilities = { \"meter\" },\n replaces = { \"demo-old\" },", 1) + if err := os.WriteFile(filepath.Join(site.bundled, "demo.lua"), []byte(replacement), 0o644); err != nil { + t.Fatal(err) + } + manager, _ := site.boot("v0.137.0-beta.2") + if err := manager.Refresh(context.Background(), "test"); err != nil { + t.Fatal(err) + } + if _, err := manager.Install(context.Background(), "test", "demo-old", ""); err != nil { + t.Fatal(err) + } + userDir := t.TempDir() + if tc.ownFile { + if err := os.WriteFile(filepath.Join(userDir, "demo-old.lua"), old, 0o644); err != nil { + t.Fatal(err) + } + } + cfg := &config.Config{Drivers: []config.Driver{{Name: "p1", Lua: "drivers/demo-old.lua"}}} + moved := manager.MigrateReplaced(cfg, userDir) + if !tc.wantMoved { + if len(moved) != 0 || cfg.Drivers[0].Lua != "drivers/demo-old.lua" { + t.Fatalf("moved = %+v, lua = %s; the operator's own file must stay", moved, cfg.Drivers[0].Lua) + } + return + } + if len(moved) != 1 || moved[0].From != "demo-old" || moved[0].To != "demo" || cfg.Drivers[0].Lua != "drivers/demo.lua" { + t.Fatalf("moved = %+v, lua = %s; want p1 on the release's drivers/demo.lua", moved, cfg.Drivers[0].Lua) + } + if active, _ := site.store.ActiveDriverRepoInstalls(); len(active) != 0 { + t.Fatalf("the retired selection is still active: %+v", active) + } + }) + } +} diff --git a/go/internal/drivers/catalog.go b/go/internal/drivers/catalog.go index 2697c39e..2d38ea64 100644 --- a/go/internal/drivers/catalog.go +++ b/go/internal/drivers/catalog.go @@ -92,6 +92,10 @@ type CatalogEntry struct { // write path, rather than FTW matching on a filename or vendor name. // Read-only remains the default for every driver in the catalog. WriteCapabilities []string `json:"write_capabilities,omitempty"` + // Replaces names catalog driver ids this driver takes over, such as + // esphome-dsmr folded into esphome_dsmr. A device running one of them is + // moved to this driver when the release ships it. + Replaces []string `json:"replaces,omitempty"` } // LoadCatalog scans dir (and any direct sub-directories) for .lua driver @@ -221,6 +225,7 @@ func parseCatalogEntry(path string) (CatalogEntry, error) { e.TestedModels = pickList(block, "tested_models") e.ConfigSecrets = pickList(block, "config_secrets") e.WriteCapabilities = pickList(block, "write_capabilities") + e.Replaces = pickList(block, "replaces") e.AuthPostPath = pickString(block, "auth_post_path") e.Controls = pickControls(block) return e, nil