From fe065e9bcdddb436fe7ede8b38576812f6ba39d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 18:14:33 +0000 Subject: [PATCH 1/2] feat: notify when a device is down, and restart the box from the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lock-screen kinds join the catalogue — a device gone quiet, and the house drawing more than the fuse — and the Box screen can restart FTW so a stuck driver does not need Termius from work. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren --- .changeset/push-and-restart.md | 5 ++ contract/push-catalogue.yaml | 10 +++ src/lib/notify/kinds.ts | 4 + src/lib/sim/api.ts | 16 ++++ src/views/Box.svelte | 7 ++ src/views/Notifications.svelte | 2 +- src/views/Notifications.svelte.test.ts | 8 +- src/views/Restart.svelte | 120 +++++++++++++++++++++++++ src/views/Restart.svelte.test.ts | 117 ++++++++++++++++++++++++ tests/api-passthrough.test.ts | 16 ++++ 10 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 .changeset/push-and-restart.md create mode 100644 src/views/Restart.svelte create mode 100644 src/views/Restart.svelte.test.ts diff --git a/.changeset/push-and-restart.md b/.changeset/push-and-restart.md new file mode 100644 index 0000000..e9aca62 --- /dev/null +++ b/.changeset/push-and-restart.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": minor +--- + +Notify when a device goes quiet or the house draws more than the fuse, and restart the box from this phone when something is stuck. diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index e7eda18..5f4a2e1 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -25,6 +25,16 @@ events: - kind: update.installed title: Your box updated itself body: "Now running {version}. Everything came back on its own." + - kind: driver.offline + # After the box's own threshold: ten minutes of silence from a driver + # that had been reporting. A blip must not become a lock-screen. + title: A device went quiet + body: "{name} stopped answering." + - kind: fuse.over_limit + # After the box's own threshold: thirty seconds over the rating. A + # kettle must not page anyone. + title: The house is drawing too much + body: "{phase} is over the fuse rating." - kind: box.unreachable # The one sentence the box cannot send about itself. The relay holds # this pre-encrypted and posts it only when the box has missed its diff --git a/src/lib/notify/kinds.ts b/src/lib/notify/kinds.ts index 533991f..1204f9b 100644 --- a/src/lib/notify/kinds.ts +++ b/src/lib/notify/kinds.ts @@ -12,6 +12,8 @@ export const KINDS = [ 'charging.session_complete', 'charging.interrupted', 'update.installed', + 'driver.offline', + 'fuse.over_limit', 'box.unreachable', ] as const @@ -33,5 +35,7 @@ export const KIND_LABELS: Record = { 'charging.session_complete': 'When the car finishes charging', 'charging.interrupted': 'If charging stops before it is done', 'update.installed': 'When your box updates itself', + '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', } diff --git a/src/lib/sim/api.ts b/src/lib/sim/api.ts index 1fb3237..8554e14 100644 --- a/src/lib/sim/api.ts +++ b/src/lib/sim/api.ts @@ -48,6 +48,8 @@ const RULE_TYPES = [ 'charging.session_complete', 'charging.interrupted', 'update.installed', + 'driver.offline', + 'fuse.over_limit', ] /** @@ -147,6 +149,9 @@ const ROUTES: Record = { 'PUT /api/notifications/rules': { tier: 'configure' }, 'POST /api/notifications/test': { tier: 'configure' }, + // Owner recovery: a late restart is the same instruction, only later. + 'POST /api/restart': { tier: 'configure' }, + // At the box, in the house. A credential, a whole file, or a person needed // in the room. 'GET /api/config': { tier: 'local' }, @@ -306,6 +311,8 @@ export class SimApi { #pushRules: { enabled: boolean; events: Record[] } | null = null /** How many test pushes were asked for, for a test to look at. */ #testPushes = 0 + /** How many times this box was asked to restart, for a test to look at. */ + #restarts = 0 constructor(opts: SimApiOptions) { this.#opts = opts @@ -374,6 +381,11 @@ export class SimApi { return this.#testPushes } + /** Restarts this box was asked for, for a test to look at. */ + get restarts(): number { + return this.#restarts + } + /** * Serve one request, or refuse it. * @@ -499,6 +511,10 @@ export class SimApi { return json(200, { status: 'sent', to: this.#pushSubscriptions.size }) } if (route === 'GET /api/notifications/history') return this.#pushHistory() + if (route === 'POST /api/restart') { + this.#restarts += 1 + return json(202, { status: 'restarting' }) + } // A real read whose answer this session cannot carry. Refused by class at // the status line, never by a list of paths, so a route added next year diff --git a/src/views/Box.svelte b/src/views/Box.svelte index a5c7e52..57774fe 100644 --- a/src/views/Box.svelte +++ b/src/views/Box.svelte @@ -38,6 +38,7 @@ import { deviceIdOnBox, openVaultStore } from '$lib/identity/vault' import Access from '$views/Access.svelte' import Notifications from '$views/Notifications.svelte' + import Restart from '$views/Restart.svelte' interface Props { site: SiteStore @@ -267,6 +268,12 @@ about the pairing, like the roster above it. --> + + +
+ + +{#if site.heardFromBox && canAsk} +
+ {#if stage === 'confirming'} +

Restart this box?

+

+ The software restarts. Devices keep running on their own until it comes + back, usually within a minute. This phone reconnects by itself. +

+ + + {:else if stage === 'restarting'} +

Restarting

+

Your box is coming back on its own. This usually takes a minute.

+ {:else} +

Restart

+

+ Restarts the software on this box. Use it when a device is stuck and will + not come back on its own. +

+ + {/if} + {#if error} +

{error}

+ {/if} +{/if} + + diff --git a/src/views/Restart.svelte.test.ts b/src/views/Restart.svelte.test.ts new file mode 100644 index 0000000..88f3927 --- /dev/null +++ b/src/views/Restart.svelte.test.ts @@ -0,0 +1,117 @@ +/* Restart, from the tap to the box's own process. + + * Real Session, real SimBox, real loopback carrier. The passkey ceremony is + * stubbed at the module the app calls, so the round trip that discovers the + * step-up still happens over the wire. A restart is configure: owner, with + * a ceremony, and a confirm before the process is asked to come back. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render } from '@testing-library/svelte' +import Restart from './Restart.svelte' +import { SiteStore } from '$lib/state/site.svelte' +import { LoopbackCarrier } from '$lib/carrier/loopback' +import { SimBox } from '$lib/sim/box' +import { ROLE_VIEWER } from '$lib/protocol/messages' + +vi.mock('$lib/identity/stepup', () => ({ + stepUp: vi.fn(async () => 'done'), + stepUpHelp: () => 'needs a ceremony', +})) + +const NOON = new Date(2026, 6, 15, 12, 0, 0).getTime() + +function open(role?: string) { + const box = new SimBox({ now: () => Date.now(), ...(role ? { role } : {}) }) + const site = new SiteStore('test') + site.connect(new LoopbackCarrier(box, { latencyMs: 0 })) + return { box, site } +} + +function text(): string { + return (document.body.textContent ?? '').replace(/\s+/g, ' ') +} + +function buttonSaying(pattern: RegExp): HTMLButtonElement | undefined { + return [...document.querySelectorAll('button')].find((b) => + pattern.test(b.textContent ?? '') + ) as HTMLButtonElement | undefined +} + +describe('restart, from the phone', () => { + beforeEach(async () => { + const { stepUp } = await import('$lib/identity/stepup') + vi.mocked(stepUp).mockResolvedValue('done') + }) + + afterEach(() => { + document.body.replaceChildren() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('asks first, then the box is told once, with one ceremony', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOON) + const { box, site } = open() + const stepup = await import('$lib/identity/stepup') + + render(Restart, { props: { site } }) + await vi.advanceTimersByTimeAsync(500) + + expect(box.api.restarts, 'drew a control that fired on sight').toBe(0) + buttonSaying(/Restart this box/)!.click() + await vi.advanceTimersByTimeAsync(10) + + expect(box.api.restarts, 'the confirm itself restarted the box').toBe(0) + expect(text()).toMatch(/Devices keep running on their own/) + + vi.mocked(stepup.stepUp).mockClear() + buttonSaying(/Restart now/)!.click() + await vi.advanceTimersByTimeAsync(500) + + expect(box.api.restarts).toBe(1) + expect(vi.mocked(stepup.stepUp), 'a restart cost more than one ceremony').toHaveBeenCalledOnce() + expect(text()).toMatch(/coming back on its own/) + }) + + it('cancels without asking the box', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOON) + const { box, site } = open() + + render(Restart, { props: { site } }) + await vi.advanceTimersByTimeAsync(500) + buttonSaying(/Restart this box/)!.click() + await vi.advanceTimersByTimeAsync(10) + buttonSaying(/Cancel/)!.click() + await vi.advanceTimersByTimeAsync(10) + + expect(box.api.restarts).toBe(0) + expect(buttonSaying(/Restart this box/)).toBeDefined() + }) + + it('shows a viewer nothing, not a button their box would refuse', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOON) + const { site } = open(ROLE_VIEWER) + + render(Restart, { props: { site } }) + await vi.advanceTimersByTimeAsync(500) + + expect(text()).not.toMatch(/Restart/i) + expect(document.querySelectorAll('button')).toHaveLength(0) + }) + + it('draws nothing until the box has said something', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOON) + const site = new SiteStore('test') + + render(Restart, { props: { site } }) + await vi.advanceTimersByTimeAsync(200) + + expect(document.querySelectorAll('button')).toHaveLength(0) + expect(text()).not.toMatch(/Restart/i) + }) +}) diff --git a/tests/api-passthrough.test.ts b/tests/api-passthrough.test.ts index 036f338..24a0e43 100644 --- a/tests/api-passthrough.test.ts +++ b/tests/api-passthrough.test.ts @@ -386,6 +386,22 @@ describe('configuration', () => { expect((decode(res.body) as { role: string }).role).toBe(ROLE_VIEWER) }) + it('restarts the box as configuration, once a ceremony has happened', async () => { + const box = new SimBox({ now: () => NOON, role: ROLE_OWNER }) + const session = connect(box) + await settle() + + await expect(session.api({ method: 'POST', path: '/api/restart' })).rejects.toMatchObject({ + detail: { code: 'E_NEEDS_STEP_UP', args: { tier: 'configure' } }, + }) + expect(box.api.restarts, 'the refusal still bounced the process').toBe(0) + + const res = await session.api({ method: 'POST', path: '/api/restart', stepUp: true }) + expect(res.status).toBe(202) + expect((decode(res.body) as { status: string }).status).toBe('restarting') + expect(box.api.restarts).toBe(1) + }) + it('can skip the ceremony only when a simulator opts in for the public demo', async () => { const box = new SimBox({ now: () => NOON, From 25547942894b932b2974094f72715a2da656692c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 18:18:47 +0000 Subject: [PATCH 2/2] docs(demo): mention a quiet device on the demo Box screen The public demo still cannot turn notifications on, but the sentence should name the same events a real home can now hear. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren --- src/views/DemoBox.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/views/DemoBox.svelte b/src/views/DemoBox.svelte index 796d302..b506f8f 100644 --- a/src/views/DemoBox.svelte +++ b/src/views/DemoBox.svelte @@ -44,7 +44,8 @@

Notifications

An installed app can show useful events on the lock screen, such as a - finished EV charge, an installed box update or a box that went quiet. + finished EV charge, a device that stops answering, or a box that went + quiet.