feat: add device configuration for HAL drivers (#20)

- Create src/config/device.ts with machine presets (Sintra, Gaia)
- Support environment variable overrides (VITE_LAMASSU_*)
- Add initializeForProduction() convenience method to ATM store
- Document configuration in docs/device-configuration.md

Default Sintra configuration:
- Validator: /dev/ttyJ5 (ID003 protocol)
- Dispenser: /dev/ttyJ7 (F56)
- Cassettes: [{ denomination: 20, count: 50 }]

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-29 21:25:27 -05:00
commit a8cfaaf66f
4 changed files with 456 additions and 0 deletions

View file

@ -0,0 +1,217 @@
/**
* Device Configuration
*
* Defines hardware configuration for ATM devices (bill validator, dispenser).
* Supports multiple machine models with sensible defaults.
*
* Configuration priority (highest to lowest):
* 1. Runtime overrides (passed directly to functions)
* 2. Environment variables (LAMASSU_*)
* 3. Machine preset defaults
*/
import type { HalConfig, CassetteConfig } from '@/services/hal'
/**
* Supported machine models
*/
export type MachineModel = 'sintra' | 'gaia' | 'custom'
/**
* Full device configuration
*/
export interface DeviceConfig {
/** Machine model name */
model: MachineModel
/** Fiat currency code (ISO 4217) */
fiatCode: string
/** Bill validator configuration */
validator: {
/** Validator protocol type */
type: 'id003'
/** Serial device path(s) */
device: string | string[]
}
/** Bill dispenser configuration */
dispenser: {
/** Dispenser model */
type: 'f56'
/** Serial device path */
device: string
/** Cassette configuration */
cassettes: CassetteConfig[]
}
}
/**
* Preset configurations for known machine models
*/
export const MACHINE_PRESETS: Record<MachineModel, Omit<DeviceConfig, 'fiatCode'>> = {
/**
* Lamassu Sintra (Gen 2)
* - Platform: Aaeon UP Board (Intel Atom x5-Z8350)
* - Validator: JCM iVIZION (ID003 protocol)
* - Dispenser: Fujitsu F56
*/
sintra: {
model: 'sintra',
validator: {
type: 'id003',
device: '/dev/ttyJ5',
},
dispenser: {
type: 'f56',
device: '/dev/ttyJ7',
cassettes: [{ denomination: 20, count: 50 }],
},
},
/**
* Lamassu Gaia
* - Validator: JCM iVIZION (ID003 protocol)
* - Dispenser: Fujitsu F56
* Note: Device paths may vary - verify on hardware
*/
gaia: {
model: 'gaia',
validator: {
type: 'id003',
device: '/dev/ttyUSB0',
},
dispenser: {
type: 'f56',
device: '/dev/ttyUSB1',
cassettes: [{ denomination: 20, count: 50 }],
},
},
/**
* Custom configuration - all values must be provided via env/runtime
*/
custom: {
model: 'custom',
validator: {
type: 'id003',
device: '/dev/ttyUSB0',
},
dispenser: {
type: 'f56',
device: '/dev/ttyUSB1',
cassettes: [],
},
},
}
/**
* Get device configuration for a machine model
*
* @param model - Machine model or 'custom' for manual config
* @param fiatCode - Fiat currency code (default: 'USD')
* @param overrides - Optional overrides for specific values
*/
export function getDeviceConfig(
model: MachineModel = 'sintra',
fiatCode: string = 'USD',
overrides?: Partial<DeviceConfig>
): DeviceConfig {
const preset = MACHINE_PRESETS[model]
const config: DeviceConfig = {
...preset,
fiatCode,
validator: { ...preset.validator },
dispenser: { ...preset.dispenser, cassettes: [...preset.dispenser.cassettes] },
}
// Apply overrides
if (overrides) {
if (overrides.fiatCode) config.fiatCode = overrides.fiatCode
if (overrides.validator?.device) config.validator.device = overrides.validator.device
if (overrides.dispenser?.device) config.dispenser.device = overrides.dispenser.device
if (overrides.dispenser?.cassettes) config.dispenser.cassettes = overrides.dispenser.cassettes
}
return config
}
/**
* Load device configuration from environment variables
*
* Supported variables:
* - LAMASSU_MACHINE_MODEL: Machine model preset (sintra, gaia, custom)
* - LAMASSU_FIAT_CODE: Fiat currency code (USD, EUR, etc.)
* - LAMASSU_VALIDATOR_DEVICE: Bill validator serial device path
* - LAMASSU_DISPENSER_DEVICE: Bill dispenser serial device path
* - LAMASSU_CASSETTES: JSON array of cassette configs
*
* @example
* LAMASSU_MACHINE_MODEL=sintra
* LAMASSU_FIAT_CODE=USD
* LAMASSU_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
*/
export function loadDeviceConfigFromEnv(): DeviceConfig {
const model = (import.meta.env.VITE_LAMASSU_MACHINE_MODEL as MachineModel) || 'sintra'
const fiatCode = import.meta.env.VITE_LAMASSU_FIAT_CODE || 'USD'
const overrides: Partial<DeviceConfig> = {}
// Validator device override
const validatorDevice = import.meta.env.VITE_LAMASSU_VALIDATOR_DEVICE
if (validatorDevice) {
overrides.validator = { type: 'id003', device: validatorDevice }
}
// Dispenser device override
const dispenserDevice = import.meta.env.VITE_LAMASSU_DISPENSER_DEVICE
if (dispenserDevice) {
overrides.dispenser = {
type: 'f56',
device: dispenserDevice,
cassettes: [],
}
}
// Cassettes override (JSON)
const cassettesJson = import.meta.env.VITE_LAMASSU_CASSETTES
if (cassettesJson) {
try {
const cassettes = JSON.parse(cassettesJson) as CassetteConfig[]
if (overrides.dispenser) {
overrides.dispenser.cassettes = cassettes
} else {
overrides.dispenser = {
type: 'f56',
device: MACHINE_PRESETS[model].dispenser.device,
cassettes,
}
}
} catch (e) {
console.error('[Config] Failed to parse LAMASSU_CASSETTES:', e)
}
}
return getDeviceConfig(model, fiatCode, overrides)
}
/**
* Convert DeviceConfig to HalConfig format
*/
export function toHalConfig(config: DeviceConfig): HalConfig {
return {
validator: {
type: config.validator.type,
device: config.validator.device,
fiatCode: config.fiatCode,
},
dispenser: {
type: config.dispenser.type,
device: config.dispenser.device,
cassettes: config.dispenser.cassettes,
},
}
}
/**
* Default export: Load config from environment with Sintra defaults
*/
export const deviceConfig = loadDeviceConfigFromEnv()

View file

@ -0,0 +1,15 @@
/**
* Configuration module
*
* Exports device configuration and utilities.
*/
export {
deviceConfig,
getDeviceConfig,
loadDeviceConfigFromEnv,
toHalConfig,
MACHINE_PRESETS,
type DeviceConfig,
type MachineModel,
} from './device'

View file

@ -11,6 +11,7 @@ import {
} from '@lamassu/state-machine'
import { initializeLightningServices } from '@/services/lightning'
import type { HalConfig, HalServices } from '@/services/hal'
import { deviceConfig, toHalConfig } from '@/config'
import type { LightningPubClient } from '@lamassu/lightning'
import type { CLINKClient } from '@lamassu/clink'
@ -389,6 +390,31 @@ export const useAtmStore = defineStore('atm', () => {
}
}
/**
* Initialize for production using device configuration
*
* Loads device config from environment variables (with Sintra defaults)
* and initializes HAL hardware + Lightning services.
*
* Environment variables (optional, have sensible defaults):
* - VITE_LAMASSU_MACHINE_MODEL: sintra | gaia | custom
* - VITE_LAMASSU_FIAT_CODE: USD, EUR, etc.
* - VITE_LAMASSU_VALIDATOR_DEVICE: /dev/ttyJ5
* - VITE_LAMASSU_DISPENSER_DEVICE: /dev/ttyJ7
* - VITE_LAMASSU_CASSETTES: JSON array of cassette configs
*/
async function initializeForProduction() {
console.log('[ATM] Initializing for production...')
console.log('[ATM] Machine model:', deviceConfig.model)
console.log('[ATM] Fiat currency:', deviceConfig.fiatCode)
console.log('[ATM] Validator device:', deviceConfig.validator.device)
console.log('[ATM] Dispenser device:', deviceConfig.dispenser.device)
console.log('[ATM] Cassettes:', deviceConfig.dispenser.cassettes)
const halConfig = toHalConfig(deviceConfig)
await initializeWithHal(halConfig)
}
function send(event: Parameters<NonNullable<typeof actor.value>['send']>[0]) {
if (!actor.value) {
console.error('[ATM] Cannot send event: machine not initialized')
@ -480,6 +506,7 @@ export const useAtmStore = defineStore('atm', () => {
initialize,
initializeWithLightning,
initializeWithHal,
initializeForProduction,
send,
selectCashIn,
selectCashOut,

View file

@ -0,0 +1,197 @@
# Device Configuration
This document describes how to configure the ATM hardware for different machine models.
## Overview
The ATM application supports multiple Lamassu machine models out of the box. Configuration is handled through:
1. **Machine presets** - Built-in defaults for known hardware (Sintra, Gaia)
2. **Environment variables** - Override any setting at runtime
3. **Runtime overrides** - Programmatic configuration
## Supported Machine Models
### Sintra (Default)
The Lamassu Sintra (Gen 2) uses:
| Device | Protocol | Path |
| -------------- | -------- | ------------ |
| Bill Validator | ID003 | `/dev/ttyJ5` |
| Bill Dispenser | F56 | `/dev/ttyJ7` |
| Printer | Nippon | `/dev/ttyJ4` |
**Hardware:**
- Platform: Aaeon UP Board (Intel Atom x5-Z8350)
- Validator: JCM iVIZION
- Dispenser: Fujitsu F53/F56
### Gaia
The Lamassu Gaia uses similar hardware with different device paths. Verify paths on your specific unit.
## Environment Variables
All configuration can be overridden via environment variables. For Vite/Electron, prefix with `VITE_`:
| Variable | Description | Default |
| ------------------------------- | ---------------------------- | ------------- |
| `VITE_LAMASSU_MACHINE_MODEL` | Machine preset | `sintra` |
| `VITE_LAMASSU_FIAT_CODE` | Fiat currency (ISO 4217) | `USD` |
| `VITE_LAMASSU_VALIDATOR_DEVICE` | Validator serial device path | (from preset) |
| `VITE_LAMASSU_DISPENSER_DEVICE` | Dispenser serial device path | (from preset) |
| `VITE_LAMASSU_CASSETTES` | JSON array of cassettes | (from preset) |
### Example: Custom Cassette Configuration
```bash
# Two cassettes: $20 bills (100 count) and $50 bills (50 count)
export VITE_LAMASSU_CASSETTES='[{"denomination":20,"count":100},{"denomination":50,"count":50}]'
```
### Example: Custom Device Paths
```bash
export VITE_LAMASSU_VALIDATOR_DEVICE="/dev/ttyUSB0"
export VITE_LAMASSU_DISPENSER_DEVICE="/dev/ttyUSB1"
```
## Cassette Configuration
Each cassette is defined with:
```typescript
interface CassetteConfig {
denomination: number // Bill denomination (e.g., 20 for $20)
count?: number // Number of bills loaded (optional, for inventory tracking)
}
```
### Default Sintra Configuration
```json
[{ "denomination": 20, "count": 50 }]
```
This configures a single cassette with $20 bills, 50 bill capacity.
### Multi-Cassette Example
```json
[
{ "denomination": 20, "count": 100 },
{ "denomination": 50, "count": 50 },
{ "denomination": 100, "count": 25 }
]
```
## Programmatic Configuration
You can also configure devices programmatically:
```typescript
import { getDeviceConfig, toHalConfig } from '@/config'
// Use preset with custom fiat code
const config = getDeviceConfig('sintra', 'EUR')
// Or with overrides
const config = getDeviceConfig('sintra', 'USD', {
dispenser: {
type: 'f56',
device: '/dev/ttyUSB1',
cassettes: [
{ denomination: 20, count: 100 },
{ denomination: 50, count: 50 },
],
},
})
// Convert to HAL config format
const halConfig = toHalConfig(config)
```
## Initialization
### For Production (Recommended)
The simplest way to initialize with hardware:
```typescript
import { useAtmStore } from '@/stores/atm'
const atm = useAtmStore()
// Uses environment variables with Sintra defaults
await atm.initializeForProduction()
```
### With Custom Config
```typescript
import { useAtmStore } from '@/stores/atm'
import { getDeviceConfig, toHalConfig } from '@/config'
const atm = useAtmStore()
const config = getDeviceConfig('sintra', 'USD', {
dispenser: {
type: 'f56',
device: '/dev/ttyJ7',
cassettes: [{ denomination: 20, count: 100 }],
},
})
await atm.initializeWithHal(toHalConfig(config))
```
## Verifying Device Paths
On a Sintra running Linux, verify the serial devices exist:
```bash
ls -la /dev/ttyJ*
```
Expected output:
```
lrwxrwxrwx 1 root root 10 Jan 29 12:00 /dev/ttyJ4 -> ttyS4
lrwxrwxrwx 1 root root 10 Jan 29 12:00 /dev/ttyJ5 -> ttyS5
lrwxrwxrwx 1 root root 10 Jan 29 12:00 /dev/ttyJ7 -> ttyS7
```
If the symlinks don't exist, check the udev rules or use the underlying `/dev/ttyS*` devices directly.
## Troubleshooting
### Permission Denied on Serial Port
Add your user to the `dialout` group:
```bash
sudo usermod -a -G dialout $USER
# Log out and back in for changes to take effect
```
### Device Not Found
1. Check the device exists: `ls -la /dev/ttyJ*` or `ls -la /dev/ttyS*`
2. Check dmesg for hardware detection: `dmesg | grep tty`
3. Verify udev rules are loaded: `udevadm info /dev/ttyS5`
### Validator Not Responding
1. Verify baud rate: ID003 uses 9600 baud, 8 data bits, even parity, 1 stop bit
2. Check cable connections
3. Power cycle the validator
4. Check validator is in "online" mode (not standalone)
### Dispenser Not Dispensing
1. Verify cassette is properly seated
2. Check for bill jams
3. Verify dispenser firmware is compatible with F56 protocol
4. Check the dispenser is powered and initialized