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") + } +}