Add bill dispenser to implementation plan

Hardware section now includes:
- One-way dev kit (cash-in only): ~$535-735
- Two-way dev kit (cash-in + cash-out): ~$955-1,555
- Bill dispenser options: Puloon LCDM-1000/2000, Fujitsu F53
- Sourcing tips for used equipment

HAL section now includes:
- BillDispenser trait definition
- Puloon LCDM RS-232 implementation
- napi-rs bindings for dispenser
- TypeScript usage examples for both validator and dispenser

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-22 15:50:13 -05:00
commit 05c6d9827c

View file

@ -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<Vec<CassetteStatus>, DispenserError>;
/// Dispense bills from a specific cassette
/// Returns number of bills actually dispensed
async fn dispense(&mut self, denomination: u32, count: u32) -> Result<u32, DispenserError>;
/// Reset after a jam or error
async fn reset(&mut self) -> Result<(), DispenserError>;
/// Check if dispenser is ready
async fn is_ready(&self) -> Result<bool, DispenserError>;
}
```
### 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<SerialStream>,
cassettes: Vec<CassetteStatus>,
}
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<Vec<u8>, 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<u8> {
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<Vec<CassetteStatus>, DispenserError> {
Ok(self.cassettes.clone())
}
async fn dispense(&mut self, denomination: u32, count: u32) -> Result<u32, DispenserError> {
// 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<bool, DispenserError> {
Ok(self.serial.is_some())
}
}
```
### 3.6 napi-rs Bindings
**packages/hal/src/lib.rs:** **packages/hal/src/lib.rs:**
```rust ```rust
@ -1214,16 +1383,16 @@ impl BillValidatorWrapper {
} }
``` ```
### 3.5 TypeScript Usage ### 3.7 TypeScript Usage
```typescript ```typescript
import { BillValidatorWrapper } from '@lamassu/hal' import { BillValidatorWrapper, BillDispenserWrapper } from '@lamassu/hal'
// === Bill Validator ===
// Development with mock // Development with mock
const validator = new BillValidatorWrapper('mock') const validator = new BillValidatorWrapper('mock')
// Production with NV200 // Production with NV200
const validator = new BillValidatorWrapper('nv200', '/dev/ttyUSB0') // const validator = new BillValidatorWrapper('nv200', '/dev/ttyUSB0')
await validator.connect() await validator.connect()
await validator.enable() await validator.enable()
@ -1232,17 +1401,35 @@ await validator.enable()
validator.on('bill', (event) => { validator.on('bill', (event) => {
console.log(`Bill inserted: ${event.denomination} ${event.currency}`) 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 - [ ] HAL crate compiles
- [ ] Mock validator works - [ ] Mock validator works
- [ ] Mock dispenser works
- [ ] napi-rs bindings build - [ ] napi-rs bindings build
- [ ] Can import in TypeScript - [ ] Can import in TypeScript
- [ ] Mock bills trigger events - [ ] Mock bills trigger events
- [ ] NV200 driver tested with real hardware (optional) - [ ] 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 ## Development Hardware
### Recommended Dev Kit ### Recommended Dev Kit (One-Way: Cash-In Only)
| Component | Model | Purpose | Est. Cost | | Component | Model | Purpose | Est. Cost |
|-----------|-------|---------|-----------| |-----------|-------|---------|-----------|
@ -1549,6 +1736,36 @@ export function useFleet() {
| NFC Reader | ACR122U | BOLT card testing | $35 | | NFC Reader | ACR122U | BOLT card testing | $35 |
| **Total** | | | **~$535-735** | | **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 ### Mock-First Development
For most development, mocks are sufficient: For most development, mocks are sufficient: