From e6f280a50bb57fe83e9580082e8f420fbb88e589 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Thu, 22 Jan 2026 16:08:47 -0500 Subject: [PATCH] Add comprehensive hardware driver catalog and porting strategy - Catalog all existing lamassu-machine drivers (7 validators, 5 dispensers, 3 printers, 4 scanners) - Add "Port, don't rewrite" principle - leverage battle-tested existing code - Update HAL structure to include all drivers to port - Replace NV200 example with ID003 (default validator in lamassu-machine) - Add Printer trait definition - Update napi-rs bindings with ValidatorDriver and DispenserDriver enums - Add configuration mapping for migrating from legacy lamassu-machine config - Update milestone checklist with prioritized driver porting tasks - Add driver complexity ranking to guide porting order Co-Authored-By: Claude Opus 4.5 --- docs/implementation-plan.md | 918 +++++++++++++++++++++++++++++++----- 1 file changed, 800 insertions(+), 118 deletions(-) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 2c60692..98e93e2 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -51,6 +51,54 @@ priority: critical > 2. **Vertical slices** - Each phase delivers working functionality > 3. **Test on real hardware early** - Dispenser essential, not optional > 4. **Ship incrementally** - Working cash-out ATM > feature-complete vaporware +> 5. **Port, don't rewrite** - Existing drivers are battle-tested + +--- + +## Hardware Driver Porting Strategy + +> [!important] Key Principle +> The existing `lamassu-machine/lib/` contains **working, battle-tested drivers** for all current Lamassu hardware. These drivers have been refined over years of production use. Our strategy is to **port these drivers to Rust** rather than rewrite from scratch. + +### Why Port Instead of Rewrite? + +| Approach | Pros | Cons | +|----------|------|------| +| **Port existing** | Known working, protocol bugs already fixed, edge cases handled | Need to understand JS code first | +| Rewrite from spec | "Clean" code | Protocol docs often incomplete, will hit same bugs again | + +### Porting Process + +``` +1. Read existing JavaScript driver thoroughly +2. Document the protocol (commands, responses, state machine) +3. Implement Rust version using existing code as specification +4. Test against same hardware +5. Verify identical behavior +``` + +### Driver Complexity Ranking + +Based on lamassu-machine source code analysis: + +| Driver | Lines | Complexity | Priority | +|--------|-------|------------|----------| +| `puloon` | ~600 | Low | **Critical** (cash-out) | +| `id003` | ~1,000 | Medium | **High** (default validator) | +| `ccnet` | ~800 | Medium | Medium | +| `f56` | ~1,200 | Medium-High | High (FSM-based) | +| `mei/cashflow_sc` | ~700 | Medium | Medium | +| `hcm2` | ~1,500 | **High** | Low (recycler) | +| `gsr50` | ~1,100 | **High** | Low (recycler) | + +> [!tip] Start with Puloon + ID003 +> These two drivers cover the most common hardware and have reasonable complexity. The Puloon dispenser is critical for the cash-out-dominant market. + +### Testing Strategy + +1. **Unit tests** - Test protocol encoding/decoding against known values from JS +2. **Integration tests** - Run against mock hardware (from `lib/mocks/`) +3. **Hardware tests** - Run against real hardware, compare behavior to JS driver --- @@ -941,7 +989,59 @@ pub fn run() { ## Phase 3: Hardware Abstraction Layer > [!goal] Deliverable -> Rust HAL with napi-rs bindings for bill validator and dispenser. +> Rust HAL with napi-rs bindings for ALL existing Lamassu hardware. + +> [!important] Porting Strategy +> The existing `lamassu-machine/lib/` contains **working, tested drivers** for all current Lamassu hardware. Our strategy is to **port these drivers to Rust** rather than rewrite from scratch. The existing JavaScript implementations serve as the specification. + +### 3.0 Existing Driver Catalog (lamassu-machine) + +The following drivers exist in the current lamassu-machine codebase and MUST be supported: + +#### Bill Validators + +| Driver | Protocol | Files | Notes | +|--------|----------|-------|-------| +| **id003** | JCM ID-003 | `lib/id003/*.js` | Default validator, FSM-based | +| **ccnet** | CashCode CCNET | `lib/ccnet/*.js` | Common in NA, has emulator | +| **cashflow_sc** | MEI CashFlow SC | `lib/mei/cashflow_sc.js` | MEI validator (11KB) | +| **bnr_advance** | MEI BNR Advance | `lib/mei/bnr_advance.js` | MEI recycler | +| **genmega** | Genmega proprietary | `lib/genmega/genmega-validator/` | Integrated with Genmega ATMs | +| **hcm2** | Hitachi HCM2 | `lib/hcm2/hcm2.js` | Recycler (23KB, complex) | +| **gsr50** | GSR50 | `lib/gsr50/gsr50.js` | Recycler (17KB) | + +#### Bill Dispensers + +| Driver | Protocol | Files | Notes | +|--------|----------|-------|-------| +| **puloon** | Puloon RS-232 | `lib/puloon/*.js` | LCDM series, common | +| **f56** | Fujitsu F53/F56 | `lib/f56/*.js` | Multi-cassette, FSM-based | +| **genmega** | Genmega proprietary | `lib/genmega/genmega-dispenser/` | Integrated dispensers | +| **hcm2** | Hitachi HCM2 | `lib/hcm2/hcm2.js` | Recycler dispense mode | +| **gsr50** | GSR50 | `lib/gsr50/gsr50.js` | Recycler dispense mode | + +#### Printers + +| Driver | Protocol | Files | Notes | +|--------|----------|-------|-------| +| **nippon** | ESC/POS variant | `lib/printer/nippon.js` | Nippon thermal (9KB) | +| **zebra** | ZPL (Zebra) | `lib/printer/zebra.js` | Label printing (13KB) | +| **genmega** | Genmega proprietary | `lib/printer/genmega.js` | Integrated printer (7KB) | + +#### Scanners/Camera + +| Driver | Files | Notes | +|--------|-------|-------| +| **manatee** | `lib/capture/scanner/manatee.js` | Manatee barcode scanner | +| **zxing** | `lib/capture/scanner/zxing.js` | ZXing-based QR scanning | +| **scanner-node** | `lib/scanner-node.js` | Node camera (7KB) | +| **scanner-genmega** | `lib/scanner-genmega.js` | Genmega integrated (2KB) | + +#### Other Peripherals + +| Driver | Files | Notes | +|--------|-------|-------| +| **leds** | `lib/leds/*.js` | LED control (3 files) | ### 3.1 HAL Crate Structure @@ -953,25 +1053,51 @@ packages/hal/ │ ├── error.rs │ ├── validators/ │ │ ├── mod.rs -│ │ ├── traits.rs # BillValidator trait -│ │ ├── nv200.rs # ITL NV200 (eSSP) -│ │ └── mock.rs # Mock for testing +│ │ ├── traits.rs # BillValidator trait +│ │ ├── id003.rs # JCM ID-003 (port from lib/id003/) +│ │ ├── ccnet.rs # CashCode CCNET (port from lib/ccnet/) +│ │ ├── mei_cashflow.rs # MEI CashFlow SC +│ │ ├── mei_bnr.rs # MEI BNR Advance +│ │ ├── genmega.rs # Genmega validator +│ │ ├── hcm2.rs # Hitachi HCM2 recycler +│ │ ├── gsr50.rs # GSR50 recycler +│ │ └── mock.rs # Mock for testing │ ├── dispensers/ │ │ ├── mod.rs -│ │ ├── traits.rs # BillDispenser trait -│ │ ├── puloon.rs # Puloon LCDM +│ │ ├── traits.rs # BillDispenser trait +│ │ ├── puloon.rs # Puloon LCDM (port from lib/puloon/) +│ │ ├── f56.rs # Fujitsu F53/F56 (port from lib/f56/) +│ │ ├── genmega.rs # Genmega dispenser +│ │ ├── hcm2.rs # Hitachi HCM2 dispense +│ │ ├── gsr50.rs # GSR50 dispense │ │ └── mock.rs │ ├── printer/ │ │ ├── mod.rs -│ │ └── escpos.rs # ESC/POS thermal printer +│ │ ├── traits.rs # Printer trait +│ │ ├── nippon.rs # Nippon ESC/POS +│ │ ├── zebra.rs # Zebra ZPL +│ │ ├── genmega.rs # Genmega printer +│ │ └── escpos.rs # Generic ESC/POS +│ ├── scanner/ +│ │ ├── mod.rs +│ │ ├── traits.rs # Scanner trait +│ │ ├── manatee.rs # Manatee barcode +│ │ ├── zxing.rs # ZXing QR +│ │ └── genmega.rs # Genmega scanner +│ ├── leds/ +│ │ ├── mod.rs +│ │ └── traits.rs # LED control trait │ └── nfc/ │ ├── mod.rs -│ └── acr122u.rs # ACR122U reader -└── index.node # napi-rs output +│ └── acr122u.rs # ACR122U reader +└── index.node # napi-rs output ``` ### 3.2 Bill Validator Trait +> [!note] Interface Derived from Existing Code +> This trait is derived from the common interface used across all existing lamassu-machine validators in `lib/bill-validator.js`. + **packages/hal/src/validators/traits.rs:** ```rust use async_trait::async_trait; @@ -982,6 +1108,16 @@ pub struct BillEvent { pub denomination: u32, pub currency: String, pub timestamp: u64, + pub event_type: BillEventType, +} + +#[derive(Debug, Clone)] +pub enum BillEventType { + Inserted, // Bill detected and validated + Accepted, // Bill sent to stacker + Rejected, // Bill returned to customer + Stacked, // Bill successfully stacked + Jammed, // Bill jammed } #[derive(Debug, Error)] @@ -998,8 +1134,13 @@ pub enum ValidatorError { HardwareError(String), } +/// Unified interface for all bill validators +/// Implementations: ID003, CCNET, MEI CashFlow, MEI BNR, Genmega, HCM2, GSR50 #[async_trait] pub trait BillValidator: Send + Sync { + /// Get validator type name (for logging/debugging) + fn driver_name(&self) -> &'static str; + /// Connect to the validator async fn connect(&mut self) -> Result<(), ValidatorError>; @@ -1023,50 +1164,104 @@ pub trait BillValidator: Send + Sync { /// Subscribe to bill events fn subscribe(&self) -> tokio::sync::broadcast::Receiver; + + /// Run the validator state machine (poll for events) + /// Most validators need continuous polling + async fn run(&mut self) -> Result<(), ValidatorError>; } ``` -### 3.3 NV200 Implementation (eSSP) +### 3.3 ID003 Implementation (JCM - Default) -**packages/hal/src/validators/nv200.rs:** +> [!note] Porting Reference +> Port from `lamassu-machine/lib/id003/`: +> - `id003.js` - Main driver +> - `id003rs232.js` - Serial communication +> - `id003fsm.js` - State machine +> - `crc.js` - CRC calculation + +**packages/hal/src/validators/id003.rs:** ```rust use super::traits::*; use async_trait::async_trait; use tokio::sync::broadcast; use tokio_serial::{SerialPortBuilderExt, SerialStream}; -pub struct NV200Validator { +/// ID003 protocol states (from id003fsm.js) +#[derive(Debug, Clone, PartialEq)] +enum Id003State { + Disconnected, + PowerUp, + Initialize, + Enable, + Accepting, + Escrowed, + Stacking, + VendValid, + Stacked, + Rejecting, + Returning, + Disabled, + Holding, + Inhibit, +} + +pub struct Id003Validator { port_path: String, serial: Option, event_tx: broadcast::Sender, + state: Id003State, enabled: bool, + fiat_code: String, } -impl NV200Validator { - pub fn new(port_path: &str) -> Self { +impl Id003Validator { + pub fn new(port_path: &str, fiat_code: &str) -> Self { let (event_tx, _) = broadcast::channel(16); Self { port_path: port_path.to_string(), serial: None, event_tx, + state: Id003State::Disconnected, enabled: false, + fiat_code: fiat_code.to_string(), } } + /// Build ID003 packet with CRC (from id003rs232.js) + fn build_packet(&self, data: &[u8]) -> Vec { + let mut packet = vec![0x02]; // SYNC + packet.push(data.len() as u8 + 4); // LEN (data + SYNC + LEN + CRC16) + packet.extend_from_slice(data); + + // CRC-16 (from crc.js) + let crc = self.calculate_crc(&packet); + packet.push((crc & 0xFF) as u8); + packet.push((crc >> 8) as u8); + packet + } + + fn calculate_crc(&self, data: &[u8]) -> u16 { + // CRC-CCITT from lamassu-machine/lib/id003/crc.js + let mut crc: u16 = 0x0000; + for &byte in data { + let mut x = ((crc >> 8) as u8) ^ byte; + x ^= x >> 4; + crc = (crc << 8) ^ ((x as u16) << 12) ^ ((x as u16) << 5) ^ (x as u16); + } + crc + } + async fn send_command(&mut self, cmd: &[u8]) -> Result, ValidatorError> { let serial = self.serial.as_mut() .ok_or(ValidatorError::ConnectionFailed("Not connected".into()))?; - // eSSP protocol: STX, SEQ, LEN, DATA..., CRC - let packet = self.build_essp_packet(cmd); + let packet = self.build_packet(cmd); - // Write command - use tokio::io::AsyncWriteExt; + use tokio::io::{AsyncWriteExt, AsyncReadExt}; serial.write_all(&packet).await .map_err(|e| ValidatorError::CommunicationError(e.to_string()))?; - // Read response - use tokio::io::AsyncReadExt; let mut buf = vec![0u8; 256]; let n = serial.read(&mut buf).await .map_err(|e| ValidatorError::CommunicationError(e.to_string()))?; @@ -1074,90 +1269,152 @@ impl NV200Validator { Ok(buf[..n].to_vec()) } - fn build_essp_packet(&self, data: &[u8]) -> Vec { - // eSSP packet structure - let mut packet = vec![0x7F]; // STX - packet.push(0x00); // Sequence (simplified) - packet.push(data.len() as u8); - packet.extend_from_slice(data); - // Add CRC16 - let crc = self.calculate_crc(&packet[1..]); - packet.extend_from_slice(&crc.to_le_bytes()); - packet + /// Parse response and emit events (from id003fsm.js) + fn handle_response(&mut self, response: &[u8]) -> Option { + if response.len() < 3 { + return None; + } + + let status = response[2]; + match status { + 0x81 => { // ESCROWED + let denomination = self.parse_denomination(&response[3..]); + self.state = Id003State::Escrowed; + Some(BillEvent { + denomination, + currency: self.fiat_code.clone(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + event_type: BillEventType::Inserted, + }) + } + 0x82 => { // STACKED + self.state = Id003State::Stacked; + Some(BillEvent { + denomination: 0, // From context + currency: self.fiat_code.clone(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + event_type: BillEventType::Stacked, + }) + } + 0x83 => { // RETURNED + self.state = Id003State::Enable; + Some(BillEvent { + denomination: 0, + currency: self.fiat_code.clone(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + event_type: BillEventType::Rejected, + }) + } + _ => None, + } } - fn calculate_crc(&self, data: &[u8]) -> u16 { - // CRC-16-CCITT - let mut crc: u16 = 0xFFFF; - for byte in data { - crc ^= (*byte as u16) << 8; - for _ in 0..8 { - if crc & 0x8000 != 0 { - crc = (crc << 1) ^ 0x8005; - } else { - crc <<= 1; - } - } + fn parse_denomination(&self, data: &[u8]) -> u32 { + // Denomination mapping depends on validator configuration + // Reference: id003.js denominationTable + if data.is_empty() { return 0; } + match data[0] { + 1 => 1, + 2 => 5, + 3 => 10, + 4 => 20, + 5 => 50, + 6 => 100, + _ => 0, } - crc } } #[async_trait] -impl BillValidator for NV200Validator { +impl BillValidator for Id003Validator { + fn driver_name(&self) -> &'static str { + "id003" + } + async fn connect(&mut self) -> Result<(), ValidatorError> { let serial = tokio_serial::new(&self.port_path, 9600) .open_native_async() .map_err(|e| ValidatorError::ConnectionFailed(e.to_string()))?; self.serial = Some(serial); + self.state = Id003State::PowerUp; - // Sync and reset - self.send_command(&[0x11]).await?; // Sync - self.send_command(&[0x01]).await?; // Reset + // Initialize sequence (from id003fsm.js) + self.send_command(&[0x40]).await?; // RESET + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - // Set channel enables (all denominations) - self.send_command(&[0x02, 0xFF, 0xFF]).await?; + self.send_command(&[0x11]).await?; // ENABLE_DENOM (all) + self.state = Id003State::Initialize; Ok(()) } async fn disconnect(&mut self) -> Result<(), ValidatorError> { self.serial = None; + self.state = Id003State::Disconnected; Ok(()) } async fn enable(&mut self) -> Result<(), ValidatorError> { - self.send_command(&[0x0A]).await?; // Enable + self.send_command(&[0x13]).await?; // ENABLE self.enabled = true; + self.state = Id003State::Enable; Ok(()) } async fn disable(&mut self) -> Result<(), ValidatorError> { - self.send_command(&[0x09]).await?; // Disable + self.send_command(&[0x14]).await?; // DISABLE self.enabled = false; + self.state = Id003State::Disabled; Ok(()) } async fn accept(&mut self) -> Result<(), ValidatorError> { - // NV200 auto-stacks, but we need to confirm - self.send_command(&[0x5C]).await?; // Payout + if self.state != Id003State::Escrowed { + return Err(ValidatorError::HardwareError("No bill escrowed".into())); + } + self.send_command(&[0x15]).await?; // STACK + self.state = Id003State::Stacking; Ok(()) } async fn reject(&mut self) -> Result<(), ValidatorError> { - self.send_command(&[0x08]).await?; // Reject + if self.state != Id003State::Escrowed { + return Err(ValidatorError::HardwareError("No bill escrowed".into())); + } + self.send_command(&[0x16]).await?; // RETURN + self.state = Id003State::Returning; Ok(()) } async fn get_bill_count(&self) -> Result { - // Query stacker level - Ok(0) // TODO: Implement + // ID003 doesn't track count - return 0 + Ok(0) } fn subscribe(&self) -> broadcast::Receiver { self.event_tx.subscribe() } + + async fn run(&mut self) -> Result<(), ValidatorError> { + // Continuous polling loop (from id003fsm.js) + loop { + let response = self.send_command(&[0x10]).await?; // STATUS + if let Some(event) = self.handle_response(&response) { + let _ = self.event_tx.send(event); + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + } } ``` @@ -1216,39 +1473,66 @@ pub trait BillDispenser: Send + Sync { ### 3.5 Puloon LCDM Implementation +> [!note] Porting Reference +> Port from `lamassu-machine/lib/puloon/`: +> - `puloonrs232.js` - Serial communication (10KB) +> - `puloon-dispenser.js` - High-level interface +> - `puloon_data.js` - Protocol constants + **packages/hal/src/dispensers/puloon.rs:** ```rust use super::traits::*; use async_trait::async_trait; use tokio_serial::{SerialPortBuilderExt, SerialStream}; +/// Puloon command codes (from puloon_data.js) +const CMD_RESET: u8 = 0x44; // 'D' - Reset +const CMD_DISPENSE: u8 = 0x45; // 'E' - Dispense +const CMD_STATUS: u8 = 0x46; // 'F' - Status +const CMD_PRESENT: u8 = 0x47; // 'G' - Present check + pub struct PuloonDispenser { port_path: String, serial: Option, cassettes: Vec, + fiat_code: String, } impl PuloonDispenser { - pub fn new(port_path: &str) -> Self { + pub fn new(port_path: &str, fiat_code: &str) -> Self { Self { port_path: port_path.to_string(), serial: None, cassettes: vec![], + fiat_code: fiat_code.to_string(), } } + /// Build Puloon packet (from puloonrs232.js) + fn build_packet(&self, data: &[u8]) -> Vec { + let mut packet = vec![0x02]; // STX + packet.extend_from_slice(data); + packet.push(0x03); // ETX + + // BCC = XOR of all bytes between STX and ETX (exclusive) + let bcc = data.iter().fold(0x03u8, |acc, &b| acc ^ b); + packet.push(bcc); + packet + } + async fn send_command(&mut self, cmd: &[u8]) -> Result, DispenserError> { let serial = self.serial.as_mut() .ok_or(DispenserError::ConnectionFailed("Not connected".into()))?; - // Puloon uses simple ASCII protocol - // Format: STX + CMD + DATA + ETX + BCC let packet = self.build_packet(cmd); use tokio::io::{AsyncWriteExt, AsyncReadExt}; serial.write_all(&packet).await .map_err(|e| DispenserError::CommunicationError(e.to_string()))?; + // Wait for ACK + response + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + let mut buf = vec![0u8; 256]; let n = serial.read(&mut buf).await .map_err(|e| DispenserError::CommunicationError(e.to_string()))?; @@ -1256,19 +1540,29 @@ impl PuloonDispenser { Ok(buf[..n].to_vec()) } - fn build_packet(&self, data: &[u8]) -> Vec { - let mut packet = vec![0x02]; // STX - packet.extend_from_slice(data); - packet.push(0x03); // ETX - // Calculate BCC (XOR of all bytes after STX) - let bcc = data.iter().fold(0x03u8, |acc, &b| acc ^ b); - packet.push(bcc); - packet + /// Parse dispense response (from puloonrs232.js dispense callback) + fn parse_dispense_response(&self, response: &[u8]) -> Result { + if response.len() < 5 { + return Err(DispenserError::CommunicationError("Short response".into())); + } + + // Check for errors + let status = response[3]; + match status { + 0x30 => Ok(response[4] as u32), // Success, return count + 0x31 => Err(DispenserError::BillJam), + 0x32 => Err(DispenserError::CassetteEmpty(0)), + _ => Err(DispenserError::HardwareError(format!("Unknown status: {}", status))), + } } } #[async_trait] impl BillDispenser for PuloonDispenser { + fn driver_name(&self) -> &'static str { + "puloon" + } + async fn connect(&mut self) -> Result<(), DispenserError> { let serial = tokio_serial::new(&self.port_path, 9600) .open_native_async() @@ -1276,8 +1570,14 @@ impl BillDispenser for PuloonDispenser { self.serial = Some(serial); - // Initialize and get status - self.send_command(b"ST").await?; // Status command + // Reset and get status (from puloon-dispenser.js init) + self.send_command(&[CMD_RESET]).await?; + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + + // Query status to populate cassettes + let status = self.send_command(&[CMD_STATUS]).await?; + // Parse cassette info from status response + // TODO: Parse cassette configuration Ok(()) } @@ -1307,26 +1607,97 @@ impl BillDispenser for PuloonDispenser { )); } - // Send dispense command - // Puloon format: "DN" + cassette + count (ASCII) - let cmd = format!("DN{}{:02}", cassette_idx + 1, count); - let response = self.send_command(cmd.as_bytes()).await?; + // Build dispense command: CMD + cassette_index + count + let cmd = vec![CMD_DISPENSE, cassette_idx as u8 + 1, count as u8]; + let response = self.send_command(&cmd).await?; + + let dispensed = self.parse_dispense_response(&response)?; - // Parse response for actual dispensed count // Update local count - self.cassettes[cassette_idx].count -= count; + self.cassettes[cassette_idx].count -= dispensed; - Ok(count) + Ok(dispensed) } async fn reset(&mut self) -> Result<(), DispenserError> { - self.send_command(b"RS").await?; + self.send_command(&[CMD_RESET]).await?; Ok(()) } async fn is_ready(&self) -> Result { Ok(self.serial.is_some()) } + + async fn bills_present(&self) -> Result { + // Puloon doesn't have optical sensor for bills at exit + // Return true (bills assumed present after dispense) + Ok(true) + } + + async fn wait_for_bills_removed(&self) -> Result<(), DispenserError> { + // Puloon doesn't detect removal - return immediately + Ok(()) + } +} +``` + +### 3.5.1 Printer Trait + +> [!note] Porting Reference +> Port from `lamassu-machine/lib/printer/`: +> - `nippon.js` - Nippon thermal (9KB) +> - `zebra.js` - Zebra ZPL (13KB) +> - `genmega.js` - Genmega integrated (7KB) + +**packages/hal/src/printer/traits.rs:** +```rust +use async_trait::async_trait; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PrinterError { + #[error("Connection failed: {0}")] + ConnectionFailed(String), + #[error("Communication error: {0}")] + CommunicationError(String), + #[error("Out of paper")] + OutOfPaper, + #[error("Paper jam")] + PaperJam, + #[error("Hardware error: {0}")] + HardwareError(String), +} + +/// Receipt data structure +pub struct ReceiptData { + pub operator_name: String, + pub transaction_id: String, + pub transaction_type: String, // "cash_in" or "cash_out" + pub fiat_amount: f64, + pub fiat_currency: String, + pub crypto_amount: f64, + pub crypto_currency: String, + pub timestamp: u64, + pub qr_data: Option, // For CLINK offer or LNURL +} + +/// Unified interface for all printers +/// Implementations: Nippon, Zebra, Genmega, Generic ESC/POS +#[async_trait] +pub trait Printer: Send + Sync { + fn driver_name(&self) -> &'static str; + + async fn connect(&mut self) -> Result<(), PrinterError>; + async fn disconnect(&mut self) -> Result<(), PrinterError>; + + /// Print a receipt + async fn print_receipt(&mut self, data: &ReceiptData) -> Result<(), PrinterError>; + + /// Check if printer is ready (has paper, no jams) + async fn is_ready(&self) -> Result; + + /// Cut paper (if supported) + async fn cut(&mut self) -> Result<(), PrinterError>; } ``` @@ -1341,9 +1712,54 @@ mod error; mod validators; mod dispensers; mod printer; +mod scanner; +mod leds; mod nfc; -use validators::{traits::BillValidator, nv200::NV200Validator, mock::MockValidator}; +use validators::{ + traits::BillValidator, + id003::Id003Validator, + ccnet::CcnetValidator, + mei_cashflow::MeiCashflowValidator, + genmega::GenmegaValidator, + hcm2::Hcm2Validator, + gsr50::Gsr50Validator, + mock::MockValidator, +}; + +use dispensers::{ + traits::BillDispenser, + puloon::PuloonDispenser, + f56::F56Dispenser, + genmega::GenmegaDispenser, + hcm2::Hcm2Dispenser, + gsr50::Gsr50Dispenser, + mock::MockDispenser, +}; + +/// Supported validator drivers (matching lamassu-machine/lib/bill-validator.js) +#[napi] +pub enum ValidatorDriver { + Id003, // JCM ID-003 (default) + Ccnet, // CashCode CCNET + CashflowSc, // MEI CashFlow SC + BnrAdvance, // MEI BNR Advance + Genmega, // Genmega validator + Hcm2, // Hitachi HCM2 recycler + Gsr50, // GSR50 recycler + Mock, // Mock for testing +} + +/// Supported dispenser drivers (matching lamassu-machine) +#[napi] +pub enum DispenserDriver { + Puloon, // Puloon LCDM series + F56, // Fujitsu F53/F56 + Genmega, // Genmega dispenser + Hcm2, // Hitachi HCM2 recycler + Gsr50, // GSR50 recycler + Mock, // Mock for testing +} #[napi] pub struct BillValidatorWrapper { @@ -1353,19 +1769,48 @@ pub struct BillValidatorWrapper { #[napi] impl BillValidatorWrapper { #[napi(constructor)] - pub fn new(driver: String, port: Option) -> Result { - let validator: Box = match driver.as_str() { - "nv200" => { - let port = port.ok_or_else(|| Error::from_reason("Port required for NV200"))?; - Box::new(NV200Validator::new(&port)) + pub fn new(driver: ValidatorDriver, port: Option, fiat_code: Option) -> Result { + let fiat = fiat_code.unwrap_or_else(|| "USD".to_string()); + + let validator: Box = match driver { + ValidatorDriver::Id003 => { + let port = port.ok_or_else(|| Error::from_reason("Port required"))?; + Box::new(Id003Validator::new(&port, &fiat)) } - "mock" => Box::new(MockValidator::new()), - _ => return Err(Error::from_reason(format!("Unknown driver: {}", driver))), + ValidatorDriver::Ccnet => { + let port = port.ok_or_else(|| Error::from_reason("Port required"))?; + Box::new(CcnetValidator::new(&port, &fiat)) + } + ValidatorDriver::CashflowSc => { + let port = port.ok_or_else(|| Error::from_reason("Port required"))?; + Box::new(MeiCashflowValidator::new(&port, &fiat)) + } + ValidatorDriver::BnrAdvance => { + let port = port.ok_or_else(|| Error::from_reason("Port required"))?; + Box::new(MeiCashflowValidator::new(&port, &fiat)) // BNR uses same base + } + ValidatorDriver::Genmega => { + Box::new(GenmegaValidator::new(&fiat)) + } + ValidatorDriver::Hcm2 => { + let port = port.ok_or_else(|| Error::from_reason("Port required"))?; + Box::new(Hcm2Validator::new(&port, &fiat)) + } + ValidatorDriver::Gsr50 => { + let port = port.ok_or_else(|| Error::from_reason("Port required"))?; + Box::new(Gsr50Validator::new(&port, &fiat)) + } + ValidatorDriver::Mock => Box::new(MockValidator::new()), }; Ok(Self { inner: validator }) } + #[napi(getter)] + pub fn driver_name(&self) -> String { + self.inner.driver_name().to_string() + } + #[napi] pub async fn connect(&mut self) -> Result<()> { self.inner.connect().await @@ -1395,56 +1840,281 @@ impl BillValidatorWrapper { self.inner.reject().await .map_err(|e| Error::from_reason(e.to_string())) } + + #[napi] + pub async fn run(&mut self) -> Result<()> { + self.inner.run().await + .map_err(|e| Error::from_reason(e.to_string())) + } +} + +#[napi] +pub struct BillDispenserWrapper { + inner: Box, +} + +#[napi] +impl BillDispenserWrapper { + #[napi(constructor)] + pub fn new(driver: DispenserDriver, port: Option, fiat_code: Option) -> Result { + let fiat = fiat_code.unwrap_or_else(|| "USD".to_string()); + + let dispenser: Box = match driver { + DispenserDriver::Puloon => { + let port = port.ok_or_else(|| Error::from_reason("Port required"))?; + Box::new(PuloonDispenser::new(&port, &fiat)) + } + DispenserDriver::F56 => { + let port = port.ok_or_else(|| Error::from_reason("Port required"))?; + Box::new(F56Dispenser::new(&port, &fiat)) + } + DispenserDriver::Genmega => { + Box::new(GenmegaDispenser::new(&fiat)) + } + DispenserDriver::Hcm2 => { + let port = port.ok_or_else(|| Error::from_reason("Port required"))?; + Box::new(Hcm2Dispenser::new(&port, &fiat)) + } + DispenserDriver::Gsr50 => { + let port = port.ok_or_else(|| Error::from_reason("Port required"))?; + Box::new(Gsr50Dispenser::new(&port, &fiat)) + } + DispenserDriver::Mock => Box::new(MockDispenser::new()), + }; + + Ok(Self { inner: dispenser }) + } + + #[napi(getter)] + pub fn driver_name(&self) -> String { + self.inner.driver_name().to_string() + } + + #[napi] + pub async fn connect(&mut self) -> Result<()> { + self.inner.connect().await + .map_err(|e| Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn disconnect(&mut self) -> Result<()> { + self.inner.disconnect().await + .map_err(|e| Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn dispense(&mut self, denomination: u32, count: u32) -> Result { + self.inner.dispense(denomination, count).await + .map_err(|e| Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn reset(&mut self) -> Result<()> { + self.inner.reset().await + .map_err(|e| Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn is_ready(&self) -> Result { + self.inner.is_ready().await + .map_err(|e| Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn bills_present(&self) -> Result { + self.inner.bills_present().await + .map_err(|e| Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn wait_for_bills_removed(&self) -> Result<()> { + self.inner.wait_for_bills_removed().await + .map_err(|e| Error::from_reason(e.to_string())) + } } ``` ### 3.7 TypeScript Usage ```typescript -import { BillValidatorWrapper, BillDispenserWrapper } from '@lamassu/hal' +import { + BillValidatorWrapper, + BillDispenserWrapper, + ValidatorDriver, + DispenserDriver, +} from '@lamassu/hal' // === Bill Validator === // Development with mock -const validator = new BillValidatorWrapper('mock') -// Production with NV200 -// const validator = new BillValidatorWrapper('nv200', '/dev/ttyUSB0') +const mockValidator = new BillValidatorWrapper(ValidatorDriver.Mock) -await validator.connect() -await validator.enable() +// Production - match your hardware: +// JCM validators (most common) +const id003Validator = new BillValidatorWrapper( + ValidatorDriver.Id003, + '/dev/ttyUSB0', + 'USD' +) + +// CashCode CCNET +const ccnetValidator = new BillValidatorWrapper( + ValidatorDriver.Ccnet, + '/dev/ttyUSB0', + 'USD' +) + +// MEI CashFlow SC +const meiValidator = new BillValidatorWrapper( + ValidatorDriver.CashflowSc, + '/dev/ttyUSB0', + 'USD' +) + +// Genmega (integrated - no port needed) +const genmegaValidator = new BillValidatorWrapper( + ValidatorDriver.Genmega, + undefined, + 'USD' +) + +// Recyclers (combined validator/dispenser) +const hcm2Validator = new BillValidatorWrapper( + ValidatorDriver.Hcm2, + '/dev/ttyUSB0', + 'USD' +) + +// Connect and enable +await id003Validator.connect() +await id003Validator.enable() + +// Run the validator state machine (polls for events) +// This runs in background, emits events +id003Validator.run().catch(console.error) // Listen for bills (via event emitter pattern) -validator.on('bill', (event) => { +id003Validator.on('bill', (event) => { console.log(`Bill inserted: ${event.denomination} ${event.currency}`) + console.log(`Event type: ${event.eventType}`) // 'inserted', 'accepted', 'rejected', 'stacked' }) // === Bill Dispenser === // Development with mock -const dispenser = new BillDispenserWrapper('mock') -// Production with Puloon -// const dispenser = new BillDispenserWrapper('puloon', '/dev/ttyUSB1') +const mockDispenser = new BillDispenserWrapper(DispenserDriver.Mock) -await dispenser.connect() +// Production - match your hardware: +// Puloon LCDM series (common) +const puloonDispenser = new BillDispenserWrapper( + DispenserDriver.Puloon, + '/dev/ttyUSB1', + 'USD' +) -// Check cassette levels -const cassettes = await dispenser.getCassetteStatus() -console.log('Cassettes:', cassettes) -// [{ denomination: 20, count: 450, capacity: 500 }] +// Fujitsu F53/F56 +const f56Dispenser = new BillDispenserWrapper( + DispenserDriver.F56, + '/dev/ttyUSB1', + 'USD' +) + +// Genmega (integrated) +const genmegaDispenser = new BillDispenserWrapper( + DispenserDriver.Genmega, + undefined, + 'USD' +) + +await puloonDispenser.connect() + +// Check if dispenser is ready +const ready = await puloonDispenser.isReady() +console.log('Dispenser ready:', ready) // Dispense $60 (3 x $20 bills) -const dispensed = await dispenser.dispense(20, 3) +const dispensed = await puloonDispenser.dispense(20, 3) console.log(`Dispensed ${dispensed} bills`) + +// For F56 and others that detect bills at exit: +const billsPresent = await puloonDispenser.billsPresent() +if (billsPresent) { + await puloonDispenser.waitForBillsRemoved() +} ``` -### 3.8 Milestone Checklist +### 3.8 Configuration Mapping +Map existing lamassu-machine device config to new HAL: + +```typescript +// Existing lamassu-machine config format (from brain.js): +// deviceConfig = { deviceType: 'id003', device: '/dev/ttyValidator' } + +function migrateValidatorConfig(oldConfig: { deviceType: string; device?: string }): { + driver: ValidatorDriver + port?: string +} { + const driverMap: Record = { + 'id003': ValidatorDriver.Id003, // default + 'ccnet': ValidatorDriver.Ccnet, + 'cashflowSc': ValidatorDriver.CashflowSc, + 'bnrAdvance': ValidatorDriver.BnrAdvance, + 'genmega': ValidatorDriver.Genmega, + 'hcm2': ValidatorDriver.Hcm2, + 'gsr50': ValidatorDriver.Gsr50, + } + + return { + driver: driverMap[oldConfig.deviceType] ?? ValidatorDriver.Id003, + port: oldConfig.device, + } +} + +// Usage: +const legacyConfig = { deviceType: 'ccnet', device: '/dev/ttyUSB0' } +const newConfig = migrateValidatorConfig(legacyConfig) +const validator = new BillValidatorWrapper(newConfig.driver, newConfig.port, 'USD') +``` + +### 3.9 Milestone Checklist + +**Core Infrastructure:** - [ ] HAL crate compiles -- [ ] Mock validator works -- [ ] Mock dispenser works - [ ] napi-rs bindings build - [ ] Can import in TypeScript -- [ ] Mock bills trigger events -- [ ] NV200 driver tested with real hardware (optional) -- [ ] Puloon dispenser driver tested with real hardware (optional) +- [ ] Mock validator works (events, state machine) +- [ ] Mock dispenser works (cassettes, dispense) + +**Bill Validators (port from lamassu-machine):** +- [ ] ID003 (JCM) - `lib/id003/` - **Priority: High** (default driver) +- [ ] CCNET (CashCode) - `lib/ccnet/` - Priority: Medium +- [ ] MEI CashFlow SC - `lib/mei/cashflow_sc.js` - Priority: Medium +- [ ] MEI BNR Advance - `lib/mei/bnr_advance.js` - Priority: Low +- [ ] Genmega - `lib/genmega/genmega-validator/` - Priority: Low +- [ ] HCM2 (Hitachi) - `lib/hcm2/hcm2.js` - Priority: Low (recycler) +- [ ] GSR50 - `lib/gsr50/gsr50.js` - Priority: Low (recycler) + +**Bill Dispensers (port from lamassu-machine):** +- [ ] Puloon LCDM - `lib/puloon/` - **Priority: Critical** (cash-out dominant) +- [ ] Fujitsu F56 - `lib/f56/` - Priority: High (multi-cassette) +- [ ] Genmega - `lib/genmega/genmega-dispenser/` - Priority: Low +- [ ] HCM2 (Hitachi) - `lib/hcm2/hcm2.js` - Priority: Low (recycler) +- [ ] GSR50 - `lib/gsr50/gsr50.js` - Priority: Low (recycler) + +**Printers (port from lamassu-machine):** +- [ ] Nippon ESC/POS - `lib/printer/nippon.js` - Priority: Medium +- [ ] Zebra ZPL - `lib/printer/zebra.js` - Priority: Low +- [ ] Genmega - `lib/printer/genmega.js` - Priority: Low +- [ ] Generic ESC/POS - Priority: Medium (fallback) + +**Scanners (port from lamassu-machine):** +- [ ] Manatee - `lib/capture/scanner/manatee.js` - Priority: Low +- [ ] ZXing - `lib/capture/scanner/zxing.js` - Priority: Medium +- [ ] Genmega - `lib/scanner-genmega.js` - Priority: Low + +**Hardware Testing:** +- [ ] ID003 tested with real JCM validator +- [ ] Puloon tested with real LCDM dispenser +- [ ] Cash-out flow works end-to-end with real hardware --- @@ -1783,22 +2453,34 @@ export function useFleet() { ### Bill Dispenser Options -| Model | Cassettes | Capacity | Protocol | Est. Used Price | -|-------|-----------|----------|----------|-----------------| -| **Puloon LCDM-1000** | 1 | 1,000 notes | RS-232 | $400-600 | -| Puloon LCDM-2000 | 2 | 2,000 notes | RS-232 | $600-900 | -| Fujitsu F53 | 4 | 2,500 notes | USB/RS-232 | $800-1,200 | +All of these have **existing tested drivers** in lamassu-machine: + +| Model | Cassettes | Protocol | Driver | Notes | Est. Used Price | +|-------|-----------|----------|--------|-------|-----------------| +| **Puloon LCDM-1000** | 1 | RS-232 | `puloon` | **Most common**, well-tested | $400-600 | +| Puloon LCDM-2000 | 2 | RS-232 | `puloon` | Multi-denomination | $600-900 | +| **Fujitsu F53/F56** | 4 | USB/RS-232 | `f56` | High capacity, FSM-based | $800-1,200 | +| Genmega CDU | varies | Proprietary | `genmega` | Genmega ATMs only | N/A | +| Hitachi HCM2 | 4 | RS-232 | `hcm2` | Recycler (complex) | $1,000-1,500 | +| GSR50 | 4 | RS-232 | `gsr50` | Recycler | $800-1,200 | > [!tip] Start with Puloon LCDM-1000 -> Single cassette handles the primary use case. Multi-cassette (for multiple denominations) can come later. +> Single cassette handles the primary use case. The `puloon` driver is the most battle-tested dispenser driver in lamassu-machine. Multi-cassette (Fujitsu F56) for multiple denominations can come later. ### Bill Validator Options (Secondary Priority) -| Model | Protocol | Notes | Est. Used Price | -|-------|----------|-------|-----------------| -| **ITL NV200** | eSSP | Industry standard, good docs | $300-500 | -| MEI Cashflow | ccTalk/MDB | Common in NA market | $250-400 | -| JCM iVizion | ID-003 | Existing lamassu driver | $200-350 | +All of these have **existing tested drivers** in lamassu-machine: + +| Model | Protocol | Driver | Notes | Est. Used Price | +|-------|----------|--------|-------|-----------------| +| **JCM iVizion/iPro** | ID-003 | `id003` | **Default driver**, most common | $200-350 | +| **CashCode SM/MVU** | CCNET | `ccnet` | Common in NA, has emulator | $250-400 | +| **MEI CashFlow SC** | Proprietary | `cashflowSc` | MEI ecosystem | $250-400 | +| MEI BNR Advance | Proprietary | `bnrAdvance` | Recycler capable | $400-600 | +| ITL NV200 | eSSP | *New driver needed* | Industry standard | $300-500 | + +> [!tip] Use Existing Driver Hardware +> For fastest development, use hardware that already has a working lamassu-machine driver (JCM, CashCode, MEI). The ID003 driver is the most battle-tested. ### Sourcing Used Equipment