From 4ce03714e77fe1b27084403607ae47fbb455bee3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Mon, 12 Oct 2026 18:22:13 +0200 Subject: [PATCH] refactor(protocol): move the wire format into its own Apache-2.0 module FTW Cloud must speak the box protocol without linking the box. Today the wire types share package appproto with the handler, which imports control, loadpoint, mpc and config, so a service that imports them links 340 packages and sqlite. go/protocol is a nested module, github.com/srcfl/ftw/go/protocol, under Apache-2.0. It holds appwire whole, the envelope, the message shapes (without wireModes), the generated registry names, the history tile geometry with the packing from buildTile, and the push catalogue. It requires only cbor and x/crypto. The box requires it through a replace directive; there are no tags yet. Aliases and one-line wrappers keep every old name in appproto and notifications, so no call site changes. Five files import appwire from its new path. The generator stays in the box and writes into go/protocol. CI and make verify run the module's own vet and tests, and a go list -deps check fails if it reaches any box package. Go's internal rule does not stop that import, because the module's path sits under the box's. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 11 +- .github/scripts/check-protocol-boundary.sh | 30 ++ .github/workflows/test.yml | 10 + Dockerfile | 3 + LICENSING.md | 5 + Makefile | 14 +- NOTICE | 7 + go/go.mod | 6 + go/internal/appenroll/enroll.go | 2 +- go/internal/appenroll/enroll_test.go | 2 +- go/internal/appproto/contract_test.go | 40 +-- .../appproto/gencontract/gencontract.go | 12 +- go/internal/appproto/generate.go | 5 +- go/internal/appproto/harness_test.go | 2 +- go/internal/appproto/history.go | 132 ++------- go/internal/appproto/history_test.go | 38 --- go/internal/appproto/modes.go | 21 ++ go/internal/appproto/protocol.go | 262 ++++++++++++++++++ go/internal/appuplink/appclient_test.go | 2 +- go/internal/appuplink/client.go | 4 +- go/internal/appuplink/codec.go | 2 +- go/internal/appuplink/session.go | 2 +- go/internal/appuplink/uplink_test.go | 2 +- go/internal/notifications/catalogue.go | 52 ++-- go/internal/notifications/catalogue_test.go | 68 +---- go/internal/notifications/generate.go | 5 +- go/protocol/LICENSE | 202 ++++++++++++++ go/{internal => protocol}/appwire/frame.go | 4 +- .../appwire/frame_test.go | 0 .../appwire/interop_test.go | 0 go/{internal => protocol}/appwire/noise.go | 0 .../appwire/noise_test.go | 0 .../appwire/testdata/generate-vectors.ts | 2 +- .../appwire/testdata/run-generator.mjs | 2 +- .../appwire/testdata/webapp-vectors.json | 0 .../appwire/transport.go | 0 .../appwire/transport_test.go | 0 .../appproto => protocol}/contract_gen.go | 2 +- go/protocol/contract_test.go | 32 +++ .../appproto => protocol}/envelope.go | 48 ++-- .../appproto => protocol}/envelope_test.go | 16 +- go/protocol/go.mod | 13 + go/protocol/go.sum | 8 + .../appproto => protocol}/messages.go | 26 +- go/protocol/push.go | 33 +++ .../catalogue_gen.go => protocol/push_gen.go} | 2 +- go/protocol/push_test.go | 67 +++++ go/protocol/tiles.go | 140 ++++++++++ go/protocol/tiles_test.go | 91 ++++++ 49 files changed, 1069 insertions(+), 358 deletions(-) create mode 100755 .github/scripts/check-protocol-boundary.sh create mode 100644 go/internal/appproto/modes.go create mode 100644 go/internal/appproto/protocol.go create mode 100644 go/protocol/LICENSE rename go/{internal => protocol}/appwire/frame.go (98%) rename go/{internal => protocol}/appwire/frame_test.go (100%) rename go/{internal => protocol}/appwire/interop_test.go (100%) rename go/{internal => protocol}/appwire/noise.go (100%) rename go/{internal => protocol}/appwire/noise_test.go (100%) rename go/{internal => protocol}/appwire/testdata/generate-vectors.ts (99%) rename go/{internal => protocol}/appwire/testdata/run-generator.mjs (94%) rename go/{internal => protocol}/appwire/testdata/webapp-vectors.json (100%) rename go/{internal => protocol}/appwire/transport.go (100%) rename go/{internal => protocol}/appwire/transport_test.go (100%) rename go/{internal/appproto => protocol}/contract_gen.go (99%) create mode 100644 go/protocol/contract_test.go rename go/{internal/appproto => protocol}/envelope.go (73%) rename go/{internal/appproto => protocol}/envelope_test.go (86%) create mode 100644 go/protocol/go.mod create mode 100644 go/protocol/go.sum rename go/{internal/appproto => protocol}/messages.go (97%) create mode 100644 go/protocol/push.go rename go/{internal/notifications/catalogue_gen.go => protocol/push_gen.go} (98%) create mode 100644 go/protocol/push_test.go create mode 100644 go/protocol/tiles.go create mode 100644 go/protocol/tiles_test.go diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 792e92524..3efec3be7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,14 +13,17 @@ version: 2 updates: # ---- Go modules ------------------------------------------------------- - # The Go module lives in the go/ subdirectory, so `directory` must point - # there (not the repo root) or Dependabot finds no go.mod. Indirect - # requires move alongside the direct ones that pull them in. + # The Go modules live in go/ and go/protocol, so `directories` must point + # there (not the repo root) or Dependabot finds no go.mod. The wire-format + # module in go/protocol is what FTW Cloud imports. Indirect requires move + # alongside the direct ones that pull them in. # # Go major bumps are import-path changes (/v2), which Dependabot cannot # rewrite — expect majors here to surface rarely or not at all. - package-ecosystem: "gomod" - directory: "/go" + directories: + - "/go" + - "/go/protocol" schedule: interval: "weekly" day: "monday" diff --git a/.github/scripts/check-protocol-boundary.sh b/.github/scripts/check-protocol-boundary.sh new file mode 100755 index 000000000..acb712ad8 --- /dev/null +++ b/.github/scripts/check-protocol-boundary.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# go/protocol is the wire format that FTW Cloud imports. It must build without +# the box, so that a service holding the protocol links no control, planner or +# storage code. Go's internal rule does not stop it: github.com/srcfl/ftw/go/ +# protocol sits under the box's path and may import the box's internal +# packages. This check does. +# +# It fails when the module, its tests included, reaches any package of +# github.com/srcfl/ftw/go outside go/protocol, or requires the box module. + +set -euo pipefail + +dir=${1:-go/protocol} +cd "${dir}" + +box_packages=$(go list -deps -test -f '{{.ImportPath}}' ./... | awk ' + $1 ~ /^github\.com\/srcfl\/ftw\/go(\/|$)/ && + $1 !~ /^github\.com\/srcfl\/ftw\/go\/protocol(\/|\.test|$)/ { print $1 } +' | sort -u) + +box_module=$(go list -m all | awk '$1 == "github.com/srcfl/ftw/go"') + +if [ -n "${box_packages}" ] || [ -n "${box_module}" ]; then + echo "go/protocol must not depend on the box module." >&2 + [ -z "${box_packages}" ] || printf 'It reaches:\n%s\n' "${box_packages}" >&2 + [ -z "${box_module}" ] || printf 'It requires:\n%s\n' "${box_module}" >&2 + exit 1 +fi + +echo "go/protocol: $(go list -deps ./... | wc -l | tr -d ' ') packages, none from the box" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ccbff8733..5f4d50e33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,6 +99,16 @@ jobs: # tests still check queue latency, cancellation and operation limits. run: go test -count=1 -timeout 300s ./... + # go/protocol is its own module, so ./... above skips it. FTW Cloud + # imports it, and it must build without the box. + - name: Protocol module + working-directory: go/protocol + run: | + go vet ./... + go test -count=1 ./... + - name: Protocol module imports nothing from the box + run: bash .github/scripts/check-protocol-boundary.sh go/protocol + web: name: web needs: changes diff --git a/Dockerfile b/Dockerfile index 3b9bcf8f1..5fe7dfbb5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,9 @@ WORKDIR /src # Cache the module download as its own layer so source edits don't # bust the dep cache. COPY go/go.mod go/go.sum ./go/ +# The wire-format module is replaced from ./protocol, so its go.mod must be +# here before any module command runs. +COPY go/protocol/go.mod go/protocol/go.sum ./go/protocol/ RUN cd go && go mod download COPY go/ ./go/ diff --git a/LICENSING.md b/LICENSING.md index 119b9e23c..7c4b7139a 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -4,6 +4,11 @@ This version uses GNU AGPL v3 only, with the narrow Energyplan combination permission in [LICENSE](LICENSE). [NOTICE](NOTICE) preserves prior Apache rights and attribution. Third-party files retain their own licenses. +The wire-format module in [`go/protocol`](go/protocol) is the exception: it +uses Apache-2.0 only, as its own [LICENSE](go/protocol/LICENSE) states. +Clients and services that speak the protocol, such as FTW Cloud, may import it +on those terms. Core links it; that does not change Core's license. + ## Use and redistribution You may inspect, run, modify and sell the AGPL-covered software. When you diff --git a/Makefile b/Makefile index 22f798539..8865765ce 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ .PHONY: help test compose-migration-test container-boundary-test release-workflow-test build build-arm64 build-amd64 release release-linux release-linux-arm64 release-linux-amd64 \ run-sim sim-ocpp dev fmt vet clean e2e ci ci-ui ci-hw-pi docs \ - verify verify-all install-hooks driver-repository-validate driver-versions \ + verify verify-all install-hooks driver-repository-validate driver-versions protocol-boundary \ drivers drivers-present driver-versions-across-pin VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) @@ -47,6 +47,7 @@ help: @echo " ci-ui browser smoke against FTW_BASE_URL" @echo " ci-hw-pi deploy candidate to Pi CI slot + browser smoke" @echo " fmt vet Go format + static checks" + @echo " protocol-boundary prove go/protocol imports nothing from the box" @echo " drivers fetch drivers/ from the pinned device-drivers commit" @echo " clean nuke build artifacts" @@ -84,6 +85,7 @@ drivers-present: test: drivers-present cd go && go test -tags=$(GO_TAGS) ./... + cd go/protocol && go test ./... compose-migration-test: bash -n scripts/enable-modular-stack.sh scripts/migrate-legacy-compose.sh scripts/upgrade-paired-release.sh scripts/install-macos.sh scripts/sync-bundled-drivers.sh scripts/check-driver-versions.sh scripts/check-debian-base.sh @@ -145,8 +147,9 @@ ci-hw-pi: # # verify-all checks both supported Linux targets on the developer host. -verify: test compose-migration-test container-boundary-test release-workflow-test native-solver-test +verify: test compose-migration-test container-boundary-test release-workflow-test native-solver-test protocol-boundary cd go && go vet -tags=$(GO_TAGS) ./... + cd go/protocol && go vet ./... cd go && go build -tags=$(GO_TAGS) ./... @echo "verify: vet + test + build clean" @@ -233,9 +236,16 @@ dev: config.local.yaml fmt: cd go && go fmt ./... + cd go/protocol && go fmt ./... vet: cd go && go vet -tags=$(GO_TAGS) ./... + cd go/protocol && go vet ./... + +# go/protocol is the wire format FTW Cloud imports. It is its own module, so +# ./... in go/ skips it, and it must build without the box. +protocol-boundary: + bash .github/scripts/check-protocol-boundary.sh go/protocol clean: rm -rf bin release diff --git a/NOTICE b/NOTICE index 6ec3fc2a1..2cfcb265b 100644 --- a/NOTICE +++ b/NOTICE @@ -15,6 +15,13 @@ The following prior notices and Apache license apply to that material. They do not offer later changes under Apache-2.0. Git history records authors, changes and dates. Separately licensed third-party files keep their notices. +Wire-format module: 2026-10-12 +------------------------------ +The Go module in go/protocol, github.com/srcfl/ftw/go/protocol, is licensed +under Apache-2.0 only, as go/protocol/LICENSE states. This includes its +changes made after the snapshot above. The rest of the project keeps the +license stated above. + Prior notice, retained verbatim ------------------------------- FTW diff --git a/go/go.mod b/go/go.mod index ca16a8ccf..09f766ee1 100644 --- a/go/go.mod +++ b/go/go.mod @@ -59,3 +59,9 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.12.1 // indirect ) + +// The wire format shared with FTW Cloud is its own module, Apache-2.0, in +// ./protocol. It has no tags yet, so the box builds it from this checkout. +require github.com/srcfl/ftw/go/protocol v0.0.0-00010101000000-000000000000 + +replace github.com/srcfl/ftw/go/protocol => ./protocol diff --git a/go/internal/appenroll/enroll.go b/go/internal/appenroll/enroll.go index 80aac947a..9d470b032 100644 --- a/go/internal/appenroll/enroll.go +++ b/go/internal/appenroll/enroll.go @@ -31,7 +31,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/apiauth" - "github.com/srcfl/ftw/go/internal/appwire" + "github.com/srcfl/ftw/go/protocol/appwire" ) const ( diff --git a/go/internal/appenroll/enroll_test.go b/go/internal/appenroll/enroll_test.go index d3fe8e1d9..11576dee7 100644 --- a/go/internal/appenroll/enroll_test.go +++ b/go/internal/appenroll/enroll_test.go @@ -13,7 +13,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/apiauth" - "github.com/srcfl/ftw/go/internal/appwire" + "github.com/srcfl/ftw/go/protocol/appwire" ) func newIdentity(t *testing.T) (*Identity, string) { diff --git a/go/internal/appproto/contract_test.go b/go/internal/appproto/contract_test.go index 9cd1e85dd..74098113f 100644 --- a/go/internal/appproto/contract_test.go +++ b/go/internal/appproto/contract_test.go @@ -9,7 +9,12 @@ import ( "github.com/srcfl/ftw/go/internal/control" ) -const registryPath = "../../../contract/registry.yaml" +const ( + registryPath = "../../../contract/registry.yaml" + // The generator lives here; its output lives in package protocol, which + // FTW Cloud imports too. + generatedPath = "../../protocol/contract_gen.go" +) // The generated constants must be what the registry currently says. A snapshot // updated without rerunning the generator is exactly the drift the registry @@ -23,12 +28,12 @@ func TestContractGenIsCurrent(t *testing.T) { if err != nil { t.Fatalf("generate: %v", err) } - got, err := os.ReadFile("contract_gen.go") + got, err := os.ReadFile(generatedPath) if err != nil { t.Fatalf("read generated file: %v", err) } if string(got) != string(want) { - t.Fatalf("contract_gen.go is stale; run: go generate ./internal/...\n"+ + t.Fatalf("protocol/contract_gen.go is stale; run: go generate ./internal/...\n"+ "(registry: %s)", filepath.Clean(registryPath)) } } @@ -52,35 +57,6 @@ func TestRegistryModesMatchCatalog(t *testing.T) { } } -// Field ids 1-9 are frozen permanently as of v1. An app of any age draws the -// core view from these alone, so a change here breaks installs nobody can -// reach. -func TestFrozenFieldIdsAreFrozen(t *testing.T) { - want := map[Fid]string{ - 1: "mode", - 2: "grid_w", - 3: "pv_w", - 4: "battery_w", - 5: "battery_soc", - 6: "load_w", - 7: "src_grid", - 8: "src_pv", - 9: "src_battery", - // Extended deliberately on 2026-08-05 for EV charging. The freeze - // covers the id's meaning, not its presence: like battery_soc, - // ev_w is sent only when the hardware exists. - 10: "ev_w", - } - for fid, name := range want { - if got := FrozenFieldNames[fid]; got != name { - t.Fatalf("field %d is %q, and was frozen as %q", fid, got, name) - } - } - if len(FrozenFieldNames) != len(want) { - t.Fatalf("the frozen set has %d fields, want %d", len(FrozenFieldNames), len(want)) - } -} - // The ops in the registry are this repository's, copied there so neither side // hand-writes an operation name or the scope it demands. defaultOps() in // command.go stays the authority on what this box accepts; this catches the diff --git a/go/internal/appproto/gencontract/gencontract.go b/go/internal/appproto/gencontract/gencontract.go index de54c9446..5249ce449 100644 --- a/go/internal/appproto/gencontract/gencontract.go +++ b/go/internal/appproto/gencontract/gencontract.go @@ -4,7 +4,9 @@ // Hand-copying those names is how this codebase ended up with three separate // authorisation namespaces, so the box reads the file instead of retyping it. // -// Run it with `go generate ./internal/appproto/...`. +// Run it with `go generate ./internal/...` from go/. It stays in the box but +// writes the wire constants and the push table into go/protocol, the module +// FTW Cloud imports. package gencontract import ( @@ -58,7 +60,7 @@ type Registry struct { } `yaml:"resolutions"` } -// Generate renders the constant file. Exported and byte-deterministic so the +// Generate renders the constant file for package protocol. Exported and byte-deterministic so the // drift test can re-run it in memory and compare, rather than shelling out. func Generate(raw []byte) ([]byte, error) { var reg Registry @@ -68,7 +70,7 @@ func Generate(raw []byte) ([]byte, error) { var b bytes.Buffer b.WriteString("// Code generated by gencontract from contract/registry.yaml. DO NOT EDIT.\n\n") - b.WriteString("package appproto\n\n") + b.WriteString("package protocol\n\n") fmt.Fprintf(&b, "// ContractVersion is the registry revision these names came from.\n") fmt.Fprintf(&b, "const ContractVersion = %d\n\n", reg.Version) @@ -361,7 +363,7 @@ type PushCatalogue struct { } `yaml:"events"` } -// GeneratePush renders the push sentence table into package notifications. +// GeneratePush renders the push sentence table into package protocol. // // It lives beside the registry generator because the two files share one // pairing rule: byte-identical with the app's copy, compared by CI on both @@ -374,7 +376,7 @@ func GeneratePush(raw []byte) ([]byte, error) { var b bytes.Buffer b.WriteString("// Code generated by gencontract from contract/push-catalogue.yaml. DO NOT EDIT.\n\n") - b.WriteString("package notifications\n\n") + b.WriteString("package protocol\n\n") fmt.Fprintf(&b, "// PushCatalogueVersion is the catalogue revision these sentences came from.\n") fmt.Fprintf(&b, "const PushCatalogueVersion = %d\n\n", cat.Version) diff --git a/go/internal/appproto/generate.go b/go/internal/appproto/generate.go index 71380dfb3..3d008cec6 100644 --- a/go/internal/appproto/generate.go +++ b/go/internal/appproto/generate.go @@ -1,3 +1,6 @@ package appproto -//go:generate go run ./gencontract/cmd proto ../../../contract/registry.yaml contract_gen.go +// The wire constants live in package protocol, which FTW Cloud imports too. +// The generator stays here, in the box, and writes across to it. + +//go:generate go run ./gencontract/cmd proto ../../../contract/registry.yaml ../../protocol/contract_gen.go diff --git a/go/internal/appproto/harness_test.go b/go/internal/appproto/harness_test.go index f39a5e7a5..af6f37748 100644 --- a/go/internal/appproto/harness_test.go +++ b/go/internal/appproto/harness_test.go @@ -16,7 +16,7 @@ import ( // testCodec is the frame layout from docs/protocol.md, implemented here so the // tests drive real bytes rather than passing structs to themselves. // -// go/internal/appwire owns the production one. Keeping a copy in _test.go means +// go/protocol/appwire owns the production one. Keeping a copy in _test.go means // a test passing here says the message layer works against the documented // layout, not that a stub agreed with itself — and it cannot be mistaken for // the real codec, because it does not ship. diff --git a/go/internal/appproto/history.go b/go/internal/appproto/history.go index 048494a10..a49755fab 100644 --- a/go/internal/appproto/history.go +++ b/go/internal/appproto/history.go @@ -3,33 +3,22 @@ package appproto import ( "context" "errors" - "fmt" - "math" "time" + + "github.com/srcfl/ftw/go/protocol" ) // History: the box's side of the tile protocol. // -// The geometry here — resolutions, tile spans, strides, ids, the etag — is -// the same arithmetic as the app's src/lib/protocol/history.ts, because the -// two ends must compute identical tiles from identical inputs or the etag -// comparison silently degrades into "always refetch". Spans and retention -// come from contract/registry.yaml through contract_gen.go; nothing here is -// hand-written twice. +// The tile geometry — resolutions, tile spans, strides, ids, the etag — lives +// in package protocol, because FTW Cloud must compute the same tiles. This +// file plans which tiles answer a query and reads them from the store. // // Power series are derived from the energy ledger: a bucket's mean power is // its energy divided by its width. That is an average, and it is presented as // one — the chunk carries its step, and the app labels it. Sub-bucket wiggles // are gone, which is the correct price for history that survives a restart. -// HistorySample is one stored bucket of one series, in mean watts. -type HistorySample struct { - // StartMs is the bucket start, wall clock. - StartMs int64 - // W is the mean power over the bucket, site-signed. - W float64 -} - // HistoryProvider answers windows of stored series. // // An interface so appproto never touches SQL, and so tests can serve @@ -43,11 +32,15 @@ type HistoryProvider interface { Window(ctx context.Context, names []string, stepMs, fromMs, toMs int64) (map[string][]HistorySample, error) } -// resolutionStepMs is the stored bucket width per resolution. This is the -// meaning of the resolution's name; the registry carries spans and retention. -var resolutionStepMs = map[Resolution]int64{ - ResN5m: 300_000, - ResN1h: 3_600_000, +// The geometry the planner below shares with the app and with FTW Cloud. +var resolutionStepMs = protocol.ResolutionStepMs + +func pointsPerTile(res Resolution) int64 { return protocol.PointsPerTile(res) } + +func tileStartFor(res Resolution, atMs int64) int64 { return protocol.TileStartFor(res, atMs) } + +func tileIDFor(res Resolution, stride, startMs int64) string { + return protocol.TileIDFor(res, stride, startMs) } // resolutionOrder is fine to coarse: the order the box clamps along. @@ -63,44 +56,13 @@ const ( historyTimeout = 15 * time.Second ) -// missingSample marks a hole on the wire. Distinct from zero, which is a -// real reading. Same value as the app's MISSING_SAMPLE. -const missingSample = math.MinInt32 - func retentionMsOf(res Resolution) int64 { return int64(ResolutionRetentionDays[res]) * 86_400_000 } -func pointsPerTile(res Resolution) int64 { - return ResolutionTileSpanMs[res] / resolutionStepMs[res] -} - -func tileStartFor(res Resolution, atMs int64) int64 { - span := ResolutionTileSpanMs[res] - return (atMs / span) * span -} - -// tileIDFor matches the app's tileId(): res/stride/tileIndex. -func tileIDFor(res Resolution, stride, startMs int64) string { - return fmt.Sprintf("%s/%d/%d", res, stride, startMs/ResolutionTileSpanMs[res]) -} - -// etagOf is FNV-1a over the packed block, eight hex digits — the same hash -// the app computes, because the whole point of an etag is that both ends -// agree on it. -func etagOf(data []byte) string { - h := uint32(0x811c9dc5) - for _, b := range data { - h ^= uint32(b) - h *= 0x01000193 - } - return fmt.Sprintf("%08x", h) -} - type plannedTile struct { id string startMs int64 - points int64 } type historyPlan struct { @@ -175,7 +137,6 @@ func planHistoryQuery(res Resolution, fromMs, toMs int64, maxPoints int) history tiles = append(tiles, plannedTile{ id: tileIDFor(chosen, stride, start), startMs: start, - points: perTile / stride, }) } @@ -427,8 +388,8 @@ func (h *Handler) serveHistory(ctx context.Context, req historyRequest) error { return h.sendBulk(MsgHistEnd, &req.id, HistEnd{ResActual: req.plan.res, Gaps: req.gaps}) } -// buildTile reads the provider and packs one tile: int32 LE columns, one -// block per series, MISSING where the store has no bucket. +// buildTile reads the provider and packs one tile. The packing is +// protocol.PackTile, which FTW Cloud shares. func (h *Handler) buildTile( ctx context.Context, series []string, @@ -438,69 +399,10 @@ func (h *Handler) buildTile( ) (HistChunk, error) { stepMs := resolutionStepMs[plan.res] tileEnd := tile.startMs + ResolutionTileSpanMs[plan.res] - points := tile.points windows, err := h.cfg.History.Window(ctx, series, stepMs, tile.startMs, tileEnd) if err != nil { return HistChunk{}, err } - - data := make([]byte, int64(len(series))*points*4) - for s, name := range series { - samples := windows[name] - - // Stored buckets fold into strided points by plain mean: the ledger's - // buckets are equal width, so each carries equal weight. - sums := make([]float64, points) - counts := make([]int64, points) - for _, sample := range samples { - if sample.StartMs < tile.startMs || sample.StartMs >= tileEnd { - continue - } - p := (sample.StartMs - tile.startMs) / (stepMs * plan.stride) - if p < 0 || p >= points { - continue - } - sums[p] += sample.W - counts[p]++ - } - - base := int64(s) * points * 4 - for p := int64(0); p < points; p++ { - v := int32(missingSample) - if counts[p] > 0 { - v = clampSample(sums[p] / float64(counts[p])) - } - off := base + p*4 - u := uint32(v) - data[off] = byte(u) - data[off+1] = byte(u >> 8) - data[off+2] = byte(u >> 16) - data[off+3] = byte(u >> 24) - } - } - - return HistChunk{ - TileID: tile.id, - Etag: etagOf(data), - Res: plan.res, - StartMs: tile.startMs, - StepMs: plan.stepMs, - Series: series, - Data: data, - Partial: tileEnd > nowMs, - }, nil -} - -func clampSample(v float64) int32 { - r := math.Round(v) - // One step inside the limits: MinInt32 itself means "missing", and a - // real reading must never be mistaken for a hole. - if r >= math.MaxInt32 { - return math.MaxInt32 - } - if r <= missingSample+1 { - return missingSample + 1 - } - return int32(r) + return protocol.PackTile(plan.res, plan.stride, tile.startMs, series, windows, nowMs), nil } diff --git a/go/internal/appproto/history_test.go b/go/internal/appproto/history_test.go index e26697965..3f20daa4e 100644 --- a/go/internal/appproto/history_test.go +++ b/go/internal/appproto/history_test.go @@ -12,44 +12,6 @@ import ( "github.com/srcfl/ftw/go/internal/mpc" ) -// The geometry must match the app's src/lib/protocol/history.ts exactly — -// both ends compute tiles independently and meet on the etag. The reference -// values below were generated by running the app's own functions, so a -// mismatch here is a genuine wire break, not a test opinion. - -func TestTileGeometryMatchesTheApp(t *testing.T) { - if got := pointsPerTile(ResN5m); got != 144 { - t.Fatalf("5m tile has %d points, the app says 144", got) - } - if got := pointsPerTile(ResN1h); got != 168 { - t.Fatalf("1h tile has %d points, the app says 168", got) - } - - // tileId: res/stride/tileIndex, epoch-aligned. - at := int64(1_760_000_000_000) - start := tileStartFor(ResN5m, at) - if start%43_200_000 != 0 || start > at || at-start >= 43_200_000 { - t.Fatalf("5m tile start %d is not the aligned tile containing %d", start, at) - } - if got, want := tileIDFor(ResN5m, 3, start), "5m/3/40740"; got != want { - t.Fatalf("tileID = %q, want %q", got, want) - } -} - -func TestEtagMatchesTheAppByteForByte(t *testing.T) { - // Values from the app's etagOf(), run on the same input. - ascending := make([]byte, 16) - for i := range ascending { - ascending[i] = byte(i) - } - if got := etagOf(ascending); got != "c8fff215" { - t.Fatalf("etag([0..15]) = %s, the app computes c8fff215", got) - } - if got := etagOf(nil); got != "811c9dc5" { - t.Fatalf("etag(empty) = %s, the app computes 811c9dc5", got) - } -} - func TestPlanHistoryQueryClampsAndStrides(t *testing.T) { // A day at 5m fits under 2000 points: two tiles, stride 1. day := planHistoryQuery(ResN5m, 1_760_000_000_000, 1_760_086_400_000, 2000) diff --git a/go/internal/appproto/modes.go b/go/internal/appproto/modes.go new file mode 100644 index 000000000..fc29600c3 --- /dev/null +++ b/go/internal/appproto/modes.go @@ -0,0 +1,21 @@ +package appproto + +import "github.com/srcfl/ftw/go/internal/control" + +// wireModes projects control's catalogue onto the wire. +// +// The catalogue is the single source of truth for what modes exist and how +// they are presented; hand-writing a list here would let the app offer a mode +// the box does not have, or hide one it does. +func wireModes(cat []control.ModeInfo) []ModeInfo { + out := make([]ModeInfo, 0, len(cat)) + for _, m := range cat { + out = append(out, ModeInfo{ + Key: string(m.Key), + Label: m.Label, + Tooltip: m.Tooltip, + Tier: string(m.Tier), + }) + } + return out +} diff --git a/go/internal/appproto/protocol.go b/go/internal/appproto/protocol.go new file mode 100644 index 000000000..348a75500 --- /dev/null +++ b/go/internal/appproto/protocol.go @@ -0,0 +1,262 @@ +// Package appproto speaks the FTW web app's client protocol. +// +// It owns message semantics only: what a hello_ok says, which lane a reply +// belongs on, when a command may be dispatched, what a plan slot means. The +// names and shapes on the wire live in github.com/srcfl/ftw/go/protocol, which +// FTW Cloud imports as well; this file re-exports them so the box's call sites +// keep their names. The bytes underneath — frame header, bucket padding and +// the Noise session — belong to protocol/appwire, which this package reaches +// through the narrow FrameCodec interface. +// +// Two rules run through everything here and are worth stating once: +// +// Every age is measured against the box's uptime, never its wall clock. A Pi +// has no RTC and reads 1970 until NTP answers, so a wall-clock age would be +// wrong by decades exactly when a user is looking at it after a power cut. +// +// Nothing in this package writes hardware. It validates intent and hands it to +// control, which is where the safety invariants live. +package appproto + +import ( + "github.com/fxamacker/cbor/v2" + + "github.com/srcfl/ftw/go/protocol" +) + +// The wire format moved to package protocol so that FTW Cloud can import it +// without linking control, the planner or the store. The aliases below are the +// same types, constants and values under their old names. New code may use +// package protocol directly. + +// Envelope and framing. +type ( + Envelope = protocol.Envelope + Frame = protocol.Frame + FrameCodec = protocol.FrameCodec +) + +const ( + ProtoMin = protocol.ProtoMin + ProtoMax = protocol.ProtoMax + ProtoFloor = protocol.ProtoFloor + LaneControl = protocol.LaneControl + LaneBulk = protocol.LaneBulk + FlagTrunc = protocol.FlagTrunc +) + +var BulkBuckets = protocol.BulkBuckets + +// Marshal encodes a body deterministically. +func Marshal(v any) (cbor.RawMessage, error) { return protocol.Marshal(v) } + +// Unmarshal decodes a body. Unknown keys are dropped without complaint. +func Unmarshal(raw cbor.RawMessage, v any) error { return protocol.Unmarshal(raw, v) } + +// EncodeEnvelope renders one envelope to CBOR. +func EncodeEnvelope(e Envelope) ([]byte, error) { return protocol.EncodeEnvelope(e) } + +// DecodeEnvelope parses one envelope, leaving the body encoded. +func DecodeEnvelope(b []byte) (Envelope, error) { return protocol.DecodeEnvelope(b) } + +func newEnvelope(t string, id *uint32, body any) (Envelope, error) { + return protocol.NewEnvelope(t, id, body) +} + +func bulkBucketFor(payloadBytes int) int { return protocol.BulkBucketFor(payloadBytes) } + +// Messages. +type ( + BoxMode = protocol.BoxMode + Hello = protocol.Hello + ProtoRange = protocol.ProtoRange + AppInfo = protocol.AppInfo + HelloOK = protocol.HelloOK + BoxInfo = protocol.BoxInfo + Clock = protocol.Clock + ModeInfo = protocol.ModeInfo + BootProgress = protocol.BootProgress + Sub = protocol.Sub + SourceWire = protocol.SourceWire + FieldDef = protocol.FieldDef + Snap = protocol.Snap + Delta = protocol.Delta + Tick = protocol.Tick + Guard = protocol.Guard + Cmd = protocol.Cmd + Expect = protocol.Expect + CmdAck = protocol.CmdAck + CmdResult = protocol.CmdResult + Observed = protocol.Observed + PlanReason = protocol.PlanReason + PlanSlot = protocol.PlanSlot + Plan = protocol.Plan + PriceGet = protocol.PriceGet + PriceSlot = protocol.PriceSlot + Price = protocol.Price + HistQuery = protocol.HistQuery + HistTileRef = protocol.HistTileRef + HistChunk = protocol.HistChunk + HistEnd = protocol.HistEnd + HistGap = protocol.HistGap + APIReq = protocol.APIReq + APIHeadMsg = protocol.APIHeadMsg + APIChunk = protocol.APIChunk + APIEnd = protocol.APIEnd + ErrorBody = protocol.ErrorBody + Event = protocol.Event + SessionTerminate = protocol.SessionTerminate +) + +const ( + MsgHello = protocol.MsgHello + MsgHelloOK = protocol.MsgHelloOK + MsgSub = protocol.MsgSub + MsgSnap = protocol.MsgSnap + MsgDelta = protocol.MsgDelta + MsgTick = protocol.MsgTick + MsgPlanGet = protocol.MsgPlanGet + MsgPlan = protocol.MsgPlan + MsgCmd = protocol.MsgCmd + MsgCmdAck = protocol.MsgCmdAck + MsgCmdResult = protocol.MsgCmdResult + MsgEvent = protocol.MsgEvent + MsgError = protocol.MsgError + MsgSessionTerminate = protocol.MsgSessionTerminate + MsgPriceGet = protocol.MsgPriceGet + MsgPrice = protocol.MsgPrice + MsgHistQuery = protocol.MsgHistQuery + MsgHistChunk = protocol.MsgHistChunk + MsgHistEnd = protocol.MsgHistEnd + MsgAPIReq = protocol.MsgAPIReq + MsgAPIHead = protocol.MsgAPIHead + MsgAPIChunk = protocol.MsgAPIChunk + MsgAPIEnd = protocol.MsgAPIEnd + OpSetMode = protocol.OpSetMode + OpPlannerPrefsSet = protocol.OpPlannerPrefsSet + OpLoadpointHold = protocol.OpLoadpointHold + OpLoadpointBoost = protocol.OpLoadpointBoost + OpLoadpointSoCSet = protocol.OpLoadpointSoCSet + OpLoadpointSurplusOnlySet = protocol.OpLoadpointSurplusOnlySet + BoxModeFull = protocol.BoxModeFull + BoxModeFloor = protocol.BoxModeFloor + BoxModeBooting = protocol.BoxModeBooting + BoxModeReadonly = protocol.BoxModeReadonly + HintAppUpdate = protocol.HintAppUpdate + BootPhaseVacuum = protocol.BootPhaseVacuum + BootPhaseMigrate = protocol.BootPhaseMigrate + BootPhaseDrivers = protocol.BootPhaseDrivers + GuardLT = protocol.GuardLT + GuardLTE = protocol.GuardLTE + GuardGT = protocol.GuardGT + GuardGTE = protocol.GuardGTE + CmdApplied = protocol.CmdApplied + CmdRejected = protocol.CmdRejected + CmdExpired = protocol.CmdExpired + CmdSuperseded = protocol.CmdSuperseded + CmdUnconfirmed = protocol.CmdUnconfirmed + ObservedSrcCore = protocol.ObservedSrcCore + ReasonCheapImport = protocol.ReasonCheapImport + ReasonExpensiveImport = protocol.ReasonExpensiveImport + ReasonSolarSurplus = protocol.ReasonSolarSurplus + ReasonPeakShaving = protocol.ReasonPeakShaving + ReasonReserveHeld = protocol.ReasonReserveHeld + ReasonExportPaid = protocol.ReasonExportPaid + ReasonIdle = protocol.ReasonIdle + GapNoData = protocol.GapNoData + GapEvicted = protocol.GapEvicted + GapBoxDown = protocol.GapBoxDown + APIGet = protocol.APIGet + APIHead = protocol.APIHead + APIPost = protocol.APIPost + APIPut = protocol.APIPut + APIPatch = protocol.APIPatch + APIDelete = protocol.APIDelete + TerminateRevoked = protocol.TerminateRevoked + TerminateEpochChanged = protocol.TerminateEpochChanged + TerminateBoxShutdown = protocol.TerminateBoxShutdown + TerminateSuperseded = protocol.TerminateSuperseded +) + +// Names generated from contract/registry.yaml. +type ( + Fid = protocol.Fid + SourceState = protocol.SourceState + Resolution = protocol.Resolution +) + +const ( + ContractVersion = protocol.ContractVersion + FidMode = protocol.FidMode + FidGridW = protocol.FidGridW + FidPvW = protocol.FidPvW + FidBatteryW = protocol.FidBatteryW + FidBatterySoc = protocol.FidBatterySoc + FidLoadW = protocol.FidLoadW + FidSrcGrid = protocol.FidSrcGrid + FidSrcPv = protocol.FidSrcPv + FidSrcBattery = protocol.FidSrcBattery + FidEvW = protocol.FidEvW + CapStatusCore = protocol.CapStatusCore + CapStatusPhases = protocol.CapStatusPhases + CapStatusDrivers = protocol.CapStatusDrivers + CapHistory5m = protocol.CapHistory5m + CapHistory1h = protocol.CapHistory1h + CapHistoryEtag = protocol.CapHistoryEtag + CapCmdLease = protocol.CapCmdLease + CapCmdPrecondition = protocol.CapCmdPrecondition + CapCmdReadback = protocol.CapCmdReadback + CapDerBattery = protocol.CapDerBattery + CapDerEv = protocol.CapDerEv + CapDerV2x = protocol.CapDerV2x + CapPlanDispatch = protocol.CapPlanDispatch + CapNetWebrtc = protocol.CapNetWebrtc + CapPriceSpot = protocol.CapPriceSpot + CapApiPassthrough = protocol.CapApiPassthrough + ScopeLiveRead = protocol.ScopeLiveRead + ScopeHistoryRead = protocol.ScopeHistoryRead + ScopePlanRead = protocol.ScopePlanRead + ScopeHealthRead = protocol.ScopeHealthRead + ScopeAssetsRead = protocol.ScopeAssetsRead + ScopeDispatchWrite = protocol.ScopeDispatchWrite + ScopeModeWrite = protocol.ScopeModeWrite + ScopeMembersRead = protocol.ScopeMembersRead + ScopeMembersWrite = protocol.ScopeMembersWrite + ErrBooting = protocol.ErrBooting + ErrUnknownOp = protocol.ErrUnknownOp + ErrCmdExpired = protocol.ErrCmdExpired + ErrPrecondition = protocol.ErrPrecondition + ErrConflict = protocol.ErrConflict + ErrScopeDenied = protocol.ErrScopeDenied + ErrGrantRevoked = protocol.ErrGrantRevoked + ErrLastOwnerProtected = protocol.ErrLastOwnerProtected + ErrRangeTooLarge = protocol.ErrRangeTooLarge + ErrUnavailable = protocol.ErrUnavailable + ErrNeedsStepUp = protocol.ErrNeedsStepUp + ErrUseCmd = protocol.ErrUseCmd + ErrUnsupportedMedia = protocol.ErrUnsupportedMedia + ErrWholeDocument = protocol.ErrWholeDocument + ErrLocalOnly = protocol.ErrLocalOnly + SourceLive = protocol.SourceLive + SourceLagging = protocol.SourceLagging + SourceStale = protocol.SourceStale + SourceDown = protocol.SourceDown + SourceNever = protocol.SourceNever + ResN1h = protocol.ResN1h + ResN5m = protocol.ResN5m +) + +var ( + FrozenFieldNames = protocol.FrozenFieldNames + FrozenFieldUnits = protocol.FrozenFieldUnits + AllCapabilities = protocol.AllCapabilities + WriteScopes = protocol.WriteScopes + RegistryOps = protocol.RegistryOps + ErrorRetryable = protocol.ErrorRetryable + ResolutionTileSpanMs = protocol.ResolutionTileSpanMs + ResolutionRetentionDays = protocol.ResolutionRetentionDays + RegistryModeTiers = protocol.RegistryModeTiers +) + +// History tiles. +type HistorySample = protocol.HistorySample diff --git a/go/internal/appuplink/appclient_test.go b/go/internal/appuplink/appclient_test.go index a8c8ae794..9e213894d 100644 --- a/go/internal/appuplink/appclient_test.go +++ b/go/internal/appuplink/appclient_test.go @@ -12,7 +12,7 @@ import ( // The app's half of Noise_IK_25519_ChaChaPoly_SHA256, written out here. // -// go/internal/appwire has the responder only, because a box that could +// go/protocol/appwire has the responder only, because a box that could // initiate is a box that could present itself as an app. So the initiator // lives in this test file, where it does two jobs: it drives the box end to // end, and it is an independent second implementation of the pattern — a diff --git a/go/internal/appuplink/client.go b/go/internal/appuplink/client.go index c61fa8ee8..43f6ed93a 100644 --- a/go/internal/appuplink/client.go +++ b/go/internal/appuplink/client.go @@ -4,8 +4,8 @@ // The relay is the only remote path between a box and a phone, and it cannot // read a watt: it joins one box uplink to a handful of browser streams under a // rendezvous handle and moves opaque bytes between them. Everything that makes -// those bytes meaningful is in go/internal/appwire (the Noise session) and -// go/internal/appproto (the messages). See srcfl/ftw-webapp relay/README.md +// those bytes meaningful is in go/protocol/appwire (the Noise session) and +// go/protocol (the messages). See srcfl/ftw-webapp relay/README.md // for the server's half. // // One rule shapes the whole file: a dropped connection is this package's diff --git a/go/internal/appuplink/codec.go b/go/internal/appuplink/codec.go index 0a41af3f1..7195a17e2 100644 --- a/go/internal/appuplink/codec.go +++ b/go/internal/appuplink/codec.go @@ -2,7 +2,7 @@ package appuplink import ( "github.com/srcfl/ftw/go/internal/appproto" - "github.com/srcfl/ftw/go/internal/appwire" + "github.com/srcfl/ftw/go/protocol/appwire" ) // codec joins the message layer to the wire. diff --git a/go/internal/appuplink/session.go b/go/internal/appuplink/session.go index 3949bb329..f36ee6c66 100644 --- a/go/internal/appuplink/session.go +++ b/go/internal/appuplink/session.go @@ -10,7 +10,7 @@ import ( "github.com/srcfl/ftw/go/internal/apiauth" "github.com/srcfl/ftw/go/internal/appenroll" "github.com/srcfl/ftw/go/internal/appproto" - "github.com/srcfl/ftw/go/internal/appwire" + "github.com/srcfl/ftw/go/protocol/appwire" ) // MaxSessions is how many phones may watch one house at once. diff --git a/go/internal/appuplink/uplink_test.go b/go/internal/appuplink/uplink_test.go index c39c3d927..1f062c45c 100644 --- a/go/internal/appuplink/uplink_test.go +++ b/go/internal/appuplink/uplink_test.go @@ -19,9 +19,9 @@ import ( "github.com/srcfl/ftw/go/internal/apiauth" "github.com/srcfl/ftw/go/internal/appenroll" "github.com/srcfl/ftw/go/internal/appproto" - "github.com/srcfl/ftw/go/internal/appwire" "github.com/srcfl/ftw/go/internal/control" "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/protocol/appwire" ) // The handle derivation must agree with the app byte for byte, or the box and diff --git a/go/internal/notifications/catalogue.go b/go/internal/notifications/catalogue.go index a7d049f0a..48c5cb74a 100644 --- a/go/internal/notifications/catalogue.go +++ b/go/internal/notifications/catalogue.go @@ -1,33 +1,33 @@ package notifications -import ( - "fmt" - "regexp" - "strings" +import "github.com/srcfl/ftw/go/protocol" + +// The push catalogue moved to package protocol so that FTW Cloud can import it +// without the rest of this package. These are the same names under their old +// spelling here. + +// PushCatalogueVersion is the catalogue revision these sentences came from. +const PushCatalogueVersion = protocol.PushCatalogueVersion + +// Push event kinds. +const ( + PushChargingConnected = protocol.PushChargingConnected + PushChargingSessionComplete = protocol.PushChargingSessionComplete + PushChargingInterrupted = protocol.PushChargingInterrupted + PushUpdateInstalled = protocol.PushUpdateInstalled + PushDriverOffline = protocol.PushDriverOffline + PushFuseOverLimit = protocol.PushFuseOverLimit + PushBoxUnreachable = protocol.PushBoxUnreachable ) -// RenderPush fills a catalogue sentence's {arg} placeholders from the event's -// own facts. It refuses to render a sentence it cannot finish: a lock screen -// showing a literal "{kwh}" is a promise the catalogue made that the event -// broke, and silence is better than gibberish. -func RenderPush(kind string, args map[string]string) (title, body string, err error) { - sentence, ok := PushSentences[kind] - if !ok { - return "", "", fmt.Errorf("notifications: %q is not in the push catalogue", kind) - } - title = fillPlaceholders(sentence.Title, args) - body = fillPlaceholders(sentence.Body, args) - if missing := placeholderPattern.FindString(title + " " + body); missing != "" { - return "", "", fmt.Errorf("notifications: %s left %s unfilled", kind, missing) - } - return title, body, nil -} +// PushSentence is one lock-screen sentence, exactly as the app wrote it. +type PushSentence = protocol.PushSentence -var placeholderPattern = regexp.MustCompile(`\{[a-z_]+\}`) +// PushSentences is every sentence a push may carry, keyed by kind. +var PushSentences = protocol.PushSentences -func fillPlaceholders(s string, args map[string]string) string { - for k, v := range args { - s = strings.ReplaceAll(s, "{"+k+"}", v) - } - return s +// RenderPush fills a catalogue sentence's {arg} placeholders from the event's +// own facts. It refuses to render a sentence it cannot finish. +func RenderPush(kind string, args map[string]string) (title, body string, err error) { + return protocol.RenderPush(kind, args) } diff --git a/go/internal/notifications/catalogue_test.go b/go/internal/notifications/catalogue_test.go index b8c0ada54..a4987f6af 100644 --- a/go/internal/notifications/catalogue_test.go +++ b/go/internal/notifications/catalogue_test.go @@ -20,75 +20,11 @@ func TestPushSentencesAreCurrent(t *testing.T) { if err != nil { t.Fatalf("generate: %v", err) } - got, err := os.ReadFile("catalogue_gen.go") + got, err := os.ReadFile("../../protocol/push_gen.go") if err != nil { t.Fatalf("read generated file: %v", err) } if string(got) != string(want) { - t.Fatal("notifications/catalogue_gen.go is stale; run: go generate ./internal/...") - } -} - -func TestRenderPushFillsPlaceholders(t *testing.T) { - title, body, err := RenderPush(PushChargingSessionComplete, map[string]string{"kwh": "7.4"}) - if err != nil { - t.Fatalf("render: %v", err) - } - if title != "Car charged" { - t.Fatalf("title = %q", title) - } - if body != "7.4 kWh delivered — ready to go." { - t.Fatalf("body = %q", body) - } - - title, body, err = RenderPush(PushUpdateInstalled, map[string]string{"version": "v1.17.0"}) - if err != nil { - t.Fatalf("render: %v", err) - } - if body != "Now running v1.17.0. Everything came back on its own." { - t.Fatalf("body = %q", body) - } - if title != "Your box updated itself" { - t.Fatalf("title = %q", title) - } - - title, body, err = RenderPush(PushDriverOffline, map[string]string{"name": "Batteri"}) - if err != nil { - t.Fatalf("render driver.offline: %v", err) - } - if title != "A device went quiet" || body != "Batteri stopped answering." { - t.Fatalf("driver.offline = %q / %q", title, body) - } - - title, body, err = RenderPush(PushFuseOverLimit, map[string]string{"phase": "L1"}) - if err != nil { - t.Fatalf("render fuse.over_limit: %v", err) - } - if title != "The house is drawing too much" || body != "L1 is over the fuse rating." { - t.Fatalf("fuse.over_limit = %q / %q", title, body) - } -} - -// A sentence with a hole in it never leaves the box. The catalogue promised -// the app "{kwh}" would be a number; an event that cannot supply one is an -// error, not a push. -func TestRenderPushRefusesAnUnfilledPlaceholder(t *testing.T) { - if _, _, err := RenderPush(PushChargingSessionComplete, nil); err == nil { - t.Fatal("rendered a sentence with {kwh} unfilled") - } - if _, _, err := RenderPush(PushDriverOffline, nil); err == nil { - t.Fatal("rendered a sentence with {name} unfilled") - } - if _, _, err := RenderPush("charging.someday", nil); err == nil { - t.Fatal("rendered a kind the catalogue does not carry") - } -} - -// The deadman payload must need no arguments: it is rendered once, encrypted, -// and handed to the relay long before the outage it describes. A placeholder -// in it could never be filled with a fact from the future. -func TestBoxUnreachableNeedsNoArguments(t *testing.T) { - if _, _, err := RenderPush(PushBoxUnreachable, nil); err != nil { - t.Fatalf("box.unreachable must render with no args: %v", err) + t.Fatal("protocol/push_gen.go is stale; run: go generate ./internal/...") } } diff --git a/go/internal/notifications/generate.go b/go/internal/notifications/generate.go index 3591fe5ce..d09ff22ee 100644 --- a/go/internal/notifications/generate.go +++ b/go/internal/notifications/generate.go @@ -3,6 +3,7 @@ package notifications // The push sentence table is generated from contract/push-catalogue.yaml — // the app's file, paired byte for byte like contract/registry.yaml. The app // owns all prose; push is the stated exception where the box must render -// text, and this is the only place it may take that text from. +// text, and this is the only place it may take that text from. The table +// lives in package protocol, which FTW Cloud imports too. -//go:generate go run ../appproto/gencontract/cmd push ../../../contract/push-catalogue.yaml catalogue_gen.go +//go:generate go run ../appproto/gencontract/cmd push ../../../contract/push-catalogue.yaml ../../protocol/push_gen.go diff --git a/go/protocol/LICENSE b/go/protocol/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/go/protocol/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/go/internal/appwire/frame.go b/go/protocol/appwire/frame.go similarity index 98% rename from go/internal/appwire/frame.go rename to go/protocol/appwire/frame.go index 930299db5..f6525fd59 100644 --- a/go/internal/appwire/frame.go +++ b/go/protocol/appwire/frame.go @@ -88,8 +88,8 @@ func mustEncMode() cbor.EncMode { // Envelope is the CBOR map every frame carries: {t, id?, b?}. // -// Field names match go/internal/appproto's, which is the layer that fills -// these in. Two spellings of one wire format is how the two drift. +// Field names match package protocol's, which is the layer that fills these +// in. Two spellings of one wire format is how the two drift. type Envelope struct { // T is the message type. A type this box has never heard of is not an // error — see DecodeFrame. diff --git a/go/internal/appwire/frame_test.go b/go/protocol/appwire/frame_test.go similarity index 100% rename from go/internal/appwire/frame_test.go rename to go/protocol/appwire/frame_test.go diff --git a/go/internal/appwire/interop_test.go b/go/protocol/appwire/interop_test.go similarity index 100% rename from go/internal/appwire/interop_test.go rename to go/protocol/appwire/interop_test.go diff --git a/go/internal/appwire/noise.go b/go/protocol/appwire/noise.go similarity index 100% rename from go/internal/appwire/noise.go rename to go/protocol/appwire/noise.go diff --git a/go/internal/appwire/noise_test.go b/go/protocol/appwire/noise_test.go similarity index 100% rename from go/internal/appwire/noise_test.go rename to go/protocol/appwire/noise_test.go diff --git a/go/internal/appwire/testdata/generate-vectors.ts b/go/protocol/appwire/testdata/generate-vectors.ts similarity index 99% rename from go/internal/appwire/testdata/generate-vectors.ts rename to go/protocol/appwire/testdata/generate-vectors.ts index d74ad5209..7d7d6cfc3 100644 --- a/go/internal/appwire/testdata/generate-vectors.ts +++ b/go/protocol/appwire/testdata/generate-vectors.ts @@ -13,7 +13,7 @@ * $lib alias and the app's crypto packages resolve exactly as they do in a * browser build: * - * node go/internal/appwire/testdata/run-generator.mjs ../ftw-webapp + * node go/protocol/appwire/testdata/run-generator.mjs ../ftw-webapp */ import { writeFileSync } from 'node:fs' diff --git a/go/internal/appwire/testdata/run-generator.mjs b/go/protocol/appwire/testdata/run-generator.mjs similarity index 94% rename from go/internal/appwire/testdata/run-generator.mjs rename to go/protocol/appwire/testdata/run-generator.mjs index b15e635b1..1d78c16cc 100644 --- a/go/internal/appwire/testdata/run-generator.mjs +++ b/go/protocol/appwire/testdata/run-generator.mjs @@ -1,6 +1,6 @@ /* Runs generate-vectors.ts against a checkout of srcfl/ftw-webapp. * - * node go/internal/appwire/testdata/run-generator.mjs ../ftw-webapp + * node go/protocol/appwire/testdata/run-generator.mjs ../ftw-webapp * * It boots the app's own Vite server so the generator sees the same aliases, * the same TypeScript settings and the same crypto packages the app ships. diff --git a/go/internal/appwire/testdata/webapp-vectors.json b/go/protocol/appwire/testdata/webapp-vectors.json similarity index 100% rename from go/internal/appwire/testdata/webapp-vectors.json rename to go/protocol/appwire/testdata/webapp-vectors.json diff --git a/go/internal/appwire/transport.go b/go/protocol/appwire/transport.go similarity index 100% rename from go/internal/appwire/transport.go rename to go/protocol/appwire/transport.go diff --git a/go/internal/appwire/transport_test.go b/go/protocol/appwire/transport_test.go similarity index 100% rename from go/internal/appwire/transport_test.go rename to go/protocol/appwire/transport_test.go diff --git a/go/internal/appproto/contract_gen.go b/go/protocol/contract_gen.go similarity index 99% rename from go/internal/appproto/contract_gen.go rename to go/protocol/contract_gen.go index 1c2032867..51e01a5a9 100644 --- a/go/internal/appproto/contract_gen.go +++ b/go/protocol/contract_gen.go @@ -1,6 +1,6 @@ // Code generated by gencontract from contract/registry.yaml. DO NOT EDIT. -package appproto +package protocol // ContractVersion is the registry revision these names came from. const ContractVersion = 1 diff --git a/go/protocol/contract_test.go b/go/protocol/contract_test.go new file mode 100644 index 000000000..5eee384a3 --- /dev/null +++ b/go/protocol/contract_test.go @@ -0,0 +1,32 @@ +package protocol + +import "testing" + +// Field ids 1-9 are frozen permanently as of v1. An app of any age draws the +// core view from these alone, so a change here breaks installs nobody can +// reach. +func TestFrozenFieldIdsAreFrozen(t *testing.T) { + want := map[Fid]string{ + 1: "mode", + 2: "grid_w", + 3: "pv_w", + 4: "battery_w", + 5: "battery_soc", + 6: "load_w", + 7: "src_grid", + 8: "src_pv", + 9: "src_battery", + // Extended deliberately on 2026-08-05 for EV charging. The freeze + // covers the id's meaning, not its presence: like battery_soc, + // ev_w is sent only when the hardware exists. + 10: "ev_w", + } + for fid, name := range want { + if got := FrozenFieldNames[fid]; got != name { + t.Fatalf("field %d is %q, and was frozen as %q", fid, got, name) + } + } + if len(FrozenFieldNames) != len(want) { + t.Fatalf("the frozen set has %d fields, want %d", len(FrozenFieldNames), len(want)) + } +} diff --git a/go/internal/appproto/envelope.go b/go/protocol/envelope.go similarity index 73% rename from go/internal/appproto/envelope.go rename to go/protocol/envelope.go index 40f5bf9b5..922e9957a 100644 --- a/go/internal/appproto/envelope.go +++ b/go/protocol/envelope.go @@ -1,20 +1,20 @@ -// Package appproto speaks the FTW web app's client protocol. +// Package protocol is the wire format between an FTW box and its clients: the +// message names and shapes, the CBOR envelope, the names generated from +// contract/registry.yaml, the history tile geometry and the push catalogue. // -// It owns message semantics only: what a hello_ok says, which lane a reply -// belongs on, when a command may be dispatched, what a plan slot means. The -// bytes underneath — frame header, bucket padding and the Noise session — -// belong to go/internal/appwire, which this package reaches through the narrow -// FrameCodec interface below. -// -// Two rules run through everything here and are worth stating once: +// It holds shapes and pure functions, and it imports nothing from the box. A +// program that only speaks the protocol, such as FTW Cloud, therefore links no +// control, planner or storage code; CI checks that with go list -deps. What a +// message means to the box — what a hello_ok says, when a command may be +// dispatched — stays in the box's go/internal/appproto. The bytes underneath — +// frame header, bucket padding and the Noise session — belong to package +// appwire, which this package reaches through the narrow FrameCodec interface +// below. // // Every age is measured against the box's uptime, never its wall clock. A Pi // has no RTC and reads 1970 until NTP answers, so a wall-clock age would be // wrong by decades exactly when a user is looking at it after a power cut. -// -// Nothing in this package writes hardware. It validates intent and hands it to -// control, which is where the safety invariants live. -package appproto +package protocol import ( "fmt" @@ -67,8 +67,8 @@ type Envelope struct { B cbor.RawMessage `cbor:"b,omitempty"` } -// Frame is one envelope on one lane. go/internal/appwire owns the byte layout, -// the padding and the Noise session; this package only decides what goes in a +// Frame is one envelope on one lane. Package appwire owns the byte layout, the +// padding and the Noise session; this package only decides what goes in a // frame and which lane carries it. type Frame struct { Lane uint8 @@ -78,7 +78,7 @@ type Frame struct { Envelope Envelope } -// FrameCodec is the whole of go/internal/appwire that this package needs. +// FrameCodec is the whole of package appwire that this package needs. // It is declared here rather than imported so the message layer builds, // tests and reviews on its own. type FrameCodec interface { @@ -112,7 +112,7 @@ func init() { func Marshal(v any) (cbor.RawMessage, error) { b, err := encMode.Marshal(v) if err != nil { - return nil, fmt.Errorf("appproto: encode body: %w", err) + return nil, fmt.Errorf("protocol: encode body: %w", err) } return b, nil } @@ -120,10 +120,10 @@ func Marshal(v any) (cbor.RawMessage, error) { // Unmarshal decodes a body. Unknown keys are dropped without complaint. func Unmarshal(raw cbor.RawMessage, v any) error { if len(raw) == 0 { - return fmt.Errorf("appproto: empty body") + return fmt.Errorf("protocol: empty body") } if err := decMode.Unmarshal(raw, v); err != nil { - return fmt.Errorf("appproto: decode body: %w", err) + return fmt.Errorf("protocol: decode body: %w", err) } return nil } @@ -137,16 +137,16 @@ func EncodeEnvelope(e Envelope) ([]byte, error) { func DecodeEnvelope(b []byte) (Envelope, error) { var e Envelope if err := decMode.Unmarshal(b, &e); err != nil { - return Envelope{}, fmt.Errorf("appproto: decode envelope: %w", err) + return Envelope{}, fmt.Errorf("protocol: decode envelope: %w", err) } if e.T == "" { - return Envelope{}, fmt.Errorf("appproto: envelope has no type") + return Envelope{}, fmt.Errorf("protocol: envelope has no type") } return e, nil } -// newEnvelope builds an outgoing envelope around a body. -func newEnvelope(t string, id *uint32, body any) (Envelope, error) { +// NewEnvelope builds an outgoing envelope around a body. +func NewEnvelope(t string, id *uint32, body any) (Envelope, error) { raw, err := Marshal(body) if err != nil { return Envelope{}, err @@ -154,10 +154,10 @@ func newEnvelope(t string, id *uint32, body any) (Envelope, error) { return Envelope{T: t, ID: id, B: raw}, nil } -// bulkBucketFor is the smallest bulk bucket a payload fits in, or 0 when it +// BulkBucketFor is the smallest bulk bucket a payload fits in, or 0 when it // fits none. Sizing by the encoded body rather than by a guess keeps a large // mode catalogue from silently overrunning a bucket. -func bulkBucketFor(payloadBytes int) int { +func BulkBucketFor(payloadBytes int) int { // The frame header costs bytes the codec adds; leave room for it rather // than have appwire discover the overrun. const headerBytes = 6 diff --git a/go/internal/appproto/envelope_test.go b/go/protocol/envelope_test.go similarity index 86% rename from go/internal/appproto/envelope_test.go rename to go/protocol/envelope_test.go index e185ad09e..229576444 100644 --- a/go/internal/appproto/envelope_test.go +++ b/go/protocol/envelope_test.go @@ -1,12 +1,18 @@ -package appproto +package protocol import ( "encoding/hex" + "strconv" "testing" ) +func ptrU32(v uint32) *uint32 { return &v } + +// fidKey is how a field id keys the fields map on the wire. +func fidKey(f Fid) string { return strconv.FormatUint(uint64(f), 10) } + func TestEnvelopeRoundTrips(t *testing.T) { - in, err := newEnvelope(MsgTick, ptrU32(17), Tick{Seq: 5, UptimeMs: 60_000}) + in, err := NewEnvelope(MsgTick, ptrU32(17), Tick{Seq: 5, UptimeMs: 60_000}) if err != nil { t.Fatal(err) } @@ -34,7 +40,7 @@ func TestEnvelopeRoundTrips(t *testing.T) { // frame's length never depends on which map order a runtime happened to pick. func TestEncodingIsDeterministic(t *testing.T) { build := func() []byte { - env, err := newEnvelope(MsgDelta, nil, Delta{ + env, err := NewEnvelope(MsgDelta, nil, Delta{ Seq: 3, UptimeMs: 61_000, Fields: map[string]int64{ @@ -116,8 +122,8 @@ func TestBulkBucketStepsUpRatherThanGrowing(t *testing.T) { {16379, 0}, } for _, c := range cases { - if got := bulkBucketFor(c.payload); got != c.want { - t.Fatalf("bulkBucketFor(%d) = %d, want %d", c.payload, got, c.want) + if got := BulkBucketFor(c.payload); got != c.want { + t.Fatalf("BulkBucketFor(%d) = %d, want %d", c.payload, got, c.want) } } } diff --git a/go/protocol/go.mod b/go/protocol/go.mod new file mode 100644 index 000000000..ab8488a58 --- /dev/null +++ b/go/protocol/go.mod @@ -0,0 +1,13 @@ +module github.com/srcfl/ftw/go/protocol + +go 1.26.0 + +require ( + github.com/fxamacker/cbor/v2 v2.9.4 + golang.org/x/crypto v0.57.0 +) + +require ( + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/sys v0.48.0 // indirect +) diff --git a/go/protocol/go.sum b/go/protocol/go.sum new file mode 100644 index 000000000..4efa28645 --- /dev/null +++ b/go/protocol/go.sum @@ -0,0 +1,8 @@ +github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo= +github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/go/internal/appproto/messages.go b/go/protocol/messages.go similarity index 97% rename from go/internal/appproto/messages.go rename to go/protocol/messages.go index 80f3ce88a..f296c6e0e 100644 --- a/go/internal/appproto/messages.go +++ b/go/protocol/messages.go @@ -1,6 +1,4 @@ -package appproto - -import "github.com/srcfl/ftw/go/internal/control" +package protocol // Message type strings. Stable for the life of the protocol. const ( @@ -118,7 +116,7 @@ type HelloOK struct { Caps []string `cbor:"caps"` CapsHash string `cbor:"capsHash"` // Modes is every mode this box accepts, in its own presentation order, - // straight from control.ModeCatalog(). Field 1 in the telemetry stream + // straight from the box's mode catalogue. Field 1 in the telemetry stream // carries an index into this list, which is what keeps lane 0 numeric // and its frames a fixed size. Modes []ModeInfo `cbor:"modes"` @@ -161,7 +159,7 @@ type Clock struct { UptimeMs int64 `cbor:"uptimeMs"` } -// ModeInfo is one mode as the box presents it. The wire shape of +// ModeInfo is one mode as the box presents it. The wire shape of the box's // control.ModeInfo. type ModeInfo struct { Key string `cbor:"key"` @@ -627,21 +625,3 @@ const ( TerminateBoxShutdown = "box_shutdown" TerminateSuperseded = "superseded" ) - -// wireModes projects control's catalogue onto the wire. -// -// The catalogue is the single source of truth for what modes exist and how -// they are presented; hand-writing a list here would let the app offer a mode -// the box does not have, or hide one it does. -func wireModes(cat []control.ModeInfo) []ModeInfo { - out := make([]ModeInfo, 0, len(cat)) - for _, m := range cat { - out = append(out, ModeInfo{ - Key: string(m.Key), - Label: m.Label, - Tooltip: m.Tooltip, - Tier: string(m.Tier), - }) - } - return out -} diff --git a/go/protocol/push.go b/go/protocol/push.go new file mode 100644 index 000000000..d1877c6ac --- /dev/null +++ b/go/protocol/push.go @@ -0,0 +1,33 @@ +package protocol + +import ( + "fmt" + "regexp" + "strings" +) + +// RenderPush fills a catalogue sentence's {arg} placeholders from the event's +// own facts. It refuses to render a sentence it cannot finish: a lock screen +// showing a literal "{kwh}" is a promise the catalogue made that the event +// broke, and silence is better than gibberish. +func RenderPush(kind string, args map[string]string) (title, body string, err error) { + sentence, ok := PushSentences[kind] + if !ok { + return "", "", fmt.Errorf("protocol: %q is not in the push catalogue", kind) + } + title = fillPlaceholders(sentence.Title, args) + body = fillPlaceholders(sentence.Body, args) + if missing := placeholderPattern.FindString(title + " " + body); missing != "" { + return "", "", fmt.Errorf("protocol: %s left %s unfilled", kind, missing) + } + return title, body, nil +} + +var placeholderPattern = regexp.MustCompile(`\{[a-z_]+\}`) + +func fillPlaceholders(s string, args map[string]string) string { + for k, v := range args { + s = strings.ReplaceAll(s, "{"+k+"}", v) + } + return s +} diff --git a/go/internal/notifications/catalogue_gen.go b/go/protocol/push_gen.go similarity index 98% rename from go/internal/notifications/catalogue_gen.go rename to go/protocol/push_gen.go index 96a3415ae..95e50bbd5 100644 --- a/go/internal/notifications/catalogue_gen.go +++ b/go/protocol/push_gen.go @@ -1,6 +1,6 @@ // Code generated by gencontract from contract/push-catalogue.yaml. DO NOT EDIT. -package notifications +package protocol // PushCatalogueVersion is the catalogue revision these sentences came from. const PushCatalogueVersion = 1 diff --git a/go/protocol/push_test.go b/go/protocol/push_test.go new file mode 100644 index 000000000..1578741bc --- /dev/null +++ b/go/protocol/push_test.go @@ -0,0 +1,67 @@ +package protocol + +import "testing" + +func TestRenderPushFillsPlaceholders(t *testing.T) { + title, body, err := RenderPush(PushChargingSessionComplete, map[string]string{"kwh": "7.4"}) + if err != nil { + t.Fatalf("render: %v", err) + } + if title != "Car charged" { + t.Fatalf("title = %q", title) + } + if body != "7.4 kWh delivered — ready to go." { + t.Fatalf("body = %q", body) + } + + title, body, err = RenderPush(PushUpdateInstalled, map[string]string{"version": "v1.17.0"}) + if err != nil { + t.Fatalf("render: %v", err) + } + if body != "Now running v1.17.0. Everything came back on its own." { + t.Fatalf("body = %q", body) + } + if title != "Your box updated itself" { + t.Fatalf("title = %q", title) + } + + title, body, err = RenderPush(PushDriverOffline, map[string]string{"name": "Batteri"}) + if err != nil { + t.Fatalf("render driver.offline: %v", err) + } + if title != "A device went quiet" || body != "Batteri stopped answering." { + t.Fatalf("driver.offline = %q / %q", title, body) + } + + title, body, err = RenderPush(PushFuseOverLimit, map[string]string{"phase": "L1"}) + if err != nil { + t.Fatalf("render fuse.over_limit: %v", err) + } + if title != "The house is drawing too much" || body != "L1 is over the fuse rating." { + t.Fatalf("fuse.over_limit = %q / %q", title, body) + } +} + +// A sentence with a hole in it never leaves the box. The catalogue promised +// the app "{kwh}" would be a number; an event that cannot supply one is an +// error, not a push. +func TestRenderPushRefusesAnUnfilledPlaceholder(t *testing.T) { + if _, _, err := RenderPush(PushChargingSessionComplete, nil); err == nil { + t.Fatal("rendered a sentence with {kwh} unfilled") + } + if _, _, err := RenderPush(PushDriverOffline, nil); err == nil { + t.Fatal("rendered a sentence with {name} unfilled") + } + if _, _, err := RenderPush("charging.someday", nil); err == nil { + t.Fatal("rendered a kind the catalogue does not carry") + } +} + +// The deadman payload must need no arguments: it is rendered once, encrypted, +// and handed to the relay long before the outage it describes. A placeholder +// in it could never be filled with a fact from the future. +func TestBoxUnreachableNeedsNoArguments(t *testing.T) { + if _, _, err := RenderPush(PushBoxUnreachable, nil); err != nil { + t.Fatalf("box.unreachable must render with no args: %v", err) + } +} diff --git a/go/protocol/tiles.go b/go/protocol/tiles.go new file mode 100644 index 000000000..a6de482e6 --- /dev/null +++ b/go/protocol/tiles.go @@ -0,0 +1,140 @@ +package protocol + +import ( + "fmt" + "math" +) + +// History tiles: the geometry both ends compute. +// +// Resolutions, tile spans, strides, ids and the etag are the same arithmetic +// as the app's src/lib/protocol/history.ts, because the two ends must compute +// identical tiles from identical inputs or the etag comparison silently +// degrades into "always refetch". Spans and retention come from +// contract/registry.yaml through contract_gen.go; nothing here is hand-written +// twice. Deciding which tiles answer a query, and reading the store, belong to +// whoever serves the query. + +// HistorySample is one stored bucket of one series, in mean watts. +type HistorySample struct { + // StartMs is the bucket start, wall clock. + StartMs int64 + // W is the mean power over the bucket, site-signed. + W float64 +} + +// ResolutionStepMs is the stored bucket width per resolution. This is the +// meaning of the resolution's name; the registry carries spans and retention. +var ResolutionStepMs = map[Resolution]int64{ + ResN5m: 300_000, + ResN1h: 3_600_000, +} + +// MissingSample marks a hole on the wire. Distinct from zero, which is a real +// reading. Same value as the app's MISSING_SAMPLE. +const MissingSample = math.MinInt32 + +// PointsPerTile is how many stored buckets one tile spans at stride 1. +func PointsPerTile(res Resolution) int64 { + return ResolutionTileSpanMs[res] / ResolutionStepMs[res] +} + +// TileStartFor is the start of the epoch-aligned tile that contains atMs. +func TileStartFor(res Resolution, atMs int64) int64 { + span := ResolutionTileSpanMs[res] + return (atMs / span) * span +} + +// TileIDFor matches the app's tileId(): res/stride/tileIndex. +func TileIDFor(res Resolution, stride, startMs int64) string { + return fmt.Sprintf("%s/%d/%d", res, stride, startMs/ResolutionTileSpanMs[res]) +} + +// EtagOf is FNV-1a over the packed block, eight hex digits — the same hash the +// app computes, because the whole point of an etag is that both ends agree on +// it. +func EtagOf(data []byte) string { + h := uint32(0x811c9dc5) + for _, b := range data { + h ^= uint32(b) + h *= 0x01000193 + } + return fmt.Sprintf("%08x", h) +} + +// PackTile packs one tile from stored buckets: int32 LE columns, one block per +// series, MissingSample where the store has no bucket. windows holds each +// series' buckets at the resolution's stored width; buckets outside the tile +// are ignored. stride must divide PointsPerTile(res). The tile is partial, +// and never cached, while it ends after nowMs. +func PackTile( + res Resolution, + stride, startMs int64, + series []string, + windows map[string][]HistorySample, + nowMs int64, +) HistChunk { + stepMs := ResolutionStepMs[res] + tileEnd := startMs + ResolutionTileSpanMs[res] + points := PointsPerTile(res) / stride + + data := make([]byte, int64(len(series))*points*4) + for s, name := range series { + samples := windows[name] + + // Stored buckets fold into strided points by plain mean: the ledger's + // buckets are equal width, so each carries equal weight. + sums := make([]float64, points) + counts := make([]int64, points) + for _, sample := range samples { + if sample.StartMs < startMs || sample.StartMs >= tileEnd { + continue + } + p := (sample.StartMs - startMs) / (stepMs * stride) + if p < 0 || p >= points { + continue + } + sums[p] += sample.W + counts[p]++ + } + + base := int64(s) * points * 4 + for p := int64(0); p < points; p++ { + v := int32(MissingSample) + if counts[p] > 0 { + v = ClampSample(sums[p] / float64(counts[p])) + } + off := base + p*4 + u := uint32(v) + data[off] = byte(u) + data[off+1] = byte(u >> 8) + data[off+2] = byte(u >> 16) + data[off+3] = byte(u >> 24) + } + } + + return HistChunk{ + TileID: TileIDFor(res, stride, startMs), + Etag: EtagOf(data), + Res: res, + StartMs: startMs, + StepMs: stepMs * stride, + Series: series, + Data: data, + Partial: tileEnd > nowMs, + } +} + +// ClampSample rounds a mean to the int32 the wire carries. +func ClampSample(v float64) int32 { + r := math.Round(v) + // One step inside the limits: MinInt32 itself means "missing", and a + // real reading must never be mistaken for a hole. + if r >= math.MaxInt32 { + return math.MaxInt32 + } + if r <= MissingSample+1 { + return MissingSample + 1 + } + return int32(r) +} diff --git a/go/protocol/tiles_test.go b/go/protocol/tiles_test.go new file mode 100644 index 000000000..a196d26bf --- /dev/null +++ b/go/protocol/tiles_test.go @@ -0,0 +1,91 @@ +package protocol + +import ( + "encoding/binary" + "math" + "testing" +) + +// The geometry must match the app's src/lib/protocol/history.ts exactly — +// both ends compute tiles independently and meet on the etag. The reference +// values below were generated by running the app's own functions, so a +// mismatch here is a genuine wire break, not a test opinion. + +func TestTileGeometryMatchesTheApp(t *testing.T) { + if got := PointsPerTile(ResN5m); got != 144 { + t.Fatalf("5m tile has %d points, the app says 144", got) + } + if got := PointsPerTile(ResN1h); got != 168 { + t.Fatalf("1h tile has %d points, the app says 168", got) + } + + // tileId: res/stride/tileIndex, epoch-aligned. + at := int64(1_760_000_000_000) + start := TileStartFor(ResN5m, at) + if start%43_200_000 != 0 || start > at || at-start >= 43_200_000 { + t.Fatalf("5m tile start %d is not the aligned tile containing %d", start, at) + } + if got, want := TileIDFor(ResN5m, 3, start), "5m/3/40740"; got != want { + t.Fatalf("tileID = %q, want %q", got, want) + } +} + +func TestEtagMatchesTheAppByteForByte(t *testing.T) { + // Values from the app's etagOf(), run on the same input. + ascending := make([]byte, 16) + for i := range ascending { + ascending[i] = byte(i) + } + if got := EtagOf(ascending); got != "c8fff215" { + t.Fatalf("etag([0..15]) = %s, the app computes c8fff215", got) + } + if got := EtagOf(nil); got != "811c9dc5" { + t.Fatalf("etag(empty) = %s, the app computes 811c9dc5", got) + } +} + +// PackTile folds stored buckets into strided points by plain mean, marks holes +// as MissingSample and keeps a real reading off that value. Two 5m buckets at +// stride 2 make one point; a bucket outside the tile is not counted. +func TestPackTileFoldsMeansAndMarksHoles(t *testing.T) { + start := TileStartFor(ResN5m, 1_760_000_000_000) + step := ResolutionStepMs[ResN5m] + windows := map[string][]HistorySample{ + "grid_w": { + {StartMs: start, W: 100}, + {StartMs: start + step, W: 201}, + {StartMs: start - step, W: 9_999}, + }, + "pv_w": { + {StartMs: start + 2*step, W: -1e12}, + }, + } + series := []string{"grid_w", "pv_w"} + chunk := PackTile(ResN5m, 2, start, series, windows, start+step) + + points := PointsPerTile(ResN5m) / 2 + if want := int64(len(series)) * points * 4; int64(len(chunk.Data)) != want { + t.Fatalf("data is %d bytes, want %d", len(chunk.Data), want) + } + at := func(s, p int64) int32 { + return int32(binary.LittleEndian.Uint32(chunk.Data[(s*points+p)*4:])) + } + if got := at(0, 0); got != 151 { + t.Fatalf("grid point 0 = %d, want the rounded mean 151", got) + } + if got := at(0, 1); got != MissingSample { + t.Fatalf("grid point 1 = %d, want MissingSample", got) + } + if got := at(1, 1); got != math.MinInt32+1 { + t.Fatalf("pv point 1 = %d, want one step above MissingSample", got) + } + if chunk.TileID != TileIDFor(ResN5m, 2, start) || chunk.StepMs != 2*step || chunk.Res != ResN5m { + t.Fatalf("chunk header = %s step %d res %s", chunk.TileID, chunk.StepMs, chunk.Res) + } + if chunk.Etag != EtagOf(chunk.Data) || !chunk.Partial { + t.Fatalf("etag %s partial %v", chunk.Etag, chunk.Partial) + } + if closed := PackTile(ResN5m, 2, start, series, windows, start+ResolutionTileSpanMs[ResN5m]); closed.Partial { + t.Fatal("a tile that ended at nowMs is still partial") + } +}