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/cloud-project-statistics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": minor
---

Add a separate, versioned Cloud statistics path for reporting installations,
configured capacity and recorded hourly energy. Older consents stay inactive
for these fields; the owner must accept the new policy.
32 changes: 30 additions & 2 deletions docs/adr/0008-ftw-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,8 @@ later step stands on it and ships on its own.
| C8 Statistics | Ingest, backfill, cell rules | DPIA signed; withdrawal purges within 24 hours |

The statistics switch ships with the start flow in C4. The box records consent
and `statsId` from then on, nothing is sent until C8, and the backfill covers
the gap. The lawyer also confirms that order: if the DPIA in C8 changes the
and `statsId` from then on. The October project policy below needs new
consent and sends only later hours; it does not backfill the earlier gap. The lawyer also confirms that order: if the DPIA in C8 changes the
rules, the app asks again. A public launch needs C2–C4 and the legal work.

## Alternatives considered
Expand Down Expand Up @@ -293,3 +293,31 @@ rules, the app asks again. A public launch needs C2–C4 and the legal work.
| Claims or defaults drift back | Claim tests; a missing grant reads as off |
| A paid app meets App Store purchase rules | Decide the payment path before charging |
| AGPL code in the App Store | An App Store exception in `ftw-app` before outside code arrives |

### Project statistics policy, October 2026

`ftw-project-stats-2026-10-v1` adds configured battery capacity, flexible
power and solar ratings to the closed-hour energy scope. This needs fresh
consent; an earlier statistics choice sends none of these fields. The owner
can read the policy through `GET /api/cloud/stats-policy`, then accept its
exact id through the existing owner-only statistics API. Stats stay off by
default. Changing the policy rotates the random stats id and starts a new
consent window. Hours before that window never contribute.

The public view counts installations reporting in the last 24 closed hours.
It sums the recorded hours, which may be incomplete; it does not claim all
FTW users, full-day production or measured control capacity. Configured
limits remain distinct from measured command results. Unknown values stay
absent. Cloud keeps the consented hourly rows for 13 months and publishes
only the existing group thresholds. Withdrawal deletes unpublished rows
when Cloud receives the signed roster; offline withdrawal takes effect on
reconnect. Published group totals remain. Native consent UI must adopt the
new policy before it can enable this scope.

C8 stays off until its signed DPIA is recorded. The service starts with no
approved statistics policy. Only an explicit operator setting enables the
exact policy in signed `hello_ok`; Core sends no hours without that signal,
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.
59 changes: 35 additions & 24 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ with two different values, so neither side's check passes through it.
| `ekm` | bstr, 32 B | The service's exporter value for this TLS session |
| `have` | map text→text | Tile id to etag, for the tiles the service holds |
| `rosterSeq` | uint | The roster number the service holds |
| `statsPolicy` | text, optional | The statistics policy this service is approved to ingest. Missing means no statistics; the box sends none |

`caps` and `grantId` describe the connection as it starts. The readable
copy can turn on or off while the link stays up, so the service takes
Expand Down Expand Up @@ -728,6 +729,9 @@ every call.
| `id` | bstr, 16 B or empty | Grant id. Empty while the readable copy is off |
| `scopes` | [text] | A subset of `cloud_scopes`. Empty while the copy is off or being withdrawn |
| `purposes` | [text] | A subset of `cloud_purposes`: `serve` while the copy is on, and `stats` while the owner contributes to statistics. Empty while the copy is off or being withdrawn |
| `statsId` | bstr, 16 B, optional | Random id for the separately accepted statistics policy; omitted when off |
| `statsPolicy` | text, optional | Exact policy the owner accepted |
| `statsSinceMs` | uint, optional | Consent start in UTC milliseconds; only later closed hours may contribute |
| `controller` | text | The data controller: `sourceful` unless a partner pairing flow sets another. No such flow exists yet |

A grant has three shapes. With an id and `serve`, the readable copy is on.
Expand All @@ -745,26 +749,32 @@ each time it receives such a roster. With no id, there is no copy.
Only a QR code is listed. A spoken code has 40 bits, and its hash would let
the service find it offline; it pairs over the relay or the home network.

**`stats`**, only while `grant.purposes` contains `stats`.
**`stats`**

Signed box to service only. Never forwarded to phones or agents. The box
sends at most one report per closed UTC hour, after fresh consent to
`ftw-project-stats-2026-10-v1`. Older policies send nothing.

| Field | Type | Meaning |
|---|---|---|
| `statsId` | bstr, 16 B | Random, drawn by the box at consent |
| `policy` | text | Version of the consent text the owner accepted |
| `zone` | text | Price zone |
| `has` | [text] | Which of `battery`, `pv` and `ev_charger` the site has |
| `hours` | [Hour] | At most 168 per message |

**Hour.** `startMs`, then Wh totals for the site, rounded to 10 Wh, under the
energy ledger's flow names: `grid_import`, `grid_export`, `pv_generation`,
`battery_charge`, `battery_discharge`, `consumer_use`, `vehicle_charge` and
`vehicle_discharge`. A flow the site lacks is absent.

The service refuses `stats` for a `statsId` it has deleted, so an old grant
record cannot turn a withdrawn consent back on.

**`stats.withdraw`** `{statsId}`. The service deletes that id's rows within
24 hours.
| `statsId` | bstr, 16 B | Must match the current signed grant |
| `policy` | text | Exact statistics policy version |
| `hourMs` | uint | Closed UTC hour start, no older than 24 hours and not before consent |
| `metrics` | map(text, uint) | Allowlisted values, rounded to 10; missing means unknown |

Keys: `battery_capacity_wh`, `flexible_power_w`, `pv_rated_w`,
`grid_import_wh`, `grid_export_wh`, `pv_generation_wh`, `battery_charge_wh`,
`battery_discharge_wh`, `vehicle_charge_wh`, `vehicle_discharge_wh`. Each
value is at most 1,000,000. Flexible power sums configured battery and charger
limits, with shared inverter groups counted once; it does not prove physical
control. Energy is recorded ledger energy and may cover only part of an hour.
No names, hardware ids, location, prices or live readings leave this path.

The service upserts by home and hour, keeps these consented rows for 13 months,
and deletes them when it receives statistics withdrawal or a home purge.
Only cells with at least 20 homes and no home above 30 percent of the sum
are public. The public view counts reporting installations, never people.
Partner-controlled sites do not send.

**`notice`**, whenever the box is linked, with the readable copy on or off.

Expand Down Expand Up @@ -1394,6 +1404,7 @@ consent text.
| Route | Who may call it |
|---|---|
| `GET /api/cloud` | An owner's session, or a client on the LAN. State, scopes, purposes, bytes sent in the last 24 hours of uptime, whether the byte budget is reached |
| `GET /api/cloud/stats-policy` | The same. Exact fields, purpose and group rules for a fresh consent |
| `GET /api/cloud/preview` | The same. The exact bytes that leave the house |
| `POST /api/cloud/grant {scopes, policy}` | An owner's session. On the LAN, only from loopback or with the house password. On a grant already on, it changes the scopes and keeps the key, home id and `grantKey` |
| `POST /api/cloud/stats {policy}` | The same gate, as a separate consent. 409 while the grant is off or a revoke waits for its receipt |
Expand All @@ -1408,11 +1419,11 @@ app's, so the grant screen claims no more than an owner's session.
or off, push nothing. Every change writes an audit line on the box. A grant,
a change of scopes and a statistics change each send a new roster.
- `POST /api/cloud/stats` draws a `statsId` the first time and keeps it while
statistics stay on. Turning statistics on again after a withdrawal draws a
new one.
statistics stay on under the same policy. A new policy draws a new id.
Turning statistics on again after a withdrawal draws a new one.
- `DELETE /api/cloud/stats` removes `stats` from the purposes at once. The
box keeps the `statsId` until `purged {what: "stats"}` arrives, so that
`stats.withdraw` can name it.
box forgets its stats id and sends a roster without statistics metadata.
Cloud deletes the rows when it receives that signed roster.

## Revocation and deletion

Expand All @@ -1432,9 +1443,9 @@ app's, so the grant screen claims no more than an owner's session.
`cloud.key` and sends only `hello` and `bye` on each connect. With a valid
`purged` it deletes the key. A box of an earlier release still ends its
grant this way.
- **Statistics withdrawal:** `stats.withdraw`. The service deletes the rows
within 24 hours and sends `purged {what: "stats"}` when done, on the same or
a later connection.
- **Statistics withdrawal:** a signed roster without the statistics consent
deletes the home's unpublished rows before accepting another report. If
the box is offline, this happens on reconnect.
- **Silence:** after an hour without the box's socket, the service serves only
`lastSeenMs` and no readings. When the roster runs out, it closes sessions
and refuses reads. After 30 days it drops tokens and APNs registrations.
Expand Down
7 changes: 7 additions & 0 deletions go/cmd/ftw/cloud_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ type plugSession struct {
n int
}

func (c *cloudSite) Statistics(ctx context.Context, now time.Time) (cloudwire.Stats, bool) {
if srv := c.api.srv.Load(); srv != nil {
return srv.CloudStatistics(ctx, now)
}
return cloudwire.Stats{}, false
}

func (c *cloudSite) Live() cloudlink.Live {
snap := c.site.Snapshot()
uptime := time.Since(c.site.started).Milliseconds()
Expand Down
1 change: 1 addition & 0 deletions go/internal/api/api_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type CloudGrant interface {
func (s *Server) cloudRoutes() {
s.handle("GET /api/cloud", Read, s.handleCloudStatus)
s.handle("GET /api/cloud/preview", Read, s.handleCloudPreview)
s.handle("GET /api/cloud/stats-policy", Read, s.handleCloudStatsPolicy)
s.handle("POST /api/cloud/grant", Configure, s.handleCloudGrant, NoStepUp)
s.handle("DELETE /api/cloud/grant", Configure, s.handleCloudRevoke, NoStepUp)
s.handle("POST /api/cloud/stats", Configure, s.handleCloudStats, NoStepUp)
Expand Down
140 changes: 140 additions & 0 deletions go/internal/api/cloud_statistics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package api

import (
"context"
"math"
"net/http"
"time"

"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/protocol/cloudwire"
)

// CloudStatistics contains only the fields named by the separate statistics
// consent. Configuration and ledger ids never cross this boundary.
func (s *Server) CloudStatistics(ctx context.Context, now time.Time) (cloudwire.Stats, bool) {
hour := now.UTC().Truncate(time.Hour).Add(-time.Hour)
out := cloudwire.Stats{HourMS: hour.UnixMilli(), Metrics: map[string]int64{}}
if s.deps == nil || s.deps.Cfg == nil || s.deps.CfgMu == nil {
return out, false
}
s.deps.CfgMu.RLock()
cfg := s.deps.Cfg
// A partner-controlled site's contract must allow statistics first.
if cfg.Nova != nil && cfg.Nova.Enabled {
s.deps.CfgMu.RUnlock()
return out, false
}
put := func(key string, value float64) {
if !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0 && value <= 1_000_000 {
out.Metrics[key] = int64(math.Round(value/10)) * 10
}
}
batteryWh, flexibleW, powerKnown := 0.0, 0.0, true
groups := map[string]float64{}
drivers := map[string]bool{}
for _, d := range cfg.Drivers {
if d.Disabled {
continue
}
drivers[d.Name] = !d.ObserveOnly && !d.BatteryTelemetryOnly
if d.BatteryCapacityWh > 0 {
batteryWh += d.BatteryCapacityWh
Comment on lines +41 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exclude vehicle capacities from the battery total

When an EV or vehicle driver carries the legacy battery_capacity_wh setting, this loop adds the vehicle's capacity to battery_capacity_wh statistics. The existing driverCapacitiesFrom logic in go/cmd/ftw/main.go:3482-3494 explicitly excludes drivers referenced by loadpoints and drivers whose catalog capability is ev or vehicle, because that value is vehicle capacity rather than installed site-battery capacity. Without equivalent filtering here, affected installations permanently publish inflated battery-capacity results under their consented statistics ID.

Useful? React with 👍 / 👎.

if !drivers[d.Name] {
continue
}
cap := math.Max(d.MaxChargeW, d.MaxDischargeW)
if cap <= 0 || math.IsNaN(cap) || math.IsInf(cap, 0) {
powerKnown = false
continue
}
group := d.InverterGroup
if group == "" {
group = d.Name
}
groups[group] = math.Max(groups[group], cap)
}
}
for _, cap := range groups {
flexibleW += cap
}
chargers := map[string]float64{}
for _, lp := range cfg.Loadpoints {
if !drivers[lp.DriverName] {
continue
}
if lp.MaxChargeW <= 0 || math.IsNaN(lp.MaxChargeW) || math.IsInf(lp.MaxChargeW, 0) {
powerKnown = false
continue
}
chargers[lp.DriverName] = math.Max(chargers[lp.DriverName], lp.MaxChargeW)
}
for _, cap := range chargers {
flexibleW += cap
}
put("battery_capacity_wh", batteryWh)
if powerKnown {
put("flexible_power_w", flexibleW)
}
if cfg.Weather != nil {
rated := cfg.Weather.PVRatedW
if len(cfg.Weather.PVArrays) > 0 {
rated = 0
for _, array := range cfg.Weather.PVArrays {
rated += array.RatedWatts()
}
}
if rated > 0 {
put("pv_rated_w", rated)
}
}
s.deps.CfgMu.RUnlock()
if s.deps.State == nil {
return out, true
}
points, truncated, err := s.deps.State.LoadEnergyHistoryContext(ctx, state.EnergyHistoryQuery{
SinceMS: hour.UnixMilli(), UntilMS: hour.Add(time.Hour).UnixMilli(), BucketMS: cloudwire.StatsHourMS, Limit: 1,
})
if err != nil || truncated {
return out, true
}
totals := map[string]float64{}
for _, point := range points {
if point.BucketStartMS != hour.UnixMilli() || point.BucketLenMS != cloudwire.StatsHourMS ||
(point.Quality != "measured" && point.Quality != "estimated" && point.Quality != "recovered") {
continue
}
key := string(point.Flow) + "_wh"
for _, allowed := range cloudwire.StatsMetricKeys {
if key == allowed {
if point.EnergyWh >= 0 && !math.IsNaN(point.EnergyWh) && !math.IsInf(point.EnergyWh, 0) {
totals[key] += point.EnergyWh
}
}
}
}
for key, value := range totals {
put(key, value)
}
return out, true
}

// The owner reads this before POST /api/cloud/stats with this policy id.
func (s *Server) handleCloudStatsPolicy(w http.ResponseWriter, r *http.Request) {
if !s.cloudGate(w, r, false) {
return
}
available := s.deps != nil && s.deps.Cloud != nil && s.deps.Cloud.Status().StatisticsReady
writeJSON(w, http.StatusOK, map[string]any{
"available": available,
"policy": cloudwire.StatsPolicy,
"purpose": "Publish group totals for FTW use and energy; improve planning. No ads or household decisions.",
"fields": cloudwire.StatsMetricKeys,
"meaning": "One opted-in installation, configured flexible power and solar ratings, battery capacity, and recorded closed-hour energy. Configured limits do not prove physical control. Missing readings are unknown.",
"resolution": "Closed UTC hours, energy rounded to 10 Wh, capacities and limits rounded to 10 Wh or W. No names, addresses, device ids or location.",
"retention": "13 months. Switching statistics off deletes unpublished household rows; published group totals remain.",
"publication_minimum_homes": cloudwire.StatsMinimumHomes,
"publication_max_home_share": 0.3,
"controller": "sourceful", "default": "off", "optional": true,
})
}
66 changes: 66 additions & 0 deletions go/internal/api/cloud_statistics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package api

import (
"context"
"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/state"
"path/filepath"
"sync"
"testing"
"time"
)

func TestCloudStatisticsConfiguredLimitsReadOnlyUnknownAndPartner(t *testing.T) {
cfg := &config.Config{Drivers: []config.Driver{
{Name: "bat1", BatteryCapacityWh: 10000, MaxChargeW: 3000, MaxDischargeW: 5000, InverterGroup: "shared"},
{Name: "bat2", BatteryCapacityWh: 5000, MaxChargeW: 4000, InverterGroup: "shared"},
{Name: "observed", BatteryCapacityWh: 20000, MaxChargeW: 10000, ObserveOnly: true},
{Name: "disabled", BatteryCapacityWh: 99999, Disabled: true}, {Name: "ev"},
}, Loadpoints: []config.Loadpoint{{DriverName: "ev", MaxChargeW: 11000}, {DriverName: "ev", MaxChargeW: 7000}}, Weather: &config.Weather{PVRatedW: 12345}}
s := &Server{deps: &Deps{Cfg: cfg, CfgMu: &sync.RWMutex{}}}
report, ok := s.CloudStatistics(context.Background(), time.Now())
if !ok {
t.Fatal("no report")
}
if report.Metrics["battery_capacity_wh"] != 35000 || report.Metrics["flexible_power_w"] != 16000 || report.Metrics["pv_rated_w"] != 12350 {
t.Fatalf("wrong totals %+v", report.Metrics)
}
if _, ok := report.Metrics["pv_generation_wh"]; ok {
t.Fatal("missing energy became zero")
}
cfg.Drivers[0].MaxChargeW = 0
cfg.Drivers[0].MaxDischargeW = 0
report, _ = s.CloudStatistics(context.Background(), time.Now())
if _, ok := report.Metrics["flexible_power_w"]; ok {
t.Fatal("unknown limit invented")
}
cfg.Nova = &config.Nova{Enabled: true}
if _, ok := s.CloudStatistics(context.Background(), time.Now()); ok {
t.Fatal("partner site sent statistics")
}
}

func TestCloudStatisticsReadsClosedLedgerHourAndRoundsEnergy(t *testing.T) {
st, err := state.Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
now := time.Date(2026, 10, 17, 12, 30, 0, 0, time.UTC)
base := now.Truncate(time.Hour).Add(-time.Hour)
for i, value := range []float64{1000, 1123} {
at := base.Add(time.Duration(i+1) * time.Minute).UnixMilli()
err := st.RecordTickWithEnergy(state.HistoryPoint{TsMs: at, JSON: "{}"}, nil, []state.EnergyObservation{{AssetID: "private-hardware", DeviceID: "private-serial", AssetKind: state.AssetGridMeter, Flow: state.FlowGridImport, AtMs: at, CounterWh: &value}})
if err != nil {
t.Fatal(err)
}
}
s := &Server{deps: &Deps{Cfg: &config.Config{}, CfgMu: &sync.RWMutex{}, State: st}}
report, ok := s.CloudStatistics(context.Background(), now)
if !ok || report.Metrics["grid_import_wh"] != 120 {
t.Fatalf("ledger energy %+v", report)
}
if _, ok := report.Metrics["pv_generation_wh"]; ok {
t.Fatal("unknown solar became zero")
}
}
Loading
Loading