diff --git a/.changeset/ftw-cloud-notification-kinds.md b/.changeset/ftw-cloud-notification-kinds.md new file mode 100644 index 0000000..b11d721 --- /dev/null +++ b/.changeset/ftw-cloud-notification-kinds.md @@ -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. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index f34ab4c..8560885 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -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." diff --git a/contract/registry.yaml b/contract/registry.yaml index d407bcd..5d71d3a 100644 --- a/contract/registry.yaml +++ b/contract/registry.yaml @@ -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. @@ -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: .. @@ -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 } @@ -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. @@ -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. diff --git a/src/lib/notify/kinds.ts b/src/lib/notify/kinds.ts index 5756c1e..1fdc4d0 100644 --- a/src/lib/notify/kinds.ts +++ b/src/lib/notify/kinds.ts @@ -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] @@ -40,4 +42,6 @@ export const KIND_LABELS: Record = { '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', } diff --git a/src/lib/protocol/contract.ts b/src/lib/protocol/contract.ts index 6275276..f5088a0 100644 --- a/src/lib/protocol/contract.ts +++ b/src/lib/protocol/contract.ts @@ -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', diff --git a/src/lib/protocol/types.ts b/src/lib/protocol/types.ts index f587fc9..c587b7b 100644 --- a/src/lib/protocol/types.ts +++ b/src/lib/protocol/types.ts @@ -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' diff --git a/src/lib/sim/api.ts b/src/lib/sim/api.ts index 228f004..b5101e7 100644 --- a/src/lib/sim/api.ts +++ b/src/lib/sim/api.ts @@ -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. * @@ -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. */ @@ -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, diff --git a/src/views/Notifications.svelte.test.ts b/src/views/Notifications.svelte.test.ts index ad7dea2..899e37f 100644 --- a/src/views/Notifications.svelte.test.ts +++ b/src/views/Notifications.svelte.test.ts @@ -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…')