From 65369daa9730dd0ed23bd3d61d4e075acb666dfb Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Tue, 22 Sep 2026 03:09:28 -0400 Subject: [PATCH 1/3] feat(device): add a Device tab with status, controls and graceful shutdown The Device tab was a placeholder, so a headless Pi could not be inspected or stopped from the phone at all. Stopping one meant pulling power on a live SD card, which is how Pis get corrupted. Everything here consumes contracts the server already exposes: - Status: `trigger_status` (mode, radar link, port, trigger type, accept and reject counters) and `power_status` (state, charge, voltage, provider). Requested on every (re)connect, since a phone joining a session already in progress cannot rely on having seen the server's unprompted push. - Controls: `toggle_debug`, `toggle_camera` and `toggle_camera_stream`, each sent only over a live connection. Socket.IO buffers emits through a transient drop and replays them on reconnect, which would otherwise flip recording or the camera behind the user's back. - Graceful shutdown: `POST /api/shutdown` behind a two-step confirm with observable pending, success, error and retry states. Deliberately excluded: `set_radar_config`. The server refuses radar config in mock mode, so it cannot be honestly verified without hardware. Notes for review: - `radar_connected` is `monitor is not None and not mock_mode`, so it reads false in mock mode while everything works. The UI reports the mode instead of calling a working setup "offline". - A nullable measurement renders an em dash, never 0, matching the Shots screen. A mains-powered Pi reports `available: false` and is shown as "No battery" rather than an empty one. - The shutdown outcome deliberately outlives the connection: a successful shutdown drops the socket ~0.5s after the server answers, and the "wait for its lights to settle" warning has to survive that drop to be read at all. - The debug card waits for the server before offering a control. Debug mode is server-global, so a default "Start" could have stopped a capture that was already running. - `emitWhileConnected` duplicates an equivalent guard on #21 and #25; whoever merges last should fold the three into one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QvMGAzMMWRuxRht8aGciAv --- __tests__/DeviceScreen.test.tsx | 536 ++++++++++++++++++++++++++++++ __tests__/shutdown.test.ts | 134 ++++++++ __tests__/socket.test.ts | 239 +++++++++++++- __tests__/useDeviceStore.test.ts | 177 ++++++++++ app/(tabs)/device.tsx | 543 ++++++++++++++++++++++++++++++- services/shutdown.ts | 54 +++ services/socket.ts | 95 +++++- stores/useDeviceStore.ts | 108 ++++++ types.ts | 71 ++++ 9 files changed, 1948 insertions(+), 9 deletions(-) create mode 100644 __tests__/DeviceScreen.test.tsx create mode 100644 __tests__/shutdown.test.ts create mode 100644 __tests__/useDeviceStore.test.ts create mode 100644 services/shutdown.ts create mode 100644 stores/useDeviceStore.ts diff --git a/__tests__/DeviceScreen.test.tsx b/__tests__/DeviceScreen.test.tsx new file mode 100644 index 0000000..d70e5d2 --- /dev/null +++ b/__tests__/DeviceScreen.test.tsx @@ -0,0 +1,536 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import DeviceScreen from '../app/(tabs)/device'; +import { requestShutdown } from '../services/shutdown'; +import { socketService } from '../services/socket'; +import { useDeviceStore } from '../stores/useDeviceStore'; +import { useSessionStore } from '../stores/useSessionStore'; +import type { + CameraStatusPayload, + ConnectionState, + PowerStatusPayload, + TriggerStatusPayload, +} from '../types'; + +jest.mock( + 'react-native-safe-area-context', + () => require('react-native-safe-area-context/jest/mock').default, +); + +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock'), +); + +// The HTTP call itself is covered against a stubbed fetch in shutdown.test.ts; +// here only the screen's use of it matters. +jest.mock('../services/shutdown', () => ({ + requestShutdown: jest.fn(() => Promise.resolve()), +})); + +const mockRequestShutdown = requestShutdown as jest.MockedFunction; + +// The socket service is exercised directly in socket.test.ts; here only the +// screen's use of it matters. +jest.mock('../services/socket', () => ({ + socketService: { + toggleDebug: jest.fn(), + toggleCamera: jest.fn(), + toggleCameraStream: jest.fn(), + }, +})); + +const mockedSocket = socketService as jest.Mocked; + +function makeTriggerStatus(overrides: Partial = {}): TriggerStatusPayload { + return { + mode: 'rolling-buffer', + trigger_type: 'audio', + radar_connected: true, + radar_port: '/dev/ttyUSB0', + triggers_total: 12, + triggers_accepted: 9, + triggers_rejected: 3, + ...overrides, + }; +} + +function makePowerStatus(overrides: Partial = {}): PowerStatusPayload { + return { + available: true, + provider: 'geekworm', + state: 'on_battery', + battery_percent: 78, + battery_voltage_v: 3.91, + external_power: false, + updated_at: '2026-09-22T05:30:00Z', + error: null, + ...overrides, + }; +} + +// render() and fireEvent are asynchronous in React Native Testing Library 14; +// every call is awaited so state is committed before the next assertion. +async function renderDevice( + connectionState: ConnectionState, + device: { + triggerStatus?: TriggerStatusPayload | null; + powerStatus?: PowerStatusPayload | null; + debug?: { enabled: boolean; logPath?: string }; + camera?: CameraStatusPayload; + } = {}, +) { + // A session is one connection span; the socket service starts a new one on + // every connect, and the shutdown panel keys off it. + useSessionStore.setState({ connectionState, sessionId: 'session-1' }); + useDeviceStore.getState().reset(); + if (device.triggerStatus) useDeviceStore.getState().applyTriggerStatus(device.triggerStatus); + if (device.powerStatus) useDeviceStore.getState().applyPowerStatus(device.powerStatus); + if (device.debug) { + useDeviceStore + .getState() + .applyDebugStatus({ enabled: device.debug.enabled, log_path: device.debug.logPath ?? null }); + } + if (device.camera) useDeviceStore.getState().applyCameraStatus(device.camera); + await render(); +} + +const SHUT_DOWN = 'Shut down'; +const CONFIRM = 'Shut down the Pi'; + +beforeEach(() => { + jest.clearAllMocks(); + mockRequestShutdown.mockResolvedValue(undefined); +}); + +afterEach(() => { + cleanup(); +}); + +describe('device status', () => { + it('reports the radar the server says it is driving', async () => { + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + + expect(screen.getByText('/dev/ttyUSB0')).toBeTruthy(); + expect(screen.getByText('audio')).toBeTruthy(); + }); + + it('counts the triggers the server has accepted and rejected', async () => { + // With no screen on the Pi, these counters are the only way to tell a + // radar that sees nothing from one rejecting everything it sees. + await renderDevice('connected', { + triggerStatus: makeTriggerStatus({ + triggers_total: 40, + triggers_accepted: 31, + triggers_rejected: 9, + }), + }); + + expect(screen.getByText('31')).toBeTruthy(); + expect(screen.getByText('9')).toBeTruthy(); + }); + + it('does not call a working mock setup a disconnected radar', async () => { + // The server reports radar_connected as `monitor is not None and not + // mock_mode`, so mock mode reads false while everything works. Saying + // "radar offline" there would send someone hunting a fault that is not + // there. + await renderDevice('connected', { + triggerStatus: makeTriggerStatus({ + mode: 'mock', + radar_connected: false, + radar_port: null, + trigger_type: null, + }), + }); + + expect(screen.getByText(/mock/i)).toBeTruthy(); + expect(screen.queryByText(/offline/i)).toBeNull(); + }); + + it('shows the battery the Pi is running on', async () => { + await renderDevice('connected', { powerStatus: makePowerStatus({ battery_percent: 78 }) }); + + expect(screen.getByText('78%')).toBeTruthy(); + }); + + it('says a Pi on mains has no battery rather than showing an empty one', async () => { + // available:false is an answer. Rendering it as 0% would look like a Pi + // about to die. + await renderDevice('connected', { + powerStatus: makePowerStatus({ + available: false, + state: 'unavailable', + battery_percent: null, + battery_voltage_v: null, + external_power: null, + }), + }); + + expect(screen.queryByText('0%')).toBeNull(); + expect(screen.getByText(/no battery|not available/i)).toBeTruthy(); + }); + + it('waits rather than claiming the hardware is missing before the server answers', async () => { + // Nothing has been reported yet; an empty state here would read as "this + // Pi has no radar", which is a different and wrong claim. + await renderDevice('connected'); + + expect(screen.queryByText(/no radar/i)).toBeNull(); + expect(screen.getByText(/waiting for the server/i)).toBeTruthy(); + }); + + it('asks for a connection before it can report anything', async () => { + await renderDevice('disconnected'); + + expect(screen.getByText(/connect to a server/i)).toBeTruthy(); + }); +}); + +describe('device controls', () => { + it('offers to start a diagnostic recording', async () => { + await renderDevice('connected', { + triggerStatus: makeTriggerStatus(), + debug: { enabled: false }, + }); + + await fireEvent.press(screen.getByLabelText('Start debug recording')); + + expect(mockedSocket.toggleDebug).toHaveBeenCalledTimes(1); + }); + + it('says where the log is being written once recording', async () => { + // On a headless Pi the path is the only way to find the file afterwards. + await renderDevice('connected', { + triggerStatus: makeTriggerStatus(), + debug: { enabled: true, logPath: '/home/pi/openflight_sessions/debug.jsonl' }, + }); + + expect(screen.getByText('/home/pi/openflight_sessions/debug.jsonl')).toBeTruthy(); + expect(screen.getByLabelText('Stop debug recording')).toBeTruthy(); + }); + + it('waits for the server to confirm a recording change', async () => { + // The server broadcasts the new state; showing it locally first would lie + // about whether anything is actually being written to disk. + await renderDevice('connected', { + triggerStatus: makeTriggerStatus(), + debug: { enabled: false }, + }); + + await fireEvent.press(screen.getByLabelText('Start debug recording')); + + expect(screen.getByLabelText('Start debug recording')).toBeTruthy(); + }); + + it('offers the camera and its stream', async () => { + await renderDevice('connected', { + triggerStatus: makeTriggerStatus(), + camera: { enabled: true, available: true, streaming: false }, + }); + + await fireEvent.press(screen.getByLabelText('Start camera stream')); + + expect(mockedSocket.toggleCameraStream).toHaveBeenCalledTimes(1); + }); + + it('shows what the server refused rather than swallowing it', async () => { + // Asking to stream with the camera off comes back in the camera_status + // envelope with an error; nothing else reports that the tap did nothing. + await renderDevice('connected', { + triggerStatus: makeTriggerStatus(), + camera: { enabled: false, available: true, streaming: false, error: 'Camera not enabled' }, + }); + + expect(screen.getByText(/camera not enabled/i)).toBeTruthy(); + }); + + it('says so when the Pi has no camera at all', async () => { + await renderDevice('connected', { + triggerStatus: makeTriggerStatus(), + camera: { enabled: false, available: false, streaming: false }, + }); + + expect(screen.getByText(/no camera/i)).toBeTruthy(); + expect(screen.queryByLabelText('Enable camera')).toBeNull(); + }); + + // The pair below is deliberately two tests rather than one that renders, + // cleans up and renders again: a cleanup() inside a test body leaves + // RNTL's container state inconsistent for whichever test runs next, which + // made every later test in this file fail while each passed on its own. + it('offers the controls while a Pi is answering', async () => { + await renderDevice('connected', { + triggerStatus: makeTriggerStatus(), + debug: { enabled: false }, + }); + + expect(screen.getByLabelText('Start debug recording')).toBeTruthy(); + }); + + it('waits for the Pi to say whether it is already recording', async () => { + // Debug mode is a server-global flag, so a session can already be + // recording when this phone connects. Showing the default "Start" before + // the answer arrives means a tap would STOP an active capture while the + // label said start -- so the control waits, like every other card here. + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + + expect(screen.queryByLabelText('Start debug recording')).toBeNull(); + expect(screen.queryByLabelText('Stop debug recording')).toBeNull(); + }); + + it('offers to stop a recording that was already running when it connected', async () => { + await renderDevice('connected', { + triggerStatus: makeTriggerStatus(), + debug: { enabled: true, logPath: '/home/pi/openflight_sessions/debug.jsonl' }, + }); + + expect(screen.getByLabelText('Stop debug recording')).toBeTruthy(); + }); + + it('offers no controls with no server to send them to', async () => { + // A tap that cannot reach the Pi must not look as though it did. Paired + // with the test above, which proves the controls exist at all. + await renderDevice('disconnected'); + + expect(screen.queryByLabelText('Start debug recording')).toBeNull(); + expect(screen.queryByLabelText('Enable camera')).toBeNull(); + }); +}); + +describe('shutting the Pi down', () => { + it('never shuts down on a single tap', async () => { + // The whole point of this screen is to stop someone killing a live SD + // card, so the destructive action is always two deliberate steps. + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + + await fireEvent.press(screen.getByText(SHUT_DOWN)); + + expect(mockRequestShutdown).not.toHaveBeenCalled(); + expect(screen.getByText(CONFIRM)).toBeTruthy(); + }); + + it('shuts down once confirmed', async () => { + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + + await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + + expect(mockRequestShutdown).toHaveBeenCalledTimes(1); + }); + + it('backs out without touching the Pi', async () => { + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + + await fireEvent.press(screen.getByLabelText('Cancel shutdown')); + + expect(mockRequestShutdown).not.toHaveBeenCalled(); + expect(screen.queryByText(CONFIRM)).toBeNull(); + }); + + it('says the Pi is stopping once the request is accepted', async () => { + // The server answers 200 and only then halts, so this reports an accepted + // request -- not a Pi that has finished stopping. + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + + await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + + await waitFor(() => expect(screen.getByText(/shutting down/i)).toBeTruthy()); + }); + + it('says so when the Pi refuses, instead of implying it stopped', async () => { + // Reporting success here would invite someone to pull the power on a Pi + // that is still writing to its SD card. + mockRequestShutdown.mockRejectedValueOnce(new Error('Shutdown request failed (500)')); + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + + await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + + await waitFor(() => expect(screen.getByText(/could not shut down/i)).toBeTruthy()); + }); + + it('offers a retry after a failure', async () => { + mockRequestShutdown.mockRejectedValueOnce(new Error('Network request failed')); + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + await waitFor(() => expect(screen.getByText(/could not shut down/i)).toBeTruthy()); + + await fireEvent.press(screen.getByLabelText('Retry shutdown')); + + expect(mockRequestShutdown).toHaveBeenCalledTimes(2); + }); + + it('cannot be asked for twice while one request is in flight', async () => { + // A second POST while the first is still open would be a second shutdown + // request against a Pi already on its way down. + // + // The guard is that confirming replaces the button with the in-flight + // state, so there is nothing left to press. Asserting it by pressing + // "Confirm shutdown" a second time cannot work: correct behaviour is + // precisely that the control is gone. What is observable is that the + // confirm is no longer offered while the request is open, and that only + // one request was ever sent. + let release: () => void = () => {}; + mockRequestShutdown.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(); + }), + ); + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + + await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + + expect(screen.queryByLabelText('Confirm shutdown')).toBeNull(); + expect(screen.getByText(/shutting down/i)).toBeTruthy(); + expect(mockRequestShutdown).toHaveBeenCalledTimes(1); + + // Let the request settle so the pending promise does not outlive the test. + await act(async () => { + release(); + }); + }); + + it('keeps telling the user not to pull power after the Pi drops the socket', async () => { + // Regression: the server answers /api/shutdown and only then halts, so the + // socket drops a moment after success. The screen gated everything on the + // connection, so the "wait for its lights to settle" line -- the one + // instruction that prevents a corrupted SD card -- was unmounted before it + // could be read, leaving the generic "not connected" message instead. + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + await waitFor(() => expect(screen.getByText(/shutting down/i)).toBeTruthy()); + + // The Pi goes down, exactly as a successful shutdown requires. + await act(async () => { + useSessionStore.setState({ connectionState: 'disconnected' }); + }); + + expect(screen.getByText(/shutting down/i)).toBeTruthy(); + expect(screen.getByText(/before cutting power/i)).toBeTruthy(); + }); + + it('keeps the in-flight state when the connection drops mid-request', async () => { + // A transient drop while the POST is open must not silently reset to idle: + // that hides an outstanding shutdown and invites a second one. + let release: () => void = () => {}; + mockRequestShutdown.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(); + }), + ); + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + + await act(async () => { + useSessionStore.setState({ connectionState: 'disconnected' }); + }); + + expect(screen.getByText(/shutting down/i)).toBeTruthy(); + expect(screen.queryByText(SHUT_DOWN)).toBeNull(); + + await act(async () => { + release(); + }); + }); + + it('still reports a failure after the connection drops', async () => { + // A refused shutdown leaves the Pi running. If the socket also drops, the + // warning must survive -- this is the case where pulling power is worst. + mockRequestShutdown.mockRejectedValueOnce(new Error('Shutdown request failed (500)')); + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + await waitFor(() => expect(screen.getByText(/could not shut down/i)).toBeTruthy()); + + await act(async () => { + useSessionStore.setState({ connectionState: 'disconnected' }); + }); + + expect(screen.getByText(/could not shut down/i)).toBeTruthy(); + }); + + it('survives the connection dropping while nothing is being shut down', async () => { + // Regression: the early return that hides this section sat above a + // useCallback, so a plain drop with phase 'idle' -- someone watching + // status when the wifi hiccups, the most ordinary use of this screen -- + // rendered fewer hooks than the render before it and React threw + // "Rendered fewer hooks than expected". + // + // Every other disconnect test here confirms a shutdown first, which keeps + // the phase in INITIATED and never reaches the branch that skips the hook. + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + + await act(async () => { + useSessionStore.setState({ connectionState: 'disconnected' }); + }); + + expect(screen.getByText('Not connected')).toBeTruthy(); + expect(screen.queryByText(SHUT_DOWN)).toBeNull(); + }); + + it('clears a finished shutdown once a Pi is answering again', async () => { + // Regression: the outcome is kept so it survives the drop a successful + // shutdown causes -- but once a Pi is answering again, "wait for its + // lights to settle" sits next to live status proving it is already back. + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + await waitFor(() => expect(screen.getByText(/before cutting power/i)).toBeTruthy()); + await act(async () => { + useSessionStore.setState({ connectionState: 'disconnected' }); + }); + + await act(async () => { + // Reconnecting starts a new session, exactly as services/socket.ts does. + useSessionStore.setState({ connectionState: 'connected', sessionId: 'session-2' }); + }); + + expect(screen.queryByText(/before cutting power/i)).toBeNull(); + expect(screen.getByText(SHUT_DOWN)).toBeTruthy(); + }); + + it('never carries a failed shutdown over to the next Pi', async () => { + // Regression: "Try again" on a stale failure called straight through to + // requestShutdown, which resolves the address from storage -- the server + // connected *now*, not the one that failed. That fires a real shutdown at + // a different Pi with no confirmation step at all. + mockRequestShutdown.mockRejectedValueOnce(new Error('Shutdown request failed (500)')); + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + await waitFor(() => expect(screen.getByText(/could not shut down/i)).toBeTruthy()); + await act(async () => { + useSessionStore.setState({ connectionState: 'disconnected' }); + }); + mockRequestShutdown.mockClear(); + + // A different Pi answers, under a new session. + await act(async () => { + useSessionStore.setState({ connectionState: 'connected', sessionId: 'session-2' }); + }); + + expect(screen.queryByLabelText('Retry shutdown')).toBeNull(); + expect(mockRequestShutdown).not.toHaveBeenCalled(); + }); + + it('is offered while a Pi is answering', async () => { + // Paired with the test below: without this one, "absent when disconnected" + // would be true of a screen that never offers a shutdown at all. + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + + expect(screen.getByText(SHUT_DOWN)).toBeTruthy(); + }); + + it('cannot be started with no server to send it to', async () => { + await renderDevice('disconnected'); + + expect(screen.queryByText(SHUT_DOWN)).toBeNull(); + }); +}); diff --git a/__tests__/shutdown.test.ts b/__tests__/shutdown.test.ts new file mode 100644 index 0000000..505a6b3 --- /dev/null +++ b/__tests__/shutdown.test.ts @@ -0,0 +1,134 @@ +import { requestShutdown } from '../services/shutdown'; + +// The kiosk posts to a relative '/api/shutdown' because it is served by the Pi +// itself (ui/src/hooks/useSocket.ts). A phone is not, so the address it +// connected to has to be turned into an absolute URL here. +const originalFetch = globalThis.fetch; + +function mockFetch(implementation: jest.Mock) { + globalThis.fetch = implementation as unknown as typeof fetch; + return implementation; +} + +function ok() { + return jest.fn(() => Promise.resolve({ ok: true, status: 200 })); +} + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe('requestShutdown', () => { + it('posts to the shutdown endpoint of the server the phone is talking to', async () => { + const fetchMock = mockFetch(ok()); + + await requestShutdown('http://192.168.1.100:8080'); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://192.168.1.100:8080/api/shutdown', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('does not double the slash when the address already ends in one', async () => { + // The field accepts whatever the user typed, and a trailing slash is a + // normal thing to type. + const fetchMock = mockFetch(ok()); + + await requestShutdown('http://192.168.1.100:8080/'); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://192.168.1.100:8080/api/shutdown', + expect.anything(), + ); + }); + + it('reports a refusal from the server rather than claiming the Pi stopped', async () => { + // Telling someone the Pi is down when it is still running invites them to + // pull the plug, which is the exact thing this feature exists to prevent. + mockFetch(jest.fn(() => Promise.resolve({ ok: false, status: 500 }))); + + await expect(requestShutdown('http://192.168.1.100:8080')).rejects.toThrow(/500/); + }); + + it('reports a request that never reached the server', async () => { + mockFetch(jest.fn(() => Promise.reject(new Error('Network request failed')))); + + await expect(requestShutdown('http://192.168.1.100:8080')).rejects.toThrow(); + }); + + it('gives up rather than hanging on a Pi that never answers', async () => { + // A Pi that is already off the network accepts the connection and then + // says nothing. Without a bound, the screen sits on a spinner forever and + // the user cannot tell whether it is safe to pull the power -- which is + // not an observable end state. + // + // Fake timers both prove the abort actually fires at the deadline and + // stop the real 10s timer outliving the test. + jest.useFakeTimers(); + try { + const fetchMock = jest.fn( + (_url: string, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => + reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })), + ); + }), + ); + mockFetch(fetchMock as unknown as jest.Mock); + + const pending = requestShutdown('http://192.168.1.100:8080'); + jest.advanceTimersByTime(10_000); + + await expect(pending).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledWith( + 'http://192.168.1.100:8080/api/shutdown', + expect.objectContaining({ signal: expect.anything() }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('works on a runtime whose AbortSignal has no timeout() helper', async () => { + // Regression: React Native polyfills AbortController/AbortSignal from + // abort-controller v3 (react-native/Libraries/Core/setUpXHR.js), which has + // no static AbortSignal.timeout(). Jest runs on Node, where it does exist, + // so a test that leaves it in place cannot tell the two runtimes apart -- + // and an earlier version of this file used it, which threw while building + // the fetch options on device. Every shutdown then reported failure + // without a request ever leaving the phone. + const AS = AbortSignal as unknown as { timeout?: unknown }; + const original = AS.timeout; + delete AS.timeout; + + try { + const fetchMock = ok(); + mockFetch(fetchMock); + + await expect(requestShutdown('http://192.168.1.100:8080')).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith( + 'http://192.168.1.100:8080/api/shutdown', + expect.objectContaining({ signal: expect.anything() }), + ); + } finally { + AS.timeout = original; + } + }); + + it('reports a timeout as a failure the user can retry', async () => { + mockFetch( + jest.fn(() => Promise.reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }))), + ); + + await expect(requestShutdown('http://192.168.1.100:8080')).rejects.toThrow(); + }); + + it('resolves when the server accepts the request', async () => { + // The server answers 200 and only then halts, so a resolved promise means + // "accepted", not "already off". + mockFetch(ok()); + + await expect(requestShutdown('http://192.168.1.100:8080')).resolves.toBeUndefined(); + }); +}); diff --git a/__tests__/socket.test.ts b/__tests__/socket.test.ts index 119c038..3309d68 100644 --- a/__tests__/socket.test.ts +++ b/__tests__/socket.test.ts @@ -1,4 +1,5 @@ import { socketService } from '../services/socket'; +import { useDeviceStore } from '../stores/useDeviceStore'; import { useSessionStore } from '../stores/useSessionStore'; import type { Shot } from '../types'; @@ -11,14 +12,20 @@ jest.mock('socket.io-client', () => { const handlers: Record void> = {}; const emit = jest.fn(); const close = jest.fn(); + // Mirrors Socket.IO's `socket.connected`; `trigger` flips it alongside the + // connect/disconnect events it fires. + const status = { connected: false }; const io = jest.fn((_url: string, _opts?: unknown) => ({ on: (event: string, cb: (...args: unknown[]) => void) => { handlers[event] = cb; }, emit, close, + get connected() { + return status.connected; + }, })); - return { io, __mock: { handlers, emit, close } }; + return { io, __mock: { handlers, emit, close, status } }; }); jest.mock('@react-native-async-storage/async-storage', () => @@ -52,12 +59,20 @@ const socketMock = jest.requireMock('socket.io-client') as { handlers: Record void>; emit: jest.Mock; close: jest.Mock; + status: { connected: boolean }; }; }; const { io: mockIo } = socketMock; -const { emit: mockEmit, close: mockClose, handlers: mockHandlers } = socketMock.__mock; +const { + emit: mockEmit, + close: mockClose, + handlers: mockHandlers, + status: mockStatus, +} = socketMock.__mock; function trigger(event: string, ...args: unknown[]) { + if (event === 'connect') mockStatus.connected = true; + if (event === 'disconnect' || event === 'connect_error') mockStatus.connected = false; mockHandlers[event]?.(...args); } @@ -90,7 +105,9 @@ function makeShot(timestamp: string, overrides: Partial = {}): Shot { beforeEach(() => { useSessionStore.setState({ connectionState: 'disconnected', sessionId: null, shots: [] }); + useDeviceStore.getState().reset(); for (const key of Object.keys(mockHandlers)) delete mockHandlers[key]; + mockStatus.connected = false; mockIo.mockClear(); mockEmit.mockClear(); mockClose.mockClear(); @@ -336,3 +353,221 @@ describe('a shot the server enriches after publishing it', () => { expect(useSessionStore.getState().shots[0].spin_rpm).toBe(2680); }); }); + +// On a headless Pi this panel is the only window onto the radar and the +// battery, so what it shows has to follow the connection honestly: present +// while a server is answering, gone once it is not. +describe('device status', () => { + const triggerStatus = { + mode: 'rolling-buffer', + trigger_type: 'audio', + radar_connected: true, + radar_port: '/dev/ttyUSB0', + triggers_total: 12, + triggers_accepted: 9, + triggers_rejected: 3, + }; + + const powerStatus = { + available: true, + provider: 'geekworm', + state: 'on_battery', + battery_percent: 78, + battery_voltage_v: 3.91, + external_power: false, + updated_at: '2026-09-22T05:30:00Z', + error: null, + }; + + it('asks for the trigger status once connected', () => { + // The server pushes it on connect, but a phone joining an already-running + // session cannot rely on having seen that push, so it asks as well. + socketService.connect('http://host:8080'); + trigger('connect'); + + expect(mockEmit).toHaveBeenCalledWith('get_trigger_status'); + }); + + it('asks again after reconnecting, since the hardware may have changed', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + mockEmit.mockClear(); + + trigger('disconnect'); + trigger('connect'); + + expect(mockEmit).toHaveBeenCalledWith('get_trigger_status'); + }); + + it('shows the trigger status the server reports', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + + trigger('trigger_status', triggerStatus); + + expect(useDeviceStore.getState().triggerStatus?.radar_port).toBe('/dev/ttyUSB0'); + expect(useDeviceStore.getState().triggerLoaded).toBe(true); + }); + + it('shows the power status the server reports', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + + trigger('power_status', powerStatus); + + expect(useDeviceStore.getState().powerStatus?.battery_percent).toBe(78); + expect(useDeviceStore.getState().powerLoaded).toBe(true); + }); + + it('ignores a malformed status rather than blanking the panel', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('trigger_status', triggerStatus); + trigger('power_status', powerStatus); + + trigger('trigger_status', null); + trigger('power_status', undefined); + + expect(useDeviceStore.getState().triggerStatus?.mode).toBe('rolling-buffer'); + expect(useDeviceStore.getState().powerStatus?.provider).toBe('geekworm'); + }); + + it('keeps the last reading through a transient drop', () => { + // Socket.IO reconnects on its own. Blanking the radar and battery every + // time the wifi hiccups would read as hardware failing, not a wobbly link. + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('trigger_status', triggerStatus); + trigger('power_status', powerStatus); + + trigger('disconnect'); + + expect(useDeviceStore.getState().triggerStatus).not.toBeNull(); + expect(useDeviceStore.getState().powerStatus).not.toBeNull(); + }); + + it('forgets the device when the user disconnects deliberately', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('trigger_status', triggerStatus); + trigger('power_status', powerStatus); + // The panel has to be showing something first, or "it is empty afterwards" + // is true of a store nothing ever filled and proves nothing. + expect(useDeviceStore.getState().triggerStatus).not.toBeNull(); + expect(useDeviceStore.getState().powerStatus).not.toBeNull(); + + socketService.disconnect(); + + const state = useDeviceStore.getState(); + expect(state.triggerStatus).toBeNull(); + expect(state.powerStatus).toBeNull(); + expect(state.triggerLoaded).toBe(false); + expect(state.powerLoaded).toBe(false); + }); + + it('asks for the debug and camera state once connected', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + + expect(mockEmit).toHaveBeenCalledWith('get_debug_status'); + expect(mockEmit).toHaveBeenCalledWith('get_camera_status'); + }); + + it('shows the debug recording state the server reports', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + + trigger('debug_status', { enabled: true, log_path: '/home/pi/debug.jsonl' }); + + expect(useDeviceStore.getState().debugEnabled).toBe(true); + expect(useDeviceStore.getState().debugLogPath).toBe('/home/pi/debug.jsonl'); + }); + + it('follows a debug toggle made on another client', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + + trigger('debug_toggled', { enabled: true, log_path: '/home/pi/debug.jsonl' }); + + expect(useDeviceStore.getState().debugEnabled).toBe(true); + }); + + it('shows the camera state the server reports', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + + trigger('camera_status', { enabled: true, available: true, streaming: true }); + + expect(useDeviceStore.getState().cameraStatus?.streaming).toBe(true); + }); + + it('asks the server to toggle debug recording while connected', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + mockEmit.mockClear(); + + socketService.toggleDebug(); + + expect(mockEmit).toHaveBeenCalledWith('toggle_debug'); + }); + + it('asks the server to toggle the camera and its stream while connected', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + mockEmit.mockClear(); + + socketService.toggleCamera(); + socketService.toggleCameraStream(); + + expect(mockEmit).toHaveBeenCalledWith('toggle_camera'); + expect(mockEmit).toHaveBeenCalledWith('toggle_camera_stream'); + }); + + it('sends no toggle during a transient drop, even once reconnected', () => { + // Socket.IO buffers anything emitted through a drop and replays it on + // reconnect, so a toggle tapped while the wifi was away would land later + // and flip recording or the camera behind the user's back. + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('disconnect'); + mockEmit.mockClear(); + + socketService.toggleDebug(); + socketService.toggleCamera(); + socketService.toggleCameraStream(); + trigger('connect'); + + expect(mockEmit).not.toHaveBeenCalledWith('toggle_debug'); + expect(mockEmit).not.toHaveBeenCalledWith('toggle_camera'); + expect(mockEmit).not.toHaveBeenCalledWith('toggle_camera_stream'); + }); + + it('forgets the debug and camera state on a deliberate disconnect', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('debug_status', { enabled: true, log_path: '/home/pi/debug.jsonl' }); + trigger('camera_status', { enabled: true, available: true, streaming: true }); + expect(useDeviceStore.getState().debugEnabled).toBe(true); + + socketService.disconnect(); + + expect(useDeviceStore.getState().debugEnabled).toBe(false); + expect(useDeviceStore.getState().debugLogPath).toBeNull(); + expect(useDeviceStore.getState().cameraStatus).toBeNull(); + }); + + it('forgets the device when switching to a different server', () => { + // Another Pi's radar port and battery must not be read as this one's. + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('trigger_status', triggerStatus); + trigger('power_status', powerStatus); + expect(useDeviceStore.getState().triggerStatus).not.toBeNull(); + expect(useDeviceStore.getState().powerStatus).not.toBeNull(); + + socketService.connect('http://other:8080'); + + expect(useDeviceStore.getState().triggerStatus).toBeNull(); + expect(useDeviceStore.getState().powerStatus).toBeNull(); + }); +}); diff --git a/__tests__/useDeviceStore.test.ts b/__tests__/useDeviceStore.test.ts new file mode 100644 index 0000000..8a2e36c --- /dev/null +++ b/__tests__/useDeviceStore.test.ts @@ -0,0 +1,177 @@ +import { useDeviceStore } from '../stores/useDeviceStore'; +import type { PowerStatusPayload, TriggerStatusPayload } from '../types'; + +// Representative payloads, shaped exactly as the server sends them: +// _get_trigger_status() in src/openflight/server.py and PowerStatus.to_dict() +// in src/openflight/power/models.py. +function makeTriggerStatus(overrides: Partial = {}): TriggerStatusPayload { + return { + mode: 'rolling-buffer', + trigger_type: 'audio', + radar_connected: true, + radar_port: '/dev/ttyUSB0', + triggers_total: 12, + triggers_accepted: 9, + triggers_rejected: 3, + ...overrides, + }; +} + +function makePowerStatus(overrides: Partial = {}): PowerStatusPayload { + return { + available: true, + provider: 'geekworm', + state: 'on_battery', + battery_percent: 78, + battery_voltage_v: 3.91, + external_power: false, + updated_at: '2026-09-22T05:30:00Z', + error: null, + ...overrides, + }; +} + +beforeEach(() => { + useDeviceStore.getState().reset(); +}); + +describe('useDeviceStore', () => { + it('knows nothing about the device until a server reports something', () => { + const state = useDeviceStore.getState(); + + expect(state.triggerStatus).toBeNull(); + expect(state.powerStatus).toBeNull(); + // A screen has to tell "not asked yet" apart from "asked, nothing there", + // so it can show a skeleton rather than claim the hardware is absent. + expect(state.triggerLoaded).toBe(false); + expect(state.powerLoaded).toBe(false); + }); + + it('mirrors the trigger status the server reports', () => { + useDeviceStore.getState().applyTriggerStatus(makeTriggerStatus({ triggers_accepted: 41 })); + + const state = useDeviceStore.getState(); + expect(state.triggerStatus?.triggers_accepted).toBe(41); + expect(state.triggerLoaded).toBe(true); + }); + + it('mirrors the power status the server reports', () => { + useDeviceStore.getState().applyPowerStatus(makePowerStatus({ battery_percent: 12 })); + + const state = useDeviceStore.getState(); + expect(state.powerStatus?.battery_percent).toBe(12); + expect(state.powerLoaded).toBe(true); + }); + + it('replaces the previous status wholesale rather than merging into it', () => { + // Each payload is a complete snapshot. Merging would strand a field from an + // older reading next to fresh ones and misreport the device. + useDeviceStore.getState().applyTriggerStatus(makeTriggerStatus({ radar_port: '/dev/ttyUSB0' })); + + useDeviceStore.getState().applyTriggerStatus(makeTriggerStatus({ radar_port: null })); + + expect(useDeviceStore.getState().triggerStatus?.radar_port).toBeNull(); + }); + + it('keeps the last good reading when a malformed payload arrives', () => { + // Blanking the panel mid-session would read as "the hardware went away", + // which is a worse lie than a reading that is a few seconds stale. + useDeviceStore.getState().applyTriggerStatus(makeTriggerStatus()); + useDeviceStore.getState().applyPowerStatus(makePowerStatus()); + + useDeviceStore.getState().applyTriggerStatus(null as unknown as TriggerStatusPayload); + useDeviceStore.getState().applyPowerStatus(undefined as unknown as PowerStatusPayload); + + expect(useDeviceStore.getState().triggerStatus?.mode).toBe('rolling-buffer'); + expect(useDeviceStore.getState().powerStatus?.provider).toBe('geekworm'); + }); + + it('accepts a device with no battery as a real answer, not a missing one', () => { + // A mains-powered Pi reports available:false. That is information, and the + // panel must show it rather than sit on a skeleton forever. + useDeviceStore.getState().applyPowerStatus( + makePowerStatus({ + available: false, + state: 'unavailable', + battery_percent: null, + battery_voltage_v: null, + external_power: null, + }), + ); + + const state = useDeviceStore.getState(); + expect(state.powerLoaded).toBe(true); + expect(state.powerStatus?.available).toBe(false); + }); + + it('does not claim the Pi is idle before it has said so', () => { + // debugEnabled starts false, which is indistinguishable on screen from a + // Pi that answered "not recording". Debug mode is server-global, so a + // session may already be recording -- the flag is what lets a screen wait + // instead of offering a Start that would actually stop it. + expect(useDeviceStore.getState().debugLoaded).toBe(false); + + useDeviceStore.getState().applyDebugStatus({ enabled: false, log_path: null }); + + expect(useDeviceStore.getState().debugLoaded).toBe(true); + }); + + it('mirrors whether the Pi is recording a debug log, and where', () => { + useDeviceStore.getState().applyDebugStatus({ enabled: true, log_path: '/home/pi/debug.jsonl' }); + + const state = useDeviceStore.getState(); + expect(state.debugEnabled).toBe(true); + expect(state.debugLogPath).toBe('/home/pi/debug.jsonl'); + }); + + it('treats a toggle-off without a path as no path, not an unchanged one', () => { + // handle_toggle_debug omits log_path entirely when disabling, so a client + // that only overwrites present keys would keep showing the old file as + // though it were still being written. + useDeviceStore.getState().applyDebugStatus({ enabled: true, log_path: '/home/pi/debug.jsonl' }); + + useDeviceStore.getState().applyDebugStatus({ enabled: false }); + + expect(useDeviceStore.getState().debugEnabled).toBe(false); + expect(useDeviceStore.getState().debugLogPath).toBeNull(); + }); + + it('mirrors the camera the server reports', () => { + useDeviceStore + .getState() + .applyCameraStatus({ enabled: true, available: true, streaming: false }); + + const state = useDeviceStore.getState(); + expect(state.cameraStatus?.enabled).toBe(true); + expect(state.cameraStatus?.streaming).toBe(false); + expect(state.cameraLoaded).toBe(true); + }); + + it('keeps a refusal the server sends rather than dropping it', () => { + // Asking to stream while the camera is off comes back in the same envelope + // with an error, and that is the only signal the request was refused. + useDeviceStore.getState().applyCameraStatus({ + enabled: false, + available: true, + streaming: false, + error: 'Camera not enabled', + }); + + expect(useDeviceStore.getState().cameraStatus?.error).toBe('Camera not enabled'); + }); + + it('forgets a device once it is no longer the one being talked to', () => { + // Status belongs to the server that reported it; another Pi's radar port + // and battery must not linger as though they described the new one. + useDeviceStore.getState().applyTriggerStatus(makeTriggerStatus()); + useDeviceStore.getState().applyPowerStatus(makePowerStatus()); + + useDeviceStore.getState().reset(); + + const state = useDeviceStore.getState(); + expect(state.triggerStatus).toBeNull(); + expect(state.powerStatus).toBeNull(); + expect(state.triggerLoaded).toBe(false); + expect(state.powerLoaded).toBe(false); + }); +}); diff --git a/app/(tabs)/device.tsx b/app/(tabs)/device.tsx index 3ac7cfd..669ab78 100644 --- a/app/(tabs)/device.tsx +++ b/app/(tabs)/device.tsx @@ -1,11 +1,542 @@ -import { PlaceholderScreen } from '../../components/PlaceholderScreen'; +import { useCallback, useState } from 'react'; +import { + ActivityIndicator, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { fontFamily } from '../../components/theme/fonts'; +import { radius, spacing, type Palette } from '../../components/theme/tokens'; +import { useThemedStyles } from '../../components/theme/useTheme'; +import { requestShutdown } from '../../services/shutdown'; +import { socketService } from '../../services/socket'; +import { loadServerUrl } from '../../storage/connection'; +import { useDeviceStore } from '../../stores/useDeviceStore'; +import { useSessionStore } from '../../stores/useSessionStore'; +import type { CameraStatusPayload, PowerStatusPayload, TriggerStatusPayload } from '../../types'; + +// The troubleshooting lifeline for a Pi with no screen attached: what the +// hardware is doing, and the only safe way to stop it. Every value is shown as +// the server states it -- an absent measurement reads as an em dash, never as +// a zero, matching the Shots screen. + +const MISSING = '—'; + +// How the server describes each power state. 'unavailable' is what a +// mains-powered Pi with no battery HAT reports, which is an answer rather than +// a missing reading. +const POWER_LABEL: Record = { + plugged_in: 'Plugged in', + on_battery: 'On battery', + low: 'Battery low', + critical: 'Battery critical', + unavailable: 'No battery', +}; + +const MODE_LABEL: Record = { + 'rolling-buffer': 'Rolling buffer', + mock: 'Mock mode', + 'swing-speed': 'Swing speed', +}; + +function Row({ label, value }: { label: string; value: string }) { + const styles = useThemedStyles(createStyles); + + return ( + + {label} + {value} + + ); +} + +function TriggerCard({ status }: { status: TriggerStatusPayload }) { + const styles = useThemedStyles(createStyles); + + // The server reports radar_connected as `monitor is not None and not + // mock_mode`, so it is false in mock mode even though everything works. + // Calling that "offline" would send someone hunting a fault that is not + // there, so the mode is what gets reported in that case. + const radar = + status.mode === 'mock' ? 'Simulated' : status.radar_connected ? 'Connected' : 'Not connected'; + + return ( + + Launch monitor + + + + + + + + + ); +} + +function PowerCard({ status }: { status: PowerStatusPayload }) { + const styles = useThemedStyles(createStyles); + + // A Pi without a battery provider still reports its absence. Showing 0% + // there would look like a Pi about to die. + const charge = + status.battery_percent === null ? MISSING : `${Math.round(status.battery_percent)}%`; + const voltage = + status.battery_voltage_v === null ? MISSING : `${status.battery_voltage_v.toFixed(2)} V`; + + return ( + + Power + + {status.available ? : null} + {status.available ? : null} + + {status.error === null ? null : } + + ); +} + +// A labelled row with an action on the right. The server owns every one of +// these states, so the control never reflects the tap -- only what came back. +function ControlRow({ + label, + detail, + action, + accessibilityLabel, + onPress, +}: { + label: string; + detail?: string; + action: string; + accessibilityLabel: string; + onPress: () => void; +}) { + const styles = useThemedStyles(createStyles); + + return ( + + + {label} + {detail ? {detail} : null} + + + {action} + + + ); +} + +function DebugCard({ enabled, logPath }: { enabled: boolean; logPath: string | null }) { + const styles = useThemedStyles(createStyles); + + return ( + + Diagnostics + socketService.toggleDebug()} + /> + {enabled && logPath !== null ? {logPath} : null} + + ); +} + +function CameraCard({ status }: { status: CameraStatusPayload }) { + const styles = useThemedStyles(createStyles); + + if (!status.available) { + return ( + + Camera + No camera on this Pi. + + ); + } + + return ( + + Camera + socketService.toggleCamera()} + /> + {status.enabled ? ( + socketService.toggleCameraStream()} + /> + ) : null} + {/* A refusal arrives in the same envelope as the status; nothing else + would tell the user their tap did nothing. */} + {status.error ? {status.error} : null} + + ); +} + +type ShutdownPhase = 'idle' | 'confirming' | 'pending' | 'done' | 'failed'; + +// Once a request has actually been sent, what happened to it outlives the +// connection: a successful shutdown takes the socket down with it ~0.5s after +// the server answers (_shutdown_process_after_delay in server.py), and a +// refused one leaves a Pi running that must not have its power pulled. Either +// way the message has to survive the drop that follows. +const INITIATED: ShutdownPhase[] = ['pending', 'done', 'failed']; + +function ShutdownSection({ isConnected }: { isConnected: boolean }) { + const styles = useThemedStyles(createStyles); + const [phase, setPhase] = useState('idle'); + + // The address is read only when it is needed, so this screen never races the + // connection bar for it on mount. + // + // Declared before any early return: every hook in this component has to run + // on every render, and hiding the section below used to skip this one, which + // crashed React the first time the wifi dropped with nothing being shut down. + const send = useCallback(async () => { + setPhase('pending'); + try { + await requestShutdown(await loadServerUrl()); + setPhase('done'); + } catch { + // Saying the Pi stopped when it did not would invite someone to pull the + // power on a live SD card, which is what this screen exists to prevent. + setPhase('failed'); + } + }, []); + + // Nothing has been sent in 'idle' or 'confirming', so there is no outcome to + // preserve and a confirm dialog for a Pi this phone is no longer talking to + // would be misleading -- those collapse with the rest of the screen. + if (!isConnected && !INITIATED.includes(phase)) return null; + + if (phase === 'pending') { + return ( + + + Shutting down… + + ); + } + + if (phase === 'done') { + return ( + + {/* The server answers before it halts, so this reports an accepted + request rather than a Pi that has finished stopping. */} + Shutting down + + The Pi accepted the request. Wait for its lights to settle before cutting power. + + + ); + } + + if (phase === 'failed') { + return ( + + Could not shut down + The Pi is still running. Do not pull its power. + void send()} + accessibilityRole="button" + accessibilityLabel="Retry shutdown" + > + Try again + + + ); + } + + if (phase === 'confirming') { + return ( + + Shut down the Pi + + This stops the server. The current session is not kept on the Pi. + + + void send()} + accessibilityRole="button" + accessibilityLabel="Confirm shutdown" + > + Shut down now + + setPhase('idle')} + accessibilityRole="button" + accessibilityLabel="Cancel shutdown" + > + Cancel + + + + ); + } + + return ( + setPhase('confirming')} + accessibilityRole="button" + accessibilityLabel="Shut down the Pi" + > + Shut down + + ); +} -// Phase 2: device status (radar/trigger health, battery) + graceful shutdown. export default function DeviceScreen() { + const styles = useThemedStyles(createStyles); + const connectionState = useSessionStore((s) => s.connectionState); + // One connection span. It changes on every (re)connect, which is what tells + // the shutdown panel that any outcome it is holding belongs to a past Pi. + const sessionId = useSessionStore((s) => s.sessionId); + const triggerStatus = useDeviceStore((s) => s.triggerStatus); + const powerStatus = useDeviceStore((s) => s.powerStatus); + const triggerLoaded = useDeviceStore((s) => s.triggerLoaded); + const powerLoaded = useDeviceStore((s) => s.powerLoaded); + const debugEnabled = useDeviceStore((s) => s.debugEnabled); + const debugLogPath = useDeviceStore((s) => s.debugLogPath); + const debugLoaded = useDeviceStore((s) => s.debugLoaded); + const cameraStatus = useDeviceStore((s) => s.cameraStatus); + const cameraLoaded = useDeviceStore((s) => s.cameraLoaded); + + const isConnected = connectionState === 'connected'; + return ( - + + + Device + + + + {isConnected ? ( + <> + {/* "Nothing reported yet" is not the same claim as "this Pi has no + radar", so an unanswered request waits rather than showing an + empty state. */} + {triggerLoaded && triggerStatus !== null ? ( + + ) : ( + + Launch monitor + Waiting for the server to report… + + )} + + {powerLoaded && powerStatus !== null ? : null} + + {/* Nothing until the Pi has answered: debug mode is server-global, + so the default "off" could contradict a session that is already + recording, and a Start that actually stops a capture is worse + than a card that appears a moment late. */} + {debugLoaded ? : null} + + {cameraLoaded && cameraStatus !== null ? : null} + + ) : ( + + Not connected + + Connect to a server on the Live tab to see how it is doing. + + + )} + + {/* Deliberately outside the connection branch: a shutdown that has + been sent takes the connection down with it, and its outcome is + what the user needs to read at exactly that moment. The section + renders nothing itself while idle and disconnected. + + Keyed on the session so a new connection remounts it, dropping an + outcome that described the previous one -- "wait for its lights to + settle" beside live status from a Pi that is plainly back, or a + stale "Try again" that would shut down whichever Pi is connected + now. A transient drop keeps the same session, so a request still in + flight is left alone. */} + + + ); } + +const createStyles = (c: Palette) => + StyleSheet.create({ + safe: { + flex: 1, + backgroundColor: c.bg, + }, + header: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.sm, + }, + title: { + fontSize: 24, + fontFamily: fontFamily.bold, + color: c.text, + }, + content: { + paddingHorizontal: spacing.lg, + paddingBottom: spacing.xl, + }, + card: { + backgroundColor: c.surface, + borderRadius: radius.card, + borderWidth: StyleSheet.hairlineWidth, + borderColor: c.border, + padding: spacing.lg, + marginBottom: spacing.md, + }, + cardTitle: { + fontSize: 12, + fontFamily: fontFamily.semibold, + color: c.textMuted, + textTransform: 'uppercase', + letterSpacing: 1, + marginBottom: spacing.sm, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 36, + }, + rowLabel: { + fontSize: 14, + fontFamily: fontFamily.regular, + color: c.textMuted, + }, + rowValue: { + fontSize: 14, + fontFamily: fontFamily.semibold, + fontVariant: ['tabular-nums'], + color: c.text, + }, + note: { + fontSize: 13, + fontFamily: fontFamily.regular, + color: c.textMuted, + marginTop: spacing.xs, + }, + controlRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.md, + minHeight: 48, + }, + controlText: { + flex: 1, + }, + controlDetail: { + fontSize: 12, + fontFamily: fontFamily.regular, + color: c.textMuted, + marginTop: 2, + }, + controlError: { + fontSize: 12, + fontFamily: fontFamily.medium, + color: c.danger, + marginTop: spacing.xs, + }, + controlButton: { + minHeight: 44, + minWidth: 88, + borderRadius: radius.control, + borderWidth: 1, + borderColor: c.border, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.md, + }, + controlButtonText: { + fontSize: 14, + fontFamily: fontFamily.semibold, + color: c.text, + }, + shutdownTrigger: { + minHeight: 48, + borderRadius: radius.control, + borderWidth: 1, + borderColor: c.danger, + alignItems: 'center', + justifyContent: 'center', + marginTop: spacing.sm, + }, + shutdownTriggerText: { + fontSize: 15, + fontFamily: fontFamily.semibold, + color: c.danger, + }, + confirmRow: { + flexDirection: 'row', + gap: spacing.sm, + marginTop: spacing.md, + }, + dangerButton: { + flex: 1, + minHeight: 48, + borderRadius: radius.control, + backgroundColor: c.danger, + alignItems: 'center', + justifyContent: 'center', + }, + dangerButtonText: { + fontSize: 15, + fontFamily: fontFamily.semibold, + color: c.accentFg, + }, + cancelButton: { + minHeight: 48, + borderRadius: radius.control, + borderWidth: 1, + borderColor: c.border, + paddingHorizontal: spacing.lg, + alignItems: 'center', + justifyContent: 'center', + }, + cancelButtonText: { + fontSize: 15, + fontFamily: fontFamily.semibold, + color: c.text, + }, + empty: { + alignItems: 'center', + paddingTop: 48, + gap: spacing.xs, + }, + emptyTitle: { + fontSize: 16, + fontFamily: fontFamily.semibold, + color: c.text, + }, + emptyDetail: { + fontSize: 13, + fontFamily: fontFamily.regular, + color: c.textMuted, + textAlign: 'center', + }, + }); diff --git a/services/shutdown.ts b/services/shutdown.ts new file mode 100644 index 0000000..156b20c --- /dev/null +++ b/services/shutdown.ts @@ -0,0 +1,54 @@ +// Graceful shutdown over HTTP. +// +// This is the one server action the phone takes outside the socket. The kiosk +// posts to a relative '/api/shutdown' because the Pi serves it (see +// ui/src/hooks/useSocket.ts); a phone has to address the Pi explicitly, so the +// server URL the user connected to is turned into an absolute endpoint here. +// +// Deliberately not on the socket: `shutdown` exists as a socket event too, but +// the web UI uses the REST route, so both clients exercise the same server +// path. The socket is about to drop anyway -- that is the point. + +const SHUTDOWN_PATH = '/api/shutdown'; + +// A Pi that has already left the network accepts the connection and then says +// nothing. Without a bound the screen sits on a spinner indefinitely, which +// leaves the user unable to tell whether it is safe to cut the power -- not an +// observable end state. Generous enough for a busy Pi on a weak LAN link. +const TIMEOUT_MS = 10_000; + +// Deliberately an AbortController rather than AbortSignal.timeout(): React +// Native polyfills AbortController/AbortSignal from abort-controller v3 +// (react-native/Libraries/Core/setUpXHR.js), which has no static timeout() +// helper. Calling it threw while building the fetch options -- before any +// request left the phone -- so every shutdown reported failure on device while +// the tests passed on Node, which does have it. + +// The server answers 200 and only then halts, on a short delay +// (_shutdown_process_after_delay in server.py). Resolving therefore means the +// Pi accepted the request, not that it has finished stopping. +export async function requestShutdown(serverUrl: string): Promise { + // The address comes from a text field, so a trailing slash is entirely + // normal and must not produce '//api/shutdown'. + const base = serverUrl.replace(/\/+$/, ''); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + + try { + const response = await fetch(`${base}${SHUTDOWN_PATH}`, { + method: 'POST', + signal: controller.signal, + }); + + // Anything but a success means the Pi is still running. Saying otherwise + // would invite someone to pull the power on a live SD card, which is the + // exact failure this feature exists to prevent -- so this throws rather + // than degrading quietly, and the caller surfaces it. + if (!response.ok) { + throw new Error(`Shutdown request failed (${response.status})`); + } + } finally { + clearTimeout(timer); + } +} diff --git a/services/socket.ts b/services/socket.ts index 086589a..b1e0392 100644 --- a/services/socket.ts +++ b/services/socket.ts @@ -1,8 +1,18 @@ import { io, type Socket } from 'socket.io-client'; +import { useDeviceStore } from '../stores/useDeviceStore'; import { useSessionStore } from '../stores/useSessionStore'; import { saveServerUrl } from '../storage/connection'; import { getShotRepository } from '../storage/db'; -import type { SessionStatePayload, Shot, ShotEnvelope } from '../types'; +import type { + CameraStatusPayload, + DebugStatusPayload, + DebugToggledPayload, + PowerStatusPayload, + SessionStatePayload, + Shot, + ShotEnvelope, + TriggerStatusPayload, +} from '../types'; // Singleton Socket.IO client, mirroring the web app's socketService shape: one // place that maps every server event onto a store mutation. Kept out of the @@ -41,6 +51,13 @@ class SocketService { this.socket = null; } + // Device status describes the server that reported it. Switching servers + // must not leave another Pi's radar port and battery on screen until the + // new one answers. + if (this.url !== null && this.url !== url) { + useDeviceStore.getState().reset(); + } + store.setConnectionState('connecting'); this.url = url; @@ -59,12 +76,50 @@ class SocketService { this.socket = null; this.url = null; useSessionStore.getState().setConnectionState('disconnected'); + // Only the deliberate disconnect forgets the device. A transient drop is + // handled by the 'disconnect' event below, which leaves the last reading + // in place: blanking the radar and battery every time the wifi hiccups + // would read as hardware failing rather than a wobbly link. + useDeviceStore.getState().reset(); } simulateShot(): void { this.socket?.emit('simulate_shot'); } + // --- Device controls --- + // Each toggle is fire-and-forget: the server flips the state and broadcasts + // the result (`debug_toggled`, `camera_status`) to every client, so there is + // nothing to update optimistically and nothing to roll back. A refusal -- + // streaming with the camera off, or a camera that never initialised -- + // arrives in that same broadcast with an `error` set. + + toggleDebug(): void { + this.emitWhileConnected('toggle_debug'); + } + + toggleCamera(): void { + this.emitWhileConnected('toggle_camera'); + } + + toggleCameraStream(): void { + this.emitWhileConnected('toggle_camera_stream'); + } + + // Socket.IO keeps the socket through a transient drop and buffers anything + // emitted meanwhile, replaying it on reconnect. A toggle tapped while the + // wifi was away would then land later and flip recording or the camera + // behind the user's back, so a change is sent only over a live connection. + // + // Named to match the identical helper on the club-selection branch: both + // features need exactly this guard, and whichever merges second should + // delete its copy rather than keep two. + private emitWhileConnected(event: string, payload?: object): void { + if (!this.socket?.connected) return; + if (payload === undefined) this.socket.emit(event); + else this.socket.emit(event, payload); + } + // Files one shot under the current visit. Callers fire and forget, so this // absorbs every failure itself rather than leaving a rejected promise loose. private async persistShot(shot: Shot): Promise { @@ -94,6 +149,16 @@ class SocketService { void saveServerUrl(url); // Re-sync the full session on every (re)connect, not just the first. socket.emit('get_session'); + // The server pushes trigger status on connect too, but a phone joining a + // session that is already running cannot rely on having seen that push -- + // and the hardware may have changed while it was away. Read-only, so it + // needs no connected-only guard: a replayed request costs a snapshot. + socket.emit('get_trigger_status'); + // Same reasoning as the trigger status: these are pushed on connect by + // the server, but a phone joining a session already in progress cannot + // rely on having seen that push. All three are read-only requests. + socket.emit('get_debug_status'); + socket.emit('get_camera_status'); }); socket.on('disconnect', () => { @@ -127,6 +192,34 @@ class SocketService { store().replaceShot(data.shot); void this.persistShot(data.shot); }); + + // Device health. Both arrive unprompted on connect and again whenever they + // change -- trigger status also after every shot -- and each payload is a + // complete snapshot, so the store applies it verbatim. The store guards a + // malformed payload rather than blanking the panel mid-session. + socket.on('trigger_status', (data: TriggerStatusPayload) => { + useDeviceStore.getState().applyTriggerStatus(data); + }); + + socket.on('power_status', (data: PowerStatusPayload) => { + useDeviceStore.getState().applyPowerStatus(data); + }); + + // Debug recording. The server answers a query with `debug_status` and a + // toggle with `debug_toggled` -- same meaning, and the latter omits + // log_path when switching off, which the store reads as "no log". + socket.on('debug_status', (data: DebugStatusPayload) => { + useDeviceStore.getState().applyDebugStatus(data); + }); + + socket.on('debug_toggled', (data: DebugToggledPayload) => { + useDeviceStore.getState().applyDebugStatus(data); + }); + + // Broadcast after every camera change, including one the server refused. + socket.on('camera_status', (data: CameraStatusPayload) => { + useDeviceStore.getState().applyCameraStatus(data); + }); } } diff --git a/stores/useDeviceStore.ts b/stores/useDeviceStore.ts new file mode 100644 index 0000000..191d3a2 --- /dev/null +++ b/stores/useDeviceStore.ts @@ -0,0 +1,108 @@ +import { create } from 'zustand'; +import type { + CameraStatusPayload, + DebugStatusPayload, + DebugToggledPayload, + PowerStatusPayload, + TriggerStatusPayload, +} from '../types'; + +// A mirror of the hardware the connected server is driving, not a source of +// truth. The server pushes `trigger_status` and `power_status` on connect and +// again whenever they change, so each payload is a complete snapshot and there +// is nothing to reconcile. +// +// Deliberately not persisted. On a headless Pi this panel is the only window +// onto the radar and the battery, and a remembered reading is indistinguishable +// on screen from a live one -- so a stale battery percentage from yesterday +// would be worse than an honest blank. Kept out of useSessionStore, which its +// own header describes as deliberately small and framework-agnostic. + +interface DeviceState { + triggerStatus: TriggerStatusPayload | null; + powerStatus: PowerStatusPayload | null; + // False until the first snapshot of each kind lands, so a screen can tell + // "not asked yet" apart from "asked, and there is no such hardware" and show + // a skeleton instead of claiming the radar or battery is absent. + triggerLoaded: boolean; + powerLoaded: boolean; + + // Debug recording: whether the Pi is writing a diagnostic log, and where it + // landed. The path is the only way to find the file on a headless box. + // + // debugEnabled starts false, which on screen is indistinguishable from a Pi + // that answered "not recording". Debug mode is server-global, so a session + // may already be recording when this phone connects -- debugLoaded is what + // lets a screen wait rather than offer a Start that would actually stop it. + debugEnabled: boolean; + debugLogPath: string | null; + debugLoaded: boolean; + cameraStatus: CameraStatusPayload | null; + cameraLoaded: boolean; + + applyTriggerStatus: (status: TriggerStatusPayload) => void; + applyPowerStatus: (status: PowerStatusPayload) => void; + applyDebugStatus: (status: DebugStatusPayload | DebugToggledPayload) => void; + applyCameraStatus: (status: CameraStatusPayload) => void; + // Drop back to the pre-connection state. Status belongs to the server that + // reported it; another Pi's radar port must not linger as though it were + // this one's. + reset: () => void; +} + +export const useDeviceStore = create((set) => ({ + triggerStatus: null, + powerStatus: null, + triggerLoaded: false, + powerLoaded: false, + debugEnabled: false, + debugLogPath: null, + debugLoaded: false, + cameraStatus: null, + cameraLoaded: false, + + // Both appliers replace wholesale rather than merging: a snapshot is the + // whole truth, and merging would strand a field from an older reading beside + // fresh ones. A malformed payload leaves the last good reading in place -- + // blanking the panel mid-session reads as "the hardware went away", which is + // a worse lie than a reading a few seconds stale. + applyTriggerStatus: (status) => { + if (!status || typeof status !== 'object') return; + set({ triggerStatus: status, triggerLoaded: true }); + }, + + applyPowerStatus: (status) => { + if (!status || typeof status !== 'object') return; + set({ powerStatus: status, powerLoaded: true }); + }, + + // `debug_status` carries log_path explicitly; `debug_toggled` omits the key + // entirely when disabling. Reading an absent key as null rather than leaving + // the previous value keeps a finished log from looking like a live one. + applyDebugStatus: (status) => { + if (!status || typeof status !== 'object') return; + set({ + debugEnabled: Boolean(status.enabled), + debugLogPath: status.log_path ?? null, + debugLoaded: true, + }); + }, + + applyCameraStatus: (status) => { + if (!status || typeof status !== 'object') return; + set({ cameraStatus: status, cameraLoaded: true }); + }, + + reset: () => + set({ + triggerStatus: null, + powerStatus: null, + triggerLoaded: false, + powerLoaded: false, + debugEnabled: false, + debugLogPath: null, + debugLoaded: false, + cameraStatus: null, + cameraLoaded: false, + }), +})); diff --git a/types.ts b/types.ts index 6188a71..3cbe873 100644 --- a/types.ts +++ b/types.ts @@ -80,3 +80,74 @@ export interface ClubChangedPayload { export interface PlayerChangedPayload { player_name: string; } + +// --- Device status (mirrors src/openflight/server.py and power/models.py) --- +// What the phone can learn about the hardware it is driving. On a headless Pi +// this is the only window onto the radar and the battery, so every field is +// reported as the server states it rather than smoothed into something tidier. + +// `trigger_status`, built by _get_trigger_status() in server.py. Requested with +// `get_trigger_status`, pushed on connect, and pushed again after each shot. +export interface TriggerStatusPayload { + mode: 'rolling-buffer' | 'mock' | 'swing-speed'; + // The detector driving captures; null outside rolling-buffer mode. + trigger_type: string | null; + // The server reports this as `monitor is not None and not mock_mode`, so it + // is false in mock mode even though the mock is working perfectly. Read it + // alongside `mode` before calling a radar offline. + radar_connected: boolean; + radar_port: string | null; + triggers_total: number; + triggers_accepted: number; + triggers_rejected: number; +} + +// PowerState in power/models.py. 'unavailable' is what a mains-powered Pi with +// no battery provider reports -- an answer, not a missing reading. +export type PowerState = 'plugged_in' | 'on_battery' | 'low' | 'critical' | 'unavailable'; + +// --- Device controls (mirror src/openflight/server.py) --- +// The server's entire runtime-mutable surface that works without a radar +// attached. Radar tuning (`set_radar_config`) is deliberately not modelled: the +// server refuses it in mock mode, so it cannot be exercised without hardware. + +// `debug_status`, from handle_get_debug_status (server.py:2547). Debug mode +// writes a JSONL log on the Pi; the path is where it landed. +export interface DebugStatusPayload { + enabled: boolean; + log_path: string | null; +} + +// `debug_toggled`, from handle_toggle_debug (server.py:2530). The server sends +// log_path only when enabling, and omits the key entirely when disabling. +export interface DebugToggledPayload { + enabled: boolean; + log_path?: string; +} + +// `camera_status`, from handle_toggle_camera / handle_toggle_camera_stream +// (server.py:2011, :2035). A refusal -- streaming while the camera is off, or +// a camera that never initialised -- arrives in this same envelope with +// `error` set, not as a separate event. +export interface CameraStatusPayload { + enabled: boolean; + available: boolean; + streaming?: boolean; + ball_detected?: boolean; + ball_confidence?: number; + error?: string; +} + +// `power_status`, from PowerStatus.to_dict(). Every measurement is nullable +// because a Pi without a battery HAT still reports its absence. +export interface PowerStatusPayload { + available: boolean; + provider: string; + state: PowerState; + battery_percent: number | null; + battery_voltage_v: number | null; + external_power: boolean | null; + // ISO-8601 UTC. + updated_at: string; + error: string | null; +} From fe2f35f44123ed7c0eb8060b31ba5649c21a6a69 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Tue, 22 Sep 2026 14:21:52 -0400 Subject: [PATCH 2/3] fix(device): drop camera controls the server does not implement The Camera card requested `get_camera_status` and emitted `toggle_camera` and `toggle_camera_stream`, but the current server has none of those handlers: its camera surface is `get_camera_capture_settings` / `camera_capture_settings`. On today's server the card could never receive a status, so it is removed along with its type, store fields and tests rather than left as dead UI. Also corrects two comments: debug status is not pushed on connect (only power and trigger status are), and the debug payload comments no longer cite server line numbers that have drifted. Co-Authored-By: Claude Opus 5.5 (1M context) --- __tests__/DeviceScreen.test.tsx | 44 +---------------------------- __tests__/socket.test.ts | 34 ++-------------------- __tests__/useDeviceStore.test.ts | 24 ---------------- app/(tabs)/device.tsx | 48 +------------------------------- services/socket.ts | 30 ++++---------------- stores/useDeviceStore.ts | 13 --------- types.ts | 24 ++++------------ 7 files changed, 16 insertions(+), 201 deletions(-) diff --git a/__tests__/DeviceScreen.test.tsx b/__tests__/DeviceScreen.test.tsx index d70e5d2..1bcf644 100644 --- a/__tests__/DeviceScreen.test.tsx +++ b/__tests__/DeviceScreen.test.tsx @@ -4,12 +4,7 @@ import { requestShutdown } from '../services/shutdown'; import { socketService } from '../services/socket'; import { useDeviceStore } from '../stores/useDeviceStore'; import { useSessionStore } from '../stores/useSessionStore'; -import type { - CameraStatusPayload, - ConnectionState, - PowerStatusPayload, - TriggerStatusPayload, -} from '../types'; +import type { ConnectionState, PowerStatusPayload, TriggerStatusPayload } from '../types'; jest.mock( 'react-native-safe-area-context', @@ -33,8 +28,6 @@ const mockRequestShutdown = requestShutdown as jest.MockedFunction ({ socketService: { toggleDebug: jest.fn(), - toggleCamera: jest.fn(), - toggleCameraStream: jest.fn(), }, })); @@ -75,7 +68,6 @@ async function renderDevice( triggerStatus?: TriggerStatusPayload | null; powerStatus?: PowerStatusPayload | null; debug?: { enabled: boolean; logPath?: string }; - camera?: CameraStatusPayload; } = {}, ) { // A session is one connection span; the socket service starts a new one on @@ -89,7 +81,6 @@ async function renderDevice( .getState() .applyDebugStatus({ enabled: device.debug.enabled, log_path: device.debug.logPath ?? null }); } - if (device.camera) useDeviceStore.getState().applyCameraStatus(device.camera); await render(); } @@ -221,38 +212,6 @@ describe('device controls', () => { expect(screen.getByLabelText('Start debug recording')).toBeTruthy(); }); - it('offers the camera and its stream', async () => { - await renderDevice('connected', { - triggerStatus: makeTriggerStatus(), - camera: { enabled: true, available: true, streaming: false }, - }); - - await fireEvent.press(screen.getByLabelText('Start camera stream')); - - expect(mockedSocket.toggleCameraStream).toHaveBeenCalledTimes(1); - }); - - it('shows what the server refused rather than swallowing it', async () => { - // Asking to stream with the camera off comes back in the camera_status - // envelope with an error; nothing else reports that the tap did nothing. - await renderDevice('connected', { - triggerStatus: makeTriggerStatus(), - camera: { enabled: false, available: true, streaming: false, error: 'Camera not enabled' }, - }); - - expect(screen.getByText(/camera not enabled/i)).toBeTruthy(); - }); - - it('says so when the Pi has no camera at all', async () => { - await renderDevice('connected', { - triggerStatus: makeTriggerStatus(), - camera: { enabled: false, available: false, streaming: false }, - }); - - expect(screen.getByText(/no camera/i)).toBeTruthy(); - expect(screen.queryByLabelText('Enable camera')).toBeNull(); - }); - // The pair below is deliberately two tests rather than one that renders, // cleans up and renders again: a cleanup() inside a test body leaves // RNTL's container state inconsistent for whichever test runs next, which @@ -292,7 +251,6 @@ describe('device controls', () => { await renderDevice('disconnected'); expect(screen.queryByLabelText('Start debug recording')).toBeNull(); - expect(screen.queryByLabelText('Enable camera')).toBeNull(); }); }); diff --git a/__tests__/socket.test.ts b/__tests__/socket.test.ts index edb7a65..0ed5564 100644 --- a/__tests__/socket.test.ts +++ b/__tests__/socket.test.ts @@ -470,12 +470,11 @@ describe('device status', () => { expect(state.powerLoaded).toBe(false); }); - it('asks for the debug and camera state once connected', () => { + it('asks for the debug state once connected', () => { socketService.connect('http://host:8080'); trigger('connect'); expect(mockEmit).toHaveBeenCalledWith('get_debug_status'); - expect(mockEmit).toHaveBeenCalledWith('get_camera_status'); }); it('shows the debug recording state the server reports', () => { @@ -497,15 +496,6 @@ describe('device status', () => { expect(useDeviceStore.getState().debugEnabled).toBe(true); }); - it('shows the camera state the server reports', () => { - socketService.connect('http://host:8080'); - trigger('connect'); - - trigger('camera_status', { enabled: true, available: true, streaming: true }); - - expect(useDeviceStore.getState().cameraStatus?.streaming).toBe(true); - }); - it('asks the server to toggle debug recording while connected', () => { socketService.connect('http://host:8080'); trigger('connect'); @@ -516,49 +506,31 @@ describe('device status', () => { expect(mockEmit).toHaveBeenCalledWith('toggle_debug'); }); - it('asks the server to toggle the camera and its stream while connected', () => { - socketService.connect('http://host:8080'); - trigger('connect'); - mockEmit.mockClear(); - - socketService.toggleCamera(); - socketService.toggleCameraStream(); - - expect(mockEmit).toHaveBeenCalledWith('toggle_camera'); - expect(mockEmit).toHaveBeenCalledWith('toggle_camera_stream'); - }); - it('sends no toggle during a transient drop, even once reconnected', () => { // Socket.IO buffers anything emitted through a drop and replays it on // reconnect, so a toggle tapped while the wifi was away would land later - // and flip recording or the camera behind the user's back. + // and flip recording behind the user's back. socketService.connect('http://host:8080'); trigger('connect'); trigger('disconnect'); mockEmit.mockClear(); socketService.toggleDebug(); - socketService.toggleCamera(); - socketService.toggleCameraStream(); trigger('connect'); expect(mockEmit).not.toHaveBeenCalledWith('toggle_debug'); - expect(mockEmit).not.toHaveBeenCalledWith('toggle_camera'); - expect(mockEmit).not.toHaveBeenCalledWith('toggle_camera_stream'); }); - it('forgets the debug and camera state on a deliberate disconnect', () => { + it('forgets the debug state on a deliberate disconnect', () => { socketService.connect('http://host:8080'); trigger('connect'); trigger('debug_status', { enabled: true, log_path: '/home/pi/debug.jsonl' }); - trigger('camera_status', { enabled: true, available: true, streaming: true }); expect(useDeviceStore.getState().debugEnabled).toBe(true); socketService.disconnect(); expect(useDeviceStore.getState().debugEnabled).toBe(false); expect(useDeviceStore.getState().debugLogPath).toBeNull(); - expect(useDeviceStore.getState().cameraStatus).toBeNull(); }); it('forgets the device when switching to a different server', () => { diff --git a/__tests__/useDeviceStore.test.ts b/__tests__/useDeviceStore.test.ts index 8a2e36c..de235a4 100644 --- a/__tests__/useDeviceStore.test.ts +++ b/__tests__/useDeviceStore.test.ts @@ -136,30 +136,6 @@ describe('useDeviceStore', () => { expect(useDeviceStore.getState().debugLogPath).toBeNull(); }); - it('mirrors the camera the server reports', () => { - useDeviceStore - .getState() - .applyCameraStatus({ enabled: true, available: true, streaming: false }); - - const state = useDeviceStore.getState(); - expect(state.cameraStatus?.enabled).toBe(true); - expect(state.cameraStatus?.streaming).toBe(false); - expect(state.cameraLoaded).toBe(true); - }); - - it('keeps a refusal the server sends rather than dropping it', () => { - // Asking to stream while the camera is off comes back in the same envelope - // with an error, and that is the only signal the request was refused. - useDeviceStore.getState().applyCameraStatus({ - enabled: false, - available: true, - streaming: false, - error: 'Camera not enabled', - }); - - expect(useDeviceStore.getState().cameraStatus?.error).toBe('Camera not enabled'); - }); - it('forgets a device once it is no longer the one being talked to', () => { // Status belongs to the server that reported it; another Pi's radar port // and battery must not linger as though they described the new one. diff --git a/app/(tabs)/device.tsx b/app/(tabs)/device.tsx index 669ab78..7bb5d14 100644 --- a/app/(tabs)/device.tsx +++ b/app/(tabs)/device.tsx @@ -16,7 +16,7 @@ import { socketService } from '../../services/socket'; import { loadServerUrl } from '../../storage/connection'; import { useDeviceStore } from '../../stores/useDeviceStore'; import { useSessionStore } from '../../stores/useSessionStore'; -import type { CameraStatusPayload, PowerStatusPayload, TriggerStatusPayload } from '../../types'; +import type { PowerStatusPayload, TriggerStatusPayload } from '../../types'; // The troubleshooting lifeline for a Pi with no screen attached: what the // hardware is doing, and the only safe way to stop it. Every value is shown as @@ -154,42 +154,6 @@ function DebugCard({ enabled, logPath }: { enabled: boolean; logPath: string | n ); } -function CameraCard({ status }: { status: CameraStatusPayload }) { - const styles = useThemedStyles(createStyles); - - if (!status.available) { - return ( - - Camera - No camera on this Pi. - - ); - } - - return ( - - Camera - socketService.toggleCamera()} - /> - {status.enabled ? ( - socketService.toggleCameraStream()} - /> - ) : null} - {/* A refusal arrives in the same envelope as the status; nothing else - would tell the user their tap did nothing. */} - {status.error ? {status.error} : null} - - ); -} - type ShutdownPhase = 'idle' | 'confirming' | 'pending' | 'done' | 'failed'; // Once a request has actually been sent, what happened to it outlives the @@ -319,8 +283,6 @@ export default function DeviceScreen() { const debugEnabled = useDeviceStore((s) => s.debugEnabled); const debugLogPath = useDeviceStore((s) => s.debugLogPath); const debugLoaded = useDeviceStore((s) => s.debugLoaded); - const cameraStatus = useDeviceStore((s) => s.cameraStatus); - const cameraLoaded = useDeviceStore((s) => s.cameraLoaded); const isConnected = connectionState === 'connected'; @@ -352,8 +314,6 @@ export default function DeviceScreen() { recording, and a Start that actually stops a capture is worse than a card that appears a moment late. */} {debugLoaded ? : null} - - {cameraLoaded && cameraStatus !== null ? : null} ) : ( @@ -456,12 +416,6 @@ const createStyles = (c: Palette) => color: c.textMuted, marginTop: 2, }, - controlError: { - fontSize: 12, - fontFamily: fontFamily.medium, - color: c.danger, - marginTop: spacing.xs, - }, controlButton: { minHeight: 44, minWidth: 88, diff --git a/services/socket.ts b/services/socket.ts index 30bfb38..880b584 100644 --- a/services/socket.ts +++ b/services/socket.ts @@ -4,7 +4,6 @@ import { useSessionStore } from '../stores/useSessionStore'; import { saveServerUrl } from '../storage/connection'; import { getShotRepository } from '../storage/db'; import type { - CameraStatusPayload, ClubChangedPayload, DebugStatusPayload, DebugToggledPayload, @@ -99,25 +98,13 @@ class SocketService { this.emitWhileConnected('set_club', { club }); } - // --- Device controls --- - // Each toggle is fire-and-forget: the server flips the state and broadcasts - // the result (`debug_toggled`, `camera_status`) to every client, so there is - // nothing to update optimistically and nothing to roll back. A refusal -- - // streaming with the camera off, or a camera that never initialised -- - // arrives in that same broadcast with an `error` set. - + // Fire-and-forget: the server flips debug mode and broadcasts the result as + // `debug_toggled` to every client, so there is nothing to update + // optimistically and nothing to roll back. toggleDebug(): void { this.emitWhileConnected('toggle_debug'); } - toggleCamera(): void { - this.emitWhileConnected('toggle_camera'); - } - - toggleCameraStream(): void { - this.emitWhileConnected('toggle_camera_stream'); - } - // Socket.IO keeps the socket through a transient drop and buffers anything // emitted meanwhile, replaying it on reconnect. A change made before the drop // could then land after the user moved on -- filing later shots under a club @@ -163,11 +150,9 @@ class SocketService { // and the hardware may have changed while it was away. Read-only, so it // needs no connected-only guard: a replayed request costs a snapshot. socket.emit('get_trigger_status'); - // Same reasoning as the trigger status: these are pushed on connect by - // the server, but a phone joining a session already in progress cannot - // rely on having seen that push. All three are read-only requests. + // The server does not push debug mode on connect, and it is server-global, + // so a recording may already be running. Read-only, like the above. socket.emit('get_debug_status'); - socket.emit('get_camera_status'); }); socket.on('disconnect', () => { @@ -232,11 +217,6 @@ class SocketService { socket.on('debug_toggled', (data: DebugToggledPayload) => { useDeviceStore.getState().applyDebugStatus(data); }); - - // Broadcast after every camera change, including one the server refused. - socket.on('camera_status', (data: CameraStatusPayload) => { - useDeviceStore.getState().applyCameraStatus(data); - }); } } diff --git a/stores/useDeviceStore.ts b/stores/useDeviceStore.ts index 191d3a2..d10926d 100644 --- a/stores/useDeviceStore.ts +++ b/stores/useDeviceStore.ts @@ -1,6 +1,5 @@ import { create } from 'zustand'; import type { - CameraStatusPayload, DebugStatusPayload, DebugToggledPayload, PowerStatusPayload, @@ -37,13 +36,10 @@ interface DeviceState { debugEnabled: boolean; debugLogPath: string | null; debugLoaded: boolean; - cameraStatus: CameraStatusPayload | null; - cameraLoaded: boolean; applyTriggerStatus: (status: TriggerStatusPayload) => void; applyPowerStatus: (status: PowerStatusPayload) => void; applyDebugStatus: (status: DebugStatusPayload | DebugToggledPayload) => void; - applyCameraStatus: (status: CameraStatusPayload) => void; // Drop back to the pre-connection state. Status belongs to the server that // reported it; another Pi's radar port must not linger as though it were // this one's. @@ -58,8 +54,6 @@ export const useDeviceStore = create((set) => ({ debugEnabled: false, debugLogPath: null, debugLoaded: false, - cameraStatus: null, - cameraLoaded: false, // Both appliers replace wholesale rather than merging: a snapshot is the // whole truth, and merging would strand a field from an older reading beside @@ -88,11 +82,6 @@ export const useDeviceStore = create((set) => ({ }); }, - applyCameraStatus: (status) => { - if (!status || typeof status !== 'object') return; - set({ cameraStatus: status, cameraLoaded: true }); - }, - reset: () => set({ triggerStatus: null, @@ -102,7 +91,5 @@ export const useDeviceStore = create((set) => ({ debugEnabled: false, debugLogPath: null, debugLoaded: false, - cameraStatus: null, - cameraLoaded: false, }), })); diff --git a/types.ts b/types.ts index 1011f77..acabf3a 100644 --- a/types.ts +++ b/types.ts @@ -110,37 +110,25 @@ export interface TriggerStatusPayload { export type PowerState = 'plugged_in' | 'on_battery' | 'low' | 'critical' | 'unavailable'; // --- Device controls (mirror src/openflight/server.py) --- -// The server's entire runtime-mutable surface that works without a radar -// attached. Radar tuning (`set_radar_config`) is deliberately not modelled: the -// server refuses it in mock mode, so it cannot be exercised without hardware. +// Only debug recording for now. Radar tuning (`set_radar_config`) is not +// modelled: the server refuses it in mock mode, so it cannot be exercised +// without hardware. Camera capture settings (`get_camera_capture_settings`) +// are left for their own change. -// `debug_status`, from handle_get_debug_status (server.py:2547). Debug mode +// `debug_status`, from handle_get_debug_status in server.py. Debug mode // writes a JSONL log on the Pi; the path is where it landed. export interface DebugStatusPayload { enabled: boolean; log_path: string | null; } -// `debug_toggled`, from handle_toggle_debug (server.py:2530). The server sends +// `debug_toggled`, from handle_toggle_debug in server.py. The server sends // log_path only when enabling, and omits the key entirely when disabling. export interface DebugToggledPayload { enabled: boolean; log_path?: string; } -// `camera_status`, from handle_toggle_camera / handle_toggle_camera_stream -// (server.py:2011, :2035). A refusal -- streaming while the camera is off, or -// a camera that never initialised -- arrives in this same envelope with -// `error` set, not as a separate event. -export interface CameraStatusPayload { - enabled: boolean; - available: boolean; - streaming?: boolean; - ball_detected?: boolean; - ball_confidence?: number; - error?: string; -} - // `power_status`, from PowerStatus.to_dict(). Every measurement is nullable // because a Pi without a battery HAT still reports its absence. export interface PowerStatusPayload { From 4b71b69d5c48fdb80578cb3bbc5f0fd91089ad29 Mon Sep 17 00:00:00 2001 From: btrippcsci Date: Tue, 22 Sep 2026 14:25:26 -0400 Subject: [PATCH 3/3] fix(device): stop OpenFlight, not "the Pi", at the connected server `POST /api/shutdown` cleans up the OpenFlight server and exits its process (_shutdown_process_after_delay in server.py); it does not halt the operating system. The screen called this "Shut down the Pi" and told the user to wait for its lights to settle before cutting power, which invites the power pull on a live SD card this feature was meant to prevent. The flow is now labelled "Stop OpenFlight", says the Pi itself stays on, and gives no power advice. The request was also addressed to the URL reloaded from storage at confirmation time. Saving the connected URL is asynchronous and allowed to fail, so after switching from Pi A to Pi B the stored value could still be A. The screen now uses the socket's active address (`socketService.currentUrl()`), captured when the user confirms. A retry reuses that captured address: while switching servers the failure card stays up until the new server connects, and re-reading the current address there would stop a Pi nobody confirmed. Co-Authored-By: Claude Opus 5.5 (1M context) --- __tests__/DeviceScreen.test.tsx | 162 +++++++++++++++++++++----------- __tests__/shutdown.test.ts | 8 +- __tests__/socket.test.ts | 32 +++++++ app/(tabs)/device.tsx | 87 +++++++++-------- services/shutdown.ts | 22 +++-- services/socket.ts | 8 ++ 6 files changed, 213 insertions(+), 106 deletions(-) diff --git a/__tests__/DeviceScreen.test.tsx b/__tests__/DeviceScreen.test.tsx index 1bcf644..bf353e0 100644 --- a/__tests__/DeviceScreen.test.tsx +++ b/__tests__/DeviceScreen.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import DeviceScreen from '../app/(tabs)/device'; import { requestShutdown } from '../services/shutdown'; import { socketService } from '../services/socket'; +import { saveServerUrl } from '../storage/connection'; import { useDeviceStore } from '../stores/useDeviceStore'; import { useSessionStore } from '../stores/useSessionStore'; import type { ConnectionState, PowerStatusPayload, TriggerStatusPayload } from '../types'; @@ -28,6 +29,7 @@ const mockRequestShutdown = requestShutdown as jest.MockedFunction ({ socketService: { toggleDebug: jest.fn(), + currentUrl: jest.fn(), }, })); @@ -84,12 +86,15 @@ async function renderDevice( await render(); } -const SHUT_DOWN = 'Shut down'; -const CONFIRM = 'Shut down the Pi'; +const SHUT_DOWN = 'Stop OpenFlight'; +const CONFIRM = 'Stop the OpenFlight server?'; +const PI_A = 'http://pi-a.local:8080'; +const PI_B = 'http://pi-b.local:8080'; beforeEach(() => { jest.clearAllMocks(); mockRequestShutdown.mockResolvedValue(undefined); + mockedSocket.currentUrl.mockReturnValue(PI_A); }); afterEach(() => { @@ -254,10 +259,10 @@ describe('device controls', () => { }); }); -describe('shutting the Pi down', () => { - it('never shuts down on a single tap', async () => { - // The whole point of this screen is to stop someone killing a live SD - // card, so the destructive action is always two deliberate steps. +describe('stopping OpenFlight', () => { + it('never stops the server on a single tap', async () => { + // Stopping ends the session for everyone at the bay, so the destructive + // action is always two deliberate steps. await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); @@ -266,11 +271,11 @@ describe('shutting the Pi down', () => { expect(screen.getByText(CONFIRM)).toBeTruthy(); }); - it('shuts down once confirmed', async () => { + it('stops the server once confirmed', async () => { await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); - await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); expect(mockRequestShutdown).toHaveBeenCalledTimes(1); }); @@ -279,43 +284,95 @@ describe('shutting the Pi down', () => { await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); - await fireEvent.press(screen.getByLabelText('Cancel shutdown')); + await fireEvent.press(screen.getByLabelText('Cancel stopping OpenFlight')); expect(mockRequestShutdown).not.toHaveBeenCalled(); expect(screen.queryByText(CONFIRM)).toBeNull(); }); - it('says the Pi is stopping once the request is accepted', async () => { - // The server answers 200 and only then halts, so this reports an accepted - // request -- not a Pi that has finished stopping. + it('says the server is exiting once the request is accepted', async () => { + // The server answers 200 and only then exits, so this reports an accepted + // request -- not a server that has finished stopping. await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); - await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); - await waitFor(() => expect(screen.getByText(/shutting down/i)).toBeTruthy()); + await waitFor(() => expect(screen.getByText(/is exiting/i)).toBeTruthy()); }); - it('says so when the Pi refuses, instead of implying it stopped', async () => { - // Reporting success here would invite someone to pull the power on a Pi - // that is still writing to its SD card. + it('never presents stopping OpenFlight as a safe point to cut power', async () => { + // Regression: /api/shutdown exits the server process and leaves the OS + // running, but the screen said to "wait for its lights to settle before + // cutting power" -- inviting exactly the power pull on a live SD card this + // feature was meant to prevent. + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + expect(screen.queryByText(/power/i)).toBeNull(); + + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); + await waitFor(() => expect(screen.getByText(/is exiting/i)).toBeTruthy()); + + expect(screen.getByText(/pi itself stays on/i)).toBeTruthy(); + expect(screen.queryByText(/power/i)).toBeNull(); + }); + + it('stops the server this phone is connected to, not the last one saved', async () => { + // Regression: the address was reloaded from storage at confirmation time. + // Saving the connected URL is asynchronous and allowed to fail, so after + // switching from Pi A to Pi B storage could still name A -- and A would be + // stopped while the user was looking at B. + await saveServerUrl(PI_A); + mockedSocket.currentUrl.mockReturnValue(PI_B); + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); + + expect(mockRequestShutdown).toHaveBeenCalledWith(PI_B); + expect(mockRequestShutdown).not.toHaveBeenCalledWith(PI_A); + }); + + it('retries against the server the user confirmed, even mid-switch', async () => { + // Switching servers replaces the address before the new one connects, and + // the failure card stays up until it does. A retry that re-read the + // current address would stop Pi B, which nobody confirmed stopping. + mockRequestShutdown.mockRejectedValueOnce(new Error('Shutdown request failed (500)')); + await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); + await fireEvent.press(screen.getByText(SHUT_DOWN)); + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); + await waitFor(() => expect(screen.getByText(/could not stop openflight/i)).toBeTruthy()); + + mockedSocket.currentUrl.mockReturnValue(PI_B); + await act(async () => { + useSessionStore.setState({ connectionState: 'connecting' }); + }); + await fireEvent.press(screen.getByLabelText('Retry stopping OpenFlight')); + + expect(mockRequestShutdown).toHaveBeenLastCalledWith(PI_A); + expect(mockRequestShutdown).not.toHaveBeenCalledWith(PI_B); + }); + + it('says so when the server refuses, instead of implying it stopped', async () => { + // Reporting success here would leave the user believing the server is + // down while it is still running. mockRequestShutdown.mockRejectedValueOnce(new Error('Shutdown request failed (500)')); await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); - await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); - await waitFor(() => expect(screen.getByText(/could not shut down/i)).toBeTruthy()); + await waitFor(() => expect(screen.getByText(/could not stop openflight/i)).toBeTruthy()); }); it('offers a retry after a failure', async () => { mockRequestShutdown.mockRejectedValueOnce(new Error('Network request failed')); await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); - await fireEvent.press(screen.getByLabelText('Confirm shutdown')); - await waitFor(() => expect(screen.getByText(/could not shut down/i)).toBeTruthy()); + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); + await waitFor(() => expect(screen.getByText(/could not stop openflight/i)).toBeTruthy()); - await fireEvent.press(screen.getByLabelText('Retry shutdown')); + await fireEvent.press(screen.getByLabelText('Retry stopping OpenFlight')); expect(mockRequestShutdown).toHaveBeenCalledTimes(2); }); @@ -340,10 +397,10 @@ describe('shutting the Pi down', () => { await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); - await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); - expect(screen.queryByLabelText('Confirm shutdown')).toBeNull(); - expect(screen.getByText(/shutting down/i)).toBeTruthy(); + expect(screen.queryByLabelText('Confirm stopping OpenFlight')).toBeNull(); + expect(screen.getByText(/stopping openflight/i)).toBeTruthy(); expect(mockRequestShutdown).toHaveBeenCalledTimes(1); // Let the request settle so the pending promise does not outlive the test. @@ -352,24 +409,23 @@ describe('shutting the Pi down', () => { }); }); - it('keeps telling the user not to pull power after the Pi drops the socket', async () => { - // Regression: the server answers /api/shutdown and only then halts, so the + it('keeps the outcome on screen after the server drops the socket', async () => { + // Regression: the server answers /api/shutdown and only then exits, so the // socket drops a moment after success. The screen gated everything on the - // connection, so the "wait for its lights to settle" line -- the one - // instruction that prevents a corrupted SD card -- was unmounted before it - // could be read, leaving the generic "not connected" message instead. + // connection, so the outcome was unmounted before it could be read, + // leaving the generic "not connected" message instead. await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); - await fireEvent.press(screen.getByLabelText('Confirm shutdown')); - await waitFor(() => expect(screen.getByText(/shutting down/i)).toBeTruthy()); + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); + await waitFor(() => expect(screen.getByText(/stopping openflight/i)).toBeTruthy()); - // The Pi goes down, exactly as a successful shutdown requires. + // The server exits, exactly as a successful stop requires. await act(async () => { useSessionStore.setState({ connectionState: 'disconnected' }); }); - expect(screen.getByText(/shutting down/i)).toBeTruthy(); - expect(screen.getByText(/before cutting power/i)).toBeTruthy(); + expect(screen.getByText(/stopping openflight/i)).toBeTruthy(); + expect(screen.getByText(/is exiting/i)).toBeTruthy(); }); it('keeps the in-flight state when the connection drops mid-request', async () => { @@ -384,13 +440,13 @@ describe('shutting the Pi down', () => { ); await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); - await fireEvent.press(screen.getByLabelText('Confirm shutdown')); + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); await act(async () => { useSessionStore.setState({ connectionState: 'disconnected' }); }); - expect(screen.getByText(/shutting down/i)).toBeTruthy(); + expect(screen.getByText(/stopping openflight/i)).toBeTruthy(); expect(screen.queryByText(SHUT_DOWN)).toBeNull(); await act(async () => { @@ -399,19 +455,19 @@ describe('shutting the Pi down', () => { }); it('still reports a failure after the connection drops', async () => { - // A refused shutdown leaves the Pi running. If the socket also drops, the - // warning must survive -- this is the case where pulling power is worst. + // A refused stop leaves OpenFlight running. If the socket also drops, the + // failure must survive rather than read as a server that went away. mockRequestShutdown.mockRejectedValueOnce(new Error('Shutdown request failed (500)')); await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); - await fireEvent.press(screen.getByLabelText('Confirm shutdown')); - await waitFor(() => expect(screen.getByText(/could not shut down/i)).toBeTruthy()); + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); + await waitFor(() => expect(screen.getByText(/could not stop openflight/i)).toBeTruthy()); await act(async () => { useSessionStore.setState({ connectionState: 'disconnected' }); }); - expect(screen.getByText(/could not shut down/i)).toBeTruthy(); + expect(screen.getByText(/could not stop openflight/i)).toBeTruthy(); }); it('survives the connection dropping while nothing is being shut down', async () => { @@ -435,12 +491,12 @@ describe('shutting the Pi down', () => { it('clears a finished shutdown once a Pi is answering again', async () => { // Regression: the outcome is kept so it survives the drop a successful - // shutdown causes -- but once a Pi is answering again, "wait for its - // lights to settle" sits next to live status proving it is already back. + // stop causes -- but once a server is answering again, "is exiting" sits + // next to live status proving it is already back. await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); - await fireEvent.press(screen.getByLabelText('Confirm shutdown')); - await waitFor(() => expect(screen.getByText(/before cutting power/i)).toBeTruthy()); + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); + await waitFor(() => expect(screen.getByText(/is exiting/i)).toBeTruthy()); await act(async () => { useSessionStore.setState({ connectionState: 'disconnected' }); }); @@ -450,20 +506,20 @@ describe('shutting the Pi down', () => { useSessionStore.setState({ connectionState: 'connected', sessionId: 'session-2' }); }); - expect(screen.queryByText(/before cutting power/i)).toBeNull(); + expect(screen.queryByText(/is exiting/i)).toBeNull(); expect(screen.getByText(SHUT_DOWN)).toBeTruthy(); }); it('never carries a failed shutdown over to the next Pi', async () => { // Regression: "Try again" on a stale failure called straight through to - // requestShutdown, which resolves the address from storage -- the server - // connected *now*, not the one that failed. That fires a real shutdown at - // a different Pi with no confirmation step at all. + // requestShutdown with whatever address was current -- the server + // connected *now*, not the one that failed. That stops a different Pi + // with no confirmation step at all. mockRequestShutdown.mockRejectedValueOnce(new Error('Shutdown request failed (500)')); await renderDevice('connected', { triggerStatus: makeTriggerStatus() }); await fireEvent.press(screen.getByText(SHUT_DOWN)); - await fireEvent.press(screen.getByLabelText('Confirm shutdown')); - await waitFor(() => expect(screen.getByText(/could not shut down/i)).toBeTruthy()); + await fireEvent.press(screen.getByLabelText('Confirm stopping OpenFlight')); + await waitFor(() => expect(screen.getByText(/could not stop openflight/i)).toBeTruthy()); await act(async () => { useSessionStore.setState({ connectionState: 'disconnected' }); }); @@ -474,7 +530,7 @@ describe('shutting the Pi down', () => { useSessionStore.setState({ connectionState: 'connected', sessionId: 'session-2' }); }); - expect(screen.queryByLabelText('Retry shutdown')).toBeNull(); + expect(screen.queryByLabelText('Retry stopping OpenFlight')).toBeNull(); expect(mockRequestShutdown).not.toHaveBeenCalled(); }); diff --git a/__tests__/shutdown.test.ts b/__tests__/shutdown.test.ts index 505a6b3..70cb0f2 100644 --- a/__tests__/shutdown.test.ts +++ b/__tests__/shutdown.test.ts @@ -60,8 +60,8 @@ describe('requestShutdown', () => { it('gives up rather than hanging on a Pi that never answers', async () => { // A Pi that is already off the network accepts the connection and then // says nothing. Without a bound, the screen sits on a spinner forever and - // the user cannot tell whether it is safe to pull the power -- which is - // not an observable end state. + // the user cannot tell whether the server stopped -- which is not an + // observable end state. // // Fake timers both prove the abort actually fires at the deadline and // stop the real 10s timer outliving the test. @@ -125,8 +125,8 @@ describe('requestShutdown', () => { }); it('resolves when the server accepts the request', async () => { - // The server answers 200 and only then halts, so a resolved promise means - // "accepted", not "already off". + // The server answers 200 and only then exits, so a resolved promise means + // "accepted", not "already stopped". mockFetch(ok()); await expect(requestShutdown('http://192.168.1.100:8080')).resolves.toBeUndefined(); diff --git a/__tests__/socket.test.ts b/__tests__/socket.test.ts index 0ed5564..2582bff 100644 --- a/__tests__/socket.test.ts +++ b/__tests__/socket.test.ts @@ -549,6 +549,38 @@ describe('device status', () => { }); }); +describe('the active server address', () => { + it('names the server most recently connected to, not an earlier one', () => { + // Stopping OpenFlight is addressed over HTTP, outside the socket, so it + // needs the address this socket is actually talking to. + socketService.connect('http://pi-a:8080'); + trigger('connect'); + + socketService.connect('http://pi-b:8080'); + trigger('connect'); + + expect(socketService.currentUrl()).toBe('http://pi-b:8080'); + }); + + it('keeps the address through a transient drop', () => { + socketService.connect('http://pi-a:8080'); + trigger('connect'); + + trigger('disconnect'); + + expect(socketService.currentUrl()).toBe('http://pi-a:8080'); + }); + + it('has no address after a deliberate disconnect', () => { + socketService.connect('http://pi-a:8080'); + trigger('connect'); + + socketService.disconnect(); + + expect(socketService.currentUrl()).toBeNull(); + }); +}); + describe('the selected club', () => { it('takes the club the server restores on connect', () => { socketService.connect('http://host:8080'); diff --git a/app/(tabs)/device.tsx b/app/(tabs)/device.tsx index 7bb5d14..d5170f5 100644 --- a/app/(tabs)/device.tsx +++ b/app/(tabs)/device.tsx @@ -13,13 +13,12 @@ import { radius, spacing, type Palette } from '../../components/theme/tokens'; import { useThemedStyles } from '../../components/theme/useTheme'; import { requestShutdown } from '../../services/shutdown'; import { socketService } from '../../services/socket'; -import { loadServerUrl } from '../../storage/connection'; import { useDeviceStore } from '../../stores/useDeviceStore'; import { useSessionStore } from '../../stores/useSessionStore'; import type { PowerStatusPayload, TriggerStatusPayload } from '../../types'; // The troubleshooting lifeline for a Pi with no screen attached: what the -// hardware is doing, and the only safe way to stop it. Every value is shown as +// hardware is doing, and a clean way to stop OpenFlight. Every value is shown as // the server states it -- an absent measurement reads as an em dash, never as // a zero, matching the Shots screen. @@ -156,31 +155,39 @@ function DebugCard({ enabled, logPath }: { enabled: boolean; logPath: string | n type ShutdownPhase = 'idle' | 'confirming' | 'pending' | 'done' | 'failed'; +// POST /api/shutdown stops the OpenFlight server process, not the Pi: the +// server cleans up its hardware and exits (_shutdown_process_after_delay in +// server.py) while the operating system keeps running. Everything here is +// worded as stopping OpenFlight so nobody reads it as safe to pull the power. +// // Once a request has actually been sent, what happened to it outlives the -// connection: a successful shutdown takes the socket down with it ~0.5s after -// the server answers (_shutdown_process_after_delay in server.py), and a -// refused one leaves a Pi running that must not have its power pulled. Either -// way the message has to survive the drop that follows. +// connection: a successful stop takes the socket down with it ~0.5s after the +// server answers, and a refused one leaves OpenFlight running. Either way the +// message has to survive the drop that follows. const INITIATED: ShutdownPhase[] = ['pending', 'done', 'failed']; function ShutdownSection({ isConnected }: { isConnected: boolean }) { const styles = useThemedStyles(createStyles); const [phase, setPhase] = useState('idle'); + // The server the user confirmed stopping. A retry goes back to it rather than + // to whatever is current: switching servers replaces the address before the + // new one connects, and a stale "Try again" must not stop a Pi nobody + // confirmed. + const [target, setTarget] = useState(null); - // The address is read only when it is needed, so this screen never races the - // connection bar for it on mount. - // // Declared before any early return: every hook in this component has to run // on every render, and hiding the section below used to skip this one, which - // crashed React the first time the wifi dropped with nothing being shut down. - const send = useCallback(async () => { + // crashed React the first time the wifi dropped with nothing being stopped. + const send = useCallback(async (url: string | null) => { + setTarget(url); setPhase('pending'); try { - await requestShutdown(await loadServerUrl()); + if (url === null) throw new Error('No server to stop'); + await requestShutdown(url); setPhase('done'); } catch { - // Saying the Pi stopped when it did not would invite someone to pull the - // power on a live SD card, which is what this screen exists to prevent. + // Saying OpenFlight stopped when it did not would leave the user + // believing the server is down while it is still running. setPhase('failed'); } }, []); @@ -194,7 +201,7 @@ function ShutdownSection({ isConnected }: { isConnected: boolean }) { return ( - Shutting down… + Stopping OpenFlight… ); } @@ -202,11 +209,11 @@ function ShutdownSection({ isConnected }: { isConnected: boolean }) { if (phase === 'done') { return ( - {/* The server answers before it halts, so this reports an accepted - request rather than a Pi that has finished stopping. */} - Shutting down + {/* The server answers before it exits, so this reports an accepted + request rather than a server that has finished stopping. */} + Stopping OpenFlight - The Pi accepted the request. Wait for its lights to settle before cutting power. + The server accepted the request and is exiting. The Pi itself stays on. ); @@ -215,13 +222,13 @@ function ShutdownSection({ isConnected }: { isConnected: boolean }) { if (phase === 'failed') { return ( - Could not shut down - The Pi is still running. Do not pull its power. + Could not stop OpenFlight + The server is still running. void send()} + onPress={() => void send(target)} accessibilityRole="button" - accessibilityLabel="Retry shutdown" + accessibilityLabel="Retry stopping OpenFlight" > Try again @@ -232,24 +239,27 @@ function ShutdownSection({ isConnected }: { isConnected: boolean }) { if (phase === 'confirming') { return ( - Shut down the Pi + Stop the OpenFlight server? - This stops the server. The current session is not kept on the Pi. + This exits the OpenFlight server. The Pi itself stays on, and the current session is not + kept on it. void send()} + // The address of the live connection, captured now so a retry + // cannot drift to a server the user switched to afterwards. + onPress={() => void send(socketService.currentUrl())} accessibilityRole="button" - accessibilityLabel="Confirm shutdown" + accessibilityLabel="Confirm stopping OpenFlight" > - Shut down now + Stop now setPhase('idle')} accessibilityRole="button" - accessibilityLabel="Cancel shutdown" + accessibilityLabel="Cancel stopping OpenFlight" > Cancel @@ -263,9 +273,9 @@ function ShutdownSection({ isConnected }: { isConnected: boolean }) { style={styles.shutdownTrigger} onPress={() => setPhase('confirming')} accessibilityRole="button" - accessibilityLabel="Shut down the Pi" + accessibilityLabel="Stop OpenFlight" > - Shut down + Stop OpenFlight ); } @@ -324,16 +334,15 @@ export default function DeviceScreen() { )} - {/* Deliberately outside the connection branch: a shutdown that has - been sent takes the connection down with it, and its outcome is - what the user needs to read at exactly that moment. The section - renders nothing itself while idle and disconnected. + {/* Deliberately outside the connection branch: a stop that has been + sent takes the connection down with it, and its outcome is what the + user needs to read at exactly that moment. The section renders + nothing itself while idle and disconnected. Keyed on the session so a new connection remounts it, dropping an - outcome that described the previous one -- "wait for its lights to - settle" beside live status from a Pi that is plainly back, or a - stale "Try again" that would shut down whichever Pi is connected - now. A transient drop keeps the same session, so a request still in + outcome that described the previous one -- "exiting" beside live + status from a server that is plainly back, or a stale "Try again". + A transient drop keeps the same session, so a request still in flight is left alone. */} diff --git a/services/shutdown.ts b/services/shutdown.ts index 156b20c..f3ef42c 100644 --- a/services/shutdown.ts +++ b/services/shutdown.ts @@ -1,9 +1,13 @@ -// Graceful shutdown over HTTP. +// Stopping the OpenFlight server over HTTP. +// +// Despite the route's name, this stops the server process only: the server +// cleans up its hardware and exits, and the Pi's operating system keeps +// running. It is not a safe point to remove power. // // This is the one server action the phone takes outside the socket. The kiosk // posts to a relative '/api/shutdown' because the Pi serves it (see // ui/src/hooks/useSocket.ts); a phone has to address the Pi explicitly, so the -// server URL the user connected to is turned into an absolute endpoint here. +// URL of the live connection is turned into an absolute endpoint here. // // Deliberately not on the socket: `shutdown` exists as a socket event too, but // the web UI uses the REST route, so both clients exercise the same server @@ -13,7 +17,7 @@ const SHUTDOWN_PATH = '/api/shutdown'; // A Pi that has already left the network accepts the connection and then says // nothing. Without a bound the screen sits on a spinner indefinitely, which -// leaves the user unable to tell whether it is safe to cut the power -- not an +// leaves the user unable to tell whether the server stopped -- not an // observable end state. Generous enough for a busy Pi on a weak LAN link. const TIMEOUT_MS = 10_000; @@ -21,12 +25,12 @@ const TIMEOUT_MS = 10_000; // Native polyfills AbortController/AbortSignal from abort-controller v3 // (react-native/Libraries/Core/setUpXHR.js), which has no static timeout() // helper. Calling it threw while building the fetch options -- before any -// request left the phone -- so every shutdown reported failure on device while +// request left the phone -- so every stop reported failure on device while // the tests passed on Node, which does have it. -// The server answers 200 and only then halts, on a short delay +// The server answers 200 and only then exits, on a short delay // (_shutdown_process_after_delay in server.py). Resolving therefore means the -// Pi accepted the request, not that it has finished stopping. +// server accepted the request, not that it has finished stopping. export async function requestShutdown(serverUrl: string): Promise { // The address comes from a text field, so a trailing slash is entirely // normal and must not produce '//api/shutdown'. @@ -41,10 +45,8 @@ export async function requestShutdown(serverUrl: string): Promise { signal: controller.signal, }); - // Anything but a success means the Pi is still running. Saying otherwise - // would invite someone to pull the power on a live SD card, which is the - // exact failure this feature exists to prevent -- so this throws rather - // than degrading quietly, and the caller surfaces it. + // Anything but a success means the server is still running. This throws + // rather than degrading quietly, and the caller surfaces it. if (!response.ok) { throw new Error(`Shutdown request failed (${response.status})`); } diff --git a/services/socket.ts b/services/socket.ts index 880b584..00aae8b 100644 --- a/services/socket.ts +++ b/services/socket.ts @@ -87,6 +87,14 @@ class SocketService { useSessionStore.getState().setClub(null); } + // The address the current socket was opened against, or null after a + // deliberate disconnect. Anything addressed to "this Pi" outside the socket + // must use this rather than the saved URL, which is written asynchronously, + // may fail to save, and can still name the previous server after a switch. + currentUrl(): string | null { + return this.url; + } + simulateShot(): void { this.socket?.emit('simulate_shot'); }