Documentation Index
Fetch the complete documentation index at: https://docs.laportenard.com/llms.txt
Use this file to discover all available pages before exploring further.
The hub server is a standalone Node.js service designed to run on a Raspberry Pi (or any ARM64/AMD64 machine) inside the restaurant’s local network. It coordinates all POS terminals via WebSocket, persists state in SQLite, and forwards events to Odoo in the background.
Package: nu_pos_hub/
Startup sequence
nu_pos_hub/src/index.ts initializes all components in order:
- Load configuration from environment
- Initialize SQLite database (WAL mode)
- Create storage layers (orders, leases, events, tables, table locks)
- Create lease manager with revoke callback
- Create table lock manager with revoke callback
- Create Odoo adapter and forwarder
- Wire up message handlers
- Start HTTP server (health, status, events, dashboard)
- Start WebSocket server (attached to HTTP server)
- Start periodic tasks (lease tick, forwarder, status broadcast)
- Start mDNS advertisement (if enabled)
- Register graceful shutdown handlers (SIGTERM, SIGINT)
Configuration
nu_pos_hub/src/config.ts — All settings via environment variables:
| Variable | Default | Description |
|---|
HUB_PORT | 8765 | WebSocket server port |
HUB_HTTP_PORT | 8766 | HTTP server port (health, dashboard) |
HUB_DB_PATH | ./hub.sqlite | SQLite database file path |
ODOO_URL | http://localhost:8069 | Odoo instance URL |
ODOO_DB | pos | Odoo database name |
ODOO_USER | admin | Odoo admin username for hub auth |
ODOO_PASSWORD | admin | Odoo admin password |
HUB_LEASE_TTL_MS | 60000 | Lease validity (ms) |
HUB_HEARTBEAT_MS | 20000 | Heartbeat / tick interval (ms) |
HUB_GRACE_MS | 10000 | Disconnect grace period (ms) |
HUB_FORWARD_INTERVAL_MS | 5000 | Odoo drain interval (ms) |
HUB_FORWARD_BATCH_SIZE | 20 | Max events per drain batch |
HUB_MDNS_ENABLED | true | Advertise via mDNS |
HUB_LOG_LEVEL | info | Pino log level |
Storage layer
All stores use the same SQLite database with WAL mode for concurrent reads.
SQLite schema
CREATE TABLE orders (
pos_reference TEXT PRIMARY KEY,
version INTEGER NOT NULL DEFAULT 1,
snapshot TEXT NOT NULL, -- JSON OrderSnapshot
table_id INTEGER,
terminal_id TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE leases (
pos_reference TEXT PRIMARY KEY,
terminal_id TEXT NOT NULL,
uid INTEGER,
role TEXT,
version INTEGER NOT NULL DEFAULT 0,
granted_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
pos_reference TEXT,
terminal_id TEXT,
payload TEXT, -- JSON
forwarded INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_events_forwarded ON events(forwarded, id);
CREATE TABLE tables (
table_id INTEGER PRIMARY KEY,
status TEXT DEFAULT 'available',
pos_reference TEXT,
terminal_id TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE table_locks (
table_id INTEGER PRIMARY KEY,
terminal_id TEXT NOT NULL,
uid INTEGER,
user_name TEXT,
granted_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
SQLite pragmas
PRAGMA journal_mode = WAL; -- Concurrent readers during writes
PRAGMA busy_timeout = 5000; -- Wait 5s on lock contention
PRAGMA synchronous = NORMAL; -- Balanced durability/performance
PRAGMA foreign_keys = ON;
Store APIs
| Store | Key operations |
|---|
orderStore | upsert(ref, snapshot, terminalId) auto-increments version; get(ref), getByTable(id), count() |
leaseStore | grant(), renew(), release(), getExpired(now), getByTerminal(id), listAll() |
eventLog | append(type, ref, terminalId, payload), getPending(limit), markForwarded(ids), getRecent(limit), count(forwarded?) |
tableStore | upsert(tableId, status, ref?, terminalId?), get(tableId), getAll() |
tableLockStore | grant(), renew(), release(), getExpired(now), getByTerminal(id), listAll() |
Transport-level PING/PONG
The WebSocket server handles {"type":"PING"} messages at the transport level before routing to protocol handlers. It responds immediately with {"type":"PONG"}. This keeps connections alive through proxies and NAT without requiring protocol-level message definitions.
Message handlers
Auth handler
handlers/authHandler.ts
- Receives raw WebSocket +
AUTH message
- Validates session token against Odoo:
POST /pos-react/api/session with the token as cookie
- On success: creates
TerminalInfo, registers in terminal registry, sends AUTH_OK
- On failure: sends
AUTH_FAIL, connection is not registered
Lease handler
handlers/leaseHandler.ts — Delegates to LeaseManager (see leasing):
LEASE_ACQUIRE → leaseManager.acquire() → sends LEASE_GRANTED or LEASE_DENIED
LEASE_RELEASE → leaseManager.release() → appends lease_release event
LEASE_HEARTBEAT → leaseManager.heartbeat() → renews lease expiry
Order handler
handlers/orderHandler.ts — The core sync handler:
- Verify terminal holds lease for
posReference
- Check
baseVersion >= storedVersion (else send CONFLICT)
- Upsert order in SQLite (version increments)
- Append
order_snapshot event to log (forwarded=0)
- Broadcast
ORDER_UPDATED to all terminals except sender
- If
snapshot.tableId is set: update table status (occupied, or available if paid)
Table lock handler
handlers/tableLockHandler.ts — Delegates to TableLockManager (see table locking):
TABLE_LOCK_ACQUIRE → grants or denies, broadcasts TABLE_LOCKS_STATE on grant
TABLE_LOCK_RELEASE → broadcasts TABLE_LOCK_RELEASED
TABLE_LOCK_HEARTBEAT → sends TABLE_LOCK_REVOKED on failure
TABLE_LOCK_FORCE_ACQUIRE → Server-side role check → evicts current holder, grants to requester
Floor plan handler
handlers/floorPlanHandler.ts — Broadcasts floor plan changes to other terminals:
FLOOR_PLAN_UPDATE → Broadcasts FLOOR_PLAN_CHANGED to all terminals except sender (includes floorId, tables, layoutMode?)
Terminal registry
server/terminalRegistry.ts — In-memory map of connected terminals:
interface TerminalInfo {
terminalId: string;
deviceId: string;
uid: number;
role: string;
name: string;
ws: WebSocket;
lastSeen: number;
}
Key methods: register(), remove(), send(), broadcast(), count(), getAll().
Odoo adapter
upstream/odooAdapter.ts — Converts snapshots to Odoo format and forwards.
| Snapshot field | Odoo payload field |
|---|
posReference | pos_reference |
lines[].productId | lines[].product_id (parsed to int) |
lines[].quantity | lines[].qty |
lines[].unitPrice | lines[].price_unit |
lines[].taxIds | lines[].tax_ids (parsed to int[]) |
lines[].id | lines[].client_line_id |
totals.total | amount_total |
totals.tax | amount_tax |
totals.subtotal | amount_untaxed |
tableId | table_id |
partnerId | partner.partner_id (parsed to int) |
paymentLines | statement_ids |
Voided lines (isVoided: true) are filtered out before forwarding.
Endpoint selection
- New orders (no
odooId): POST /pos-react/api/orders/create
- Existing orders (has
odooId): POST /pos-react/api/orders/update
Both endpoints are idempotent via pos_reference.
HTTP endpoints
| Endpoint | Method | Purpose |
|---|
/health | GET | Hub health: status, uptime, terminal count, orders, pending/forwarded events |
/status | GET | Connected terminals list, active leases list, pending count |
/events | GET | Last 50 events with type, reference, forwarded status, timestamp |
/orders | GET | Active orders with reference, table, status, item count, version |
/tables | GET | Table statuses with current order reference and terminal |
/table-locks | GET | Active table locks with holder name, terminal, expiry |
/ticket-sequences | GET | Today’s ticket sequences by order type (takeout, delivery) |
/reset | POST | Reset all SQLite data |
/ or /dashboard | GET | Self-contained monitoring dashboard |
* | OPTIONS | CORS preflight |
Periodic tasks
| Task | Interval | Action |
|---|
| Lease tick | 20s | Expire stale leases, send LEASE_REVOKED |
| Table lock tick | 15s | Expire stale table locks, send TABLE_LOCK_REVOKED |
| Event forwarder | 5s | Drain pending events to Odoo |
| Status broadcast | 30s | Send SYNC_STATUS to all terminals |
Graceful shutdown
On SIGTERM or SIGINT:
- Stop all periodic timers
- Stop forwarder
- Stop mDNS
- Close HTTP server
- Close SQLite database
- Exit process
File map
nu_pos_hub/src/
├── index.ts # Entry point, wires everything together
├── config.ts # Environment-based configuration
├── storage/
│ ├── db.ts # SQLite init, schema, WAL mode
│ ├── orderStore.ts # Order CRUD with versioning
│ ├── leaseStore.ts # Lease CRUD with expiry
│ ├── eventLog.ts # Append-only event log
│ ├── tableStore.ts # Table status CRUD
│ └── tableLockStore.ts # Table lock CRUD with expiry
├── leasing/
│ ├── leaseManager.ts # Lease business logic
│ └── tableLockManager.ts # Table lock business logic
├── server/
│ ├── httpServer.ts # Health, status, events, dashboard endpoints
│ ├── wsServer.ts # WebSocket connection handler
│ ├── messageRouter.ts # JSON parse, validate, route
│ └── terminalRegistry.ts # In-memory terminal map
├── handlers/
│ ├── authHandler.ts # AUTH message handler
│ ├── leaseHandler.ts # LEASE_* message handler
│ ├── orderHandler.ts # ORDER_SNAPSHOT handler
│ ├── tableHandler.ts # TABLE_UPDATE handler
│ ├── tableLockHandler.ts # TABLE_LOCK_* message handler
│ └── floorPlanHandler.ts # FLOOR_PLAN_UPDATE handler
├── upstream/
│ ├── types.ts # UpstreamAdapter interface
│ ├── odooAdapter.ts # Odoo HTTP client
│ └── forwarder.ts # Background event drain
├── discovery/
│ └── mdns.ts # Bonjour/mDNS advertisement
├── dashboard/
│ └── html.ts # Self-contained HTML dashboard
└── cli/
└── index.ts # CLI: stats, drain, events, reset-leases, reset-data