barcode scanner beginnings

This commit is contained in:
legop3
2026-06-09 14:27:48 -04:00
parent 5dca976f97
commit 5f00d8c715
15 changed files with 616 additions and 134 deletions
+1
View File
@@ -40,6 +40,7 @@ require('./src/services/liftService');
require('./src/services/audioLevelsService');
require('./src/services/audioForwardService');
require('./src/services/buttonBoxService');
require('./src/services/barcodeScannerService');
require('./src/services/kinectService');
require('./src/services/sessionService');
require('./src/services/batteryManager');
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CjC9M_J1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D6YORRLd.css">
<script type="module" crossorigin src="/assets/index-DXjLhK2k.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-lG2-eOU9.css">
</head>
<body>
<div id="root"></div>
@@ -0,0 +1,246 @@
// Barcode Scanner Service
// Purpose: Owns the server-side registry and runtime state for the rover-operated barcode scanner station.
// Scope: Keeps barcode meaning, access-mode gates, and scan result formatting on the server so the scanner page stays IO-only.
const fs = require('fs');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeScannerService');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { getMode, MODES, modeEvents } = require('../modeManager');
const DATA_DIR = resolveDataDir();
const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
const RECENT_SCAN_LIMIT = 8;
const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/;
let lastKnownGoodRegistry = null;
let lastRegistryError = null;
let state = {
lastScan: null,
recentScans: [],
registryError: null,
};
function isBeepAllowed() {
const mode = getMode();
// The page asks the server whether beeping is appropriate because the access
// policy belongs with the rest of the server mode logic. Open and turns are
// public access modes; admin and lockdown are closed modes where the scanner
// should still accept input silently for operator testing.
return mode === MODES.OPEN || mode === MODES.TURNS;
}
function normalizeCode(input) {
// Scanners behave like keyboards, but different models can append whitespace
// or vary casing. The scanner registry is intentionally lowercase and fixed
// width so physical labels can remain short and easy for rover cameras/scanner
// optics to read.
return String(input || '').trim().toLowerCase();
}
function createDefaultRegistry() {
return {
codes: {
r001: {
type: 'rover',
entityId: 'rover1',
label: 'rover 1',
},
o001: {
type: 'object',
entityId: 'object1',
label: 'object 1',
},
},
};
}
function ensureRegistryFile() {
if (fs.existsSync(REGISTRY_PATH)) return;
// The registry file is created only when missing so future local edits remain
// fully operator-owned. This gives the scanner system a working first-run
// setup without silently overwriting live barcode assignments.
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.writeFileSync(REGISTRY_PATH, `${JSON.stringify(createDefaultRegistry(), null, 2)}\n`, 'utf8');
}
function validateRegistry(rawRegistry) {
if (!rawRegistry || typeof rawRegistry !== 'object' || Array.isArray(rawRegistry)) {
throw new Error('registry root must be an object');
}
if (!rawRegistry.codes || typeof rawRegistry.codes !== 'object' || Array.isArray(rawRegistry.codes)) {
throw new Error('registry.codes must be an object');
}
const normalizedCodes = {};
Object.entries(rawRegistry.codes).forEach(([rawCode, rawEntry]) => {
const code = normalizeCode(rawCode);
if (!VALID_CODE_PATTERN.test(code)) {
throw new Error(`invalid barcode id "${rawCode}"`);
}
if (!rawEntry || typeof rawEntry !== 'object' || Array.isArray(rawEntry)) {
throw new Error(`registry entry ${code} must be an object`);
}
const type = String(rawEntry.type || '').trim().toLowerCase();
if (type !== 'rover' && type !== 'object') {
throw new Error(`registry entry ${code} has unsupported type "${rawEntry.type}"`);
}
const entityId = String(rawEntry.entityId || '').trim();
const label = String(rawEntry.label || '').replace(/\s+/g, ' ').trim();
if (!entityId) {
throw new Error(`registry entry ${code} needs entityId`);
}
if (!label) {
throw new Error(`registry entry ${code} needs label`);
}
normalizedCodes[code] = {
type,
entityId,
label,
};
});
return { codes: normalizedCodes };
}
function loadRegistryForScan() {
try {
ensureRegistryFile();
const raw = fs.readFileSync(REGISTRY_PATH, 'utf8');
const parsed = JSON.parse(raw);
const registry = validateRegistry(parsed);
lastKnownGoodRegistry = registry;
lastRegistryError = null;
return { registry, error: null };
} catch (err) {
lastRegistryError = err.message;
logger.warn('Failed to reload barcode registry; keeping last valid registry if available', {
path: REGISTRY_PATH,
error: err.message,
});
return {
registry: lastKnownGoodRegistry,
error: err.message,
};
}
}
function buildStatePayload() {
return {
mode: getMode(),
beepAllowed: isBeepAllowed(),
lastScan: state.lastScan,
recentScans: state.recentScans,
registryError: state.registryError,
};
}
function broadcastState() {
io.emit('barcode:state', buildStatePayload());
}
function resolveScan(rawCode) {
const code = normalizeCode(rawCode);
const loaded = loadRegistryForScan();
const registry = loaded.registry;
const scannedAt = Date.now();
if (!VALID_CODE_PATTERN.test(code)) {
return {
code,
known: false,
type: null,
entityId: null,
label: 'unknown',
speechText: 'unknown',
scannedAt,
registryError: loaded.error || null,
error: code ? 'invalid barcode format' : 'empty barcode',
};
}
if (!registry) {
return {
code,
known: false,
type: null,
entityId: null,
label: 'unknown',
speechText: 'unknown',
scannedAt,
registryError: loaded.error || 'barcode registry unavailable',
error: 'barcode registry unavailable',
};
}
const entry = registry.codes[code] || null;
if (!entry) {
return {
code,
known: false,
type: null,
entityId: null,
label: 'unknown',
speechText: 'unknown',
scannedAt,
registryError: loaded.error || null,
error: null,
};
}
return {
code,
known: true,
type: entry.type,
entityId: entry.entityId,
label: entry.label,
speechText: entry.label,
scannedAt,
registryError: loaded.error || null,
error: null,
};
}
function applyScan(rawCode) {
const result = resolveScan(rawCode);
// The latest result is the canonical display state. Recent scans are retained
// only for debugging and future scanner-page variants; the rover-facing first
// pass can ignore them and simply render lastScan.
state = {
lastScan: result,
recentScans: [result, ...state.recentScans].slice(0, RECENT_SCAN_LIMIT),
registryError: result.registryError || null,
};
broadcastState();
return result;
}
io.on('connection', (socket) => {
socket.emit('barcode:state', buildStatePayload());
socket.on('barcode:scan', ({ code } = {}, cb = () => {}) => {
try {
const result = applyScan(code);
cb({ success: true, result, state: buildStatePayload() });
} catch (err) {
// Socket handlers should never let a malformed scan or registry edge case
// bubble out to the process. The page gets a normal failed acknowledgement
// and the service keeps running for the next scan.
logger.warn('Barcode scan failed unexpectedly', err);
cb({ error: err.message || 'barcode scan failed' });
}
});
});
modeEvents.on('change', () => {
// Access-mode changes affect whether the scanner page should beep when it
// submits a code, so scanner clients need a fresh state packet even without a
// new scan.
broadcastState();
});
loadRegistryForScan();
module.exports = {
REGISTRY_PATH,
applyScan,
buildStatePayload,
};
@@ -4,7 +4,7 @@
const { app } = require('../../globals/http');
const { renderIndexHtml, renderOgImage } = require('../embedService');
app.get(['/', '/spectate', '/mini', '/display'], async (req, res) => {
app.get(['/', '/spectate', '/mini', '/display', '/scanner'], async (req, res) => {
try {
const html = await renderIndexHtml(req);
res.type('html').send(html);