diff --git a/Cargo.lock b/Cargo.lock index ec31d7d90a..9299d5c6d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,10 +259,14 @@ name = "controllerd" version = "0.1.0" dependencies = [ "crc16", + "ctrlc", "num_enum", + "serde", + "serde_json", "serialport", "thiserror", "uinput", + "vx-logging", ] [[package]] @@ -333,6 +337,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "ctrlc" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b467862cc8610ca6fc9a1532d7777cee0804e678ab45410897b9396495994a0b" +dependencies = [ + "nix 0.27.1", + "windows-sys 0.52.0", +] + [[package]] name = "custom_derive" version = "0.1.7" @@ -901,6 +915,17 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.4.1", + "cfg-if 1.0.0", + "libc", +] + [[package]] name = "num" version = "0.4.1" @@ -1020,6 +1045,15 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "patinputd" +version = "0.1.0" +dependencies = [ + "ctrlc", + "uinput", + "vx-logging", +] + [[package]] name = "pkg-config" version = "0.3.27" diff --git a/Cargo.toml b/Cargo.toml index e575828021..a4402ee31b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "libs/logging", "libs/types-rs", "apps/mark-scan/accessible-controller", + "apps/mark-scan/pat-device-input", ] [workspace.dependencies] @@ -12,6 +13,7 @@ base64 = "0.21.4" bitter = "0.6.1" clap = { version = "4.0.29", features = ["cargo"] } crc16 = "0.4.0" +ctrlc = "3.4.2" hex = "0.4.3" image = "0.24.5" imageproc = "0.23.0" diff --git a/apps/mark-scan/accessible-controller/Cargo.toml b/apps/mark-scan/accessible-controller/Cargo.toml index 0f6cf2c98f..e5e076ee21 100644 --- a/apps/mark-scan/accessible-controller/Cargo.toml +++ b/apps/mark-scan/accessible-controller/Cargo.toml @@ -9,7 +9,11 @@ path = "src/controllerd.rs" [dependencies] crc16 = { workspace = true } +ctrlc = { workspace = true } num_enum = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } serialport = { workspace = true } thiserror = { workspace = true } +vx-logging = { workspace = true } uinput = { workspace = true } diff --git a/apps/mark-scan/accessible-controller/Makefile b/apps/mark-scan/accessible-controller/Makefile new file mode 100644 index 0000000000..e170812a73 --- /dev/null +++ b/apps/mark-scan/accessible-controller/Makefile @@ -0,0 +1,9 @@ +# a phony dependency that can be used as a dependency to force builds +FORCE: + +build: FORCE + mkdir -p target && cargo build --release --target-dir target/. + +run: + ./target/release/controllerd + diff --git a/apps/mark-scan/accessible-controller/README.md b/apps/mark-scan/accessible-controller/README.md index c1809e0713..cc23ca2054 100644 --- a/apps/mark-scan/accessible-controller/README.md +++ b/apps/mark-scan/accessible-controller/README.md @@ -16,14 +16,14 @@ To build: ``` cd apps/mark-scan/accessible-controller -cargo build +make build ``` To run: ``` -// From vxsuite root -sudo ./target/debug/controllerd +// From apps/mark-scan/accessible-controller +sudo ./target/release/controllerd ``` To test: diff --git a/apps/mark-scan/accessible-controller/src/controllerd.rs b/apps/mark-scan/accessible-controller/src/controllerd.rs index cf1e388c35..33a6d09f64 100644 --- a/apps/mark-scan/accessible-controller/src/controllerd.rs +++ b/apps/mark-scan/accessible-controller/src/controllerd.rs @@ -1,15 +1,31 @@ +//! Daemon whose purpose is to expose signal from VSAP's accessible controller +//! to the mark-scan application. +//! +//! Signal from the accessible controller is available in userspace over +//! the serialport protocol. The daemon connects to the controller and polls +//! for change in signal value. When a button press is detected, it sends +//! a keypress event for consumption by the mark-scan application. + use serialport::{self, SerialPort}; use std::{ - io, thread, + io, + process::exit, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, time::{Duration, Instant}, }; use uinput::{ event::{keyboard, Keyboard}, Device, }; +use vx_logging::{log, set_app_name, Disposition, EventId, EventType}; -const POLL_INTERVAL: Duration = Duration::from_millis(10); -const MAX_ECHO_RESPONSE_WAIT: Duration = Duration::from_secs(1); +const APP_NAME: &str = "vx-mark-scan-controller-daemon"; +const POLL_INTERVAL: Duration = Duration::from_millis(50); +const MAX_ECHO_RESPONSE_WAIT: Duration = Duration::from_secs(5); const UINPUT_PATH: &str = "/dev/uinput"; const DEVICE_PATH: &str = "/dev/ttyACM1"; const DEVICE_BAUD_RATE: u32 = 9600; @@ -200,11 +216,11 @@ enum Action { fn send_key(device: &mut Device, key: keyboard::Key) -> Result<(), CommandError> { device.click(&key)?; - device.synchronize().unwrap(); + device.synchronize()?; Ok(()) } -fn handle_command(device: &mut Device, data: &[u8]) -> Result<(), CommandError> { +fn handle_command(data: &[u8]) -> Result, CommandError> { let ButtonStatusCommand { button, action } = data.try_into()?; let key: keyboard::Key; @@ -244,18 +260,17 @@ fn handle_command(device: &mut Device, data: &[u8]) -> Result<(), CommandError> }, Action::Released => { // Button release is a no-op since we already sent the keypress event - return Ok(()); + return Ok(None); } } - - send_key(device, key) + Ok(Some(key)) } fn validate_connection(port: &mut Box) -> Result<(), io::Error> { let echo_command = EchoCommand::new(vec![0x01, 0x02, 0x03, 0x04, 0x05]); let echo_command: Vec = echo_command.into(); match port.write(&echo_command) { - Ok(_) => println!("Echo command sent"), + Ok(_) => log!(EventId::ControllerHandshakeInit; EventType::SystemAction), Err(error) => eprintln!("{error:?}"), } @@ -271,11 +286,22 @@ fn validate_connection(port: &mut Box) -> Result<(), io::Error> "Received different response from echo command: {echo_response:x?}" ); - println!("Received valid echo command response"); + log!( + event_id: EventId::ControllerHandshakeComplete, + event_type: EventType::SystemAction, + disposition: Disposition::Success + ); return Ok(()); } Err(ref e) if e.kind() == io::ErrorKind::TimedOut => (), - Err(e) => eprintln!("Error reading echo response: {e:?}"), + Err(e) => { + log!( + event_id: EventId::ControllerHandshakeComplete, + message: format!("Error reading echo response: {e:?}"), + event_type: EventType::SystemAction, + disposition: Disposition::Failure + ); + } } if start_time.elapsed() >= MAX_ECHO_RESPONSE_WAIT { @@ -285,6 +311,12 @@ fn validate_connection(port: &mut Box) -> Result<(), io::Error> thread::sleep(POLL_INTERVAL); } + log!( + event_id: EventId::ControllerHandshakeComplete, + message: "No echo response received".to_string(), + event_type: EventType::SystemAction, + disposition: Disposition::Failure + ); Err(io::Error::new( io::ErrorKind::TimedOut, "No echo response received", @@ -303,42 +335,107 @@ fn create_virtual_device() -> Device { } fn main() { - // Open the serial port - let port = serialport::new(DEVICE_PATH, DEVICE_BAUD_RATE) - .timeout(POLL_INTERVAL) - .open(); - - println!("Opened controller serial port at {DEVICE_PATH}"); + set_app_name(APP_NAME); + log!( + EventId::ProcessStarted; + EventType::SystemAction + ); + + let running = Arc::new(AtomicBool::new(true)); + + if let Err(e) = ctrlc::set_handler({ + let running = running.clone(); + move || { + running.store(false, Ordering::SeqCst); + } + }) { + log!( + event_id: EventId::ErrorSettingSigintHandler, + message: e.to_string(), + event_type: EventType::SystemStatus + ); + } // Create virtual device for keypress events + log!( + EventId::CreateVirtualUinputDeviceInit; + EventType::SystemAction + ); let mut device = create_virtual_device(); - println!("Created virtual device"); - // Wait for virtual device to register thread::sleep(Duration::from_secs(1)); + log!( + event_id: EventId::CreateVirtualUinputDeviceComplete, + disposition: Disposition::Success, + event_type: EventType::SystemAction + ); + + log!( + EventId::ControllerConnectionInit; + EventType::SystemAction + ); + + // Open the serial port + let port = serialport::new(DEVICE_PATH, DEVICE_BAUD_RATE) + .timeout(POLL_INTERVAL) + .open(); match port { Ok(mut port) => { validate_connection(&mut port).unwrap(); - println!("Receiving data on {DEVICE_PATH} at {DEVICE_BAUD_RATE} baud"); + log!( + event_id: EventId::ControllerConnectionComplete, + message: format!("Receiving data on {DEVICE_PATH} at {DEVICE_BAUD_RATE} baud"), + event_type: EventType::SystemAction, + disposition: Disposition::Success + ); let mut serial_buf: Vec = vec![0; 1000]; loop { + if !running.load(Ordering::SeqCst) { + log!( + EventId::ProcessTerminated; + EventType::SystemAction + ); + exit(0); + } match port.read(serial_buf.as_mut_slice()) { - Ok(size) => { - if let Err(e) = handle_command(&mut device, &serial_buf[..size]) { - eprintln!("Unexpected error handling command: {e}"); + Ok(size) => match handle_command(&serial_buf[..size]) { + Ok(Some(key)) => { + if let Err(err) = send_key(&mut device, key) { + log!(EventId::UnknownError, "Error sending key: {err}"); + } } - } + Ok(None) => {} + Err(err) => log!( + event_id: EventId::UnknownError, + message: format!( + "Unexpected error when handling controller command: {err}" + ), + event_type: EventType::SystemStatus + ), + }, // Timeout error just means no event was sent in the current polling period Err(ref e) if e.kind() == io::ErrorKind::TimedOut => (), - Err(e) => eprintln!("{e:?}"), + Err(e) => { + log!( + event_id: EventId::UnknownError, + message: format!("Unexpected error when opening serial port: {e}"), + event_type: EventType::SystemStatus + ); + } } } } Err(e) => { - panic!(r#"Failed to open "{DEVICE_PATH}". Error: {e}"#); + log!( + event_id: EventId::ControllerConnectionComplete, + message: format!("Failed to open {DEVICE_PATH}. Error: {e}"), + event_type: EventType::SystemAction, + disposition: Disposition::Failure + ); + exit(1); } } } @@ -347,13 +444,10 @@ fn main() { mod tests { use super::*; - const DEVICE_WAIT_DURATION: Duration = Duration::from_millis(100); - #[test] fn test_handle_command_packet_length_error() { - let mut device = create_virtual_device(); let bad_data = [0x01]; - match handle_command(&mut device, &bad_data) { + match handle_command(&bad_data) { Err(CommandError::UnexpectedPacketSize(size)) => assert_eq!(size, 1), result => panic!("Unexpected result: {result:?}"), } @@ -362,9 +456,8 @@ mod tests { #[test] fn test_handle_command_data_length() { let bad_data_length: u8 = 0x03; - let mut device = create_virtual_device(); let bad_data = [0x30, 0x00, bad_data_length, 0x00, 0x00, 0x00, 0x00]; - match handle_command(&mut device, &bad_data) { + match handle_command(&bad_data) { Err(CommandError::UnexpectedDataLength(length)) => { assert_eq!(length, bad_data_length as u16) } @@ -374,11 +467,6 @@ mod tests { #[test] fn test_handle_command_success() { - let mut device = create_virtual_device(); - // In prod we wait 1s for the device to register. - // We can afford to be riskier to speed up tests. - thread::sleep(DEVICE_WAIT_DURATION); - let data = [ 0x30, 0x00, @@ -388,6 +476,6 @@ mod tests { 0xc8, 0x37, ]; - handle_command(&mut device, &data).unwrap(); + assert_eq!(handle_command(&data).unwrap().unwrap(), keyboard::Key::R); } } diff --git a/apps/mark-scan/backend/build-patinputd.sh b/apps/mark-scan/backend/build-patinputd.sh deleted file mode 100755 index a17f70cfb3..0000000000 --- a/apps/mark-scan/backend/build-patinputd.sh +++ /dev/null @@ -1 +0,0 @@ -gcc src/pat-input/vsapgpio.c src/pat-input/patinputd.c -o build/patinputd diff --git a/apps/mark-scan/backend/package.json b/apps/mark-scan/backend/package.json index 66e24cd8e1..ca6d299672 100644 --- a/apps/mark-scan/backend/package.json +++ b/apps/mark-scan/backend/package.json @@ -8,7 +8,7 @@ "build" ], "scripts": { - "build": "tsc --build tsconfig.build.json && copyfiles -u 3 src/custom-paper-handler/fixtures/*.jpg build/custom-paper-handler/fixtures && ./build-patinputd.sh", + "build": "tsc --build tsconfig.build.json && copyfiles -u 3 src/custom-paper-handler/fixtures/*.jpg build/custom-paper-handler/fixtures", "clean": "rm -rf build tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo", "format": "prettier '**/*.+(css|graphql|json|less|md|mdx|sass|scss|yaml|yml)' --write", "lint": "pnpm type-check && eslint .", diff --git a/apps/mark-scan/backend/src/app.ts b/apps/mark-scan/backend/src/app.ts index e08e00ca49..01e82d2009 100644 --- a/apps/mark-scan/backend/src/app.ts +++ b/apps/mark-scan/backend/src/app.ts @@ -264,9 +264,11 @@ export function buildApi( stateMachine.invalidateBallot(); }, - confirmInvalidateBallot(): void { + async confirmInvalidateBallot(): Promise { assert(stateMachine); + await logger.log(LogEventId.BallotInvalidated, 'poll_worker'); + stateMachine.confirmInvalidateBallot(); }, 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 d941060b86..283574deed 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 @@ -358,7 +358,8 @@ function loadMetadataAndInterpretBallot( export function buildMachine( initialContext: Context, - auth: InsertedSmartCardAuthApi + auth: InsertedSmartCardAuthApi, + logger: Logger ): StateMachine< Context, StateSchema, @@ -531,7 +532,10 @@ export function buildMachine( // 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(), + entry: async (context) => { + await logger.log(LogEventId.BlankInterpretation, 'system'); + await context.driver.presentPaper(); + }, on: { NO_PAPER_ANYWHERE: 'accepting_paper' }, }, accepting_paper: { @@ -802,7 +806,7 @@ export async function getPaperHandlerStateMachine({ authPollingIntervalMs, }; - const machine = buildMachine(initialContext, auth); + const machine = buildMachine(initialContext, auth, logger); const machineService = interpret(machine).start(); setUpLogging(machineService, logger); await setDefaults(driver); @@ -909,12 +913,15 @@ export async function getPaperHandlerStateMachine({ machineService.send({ type: 'VOTER_VALIDATED_BALLOT', }); + void logger.log(LogEventId.VoteCast, 'cardless_voter'); }, invalidateBallot(): void { machineService.send({ type: 'VOTER_INVALIDATED_BALLOT', }); + + void logger.log(LogEventId.BallotInvalidated, 'cardless_voter'); }, setPatDeviceIsCalibrated(): void { diff --git a/apps/mark-scan/backend/src/pat-input/patinputd.c b/apps/mark-scan/backend/src/pat-input/patinputd.c deleted file mode 100644 index 83e3cf6c6f..0000000000 --- a/apps/mark-scan/backend/src/pat-input/patinputd.c +++ /dev/null @@ -1,163 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "vsapgpio.h" - -bool should_exit_cleanly = false; -char *pat_is_connected_gpio_number = "478"; -char *pat_a_signal_gpio_number = "481"; -char *pat_b_signal_gpio_number = "476"; - -const long INTERVAL_MS = 100; -struct timespec interval_timespec = { - 0, - (INTERVAL_MS % 1000) * 1000000, -}; -struct timespec rem; - -void emit(int fd, int type, int code, int val) -{ - struct input_event ie; - - ie.type = type; - ie.code = code; - ie.value = val; - /* timestamp values below are ignored */ - ie.time.tv_sec = 0; - ie.time.tv_usec = 0; - - write(fd, &ie, sizeof(ie)); -} - -void interrupt(int signal) -{ - printf("Got exit signal %d\n", signal); - should_exit_cleanly = true; -} - -int main(void) -{ - struct uinput_setup usetup; - - int uinput_fd = open("/dev/uinput", O_WRONLY | O_NONBLOCK); - - /* - * The ioctls below will enable the device that is about to be - * created, to pass key events. - */ - ioctl(uinput_fd, UI_SET_EVBIT, EV_KEY); - ioctl(uinput_fd, UI_SET_KEYBIT, KEY_1); - ioctl(uinput_fd, UI_SET_KEYBIT, KEY_2); - - memset(&usetup, 0, sizeof(usetup)); - usetup.id.bustype = BUS_USB; - // Vendor and product ID are required but their values are never read, - // so we use dummy values. - usetup.id.vendor = 0x1234; - usetup.id.product = 0x5678; - strcpy(usetup.name, "PAT Input daemon virtual device"); - - ioctl(uinput_fd, UI_DEV_SETUP, &usetup); - ioctl(uinput_fd, UI_DEV_CREATE); - - export_pin(pat_is_connected_gpio_number); - export_pin(pat_a_signal_gpio_number); - export_pin(pat_b_signal_gpio_number); - - set_pin_direction_in(pat_is_connected_gpio_number); - set_pin_direction_in(pat_a_signal_gpio_number); - set_pin_direction_in(pat_b_signal_gpio_number); - - int is_connected_value_fd = get_pin_value_fd(pat_is_connected_gpio_number); - bool is_connected = get_bool_pin_value(is_connected_value_fd); - - printf("Initial PAT device is connected: %s\n", is_connected ? "true" : "false"); - - int a_signal_fd = get_pin_value_fd(pat_a_signal_gpio_number); - bool a_signal = get_bool_pin_value(a_signal_fd); - printf("Initial A value: %s\n", a_signal ? "true" : "false"); - - int b_signal_fd = get_pin_value_fd(pat_b_signal_gpio_number); - bool b_signal = get_bool_pin_value(b_signal_fd); - printf("Initial B value: %s\n", b_signal ? "true" : "false"); - - signal(SIGINT, interrupt); - - while (!should_exit_cleanly) - { - // Need to get file descriptor each time we read or the value will be stale - a_signal_fd = get_pin_value_fd(pat_a_signal_gpio_number); - bool new_a_signal = get_bool_pin_value(a_signal_fd); - - b_signal_fd = get_pin_value_fd(pat_b_signal_gpio_number); - bool new_b_signal = get_bool_pin_value(b_signal_fd); - - // Only emit keyboard event when signal changes - if (new_a_signal && !a_signal) - { - printf("'A' signal triggered\n"); - /* Key press, report the event, send key release, and report again */ - emit(uinput_fd, EV_KEY, KEY_1, 1); - emit(uinput_fd, EV_SYN, SYN_REPORT, 0); - emit(uinput_fd, EV_KEY, KEY_1, 0); - emit(uinput_fd, EV_SYN, SYN_REPORT, 0); - } - - if (new_b_signal && !b_signal) - { - printf("'B' signal triggered\n"); - /* Key press, report the event, send key release, and report again */ - emit(uinput_fd, EV_KEY, KEY_2, 1); - emit(uinput_fd, EV_SYN, SYN_REPORT, 0); - emit(uinput_fd, EV_KEY, KEY_2, 0); - emit(uinput_fd, EV_SYN, SYN_REPORT, 0); - } - - a_signal = new_a_signal; - b_signal = new_b_signal; - - if (close(a_signal_fd) != 0) - { - perror("Failed to close A signal file descriptor\n"); - } - - if (close(b_signal_fd) != 0) - { - perror("Failed to close B signal file descriptor\n"); - } - - // Sleep accepts integer seconds only and we want to poll more frequently - nanosleep(&interval_timespec, &rem); - } - - printf("Unexporting pins\n"); - unexport_pin(pat_is_connected_gpio_number); - unexport_pin(pat_a_signal_gpio_number); - unexport_pin(pat_b_signal_gpio_number); - - printf("Closing GPIO sysfs file descriptors\n"); - close(a_signal); - close(b_signal); - close(is_connected_value_fd); - - /* - * Events are unlikely to have been sent recently, but we still - * give userspace some time to read the events before we clean up by - * closing GPIO connections and destroying the virtual device with - * UI_DEV_DESTOY. - */ - sleep(1); - - printf("Cleaning up virtual device\n"); - ioctl(uinput_fd, UI_DEV_DESTROY); - close(uinput_fd); - - return 0; -} diff --git a/apps/mark-scan/backend/src/pat-input/vsapgpio.c b/apps/mark-scan/backend/src/pat-input/vsapgpio.c deleted file mode 100644 index 036d1effe2..0000000000 --- a/apps/mark-scan/backend/src/pat-input/vsapgpio.c +++ /dev/null @@ -1,117 +0,0 @@ -#include -#include -#include -#include -#include -#include - -const int MAX_PIN_NUMBER_DIGITS = 3; - -int ascii_to_int(int ascii_char) -{ - return ascii_char - '0'; -} - -void unexport_pin(char *pin) -{ - // Unexport the pin by writing to /sys/class/gpio/unexport - int fd = open("/sys/class/gpio/unexport", O_WRONLY); - if (fd == -1) - { - perror("Unable to open /sys/class/gpio/unexport"); - exit(1); - } - - if (write(fd, pin, MAX_PIN_NUMBER_DIGITS) != MAX_PIN_NUMBER_DIGITS) - { - perror("Error writing to /sys/class/gpio/unexport"); - exit(1); - } - - close(fd); -} - -void export_pin(char *pin) -{ - // Export the desired pin by writing to /sys/class/gpio/export - int fd = open("/sys/class/gpio/export", O_WRONLY); - if (fd == -1) - { - perror("Unable to open /sys/class/gpio/export"); - exit(1); - } - - if (write(fd, pin, MAX_PIN_NUMBER_DIGITS) != MAX_PIN_NUMBER_DIGITS) - { - perror("Error writing to /sys/class/gpio/export"); - exit(1); - } - - close(fd); -} - -void set_pin_direction_in(char *pin) -{ - char path[strlen("/sys/class/gpio/gpio/direction") + MAX_PIN_NUMBER_DIGITS]; - sprintf(path, "/sys/class/gpio/gpio%s/direction", pin); - printf("Attempting to open fd to path: %s\n", path); - int fd = open(path, O_WRONLY); - if (fd == -1) - { - perror("Unable to open pin direction fd"); - exit(1); - } - - if (write(fd, "in", 3) != 3) - { - perror("Error writing to pin direction file"); - exit(1); - } - - close(fd); -} - -// Gets a file descriptor for a pin's value file at /sys/class/gpio/gpio/value -int get_pin_value_fd(char *pin) -{ - char value_path[strlen("/sys/class/gpio/gpio/value") + MAX_PIN_NUMBER_DIGITS]; - sprintf(value_path, - "/sys/class/gpio/gpio%s/value", - pin); - int fd = open(value_path, O_RDONLY); - if (fd == -1) - { - perror("Unable to open value pin"); - exit(1); - } - - return fd; -} - -// Reads and return the value of a GPIO pin given a file descriptor -// from get_pin_value_fd. Assumes the pin has already been exported. -int read_pin_value_from_fd(int fd) -{ - // Pin value should be exactly 1 ASCII char == 1 byte - char pin_value_ascii; - read(fd, &pin_value_ascii, 1); - - return ascii_to_int(pin_value_ascii); -} - -// Convenience function that returns value of a pin that follows typical boolean conventions. -// The pin that is read is specified by the given file descriptor. -bool get_bool_pin_value(int fd) -{ - // 1 is the default state, 0 is actioned state - // connection status: 1 when PAT device is not plugged in, 0 when plugged in - // A/B signal: 1 when no signal is sent from device, 0 when signal is sent - int value = read_pin_value_from_fd(fd); - - if (value == 0) - { - return true; - } - - return false; -} diff --git a/apps/mark-scan/backend/src/pat-input/vsapgpio.h b/apps/mark-scan/backend/src/pat-input/vsapgpio.h deleted file mode 100644 index cfd518fbf3..0000000000 --- a/apps/mark-scan/backend/src/pat-input/vsapgpio.h +++ /dev/null @@ -1,7 +0,0 @@ -int ascii_to_int(int ascii_char); -void unexport_pin(char *pin); -void export_pin(char *pin); -void set_pin_direction_in(char *pin); -int get_pin_value_fd(char *pin); -int read_pin_value_from_fd(int fd); -bool get_bool_pin_value(int fd); diff --git a/apps/mark-scan/backend/src/server.ts b/apps/mark-scan/backend/src/server.ts index 8d66ab858a..5dd9bb36f8 100644 --- a/apps/mark-scan/backend/src/server.ts +++ b/apps/mark-scan/backend/src/server.ts @@ -38,19 +38,28 @@ export interface StartOptions { stateMachine?: PaperHandlerStateMachine; } -async function resolveDriver(): Promise< - PaperHandlerDriverInterface | undefined -> { +async function resolveDriver( + logger: Logger +): Promise { 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(); + if (!driver) { + await logger.log(LogEventId.PaperHandlerConnection, 'system', { + disposition: 'failure', + }); + + if ( + isFeatureFlagEnabled( + BooleanEnvironmentVariableName.SKIP_PAPER_HANDLER_HARDWARE_CHECK + ) + ) { + debug('No paper handler found. Starting server with mock driver'); + return new MockPaperHandlerDriver(); + } + } else { + await logger.log(LogEventId.PaperHandlerConnection, 'system', { + disposition: 'success', + }); } return driver; @@ -67,7 +76,7 @@ export async function start({ }: StartOptions): Promise { /* istanbul ignore next */ const resolvedAuth = auth ?? getDefaultAuth(logger); - const driver = await resolveDriver(); + const driver = await resolveDriver(logger); let patConnectionStatusReader = new PatConnectionStatusReader(logger); const canReadPatConnectionStatus = await patConnectionStatusReader.open(); diff --git a/apps/mark-scan/frontend/src/pages/remove_invalidated_ballot_page.test.tsx b/apps/mark-scan/frontend/src/pages/remove_invalidated_ballot_page.test.tsx new file mode 100644 index 0000000000..5a64ede860 --- /dev/null +++ b/apps/mark-scan/frontend/src/pages/remove_invalidated_ballot_page.test.tsx @@ -0,0 +1,28 @@ +import userEvent from '@testing-library/user-event'; +import { screen } from '../../test/react_testing_library'; +import { render } from '../../test/test_utils'; +import { ApiMock, createApiMock } from '../../test/helpers/mock_api_client'; +import { ApiProvider } from '../api_provider'; +import { RemoveInvalidatedBallotPage } from './remove_invalidated_ballot_page'; + +let apiMock: ApiMock; + +beforeEach(() => { + apiMock = createApiMock(); +}); + +afterEach(() => { + apiMock.mockApiClient.assertComplete(); +}); + +test('continue button', () => { + render( + + + + ); + + apiMock.expectConfirmInvalidateBallot(); + + userEvent.click(screen.getByText('Continue')); +}); diff --git a/apps/mark-scan/frontend/src/pages/remove_invalidated_ballot_page.tsx b/apps/mark-scan/frontend/src/pages/remove_invalidated_ballot_page.tsx index ac2b4b1c4d..d350928382 100644 --- a/apps/mark-scan/frontend/src/pages/remove_invalidated_ballot_page.tsx +++ b/apps/mark-scan/frontend/src/pages/remove_invalidated_ballot_page.tsx @@ -18,15 +18,14 @@ export function RemoveInvalidatedBallotPage(props: Props): JSX.Element { history.push('/ready-to-review'); }); - function onPressContinue() { - confirmInvalidateBallotMutation.mutate(undefined); - } - return ( + } diff --git a/apps/mark-scan/pat-device-input/Cargo.toml b/apps/mark-scan/pat-device-input/Cargo.toml new file mode 100644 index 0000000000..a76f8390b6 --- /dev/null +++ b/apps/mark-scan/pat-device-input/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "patinputd" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "patinputd" +path = "src/patinputd.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +ctrlc = { workspace = true } +vx-logging = { workspace = true } +uinput = { workspace = true } diff --git a/apps/mark-scan/pat-device-input/Makefile b/apps/mark-scan/pat-device-input/Makefile new file mode 100644 index 0000000000..539c12cf3f --- /dev/null +++ b/apps/mark-scan/pat-device-input/Makefile @@ -0,0 +1,12 @@ +# a phony dependency that can be used as a dependency to force builds +FORCE: + +build-debug: FORCE + mkdir -p target && cargo build --target-dir target/. + +build: FORCE + mkdir -p target && cargo build --release --target-dir target/. + +run: + ./target/release/patinputd + diff --git a/apps/mark-scan/pat-device-input/src/patinputd.rs b/apps/mark-scan/pat-device-input/src/patinputd.rs new file mode 100644 index 0000000000..22f172ef61 --- /dev/null +++ b/apps/mark-scan/pat-device-input/src/patinputd.rs @@ -0,0 +1,149 @@ +//! Daemon whose purpose is to expose signal from VSAP's input for personal +//! assistive technology (PAT) devices to the mark-scan application. +//! +//! PAT input status is made accessible in userspace with GPIO pins. The daemon +//! connects to the pins then polls their values. When a change in value is read, +//! the daemon sends a keypress event for consumption by the application. +//! +//! Notably, running this daemon is required for the mark-scan app to read PAT +//! device connection status. + +use pin::Pin; +use std::{ + io, + process::exit, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread, + time::Duration, +}; +use uinput::{ + event::{keyboard, Keyboard}, + Device, +}; + +use vx_logging::{log, set_app_name, types::EventType, Disposition, EventId}; + +mod pin; + +const UINPUT_PATH: &str = "/dev/uinput"; +const POLL_INTERVAL: Duration = Duration::from_millis(50); +const APP_NAME: &str = "vx-mark-scan-pat-input-daemon"; +const IS_CONNECTED_PIN: Pin = Pin::new(478); +const SIGNAL_A_PIN: Pin = Pin::new(481); +const SIGNAL_B_PIN: Pin = Pin::new(476); + +fn send_key(device: &mut Device, key: keyboard::Key) -> Result<(), uinput::Error> { + device.click(&key)?; + device.synchronize()?; + Ok(()) +} + +fn create_virtual_device() -> Device { + uinput::open(UINPUT_PATH) + .unwrap() + .name("PAT Input Daemon Virtual Device") + .unwrap() + .event(Keyboard::All) + .unwrap() + .create() + .unwrap() +} + +fn set_up_pins() -> io::Result<()> { + IS_CONNECTED_PIN.set_up()?; + SIGNAL_A_PIN.set_up()?; + SIGNAL_B_PIN.set_up()?; + Ok(()) +} + +fn main() { + set_app_name(APP_NAME); + log!(EventId::ProcessStarted; EventType::SystemAction); + + let running = Arc::new(AtomicBool::new(true)); + + if let Err(e) = ctrlc::set_handler({ + let running = running.clone(); + move || { + running.store(false, Ordering::SeqCst); + } + }) { + log!( + event_id: EventId::ErrorSettingSigintHandler, + message: e.to_string(), + event_type: EventType::SystemStatus + ); + } + + // Create virtual device for keypress events + log!(EventId::CreateVirtualUinputDeviceInit; EventType::SystemAction); + let mut device = create_virtual_device(); + // Wait for virtual device to register + thread::sleep(Duration::from_secs(1)); + log!( + event_id: EventId::CreateVirtualUinputDeviceComplete, + disposition: Disposition::Success, + event_type: EventType::SystemAction + ); + + log!(EventId::ConnectToPatInputInit); + + // Export pins and set direction + if let Err(err) = set_up_pins() { + log!( + event_id: EventId::ConnectToPatInputComplete, + disposition: Disposition::Failure, + message: format!("An error occurred during GPIO pin connection: {err}") + ); + exit(1); + }; + + // is_connected is unused for keypresses in this daemon but it's important to export + // the pin so it can be read by the mark-scan backend + let is_connected = IS_CONNECTED_PIN.is_active(); + let mut signal_a = SIGNAL_A_PIN.is_active(); + let mut signal_b = SIGNAL_B_PIN.is_active(); + + log!( + EventId::ConnectToPatInputInit, + "Connected to PAT with initial values [is_connected={is_connected}], [signal_a={signal_a}], [signal_b={signal_b}]" + ); + + loop { + if !running.load(Ordering::SeqCst) { + log!(EventId::ProcessTerminated; EventType::SystemAction); + IS_CONNECTED_PIN.tear_down(); + SIGNAL_A_PIN.tear_down(); + SIGNAL_B_PIN.tear_down(); + exit(0); + } + + let new_signal_a = SIGNAL_A_PIN.is_active(); + let new_signal_b = SIGNAL_B_PIN.is_active(); + + if new_signal_a && !signal_a { + if let Err(err) = send_key(&mut device, keyboard::Key::_1) { + log!( + EventId::PatDeviceError, + "Error sending 1 keypress event: {err}" + ); + } + } + if new_signal_b && !signal_b { + if let Err(err) = send_key(&mut device, keyboard::Key::_2) { + log!( + EventId::PatDeviceError, + "Error sending 2 keypress event: {err}" + ); + } + } + + signal_a = new_signal_a; + signal_b = new_signal_b; + + thread::sleep(POLL_INTERVAL); + } +} diff --git a/apps/mark-scan/pat-device-input/src/pin.rs b/apps/mark-scan/pat-device-input/src/pin.rs new file mode 100644 index 0000000000..1fb855acfe --- /dev/null +++ b/apps/mark-scan/pat-device-input/src/pin.rs @@ -0,0 +1,120 @@ +use std::{ + fmt, + fs::{self, File}, + io::{self, Read}, +}; + +use vx_logging::{log, EventId}; + +const EXPORT_PIN_FILEPATH: &str = "/sys/class/gpio/export"; +const UNEXPORT_PIN_FILEPATH: &str = "/sys/class/gpio/unexport"; + +/// Pin provides an interface to read GPIO pins that support the sysfs protocol. +/// +/// # Example +/// +/// ``` +/// const PIN_NUMBER = 478; +/// const MY_PIN: Pin = Pin::new(PIN_NUMBER); +/// MY_PIN.set_up()?; +/// let is_active = MY_PIN.is_active(); +/// do_something_with_value(is_active); +/// ``` +pub struct Pin { + address: u16, +} + +impl Pin { + /// Creates a new instance of Pin at the given address (pin number). + pub const fn new(address: u16) -> Self { + Self { address } + } + + // Exports the pin to be globally accessible from userspace. + fn export(&self) -> io::Result<()> { + fs::write(EXPORT_PIN_FILEPATH, self.to_string()) + } + + // Unexports the pin, removing access from userspace. + fn unexport(&self) -> io::Result<()> { + fs::write(UNEXPORT_PIN_FILEPATH, self.to_string()) + } + + // Sets the pin direction to "in" so its value is readable. Must be called after + // `export` + fn set_direction_in(&self) -> io::Result<()> { + let filepath = format!("/sys/class/gpio/gpio{self}/direction"); + fs::write(filepath, b"in") + } + + /// Removes access to the pin from userspace. + pub fn tear_down(&self) { + self.unexport() + .expect("Unexpected failure to tear down pin"); + } + + /// Makes the pin accessible from userspace. If the pin is already set up due to + /// a previous `set_up` call, or from other callers of the sysfs interface, `set_up` + /// will tear down the pin and attempt one more time to set up. This may cause + /// interruption to other processes attempting to read the pin value. + pub fn set_up(&self) -> io::Result<()> { + if let Err(err) = self.export() { + log!( + EventId::Unspecified, + "Pin {self} export failed with err {err}. Attempting to unexport.", + ); + self.unexport()?; + log!(EventId::Unspecified, "Unexported pin {self} successfully"); + self.export()?; + } + + self.set_direction_in() + } + + /// Reads the pin value and returns the boolean inverse of the pin's raw value. + /// # Example + /// When the pin's value is b'0', `is_active` returns true. + pub fn is_active(&self) -> bool { + let filepath = format!("/sys/class/gpio/gpio{self}/value"); + let Ok(mut file) = File::open(filepath) else { + log!( + EventId::PatDeviceError, + "Failed to open file for pin {self}", + ); + return false; + }; + + let mut buf = [0; 1]; + if let Err(e) = file.read_exact(&mut buf) { + log!( + EventId::PatDeviceError, + "Failed to read file for pin {self}: {e}", + ); + return false; + } + + // Pin status does not follow boolean convention. We translate to typical + // boolean convention here. + // 1 is the default state, 0 is actioned state + // connection status: 1 when PAT device is not plugged in, 0 when plugged in + // A/B signal: 1 when no signal is sent from device, 0 when signal is sent + match buf[0] { + b'0' => true, + b'1' => false, + _ => { + log!( + EventId::PatDeviceError, + "Unexpected value for pin #{self}: {char}", + char = buf[0] as char, + ); + false + } + } + } +} + +impl fmt::Display for Pin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.address) + } +} diff --git a/libs/logging/types-rust/src/lib.rs b/libs/logging/types-rust/src/lib.rs index dd676492c7..f8d919c2dd 100644 --- a/libs/logging/types-rust/src/lib.rs +++ b/libs/logging/types-rust/src/lib.rs @@ -61,6 +61,13 @@ macro_rules! log { ..Default::default() }); }; + ($event_id:expr; $event_type:expr) => { + $crate::print_log(&$crate::Log { + event_id: $event_id, + event_type: $event_type, + ..Default::default() + }); + }; ($($arg:tt)*) => { $crate::print_log(&$crate::Log { $($arg)*, diff --git a/vxsuite.code-workspace b/vxsuite.code-workspace index b8bf3c2391..e9fd07d8d6 100644 --- a/vxsuite.code-workspace +++ b/vxsuite.code-workspace @@ -56,6 +56,14 @@ "name": "apps/mark-scan/integration-testing", "path": "apps/mark-scan/integration-testing" }, + { + "name": "apps/mark-scan/accessible-controller", + "path": "apps/mark-scan/accessible-controller" + }, + { + "name": "apps/mark-scan/pat-device-input", + "path": "apps/mark-scan/pat-device-input" + }, { "name": "apps/scan/backend", "path": "apps/scan/backend"