@milpa/comal 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,240 @@
1
+ // /rc device scan: reads the M5Stack's pairing QR with a local camera, only while the person asked for it, 30 s at most
2
+ // in total (finding the camera included). One ffmpeg stays open for the scan (so focus and exposure settle) and writes
3
+ // 2 frames a second into a private 0700 directory; each completed frame is decoded with zbarimg and deleted right
4
+ // after, and the directory goes at the end (and on process exit). Nothing is kept and nothing leaves this machine.
5
+ // Processes are spawned with argv arrays (no shell). `spawn`, `now`, `sleep`, `readdir` (of /dev) and `tmpRoot` are
6
+ // injectable so tests run without a camera.
7
+ import { spawn as nodeSpawn } from 'node:child_process';
8
+ import { rmSync } from 'node:fs';
9
+ import { chmod, lstat, mkdtemp, readdir as fsReaddir, rm } from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { t, registerMessages } from './i18n/index.mjs';
13
+
14
+ /** The QR on the device's pairing screen: this prefix plus the code as shown, uppercase Crockford XXXX-XXXX.
15
+ * It carries nothing secret (the device's poll token never goes in it). Same constant as
16
+ * devices/m5stack/lib/comal_core/pairing.h (PAIR_QR_PREFIX). */
17
+ export const PAIR_QR_PREFIX = 'comal-pair:v1:';
18
+ const PAIR_QR = /^comal-pair:v1:([0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4})$/;
19
+ const CAMERA = /^\/dev\/video[0-9]+$/;
20
+ export const SCAN_TIMEOUT_MS = 30000;
21
+ export const SCAN_INTERVAL_MS = 250; // how often the private directory is looked at for completed frames
22
+ const STREAM_SIZE = '1280x720';
23
+ const STREAM_FPS = 2;
24
+ const PROBE_TIMEOUT_MS = 5000;
25
+ const GRAB_TIMEOUT_MS = 10000;
26
+ const FRAME_MAX_BYTES = 32 * 1024 * 1024;
27
+ const FRAME = /^f[0-9]{6}\.png$/;
28
+ const DIR_PREFIX = 'comal-qr-';
29
+ const DIR_SHAPE = /^comal-qr-[A-Za-z0-9]{6}$/; // what mkdtemp makes of DIR_PREFIX: only these are swept
30
+ const STALE_MS = 120000; // a scan touches its directory every half second; one untouched for 2 min is left over
31
+
32
+ registerMessages({
33
+ 'The camera must be a /dev/videoN device.': 'La cámara debe ser un dispositivo /dev/videoN.',
34
+ 'COMAL_CAMERA must be a /dev/videoN device.': 'COMAL_CAMERA debe ser un dispositivo /dev/videoN.',
35
+ 'No camera found (/dev/video*). Pass one with /rc device scan /dev/videoN, or type the code: /rc device XXXX-XXXX.': 'No se encontró una cámara (/dev/video*). Indica una con /rc device scan /dev/videoN, o escribe el código: /rc device XXXX-XXXX.',
36
+ 'Scanning needs ffmpeg and zbarimg. Install the ffmpeg and zbar packages, or type the code: /rc device XXXX-XXXX.': 'Escanear necesita ffmpeg y zbarimg. Instala los paquetes ffmpeg y zbar, o escribe el código: /rc device XXXX-XXXX.',
37
+ 'Could not read the camera {camera}: {reason}': 'No se pudo leer la cámara {camera}: {reason}',
38
+ 'No pairing QR seen in {seconds} s. Bring the M5Stack 10-20 cm from the camera, so the QR fills about a third of the picture, and try again, or type the code: /rc device XXXX-XXXX.': 'No se vio un QR de emparejamiento en {seconds} s. Acerca el M5Stack a 10-20 cm de la cámara, que el QR ocupe un tercio de la imagen, e inténtalo de nuevo, o escribe el código: /rc device XXXX-XXXX.',
39
+ 'QR scan cancelled; the camera is off.': 'Escaneo del QR cancelado; la cámara está apagada.',
40
+ });
41
+
42
+ /** zbarimg's stdout counts only when it is exactly one symbol: one matching line plus its newline. */
43
+ function onlyPairCode(stdout) {
44
+ const text = stdout.toString('utf8').replace(/\r?\n$/, '');
45
+ return /[\r\n]/.test(text) ? null : pairCodeFromQr(text);
46
+ }
47
+
48
+ /** The code in a pairing QR's text, or null for anything else (which is never echoed). */
49
+ export function pairCodeFromQr(text) {
50
+ const match = PAIR_QR.exec(typeof text === 'string' ? text : '');
51
+ return match ? match[1] : null;
52
+ }
53
+
54
+ export const isCameraPath = value => typeof value === 'string' && CAMERA.test(value);
55
+
56
+ const failure = (code, message) => Object.assign(new Error(message), { code });
57
+ const aborted = () => Object.assign(new Error(t('QR scan cancelled; the camera is off.')), { name: 'AbortError', code: 'ABORT_ERR' });
58
+ const missingTools = () => failure('QR_SCAN_TOOLS', t('Scanning needs ffmpeg and zbarimg. Install the ffmpeg and zbar packages, or type the code: /rc device XXXX-XXXX.'));
59
+ const timedOut = seconds => failure('QR_SCAN_TIMEOUT', t('No pairing QR seen in {seconds} s. Bring the M5Stack 10-20 cm from the camera, so the QR fills about a third of the picture, and try again, or type the code: /rc device XXXX-XXXX.', { seconds: String(seconds) }));
60
+ const oneLine = text => String(text || '').split(/\r?\n/).map(line => line.trim()).find(Boolean)?.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 200) || 'no frame';
61
+
62
+ /** Runs one process to its end: {code, stdout, stderr}. Killed on abort or after `timeoutMs`; ENOENT → missing tools. */
63
+ function run(spawn, cmd, args, { signal, timeoutMs = GRAB_TIMEOUT_MS } = {}) {
64
+ return new Promise((resolve, reject) => {
65
+ if (signal?.aborted) { reject(aborted()); return; }
66
+ let child;
67
+ try { child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false }); }
68
+ catch (error) { reject(error.code === 'ENOENT' ? missingTools() : error); return; }
69
+ const out = [], err = [];
70
+ let size = 0, errSize = 0, settled = false;
71
+ const settle = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener('abort', onAbort); fn(value); };
72
+ const kill = () => { try { child.kill('SIGKILL'); } catch {} };
73
+ const onAbort = () => { kill(); settle(reject, aborted()); };
74
+ const timer = setTimeout(() => { kill(); settle(resolve, { code: null, stdout: Buffer.alloc(0), stderr: 'timed out' }); }, timeoutMs);
75
+ timer.unref?.();
76
+ signal?.addEventListener('abort', onAbort, { once: true });
77
+ child.stdout?.on('data', chunk => { size += chunk.length; if (size > FRAME_MAX_BYTES) { kill(); settle(resolve, { code: null, stdout: Buffer.alloc(0), stderr: 'frame too large' }); } else out.push(chunk); });
78
+ child.stderr?.on('data', chunk => { if (errSize < 4096) { err.push(chunk); errSize += chunk.length; } });
79
+ child.on('error', error => settle(reject, error.code === 'ENOENT' ? missingTools() : error));
80
+ child.on('close', code => settle(resolve, { code, stdout: Buffer.concat(out), stderr: Buffer.concat(err).toString('utf8') }));
81
+ });
82
+ }
83
+
84
+ const grabArgs = device => ['-hide_banner', '-loglevel', 'error', '-f', 'v4l2', '-i', device, '-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', '-'];
85
+ const DECODE_ARGS = ['--raw', '-q', '-Sdisable', '-Sqrcode.enable'];
86
+
87
+ /** True when `v4l2-ctl --all` says this node itself captures video (Device Caps; Capabilities is the whole device's). */
88
+ function capturesVideo(text) {
89
+ const lines = String(text).split('\n');
90
+ const start = lines.findIndex(line => /^\s*Device Caps\s*:/.test(line));
91
+ if (start < 0) return false;
92
+ const indent = lines[start].match(/^\s*/)[0].length;
93
+ for (const line of lines.slice(start + 1)) {
94
+ if (!line.trim() || line.match(/^\s*/)[0].length <= indent) break;
95
+ if (/^\s*Video Capture(?: Multiplanar)?\s*$/.test(line)) return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ /** The camera: `camera` (argument) > env COMAL_CAMERA > the first /dev/videoN that v4l2-ctl reports as Video Capture
101
+ * (without v4l2-ctl: the first one ffmpeg can read a frame from). Anything that is not /dev/videoN is refused. */
102
+ export async function pickCamera({ camera, env = process.env, spawn = nodeSpawn, readdir = fsReaddir, signal, deadline = Infinity, now = Date.now, seconds = SCAN_TIMEOUT_MS / 1000 } = {}) {
103
+ if (camera !== undefined && camera !== null && camera !== '') {
104
+ if (!isCameraPath(camera)) throw failure('QR_SCAN_CAMERA_PATH', t('The camera must be a /dev/videoN device.'));
105
+ return camera;
106
+ }
107
+ const fromEnv = env?.COMAL_CAMERA?.trim();
108
+ if (fromEnv) {
109
+ if (!isCameraPath(fromEnv)) throw failure('QR_SCAN_CAMERA_PATH', t('COMAL_CAMERA must be a /dev/videoN device.'));
110
+ return fromEnv;
111
+ }
112
+ let names = [];
113
+ try { names = await readdir('/dev'); } catch {}
114
+ const candidates = names.filter(name => /^video[0-9]+$/.test(name)).sort((a, b) => Number(a.slice(5)) - Number(b.slice(5))).map(name => '/dev/' + name);
115
+ // Probing counts toward the scan's time: each probe gets what is left, and none starts past the deadline.
116
+ const left = limit => { const ms = Math.min(limit, deadline - now()); if (ms <= 0) throw timedOut(seconds); return ms; };
117
+ let haveV4l2 = true;
118
+ for (const device of candidates) {
119
+ if (haveV4l2) {
120
+ try {
121
+ const result = await run(spawn, 'v4l2-ctl', ['--device', device, '--all'], { signal, timeoutMs: left(PROBE_TIMEOUT_MS) });
122
+ if (result.code === 0 && capturesVideo(result.stdout.toString('utf8'))) return device;
123
+ continue;
124
+ } catch (error) {
125
+ if (error.name === 'AbortError' || error.code === 'QR_SCAN_TIMEOUT') throw error;
126
+ if (error.code !== 'QR_SCAN_TOOLS') continue;
127
+ haveV4l2 = false;
128
+ }
129
+ }
130
+ const result = await run(spawn, 'ffmpeg', grabArgs(device), { signal, timeoutMs: left(GRAB_TIMEOUT_MS) });
131
+ if (result.code === 0 && result.stdout.length) return device;
132
+ }
133
+ throw failure('QR_SCAN_NO_CAMERA', t('No camera found (/dev/video*). Pass one with /rc device scan /dev/videoN, or type the code: /rc device XXXX-XXXX.'));
134
+ }
135
+
136
+ const abortableSleep = (ms, signal) => new Promise((resolve, reject) => {
137
+ if (signal?.aborted) { reject(aborted()); return; }
138
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
139
+ const onAbort = () => { clearTimeout(timer); reject(aborted()); };
140
+ signal?.addEventListener('abort', onAbort, { once: true });
141
+ });
142
+
143
+ // How the camera is opened, in order, each tried when the one before ends without a frame: 1280x720 MJPEG (a UVC
144
+ // camera often has 1280x720 only as MJPEG and silently gives 640x480 raw frames for a bare -video_size), 1280x720 in
145
+ // its default format, then its own default size.
146
+ const STREAM_ATTEMPTS = [['-input_format', 'mjpeg', '-video_size', STREAM_SIZE], ['-video_size', STREAM_SIZE], []];
147
+ // `-t` (an input option) is the seconds left to the scan, rounded up: if Comal dies without its exit hook (SIGKILL, OOM,
148
+ // a crash) ffmpeg still ends itself, and turns the camera off, at the scan's deadline.
149
+ const streamArgs = (device, dir, attempt, seconds) => ['-hide_banner', '-loglevel', 'error', '-f', 'v4l2', ...STREAM_ATTEMPTS[attempt], '-t', String(seconds), '-i', device, '-vf', `fps=${STREAM_FPS}`, '-f', 'image2', path.join(dir, 'f%06d.png')];
150
+
151
+ /** The long-running ffmpeg of a scan: {child, exited, error, stderr, closed}. */
152
+ function startStream(spawn, args) {
153
+ const stream = { child: null, exited: false, error: null, stderr: '' };
154
+ stream.closed = new Promise(resolve => {
155
+ try { stream.child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'], shell: false }); }
156
+ catch (error) { stream.exited = true; stream.error = error; resolve(); return; }
157
+ stream.child.stderr?.on('data', chunk => { if (stream.stderr.length < 4096) stream.stderr += chunk.toString('utf8'); });
158
+ stream.child.on('error', error => { stream.error = error; stream.exited = true; resolve(); });
159
+ stream.child.on('close', () => { stream.exited = true; resolve(); });
160
+ });
161
+ return stream;
162
+ }
163
+ /** Kills a stream and waits (briefly) until it is gone, so nothing writes into the directory being removed. */
164
+ async function stopStream(stream) {
165
+ if (!stream || stream.exited) return;
166
+ try { stream.child.kill('SIGKILL'); } catch {}
167
+ let timer;
168
+ await Promise.race([stream.closed, new Promise(resolve => { timer = setTimeout(resolve, 2000); timer.unref?.(); })]);
169
+ clearTimeout(timer);
170
+ }
171
+
172
+ /** Removes this user's own comal-qr-* directories in `root` that no scan has touched for a while (a killed process). */
173
+ async function sweepStale(root) {
174
+ let names = [];
175
+ try { names = await fsReaddir(root); } catch { return; }
176
+ const uid = process.getuid?.();
177
+ for (const name of names) {
178
+ if (!DIR_SHAPE.test(name)) continue;
179
+ try {
180
+ const info = await lstat(path.join(root, name));
181
+ if (!info.isDirectory() || (uid !== undefined && info.uid !== uid) || Date.now() - info.mtimeMs < STALE_MS) continue;
182
+ await rm(path.join(root, name), { recursive: true, force: true });
183
+ } catch {}
184
+ }
185
+ }
186
+
187
+ /** Scans for the pairing QR for up to `timeoutMs` in total, finding the camera included. Resolves {code, camera}. */
188
+ export async function scanPairingQr({ camera, env = process.env, timeoutMs = SCAN_TIMEOUT_MS, intervalMs = SCAN_INTERVAL_MS, signal, spawn = nodeSpawn, now = Date.now, sleep, readdir = fsReaddir, tmpRoot = os.tmpdir() } = {}) {
189
+ if (signal?.aborted) throw aborted();
190
+ const deadline = now() + timeoutMs, seconds = Math.round(timeoutMs / 1000);
191
+ const left = limit => { const ms = Math.min(limit, deadline - now()); if (ms <= 0) throw timedOut(seconds); return ms; };
192
+ const pause = sleep ?? (ms => abortableSleep(ms, signal));
193
+ // Both tools are checked before the camera turns on.
194
+ for (const [cmd, args] of [['ffmpeg', ['-hide_banner', '-version']], ['zbarimg', ['--version']]]) await run(spawn, cmd, args, { signal, timeoutMs: left(PROBE_TIMEOUT_MS) });
195
+ const root = path.resolve(tmpRoot);
196
+ await sweepStale(root);
197
+ const dir = await mkdtemp(path.join(root, DIR_PREFIX));
198
+ let stream = null;
199
+ const onExit = () => { try { stream?.child?.kill('SIGKILL'); } catch {} try { rmSync(dir, { recursive: true, force: true }); } catch {} };
200
+ process.once('exit', onExit);
201
+ try {
202
+ await chmod(dir, 0o700);
203
+ const device = await pickCamera({ camera, env, spawn, readdir, signal, deadline, now, seconds });
204
+ left(1);
205
+ let attempt = 0, seen = 0;
206
+ const secondsLeft = () => Math.max(1, Math.ceil((deadline - now()) / 1000));
207
+ stream = startStream(spawn, streamArgs(device, dir, attempt, secondsLeft()));
208
+ for (;;) {
209
+ await pause(Math.max(0, Math.min(intervalMs, deadline - now())));
210
+ if (signal?.aborted) throw aborted();
211
+ // Read whether ffmpeg ended BEFORE listing: then every listed frame is complete. While it runs, the newest frame
212
+ // may still be being written, so only the ones older than it are complete. Of those only the newest is decoded:
213
+ // older ones are deleted unread, so a slow decode never builds a backlog of stale frames.
214
+ const ended = stream.exited;
215
+ const frames = (await fsReaddir(dir)).filter(name => FRAME.test(name)).sort();
216
+ const complete = ended ? frames : frames.slice(0, -1);
217
+ if (complete.length) {
218
+ seen += complete.length;
219
+ for (const name of complete.slice(0, -1)) await rm(path.join(dir, name), { force: true });
220
+ const file = path.join(dir, complete.at(-1));
221
+ let decoded;
222
+ try { decoded = await run(spawn, 'zbarimg', [...DECODE_ARGS, file], { signal, timeoutMs: Math.max(1, Math.min(PROBE_TIMEOUT_MS, deadline - now())) }); }
223
+ finally { await rm(file, { force: true }); }
224
+ const code = decoded.code === 0 ? onlyPairCode(decoded.stdout) : null;
225
+ if (code) return { code, camera: device };
226
+ }
227
+ if (ended && !frames.length) {
228
+ if (stream.error) throw stream.error.code === 'ENOENT' ? missingTools() : stream.error;
229
+ // A camera that refuses a format or size is opened again the next way, down to its own default.
230
+ if (!seen && attempt + 1 < STREAM_ATTEMPTS.length) { attempt += 1; left(1); stream = startStream(spawn, streamArgs(device, dir, attempt, secondsLeft())); continue; }
231
+ throw failure('QR_SCAN_CAMERA', t('Could not read the camera {camera}: {reason}', { camera: device, reason: oneLine(stream.stderr) }));
232
+ }
233
+ if (now() >= deadline) throw timedOut(seconds);
234
+ }
235
+ } finally {
236
+ await stopStream(stream);
237
+ await rm(dir, { recursive: true, force: true });
238
+ process.removeListener('exit', onExit);
239
+ }
240
+ }
@@ -9,6 +9,19 @@ import { RELAY_ANSWERS } from '../remote-relay.mjs';
9
9
  // intento (otra superficie, un control repetido o viejo) responde already_decided sin mutar.
10
10
  // Hace falta en Comal porque relayd admite rechazar un plan ya aprobado y aún no aplicado.
11
11
  registerMessages({
12
+ 'Open /rc first, with a Comal backend that supports devices.': 'Abre /rc primero, con un backend de Comal que admita dispositivos.',
13
+ 'Device: {label} ({id}){connected}.': 'Dispositivo: {label} ({id}){connected}.',
14
+ ', connected {time}': ', conectado {time}',
15
+ 'No device paired.': 'Ningún dispositivo emparejado.',
16
+ 'Device removed; its permission answers are off.': 'Dispositivo quitado; sus respuestas de permisos quedan apagadas.',
17
+ 'Permission answers from the M5Stack: armed until {time}.': 'Respuestas de permisos desde el M5Stack: armadas hasta las {time}.',
18
+ 'Permission answers from the M5Stack: off.': 'Respuestas de permisos desde el M5Stack: apagadas.',
19
+ 'Pair a device first with /rc device CODE.': 'Empareja un dispositivo primero con /rc device CODE.',
20
+ 'The /rc session has expired; open /rc again.': 'La sesión de /rc caducó; abre /rc de nuevo.',
21
+ 'M5Stack permission answers are armed until {time}. Each card\'s command or path goes through comal.milpahq.com to the device and is not stored.': 'Permisos desde el M5Stack activos hasta {time}. El comando o la ruta de cada tarjeta pasa por comal.milpahq.com hacia el aparato y no se guarda.',
22
+ 'Device paired: {label} ({id}).': 'Dispositivo emparejado: {label} ({id}).',
23
+ 'Answered on the M5Stack: {decision} · {tool}': 'Contestada en el M5Stack: {decision} · {tool}',
24
+ 'allowed once': 'permitida una vez', 'denied': 'negada',
12
25
  '{what} was decided from the browser ({outcome}).': '{what} se decidió desde el navegador ({outcome}).',
13
26
  'Already decided from the browser ({outcome}); nothing was sent.': 'Ya decidida desde el navegador ({outcome}); no se envió nada.',
14
27
  'This card is being decided in this terminal right now.': 'Esta tarjeta se está decidiendo ahora mismo en esta terminal.',