From 61630caf1b7458286f51a8b0d282fc8cc7dcf40f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 11:24:05 +0200 Subject: [PATCH 1/3] feat(appuplink): the watch exchange, a watch's one-shot door to the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0009 milestone 5, decisions 1, 2 and 6. Paired with srcfl/ftw-webapp#85, the relay's half. The uplink keeps a second socket, /r///exchange, beside each connection, under the same handle and with its own backoff. The relay hands it id(16) ‖ Noise message per watch request; the box writes id ‖ answer back and reads nothing into the id. Watch sessions live in a table of eight, apart from the phones' four, and end after four exchanges, a minute idle or three minutes. At most 60 handshakes a minute. The handshake is Noise IK with prologue ftw.exchange.v1: and the box's key; message 1 carries the watch's hello with its pairing code in hello.code, and message 2 an ExchangeHelloOK in the 1 KiB bulk bucket with rev and, while it fits, cloud. appproto.Config.Exchange makes a handler that takes hello, cmd and now, answers everything else E_UNKNOWN_OP, never streams and pushes no plan. Every answer is two lane 0 frames padded with ticks, so a read, a command and a refusal have one length; an unsettled result leaves a tick and asking again returns it. now carries the frozen fields, source ages keyed by fields 7 to 9, and each charger's plug state, level and power. The box now says cmd.exchange. Golden vectors for every step are in appuplink/testdata/exchange-vectors.json. An end-to-end test runs against a real relay when FTW_RELAY_URL is set. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/watch-exchange.md | 5 + docs/adr/0009-watch-exchange.md | 8 +- docs/architecture.md | 36 +- go/cmd/ftw/app_link.go | 43 +- go/internal/appproto/ev_test.go | 15 + go/internal/appproto/exchange.go | 282 ++++++ go/internal/appproto/exchange_test.go | 140 +++ go/internal/appproto/handler.go | 13 +- go/internal/appproto/ports.go | 15 + go/internal/appproto/protocol.go | 5 + go/internal/appproto/watch_test.go | 2 +- go/internal/appuplink/client.go | 33 +- go/internal/appuplink/exchange.go | 522 +++++++++++ go/internal/appuplink/exchange_test.go | 822 ++++++++++++++++++ go/internal/appuplink/realrelay_test.go | 235 +++++ go/internal/appuplink/relay_test.go | 94 +- .../appuplink/testdata/exchange-vectors.json | 70 ++ go/internal/appuplink/vectors_test.go | 320 +++++++ go/internal/appuplink/watchclient_test.go | 196 +++++ go/protocol/messages.go | 67 ++ 20 files changed, 2889 insertions(+), 34 deletions(-) create mode 100644 .changeset/watch-exchange.md create mode 100644 go/internal/appproto/exchange.go create mode 100644 go/internal/appproto/exchange_test.go create mode 100644 go/internal/appuplink/exchange.go create mode 100644 go/internal/appuplink/exchange_test.go create mode 100644 go/internal/appuplink/realrelay_test.go create mode 100644 go/internal/appuplink/testdata/exchange-vectors.json create mode 100644 go/internal/appuplink/vectors_test.go create mode 100644 go/internal/appuplink/watchclient_test.go diff --git a/.changeset/watch-exchange.md b/.changeset/watch-exchange.md new file mode 100644 index 000000000..755bd21cd --- /dev/null +++ b/.changeset/watch-exchange.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +An Apple Watch can charge the car and read the house without its phone (ADR 0009). The box keeps a second relay socket, `/r///exchange`, beside its uplink, and answers a watch's one-shot HTTPS requests on it: a Noise IK handshake with prologue `ftw.exchange.v1:` that carries the watch's hello and the box's `hello_ok`, then `cmd` and `now`. Watch sessions live in a table of eight apart from the phones' four, so a watch never pushes a phone out, and each ends after four exchanges, a minute idle or three minutes. Every answer is two lane 0 frames, so a read, a command and a refusal have one length; a command's result that has not settled in 3 s leaves a tick in its place, and asking again with the same `cmdId` returns it. The box takes at most 60 handshakes a minute and answers a stranger or a removed watch with silence. It now says `cmd.exchange`, so a phone can offer "Add watch". Losing the exchange socket changes nothing for the uplink or local control. diff --git a/docs/adr/0009-watch-exchange.md b/docs/adr/0009-watch-exchange.md index b00c6a7cd..2137a94ad 100644 --- a/docs/adr/0009-watch-exchange.md +++ b/docs/adr/0009-watch-exchange.md @@ -1,8 +1,10 @@ # ADR 0009: An Apple Watch works without the phone -- Status: accepted. Milestones 1 to 3 are built: the command log on the box, - the registry names, and the watch role with its enrolment. The box does not - say `cmd.exchange` yet. Nothing else described here has shipped. +- Status: accepted. Core has milestones 1, 2, 3 and 5: the command log on + the box, the registry names, the watch role with its enrolment, and the + exchange socket with its sessions, so the box says `cmd.exchange`. The + relay's half is milestone 4, in srcfl/ftw-webapp. Nothing else described + here has shipped. - Date: 2026-10-14 - Decided by: Fredrik on 14 October 2026. Before the design: a watch controls the box directly as well as through the phone; an unlocked watch on the diff --git a/docs/architecture.md b/docs/architecture.md index 79ae12c0b..d2a9d3558 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -362,8 +362,9 @@ from the same SQLite snapshot so older Core versions also read current settings. ## Remote access boundary Remote access is the FTW app and nothing else. The box holds one outbound WSS -connection to `wss://relay.ftw.energy`; there is no inbound listener, no -NAT-traversal layer, no cloud account and no browser-managed site directory. +connection to `wss://relay.ftw.energy`, and beside it a second socket for a +watch's one-shot exchanges; there is no inbound listener, no NAT-traversal +layer, no cloud account and no browser-managed site directory. See [ADR 0006](adr/0006-app-uplink.md) for why Home Link was removed rather than kept alongside it. @@ -413,19 +414,25 @@ second outbound connection for homes whose owner turns it on. It carries to never carries control. Commands and long history stay on the relay path above. [protocol-cloud.md](protocol-cloud.md) specifies its wire format. -Accepted, not shipped: [ADR 0009](adr/0009-watch-exchange.md) lets an Apple -Watch work without the phone. watchOS allows no WebSocket, so each step is one -HTTPS request: commands go to the box through a second relay socket for -one-shot exchanges, and reads go to FTW Cloud in one request. The watch is a -device of its own and never holds more than the phone that added it. +[ADR 0009](adr/0009-watch-exchange.md) lets an Apple Watch work without the +phone. watchOS allows no WebSocket, so each step is one HTTPS request. Commands +go to the box through the relay's exchange: the box keeps a second socket, +`/r///exchange`, under the uplink's handle, and the relay hands +it one Noise message per request behind an id of its own. The box holds those +sessions in a table of eight, apart from the phones' four, so a watch never +pushes a phone out; each ends after four exchanges, a minute idle or three +minutes. Every frame on it is lane 0 apart from `hello_ok`, and every answer is +two frames, so a read, a command and a refusal have one length. Reads go to +FTW Cloud in one request, which has not shipped. The watch is a device of its +own and never holds more than the phone that added it. ### Who is let in, and as what An enrolment carries a role from `contract/registry.yaml`: `owner`, which may -change things, or `viewer`, which may only look. It is stamped by the box when -a pairing code is spent, and it is stamped from the role the box minted that -code for — never from anything the app sends, because a role a holder can edit -is not a role. +change things, `viewer`, which may only look, or `watch`, below. It is stamped +by the box when a pairing code is spent, and it is stamped from the role the +box minted that code for — never from anything the app sends, because a role a +holder can edit is not a role. An invite is therefore not a new cryptographic object. It is the same single-use pairing code with a different role behind it, so the QR payload does @@ -451,6 +458,13 @@ key and taking a guest's access away are one action, not two with two sets of bugs. Changing a role takes effect on a session that is already open, because both doors re-read the grant on every privileged request. +A watch is a device of its own that an owner's phone added. Only an owner's +session may ask for its code, and only with a passkey step-up on that request; +the code binds to that phone and has a slot of its own, so it never cancels an +invite. The watch holds the `watch` role's scopes that its phone holds at that +moment, spends its phone's command ids, and is listed and removed with its +phone. It reaches the box through the exchange and never through the stream. + The routes behind that list have two doors, and what each one PROVES decides what it opens: diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index c5b2cdba6..7916367d3 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -422,6 +422,22 @@ func (a *appLoadpoints) SetSoC(id string, soc float64) bool { return true } +// Readings is every charger as the manager holds it. The car's level counts +// only while it is plugged in: unplugged, the manager's number is the last +// session's, and a watch would draw it as now. +func (a *appLoadpoints) Readings() []appproto.LoadpointReading { + states := a.mgr.States() + out := make([]appproto.LoadpointReading, 0, len(states)) + for _, st := range states { + out = append(out, appproto.LoadpointReading{ + ID: st.ID, Plugged: st.PluggedIn, + SoC: st.CurrentSoC, SoCKnown: st.PluggedIn, + PowerW: st.CurrentPowerW, + }) + } + return out +} + func (a *appLoadpoints) ObservedSoC(id string) (float64, bool) { st, ok := a.mgr.State(id) return st.CurrentSoC, ok @@ -591,17 +607,20 @@ func startAppLink( // and a retry in the next session must meet the id the last one took. cmds := appproto.NewCmdLog() - uplink, err := appuplink.New(appuplink.Options{ - Enroll: enroll, - Deadman: deadman, - Handler: func( + // The box takes a watch through the relay's exchange (ADR 0009), and + // says so, so a phone offers "Add watch". + caps = append(caps, appproto.CapCmdExchange) + + // handlerFor builds one handler for one session: a phone's stream, or a + // watch's exchange. They share the ports above, which are read-only + // apart from the mode and the command log, and the mode goes through + // control's own validation. + handlerFor := func(exchange bool) appuplink.HandlerBuilder { + return func( sender appproto.Sender, caller apiauth.Caller, grants appproto.GrantReader, ) (*appproto.Handler, error) { - // One handler per app session. They share the ports above, which - // are read-only apart from the mode and the command log, and the - // mode goes through control's own validation. return appproto.New(appproto.Config{ Clock: appproto.SystemClock{StartedAt: site.started, Source: "ntp"}, Site: site, @@ -616,6 +635,7 @@ func startAppLink( Caller: caller, Grants: grants, Commands: cmds, + Exchange: exchange, // FTW Cloud is asked per session: its link is bound after // this returns, and its grant moves while the box runs. Caps: cloud.caps(caps), @@ -629,7 +649,14 @@ func startAppLink( SrcPV: siteMeterName(ctrl, ctrlMu), SrcBattery: siteMeterName(ctrl, ctrlMu), }) - }, + } + } + + uplink, err := appuplink.New(appuplink.Options{ + Enroll: enroll, + Deadman: deadman, + Handler: handlerFor(false), + ExchangeHandler: handlerFor(true), }) if err != nil { return enroll, nil, enabled, err diff --git a/go/internal/appproto/ev_test.go b/go/internal/appproto/ev_test.go index 0fa694836..e4d9b5d4d 100644 --- a/go/internal/appproto/ev_test.go +++ b/go/internal/appproto/ev_test.go @@ -3,6 +3,8 @@ package appproto import ( "context" "errors" + "maps" + "slices" "testing" "time" @@ -44,10 +46,23 @@ type fakeLoadpoints struct { surplusCalls int onSurplus func() persistenceErr error + + // powerW is what Readings reports the charger drawing. + powerW float64 } func (f *fakeLoadpoints) WaitForPersistence(context.Context) error { return f.persistenceErr } +func (f *fakeLoadpoints) Readings() []LoadpointReading { + out := []LoadpointReading{} + for _, id := range slices.Sorted(maps.Keys(f.ids)) { + out = append(out, LoadpointReading{ + ID: id, Plugged: f.plugged, SoC: f.soc, SoCKnown: f.plugged && f.socKnown, PowerW: f.powerW, + }) + } + return out +} + func TestLoadpointWriteDoesNotConfirmUncommittedChoice(t *testing.T) { h, lp, _, rec, _ := newEVRig(t) subscribe(t, h, rec) diff --git a/go/internal/appproto/exchange.go b/go/internal/appproto/exchange.go new file mode 100644 index 000000000..be22d3fa7 --- /dev/null +++ b/go/internal/appproto/exchange.go @@ -0,0 +1,282 @@ +package appproto + +import ( + "context" + "sort" + + "github.com/srcfl/ftw/go/internal/units" +) + +// A watch's exchange session (ADR 0009, decision 2). +// +// A watch speaks HTTPS and nothing lower, so each of its requests is one Noise +// message through the relay and each answer one HTTP body. The session takes +// hello, cmd and now. It never streams: there is no socket to stream on. Every +// answer is two lane 0 frames of one size whatever the op or the outcome, so +// the relay cannot tell a read from a command or a refusal from a success by +// length. appuplink pads an answer to two frames with Pad. + +func (h *Handler) dispatchExchange(ctx context.Context, env Envelope) error { + switch env.T { + case MsgHello: + return h.onExchangeHello(env) + case MsgCmd: + return h.onCmd(ctx, env) + case MsgNow: + return h.onNow() + default: + // sub, api.req, hist.query, plan.get, price.get and anything newer. + // Answered rather than ignored, because the watch waits for an + // answer either way and a named refusal costs the same frame. + return h.sendError(env.ID, ErrorBody{ + Code: ErrUnknownOp, + Retryable: ErrorRetryable[ErrUnknownOp], + Args: map[string]any{"t": env.T}, + }) + } +} + +// onExchangeHello answers a watch's hello with what it needs: the protocol, +// the box's mode, the clock, its role and scopes, the controlRev a command +// expects, and FTW Cloud's session information while FTW Cloud is on. +func (h *Handler) onExchangeHello(env Envelope) error { + var hello Hello + if err := Unmarshal(env.B, &hello); err != nil { + h.log.Warn("undecodable hello dropped", "err", err) + return nil + } + + proto := ProtoMax + if hello.Proto.Max < ProtoMax { + proto = max(ProtoFloor, hello.Proto.Max) + } + mode := BoxModeFull + switch { + case h.cfg.Info.Boot() != nil: + mode = BoxModeBooting + case proto == ProtoFloor: + mode = BoxModeFloor + case !h.canWrite(): + mode = BoxModeReadonly + } + + var cloud *CloudSession + if h.cfg.Cloud != nil && proto != ProtoFloor { + cloud = h.cfg.Cloud() + } + source, syncedAtMs := h.cfg.Clock.Sync() + + h.mu.Lock() + h.proto = proto + h.mu.Unlock() + + body := ExchangeHelloOK{ + Proto: proto, + Mode: mode, + Clock: Clock{ + Source: source, + SyncedAtMs: syncedAtMs, + UptimeMs: h.cfg.Clock.UptimeMs(), + }, + Role: h.cfg.Caller.Role, + Scopes: h.cfg.Caller.Scopes.Names(), + Rev: h.cfg.Site.Snapshot().ControlRev, + Cloud: cloud, + } + if body.Cloud != nil { + size, err := encodedSize(MsgHelloOK, body) + if err != nil { + return err + } + if bucket := bulkBucketFor(size); bucket == 0 || bucket > ExchangeHelloMaxBucket { + // A house with too many sources to name in 1 KiB says less. The + // watch then reads now on the exchange, as with FTW Cloud off. + h.log.Warn("exchange hello_ok left FTW Cloud out to fit the relay's answer", "bytes", size) + body.Cloud = nil + } + } + return h.sendBulk(MsgHelloOK, nil, body) +} + +// ExchangeHelloMaxBucket is the largest bucket an exchange hello_ok may take: +// the smallest one. The relay answers a watch with at most 4 KiB behind its +// id, and handshake message 2 adds 48 bytes to the frame, so the next bucket, +// 4 KiB, never fits. +const ExchangeHelloMaxBucket = 1024 + +func encodedSize(t string, body any) (int, error) { + env, err := newEnvelope(t, nil, body) + if err != nil { + return 0, err + } + raw, err := EncodeEnvelope(env) + if err != nil { + return 0, err + } + return len(raw), nil +} + +// onNow answers a watch's read with the Now screen in one lane 0 frame. +func (h *Handler) onNow() error { + caller, enrolled := h.liveCaller() + if !enrolled { + if err := h.sendError(nil, ErrorBody{ + Code: ErrGrantRevoked, + Retryable: ErrorRetryable[ErrGrantRevoked], + }); err != nil { + return err + } + return h.Terminate(TerminateRevoked) + } + if !caller.Scopes.Has(ScopeLiveRead) { + return h.sendError(nil, ErrorBody{ + Code: ErrScopeDenied, + Retryable: ErrorRetryable[ErrScopeDenied], + Args: map[string]any{"t": MsgNow, "needScope": ScopeLiveRead, "role": caller.Role}, + }) + } + if boot := h.cfg.Info.Boot(); boot != nil { + return h.sendError(nil, ErrorBody{ + Code: ErrBooting, + Retryable: ErrorRetryable[ErrBooting], + Args: map[string]any{"phase": boot.Phase, "pct": boot.Pct}, + }) + } + + uptimeMs := h.cfg.Clock.UptimeMs() + snap := h.cfg.Site.Snapshot() + blocked := snap.DispatchBlockedBy + if blocked == nil { + blocked = []string{} + } + body := Now{ + UptimeMs: uptimeMs, + ControlRev: snap.ControlRev, + Fields: fieldValues(snap, h.modes), + Sources: h.nowSources(snap, uptimeMs), + DispatchBlockedBy: blocked, + Loadpoints: h.nowLoadpoints(), + } + + h.mu.Lock() + bucket := h.bucket + h.mu.Unlock() + flags, err := fitNow(&body, bucket) + if err != nil { + return err + } + return h.sendControl(MsgNow, nil, body, flags, bucket) +} + +// nowSources ages the three sources the power fields draw on, keyed by the +// frozen field that names each one. +func (h *Handler) nowSources(snap Snapshot, uptimeMs int64) map[string]NowSource { + out := map[string]NowSource{} + for _, ref := range []struct { + fid Fid + id string + }{ + {FidSrcGrid, h.cfg.SrcGrid}, + {FidSrcPv, h.cfg.SrcPV}, + {FidSrcBattery, h.cfg.SrcBattery}, + } { + for _, s := range snap.Sources { + if ref.id == "" || s.ID != ref.id { + continue + } + age := int64(-1) + if s.LastOkUptimeMs >= 0 { + age = max(0, uptimeMs-s.LastOkUptimeMs) + } + out[fidKey(ref.fid)] = NowSource{State: sourceState(s, uptimeMs), AgeMs: age} + break + } + } + return out +} + +func (h *Handler) nowLoadpoints() []NowLoadpoint { + out := []NowLoadpoint{} + if h.cfg.Loadpoints == nil { + return out + } + for _, r := range h.cfg.Loadpoints.Readings() { + lp := NowLoadpoint{ID: r.ID, Plugged: r.Plugged, PowerW: roundW(r.PowerW)} + if r.SoCKnown { + soc := units.PermilleFromFraction(r.SoC) + lp.SocPermille = &soc + } + out = append(out, lp) + } + return out +} + +// fitNow trims a now until it fits its bucket, and flags what it dropped. +// +// The car comes before the sources' ages, because charging is what a watch is +// for. Chargers go from the last, then the blocked list, then the source ages, +// then the first charger, then fields from the highest id. +func fitNow(body *Now, bucket int) (uint8, error) { + const headerBytes = 6 + fits := func() (bool, error) { + env, err := newEnvelope(MsgNow, nil, body) + if err != nil { + return false, err + } + raw, err := EncodeEnvelope(env) + if err != nil { + return false, err + } + return len(raw)+headerBytes <= bucket, nil + } + + ok, err := fits() + if err != nil || ok { + return 0, err + } + for len(body.Loadpoints) > 1 { + body.Loadpoints = body.Loadpoints[:len(body.Loadpoints)-1] + if ok, err = fits(); err != nil || ok { + return FlagTrunc, err + } + } + body.DispatchBlockedBy = []string{} + if ok, err = fits(); err != nil || ok { + return FlagTrunc, err + } + body.Sources = map[string]NowSource{} + if ok, err = fits(); err != nil || ok { + return FlagTrunc, err + } + body.Loadpoints = []NowLoadpoint{} + if ok, err = fits(); err != nil || ok { + return FlagTrunc, err + } + keys := make([]string, 0, len(body.Fields)) + for k := range body.Fields { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return fidOf(keys[i]) < fidOf(keys[j]) }) + for len(keys) > 0 { + delete(body.Fields, keys[len(keys)-1]) + keys = keys[:len(keys)-1] + if ok, err = fits(); err != nil || ok { + return FlagTrunc, err + } + } + return FlagTrunc, nil +} + +// PadFrame is one tick on lane 0, encoded and not sent. The exchange fills +// every answer to two frames with it, so a refusal, a read and a command +// answer with the same length. +func (h *Handler) PadFrame() ([]byte, error) { + h.mu.Lock() + bucket := h.bucket + h.mu.Unlock() + env, err := newEnvelope(MsgTick, nil, Tick{UptimeMs: h.cfg.Clock.UptimeMs()}) + if err != nil { + return nil, err + } + return h.cfg.Codec.EncodeFrame(Frame{Lane: LaneControl, Bucket: bucket, Envelope: env}) +} diff --git a/go/internal/appproto/exchange_test.go b/go/internal/appproto/exchange_test.go new file mode 100644 index 000000000..861249433 --- /dev/null +++ b/go/internal/appproto/exchange_test.go @@ -0,0 +1,140 @@ +package appproto + +import ( + "fmt" + "testing" +) + +// newExchangeRig is a watch's exchange session over the EV rig's box. +func newExchangeRig(t *testing.T) (*Handler, *fakeLoadpoints, *fakeBox, *recorder) { + t.Helper() + h, lp, box, rec, _ := newEVRig(t) + h.cfg.Exchange = true + h.cfg.Caller = watchCaller() + h.cfg.Grants = newWatchGrants() + lp.plugged, lp.soc, lp.socKnown, lp.powerW = true, 0.62, true, 7360 + return h, lp, box, rec +} + +// hello_ok on the exchange says what a watch needs, in a bulk bucket, and +// carries the controlRev a command expects. +func TestAnExchangeHelloNamesTheRevAndNoCatalogue(t *testing.T) { + h, _, box, rec := newExchangeRig(t) + h.cfg.Cloud = func() *CloudSession { return &CloudSession{Home: "home-1", PK: make([]byte, 65), Boot: make([]byte, 16)} } + + deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: ProtoMin, Max: ProtoMax}}) + frame := rec.only(t, MsgHelloOK) + if frame.lane != LaneBulk || frame.size != 1024 { + t.Fatalf("hello_ok went on lane %d in %d B, want the 1 KiB bulk bucket", frame.lane, frame.size) + } + ok := body[ExchangeHelloOK](t, frame) + if ok.Rev != box.snap.ControlRev || ok.Role != watchCaller().Role || ok.Cloud == nil || ok.Cloud.Home != "home-1" { + t.Fatalf("hello_ok = %+v", ok) + } + keys := body[map[string]any](t, frame) + for _, stream := range []string{"modes", "caps", "capsHash", "box"} { + if _, ok := keys[stream]; ok { + t.Fatalf("the exchange hello_ok carries %q, which only the stream needs", stream) + } + } +} + +// The relay answers a watch with at most 4 KiB, so hello_ok never takes the +// 4 KiB bucket. A house too big for 1 KiB leaves FTW Cloud out, and the watch +// reads now instead. +func TestAnExchangeHelloTooBigForTheRelayLeavesCloudOut(t *testing.T) { + h, _, _, rec := newExchangeRig(t) + sources := map[string]string{} + for i := 0; i < 80; i++ { + sources[fmt.Sprintf("opaque-source-%03d", i)] = fmt.Sprintf("a-rather-long-driver-name-%03d", i) + } + h.cfg.Cloud = func() *CloudSession { return &CloudSession{Home: "home-1", Sources: sources} } + + deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: ProtoMin, Max: ProtoMax}}) + frame := rec.only(t, MsgHelloOK) + if frame.size > ExchangeHelloMaxBucket { + t.Fatalf("hello_ok took %d B", frame.size) + } + if ok := body[ExchangeHelloOK](t, frame); ok.Cloud != nil { + t.Fatal("hello_ok kept FTW Cloud past what the relay can carry") + } +} + +// An applied command on the exchange is answered by its ack and result and +// nothing after them: the plan the stream pushes would be a third frame. +func TestAnExchangeCommandPushesNoPlan(t *testing.T) { + h, _, _, rec := newExchangeRig(t) + deliver(t, h, MsgCmd, nil, cmdLoadpoint(OpLoadpointHold, "x-hold", holdArgs(), 7, 200_000)) + + if got := rec.types(); got != fmt.Sprint([]string{MsgCmdAck, MsgCmdResult}) { + t.Fatalf("an applied command sent %s", got) + } + if res := body[CmdResult](t, rec.only(t, MsgCmdResult)); res.State != CmdApplied { + t.Fatalf("the hold ended %q", res.State) + } +} + +// now is one lane 0 frame. What does not fit is left out and flagged, never +// sent in a bigger frame. +func TestNowFitsLaneZeroOrSaysWhatItLeftOut(t *testing.T) { + h, lp, _, rec := newExchangeRig(t) + deliver(t, h, MsgNow, nil, struct{}{}) + frame := rec.only(t, MsgNow) + if frame.lane != LaneControl || frame.size != 512 || frame.flags&FlagTrunc != 0 { + t.Fatalf("now went on lane %d in %d B with flags %d", frame.lane, frame.size, frame.flags) + } + now := body[Now](t, frame) + if len(now.Loadpoints) != 1 || now.Loadpoints[0].SocPermille == nil || *now.Loadpoints[0].SocPermille != 620 { + t.Fatalf("now = %+v", now) + } + if _, ok := now.Sources[fidKey(FidSrcGrid)]; !ok { + t.Fatalf("now ages no grid source: %+v", now.Sources) + } + rec.reset() + + for i := 0; i < 40; i++ { + lp.ids[fmt.Sprintf("charger-with-a-long-name-%02d", i)] = true + } + deliver(t, h, MsgNow, nil, struct{}{}) + frame = rec.only(t, MsgNow) + if frame.size != 512 || frame.flags&FlagTrunc == 0 { + t.Fatalf("an overfull now went in %d B with flags %d", frame.size, frame.flags) + } + now = body[Now](t, frame) + if len(now.Loadpoints) == 0 || len(now.Fields) == 0 { + t.Fatalf("a trimmed now kept %d chargers and %d fields", len(now.Loadpoints), len(now.Fields)) + } +} + +// A removed watch's read is refused and its session ended, as a command is. +func TestARemovedWatchReadsNothing(t *testing.T) { + h, _, _, rec := newExchangeRig(t) + h.cfg.Grants.(*fakeGrants).revoke() + + deliver(t, h, MsgNow, nil, struct{}{}) + if rec.has(MsgNow) { + t.Fatal("a removed watch read the house") + } + if e := body[ErrorBody](t, rec.only(t, MsgError)); e.Code != ErrGrantRevoked { + t.Fatalf("the refusal is %s", e.Code) + } + if !rec.has(MsgSessionTerminate) { + t.Fatal("the session went on") + } +} + +// The pad is a lane 0 tick of the session's one size. +func TestThePadIsALaneZeroTick(t *testing.T) { + h, _, _, _ := newExchangeRig(t) + raw, err := h.PadFrame() + if err != nil { + t.Fatal(err) + } + f, err := testCodec{}.DecodeFrame(raw) + if err != nil { + t.Fatal(err) + } + if len(raw) != 512 || f.Lane != LaneControl || f.Envelope.T != MsgTick { + t.Fatalf("the pad is %d B, lane %d, %q", len(raw), f.Lane, f.Envelope.T) + } +} diff --git a/go/internal/appproto/handler.go b/go/internal/appproto/handler.go index 502d3d82d..8cc72d0b8 100644 --- a/go/internal/appproto/handler.go +++ b/go/internal/appproto/handler.go @@ -68,6 +68,12 @@ type Config struct { // next session act a second time. Commands *CmdLog + // Exchange makes this a watch's exchange session (ADR 0009, decision 2). + // It takes hello, cmd and now, and answers every other request + // E_UNKNOWN_OP. It never streams and pushes no plan after a command. + // Every frame it sends is lane 0 apart from hello_ok. + Exchange bool + // Caps is what this box advertises. Names must come from the generated // contract constants; a typo is refused at construction rather than // silently hiding a feature in the app. @@ -346,6 +352,9 @@ func (h *Handler) Handle(ctx context.Context, frame []byte) error { } func (h *Handler) dispatch(ctx context.Context, env Envelope) error { + if h.cfg.Exchange { + return h.dispatchExchange(ctx, env) + } switch env.T { case MsgHello: return h.onHello(env) @@ -973,7 +982,9 @@ func (h *Handler) settleAndReport(cmdID string, res CmdResult) error { if err := h.sendCmdResult(res); err != nil { return err } - if res.State == CmdApplied { + // An exchange answers with two frames of one size and nothing after + // them, so a watch reads the new plan from FTW Cloud instead. + if res.State == CmdApplied && !h.cfg.Exchange { return h.sendPlan(nil) } return nil diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index 0b5d95a43..44b41be73 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -179,6 +179,21 @@ type Loadpoints interface { SetSurplusOnly(id string, v bool) (prev bool, ok bool) // ObservedSurplusOnly is the flag as the box holds it now. ObservedSurplusOnly(id string) (v bool, ok bool) + // Readings is every charger as the box holds it now, in its configured + // order: what a watch's now draws. + Readings() []LoadpointReading +} + +// LoadpointReading is one charger as the box holds it now. +type LoadpointReading struct { + ID string + Plugged bool + // SoC is the car's level as a fraction in [0,1], meaningful only while + // SoCKnown. + SoC float64 + SoCKnown bool + // PowerW is the charger's draw, positive while charging. + PowerW float64 } // PlannerPrefsSnapshot is the household planner preference as the box holds diff --git a/go/internal/appproto/protocol.go b/go/internal/appproto/protocol.go index 89886c784..ff3b7e7bd 100644 --- a/go/internal/appproto/protocol.go +++ b/go/internal/appproto/protocol.go @@ -107,6 +107,10 @@ type ( Event = protocol.Event SessionTerminate = protocol.SessionTerminate CloudSession = protocol.CloudSession + ExchangeHelloOK = protocol.ExchangeHelloOK + Now = protocol.Now + NowSource = protocol.NowSource + NowLoadpoint = protocol.NowLoadpoint ) const ( @@ -133,6 +137,7 @@ const ( MsgAPIHead = protocol.MsgAPIHead MsgAPIChunk = protocol.MsgAPIChunk MsgAPIEnd = protocol.MsgAPIEnd + MsgNow = protocol.MsgNow OpSetMode = protocol.OpSetMode OpPlannerPrefsSet = protocol.OpPlannerPrefsSet OpLoadpointHold = protocol.OpLoadpointHold diff --git a/go/internal/appproto/watch_test.go b/go/internal/appproto/watch_test.go index 1615ef25c..3dc96bf09 100644 --- a/go/internal/appproto/watch_test.go +++ b/go/internal/appproto/watch_test.go @@ -159,6 +159,6 @@ func TestAWatchsHelloNamesItsRoleAndScopes(t *testing.T) { t.Fatalf("hello_ok scopes = %v, want %v", ok.Scopes, want) } if slices.Contains(ok.Caps, CapCmdExchange) { - t.Fatal("hello_ok says cmd.exchange before the box opens the exchange") + t.Fatal("hello_ok says cmd.exchange for a box that was not given it") } } diff --git a/go/internal/appuplink/client.go b/go/internal/appuplink/client.go index 7571605d5..67c5cd8fb 100644 --- a/go/internal/appuplink/client.go +++ b/go/internal/appuplink/client.go @@ -128,6 +128,11 @@ type Options struct { // control or the planner. Handler HandlerBuilder + // ExchangeHandler builds one handler per watch session, with + // appproto.Config.Exchange set (ADR 0009). Nil leaves the exchange + // socket closed: the box takes no watch, and nothing else changes. + ExchangeHandler HandlerBuilder + Logger *slog.Logger // Deadman, when set, arms the dead man's switch: rows posted to the @@ -179,6 +184,11 @@ type Uplink struct { liveMu sync.Mutex live []*liveLink + // exchanges is the box's table of watch sessions, nil without an + // ExchangeHandler. One for the box rather than one per connection, so a + // watch's session survives the hour turning under it. + exchanges *exchanges + // The dead man's switch bookkeeping: which row ids the relay holds, // so a removed subscription's row is deleted rather than left armed. deadmanMu sync.Mutex @@ -208,6 +218,9 @@ func (u *Uplink) DropSessionsByAppKey(pub []byte) int { for _, l := range live { dropped += l.sessions.dropByAppKey(pub) } + if u.exchanges != nil { + dropped += u.exchanges.dropByAppKey(pub) + } return dropped } @@ -266,6 +279,15 @@ func New(opts Options) (*Uplink, error) { if u.deadmanRefresh <= 0 { u.deadmanRefresh = deadmanRefreshInterval } + if opts.ExchangeHandler != nil { + u.exchanges = &exchanges{ + build: opts.ExchangeHandler, + enroll: opts.Enroll, + static: opts.Enroll.StaticKey(), + log: u.log, + now: time.Now, + } + } return u, nil } @@ -326,7 +348,7 @@ func (u *Uplink) once(ctx context.Context) (time.Duration, error) { // this, an hourly rotation would compound the backoff until a box that // works perfectly waits a minute after every rotation. connectedAt := u.now() - end := u.ride(ctx, u.start(ctx, conn, epoch)) + end := u.ride(ctx, u.start(ctx, conn, epoch, handle)) if u.now().Sub(connectedAt) >= backoffCap { u.attempt = 0 } @@ -388,11 +410,16 @@ type linkEnd struct { err error } -func (u *Uplink) start(ctx context.Context, conn *websocket.Conn, epoch int64) *link { +func (u *Uplink) start(ctx context.Context, conn *websocket.Conn, epoch int64, handle string) *link { ctx, stop := context.WithCancel(ctx) l := &link{epoch: epoch, stop: stop, done: make(chan linkEnd, 1)} go func() { defer stop() + // The exchange socket rides beside this connection, under the same + // epoch and handle, and ends with it. + if u.exchanges != nil { + go u.keepExchange(ctx, epoch, handle) + } code, reason, err := u.serve(ctx, conn) l.done <- linkEnd{code: code, reason: reason, err: err} }() @@ -505,7 +532,7 @@ func (u *Uplink) join(ctx context.Context, epoch int64) *link { u.log.Debug("could not join the new hour's handle yet", "err", err) return nil } - return u.start(ctx, conn, epoch) + return u.start(ctx, conn, epoch, handle) } // serve runs one connection. It returns the close code and reason when the diff --git a/go/internal/appuplink/exchange.go b/go/internal/appuplink/exchange.go new file mode 100644 index 000000000..5772267e6 --- /dev/null +++ b/go/internal/appuplink/exchange.go @@ -0,0 +1,522 @@ +package appuplink + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "slices" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll" + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/protocol/appwire" +) + +// The exchange: a watch's one-shot door to the box (ADR 0009, decisions 1 +// and 2; the relay's half is srcfl/ftw-webapp docs/watch-exchange.md). +// +// watchOS gives an app HTTPS and nothing lower, so a watch cannot hold a +// stream. It posts one Noise message to the relay, and the relay hands it to +// the box on a second socket, /r///exchange, as +// id(16) ‖ message. The box answers id ‖ reply on the same socket. The id is +// the relay's own random number; the box copies it back and reads nothing +// into it. +// +// Exchange sessions are a class of their own: a table of at most eight, apart +// from the four stream places, so a watch never pushes a phone out. Losing +// the exchange socket changes nothing for the uplink, local control or the +// planner. + +const ( + // MaxExchangeSessions is how many watch sessions the box holds at once. + MaxExchangeSessions = 8 + + // ExchangeIDBytes is the relay's id in front of every message. + ExchangeIDBytes = 16 + + // exchangeFrameBytes is the one size every frame takes but hello_ok: + // lane 0's 512 B bucket. + exchangeFrameBytes = 512 + // exchangeOpenBytes is handshake message 1 with a lane 0 hello. + exchangeOpenBytes = appwire.Message1Overhead + exchangeFrameBytes + // exchangeRequestBytes is one transport message with one lane 0 frame. + exchangeRequestBytes = appwire.TransportOverhead + exchangeFrameBytes + // exchangeAnswerFrames is how many frames every answer after the + // handshake carries, so a read, a command and a refusal have one length. + exchangeAnswerFrames = 2 + + // A session ends after four exchanges, a minute without one, or three + // minutes, whichever comes first. A watch opens one when its confirm + // screen appears and spends it within seconds. + exchangeUses = 4 + exchangeIdle = 60 * time.Second + exchangeLifetime = 3 * time.Minute + + // exchangeOpensPerMinute bounds handshakes, each of which costs the box + // up to four X25519 operations. The rest are dropped unanswered. + exchangeOpensPerMinute = 60 + + // exchangeSettle is how long the box holds an answer for a command's + // result. Core settles a charger's result in the pass that takes the + // intent, within 2 s for the EV save, so one answer carries both. Past + // this the answer carries a tick in the result's place and the watch + // asks again with the same cmdId. + exchangeSettle = 3 * time.Second + + // exchangeInFlight bounds requests handled at once. The relay allows two + // open exchanges per room; this is the box's own ceiling. + exchangeInFlight = 8 + + // exchangeReadLimit admits the largest request plus its id. + exchangeReadLimit = 1024 +) + +// exchangePrologue binds an exchange handshake to this box, and keeps it +// apart from a stream handshake: one can never stand in for the other. +func exchangePrologue(boxStatic []byte) []byte { + return append([]byte("ftw.exchange.v1:"), boxStatic...) +} + +var errExchangeRate = errors.New("appuplink: too many exchange handshakes this minute") + +// exchangeSession is one watch's short conversation with the box. +type exchangeSession struct { + transport *appwire.Transport + handler *appproto.Handler + // appStatic is the watch's key as the handshake authenticated it. + appStatic []byte + openedAt time.Time + + // run lets one request at a time through the handler. A command whose + // answer went without its result may still be running when the watch + // asks again; the second request waits for it. + run sync.Mutex + + mu sync.Mutex + lastAt time.Time + uses int + // gen names the request whose frames are being caught, and catching + // says whether one is. A frame from any other run is dropped. + gen uint64 + catching bool + capture [][]byte +} + +// Send collects the handler's frames for the request being answered now. A +// frame sent when no answer is waiting, such as a result that settled after +// its answer went, is dropped: the command log holds it, and asking again +// returns it. +func (s *exchangeSession) Send(frame []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.catching && len(s.capture) < exchangeAnswerFrames { + s.capture = append(s.capture, append([]byte(nil), frame...)) + } + return nil +} + +// next numbers a request and stamps it at now. +func (s *exchangeSession) next(now time.Time) uint64 { + s.mu.Lock() + defer s.mu.Unlock() + s.uses++ + s.lastAt = now + return uint64(s.uses) +} + +// catch starts collecting frames for one request. +func (s *exchangeSession) catch(gen uint64) { + s.mu.Lock() + defer s.mu.Unlock() + s.gen, s.capture, s.catching = gen, nil, true +} + +// release stops collecting and returns what the request sent. Nil when the +// request never started catching, because an earlier one still held the +// handler. +func (s *exchangeSession) release(gen uint64) [][]byte { + s.mu.Lock() + defer s.mu.Unlock() + if s.gen != gen || !s.catching { + return nil + } + frames := s.capture + s.capture, s.catching = nil, false + return frames +} + +// spent reports whether the session has run its course. +func (s *exchangeSession) spent(now time.Time) bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.uses >= exchangeUses || + now.Sub(s.lastAt) > exchangeIdle || + now.Sub(s.openedAt) > exchangeLifetime +} + +func (s *exchangeSession) close() { + s.handler.Close() + s.transport.Close() +} + +// exchanges is the box's table of watch sessions. One per box: a watch's +// request may arrive on the old hour's socket or the new one, and finds its +// session either way. +type exchanges struct { + build HandlerBuilder + enroll *appenroll.Identity + static appwire.KeyPair + log *slog.Logger + // now is the real clock. Session ages are durations, and the uplink's + // injectable clock answers which epoch it is. + now func() time.Time + // settle is exchangeSettle; a test shortens it. + settle time.Duration + + mu sync.Mutex + live []*exchangeSession + opens []time.Time +} + +// answer takes one request off the exchange socket and returns the reply, or +// nil for silence. A stranger, a removed key, a spent session, a malformed +// body and a handshake over the rate all get silence. +func (x *exchanges) answer(ctx context.Context, body []byte) []byte { + x.prune() + switch len(body) { + case exchangeOpenBytes: + reply, err := x.open(ctx, body) + if err != nil { + x.log.Debug("exchange handshake opened no session", "err", err) + return nil + } + return reply + case exchangeRequestBytes: + return x.route(ctx, body) + default: + return nil + } +} + +// open runs the responder half of a handshake for a watch. The watch's hello +// rides in message 1 and its hello_ok in message 2, so a session costs one +// round trip. +func (x *exchanges) open(ctx context.Context, message1 []byte) ([]byte, error) { + if !x.allowOpen() { + return nil, errExchangeRate + } + handshake, err := appwire.NewResponder(appwire.HandshakeOptions{ + StaticKey: x.static, + Prologue: exchangePrologue(x.static.Public()), + }) + if err != nil { + return nil, err + } + payload, err := handshake.ReadMessage(message1) + if err != nil { + return nil, err + } + frame, err := appwire.DecodeFrame(payload) + if err != nil { + return nil, err + } + if frame.Envelope.T != appproto.MsgHello { + return nil, fmt.Errorf("appuplink: message 1 carries %q, not a hello", frame.Envelope.T) + } + var hello appproto.Hello + if err := appproto.Unmarshal(frame.Envelope.B, &hello); err != nil { + return nil, err + } + + // Checked before anything is written: answering a stranger would confirm + // that a box is on this handle and hand them a fresh ephemeral key. + grant, err := x.enroll.AuthoriseWatch(handshake.RemoteStatic(), hello.Code) + if err != nil { + return nil, err + } + + now := x.now() + sess := &exchangeSession{appStatic: handshake.RemoteStatic(), openedAt: now, lastAt: now} + handler, err := x.build(sess, apiauth.Caller{ + Subject: subjectOf(grant.DeviceID), + Kind: apiauth.KindApp, + Role: grant.Role, + Scopes: appproto.ScopesForGrant(grant.Role, grant.ParentRole), + Epoch: grant.Epoch, + }, liveGrant{enroll: x.enroll, deviceID: grant.DeviceID}) + if err != nil { + return nil, err + } + sess.handler = handler + + sess.catch(0) + err = handler.Handle(ctx, payload) + frames := sess.release(0) + if err != nil || len(frames) != 1 { + handler.Close() + return nil, fmt.Errorf("appuplink: hello made %d frames: %v", len(frames), err) + } + reply, err := handshake.WriteMessage(frames[0]) + if err != nil { + handler.Close() + return nil, err + } + noise, err := handshake.Split() + if err != nil { + handler.Close() + return nil, err + } + sess.transport = appwire.NewTransport(noise) + x.admit(sess) + return reply, nil +} + +// route finds the session a transport message belongs to by letting each +// one's AEAD try, and runs the exchange in it. +func (x *exchanges) route(ctx context.Context, message []byte) []byte { + x.mu.Lock() + live := slices.Clone(x.live) + x.mu.Unlock() + + for _, sess := range live { + frame, err := sess.transport.Decrypt(message) + if err != nil { + continue + } + reply := x.exchange(ctx, sess, frame) + if sess.spent(x.now()) { + x.drop(sess) + } + return reply + } + return nil +} + +// exchange runs one request through the session's handler and seals the +// answer: exactly two lane 0 frames. A command's ack and result when the +// result settles in time; otherwise a tick takes the missing place. +func (x *exchanges) exchange(ctx context.Context, sess *exchangeSession, frame []byte) []byte { + gen := sess.next(x.now()) + done := make(chan error, 1) + go func() { + sess.run.Lock() + defer sess.run.Unlock() + sess.catch(gen) + done <- sess.handler.Handle(ctx, frame) + }() + + wait := x.settle + if wait <= 0 { + wait = exchangeSettle + } + settle := time.NewTimer(wait) + select { + case err := <-done: + if err != nil { + x.log.Warn("watch exchange failed on a frame", "err", err) + } + case <-settle.C: + case <-ctx.Done(): + } + settle.Stop() + + frames := sess.release(gen) + for len(frames) < exchangeAnswerFrames { + tick, err := sess.handler.PadFrame() + if err != nil { + return nil + } + frames = append(frames, tick) + } + + var reply []byte + for _, f := range frames { + sealed, err := sess.transport.Encrypt(f) + if err != nil { + return nil + } + reply = append(reply, sealed...) + } + return reply +} + +// allowOpen counts a handshake against the minute's budget. +func (x *exchanges) allowOpen() bool { + x.mu.Lock() + defer x.mu.Unlock() + now := x.now() + x.opens = slices.DeleteFunc(x.opens, func(at time.Time) bool { return now.Sub(at) >= time.Minute }) + if len(x.opens) >= exchangeOpensPerMinute { + return false + } + x.opens = append(x.opens, now) + return true +} + +// admit keeps a new session, ending the oldest when the table is full. The +// newcomer is the watch somebody is holding now. +func (x *exchanges) admit(sess *exchangeSession) { + x.mu.Lock() + defer x.mu.Unlock() + x.live = append(x.live, sess) + for len(x.live) > MaxExchangeSessions { + x.live[0].close() + x.live = x.live[1:] + } +} + +// prune ends every session that has run its course. +func (x *exchanges) prune() { + now := x.now() + x.mu.Lock() + defer x.mu.Unlock() + x.live = slices.DeleteFunc(x.live, func(s *exchangeSession) bool { + if s.spent(now) { + s.close() + return true + } + return false + }) +} + +func (x *exchanges) drop(sess *exchangeSession) { + x.mu.Lock() + defer x.mu.Unlock() + x.live = slices.DeleteFunc(x.live, func(s *exchangeSession) bool { + if s == sess { + s.close() + return true + } + return false + }) +} + +// dropByAppKey ends every session a removed key holds. Its next message 1 +// meets AuthoriseWatch and gets silence. +func (x *exchanges) dropByAppKey(pub []byte) int { + x.mu.Lock() + defer x.mu.Unlock() + dropped := 0 + x.live = slices.DeleteFunc(x.live, func(s *exchangeSession) bool { + if bytes.Equal(s.appStatic, pub) { + s.close() + dropped++ + return true + } + return false + }) + return dropped +} + +func (x *exchanges) count() int { + x.mu.Lock() + defer x.mu.Unlock() + return len(x.live) +} + +// -------------------------------------------------------------------------- +// The socket +// -------------------------------------------------------------------------- + +// keepExchange holds the exchange socket for one relay connection's epoch and +// handle, and reconnects it with its own backoff until the connection ends. A +// relay that refuses the role, or a socket that drops, costs the watch its +// door and nothing else. +func (u *Uplink) keepExchange(ctx context.Context, epoch int64, handle string) { + attempt := 0 + for ctx.Err() == nil { + target := fmt.Sprintf("%s/r/%d/%s/exchange", u.opts.Endpoint, epoch, handle) + dialCtx, cancel := context.WithTimeout(ctx, dialTimeout) + conn, response, err := u.dialer.DialContext(dialCtx, target, nil) + cancel() + if response != nil && response.Body != nil { + _ = response.Body.Close() + } + if err == nil { + started := time.Now() + err = u.serveExchange(ctx, conn) + if time.Since(started) >= backoffCap { + attempt = 0 + } + } + if ctx.Err() != nil { + return + } + u.log.Debug("exchange socket dropped; retrying", "err", err) + + ceiling := min(backoffBase<//exchange beside its uplink, under the +// same handle, and a watch charges through it. +func TestTheBoxOpensAnExchangeBesideItsUplink(t *testing.T) { + charger := &fakeCharger{} + r := newExchangeRig(t, charger) + waitForExchange(t, r.relay) + + handle, err := Handle(r.enroll.RendezvousSecret(), 481234) + if err != nil { + t.Fatal(err) + } + joinedUnder := 0 + for _, h := range r.relay.seenHandles() { + if h == handle { + joinedUnder++ + } + } + if joinedUnder < 2 { + t.Fatalf("the box joined its handle %d times; the uplink and the exchange share it", joinedUnder) + } + + code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) + if err != nil { + t.Fatal(err) + } + phoneKey, err := appwire.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + phone, err := r.enroll.Authorise(phoneKey.Public(), code) + if err != nil { + t.Fatal(err) + } + watchCode, _, err := r.enroll.MintWatchCode(phone.DeviceID, appenroll.WatchTTL) + if err != nil { + t.Fatal(err) + } + w, err := newWatchClient() + if err != nil { + t.Fatal(err) + } + message1, err := w.open(r.enroll.StaticKey().Public(), watchCode) + if err != nil { + t.Fatal(err) + } + message2 := r.relay.post(1, message1, 5*time.Second) + if message2 == nil { + t.Fatal("the box did not answer message 1 on its exchange socket") + } + if _, _, err := w.opened(message2); err != nil { + t.Fatal(err) + } + request, err := w.request(appproto.MsgCmd, chargeNow("socket-1")) + if err != nil { + t.Fatal(err) + } + frames, err := w.answer(r.relay.post(2, request, 5*time.Second)) + if err != nil { + t.Fatal(err) + } + if got := types(frames); len(got) != 2 || got[1] != appproto.MsgCmdResult { + t.Fatalf("the answer carries %v", got) + } + if charger.count() != 1 { + t.Fatalf("the charger saw %d holds", charger.count()) + } + + // Removing the watch through the uplink ends its exchange session too. + if dropped := r.uplink.DropSessionsByAppKey(w.static.Public()); dropped != 1 { + t.Fatalf("DropSessionsByAppKey dropped %d sessions, want the watch's one", dropped) + } +} + +// Losing the exchange socket changes nothing for the uplink, and the box +// opens it again. +func TestLosingTheExchangeLeavesTheUplink(t *testing.T) { + r := newExchangeRig(t, &fakeCharger{}) + waitForExchange(t, r.relay) + + code, _, err := r.enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) + if err != nil { + t.Fatal(err) + } + conn := r.dialApp(t, 481234) + app, err := newAppClient() + if err != nil { + t.Fatal(err) + } + message1, err := app.message1(r.enroll.StaticKey().Public(), code) + if err != nil { + t.Fatal(err) + } + if err := conn.WriteMessage(websocket.BinaryMessage, message1); err != nil { + t.Fatal(err) + } + if err := app.readMessage2(waitForBinary(t, conn)); err != nil { + t.Fatal(err) + } + + r.relay.dropExchange() + waitForExchange(t, r.relay) + + send(t, conn, app, appproto.MsgHello, 512, appproto.Hello{ + Proto: appproto.ProtoRange{Min: 0, Max: appproto.ProtoMax}, + App: appproto.AppInfo{Build: "test", UA: "pwa"}, + }) + if got := receive(t, conn, app); got.Envelope.T != appproto.MsgHelloOK { + t.Fatalf("after the exchange dropped, the phone's session answered %q", got.Envelope.T) + } +} diff --git a/go/internal/appuplink/realrelay_test.go b/go/internal/appuplink/realrelay_test.go new file mode 100644 index 000000000..f2fb84c2b --- /dev/null +++ b/go/internal/appuplink/realrelay_test.go @@ -0,0 +1,235 @@ +package appuplink + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/appenroll" + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/protocol/appwire" +) + +// The exchange end to end: this box, srcfl/ftw-webapp's relay, and a watch in +// Go posting over HTTP (ADR 0009, milestone 5). Skipped unless a relay with +// the exchange route is running: +// +// PORT=8797 HOST=127.0.0.1 node relay/src/main.ts # in srcfl/ftw-webapp +// FTW_RELAY_URL=ws://127.0.0.1:8797 go test ./internal/appuplink -run RealRelay -v +// +// FTW_EV_SAVE_MS sets how long the charger's save takes, so the timing below +// can show what an applied command waits for. The relay allows 30 exchanges a +// minute per room, so the run stays under that. +func TestExchangeThroughARealRelay(t *testing.T) { + endpoint := os.Getenv("FTW_RELAY_URL") + if endpoint == "" { + t.Skip("set FTW_RELAY_URL to a relay with the exchange route") + } + base := "http" + strings.TrimPrefix(endpoint, "ws") + save, _ := strconv.Atoi(os.Getenv("FTW_EV_SAVE_MS")) + + charger := &fakeCharger{save: time.Duration(save) * time.Millisecond} + enroll, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "nova.key")) + if err != nil { + t.Fatal(err) + } + cmds := appproto.NewCmdLog() + uplink, err := New(Options{ + Endpoint: endpoint, + Enroll: enroll, + Handler: handlerWith(false, charger, cmds), + ExchangeHandler: handlerWith(true, charger, cmds), + Logger: slog.New(slog.DiscardHandler), + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = uplink.Run(ctx) }() + + code, _, err := enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL) + if err != nil { + t.Fatal(err) + } + phoneKey, err := appwire.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + phone, err := enroll.Authorise(phoneKey.Public(), code) + if err != nil { + t.Fatal(err) + } + watchCode, _, err := enroll.MintWatchCode(phone.DeviceID, appenroll.WatchTTL) + if err != nil { + t.Fatal(err) + } + w, err := newWatchClient() + if err != nil { + t.Fatal(err) + } + + epoch := CurrentEpoch(time.Now().UnixMilli()) + post := func(body []byte) (int, []byte, time.Duration) { + t.Helper() + handle, err := Handle(enroll.RendezvousSecret(), epoch) + if err != nil { + t.Fatal(err) + } + started := time.Now() + resp, err := http.Post(fmt.Sprintf("%s/x/%d/%s", base, epoch, handle), "application/octet-stream", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + answer, _ := io.ReadAll(resp.Body) + return resp.StatusCode, answer, time.Since(started) + } + + // X1. The box may still be joining, which the relay answers 404 (no box) + // or 501 (a box with no exchange socket yet). + message1, err := w.open(enroll.StaticKey().Public(), watchCode) + if err != nil { + t.Fatal(err) + } + var status int + var message2 []byte + for try := 0; try < 6; try++ { + status, message2, _ = post(message1) + if status == http.StatusConflict { + if n, err := strconv.ParseInt(string(message2), 10, 64); err == nil { + epoch = n + } + continue + } + if status != http.StatusNotFound && status != http.StatusNotImplemented { + break + } + time.Sleep(500 * time.Millisecond) + } + if status != http.StatusOK { + t.Fatalf("X1 through the relay answered %d", status) + } + if len(message2) != appwire.Message2Overhead+1024 { + t.Fatalf("message 2 is %d bytes", len(message2)) + } + hello, _, err := w.opened(message2) + if err != nil { + t.Fatal(err) + } + if hello.Role != apiauth.RoleWatch { + t.Fatalf("hello_ok = %+v", hello) + } + + exchange := func(msgType string, body any) ([]appwire.Frame, time.Duration) { + t.Helper() + request, err := w.request(msgType, body) + if err != nil { + t.Fatal(err) + } + status, answer, took := post(request) + if status != http.StatusOK || len(answer) != exchangeAnswerFrames*exchangeRequestBytes { + t.Fatalf("%s through the relay answered %d with %d bytes", msgType, status, len(answer)) + } + frames, err := w.answer(answer) + if err != nil { + t.Fatal(err) + } + return frames, took + } + reopen := func() { + t.Helper() + message1, err := w.open(enroll.StaticKey().Public(), nil) + if err != nil { + t.Fatal(err) + } + status, message2, _ := post(message1) + if status != http.StatusOK { + t.Fatalf("reopening answered %d", status) + } + if _, _, err := w.opened(message2); err != nil { + t.Fatal(err) + } + } + + // A charge, the same charge again, a refusal and a read: one session. + frames, _ := exchange(appproto.MsgCmd, chargeNow("relay-1")) + if got := types(frames); got[0] != appproto.MsgCmdAck || got[1] != appproto.MsgCmdResult { + t.Fatalf("the charge answered %v", got) + } + frames, _ = exchange(appproto.MsgCmd, chargeNow("relay-1")) + if got := types(frames); got[1] != appproto.MsgCmdResult || charger.count() != 1 { + t.Fatalf("asking again answered %v after %d holds", got, charger.count()) + } + frames, _ = exchange(appproto.MsgCmd, appproto.Cmd{CmdID: "relay-2", Op: appproto.OpSetMode, + Args: map[string]any{"mode": "charge"}, NotValidAfterMs: 600_000, Expect: appproto.Expect{Rev: 1}}) + if res := decodeAs[appproto.CmdResult](t, frames[0]); res.Error == nil || res.Error.Code != appproto.ErrScopeDenied { + t.Fatalf("site.mode.set from a watch answered %+v", res) + } + frames, _ = exchange(appproto.MsgNow, struct{}{}) + if frames[0].Envelope.T != appproto.MsgNow { + t.Fatalf("now answered %v", types(frames)) + } + + // Timing (decision F): applied and refused, through the relay. + var applied, refused []time.Duration + for i := 0; i < 8; i++ { + if i%2 == 0 { + reopen() + } + _, took := exchange(appproto.MsgCmd, chargeNow(fmt.Sprintf("relay-timing-%d", i))) + applied = append(applied, took) + _, took = exchange(appproto.MsgCmd, appproto.Cmd{CmdID: fmt.Sprintf("relay-refused-%d", i), Op: appproto.OpSetMode, + Args: map[string]any{"mode": "charge"}, NotValidAfterMs: 600_000, Expect: appproto.Expect{Rev: 1}}) + refused = append(refused, took) + } + t.Logf("through the relay, EV save %d ms: applied %s; refused %s", save, spread(applied), spread(refused)) +} + +// TestExchangeTimingAtTheBox times the same two answers without the relay. +// Skipped unless FTW_EXCHANGE_TIMING is set; it reports and asserts nothing. +func TestExchangeTimingAtTheBox(t *testing.T) { + if os.Getenv("FTW_EXCHANGE_TIMING") == "" { + t.Skip("set FTW_EXCHANGE_TIMING=1 to time answers at the box") + } + save, _ := strconv.Atoi(os.Getenv("FTW_EV_SAVE_MS")) + r := newXRig(t) + r.charger.save = time.Duration(save) * time.Millisecond + var applied, refused []time.Duration + var w *watchClient + for i := 0; i < 200; i++ { + if i%2 == 0 { + // A second on the rig's clock per handshake keeps the run under + // the sixty a minute the box allows. + r.advance(time.Second) + w, _ = r.addWatch(t) + } + started := time.Now() + r.ask(t, w, appproto.MsgCmd, chargeNow(fmt.Sprintf("box-timing-%d", i))) + applied = append(applied, time.Since(started)) + started = time.Now() + r.ask(t, w, appproto.MsgCmd, appproto.Cmd{CmdID: fmt.Sprintf("box-refused-%d", i), Op: appproto.OpSetMode, + Args: map[string]any{"mode": "charge"}, NotValidAfterMs: 600_000, Expect: appproto.Expect{Rev: 1}}) + refused = append(refused, time.Since(started)) + } + t.Logf("at the box, EV save %d ms: applied %s; refused %s", save, spread(applied), spread(refused)) +} + +// spread is p50 and p95 of a sample. +func spread(d []time.Duration) string { + s := slices.Clone(d) + slices.Sort(s) + at := func(p float64) time.Duration { return s[min(len(s)-1, int(p*float64(len(s))))] } + return fmt.Sprintf("p50 %s p95 %s (n=%d)", at(0.5).Round(10*time.Microsecond), at(0.95).Round(10*time.Microsecond), len(s)) +} diff --git a/go/internal/appuplink/relay_test.go b/go/internal/appuplink/relay_test.go index ac4b17f9f..a0b0caece 100644 --- a/go/internal/appuplink/relay_test.go +++ b/go/internal/appuplink/relay_test.go @@ -1,12 +1,14 @@ package appuplink import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" "strconv" "strings" "sync" + "time" "github.com/gorilla/websocket" ) @@ -45,6 +47,13 @@ type fakeRelay struct { joined chan struct{} deadman chan struct{} + + // The exchange half: the box's second socket, every answer it writes, + // and how many times it has joined. + exchange *websocket.Conn + answers chan []byte + exchangeJoins int + exchangeJoined chan struct{} } // deadmanRowWire is the POST /deadman body, as the contract writes it. @@ -58,10 +67,13 @@ type deadmanRowWire struct { func newFakeRelay(epoch int64) *fakeRelay { r := &fakeRelay{ - epoch: epoch, - joined: make(chan struct{}, 16), - deadman: make(chan struct{}, 64), - deadmanRows: map[string]deadmanRowWire{}, + epoch: epoch, + joined: make(chan struct{}, 16), + deadman: make(chan struct{}, 64), + answers: make(chan []byte, 64), + + exchangeJoined: make(chan struct{}, 16), + deadmanRows: map[string]deadmanRowWire{}, } r.server = httptest.NewServer(http.HandlerFunc(r.handle)) return r @@ -125,11 +137,14 @@ func (r *fakeRelay) handle(w http.ResponseWriter, req *http.Request) { return } - if role == "box" { + switch role { + case "box": r.serveUplink(conn) - return + case "exchange": + r.serveExchange(conn) + default: + r.serveStream(conn) } - r.serveStream(conn) } func (r *fakeRelay) serveUplink(conn *websocket.Conn) { @@ -233,6 +248,71 @@ func (r *fakeRelay) serveStream(conn *websocket.Conn) { } } +// serveExchange holds the box's exchange socket and keeps every answer it +// writes. A test plays the watch's side with post. +func (r *fakeRelay) serveExchange(conn *websocket.Conn) { + r.mu.Lock() + r.exchange = conn + r.exchangeJoins++ + r.mu.Unlock() + select { + case r.exchangeJoined <- struct{}{}: + default: + } + defer func() { + r.mu.Lock() + if r.exchange == conn { + r.exchange = nil + } + r.mu.Unlock() + _ = conn.Close() + }() + for { + kind, message, err := conn.ReadMessage() + if err != nil { + return + } + if kind == websocket.BinaryMessage { + r.answers <- message + } + } +} + +// post is the relay's /x/ route in one call: an id of its own in front of the +// body, down the exchange socket, and the answer that comes back behind the +// same id. Nil when the box stays silent. +func (r *fakeRelay) post(id byte, body []byte, wait time.Duration) []byte { + r.mu.Lock() + conn := r.exchange + r.mu.Unlock() + if conn == nil { + return nil + } + tag := bytes.Repeat([]byte{id}, 16) + r.write(conn, websocket.BinaryMessage, append(append([]byte(nil), tag...), body...)) + deadline := time.After(wait) + for { + select { + case answer := <-r.answers: + if bytes.Equal(answer[:16], tag) { + return answer[16:] + } + case <-deadline: + return nil + } + } +} + +// dropExchange closes the box's exchange socket from the relay's side. +func (r *fakeRelay) dropExchange() { + r.mu.Lock() + conn := r.exchange + r.mu.Unlock() + if conn != nil { + _ = conn.Close() + } +} + func (r *fakeRelay) write(conn *websocket.Conn, kind int, message []byte) { r.wmu.Lock() defer r.wmu.Unlock() diff --git a/go/internal/appuplink/testdata/exchange-vectors.json b/go/internal/appuplink/testdata/exchange-vectors.json new file mode 100644 index 000000000..8dffe046c --- /dev/null +++ b/go/internal/appuplink/testdata/exchange-vectors.json @@ -0,0 +1,70 @@ +{ + "about": "ADR 0009 watch exchange: Noise IK with prologue ftw.exchange.v1: and the box's static key. Every frame but hello_ok is lane 0 in 512 B; every answer after the handshake is two transport messages.", + "protocolName": "Noise_IK_25519_ChaChaPoly_SHA256", + "prologue": "6674772e65786368616e67652e76313a07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c", + "boxStatic": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "boxStaticPublic": "07a37cbc142093c8b755dc1b10e86cb426374ad16aa853ed0bdfc0b2b86d1c7c", + "boxEphemeral": "6162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f80", + "watchStatic": "2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40", + "watchStaticPublic": "5869aff450549732cbaaed5e5df9b30a6da31cb0e5742bad5ad4a1a768f1a67b", + "watchEphemeral": "4142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f60", + "handshakeHash": "774830fbffaa569c1fa626f5c527ad12e1085cb996a2cd60d60028344675ca8b", + "steps": [ + { + "name": "x1.request", + "from": "watch", + "frames": [ + "01000000005aa261746568656c6c6f6162a463617070a26275616777617463686f73656275696c6467766563746f727364636f646550a1a2a3a4a5a6a7a8a9aaabacadaeafb06570726f746fa2636d617801636d696e00676c6f63616c6573f60000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ], + "body": "64b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d4846616dbb9de4a9e094239c6b6f322ce389abeff592a58ae1d32e9ce750b0388b9801ea66db7bd728aecae5f2e271dfa40f7c36852a5e3fbccf7a61a1f6de53d0f6bb0a0e1974d707403b77e19748b7c341b29697ead61a52340567a0abbdbee1e9307318322030306e6dbfe817ae2fbfeb54ca044286a855abc7335a3d67961e8d3f8665a14e25de60db22318fc40fdfddfc2d9896c4e6495995219e081124e3089707b6b66337280b6e4ab0182eb19e5e4ec57392f2a802c1dc2dbdc774e723ac0bf16f68b9f4a79a66388043beb5073a972e3160bebc192c02cc82339e72cf31b8662a6de21104774c0c22de6772b2fa19603b6f18185c2d8eafbaf5bd9dbf1546f2f02ddb2e24b3b6adcdbf2e1726202861e647c8943fa24c53ac7d81039d93f8399611761a3310b3794fc0a6afcfea03702be673a46002d5d6cc1c3f58d214092a1342020353b13daf7f1a4a73b367d2418cc0f25e4cfde316dd67cdb55f14136d754bb2277eee456653ac05da873b0e327210a70de55bbe3ae430137336f49a0f51858db8c8b6761575d56a0c9a6aa36357da6dad200d51b32eb4c0b85b129feb10d2d40ee3be0c0eeed918e34d5cc58ddcbfc7cdff9660d0e953a978ad7cba41c6a0746bb5d3c40b59ee4b482e035084d40a5817104a5fe80982283c0849406bdf36b32497ad46aa94a84f66f081f4b248fc29f50e289bf888d2a7ab8d74ac2821db0faa0c13f7ea5ed4e7eaca6b3c1444d05baa50aa71be40edc003db7418b78645680e26c92d8672be523350a3803a57ace446dd88e20bdbdc465fc36b4", + "relay": "8182838485868788898a8b8c8d8e8f9064b101b1d0be5a8704bd078f9895001fc03e8e9f9522f188dd128d9846d4846616dbb9de4a9e094239c6b6f322ce389abeff592a58ae1d32e9ce750b0388b9801ea66db7bd728aecae5f2e271dfa40f7c36852a5e3fbccf7a61a1f6de53d0f6bb0a0e1974d707403b77e19748b7c341b29697ead61a52340567a0abbdbee1e9307318322030306e6dbfe817ae2fbfeb54ca044286a855abc7335a3d67961e8d3f8665a14e25de60db22318fc40fdfddfc2d9896c4e6495995219e081124e3089707b6b66337280b6e4ab0182eb19e5e4ec57392f2a802c1dc2dbdc774e723ac0bf16f68b9f4a79a66388043beb5073a972e3160bebc192c02cc82339e72cf31b8662a6de21104774c0c22de6772b2fa19603b6f18185c2d8eafbaf5bd9dbf1546f2f02ddb2e24b3b6adcdbf2e1726202861e647c8943fa24c53ac7d81039d93f8399611761a3310b3794fc0a6afcfea03702be673a46002d5d6cc1c3f58d214092a1342020353b13daf7f1a4a73b367d2418cc0f25e4cfde316dd67cdb55f14136d754bb2277eee456653ac05da873b0e327210a70de55bbe3ae430137336f49a0f51858db8c8b6761575d56a0c9a6aa36357da6dad200d51b32eb4c0b85b129feb10d2d40ee3be0c0eeed918e34d5cc58ddcbfc7cdff9660d0e953a978ad7cba41c6a0746bb5d3c40b59ee4b482e035084d40a5817104a5fe80982283c0849406bdf36b32497ad46aa94a84f66f081f4b248fc29f50e289bf888d2a7ab8d74ac2821db0faa0c13f7ea5ed4e7eaca6b3c1444d05baa50aa71be40edc003db7418b78645680e26c92d8672be523350a3803a57ace446dd88e20bdbdc465fc36b4" + }, + { + "name": "x1.answer", + "from": "box", + "frames": [ + "0101000000a9a261746868656c6c6f5f6f6b6162a66372657607646d6f64656466756c6c64726f6c6565776174636865636c6f636ba366736f75726365636e747068757074696d654d7319ea606a73796e63656441744d731b00000199c82cc0006570726f746f016673636f70657384726674772e64697370617463682e77726974656f6674772e6576656e74732e726561646d6674772e6c6976652e726561646d6674772e706c616e2e72656164000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ], + "body": "244fe3b963e899dd295baffce248d3530f3a9a7479ba063002680ebfe7adad4991a408cd653ddf237f674b24d2df0548a107b8b35917e568c1d82f4463ba1da63832f4cd8f1db95e1d3a4ac19d27eb387fc479d455516c17e3e7a00136f9bf3b09fb666854531c0dd747cdc85109219b3996982844b365370e1616744294a597c19813bab2c15d2a3d9f59ef3a4e06ae1b5d18bdbe3356f12480871c20682670114ad3fb5b4b1e4cf6ea3ca85e172f39e934b73092cd80e280a496233c68179307c72d9e1ec6fe539d3bfa0a56dfbd1d10a02d54bc03bd2e3977bf7490b5fb4e1abb6ee28641ee44444c9beca86c673266971f98d2b1f0dbd123804c8a0fb9d3709c65348370e410c43f2042e7ffaeda2745fe81a11c6a961ac90ffb86f51c3e108ab8ea932620a2b7ef48377d4cc7b8478db79c2e2d329f4d22efa8a2bc098d6db8872b0271d2d8298a8bbff67dfd7a1bd63d284087fdb063f6b2bb32deee2740862e8ce3bd83bfd7dc2e898296e0b9caccad7027126f40c1987992d5a75d9824498d2e226740f892dd780c715f38b34034ccbdd98171b7868201ab8b4ebe0970d599f1962ed15d2ecc045667734ae1b1c7f210b6e0b44e198f2c44155a00d1befd484430cf97a974d540ce6bb20df298690ffb5606cc02c4b27f24e99dbe086df83abb7a240e873144e4091495be53a3b274956de126a10f84e5b18dd50666cc99459a23bb0ec16fb78110c577b4b46888fb15e8617d853876ea185a45b68375c8fff37cffc564ae0f6ee397ce4cba22011ac1c6bf87312a5a354b74c83e1cc4584adb51ae20695f69b2d6708e860a95d17b206afad31a7848e89a1c489cd849bb338d2e0601e1925b0265768d4e85233c185c107dad76328e9398c314d763ab2c1c400295d5f48ed728fcfa789bed80ad818b94dbad263632a9ec8f1828b56f0bd0345da0f218c81483a9c688eaaf26fb35085e5db204bc52c0f70d300795207b2f30d1975c6798c8e4ce6c8494886bc655753c9d5e851da7c22d631fd7da87b485f0506d906ebade0a9c9140bc29b5f92655da7f88c0fc3523d861320b9f9cf91cedecf0c06eafff0d9eeb9acf71f4cd00922b270dc85534daf03ac7e92e0e0a3da9781e2635677ecf28f1e6a7475c021d0dc141a9ab2b7a54375a3eff83093c8d07a00e5a20b105ac7977ee03b281c44c7e6e54ae3d7e946e4192c57ad65a55282e7472ac2a923a749ea03aa491bc4fffda6f7192a1a417b5b50a9f315b9753d72536c37ff047ca40b11a68a90d6468dec4e2e18134389fd876c0d550ba7f8cc15e7cc4fb02356c2e3e45e01e646fb357a81c9d47ea4454f982037864eea74762eb1fc3e3123e461e73dace602cffe66dcfc89ed0ae0545e5715992303a724003e175c86e7da8a9b77c6866d9afb50d505247c313b86aead1fbc0a7130a7e409cb9b8439f36155a8ca64ddb6430c952a4509d3d95fc84452ddcc502514d025891f424ead9ccd1ae8b04540f1c8f", + "relay": "8182838485868788898a8b8c8d8e8f90244fe3b963e899dd295baffce248d3530f3a9a7479ba063002680ebfe7adad4991a408cd653ddf237f674b24d2df0548a107b8b35917e568c1d82f4463ba1da63832f4cd8f1db95e1d3a4ac19d27eb387fc479d455516c17e3e7a00136f9bf3b09fb666854531c0dd747cdc85109219b3996982844b365370e1616744294a597c19813bab2c15d2a3d9f59ef3a4e06ae1b5d18bdbe3356f12480871c20682670114ad3fb5b4b1e4cf6ea3ca85e172f39e934b73092cd80e280a496233c68179307c72d9e1ec6fe539d3bfa0a56dfbd1d10a02d54bc03bd2e3977bf7490b5fb4e1abb6ee28641ee44444c9beca86c673266971f98d2b1f0dbd123804c8a0fb9d3709c65348370e410c43f2042e7ffaeda2745fe81a11c6a961ac90ffb86f51c3e108ab8ea932620a2b7ef48377d4cc7b8478db79c2e2d329f4d22efa8a2bc098d6db8872b0271d2d8298a8bbff67dfd7a1bd63d284087fdb063f6b2bb32deee2740862e8ce3bd83bfd7dc2e898296e0b9caccad7027126f40c1987992d5a75d9824498d2e226740f892dd780c715f38b34034ccbdd98171b7868201ab8b4ebe0970d599f1962ed15d2ecc045667734ae1b1c7f210b6e0b44e198f2c44155a00d1befd484430cf97a974d540ce6bb20df298690ffb5606cc02c4b27f24e99dbe086df83abb7a240e873144e4091495be53a3b274956de126a10f84e5b18dd50666cc99459a23bb0ec16fb78110c577b4b46888fb15e8617d853876ea185a45b68375c8fff37cffc564ae0f6ee397ce4cba22011ac1c6bf87312a5a354b74c83e1cc4584adb51ae20695f69b2d6708e860a95d17b206afad31a7848e89a1c489cd849bb338d2e0601e1925b0265768d4e85233c185c107dad76328e9398c314d763ab2c1c400295d5f48ed728fcfa789bed80ad818b94dbad263632a9ec8f1828b56f0bd0345da0f218c81483a9c688eaaf26fb35085e5db204bc52c0f70d300795207b2f30d1975c6798c8e4ce6c8494886bc655753c9d5e851da7c22d631fd7da87b485f0506d906ebade0a9c9140bc29b5f92655da7f88c0fc3523d861320b9f9cf91cedecf0c06eafff0d9eeb9acf71f4cd00922b270dc85534daf03ac7e92e0e0a3da9781e2635677ecf28f1e6a7475c021d0dc141a9ab2b7a54375a3eff83093c8d07a00e5a20b105ac7977ee03b281c44c7e6e54ae3d7e946e4192c57ad65a55282e7472ac2a923a749ea03aa491bc4fffda6f7192a1a417b5b50a9f315b9753d72536c37ff047ca40b11a68a90d6468dec4e2e18134389fd876c0d550ba7f8cc15e7cc4fb02356c2e3e45e01e646fb357a81c9d47ea4454f982037864eea74762eb1fc3e3123e461e73dace602cffe66dcfc89ed0ae0545e5715992303a724003e175c86e7da8a9b77c6866d9afb50d505247c313b86aead1fbc0a7130a7e409cb9b8439f36155a8ca64ddb6430c952a4509d3d95fc84452ddcc502514d025891f424ead9ccd1ae8b04540f1c8f" + }, + { + "name": "cmd.request", + "from": "watch", + "frames": [ + "0100000000a0a2617463636d646162a5626f706e6c6f6164706f696e742e686f6c646461726773a4626964636c703166686f6c645f730067706f7765725f77191cc06a70686173655f6d6f646562337065636d644964782430313932663261302d376331652d373030302d383030302d30313233343536373839616266657870656374a2637265760766677561726473f66f6e6f7456616c696441667465724d731a00015f9000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ], + "body": "00000000000000002f05f2f827669013b6401855f2c3530841cb799d4eefd8e17b1e37645aae08390436a01cd65d95d5d99ee068e6c4b29e17fae5691ed76e590e137c5c5bc511aae997370da80239ab098e976b3f52268757596ceec30e2c53b8c06a0a045bbc1cc1eae5b96b6bb5ebef1de9e33ef289d080e76709f70ad415fbe8f4384c854af091a421343dba5b1517dac9b4b6bcb36666b0788dbe76f941ed69c63ecffda45567b4bf8c2added7b80ad2fa111d1a2108b3bfe4c496a80fa9cb0a7b5ea783b98a6aafea982ff73c09b8f9aacfdf1cac0344b7d49023b16fccd2f06f79f8adfbe50394962711a16cb9f6ca18220d279eeb34eb8492d0e81bbec5277eeba7e3457f6616aa78c7099441245cf1fcf2006842a3ed9e4da83c28a867a470f8ff28c3444f2c443b222690cd1396f3005474511729c8db56d280789df8518ef6caecaa3dbab9b8cc24ba8bf6051d4dcef0da189694b2a5f7e537dcc3f10782b2b8cf39e2dc42be4910aed75a46e56a5d629480b331dce2443c072275dbfeda40e03c76d2afb6467af46e8b1f8055b612f9745612fd7dea0c3f64366c72e0e032a9fc2ed5f997ba12e88483a9a9ff539c3ad075a6227efe77009de6d0306298c059d394c2258a9efc165f5ac5be04e3e88a124a82454ab5581c9313ab193e2e44ea7f99220a9bb909d8fe6c0a274e0db5b7058ee7042492bf35ea0d12853283138d9ca4b9639dfd1674528f79ba59485e77617a3", + "relay": "8182838485868788898a8b8c8d8e8f9000000000000000002f05f2f827669013b6401855f2c3530841cb799d4eefd8e17b1e37645aae08390436a01cd65d95d5d99ee068e6c4b29e17fae5691ed76e590e137c5c5bc511aae997370da80239ab098e976b3f52268757596ceec30e2c53b8c06a0a045bbc1cc1eae5b96b6bb5ebef1de9e33ef289d080e76709f70ad415fbe8f4384c854af091a421343dba5b1517dac9b4b6bcb36666b0788dbe76f941ed69c63ecffda45567b4bf8c2added7b80ad2fa111d1a2108b3bfe4c496a80fa9cb0a7b5ea783b98a6aafea982ff73c09b8f9aacfdf1cac0344b7d49023b16fccd2f06f79f8adfbe50394962711a16cb9f6ca18220d279eeb34eb8492d0e81bbec5277eeba7e3457f6616aa78c7099441245cf1fcf2006842a3ed9e4da83c28a867a470f8ff28c3444f2c443b222690cd1396f3005474511729c8db56d280789df8518ef6caecaa3dbab9b8cc24ba8bf6051d4dcef0da189694b2a5f7e537dcc3f10782b2b8cf39e2dc42be4910aed75a46e56a5d629480b331dce2443c072275dbfeda40e03c76d2afb6467af46e8b1f8055b612f9745612fd7dea0c3f64366c72e0e032a9fc2ed5f997ba12e88483a9a9ff539c3ad075a6227efe77009de6d0306298c059d394c2258a9efc165f5ac5be04e3e88a124a82454ab5581c9313ab193e2e44ea7f99220a9bb909d8fe6c0a274e0db5b7058ee7042492bf35ea0d12853283138d9ca4b9639dfd1674528f79ba59485e77617a3" + }, + { + "name": "cmd.answer", + "from": "box", + "frames": [ + "010000000061a2617467636d642e61636b6162a365636d644964782430313932663261302d376331652d373030302d383030302d303132333435363738396162676c6561736549646d6c656173652d766563746f72736b6578706972657341744d731a000ea60000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "010000000073a261746a636d642e726573756c746162a365636d644964782430313932663261302d376331652d373030302d383030302d303132333435363738396162657374617465676170706c696564686f62736572766564a36373726364636f72656576616c7565f96f3068757074696d654d7319ee4800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ], + "body": "0000000000000000838f22d621c1e66d73bbdc82f73caa54360eef54cda0234b1fbfab2b827995c8ba42e1f96d434086fcc169a948ced5c831a611c8ffa39b090fd153aca92d25ef3ef58ac0e2ab6c8a22a5deca2b417f379cc7c5aa24d97ba847bdde7f7bf2934c508d678e53e484c92330f1ff050d413c4ab057e2b63a3b9dad88c2674e30771bb2a4702bb4b22aa37e21d8c795216571bdb5965d735e287ca457c4e7718d703e37472df910ca24b1bf28ec623862938dcf0e18bcafcdcad040ccbfbbc673d4ad0eb41adb10e9562581c83b98faa33dd2ee024d2445a899771f256e3a5f32c8486c22dda854c7343366eae435b3b13e774ef777a73cb074e7008d81ac888ce91655ea6a301372a91bc7a1838394cc7068a134f5055fc173d1366e176e2920415a74db10eda3fdfa004e24e2791d92694577013fc2cd24ba4157ad4ad338ff86c27ec265765d5eeb82b7e784d321c26d572b673623ec07faef9c97e18560ddff9f841b01a4b8bbe4599d5efb45b76020d7b76b2173df6881f8d37718f48caecb7a7e0099ad624d1798190194aaaca22f490c2c644f7c7d7e8d15704e80882b4012264889a429b9d71bded1d1f7878d5fa2dccdd261ca5059455156769e85e8f430ed12dc85d0aa8161066434ea18307eb766c460fece36993ca4a0e55f79d94eb74b1bc3759f75726b82082f661aa977f71e97d85fa7c82ec5a30029e50a8e76d23757f68dcf53585fa336e5b309621d2e0000000000000001bcec1b5bc5c32c1ccbeb75970da3607c892818005fa238dd8d499f2872d82be3c629c65e672f33090d8961a0dc2360e37b9648b11f888428ebd32b6a8cbc75154eadbfe23814cf8210a2154c13225781cafaa1d5fd7cd5dd0b851c6d1c927886b40f34b301ce45b8d387063506cfcc1e8c2e0ea6459e6300cff9095eff3ba6101256a7c1aee64ae8bf4be263ad6e989606f171d7f21e2f5bcdf2c27ec0d5ead1d37e3f669286691d9b5190a3825dbd51a8917df93893844350bbb048d73b005e9a6d869eb01155bd26ad6d82b2b88f50e45d9fc3ab35f1498a2bff32799b5d59d293fa7b88629e4b8bbeec640cf123595962da041278709aa32dd55a75088fb11e584fac66b82e1776444b67f049485c9101ef81868b3ffd2f872644c87f66d324e5b38e5fe96f000081f139b73e57dd42e58e7456105c2dbce6b8660d3c93ce64e35262867586bedc8405f698855c3f993ffbbddb34b320794e512da21ff829d3ae8ca35c42b7381397b8d4b92d6f2b73df93c5194ba4b31d2754784fc42629d9659bc403a80d9243d6ece4869b2c1d2f39e7da18ea77429ecd29e1acb41aaaca7516fd7908f04ec447e02c928d062ddf25b8dae802d9e1d4db2a8d38f6a8f74c1029209f132a0bcdbeb61cda0ff632c031c62669561805d75a317f8b10d690e5bdee169b0f225d746b9616ad245ebee0fc8a386de1c7aabf9d4c13bd51882f48d1808a995d69f9c88b988434a74712", + "relay": "8182838485868788898a8b8c8d8e8f900000000000000000838f22d621c1e66d73bbdc82f73caa54360eef54cda0234b1fbfab2b827995c8ba42e1f96d434086fcc169a948ced5c831a611c8ffa39b090fd153aca92d25ef3ef58ac0e2ab6c8a22a5deca2b417f379cc7c5aa24d97ba847bdde7f7bf2934c508d678e53e484c92330f1ff050d413c4ab057e2b63a3b9dad88c2674e30771bb2a4702bb4b22aa37e21d8c795216571bdb5965d735e287ca457c4e7718d703e37472df910ca24b1bf28ec623862938dcf0e18bcafcdcad040ccbfbbc673d4ad0eb41adb10e9562581c83b98faa33dd2ee024d2445a899771f256e3a5f32c8486c22dda854c7343366eae435b3b13e774ef777a73cb074e7008d81ac888ce91655ea6a301372a91bc7a1838394cc7068a134f5055fc173d1366e176e2920415a74db10eda3fdfa004e24e2791d92694577013fc2cd24ba4157ad4ad338ff86c27ec265765d5eeb82b7e784d321c26d572b673623ec07faef9c97e18560ddff9f841b01a4b8bbe4599d5efb45b76020d7b76b2173df6881f8d37718f48caecb7a7e0099ad624d1798190194aaaca22f490c2c644f7c7d7e8d15704e80882b4012264889a429b9d71bded1d1f7878d5fa2dccdd261ca5059455156769e85e8f430ed12dc85d0aa8161066434ea18307eb766c460fece36993ca4a0e55f79d94eb74b1bc3759f75726b82082f661aa977f71e97d85fa7c82ec5a30029e50a8e76d23757f68dcf53585fa336e5b309621d2e0000000000000001bcec1b5bc5c32c1ccbeb75970da3607c892818005fa238dd8d499f2872d82be3c629c65e672f33090d8961a0dc2360e37b9648b11f888428ebd32b6a8cbc75154eadbfe23814cf8210a2154c13225781cafaa1d5fd7cd5dd0b851c6d1c927886b40f34b301ce45b8d387063506cfcc1e8c2e0ea6459e6300cff9095eff3ba6101256a7c1aee64ae8bf4be263ad6e989606f171d7f21e2f5bcdf2c27ec0d5ead1d37e3f669286691d9b5190a3825dbd51a8917df93893844350bbb048d73b005e9a6d869eb01155bd26ad6d82b2b88f50e45d9fc3ab35f1498a2bff32799b5d59d293fa7b88629e4b8bbeec640cf123595962da041278709aa32dd55a75088fb11e584fac66b82e1776444b67f049485c9101ef81868b3ffd2f872644c87f66d324e5b38e5fe96f000081f139b73e57dd42e58e7456105c2dbce6b8660d3c93ce64e35262867586bedc8405f698855c3f993ffbbddb34b320794e512da21ff829d3ae8ca35c42b7381397b8d4b92d6f2b73df93c5194ba4b31d2754784fc42629d9659bc403a80d9243d6ece4869b2c1d2f39e7da18ea77429ecd29e1acb41aaaca7516fd7908f04ec447e02c928d062ddf25b8dae802d9e1d4db2a8d38f6a8f74c1029209f132a0bcdbeb61cda0ff632c031c62669561805d75a317f8b10d690e5bdee169b0f225d746b9616ad245ebee0fc8a386de1c7aabf9d4c13bd51882f48d1808a995d69f9c88b988434a74712" + }, + { + "name": "now.request", + "from": "watch", + "frames": [ + "01000000000aa26174636e6f776162a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ], + "body": "0000000000000001eb023948c7de3836d2d2b54f2c07a885cbf5571e6f49c475b2b8975c6f2a04f5820a0e2cfacb4c98391c88b5a96539d611340e69050bfe0276bb5f3d634457c8ea82f347837fd85aa7b64ec223a8c818c0207b1eb79173cf86cb3133e939f19d6188e9d318dbc6b63826379fc08f27e9aa3636abca17bba3f11c260b281884f51d45d1290e97048cdc7683d1ff29da30d8e20804bab193c80f9a67d15cf637d228964d13ea89e471a00c085aa8d2437bdb38755a51fecbd26624e3130161b38a85223ae40bf8125dd06fab7bd341529572aa364b778a5c976c281e32214252fd288463ba2ee566f07b24b8a49039c0485f51244f1d1f18e950a077f55a0ddeca83c9dbfdaf894d2f7f2dde4c9a89efc46a4021b9139d854dd9a36782a810a92840d962d1e711e1e1044e24b4bebed02649922254ba96bb4f99d89fd161f11e0a8554109dc03b747f11a8a92149958d9c8be127de0f4e255a3244d0633db10df472500d710d4db65f86f928efa73dea0d23a133682e5fca385ad37924de2cfc772c04474e3b4c0b7d230fec5e25019f3e341379315e0a4a30c78c40af81ae932f76746f2bdf7a39b1aa2ad098679c3626e6b940d67c9d6c35cffc3c6070c7a33ee5d231336a231b9098e1440c30714fcdadf7031c128c46f881eb16eaa83668a2ba8d8021faf6db9522919de2918a05cdca883c1c3170c77c605e030a89835535378d68207e24b6b1565e9ef3196d5415", + "relay": "8182838485868788898a8b8c8d8e8f900000000000000001eb023948c7de3836d2d2b54f2c07a885cbf5571e6f49c475b2b8975c6f2a04f5820a0e2cfacb4c98391c88b5a96539d611340e69050bfe0276bb5f3d634457c8ea82f347837fd85aa7b64ec223a8c818c0207b1eb79173cf86cb3133e939f19d6188e9d318dbc6b63826379fc08f27e9aa3636abca17bba3f11c260b281884f51d45d1290e97048cdc7683d1ff29da30d8e20804bab193c80f9a67d15cf637d228964d13ea89e471a00c085aa8d2437bdb38755a51fecbd26624e3130161b38a85223ae40bf8125dd06fab7bd341529572aa364b778a5c976c281e32214252fd288463ba2ee566f07b24b8a49039c0485f51244f1d1f18e950a077f55a0ddeca83c9dbfdaf894d2f7f2dde4c9a89efc46a4021b9139d854dd9a36782a810a92840d962d1e711e1e1044e24b4bebed02649922254ba96bb4f99d89fd161f11e0a8554109dc03b747f11a8a92149958d9c8be127de0f4e255a3244d0633db10df472500d710d4db65f86f928efa73dea0d23a133682e5fca385ad37924de2cfc772c04474e3b4c0b7d230fec5e25019f3e341379315e0a4a30c78c40af81ae932f76746f2bdf7a39b1aa2ad098679c3626e6b940d67c9d6c35cffc3c6070c7a33ee5d231336a231b9098e1440c30714fcdadf7031c128c46f881eb16eaa83668a2ba8d8021faf6db9522919de2918a05cdca883c1c3170c77c605e030a89835535378d68207e24b6b1565e9ef3196d5415" + }, + { + "name": "now.answer", + "from": "box", + "frames": [ + "0100000000e3a26174636e6f776162a6666669656c6473a761310261321904b06133390d476134190384613519026c6136190e74623130191cc067736f7572636573a36137a2656167654d73190190657374617465646c6976656138a2656167654d73190190657374617465646c6976656139a2656167654d73190190657374617465646c69766568757074696d654d7319f2306a636f6e74726f6c526576076a6c6f6164706f696e747381a4626964636c703166706f77657257191cc067706c7567676564f56b736f635065726d696c6c6519026c716469737061746368426c6f636b6564427980000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "01000000001ca26174647469636b6162a2637365710068757074696d654d7319f23000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + ], + "body": "0000000000000002eb18033457557e46323af76b55c1075142e8a82f1df52f9317ff6bda3dca6c75ff401c1b178f915736e4afc88fc70fbf6f26f0f6288d3ef9675299cf5852ea6fe489fece2e55a278c996f4371fa056c290d95d3f72a5ed925e85f957a8461f1670ce1ca74a8d17e770ad950adfa84011f7a25e23bcaa0fcb088bdfc37014476d2ba18e7b06b751992ceb59b692ee1b1170f09466936bda632cfac87bc422bd6e0faf9bb41c40241a4d6cb52c78eec556d32d08fddb0262dee192aadedd0647963ae88c344838d393200865724ff5cf704ff238bee8beb2c2476f49166765eccc354d85cccf28d2b1ee66fdd5863f2f85762706fb335245b3f9b447734b1f52e40a76448c33e03c189979c7f8a9849da73526bcbb0801a9024531000f51424202ec778ee70316909d6bb9df79187ce1201434b90808cb01a4c8f9ca94a7a8a4fde75dc931fb429c69ffa09c52f554bd84ad09559fa4a650e8267b2b99cdd464d30ad6a9b1bce6d678ce4f964045bd35fb66e245d9ec3c4f68bbae38d9d96a077bc6b9f0e1555bf89d0e60b929258083316d45d5e61867c11a7b8a6c9ff2ccc4ef0faac3d946d78157b8757eca309e20f96bfcddd8c424380599357885bb9873c65972953bf46c3ef48277ac25ed7d8d6689a076ebd812678a30644eb29b134b3dab441d104e8e08b13226bc4c57fb8a633fc7306339707eaa1bb2c7740c19a5e236c70c8d8c51aa8cbe3f6eea6597a29b000000000000000390a50f3904d67213ca2b7434b382230d68f7562fbfe5e283b2548559239aeea7e2b91e7d63bfe8e628b7b53d134a83e321386dd0c4034c8d77cd4a675cb28477d5b27ff62135c8b8e1964b002f083ede920a3f23a8244eb98f1869ce01916fb848529b614d3f6b9f74d46462486cc79b9410369dd91c0b3add128f848f94f4effbb0fd035da1e780c595c8375aa26f6305fccdd25241b1b05a44b56044db443ce2e5100b19ab29e020a1b3c4a18463aaeabd9c9f42ae682bca5b4df1e739fc61ce9ad49a51b039e9abf2fb4f74c68ffce90325e51568f44bf7fd634abef35376f416334cc64547371b0298a8a1dbebf4d8291265e3260a4e94f84c8779df418c4c9dda8d6e5d0ffabbe4506e4732723f8a463b56fca8aeca1b4bf20cf831e88c309729d49bdbfd290090ea55caced3deb8805994e3e90440b6d79066ec00a337ff9e02cde9f7520faaf27824de79f1177c301ff7bfc41bdfbcfaa9f93ccfd2e2298f5023fe5176209c785cf6a397bb5d2531c50f84bcfe1cba89633df4a6da8658b76a10f3f5864c2f4875260468754d801f48d24b693a8178fe4c1acc4f13240db5393d6c34a9aa0b2730c48c248fa6f0dc440997f2d37ce4fdfd234350f8f58e3219572f92bcd828ae6d3c27b90644669d2268fe865d818e9dd811b9542a9e9ae553070bad06f63b2a8a54f3905e8207f2aafe899659ce966cc4d0ffb47ebd94af6202855672789b1373931964d8d4", + "relay": "8182838485868788898a8b8c8d8e8f900000000000000002eb18033457557e46323af76b55c1075142e8a82f1df52f9317ff6bda3dca6c75ff401c1b178f915736e4afc88fc70fbf6f26f0f6288d3ef9675299cf5852ea6fe489fece2e55a278c996f4371fa056c290d95d3f72a5ed925e85f957a8461f1670ce1ca74a8d17e770ad950adfa84011f7a25e23bcaa0fcb088bdfc37014476d2ba18e7b06b751992ceb59b692ee1b1170f09466936bda632cfac87bc422bd6e0faf9bb41c40241a4d6cb52c78eec556d32d08fddb0262dee192aadedd0647963ae88c344838d393200865724ff5cf704ff238bee8beb2c2476f49166765eccc354d85cccf28d2b1ee66fdd5863f2f85762706fb335245b3f9b447734b1f52e40a76448c33e03c189979c7f8a9849da73526bcbb0801a9024531000f51424202ec778ee70316909d6bb9df79187ce1201434b90808cb01a4c8f9ca94a7a8a4fde75dc931fb429c69ffa09c52f554bd84ad09559fa4a650e8267b2b99cdd464d30ad6a9b1bce6d678ce4f964045bd35fb66e245d9ec3c4f68bbae38d9d96a077bc6b9f0e1555bf89d0e60b929258083316d45d5e61867c11a7b8a6c9ff2ccc4ef0faac3d946d78157b8757eca309e20f96bfcddd8c424380599357885bb9873c65972953bf46c3ef48277ac25ed7d8d6689a076ebd812678a30644eb29b134b3dab441d104e8e08b13226bc4c57fb8a633fc7306339707eaa1bb2c7740c19a5e236c70c8d8c51aa8cbe3f6eea6597a29b000000000000000390a50f3904d67213ca2b7434b382230d68f7562fbfe5e283b2548559239aeea7e2b91e7d63bfe8e628b7b53d134a83e321386dd0c4034c8d77cd4a675cb28477d5b27ff62135c8b8e1964b002f083ede920a3f23a8244eb98f1869ce01916fb848529b614d3f6b9f74d46462486cc79b9410369dd91c0b3add128f848f94f4effbb0fd035da1e780c595c8375aa26f6305fccdd25241b1b05a44b56044db443ce2e5100b19ab29e020a1b3c4a18463aaeabd9c9f42ae682bca5b4df1e739fc61ce9ad49a51b039e9abf2fb4f74c68ffce90325e51568f44bf7fd634abef35376f416334cc64547371b0298a8a1dbebf4d8291265e3260a4e94f84c8779df418c4c9dda8d6e5d0ffabbe4506e4732723f8a463b56fca8aeca1b4bf20cf831e88c309729d49bdbfd290090ea55caced3deb8805994e3e90440b6d79066ec00a337ff9e02cde9f7520faaf27824de79f1177c301ff7bfc41bdfbcfaa9f93ccfd2e2298f5023fe5176209c785cf6a397bb5d2531c50f84bcfe1cba89633df4a6da8658b76a10f3f5864c2f4875260468754d801f48d24b693a8178fe4c1acc4f13240db5393d6c34a9aa0b2730c48c248fa6f0dc440997f2d37ce4fdfd234350f8f58e3219572f92bcd828ae6d3c27b90644669d2268fe865d818e9dd811b9542a9e9ae553070bad06f63b2a8a54f3905e8207f2aafe899659ce966cc4d0ffb47ebd94af6202855672789b1373931964d8d4" + } + ] +} diff --git a/go/internal/appuplink/vectors_test.go b/go/internal/appuplink/vectors_test.go new file mode 100644 index 000000000..9905f4070 --- /dev/null +++ b/go/internal/appuplink/vectors_test.go @@ -0,0 +1,320 @@ +package appuplink + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "flag" + "os" + "testing" + + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/protocol/appwire" +) + +// The exchange's golden vectors (ADR 0009, "Vectors"). +// +// testdata/exchange-vectors.json holds the bytes of every step of one watch +// session, from fixed keys: X1 with a pairing code, a command answered by its +// ack and result, and a read answered by now and a tick. The Swift client in +// srcfl/ftw-app replays the same file, so Go and Swift agree on every byte or +// one of them fails. Regenerate with +// +// go test ./internal/appuplink -run TestExchangeVectors -update-exchange-vectors + +var updateExchangeVectors = flag.Bool("update-exchange-vectors", false, "rewrite testdata/exchange-vectors.json") + +type exchangeVectors struct { + About string `json:"about"` + ProtocolName string `json:"protocolName"` + Prologue string `json:"prologue"` + + BoxStatic string `json:"boxStatic"` + BoxStaticPublic string `json:"boxStaticPublic"` + BoxEphemeral string `json:"boxEphemeral"` + WatchStatic string `json:"watchStatic"` + WatchStaticPublic string `json:"watchStaticPublic"` + WatchEphemeral string `json:"watchEphemeral"` + HandshakeHash string `json:"handshakeHash"` + + Steps []exchangeVectorStep `json:"steps"` +} + +// exchangeVectorStep is one message across the relay. Frames are the +// plaintext lane frames inside it; Body is what follows the relay's id, the +// bytes the watch posts or the answer it receives; Relay is id ‖ Body as it +// crosses the box's exchange socket. +type exchangeVectorStep struct { + Name string `json:"name"` + From string `json:"from"` + Frames []string `json:"frames"` + Body string `json:"body"` + Relay string `json:"relay"` +} + +// fixed is n bytes counting up from first: a key nobody would use, which is +// the point. +func fixed(first byte, n int) []byte { + out := make([]byte, n) + for i := range out { + out[i] = first + byte(i) + } + return out +} + +func frameOf(t *testing.T, lane uint8, bucket int, msgType string, body any) []byte { + t.Helper() + raw, err := appproto.Marshal(body) + if err != nil { + t.Fatal(err) + } + frame, err := appwire.EncodeFrame(appwire.Frame{ + Lane: lane, Bucket: bucket, Envelope: appwire.Envelope{T: msgType, B: raw}, + }) + if err != nil { + t.Fatal(err) + } + return frame +} + +func buildExchangeVectors(t *testing.T) exchangeVectors { + t.Helper() + keyPair := func(first byte) appwire.KeyPair { + kp, err := appwire.KeyPairFromSecret(fixed(first, appwire.DHBytes)) + if err != nil { + t.Fatal(err) + } + return kp + } + boxStatic, boxEphemeral := keyPair(0x01), keyPair(0x61) + watchStatic, watchEphemeral := keyPair(0x21), keyPair(0x41) + id := fixed(0x81, ExchangeIDBytes) + prologueBytes := exchangePrologue(boxStatic.Public()) + + watch, err := appwire.NewInitiator(appwire.InitiatorOptions{ + StaticKey: watchStatic, RemoteStatic: boxStatic.Public(), Prologue: prologueBytes, Ephemeral: &watchEphemeral, + }) + if err != nil { + t.Fatal(err) + } + box, err := appwire.NewResponder(appwire.HandshakeOptions{ + StaticKey: boxStatic, Prologue: prologueBytes, Ephemeral: &boxEphemeral, + }) + if err != nil { + t.Fatal(err) + } + + v := exchangeVectors{ + About: "ADR 0009 watch exchange: Noise IK with prologue ftw.exchange.v1: and the box's static key. " + + "Every frame but hello_ok is lane 0 in 512 B; every answer after the handshake is two transport messages.", + ProtocolName: appwire.ProtocolName, + Prologue: hex.EncodeToString(prologueBytes), + BoxStatic: hex.EncodeToString(fixed(0x01, appwire.DHBytes)), + BoxStaticPublic: hex.EncodeToString(boxStatic.Public()), + BoxEphemeral: hex.EncodeToString(fixed(0x61, appwire.DHBytes)), + WatchStatic: hex.EncodeToString(fixed(0x21, appwire.DHBytes)), + WatchStaticPublic: hex.EncodeToString(watchStatic.Public()), + WatchEphemeral: hex.EncodeToString(fixed(0x41, appwire.DHBytes)), + } + step := func(name, from string, frames [][]byte, body []byte) { + s := exchangeVectorStep{Name: name, From: from, Body: hex.EncodeToString(body), + Relay: hex.EncodeToString(append(append([]byte(nil), id...), body...))} + for _, f := range frames { + s.Frames = append(s.Frames, hex.EncodeToString(f)) + } + v.Steps = append(v.Steps, s) + } + + // X1: hello with the pairing code in message 1, hello_ok in message 2. + hello := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgHello, appproto.Hello{ + Proto: appproto.ProtoRange{Min: appproto.ProtoMin, Max: appproto.ProtoMax}, + App: appproto.AppInfo{Build: "vectors", UA: "watchos"}, + Code: fixed(0xa1, 16), + }) + message1, err := watch.WriteMessage(hello) + if err != nil { + t.Fatal(err) + } + if got, err := box.ReadMessage(message1); err != nil || !bytes.Equal(got, hello) { + t.Fatalf("the box read message 1 as %x, %v", got, err) + } + step("x1.request", "watch", [][]byte{hello}, message1) + + helloOK := frameOf(t, appwire.LaneBulk, 1024, appproto.MsgHelloOK, appproto.ExchangeHelloOK{ + Proto: appproto.ProtoMax, + Mode: appproto.BoxModeFull, + Clock: appproto.Clock{Source: "ntp", SyncedAtMs: 1_760_000_000_000, UptimeMs: 60_000}, + Role: "watch", + Scopes: []string{"ftw.dispatch.write", "ftw.events.read", "ftw.live.read", "ftw.plan.read"}, + Rev: 7, + }) + message2, err := box.WriteMessage(helloOK) + if err != nil { + t.Fatal(err) + } + if got, err := watch.ReadMessage(message2); err != nil || !bytes.Equal(got, helloOK) { + t.Fatalf("the watch read message 2 as %x, %v", got, err) + } + step("x1.answer", "box", [][]byte{helloOK}, message2) + + watchSession, err := watch.Split() + if err != nil { + t.Fatal(err) + } + boxSession, err := box.Split() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(watchSession.HandshakeHash, boxSession.HandshakeHash) { + t.Fatal("the two ends disagree on the handshake hash") + } + v.HandshakeHash = hex.EncodeToString(boxSession.HandshakeHash) + watchT, boxT := appwire.NewTransport(watchSession), appwire.NewTransport(boxSession) + + seal := func(tr *appwire.Transport, frames ...[]byte) []byte { + var out []byte + for _, f := range frames { + sealed, err := tr.Encrypt(f) + if err != nil { + t.Fatal(err) + } + out = append(out, sealed...) + } + return out + } + open := func(tr *appwire.Transport, body []byte, frames ...[]byte) { + for i, want := range frames { + got, err := tr.Decrypt(body[i*exchangeRequestBytes : (i+1)*exchangeRequestBytes]) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("transport message %d opened as %x, %v", i, got, err) + } + } + } + + // A command, answered by its ack and its result. + cmd := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgCmd, appproto.Cmd{ + CmdID: "0192f2a0-7c1e-7000-8000-0123456789ab", Op: appproto.OpLoadpointHold, + Args: map[string]any{"id": "lp1", "power_w": 7360, "hold_s": 0, "phase_mode": "3p"}, + NotValidAfterMs: 90_000, Expect: appproto.Expect{Rev: 7}, + }) + body := seal(watchT, cmd) + open(boxT, body, cmd) + step("cmd.request", "watch", [][]byte{cmd}, body) + + ack := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgCmdAck, appproto.CmdAck{ + CmdID: "0192f2a0-7c1e-7000-8000-0123456789ab", LeaseID: "lease-vectors", ExpiresAtMs: 960_000, + }) + result := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgCmdResult, appproto.CmdResult{ + CmdID: "0192f2a0-7c1e-7000-8000-0123456789ab", State: appproto.CmdApplied, + Observed: &appproto.Observed{Value: 7360, Src: appproto.ObservedSrcCore, UptimeMs: 61_000}, + }) + body = seal(boxT, ack, result) + open(watchT, body, ack, result) + step("cmd.answer", "box", [][]byte{ack, result}, body) + + // A read, answered by now and a tick. + soc := int64(620) + read := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgNow, struct{}{}) + body = seal(watchT, read) + open(boxT, body, read) + step("now.request", "watch", [][]byte{read}, body) + + now := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgNow, appproto.Now{ + UptimeMs: 62_000, ControlRev: 7, + Fields: map[string]int64{"1": 2, "2": 1200, "3": -3400, "4": 900, "5": 620, "6": 3700, "10": 7360}, + Sources: map[string]appproto.NowSource{ + "7": {State: appproto.SourceLive, AgeMs: 400}, + "8": {State: appproto.SourceLive, AgeMs: 400}, + "9": {State: appproto.SourceLive, AgeMs: 400}, + }, + DispatchBlockedBy: []string{}, + Loadpoints: []appproto.NowLoadpoint{{ID: "lp1", Plugged: true, SocPermille: &soc, PowerW: 7360}}, + }) + tick := frameOf(t, appwire.LaneControl, exchangeFrameBytes, appproto.MsgTick, appproto.Tick{UptimeMs: 62_000}) + body = seal(boxT, now, tick) + open(watchT, body, now, tick) + step("now.answer", "box", [][]byte{now, tick}, body) + + return v +} + +func TestExchangeVectors(t *testing.T) { + got, err := json.MarshalIndent(buildExchangeVectors(t), "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + const path = "testdata/exchange-vectors.json" + if *updateExchangeVectors { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v; regenerate with -update-exchange-vectors", err) + } + if !bytes.Equal(got, want) { + t.Fatal("the exchange no longer produces the bytes in " + path + + "; if the change is meant, regenerate it and update srcfl/ftw-app's copy in the same pair of PRs") + } + + // The sizes the relay checks and the docs promise. + var v exchangeVectors + if err := json.Unmarshal(want, &v); err != nil { + t.Fatal(err) + } + sizes := map[string]int{ + "x1.request": exchangeOpenBytes, "x1.answer": appwire.Message2Overhead + 1024, + "cmd.request": exchangeRequestBytes, "cmd.answer": 2 * exchangeRequestBytes, + "now.request": exchangeRequestBytes, "now.answer": 2 * exchangeRequestBytes, + } + for _, s := range v.Steps { + if n := len(s.Body) / 2; n != sizes[s.Name] { + t.Fatalf("%s is %d bytes, want %d", s.Name, n, sizes[s.Name]) + } + } +} + +// The box's own code path reads the vectors' message 1 and answers with their +// message 2, given the same fixed ephemeral key: the vectors are what the box +// does, not a second implementation that happens to agree with itself. +func TestTheBoxAnswersTheVectorsHandshake(t *testing.T) { + v := buildExchangeVectors(t) + boxStatic, err := appwire.KeyPairFromSecret(fixed(0x01, appwire.DHBytes)) + if err != nil { + t.Fatal(err) + } + boxEphemeral, err := appwire.KeyPairFromSecret(fixed(0x61, appwire.DHBytes)) + if err != nil { + t.Fatal(err) + } + box, err := appwire.NewResponder(appwire.HandshakeOptions{ + StaticKey: boxStatic, Prologue: exchangePrologue(boxStatic.Public()), Ephemeral: &boxEphemeral, + }) + if err != nil { + t.Fatal(err) + } + message1, _ := hex.DecodeString(v.Steps[0].Body) + payload, err := box.ReadMessage(message1) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(payload) != v.Steps[0].Frames[0] { + t.Fatal("the box read a different hello out of the vectors' message 1") + } + helloOK, _ := hex.DecodeString(v.Steps[1].Frames[0]) + message2, err := box.WriteMessage(helloOK) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(message2) != v.Steps[1].Body { + t.Fatal("the box's message 2 differs from the vectors'") + } + if hex.EncodeToString(box.RemoteStatic()) != v.WatchStaticPublic { + t.Fatal("the box authenticated a different watch key") + } +} diff --git a/go/internal/appuplink/watchclient_test.go b/go/internal/appuplink/watchclient_test.go new file mode 100644 index 000000000..4908063ae --- /dev/null +++ b/go/internal/appuplink/watchclient_test.go @@ -0,0 +1,196 @@ +package appuplink + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/srcfl/ftw/go/internal/appproto" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/protocol/appwire" +) + +// watchClient is a watch, in Go: the initiator side of the exchange, built on +// appwire's initiator the way the Swift one is built on CryptoKit. It holds a +// static key across sessions and nothing else. +type watchClient struct { + static appwire.KeyPair + handshake *appwire.InitiatorState + transport *appwire.Transport +} + +func newWatchClient() (*watchClient, error) { + static, err := appwire.GenerateKeyPair() + if err != nil { + return nil, err + } + return &watchClient{static: static}, nil +} + +// lane0 frames one message in lane 0's 512 B bucket, as every exchange +// request is. +func lane0(msgType string, id *uint32, body any) ([]byte, error) { + raw, err := appproto.Marshal(body) + if err != nil { + return nil, err + } + return appwire.EncodeFrame(appwire.Frame{ + Lane: appwire.LaneControl, + Bucket: exchangeFrameBytes, + Envelope: appwire.Envelope{T: msgType, ID: id, B: raw}, + }) +} + +// open is X1's request: handshake message 1 carrying a hello, with the +// pairing code on the first run only. +func (w *watchClient) open(boxStatic, code []byte) ([]byte, error) { + handshake, err := appwire.NewInitiator(appwire.InitiatorOptions{ + StaticKey: w.static, + RemoteStatic: boxStatic, + Prologue: exchangePrologue(boxStatic), + }) + if err != nil { + return nil, err + } + hello, err := lane0(appproto.MsgHello, nil, appproto.Hello{ + Proto: appproto.ProtoRange{Min: appproto.ProtoMin, Max: appproto.ProtoMax}, + App: appproto.AppInfo{Build: "test", UA: "watchos"}, + Code: code, + }) + if err != nil { + return nil, err + } + w.handshake = handshake + w.transport = nil + return handshake.WriteMessage(hello) +} + +// opened reads X1's answer: handshake message 2 carrying hello_ok. +func (w *watchClient) opened(message2 []byte) (appproto.ExchangeHelloOK, appwire.Frame, error) { + var ok appproto.ExchangeHelloOK + payload, err := w.handshake.ReadMessage(message2) + if err != nil { + return ok, appwire.Frame{}, err + } + session, err := w.handshake.Split() + if err != nil { + return ok, appwire.Frame{}, err + } + w.transport = appwire.NewTransport(session) + frame, err := appwire.DecodeFrame(payload) + if err != nil { + return ok, frame, err + } + if frame.Envelope.T != appproto.MsgHelloOK { + return ok, frame, fmt.Errorf("message 2 carries %q", frame.Envelope.T) + } + return ok, frame, appproto.Unmarshal(frame.Envelope.B, &ok) +} + +// request seals one request in the session. +func (w *watchClient) request(msgType string, body any) ([]byte, error) { + frame, err := lane0(msgType, nil, body) + if err != nil { + return nil, err + } + return w.transport.Encrypt(frame) +} + +// answer opens an answer: transport messages back to back, one per frame. +func (w *watchClient) answer(reply []byte) ([]appwire.Frame, error) { + if len(reply)%exchangeRequestBytes != 0 { + return nil, fmt.Errorf("an answer of %d bytes is not whole transport messages", len(reply)) + } + var frames []appwire.Frame + for len(reply) > 0 { + plain, err := w.transport.Decrypt(reply[:exchangeRequestBytes]) + if err != nil { + return nil, err + } + frame, err := appwire.DecodeFrame(plain) + if err != nil { + return nil, err + } + frames = append(frames, frame) + reply = reply[exchangeRequestBytes:] + } + return frames, nil +} + +// -------------------------------------------------------------------------- +// A charger, so a watch has something to do +// -------------------------------------------------------------------------- + +// fakeCharger is one loadpoint, "lp1", plugged in. save is how long the EV +// save takes, which is what an applied charge waits for; slow holds the +// command itself up past what a save may take. +type fakeCharger struct { + mu sync.Mutex + holds int + hold loadpoint.ManualHold + held bool + save time.Duration + slow time.Duration +} + +func (c *fakeCharger) count() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.holds +} + +func (c *fakeCharger) WaitForPersistence(ctx context.Context) error { + c.mu.Lock() + save := c.save + c.mu.Unlock() + select { + case <-time.After(save): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *fakeCharger) Exists(id string) bool { return id == "lp1" } +func (c *fakeCharger) Hold(_ string, h loadpoint.ManualHold) { + c.mu.Lock() + slow := c.slow + c.holds++ + c.hold, c.held = h, true + c.mu.Unlock() + time.Sleep(slow) +} +func (c *fakeCharger) ClearHold(string) { + c.mu.Lock() + defer c.mu.Unlock() + c.held = false +} +func (c *fakeCharger) ObservedHold(string, time.Time) (loadpoint.ManualHold, bool) { + c.mu.Lock() + defer c.mu.Unlock() + return c.hold, c.held +} +func (c *fakeCharger) Boost(string, loadpoint.BatteryBoostLease, time.Time) error { return nil } +func (c *fakeCharger) CancelBoost(string, time.Time) {} +func (c *fakeCharger) ObservedBoost(string, time.Time) loadpoint.BatteryBoostStatus { + return loadpoint.BatteryBoostStatus{} +} +func (c *fakeCharger) SetSoC(string, float64) bool { return true } +func (c *fakeCharger) ObservedSoC(string) (float64, bool) { return 0.62, true } +func (c *fakeCharger) SetSurplusOnly(string, bool) (bool, bool) { return false, true } +func (c *fakeCharger) ObservedSurplusOnly(string) (bool, bool) { return false, true } +func (c *fakeCharger) Readings() []appproto.LoadpointReading { + return []appproto.LoadpointReading{{ID: "lp1", Plugged: true, SoC: 0.62, SoCKnown: true, PowerW: 7360}} +} + +// chargeNow is the watch's one button: hold lp1 at 7.4 kW until cleared. +func chargeNow(cmdID string) appproto.Cmd { + return appproto.Cmd{ + CmdID: cmdID, + Op: appproto.OpLoadpointHold, + Args: map[string]any{"id": "lp1", "power_w": 7360, "hold_s": 0, "phase_mode": "3p"}, + NotValidAfterMs: 10 * 60 * 1000, + Expect: appproto.Expect{Rev: 1}, + } +} diff --git a/go/protocol/messages.go b/go/protocol/messages.go index 7876a89aa..5d755a1c2 100644 --- a/go/protocol/messages.go +++ b/go/protocol/messages.go @@ -25,6 +25,9 @@ const ( MsgAPIHead = "api.head" MsgAPIChunk = "api.chunk" MsgAPIEnd = "api.end" + // MsgNow is a watch's read of the home over the exchange, and the box's + // answer to it (ADR 0009). The same type both ways, as tick is. + MsgNow = "now" ) // Operations a client may ask for, from contract/registry.yaml `ops` — the @@ -86,6 +89,10 @@ type Hello struct { // ignore this additive field; subscribed in hello_ok tells a newer app whether // it still needs to send the ordinary sub message. Sub *Sub `cbor:"sub,omitempty"` + // Code is a watch's pairing code, sent once, in the hello that rides in + // the exchange's handshake message 1. A phone's code travels as the + // whole of its message 1 payload instead, and a paired watch sends none. + Code []byte `cbor:"code,omitempty"` } type ProtoRange struct { @@ -142,6 +149,27 @@ type HelloOK struct { Cloud *CloudSession `cbor:"cloud,omitempty"` } +// ExchangeHelloOK answers a watch's hello on the exchange, in handshake +// message 2 (ADR 0009, decision 2). +// +// It carries what a watch needs and nothing more. The capability list and the +// mode catalogue stay on the stream: a watch sends cmd and now, and nothing it +// shows needs them. The smallest bulk bucket it fits, normally 1 KiB; its size +// follows the house's equipment, never what happens in the house. +type ExchangeHelloOK struct { + Proto int `cbor:"proto"` + Mode BoxMode `cbor:"mode"` + Clock Clock `cbor:"clock"` + Role string `cbor:"role"` + Scopes []string `cbor:"scopes"` + // Rev is the site's controlRev. A command carries the rev it expects, and + // the exchange has no snapshot to learn it from. + Rev uint64 `cbor:"rev"` + // Cloud is what the watch needs to read FTW Cloud on its own, while an + // owner has FTW Cloud on. Absent means off. + Cloud *CloudSession `cbor:"cloud,omitempty"` +} + // CloudSession lets a phone check and read what FTW Cloud serves it. It comes // from the box over Noise and never from the service, so the service cannot // sign for the box, swap its key or rename a source. @@ -290,6 +318,45 @@ type Tick struct { UptimeMs int64 `cbor:"uptimeMs"` } +// Now is the home as the Now screen shows it, answered on the exchange in one +// lane 0 frame (ADR 0009, decision 2). A watch reads it when FTW Cloud is off. +// +// The request is a now with an empty body. What does not fit the frame is +// left out and the frame flagged TRUNC, as a delta is. +type Now struct { + UptimeMs int64 `cbor:"uptimeMs"` + // ControlRev is the site's controlRev, which a command expects. + ControlRev uint64 `cbor:"controlRev"` + // Fields are the frozen fields, as a snapshot carries them. + Fields map[string]int64 `cbor:"fields"` + // Sources are keyed by the frozen source fields, 7 for the grid, 8 for + // PV and 9 for the battery, so a watch with no field dictionary can still + // age each reading. + Sources map[string]NowSource `cbor:"sources"` + // DispatchBlockedBy names the sources stopping dispatch, as a snapshot + // does. A watch that sees it knows a charge will be refused. + DispatchBlockedBy []string `cbor:"dispatchBlockedBy"` + // Loadpoints are the box's chargers, in its configured order. + Loadpoints []NowLoadpoint `cbor:"loadpoints"` +} + +// NowSource is one source's freshness, without its name. +type NowSource struct { + State SourceState `cbor:"state"` + // AgeMs is box uptime since the last good reading, or -1 for none. + AgeMs int64 `cbor:"ageMs"` +} + +// NowLoadpoint is one charger as a watch draws it. +type NowLoadpoint struct { + ID string `cbor:"id"` + Plugged bool `cbor:"plugged"` + // SocPermille is the car's level, absent when the box does not know it. + SocPermille *int64 `cbor:"socPermille,omitempty"` + // PowerW is the charger's draw, positive while charging. + PowerW int64 `cbor:"powerW"` +} + // -------------------------------------------------------------------------- // Commands // -------------------------------------------------------------------------- From 6812cf05b8007bcefd7358595198a5e7bc2abb55 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 13:03:37 +0200 Subject: [PATCH 2/3] feat(appuplink): hold an exchange command's answer for 400 ms ADR 0009 decision F, settled by Fredrik on 14 October 2026. The timing test showed the gap: a refusal answered at once and an applied charge after its EV save, so the time to answer told the relay which. The box now answers a watch's cmd no sooner than 400 ms after it arrived, applied or refused. A command that takes longer answers when it is done and waits no more; reads and the handshake are not held. New wires the floor; a table built by hand in a test has none. Measured again with the floor, through the relay on ftw-webapp main and at the box: applied and refused both answer in about 402 ms for any EV save under 400 ms, and a 1 s save answers in 1 s. ADR 0009 records the decision and its date. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- .changeset/watch-exchange.md | 2 +- docs/adr/0009-watch-exchange.md | 13 ++++- docs/architecture.md | 4 +- go/internal/appuplink/client.go | 11 +++-- go/internal/appuplink/exchange.go | 39 ++++++++++++++- go/internal/appuplink/exchange_test.go | 66 +++++++++++++++++++++++++ go/internal/appuplink/realrelay_test.go | 3 +- 7 files changed, 126 insertions(+), 12 deletions(-) diff --git a/.changeset/watch-exchange.md b/.changeset/watch-exchange.md index 755bd21cd..196d6655a 100644 --- a/.changeset/watch-exchange.md +++ b/.changeset/watch-exchange.md @@ -2,4 +2,4 @@ "ftw": minor --- -An Apple Watch can charge the car and read the house without its phone (ADR 0009). The box keeps a second relay socket, `/r///exchange`, beside its uplink, and answers a watch's one-shot HTTPS requests on it: a Noise IK handshake with prologue `ftw.exchange.v1:` that carries the watch's hello and the box's `hello_ok`, then `cmd` and `now`. Watch sessions live in a table of eight apart from the phones' four, so a watch never pushes a phone out, and each ends after four exchanges, a minute idle or three minutes. Every answer is two lane 0 frames, so a read, a command and a refusal have one length; a command's result that has not settled in 3 s leaves a tick in its place, and asking again with the same `cmdId` returns it. The box takes at most 60 handshakes a minute and answers a stranger or a removed watch with silence. It now says `cmd.exchange`, so a phone can offer "Add watch". Losing the exchange socket changes nothing for the uplink or local control. +An Apple Watch can charge the car and read the house without its phone (ADR 0009). The box keeps a second relay socket, `/r///exchange`, beside its uplink, and answers a watch's one-shot HTTPS requests on it: a Noise IK handshake with prologue `ftw.exchange.v1:` that carries the watch's hello and the box's `hello_ok`, then `cmd` and `now`. Watch sessions live in a table of eight apart from the phones' four, so a watch never pushes a phone out, and each ends after four exchanges, a minute idle or three minutes. Every answer is two lane 0 frames, so a read, a command and a refusal have one length; a command's result that has not settled in 3 s leaves a tick in its place, and asking again with the same `cmdId` returns it. The box answers a command no sooner than 400 ms after it arrived, applied or refused, so the time to answer does not tell the relay which; a command that takes longer answers when it is done. The box takes at most 60 handshakes a minute and answers a stranger or a removed watch with silence. It now says `cmd.exchange`, so a phone can offer "Add watch". Losing the exchange socket changes nothing for the uplink or local control. diff --git a/docs/adr/0009-watch-exchange.md b/docs/adr/0009-watch-exchange.md index 2137a94ad..60de82cad 100644 --- a/docs/adr/0009-watch-exchange.md +++ b/docs/adr/0009-watch-exchange.md @@ -12,7 +12,9 @@ remove alone. On the design: yes to A–F below ("ja på alla"), and the watch must work fully on its own, for example on LTE with the phone away. Decisions 7–9 follow from that last answer and from his rule that FTW Cloud - speeds things up and holds state while control never goes through it. + speeds things up and holds state while control never goes through it. The + same day, after the timing test showed the gap, he set decision F's floor + at 400 ms ("ja kör på ditt förslag ovan med tider"). - Pair: the exchange's wire, the relay route and the client changes are in [ftw-webapp `docs/watch-exchange.md`](https://github.com/srcfl/ftw-webapp/blob/main/docs/watch-exchange.md). Neither side works without the other. @@ -77,7 +79,8 @@ to remove it.** other frame is lane 0, 512 B. - The answer to `cmd` is two transport messages: `cmd.ack`, then `cmd.result`, or `tick` in its place if the result has not settled - within 3 s. The same `cmd` again returns the stored outcome. + within 3 s. The same `cmd` again returns the stored outcome. The box + answers a `cmd` no sooner than 400 ms after it arrived (decision F). - The answer to `now` is `now`, then `tick`: frozen fields 1–10, source states and ages, and each loadpoint's plug state, level, power and `controlRev`, flagged `TRUNC` if they do not fit. A read and a command @@ -212,6 +215,12 @@ Fredrik answered yes to all six on 14 October 2026. - **F. Timing floor.** A refusal answers in milliseconds; an applied command waits up to 2 s for the EV save. The box answers no sooner than a fixed floor only if the timing test shows that gap through the relay. + - Settled on 14 October 2026. The test showed the gap: with a 250 ms EV + save, an applied charge answered in 252 ms and a refusal in under 2 ms + through the relay. Fredrik chose a 400 ms floor. The box answers an + exchange `cmd` no sooner than 400 ms after it arrived, applied or + refused. A command that takes longer, an EV save of up to 2 s, answers + when it is done and waits no more. Reads are not held. ## Milestones diff --git a/docs/architecture.md b/docs/architecture.md index d2a9d3558..ca68dc414 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -422,7 +422,9 @@ it one Noise message per request behind an id of its own. The box holds those sessions in a table of eight, apart from the phones' four, so a watch never pushes a phone out; each ends after four exchanges, a minute idle or three minutes. Every frame on it is lane 0 apart from `hello_ok`, and every answer is -two frames, so a read, a command and a refusal have one length. Reads go to +two frames, so a read, a command and a refusal have one length. A command +answers no sooner than 400 ms after it arrives, so an applied one and a refused +one take the same time unless the EV save runs longer. Reads go to FTW Cloud in one request, which has not shipped. The watch is a device of its own and never holds more than the phone that added it. diff --git a/go/internal/appuplink/client.go b/go/internal/appuplink/client.go index 67c5cd8fb..cab90f236 100644 --- a/go/internal/appuplink/client.go +++ b/go/internal/appuplink/client.go @@ -281,11 +281,12 @@ func New(opts Options) (*Uplink, error) { } if opts.ExchangeHandler != nil { u.exchanges = &exchanges{ - build: opts.ExchangeHandler, - enroll: opts.Enroll, - static: opts.Enroll.StaticKey(), - log: u.log, - now: time.Now, + build: opts.ExchangeHandler, + enroll: opts.Enroll, + static: opts.Enroll.StaticKey(), + log: u.log, + now: time.Now, + cmdFloor: ExchangeCmdFloor, } } return u, nil diff --git a/go/internal/appuplink/exchange.go b/go/internal/appuplink/exchange.go index 5772267e6..c2ceb903d 100644 --- a/go/internal/appuplink/exchange.go +++ b/go/internal/appuplink/exchange.go @@ -68,6 +68,14 @@ const ( // asks again with the same cmdId. exchangeSettle = 3 * time.Second + // ExchangeCmdFloor is the soonest the box answers a watch's cmd, counted + // from when the request arrived (ADR 0009, decision F; Fredrik, 14 + // October 2026). A refusal is ready in a millisecond and an applied + // charge waits for the EV save, so without a floor the time to answer + // would tell the relay which happened. A command that takes longer, an + // EV save of up to 2 s, answers when it is done and waits no more. + ExchangeCmdFloor = 400 * time.Millisecond + // exchangeInFlight bounds requests handled at once. The relay allows two // open exchanges per room; this is the box's own ceiling. exchangeInFlight = 8 @@ -177,6 +185,9 @@ type exchanges struct { now func() time.Time // settle is exchangeSettle; a test shortens it. settle time.Duration + // cmdFloor is ExchangeCmdFloor as New wires it. Zero holds nothing, + // which only a test that builds the table by hand gets. + cmdFloor time.Duration mu sync.Mutex live []*exchangeSession @@ -187,6 +198,7 @@ type exchanges struct { // nil for silence. A stranger, a removed key, a spent session, a malformed // body and a handshake over the rate all get silence. func (x *exchanges) answer(ctx context.Context, body []byte) []byte { + arrived := time.Now() x.prune() switch len(body) { case exchangeOpenBytes: @@ -197,7 +209,7 @@ func (x *exchanges) answer(ctx context.Context, body []byte) []byte { } return reply case exchangeRequestBytes: - return x.route(ctx, body) + return x.route(ctx, body, arrived) default: return nil } @@ -278,7 +290,7 @@ func (x *exchanges) open(ctx context.Context, message1 []byte) ([]byte, error) { // route finds the session a transport message belongs to by letting each // one's AEAD try, and runs the exchange in it. -func (x *exchanges) route(ctx context.Context, message []byte) []byte { +func (x *exchanges) route(ctx context.Context, message []byte, arrived time.Time) []byte { x.mu.Lock() live := slices.Clone(x.live) x.mu.Unlock() @@ -289,6 +301,9 @@ func (x *exchanges) route(ctx context.Context, message []byte) []byte { continue } reply := x.exchange(ctx, sess, frame) + if reply != nil && x.cmdFloor > 0 && isCmd(frame) { + holdUntil(ctx, arrived.Add(x.cmdFloor)) + } if sess.spent(x.now()) { x.drop(sess) } @@ -297,6 +312,26 @@ func (x *exchanges) route(ctx context.Context, message []byte) []byte { return nil } +// isCmd reports whether a request frame is a command. +func isCmd(frame []byte) bool { + f, err := appwire.DecodeFrame(frame) + return err == nil && f.Envelope.T == appproto.MsgCmd +} + +// holdUntil waits for a moment on the real clock, or for the socket to go. +func holdUntil(ctx context.Context, at time.Time) { + wait := time.Until(at) + if wait <= 0 { + return + } + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-timer.C: + case <-ctx.Done(): + } +} + // exchange runs one request through the session's handler and seals the // answer: exactly two lane 0 frames. A command's ack and result when the // result settles in time; otherwise a tick takes the missing place. diff --git a/go/internal/appuplink/exchange_test.go b/go/internal/appuplink/exchange_test.go index 82c280b4e..bb8c7a4df 100644 --- a/go/internal/appuplink/exchange_test.go +++ b/go/internal/appuplink/exchange_test.go @@ -820,3 +820,69 @@ func TestLosingTheExchangeLeavesTheUplink(t *testing.T) { t.Fatalf("after the exchange dropped, the phone's session answered %q", got.Envelope.T) } } + +// -------------------------------------------------------------------------- +// The floor (decision F) +// -------------------------------------------------------------------------- + +// timed sends one request and returns how long the box took to answer it. +func (r *xrig) timed(t *testing.T, w *watchClient, msgType string, body any) ([]appwire.Frame, time.Duration) { + t.Helper() + request, err := w.request(msgType, body) + if err != nil { + t.Fatal(err) + } + started := time.Now() + reply := r.x.answer(context.Background(), request) + took := time.Since(started) + frames, err := w.answer(reply) + if err != nil { + t.Fatal(err) + } + return frames, took +} + +// A command answers no sooner than the floor, applied or refused, so the +// time to answer does not say which. One that takes longer answers when it +// is done and waits no more. A read is not a command and is not held. +func TestACommandAnswersNoSoonerThanTheFloor(t *testing.T) { + r := newXRig(t) + r.x.cmdFloor = ExchangeCmdFloor + slack := 300 * time.Millisecond + + w, _ := r.addWatch(t) + frames, applied := r.timed(t, w, appproto.MsgCmd, chargeNow("floor-applied")) + if decodeAs[appproto.CmdResult](t, frames[1]).State != appproto.CmdApplied { + t.Fatalf("the charge answered %v", types(frames)) + } + _, refused := r.timed(t, w, appproto.MsgCmd, appproto.Cmd{CmdID: "floor-refused", Op: appproto.OpSetMode, + Args: map[string]any{"mode": "charge"}, NotValidAfterMs: 600_000, Expect: appproto.Expect{Rev: 1}}) + _, read := r.timed(t, w, appproto.MsgNow, struct{}{}) + + for name, took := range map[string]time.Duration{"applied": applied, "refused": refused} { + if took < ExchangeCmdFloor || took > ExchangeCmdFloor+slack { + t.Fatalf("an %s command answered after %s, want %s", name, took, ExchangeCmdFloor) + } + } + if read >= ExchangeCmdFloor { + t.Fatalf("a read was held %s", read) + } + + // An EV save longer than the floor sets the time alone. + r.charger.mu.Lock() + r.charger.save = 600 * time.Millisecond + r.charger.mu.Unlock() + w, _ = r.addWatch(t) + _, slow := r.timed(t, w, appproto.MsgCmd, chargeNow("floor-slow")) + if slow < 600*time.Millisecond || slow > 600*time.Millisecond+slack { + t.Fatalf("a charge with a 600 ms save answered after %s; the floor must not add to it", slow) + } +} + +// The box wires the floor; only a table built by hand in a test goes without. +func TestTheUplinkHoldsCommandsForTheFloor(t *testing.T) { + r := newExchangeRig(t, &fakeCharger{}) + if r.uplink.exchanges.cmdFloor != ExchangeCmdFloor { + t.Fatalf("the uplink holds commands for %s, want %s", r.uplink.exchanges.cmdFloor, ExchangeCmdFloor) + } +} diff --git a/go/internal/appuplink/realrelay_test.go b/go/internal/appuplink/realrelay_test.go index f2fb84c2b..e781d02dd 100644 --- a/go/internal/appuplink/realrelay_test.go +++ b/go/internal/appuplink/realrelay_test.go @@ -205,10 +205,11 @@ func TestExchangeTimingAtTheBox(t *testing.T) { } save, _ := strconv.Atoi(os.Getenv("FTW_EV_SAVE_MS")) r := newXRig(t) + r.x.cmdFloor = ExchangeCmdFloor r.charger.save = time.Duration(save) * time.Millisecond var applied, refused []time.Duration var w *watchClient - for i := 0; i < 200; i++ { + for i := 0; i < 40; i++ { if i%2 == 0 { // A second on the rig's clock per handshake keeps the run under // the sixty a minute the box allows. From 52afce7f9df4bf1d7cc4a9a55a234dd4d80d227e Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 13:04:29 +0200 Subject: [PATCH 3/3] docs(adr): ADR 0009 names milestone 6 as built Co-Authored-By: Claude Opus 5.5 Signed-off-by: Fredrik Ahlgren --- docs/adr/0009-watch-exchange.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/adr/0009-watch-exchange.md b/docs/adr/0009-watch-exchange.md index 60de82cad..13acbadb8 100644 --- a/docs/adr/0009-watch-exchange.md +++ b/docs/adr/0009-watch-exchange.md @@ -1,10 +1,11 @@ # ADR 0009: An Apple Watch works without the phone -- Status: accepted. Core has milestones 1, 2, 3 and 5: the command log on - the box, the registry names, the watch role with its enrolment, and the - exchange socket with its sessions, so the box says `cmd.exchange`. The - relay's half is milestone 4, in srcfl/ftw-webapp. Nothing else described - here has shipped. +- Status: accepted. This repository has milestones 1, 2, 3, 5 and 6: the + command log on the box, the registry names, the watch role with its + enrolment, the exchange socket with its sessions, so the box says + `cmd.exchange`, and the wire for FTW Cloud's one-shot read. The relay's + half is milestone 4, in srcfl/ftw-webapp. Nothing else described here has + shipped. - Date: 2026-10-14 - Decided by: Fredrik on 14 October 2026. Before the design: a watch controls the box directly as well as through the phone; an unlocked watch on the