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:**
|
**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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue