Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/watch-exchange.md
Original file line number Diff line number Diff line change
@@ -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/<epoch>/<handle>/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.
22 changes: 17 additions & 5 deletions docs/adr/0009-watch-exchange.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
# 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. 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
wrist counts as unlocked; each watch has its own key, which the owner can
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.
Expand Down Expand Up @@ -75,7 +80,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
Expand Down Expand Up @@ -210,6 +216,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

Expand Down
38 changes: 27 additions & 11 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -413,19 +414,27 @@ 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/<epoch>/<handle>/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. 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.

### 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
Expand All @@ -451,6 +460,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:

Expand Down
43 changes: 35 additions & 8 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand All @@ -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
Expand Down
15 changes: 15 additions & 0 deletions go/internal/appproto/ev_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package appproto
import (
"context"
"errors"
"maps"
"slices"
"testing"
"time"

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading