From de9073ba0dae02f7abe00125ab97079b7768fc11 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 12 Nov 2026 10:36:47 +0100 Subject: [PATCH] feat(stats): send the daily energy report straight to FTW Cloud until a relay exists The energy report waited for an independent relay that does not exist, so no box sent one and stats.ftw.energy showed no energy. A build without a relay now posts it to https://cloud.ftw.energy/v1/energy, as the fleet report goes to /v1/fleet since 8 November: every box that shares reports (Fredrik, 12 November 2026). The body is the same padded inner request the gateway would open, unsealed. FTW Cloud sees the address while the request is open and keeps neither the address nor the report. Settings says where it goes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/energy-report-direct.md | 5 + docs/adr/0008-ftw-cloud.md | 13 +++ docs/architecture.md | 5 +- docs/protocol-cloud.md | 4 +- go/cmd/ftw/energy_report.go | 2 +- go/internal/api/api_fleet_ping_test.go | 13 +-- go/internal/energyreport/energyreport.go | 32 ++++--- go/internal/energyreport/energyreport_test.go | 33 ++++--- go/internal/statsrelay/statsrelay.go | 94 ++++++++++++++++--- go/internal/statsrelay/statsrelay_test.go | 39 ++++++++ .../statsrelaytest/statsrelaytest.go | 38 ++++++++ web/fleet-ping-tab.test.mjs | 20 +++- web/settings/tabs/fleet.js | 31 +++--- 13 files changed, 263 insertions(+), 66 deletions(-) create mode 100644 .changeset/energy-report-direct.md diff --git a/.changeset/energy-report-direct.md b/.changeset/energy-report-direct.md new file mode 100644 index 000000000..dc8686952 --- /dev/null +++ b/.changeset/energy-report-direct.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +The daily energy report now goes straight to FTW Cloud, as the fleet report has since 8 November. It waited for an independent relay that does not exist yet, so no box sent one and stats.ftw.energy showed no energy. FTW Cloud sees the box's address while the request is open and keeps neither the address nor the report. Settings → FTW app → Anonymous statistics says where the report goes. diff --git a/docs/adr/0008-ftw-cloud.md b/docs/adr/0008-ftw-cloud.md index a0f7c5868..49e6cfa41 100644 --- a/docs/adr/0008-ftw-cloud.md +++ b/docs/adr/0008-ftw-cloud.md @@ -431,6 +431,19 @@ build with the relay sends the fleet report to FTW Cloud only through it. release after the first that pins the relay. A collector the household set in `fleet_ping.endpoint` is still posted to directly. +**Amended 8 and 12 November 2026.** No relay exists yet. On 8 November +Fredrik decided that every box that shares sends its fleet report straight to +`/v1/fleet`, not only one that said yes. On 12 November he decided the same +for the energy report, so that stats.ftw.energy can show energy: a build +without a relay posts it to `POST /v1/energy` (`statsrelay.DirectURL`). That +route takes the same padded inner request, unsealed, checks its id and day +the same way and adds it to the same totals. FTW Cloud then sees the box's +address while the request is open. It writes no request log and keeps +neither the address nor the report, but until a relay exists Sourceful alone +could match an energy report to a fleet report by address and time; that is +a rule in code. Once a release pins a relay, both reports go through it as +described above. + **Who sees what.** The relay sees the box's address, the time and a ciphertext of fixed length, never a report. Cloudflare's relay, if chosen, keeps those logs for about 124 days. The gateway sees each report, the diff --git a/docs/architecture.md b/docs/architecture.md index 2a5dd2d57..110a444eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -643,8 +643,9 @@ counts health only from reports that carry it. Once a release names an Oblivious HTTP relay (`go/internal/statsrelay`), it goes through that relay to FTW Cloud's gateway, sealed, so FTW Cloud never sees the box's address. Until then it goes over HTTPS to -`https://cloud.ftw.energy/v1/fleet`, and only from a box that said yes. It never -enters the Noise session. The HTTP handler validates the fixed payload, adds it to the UTC +`https://cloud.ftw.energy/v1/fleet` from every box that shares, and the energy +report to `https://cloud.ftw.energy/v1/energy` (ADR 0008, amended 8 and 12 +November 2026). It never enters the Noise session. The HTTP handler validates the fixed payload, adds it to the UTC day's counters and drops it. It keeps 90 calendar days of totals, not raw reports. An operator reads totals only on the loopback metrics listener. An explicitly saved former default resolves to Cloud; a private collector keeps diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 6a4130fbb..f6628dde0 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -1860,6 +1860,6 @@ is empty. Withdrawal and retention continue while it is paused. Since 24 October 2026 a box without Cloud no longer starts a statistics-only roster: its statistics are the two anonymous daily reports, through the -Oblivious HTTP relay (ADR 0008, "Anonymous daily reports and one statistics -answer"). Statistics-only rosters already running keep working until the +Oblivious HTTP relay once one exists and straight to FTW Cloud until then +(ADR 0008, "Anonymous daily reports and one statistics answer"). Statistics-only rosters already running keep working until the owner turns statistics off. diff --git a/go/cmd/ftw/energy_report.go b/go/cmd/ftw/energy_report.go index e40d8b84b..218c6fae9 100644 --- a/go/cmd/ftw/energy_report.go +++ b/go/cmd/ftw/energy_report.go @@ -17,7 +17,7 @@ import ( // startEnergyReport builds the sender and starts its schedule. It is built // even when nothing may be sent, so Settings can show the report this box -// would send. A build with no relay or gateway key sends nothing at all. +// would send. A build with no relay sends it straight to FTW Cloud. func startEnergyReport(ctx context.Context, cfg *config.Config, cfgMu *sync.RWMutex, st *state.Store, answer statsAnswer) *energyreport.Sender { if st == nil { return nil diff --git a/go/internal/api/api_fleet_ping_test.go b/go/internal/api/api_fleet_ping_test.go index d1b77ac9f..140753230 100644 --- a/go/internal/api/api_fleet_ping_test.go +++ b/go/internal/api/api_fleet_ping_test.go @@ -182,7 +182,7 @@ func (dayLedger) LoadEnergyHistoryContext(_ context.Context, q state.EnergyHisto // The daily energy report rides under the same switch, so Settings shows it // beside the fleet report, built by the call its sender uses. A build with no -// relay says it sends nothing. +// relay sends it straight to FTW Cloud and says so. func TestFleetPingShowsTheEnergyReportItWouldSend(t *testing.T) { cfg := &config.Config{FleetPing: &config.FleetPing{Enabled: true}} srv := fleetPingServer(t, cfg) @@ -197,17 +197,18 @@ func TestFleetPingShowsTheEnergyReportItWouldSend(t *testing.T) { srv.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/fleet-ping", nil)) var got struct { Energy struct { - Relay string `json:"relay"` - Sending bool `json:"sending"` - Day string `json:"day"` - Payload map[string]any `json:"payload"` + Relay string `json:"relay"` + Endpoint string `json:"endpoint"` + Sending bool `json:"sending"` + Day string `json:"day"` + Payload map[string]any `json:"payload"` } `json:"energy"` } if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } e := got.Energy - if e.Relay != "" || e.Sending || e.Day != "2026-10-23" || e.Payload["grid_import_kwh"] != 14.0 || e.Payload["date"] != "2026-10-23" || len(e.Payload) != 8 { + if e.Relay != "" || e.Endpoint != "https://cloud.ftw.energy/v1/energy" || !e.Sending || e.Day != "2026-10-23" || e.Payload["grid_import_kwh"] != 14.0 || e.Payload["date"] != "2026-10-23" || len(e.Payload) != 8 { t.Fatalf("energy = %+v", e) } } diff --git a/go/internal/energyreport/energyreport.go b/go/internal/energyreport/energyreport.go index 75ce7451b..c143e39ff 100644 --- a/go/internal/energyreport/energyreport.go +++ b/go/internal/energyreport/energyreport.go @@ -1,16 +1,20 @@ -// Package energyreport sends one anonymous energy report a day, through an -// independent Oblivious HTTP relay, so that neither the relay nor FTW Cloud -// can tell which home sent it. +// Package energyreport sends one anonymous energy report a day to FTW Cloud. // // The report is six whole-kWh sums of one closed UTC day and its date -// (package energywire). Package statsrelay seals it to FTW Cloud's gateway -// key, pinned in this release, and posts it to the relay: +// (package energywire). Package statsrelay carries it. With a relay pinned in +// this release, it seals the report to FTW Cloud's gateway key and posts it +// to the relay: // // - the relay sees the box's address, the time and a ciphertext of fixed // length, never the report; // - the gateway sees the report and the relay's address, never the box's. // It adds the sums to that day's totals and keeps nothing of the report. // +// Until a relay exists, the report goes straight to FTW Cloud, as the fleet +// report does (Fredrik, 12 November 2026): every box that shares reports. +// FTW Cloud then sees the box's address while the request is open, adds the +// sums the same way and keeps neither the address nor the report. +// // Two things keep the report from being joined to the daily fleet report, // which carries the box's drivers, zone and size: it carries no field the // fleet report carries, and it is sent alone at its own time, drawn afresh @@ -59,7 +63,8 @@ type Options struct { Ledger Ledger KV KV - // Relay defaults to statsrelay.Pinned. Tests set a fake relay. + // Relay defaults to statsrelay.Default: the pinned relay, or FTW Cloud + // directly. Tests set a fake. Relay *statsrelay.Client Logger *slog.Logger @@ -88,7 +93,7 @@ func New(opts Options) (*Sender, error) { s := &Sender{enabled: opts.Enabled, ledger: opts.Ledger, kv: opts.KV, relay: opts.Relay, log: opts.Logger, now: opts.Now, random: opts.Random, synced: opts.ClockSynced} if s.relay == nil { - s.relay = statsrelay.Pinned() + s.relay = statsrelay.Default() } if s.enabled == nil { s.enabled = func() bool { return false } @@ -111,10 +116,12 @@ func New(opts Options) (*Sender, error) { // Preview is what the box would send for yesterday, for Settings. type Preview struct { - // Relay is where it goes, empty when this build has no relay. + // Relay is the relay it goes through, empty when it goes straight to + // FTW Cloud. Relay string `json:"relay"` - // Sending is whether this box sends at all: a relay and a gateway key in - // this build, and the owner's answer now. + // Endpoint is where the box posts it: the relay, or FTW Cloud. + Endpoint string `json:"endpoint"` + // Sending is whether this box sends at all: the owner's answer now. Sending bool `json:"sending"` // Day is the closed UTC day it describes. Day string `json:"day"` @@ -127,7 +134,7 @@ func (s *Sender) Preview(ctx context.Context) Preview { day := s.now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -1) p := Preview{Day: day.Format(time.DateOnly)} if s.relay != nil { - p.Relay, p.Sending = s.relay.Relay(), s.enabled() + p.Relay, p.Endpoint, p.Sending = s.relay.Relay(), s.relay.Endpoint(), s.enabled() } if r, ok, err := Day(ctx, s.ledger, day); err == nil && ok { p.Payload = &r @@ -135,8 +142,7 @@ func (s *Sender) Preview(ctx context.Context) Preview { return p } -// Run sends once a day until ctx ends. Without a relay and a gateway key it -// returns at once and sends nothing. +// Run sends once a day until ctx ends. func (s *Sender) Run(ctx context.Context) { if s.relay == nil { return diff --git a/go/internal/energyreport/energyreport_test.go b/go/internal/energyreport/energyreport_test.go index b6b19863e..0a4ce18e1 100644 --- a/go/internal/energyreport/energyreport_test.go +++ b/go/internal/energyreport/energyreport_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/statsrelay" "github.com/srcfl/ftw/go/internal/statsrelay/statsrelaytest" ) @@ -95,22 +96,26 @@ func TestNothingIsSentWithoutTheOwnersAnswer(t *testing.T) { } } -// Without a relay and a gateway key, Run returns and never reads the ledger. -func TestWithoutARelayRunSendsNothing(t *testing.T) { - l := &countingLedger{} - s := newSender(t, Options{Ledger: l, Enabled: func() bool { return true }}) - done := make(chan struct{}) - go func() { s.Run(context.Background()); close(done) }() - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("Run waited with nowhere to send") +// Without a relay the report goes straight to FTW Cloud, and Settings says +// where. +func TestWithoutARelayTheReportGoesStraightToFTWCloud(t *testing.T) { + s := newSender(t, Options{Enabled: func() bool { return true }}) + if statsrelay.Pinned() == nil { + if p := s.Preview(context.Background()); p.Relay != "" || p.Endpoint != statsrelay.DirectURL || !p.Sending || p.Day == "" { + t.Fatalf("preview %+v", p) + } } - if l.n != 0 { - t.Fatal("read the ledger with nowhere to send") + + client, cloud := statsrelaytest.NewDirect(t, day.Add(30*time.Hour)) + kv := memKV{} + s = newSender(t, Options{Ledger: whole(t), KV: kv, Relay: client, Enabled: func() bool { return true }, + Now: func() time.Time { return day.Add(30 * time.Hour) }}) + s.send(context.Background(), day) + if got := cloud.Energy(); len(got) != 1 || got[0].Date != "2026-10-23" || got[0].GridImport != 24 { + t.Fatalf("FTW Cloud got %+v", got) } - if p := s.Preview(context.Background()); p.Relay != "" || p.Day == "" { - t.Fatalf("preview %+v", p) + if kv[lastDayKey] != "2026-10-23" { + t.Fatalf("kv %v", kv) } } diff --git a/go/internal/statsrelay/statsrelay.go b/go/internal/statsrelay/statsrelay.go index 2610de8f6..335ae0d43 100644 --- a/go/internal/statsrelay/statsrelay.go +++ b/go/internal/statsrelay/statsrelay.go @@ -7,6 +7,12 @@ // Two reports use it, the fleet report (package fleetping) and the energy // report (package energyreport), each at its own random time and never in // one message. +// +// Until a release names a relay, the energy report goes straight to FTW +// Cloud instead (Direct), as the fleet report goes to /v1/fleet: every box +// that shares reports (Fredrik, 8 and 12 November 2026). FTW Cloud then sees +// the box's address while the request is open, the way any website does, and +// keeps neither the address nor the report. package statsrelay import ( @@ -32,8 +38,9 @@ import ( // test build may set them with -ldflags -X. var ( // RelayURL is the OHTTP relay, run by someone other than Sourceful. - // Empty until that relay exists: the energy report is then not sent, - // and the fleet report goes straight to FTW Cloud as before. + // Empty until that relay exists: both reports then go straight to FTW + // Cloud, the energy report to DirectURL and the fleet report to + // /v1/fleet. RelayURL = "" // GatewayKey is FTW Cloud's gateway key configuration (RFC 9458, // section 3), base64url, as @@ -42,19 +49,29 @@ var ( // box on a release uses the same key and a key swapped in at the service // reads nothing. Empty means no gateway key exists for this build. GatewayKey = "" + // DirectURL is FTW Cloud's route for the energy report from a build + // without a relay. It takes the same padded inner request the gateway + // opens, unsealed. + DirectURL = "https://cloud.ftw.energy/v1/energy" ) +// DirectType is the direct request's media type: the inner request is a +// Binary HTTP message (RFC 9292). +const DirectType = "message/bhttp" + const ( sendTimeout = 30 * time.Second maxResponseBytes = 1 << 10 ) -// Client posts sealed reports to one relay under one gateway key. +// Client posts reports to one place: sealed to one relay under one gateway +// key, or, when direct, unsealed to FTW Cloud. type Client struct { - relay string - key ohttp.KeyConfig - http *http.Client - now func() time.Time + url string + key ohttp.KeyConfig + direct bool + http *http.Client + now func() time.Time } // Pinned is this build's client, or nil when the build names no usable relay @@ -67,6 +84,15 @@ func Pinned() *Client { return New(relay, key, nil) } +// Default is the relay when this build pins one, and FTW Cloud directly when +// it does not. +func Default() *Client { + if c := Pinned(); c != nil { + return c + } + return NewDirect(DirectURL, nil) +} + func pinned(relay, key string) (string, ohttp.KeyConfig, bool) { raw, err := base64.RawURLEncoding.DecodeString(key) if err != nil || relay == "" || key == "" { @@ -91,15 +117,25 @@ func pinned(relay, key string) (string, ohttp.KeyConfig, bool) { // New is a client for one relay and key. Tests point it at a fake relay. func New(relay string, key ohttp.KeyConfig, client *http.Client) *Client { + return &Client{url: relay, key: key, http: httpClient(client), now: time.Now} +} + +// NewDirect is a client that posts each report, unsealed, to endpoint. Tests +// point it at a fake FTW Cloud. +func NewDirect(endpoint string, client *http.Client) *Client { + return &Client{url: endpoint, direct: true, http: httpClient(client), now: time.Now} +} + +func httpClient(client *http.Client) *http.Client { if client == nil { client = &http.Client{Timeout: sendTimeout} } else { c := *client client = &c } - // A redirect would resend the sealed report to an address nobody chose. + // A redirect would resend the report to an address nobody chose. client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } - return &Client{relay: relay, key: key, http: client, now: time.Now} + return client } // WithClock makes c stamp reports with the day now reads. Tests pin it to @@ -109,8 +145,16 @@ func (c *Client) WithClock(now func() time.Time) *Client { return c } -// Relay is the relay's URL, for Settings. -func (c *Client) Relay() string { return c.relay } +// Relay is the relay's URL, for Settings, and empty when c posts directly. +func (c *Client) Relay() string { + if c.direct { + return "" + } + return c.url +} + +// Endpoint is where c posts: the relay, or FTW Cloud when direct. +func (c *Client) Endpoint() string { return c.url } // Send seals one report body and reads the gateway's answer. Only a sealed // 204 from the gateway the box encrypted to counts as delivered. @@ -127,13 +171,16 @@ func (c *Client) Send(ctx context.Context, body []byte) error { if !ok { return errors.New("statsrelay: the report does not fit the padded request") } + if c.direct { + return c.sendDirect(ctx, inner) + } sealed, reader, err := ohttp.Encapsulate(c.key, inner) if err != nil { return err } ctx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.relay, bytes.NewReader(sealed)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(sealed)) if err != nil { return err } @@ -167,3 +214,26 @@ func (c *Client) Send(ctx context.Context, body []byte) error { } return nil } + +// sendDirect posts the inner request to FTW Cloud. Only a 204 counts as +// delivered; the answer carries nothing else the box would read. +func (c *Client) sendDirect(ctx context.Context, inner []byte) error { + ctx, cancel := context.WithTimeout(ctx, sendTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(inner)) + if err != nil { + return err + } + req.Header.Set("Content-Type", DirectType) + req.Header.Set("User-Agent", "ftw-report") + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes)) + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("statsrelay: FTW Cloud answered %d", resp.StatusCode) + } + return nil +} diff --git a/go/internal/statsrelay/statsrelay_test.go b/go/internal/statsrelay/statsrelay_test.go index 00d4559e1..8f693374f 100644 --- a/go/internal/statsrelay/statsrelay_test.go +++ b/go/internal/statsrelay/statsrelay_test.go @@ -89,6 +89,45 @@ func TestAReportReachesTheGatewayThroughTheRelay(t *testing.T) { } } +// Without a relay, the box posts the same padded inner request straight to +// FTW Cloud, and believes only a 204. +func TestWithoutARelayAReportGoesStraightToFTWCloud(t *testing.T) { + if statsrelay.Pinned() == nil { + c := statsrelay.Default() + if c.Relay() != "" || c.Endpoint() != statsrelay.DirectURL || c.Endpoint() != "https://cloud.ftw.energy/v1/energy" { + t.Fatalf("default goes to %q through %q", c.Endpoint(), c.Relay()) + } + } + client, cloud := statsrelaytest.NewDirect(t, now) + body, _ := json.Marshal(energywire.Report{Schema: energywire.Schema, Date: "2026-10-23", PVGeneration: 31}) + if err := client.Send(context.Background(), body); err != nil { + t.Fatal(err) + } + if got := cloud.Energy(); len(got) != 1 || got[0].PVGeneration != 31 { + t.Fatalf("FTW Cloud got %+v", got) + } + h := cloud.Headers()[0] + if h.Get("Content-Type") != statsrelay.DirectType || h.Get("User-Agent") != "ftw-report" || cloud.Sizes()[0] != reportwire.RequestSize { + t.Fatalf("headers %v, %d bytes", h, cloud.Sizes()[0]) + } + if client.Relay() != "" || client.Endpoint() != cloud.URL { + t.Fatalf("relay %q endpoint %q", client.Relay(), client.Endpoint()) + } + for name, h := range map[string]http.HandlerFunc{ + "error": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) }, + "plain ok": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }, + "redirect": func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://elsewhere.example/", http.StatusTemporaryRedirect) + }, + } { + srv := httptest.NewServer(h) + if err := statsrelay.NewDirect(srv.URL, nil).Send(context.Background(), body); err == nil { + t.Fatalf("%s: accepted", name) + } + srv.Close() + } +} + // The box believes only a sealed 204 from the gateway it encrypted to. func TestOnlyTheGatewaysSealed204Counts(t *testing.T) { k, _ := ecdh.X25519().GenerateKey(nil) diff --git a/go/internal/statsrelay/statsrelaytest/statsrelaytest.go b/go/internal/statsrelay/statsrelaytest/statsrelaytest.go index bee5e374a..bc40bea26 100644 --- a/go/internal/statsrelay/statsrelaytest/statsrelaytest.go +++ b/go/internal/statsrelay/statsrelaytest/statsrelaytest.go @@ -30,6 +30,9 @@ type Gateway struct { fleet []fleetwire.Report energy []energywire.Report last int + + headers []http.Header + sizes []int } // Relay forwards only the body and its content type, and records what it saw. @@ -89,6 +92,41 @@ func New(t testing.TB, now time.Time) (*statsrelay.Client, *Gateway, *Relay) { return statsrelay.New(rl.URL, g.Key, nil).WithClock(func() time.Time { return now }), g, rl } +// NewDirect starts a fake FTW Cloud whose clock reads now, taking reports +// unsealed as POST /v1/energy does, and a client posting straight to it. The +// Gateway's Headers are what came with each request. +func NewDirect(t testing.TB, now time.Time) (*statsrelay.Client, *Gateway) { + t.Helper() + g := &Gateway{now: now} + g.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + g.mu.Lock() + g.headers = append(g.headers, r.Header.Clone()) + g.sizes = append(g.sizes, len(body)) + g.mu.Unlock() + if r.Header.Get("Content-Type") != statsrelay.DirectType { + w.WriteHeader(http.StatusUnsupportedMediaType) + return + } + w.WriteHeader(g.add(body)) + })) + t.Cleanup(g.Close) + return statsrelay.NewDirect(g.URL, nil).WithClock(func() time.Time { return now }), g +} + +// Headers is what came with each direct request, and Sizes each body's length. +func (g *Gateway) Headers() []http.Header { + g.mu.Lock() + defer g.mu.Unlock() + return append([]http.Header(nil), g.headers...) +} + +func (g *Gateway) Sizes() []int { + g.mu.Lock() + defer g.mu.Unlock() + return append([]int(nil), g.sizes...) +} + func (g *Gateway) add(msg []byte) int { g.mu.Lock() defer g.mu.Unlock() diff --git a/web/fleet-ping-tab.test.mjs b/web/fleet-ping-tab.test.mjs index b8a39c526..4743e13e1 100644 --- a/web/fleet-ping-tab.test.mjs +++ b/web/fleet-ping-tab.test.mjs @@ -327,9 +327,19 @@ describe("the daily energy report", () => { assert.ok(extra, "an unexpected field was silently dropped"); }); - it("says nothing is sent from a release without a relay", () => { - const line = energyLine({ relay: "", sending: false, day: "2026-10-23", payload: energy }); - assert.match(line, /no relay yet/i); + it("says a release without a relay sends straight to FTW Cloud", () => { + const endpoint = "https://cloud.ftw.energy/v1/energy"; + const on = energyLine({ relay: "", endpoint, sending: true, day: "2026-10-23", payload: energy }); + assert.match(on, /^Sent straight to FTW Cloud at https:\/\/cloud\.ftw\.energy\/v1\/energy/); + assert.match(on, /sees this box's address while the request is open/); + assert.match(on, /keeps neither the report nor the address/); + assert.doesNotMatch(on, /independent relay/); + assert.match(energyLine({ relay: "", endpoint, sending: false, payload: energy }), /^Nothing is sent/); + }); + + it("says nothing is sent when the box has nowhere to send", () => { + const line = energyLine({ relay: "", endpoint: "", sending: true, day: "2026-10-23", payload: energy }); + assert.match(line, /nowhere to send/i); assert.doesNotMatch(line, /^Sent/); }); @@ -340,7 +350,7 @@ describe("the daily energy report", () => { it("says who sees what while it is sent, and nothing is sent while it is off", () => { const relay = "https://relay.example.net/ftw"; - const on = energyLine({ relay, sending: true, payload: energy }); + const on = energyLine({ relay, endpoint: relay, sending: true, payload: energy }); assert.match(on, /^Sent through https:\/\/relay\.example\.net/); assert.match(on, /sees this box's address but not the report/); assert.match(on, /reads the report but not the address/); @@ -350,7 +360,7 @@ describe("the daily energy report", () => { it("names the fields, the missing id and the relay in its paragraph", () => { const html = render({}); assert.match(html, /id="fleet-energy-payload"/); - for (const want of [/solar generation/i, /grid import and export/i, /battery charge/i, /vehicle charge/i, /whole kWh/i, /no box id/i, /independent relay/i, /skipped/i]) { + for (const want of [/solar generation/i, /grid import and export/i, /battery charge/i, /vehicle charge/i, /whole kWh/i, /no box id/i, /independent relay/i, /straight to FTW Cloud/i, /skipped/i]) { assert.match(html, want, `the paragraph does not say ${want}`); } }); diff --git a/web/settings/tabs/fleet.js b/web/settings/tabs/fleet.js index 4d5d02e34..c7f1b8272 100644 --- a/web/settings/tabs/fleet.js +++ b/web/settings/tabs/fleet.js @@ -150,21 +150,28 @@ } // energyLine is whereLine for the energy report. It follows the box's own - // answer, and says so when this release has no relay to send through. + // answer, and says whether the report goes through a relay or straight to + // FTW Cloud. // // Without a report to show, it does not point at one. function energyLine(energy) { var shown = !!(energy && energy.payload); - if (!energy || !energy.relay) { - return "This release has no relay yet, so no energy report is sent." + + if (!energy || !(energy.relay || energy.endpoint)) { + return "This box has nowhere to send an energy report, so none is sent." + (shown ? " This is the report it would send." : ""); } - var how = " The relay sees this box's address but not the report. FTW Cloud reads the report but not the address, adds it to that day's totals and keeps nothing of it."; + var relayed = !!energy.relay; + var where = relayed + ? "through " + energy.relay + ", an independent relay," + : "straight to FTW Cloud at " + energy.endpoint + ","; + var how = relayed + ? " The relay sees this box's address but not the report. FTW Cloud reads the report but not the address, adds it to that day's totals and keeps nothing of it." + : " FTW Cloud sees this box's address while the request is open, the way any website does. It adds the report to that day's totals and keeps neither the report nor the address."; if (energy.sending) { - return "Sent through " + energy.relay + ", an independent relay, once a day, 1 to 12 hours after the UTC day ends." + how; + return "Sent " + where + " once a day, 1 to 12 hours after the UTC day ends." + how; } - return "Nothing is sent. " + (shown ? "This is the report that would go" : "A report would go") + " through " + - energy.relay + ", an independent relay, once a day, if sharing were on." + how; + return "Nothing is sent. " + (shown ? "This is the report that would go" : "A report would go") + " " + + where + " once a day, if sharing were on." + how; } function row(label, value) { @@ -363,10 +370,12 @@ "nothing from the fleet report, and leaves at its own time, so the two " + "cannot be matched. They never travel together: device kinds and a price " + "zone can point at one home, and the energy beside them would then be " + - "that home's. It goes through an independent relay, so FTW Cloud reads " + - "the numbers without this box's address, and the relay sees the address " + - "without the numbers. Sealed with it go a random number, drawn new for " + - "each report so FTW Cloud counts it once, and the UTC day it was sent. " + + "that home's. Once FTW names an independent relay, the report goes " + + "through it, so FTW Cloud reads the numbers without this box's address. " + + "Until then it goes straight to FTW Cloud, which sees the address while " + + "the request is open and keeps neither the address nor the report. With " + + "it go a random number, drawn new for each report so FTW Cloud counts it " + + "once, and the UTC day it was sent. " + "A day with a gap in the energy record is skipped.

" + '
' + ""