From 8a659cc81c4abb07016a86a72c8ac84b9a212e06 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 19 Oct 2026 09:13:40 +0200 Subject: [PATCH 1/3] feat(setup): guide a new box through MCP onboarding --- .changeset/complete-mcp-onboarding.md | 9 + docs/adr/0011-agent-led-setup.md | 39 ++- docs/roadmap.md | 2 +- go/cmd/ftw/bootstrap.go | 70 ++++- go/cmd/ftw/bootstrap_assistant_test.go | 109 ++++++++ go/cmd/ftw/bootstrap_storage.go | 108 +++++++ go/cmd/ftw/forecast_site.go | 37 ++- go/cmd/ftw/forecast_site_test.go | 45 +++ go/cmd/ftw/main.go | 95 ++++++- go/internal/api/api.go | 19 ++ go/internal/api/api_agents.go | 3 + go/internal/api/api_drivers_debug.go | 18 +- go/internal/api/api_drivers_fingerprint.go | 19 +- go/internal/api/api_myuplink_oauth.go | 41 ++- go/internal/api/api_setup.go | 85 +++++- go/internal/api/api_setup_connection.go | 44 +++ go/internal/api/api_setup_oauth.go | 121 ++++++++ go/internal/api/api_setup_oauth_test.go | 97 +++++++ go/internal/api/api_setup_prepare.go | 263 ++++++++++++++++++ go/internal/api/api_setup_prepare_test.go | 134 +++++++++ go/internal/api/lan_auth.go | 9 + go/internal/config/config.go | 7 +- go/internal/config/restart_required.go | 3 + go/internal/config/setup.go | 168 +++++++++++ go/internal/config/setup_test.go | 60 ++++ go/internal/drivers/host.go | 19 ++ go/internal/drivers/lua.go | 5 + go/internal/drivers/registry.go | 60 +++- go/internal/drivers/setup_measurement_test.go | 101 +++++++ go/internal/loadmodel/annual_prior_test.go | 44 +++ go/internal/loadmodel/service.go | 28 ++ go/internal/onboarding/guide.go | 69 +++++ go/internal/onboarding/prepare.go | 80 ++++++ go/internal/onboarding/schema.go | 37 +++ go/internal/state/initial_settings.go | 48 ++++ web/project-statistics.js | 18 +- web/settings.js | 27 +- .../tabs/access-invitations-flow.test.mjs | 1 + web/settings/tabs/access-setup-flow.test.mjs | 12 +- web/settings/tabs/access.js | 28 +- web/settings/tabs/access.test.mjs | 2 +- web/settings/tabs/control.js | 47 +++- web/setup-vendor.html | 26 ++ web/setup-vendor.js | 23 ++ web/setup.html | 16 ++ web/setup.js | 40 +++ 46 files changed, 2291 insertions(+), 45 deletions(-) create mode 100644 .changeset/complete-mcp-onboarding.md create mode 100644 go/cmd/ftw/bootstrap_assistant_test.go create mode 100644 go/cmd/ftw/bootstrap_storage.go create mode 100644 go/internal/api/api_setup_connection.go create mode 100644 go/internal/api/api_setup_oauth.go create mode 100644 go/internal/api/api_setup_oauth_test.go create mode 100644 go/internal/api/api_setup_prepare.go create mode 100644 go/internal/api/api_setup_prepare_test.go create mode 100644 go/internal/config/setup.go create mode 100644 go/internal/config/setup_test.go create mode 100644 go/internal/drivers/setup_measurement_test.go create mode 100644 go/internal/loadmodel/annual_prior_test.go create mode 100644 go/internal/onboarding/guide.go create mode 100644 go/internal/onboarding/prepare.go create mode 100644 go/internal/state/initial_settings.go create mode 100644 web/setup-vendor.html create mode 100644 web/setup-vendor.js diff --git a/.changeset/complete-mcp-onboarding.md b/.changeset/complete-mcp-onboarding.md new file mode 100644 index 000000000..53f5dd23d --- /dev/null +++ b/.changeset/complete-mcp-onboarding.md @@ -0,0 +1,9 @@ +--- +"ftw": minor +--- + +Set up a new box with an MCP assistant. Start with no equipment in measurement +mode, resume answers from a durable draft, discover and test equipment, and +finish MyUplink sign-in without putting tokens in chat. Review the physical +connection and live meter before enabling control. Save optional solar and +annual-load estimates as cold-start priors without changing measured history. diff --git a/docs/adr/0011-agent-led-setup.md b/docs/adr/0011-agent-led-setup.md index 47e404bf9..bae55156d 100644 --- a/docs/adr/0011-agent-led-setup.md +++ b/docs/adr/0011-agent-led-setup.md @@ -1,6 +1,6 @@ # ADR 0011: Set up a home through an authorized agent -- Status: proposed; implementation and field tests remain work to do. +- Status: implemented for beta review; novice and hardware trials remain. - Date: 2026-10-18 - Owner: Fredrik. Requested complete setup through MCP on 18 October 2026. @@ -88,10 +88,39 @@ preserve exact numbers, and audit the caller and changed field paths without logging credentials. A reply must distinguish saved, applied, restart needed and verified on hardware. -Current first-run bootstrap serves only the web wizard. It must gain a -passive owner-claim path before claiming that a fresh installation can use -Cloud MCP. Existing agents can read and propose goals; that is not authority -to replace configuration. Both gaps need code and tests. +First-run bootstrap now offers assistant setup alongside the wizard. It stores +an empty measurement-only configuration and a private house-password hash in +one published SQLite settings file, then restarts. The owner signs in from +another device on the LAN, reviews Cloud terms and approves the setup grant +in Access. Statistics-only sharing cannot carry an assistant session. + +`setup_begin` requests diagnostic and setup access together. The box supplies +the complete typed configuration schema, supported modes, runtime time zone, +driver catalogue, outstanding questions, version and commissioning status. +`setup_discover` scans only after confirmation. `setup_test` uses private draft +credentials and blocks equipment writes. It reads an existing driver's health +instead of forking a running OAuth token family. `setup_connect` binds MyUplink +browser consent to the exact draft and grant. Other vendors use their driver +catalogue fields. A driver that needs an undeclared authentication or read +subscription write may require a driver update before it works in measurement +mode; do not present that as a verified connection. + +Save equipment in measurement mode first. Read fresh measurements and the +running meter's identity. While control remains blocked, use the ordinary +`goal_request` and `car_goal_request` tools to choose mode, planning style and +car departure goals. The owner approves these in local Access or the FTW app; +Check requests and access refreshes the local list. Read both request receipts +and active values. These goals use their existing state and never replay old +setup answers over later choices. Confirm the host time zone before local +HH:MM targets; a wrong host zone needs a host change and Core restart. + +The final draft records the reviewed connection digest, equipment and live +meter identity. Enabling control requires a fresh grid-boundary meter with +usable phase currents for the configured fuse, and unchanged meter connection +settings. Changing measurement mode requires a restart. Saved mode and active +mode appear separately. Measurement mode blocks driver writes even in init, +poll and cleanup; a changed meter identity blocks new dispatch while allowing +Core's safe stop/default paths. Hardware response still needs a measured test. ## Useful forecasts before learning diff --git a/docs/roadmap.md b/docs/roadmap.md index 2d78cdc4c..2ce58eed6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -25,7 +25,7 @@ of Core; new solver work belongs in Energyplan. |---|---|---| | Remote clients, 17 October 2026 | [Core #1687](https://github.com/srcfl/ftw/pull/1687), [Cloud #28](https://github.com/srcfl/ftw-cloud/pull/28), [web #95](https://github.com/srcfl/ftw-webapp/pull/95) and [iOS #83](https://github.com/srcfl/ftw-app/pull/83) converge on the Cloud tunnel. Core validates each command; browser offline notices remain box-encrypted. Core [v0.147.0-beta.1](https://github.com/srcfl/ftw/releases/tag/v0.147.0-beta.1) and the web client are published; iOS 0.4.8 build 13 is uploaded to App Store Connect. Cloud runs in Stockholm and the Frankfurt relay is stopped, with disk and address kept for rollback. Escrow remains separate. The production Cloud test paired a simulated box, confirmed 50 commands and resumed five times with no relay dial. | Update older boxes and pair older clients again. Confirm physical-site behaviour separately; the Cloud test used no hardware. | | Mixed equipment | Separate SolarEdge legacy and Pixii drivers exist. Their [published evidence](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/solaredge_legacy.lua) and [Pixii metadata](https://github.com/srcfl/device-drivers/blob/7e5946555245dee2150d46d1b7278aaec5ebe242/drivers/lua/pixii.lua) still say experimental; the legacy header and declared curtail capability also need to agree. | Reconcile known field runs with the catalog, then verify the named inverter + battery + charger combination. Distinguish measured telemetry, verified commands and unverified control. Metadata is not evidence that no user has ever run the hardware. | -| Setup and usable power | Discovery, fingerprinting and per-device settings exist. [Planner battery limits](../go/cmd/ftw/main.go) use configured limits or a 0.5C estimate, then an aggregate fuse cap. | Complete the [agent-led setup](adr/0011-agent-led-setup.md) path from a new box, including resumable drafts, explicit setup rights, shared access by code and verified commissioning. A guided commissioning result and measured usable-power learning are still missing from the reviewed setup path. Read verified limits first; learn response within them. A capacity-derived estimate is not a learned power limit. | +| Setup and usable power | Discovery, fingerprinting and per-device settings exist. Agent-led setup now adds a passive first claim, resumable MCP interview, draft discovery/vendor sign-in, optional annual-load and solar priors, and an exact connection review before control. [Planner battery limits](../go/cmd/ftw/main.go) use configured limits or a 0.5C estimate, then an aggregate fuse cap. | Run a complete novice trial of [agent-led setup](adr/0011-agent-led-setup.md) through two MCP clients and supported hardware. The setup status separates configured, measuring and control evidence; measured usable-power learning remains work to do. Read verified limits first; learn response within them. A capacity-derived estimate is not a learned power limit. | | Forecasts and defaults | [PV learning](../optimizer/native/README.md) needs no panel geometry or rating. [Cold-load selection](../go/cmd/ftw/forecast_tracking.go) keeps the site prior until learning, and [load/net-risk tests](../go/cmd/ftw/forecast_load_risk_test.go) cover uncertainty. Charge and discharge efficiency are included. The [minimum arbitrage spread](../go/internal/config/config.go) defaults to zero. | Verify first-day and learned performance on held-out site periods. Keep the separate opt-in wear-cost requirement open; a minimum arbitrage spread is not a general wear model. Align all persisted defaults and worker support before exposing such a setting. Do not rebuild cold-start support already delivered in #1204. | | Daily charging | [The webapp panel](https://github.com/srcfl/ftw-webapp/blob/ff7af033fa3fcdeb38882e3ff365e8d6d7aba75a/src/views/EvPanel.svelte) already saves SoC on slider release, changes schedules without a Save button and supports Charge now. | [Now](https://github.com/srcfl/ftw-webapp/blob/ff7af033fa3fcdeb38882e3ff365e8d6d7aba75a/src/views/Now.svelte) normally opens that panel after a charger tap or notification link. Bring the relevant SoC action directly into the post-plug-in entry experience. Complete the goal → plan → delivered-energy flow on real chargers, including offline cars and restarts. | | Notifications | Core and the webapp implement subscription, charging connection/completion/interruption events and device alerts. See the [shared push catalogue](../contract/push-catalogue.yaml) and [rule defaults](../go/internal/notifications/service.go). | There is no dedicated predicted-missed-departure event in that catalogue. Add an actionable goal-risk notification and make activation clear during charging setup, with user consent. Verify delivery while the app is closed; an interrupted-session alert alone does not cover a future shortfall. | diff --git a/go/cmd/ftw/bootstrap.go b/go/cmd/ftw/bootstrap.go index a769e3920..d9383bdc7 100644 --- a/go/cmd/ftw/bootstrap.go +++ b/go/cmd/ftw/bootstrap.go @@ -2,12 +2,14 @@ package main import ( "encoding/json" + "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "strings" + "sync" "github.com/srcfl/ftw/go/internal/api" "github.com/srcfl/ftw/go/internal/config" @@ -22,6 +24,15 @@ func runBootstrap(configPath, webDir, driverDir string) { slog.Info("no config found — starting setup wizard", "url", "http://localhost:8080/setup") mux := http.NewServeMux() + var initialWriteMu sync.Mutex + // Both first-run choices share one write lock. A late wizard request must + // not replace an assistant installation which has already started. + saveInitial := func(cfg *config.Config) error { + initialWriteMu.Lock() + defer initialWriteMu.Unlock() + return saveInitialConfig(configPath, cfg) + } + mux.HandleFunc("POST /api/setup/assistant", bootstrapAssistant(saveInitial, func() error { return execCurrentProcess(nil) })) mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { http.Redirect(w, r, "/setup", http.StatusFound) @@ -103,7 +114,7 @@ func runBootstrap(configPath, webDir, driverDir string) { writeBootstrapJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) return } - if err := config.SaveAtomic(configPath, &cfg); err != nil { + if err := saveInitial(&cfg); err != nil { writeBootstrapJSON(w, http.StatusInternalServerError, map[string]string{"error": "write config: " + err.Error()}) return } @@ -123,6 +134,63 @@ func runBootstrap(configPath, webDir, driverDir string) { } } +func saveInitialConfig(path string, cfg *config.Config) error { + if _, err := os.Lstat(path); !os.IsNotExist(err) { + return fmt.Errorf("settings already exist or cannot be checked; reload the box instead of starting again") + } + // A lost locator is a recovery case, never a new household. Check both + // the default working directory and the seed's directory without opening + // or creating a database. + for _, db := range []string{"state.db", filepath.Join(filepath.Dir(path), "state.db")} { + if _, err := os.Lstat(db); !os.IsNotExist(err) { + return fmt.Errorf("existing data found at %s; restore its settings locator before setup", db) + } + } + if err := cfg.Validate(); err != nil { + return err + } + if cfg.API.LANAuth { + return saveInitialAssistantStorage(path, cfg) + } + return config.SaveAtomic(path, cfg) +} + +func bootstrapAssistant(save func(*config.Config) error, restart func() error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var q struct { + Confirmed bool `json:"confirmed"` + Password string `json:"password"` + } + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)) + dec.DisallowUnknownFields() + if err := dec.Decode(&q); err != nil || !q.Confirmed { + writeBootstrapJSON(w, http.StatusBadRequest, map[string]string{"error": "confirm starting an empty measurement installation"}) + return + } + if err := dec.Decode(new(any)); err != io.EOF { + writeBootstrapJSON(w, http.StatusBadRequest, map[string]string{"error": "send one setup request"}) + return + } + hash, err := api.HashInitialLANPassword(q.Password) + if err != nil { + writeBootstrapJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + cfg := config.NewSetup() + cfg.API.LANAuth, cfg.LANPasswordHash = true, hash + if err := save(cfg); err != nil { + writeBootstrapJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return + } + writeBootstrapJSON(w, http.StatusOK, map[string]any{"saved": true, "restart_pending": true, "next": "/?setup=assistant"}) + go func() { + if err := restart(); err != nil { + slog.Error("assistant setup restart failed", "err", err) + } + }() + } +} + // Bootstrap needs the same country/zone choices before any config exists. func bootstrapPriceZones(w http.ResponseWriter, _ *http.Request) { zones := prices.Zones() diff --git a/go/cmd/ftw/bootstrap_assistant_test.go b/go/cmd/ftw/bootstrap_assistant_test.go new file mode 100644 index 000000000..6db95984e --- /dev/null +++ b/go/cmd/ftw/bootstrap_assistant_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/state" +) + +func TestBootstrapAssistantCreatesARealEmptyInstallation(t *testing.T) { + t.Setenv("FTW_API_TOKEN", "") + path := filepath.Join(t.TempDir(), "config.yaml") + restarted := make(chan struct{}, 1) + handler := secureBootstrapMutations(bootstrapAssistant(func(cfg *config.Config) error { return saveInitialConfig(path, cfg) }, func() error { restarted <- struct{}{}; return nil })) + for _, tc := range []struct { + body, origin string + want int + }{ + {`{"confirmed":true}`, "https://attacker.example", 403}, + {`{"confirmed":false}`, "http://box.local", 400}, + {`{"confirmed":true,"cloud":true}`, "http://box.local", 400}, + {`{"confirmed":true,"password":"first-owner-password"}`, "http://box.local", 200}, + } { + req := httptest.NewRequest("POST", "http://box.local/api/setup/assistant", strings.NewReader(tc.body)) + req.RemoteAddr = "192.168.1.12:1234" + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Origin", tc.origin) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != tc.want { + t.Fatalf("%s: %d %s", tc.body, w.Code, w.Body.String()) + } + } + <-restarted + cfg, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + if !cfg.MeasurementOnly() || len(cfg.Drivers) != 0 || cfg.Fuse.MaxAmps != 0 || cfg.FleetPing.Enabled || !cfg.AppLink.Enabled || cfg.Setup.ConnectionConfirmed || !cfg.API.LANAuth || cfg.LANPasswordHash == "" { + t.Fatal("first boot invented equipment, limits or consent", cfg) + } + + runtimeStore, err := state.Open(cfg.ConfigDatabase) + if err != nil { + t.Fatal("first real boot could not open its history", err) + } + if err := runtimeStore.Close(); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(path) + if strings.Contains(string(before), "first-owner-password") || strings.Contains(string(before), cfg.LANPasswordHash) { + t.Fatal("locator contains the house password or its private hash") + } + if err := saveInitialConfig(path, config.NewSetup()); err == nil { + t.Fatal("a late first-run request replaced settings") + } + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("refused first-run request changed settings") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + recovered, err := config.Load(path) + if err != nil || recovered.Revision != cfg.Revision || recovered.LANPasswordHash != cfg.LANPasswordHash { + t.Fatal("interrupted locator publication lost first-claim settings", err) + } +} + +func TestBootstrapDoesNotReplaceAnOrphanedDatabase(t *testing.T) { + dir := t.TempDir() + data := []byte("existing database must stay untouched") + db := filepath.Join(dir, "state.db") + if err := os.WriteFile(db, data, 0600); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "config.yaml") + if err := saveInitialConfig(path, config.NewSetup()); err == nil { + t.Fatal("offered a new home over existing data") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("created a new settings locator") + } + got, _ := os.ReadFile(db) + if string(got) != string(data) { + t.Fatal("changed existing database") + } +} + +func TestSetupCannotEnableChangedMeterConfiguration(t *testing.T) { + active := &config.Config{Drivers: []config.Driver{{Name: "meter", Lua: "meter.lua", IsSiteMeter: true, Config: map[string]any{"host": "192.0.2.1", "scale": 1.0}}}} + candidate := &config.Config{Drivers: []config.Driver{{Name: "meter", Lua: "meter.lua", IsSiteMeter: true, Config: map[string]any{"host": "192.0.2.1", "scale": 1.0}}}} + if !sameSetupMeterConfiguration(active, candidate) { + t.Fatal("same meter rejected") + } + candidate.Drivers[0].Config["scale"] = 2.0 + if sameSetupMeterConfiguration(active, candidate) { + t.Fatal("old readings accepted for new scaling") + } + candidate.Drivers[0].Config["scale"] = 1.0 + candidate.Drivers[0].Config["host"] = "192.0.2.2" + if sameSetupMeterConfiguration(active, candidate) { + t.Fatal("old readings accepted for another endpoint") + } +} diff --git a/go/cmd/ftw/bootstrap_storage.go b/go/cmd/ftw/bootstrap_storage.go new file mode 100644 index 000000000..d4f8c1743 --- /dev/null +++ b/go/cmd/ftw/bootstrap_storage.go @@ -0,0 +1,108 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "reflect" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/state" +) + +// Publish only a complete first-claim database. Interrupted preparation stays +// in an owned temporary directory, never at the authoritative state.db path. +func saveInitialAssistantStorage(path string, cfg *config.Config) error { + if cfg.LANPasswordHash == "" { + return fmt.Errorf("initial house password is missing") + } + parent, err := filepath.Abs(filepath.Dir(path)) + if err != nil { + return err + } + database := filepath.Join(parent, "state.db") + dir, err := os.MkdirTemp(parent, ".ftw-initial-setup-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + staging := filepath.Join(dir, "prepare.db") + st, err := state.CreateInitialSettings(staging) + if err != nil { + return err + } + defer st.Close() + cfg.ConfigDatabase = staging + if err := config.SaveStored(st, path, cfg); err != nil { + return err + } + complete := filepath.Join(dir, "complete.db") + if err := st.SnapshotInitialSettings(complete); err != nil { + return err + } + document, err := state.ReadConfiguration(complete) + if err != nil || document.Revision != cfg.Revision { + return fmt.Errorf("initial settings were not captured") + } + if err := os.Chmod(complete, 0600); err != nil { + return err + } + file, err := os.OpenFile(complete, os.O_RDWR, 0600) + if err != nil { + return err + } + if err := file.Sync(); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + // Link is an atomic create-if-absent on this same filesystem. It cannot + // replace another install, including one created after the first check. + if err := os.Link(complete, database); err != nil { + return err + } + directory, err := os.Open(parent) + if err != nil { + return err + } + syncErr := directory.Sync() + _ = directory.Close() + if syncErr != nil { + return fmt.Errorf("initial settings published but directory sync failed: %w", syncErr) + } + cfg.ConfigDatabase = database + if err := config.SaveAtomic(path, cfg); err != nil { + // The transaction is already durable. Load recovers Settings beside a + // missing locator; do not ask the owner to repeat a successful claim. + loaded, recoveryErr := config.Load(path) + if recoveryErr != nil || loaded.Revision != cfg.Revision || loaded.LANPasswordHash != cfg.LANPasswordHash { + return err + } + slog.Warn("initial settings saved; startup will repair the settings locator", "err", err) + } + return nil +} + +// sameSetupMeterConfiguration prevents commissioning with readings from a +// different endpoint or scaling than the configuration being enabled. +func sameSetupMeterConfiguration(active, candidate *config.Config) bool { + name := candidate.SiteMeterDriver() + if name == "" || active.SiteMeterDriver() != name { + return false + } + var before, after *config.Driver + for i := range active.Drivers { + if active.Drivers[i].Name == name { + before = &active.Drivers[i] + } + } + for i := range candidate.Drivers { + if candidate.Drivers[i].Name == name { + after = &candidate.Drivers[i] + } + } + return before != nil && after != nil && reflect.DeepEqual(*before, *after) +} diff --git a/go/cmd/ftw/forecast_site.go b/go/cmd/ftw/forecast_site.go index ff02fa53b..dd7eb8585 100644 --- a/go/cmd/ftw/forecast_site.go +++ b/go/cmd/ftw/forecast_site.go @@ -36,6 +36,8 @@ type forecastSiteConfig struct { bindingDirty bool configuredAt time.Time heatingPrior, ratedPV float64 + annualBase float64 + priorRevision string } type forecastIdentityReceipt struct { @@ -50,7 +52,7 @@ const forecastIdentityReceiptKey = "forecast/live_identity_v1" // The evaluation cohort keys on it rather than on the Core version, so error // bands and baselines survive updates that leave forecasting alone. Bump it // whenever Core changes what reaches the planner. -const forecastPipelinePolicy = "energyplan-primary-v4" +const forecastPipelinePolicy = "energyplan-primary-v5" func newForecastSiteConfig(st *state.Store) *forecastSiteConfig { id, _ := st.LoadConfig("forecast/site_id") @@ -109,12 +111,18 @@ func (s *forecastSiteConfig) Configure(cfg *config.Config, catalog []drivers.Cat driverInputs := append([]config.Driver(nil), cfg.Drivers...) sort.Slice(driverInputs, func(i, j int) bool { return driverInputs[i].Name < driverInputs[j].Name }) learningDrivers := forecastLearningDrivers(v.Meter, v.Options.ExpectedFlows, driverInputs) - baseRevision, err := forecastStaticRevision(v, weather, learningDrivers) + // Nameplate and heating estimates change predictions, not the meaning of + // a measured sample. Keep their revision out of the learning identity. + learningWeather := forecastLearningWeather(weather) + baseRevision, err := forecastStaticRevision(v, learningWeather, learningDrivers) // Earlier policies hashed the resolved script path: beta.4 the measurement // drivers, beta.3 every driver. Verify that exact old contract before // retaining its learning ID. A release that also moved the path cannot // match, so such a site starts over once, as every update did before. earlier := make([]string, 0, 2) + if old, oldErr := forecastStaticRevision(v, weather, learningDrivers); oldErr == nil { + earlier = append(earlier, old) + } for _, inputs := range [][]config.Driver{learningDrivers, driverInputs} { if old, oldErr := forecastResolvedPathRevision(v, weather, inputs); oldErr == nil { earlier = append(earlier, old) @@ -172,6 +180,12 @@ func (s *forecastSiteConfig) Configure(cfg *config.Config, catalog []drivers.Cat s.heatingPrior = cfg.Weather.HeatingWPerDegC } s.ratedPV = forecastRatedPVW(cfg.Weather) + s.annualBase, _ = cfg.AnnualBaseKWh() + priorData, _ := json.Marshal(struct { + Weather *config.Weather + AnnualBase float64 + }{weather, s.annualBase}) + s.priorRevision = fmt.Sprintf("%x", sha256.Sum256(priorData)) s.mu.Unlock() s.RefreshIdentity(time.Now()) return previous != s.Snapshot().Revision @@ -216,7 +230,7 @@ func (s *forecastSiteConfig) RefreshIdentity(now time.Time) bool { learningBase = s.accepted.LearningBaseRevision } learning := fmt.Sprintf("site-v2:%x", sha256.Sum256([]byte(learningBase+"/"+string(data)))) - cohort := learning + "/" + s.engineVersion + "/" + forecastPipelinePolicy + cohort := learning + "/" + s.engineVersion + "/" + forecastPipelinePolicy + "/" + s.priorRevision revision := fmt.Sprintf("forecast-v1:%x", sha256.Sum256([]byte(cohort))) opts := s.baseOptions if pending && opts.HouseholdInvalidReason == "" { @@ -366,6 +380,23 @@ func (s *forecastSiteConfig) ModelPriors() (heating, rated float64) { return s.heatingPrior, s.ratedPV } +func (s *forecastSiteConfig) AnnualBase() float64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.annualBase +} + +func forecastLearningWeather(w *config.Weather) *config.Weather { + if w == nil { + return nil + } + cp := *w + cp.PVRatedW, cp.HeatingWPerDegC = 0, 0 + cp.PVArrays = nil + cp.PVTiltDeg, cp.PVAzimuthDeg = 0, 0 + return &cp +} + func forecastRatedPVW(w *config.Weather) float64 { if w == nil { return 0 diff --git a/go/cmd/ftw/forecast_site_test.go b/go/cmd/ftw/forecast_site_test.go index 6f13cb9f9..b9f067ba9 100644 --- a/go/cmd/ftw/forecast_site_test.go +++ b/go/cmd/ftw/forecast_site_test.go @@ -43,6 +43,23 @@ func TestForecastSiteSeparatesLearningAndEvaluationRevisions(t *testing.T) { if build.LearningRevision != first.LearningRevision || build.Revision == first.Revision { t.Fatal("worker build must change evaluation cohort, not learned site binding") } + + annual := 14000.0 + noEV := false + cfg.ForecastPriors = &config.ForecastPriors{AnnualKWh: &annual, AnnualBasis: "total_consumption", IncludesEV: &noEV} + cfg.Weather.PVTiltDeg = 30 + cfg.Weather.HeatingWPerDegC = 180 + cfg.ForecastPriors.IncludesHeating = &noEV + s.Configure(cfg, nil) + prior := s.Snapshot() + if prior.LearningRevision != build.LearningRevision || prior.Revision == build.Revision || s.AnnualBase() != annual { + t.Fatal("owner estimates must change predictions without discarding measured learning") + } + cfg.ForecastPriors.Source = "corrected source label" + s.Configure(cfg, nil) + if s.Snapshot().Revision != prior.Revision { + t.Fatal("source note reset prediction calibration") + } cfg.Drivers[0].Config["scale"] = 2.0 s.Configure(cfg, nil) scaled := s.Snapshot() @@ -473,3 +490,31 @@ func TestForecastEvaluationCohortSurvivesCoreUpdate(t *testing.T) { t.Fatal("a new forecast worker kept the old evaluation cohort") } } + +func TestForecastSolarPriorKeepsExistingMeasurementLearning(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + cfg := &config.Config{Drivers: []config.Driver{{Name: "meter", Lua: "meter.lua", IsSiteMeter: true}, {Name: "pv", Lua: "pv.lua"}}, Weather: &config.Weather{Provider: "open_meteo", Latitude: 59, Longitude: 18, PVRatedW: 6000}} + catalog := []drivers.CatalogEntry{{Filename: "meter.lua", Capabilities: []string{"meter"}}, {Filename: "pv.lua", Capabilities: []string{"pv"}}} + s := newForecastSiteConfig(st) + s.identity = func(name string) (string, bool) { return name + ":serial", true } + s.Configure(cfg, catalog) + before := s.Snapshot() + cfg.Weather.PVRatedW = 9000 + cfg.Weather.PVTiltDeg = 35 + cfg.Weather.PVAzimuthDeg = 180 + s.Configure(cfg, catalog) + after := s.Snapshot() + if after.IdentityPending || before.LearningRevision != after.LearningRevision || before.Revision == after.Revision { + t.Fatal("solar estimates reset measured learning or retained old evaluation cohort") + } + restarted := newForecastSiteConfig(st) + restarted.identity = s.identity + restarted.Configure(cfg, catalog) + if restarted.Snapshot().LearningRevision != before.LearningRevision { + t.Fatal("solar prior discarded learning after restart") + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index e2b154523..7241b681a 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -707,6 +707,34 @@ func main() { // cfgMu guards cfg, which a config apply replaces in place. cfgMu := &sync.RWMutex{} reg := newDriverRegistry(tel, st) + setupMeasurementAtBoot := cfg.MeasurementOnly() + reg.ControlBlocked = func() bool { return setupMeasurementAtBoot } + setupRuntimeBlockReason := func() string { + cfgMu.RLock() + configuredMeasurementOnly := cfg.MeasurementOnly() + meter, expected := cfg.SiteMeterDriver(), "" + if cfg.Setup != nil { + expected = cfg.Setup.MeterIdentity + } + cfgMu.RUnlock() + if setupMeasurementAtBoot { + if !configuredMeasurementOnly { + return "setup_restart_required" + } + return "setup_measurement_only" + } + if expected != "" { + id, ok := runningDeviceID(reg, meter) + if !ok { + return "setup_meter_identity_unconfirmed" + } + if id != expected { + return "setup_meter_identity_changed" + } + } + return "" + } + reg.CommandBlocked = func() bool { return setupRuntimeBlockReason() != "" } reg.SiteLocation = func() (float64, float64, bool) { cfgMu.RLock() defer cfgMu.RUnlock() @@ -1014,6 +1042,7 @@ func main() { slog.Warn("loadmodel configuration not saved", "err", err) } loadSvc.SeedHeatingCoef(heating) + loadSvc.SetAnnualBase(forecastSettings.AnnualBase()) } } if pvSvc != nil { @@ -1369,6 +1398,8 @@ func main() { slog.Warn("loadmodel configuration not saved", "err", err) } } + annualBase, _ := cfg.AnnualBaseKWh() + loadSvc.SetAnnualBase(annualBase) // SeedHeatingCoef — operator config is a cold-start prior. Once the // load model has accumulated samples in production, its // telemetry-fit HeatingW_per_degC survives restart and the config @@ -1878,7 +1909,12 @@ func main() { // SendCycle and must take this same path; wiring those straight to the // registry is how planner ticks never reached an OCPP wallbox. evRouter := newEVCommandRouter(ocppSrv, reg.Send, reg.SendWithOutcome, reg.SendEVContinuation) - evSend := evRouter.Send + evSend := func(ctx context.Context, name string, payload []byte) error { + if reg.ControlBlocked() || reg.CommandBlocked() && !drivers.IsEVStanddown(payload) { + return fmt.Errorf("setup measurement mode or meter identity blocks charger commands") + } + return evRouter.Send(ctx, name, payload) + } // ---- EV loadpoint controller ---- // loadpoint.Controller owns per-tick EV dispatch, including the @@ -1922,8 +1958,18 @@ func main() { // current and the plan keeps counting the load. Only the periodic // ev_set_current is reported — see loadpoint.DispatchOutcomeFunc // for the sends that are deliberately not. - lpController.SetOutcomeSender(evRouter.SendWithOutcome) - lpController.SetCycleSender(evRouter.SendCycle) + lpController.SetOutcomeSender(func(ctx context.Context, name string, payload []byte, outcome func(error)) error { + if reg.ControlBlocked() || reg.CommandBlocked() && !drivers.IsEVStanddown(payload) { + return fmt.Errorf("setup measurement mode or meter identity blocks charger commands") + } + return evRouter.SendWithOutcome(ctx, name, payload, outcome) + }) + lpController.SetCycleSender(func(ctx context.Context, name string, payload []byte, cycleID uint64) error { + if reg.ControlBlocked() || reg.CommandBlocked() && !drivers.IsEVStanddown(payload) { + return fmt.Errorf("setup measurement mode or meter identity blocks charger commands") + } + return evRouter.SendCycle(ctx, name, payload, cycleID) + }) lpController.SetVehicleControlAllowed(reg.CanControl) lpController.SetDispatchOutcome(actuation.recordCommandOutcome) lpController.SetDriverOnline(func(name string) bool { @@ -2504,8 +2550,37 @@ func main() { OCPPChargers: ocppChargersFn, EVSend: evSend, SiteDispatchBlocked: func() string { + if reason := setupRuntimeBlockReason(); reason != "" { + return reason + } return siteDispatchNow(tel, cfg, cfgMu, ctrl, ctrlMu, time.Now()).Reason }, + SetupRuntimeMeasurementOnly: func() bool { return setupMeasurementAtBoot }, + SetupMeterIdentity: func(name string) (string, bool) { return runningDeviceID(reg, name) }, + SetupControlReady: func(candidate *config.Config) error { + if err := candidate.Validate(); err != nil { + return err + } + meter := candidate.SiteMeterDriver() + cfgMu.RLock() + same := sameSetupMeterConfiguration(cfg, candidate) + cfgMu.RUnlock() + if !same { + return fmt.Errorf("save and measure the selected site meter before enabling control") + } + id, ok := runningDeviceID(reg, meter) + if !ok || candidate.Setup == nil || id != candidate.Setup.MeterIdentity { + return fmt.Errorf("the selected site meter has not confirmed this device identity") + } + timeout := time.Duration(candidate.Site.WatchdogTimeoutS) * time.Second + if timeout <= 0 { + timeout = 60 * time.Second + } + if gate := evaluateSiteDispatchFreshnessAt(tel, meter, candidate.Fuse.MaxAmps, candidate.Fuse.Phases, timeout, time.Now()); !gate.Allowed() { + return fmt.Errorf("measure before enabling control: %s", gate.Reason) + } + return nil + }, HistoryMeasurementSources: liveMeasurementOptions, SiteMeasurementSources: func() telemetry.ForecastOptions { return forecastSettings.Snapshot().Options @@ -2868,7 +2943,18 @@ func main() { cfgMu.RLock() watchdogTimeout := time.Duration(cfg.Site.WatchdogTimeoutS) * time.Second troubleshootingMode := cfg.Site.TroubleshootingMode + measurementOnly := setupMeasurementAtBoot cfgMu.RUnlock() + if measurementOnly { + // Keep the record and telemetry alive without watchdog defaults, + // charger standdowns, self-tuning or normal physical dispatch. + lpMgr.RollSchedules(tickNow.UTC()) + carsRT.Tick() + if _, err := persistTelemetryTick(st, tel, snapshotTickPersistControl(ctrl, ctrlMu), nowMs, watchdogTimeout, energyIdentity, forecastSettings.Snapshot().HistoryOptions); err != nil { + slog.Warn("setup measurement persistence failed", "err", err) + } + continue + } if watchdogTimeout <= 0 { watchdogTimeout = 60 * time.Second } @@ -2918,6 +3004,9 @@ func main() { freshness := evaluateSiteDispatchFreshnessAt( tel, siteMeterDriver, siteFuseAmps, siteFusePhases, watchdogTimeout, tickNow, ) + if reason := setupRuntimeBlockReason(); reason != "" { + freshness.Reason = reason + } pendingDefaults, enteredStale, recovered := staleDefaults.update( !freshness.Allowed(), siteMeterDriver, reg.Names(), ) diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 1909e8ac4..d9e2a9b4b 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -198,6 +198,11 @@ type Deps struct { // "" when it is allowed. Operator setpoints that bypass the tick (the // manual V2X command) check it. Nil refuses those setpoints. SiteDispatchBlocked func() string + // SetupControlReady checks live measurement and identity against the + // exact draft before its first transition from measurement to control. + SetupControlReady func(*config.Config) error + SetupMeterIdentity func(string) (string, bool) + SetupRuntimeMeasurementOnly func() bool // Optional: returns the running HA MQTT bridge, or nil when HA is // disabled or not connected yet. Nil func means HA is not wired. @@ -1720,6 +1725,20 @@ func (s *Server) handlePostConfigWith(w http.ResponseWriter, r *http.Request, re s.deps.CfgMu.RLock() oldCfg := *s.deps.Cfg s.deps.CfgMu.RUnlock() + if s.deps.SetupRuntimeMeasurementOnly != nil && !s.deps.SetupRuntimeMeasurementOnly() && !(newCfg.Fuse.MaxAmps > 0) { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "Keep a positive main fuse while control is active. Save measurement mode, restart, then change unknown connection values."}) + return + } + if oldCfg.MeasurementOnly() && !newCfg.MeasurementOnly() { + if s.deps.SetupControlReady == nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "Live commissioning check is unavailable; keep measurement mode on."}) + return + } + if err := s.deps.SetupControlReady(&newCfg); err != nil { + writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()}) + return + } + } // The house-password flag only moves through POST /api/auth/password. // A whole-document save must not turn the lock on (or off) by itself. newCfg.API.LANAuth = oldCfg.API.LANAuth diff --git a/go/internal/api/api_agents.go b/go/internal/api/api_agents.go index 3505f4f4d..7634e13d6 100644 --- a/go/internal/api/api_agents.go +++ b/go/internal/api/api_agents.go @@ -21,6 +21,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "github.com/srcfl/ftw/go/internal/localzone" "net/http" "slices" "strconv" @@ -71,6 +72,7 @@ type AgentRequest struct { // AgentFacts say whether this box can take a grant now, and where the owner's // browser looks a code up. type AgentFacts struct { + Timezone string `json:"timezone"` // Offers says this build pins an FTW Cloud offer key. A build that pins // none opens no offer. Offers bool `json:"offers"` @@ -242,6 +244,7 @@ func (s *Server) handleAgentsList(w http.ResponseWriter, r *http.Request) { } a := s.deps.Agents facts := a.Facts() + facts.Timezone = localzone.Name() facts.Widen, _ = lanSecretFrom(r.Context()) writeJSON(w, http.StatusOK, agentsPage{ Agents: nonNil(a.Agents()), Requests: nonNil(a.Requests()), Keys: nonNil(a.ApprovalKeys()), diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index b37c25f36..0747bc92e 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -231,6 +231,17 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { cfg = wrapped.Drivers[0] } + s.runDriverTest(w, r, cfg, nil) +} + +// runDriverTest also serves setup drafts. A setup caller supplies credentials +// from its exact draft and a private rotation sink; it never borrows or writes +// the live Settings document. +func (s *Server) runDriverTest(w http.ResponseWriter, r *http.Request, cfg config.Driver, prepareProbe func(*drivers.Registry, string)) { + if strings.TrimSpace(cfg.Lua) == "" { + writeJSON(w, 400, map[string]string{"error": "missing driver lua path"}) + return + } // Resolve UI-relative Lua paths the same way config.Load does. baseDir := "." if s.deps.ConfigPath != "" { @@ -287,7 +298,12 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { reg.MQTTFactory = s.deps.DriverMQTTFactory reg.ModbusFactory = s.deps.DriverModbusFactory reg.ARPLookup = s.deps.DriverARPLookup - probeChangedSharedSecret := s.wireDriverProbeSecrets(reg, testName, probe) + probeChangedSharedSecret := func() string { return "" } + if prepareProbe != nil { + prepareProbe(reg, testName) + } else { + probeChangedSharedSecret = s.wireDriverProbeSecrets(reg, testName, probe) + } ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) defer cancel() diff --git a/go/internal/api/api_drivers_fingerprint.go b/go/internal/api/api_drivers_fingerprint.go index 4bdcb6b82..de97dcf35 100644 --- a/go/internal/api/api_drivers_fingerprint.go +++ b/go/internal/api/api_drivers_fingerprint.go @@ -9,6 +9,7 @@ package api import ( + "context" "fmt" "net" "net/http" @@ -121,6 +122,10 @@ func (s *Server) handleDriverFingerprint(w http.ResponseWriter, r *http.Request) // unreachable host can stall the caller; candidates that don't fit are // simply not probed. func (s *Server) sweepFingerprint(protocol, host string, port, unit int) []drivers.Fingerprint { + return s.sweepFingerprintContext(context.Background(), protocol, host, port, unit) +} + +func (s *Server) sweepFingerprintContext(ctx context.Context, protocol, host string, port, unit int) []drivers.Fingerprint { dir := s.deps.DriverDir if dir == "" { dir = filepath.Join(filepath.Dir(s.deps.ConfigPath), "drivers") @@ -132,6 +137,9 @@ func (s *Server) sweepFingerprint(protocol, host string, port, unit int) []drive deadline := time.Now().Add(fingerprintBudget) var tried []drivers.Fingerprint for _, e := range entries { + if ctx.Err() != nil { + break + } if !hasProtocol(e.Protocols, protocol) { continue } @@ -257,14 +265,21 @@ type scannedDevice struct { // when no Modbus factory is wired — pass through untouched. This is what // turns "port 502 is open on 10.0.0.7" into "that's a SolarEdge". func (s *Server) enrichScanWithFingerprints(devices []scanner.FoundDevice) []scannedDevice { + return s.enrichScanWithFingerprintsContext(context.Background(), devices) +} + +func (s *Server) enrichScanWithFingerprintsContext(ctx context.Context, devices []scanner.FoundDevice) []scannedDevice { out := make([]scannedDevice, len(devices)) for i, d := range devices { + if ctx.Err() != nil { + return out[:i] + } sd := scannedDevice{FoundDevice: d} switch { case d.Protocol == "modbus" && s.deps.DriverModbusFactory != nil: - sd.Matches = matchesOf(s.sweepFingerprint("modbus", d.IP, d.Port, 1)) + sd.Matches = matchesOf(s.sweepFingerprintContext(ctx, "modbus", d.IP, d.Port, 1)) case d.Protocol == "http": - sd.Matches = matchesOf(s.sweepFingerprint("http", d.IP, d.Port, 1)) + sd.Matches = matchesOf(s.sweepFingerprintContext(ctx, "http", d.IP, d.Port, 1)) } out[i] = sd } diff --git a/go/internal/api/api_myuplink_oauth.go b/go/internal/api/api_myuplink_oauth.go index d4a3823ab..535d32262 100644 --- a/go/internal/api/api_myuplink_oauth.go +++ b/go/internal/api/api_myuplink_oauth.go @@ -78,6 +78,7 @@ type oauthPending struct { // B2C can). Bound to the state so it never leaves the Pi. codeVerifier string expires time.Time + setup *setupOAuthPending } // newPKCEPair returns a high-entropy code_verifier and its S256 @@ -251,7 +252,11 @@ func (s *Server) handleMyUplinkOAuthCallback(w http.ResponseWriter, r *http.Requ renderOAuthResult(w, false, err.Error()) return } - renderOAuthResult(w, true, "MyUplink connected for driver \""+driver+"\". You can close this tab and return to FTW.") + if driver.Draft { + renderOAuthResultTitle(w, true, "Sign-in saved to setup draft", "MyUplink sign-in is saved for "+driver.Driver+" in the setup draft. These settings are not running yet. Return to your assistant to review and continue.") + return + } + renderOAuthResult(w, true, "MyUplink connected for driver \""+driver.Driver+"\". You can close this tab and return to FTW.") } // handleMyUplinkOAuthExchange: POST /api/oauth/myuplink/exchange @@ -289,14 +294,23 @@ func (s *Server) handleMyUplinkOAuthExchange(w http.ResponseWriter, r *http.Requ writeJSON(w, 400, map[string]string{"error": err.Error()}) return } - writeJSON(w, 200, map[string]string{"status": "connected", "driver": driver}) + if driver.Draft { + writeJSON(w, 200, map[string]any{"status": "saved_to_draft", "driver": driver.Driver, "applied": false}) + return + } + writeJSON(w, 200, map[string]string{"status": "connected", "driver": driver.Driver}) } // completeMyUplinkConsent validates+consumes the pending state, exchanges the // authorization code for a refresh_token, and persists it (config + KV) with a // driver restart. Shared by the auto-callback (GET) and the manual exchange // (POST). Returns the driver name on success. Errors are operator-facing. -func (s *Server) completeMyUplinkConsent(r *http.Request, stateTok, code string) (string, error) { +type myUplinkConsentResult struct { + Driver string + Draft bool +} + +func (s *Server) completeMyUplinkConsent(r *http.Request, stateTok, code string) (myUplinkConsentResult, error) { oauthMu.Lock() pruneOAuthStateLocked(time.Now()) pending, found := oauthPending_[stateTok] @@ -306,26 +320,30 @@ func (s *Server) completeMyUplinkConsent(r *http.Request, stateTok, code string) oauthMu.Unlock() if !found { - return "", fmt.Errorf("invalid or expired state — start the connect again from Settings → Devices") + return myUplinkConsentResult{}, fmt.Errorf("invalid or expired sign-in state; start again with setup_connect or Settings → Devices. Your setup draft is retained") } if code == "" { - return "", fmt.Errorf("no authorization code found") + return myUplinkConsentResult{}, fmt.Errorf("no authorization code found") + } + if pending.setup != nil { + driver, err := s.completeSetupMyUplink(r, pending, code) + return myUplinkConsentResult{Driver: driver, Draft: true}, err } clientID, _ := s.driverConfigValue(pending.driver, "client_id") clientSecret, _ := s.driverConfigValue(pending.driver, "client_secret") if clientID == "" || clientSecret == "" { - return "", fmt.Errorf("missing Client ID / Secret for driver %q", pending.driver) + return myUplinkConsentResult{}, fmt.Errorf("missing Client ID / Secret for driver %q", pending.driver) } refreshToken, err := s.exchangeMyUplinkCode(code, clientID, clientSecret, pending.redirectURI, pending.codeVerifier) if err != nil { - return "", fmt.Errorf("token exchange failed: %w", err) + return myUplinkConsentResult{}, fmt.Errorf("token exchange failed: %w", err) } if err := s.persistMyUplinkRefreshToken(r, pending.driver, refreshToken); err != nil { - return "", fmt.Errorf("could not save the token: %w", err) + return myUplinkConsentResult{}, fmt.Errorf("could not save the token: %w", err) } - return pending.driver, nil + return myUplinkConsentResult{Driver: pending.driver}, nil } // exchangeMyUplinkCode swaps an authorization code for a refresh_token. @@ -421,8 +439,11 @@ func (s *Server) persistMyUplinkRefreshToken(r *http.Request, driver, refreshTok // renderOAuthResult writes a minimal self-contained result page. The browser // lands here from the MyUplink redirect, so it cannot rely on the SPA chrome. func renderOAuthResult(w http.ResponseWriter, ok bool, msg string) { + renderOAuthResultTitle(w, ok, "MyUplink connected", msg) +} + +func renderOAuthResultTitle(w http.ResponseWriter, ok bool, title, msg string) { status := http.StatusOK - title := "MyUplink connected" accent := "#1f9d57" if !ok { status = http.StatusBadRequest diff --git a/go/internal/api/api_setup.go b/go/internal/api/api_setup.go index dfd4e145f..ff7f3e55e 100644 --- a/go/internal/api/api_setup.go +++ b/go/internal/api/api_setup.go @@ -7,13 +7,17 @@ import ( "fmt" "io" "net/http" + "os" "path/filepath" "slices" + "time" "github.com/google/uuid" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/localzone" "github.com/srcfl/ftw/go/internal/onboarding" + "github.com/srcfl/ftw/go/internal/telemetry" ) // One identity per process, rather than per API server, so rebuilding the @@ -63,12 +67,13 @@ func (s *Server) setupRoutes() { s.handle("POST /api/setup/validate", Configure, s.handleSetupValidate) s.handle("POST /api/setup/commit", Configure, s.handleSetupCommit) s.handle("POST /api/setup/restart", Configure, s.handleSetupRestart) + s.handle("POST /api/setup/connection", Configure, s.handleSetupConnection) } func (s *Server) handleSetupSchema(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "driver_catalog": s.driverCatalogReply(), "price_zones": priceZoneItems(), "runtime_timezone": localzone.Name(), - "schema": onboarding.Schema(), "drivers": "/api/drivers/catalog", "price_areas": "/api/prices/zones", + "schema": onboarding.Schema(), "control_modes": control.ModeCatalog(), "drivers": "/api/drivers/catalog", "price_areas": "/api/prices/zones", "workflow": []string{ "Read the current draft. Ask only for missing facts and choices.", "Discover equipment only when the user asks; reading a draft does not scan.", @@ -174,7 +179,13 @@ func (s *Server) writeSetupDraft(w http.ResponseWriter, live *config.Config, d o } } w.Header().Set("Cache-Control", "no-store") + hostname, _ := os.Hostname() + home := "" + if s.deps.Agents != nil { + home = s.deps.Agents.Home() + } writeJSON(w, http.StatusOK, map[string]any{ + "box": map[string]any{"hostname": hostname, "version": s.deps.Version, "home": home}, "revision": d.Revision, "base_revision": d.BaseRevision, "live_revision": live.Revision, "normalized_from_revision": d.NormalizedFromRevision, "validation_warnings": d.ValidationWarnings, "normalized": d.NormalizedFromRevision > 0, @@ -183,14 +194,72 @@ func (s *Server) writeSetupDraft(w http.ResponseWriter, live *config.Config, d o "normalization_required": validation == "" && !bytes.Equal(normalizedJSON, d.Config), "saved": saved, "applied": applied, "hardware_verified": false, "last_commit": receipt, "restart_pending": restartPending, "restart_reasons": restartReasons, + "guide": onboarding.Guide(&candidate), "commissioning": s.setupCommissioning(live), "next_step": "Review the returned draft after validation, including a new revision, checksum or normalization warnings. Save only that exact reviewed revision. After a completed save, a new draft starts from current settings.", }) } +func (s *Server) setupCommissioning(live *config.Config) map[string]any { + meter := live.SiteMeterDriver() + id, identified := "", false + if s.deps.SetupMeterIdentity != nil { + id, identified = s.deps.SetupMeterIdentity(meter) + } + var reading *telemetry.DerReading + if s.deps.Tel != nil { + reading = s.deps.Tel.Get(meter, telemetry.DerMeter) + } + timeout := time.Duration(live.Site.WatchdogTimeoutS) * time.Second + if timeout <= 0 { + timeout = 60 * time.Second + } + fresh := reading != nil && time.Since(reading.UpdatedAt) <= timeout + blocked := "live_control_check_unavailable" + if s.deps.SiteDispatchBlocked != nil { + blocked = s.deps.SiteDispatchBlocked() + } + measurementOnly := live.MeasurementOnly() + if s.deps.SetupRuntimeMeasurementOnly != nil { + measurementOnly = s.deps.SetupRuntimeMeasurementOnly() + } + mode := "" + if s.deps.Ctrl != nil && s.deps.CtrlMu != nil { + s.deps.CtrlMu.Lock() + mode = string(s.deps.Ctrl.Mode) + s.deps.CtrlMu.Unlock() + } + var prefs any + if s.deps.PlannerPrefs != nil { + trust, export, k := s.deps.PlannerPrefs.Get() + prefs = map[string]any{"forecast_trust": trust, "battery_export": export, "safety_k": k} + } + devices := []map[string]any{} + for _, d := range live.Drivers { + item := map[string]any{"name": d.Name, "configured": true, "hardware_control_verified": false} + if s.deps.Tel != nil { + item["health"] = s.deps.Tel.DriverHealth(d.Name) + } + devices = append(devices, item) + } + return map[string]any{ + "site_meter": meter, "meter_identity": id, "meter_identified": identified, + "meter_fresh": fresh, "measurement_only": measurementOnly, "configured_measurement_only": live.MeasurementOnly(), + "control_blocked_reason": blocked, "devices": devices, + "runtime_timezone": localzone.Name(), "active_operating_mode": mode, "active_planner_preferences": prefs, + "hardware_control_verified": false, + "next": "Check fresh measurements from each selected device. Device contact, command acceptance and measured physical effect are separate facts. Offer bounded control tests only after the user agrees to their effect. Do not claim an untested device works.", + } +} + type setupEdit struct { - Revision int64 `json:"revision"` - Patch json.RawMessage `json:"patch"` - Operations json.RawMessage `json:"operations"` + Revision int64 `json:"revision"` + Patch json.RawMessage `json:"patch"` + Operations json.RawMessage `json:"operations"` + Action string `json:"action,omitempty"` + Driver string `json:"driver,omitempty"` + Confirmed bool `json:"confirmed,omitempty"` + Fingerprint bool `json:"fingerprint,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` } func setupJSON(w http.ResponseWriter, r *http.Request, v any) bool { @@ -213,6 +282,14 @@ func (s *Server) handleSetupEdit(w http.ResponseWriter, r *http.Request) { if !setupJSON(w, r, &q) { return } + if q.Action != "" { + s.handleSetupPrepare(w, r, q) + return + } + if q.Driver != "" || q.Confirmed || q.Fingerprint || q.RedirectURI != "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "setup action required"}) + return + } live, err := s.setupLive() if err != nil { s.setupError(w, err) diff --git a/go/internal/api/api_setup_connection.go b/go/internal/api/api_setup_connection.go new file mode 100644 index 000000000..e9e18c8cd --- /dev/null +++ b/go/internal/api/api_setup_connection.go @@ -0,0 +1,44 @@ +package api + +import ( + "net/http" + + "github.com/srcfl/ftw/go/internal/config" +) + +// The local Settings review computes the same binding as the MCP guide. +// It changes no settings or draft and grants no permission by itself. +func (s *Server) handleSetupConnection(w http.ResponseWriter, r *http.Request) { + var q struct { + Fuse config.Fuse `json:"fuse"` + } + if !setupJSON(w, r, &q) { + return + } + check := config.NewSetup() + check.Setup, check.Fuse = nil, q.Fuse + if err := check.Validate(); err != nil { + s.setupError(w, err) + return + } + live, err := s.setupLive() + if err != nil { + s.setupError(w, err) + return + } + digest := check.SetupConnectionDigest() + id, _ := s.setupCommissioning(live)["meter_identity"].(string) + candidate := *live + candidate.Fuse = q.Fuse + candidate.Setup = &config.Setup{ConnectionConfirmed: true, EquipmentReviewed: true, ConnectionDigest: digest, MeterIdentity: id} + ready, reason := false, "Live equipment checks are unavailable. Keep measurement mode on." + if s.deps.SetupControlReady != nil { + if err := s.deps.SetupControlReady(&candidate); err != nil { + reason = err.Error() + } else { + ready, reason = true, "" + } + } + writeJSON(w, http.StatusOK, map[string]any{"connection_digest": digest, "fuse": q.Fuse, "site_meter": live.SiteMeterDriver(), "meter_identity": id, + "can_enable_control": ready, "blocked_reason": reason, "saved": false, "hardware_control_verified": false}) +} diff --git a/go/internal/api/api_setup_oauth.go b/go/internal/api/api_setup_oauth.go new file mode 100644 index 000000000..2b73ef90e --- /dev/null +++ b/go/internal/api/api_setup_oauth.go @@ -0,0 +1,121 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/url" + "path/filepath" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" + "github.com/srcfl/ftw/go/internal/onboarding" +) + +type setupOAuthPending struct { + server *Server + draftRevision, liveRevision int64 + credentialOwner string + authorize func() error +} + +func (s *Server) startSetupMyUplink(r *http.Request, driver config.Driver, revision, base int64, redirect string) (any, error) { + resolved := config.Config{Drivers: []config.Driver{driver}} + resolved.ResolveDriverPaths(filepath.Dir(s.deps.ConfigPath)) + entry, err := drivers.ParseCatalogFile(resolved.Drivers[0].Lua) + if err != nil || entry.ID != "myuplink" { + return nil, errors.New("this driver has no built-in browser sign-in; use its catalogue connection fields in the draft") + } + clientID, _ := driver.Config["client_id"].(string) + clientSecret, _ := driver.Config["client_secret"].(string) + if clientID == "" || clientSecret == "" { + return nil, errors.New("save this vendor's Client ID and Client Secret in the draft first; do not send tokens to the assistant") + } + if !validMyUplinkCallback(redirect) { + return nil, errors.New("provide the box's browser callback address ending in /api/oauth/myuplink/callback; it must match the vendor registration") + } + verifier, challenge, err := newPKCEPair() + if err != nil { + return nil, err + } + state := make([]byte, 16) + if _, err := rand.Read(state); err != nil { + return nil, err + } + stateToken := hex.EncodeToString(state) + scope, _ := driver.Config["oauth_scope"].(string) + bound := &setupOAuthPending{server: s, draftRevision: revision, liveRevision: base, credentialOwner: driver.CredentialOwner} + bound.authorize, _ = r.Context().Value(setupAuthorityKey{}).(func() error) + oauthMu.Lock() + pruneOAuthStateLocked(time.Now()) + if len(oauthPending_) >= 128 { + oauthMu.Unlock() + return nil, errors.New("too many pending vendor sign-ins; finish one or wait for it to expire") + } + oauthPending_[stateToken] = oauthPending{driver: driver.Name, redirectURI: redirect, codeVerifier: verifier, expires: time.Now().Add(15 * time.Minute), setup: bound} + oauthMu.Unlock() + q := url.Values{"response_type": {"code"}, "client_id": {clientID}, "redirect_uri": {redirect}, "scope": {myUplinkOAuthScope(scope)}, "state": {stateToken}, "code_challenge": {challenge}, "code_challenge_method": {"S256"}} + return map[string]any{ + "provider": "myuplink", "authorize_url": myUplinkOAuthBase + "/oauth/authorize?" + q.Encode(), "redirect_uri": redirect, + "expires_in_s": 900, "draft_revision": revision, + "manual_completion_path": "/setup-vendor.html", + "manual_completion_help": "If the registered callback cannot reach this box, open /setup-vendor.html on the local box address you use for FTW, sign in with the house password, then paste the full redirected URL there. Keep that URL out of chat.", + "next": "Open the vendor's sign-in page in your browser. Credentials return only to this exact box draft. Leave the draft unchanged until sign-in finishes; then read setup_status. If it expires or the draft changes, start sign-in again. Never paste access or refresh tokens into chat.", + }, nil +} + +func (s *Server) completeSetupMyUplink(r *http.Request, pending oauthPending, code string) (string, error) { + bound := pending.setup + if bound.server != s { + return "", errors.New("vendor sign-in belongs to another box session") + } + live, err := s.setupLive() + if err != nil { + return "", err + } + if live.Revision != bound.liveRevision { + return "", onboarding.ErrConflict + } + check := func() error { + if err := r.Context().Err(); err != nil { + return err + } + if bound.authorize != nil { + if err := bound.authorize(); err != nil { + return errors.New("setup access ended; connect again before signing in") + } + } + current, err := s.setupLive() + if err != nil { + return err + } + if current.Revision != bound.liveRevision { + return onboarding.ErrConflict + } + return nil + } + _, err = s.setup.Prepare(live, bound.draftRevision, func(candidate *config.Config) error { + for i := range candidate.Drivers { + driver := &candidate.Drivers[i] + if driver.Name != pending.driver || driver.CredentialOwner != bound.credentialOwner { + continue + } + clientID, _ := driver.Config["client_id"].(string) + clientSecret, _ := driver.Config["client_secret"].(string) + if clientID == "" || clientSecret == "" { + return errors.New("vendor client credentials are missing from this draft") + } + token, err := s.exchangeMyUplinkCode(code, clientID, clientSecret, pending.redirectURI, pending.codeVerifier) + if err != nil { + return fmt.Errorf("vendor sign-in failed; start it again: %w", err) + } + driver.Config["refresh_token"] = token + return nil + } + return errors.New("the draft's selected driver changed; start sign-in again") + }, check) + return pending.driver, err +} diff --git a/go/internal/api/api_setup_oauth_test.go b/go/internal/api/api_setup_oauth_test.go new file mode 100644 index 000000000..acc4695dc --- /dev/null +++ b/go/internal/api/api_setup_oauth_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/srcfl/ftw/go/protocol" +) + +func TestSetupVendorConsentStaysWithTheExactDraftAndGrant(t *testing.T) { + var exchanges atomic.Int32 + vendor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + exchanges.Add(1) + if err := r.ParseForm(); err != nil { + t.Error(err) + } + if r.Form.Get("client_secret") != "draft-client-secret" || r.Form.Get("code_verifier") == "" { + t.Error("token exchange lost its draft credentials or PKCE") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"refresh_token":"vendor-refresh-secret"}`)) + })) + defer vendor.Close() + old := myUplinkOAuthBase + myUplinkOAuthBase = vendor.URL + defer func() { myUplinkOAuthBase = old }() + for _, scenario := range []string{"success", "draft_changed", "revoked"} { + t.Run(scenario, func(t *testing.T) { + s, live := setupTestServer(t) + file := filepath.Join(t.TempDir(), "myuplink.lua") + if err := os.WriteFile(file, []byte("DRIVER = {\n id=\"myuplink\",\n config_secrets={\"client_secret\", \"refresh_token\"}\n}\n"), 0600); err != nil { + t.Fatal(err) + } + s.deps.UserDriverDir = filepath.Dir(file) + w, draft := setupCall(t, s, "POST", "/api/setup/draft", map[string]any{"revision": 0, "patch": map[string]any{"drivers": []any{map[string]any{ + "name": "heat", "lua": file, "capabilities": map[string]any{"standalone": true}, "config": map[string]any{"client_id": "draft-client", "client_secret": "draft-client-secret"}, + }}}}) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + revoked := false + guard := func() error { + if revoked { + return errors.New("revoked") + } + return nil + } + w, started := agentSetupCall(t, s, protocol.MsgSetupEdit, map[string]any{ + "revision": draft["revision"], "action": "connect", "driver": "heat", "confirmed": true, + "redirect_uri": "http://box.local:8080/api/oauth/myuplink/callback", + }, []string{protocol.ScopeSetupWrite}, guard) + if w.Code != 200 || strings.Contains(w.Body.String(), "draft-client-secret") { + t.Fatal(w.Body.String()) + } + + if started["result"].(map[string]any)["manual_completion_path"] != "/setup-vendor.html" { + t.Fatal("fallback must use owner's local box address") + } + link, err := url.Parse(started["result"].(map[string]any)["authorize_url"].(string)) + if err != nil { + t.Fatal(err) + } + if scenario == "draft_changed" { + w, _ = setupCall(t, s, "POST", "/api/setup/draft", map[string]any{"revision": draft["revision"], "patch": map[string]any{"site": map[string]any{"name": "Changed while signing in"}}}) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + } + revoked = scenario == "revoked" + beforeExchanges := exchanges.Load() + callback := httptest.NewRequest("GET", "http://box.local:8080/api/oauth/myuplink/callback?code=one-time-code&state="+link.Query().Get("state"), nil) + reply := httptest.NewRecorder() + s.Handler().ServeHTTP(reply, callback) + stored, err := s.setup.Read(live) + if err != nil { + t.Fatal(err) + } + if live.EVCharger.Password != "before-secret" || strings.Contains(reply.Body.String(), "vendor-refresh-secret") { + t.Fatal("consent changed live config or returned a token") + } + if scenario == "success" { + if reply.Code != 200 || !strings.Contains(string(stored.Config), "vendor-refresh-secret") || stored.Revision != 2 || !strings.Contains(reply.Body.String(), "saved to setup draft") { + t.Fatal(reply.Code, reply.Body.String(), stored.Revision) + } + } else if reply.Code < 400 || exchanges.Load() != beforeExchanges || strings.Contains(string(stored.Config), "vendor-refresh-secret") { + t.Fatal("stale or revoked consent reached the vendor or changed a draft", reply.Code, reply.Body.String()) + } + }) + } +} diff --git a/go/internal/api/api_setup_prepare.go b/go/internal/api/api_setup_prepare.go new file mode 100644 index 000000000..fd48f1a74 --- /dev/null +++ b/go/internal/api/api_setup_prepare.go @@ -0,0 +1,263 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" + "github.com/srcfl/ftw/go/internal/onboarding" + "github.com/srcfl/ftw/go/internal/scanner" +) + +func (s *Server) handleSetupPrepare(w http.ResponseWriter, r *http.Request, q setupEdit) { + if !q.Confirmed || len(q.Patch) > 0 || len(q.Operations) > 0 { + writeJSON(w, 400, map[string]string{"error": "confirm one setup action without a config patch"}) + return + } + if q.Action != "discover" && q.Action != "test" && q.Action != "connect" { + writeJSON(w, 400, map[string]string{"error": "unknown setup action"}) + return + } + live, err := s.setupLive() + if err != nil { + s.setupError(w, err) + return + } + check := func() error { + if err := setupWriteCheck(r)(); err != nil { + return err + } + current, err := s.setupLive() + if err != nil { + return err + } + if current.Revision != live.Revision { + return onboarding.ErrConflict + } + return nil + } + var result any + var secrets []string + if q.Action == "discover" { + if q.Driver != "" || q.RedirectURI != "" { + s.setupError(w, errors.New("discovery does not take a driver or redirect URI")) + return + } + before, err := s.setup.Read(live) + if err != nil { + s.setupError(w, err) + return + } + if before.Revision != q.Revision || before.BaseRevision != live.Revision { + s.setupError(w, onboarding.ErrConflict) + return + } + if err := check(); err != nil { + s.setupError(w, err) + return + } + devices, err := scanner.Scan(r.Context()) + if err != nil { + s.setupError(w, err) + return + } + if q.Fingerprint { + result = s.enrichScanWithFingerprintsContext(r.Context(), devices) + } else { + result = devices + } + if err := check(); err != nil { + s.setupError(w, err) + return + } + after, err := s.setup.Read(live) + if err != nil { + s.setupError(w, err) + return + } + if before.Revision != after.Revision || before.SHA256 != after.SHA256 { + s.setupError(w, onboarding.ErrConflict) + return + } + writeJSON(w, 200, map[string]any{"action": q.Action, "revision": after.Revision, "result": result, "hardware_control_verified": false, + "next": "Review found devices before adding them to the draft. Discovery does not select, configure or command equipment."}) + return + } + d, err := s.setup.Prepare(live, q.Revision, func(candidate *config.Config) error { + if q.Fingerprint { + return errors.New("fingerprint is only for discovery") + } + var selected *config.Driver + for i := range candidate.Drivers { + if candidate.Drivers[i].Name == q.Driver { + selected = &candidate.Drivers[i] + break + } + } + if selected == nil { + return errors.New("select a driver in this draft first") + } + secrets = s.setupSecretValues(candidate) + if q.Action == "connect" { + result, err = s.startSetupMyUplink(r, *selected, q.Revision, live.Revision, q.RedirectURI) + return err + } + if q.RedirectURI != "" { + return errors.New("a connection test does not take a redirect URI") + } + for _, saved := range live.Drivers { + if saved.SecretOwner() != selected.SecretOwner() { + continue + } + token, _ := saved.Config["refresh_token"].(string) + _, rotated := s.deps.State.LoadConfig(driverSecretStateKey(saved.SecretOwner(), "refresh_token")) + if token == "" && !rotated { + continue + } + // A second OAuth reader can rotate the running driver's token + // family. Inspect the existing reader instead of forking it. + answer := map[string]any{"state": "shared_live_credentials", "existing_driver": saved.Name, "probe_was_run": false, "draft_connection_verified": false, + "note": "This draft retains the running driver's token family. Inspect its current readings and health. A separate probe could revoke its token. Changes in the draft have not been tested; use vendor sign-in to connect a different account."} + if s.deps.Tel != nil { + answer["health"] = s.deps.Tel.DriverHealth(saved.Name) + answer["metrics"] = s.deps.Tel.LatestMetricsByDriver(saved.Name) + } + result = answer + return nil + } + capture := &setupCapture{header: http.Header{}} + s.runDriverTest(capture, r, *selected, func(reg *drivers.Registry, testName string) { + reg.ControlBlocked = func() bool { return true } + // Token rotation belongs to this exact draft. Never save it to + // live Settings or reuse another driver's credential namespace. + var mu sync.Mutex + reg.SecretPersister = func(name, key, value string) error { + mu.Lock() + defer mu.Unlock() + if name != testName { + return errors.New("probe credential owner changed") + } + if selected.Config == nil { + selected.Config = map[string]any{} + } + selected.Config[key] = value + return nil + } + }) + if capture.overflow { + return errors.New("connection test answer is too large") + } + var answer any + dec := json.NewDecoder(bytes.NewReader(capture.body.Bytes())) + dec.UseNumber() + if err := dec.Decode(&answer); err != nil { + return errors.New("invalid connection test answer") + } + redactSensitive(answer) + result = map[string]any{"status": capture.status, "response": answer} + secrets = append(secrets, s.setupSecretValues(candidate)...) + return nil + }, check) + if err != nil { + s.setupError(w, setupScrubbedError{err, scrubSetupResult(err.Error(), secrets).(string)}) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "action": q.Action, "revision": d.Revision, "base_revision": d.BaseRevision, "sha256": d.SHA256, + "result": scrubSetupResult(result, secrets), "hardware_control_verified": false, + "next": "Read setup_status to continue from this draft. A discovery or connection test does not select equipment, apply settings or prove control. Vendor sign-in saves credentials to this draft only.", + }) +} + +// setupCapture reuses the existing bounded connection probe without making +// an HTTP request or replacing the authenticated caller. +type setupCapture struct { + header http.Header + body bytes.Buffer + status int + overflow bool +} + +func (c *setupCapture) Header() http.Header { return c.header } +func (c *setupCapture) WriteHeader(status int) { c.status = status } +func (c *setupCapture) Write(p []byte) (int, error) { + if c.status == 0 { + c.status = http.StatusOK + } + if c.body.Len()+len(p) > onboarding.MaxDraftBytes { + c.overflow = true + return 0, fmt.Errorf("setup result too large") + } + return c.body.Write(p) +} + +func (s *Server) setupSecretValues(cfg *config.Config) []string { + masked := cfg.MaskSecrets() + maskDriverConfigSecrets(&masked, s.driverSecretKeys()) + before, _ := json.Marshal(cfg) + after, _ := json.Marshal(masked) + var plain, hidden any + _ = json.Unmarshal(before, &plain) + _ = json.Unmarshal(after, &hidden) + values := []string{} + var walk func(any, any) + walk = func(a, b any) { + switch v := a.(type) { + case map[string]any: + other, _ := b.(map[string]any) + for key, val := range v { + walk(val, other[key]) + } + case []any: + other, _ := b.([]any) + for i, val := range v { + if i < len(other) { + walk(val, other[i]) + } + } + case string: + masked, hidden := b.(string) + if (b == nil || hidden && (masked == maskedPlaceholder || masked == "")) && v != "" && v != maskedPlaceholder { + values = append(values, v) + } + } + } + walk(plain, hidden) + return values +} + +type setupScrubbedError struct { + cause error + message string +} + +func (e setupScrubbedError) Error() string { return e.message } +func (e setupScrubbedError) Unwrap() error { return e.cause } + +func scrubSetupResult(result any, secrets []string) any { + switch v := result.(type) { + case string: + for _, secret := range secrets { + v = strings.ReplaceAll(v, secret, maskedPlaceholder) + } + return v + case map[string]any: + for key, val := range v { + v[key] = scrubSetupResult(val, secrets) + } + return v + case []any: + for i, val := range v { + v[i] = scrubSetupResult(val, secrets) + } + return v + default: + return result + } +} diff --git a/go/internal/api/api_setup_prepare_test.go b/go/internal/api/api_setup_prepare_test.go new file mode 100644 index 000000000..e8d79c34a --- /dev/null +++ b/go/internal/api/api_setup_prepare_test.go @@ -0,0 +1,134 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/protocol" +) + +func TestSetupConnectionTestUsesOnlyItsDraftAndKeepsRotatedCredentials(t *testing.T) { + s, live := setupTestServer(t) + dir := t.TempDir() + luaPath := filepath.Join(dir, "draft_probe.lua") + if err := os.WriteFile(luaPath, []byte(` +DRIVER = { + id="draft_probe", + config_secrets={"refresh_token"} +} +function driver_init(config) + if config.refresh_token ~= "draft-secret" then error("WRONG CREDENTIAL") end + host.persist_secret("refresh_token", "rotated-draft-secret") + host.emit_metric("draft_connected", 1) +end +function driver_poll() return 100 end +function driver_default_mode() error("MUST NOT CONTROL") end +function driver_command() error("MUST NOT CONTROL") end +function driver_cleanup() error("MUST NOT CONTROL") end +`), 0600); err != nil { + t.Fatal(err) + } + s.deps.UserDriverDir = dir + w, draft := setupCall(t, s, "POST", "/api/setup/draft", map[string]any{"revision": 0, "patch": map[string]any{ + "drivers": []any{map[string]any{"name": "new-meter", "lua": luaPath, "is_site_meter": true, "capabilities": map[string]any{"standalone": true}, "config": map[string]any{"refresh_token": "draft-secret"}}}, + }}) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + for _, action := range []map[string]any{ + {"revision": draft["revision"], "action": "test", "driver": "new-meter", "confirmed": false}, + {"revision": 0, "action": "test", "driver": "new-meter", "confirmed": true}, + {"revision": draft["revision"], "action": "connect", "driver": "missing", "confirmed": true}, + } { + w, _ = setupCall(t, s, "POST", "/api/setup/draft", action) + if w.Code < 400 { + t.Fatal("invalid action ran", w.Body.String()) + } + } + w, tested := setupCall(t, s, "POST", "/api/setup/draft", map[string]any{"revision": draft["revision"], "action": "test", "driver": "new-meter", "confirmed": true}) + if w.Code != 200 || tested["revision"] != float64(2) || !strings.Contains(w.Body.String(), "draft_connected") || strings.Contains(w.Body.String(), "draft-secret") { + t.Fatal("draft connection did not retain its own token rotation", w.Body.String()) + } + if live.EVCharger.Password != "before-secret" { + t.Fatal("probe changed live credentials") + } + w, resumed := setupCall(t, New(s.deps), "GET", "/api/setup/draft", nil) + if w.Code != 200 || resumed["revision"] != float64(2) || strings.Contains(w.Body.String(), "draft-secret") { + t.Fatal(w.Body.String()) + } + stored, err := s.setup.Read(live) + if err != nil || !strings.Contains(string(stored.Config), "rotated-draft-secret") { + t.Fatal("rotated token did not survive resume", err) + } +} + +func TestSetupPreparationHonoursItsGrantBeforeAnyAction(t *testing.T) { + s, _ := setupTestServer(t) + for _, scopes := range [][]string{{protocol.ScopeDiagRun}, {protocol.ScopeSetupWrite}} { + guard := func() error { return errors.New("revoked") } + w, _ := agentSetupCall(t, s, protocol.MsgSetupEdit, map[string]any{"revision": 0, "action": "discover", "confirmed": true}, scopes, guard) + if w.Code != http.StatusForbidden { + t.Fatal("revoked discovery proceeded", w.Body.String()) + } + } +} + +func TestSetupGuideResumesAnswersWithoutInventingVerification(t *testing.T) { + s, _ := setupTestServer(t) + patch := map[string]any{"forecast_priors": map[string]any{"annual_kwh": 16000, "annual_basis": "grid_import", "source": "owner electricity bill"}} + w, draft := setupCall(t, s, "POST", "/api/setup/draft", map[string]any{"revision": 0, "patch": patch}) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + w, resumed := setupCall(t, New(s.deps), "GET", "/api/setup/draft", nil) + guide := resumed["guide"].(map[string]any) + prior := guide["forecast_priors"].(map[string]any) + if resumed["revision"] != draft["revision"] || prior["load_prior_eligible"] != false || prior["reason"] != "total_consumption_needed" || resumed["hardware_verified"] != false { + t.Fatal("resume confused purchases with consumption or configuration with verification", w.Body.String()) + } + encoded, _ := json.Marshal(guide) + if !strings.Contains(string(encoded), "annual_consumption") { + t.Fatal("missing actionable next question") + } +} + +func TestSetupDoesNotForkARunningOAuthTokenFamily(t *testing.T) { + s, live := setupTestServer(t) + file := filepath.Join(t.TempDir(), "oauth.lua") + if err := os.WriteFile(file, []byte("DRIVER = {\n id=\"oauth\"\n}\nfunction driver_init() error(\"PROBE MUST NOT START\") end"), 0600); err != nil { + t.Fatal(err) + } + s.deps.UserDriverDir = filepath.Dir(file) + live.Drivers = []config.Driver{{Name: "meter", Lua: file, IsSiteMeter: true, Config: map[string]any{"refresh_token": "live-token-secret"}, Capabilities: config.Capabilities{Standalone: true}}} + if err := config.SaveStored(s.deps.State, s.deps.ConfigPath, live); err != nil { + t.Fatal(err) + } + w, draft := setupCall(t, s, "GET", "/api/setup/draft", nil) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + w, result := setupCall(t, s, "POST", "/api/setup/draft", map[string]any{"revision": draft["revision"], "action": "test", "driver": "meter", "confirmed": true}) + if w.Code != 200 { + t.Fatal(w.Body.String()) + } + answer := result["result"].(map[string]any) + if answer["state"] != "shared_live_credentials" || answer["probe_was_run"] != false || answer["draft_connection_verified"] != false || strings.Contains(w.Body.String(), "live-token-secret") { + t.Fatal(w.Body.String()) + } +} + +func TestSetupScrubsBlankedSecretsFromVendorErrors(t *testing.T) { + s, _ := setupTestServer(t) + cfg := config.NewSetup() + cfg.Weather = &config.Weather{APIKey: "blanked-api-secret"} + values := s.setupSecretValues(cfg) + if got := scrubSetupResult("vendor echoed blanked-api-secret", values); strings.Contains(got.(string), "blanked-api-secret") { + t.Fatal(got) + } +} diff --git a/go/internal/api/lan_auth.go b/go/internal/api/lan_auth.go index d62f42d13..4b8cd579d 100644 --- a/go/internal/api/lan_auth.go +++ b/go/internal/api/lan_auth.go @@ -226,6 +226,15 @@ func hashLANPassword(password string) (string, error) { ), nil } +// HashInitialLANPassword lets the first-run server establish the owner's +// lock before the normal API starts. It grants no password-change authority. +func HashInitialLANPassword(password string) (string, error) { + if len(password) < minLANPasswordLen || len(password) > 512 { + return "", fmt.Errorf("house password must be between %d and 512 characters", minLANPasswordLen) + } + return hashLANPassword(password) +} + func verifyLANPassword(encoded, password string) bool { salt, want, timeCost, memory, threads, keyLen, ok := parseLANPasswordHash(encoded) if !ok { diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 378643868..8f470691f 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -43,6 +43,8 @@ type Config struct { Nova *Nova `yaml:"nova,omitempty" json:"nova,omitempty"` DeviceRepository *DeviceRepository `yaml:"device_repository,omitempty" json:"device_repository,omitempty"` OCPP *OCPP `yaml:"ocpp,omitempty" json:"ocpp,omitempty"` + Setup *Setup `yaml:"setup,omitempty" json:"setup,omitempty"` + ForecastPriors *ForecastPriors `yaml:"forecast_priors,omitempty" json:"forecast_priors,omitempty"` // LoadWarnings collects recoverable problems Parse repaired instead of // refusing the file: an on-disk config an older version accepted must @@ -1916,6 +1918,9 @@ func isLegacyDefaultDriverRepository(repo DriverRepositorySource) bool { // Validate ensures the config is internally consistent and safe to run with. func (c *Config) Validate() error { c.NormalizeUnits() + if err := c.validateSetup(); err != nil { + return err + } if err := c.normalizeLoadpointSteps(); err != nil { return err } @@ -2034,7 +2039,7 @@ func (c *Config) Validate() error { if c.Site.MinDispatchIntervalS < 0 { return errors.New("site.min_dispatch_interval_s must be >= 0") } - if c.Fuse.MaxAmps <= 0 { + if c.Fuse.MaxAmps < 0 || c.Fuse.MaxAmps == 0 && !c.MeasurementOnly() { return errors.New("fuse.max_amps must be > 0") } if c.Fuse.Phases <= 0 { diff --git a/go/internal/config/restart_required.go b/go/internal/config/restart_required.go index 4d9a623dd..b3aeaa4f5 100644 --- a/go/internal/config/restart_required.go +++ b/go/internal/config/restart_required.go @@ -21,6 +21,9 @@ func RestartRequiredFor(oldCfg, newCfg *Config) []string { // A save that needs no restart is the common path; clients type-assert // restart_reasons as an array. reasons := []string{} + if oldCfg.MeasurementOnly() != newCfg.MeasurementOnly() { + reasons = append(reasons, "setup.measurement_only — restart to change equipment control") + } if oldCfg.API.Port != newCfg.API.Port { reasons = append(reasons, "api.port — HTTP server binds the port at startup") diff --git a/go/internal/config/setup.go b/go/internal/config/setup.go new file mode 100644 index 000000000..78956380d --- /dev/null +++ b/go/internal/config/setup.go @@ -0,0 +1,168 @@ +package config + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "math" + "time" +) + +// Setup keeps first-install choices with the settings the owner reviews. +// Existing installations without this section keep their current behaviour. +// Confirmation records what the owner checked; it is not hardware evidence. +type Setup struct { + GoalsReviewed bool `yaml:"goals_reviewed,omitempty" json:"goals_reviewed,omitempty"` + MeasurementOnly bool `yaml:"measurement_only" json:"measurement_only"` + ConnectionConfirmed bool `yaml:"connection_confirmed" json:"connection_confirmed"` + EquipmentReviewed bool `yaml:"equipment_reviewed" json:"equipment_reviewed"` + PriceReviewed bool `yaml:"price_reviewed" json:"price_reviewed"` + SolarPresent *bool `yaml:"solar_present,omitempty" json:"solar_present,omitempty"` + ConnectionDigest string `yaml:"connection_digest,omitempty" json:"connection_digest,omitempty"` + MeterIdentity string `yaml:"meter_identity,omitempty" json:"meter_identity,omitempty"` +} + +// NewSetup starts local services with no equipment or assumed fuse rating. +// Enabling the local connection service does not accept Cloud terms. +func NewSetup() *Config { + c := &Config{ + Setup: &Setup{MeasurementOnly: true}, + AppLink: &AppLink{Enabled: true}, + FleetPing: &FleetPing{Enabled: false}, + Drivers: []Driver{}, + } + applyDefaults(c) + return c +} + +// ForecastPriors records optional owner estimates, not measured history. +// AnnualKWh is a full year's electricity use. Grid purchases alone cannot +// establish household demand when solar or storage also supplies the home. +type ForecastPriors struct { + Reviewed bool `yaml:"reviewed,omitempty" json:"reviewed,omitempty"` + AnnualKWh *float64 `yaml:"annual_kwh,omitempty" json:"annual_kwh,omitempty"` + AnnualBasis string `yaml:"annual_basis,omitempty" json:"annual_basis,omitempty"` // total_consumption, grid_import, unknown + AnnualPeriod string `yaml:"annual_period,omitempty" json:"annual_period,omitempty"` + IncludesEV *bool `yaml:"includes_ev,omitempty" json:"includes_ev,omitempty"` + EVAnnualKWh *float64 `yaml:"ev_annual_kwh,omitempty" json:"ev_annual_kwh,omitempty"` + IncludesHeating *bool `yaml:"includes_heating,omitempty" json:"includes_heating,omitempty"` + HeatingAnnualKWh *float64 `yaml:"heating_annual_kwh,omitempty" json:"heating_annual_kwh,omitempty"` + HeatingType string `yaml:"heating_type,omitempty" json:"heating_type,omitempty"` + Occupants *int `yaml:"occupants,omitempty" json:"occupants,omitempty"` + Source string `yaml:"source,omitempty" json:"source,omitempty"` + RecordedAt string `yaml:"recorded_at,omitempty" json:"recorded_at,omitempty"` // RFC3339 + SolarSource string `yaml:"solar_source,omitempty" json:"solar_source,omitempty"` + SolarRecordedAt string `yaml:"solar_recorded_at,omitempty" json:"solar_recorded_at,omitempty"` +} + +// AnnualBaseKWh returns an estimate for the load model's non-EV baseline. +// A separately modelled heating term must also be removed from the annual +// total. Missing answers leave the current generic prior in place. +func (c *Config) AnnualBaseKWh() (float64, string) { + p := c.ForecastPriors + if p == nil || p.AnnualKWh == nil { + return 0, "annual_use_unknown" + } + if p.AnnualBasis != "total_consumption" { + return 0, "total_consumption_needed" + } + if p.IncludesEV == nil { + return 0, "ev_inclusion_unknown" + } + base := *p.AnnualKWh + if *p.IncludesEV { + if p.EVAnnualKWh == nil { + return 0, "ev_use_unknown" + } + base -= *p.EVAnnualKWh + } + if c.Weather != nil && c.Weather.HeatingWPerDegC > 0 { + if p.IncludesHeating == nil { + return 0, "heating_inclusion_unknown" + } + if *p.IncludesHeating { + if p.HeatingAnnualKWh == nil { + return 0, "heating_use_unknown" + } + base -= *p.HeatingAnnualKWh + } + } + if base <= 0 || math.IsNaN(base) || math.IsInf(base, 0) { + return 0, "annual_components_exceed_total" + } + return base, "" +} + +func (c *Config) MeasurementOnly() bool { + return c.Setup != nil && c.Setup.MeasurementOnly +} + +// SetupConnectionDigest binds the owner's confirmation to the physical +// electrical connection. The live device identity is a +// separate runtime check, never inferred from a display name. +func (c *Config) SetupConnectionDigest() string { + data, _ := json.Marshal(struct { + MaxAmps float64 + Phases int + Voltage float64 + }{c.Fuse.MaxAmps, c.Fuse.Phases, c.Fuse.Voltage}) + return fmt.Sprintf("%x", sha256.Sum256(data)) +} + +func (c *Config) validateSetup() error { + if s := c.Setup; s != nil && !s.MeasurementOnly { + if !s.ConnectionConfirmed || !(c.Fuse.MaxAmps > 0) || c.Fuse.Phases < 1 || c.Fuse.Phases > 3 || !(c.Fuse.Voltage > 0) || math.IsInf(c.Fuse.MaxAmps, 0) || math.IsInf(c.Fuse.Voltage, 0) { + return fmt.Errorf("setup: confirm the main fuse, phases and voltage per phase before enabling control") + } + if !s.EquipmentReviewed { + return fmt.Errorf("setup: review the selected equipment before enabling control") + } + if c.SiteMeterDriver() == "" || s.MeterIdentity == "" || s.ConnectionDigest != c.SetupConnectionDigest() { + return fmt.Errorf("setup: confirm this electrical connection and the live site meter identity before enabling control") + } + } + p := c.ForecastPriors + if p == nil { + return nil + } + for _, field := range []struct { + name string + v *float64 + }{{"annual_kwh", p.AnnualKWh}, {"ev_annual_kwh", p.EVAnnualKWh}, {"heating_annual_kwh", p.HeatingAnnualKWh}} { + if field.v != nil && (math.IsNaN(*field.v) || math.IsInf(*field.v, 0) || *field.v < 0 || *field.v > 10_000_000) { + return fmt.Errorf("forecast_priors.%s must be a finite annual kWh value between 0 and 10000000", field.name) + } + } + switch p.AnnualBasis { + case "", "unknown", "total_consumption", "grid_import": + default: + return fmt.Errorf("forecast_priors.annual_basis must be total_consumption, grid_import or unknown") + } + if p.Occupants != nil && (*p.Occupants < 0 || *p.Occupants > 1000) { + return fmt.Errorf("forecast_priors.occupants must be between 0 and 1000") + } + if len(p.Source) > 512 || len(p.SolarSource) > 512 || len(p.HeatingType) > 128 || len(p.AnnualPeriod) > 128 { + return fmt.Errorf("forecast_priors source or heating_type is too long") + } + for _, recorded := range []string{p.RecordedAt, p.SolarRecordedAt} { + if recorded == "" { + continue + } + if _, err := time.Parse(time.RFC3339, recorded); err != nil { + return fmt.Errorf("forecast_priors recorded times must be RFC3339") + } + } + if p.AnnualKWh != nil { + parts := 0.0 + if p.IncludesEV != nil && *p.IncludesEV && p.EVAnnualKWh != nil { + parts += *p.EVAnnualKWh + } + if p.IncludesHeating != nil && *p.IncludesHeating && p.HeatingAnnualKWh != nil { + parts += *p.HeatingAnnualKWh + } + if parts > *p.AnnualKWh { + return fmt.Errorf("forecast_priors: included EV and heating kWh exceed annual_kwh") + } + } + return nil +} diff --git a/go/internal/config/setup_test.go b/go/internal/config/setup_test.go new file mode 100644 index 000000000..99fa3499b --- /dev/null +++ b/go/internal/config/setup_test.go @@ -0,0 +1,60 @@ +package config + +import ( + "math" + "testing" +) + +func TestAnnualPriorAccounting(t *testing.T) { + yes, no := true, false + total, ev, heat := 20000.0, 4000.0, 8000.0 + c := Config{Weather: &Weather{HeatingWPerDegC: 250}, ForecastPriors: &ForecastPriors{ + AnnualKWh: &total, AnnualBasis: "total_consumption", IncludesEV: &yes, EVAnnualKWh: &ev, + IncludesHeating: &yes, HeatingAnnualKWh: &heat, + }} + if kWh, why := c.AnnualBaseKWh(); kWh != 8000 || why != "" { + t.Fatalf("base = %v, %q", kWh, why) + } + for _, change := range []func(*ForecastPriors){ + func(p *ForecastPriors) { p.AnnualBasis = "grid_import" }, + func(p *ForecastPriors) { p.IncludesEV = nil }, + func(p *ForecastPriors) { p.EVAnnualKWh = nil }, + func(p *ForecastPriors) { p.IncludesHeating = nil }, + func(p *ForecastPriors) { p.HeatingAnnualKWh = nil }, + } { + copy := *c.ForecastPriors + change(©) + next := c + next.ForecastPriors = © + if kWh, why := next.AnnualBaseKWh(); kWh != 0 || why == "" { + t.Fatalf("incomplete prior applied: %v, %q", kWh, why) + } + } + c.ForecastPriors.IncludesEV, c.ForecastPriors.IncludesHeating = &no, &no + if kWh, why := c.AnnualBaseKWh(); kWh != total || why != "" { + t.Fatalf("excluded components subtracted: %v, %q", kWh, why) + } +} + +func TestSetupOptionalAndControlValidation(t *testing.T) { + c := *NewSetup() + if err := c.Validate(); err != nil { + t.Fatal(err) + } + c.Setup.MeasurementOnly = false + if err := c.Validate(); err == nil { + t.Fatal("enabled control without a confirmed connection") + } + c.Setup.ConnectionConfirmed, c.Setup.EquipmentReviewed = true, true + c.Fuse = Fuse{MaxAmps: 20, Phases: 3, Voltage: 230} + c.Drivers = []Driver{{Name: "meter", Lua: "drivers/meter.lua", IsSiteMeter: true, Capabilities: Capabilities{Standalone: true}}} + c.Setup.ConnectionDigest, c.Setup.MeterIdentity = c.SetupConnectionDigest(), "serial:test-meter" + if err := c.Validate(); err != nil { + t.Fatal(err) + } + nan := math.NaN() + c.ForecastPriors = &ForecastPriors{AnnualKWh: &nan} + if err := c.Validate(); err == nil { + t.Fatal("accepted nonfinite prior") + } +} diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go index a94ee5752..7cb794148 100644 --- a/go/internal/drivers/host.go +++ b/go/internal/drivers/host.go @@ -128,6 +128,10 @@ type HostEnv struct { // DeviceReadOnly blocks device writes in every Lua phase when an // external Modbus controller owns the endpoint. DeviceReadOnly bool + // ControlBlocked is a live installation gate, including init and poll. + // Authentication paths come from this driver's catalog metadata. + ControlBlocked func() bool + SetupAuthPaths []string // BatteryCapacityWh mirrors the operator's `battery_capacity_wh` // declaration for this driver. Zero means "no physical battery @@ -254,6 +258,18 @@ func (h *HostEnv) permissionAllowed(permission string) bool { // device, so it is admitted here instead, confined to the single path the // signed manifest names and only for a driver published read-only. func (h *HostEnv) allowAuthPost(rawURL string) bool { + if h.ControlBlocked != nil && h.ControlBlocked() && h.RuntimePolicy == nil { + parsed, err := net_url.Parse(rawURL) + if err != nil { + return false + } + for _, path := range h.SetupAuthPaths { + if path != "" && parsed.Path == path { + return true + } + } + return false + } if h.RuntimePolicy == nil || !h.RuntimePolicy.ReadOnly { return false } @@ -274,6 +290,9 @@ func (h *HostEnv) allowAuthPost(rawURL string) bool { } func (h *HostEnv) allowWrite(permission string) error { + if h.ControlBlocked != nil && h.ControlBlocked() { + return fmt.Errorf("%s: setup measurement mode blocks device writes", permission) + } if h.DeviceReadOnly { return fmt.Errorf("%s: external Modbus client owns control; FTW only reads", permission) } diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go index 6132fa42c..7c4ca7a9a 100644 --- a/go/internal/drivers/lua.go +++ b/go/internal/drivers/lua.go @@ -1495,6 +1495,11 @@ func registerHost(L *lua.LState, env *HostEnv) { })) host.RawSetString("ws_send", L.NewFunction(func(L *lua.LState) int { + if err := env.allowWrite("websocket.send"); err != nil { + L.Push(lua.LNil) + L.Push(lua.LString(err.Error())) + return 2 + } if env.WS == nil { L.Push(lua.LNil) L.Push(lua.LString("ws: capability not granted")) diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index a265ef7dc..07ea3bca4 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -80,6 +80,10 @@ type DriverControlStatus struct { type Registry struct { tel *telemetry.Store troubleshootingMode bool + // Set before Add. The callback reads the live installation mode. + ControlBlocked func() bool + // CommandBlocked stops new dispatch while safe defaults remain possible. + CommandBlocked func() bool // MQTTFactory creates an MQTT capability for a driver given its config. // Called on Add; the returned MQTTCap belongs to that driver alone. @@ -138,6 +142,32 @@ func NewRegistry(tel *telemetry.Store) *Registry { } } +func (r *Registry) controlBlocked() bool { + return r.ControlBlocked != nil && r.ControlBlocked() +} + +func (r *Registry) commandBlocked(payload []byte) bool { + return r.CommandBlocked != nil && r.CommandBlocked() && !IsEVStanddown(payload) +} + +// IsEVStanddown admits only the explicit stop forms used by Core. Extra +// fields cannot turn the zero-power exception into a different command. +func IsEVStanddown(payload []byte) bool { + var fields map[string]json.RawMessage + if json.Unmarshal(payload, &fields) != nil { + return false + } + var action string + if json.Unmarshal(fields["action"], &action) != nil { + return false + } + if action == "ev_pause" && len(fields) == 1 { + return true + } + var power *float64 + return action == "ev_set_current" && len(fields) == 2 && json.Unmarshal(fields["power_w"], &power) == nil && power != nil && *power == 0 +} + func (r *Registry) acquireLifecycle(name string) func() { r.lifecycleMu.Lock() if r.lifecycleGates == nil { @@ -530,6 +560,10 @@ func (r *Registry) add(ctx context.Context, cfg config.Driver, startupDefault bo } env := NewHostEnv(cfg.Name, r.tel) + env.ControlBlocked = r.ControlBlocked + if entry, err := ParseCatalogFile(cfg.Lua); err == nil { + env.SetupAuthPaths = append([]string{entry.AuthPostPath}, entry.AuthPostPaths...) + } // runLoop owns the capabilities once the driver is registered. Until then // every failed return closes what it opened: a leaked MQTT client keeps // reconnecting under the same client ID and fights the next Add. @@ -717,7 +751,7 @@ func (r *Registry) add(ctx context.Context, cfg config.Driver, startupDefault bo return fmt.Errorf("driver_init: %w", err) } var startupDefaultErr error - if startupDefault && !cfg.ObserveOnly { + if startupDefault && !cfg.ObserveOnly && !r.controlBlocked() { defaultCtx, cancel := context.WithTimeout(ctx, defaultRecoveryTimeout) startupDefaultErr = drv.DefaultMode(defaultCtx) cancel() @@ -739,11 +773,12 @@ func (r *Registry) add(ctx context.Context, cfg config.Driver, startupDefault bo stop: make(chan bool, 1), done: make(chan struct{}), } + controlBlockedAtStart := r.controlBlocked() r.mu.Lock() r.nextGeneration++ rd.generation = r.nextGeneration inheritsRecovery := r.recoveryRequired[cfg.Name] - if startupDefault && !cfg.ObserveOnly { + if startupDefault && !cfg.ObserveOnly && !controlBlockedAtStart { if startupDefaultErr == nil { rd.defaultConfirmed = true if inheritsRecovery { @@ -841,6 +876,9 @@ func (r *Registry) runLoop(rd *runningDriver) { scheduleRecovery() } attemptDefault := func() error { + if r.controlBlocked() { + return fmt.Errorf("setup measurement mode blocks device defaults") + } // The actor owns this transition. Invalidate any pause→resume // continuation before the default call starts, regardless of whether // the device later confirms it. @@ -869,6 +907,12 @@ func (r *Registry) runLoop(rd *runningDriver) { return commandOutcome } handleDefault := func(cmd driverCmd) { + if r.controlBlocked() { + if cmd.result != nil { + cmd.result <- fmt.Errorf("setup measurement mode blocks device defaults") + } + return + } invalidateCommandSequence() cmdCtx := cmd.ctx if cmdCtx == nil { @@ -911,6 +955,7 @@ func (r *Registry) runLoop(rd *runningDriver) { } select { case skipDefault := <-rd.stop: + skipDefault = skipDefault || r.controlBlocked() if skipDefault { rd.driver.Discard() } else { @@ -956,6 +1001,10 @@ func (r *Registry) runLoop(rd *runningDriver) { } switch cmd.kind { case "command": + if r.controlBlocked() || r.commandBlocked(cmd.payload) { + err = fmt.Errorf("setup measurement mode blocks device commands") + break + } metadata := commandMetadata(cmd.payload) action := metadata.Action cyclePause := action == "ev_pause" && cmd.cycleID != 0 @@ -1131,6 +1180,7 @@ func (r *Registry) remove(name string, skipDefault bool) { // lets Restart and Reload keep the old generation and its replacement in one // serialized transition instead of opening an Add/Remove gap. func (r *Registry) removeLocked(name string, skipDefault bool) { + skipDefault = skipDefault || r.controlBlocked() r.mu.Lock() rd, ok := r.rec[name] if !ok { @@ -1218,6 +1268,9 @@ func (r *Registry) SendWithGeneration(ctx context.Context, name string, payload } func (r *Registry) sendWithGeneration(ctx context.Context, name string, payload []byte, cycleID uint64, checkEVHealth bool, outcome func(error)) (uint64, error) { + if r.controlBlocked() || r.commandBlocked(payload) { + return 0, fmt.Errorf("setup measurement mode blocks device commands") + } if ctx == nil { ctx = context.Background() } @@ -1272,6 +1325,9 @@ func (r *Registry) sendWithGeneration(ctx context.Context, name string, payload // blocked until the default succeeds or the recovery timer retries it. An // observe_only driver is refused with ErrObserveOnly, as main's watchdog does. func (r *Registry) SendDefault(ctx context.Context, name string) error { + if r.controlBlocked() { + return fmt.Errorf("setup measurement mode blocks device defaults") + } if ctx == nil { ctx = context.Background() } diff --git a/go/internal/drivers/setup_measurement_test.go b/go/internal/drivers/setup_measurement_test.go new file mode 100644 index 000000000..c14c20e47 --- /dev/null +++ b/go/internal/drivers/setup_measurement_test.go @@ -0,0 +1,101 @@ +package drivers + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestMeasurementSetupBlocksWritesInEveryLuaPhase(t *testing.T) { + var blocked atomic.Bool + blocked.Store(true) + env := NewHostEnv("meter", telemetry.NewStore()) + env.ControlBlocked = blocked.Load + env.SetupAuthPaths = []string{"/oauth/token"} + for _, verb := range []string{"modbus.write", "mqtt.publish", "http.post", "http.patch", "websocket.send"} { + if err := env.allowWrite(verb); err == nil { + t.Fatal("setup allowed", verb) + } + } + if !env.allowAuthPost("https://vendor.example/oauth/token") || env.allowAuthPost("https://vendor.example/device/charge") { + t.Fatal("setup did not confine authentication to the declared path") + } + blocked.Store(false) + if err := env.allowWrite("modbus.write"); err != nil { + t.Fatal(err) + } + blocked.Store(true) + path := filepath.Join(t.TempDir(), "meter.lua") + // A physical command hook would fail loudly if any lifecycle path ran it. + if err := os.WriteFile(path, []byte(` +function driver_init(config) host.emit_metric("setup_started", 1) end +function driver_poll() return 100 end +function driver_command() error("COMMAND MUST NOT RUN") end +function driver_default_mode() error("DEFAULT MUST NOT RUN") end +function driver_cleanup() error("CLEANUP MUST NOT RUN") end +`), 0600); err != nil { + t.Fatal(err) + } + reg := NewRegistry(telemetry.NewStore()) + reg.ControlBlocked = blocked.Load + if err := reg.Add(context.Background(), config.Driver{Name: "meter", Lua: path}); err != nil { + t.Fatal(err) + } + if err := reg.Send(context.Background(), "meter", []byte(`{"action":"battery","power_w":1000}`)); err == nil || !strings.Contains(err.Error(), "measurement") { + t.Fatal("setup sent a hardware command", err) + } + if err := reg.SendDefault(context.Background(), "meter"); err == nil { + t.Fatal("setup sent a default") + } + reg.Remove("meter") +} + +func TestSetupStanddownHasNoCommandFields(t *testing.T) { + for _, raw := range []string{`{"action":"ev_pause"}`, `{"action":"ev_set_current","power_w":0}`} { + if !IsEVStanddown([]byte(raw)) { + t.Fatal("standdown rejected", raw) + } + } + for _, raw := range []string{`{"action":"ev_set_current","power_w":null}`, `{"action":"ev_set_current"}`, `{"action":"ev_set_current","power_w":1}`, `{"action":"ev_pause","power_w":1000}`, `{"action":"ev_pause","extra":true}`, `null`} { + if IsEVStanddown([]byte(raw)) { + t.Fatal("command passed as standdown", raw) + } + } +} + +func TestSetupIdentityBlockKeepsSafeDefaultAndEVStop(t *testing.T) { + path := filepath.Join(t.TempDir(), "safe.lua") + if err := os.WriteFile(path, []byte(` +local defaults=0 +function driver_init() end +function driver_poll() return 100 end +function driver_default_mode() defaults=defaults+1 end +function driver_command(action) + if action ~= "ev_pause" then error("new dispatch must be blocked") end + if defaults < 2 then error("safe default did not run") end +end +`), 0600); err != nil { + t.Fatal(err) + } + reg := NewRegistry(telemetry.NewStore()) + reg.CommandBlocked = func() bool { return true } + if err := reg.Add(context.Background(), config.Driver{Name: "charger", Lua: path}); err != nil { + t.Fatal(err) + } + defer reg.Remove("charger") + if err := reg.Send(context.Background(), "charger", []byte(`{"action":"ev_set_current","power_w":1000}`)); err == nil { + t.Fatal("new dispatch passed identity block") + } + if err := reg.SendDefault(context.Background(), "charger"); err != nil { + t.Fatal("identity block prevented safe default", err) + } + if err := reg.Send(context.Background(), "charger", []byte(`{"action":"ev_pause"}`)); err != nil { + t.Fatal("identity block prevented EV stop", err) + } +} diff --git a/go/internal/loadmodel/annual_prior_test.go b/go/internal/loadmodel/annual_prior_test.go new file mode 100644 index 000000000..aafa82c8d --- /dev/null +++ b/go/internal/loadmodel/annual_prior_test.go @@ -0,0 +1,44 @@ +package loadmodel + +import ( + "math" + "reflect" + "testing" + "time" +) + +func TestAnnualPriorLeavesLearningIntact(t *testing.T) { + s := NewService(nil, nil, "meter", 5000, 0) + annual := 8760.0 + s.SetAnnualBase(annual) + m := s.Model() + mean := 0.0 + start := time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC) + for i := 0; i < Buckets; i++ { + mean += m.PredictNoTemp(start.Add(time.Duration(i)*time.Hour)) / Buckets + } + if math.Abs(mean-1000) > 1e-8 || m.Samples != 0 { + t.Fatalf("annual prior mean=%v samples=%v", mean, m.Samples) + } + trained := s.models[ProfileHome] + for i := range trained.Bucket { + trained.Bucket[i].Mean = 750 + trained.Bucket[i].Days = MinTrustSamples + trained.Bucket[i].Samples = 100 + } + trained.Samples = 1000 + trained.ConfigRevision = "physical-source-1" + before := *trained + s.SetAnnualBase(17520) + after := s.Model() + if !reflect.DeepEqual(before.Bucket, after.Bucket) || before.Samples != after.Samples || before.ConfigRevision != after.ConfigRevision { + t.Fatal("changing prior changed measured learning") + } + if after.PredictNoTemp(start) != before.PredictNoTemp(start) { + t.Fatal("prior changed a fully learned bucket") + } + s.SetAnnualBase(0) + if s.Model().PriorScale != 1 { + t.Fatal("removing prior did not restore generic estimate") + } +} diff --git a/go/internal/loadmodel/service.go b/go/internal/loadmodel/service.go index b0f232234..131cdb1de 100644 --- a/go/internal/loadmodel/service.go +++ b/go/internal/loadmodel/service.go @@ -300,6 +300,34 @@ func (s *Service) Predict(t time.Time) float64 { return s.activeModelLocked().Predict(t, temp) } +// SetAnnualBase changes only the unlearned part of the household estimate. +// It neither trains buckets nor changes their sample counts or learning ID. +// Zero restores the generic prior. EV and separately modelled heating must +// already have been removed by the caller. +func (s *Service) SetAnnualBase(kWh float64) { + if s == nil || math.IsNaN(kWh) || math.IsInf(kWh, 0) || kWh < 0 { + return + } + mean := 0.0 + for i := 0; i < Buckets; i++ { + mean += typicalPrior(i) / Buckets + } + scale := 1.0 + if kWh > 0 { + scale = kWh * 1000 / (365 * 24 * mean) + } + s.mu.Lock() + defer s.mu.Unlock() + for _, p := range Profiles() { + if m := s.models[p]; m != nil { + m.PriorScale = scale + if p == ProfileAway { + m.PriorScale *= awayPriorScale + } + } + } +} + // PredictWith is like Predict but forces a specific profile's model, // regardless of which profile is currently active. The calendar service // (issue #498) uses it to predict reduced "away"-profile load for the slots diff --git a/go/internal/onboarding/guide.go b/go/internal/onboarding/guide.go new file mode 100644 index 000000000..e4c8b8f31 --- /dev/null +++ b/go/internal/onboarding/guide.go @@ -0,0 +1,69 @@ +package onboarding + +import "github.com/srcfl/ftw/go/internal/config" + +type Question struct { + ID string `json:"id"` + Prompt string `json:"prompt"` + Paths []string `json:"paths"` + RequiredFor string `json:"required_for,omitempty"` + Unit string `json:"unit,omitempty"` + Help string `json:"help,omitempty"` +} + +// Guide uses the same Config type as Settings. The box, rather than Cloud, +// decides which facts are missing. Optional unknowns never prevent saving. +func Guide(c *config.Config) map[string]any { + questions := []Question{} + add := func(id, prompt, need, unit, help string, paths ...string) { + questions = append(questions, Question{id, prompt, paths, need, unit, help}) + } + if c.Site.Name == "" { + add("home_name", "What would you like to call this home?", "", "", "Use the box identity to confirm the right home before editing.", "/site/name") + } + if c.Setup == nil || !c.Setup.ConnectionConfirmed || c.Setup.ConnectionDigest != c.SetupConnectionDigest() { + add("electrical_connection", "What main fuse and connection does your electricity network contract show?", "automatic_control", "A; phases; V per phase", "Do not guess from the country. A three-phase 400/230 V supply uses voltage=230 here. Keep measurement mode on if unknown. Confirm the exact returned connection_digest after reviewing these values.", "/fuse", "/setup/connection_confirmed", "/setup/connection_digest") + } + if c.SiteMeterDriver() == "" { + add("site_meter", "Which meter measures this whole home's import and export at the grid boundary?", "automatic_control", "", "Offer explicit network discovery, or manual entry from the driver catalogue. A found device is not yet selected or tested.", "/drivers") + } + if c.Setup == nil || !c.Setup.EquipmentReviewed { + add("equipment", "Which equipment should FTW use: meter, solar, battery, chargers, cars, heating or other integrations?", "automatic_control", "", "Read detected facts and catalogue fields before asking. Configure all requested devices and integrations with the schema. An optional offline device can remain for later; report it clearly.", "/drivers", "/batteries", "/ev_charger", "/loadpoints", "/vehicles", "/ocpp", "/homeassistant", "/notifications", "/setup/equipment_reviewed") + } + if c.Setup == nil || c.Setup.SolarPresent == nil { + add("solar", "Does this home have solar panels? If so, do you know their total kWp?", "", "kWp (convert to W for pv_rated_w)", "Capacity and roof orientation are optional estimates. Record their source and time in forecast_priors.solar_source and solar_recorded_at. They are not verified inverter control limits. Do not ask for a capacity already known from reliable device metadata.", "/setup/solar_present", "/weather/pv_rated_w", "/weather/pv_arrays") + } + if c.Setup == nil || !c.Setup.GoalsReviewed { + add("goals", "What should FTW do for this home: use solar, shift energy to cheap hours, keep a battery reserve, or prepare cars for departure?", "", "W, Wh; SoC as 0..1", "Read the mode catalogue in setup_schema and active mode/preferences in commissioning. Confirm runtime_timezone before any local HH:MM departure target. If it is wrong, help the owner set the host time zone and restart FTW; there is no writable configuration time-zone field. Store equipment limits and car/loadpoint configuration in this draft. After configuration is running and measurements are fresh, while measurement mode still blocks equipment control, use car_goal_request for car departure schedules and goal_request for the chosen operating mode and planning style, including explicit battery-export permission. The owner approves each request in Access on the local box or in the FTW app; read requests_list and active status to verify the result. Planner config fields do not replace these active preferences. Use Check requests and access on the local Access page to refresh approvals. Mark goals_reviewed only after applying the requested goals or the owner explicitly leaves them for later; include it in the final control-enabling review.", "/setup/goals_reviewed", "/batteries", "/vehicles", "/loadpoints", "/v2x") + } + if c.Setup == nil || !c.Setup.PriceReviewed { + add("electricity_price", "Which electricity price area and import/export contract apply here?", "", "", "Read supported countries, zones and currencies from setup_schema. Ask only for missing contract terms. If unknown, continue measuring and show that price planning still needs this information.", "/price", "/setup/price_reviewed") + } + if c.Weather == nil || c.Weather.Provider == "" { + add("weather", "May FTW use this home's location for weather and solar estimates?", "", "latitude/longitude in degrees", "Use a suitable provider from the schema. Ask for a town or coordinates only when the location is not already known; never invent an address.", "/weather") + } + base, reason := c.AnnualBaseKWh() + if reason != "" && (c.ForecastPriors == nil || !c.ForecastPriors.Reviewed && c.ForecastPriors.AnnualBasis != "unknown") { + add("annual_consumption", "Do you know roughly how much electricity this home uses in a year? You can skip this.", "", "kWh per full year", "Record whether this is total consumption or electricity bought from the grid, whether EV and electric heating are included, and the source and time. Grid purchases alone cannot establish total demand with solar. Remove EV use and separately modelled heating before applying a load prior. Do not invent a split. Read forecast_priors.reason for the missing part; keep earlier answers.", "/forecast_priors") + } + if c.MeasurementOnly() { + add("automatic_control", "After the measurements have been checked, should FTW start automatic control of the equipment in this review?", "automatic_control", "", "First save and measure. Read the live meter identity from commissioning, confirm the exact electrical connection and selected equipment, then validate and review a new draft with measurement_only=false. Save and restart only after the owner confirms. A valid config does not prove an actuation test.", "/setup/measurement_only", "/setup/meter_identity", "/setup/equipment_reviewed") + } + source := "generic_prior" + if c.ForecastPriors != nil && c.ForecastPriors.Source != "" { + source = c.ForecastPriors.Source + } + return map[string]any{ + "questions": questions, + "ask_at_most": 3, + "connection_digest": c.SetupConnectionDigest(), + "forecast_priors": map[string]any{ + "load_prior_eligible": reason == "", "proposed_base_annual_kwh": base, "reason": reason, + "source": source, "quality": "cold_start_prior", "uncertainty": "uncalibrated", + "note": "These answers change only the estimate before sufficient measurements exist. They do not create history, increase sample counts or establish hardware limits. Measured hourly buckets progressively replace the load prior. Unknown PV capacity remains unknown.", + }, + "resume": "Answers live in this box's versioned draft. Read setup_status after a pause or client change; retain saved fields and masked credentials. Never reset merely because a reply was lost.", + "review": "Show configured equipment, connection, goals, estimates, unresolved items and restart reasons. Confirmation applies only to the exact normalized revision and checksum returned by validation.", + "everyday_use": "After setup, use status, history, plans and goals directly. Start an interview only when the user asks for setup or changes.", + } +} diff --git a/go/internal/onboarding/prepare.go b/go/internal/onboarding/prepare.go new file mode 100644 index 000000000..4218b6f2d --- /dev/null +++ b/go/internal/onboarding/prepare.go @@ -0,0 +1,80 @@ +package onboarding + +import ( + "bytes" + "encoding/json" + "errors" + + "github.com/srcfl/ftw/go/internal/config" +) + +// Prepare holds one exact draft while a bounded setup operation uses it. +// Its callback may update credentials, never live settings. A concurrent +// edit cannot replace the driver halfway through authentication or a probe. +func (s *Service) Prepare(live *config.Config, revision int64, prepare func(*config.Config) error, check func() error) (Draft, error) { + s.mu.Lock() + defer s.mu.Unlock() + d, found, err := s.current() + if err != nil { + return Draft{}, err + } + if !found { + d, err = initial(live) + if err != nil { + return Draft{}, err + } + } + if live == nil || d.Revision != revision || d.BaseRevision != live.Revision { + return Draft{}, ErrConflict + } + cfg, err := decodeConfig(d.Config) + if err != nil { + return Draft{}, err + } + if check != nil { + if err := check(); err != nil { + return Draft{}, err + } + } + if err := prepare(cfg); err != nil { + return Draft{}, err + } + previous, err := decodeConfig(d.Config) + if err != nil { + return Draft{}, err + } + if err := config.PrepareDraftCredentials(cfg, previous); err != nil { + return Draft{}, err + } + if err := config.PrepareDraftCredentials(cfg, live); err != nil { + return Draft{}, err + } + s.portablePaths(cfg) + raw, err := json.Marshal(cfg) + if err != nil { + return Draft{}, err + } + if check != nil { + if err := check(); err != nil { + return Draft{}, err + } + } + if bytes.Equal(raw, d.Config) { + return d, nil + } + d.Config = raw + d.Revision++ + d.NormalizedFromRevision, d.ValidationWarnings = 0, nil + d.SHA256 = digest(raw, d.BaseRevision) + encoded, err := json.Marshal(d) + if err != nil { + return Draft{}, err + } + if len(encoded) > MaxDraftBytes { + return Draft{}, errors.New("setup draft is too large") + } + if err := s.store.SaveConfig(draftKey, string(encoded)); err != nil { + return Draft{}, err + } + return d, nil +} diff --git a/go/internal/onboarding/schema.go b/go/internal/onboarding/schema.go index b9be17710..1d212da6e 100644 --- a/go/internal/onboarding/schema.go +++ b/go/internal/onboarding/schema.go @@ -37,6 +37,9 @@ func schemaFor(t reflect.Type) map[string]any { name = f.Name } field := schemaFor(f.Type) + if description := setupFieldDescription(t, name); description != "" { + field["description"] = description + } if t == reflect.TypeFor[config.API]() && name == "lan_auth" { field["readOnly"] = true field["description"] = "Read current house-password protection. Change it through /api/auth/password on the box, not a setup draft." @@ -62,3 +65,37 @@ func schemaFor(t reflect.Type) map[string]any { return map[string]any{} } } + +func setupFieldDescription(t reflect.Type, name string) string { + if t == reflect.TypeFor[config.Setup]() { + return map[string]string{ + "goals_reviewed": "The owner reviewed operating mode, battery reserve/export, planning style and any car/loadpoint goals. Unknown optional goals can wait.", + "measurement_only": "Keep true until equipment has been saved and is measuring. False requests automatic control after exact review; changing this requires a restart.", + "connection_confirmed": "True only after the owner checks the fuse, phase count and per-phase voltage against the network contract.", + "connection_digest": "Exact connection_digest from the current guide after reviewing its fuse and chosen site meter. Changes invalidate the confirmation.", + "meter_identity": "Exact live meter_identity from commissioning. Never invent an identity or use a display name.", + "equipment_reviewed": "The owner reviewed which equipment belongs to this home and the grid-boundary meter. This does not verify actuation.", + "price_reviewed": "The owner has reviewed the price setup, including a choice to leave unknown contract terms for later.", + "solar_present": "Owner's answer about installed solar. Unknown is null, never false by assumption.", + }[name] + } + if t == reflect.TypeFor[config.ForecastPriors]() { + return map[string]string{ + "reviewed": "True after the optional forecast questions were reviewed, including a choice to skip unknown answers. Retain answers and stop repeating these questions.", + "annual_kwh": "Optional electricity use for a full year, kWh. Record its basis and included components; never write historical samples from this answer.", + "annual_basis": "total_consumption, grid_import or unknown. Grid import alone cannot establish total demand with solar or storage.", + "annual_period": "Period the annual estimate describes, such as 2025 or the last twelve months.", + "includes_ev": "Whether annual_kwh includes EV charging. Null means unknown and prevents applying this annual prior.", + "ev_annual_kwh": "Annual EV charging kWh included in annual_kwh; subtract before the household baseline because EV is planned separately.", + "includes_heating": "Whether annual_kwh includes electric heating. Needed when a separate weather heating term is configured.", + "heating_annual_kwh": "Annual electric heating kWh included in annual_kwh. Remove it before adding a separately modelled heating term.", + "heating_type": "Optional owner's description. Do not turn this into an invented heating coefficient.", + "occupants": "Optional household size. Stored context, not an automatic guess about energy use.", + "source": "Source of the annual estimate, such as an owner estimate, full-year meter total or electricity bill. A bill may show grid purchases only.", + "recorded_at": "RFC3339 time the annual answers were recorded.", + "solar_source": "Source of optional PV capacity/geometry estimates. These do not establish inverter control limits.", + "solar_recorded_at": "RFC3339 time the solar answers were recorded.", + }[name] + } + return "" +} diff --git a/go/internal/state/initial_settings.go b/go/internal/state/initial_settings.go new file mode 100644 index 000000000..a6f129c32 --- /dev/null +++ b/go/internal/state/initial_settings.go @@ -0,0 +1,48 @@ +package state + +import ( + "context" + "errors" + "os" +) + +// SnapshotInitialSettings produces a complete SQLite file for atomic first- +// install publication. It is not a history export or an existing-home backup. +func (s *Store) SnapshotInitialSettings(path string) error { + doc, found, err := s.Configuration() + if err != nil { + return err + } + if !found || doc.Revision != 1 { + return errors.New("initial settings snapshot requires the first committed revision") + } + return s.copyStateForBackup(context.Background(), path) +} + +// CreateInitialSettings prepares only settings in a newly owned staging file. +// History is created by the first real boot, beside the published state.db. +// Opening a full Store here would bind a history file in the staging directory. +func CreateInitialSettings(path string) (*Store, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + if err = file.Close(); err != nil { + return nil, err + } + db, err := openRaw(path) + if err != nil { + return nil, err + } + cache, err := openRaw(path + ".cache") + if err != nil { + db.Close() + return nil, err + } + s := &Store{db: db, cache: cache, mainDBPath: path} + if err := s.migrate(); err != nil { + s.Close() + return nil, err + } + return s, nil +} diff --git a/web/project-statistics.js b/web/project-statistics.js index b7af63748..dd9200359 100644 --- a/web/project-statistics.js +++ b/web/project-statistics.js @@ -46,9 +46,11 @@ } var panel=node("fieldset",null,host); panel.className="project-statistics-panel"; + var assistant=host.id === "access-cloud-settings"; var cloud=status.link==="on" && !status.statisticsOnly; - node("legend",cloud?(status.cloudTermsAccepted?"FTW Cloud":"Review FTW Cloud terms"):"Contribute to FTW statistics?",panel); - if(cloud) say(panel,status.cloudTermsAccepted?"The current Cloud terms are accepted. FTW Cloud includes project statistics.":"FTW Cloud includes project statistics. Sourceful can read its copy of your energy data. Accept the updated terms for the app and remote access, or turn Cloud off and keep using the box locally."); + node("legend",assistant?"FTW Cloud for assistants":cloud?(status.cloudTermsAccepted?"FTW Cloud":"Review FTW Cloud terms"):"Contribute to FTW statistics?",panel); + if(assistant && !cloud) say(panel,"FTW Cloud lets your assistant reach this box. Sourceful can read its Cloud copy of your energy data. Cloud includes project statistics. Review the terms below before accepting."); + else if(cloud) say(panel,status.cloudTermsAccepted?"The current Cloud terms are accepted. FTW Cloud includes project statistics.":"FTW Cloud includes project statistics. Sourceful can read its copy of your energy data. Accept the updated terms for the app and remote access, or turn Cloud off and keep using the box locally."); else say(panel,"For local FTW use, sharing is your choice. Yes is the suggested choice. Nothing starts until you answer, and saying no leaves local operation working. Statistics-only sharing gives no app, phone or assistant remote access."); say(panel,"Shares recorded hourly energy and configured system capacities. Reports stay for 48 hours; public totals require at least 20 homes."); var disclosure=node("details",null,panel); @@ -74,6 +76,12 @@ if(!window.confirm("Turn FTW Cloud off? The app and assistants lose remote access. The box keeps working locally. You may need to pair phones again if you turn Cloud back on."))return; return write("DELETE","/api/cloud"); },false,feedback); + } else if(assistant) { + button(actions,"Accept Cloud terms and turn it on",function(){return write("POST","/api/cloud/grant",{scopes:policy.cloud_scopes,policy:policy.cloud_policy});},true,feedback); + var local=node("details",null,panel);node("summary","Statistics without assistant remote access",local); + say(local,"Statistics-only sharing gives no app, phone or assistant remote access."); + if(status.statisticsOnly) button(local,"Stop sharing statistics",function(){return write("DELETE","/api/cloud/stats");},false,feedback); + else {button(local,"Yes, contribute",function(){return write("POST","/api/cloud/stats",{policy:policy.policy});},false,feedback);} } else if(status.statisticsOnly) { say(panel,"Local statistics sharing is on. Remote Cloud functions are off."); button(actions,"Stop sharing statistics",function(){return write("DELETE","/api/cloud/stats");},false,feedback); @@ -82,8 +90,9 @@ if(status.statisticsDecisionRequired) button(actions,"No, keep it local",function(){return write("DELETE","/api/cloud/stats");},false,feedback); else say(panel,"Local statistics sharing is off."); } - if(settings && !cloud) { - var more=node("details",null,panel);node("summary","Use FTW Cloud and the app",more); + if(settings && !cloud && !assistant) { + var more=node("details",null,panel); + node("summary","Use FTW Cloud and the app",more); say(more,"FTW Cloud adds app and assistant remote access and a readable state copy. Statistics are included for every Cloud user. Read the information above before accepting."); button(more,"Accept Cloud terms and turn it on",function(){return write("POST","/api/cloud/grant",{scopes:policy.cloud_scopes,policy:policy.cloud_policy});},true,feedback); } @@ -95,6 +104,7 @@ }).then(function(v){ if(current!==refreshNumber || saving || !v[0] || !v[1])return; status=v[0];policy=v[1];authStatus=v[2]; + if(window.dispatchEvent && typeof CustomEvent === "function") window.dispatchEvent(new CustomEvent("ftw-cloud-choice-changed", {detail:status})); if(!policy.cloud_includes_statistics || !policy.standalone)return; var prompt=document.getElementById("project-statistics-prompt"); if(!prompt){ diff --git a/web/settings.js b/web/settings.js index 0d6c39619..90d3077ef 100644 --- a/web/settings.js +++ b/web/settings.js @@ -83,6 +83,17 @@ openBtn.click(); }; + // A first-run restart returns to the existing local owner controls. No + // URL parameter can approve a grant, accept terms or save a password. + if (window.location && new URLSearchParams(window.location.search).get("setup") === "assistant") { + window.addEventListener("load", function () { + var url = new URL(window.location.href); + url.searchParams.delete("setup"); + window.history.replaceState(null, "", url.pathname + url.search + url.hash); + S.open("access"); + }, {once: true}); + } + function closeSettings() { modal.classList.add("hidden"); if (returnFocus && returnFocus.isConnected && returnFocus.getClientRects().length) { @@ -147,11 +158,11 @@ setStatus("Saving..."); var headers = { "Content-Type": "application/json" }; if (configETag) headers["If-Match"] = configETag; - return apiFetch("/api/config", { + return prepareSetupConnection().then(function () { return apiFetch("/api/config", { method: "POST", headers: headers, body: JSON.stringify(currentConfig), - }) + }); }) .then(function (r) { if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || ("HTTP " + r.status)); }); configETag = r.headers && r.headers.get ? r.headers.get("ETag") : configETag; @@ -187,6 +198,18 @@ }); } + function prepareSetupConnection() { + if (!currentConfig || !currentConfig.setup || currentConfig.setup.measurement_only) return Promise.resolve(); + return apiFetch("/api/setup/connection", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({fuse: currentConfig.fuse})}) + .then(function (r) { return r.json().then(function (body) { if (!r.ok) throw new Error(body.error || "Could not check the electricity connection."); return body; }); }) + .then(function (review) { + if (!currentConfig.setup.connection_confirmed || currentConfig.setup.connection_digest !== review.connection_digest) { + navigateTab("control"); + throw new Error("Review the electricity connection and confirm the shown values under Equipment control, then save again."); + } + }); + } + // ---- Restart-required modal ---- function showRestartModal(reasons) { diff --git a/web/settings/tabs/access-invitations-flow.test.mjs b/web/settings/tabs/access-invitations-flow.test.mjs index 67d3c1c9f..6dbee752d 100644 --- a/web/settings/tabs/access-invitations-flow.test.mjs +++ b/web/settings/tabs/access-invitations-flow.test.mjs @@ -35,6 +35,7 @@ function rig({ loseReply = false } = {}) { const context = vm.createContext({ window: {}, document: { createElement: element, getElementById: id => all().find(e => e.id === id) || null }, Uint8Array, crypto: webcrypto, atob, btoa, navigator: {}, setTimeout() {}, clearTimeout() {}, console, fetch: (path, opts) => { + if (path === '/api/cloud') return answer({link:'on',cloudTermsAccepted:true,statisticsOnly:false}); if (path === '/api/auth/status') return answer({ lan_auth: true, configured: true }); if (path === '/api/agents') return answer({ agents: [], requests: [], registry: { agentOps: {}, writeScopes: ['ftw.setup.write'], callScopes: ['ftw.setup.write'] }, box: { widen: true, offers: true, clock: 'ok', nowMs: Date.now() } }); if (path === 'https://cloud.ftw.energy/oauth/invitations') { diff --git a/web/settings/tabs/access-setup-flow.test.mjs b/web/settings/tabs/access-setup-flow.test.mjs index 4f2bea78d..fcf60960f 100644 --- a/web/settings/tabs/access-setup-flow.test.mjs +++ b/web/settings/tabs/access-setup-flow.test.mjs @@ -6,7 +6,7 @@ import vm from 'node:vm'; const source = readFileSync(new URL('./access.js', import.meta.url), 'utf8'); const setup = 'ftw.setup.write'; -function rig(renew = false) { +function rig(renew = false, cloud = {link:'on',cloudTermsAccepted:true,statisticsOnly:false}) { function element(tag) { const e = { tag, children: [], listeners: {}, checked: false, value: '', setAttribute(k, v) { this[k] = v; }, @@ -36,7 +36,8 @@ function rig(renew = false) { createElement: element, getElementById: id => all().find(e => e.id === id) || null, }, fetch: (path, opts) => { if (opts?.method === 'POST') posts.push({ path, body: JSON.parse(opts.body) }); - if (path === '/api/auth/status') return answer({ lan_auth: true, configured: true }); + if (path === '/api/cloud') return answer(cloud); + if (path === '/api/auth/status') return answer({ lan_auth: true, configured: true }); if (path === '/api/agents' && !opts) return answer({ agents: renew ? [row] : [], requests: [], registry, box: { widen: true, offers: true, clock: 'ok', nowMs: Date.now(), lookup: '/lookup', longestMs: { agent: 31536000000 } } }); if (path.startsWith('/lookup?')) return answer({ offer: 'closed-fixture', status: 'pending' }); if (path === '/api/agents/offer') return answer({ ...row, code: 'TESTCODE', client: 'ChatGPT', ttlS: 2592000, expMs: Date.now() + 600000 }); @@ -74,3 +75,10 @@ for (const keep of [false, true]) test(`renewal preserves the setup choice (${ke assert.ok(sent); assert.equal(sent.body.scopes.includes(setup), keep); }); + +for (const cloud of [{link:'off'}, {link:'on',statisticsOnly:true}, {link:'ending'}, {link:'on',cloudTermsAccepted:false}]) test(`Cloud readiness blocks code consumption: ${JSON.stringify(cloud)}`,async()=>{ + const r=rig(false,cloud);await settle(); + r.roots['access-code'].value='TESTCODE';r.roots['access-code-form'].submit();await settle(); + assert.equal(r.posts.some(p=>p.path==='/api/agents/offer'),false); + assert.equal(r.roots['access-lookup'].disabled,true); +}); diff --git a/web/settings/tabs/access.js b/web/settings/tabs/access.js index 180ccf58a..fc5394c25 100644 --- a/web/settings/tabs/access.js +++ b/web/settings/tabs/access.js @@ -57,6 +57,8 @@ invites: null, inviteDraft: null, list: null, // GET /api/agents unavailable: "", // why the box has no agents, when it has none + cloudChecked: false, + cloud: null, // GET /api/cloud auth: null, // GET /api/auth/status held: null, // {raw, view, lookup, fetchedAt} note: null, // {text, kind} under the code field @@ -249,6 +251,7 @@ // What stops a grant before the owner types a code, or null. The order is // the order to fix them in. function readiness(list, unavailable, auth) { + if (state.cloudChecked && (!state.cloud || state.cloud.link !== "on" || state.cloud.statisticsOnly || !state.cloud.cloudTermsAccepted)) return {kind:"cloud"}; if (unavailable) return { kind: "unavailable" }; if (!list) return { kind: "loading" }; if (!auth || !auth.lan_auth || !auth.configured) return { kind: "password" }; @@ -375,13 +378,18 @@ if (typeof v === "boolean") return v ? "yes" : "no"; if (typeof v !== "number") return cleanText(v, 80); if (!Number.isFinite(v)) return "?"; + if(arg.name === "days" && Number.isInteger(v) && v>=0 && v<=127) { + if(v===0) return "Once at the next ready time"; + if(v===127) return "Every day"; + return ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"].filter(function(_,i){return !!(v & (1<= 0 && v < 1440) { - return String(Math.floor(v / 60)).padStart(2, "0") + ":" + String(v % 60).padStart(2, "0"); + return String(Math.floor(v / 60)).padStart(2, "0") + ":" + String(v % 60).padStart(2, "0") + (state.list && state.list.box && state.list.box.timezone ? " ("+cleanText(state.list.box.timezone,80)+")" : " (box local time)"); } return String(v); } @@ -416,10 +424,11 @@ }), fetch("/api/auth/status").then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }), fetch("/api/agent-invites").then(function(r){ return r.ok ? r.json() : null; }).catch(function(){ return null; }), + fetch("/api/cloud").then(function(r){return r.ok?r.json():null;}).catch(function(){return null;}), ]).then(function (got) { state.unavailable = got[0].unavailable ? "off" : ""; state.list = got[0].unavailable ? null : got[0]; - state.auth = got[1]; state.invites = got[2]; + state.auth = got[1]; state.invites = got[2]; state.cloud = got[3]; state.cloudChecked=true; if(state.inviteDraft && state.inviteDraft.request && state.invites) { var latest=(state.invites.invitations || []).find(function(v){return v.id===state.inviteDraft.request.id;}); if(latest) { @@ -478,6 +487,9 @@ var box = el("div", { className: "access-blocked", role: "status" }); slot.appendChild(box); switch (problem.kind) { + case "cloud": + box.appendChild(el("p", {text:"Accept FTW Cloud terms above before entering the assistant code. Statistics-only sharing gives no assistant remote access. If Cloud is stopping, wait for its deletion receipt first."})); + return; case "loading": box.className = "access-quiet"; box.textContent = "Checking the box…"; @@ -542,6 +554,7 @@ var input = document.getElementById("access-code"); var button = document.getElementById("access-lookup"); if (!input || !button || !state.list) return; + if(readiness(state.list,state.unavailable,state.auth)) {drawReady(ctx);return;} var code = normalizeCode(input.value); state.done = null; if (!code) { @@ -1096,6 +1109,10 @@ } var lastCtx = null; + if(window.addEventListener) window.addEventListener("ftw-cloud-choice-changed",function(event){ + state.cloud=event.detail;state.cloudChecked=true; + if(document.getElementById("access-ready")) drawReady(lastCtx); + }); function refresh(ctx) { if (ctx) lastCtx = ctx; @@ -1116,6 +1133,8 @@ '

Let an AI assistant, such as Claude or ChatGPT, read this home and, ' + "if you allow it, control it within limits. It connects through FTW Cloud. This box " + "decides what it may do and checks every call. No phone app is needed.

" + + '

For a new box: sign in with the house password you chose, review Cloud terms, then ask your assistant to begin FTW setup. Approve its setup connection below. If its connection page offers a choice, choose “Set up and configure”. The assistant can resume the draft stored on this box.

' + + '
' + '
' + '
' + '' + @@ -1130,6 +1149,7 @@ "
" + '
' + "" + + '

' + '