mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
barcode scanner beginnings
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
1. Overseer personality swapping.
|
||||
2. make rs commands work from site chat, not just discord
|
||||
3. FIX RS BRIDGE DISABLE FOR NON ADMINS AAAAAH
|
||||
2. make mobile joystick more betterer
|
||||
3. make rs commands work from site chat, not just discord
|
||||
4. make google tts the default everywhere but roverd
|
||||
5. fix rover request spam queue cheat
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function RoverLabel({
|
||||
'inline-block rounded border border-transparent px-1 py-[1px] font-semibold text-white',
|
||||
className,
|
||||
)}
|
||||
style={{ ...(style || {}), ...(roverNameChromeStyle(resolvedColor, 0.26) || {}) }}
|
||||
style={{ ...(style || {}), ...(roverNameChromeStyle(resolvedColor, 1) || {}) }}
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function DisplayChatFeed() {
|
||||
const displayChatScale = 4.6;
|
||||
|
||||
return (
|
||||
<section className="h-full min-h-0 overflow-hidden border-t border-slate-800 bg-black p-[0.45vw]">
|
||||
<section className="h-full min-h-0 overflow-hidden border-t border-slate-800 bg-black">
|
||||
<div
|
||||
className="origin-top-left"
|
||||
style={{
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ChatProvider } from './context/ChatContext.jsx'
|
||||
import SpectatorApp from './spectate/SpectatorApp/SpectatorAppRoot.jsx'
|
||||
import MiniSummaryApp from './mini/MiniSummaryApp/MiniSummaryAppRoot.jsx'
|
||||
import ServerDisplayApp from './display/ServerDisplayApp/ServerDisplayAppRoot.jsx'
|
||||
import ScannerApp from './scanner/ScannerApp/ScannerAppRoot.jsx'
|
||||
import { SettingsProvider } from './settings/index.js'
|
||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||
|
||||
@@ -29,6 +30,7 @@ createRoot(document.getElementById('root')).render(
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
<Route path="/scanner" element={<ScannerApp />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ChatProvider>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Scanner App Root
|
||||
// Purpose: Exposes the dedicated scanner route as a separate spectator-style app surface.
|
||||
// Scope: Keeps route wiring thin so scanner behavior remains isolated in ScannerContent.
|
||||
import ScannerContent from './ScannerContent.jsx';
|
||||
|
||||
export default function ScannerAppRoot() {
|
||||
return <ScannerContent />;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Scanner Content
|
||||
// Purpose: Provides the rover-facing IO page for the server-run barcode scanner system.
|
||||
// Scope: Captures keyboard-style scanner input, emits raw scans, renders server state, and plays local beep/TTS output.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
||||
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||
import useScannerSpeech from './useScannerSpeech.js';
|
||||
|
||||
const EMPTY_SCANNER_STATE = {
|
||||
beepAllowed: false,
|
||||
lastScan: null,
|
||||
registryError: null,
|
||||
};
|
||||
|
||||
function playSubmitBeep() {
|
||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
|
||||
const audioContext = new AudioContextClass();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gain = audioContext.createGain();
|
||||
const now = audioContext.currentTime;
|
||||
|
||||
// The beep is deliberately short and loud because it confirms that the scan
|
||||
// computer submitted input. It is not tied to success; success/failure comes
|
||||
// back from the server as the large display text and spoken label.
|
||||
oscillator.type = 'square';
|
||||
oscillator.frequency.setValueAtTime(980, now);
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.95, now + 0.015);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2);
|
||||
oscillator.connect(gain);
|
||||
gain.connect(audioContext.destination);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + 0.22);
|
||||
oscillator.onended = () => {
|
||||
audioContext.close().catch(() => {});
|
||||
};
|
||||
}
|
||||
|
||||
export default function ScannerContent() {
|
||||
const socket = useSocket();
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const inputRef = useRef(null);
|
||||
const [scannerState, setScannerState] = useState(EMPTY_SCANNER_STATE);
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
useDefaultNickname();
|
||||
useUserIdentitySync();
|
||||
useSpectatorMode();
|
||||
useScannerSpeech(scannerState.lastScan);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
focusInput();
|
||||
}, [focusInput]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleScannerState(nextState = {}) {
|
||||
setScannerState({
|
||||
...EMPTY_SCANNER_STATE,
|
||||
...(nextState && typeof nextState === 'object' ? nextState : {}),
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('barcode:state', handleScannerState);
|
||||
return () => {
|
||||
socket.off('barcode:state', handleScannerState);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
const submitScan = useCallback(() => {
|
||||
const code = String(inputRef.current?.value || '').trim();
|
||||
if (!code) {
|
||||
focusInput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (scannerState.beepAllowed) {
|
||||
playSubmitBeep();
|
||||
}
|
||||
|
||||
// The page intentionally sends only raw scanner text. The server reloads
|
||||
// the barcode registry, resolves labels/types, applies access policy, and
|
||||
// broadcasts the display state back to every scanner page.
|
||||
socket.emit('barcode:scan', { code }, () => {});
|
||||
inputRef.current.value = '';
|
||||
focusInput();
|
||||
}, [focusInput, scannerState.beepAllowed, socket]);
|
||||
|
||||
const lastScan = scannerState.lastScan;
|
||||
const label = lastScan?.label || 'waiting';
|
||||
const code = lastScan?.code || '';
|
||||
const statusText = !connected ? 'offline' : focused ? 'scanner ready' : 'click page';
|
||||
const showCode = Boolean(code && label !== 'waiting');
|
||||
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-screen cursor-default flex-col items-center justify-center overflow-hidden bg-black px-6 text-center text-white"
|
||||
onClick={focusInput}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
aria-label="barcode scanner input"
|
||||
className="absolute left-0 top-0 h-px w-px opacity-0"
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onBlur={() => setFocused(false)}
|
||||
onFocus={() => setFocused(true)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
submitScan();
|
||||
}}
|
||||
/>
|
||||
<section className="flex min-h-0 w-full flex-1 flex-col items-center justify-center">
|
||||
<h1 className="max-w-full break-words text-[6rem] font-black leading-none tracking-normal text-white md:text-[9rem] lg:text-[11rem]">
|
||||
{label}
|
||||
</h1>
|
||||
{showCode ? (
|
||||
<p className="mt-8 text-[3rem] font-bold leading-none tracking-normal text-white md:text-[4rem]">
|
||||
{code}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
<footer className="flex h-20 w-full items-center justify-center text-[2rem] font-bold leading-none tracking-normal text-white">
|
||||
{statusText}
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Scanner Speech Hook
|
||||
// Purpose: Speaks server-provided scan labels on the scanner computer.
|
||||
// Scope: Keeps browser TTS as a local output device while leaving scan meaning and phrase selection on the server.
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const SPEECH_START_TIMEOUT_MS = 1500;
|
||||
const SPEECH_RETRY_DELAY_MS = 700;
|
||||
|
||||
export default function useScannerSpeech(scan) {
|
||||
const retryTimerRef = useRef(null);
|
||||
const startTimerRef = useRef(null);
|
||||
const spokenScanKeyRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.clearTimeout(retryTimerRef.current);
|
||||
window.clearTimeout(startTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const speechText = String(scan?.speechText || '').trim();
|
||||
const scanKey = scan?.scannedAt ? `${scan.scannedAt}:${speechText}` : '';
|
||||
if (!speechText || !scanKey || spokenScanKeyRef.current === scanKey) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
spokenScanKeyRef.current = scanKey;
|
||||
|
||||
function clearSpeechTimers() {
|
||||
window.clearTimeout(retryTimerRef.current);
|
||||
window.clearTimeout(startTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
startTimerRef.current = null;
|
||||
}
|
||||
|
||||
function retryLater() {
|
||||
if (cancelled) return;
|
||||
clearSpeechTimers();
|
||||
retryTimerRef.current = window.setTimeout(() => speakOnce(), SPEECH_RETRY_DELAY_MS);
|
||||
}
|
||||
|
||||
function speakOnce() {
|
||||
if (cancelled) return;
|
||||
const synth = window.speechSynthesis;
|
||||
if (!synth || typeof window.SpeechSynthesisUtterance !== 'function') {
|
||||
retryLater();
|
||||
return;
|
||||
}
|
||||
|
||||
clearSpeechTimers();
|
||||
// Cancelling before retrying prevents stale utterances from piling up if a
|
||||
// browser reports an error or never fires the expected start callback.
|
||||
synth.cancel();
|
||||
const utterance = new window.SpeechSynthesisUtterance(speechText);
|
||||
utterance.rate = 0.92;
|
||||
utterance.pitch = 1;
|
||||
utterance.volume = 1;
|
||||
utterance.onstart = () => {
|
||||
window.clearTimeout(startTimerRef.current);
|
||||
startTimerRef.current = null;
|
||||
};
|
||||
utterance.onend = () => {
|
||||
clearSpeechTimers();
|
||||
};
|
||||
utterance.onerror = () => {
|
||||
retryLater();
|
||||
};
|
||||
synth.speak(utterance);
|
||||
|
||||
// Some browser/audio states fail without surfacing onerror. A small start
|
||||
// watchdog keeps the required scanner audio from silently dying, while the
|
||||
// UI remains clean and rover-readable.
|
||||
startTimerRef.current = window.setTimeout(() => {
|
||||
synth.cancel();
|
||||
retryLater();
|
||||
}, SPEECH_START_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
speakOnce();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearSpeechTimers();
|
||||
};
|
||||
}, [scan]);
|
||||
}
|
||||
Reference in New Issue
Block a user