diff --git a/__tests__/DeviceScreen.test.tsx b/__tests__/DeviceScreen.test.tsx new file mode 100644 index 0000000..bf353e0 --- /dev/null +++ b/__tests__/DeviceScreen.test.tsx @@ -0,0 +1,550 @@ +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 { saveServerUrl } from '../storage/connection'; +import { useDeviceStore } from '../stores/useDeviceStore'; +import { useSessionStore } from '../stores/useSessionStore'; +import type { 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(), + currentUrl: 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 }; + } = {}, +) { + // 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 }); + } + await render(); +} + +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(() => { + 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(); + }); + + // 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(); + }); +}); + +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)); + + expect(mockRequestShutdown).not.toHaveBeenCalled(); + expect(screen.getByText(CONFIRM)).toBeTruthy(); + }); + + 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 stopping OpenFlight')); + + 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 stopping OpenFlight')); + + expect(mockRequestShutdown).not.toHaveBeenCalled(); + expect(screen.queryByText(CONFIRM)).toBeNull(); + }); + + 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 stopping OpenFlight')); + + await waitFor(() => expect(screen.getByText(/is exiting/i)).toBeTruthy()); + }); + + 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 stopping OpenFlight')); + + 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 stopping OpenFlight')); + await waitFor(() => expect(screen.getByText(/could not stop openflight/i)).toBeTruthy()); + + await fireEvent.press(screen.getByLabelText('Retry stopping OpenFlight')); + + 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 stopping OpenFlight')); + + 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. + await act(async () => { + release(); + }); + }); + + 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 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 stopping OpenFlight')); + await waitFor(() => expect(screen.getByText(/stopping openflight/i)).toBeTruthy()); + + // The server exits, exactly as a successful stop requires. + await act(async () => { + useSessionStore.setState({ connectionState: 'disconnected' }); + }); + + 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 () => { + // 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 stopping OpenFlight')); + + await act(async () => { + useSessionStore.setState({ connectionState: 'disconnected' }); + }); + + expect(screen.getByText(/stopping openflight/i)).toBeTruthy(); + expect(screen.queryByText(SHUT_DOWN)).toBeNull(); + + await act(async () => { + release(); + }); + }); + + it('still reports a failure after the connection drops', async () => { + // 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 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 stop openflight/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 + // 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 stopping OpenFlight')); + await waitFor(() => expect(screen.getByText(/is exiting/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(/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 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 stopping OpenFlight')); + await waitFor(() => expect(screen.getByText(/could not stop openflight/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 stopping OpenFlight')).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..70cb0f2 --- /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 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. + 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 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 48bf9d8..2582bff 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'; @@ -109,6 +110,7 @@ beforeEach(() => { shots: [], club: null, }); + useDeviceStore.getState().reset(); for (const key of Object.keys(mockHandlers)) delete mockHandlers[key]; mockStatus.connected = false; mockIo.mockClear(); @@ -357,6 +359,228 @@ describe('a shot the server enriches after publishing it', () => { }); }); +// 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 state once connected', () => { + socketService.connect('http://host:8080'); + trigger('connect'); + + expect(mockEmit).toHaveBeenCalledWith('get_debug_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('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('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 behind the user's back. + socketService.connect('http://host:8080'); + trigger('connect'); + trigger('disconnect'); + mockEmit.mockClear(); + + socketService.toggleDebug(); + trigger('connect'); + + expect(mockEmit).not.toHaveBeenCalledWith('toggle_debug'); + }); + + 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' }); + expect(useDeviceStore.getState().debugEnabled).toBe(true); + + socketService.disconnect(); + + expect(useDeviceStore.getState().debugEnabled).toBe(false); + expect(useDeviceStore.getState().debugLogPath).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(); + }); +}); + +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/__tests__/useDeviceStore.test.ts b/__tests__/useDeviceStore.test.ts new file mode 100644 index 0000000..de235a4 --- /dev/null +++ b/__tests__/useDeviceStore.test.ts @@ -0,0 +1,153 @@ +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('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..d5170f5 100644 --- a/app/(tabs)/device.tsx +++ b/app/(tabs)/device.tsx @@ -1,11 +1,505 @@ -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 { 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 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. + +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} + + ); +} + +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 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); + + // 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 stopped. + const send = useCallback(async (url: string | null) => { + setTarget(url); + setPhase('pending'); + try { + if (url === null) throw new Error('No server to stop'); + await requestShutdown(url); + setPhase('done'); + } catch { + // Saying OpenFlight stopped when it did not would leave the user + // believing the server is down while it is still running. + 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 ( + + + Stopping OpenFlight… + + ); + } + + if (phase === 'done') { + return ( + + {/* The server answers before it exits, so this reports an accepted + request rather than a server that has finished stopping. */} + Stopping OpenFlight + + The server accepted the request and is exiting. The Pi itself stays on. + + + ); + } + + if (phase === 'failed') { + return ( + + Could not stop OpenFlight + The server is still running. + void send(target)} + accessibilityRole="button" + accessibilityLabel="Retry stopping OpenFlight" + > + Try again + + + ); + } + + if (phase === 'confirming') { + return ( + + Stop the OpenFlight server? + + This exits the OpenFlight server. The Pi itself stays on, and the current session is not + kept on it. + + + void send(socketService.currentUrl())} + accessibilityRole="button" + accessibilityLabel="Confirm stopping OpenFlight" + > + Stop now + + setPhase('idle')} + accessibilityRole="button" + accessibilityLabel="Cancel stopping OpenFlight" + > + Cancel + + + + ); + } + + return ( + setPhase('confirming')} + accessibilityRole="button" + accessibilityLabel="Stop OpenFlight" + > + Stop OpenFlight + + ); +} -// 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 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} + + ) : ( + + Not connected + + Connect to a server on the Live tab to see how it is doing. + + + )} + + {/* 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 -- "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. */} + + + ); } + +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, + }, + 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..f3ef42c --- /dev/null +++ b/services/shutdown.ts @@ -0,0 +1,56 @@ +// 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 +// 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 +// 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 the server stopped -- 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 stop reported failure on device while +// the tests passed on Node, which does have it. + +// The server answers 200 and only then exits, on a short delay +// (_shutdown_process_after_delay in server.py). Resolving therefore means the +// 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'. + 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 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})`); + } + } finally { + clearTimeout(timer); + } +} diff --git a/services/socket.ts b/services/socket.ts index 731eeeb..00aae8b 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 { ClubChangedPayload, SessionStatePayload, Shot, ShotEnvelope } from '../types'; +import type { + ClubChangedPayload, + 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,9 +51,12 @@ class SocketService { this.socket = null; } - // The selected club belongs to the server that reported it; a different - // server restores its own through session_state once connected. + // Device status and the selected club both describe the server that + // reported them. Switching servers must not leave another Pi's radar port, + // battery or club on screen; the new one restores its own club through + // session_state once connected. if (this.url !== null && this.url !== url) { + useDeviceStore.getState().reset(); store.setClub(null); } @@ -65,11 +78,23 @@ class SocketService { this.socket = null; this.url = null; useSessionStore.getState().setConnectionState('disconnected'); - // Only the deliberate disconnect forgets the club; a transient drop keeps - // showing it, since reconnecting restores the same server's selection. + // Only the deliberate disconnect forgets the device and the club. A + // transient drop is handled by the 'disconnect' event below, which leaves + // both in place: blanking the radar and battery every time the wifi + // hiccups would read as hardware failing rather than a wobbly link, and + // reconnecting restores the same server's club. + useDeviceStore.getState().reset(); 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'); } @@ -81,12 +106,22 @@ class SocketService { this.emitWhileConnected('set_club', { club }); } + // 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'); + } + // Socket.IO keeps the socket through a transient drop and buffers anything - // emitted meanwhile, replaying it on reconnect. A selection made before the - // drop could then land after the user moved on and decide how the next - // shots are filed, so a selection is sent only over a live connection. - private emitWhileConnected(event: string, payload: object): void { - if (this.socket?.connected) this.socket.emit(event, payload); + // 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 + // picked earlier, or flipping recording behind the user's back -- so a change + // is sent only over a live connection. + 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 @@ -118,6 +153,14 @@ 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'); + // 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.on('disconnect', () => { @@ -159,6 +202,29 @@ 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); + }); } } diff --git a/stores/useDeviceStore.ts b/stores/useDeviceStore.ts new file mode 100644 index 0000000..d10926d --- /dev/null +++ b/stores/useDeviceStore.ts @@ -0,0 +1,95 @@ +import { create } from 'zustand'; +import type { + 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; + + applyTriggerStatus: (status: TriggerStatusPayload) => void; + applyPowerStatus: (status: PowerStatusPayload) => void; + applyDebugStatus: (status: DebugStatusPayload | DebugToggledPayload) => 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, + + // 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, + }); + }, + + reset: () => + set({ + triggerStatus: null, + powerStatus: null, + triggerLoaded: false, + powerLoaded: false, + debugEnabled: false, + debugLogPath: null, + debugLoaded: false, + }), +})); diff --git a/types.ts b/types.ts index cb72061..acabf3a 100644 --- a/types.ts +++ b/types.ts @@ -83,3 +83,62 @@ 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) --- +// 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 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 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; +} + +// `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; +}