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:
parent
b80f174b08
commit
05c6d9827c
1 changed files with 225 additions and 8 deletions
|
|
@ -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:**
|
||||
```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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue