diff --git a/.env b/.env index a710c4a24a..682dab59be 100644 --- a/.env +++ b/.env @@ -16,3 +16,5 @@ REACT_APP_VX_CAST_VOTE_RECORD_OPTIMIZATION_EXCLUDE_REDUNDANT_METADATA=TRUE REACT_APP_VX_DISABLE_BALLOT_BOX_CHECK=FALSE REACT_APP_VX_CONVERTER=ms-sems REACT_APP_VX_PRECINCT_REPORT_DESTINATION=thermal-sheet-printer +REACT_APP_VX_DISABLE_BALLOT_BOX_CHECK=FALSE +REACT_APP_VX_SKIP_PAPER_HANDLER_HARDWARE_CHECK=FALSE diff --git a/apps/mark-scan/backend/README.md b/apps/mark-scan/backend/README.md index 96a4881ce0..2ae8e04de0 100644 --- a/apps/mark-scan/backend/README.md +++ b/apps/mark-scan/backend/README.md @@ -9,6 +9,21 @@ Follow the instructions in the [VxSuite README](../../../README.md) You generally should not need to run the backend directly. Instead, run the frontend, which will automatically run the backend. +For the backend to recognize the USB PAT switch you may need to extend your udev +rules. Create or edit `/etc/udev/rules.d/50-usb-hid.rules` with: + +``` +SUBSYSTEM=="input", GROUP="input", MODE="0666" +SUBSYSTEM=="usb", ATTR{idVendor}=="0a95", ATTR{idProduct}=="0012", MODE="0666", GROUP="plugdev" +KERNEL=="hidraw*", ATTRS{idVendor}=="0a95", ATTRS{idProduct}=="0012", MODE="0666", GROUP="plugdev" +``` + +then run + +``` +sudo udevadm control --reload-rules && sudo udevadm trigger +``` + ```sh cd apps/mark/frontend pnpm start diff --git a/apps/mark-scan/backend/package.json b/apps/mark-scan/backend/package.json index f6d86fd16b..6d1ed8846f 100644 --- a/apps/mark-scan/backend/package.json +++ b/apps/mark-scan/backend/package.json @@ -46,6 +46,7 @@ "@votingworks/grout": "workspace:*", "@votingworks/image-utils": "workspace:*", "@votingworks/logging": "workspace:*", + "@votingworks/message-coder": "workspace:*", "@votingworks/types": "workspace:*", "@votingworks/usb-drive": "workspace:*", "@votingworks/utils": "workspace:*", @@ -57,6 +58,7 @@ "fs-extra": "^11.1.1", "js-sha256": "^0.9.0", "luxon": "^3.0.0", + "node-hid": "^2.1.2", "rxjs": "^7.8.1", "tmp": "^0.2.1", "uuid": "^9.0.0", @@ -71,6 +73,7 @@ "@types/jest": "^29.5.3", "@types/luxon": "^3.0.0", "@types/node": "16.18.23", + "@types/node-hid": "^1.3.2", "@types/tmp": "^0.2.3", "@types/uuid": "^9.0.2", "@votingworks/test-utils": "workspace:*", diff --git a/apps/mark-scan/backend/src/app.ts b/apps/mark-scan/backend/src/app.ts index cbc8824ef4..a15b8cfd35 100644 --- a/apps/mark-scan/backend/src/app.ts +++ b/apps/mark-scan/backend/src/app.ts @@ -21,7 +21,9 @@ import { InterpretedBmdPage, } from '@votingworks/types'; import { + BooleanEnvironmentVariableName, isElectionManagerAuth, + isFeatureFlagEnabled, singlePrecinctSelectionFor, } from '@votingworks/utils'; @@ -181,10 +183,25 @@ export function buildApi( }, setAcceptingPaperState(): void { + if ( + isFeatureFlagEnabled( + BooleanEnvironmentVariableName.SKIP_PAPER_HANDLER_HARDWARE_CHECK + ) + ) { + return; + } + assert(stateMachine); + stateMachine.setAcceptingPaper(); }, + setPatDeviceIsCalibrated(): void { + assert(stateMachine, 'No state machine'); + + stateMachine.setPatDeviceIsCalibrated(); + }, + printBallot(input: { pdfData: Buffer }): void { assert(stateMachine); diff --git a/apps/mark-scan/backend/src/custom-paper-handler/cli/state_machine_cli.ts b/apps/mark-scan/backend/src/custom-paper-handler/cli/state_machine_cli.ts index abd05810fc..89a2efef09 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/cli/state_machine_cli.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/cli/state_machine_cli.ts @@ -8,7 +8,10 @@ import { join } from 'path'; import { LogSource, Logger } from '@votingworks/logging'; import { createWorkspace } from '../../util/workspace'; import { MARK_SCAN_WORKSPACE } from '../../globals'; -import { DEV_PAPER_HANDLER_STATUS_POLLING_INTERVAL_MS } from '../constants'; +import { + AUTH_STATUS_POLLING_INTERVAL_MS, + DEV_DEVICE_STATUS_POLLING_INTERVAL_MS, +} from '../constants'; import { PaperHandlerStateMachine, getPaperHandlerStateMachine, @@ -79,13 +82,14 @@ export async function main(): Promise { 'Could not get paper handler driver. Is a paper handler device connected?' ); - const stateMachine = await getPaperHandlerStateMachine( - driver, + const stateMachine = await getPaperHandlerStateMachine({ workspace, auth, logger, - DEV_PAPER_HANDLER_STATUS_POLLING_INTERVAL_MS - ); + driver, + devicePollingIntervalMs: DEV_DEVICE_STATUS_POLLING_INTERVAL_MS, + authPollingIntervalMs: AUTH_STATUS_POLLING_INTERVAL_MS, + }); assert(stateMachine !== undefined, 'Unexpected undefined state machine'); stateMachine.setAcceptingPaper(); diff --git a/apps/mark-scan/backend/src/custom-paper-handler/constants.ts b/apps/mark-scan/backend/src/custom-paper-handler/constants.ts index 642a4389d5..429861d0f2 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/constants.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/constants.ts @@ -1,11 +1,11 @@ -export const PAPER_HANDLER_STATUS_POLLING_INTERVAL_MS = 200; +export const DEVICE_STATUS_POLLING_INTERVAL_MS = 200; export const AUTH_STATUS_POLLING_INTERVAL_MS = 200; // Slower interval for limited hardware -export const DEV_PAPER_HANDLER_STATUS_POLLING_INTERVAL_MS = 750; -export const DEV_AUTH_STATUS_POLLING_INTERVAL_MS = 750; +export const DEV_DEVICE_STATUS_POLLING_INTERVAL_MS = 1000; +export const DEV_AUTH_STATUS_POLLING_INTERVAL_MS = 1000; -export const PAPER_HANDLER_STATUS_POLLING_TIMEOUT_MS = 30_000; +export const DEVICE_STATUS_POLLING_TIMEOUT_MS = 30_000; export const AUTH_STATUS_POLLING_TIMEOUT_MS = 30_000; export const RESET_DELAY_MS = 8_000; export const RESET_AFTER_JAM_DELAY_MS = 3_000; @@ -15,3 +15,6 @@ export const DELAY_BEFORE_DECLARING_REAR_JAM_MS = 3_000; export const SCAN_DPI = 72; export const PRINT_DPI = 200; + +export const ORIGIN_VENDOR_ID = 0x0a95; +export const ORIGIN_SWIFTY_PRODUCT_ID = 0x0012; diff --git a/apps/mark-scan/backend/src/custom-paper-handler/state_machine.test.ts b/apps/mark-scan/backend/src/custom-paper-handler/state_machine.test.ts index 075813d939..31b8b9071a 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/state_machine.test.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/state_machine.test.ts @@ -3,6 +3,7 @@ import { PaperHandlerDriver, MinimalWebUsbDevice, PaperHandlerStatus, + defaultPaperHandlerStatus, } from '@votingworks/custom-paper-handler'; import { dirSync } from 'tmp'; import { Logger, fakeLogger } from '@votingworks/logging'; @@ -12,7 +13,6 @@ import { getPaperHandlerStateMachine, } from './state_machine'; import { Workspace, createWorkspace } from '../util/workspace'; -import { defaultPaperHandlerStatus } from '../../test/app_helpers'; // Use shorter polling interval in tests to reduce run times const TEST_POLLING_INTERVAL_MS = 10; @@ -44,13 +44,14 @@ beforeEach(async () => { .spyOn(driver, 'getPaperHandlerStatus') .mockImplementation(() => Promise.resolve(defaultPaperHandlerStatus())); - machine = (await getPaperHandlerStateMachine( - driver, + machine = (await getPaperHandlerStateMachine({ workspace, auth, logger, - TEST_POLLING_INTERVAL_MS - )) as PaperHandlerStateMachine; + driver, + devicePollingIntervalMs: TEST_POLLING_INTERVAL_MS, + authPollingIntervalMs: TEST_POLLING_INTERVAL_MS, + })) as PaperHandlerStateMachine; }); afterEach(() => { diff --git a/apps/mark-scan/backend/src/custom-paper-handler/state_machine.ts b/apps/mark-scan/backend/src/custom-paper-handler/state_machine.ts index c85e6fdcfd..60c025e303 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/state_machine.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/state_machine.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import makeDebug from 'debug'; +import HID from 'node-hid'; import { PaperHandlerDriver, PaperHandlerStatus, @@ -35,8 +36,10 @@ import { AUTH_STATUS_POLLING_INTERVAL_MS, AUTH_STATUS_POLLING_TIMEOUT_MS, DELAY_BEFORE_DECLARING_REAR_JAM_MS, - PAPER_HANDLER_STATUS_POLLING_INTERVAL_MS, - PAPER_HANDLER_STATUS_POLLING_TIMEOUT_MS, + DEVICE_STATUS_POLLING_INTERVAL_MS, + DEVICE_STATUS_POLLING_TIMEOUT_MS, + ORIGIN_SWIFTY_PRODUCT_ID, + ORIGIN_VENDOR_ID, RESET_AFTER_JAM_DELAY_MS, } from './constants'; import { @@ -61,6 +64,7 @@ interface Context { authPollingIntervalMs: number; scannedImagePaths?: SheetOf; interpretation?: SheetOf; + patDevice?: HID.HID; } function assign(arg: Assigner | PropertyAssigner) { @@ -87,7 +91,12 @@ type PaperHandlerStatusEvent = | { type: 'VOTER_INVALIDATED_BALLOT' } | { type: 'AUTH_STATUS_CARDLESS_VOTER' } | { type: 'AUTH_STATUS_POLL_WORKER' } - | { type: 'AUTH_STATUS_UNHANDLED' }; + | { type: 'AUTH_STATUS_UNHANDLED' } + | { type: 'PAT_DEVICE_CONNECTED'; patDevice: HID.HID } + | { type: 'PAT_DEVICE_DISCONNECTED' } + | { type: 'VOTER_CONFIRMED_PAT_DEVICE_CALIBRATION' } + | { type: 'PAT_DEVICE_NO_STATUS_CHANGE' } + | { type: 'PAT_DEVICE_STATUS_UNHANDLED'; patDevice?: HID.HID }; const debug = makeDebug('mark-scan:state-machine'); const debugEvents = debug.extend('events'); @@ -102,6 +111,7 @@ export interface PaperHandlerStateMachine { validateBallot(): void; invalidateBallot(): void; confirmInvalidateBallot(): void; + setPatDeviceIsCalibrated(): void; } function paperHandlerStatusToEvent( @@ -178,7 +188,7 @@ function buildPaperStatusObservable() { } }), timeout({ - each: PAPER_HANDLER_STATUS_POLLING_TIMEOUT_MS, + each: DEVICE_STATUS_POLLING_TIMEOUT_MS, with: () => throwError(() => new Error('paper_status_timed_out')), }) ) @@ -201,13 +211,10 @@ function buildAuthStatusObservable() { const authStatus = await auth.getAuthStatus( constructAuthMachineState(workspace) ); - debug('auth status polled'); if (isCardlessVoterAuth(authStatus)) { - debug('emit cardless voter auth event'); return { type: 'AUTH_STATUS_CARDLESS_VOTER' }; } if (isPollWorkerAuth(authStatus)) { - debug('emit poll worker auth event'); return { type: 'AUTH_STATUS_POLL_WORKER' }; } @@ -233,6 +240,71 @@ function pollAuthStatus(): InvokeConfig { }; } +// Finds the Origin Swifty PAT input converter or returns an empty Promise. +// This is a Promise because Observable's switchMap gives a type error if it's not. +function findPatDevice(): Promise { + // `new HID.HID(vendorId, productId)` throws if device is not found. + // Listing devices first avoids hard failure. + const devices = HID.devices(); + const patUsbAdapterInfo = devices.find( + (device) => + device.productId === ORIGIN_SWIFTY_PRODUCT_ID && + device.vendorId === ORIGIN_VENDOR_ID + ); + + if (!patUsbAdapterInfo || !patUsbAdapterInfo.path) { + return Promise.resolve(); + } + + return Promise.resolve(new HID.HID(patUsbAdapterInfo.path)); +} + +function buildPatDeviceConnectionStatusObservable() { + return ({ + devicePollingIntervalMs, + patDevice: existingPatDevice, + }: Context) => { + return timer(0, devicePollingIntervalMs).pipe( + switchMap(async () => { + try { + const currentPatDevice = await findPatDevice(); + + if (existingPatDevice && !currentPatDevice) { + return { type: 'PAT_DEVICE_DISCONNECTED' }; + } + + if (!existingPatDevice && currentPatDevice) { + return { + type: 'PAT_DEVICE_CONNECTED', + patDevice: currentPatDevice, + }; + } + + return { type: 'PAT_DEVICE_NO_STATUS_CHANGE' }; + } catch (err) { + debug('Error in PAT device observable: %O', err); + return { type: 'PAT_DEVICE_STATUS_UNHANDLED' }; + } + }), + timeout({ + each: DEVICE_STATUS_POLLING_TIMEOUT_MS, + with: () => + throwError(() => new Error('pat_device_connection_status_timed_out')), + }) + ); + }; +} + +function pollPatDeviceConnectionStatus(): InvokeConfig< + Context, + PaperHandlerStatusEvent +> { + return { + id: 'pollPatDeviceConnectionStatus', + src: buildPatDeviceConnectionStatusObservable(), + }; +} + function loadMetadataAndInterpretBallot( context: Context ): Promise> { @@ -289,267 +361,308 @@ export function buildMachine( events: {} as PaperHandlerStatusEvent, }, id: 'bmd', - initial: 'not_accepting_paper', + initial: 'voting_flow', context: initialContext, on: { - PAPER_JAM: 'jammed', - JAMMED_STATUS_NO_PAPER: 'jam_physically_cleared', - }, - states: { - // Initial state. Doesn't accept paper and transitions away when the frontend says it's ready to accept - not_accepting_paper: { - invoke: pollPaperStatus(), - on: { - // Paper may be inside the machine from previous testing or machine failure. We should eject - // the paper (not to ballot bin) because we don't know whether the page has been printed. - PAPER_INSIDE_NO_JAM: 'eject_to_front', - PAPER_PARKED: 'eject_to_front', - BEGIN_ACCEPTING_PAPER: 'accepting_paper', - }, + PAPER_JAM: 'voting_flow.jammed', + JAMMED_STATUS_NO_PAPER: 'voting_flow.jam_physically_cleared', + PAT_DEVICE_CONNECTED: { + actions: assign({ + patDevice: (_, event) => event.patDevice, + }), + target: 'pat_device_connected', }, - accepting_paper: { - invoke: pollPaperStatus(), - on: { - PAPER_READY_TO_LOAD: 'loading_paper', - }, + PAT_DEVICE_DISCONNECTED: { + actions: assign({ + patDevice: () => undefined, + }), + // Without this target, the PAT device observable won't have an updated value + // for context.patDevice + target: 'voting_flow.history', }, - loading_paper: { - invoke: [ - pollPaperStatus(), - { - id: 'loadAndPark', - src: (context) => { - return loadAndParkPaper(context.driver); + }, + invoke: [pollPatDeviceConnectionStatus()], + states: { + voting_flow: { + initial: 'not_accepting_paper', + states: { + history: { + type: 'history', + history: 'shallow', + }, + // Initial state. Doesn't accept paper and transitions away when the frontend says it's ready to accept + not_accepting_paper: { + invoke: pollPaperStatus(), + on: { + // Paper may be inside the machine from previous testing or machine failure. We should eject + // the paper (not to ballot bin) because we don't know whether the page has been printed. + PAPER_INSIDE_NO_JAM: 'eject_to_front', + PAPER_PARKED: 'eject_to_front', + BEGIN_ACCEPTING_PAPER: 'accepting_paper', }, }, - ], - on: { - PAPER_PARKED: 'waiting_for_ballot_data', - NO_PAPER_ANYWHERE: 'accepting_paper', - }, - }, - waiting_for_ballot_data: { - on: { - VOTER_INITIATED_PRINT: 'printing_ballot', - }, - }, - printing_ballot: { - invoke: [ - { - id: 'printBallot', - src: (context, event) => { - assert(event.type === 'VOTER_INITIATED_PRINT'); - return driverPrintBallot(context.driver, event.pdfData, {}); + accepting_paper: { + invoke: pollPaperStatus(), + on: { + PAPER_READY_TO_LOAD: 'loading_paper', + PAPER_PARKED: 'waiting_for_ballot_data', }, - onDone: 'scanning', }, - pollPaperStatus(), - ], - }, - scanning: { - invoke: [ - { - id: 'scanAndSave', - src: (context) => { - return scanAndSave(context.driver); + loading_paper: { + invoke: [ + pollPaperStatus(), + { + id: 'loadAndPark', + src: (context) => { + return loadAndParkPaper(context.driver); + }, + }, + ], + on: { + PAPER_PARKED: 'waiting_for_ballot_data', + NO_PAPER_ANYWHERE: 'accepting_paper', }, - onDone: { - target: 'interpreting', - actions: assign({ scannedImagePaths: (_, event) => event.data }), + }, + waiting_for_ballot_data: { + on: { + VOTER_INITIATED_PRINT: 'printing_ballot', }, }, - pollPaperStatus(), - ], - }, - interpreting: { - // Paper is in the paper handler for the duration of the interpreting stage and paper handler - // motors are never moved, so we don't need to poll paper status or handle jams. - invoke: { - id: 'interpretScannedBallot', - src: loadMetadataAndInterpretBallot, - onDone: { - target: 'transition_interpretation', - actions: assign({ - interpretation: (_, event) => event.data, - }), + printing_ballot: { + invoke: [ + { + id: 'printBallot', + src: (context, event) => { + assert(event.type === 'VOTER_INITIATED_PRINT'); + return driverPrintBallot(context.driver, event.pdfData, {}); + }, + onDone: 'scanning', + }, + pollPaperStatus(), + ], }, - }, - }, - // Intermediate state to conditionally transition based on ballot interpretation - transition_interpretation: { - entry: (context) => { - const interpretationType = assertDefined(context.interpretation)[0] - .interpretation.type; - assert( - interpretationType === 'InterpretedBmdPage' || - interpretationType === 'BlankPage', - `Unexpected interpretation type: ${interpretationType}` - ); - }, - always: [ - { - target: 'presenting_ballot', - cond: (context) => - // context.interpretation is already asserted in the entry function but Typescript is unaware - assertDefined(context.interpretation)[0].interpretation.type === - 'InterpretedBmdPage', + scanning: { + invoke: [ + { + id: 'scanAndSave', + src: (context) => { + return scanAndSave(context.driver); + }, + onDone: { + target: 'interpreting', + actions: assign({ + scannedImagePaths: (_, event) => event.data, + }), + }, + }, + pollPaperStatus(), + ], }, - { - target: 'blank_page_interpretation', - cond: (context) => - assertDefined(context.interpretation)[0].interpretation.type === - 'BlankPage', + interpreting: { + // Paper is in the paper handler for the duration of the interpreting stage and paper handler + // motors are never moved, so we don't need to poll paper status or handle jams. + invoke: { + id: 'interpretScannedBallot', + src: loadMetadataAndInterpretBallot, + onDone: { + target: 'transition_interpretation', + actions: assign({ + interpretation: (_, event) => event.data, + }), + }, + }, }, - ], - }, - blank_page_interpretation: { - invoke: pollPaperStatus(), - initial: 'presenting_paper', - // These nested states differ slightly from the top-level paper load states so we can't reuse the latter. - states: { - presenting_paper: { - // Heavier paper can fail to eject completely and trigger a jam state even though the paper isn't physically jammed. - // To work around this, we avoid ejecting to front. Instead, we present the paper so it's held by the device in a - // stable non-jam state. We instruct the poll worker to remove the paper directly from the 'presenting' state. - entry: async (context) => await context.driver.presentPaper(), - on: { NO_PAPER_ANYWHERE: 'accepting_paper' }, + // Intermediate state to conditionally transition based on ballot interpretation + transition_interpretation: { + entry: (context) => { + const interpretationType = assertDefined( + context.interpretation + )[0].interpretation.type; + assert( + interpretationType === 'InterpretedBmdPage' || + interpretationType === 'BlankPage', + `Unexpected interpretation type: ${interpretationType}` + ); + }, + always: [ + { + target: 'presenting_ballot', + cond: (context) => + // context.interpretation is already asserted in the entry function but Typescript is unaware + assertDefined(context.interpretation)[0].interpretation + .type === 'InterpretedBmdPage', + }, + { + target: 'blank_page_interpretation', + cond: (context) => + assertDefined(context.interpretation)[0].interpretation + .type === 'BlankPage', + }, + ], }, - accepting_paper: { + blank_page_interpretation: { + invoke: pollPaperStatus(), + initial: 'presenting_paper', + // These nested states differ slightly from the top-level paper load states so we can't reuse the latter. + states: { + presenting_paper: { + // Heavier paper can fail to eject completely and trigger a jam state even though the paper isn't physically jammed. + // To work around this, we avoid ejecting to front. Instead, we present the paper so it's held by the device in a + // stable non-jam state. We instruct the poll worker to remove the paper directly from the 'presenting' state. + entry: async (context) => await context.driver.presentPaper(), + on: { NO_PAPER_ANYWHERE: 'accepting_paper' }, + }, + accepting_paper: { + on: { + PAPER_READY_TO_LOAD: 'load_paper', + }, + }, + load_paper: { + entry: async (context) => { + await context.driver.loadPaper(); + await context.driver.parkPaper(); + }, + on: { + PAPER_PARKED: { + target: 'done', + actions: () => { + assign({ + interpretation: undefined, + scannedImagePaths: undefined, + }); + }, + }, + NO_PAPER_ANYWHERE: 'accepting_paper', + }, + }, + done: { + type: 'final', + }, + }, + onDone: 'paper_reloaded', + }, + // `paper_reloaded` could be a substate of `blank_page_interpretation` but by keeping + // them separate we can avoid exposing all the substates of `blank_page_interpretation` + // to the frontend. + paper_reloaded: { + invoke: pollAuthStatus(), on: { - PAPER_READY_TO_LOAD: 'load_paper', + AUTH_STATUS_CARDLESS_VOTER: 'waiting_for_ballot_data', }, }, - load_paper: { + presenting_ballot: { + invoke: pollPaperStatus(), + entry: async (context) => { + await context.driver.presentPaper(); + }, + on: { + VOTER_VALIDATED_BALLOT: 'eject_to_rear', + VOTER_INVALIDATED_BALLOT: + 'waiting_for_invalidated_ballot_confirmation', + NO_PAPER_ANYWHERE: 'resetting_state_machine_after_success', + }, + }, + // Ballot invalidation is a 2-stage process so the frontend can prompt the voter to get a pollworker + waiting_for_invalidated_ballot_confirmation: { + on: { + VOTER_CONFIRMED_INVALIDATED_BALLOT: 'eject_to_front', + // Even if ballot is removed from front, we still want the frontend to require pollworker auth before continuing + NO_PAPER_ANYWHERE: undefined, + }, + }, + // Eject-to-rear jam handling is a little clunky. It + // 1. Tries to transition to success if no paper is detected + // 2. If after a timeout we have not transitioned away (because paper still present), transition to jammed state + // 3. Jam detection state transitions to jam reset state once it confirms no paper present + eject_to_rear: { + invoke: pollPaperStatus(), entry: async (context) => { - await context.driver.loadPaper(); await context.driver.parkPaper(); + await context.driver.ejectBallotToRear(); }, on: { - PAPER_PARKED: { - target: 'done', - actions: () => { - assign({ - interpretation: undefined, - scannedImagePaths: undefined, - }); - }, + NO_PAPER_ANYWHERE: 'resetting_state_machine_after_success', + PAPER_JAM: 'jammed', + }, + after: { + [DELAY_BEFORE_DECLARING_REAR_JAM_MS]: 'jammed', + }, + }, + eject_to_front: { + invoke: pollPaperStatus(), + entry: async (context) => { + await context.driver.ejectPaperToFront(); + }, + on: { + NO_PAPER_ANYWHERE: 'resetting_state_machine_after_success', + }, + }, + jammed: { + invoke: pollPaperStatus(), + on: { + NO_PAPER_ANYWHERE: 'jam_physically_cleared', + }, + }, + jam_physically_cleared: { + invoke: { + id: 'resetScanAndDriver', + src: (context) => { + // Issues `reset scan` command, creates a new WebUSBDevice, and reconnects + return resetAndReconnect(context.driver); + }, + onDone: { + target: 'resetting_state_machine_after_jam', + actions: assign({ + // Overwrites the old nonfunctional driver in context with the new functional one + driver: (_, event) => event.data, + }), }, - NO_PAPER_ANYWHERE: 'accepting_paper', }, }, - done: { - type: 'final', + resetting_state_machine_after_jam: { + entry: async (context) => { + await auth.endCardlessVoterSession( + constructAuthMachineState(context.workspace) + ); + + assign({ + interpretation: undefined, + scannedImagePaths: undefined, + patDevice: undefined, + }); + }, + after: { + // The frontend needs time to idle in this state so the user can read the status message + [RESET_AFTER_JAM_DELAY_MS]: 'not_accepting_paper', + }, + }, + resetting_state_machine_after_success: { + entry: async (context) => { + await auth.endCardlessVoterSession( + constructAuthMachineState(context.workspace) + ); + + assign({ + interpretation: undefined, + scannedImagePaths: undefined, + patDevice: undefined, + }); + }, + always: 'not_accepting_paper', }, - }, - onDone: 'paper_reloaded', - }, - // `paper_reloaded` could be a substate of `blank_page_interpretation` but by keeping - // them separate we can avoid exposing all the substates of `blank_page_interpretation` - // to the frontend. - paper_reloaded: { - invoke: pollAuthStatus(), - on: { - AUTH_STATUS_CARDLESS_VOTER: 'waiting_for_ballot_data', - }, - }, - presenting_ballot: { - invoke: pollPaperStatus(), - entry: async (context) => { - await context.driver.presentPaper(); - }, - on: { - VOTER_VALIDATED_BALLOT: 'eject_to_rear', - VOTER_INVALIDATED_BALLOT: - 'waiting_for_invalidated_ballot_confirmation', - NO_PAPER_ANYWHERE: 'resetting_state_machine_after_success', - }, - }, - // Ballot invalidation is a 2-stage process so the frontend can prompt the voter to get a pollworker - waiting_for_invalidated_ballot_confirmation: { - on: { - VOTER_CONFIRMED_INVALIDATED_BALLOT: 'eject_to_front', - // Even if ballot is removed from front, we still want the frontend to require pollworker auth before continuing - NO_PAPER_ANYWHERE: undefined, - }, - }, - // Eject-to-rear jam handling is a little clunky. It - // 1. Tries to transition to success if no paper is detected - // 2. If after a timeout we have not transitioned away (because paper still present), transition to jammed state - // 3. Jam detection state transitions to jam reset state once it confirms no paper present - eject_to_rear: { - invoke: pollPaperStatus(), - entry: async (context) => { - await context.driver.parkPaper(); - await context.driver.ejectBallotToRear(); - }, - on: { - NO_PAPER_ANYWHERE: 'resetting_state_machine_after_success', - PAPER_JAM: 'jammed', - }, - after: { - [DELAY_BEFORE_DECLARING_REAR_JAM_MS]: 'jammed', - }, - }, - eject_to_front: { - invoke: pollPaperStatus(), - entry: async (context) => { - await context.driver.ejectPaperToFront(); - }, - on: { - NO_PAPER_ANYWHERE: 'resetting_state_machine_after_success', }, }, - jammed: { - invoke: pollPaperStatus(), + pat_device_connected: { on: { - NO_PAPER_ANYWHERE: 'jam_physically_cleared', - }, - }, - jam_physically_cleared: { - invoke: { - id: 'resetScanAndDriver', - src: (context) => { - // Issues `reset scan` command, creates a new WebUSBDevice, and reconnects - return resetAndReconnect(context.driver); - }, - onDone: { - target: 'resetting_state_machine_after_jam', + VOTER_CONFIRMED_PAT_DEVICE_CALIBRATION: 'voting_flow.history', + PAT_DEVICE_DISCONNECTED: { actions: assign({ - // Overwrites the old nonfunctional driver in context with the new functional one - driver: (_, event) => event.data, + patDevice: () => undefined, }), + target: 'voting_flow.history', }, }, }, - resetting_state_machine_after_jam: { - entry: async (context) => { - await auth.endCardlessVoterSession( - constructAuthMachineState(context.workspace) - ); - - assign({ - interpretation: undefined, - scannedImagePaths: undefined, - }); - }, - after: { - // The frontend needs time to idle in this state so the user can read the status message - [RESET_AFTER_JAM_DELAY_MS]: 'not_accepting_paper', - }, - }, - resetting_state_machine_after_success: { - entry: async (context) => { - await auth.endCardlessVoterSession( - constructAuthMachineState(context.workspace) - ); - - assign({ - interpretation: undefined, - scannedImagePaths: undefined, - }); - }, - always: 'not_accepting_paper', - }, }, }); } @@ -576,12 +689,15 @@ function setUpLogging( ([key, value]) => previousContext[key as keyof Context] !== value ) // We only log fields that are key for understanding state machine - // behavior, since others would be too verbose (e.g. scanner client + // behavior, since others would be too verbose (e.g. paper-handler client // object) .filter(([key]) => - ['pollingIntervalMs', 'scannedImagePaths', 'interpretation'].includes( - key - ) + [ + 'pollingIntervalMs', + 'scannedImagePaths', + 'interpretation', + 'patDevice', + ].includes(key) ) // To protect voter privacy, only log the interpretation type .map(([key, value]) => @@ -620,14 +736,21 @@ function setUpLogging( }); } -export async function getPaperHandlerStateMachine( - driver: PaperHandlerDriverInterface, - workspace: Workspace, - auth: InsertedSmartCardAuthApi, - logger: Logger, - devicePollingIntervalMs: number = PAPER_HANDLER_STATUS_POLLING_INTERVAL_MS, - authPollingIntervalMs: number = AUTH_STATUS_POLLING_INTERVAL_MS -): Promise> { +export async function getPaperHandlerStateMachine({ + workspace, + auth, + logger, + driver, + devicePollingIntervalMs = DEVICE_STATUS_POLLING_INTERVAL_MS, + authPollingIntervalMs = AUTH_STATUS_POLLING_INTERVAL_MS, +}: { + workspace: Workspace; + auth: InsertedSmartCardAuthApi; + logger: Logger; + driver: PaperHandlerDriverInterface; + devicePollingIntervalMs: number; + authPollingIntervalMs: number; +}): Promise> { const initialContext: Context = { auth, workspace, @@ -654,44 +777,49 @@ export async function getPaperHandlerStateMachine( const { state } = machineService; switch (true) { - case state.matches('not_accepting_paper'): + case state.matches('voting_flow.not_accepting_paper'): return 'not_accepting_paper'; - case state.matches('accepting_paper'): + case state.matches('voting_flow.accepting_paper'): return 'accepting_paper'; - case state.matches('loading_paper'): + case state.matches('voting_flow.loading_paper'): return 'loading_paper'; - case state.matches('waiting_for_ballot_data'): + case state.matches('voting_flow.waiting_for_ballot_data'): return 'waiting_for_ballot_data'; - case state.matches('printing_ballot'): + case state.matches('voting_flow.printing_ballot'): return 'printing_ballot'; - case state.matches('scanning'): + case state.matches('voting_flow.scanning'): return 'scanning'; - case state.matches('interpreting'): + case state.matches('voting_flow.interpreting'): return 'interpreting'; - case state.matches('waiting_for_invalidated_ballot_confirmation'): + case state.matches( + 'voting_flow.waiting_for_invalidated_ballot_confirmation' + ): return 'waiting_for_invalidated_ballot_confirmation'; - case state.matches('presenting_ballot'): + case state.matches('voting_flow.presenting_ballot'): return 'presenting_ballot'; - case state.matches('eject_to_front'): + case state.matches('voting_flow.eject_to_front'): return 'ejecting_to_front'; - case state.matches('eject_to_rear'): + case state.matches('voting_flow.eject_to_rear'): return 'ejecting_to_rear'; - case state.matches('jammed'): + case state.matches('voting_flow.jammed'): return 'jammed'; - case state.matches('jam_physically_cleared'): + case state.matches('voting_flow.jam_physically_cleared'): return 'jam_cleared'; - case state.matches('resetting_state_machine_after_jam'): + case state.matches('voting_flow.resetting_state_machine_after_jam'): return 'resetting_state_machine_after_jam'; - case state.matches('resetting_state_machine_after_success'): + case state.matches('voting_flow.resetting_state_machine_after_success'): return 'resetting_state_machine_after_success'; - case state.matches('transition_interpretation'): + case state.matches('voting_flow.transition_interpretation'): return 'interpreting'; - case state.matches('blank_page_interpretation'): + case state.matches('voting_flow.blank_page_interpretation'): // blank_page_interpretation has multiple child states but all are handled the same by the frontend return 'blank_page_interpretation'; - case state.matches('paper_reloaded'): + case state.matches('voting_flow.paper_reloaded'): return 'paper_reloaded'; + case state.matches('pat_device_connected'): + return 'pat_device_connected'; default: + debug('Unhandled state: %O', state.value); return 'no_hardware'; } }, @@ -734,6 +862,12 @@ export async function getPaperHandlerStateMachine( }); }, + setPatDeviceIsCalibrated(): void { + machineService.send({ + type: 'VOTER_CONFIRMED_PAT_DEVICE_CALIBRATION', + }); + }, + confirmInvalidateBallot(): void { machineService.send({ type: 'VOTER_CONFIRMED_INVALIDATED_BALLOT', diff --git a/apps/mark-scan/backend/src/custom-paper-handler/types.ts b/apps/mark-scan/backend/src/custom-paper-handler/types.ts index d2ac2b8970..085aa397a3 100644 --- a/apps/mark-scan/backend/src/custom-paper-handler/types.ts +++ b/apps/mark-scan/backend/src/custom-paper-handler/types.ts @@ -11,6 +11,7 @@ export type SimpleStatus = | 'loading_paper' | 'not_accepting_paper' | 'paper_reloaded' + | 'pat_device_connected' | 'presenting_ballot' | 'printing_ballot' | 'resetting_state_machine_after_jam' @@ -31,6 +32,7 @@ export const SimpleStatusSchema: z.ZodSchema = z.union([ z.literal('loading_paper'), z.literal('not_accepting_paper'), z.literal('paper_reloaded'), + z.literal('pat_device_connected'), z.literal('presenting_ballot'), z.literal('printing_ballot'), z.literal('resetting_state_machine_after_jam'), diff --git a/apps/mark-scan/backend/src/server.ts b/apps/mark-scan/backend/src/server.ts index 1f742e28a8..d2286f3b98 100644 --- a/apps/mark-scan/backend/src/server.ts +++ b/apps/mark-scan/backend/src/server.ts @@ -2,8 +2,17 @@ import { Server } from 'http'; import { InsertedSmartCardAuthApi } from '@votingworks/auth'; import { LogEventId, Logger } from '@votingworks/logging'; -import { getPaperHandlerDriver } from '@votingworks/custom-paper-handler'; -import { isIntegrationTest } from '@votingworks/utils'; +import makeDebug from 'debug'; +import { + getPaperHandlerDriver, + MockPaperHandlerDriver, + PaperHandlerDriverInterface, +} from '@votingworks/custom-paper-handler'; +import { + isIntegrationTest, + BooleanEnvironmentVariableName, + isFeatureFlagEnabled, +} from '@votingworks/utils'; import { detectUsbDrive, MockFileUsbDrive } from '@votingworks/usb-drive'; import { buildApp } from './app'; import { Workspace } from './util/workspace'; @@ -14,9 +23,11 @@ import { import { getDefaultAuth } from './util/auth'; import { DEV_AUTH_STATUS_POLLING_INTERVAL_MS, - DEV_PAPER_HANDLER_STATUS_POLLING_INTERVAL_MS, + DEV_DEVICE_STATUS_POLLING_INTERVAL_MS, } from './custom-paper-handler/constants'; +const debug = makeDebug('mark-scan:server'); + export interface StartOptions { auth?: InsertedSmartCardAuthApi; logger: Logger; @@ -26,6 +37,24 @@ export interface StartOptions { stateMachine?: PaperHandlerStateMachine; } +async function resolveDriver(): Promise< + PaperHandlerDriverInterface | undefined +> { + const driver = await getPaperHandlerDriver(); + + if ( + isFeatureFlagEnabled( + BooleanEnvironmentVariableName.SKIP_PAPER_HANDLER_HARDWARE_CHECK + ) && + !driver + ) { + debug('No paper handler found. Starting server with mock driver'); + return new MockPaperHandlerDriver(); + } + + return driver; +} + /** * Starts the server with all the default options. */ @@ -37,18 +66,19 @@ export async function start({ }: StartOptions): Promise { /* istanbul ignore next */ const resolvedAuth = auth ?? getDefaultAuth(logger); + const driver = await resolveDriver(); - const paperHandlerDriver = await getPaperHandlerDriver(); - const stateMachine = paperHandlerDriver - ? await getPaperHandlerStateMachine( - paperHandlerDriver, - workspace, - resolvedAuth, - logger, - DEV_PAPER_HANDLER_STATUS_POLLING_INTERVAL_MS, - DEV_AUTH_STATUS_POLLING_INTERVAL_MS - ) - : undefined; + let stateMachine; + if (driver) { + stateMachine = await getPaperHandlerStateMachine({ + workspace, + auth: resolvedAuth, + logger, + driver, + devicePollingIntervalMs: DEV_DEVICE_STATUS_POLLING_INTERVAL_MS, + authPollingIntervalMs: DEV_AUTH_STATUS_POLLING_INTERVAL_MS, + }); + } const usbDrive = isIntegrationTest() ? new MockFileUsbDrive() diff --git a/apps/mark-scan/backend/test/app_helpers.ts b/apps/mark-scan/backend/test/app_helpers.ts index e1dc7f14b9..d5d0893d00 100644 --- a/apps/mark-scan/backend/test/app_helpers.ts +++ b/apps/mark-scan/backend/test/app_helpers.ts @@ -21,9 +21,9 @@ import { TEST_JURISDICTION, } from '@votingworks/types'; import { + defaultPaperHandlerStatus, MinimalWebUsbDevice, PaperHandlerDriver, - PaperHandlerStatus, } from '@votingworks/custom-paper-handler'; import { assert } from '@votingworks/basics'; import { createMockUsbDrive, MockUsbDrive } from '@votingworks/usb-drive'; @@ -33,54 +33,13 @@ import { getPaperHandlerStateMachine, PaperHandlerStateMachine, } from '../src/custom-paper-handler'; -import { DEV_PAPER_HANDLER_STATUS_POLLING_INTERVAL_MS } from '../src/custom-paper-handler/constants'; +import { + DEV_AUTH_STATUS_POLLING_INTERVAL_MS, + DEV_DEVICE_STATUS_POLLING_INTERVAL_MS, +} from '../src/custom-paper-handler/constants'; jest.mock('@votingworks/custom-paper-handler'); -export function defaultPaperHandlerStatus(): PaperHandlerStatus { - return { - // Scanner status - requestId: 1, - returnCode: 1, - parkSensor: false, - paperOutSensor: false, - paperPostCisSensor: false, - paperPreCisSensor: false, - paperInputLeftInnerSensor: false, - paperInputRightInnerSensor: false, - paperInputLeftOuterSensor: false, - paperInputRightOuterSensor: false, - printHeadInPosition: false, - scanTimeout: false, - motorMove: false, - scanInProgress: false, - jamEncoder: false, - paperJam: false, - coverOpen: false, - optoSensor: false, - ballotBoxDoorSensor: false, - ballotBoxAttachSensor: false, - preHeadSensor: false, - - // Printer status - ticketPresentInOutput: false, - paperNotPresent: true, - dragPaperMotorOn: false, - spooling: false, - printingHeadUpError: false, - notAcknowledgeCommandError: false, - powerSupplyVoltageError: false, - headNotConnected: false, - comError: false, - headTemperatureError: false, - diverterError: false, - headErrorLocked: false, - printingHeadReadyToPrint: true, - eepromError: false, - ramError: false, - }; -} - export async function getMockStateMachine( workspace: Workspace, logger: Logger @@ -99,13 +58,14 @@ export async function getMockStateMachine( jest .spyOn(driver, 'getPaperHandlerStatus') .mockImplementation(() => Promise.resolve(defaultPaperHandlerStatus())); - const stateMachine = await getPaperHandlerStateMachine( - driver, + const stateMachine = await getPaperHandlerStateMachine({ workspace, auth, logger, - DEV_PAPER_HANDLER_STATUS_POLLING_INTERVAL_MS - ); + driver, + devicePollingIntervalMs: DEV_DEVICE_STATUS_POLLING_INTERVAL_MS, + authPollingIntervalMs: DEV_AUTH_STATUS_POLLING_INTERVAL_MS, + }); assert(stateMachine); return stateMachine; diff --git a/apps/mark-scan/frontend/src/api.ts b/apps/mark-scan/frontend/src/api.ts index 6fc8478e2e..8121a08f3c 100644 --- a/apps/mark-scan/frontend/src/api.ts +++ b/apps/mark-scan/frontend/src/api.ts @@ -349,3 +349,15 @@ export const confirmInvalidateBallot = { }); }, } as const; + +export const setPatDeviceIsCalibrated = { + useMutation() { + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + return useMutation(apiClient.setPatDeviceIsCalibrated, { + async onSuccess() { + await queryClient.invalidateQueries(getStateMachineState.queryKey()); + }, + }); + }, +} as const; diff --git a/apps/mark-scan/frontend/src/app_root.tsx b/apps/mark-scan/frontend/src/app_root.tsx index 8ce812c474..7714963da8 100644 --- a/apps/mark-scan/frontend/src/app_root.tsx +++ b/apps/mark-scan/frontend/src/app_root.tsx @@ -25,6 +25,8 @@ import { isPollWorkerAuth, isSystemAdministratorAuth, randomBallotId, + isFeatureFlagEnabled, + BooleanEnvironmentVariableName, } from '@votingworks/utils'; import { LogEventId, Logger } from '@votingworks/logging'; @@ -82,6 +84,7 @@ import { ValidateBallotPage } from './pages/validate_ballot_page'; import { BallotInvalidatedPage } from './pages/ballot_invalidated_page'; import { BlankPageInterpretationPage } from './pages/blank_page_interpretation_page'; import { PaperReloadedPage } from './pages/paper_reloaded_page'; +import { PatDeviceCalibrationPage } from './pages/pat_device_identification/pat_device_calibration_page'; interface UserState { votes?: VotesDict; @@ -597,9 +600,16 @@ export function AppRoot({ if (!cardReader) { return ; } - if (stateMachineState === 'no_hardware') { + + if ( + stateMachineState === 'no_hardware' && + !isFeatureFlagEnabled( + BooleanEnvironmentVariableName.SKIP_PAPER_HANDLER_HARDWARE_CHECK + ) + ) { return ; } + if ( authStatus.status === 'logged_out' && authStatus.reason === 'card_error' @@ -702,14 +712,17 @@ export function AppRoot({ return ; } - // Blank page interpretation handling must take priority over PollWorkerScreen. - // PollWorkerScreen will warn that votes exist in ballot state, but preserving - // ballot state is the desired behavior when handling blank page interpretations. - if ( - (isPollWorkerAuth(authStatus) || isCardlessVoterAuth(authStatus)) && - stateMachineState === 'blank_page_interpretation' - ) { - return ; + if (isPollWorkerAuth(authStatus) || isCardlessVoterAuth(authStatus)) { + if (stateMachineState === 'blank_page_interpretation') { + // Blank page interpretation handling must take priority over PollWorkerScreen. + // PollWorkerScreen will warn that votes exist in ballot state, but preserving + // ballot state is the desired behavior when handling blank page interpretations. + return ; + } + + if (stateMachineState === 'pat_device_connected') { + return ; + } } if ( @@ -759,7 +772,6 @@ export function AppRoot({ stateMachineState === 'waiting_for_invalidated_ballot_confirmation') ) { let ballotContextProviderChild = ; - // Pages that condition on state machine state aren't nested under Ballot because Ballot uses // frontend browser routing for flow control and is completely independent of the state machine. // We still want to nest pages that condition on the state machine under BallotContext so we render them here. diff --git a/apps/mark-scan/frontend/src/app_states.test.tsx b/apps/mark-scan/frontend/src/app_states.test.tsx index c383355223..4302dc0a42 100644 --- a/apps/mark-scan/frontend/src/app_states.test.tsx +++ b/apps/mark-scan/frontend/src/app_states.test.tsx @@ -186,6 +186,23 @@ test('`blank_page_interpretation` state renders BlankPageInterpretationPage for await screen.findByText('Load New Ballot Sheet'); }); +test('`pat_device_connected` state renders PAT device calibration page', async () => { + apiMock.setAuthStatusPollWorkerLoggedIn(electionDefinition); + + render( + + ); + + apiMock.setPaperHandlerState('pat_device_connected'); + apiMock.setAuthStatusCardlessVoterLoggedInWithDefaults(electionDefinition); + await screen.findByText('Test Your Device'); +}); + test('`paper_reloaded` state renders PaperReloadedPage', async () => { apiMock.setAuthStatusPollWorkerLoggedIn(electionDefinition); diff --git a/apps/mark-scan/frontend/src/lib/gamepad.ts b/apps/mark-scan/frontend/src/lib/gamepad.ts index 911db00eb1..38ba7807ef 100644 --- a/apps/mark-scan/frontend/src/lib/gamepad.ts +++ b/apps/mark-scan/frontend/src/lib/gamepad.ts @@ -104,6 +104,14 @@ export function handleGamepadKeyboardEvent(event: KeyboardEvent): void { case ']': handleClick(); break; + // Current PAT device support uses a USB switch that emulates keypresses 1 and 2. + // These signals are used to navigate DOM focus and select the focused element. + case '1': + handleArrowDown(); + break; + case '2': + handleClick(); + break; case 'Enter': // Enter already acts like a click // handleClick() diff --git a/apps/mark-scan/frontend/src/pages/accessible_controller_diagnostic_screen.tsx b/apps/mark-scan/frontend/src/pages/accessible_controller_diagnostic_screen.tsx index 0071bdde81..7087c0537a 100644 --- a/apps/mark-scan/frontend/src/pages/accessible_controller_diagnostic_screen.tsx +++ b/apps/mark-scan/frontend/src/pages/accessible_controller_diagnostic_screen.tsx @@ -1,28 +1,15 @@ import { useEffect, useState } from 'react'; -import { Button, Font, H1, Main, P, Prose, Screen } from '@votingworks/ui'; +import { Button, Font, H1, Main, P, Screen } from '@votingworks/ui'; import { DateTime } from 'luxon'; import styled from 'styled-components'; import { ScreenReader } from '../config/types'; +import { + DiagnosticScreenHeader, + StepContainer, +} from './diagnostic_screen_components'; type ButtonName = 'Up' | 'Down' | 'Left' | 'Right' | 'Select'; -const Header = styled(Prose).attrs({ - maxWidth: false, -})` - display: flex; - align-items: baseline; - justify-content: space-between; - width: 100%; - padding: 40px; -`; - -const StepContainer = styled.div` - display: flex; - flex: 1; - align-items: center; - min-width: 1080px; -`; - const StepInnerContainer = styled.div` display: flex; width: 100%; @@ -282,13 +269,13 @@ export function AccessibleControllerDiagnosticScreen({ return (
-
+

Accessible Controller Test — Step{' '} {step + 1} of {steps.length}

-
+ {steps[step]}
diff --git a/apps/mark-scan/frontend/src/pages/diagnostic_screen_components.tsx b/apps/mark-scan/frontend/src/pages/diagnostic_screen_components.tsx new file mode 100644 index 0000000000..881d2f81b5 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/diagnostic_screen_components.tsx @@ -0,0 +1,23 @@ +import { Prose } from '@votingworks/ui'; +import styled from 'styled-components'; + +export const DiagnosticScreenHeader = styled(Prose).attrs({ + maxWidth: false, +})` + display: flex; + align-items: baseline; + justify-content: space-between; + width: 100%; + padding: 40px; +`; + +interface StepContainerProps { + fullWidth?: boolean; +} +export const StepContainer = styled.div` + display: flex; + flex: 1; + align-items: center; + min-width: 1080px; + width: ${(p) => (p.fullWidth ? '100%' : undefined)}; +`; diff --git a/apps/mark-scan/frontend/src/pages/paper_handler_hardware_check_disabled_screen.tsx b/apps/mark-scan/frontend/src/pages/paper_handler_hardware_check_disabled_screen.tsx new file mode 100644 index 0000000000..54eae73d87 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/paper_handler_hardware_check_disabled_screen.tsx @@ -0,0 +1,26 @@ +import { Text, Main, Screen, H1, P } from '@votingworks/ui'; + +interface Props { + message?: string; +} +export function PaperHandlerHardwareCheckDisabledScreen({ + message, +}: Props): JSX.Element { + return ( + +
+ +

Hardware Check Disabled

+
+ +

+ The paper handler hardware check is disabled for development. + Functionality that relies on hardware, like the paper load and print + flows, will not work. +

+ {message &&

{message}

} +
+
+
+ ); +} diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/confirm_exit_pat_device_identification_page.test.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/confirm_exit_pat_device_identification_page.test.tsx new file mode 100644 index 0000000000..019ce85469 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/confirm_exit_pat_device_identification_page.test.tsx @@ -0,0 +1,24 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../../test/react_testing_library'; +import { ConfirmExitPatDeviceIdentificationPage } from './confirm_exit_pat_device_identification_page'; + +test('calls provided Back and Continue functions', () => { + const backFn = jest.fn(); + const continueFn = jest.fn(); + + render( + + ); + + screen.getByText('Device Inputs Identified'); + expect(backFn).not.toHaveBeenCalled(); + userEvent.click(screen.getByText('Back')); + expect(backFn).toHaveBeenCalled(); + + expect(continueFn).not.toHaveBeenCalled(); + userEvent.click(screen.getByText('Continue with Voting')); + expect(continueFn).toHaveBeenCalled(); +}); diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/confirm_exit_pat_device_identification_page.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/confirm_exit_pat_device_identification_page.tsx new file mode 100644 index 0000000000..e5116911c3 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/confirm_exit_pat_device_identification_page.tsx @@ -0,0 +1,33 @@ +import { Main, Screen, H1, P, Button, Icons } from '@votingworks/ui'; +import { ButtonFooter } from '../../components/button_footer'; +import { PortraitStepInnerContainer } from './portrait_step_inner_container'; + +interface Props { + onPressBack: () => void; + onPressContinue: () => void; +} + +export function ConfirmExitPatDeviceIdentificationPage({ + onPressBack, + onPressContinue, +}: Props): JSX.Element { + return ( + +
+ + +

Device Inputs Identified

+

You may continue with voting or go back to the previous screen.

+
+
+ + + + +
+ ); +} diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/constants.ts b/apps/mark-scan/frontend/src/pages/pat_device_identification/constants.ts new file mode 100644 index 0000000000..de428fd5f5 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/constants.ts @@ -0,0 +1,8 @@ +export const behaviorToKeypressMap = { + Move: '1', + Select: '2', +} as const; + +export const validKeypressValues: string[] = Object.values( + behaviorToKeypressMap +); diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/identify_input_step.test.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/identify_input_step.test.tsx new file mode 100644 index 0000000000..d87ac96762 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/identify_input_step.test.tsx @@ -0,0 +1,78 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../../test/react_testing_library'; +import { IdentifyInputStep, InputBehavior } from './identify_input_step'; + +const testSpecs: Array<{ + desiredInput: InputBehavior; + otherInput: InputBehavior; + desiredKey: string; + otherKey: string; +}> = [ + { + desiredInput: 'Move', + desiredKey: '1', + otherInput: 'Select', + otherKey: '2', + }, + { + desiredInput: 'Select', + desiredKey: '2', + otherInput: 'Move', + otherKey: '1', + }, +]; + +test.each(testSpecs)( + 'confirms when $desiredInput is triggered', + ({ desiredInput, desiredKey }) => { + const onStepCompleted = jest.fn(); + render( + + ); + + screen.getByText(`Identify the "${desiredInput}" Input`); + userEvent.keyboard(desiredKey); + screen.getByText(`"${desiredInput}" Input Identified`); + screen.getByText('Trigger the input again to continue.'); + + expect(onStepCompleted).not.toHaveBeenCalled(); + userEvent.keyboard(desiredKey); + expect(onStepCompleted).toHaveBeenCalled(); + } +); + +test.each(testSpecs)( + 'when desired input is $desiredInput, warns when $otherInput is triggered', + ({ desiredInput, desiredKey, otherInput, otherKey }) => { + const onStepCompleted = jest.fn(); + render( + + ); + + screen.getByText(`Identify the "${desiredInput}" Input`); + userEvent.keyboard(otherKey); + screen.getByText(`"${otherInput}" Input Triggered`); + screen.getByText('Try the other input.'); + + userEvent.keyboard(desiredKey); + screen.getByText(`"${desiredInput}" Input Identified`); + screen.getByText('Trigger the input again to continue.'); + } +); + +test('non-PAT keys are ignored', () => { + const onStepCompleted = jest.fn(); + render( + + ); + + userEvent.keyboard('3'); + screen.getByText('Identify the "Move" Input'); + expect(onStepCompleted).not.toBeCalled(); +}); diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/identify_input_step.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/identify_input_step.tsx new file mode 100644 index 0000000000..a4dd1c8067 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/identify_input_step.tsx @@ -0,0 +1,109 @@ +import { useCallback, useState, useEffect } from 'react'; +import { H1, Icons, P } from '@votingworks/ui'; +import { throwIllegalValue } from '@votingworks/basics'; +import { behaviorToKeypressMap, validKeypressValues } from './constants'; +import { PortraitStepInnerContainer } from './portrait_step_inner_container'; + +export type InputBehavior = 'Move' | 'Select'; + +// Each input identification step is broken into these sub-steps, named Phases for disambiguation +type InputIdentificationPhase = + | 'unidentified' + | 'identified' + // The "wrong" input has been triggered. Devices vary so it's not possible to give detailed + // information on which input maps to which behavior. Messaging for this state + // should be friendly and forgiving because instructions to the voter will be limited. + | 'other_input'; + +function getOtherInputName(inputName: InputBehavior) { + return inputName === 'Move' ? 'Select' : 'Move'; +} + +export function IdentifyInputStep({ + inputName, + onStepCompleted, +}: { + inputName: InputBehavior; + onStepCompleted: () => void; +}): JSX.Element { + const [inputIdentificationPhase, setInputIdentificationPhase] = + useState('unidentified'); + + const handleInput = useCallback( + (event: KeyboardEvent) => { + if (!validKeypressValues.includes(event.key)) { + return; + } + + event.preventDefault(); + + if (event.key === behaviorToKeypressMap[inputName]) { + switch (inputIdentificationPhase) { + case 'unidentified': + setInputIdentificationPhase('identified'); + break; + case 'identified': + onStepCompleted(); + break; + case 'other_input': + setInputIdentificationPhase('identified'); + break; + /* istanbul ignore next - compile time check for completeness */ + default: + throwIllegalValue(inputIdentificationPhase); + } + } else if ( + event.key === behaviorToKeypressMap[getOtherInputName(inputName)] + ) { + setInputIdentificationPhase('other_input'); + } + }, + [ + inputName, + inputIdentificationPhase, + setInputIdentificationPhase, + onStepCompleted, + ] + ); + + useEffect(() => { + document.addEventListener('keydown', handleInput); + + return () => { + document.removeEventListener('keydown', handleInput); + }; + }); + + let headerContent = ''; + let bodyContent = ''; + let icon = null; + + switch (inputIdentificationPhase) { + case 'unidentified': + headerContent = `Identify the "${inputName}" Input`; + bodyContent = 'Try an input to continue.'; + icon = ; + break; + case 'identified': + headerContent = `"${inputName}" Input Identified`; + bodyContent = 'Trigger the input again to continue.'; + icon = ; + break; + case 'other_input': + headerContent = `"${getOtherInputName(inputName)}" Input Triggered`; + bodyContent = 'Try the other input.'; + icon = ; + break; + /* istanbul ignore next - compile time check for completeness */ + default: + throwIllegalValue(inputIdentificationPhase); + } + + return ( + + {icon} +

{headerContent}

+ {bodyContent &&

{bodyContent}

} +
+ ); +} diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_calibration_page.test.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_calibration_page.test.tsx new file mode 100644 index 0000000000..5d7b7da8fd --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_calibration_page.test.tsx @@ -0,0 +1,70 @@ +import userEvent from '@testing-library/user-event'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { render, screen } from '../../../test/react_testing_library'; +import { PatDeviceCalibrationPage } from './pat_device_calibration_page'; +import { ApiMock, createApiMock } from '../../../test/helpers/mock_api_client'; +import { ApiClientContext, createQueryClient } from '../../api'; + +let apiMock: ApiMock; + +beforeEach(() => { + apiMock = createApiMock(); +}); + +afterEach(() => { + apiMock.mockApiClient.assertComplete(); +}); + +function identifyInputs() { + // Continue pass intructions + userEvent.keyboard('1'); + + // Identify first input + userEvent.keyboard('1'); + userEvent.keyboard('1'); + + // Identify second input + userEvent.keyboard('2'); + userEvent.keyboard('2'); + + screen.getByText('Device Inputs Identified'); +} + +function renderComponent() { + render( + + + + + + ); +} + +test('can restart the device ID flow', () => { + renderComponent(); + + screen.getByText('PAT Device Identification'); + + identifyInputs(); + userEvent.click(screen.getByText('Back')); + + screen.getByText('Test Your Device'); +}); + +test('sets backend calibration state if "Skip" button is pressed', () => { + renderComponent(); + + screen.getByText('PAT Device Identification'); + apiMock.expectSetPatDeviceIsCalibrated(); + userEvent.click(screen.getByText('Skip Identification')); +}); + +test('sets backend calibration state if "Continue with Voting" button is pressed', () => { + renderComponent(); + + screen.getByText('PAT Device Identification'); + + identifyInputs(); + apiMock.expectSetPatDeviceIsCalibrated(); + userEvent.click(screen.getByText('Continue with Voting')); +}); diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_calibration_page.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_calibration_page.tsx new file mode 100644 index 0000000000..97ccfe9e38 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_calibration_page.tsx @@ -0,0 +1,37 @@ +import { useCallback, useState } from 'react'; +import { ConfirmExitPatDeviceIdentificationPage } from './confirm_exit_pat_device_identification_page'; +import { PatDeviceIdentificationPage } from './pat_device_identification_page'; +import { setPatDeviceIsCalibrated } from '../../api'; + +export function PatDeviceCalibrationPage(): JSX.Element { + const setPatDeviceIsCalibratedMutation = + setPatDeviceIsCalibrated.useMutation(); + function onExitCalibration() { + setPatDeviceIsCalibratedMutation.mutate(); + } + const [areInputsIdentified, setAreInputsIdentified] = useState(false); + + const onPressBack = useCallback(() => { + setAreInputsIdentified(false); + }, [setAreInputsIdentified]); + + const onAllInputsIdentified = useCallback(() => { + setAreInputsIdentified(true); + }, [setAreInputsIdentified]); + + if (areInputsIdentified) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_identification_page.test.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_identification_page.test.tsx new file mode 100644 index 0000000000..8cf4d762b9 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_identification_page.test.tsx @@ -0,0 +1,20 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../../test/react_testing_library'; +import { PatDeviceIdentificationPage } from './pat_device_identification_page'; + +test('advances to next step', () => { + const onAllInputsIdentified = jest.fn(); + const onExitCalibration = jest.fn(); + + render( + + ); + + screen.getByText('PAT Device Identification'); + screen.getByText('Trigger any input to continue.'); + userEvent.keyboard('1'); + screen.getByText('Identify the "Move" Input'); +}); diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_identification_page.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_identification_page.tsx new file mode 100644 index 0000000000..f658cdd540 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_device_identification_page.tsx @@ -0,0 +1,52 @@ +import { Main, Screen, P, Font, Button } from '@votingworks/ui'; +import { useCallback, useState } from 'react'; +import { + DiagnosticScreenHeader, + StepContainer, +} from '../diagnostic_screen_components'; +import { PatIntroductionStep } from './pat_introduction_step'; +import { IdentifyInputStep } from './identify_input_step'; +import { ButtonFooter } from '../../components/button_footer'; + +interface Props { + onAllInputsIdentified: () => void; + onExitCalibration: () => void; +} + +export function PatDeviceIdentificationPage({ + onAllInputsIdentified, + onExitCalibration, +}: Props): JSX.Element { + const [step, setStep] = useState(0); + + const nextStep = useCallback(() => { + setStep(step + 1); + }, [step, setStep]); + + const steps = [ + , + , + , + ]; + + return ( + +
+ +

+ PAT Device Identification — Step{' '} + {step + 1} of {steps.length} +

+
+ {steps[step]} +
+ + + +
+ ); +} diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_introduction_step.test.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_introduction_step.test.tsx new file mode 100644 index 0000000000..dc5ba0807d --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_introduction_step.test.tsx @@ -0,0 +1,14 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../../test/react_testing_library'; +import { PatIntroductionStep } from './pat_introduction_step'; + +test('calls provided onStepCompleted fn when valid input is pressed', () => { + const onStepCompleted = jest.fn(); + + render(); + + screen.getByText('Test Your Device'); + expect(onStepCompleted).not.toHaveBeenCalled(); + userEvent.keyboard('1'); + expect(onStepCompleted).toHaveBeenCalled(); +}); diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_introduction_step.tsx b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_introduction_step.tsx new file mode 100644 index 0000000000..da4e74b662 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/pat_introduction_step.tsx @@ -0,0 +1,42 @@ +import { useEffect, useCallback } from 'react'; +import { H1, Icons, P, Text } from '@votingworks/ui'; +import { validKeypressValues } from './constants'; +import { PortraitStepInnerContainer } from './portrait_step_inner_container'; + +export function PatIntroductionStep({ + onStepCompleted, +}: { + onStepCompleted: () => void; +}): JSX.Element { + const handleInput = useCallback( + (event: KeyboardEvent) => { + if (validKeypressValues.includes(event.key)) { + event.preventDefault(); + onStepCompleted(); + } + }, + [onStepCompleted] + ); + + useEffect(() => { + document.addEventListener('keydown', handleInput); + + return () => { + document.removeEventListener('keydown', handleInput); + }; + }); + + return ( + + +

Test Your Device

+

+ Your device's two inputs can be used to Move focus between + two items on the screen and Select an item. +

+ +

Trigger any input to continue.

+
+
+ ); +} diff --git a/apps/mark-scan/frontend/src/pages/pat_device_identification/portrait_step_inner_container.ts b/apps/mark-scan/frontend/src/pages/pat_device_identification/portrait_step_inner_container.ts new file mode 100644 index 0000000000..07251fab25 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/pat_device_identification/portrait_step_inner_container.ts @@ -0,0 +1,12 @@ +import styled from 'styled-components'; + +export const PortraitStepInnerContainer = styled.div` + width: 100%; + padding: 0 40px; + + svg { + height: 10em; + display: block; + margin: 0 auto; + } +`; diff --git a/apps/mark-scan/frontend/src/pages/poll_worker_screen.test.tsx b/apps/mark-scan/frontend/src/pages/poll_worker_screen.test.tsx index ce1ccf0a25..b9f29449d3 100644 --- a/apps/mark-scan/frontend/src/pages/poll_worker_screen.test.tsx +++ b/apps/mark-scan/frontend/src/pages/poll_worker_screen.test.tsx @@ -4,7 +4,12 @@ import { } from '@votingworks/fixtures'; import { ElectionDefinition, InsertedSmartCardAuth } from '@votingworks/types'; -import { MemoryHardware, singlePrecinctSelectionFor } from '@votingworks/utils'; +import { + BooleanEnvironmentVariableName, + MemoryHardware, + getFeatureFlagMock, + singlePrecinctSelectionFor, +} from '@votingworks/utils'; import { hasTextAcrossElements } from '@votingworks/test-utils'; import userEvent from '@testing-library/user-event'; @@ -28,6 +33,14 @@ import { const { election } = electionGeneralDefinition; let apiMock: ApiMock; +const mockFeatureFlagger = getFeatureFlagMock(); + +jest.mock('@votingworks/utils', (): typeof import('@votingworks/utils') => { + return { + ...jest.requireActual('@votingworks/utils'), + isFeatureFlagEnabled: (flag) => mockFeatureFlagger.isEnabled(flag), + }; +}); beforeEach(() => { jest.useFakeTimers(); @@ -214,3 +227,22 @@ test('returns null if status is unhandled', () => { expect(screen.queryByText('Paper has been loaded.')).toBeNull(); expect(screen.queryByText('Poll Worker Actions')).toBeNull(); }); + +test('renders a warning screen when hardware check is off', async () => { + mockFeatureFlagger.enableFeatureFlag( + BooleanEnvironmentVariableName.SKIP_PAPER_HANDLER_HARDWARE_CHECK + ); + + const electionDefinition = electionGeneralDefinition; + const pollWorkerAuth = fakeCardlessVoterAuth(electionDefinition); + apiMock.setPaperHandlerState('no_hardware'); + + renderScreen({ + pollsState: 'polls_open', + pollWorkerAuth, + machineConfig: fakeMachineConfig(), + electionDefinition, + }); + + await screen.findByText('Hardware Check Disabled'); +}); diff --git a/apps/mark-scan/frontend/src/pages/poll_worker_screen.tsx b/apps/mark-scan/frontend/src/pages/poll_worker_screen.tsx index 171d148987..39131054fb 100644 --- a/apps/mark-scan/frontend/src/pages/poll_worker_screen.tsx +++ b/apps/mark-scan/frontend/src/pages/poll_worker_screen.tsx @@ -41,6 +41,8 @@ import { getPollsStateName, getPollsTransitionAction, getPollTransitionsFromState, + isFeatureFlagEnabled, + BooleanEnvironmentVariableName, } from '@votingworks/utils'; import type { MachineConfig } from '@votingworks/mark-scan-backend'; @@ -52,6 +54,7 @@ import { triggerAudioFocus } from '../utils/trigger_audio_focus'; import { DiagnosticsScreen } from './diagnostics_screen'; import { LoadPaperPage } from './load_paper_page'; import { getStateMachineState, setAcceptingPaperState } from '../api'; +import { PaperHandlerHardwareCheckDisabledScreen } from './paper_handler_hardware_check_disabled_screen'; const VotingSession = styled.div` margin: 30px 0 60px; @@ -262,6 +265,16 @@ export function PollWorkerScreen({ } if (pollWorkerAuth.cardlessVoterUser) { + if ( + isFeatureFlagEnabled( + BooleanEnvironmentVariableName.SKIP_PAPER_HANDLER_HARDWARE_CHECK + ) + ) { + return ( + + ); + } + if ( stateMachineState === 'accepting_paper' || stateMachineState === 'loading_paper' @@ -313,7 +326,6 @@ export function PollWorkerScreen({ ); } - // Unexpected state machine state. return null; } diff --git a/apps/mark-scan/frontend/test/helpers/mock_api_client.tsx b/apps/mark-scan/frontend/test/helpers/mock_api_client.tsx index 238e974241..d3e8fb9782 100644 --- a/apps/mark-scan/frontend/test/helpers/mock_api_client.tsx +++ b/apps/mark-scan/frontend/test/helpers/mock_api_client.tsx @@ -295,6 +295,10 @@ export function createApiMock() { expectEjectUsbDrive() { mockApiClient.ejectUsbDrive.expectCallWith().resolves(); }, + + expectSetPatDeviceIsCalibrated() { + mockApiClient.setPatDeviceIsCalibrated.expectCallWith().resolves(); + }, }; } diff --git a/libs/custom-paper-handler/src/driver/index.ts b/libs/custom-paper-handler/src/driver/index.ts index b380acfc19..39cfb6fa4b 100644 --- a/libs/custom-paper-handler/src/driver/index.ts +++ b/libs/custom-paper-handler/src/driver/index.ts @@ -1,7 +1,9 @@ /* istanbul ignore file */ export * from './driver'; +export * from './mock_driver'; export * from './driver_interface'; export * from './coders'; export * from './constants'; export * from './helpers'; export * from './minimal_web_usb_device'; +export * from './test_utils'; diff --git a/libs/custom-paper-handler/src/driver/mock_driver.ts b/libs/custom-paper-handler/src/driver/mock_driver.ts new file mode 100644 index 0000000000..9c2c372e61 --- /dev/null +++ b/libs/custom-paper-handler/src/driver/mock_driver.ts @@ -0,0 +1,326 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable vx/gts-no-public-class-fields */ +import { Result } from '@votingworks/basics'; +import { ImageFromScanner } from '@votingworks/custom-scanner'; +import { Coder, CoderError } from '@votingworks/message-coder'; +import makeDebug from 'debug'; +import { MinimalWebUsbDevice } from './minimal_web_usb_device'; +import { PaperHandlerBitmap, PaperHandlerStatus } from './coders'; +import { Lock } from './lock'; +import { ScannerCapability } from './scanner_capability'; +import { + ScannerConfig, + ScanLight, + ScanDataFormat, + Resolution, + PaperMovementAfterScan, + ScanDirection, + getDefaultConfig, +} from './scanner_config'; +import { PaperHandlerDriverInterface } from './driver_interface'; +import { + PrintingDensity, + PrintingSpeed, + RealTimeRequestIds, +} from './constants'; +import { mockMinimalWebUsbDevice } from './mock_minimal_web_usb_device'; +import { defaultPaperHandlerStatus } from './test_utils'; + +const debug = makeDebug('custom-paper-handler:mock-driver'); + +// USBOutTransferResult is undefined at runtime +function makeUsbOutTransferResult( + status: USBTransferStatus, + bytesWritten: number +) { + return { + status, + bytesWritten, + }; +} + +export class MockPaperHandlerDriver implements PaperHandlerDriverInterface { + readonly genericLock = new Lock(); + readonly realTimeLock = new Lock(); + readonly scannerConfig: ScannerConfig = getDefaultConfig(); + readonly webDevice: MinimalWebUsbDevice = mockMinimalWebUsbDevice(); + + connect(): Promise { + throw new Error('Method not implemented.'); + } + disconnect(): Promise { + throw new Error('Method not implemented.'); + } + getWebDevice(): MinimalWebUsbDevice { + throw new Error('Method not implemented.'); + } + transferInGeneric(): Promise { + throw new Error('Method not implemented.'); + } + clearGenericInBuffer(): Promise { + throw new Error('Method not implemented.'); + } + transferOutRealTime(_requestId: number): Promise { + throw new Error('Method not implemented.'); + } + transferInRealTime(): Promise { + throw new Error('Method not implemented.'); + } + handleRealTimeExchange( + _requestId: RealTimeRequestIds, + _coder: Coder + ): Promise> { + throw new Error('Method not implemented.'); + } + transferOutGeneric( + _coder: Coder, + _value: T + ): Promise { + throw new Error('Method not implemented.'); + } + initializePrinter(): Promise { + debug('initializePrinter called'); + return Promise.resolve(); + } + validateRealTimeExchangeResponse( + _expectedRequestId: RealTimeRequestIds, + _response: + | { + requestId: number; + returnCode: number; + parkSensor: boolean; + paperOutSensor: boolean; + paperPostCisSensor: boolean; + paperPreCisSensor: boolean; + paperInputLeftInnerSensor: boolean; + paperInputRightInnerSensor: boolean; + paperInputLeftOuterSensor: boolean; + paperInputRightOuterSensor: boolean; + printHeadInPosition: boolean; + scanTimeout: boolean; + motorMove: boolean; + scanInProgress: boolean; + jamEncoder: boolean; + paperJam: boolean; + coverOpen: boolean; + optoSensor: boolean; + ballotBoxDoorSensor: boolean; + ballotBoxAttachSensor: boolean; + preHeadSensor: boolean; + startOfPacket?: unknown; + token?: unknown; + optionalDataLength?: unknown; + } + | { + requestId: number; + returnCode: number; + coverOpen: boolean; + ticketPresentInOutput: boolean; + paperNotPresent: boolean; + dragPaperMotorOn: boolean; + spooling: boolean; + printingHeadUpError: boolean; + notAcknowledgeCommandError: boolean; + powerSupplyVoltageError: boolean; + headNotConnected: boolean; + comError: boolean; + headTemperatureError: boolean; + diverterError: boolean; + headErrorLocked: boolean; + printingHeadReadyToPrint: boolean; + eepromError: boolean; + ramError: boolean; + startOfPacket?: unknown; + token?: unknown; + optionalDataLength?: unknown; + dle?: unknown; + eot?: unknown; + } + | { + requestId: number; + returnCode: number; + startOfPacket?: unknown; + token?: unknown; + } + ): void { + throw new Error('Method not implemented.'); + } + getScannerStatus(): Promise<{ + requestId: number; + returnCode: number; + parkSensor: boolean; + paperOutSensor: boolean; + paperPostCisSensor: boolean; + paperPreCisSensor: boolean; + paperInputLeftInnerSensor: boolean; + paperInputRightInnerSensor: boolean; + paperInputLeftOuterSensor: boolean; + paperInputRightOuterSensor: boolean; + printHeadInPosition: boolean; + scanTimeout: boolean; + motorMove: boolean; + scanInProgress: boolean; + jamEncoder: boolean; + paperJam: boolean; + coverOpen: boolean; + optoSensor: boolean; + ballotBoxDoorSensor: boolean; + ballotBoxAttachSensor: boolean; + preHeadSensor: boolean; + startOfPacket?: unknown; + token?: unknown; + optionalDataLength?: unknown; + }> { + throw new Error('Method not implemented.'); + } + getPrinterStatus(): Promise<{ + requestId: number; + returnCode: number; + coverOpen: boolean; + ticketPresentInOutput: boolean; + paperNotPresent: boolean; + dragPaperMotorOn: boolean; + spooling: boolean; + printingHeadUpError: boolean; + notAcknowledgeCommandError: boolean; + powerSupplyVoltageError: boolean; + headNotConnected: boolean; + comError: boolean; + headTemperatureError: boolean; + diverterError: boolean; + headErrorLocked: boolean; + printingHeadReadyToPrint: boolean; + eepromError: boolean; + ramError: boolean; + startOfPacket?: unknown; + token?: unknown; + optionalDataLength?: unknown; + dle?: unknown; + eot?: unknown; + }> { + throw new Error('Method not implemented.'); + } + abortScan(): Promise { + throw new Error('Method not implemented.'); + } + resetScan(): Promise { + throw new Error('Method not implemented.'); + } + + getPaperHandlerStatus(): Promise { + return Promise.resolve(defaultPaperHandlerStatus()); + } + + handleGenericCommandWithAcknowledgement( + _coder: Coder, + _value: T + ): Promise { + throw new Error('Method not implemented.'); + } + getScannerCapability(): Promise { + throw new Error('Method not implemented.'); + } + syncScannerConfig(): Promise { + throw new Error('Method not implemented.'); + } + setScanLight(_scanLight: ScanLight): Promise { + throw new Error('Method not implemented.'); + } + setScanDataFormat(_scanDataFormat: ScanDataFormat): Promise { + throw new Error('Method not implemented.'); + } + setScanResolution(_resolution: { + horizontalResolution: Resolution; + verticalResolution: Resolution; + }): Promise { + throw new Error('Method not implemented.'); + } + setPaperMovementAfterScan( + _paperMovementAfterScan: PaperMovementAfterScan + ): Promise { + throw new Error('Method not implemented.'); + } + setScanDirection(_scanDirection: ScanDirection): Promise { + throw new Error('Method not implemented.'); + } + scan(): Promise { + throw new Error('Method not implemented.'); + } + scanAndSave(_pathOut: string): Promise { + throw new Error('Method not implemented.'); + } + loadPaper(): Promise { + throw new Error('Method not implemented.'); + } + ejectPaperToFront(): Promise { + throw new Error('Method not implemented.'); + } + parkPaper(): Promise { + throw new Error('Method not implemented.'); + } + presentPaper(): Promise { + throw new Error('Method not implemented.'); + } + ejectBallotToRear(): Promise { + throw new Error('Method not implemented.'); + } + calibrate(): Promise { + throw new Error('Method not implemented.'); + } + enablePrint(): Promise { + throw new Error('Method not implemented.'); + } + disablePrint(): Promise { + throw new Error('Method not implemented.'); + } + setMotionUnits(_x: number, _y: number): Promise { + throw new Error('Method not implemented.'); + } + setLeftMargin(_numMotionUnits: number): Promise { + throw new Error('Method not implemented.'); + } + setPrintingAreaWidth(_numMotionUnits: number): Promise { + throw new Error('Method not implemented.'); + } + setLineSpacing(numMotionUnits: number): Promise { + debug('setLineSpacing called with numMotionUnits: %d', numMotionUnits); + return Promise.resolve(makeUsbOutTransferResult('ok', 0)); + } + setPrintingSpeed( + printingSpeed: PrintingSpeed + ): Promise { + debug('setPrintingSpeed called with printingSpeed: %s', printingSpeed); + return Promise.resolve(makeUsbOutTransferResult('ok', 0)); + } + setPrintingDensity( + _printingDensity: PrintingDensity + ): Promise { + throw new Error('Method not implemented.'); + } + setAbsolutePrintPosition( + _numMotionUnits: number + ): Promise { + throw new Error('Method not implemented.'); + } + setRelativePrintPosition( + _numMotionUnits: number + ): Promise { + throw new Error('Method not implemented.'); + } + setRelativeVerticalPrintPosition( + _numMotionUnits: number + ): Promise { + throw new Error('Method not implemented.'); + } + bufferChunk( + _chunkedCustomBitmap: PaperHandlerBitmap + ): Promise { + throw new Error('Method not implemented.'); + } + printChunk(_chunkedCustomBitmap: PaperHandlerBitmap): Promise { + throw new Error('Method not implemented.'); + } + print(_numMotionUnitsToFeedPaper?: number): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/libs/custom-paper-handler/src/driver/mock_minimal_web_usb_device.ts b/libs/custom-paper-handler/src/driver/mock_minimal_web_usb_device.ts new file mode 100644 index 0000000000..a2fa95ea26 --- /dev/null +++ b/libs/custom-paper-handler/src/driver/mock_minimal_web_usb_device.ts @@ -0,0 +1,24 @@ +import { MinimalWebUsbDevice } from './minimal_web_usb_device'; + +export function mockMinimalWebUsbDevice(): MinimalWebUsbDevice { + return { + open: () => { + return Promise.resolve(); + }, + close: () => { + return Promise.resolve(); + }, + transferOut: () => { + return Promise.resolve(new USBOutTransferResult('ok')); + }, + transferIn: () => { + return Promise.resolve(new USBInTransferResult('ok')); + }, + claimInterface: (): Promise => { + return Promise.resolve(); + }, + selectConfiguration(): Promise { + return Promise.resolve(); + }, + }; +} diff --git a/libs/custom-paper-handler/src/driver/test_utils.ts b/libs/custom-paper-handler/src/driver/test_utils.ts index 4922cf7a63..d53b2b6420 100644 --- a/libs/custom-paper-handler/src/driver/test_utils.ts +++ b/libs/custom-paper-handler/src/driver/test_utils.ts @@ -5,6 +5,7 @@ import { REAL_TIME_ENDPOINT_OUT, PACKET_SIZE, } from './driver'; +import { PaperHandlerStatus } from './coders'; type MockWebUsbDevice = mocks.MockWebUsbDevice; @@ -76,3 +77,47 @@ export function setUpMockWebUsbDevice( return { legacyDevice, mockWebUsbDevice }; } + +export function defaultPaperHandlerStatus(): PaperHandlerStatus { + return { + // Scanner status + requestId: 1, + returnCode: 1, + parkSensor: false, + paperOutSensor: false, + paperPostCisSensor: false, + paperPreCisSensor: false, + paperInputLeftInnerSensor: false, + paperInputRightInnerSensor: false, + paperInputLeftOuterSensor: false, + paperInputRightOuterSensor: false, + printHeadInPosition: false, + scanTimeout: false, + motorMove: false, + scanInProgress: false, + jamEncoder: false, + paperJam: false, + coverOpen: false, + optoSensor: false, + ballotBoxDoorSensor: false, + ballotBoxAttachSensor: false, + preHeadSensor: false, + + // Printer status + ticketPresentInOutput: false, + paperNotPresent: true, + dragPaperMotorOn: false, + spooling: false, + printingHeadUpError: false, + notAcknowledgeCommandError: false, + powerSupplyVoltageError: false, + headNotConnected: false, + comError: false, + headTemperatureError: false, + diverterError: false, + headErrorLocked: false, + printingHeadReadyToPrint: true, + eepromError: false, + ramError: false, + }; +} diff --git a/libs/ui/src/icons.tsx b/libs/ui/src/icons.tsx index 0c98402ab1..aed8530f74 100644 --- a/libs/ui/src/icons.tsx +++ b/libs/ui/src/icons.tsx @@ -31,6 +31,7 @@ import { faCaretDown, faCirclePlus, faRotateRight, + faCircleQuestion, } from '@fortawesome/free-solid-svg-icons'; import { faXmarkCircle, @@ -169,6 +170,10 @@ export const Icons = { return ; }, + Question(): JSX.Element { + return ; + }, + RightChevron(): JSX.Element { return ; }, diff --git a/libs/utils/src/env.d.ts b/libs/utils/src/env.d.ts index a51a58ebd9..45b1b8de68 100644 --- a/libs/utils/src/env.d.ts +++ b/libs/utils/src/env.d.ts @@ -21,5 +21,7 @@ declare namespace NodeJS { REACT_APP_VX_DISABLE_BALLOT_BOX_CHECK?: string; REACT_APP_VX_CONVERTER?: string; REACT_APP_VX_PRECINCT_REPORT_DESTINATION?: string; + REACT_APP_VX_DISABLE_BALLOT_BOX_CHECK?: string; + REACT_APP_VX_SKIP_PAPER_HANDLER_HARDWARE_CHECK?: string; } } diff --git a/libs/utils/src/environment_variable.ts b/libs/utils/src/environment_variable.ts index 9c4db7d204..d1a0d45d7a 100644 --- a/libs/utils/src/environment_variable.ts +++ b/libs/utils/src/environment_variable.ts @@ -45,6 +45,8 @@ export enum BooleanEnvironmentVariableName { // Disables the ballot box check on VxMarkScan. If false, the app will block until the ballot // box is attached DISABLE_BALLOT_BOX_CHECK = 'REACT_APP_VX_DISABLE_BALLOT_BOX_CHECK', + // Allows VxMarkScan to run without a connection to the Custom paper handler + SKIP_PAPER_HANDLER_HARDWARE_CHECK = 'REACT_APP_VX_SKIP_PAPER_HANDLER_HARDWARE_CHECK', } // This is not fully generic since string variables may want the getter to return a custom type. @@ -105,12 +107,14 @@ export function getEnvironmentVariable( case BooleanEnvironmentVariableName.CAST_VOTE_RECORD_OPTIMIZATION_EXCLUDE_REDUNDANT_METADATA: return process.env .REACT_APP_VX_CAST_VOTE_RECORD_OPTIMIZATION_EXCLUDE_REDUNDANT_METADATA; - case BooleanEnvironmentVariableName.DISABLE_BALLOT_BOX_CHECK: - return process.env.REACT_APP_VX_DISABLE_BALLOT_BOX_CHECK; case StringEnvironmentVariableName.CONVERTER: return process.env.REACT_APP_VX_CONVERTER; case StringEnvironmentVariableName.PRECINCT_REPORT_DESTINATION: return process.env.REACT_APP_VX_PRECINCT_REPORT_DESTINATION; + case BooleanEnvironmentVariableName.DISABLE_BALLOT_BOX_CHECK: + return process.env.REACT_APP_VX_DISABLE_BALLOT_BOX_CHECK; + case BooleanEnvironmentVariableName.SKIP_PAPER_HANDLER_HARDWARE_CHECK: + return process.env.REACT_APP_VX_SKIP_PAPER_HANDLER_HARDWARE_CHECK; /* c8 ignore next 2 */ default: throwIllegalValue(name); @@ -217,6 +221,12 @@ export function getBooleanEnvVarConfig( allowInProduction: false, autoEnableInDevelopment: true, }; + case BooleanEnvironmentVariableName.SKIP_PAPER_HANDLER_HARDWARE_CHECK: + return { + name, + allowInProduction: false, + autoEnableInDevelopment: false, + }; /* c8 ignore next 2 */ default: throwIllegalValue(name); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32dbe596ef..cc9025de06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1550,6 +1550,9 @@ importers: '@votingworks/logging': specifier: workspace:* version: link:../../../libs/logging + '@votingworks/message-coder': + specifier: workspace:* + version: link:../../../libs/message-coder '@votingworks/types': specifier: workspace:* version: link:../../../libs/types @@ -1583,6 +1586,9 @@ importers: luxon: specifier: ^3.0.0 version: 3.3.0 + node-hid: + specifier: ^2.1.2 + version: 2.1.2 rxjs: specifier: ^7.8.1 version: 7.8.1 @@ -1620,6 +1626,9 @@ importers: '@types/node': specifier: 16.18.23 version: 16.18.23 + '@types/node-hid': + specifier: ^1.3.2 + version: 1.3.2 '@types/tmp': specifier: ^0.2.3 version: 0.2.3 @@ -11598,6 +11607,12 @@ packages: form-data: 3.0.1 dev: true + /@types/node-hid@1.3.2: + resolution: {integrity: sha512-x+otxZ/xgoEibM4QLuV0orHYcIc7aqXC/MJz6EaqsN0f5NL2UJN0XV8p0cPduXJeYSw7X2SVp/LuMD84IV+I7w==} + dependencies: + '@types/node': 16.18.23 + dev: true + /@types/node@16.18.23: resolution: {integrity: sha512-XAMpaw1s1+6zM+jn2tmw8MyaRDIJfXxqmIQIS0HfoGYPuf7dUWeiUKopwq13KFX9lEp1+THGtlaaYx39Nxr58g==} @@ -20560,6 +20575,10 @@ packages: resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==} dev: false + /node-addon-api@3.2.1: + resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} + dev: false + /node-addon-api@5.1.0: resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} dev: false @@ -20595,6 +20614,17 @@ packages: hasBin: true dev: false + /node-hid@2.1.2: + resolution: {integrity: sha512-qhCyQqrPpP93F/6Wc/xUR7L8mAJW0Z6R7HMQV8jCHHksAxNDe/4z4Un/H9CpLOT+5K39OPyt9tIQlavxWES3lg==} + engines: {node: '>=10'} + hasBin: true + requiresBuild: true + dependencies: + bindings: 1.5.0 + node-addon-api: 3.2.1 + prebuild-install: 7.1.1 + dev: false + /node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} @@ -21781,6 +21811,25 @@ packages: tunnel-agent: 0.6.0 dev: false + /prebuild-install@7.1.1: + resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} + engines: {node: '>=10'} + hasBin: true + dependencies: + detect-libc: 2.0.1 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 1.0.2 + node-abi: 3.15.0 + pump: 3.0.0 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.1 + tunnel-agent: 0.6.0 + dev: false + /prelude-ls@1.1.2: resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} engines: {node: '>= 0.8.0'}