diff --git a/.changeset/driver-replaces.md b/.changeset/driver-replaces.md new file mode 100644 index 000000000..f09473b2e --- /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 7daa8bcba..97b2c4c13 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 db88bfe65..e331ff2be 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 000000000..491e79574 --- /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 000000000..c46c43a60 --- /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 2697c39ec..2d38ea649 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