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
11 changes: 11 additions & 0 deletions .changeset/one-id-drivers.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 8 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Comment on lines +193 to +195

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 Promote only the validated driver beta

When main has advanced since the Core beta was tested, this fallback republishes the newer main commit to drivers-beta and then immediately promotes it, so drivers-stable can receive driver code that was neither included in nor validated with the stable Core candidate. Do not instruct an immediate promotion here; the exact driver beta being promoted must complete the required validation first.

AGENTS.md reference: AGENTS.md:L167-L170

Useful? React with 👍 / 👎.


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
Expand Down
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": "489c9373be1fb391d885b948bf736399b3e40f2e",
"commit": "43710c841f4715bdbc6070bea38c89fc60fd830a",
"source_dir": "drivers/lua",
"drivers": [
"ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud",
Expand Down
25 changes: 7 additions & 18 deletions go/internal/api/api_device_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down
21 changes: 12 additions & 9 deletions go/internal/api/api_device_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<filename>.
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})`)),
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 0 additions & 34 deletions go/internal/driverrepo/identity.go

This file was deleted.

28 changes: 0 additions & 28 deletions go/internal/driverrepo/identity_test.go

This file was deleted.

14 changes: 7 additions & 7 deletions go/internal/drivers/catalog_verification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
}
Expand All @@ -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
Expand Down
20 changes: 1 addition & 19 deletions go/internal/drivers/esphome_dsmr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ package drivers

import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion go/internal/drivers/ferroamp_modbus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions go/internal/drivers/solaredge_curtail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion go/internal/drivers/solis_string_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion web/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading