diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 1ae1e5f..a553bd1 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -1146,7 +1146,176 @@ impl BillValidator for NV200Validator { } ``` -### 3.4 napi-rs Bindings +### 3.4 Bill Dispenser Trait + +**packages/hal/src/dispensers/traits.rs:** +```rust +use async_trait::async_trait; +use thiserror::Error; + +#[derive(Debug, Clone)] +pub struct CassetteStatus { + pub denomination: u32, + pub count: u32, + pub capacity: u32, +} + +#[derive(Debug, Error)] +pub enum DispenserError { + #[error("Connection failed: {0}")] + ConnectionFailed(String), + #[error("Communication error: {0}")] + CommunicationError(String), + #[error("Insufficient bills: need {0}, have {1}")] + InsufficientBills(u32, u32), + #[error("Cassette empty: {0}")] + CassetteEmpty(u32), + #[error("Bill jam")] + BillJam, + #[error("Hardware error: {0}")] + HardwareError(String), +} + +#[async_trait] +pub trait BillDispenser: Send + Sync { + /// Connect to the dispenser + async fn connect(&mut self) -> Result<(), DispenserError>; + + /// Disconnect from the dispenser + async fn disconnect(&mut self) -> Result<(), DispenserError>; + + /// Get status of all cassettes + async fn get_cassette_status(&self) -> Result, DispenserError>; + + /// Dispense bills from a specific cassette + /// Returns number of bills actually dispensed + async fn dispense(&mut self, denomination: u32, count: u32) -> Result; + + /// Reset after a jam or error + async fn reset(&mut self) -> Result<(), DispenserError>; + + /// Check if dispenser is ready + async fn is_ready(&self) -> Result; +} +``` + +### 3.5 Puloon LCDM Implementation + +**packages/hal/src/dispensers/puloon.rs:** +```rust +use super::traits::*; +use async_trait::async_trait; +use tokio_serial::{SerialPortBuilderExt, SerialStream}; + +pub struct PuloonDispenser { + port_path: String, + serial: Option, + cassettes: Vec, +} + +impl PuloonDispenser { + pub fn new(port_path: &str) -> Self { + Self { + port_path: port_path.to_string(), + serial: None, + cassettes: vec![], + } + } + + 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()))?; + + let mut buf = vec![0u8; 256]; + let n = serial.read(&mut buf).await + .map_err(|e| DispenserError::CommunicationError(e.to_string()))?; + + 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 + } +} + +#[async_trait] +impl BillDispenser for PuloonDispenser { + async fn connect(&mut self) -> Result<(), DispenserError> { + let serial = tokio_serial::new(&self.port_path, 9600) + .open_native_async() + .map_err(|e| DispenserError::ConnectionFailed(e.to_string()))?; + + self.serial = Some(serial); + + // Initialize and get status + self.send_command(b"ST").await?; // Status command + + Ok(()) + } + + async fn disconnect(&mut self) -> Result<(), DispenserError> { + self.serial = None; + Ok(()) + } + + async fn get_cassette_status(&self) -> Result, DispenserError> { + Ok(self.cassettes.clone()) + } + + async fn dispense(&mut self, denomination: u32, count: u32) -> Result { + // Find cassette with this denomination + let cassette_idx = self.cassettes.iter() + .position(|c| c.denomination == denomination) + .ok_or(DispenserError::HardwareError( + format!("No cassette for denomination {}", denomination) + ))?; + + // Check availability + if self.cassettes[cassette_idx].count < count { + return Err(DispenserError::InsufficientBills( + count, + self.cassettes[cassette_idx].count + )); + } + + // 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?; + + // Parse response for actual dispensed count + // Update local count + self.cassettes[cassette_idx].count -= count; + + Ok(count) + } + + async fn reset(&mut self) -> Result<(), DispenserError> { + self.send_command(b"RS").await?; + Ok(()) + } + + async fn is_ready(&self) -> Result { + Ok(self.serial.is_some()) + } +} +``` + +### 3.6 napi-rs Bindings **packages/hal/src/lib.rs:** ```rust @@ -1214,16 +1383,16 @@ impl BillValidatorWrapper { } ``` -### 3.5 TypeScript Usage +### 3.7 TypeScript Usage ```typescript -import { BillValidatorWrapper } from '@lamassu/hal' +import { BillValidatorWrapper, BillDispenserWrapper } from '@lamassu/hal' +// === Bill Validator === // Development with mock const validator = new BillValidatorWrapper('mock') - // Production with NV200 -const validator = new BillValidatorWrapper('nv200', '/dev/ttyUSB0') +// const validator = new BillValidatorWrapper('nv200', '/dev/ttyUSB0') await validator.connect() await validator.enable() @@ -1232,17 +1401,35 @@ await validator.enable() validator.on('bill', (event) => { console.log(`Bill inserted: ${event.denomination} ${event.currency}`) }) + +// === Bill Dispenser === +// Development with mock +const dispenser = new BillDispenserWrapper('mock') +// Production with Puloon +// const dispenser = new BillDispenserWrapper('puloon', '/dev/ttyUSB1') + +await dispenser.connect() + +// Check cassette levels +const cassettes = await dispenser.getCassetteStatus() +console.log('Cassettes:', cassettes) +// [{ denomination: 20, count: 450, capacity: 500 }] + +// Dispense $60 (3 x $20 bills) +const dispensed = await dispenser.dispense(20, 3) +console.log(`Dispensed ${dispensed} bills`) ``` -### 3.6 Milestone Checklist +### 3.8 Milestone Checklist - [ ] 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 implemented +- [ ] Puloon dispenser driver tested with real hardware (optional) --- @@ -1538,7 +1725,7 @@ export function useFleet() { ## Development Hardware -### Recommended Dev Kit +### Recommended Dev Kit (One-Way: Cash-In Only) | Component | Model | Purpose | Est. Cost | |-----------|-------|---------|-----------| @@ -1549,6 +1736,36 @@ export function useFleet() { | NFC Reader | ACR122U | BOLT card testing | $35 | | **Total** | | | **~$535-735** | +### Full Dev Kit (Two-Way: Cash-In + Cash-Out) + +| Component | Model | Purpose | Est. Cost | +|-----------|-------|---------|-----------| +| SBC | Raspberry Pi 5 (8GB) | Development machine | $80 | +| Display | Waveshare 10.1" touch | Larger UI for two-way | $90 | +| Bill Validator | ITL NV200 (used) | Cash-in acceptance | $300-500 | +| **Bill Dispenser** | Puloon LCDM-1000 (used) | Cash-out dispensing | $400-800 | +| Printer | Generic ESC/POS | Receipt testing | $50 | +| NFC Reader | ACR122U | BOLT card testing | $35 | +| **Total** | | | **~$955-1,555** | + +### 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 | + +> [!tip] Start with Puloon LCDM-1000 +> Single cassette is sufficient for development. Simple RS-232 protocol with existing lamassu-machine drivers to reference. + +### Sourcing Used Equipment + +- **eBay** - Search "bill dispenser ATM" or "Puloon LCDM" +- **Alibaba** - New units, but longer shipping +- **ATM parts suppliers** - atmpartsnow.com, atmequipment.com +- **Bitcoin ATM operators** - Sometimes sell retired units + ### Mock-First Development For most development, mocks are sufficient: