Skip to content
Closed
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/ftw-cloud-notification-kinds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw-webapp": patch
---

Know the two FTW Cloud notices, "FTW Cloud is on" and "FTW Cloud is off". A box that sends them shows two more notification switches, on by default, and an older box shows none. The shared contract also names the FTW Cloud scope, capability and carrier state the box and the iPhone app use.
9 changes: 9 additions & 0 deletions contract/push-catalogue.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,12 @@ events:
# has. The box encrypts it before it ever leaves home.
title: Your box is out of reach
body: "It stopped answering. Power cut, or the internet at home is down."
- kind: cloud.granted
# To every owner, when an owner turns FTW Cloud on. Sharing a readable
# copy of the house is a choice every owner should hear about.
title: FTW Cloud is on
body: "Your box now shares a copy of its readings with FTW Cloud. You can turn it off in the app or on the box."
- kind: cloud.revoked
# To every owner, when FTW Cloud is turned off at either door.
title: FTW Cloud is off
body: "Your box stopped sharing with FTW Cloud and asked it to delete its copy."
27 changes: 24 additions & 3 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
# The single source for every name shared between this app and the box, and
# the same file in both repositories — byte for byte.
#
# In srcfl/ftw it generates Go constants: go/internal/appproto/contract_gen.go
# and go/internal/apiauth/contract_gen.go, from `go generate ./internal/...`.
# In srcfl/ftw it generates Go constants: go/protocol/contract_gen.go and
# go/internal/apiauth/contract_gen.go, from `go generate ./internal/...`.
# In srcfl/ftw-webapp there is no generator — src/lib/protocol/contract.ts and
# the error table in src/lib/protocol/messages.ts are written by hand and read
# back against this file by tests/registry-contract.test.ts.
Expand Down Expand Up @@ -76,6 +76,10 @@ capabilities:
# history.5m. Present does not mean every path is reachable: reads and
# configuration go through, anything that moves energy stays on cmd.
- api.passthrough
# The box can run the FTW Cloud uplink. Absent means the app uses the relay
# only and offers no FTW Cloud. It says nothing about whether the owner
# turned FTW Cloud on; the box's grant decides that.
- cloud.link

# ---------------------------------------------------------------------------
# Scopes. One object axis, two verb axes: <object>.<read|write>.
Expand All @@ -87,6 +91,7 @@ scopes:
- { name: ftw.plan.read, desc: Dispatch plan and its reasoning }
- { name: ftw.health.read, desc: Box and driver health }
- { name: ftw.assets.read, desc: Devices and their capabilities }
- { name: ftw.events.read, desc: Events behind notifications }
- { name: ftw.dispatch.write, desc: Control energy assets }
- { name: ftw.mode.write, desc: Change site operating mode }
- { name: ftw.members.read, desc: See who has access }
Expand All @@ -100,6 +105,22 @@ roles:
label: Viewer
scopes: [ftw.live.read]

# ---------------------------------------------------------------------------
# FTW Cloud. What an owner can let the box share with FTW Cloud, and why.
# docs/protocol-cloud.md in srcfl/ftw is the wire format.
#
# cloud_scopes are the scopes a grant may name: the box sends a class only
# when the grant holds its scope, and a device reads it only when its own
# role carries the scope too. ftw.assets.read is left out on purpose: the
# box sends no device inventory, names or capabilities to FTW Cloud.
#
# cloud_purposes are what the data may be used for. serve is the owner's own
# phones and widgets. stats is household statistics, a separate consent that
# is off unless the owner turns it on.
# ---------------------------------------------------------------------------
cloud_scopes: [ftw.live.read, ftw.history.read, ftw.plan.read, ftw.health.read, ftw.events.read]
cloud_purposes: [serve, stats]

# ---------------------------------------------------------------------------
# Command operations. What a `cmd` frame may name, and the scope the grant
# must carry before the box acts on it.
Expand Down Expand Up @@ -219,7 +240,7 @@ client_errors:
# the two into one enum cannot express "connected, but the inverter is quiet".
# ---------------------------------------------------------------------------
source_states: [live, lagging, stale, down, never]
carrier_states: [webrtc, relay, cache, none]
carrier_states: [webrtc, relay, cloud, cache, none]

# ---------------------------------------------------------------------------
# History resolutions backed by what the box actually stores.
Expand Down
4 changes: 4 additions & 0 deletions src/lib/notify/kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const KINDS = [
'driver.offline',
'fuse.over_limit',
'box.unreachable',
'cloud.granted',
'cloud.revoked',
] as const

export type PushKind = (typeof KINDS)[number]
Expand All @@ -40,4 +42,6 @@ export const KIND_LABELS: Record<PushKind, string> = {
'driver.offline': 'If a device goes quiet',
'fuse.over_limit': 'If the house draws more than the fuse allows',
'box.unreachable': 'If your box goes out of reach',
'cloud.granted': 'When FTW Cloud is turned on',
'cloud.revoked': 'When FTW Cloud is turned off',
}
1 change: 1 addition & 0 deletions src/lib/protocol/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const SCOPES = [
'ftw.plan.read',
'ftw.health.read',
'ftw.assets.read',
'ftw.events.read',
'ftw.dispatch.write',
'ftw.mode.write',
'ftw.members.read',
Expand Down
2 changes: 1 addition & 1 deletion src/lib/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

/** How frames are reaching us. Orthogonal to SourceState — see FreshnessBand. */
export type CarrierState = 'webrtc' | 'relay' | 'cache' | 'none'
export type CarrierState = 'webrtc' | 'relay' | 'cloud' | 'cache' | 'none'

/** Whether a box-side source is answering. Orthogonal to CarrierState. */
export type SourceState = 'live' | 'lagging' | 'stale' | 'down' | 'never'
Expand Down
11 changes: 9 additions & 2 deletions src/lib/sim/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,14 @@ const RULE_TYPES = [
'update.installed',
'driver.offline',
'fuse.over_limit',
'cloud.granted',
'cloud.revoked',
]

/** The box seeds these on: each says the house started or stopped sending a
* readable copy to FTW Cloud, which every owner should hear unasked. */
const ON_BY_DEFAULT = new Set(['cloud.granted', 'cloud.revoked'])

/**
* How much a route costs before it runs. Four, not three.
*
Expand Down Expand Up @@ -386,7 +392,8 @@ export class SimApi {
}

/**
* The default rule set, every kind disabled, exactly as the box seeds it.
* The default rule set exactly as the box seeds it: every kind disabled
* but the two FTW Cloud notices.
* The thresholds exist so a partial PUT entry would visibly wipe them —
* the trap the app's whole-entry rule guards against.
*/
Expand All @@ -395,7 +402,7 @@ export class SimApi {
enabled: false,
events: RULE_TYPES.map((type) => ({
type,
enabled: false,
enabled: ON_BY_DEFAULT.has(type),
threshold_s: 300,
threshold_n: 0,
priority: 3,
Expand Down
6 changes: 4 additions & 2 deletions src/views/Notifications.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,10 @@ describe('the notifications section', () => {
render(Notifications, { props: { site } })
await vi.advanceTimersByTimeAsync(500)
const boxes = [...document.querySelectorAll('input[type="checkbox"]')] as HTMLInputElement[]
expect(boxes.length).toBe(6)
expect(boxes.every(b => !b.checked)).toBe(true)
// Six opt-in kinds, then the two FTW Cloud notices the box seeds on.
expect(boxes.length).toBe(8)
expect(boxes.slice(0, 6).every(b => !b.checked)).toBe(true)
expect(boxes.slice(6).every(b => b.checked)).toBe(true)
boxes[0]!.click()
await vi.advanceTimersByTimeAsync(1)
expect(text()).toContain('Saving…')
Expand Down
Loading