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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/anonymous-daily-energy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": minor
---

Statistics are one answer on the box, for two anonymous daily reports. With FTW Cloud they are part of Cloud. Without Cloud they are on until you turn them off under Settings → FTW app → Anonymous statistics, which shows both messages; nobody is asked any more. A No, a fleet report switched off, or Cloud turned off on an earlier release stays off.

Beside the fleet report, the box can now send one energy report a day: the date and that UTC day's solar generation, grid import and export, battery charge and discharge and vehicle charge, in whole kWh, never in the same message as the fleet report and at its own time. It skips any day with a gap in the energy record and any site a partner controls. Once a release names an Oblivious HTTP relay, both reports go through it, so FTW Cloud never sees the box's address. This release names none yet: the energy report is not sent, a box that said yes sends the fleet report straight to FTW Cloud as before, and a box that shares only by default sends nothing until a relay is named.
24 changes: 14 additions & 10 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -220,16 +220,20 @@ state:
app_link:
enabled: true

# Anonymous fleet ping. Once a day, at a time that moves, the box tells the
# FTW relay six things: its FTW version, its release channel, which driver
# types it runs, roughly how much battery it has, its price zone and roughly
# how old the install is. No id, no key, no serial, no site name, no counter
# and no timestamp — nothing in the message says which box sent it. Two
# limits: those six fields still describe a household, so a rare mix may be
# the only one of its kind, and the relay sees the address during the request.
# It saves only daily totals, so the result counts reports, not unique boxes.
# Settings → FTW app → Fleet statistics shows the exact message this box would send. On by
# default; `enabled: false` turns it off without a restart.
# Anonymous daily reports. Statistics are one answer on the box: part of FTW
# Cloud, and on for a box without Cloud until its owner turns them off under
# Settings → FTW app → Anonymous statistics, which shows each message. Two
# reports, each alone and at its own time. The fleet report carries six
# things: FTW version, release channel, driver types, roughly how much
# battery, price zone and roughly how old the install is. The energy report
# carries a closed UTC day's six whole-kWh sums. No box id, no key, no serial,
# no site name, no counter. Once a release names a relay, both go through it,
# sealed with a random number drawn new for each report and the day it was
# sent, so FTW Cloud never sees the address; until then the fleet report goes
# straight to `endpoint`, which sees the address, and the energy report is not
# sent.
# `enabled: false` from an earlier release is read once and kept as "off"; it
# is no longer a switch of its own.
fleet_ping:
enabled: true
# endpoint: https://cloud.ftw.energy/v1/fleet
Expand Down
109 changes: 109 additions & 0 deletions docs/adr/0008-ftw-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ Owners who decline use the box locally without Cloud.

Local-only users get a separate question, with Yes as the suggested action.
Nothing starts before an active answer. No leaves local control working.
(Replaced on 24 October 2026: a box without Cloud shares the two anonymous
reports until its owner turns them off; see "Anonymous daily reports and one
statistics answer" below.)
This statistics-only link publishes no phone keys, pairing codes, Noise key,
read grant, live state or remote access. It reuses the signed upload path.
A policy or purpose change requires a new version and a fresh answer.
Expand Down Expand Up @@ -329,6 +332,8 @@ who declines its terms pauses Cloud on that phone and keeps its local keys.

The daily anonymous report also waits for the current project choice.
Its saved config switch cannot bypass a missing answer or a local No.
(Replaced on 24 October 2026; see "Anonymous daily reports and one
statistics answer" below.)
A new policy rotates the stats id and starts a new reporting window. An
unset wall clock starts that window only once time is known; no earlier
hours are filled in.
Expand All @@ -350,3 +355,107 @@ even if an owner previously chose to contribute. The owner policy response
reports this readiness so native clients can explain the wait. Pausing the
service policy stops collection while withdrawal and retention still run.
Deploying this code does not sign the DPIA or accept a household's consent.

### Anonymous daily reports and one statistics answer, October 2026

Fredrik decided this on 24 October 2026, so that stats.ftw.energy can show
energy without C8's household rows, and so that statistics are one thing:
"antingen delar man med sig av stats eller inte".

**One answer.** Statistics are one answer on the box that covers both
anonymous daily reports, with no second setting:

- With FTW Cloud they are part of Cloud, with no separate off switch, as in
the 17 October amendment. A Cloud grant under older terms sends nothing
until the owner accepts the current ones.
- Without Cloud they are on until the owner turns them off, under Settings →
FTW app → Anonymous statistics, where the box shows each message. Nobody is
asked any more; the local question and its "nothing before an answer" rule
are gone.
- Stored state is read once (`cloudlink.MigrateStatistics`). A local No, a
fleet report switched off in the config (`fleet_ping.enabled: false`), or
Cloud turned off before this release (whose screen said that stops
statistics) stays off. Every other box is on. `fleet_ping.enabled` is no
longer a switch.
- A box without Cloud no longer starts a statistics-only Cloud link. Those
already running keep working until turned off. Hourly C8 rows for local
boxes therefore have no way in; if C8 is ever activated for them, it needs
its own consent.
- The hourly C8 upload follows the same answer: off, or Cloud under older
terms, sends no hour whatever the grant carries, and an answer that is off
takes the stats purpose out of the grant. Under older terms, asking for
statistics is refused until the owner accepts the current terms.
- A stored grant counts only through the running link, which honours it only
with the key it was made with.

**Two reports, never one message.** Each leaves at its own random time:

- the fleet report (`go/internal/fleetping`): version, channel, drivers,
battery size, price zone, install age, as before;
- the energy report (`go/internal/energyreport`): once per closed UTC day,
1 to 12 hours after it closes, the date and six whole-kWh sums (solar
generation, grid import and export, battery charge and discharge, vehicle
charge). A day with a gap in the energy ledger is skipped, never sent in
part. Never for a site a partner controls.

With a fleet of about eight boxes, a driver set such as Sungrow, Easee,
myuplink and tesla-ha in SE4 points at one home. Energy sent beside it would
be that home's energy again. So the two share no field and never travel
together.

**Path.** Both go through one independent Oblivious HTTP relay (RFC 9458) to
FTW Cloud's gateway, `POST /v1/ohttp` in `ftwcloudd`, as one padded inner
request each (`go/protocol/reportwire`, 1,536 bytes; the relay always sees
1,591). Inside, each report carries a random id and the UTC date it was
sent. The gateway reads the schema, adds the report to its own day totals
(`/v1/fleet/public`, `/v1/energy/public`) and keeps nothing per report but
a hash of its id, for three days, so a replay or a retry counts once
(RFC 9458, section 6.5). A report sent before yesterday is refused. Both
public views list closed days only, so nobody can match a new count to the
report that just arrived.
`go/internal/statsrelay` holds the relay URL and the gateway key as
compile-time pins. The box encrypts only to the pinned key and never asks the
service for one, so every box on a release uses the same key and a key
swapped in at the service opens nothing. Until both pins are set, the energy
report is not sent, and the fleet report posts straight to `/v1/fleet` as
before, but only from a box that said yes: FTW Cloud's terms or a local yes.
A box that shares only by default never posts straight to FTW Cloud, which
would see its address; it waits for the relay (Fredrik, 24 October 2026). A
build with the relay sends the fleet report to FTW Cloud only through it.
`POST /v1/fleet` stays for boxes on older releases; it can go one stable
release after the first that pins the relay. A collector the household set
in `fleet_ping.endpoint` is still posted to directly.

**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
relay's address and the time, never the box's address. FTW Cloud keeps per
date only the counts and sums; a public energy date appears once no report
may still name it, four days after it ends, and needs at least 5 reports. A
sum that one to four of them had any of shows null: five reports with one car
would otherwise publish that car's charge.

Limits, plainly:

- The relay and Sourceful together could join reports to addresses by time.
The relay's independence is the control.
- FTW Cloud holds readings from homes whose owners turned on the readable
copy, including 48 hours of history tiles. The same daily sums could be
computed from them and matched with a report. The gateway does not do this
and keeps nothing to match with, but that is a rule in code, not one the
cryptography enforces.
- The floor of five counts reports, not boxes; Fredrik accepted this on
24 October 2026. An anonymous report cannot prove that a real box sent
it. The relay alone can drop the other reports and add fakes to isolate
one home's day, and that shows on our side as missing reports. Anyone
else can do it only on a day when one real home reported. Rejecting
replays and listing closed days only remove the cheap attacks. Anonymous
tokens (Privacy Pass) are the later fix, once the fleet grows or someone
shows a need.

**Open for Fredrik's lawyer, not decided here.** Reports that are anonymous
to Sourceful through OHTTP should be outside GDPR. Reading data from the
owner's equipment to make them may still need consent under the ePrivacy
rule (Swedish LEK, chapter 9, section 28). If it does, neither the opt-out for
boxes without Cloud nor the requirement with Cloud holds, and both need an
active yes. Fredrik chose this design; it is built as he asked. C8 stays off.
36 changes: 23 additions & 13 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,10 +614,14 @@ The box's anonymous daily report is separate from the Cloud readable-copy
grant. Once a day it posts its FTW version and channel, the driver types in use, a
battery-capacity bucket, its price zone and an install-age bucket. It answers
how many reports arrive and what they describe, which helps size engineering
bets.

It goes over HTTPS to `https://cloud.ftw.energy/v1/fleet`. It never enters the
Noise session. The HTTP handler validates the fixed payload, adds it to the UTC
bets. It is one of two anonymous daily reports under one statistics answer, with
the energy report beside it (ADR 0008).

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
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
Expand All @@ -627,7 +631,11 @@ Fleet totals must not form a household record. The message carries no gateway ID
name, no counter and no timestamp — nothing in it says which box sent it —
values are bucketed rather than reported, the version travels only when it is a
release tag so a developer's build reports as unknown, and the send time is
drawn fresh each day rather than sitting in one slot.
drawn fresh each day rather than sitting in one slot. Through the relay the
sealed request also carries a random number, drawn new for each report so FTW
Cloud counts a report once if it arrives twice, and the UTC day it was sent, so
an old request is refused. Neither names the box, and FTW Cloud keeps a hash of
the number for three days only.

Driver names get their own rule, because a driver file's name is whatever the
thing that installed it called it, and a household can install its own. A name
Expand Down Expand Up @@ -674,14 +682,16 @@ Cloud service's loopback stats response. The fields still describe a household,
payload remains a quasi-identifier: a beta box in a small price zone with a big
battery may be the only one like it, and coarse buckets with a small field set
are what keep that population large rather than a proof that it is. The endpoint
also sees the source IP while the request is open, though the Cloud service writes no
request log or source address. And with no id there is no way to dedupe, so the totals count
reports, not unique boxes.

A failed send is forgotten, never retried. Settings → FTW app → Fleet statistics renders the
exact payload from the same call the sender uses, so the claim is checkable
rather than promised. It is on by default; saving `enabled: false` opts out
without a restart. See [`go/internal/fleetping`](../go/internal/fleetping).
also sees the source IP while the request is open when the report goes straight
to it, though the Cloud service writes no request log or source address; through
the relay it does not. And nothing ties two reports to one box, so the totals
count reports, not unique boxes.

A failed send is forgotten, never retried. Settings → FTW app → Anonymous
statistics renders the exact payload from the same call the sender uses, and
what goes beside it, so the claim is checkable rather than promised. It follows
the one statistics answer, whose switch is on that screen. See
[`go/internal/fleetping`](../go/internal/fleetping).

## Releases

Expand Down
6 changes: 6 additions & 0 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -1858,3 +1858,9 @@ codes, Noise key or browser subscriptions. It grants no remote access. Core
also rejects tunnels locally in that mode and emits no live or event data.
Collection stays off until the signed C8 review; the default operator setting
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
owner turns statistics off.
49 changes: 49 additions & 0 deletions go/cmd/ftw/energy_report.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package main

import (
"context"
"log/slog"
"sync"

"github.com/srcfl/ftw/go/internal/cloudlink"
"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/energyreport"
"github.com/srcfl/ftw/go/internal/state"
)

// The anonymous daily energy report, wired to the box. What it sends and how
// is decided in go/internal/energyreport; this file only gives it the ledger
// and the owner's answer.

// 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.
func startEnergyReport(ctx context.Context, cfg *config.Config, cfgMu *sync.RWMutex, st *state.Store, answer statsAnswer) *energyreport.Sender {
if st == nil {
return nil
}
sender, err := energyreport.New(energyreport.Options{
Enabled: energyReportAllowed(cfg, cfgMu, answer),
Ledger: st,
KV: st,
ClockSynced: cloudlink.ClockSynced,
})
if err != nil {
slog.Warn("energy report unavailable", "err", err)
return nil
}
go sender.Run(ctx)
return sender
}

// energyReportAllowed is the fleet report's answer, and no site a partner
// controls: as for the hourly project statistics, a partner's contract has to
// let such a home in first.
func energyReportAllowed(cfg *config.Config, cfgMu *sync.RWMutex, answer statsAnswer) func() bool {
return func() bool {
cfgMu.RLock()
partner := cfg.Nova != nil && cfg.Nova.Enabled
cfgMu.RUnlock()
return !partner && answer.shares()
}
}
Loading
Loading