From 8cca4203666580ff84d7a406df035fbab520502d Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 07:49:21 +0200 Subject: [PATCH 1/3] feat(protocol): signed events, push registration and the alert payload FTW Cloud C4 carries notifications. cloudwire gains the event class body (eventId, ts, kind, severity, args, src, lp), the phone's push.register and push.unregister, and the APNs payload the service builds: the same aps for every alert, and the box's envelope unchanged under "ftw". ReadAlert is the reference for what a phone's notification extension makes of a payload: a fresh, anchored, box-signed event of a known kind, the service's own box.unreachable, or the generic line. The catalogue gains charging.goal_at_risk. cloud-vectors.json gains three signed events; push-vectors.json holds fourteen payloads, the phone's clock and the verdict each must get, for the service and the app to replay. Co-Authored-By: Claude Opus 5.5 --- contract/push-catalogue.yaml | 6 + go/protocol/cloudwire/allowlist.go | 7 +- go/protocol/cloudwire/push.go | 220 +++++++++++++ go/protocol/cloudwire/push_vectors_test.go | 289 ++++++++++++++++++ .../cloudwire/testdata/cloud-vectors.json | 30 ++ .../cloudwire/testdata/push-vectors.json | 181 +++++++++++ go/protocol/cloudwire/vectors_test.go | 15 + go/protocol/push_gen.go | 2 + 8 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 go/protocol/cloudwire/push.go create mode 100644 go/protocol/cloudwire/push_vectors_test.go create mode 100644 go/protocol/cloudwire/testdata/push-vectors.json diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 8560885d..8473df44 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -19,6 +19,12 @@ events: - kind: charging.session_complete title: Car charged body: "{kwh} kWh delivered — ready to go." + - kind: charging.goal_at_risk + # Only when the box knows the car's level, from the car or from someone + # who set it, and the plan cannot reach the goal by the ready time. An + # assumed level would make this a guess. Once per plug-in. + title: The car may not be ready + body: "It will not reach {target} % by {by} at this pace." - kind: charging.interrupted # Only after the hysteresis window the box applies: a charge that had # been running steadily and then stopped without finishing. A flapping diff --git a/go/protocol/cloudwire/allowlist.go b/go/protocol/cloudwire/allowlist.go index 42acd382..22fb4f80 100644 --- a/go/protocol/cloudwire/allowlist.go +++ b/go/protocol/cloudwire/allowlist.go @@ -31,6 +31,8 @@ var Fields = map[string][]string{ MsgHB: {"clock", "srcRev", "liveSeq", "lpSeq", "ages"}, + MsgEvent: {"eventId", "ts", "kind", "severity", "args", "src", "lp"}, + MsgRoster: {"rosterSeq", "ttlS", "devices", "grant"}, "Device": {"id", "key", "role", "scopes", "epoch"}, "Grant": {"id", "scopes", "purposes", "controller"}, @@ -39,14 +41,15 @@ var Fields = map[string][]string{ } // Bodies maps each class the box sends to its body type. Classes the box does -// not send yet (plan, health, event, tiles) have no entry; the service treats -// their bytes as opaque either way. +// not send yet (plan, health, tiles) have no entry; the service treats their +// bytes as opaque either way. var Bodies = map[string]any{ MsgHello: Hello{}, MsgLive: Live{}, MsgLP: LP{}, MsgPrices: Prices{}, MsgHB: HB{}, + MsgEvent: Event{}, MsgRoster: Roster{}, MsgBye: Bye{}, } diff --git a/go/protocol/cloudwire/push.go b/go/protocol/cloudwire/push.go new file mode 100644 index 00000000..57650dfc --- /dev/null +++ b/go/protocol/cloudwire/push.go @@ -0,0 +1,220 @@ +package cloudwire + +import ( + "encoding/json" + + "github.com/srcfl/ftw/go/protocol" +) + +// Events and push: what the box tells phones that are not looking, how a +// phone tells the service where Apple can reach it, and what the service +// hands Apple. docs/protocol-cloud.md, "Push", is the normative text. +// +// The box signs every event. The service wraps the envelope, unchanged, in an +// APNs alert; the phone's notification extension checks the signature with +// the box key it learned over Noise, writes its own text from the kind, and +// picks the buttons from the verified kind alone. The service picks nothing: +// not the words, not the buttons, not the priority. box.unreachable is the +// one alert the service writes itself, because a box that cannot reach the +// service cannot sign one; it carries no arguments and no buttons. + +// Message types a phone sends the service over its app session. +const ( + MsgPushRegister = "push.register" + MsgPushUnregister = "push.unregister" +) + +// APNs environments. A development build of the app gets sandbox tokens. +const ( + PushSandbox = "sandbox" + PushProduction = "production" +) + +// PushKindAlert is the only kind of registration until widgets and Live +// Activities get their own pushes. +const PushKindAlert = "alert" + +// APNsTokenMaxBytes bounds a device token. Apple's are 32 bytes today and +// documented as variable length. +const APNsTokenMaxBytes = 100 + +// Event severities, from the box's notification priority. +const ( + SeverityInfo = "info" + SeverityWarning = "warning" + SeverityAlarm = "alarm" +) + +// Event is one notification the box raised, for devices with +// ftw.events.read. Args are the catalogue's placeholders except names: an +// event about a device names it by Src, and the phone looks the name up in +// the table it holds from the box. +type Event struct { + Header + // EventID is unique per event. + EventID string `cbor:"eventId"` + // Ts is the box's wall time when the event happened, present only + // while NTP has synced the box clock. It can be earlier than the + // header's wallMs when the event waited for the uplink. + Ts *int64 `cbor:"ts,omitempty"` + Kind string `cbor:"kind"` + Severity string `cbor:"severity"` + Args map[string]string `cbor:"args"` + // Src is the opaque source id of the device the event concerns. + Src string `cbor:"src,omitempty"` + // LP is the opaque loadpoint id of the loadpoint it concerns. + LP string `cbor:"lp,omitempty"` +} + +// PushRegister tells the service a device's APNs token for one kind of push. +// The service binds it to the device's roster entry and forgets it when the +// roster drops the device, its epoch changes, or Apple says the token is +// gone. +type PushRegister struct { + APNsToken []byte `cbor:"apnsToken"` + Environment string `cbor:"environment"` + Kind string `cbor:"kind"` + // ActivityID names a Live Activity. Unused until its push exists. + ActivityID string `cbor:"activityId,omitempty"` +} + +// PushUnregister forgets one token. +type PushUnregister struct { + APNsToken []byte `cbor:"apnsToken"` +} + +// AlertMaxAgeMs is how old an event may be and still get its text and +// buttons on the phone. An older one, or one the phone cannot anchor in +// time, shows the generic line with no buttons. +const AlertMaxAgeMs = 10 * 60 * 1000 + +// UnreachableAfterMs is how long the service waits without the box's socket +// before it sends box.unreachable. A bye for an update holds it back until +// the box returns or UpdateGraceMs has passed. +const ( + UnreachableAfterMs = 600 * 1000 + UpdateGraceMs = 60 * 60 * 1000 +) + +// Alert is the "ftw" member of an APNs alert payload. Event is the box's +// envelope exactly as it arrived; Kind is set only on the alert the service +// writes itself, box.unreachable, and a phone accepts no other kind there. +type Alert struct { + V int `json:"v"` + Home string `json:"home"` + Event []byte `json:"event,omitempty"` + Kind string `json:"kind,omitempty"` +} + +// AlertKey is the payload key Alert sits under, beside Apple's "aps". +const AlertKey = "ftw" + +// Localisation keys of the line Apple shows if the extension does not run. +// The app's own strings hold the words, so the service writes none. +const ( + AlertTitleLocKey = "push.title" + AlertBodyLocKey = "push.generic" +) + +// AlertPayload is the whole APNs payload for one alert: Apple's "aps" with +// the generic line and mutable-content, so the extension runs, and Alert +// under AlertKey. Every alert gets the same aps, whatever its kind. +func AlertPayload(a Alert) ([]byte, error) { + type apsAlert struct { + TitleLocKey string `json:"title-loc-key"` + LocKey string `json:"loc-key"` + } + type aps struct { + Alert apsAlert `json:"alert"` + MutableContent int `json:"mutable-content"` + Sound string `json:"sound"` + } + a.V = Version + return json.Marshal(struct { + APS aps `json:"aps"` + FTW Alert `json:"ftw"` + }{aps{apsAlert{AlertTitleLocKey, AlertBodyLocKey}, 1, "default"}, a}) +} + +// What a phone makes of an alert. +const ( + // AlertEvent: a box-signed event, anchored and fresh, of a kind the + // catalogue has. The phone writes its text and offers its buttons. + AlertEvent = "event" + // AlertUnreachable: the service's box.unreachable. Its own text, no + // buttons. + AlertUnreachable = "unreachable" + // AlertGeneric: anything else. The generic line, no buttons. + AlertGeneric = "generic" +) + +// AlertView is what an alert says once checked. +type AlertView struct { + Verdict string + Home string + // The rest is set only for AlertEvent. + Kind string + Args map[string]string + Src string + LP string + Boot []byte + Seq uint64 + // AtMs is when the event happened by the phone's reading of the box + // clock: never later than it arrived. + AtMs int64 +} + +// ClockLeadMs is how far ahead of the phone a box's wall clock may run +// before the phone stops trusting it (rule N). +const ClockLeadMs = 2000 + +// ReadAlert is the reference for what a phone's notification extension does +// with an alert payload. boxKey returns the key the phone learned over Noise +// for a home, nil when it has none; nowMs is the phone's clock at arrival. +// Whether a (boot, seq) was seen before is the caller's state, not this +// function's. +func ReadAlert(payload []byte, boxKey func(home string) []byte, nowMs int64) AlertView { + var p struct { + FTW *Alert `json:"ftw"` + } + if json.Unmarshal(payload, &p) != nil || p.FTW == nil || p.FTW.V != Version { + return AlertView{Verdict: AlertGeneric} + } + a := *p.FTW + view := AlertView{Verdict: AlertGeneric, Home: a.Home} + if len(a.Event) == 0 { + if a.Kind == protocol.PushBoxUnreachable { + view.Verdict = AlertUnreachable + } + return view + } + if a.Kind != "" { + return view + } + env, err := DecodeEnvelope(a.Event) + if err != nil || env.T != MsgEvent { + return view + } + pk := boxKey(a.Home) + if pk == nil { + return view + } + pub, err := ParsePublicKey(pk) + if err != nil || Verify(pub, a.Home, env) != nil { + return view + } + var ev Event + if Unmarshal(env.B, &ev) != nil || ev.WallMs == nil || *ev.WallMs > nowMs+ClockLeadMs { + return view + } + at := min(*ev.WallMs, nowMs) + if ev.Ts != nil { + at = min(at, *ev.Ts) + } + _, known := protocol.PushSentences[ev.Kind] + if !known || ev.Kind == protocol.PushBoxUnreachable || nowMs-at > AlertMaxAgeMs { + return view + } + return AlertView{Verdict: AlertEvent, Home: a.Home, Kind: ev.Kind, Args: ev.Args, Src: ev.Src, LP: ev.LP, + Boot: ev.Boot, Seq: ev.Seq, AtMs: at} +} diff --git a/go/protocol/cloudwire/push_vectors_test.go b/go/protocol/cloudwire/push_vectors_test.go new file mode 100644 index 00000000..66002ba5 --- /dev/null +++ b/go/protocol/cloudwire/push_vectors_test.go @@ -0,0 +1,289 @@ +package cloudwire + +import ( + "bytes" + "crypto/ecdsa" + "crypto/rand" + "encoding/hex" + "encoding/json" + "os" + "slices" + "testing" + + "github.com/srcfl/ftw/go/protocol" +) + +// Push vectors: APNs payloads as the service makes them, the phone's clock +// when each arrives, and what the phone must make of it. ReadAlert is the +// reference; the phone's notification extension replays the same file. +// +// go test ./cloudwire -run TestPushVectors -update +// +// Signatures are stored, as in cloud-vectors.json, so an update that only +// adds a case leaves the others' bytes alone. + +const pushVectorsPath = "testdata/push-vectors.json" + +type pushVectorFile struct { + // Keys are the box keys the phone holds, by home. + Keys []pushVectorKey `json:"keys"` + Cases []pushVectorCase `json:"cases"` +} + +type pushVectorKey struct { + Home string `json:"home"` + PK string `json:"pk"` +} + +type pushVectorCase struct { + Name string `json:"name"` + // Payload is the APNs payload, exactly as the service sends it. + Payload string `json:"payload"` + // Sig is the event's stored signature, hex, empty when there is no + // event or it is unsigned. + Sig string `json:"sig"` + NowMs int64 `json:"nowMs"` + Want pushVectorOut `json:"want"` +} + +type pushVectorOut struct { + Verdict string `json:"verdict"` + Home string `json:"home"` + Kind string `json:"kind,omitempty"` + Args map[string]string `json:"args,omitempty"` + Src string `json:"src,omitempty"` + LP string `json:"lp,omitempty"` + Boot string `json:"boot,omitempty"` + Seq uint64 `json:"seq,omitempty"` + AtMs int64 `json:"atMs,omitempty"` +} + +func TestPushVectors(t *testing.T) { + box := testKey(t, "ftw.cloud vector box") + other := testKey(t, "ftw.cloud vector box two") + home, boxPK := homeOf(t, box) + otherHome, otherPK := homeOf(t, other) + grantKey := bytes.Repeat([]byte{0x77}, GrantKeyBytes) + charger := SourceID(grantKey, "easee") + garage := LoadpointID(grantKey, "garage") + boot := bytes.Repeat([]byte{0xb0}, BootBytes) + wall := int64(1_791_820_800_000) + at := func(uptime int64) *int64 { return i64(wall + uptime - 60_000) } + + var stored pushVectorFile + if !*update { + raw, err := os.ReadFile(pushVectorsPath) + if err != nil { + t.Fatalf("%v (run with -update to create it)", err) + } + if err := json.Unmarshal(raw, &stored); err != nil { + t.Fatal(err) + } + } + storedSig := func(i int, name string) []byte { + if i >= len(stored.Cases) || stored.Cases[i].Name != name { + t.Fatalf("push vector %d is not %q in %s; regenerate with -update", i, name, pushVectorsPath) + } + sig, _ := hex.DecodeString(stored.Cases[i].Sig) + return sig + } + + connected := Event{ + Header: Header{Seq: 9, Boot: boot, UptimeMs: 71_000, WallMs: at(71_000)}, + EventID: "Q2ZrMjR0dzVh", Ts: i64(*at(71_000) - 400), Kind: protocol.PushChargingConnected, + Severity: SeverityInfo, Args: map[string]string{}, Src: charger, LP: garage, + } + interrupted := Event{ + Header: Header{Seq: 10, Boot: boot, UptimeMs: 72_000, WallMs: at(72_000)}, + EventID: "aW50cnB0ZDAx", Ts: i64(*at(72_000) - 300), Kind: protocol.PushChargingInterrupted, + Severity: SeverityWarning, Args: map[string]string{}, Src: charger, LP: garage, + } + noClock := connected + noClock.WallMs, noClock.Ts, noClock.Seq = nil, nil, 12 + ahead := connected + ahead.WallMs, ahead.Ts, ahead.Seq = at(80_000), nil, 13 + unknown := connected + unknown.Kind, unknown.Seq = "charging.someday", 14 + goal := Event{ + Header: Header{Seq: 15, Boot: boot, UptimeMs: 74_000, WallMs: at(74_000)}, + EventID: "Z29hbHJpc2sx", Ts: i64(*at(74_000) - 100), Kind: protocol.PushChargingGoalAtRisk, + Severity: SeverityWarning, Args: map[string]string{"target": "80", "by": "07:00"}, LP: garage, + } + signedAsOther := connected + signedAsOther.Seq = 16 + + type spec struct { + name string + key *ecdsa.PrivateKey // signer; nil for no event + signFor string // home the box signs for + event *Event + alert Alert // Home and Kind; Event is filled in + mutate func(env []byte) []byte + nowMs int64 + want pushVectorOut + } + fresh := func(e Event) int64 { return *e.WallMs + 1_200 } + event := func(e Event) pushVectorOut { + at := min(*e.WallMs, fresh(e)) + if e.Ts != nil { + at = min(at, *e.Ts) + } + return pushVectorOut{Verdict: AlertEvent, Home: home, Kind: e.Kind, Args: e.Args, Src: e.Src, LP: e.LP, + Boot: hex.EncodeToString(e.Boot), Seq: e.Seq, AtMs: at} + } + generic := pushVectorOut{Verdict: AlertGeneric, Home: home} + specs := []spec{ + {name: "connected, fresh", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + nowMs: fresh(connected), want: event(connected)}, + {name: "interrupted, fresh", key: box, signFor: home, event: &interrupted, alert: Alert{Home: home}, + nowMs: fresh(interrupted), want: event(interrupted)}, + {name: "goal at risk, fresh", key: box, signFor: home, event: &goal, alert: Alert{Home: home}, + nowMs: fresh(goal), want: event(goal)}, + {name: "one byte of the body changed", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + mutate: func(env []byte) []byte { + out := bytes.Clone(env) + i := bytes.Index(out, []byte("charging.connected")) + out[i+len("charging.")] ^= 0x01 + return out + }, + nowMs: fresh(connected), want: generic}, + {name: "named for another home the phone holds", key: box, signFor: home, event: &connected, + alert: Alert{Home: otherHome}, nowMs: fresh(connected), + want: pushVectorOut{Verdict: AlertGeneric, Home: otherHome}}, + {name: "signed by another box for this home", key: other, signFor: home, event: &signedAsOther, + alert: Alert{Home: home}, nowMs: fresh(signedAsOther), want: generic}, + {name: "two hours old", key: box, signFor: home, event: &connected, alert: Alert{Home: home}, + nowMs: *connected.WallMs + 2*60*60*1000, want: generic}, + {name: "ten minutes old is still fresh", key: box, signFor: home, event: &interrupted, alert: Alert{Home: home}, + nowMs: *interrupted.Ts + AlertMaxAgeMs, + want: func() pushVectorOut { + o := event(interrupted) + o.AtMs = *interrupted.Ts + return o + }()}, + {name: "box clock not synced", key: box, signFor: home, event: &noClock, alert: Alert{Home: home}, + nowMs: fresh(connected), want: generic}, + {name: "box clock ahead of the phone", key: box, signFor: home, event: &ahead, alert: Alert{Home: home}, + nowMs: *ahead.WallMs - ClockLeadMs - 1, want: generic}, + {name: "a kind the phone does not know", key: box, signFor: home, event: &unknown, alert: Alert{Home: home}, + nowMs: fresh(unknown), want: generic}, + {name: "unreachable, from the service", alert: Alert{Home: home, Kind: protocol.PushBoxUnreachable}, + nowMs: wall, want: pushVectorOut{Verdict: AlertUnreachable, Home: home}}, + {name: "another kind from the service", alert: Alert{Home: home, Kind: protocol.PushChargingConnected}, + nowMs: wall, want: generic}, + {name: "a home the phone does not hold", key: box, signFor: home, event: &connected, + alert: Alert{Home: "aaaaaaaaaaaaaaaaaaaaaaaaaa"}, nowMs: fresh(connected), + want: pushVectorOut{Verdict: AlertGeneric, Home: "aaaaaaaaaaaaaaaaaaaaaaaaaa"}}, + } + + keys := map[string][]byte{home: boxPK, otherHome: otherPK} + out := pushVectorFile{Keys: []pushVectorKey{ + {Home: home, PK: hex.EncodeToString(boxPK)}, + {Home: otherHome, PK: hex.EncodeToString(otherPK)}, + }} + for i, s := range specs { + a := s.alert + var sig []byte + if s.event != nil { + body, err := Marshal(*s.event) + if err != nil { + t.Fatal(err) + } + if *update { + if sig, err = Sign(rand.Reader, s.key, s.signFor, MsgEvent, body); err != nil { + t.Fatal(err) + } + } else { + sig = storedSig(i, s.name) + } + if err := Verify(&s.key.PublicKey, s.signFor, Envelope{T: MsgEvent, B: body, Sig: sig}); err != nil { + t.Fatalf("%s: stored signature does not verify: %v", s.name, err) + } + env, err := Envelope{T: MsgEvent, B: body, Sig: sig}.Encode() + if err != nil { + t.Fatal(err) + } + if s.mutate != nil { + env = s.mutate(env) + } + a.Event = env + } + payload, err := AlertPayload(a) + if err != nil { + t.Fatal(err) + } + if len(payload) > 4096 { + t.Fatalf("%s: %d bytes is over APNs' 4 KB", s.name, len(payload)) + } + + view := ReadAlert(payload, func(h string) []byte { return keys[h] }, s.nowMs) + got := pushVectorOut{Verdict: view.Verdict, Home: view.Home, Kind: view.Kind, Args: view.Args, + Src: view.Src, LP: view.LP, Seq: view.Seq, AtMs: view.AtMs} + if view.Boot != nil { + got.Boot = hex.EncodeToString(view.Boot) + } + if !equalOut(got, s.want) { + t.Errorf("%s: got %+v, want %+v", s.name, got, s.want) + } + out.Cases = append(out.Cases, pushVectorCase{Name: s.name, Payload: string(payload), + Sig: hex.EncodeToString(sig), NowMs: s.nowMs, Want: s.want}) + } + + got, err := json.MarshalIndent(out, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if *update { + if err := os.WriteFile(pushVectorsPath, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, _ := os.ReadFile(pushVectorsPath) + if !bytes.Equal(got, want) { + t.Fatalf("the bytes no longer match %s. If the change is intended, regenerate with -update "+ + "and pair it with srcfl/ftw-cloud and the app", pushVectorsPath) + } +} + +func equalOut(a, b pushVectorOut) bool { + if len(a.Args) == 0 && len(b.Args) == 0 { + a.Args, b.Args = nil, nil + } + ka, kb := make([]string, 0, len(a.Args)), make([]string, 0, len(b.Args)) + for k, v := range a.Args { + ka = append(ka, k+"="+v) + } + for k, v := range b.Args { + kb = append(kb, k+"="+v) + } + slices.Sort(ka) + slices.Sort(kb) + a.Args, b.Args = nil, nil + return a.Verdict == b.Verdict && a.Home == b.Home && a.Kind == b.Kind && a.Src == b.Src && a.LP == b.LP && + a.Boot == b.Boot && a.Seq == b.Seq && a.AtMs == b.AtMs && slices.Equal(ka, kb) +} + +// The service's alert has the same aps whatever it carries, so Apple learns +// a kind only from the signed event, never from priority or wording. +func TestEveryAlertHasTheSameAps(t *testing.T) { + a, err := AlertPayload(Alert{Home: "h", Event: []byte{1, 2, 3}}) + if err != nil { + t.Fatal(err) + } + b, err := AlertPayload(Alert{Home: "h", Kind: protocol.PushBoxUnreachable}) + if err != nil { + t.Fatal(err) + } + var pa, pb map[string]json.RawMessage + if json.Unmarshal(a, &pa) != nil || json.Unmarshal(b, &pb) != nil { + t.Fatal("not JSON") + } + if !bytes.Equal(pa["aps"], pb["aps"]) { + t.Fatalf("aps differs: %s vs %s", pa["aps"], pb["aps"]) + } + if !bytes.Contains(pa["aps"], []byte(`"mutable-content":1`)) { + t.Fatalf("no mutable-content: %s", pa["aps"]) + } +} diff --git a/go/protocol/cloudwire/testdata/cloud-vectors.json b/go/protocol/cloudwire/testdata/cloud-vectors.json index d578b866..9ad22446 100644 --- a/go/protocol/cloudwire/testdata/cloud-vectors.json +++ b/go/protocol/cloudwire/testdata/cloud-vectors.json @@ -161,6 +161,36 @@ "sig": "910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", "envelope": "a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", "frame": "010100000086a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32" + }, + { + "name": "event connected", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a00011558", + "signedInputSha256": "6e13d5c342e20d31aef8530bd5d8d89b08540809278bd1aa93d2d156fe2b6d29", + "sig": "490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "envelope": "a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "frame": "0101000000f9a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23" + }, + { + "name": "event interrupted", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a00011940", + "signedInputSha256": "b29e1deef680d5b19f631a1ee29965bf70774e690afe1dbab5e7e25c4738ddbb", + "sig": "ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "envelope": "a3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "frame": "0101000000fea3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25" + }, + { + "name": "event session complete", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d28", + "signedInputSha256": "37a352e6f700b18e8dfec643cc783a67bf284209ec5ef16956266dfe84ed0b0a", + "sig": "e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "envelope": "a3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "frame": "01010000010aa3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb" } ] } diff --git a/go/protocol/cloudwire/testdata/push-vectors.json b/go/protocol/cloudwire/testdata/push-vectors.json new file mode 100644 index 00000000..218d502f --- /dev/null +++ b/go/protocol/cloudwire/testdata/push-vectors.json @@ -0,0 +1,181 @@ +{ + "keys": [ + { + "home": "fjzyr67buzpyncqmjliwgezvde", + "pk": "04cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b8" + }, + { + "home": "t3w5bfotxeygcjxjdhjaeo6pvq", + "pk": "04003ed3e19bde3ad2abbe687ce92a9d63cfacd49cb4bace9903c3ee95093700804081439d960c07e29171ab20fdaf94c603d87e4669f961d7a227e8d8616111f9" + } + ], + "cases": [ + { + "name": "connected, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEABMIFtaHgkzfeMrtApu30UgdXtawe2kzcX4lBlhZ9yVOdjZkXlCSoSUrd2nHXDYI4pClpW4ReFKP/H9mqucV2H\"}}", + "sig": "0130816d687824cdf78caed029bb7d1481d5ed6b07b6933717e25065859f7254e7636645e5092a1252b7769c75c3608e290a5a56e1178528ffc7f66aae715d87", + "nowMs": 1791820812200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.connected", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 9, + "atMs": 1791820810600 + } + }, + { + "name": "interrupted, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQKJwjC0k3f6XPDpapuR7NTwgus1FBgURGLD5DSpoTIsFWtfQ2iow7T1MWUHBPcWxPna0WQUCzpdkh5EcsZQULTs=\"}}", + "sig": "a2708c2d24ddfe973c3a5aa6e47b353c20bacd4506051118b0f90d2a684c8b055ad7d0da2a30ed3d4c5941c13dc5b13e76b4590502ce976487911cb194142d3b", + "nowMs": 1791820813200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "goal at risk, fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWK+qYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw19ZMY3NlcQ9kYXJnc6JiYnllMDc6MDBmdGFyZ2V0YjgwZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdWNoYXJnaW5nLmdvYWxfYXRfcmlza2Z3YWxsTXMbAAABoTDX1rBnZXZlbnRJZGxaMjloYkhKcGMyc3hoc2V2ZXJpdHlnd2FybmluZ2h1cHRpbWVNcxoAASEQYXRlZXZlbnRjc2lnWEDAGlZS76Snuq3dzP6oBe1buyz2kzW/N79ukTRxpHgali+4Bqh7Um61zOsv4QTfS6Ss6vENXdDC6biX122t3pJg\"}}", + "sig": "c01a5652efa4a7baadddccfea805ed5bbb2cf69335bf37bf6e913471a4781a962fb806a87b526eb5cceb2fe104df4ba4aceaf10d5dd0c2e9b897d76dadde9260", + "nowMs": 1791820815200, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.goal_at_risk", + "args": { + "by": "07:00", + "target": "80" + }, + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 15, + "atMs": 1791820813900 + } + }, + { + "name": "one byte of the body changed", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmJvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECNIbMUmau1F9d1HqNW3rNdV0jlDVXEjK/Q3/b8bBjF7yCpDjUzd9n2FfuL0zIv0aJUJkc3BbScu+pvpr8piwGb\"}}", + "sig": "8d21b31499abb517d7751ea356deb35d5748e50d55c48cafd0dff6fc6c18c5ef20a90e353377d9f615fb8bd3322fd1a25426473705b49cbbea6fa6bf298b019b", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "named for another home the phone holds", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"t3w5bfotxeygcjxjdhjaeo6pvq\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBH6wZnxbr20B6MN8GTyLZcR25rSVN1bugYCsIleoMmLRG5mELw/q7JcRUOiucK7LLM3ka46TK2T7FnZh3mxuIl\"}}", + "sig": "47eb0667c5baf6d01e8c37c193c8b65c476e6b4953756ee8180ac2257a83262d11b99842f0feaec971150e8ae70aecb2ccde46b8e932b64fb167661de6c6e225", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "t3w5bfotxeygcjxjdhjaeo6pvq" + } + }, + { + "name": "signed by another box for this home", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcRBjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEBJ68gFtSwOcqFpSXiK6MHVsHr2RiTTMxIm9h1lNwn/IroE4rs65mYQCkTY+xLXrenrOasV/suwp6HzhwgoHk8z\"}}", + "sig": "49ebc805b52c0e72a16949788ae8c1d5b07af64624d3331226f61d653709ff22ba04e2bb3ae666100a44d8fb12d7ade9eb39ab15fecbb0a7a1f38708281e4f33", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "two hours old", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEAg1t+OvFYf9y9gpMPg21Q7dzGvrJ5V8CrBNIr2EWpgLZl1QZgbSA8ELs8nrATH2p+tUjDAmJSyG1m8YlsgYEap\"}}", + "sig": "20d6df8ebc561ff72f60a4c3e0db543b7731afac9e55f02ac1348af6116a602d997541981b480f042ecf27ac04c7da9fad5230c09894b21b59bc625b206046a9", + "nowMs": 1791828011000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "ten minutes old is still fresh", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKurYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw1820Y3NlcQpjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kdGNoYXJnaW5nLmludGVycnVwdGVkZndhbGxNcxsAAAGhMNfO4GdldmVudElkbGFXNTBjbkIwWkRBeGhzZXZlcml0eWd3YXJuaW5naHVwdGltZU1zGgABGUBhdGVldmVudGNzaWdYQB7Mfxasg5x9bJLORP3yYOgcto/sS+Y/Z2SgGKkgJucoOlYH2mg5ex6sShXDjDHRYZ4RHhRgC3ENSJ+FxFIryMI=\"}}", + "sig": "1ecc7f16ac839c7d6c92ce44fdf260e81cb68fec4be63f6764a018a92026e7283a5607da68397b1eac4a15c38c31d1619e111e14600b710d489f85c4522bc8c2", + "nowMs": 1791821411700, + "want": { + "verdict": "event", + "home": "fjzyr67buzpyncqmjliwgezvde", + "kind": "charging.interrupted", + "src": "DUYWWQmn-Xg", + "lp": "j3HZORNCWHs", + "boot": "b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0", + "seq": 10, + "atMs": 1791820811700 + } + }, + { + "name": "box clock not synced", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWIqpYmxwa2ozSFpPUk5DV0hzY3NlcQxjc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGdldmVudElkbFEyWnJNalIwZHpWaGhzZXZlcml0eWRpbmZvaHVwdGltZU1zGgABFVhhdGVldmVudGNzaWdYQA4D7BC9hbsDGWWnl69+YzZWkvCG2DyNVfPtNFNW4O27uftmRBxY0ewGRUt+ilCSn+mRKmIIqpbBh0wAgJxCVaI=\"}}", + "sig": "0e03ec10bd85bb031965a797af7e63365692f086d83c8d55f3ed345356e0edbbb9fb66441c58d1ec06454b7e8a50929fe9912a6208aa96c1874c00809c4255a2", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "box clock ahead of the phone", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWJqqYmxwa2ozSFpPUk5DV0hzY3NlcQ1jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDX7iBnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWEDNcUjPNmeqdosZjUen9w+4n17dD+hThNnqvSLF0A89nUqTWpscn7BRZybd7FyfIHQ8We5wyuZLHQYmf4s2Ggd5\"}}", + "sig": "cd7148cf3667aa768b198d47a7f70fb89f5edd0fe85384d9eabd22c5d00f3d9d4a935a9b1c9fb0516726ddec5c9f20743c59ee70cae64b1d06267f8b361a0779", + "nowMs": 1791820817999, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a kind the phone does not know", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"event\":\"o2FiWKSrYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQ5jc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcGNoYXJnaW5nLnNvbWVkYXlmd2FsbE1zGwAAAaEw18r4Z2V2ZW50SWRsUTJack1qUjBkelZoaHNldmVyaXR5ZGluZm9odXB0aW1lTXMaAAEVWGF0ZWV2ZW50Y3NpZ1hAuiASso5HdVyi4+dXlmi5PKDjO5DBTXiaChzNJqz5l/nXU7o++4oPDzfzx3k0Ra0tM3vXtZ2GoHJafIP+dypV6g==\"}}", + "sig": "ba2012b28e47755ca2e3e7579668b93ca0e33b90c14d789a0a1ccd26acf997f9d753ba3efb8a0f0f37f3c7793445ad2d337bd7b59d86a0725a7c83fe772a55ea", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "unreachable, from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"box.unreachable\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "unreachable", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "another kind from the service", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"fjzyr67buzpyncqmjliwgezvde\",\"kind\":\"charging.connected\"}}", + "sig": "", + "nowMs": 1791820800000, + "want": { + "verdict": "generic", + "home": "fjzyr67buzpyncqmjliwgezvde" + } + }, + { + "name": "a home the phone does not hold", + "payload": "{\"aps\":{\"alert\":{\"title-loc-key\":\"push.title\",\"loc-key\":\"push.generic\"},\"mutable-content\":1,\"sound\":\"default\"},\"ftw\":{\"v\":1,\"home\":\"aaaaaaaaaaaaaaaaaaaaaaaaaa\",\"event\":\"o2FiWKarYmxwa2ozSFpPUk5DV0hzYnRzGwAAAaEw18loY3NlcQljc3Jja0RVWVdXUW1uLVhnZGFyZ3OgZGJvb3RQsLCwsLCwsLCwsLCwsLCwsGRraW5kcmNoYXJnaW5nLmNvbm5lY3RlZGZ3YWxsTXMbAAABoTDXyvhnZXZlbnRJZGxRMlpyTWpSMGR6Vmhoc2V2ZXJpdHlkaW5mb2h1cHRpbWVNcxoAARVYYXRlZXZlbnRjc2lnWECFrKOjjhGZn6O0MCWuzT1paANIFK44ZLUd7mRqIzBcRgdME/ZjLp6jHx96zZY87aqltd41D9CG4Fn1IrGtzuNz\"}}", + "sig": "85aca3a38e11999fa3b43025aecd3d6968034814ae3864b51dee646a23305c46074c13f6632e9ea31f1f7acd963cedaaa5b5de350fd086e059f522b1adcee373", + "nowMs": 1791820812200, + "want": { + "verdict": "generic", + "home": "aaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } + ] +} diff --git a/go/protocol/cloudwire/vectors_test.go b/go/protocol/cloudwire/vectors_test.go index a263773a..17225384 100644 --- a/go/protocol/cloudwire/vectors_test.go +++ b/go/protocol/cloudwire/vectors_test.go @@ -159,6 +159,21 @@ func vectorBodies(grantKey []byte, boxPK []byte) []struct { {"bye", "box", MsgBye, Bye{Header: h(8, 70_000), Why: ByeRevoked, Purge: true}}, {"purged", "service", MsgPurged, Purged{AtMs: 1_791_821_000_000, What: PurgedHome, GrantID: bytes.Repeat([]byte{0x44}, GrantIDBytes)}}, + {"event connected", "box", MsgEvent, Event{ + Header: h(9, 71_000), EventID: "Q2ZrMjR0dzVh", Ts: i64(wall + 71_000 - 60_000 - 400), + Kind: protocol.PushChargingConnected, Severity: SeverityInfo, Args: map[string]string{}, + Src: charger, LP: garage, + }}, + {"event interrupted", "box", MsgEvent, Event{ + Header: h(10, 72_000), EventID: "aW50cnB0ZDAx", Ts: i64(wall + 72_000 - 60_000 - 300), + Kind: protocol.PushChargingInterrupted, Severity: SeverityWarning, Args: map[string]string{}, + Src: charger, LP: garage, + }}, + {"event session complete", "box", MsgEvent, Event{ + Header: h(11, 73_000), EventID: "ZG9uZTAwMDAx", Ts: i64(wall + 73_000 - 60_000 - 200), + Kind: protocol.PushChargingSessionComplete, Severity: SeverityInfo, + Args: map[string]string{"kwh": "18.4"}, Src: charger, LP: garage, + }}, } } diff --git a/go/protocol/push_gen.go b/go/protocol/push_gen.go index c0454eea..bb6a93f2 100644 --- a/go/protocol/push_gen.go +++ b/go/protocol/push_gen.go @@ -10,6 +10,7 @@ const PushCatalogueVersion = 1 const ( PushChargingConnected = "charging.connected" PushChargingSessionComplete = "charging.session_complete" + PushChargingGoalAtRisk = "charging.goal_at_risk" PushChargingInterrupted = "charging.interrupted" PushUpdateInstalled = "update.installed" PushDriverOffline = "driver.offline" @@ -30,6 +31,7 @@ type PushSentence struct { var PushSentences = map[string]PushSentence{ PushChargingConnected: {Title: "Car plugged in", Body: "Open to see charging status, check the battery level and set a ready time."}, PushChargingSessionComplete: {Title: "Car charged", Body: "{kwh} kWh delivered — ready to go."}, + PushChargingGoalAtRisk: {Title: "The car may not be ready", Body: "It will not reach {target} % by {by} at this pace."}, PushChargingInterrupted: {Title: "Charging stopped early", Body: "The car stopped charging before it was done."}, PushUpdateInstalled: {Title: "Your box updated itself", Body: "Now running {version}. Everything came back on its own."}, PushDriverOffline: {Title: "A device went quiet", Body: "{name} stopped answering."}, From 19ea338cd3ec9bc2458722cd2744543fd7b023a7 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:05:17 +0200 Subject: [PATCH 2/3] feat(cloud): send the box's notifications to FTW Cloud as signed events K5 and the Core half of FTW Cloud C4. - A catalogue message carries its kind, its arguments, the device it concerns and when it happened (dispatchCatalogue set no kind before). - Every transport publishes side by side under its own 10 s limit, so a gateway that hangs costs only itself. - notifications.CloudPublisher only queues. cloudlink.QueueEvent keeps up to 32 events; the writer signs each as an event with eventId, ts and seq, cuts opaque ids for the driver and loadpoint, and drops name arguments. An event older than ten minutes is dropped and counted in GET /api/cloud (eventsDropped). The grant's events scope gates it all. - Link.Bye sends bye{why:"update"} for a restart the box chose, and bye{why:"shutdown"} on SIGTERM, then stops dialling, so FTW Cloud holds back box.unreachable through an update. - charging.goal_at_risk: once per plug-in, from a fresh plan's shortfall (at least 1 kWh), only on a level that is not assumed and a ready time still ahead. Opt-in like the other charging rules. - charging.session_complete for a car that reports nothing: when the box's estimate from a level someone set reaches the target, in kWh, once, and not after a restart beside a car that is already done. - charging.connected waits 90 s and goes only when the level is assumed or no goal is set. Co-Authored-By: Claude Opus 5.5 --- .changeset/ftw-cloud-events.md | 5 + go/cmd/ftw/cloud_link.go | 14 ++ go/cmd/ftw/main.go | 28 +++ go/internal/cloudlink/conn.go | 2 +- go/internal/cloudlink/events.go | 196 ++++++++++++++++++ go/internal/cloudlink/events_test.go | 158 ++++++++++++++ go/internal/cloudlink/link.go | 26 ++- go/internal/cloudlink/sender.go | 12 +- go/internal/events/bus.go | 24 ++- go/internal/loadpoint/goal_risk.go | 59 ++++++ go/internal/loadpoint/goal_risk_test.go | 136 ++++++++++++ go/internal/loadpoint/loadpoint.go | 22 ++ go/internal/notifications/catalogue.go | 1 + go/internal/notifications/charging.go | 51 +++++ go/internal/notifications/cloud.go | 22 ++ .../notifications/cloud_events_test.go | 150 ++++++++++++++ go/internal/notifications/service.go | 98 ++++++--- 17 files changed, 964 insertions(+), 40 deletions(-) create mode 100644 .changeset/ftw-cloud-events.md create mode 100644 go/internal/cloudlink/events.go create mode 100644 go/internal/cloudlink/events_test.go create mode 100644 go/internal/loadpoint/goal_risk.go create mode 100644 go/internal/loadpoint/goal_risk_test.go create mode 100644 go/internal/notifications/charging.go create mode 100644 go/internal/notifications/cloud_events_test.go diff --git a/.changeset/ftw-cloud-events.md b/.changeset/ftw-cloud-events.md new file mode 100644 index 00000000..250b8348 --- /dev/null +++ b/.changeset/ftw-cloud-events.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +With FTW Cloud on and events in the grant, the box sends its notifications as signed events for the owners' phones; names never leave the house. A restart the box chooses, an update among them, tells FTW Cloud so it holds back "box out of reach". A new notification, "The car may not be ready", tells once per plug-in when the box knows the car's level and the plan cannot reach the goal by the ready time. A car that reports nothing itself gets "Car charged" in kWh once the box's estimate from a level someone set reaches the target. "Car plugged in" now waits 90 seconds and is sent only when the box needs the level or a goal. Each notification transport now has its own 10-second limit, so one that hangs no longer delays the others. diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 92120aab..065969b2 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -337,6 +337,20 @@ func startCloudLink( return link } +// cloudByeWait bounds how long shutdown waits for the bye to leave. +const cloudByeWait = 2 * time.Second + +// cloudEventQueue turns a rendered notification into an event for the +// uplink, which cuts the names out before it signs. +func cloudEventQueue(link *cloudlink.Link) func(notifications.Message) bool { + return func(m notifications.Message) bool { + return link.QueueEvent(cloudlink.EventIn{ + Kind: m.Kind, Priority: m.Priority, Args: m.Args, + Driver: m.Device, Loadpoint: m.LoadpointID, At: m.At, + }) + } +} + // cloudForAPI hands the link to the API, or nothing: a typed nil in an // interface would pass the routes' nil check and panic on first use. func cloudForAPI(link *cloudlink.Link) api.CloudGrant { diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 95a9686c..cb9e6160 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -68,6 +68,7 @@ import ( "github.com/srcfl/ftw/go/internal/selfupdate" "github.com/srcfl/ftw/go/internal/state" "github.com/srcfl/ftw/go/internal/telemetry" + "github.com/srcfl/ftw/go/protocol/cloudwire" ) // Version gets injected at build time via -ldflags. Defaults to "dev" for @@ -2657,6 +2658,20 @@ func main() { notifSvc.AddPublisher(webPush) deps.WebPush = webPush } + // FTW Cloud carries the catalogue's events to the owners' phones, + // signed, when the grant includes them. The publisher only queues. + if cloudLink != nil { + notifSvc.AddPublisher(notifications.CloudPublisher{Queue: cloudEventQueue(cloudLink)}) + } + notifSvc.SetChargingFacts(func(id string) (notifications.ChargingFacts, bool) { + lp, ok := lpMgr.State(id) + if !ok { + return notifications.ChargingFacts{}, false + } + return notifications.ChargingFacts{ + Plugged: lp.PluggedIn, LevelKnown: lp.PluggedIn && lp.SoCSource != "assumed", HasGoal: lp.TargetSoC > 0, + }, true + }) if appUplink != nil { deps.PushResync = appUplink.ResyncDeadman } @@ -2773,6 +2788,7 @@ func main() { select { case <-sigc: slog.Info("shutting down") + cloudLink.Bye(cloudwire.ByeShutdown, cloudByeWait) markNativeCleanExit(nativeRoot) flushHistoryOnStop(st) if err := st.RecordEvent("shutdown"); err != nil { @@ -2785,6 +2801,9 @@ func main() { } else { slog.Info("restart requested via API — exiting cleanly so the supervisor brings us back") } + // A restart the box chose, an update among them: FTW Cloud holds + // back "box out of reach" until the box is back. + cloudLink.Bye(cloudwire.ByeUpdate, cloudByeWait) markNativeCleanExit(nativeRoot) flushHistoryOnStop(st) if err := st.RecordEvent("restart"); err != nil { @@ -3195,6 +3214,15 @@ func main() { } } + // A car whose plan cannot make its goal: the loadpoint manager + // tells the household once per plug-in, from a plan that is + // neither being remade nor out of date. + if mpcSvc != nil { + if snap := mpcSvc.PlanSnapshot(); snap.Plan != nil && !snap.Pending && !snap.Outdated { + lpMgr.ObservePlanShortfall(snap.Plan.LoadpointShortfallWh, tickNow) + } + } + // First-vehicle-SoC replan trigger: as soon as any // DerVehicle driver is online and reporting SoC, redo the // plan once with measured-truth instead of the pluginSoC diff --git a/go/internal/cloudlink/conn.go b/go/internal/cloudlink/conn.go index 0d5bf20f..4559c59a 100644 --- a/go/internal/cloudlink/conn.go +++ b/go/internal/cloudlink/conn.go @@ -69,7 +69,7 @@ func (l *Link) once(ctx context.Context, snap grantSnapshot) (time.Duration, err l.attempt = 0 } l.mu.Unlock() - if errors.Is(err, errPurged) { + if errors.Is(err, errPurged) || errors.Is(err, errClosed) { return 0, nil } return l.backoff(), err diff --git a/go/internal/cloudlink/events.go b/go/internal/cloudlink/events.go new file mode 100644 index 00000000..3ad8cce2 --- /dev/null +++ b/go/internal/cloudlink/events.go @@ -0,0 +1,196 @@ +package cloudlink + +import ( + "encoding/base64" + "errors" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// Events and the last word. +// +// The box's notifications reach FTW Cloud as signed events, which the +// service wraps unchanged in an alert for the owners' phones. QueueEvent only +// queues; the writer signs and sends on its own goroutine, ahead of anything +// else due on that tick. An event waits at most cloudwire.AlertMaxAgeMs for +// the uplink: older, a phone would show it without its text and buttons, so +// it is dropped instead. A reconnect sends the events still waiting and +// replays none that left. + +// EventIn is one notification the box raised, before ids are cut. +type EventIn struct { + Kind string + // Priority is the rule's, 1 to 5. It becomes the event's severity. + Priority int + // Args are the catalogue's placeholders. Names are never sent: the + // link drops the name placeholders and sends the device's opaque id. + Args map[string]string + // Driver and Loadpoint are the box's own names for what the event + // concerns, empty when it concerns neither. + Driver string + Loadpoint string + At time.Time +} + +const ( + // eventRing is how many events wait for the uplink. When it is full + // the oldest goes, and the drop is counted. + eventRing = 32 + // eventIDBytes makes a 12-character event id. + eventIDBytes = 9 +) + +// nameArgs are catalogue placeholders that hold a name. The phone fills +// them from the table it holds from the box. +var nameArgs = map[string]bool{"name": true} + +// errClosed ends the uplink after its bye, as the box shuts down. +var errClosed = errors.New("cloudlink: the box is shutting down") + +// QueueEvent keeps an event for the uplink to sign and send. It never +// blocks. False means FTW Cloud will not carry it: off, being revoked, or a +// grant that leaves events out. +func (l *Link) QueueEvent(e EventIn) bool { + l.mu.Lock() + g := l.grant + if g == nil || g.Revoked || !g.has(protocol.ScopeEventsRead) || l.closing { + l.mu.Unlock() + return false + } + if e.At.IsZero() { + e.At = time.Now() + } + if len(l.events) >= eventRing { + l.events = l.events[1:] + l.eventsDropped++ + } + l.events = append(l.events, e) + l.mu.Unlock() + l.signal() + return true +} + +// sendEvents signs and sends what waits, oldest first. +func (w *writer) sendEvents(snap grantSnapshot, now time.Time) error { + l := w.link + if !snap.allowed(cloudwire.MsgEvent) { + return nil + } + l.mu.Lock() + queued := l.events + l.events = nil + l.mu.Unlock() + for i, e := range queued { + if now.Sub(e.At) > cloudwire.AlertMaxAgeMs*time.Millisecond { + l.mu.Lock() + l.eventsDropped++ + l.mu.Unlock() + continue + } + ev, err := l.projectEvent(snap, e) + if err != nil { + return err + } + if _, err := w.sendBody(snap, cloudwire.MsgEvent, &ev, true); err != nil { + // The one that failed may have left; the rest have not. + l.mu.Lock() + l.events = append(queued[i+1:len(queued):len(queued)], l.events...) + l.mu.Unlock() + return err + } + } + return nil +} + +// projectEvent cuts opaque ids and drops names. +func (l *Link) projectEvent(snap grantSnapshot, e EventIn) (cloudwire.Event, error) { + id, err := randomBytes(eventIDBytes) + if err != nil { + return cloudwire.Event{}, err + } + gk := snap.g.GrantKey + ev := cloudwire.Event{ + EventID: base64.RawURLEncoding.EncodeToString(id), Kind: e.Kind, + Severity: severity(e.Priority), Args: map[string]string{}, + } + for k, v := range e.Args { + if !nameArgs[k] { + ev.Args[k] = v + } + } + if e.Driver != "" { + ev.Src = cloudwire.SourceID(gk, e.Driver) + } + if e.Loadpoint != "" { + ev.LP = cloudwire.LoadpointID(gk, e.Loadpoint) + } + if l.clockSynced() { + ts := e.At.UnixMilli() + ev.Ts = &ts + } + return ev, nil +} + +func severity(priority int) string { + switch { + case priority >= 5: + return cloudwire.SeverityAlarm + case priority == 4: + return cloudwire.SeverityWarning + default: + return cloudwire.SeverityInfo + } +} + +// Bye tells the service why the box is going and stops the uplink for good. +// why is cloudwire.ByeUpdate for a restart the box chose, such as an update, +// which holds back box.unreachable for an hour; ByeShutdown otherwise. It +// waits at most wait for the bye to leave, and returns at once when the +// uplink is down. +func (l *Link) Bye(why string, wait time.Duration) { + if l == nil { + return + } + l.mu.Lock() + if l.closing { + l.mu.Unlock() + return + } + l.closing, l.byeWhy = true, why + done := make(chan struct{}) + l.byeDone = done + connected := l.connected + l.mu.Unlock() + l.signal() + if !connected { + return + } + select { + case <-done: + case <-time.After(wait): + } +} + +// closeIfAsked sends the bye Bye asked for and ends the connection. +func (w *writer) closeIfAsked(snap grantSnapshot) error { + l := w.link + l.mu.Lock() + closing, why, done := l.closing, l.byeWhy, l.byeDone + l.mu.Unlock() + if !closing { + return nil + } + err := w.bye(snap, why, false) + l.mu.Lock() + l.byeDone = nil + l.mu.Unlock() + if done != nil { + close(done) + } + if err != nil { + return err + } + return errClosed +} diff --git a/go/internal/cloudlink/events_test.go b/go/internal/cloudlink/events_test.go new file mode 100644 index 00000000..a91f36b4 --- /dev/null +++ b/go/internal/cloudlink/events_test.go @@ -0,0 +1,158 @@ +package cloudlink + +import ( + "bytes" + "testing" + "time" + + "github.com/srcfl/ftw/go/protocol" + "github.com/srcfl/ftw/go/protocol/cloudwire" +) + +// An event leaves signed, numbered and timed, with opaque ids for what it +// concerns and no name in it anywhere. +func TestAnEventLeavesSignedWithoutNames(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + + at := time.Now().Add(-3 * time.Second) + if !h.link.QueueEvent(EventIn{Kind: protocol.PushDriverOffline, Priority: 4, + Args: map[string]string{"name": chargerName}, Driver: chargerName, At: at}) { + t.Fatal("a grant with events refused an event") + } + if !h.link.QueueEvent(EventIn{Kind: protocol.PushChargingInterrupted, Priority: 4, Loadpoint: lpName}) { + t.Fatal("a grant with events refused an event") + } + first := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + second := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + + pub, err := cloudwire.ParsePublicKey(h.link.current().pk) + if err != nil { + t.Fatal(err) + } + var a, b cloudwire.Event + for _, x := range []struct { + env cloudwire.Envelope + out *cloudwire.Event + }{{first, &a}, {second, &b}} { + if err := cloudwire.Verify(pub, svc.home, x.env); err != nil { + t.Fatalf("event not signed by the box: %v", err) + } + if err := cloudwire.Unmarshal(x.env.B, x.out); err != nil { + t.Fatal(err) + } + } + gk := h.link.current().g.GrantKey + if a.Kind != protocol.PushDriverOffline || a.Severity != cloudwire.SeverityWarning || + a.Src != cloudwire.SourceID(gk, chargerName) || len(a.Args) != 0 || a.LP != "" { + t.Fatalf("driver.offline went as %+v", a) + } + if a.Ts == nil || *a.Ts != at.UnixMilli() || a.WallMs == nil || len(a.EventID) != 12 { + t.Fatalf("driver.offline has no time or id: %+v", a) + } + if b.Kind != protocol.PushChargingInterrupted || b.LP != cloudwire.LoadpointID(gk, lpName) || + b.Seq <= a.Seq || b.EventID == a.EventID { + t.Fatalf("charging.interrupted went as %+v after %+v", b, a) + } + for _, raw := range [][]byte{first.B, second.B} { + if bytes.Contains(raw, []byte(chargerName)) || bytes.Contains(raw, []byte(lpName)) { + t.Fatalf("a name left the house in %x", raw) + } + } +} + +// Events are their own scope: a grant without it sends none. +func TestAGrantWithoutEventsQueuesNothing(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued with FTW Cloud off") + } + h.grant(t, protocol.ScopeLiveRead) + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued under a grant that leaves events out") + } +} + +// An event that waited longer than a phone would act on it is dropped and +// counted, not sent late. +func TestAnEventTooOldForThePhoneIsDropped(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected, At: time.Now().Add(-11 * time.Minute)}) + h.link.QueueEvent(EventIn{Kind: protocol.PushChargingInterrupted}) + env := svc.waitFor(cloudwire.MsgEvent, 5*time.Second) + var e cloudwire.Event + if err := cloudwire.Unmarshal(env.B, &e); err != nil { + t.Fatal(err) + } + if e.Kind != protocol.PushChargingInterrupted { + t.Fatalf("sent %s, an event eleven minutes old", e.Kind) + } + if st := h.link.Status(); st.EventsDropped != 1 { + t.Fatalf("dropped %d", st.EventsDropped) + } +} + +// The queue holds the newest events and counts what it pushed out. Here the +// service fails the binding, so nothing drains the queue. +func TestTheEventQueueKeepsTheNewest(t *testing.T) { + svc := newFakeService(t) + svc.badEKM = true + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead, protocol.ScopeEventsRead) + for range eventRing + 3 { + if !h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("refused") + } + } + h.link.mu.Lock() + n := len(h.link.events) + h.link.mu.Unlock() + if st := h.link.Status(); n != eventRing || st.EventsDropped != 3 { + t.Fatalf("queue %d, dropped %d", n, st.EventsDropped) + } +} + +// A restart the box chose says so, and the link stops dialling. +func TestByeForAnUpdateLeavesAndTheLinkStops(t *testing.T) { + svc := newFakeService(t) + h := newHarness(t, svc) + h.grant(t, protocol.ScopeLiveRead) + svc.waitFor(cloudwire.MsgLive, 5*time.Second) + for !h.link.Status().Connected { + time.Sleep(10 * time.Millisecond) + } + + h.link.Bye(cloudwire.ByeUpdate, 2*time.Second) + env := svc.waitFor(cloudwire.MsgBye, time.Second) + var b cloudwire.Bye + if err := cloudwire.Unmarshal(env.B, &b); err != nil { + t.Fatal(err) + } + if b.Why != cloudwire.ByeUpdate || b.Purge { + t.Fatalf("bye %+v", b) + } + select { + case err := <-h.done: + if err != nil { + t.Fatalf("Run ended with %v", err) + } + h.done <- nil // for the cleanup + case <-time.After(3 * time.Second): + t.Fatal("the link kept running after its bye") + } + if h.link.QueueEvent(EventIn{Kind: protocol.PushChargingConnected}) { + t.Fatal("an event was queued after the bye") + } + svc.mu.Lock() + conns := svc.conns + svc.mu.Unlock() + if conns != 1 { + t.Fatalf("the box dialled %d times", conns) + } +} diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 7f4f1582..3b15fe92 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -160,6 +160,15 @@ type Link struct { attempt int purgedAt int64 rosterDue bool + + // events wait for the writer; eventsDropped counts those that never + // left. closing is set by Bye, after which the link does not dial + // again. + events []EventIn + eventsDropped int64 + closing bool + byeWhy string + byeDone chan struct{} } // New loads the grant and the key and returns a link that runs once Run is @@ -461,6 +470,10 @@ type Status struct { BudgetReached bool `json:"budgetReached"` // PurgedAtMs is the time on the service's receipt for the last deletion. PurgedAtMs int64 `json:"purgedAtMs,omitempty"` + // EventsDropped counts notifications that never reached the service: + // the queue was full, or the uplink was down for longer than a phone + // would still act on them. + EventsDropped int64 `json:"eventsDropped"` } // Status reports the grant and the link. @@ -470,7 +483,7 @@ func (l *Link) Status() Status { s := Status{ State: "off", Notice: l.notice, Available: len(l.opts.ServiceKeys) > 0, Scopes: []string{}, Purposes: []string{}, Connected: l.connected, - LastSendAgeMs: -1, PurgedAtMs: l.purgedAt, + LastSendAgeMs: -1, PurgedAtMs: l.purgedAt, EventsDropped: l.eventsDropped, BytesLast24h: l.budget.total(l.uptimeMs()), BudgetBytes: budgetBytes, } s.BudgetReached = s.BytesLast24h >= budgetBytes @@ -517,6 +530,9 @@ func (l *Link) current() grantSnapshot { // Run keeps the uplink up while a grant is on, until ctx ends. func (l *Link) Run(ctx context.Context) error { for { + if l.isClosing() { + return nil + } snap := l.current() if snap.g == nil || len(l.opts.ServiceKeys) == 0 { select { @@ -543,6 +559,12 @@ func (l *Link) Run(ctx context.Context) error { } } +func (l *Link) isClosing() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.closing +} + // allowed reports whether the grant lets a class leave the house. func (snap grantSnapshot) allowed(class string) bool { scope, forwarded := cloudwire.ClassScope[class] @@ -555,7 +577,7 @@ func (snap grantSnapshot) allowed(class string) bool { // caps is the classes this box sends under the grant. func (snap grantSnapshot) caps() []string { var out []string - for _, c := range []string{cloudwire.MsgLive, cloudwire.MsgLP, cloudwire.MsgPrices, cloudwire.MsgHB} { + for _, c := range []string{cloudwire.MsgLive, cloudwire.MsgLP, cloudwire.MsgPrices, cloudwire.MsgHB, cloudwire.MsgEvent} { if snap.allowed(c) { out = append(out, c) } diff --git a/go/internal/cloudlink/sender.go b/go/internal/cloudlink/sender.go index 04d528a7..499e735a 100644 --- a/go/internal/cloudlink/sender.go +++ b/go/internal/cloudlink/sender.go @@ -120,8 +120,16 @@ func (w *writer) run(ctx context.Context, events <-chan cloudwire.Envelope) erro } else if now.Sub(purgeSince) > purgeWait { return errors.New("cloudlink: no purge receipt yet") } - } else if err := w.tick(snap, st, now); err != nil { - return err + } else { + if err := w.closeIfAsked(snap); err != nil { + return err + } + if err := w.sendEvents(snap, now); err != nil { + return err + } + if err := w.tick(snap, st, now); err != nil { + return err + } } select { diff --git a/go/internal/events/bus.go b/go/internal/events/bus.go index 27072c48..fceaad48 100644 --- a/go/internal/events/bus.go +++ b/go/internal/events/bus.go @@ -71,6 +71,7 @@ const ( KindChargingConnected = "charging.connected" KindChargingSessionComplete = "charging.session_complete" KindChargingInterrupted = "charging.interrupted" + KindChargingGoalAtRisk = "charging.goal_at_risk" ) // HealthTick is fired every control-loop tick with the current telemetry @@ -156,9 +157,11 @@ type UpdateInstalled struct { func (UpdateInstalled) Kind() string { return KindUpdateInstalled } -// ChargingSessionComplete is emitted once per session when a fresh, -// matched vehicle BMS reading confirms that the active target was reached. -// A charger refusing current does not establish battery state. +// ChargingSessionComplete is emitted once per session when the active target +// was reached: by a fresh, matched vehicle BMS reading, or, for a car that +// reports nothing itself, by the box's estimate from a level someone set. +// The estimate is energy the box measured, so subscribers say it in kWh. A +// charger refusing current does not establish battery state. type ChargingSessionComplete struct { LoadpointID string KWh float64 // what the session meter delivered @@ -185,3 +188,18 @@ type ChargingConnected struct { } func (ChargingConnected) Kind() string { return KindChargingConnected } + +// ChargingGoalAtRisk is emitted once per plug-in when the plan cannot bring +// the car to its target by the ready time, and the box knows the car's +// level rather than assuming it. +type ChargingGoalAtRisk struct { + LoadpointID string + TargetSoC float64 // fraction + By time.Time + // ByClock is By as a household reads it, "07:00" in the box's zone. + ByClock string + ShortfallWh float64 + At time.Time +} + +func (ChargingGoalAtRisk) Kind() string { return KindChargingGoalAtRisk } diff --git a/go/internal/loadpoint/goal_risk.go b/go/internal/loadpoint/goal_risk.go new file mode 100644 index 00000000..af76420c --- /dev/null +++ b/go/internal/loadpoint/goal_risk.go @@ -0,0 +1,59 @@ +package loadpoint + +import ( + "time" + + "github.com/srcfl/ftw/go/internal/events" +) + +const ( + // GoalRiskMinWh is the smallest shortfall worth telling anyone about. + // Below it the plan is close enough that a rounding of the estimate or + // a slightly cheaper slot changes the answer. + GoalRiskMinWh = 1000 + // minCompletionWh keeps "car charged" for sessions that charged. + minCompletionWh = 100 +) + +// ObservePlanShortfall takes a fresh plan's shortfall per loadpoint, in +// battery watt-hours, and raises charging.goal_at_risk once per plug-in for +// a car that will not reach its target by its ready time. Only when the box +// knows the car's level, from the car or from someone who set it: on an +// assumed level the shortfall is a guess, and the plug-in notification +// already asks for the level. The caller passes only plans that are neither +// being remade nor out of date. +func (m *Manager) ObservePlanShortfall(shortfallWh map[string]float64, now time.Time) { + if m == nil || len(shortfallWh) == 0 { + return + } + var fired []events.Event + m.mu.Lock() + bus := m.bus + for _, id := range m.order { + lp, ok := m.byID[id] + if !ok || !lp.pluggedIn || lp.goalRiskNotified || !lp.socConfirmed || lp.completionNotified || + lp.targetSoC <= 0 || lp.targetTime.IsZero() || !lp.targetTime.After(now) { + continue + } + wh := shortfallWh[id] + if wh < GoalRiskMinWh { + continue + } + lp.goalRiskNotified = true + by := lp.targetTime.Local() + if m.loc != nil { + by = lp.targetTime.In(m.loc) + } + fired = append(fired, events.ChargingGoalAtRisk{ + LoadpointID: id, TargetSoC: lp.targetSoC, By: lp.targetTime, ByClock: by.Format("15:04"), + ShortfallWh: wh, At: now, + }) + } + m.mu.Unlock() + if bus == nil { + return + } + for _, e := range fired { + bus.Publish(e) + } +} diff --git a/go/internal/loadpoint/goal_risk_test.go b/go/internal/loadpoint/goal_risk_test.go new file mode 100644 index 00000000..6a99541f --- /dev/null +++ b/go/internal/loadpoint/goal_risk_test.go @@ -0,0 +1,136 @@ +package loadpoint + +import ( + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/events" +) + +func goalRiskLog(bus *events.Bus) func() []events.ChargingGoalAtRisk { + var mu sync.Mutex + var got []events.ChargingGoalAtRisk + bus.Subscribe(events.KindChargingGoalAtRisk, func(e events.Event) { + if ev, ok := e.(events.ChargingGoalAtRisk); ok { + mu.Lock() + got = append(got, ev) + mu.Unlock() + } + }) + return func() []events.ChargingGoalAtRisk { + mu.Lock() + defer mu.Unlock() + return append([]events.ChargingGoalAtRisk(nil), got...) + } +} + +// A car that reports nothing is done when the box's estimate, from a level +// someone set, reaches the target. Once, in kWh. +func TestACarWithoutItsOwnDataIsDoneAtTheBoxsEstimate(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.mgr.SetCurrentSoC("garage", 0.5) + r.tick(time.Hour, true, 11_000, 10_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatal("done before the estimate reached the target") + } + need := 0.3 * 60_000 / DefaultChargeEfficiency + r.tick(time.Hour, true, 11_000, need+50, true) + if c, _ := r.log.counts(); c != 1 { + t.Fatalf("complete events = %d, want 1", c) + } + if got := r.log.complete[0]; got.KWh < need/1000 || got.LoadpointID != "garage" { + t.Fatalf("event %+v", got) + } + for range 5 { + r.tick(10*time.Minute, true, 0, need+50, false) + } + if c, _ := r.log.counts(); c != 1 { + t.Fatalf("complete events = %d after hours, want 1", c) + } +} + +// On an assumed level the estimate is a guess, so nothing claims the car is +// done. +func TestAnAssumedLevelNeverSaysDone(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.tick(5*time.Hour, true, 11_000, 60_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d on an assumed level", c) + } +} + +// A car that reports its own level is done by its own reading, not by the +// box's estimate. +func TestACarWithItsOwnDataIsDoneOnlyByItsReading(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 11_000, 0, true) + r.mgr.AnchorVehicleSoC("garage", 0.5) + r.tick(5*time.Hour, true, 11_000, 30_000, true) + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d from an estimate for a car that reports", c) + } +} + +// After a restart beside a car that is already done, nothing is said: it +// has not drawn power since the box started. +func TestARestartBesideAFinishedCarSaysNothing(t *testing.T) { + r := newSessionRig(t) + r.mgr.SetTarget("garage", 0.8, time.Time{}) + r.tick(0, true, 0, 25_000, false) + r.mgr.SetCurrentSoC("garage", 0.85) + for range 5 { + r.tick(time.Minute, true, 0, 25_000, false) + } + if c, _ := r.log.counts(); c != 0 { + t.Fatalf("complete events = %d after a restart", c) + } +} + +// The plan's shortfall tells the household once per plug-in, and only when +// the level is known, the goal has a time ahead, and the gap matters. +func TestGoalAtRiskOncePerPlugInOnAKnownLevel(t *testing.T) { + r := newSessionRig(t) + got := goalRiskLog(r.mgr.bus) + by := r.now.Add(8 * time.Hour) + r.mgr.SetTarget("garage", 0.8, by) + r.tick(0, true, 0, 0, true) + + short := map[string]float64{"garage": 9_000} + r.mgr.ObservePlanShortfall(short, r.now) + if len(got()) != 0 { + t.Fatal("goal at risk on an assumed level") + } + r.mgr.SetCurrentSoC("garage", 0.2) + r.mgr.ObservePlanShortfall(map[string]float64{"garage": GoalRiskMinWh - 1}, r.now) + if len(got()) != 0 { + t.Fatal("goal at risk for a gap under the threshold") + } + r.mgr.ObservePlanShortfall(short, r.now) + r.mgr.ObservePlanShortfall(short, r.now.Add(time.Minute)) + evs := got() + if len(evs) != 1 { + t.Fatalf("goal at risk %d times, want once", len(evs)) + } + if e := evs[0]; e.LoadpointID != "garage" || e.TargetSoC != 0.8 || !e.By.Equal(by) || e.ShortfallWh != 9_000 { + t.Fatalf("event %+v", e) + } + + // A new plug-in may speak again, but not about a ready time that passed. + r.tick(time.Minute, false, 0, 0, false) + r.tick(time.Minute, true, 0, 0, true) + r.mgr.SetCurrentSoC("garage", 0.2) + r.mgr.ObservePlanShortfall(short, by.Add(time.Minute)) + if len(got()) != 1 { + t.Fatal("goal at risk for a ready time already gone") + } + r.mgr.ObservePlanShortfall(short, r.now) + if len(got()) != 2 { + t.Fatal("the next plug-in was not told") + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index ce4dcfa4..53034836 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -363,6 +363,13 @@ type loadpointRuntime struct { sessionID string socRetention string completionNotified bool + // goalRiskNotified latches charging.goal_at_risk once per plug-in. + // vehicleAnchored is true once the car itself reported a level this + // session; chargedSinceStart once it drew power in this process, so a + // restart beside a car that is already done says nothing. + goalRiskNotified bool + vehicleAnchored bool + chargedSinceStart bool Config pluggedIn bool @@ -771,6 +778,9 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.sessionPluginSoC = anchor lp.socConfirmed = false lp.completionNotified = false + lp.goalRiskNotified = false + lp.vehicleAnchored = false + lp.chargedSinceStart = false lp.deliveredHistory = nil lp.notRequestingSince = time.Time{} lp.chargingDeclined = false @@ -878,6 +888,17 @@ func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64 if pluggedIn { lp.currentSoC = estimateSoC(lp.sessionPluginSoC, deliveredWh, lp.VehicleCapacityWh) + if powerW >= DeliveringW { + lp.chargedSinceStart = true + } + // A car that reports nothing itself is done when the box's own + // estimate, from a level someone set, reaches the target. Said in + // kWh, which the box measured; the level is an estimate. + if !lp.completionNotified && !lp.vehicleAnchored && lp.socConfirmed && lp.chargedSinceStart && + lp.targetSoC > 0 && lp.currentSoC >= lp.targetSoC && deliveredWh >= minCompletionWh { + lp.completionNotified = true + fired = append(fired, events.ChargingSessionComplete{LoadpointID: id, KWh: deliveredWh / 1000, At: now}) + } } else { lp.currentSoC = 0 } @@ -1140,6 +1161,7 @@ func (m *Manager) AnchorVehicleSoCAt(id string, socPct float64, observedAt time. } } reanchorSoCAtLocked(lp, socPct, deliveredAt, m.now()) + lp.vehicleAnchored = true if lp.targetSoC > 0 && lp.currentSoC >= lp.targetSoC && !lp.completionNotified { lp.completionNotified = true completion = &events.ChargingSessionComplete{LoadpointID: id, KWh: lp.deliveredWhSession / 1000, At: m.now()} diff --git a/go/internal/notifications/catalogue.go b/go/internal/notifications/catalogue.go index 52a38c12..868ca275 100644 --- a/go/internal/notifications/catalogue.go +++ b/go/internal/notifications/catalogue.go @@ -14,6 +14,7 @@ const ( PushChargingConnected = protocol.PushChargingConnected PushChargingSessionComplete = protocol.PushChargingSessionComplete PushChargingInterrupted = protocol.PushChargingInterrupted + PushChargingGoalAtRisk = protocol.PushChargingGoalAtRisk PushUpdateInstalled = protocol.PushUpdateInstalled PushDriverOffline = protocol.PushDriverOffline PushFuseOverLimit = protocol.PushFuseOverLimit diff --git a/go/internal/notifications/charging.go b/go/internal/notifications/charging.go new file mode 100644 index 00000000..4ca51b79 --- /dev/null +++ b/go/internal/notifications/charging.go @@ -0,0 +1,51 @@ +package notifications + +import "time" + +// ConnectedSettle is how long after a plug-in the box decides whether to +// tell anyone. A car that reports its own level usually does so within it, +// and a car whose level and goal are both known needs no question. +const ConnectedSettle = 90 * time.Second + +// ChargingFacts is what the plug-in rule needs to know about a loadpoint. +type ChargingFacts struct { + Plugged bool + // LevelKnown is true when the car's level came from the car or from + // someone who set it, not from the plug-in assumption. + LevelKnown bool + // HasGoal is true when a target level is set. + HasGoal bool +} + +// SetChargingFacts wires what the plug-in rule reads. Nil tells every +// plug-in, as before. +func (s *Service) SetChargingFacts(f func(loadpointID string) (ChargingFacts, bool)) { + if s == nil { + return + } + s.mu.Lock() + s.charging = f + s.mu.Unlock() +} + +// handleConnected tells a plug-in only when the box needs the household: +// the car's level is an assumption, or no goal is set. It decides after +// ConnectedSettle, and says nothing if the cable came out meanwhile. +func (s *Service) handleConnected(loadpointID string) { + if s == nil { + return + } + s.mu.Lock() + facts, settle := s.charging, s.connectedSettle + s.mu.Unlock() + if facts != nil { + if settle > 0 { + time.Sleep(settle) + } + f, ok := facts(loadpointID) + if !ok || !f.Plugged || (f.LevelKnown && f.HasGoal) { + return + } + } + s.handleCatalogued(PushChargingConnected, loadpointID, nil) +} diff --git a/go/internal/notifications/cloud.go b/go/internal/notifications/cloud.go index f2e62af1..e5a4e014 100644 --- a/go/internal/notifications/cloud.go +++ b/go/internal/notifications/cloud.go @@ -1,5 +1,7 @@ package notifications +import "context" + // NotifyCloudGrant tells every owner that FTW Cloud was turned on or off, // with the catalogue's sentence for kind. The rule gates it like any other, // but a box whose stored rules predate these kinds still sends it: a missing @@ -34,3 +36,23 @@ func (s *Service) NotifyCloudGrant(kind string) { } s.deliver(kind, "", Message{Kind: kind, Title: title, Body: body, Priority: prio, Tags: splitTags(rule.Tags)}) } + +// CloudPublisher hands each catalogue-worded message to FTW Cloud's uplink, +// which signs it and sends it as an event for the owners' phones. It only +// queues: the uplink sends on its own goroutine, so a slow or absent service +// never holds up another transport. Template rules carry no kind and stay on +// the box's own transports. +type CloudPublisher struct { + // Queue takes one message and reports whether FTW Cloud will carry it. + // False means FTW Cloud is off or its grant leaves events out. + Queue func(Message) bool +} + +// Publish queues m, or answers ErrNothingToSend when FTW Cloud will not +// carry it. +func (p CloudPublisher) Publish(_ context.Context, m Message) error { + if m.Kind == "" || p.Queue == nil || !p.Queue(m) { + return ErrNothingToSend + } + return nil +} diff --git a/go/internal/notifications/cloud_events_test.go b/go/internal/notifications/cloud_events_test.go new file mode 100644 index 00000000..2bf9a1ed --- /dev/null +++ b/go/internal/notifications/cloud_events_test.go @@ -0,0 +1,150 @@ +package notifications + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/events" +) + +// K5: a catalogue sentence carries its kind and its arguments, so a +// transport whose phones write their own words, as FTW Cloud's do, gets the +// facts and not only the English. +func TestACatalogueMessageCarriesItsKindAndArgs(t *testing.T) { + pub := &fakePub{} + svc, clk := newSvc(pushCfg(config.NotificationRule{ + Type: PushDriverOffline, Enabled: true, ThresholdS: 600, Priority: 4, CooldownS: 3600, + }), pub) + last := clk.now() + clk.advance(800 * time.Second) + svc.Observe(healthStale(last)) + msgs := pub.Messages() + if len(msgs) != 1 { + t.Fatalf("got %d msgs", len(msgs)) + } + m := msgs[0] + if m.Kind != PushDriverOffline || m.Args["name"] != "ferroamp" || m.Device != "ferroamp" || m.At.IsZero() { + t.Fatalf("message %+v", m) + } +} + +// hangingPub never answers until its context ends. +type hangingPub struct{ entered chan struct{} } + +func (h hangingPub) Publish(ctx context.Context, _ Message) error { + close(h.entered) + <-ctx.Done() + return ctx.Err() +} + +// Each transport has its own time: one that hangs does not hold up another. +func TestAHangingTransportDoesNotDelayAnother(t *testing.T) { + quick := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{ + Type: PushChargingInterrupted, Enabled: true, Priority: 4, + }), hangingPub{entered: make(chan struct{})}) + svc.AddPublisher(quick) + bus := events.NewBus() + svc.Subscribe(bus) + + start := time.Now() + bus.Publish(events.ChargingInterrupted{LoadpointID: "garage", At: start}) + select { + case <-quick.published: + case <-time.After(2 * time.Second): + t.Fatal("a hanging transport held up the other") + } + if waited := time.Since(start); waited > time.Second { + t.Fatalf("the quick transport waited %s", waited) + } +} + +// The FTW Cloud transport only queues, and only messages with a kind. +func TestCloudPublisherQueuesOnlyCatalogueKinds(t *testing.T) { + var mu sync.Mutex + var queued []Message + on := true + p := CloudPublisher{Queue: func(m Message) bool { + mu.Lock() + defer mu.Unlock() + queued = append(queued, m) + return on + }} + if err := p.Publish(context.Background(), Message{Title: "FTW: test"}); err != ErrNothingToSend { + t.Fatalf("a template message went to FTW Cloud: %v", err) + } + if err := p.Publish(context.Background(), Message{Kind: PushChargingConnected}); err != nil { + t.Fatal(err) + } + on = false + if err := p.Publish(context.Background(), Message{Kind: PushChargingConnected}); err != ErrNothingToSend { + t.Fatalf("FTW Cloud off answered %v", err) + } + if len(queued) != 2 { + t.Fatalf("queued %d", len(queued)) + } + if err := (CloudPublisher{}).Publish(context.Background(), Message{Kind: PushChargingConnected}); err != ErrNothingToSend { + t.Fatalf("no queue answered %v", err) + } +} + +// The plug-in notification asks only when the box needs the household: the +// level is assumed or no goal is set. It decides after a pause, and says +// nothing about a cable already out. +func TestPlugInAsksOnlyWhenTheBoxNeedsTheHousehold(t *testing.T) { + cases := []struct { + name string + facts ChargingFacts + ok bool + want int + }{ + {"assumed level, goal set", ChargingFacts{Plugged: true, HasGoal: true}, true, 1}, + {"known level, no goal", ChargingFacts{Plugged: true, LevelKnown: true}, true, 1}, + {"known level and goal", ChargingFacts{Plugged: true, LevelKnown: true, HasGoal: true}, true, 0}, + {"unplugged meanwhile", ChargingFacts{}, true, 0}, + {"unknown loadpoint", ChargingFacts{}, false, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + pub := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{Type: PushChargingConnected, Enabled: true}), pub) + svc.connectedSettle = 0 + svc.SetChargingFacts(func(id string) (ChargingFacts, bool) { + if id != "garage" { + t.Errorf("asked about %q", id) + } + return c.facts, c.ok + }) + bus := events.NewBus() + svc.Subscribe(bus) + bus.Publish(events.ChargingConnected{LoadpointID: "garage", At: time.Now()}) + if c.want == 1 { + waitForMsgs(t, pub, 1) + return + } + settle(svc) + if n := len(pub.Messages()); n != 0 { + t.Fatalf("sent %d", n) + } + }) + } +} + +// Goal at risk says the target and the ready time. +func TestGoalAtRiskSaysTargetAndTime(t *testing.T) { + pub := &fakePub{published: make(chan struct{}, 1)} + svc, _ := newSvc(pushCfg(config.NotificationRule{Type: PushChargingGoalAtRisk, Enabled: true, Priority: 4}), pub) + bus := events.NewBus() + svc.Subscribe(bus) + bus.Publish(events.ChargingGoalAtRisk{LoadpointID: "garage", TargetSoC: 0.8, ByClock: "07:00", At: time.Now()}) + m := waitForMsgs(t, pub, 1)[0] + if m.Kind != PushChargingGoalAtRisk || m.LoadpointID != "garage" || m.Args["target"] != "80" || m.Args["by"] != "07:00" { + t.Fatalf("message %+v", m) + } + if m.Title != "The car may not be ready" || m.Body != "It will not reach 80 % by 07:00 at this pace." { + t.Fatalf("words %q / %q", m.Title, m.Body) + } +} diff --git a/go/internal/notifications/service.go b/go/internal/notifications/service.go index e481fe4b..bc63b590 100644 --- a/go/internal/notifications/service.go +++ b/go/internal/notifications/service.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "log/slog" + "math" "sort" "strconv" "strings" @@ -97,6 +98,9 @@ func DefaultRules() []config.NotificationRule { {Type: PushChargingConnected, Enabled: false, Priority: 3, CooldownS: 60}, {Type: PushChargingSessionComplete, Enabled: false, Priority: 3}, {Type: PushChargingInterrupted, Enabled: false, Priority: 4, CooldownS: 3600}, + // Once per plug-in, only when the box knows the car's level and the + // plan cannot make the goal: rare, and the moment to act. + {Type: PushChargingGoalAtRisk, Enabled: false, Priority: 4}, {Type: PushUpdateInstalled, Enabled: false, Priority: 2}, // Phone lock-screen counterparts of the operator rules above. // Same thresholds so a blip or a kettle is not a notification; @@ -122,7 +126,7 @@ func KnownRuleTypes() []string { EventDriverOffline, EventDriverRecovered, EventUpdateAvailable, EventFuseOverLimit, EventConcurrentDriversOffline, PushChargingConnected, PushChargingSessionComplete, PushChargingInterrupted, - PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, + PushChargingGoalAtRisk, PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, PushCloudGranted, PushCloudRevoked, } } @@ -145,6 +149,14 @@ type Message struct { Body string Priority int Tags []string + // Args are the catalogue's placeholders, for a transport whose phones + // write their own words, as FTW Cloud's do. Nil for template rules. + Args map[string]string + // Device is the driver the event concerns, for a transport that names + // it some other way than by its name. + Device string + // At is when the event happened. + At time.Time } // Publisher dispatches a rendered Message to its transport. @@ -261,6 +273,10 @@ type Service struct { sent uint64 failed uint64 now func() time.Time + // charging answers what the plug-in rule needs to know about a + // loadpoint; connectedSettle is how long it waits before asking. + charging func(loadpointID string) (ChargingFacts, bool) + connectedSettle time.Duration } // New constructs a Service. cfg may be nil (no-op until Reload is called). @@ -274,6 +290,7 @@ func New(cfg *config.Notifications, pub Publisher, lookup DeviceLookup) *Service activeAlert: map[string]bool{}, fuseFirstOverAt: map[string]time.Time{}, now: time.Now, + connectedSettle: ConnectedSettle, } } @@ -398,7 +415,15 @@ func (s *Service) Subscribe(bus *events.Bus) { // cooldown. Words come from the catalogue and nowhere else. bus.Subscribe(events.KindChargingConnected, func(e events.Event) { if ev, ok := e.(events.ChargingConnected); ok { - go s.handleCatalogued(PushChargingConnected, ev.LoadpointID, nil) + go s.handleConnected(ev.LoadpointID) + } + }) + bus.Subscribe(events.KindChargingGoalAtRisk, func(e events.Event) { + if ev, ok := e.(events.ChargingGoalAtRisk); ok { + go s.handleCatalogued(PushChargingGoalAtRisk, ev.LoadpointID, map[string]string{ + "target": strconv.Itoa(int(math.Round(ev.TargetSoC * 100))), + "by": ev.ByClock, + }) } }) bus.Subscribe(events.KindChargingSessionComplete, func(e events.Event) { @@ -477,6 +502,8 @@ func (s *Service) handleCatalogued(kind, device string, args map[string]string) Body: body, Priority: prio, Tags: splitTags(rule.Tags), + Args: args, + At: now, }) } @@ -874,19 +901,7 @@ func (s *Service) SendTest() error { Body: fmt.Sprintf("Test notification sent at %s.", time.Now().UTC().Format(time.RFC3339)), Priority: prio, } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - var sent int - var errs []error - for _, pub := range pubs { - switch err := pub.Publish(ctx, msg); { - case err == nil: - sent++ - case errors.Is(err, ErrNothingToSend): - default: - errs = append(errs, err) - } - } + sent, errs := publishAll(pubs, msg) var err error if sent == 0 { if len(errs) > 0 { @@ -953,7 +968,8 @@ func (s *Service) buildData(driver, eventType string, since time.Duration, now t } func (s *Service) dispatchCatalogue(cfg *config.Notifications, rule config.NotificationRule, data templateData) { - title, body, err := RenderPush(rule.Type, catalogueArgs(rule.Type, data)) + args := catalogueArgs(rule.Type, data) + title, body, err := RenderPush(rule.Type, args) if err != nil { slog.Warn("notifications: catalogue render failed", "event", rule.Type, "err", err) s.bumpFailed() @@ -965,10 +981,14 @@ func (s *Service) dispatchCatalogue(cfg *config.Notifications, rule config.Notif prio = cfg.DefaultPriority } s.deliver(rule.Type, data.Device, Message{ + Kind: rule.Type, Title: title, Body: body, Priority: prio, Tags: splitTags(rule.Tags), + Args: args, + Device: data.Device, + At: s.now(), }) } @@ -1040,22 +1060,7 @@ func (s *Service) dispatch(cfg *config.Notifications, rule config.NotificationRu // enabled with no provider installed — and stays a truthful failure in the // history rather than a panic or a silence. func (s *Service) deliver(eventType, device string, msg Message) { - pubs := s.publishers() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - var sent int - var errs []error - for _, pub := range pubs { - switch err := pub.Publish(ctx, msg); { - case err == nil: - sent++ - case errors.Is(err, ErrNothingToSend): - // Absence, not failure. - default: - errs = append(errs, err) - } - } + sent, errs := publishAll(s.publishers(), msg) switch { case sent > 0: if len(errs) > 0 { @@ -1076,6 +1081,35 @@ func (s *Service) deliver(eventType, device string, msg Message) { } } +// PublishTimeout bounds one transport's Publish. Each transport has its own, +// and they run side by side, so a gateway that hangs costs only itself. +const PublishTimeout = 10 * time.Second + +// publishAll hands msg to every transport at once, each under its own +// timeout, and counts the outcome. ErrNothingToSend is absence, not failure. +func publishAll(pubs []Publisher, msg Message) (sent int, errs []error) { + results := make([]error, len(pubs)) + var wg sync.WaitGroup + for i, pub := range pubs { + wg.Go(func() { + ctx, cancel := context.WithTimeout(context.Background(), PublishTimeout) + defer cancel() + results[i] = pub.Publish(ctx, msg) + }) + } + wg.Wait() + for _, err := range results { + switch { + case err == nil: + sent++ + case errors.Is(err, ErrNothingToSend): + default: + errs = append(errs, err) + } + } + return sent, errs +} + // emitDispatched publishes a NotificationDispatched event for history // loggers + future audit subscribers. Safe when the bus is nil. func (s *Service) emitDispatched(eventType, driver string, m Message, status, errStr string) { From fe359327050dafe3a301575a485358a53190217c Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 13 Oct 2026 08:17:39 +0200 Subject: [PATCH 3/3] test(sim-ocpp): a charger that stalls with the car still asking POST /charger/{id}/stall makes the simulated charger deliver nothing while it still reports Charging, as a tripped relay does; /resume lets it go on. It is what charging.interrupted is for, and the FTW Cloud C4 end-to-end test raises one with it. Co-Authored-By: Claude Opus 5.5 --- go/cmd/sim-ocpp/main.go | 11 +++++++++++ go/cmd/sim-ocpp/ocppcp/physics.go | 5 ++++- go/cmd/sim-ocpp/ocppcp/sim.go | 7 +++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/go/cmd/sim-ocpp/main.go b/go/cmd/sim-ocpp/main.go index bef67bd4..92a1b46c 100644 --- a/go/cmd/sim-ocpp/main.go +++ b/go/cmd/sim-ocpp/main.go @@ -121,6 +121,17 @@ func main() { } w.WriteHeader(204) }) + for path, stalled := range map[string]bool{"stall": true, "resume": false} { + mux.HandleFunc("POST /charger/{id}/"+path, func(w http.ResponseWriter, r *http.Request) { + sim := findSim(sims, r.PathValue("id")) + if sim == nil { + http.NotFound(w, r) + return + } + sim.Stall(stalled) + w.WriteHeader(204) + }) + } go func() { slog.Info("control listening", "addr", *control) if err := http.ListenAndServe(*control, mux); err != nil { diff --git a/go/cmd/sim-ocpp/ocppcp/physics.go b/go/cmd/sim-ocpp/ocppcp/physics.go index 40377afa..badd76fb 100644 --- a/go/cmd/sim-ocpp/ocppcp/physics.go +++ b/go/cmd/sim-ocpp/ocppcp/physics.go @@ -19,6 +19,9 @@ type Physics struct { DrawA float64 EnergyWh float64 Plugged bool + // Stalled is a charger that stops delivering while the car still wants + // energy, as a tripped relay or a cloud-side fault does. + Stalled bool } func newPhysics(m Model, tauS float64) Physics { @@ -39,7 +42,7 @@ func (p Physics) PowerW() float64 { } func (p Physics) targetA() float64 { - if !p.Plugged || p.LimitA < minChargeAmps { + if !p.Plugged || p.Stalled || p.LimitA < minChargeAmps { return 0 } if p.LimitA > p.MaxAmps { diff --git a/go/cmd/sim-ocpp/ocppcp/sim.go b/go/cmd/sim-ocpp/ocppcp/sim.go index 685135a0..59886a80 100644 --- a/go/cmd/sim-ocpp/ocppcp/sim.go +++ b/go/cmd/sim-ocpp/ocppcp/sim.go @@ -169,6 +169,13 @@ func (s *Sim) Unplug() error { return s.statusPlugged(false) } +// Stall stops delivery with the car still asking, or lets it go on. +func (s *Sim) Stall(stalled bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.physics.Stalled = stalled +} + // Tick advances physics. Tests call this with a positive dt and TauS=0 to settle. func (s *Sim) Tick(dt time.Duration) { s.mu.Lock()