Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/driver-replaces.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion drivers/BUNDLED_SOURCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +556 to +557

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve migration state when settings persistence fails

If SaveStored fails after MigrateReplaced successfully deactivated the old selection, startup continues using the in-memory replacement while durable settings still name the old path. On the next restart there is no active old install to trigger this migration again, so the configured driver resolves to a missing retired file and may leave the site meter offline; make the save failure fatal or restore the old activation/configuration before continuing.

AGENTS.md reference: AGENTS.md:L20-L25

Useful? React with 馃憤聽/ 馃憥.

}
}
config.ManagedDriversDirOverride = driverRepository.EffectiveDir()
cfg.ResolveDriverPaths(filepath.Dir(*configPath))

Expand Down
81 changes: 81 additions & 0 deletions go/internal/driverrepo/replaces.go
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor pinned versions before retiring a replaced driver

When an operator deliberately rolled this old driver back to an earlier managed version, Chosen(installed.LogicalPath, installed.Version) records that choice, but this unconditional Deactivate clears it and moves the device to the release version anyway. That can reintroduce the exact hardware problem the rollback avoided; skip or explicitly preserve/translate chosen selections until the owner changes them.

AGENTS.md reference: AGENTS.md:L175-L179

Useful? React with 馃憤聽/ 馃憥.

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
}
75 changes: 75 additions & 0 deletions go/internal/driverrepo/replaces_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
5 changes: 5 additions & 0 deletions go/internal/drivers/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading