diff --git a/apps/field/src/composables/useScanner.js b/apps/field/src/composables/useScanner.js index d2c1ab8..5f79bf9 100644 --- a/apps/field/src/composables/useScanner.js +++ b/apps/field/src/composables/useScanner.js @@ -2,43 +2,61 @@ import { ref, watch } from 'vue' import { scanBarcodes } from 'src/api/ocr' import { useOfflineStore } from 'src/stores/offline' -const SCAN_TIMEOUT_MS = 8000 +const GEMINI_TIMEOUT_MS = 8000 +const MAX_CODES = 3 /** - * Barcode scanner using device camera photo capture + Gemini Vision AI. + * Hybrid barcode scanner for field techs. * - * Strategy: Use which triggers - * the native camera app — this gives proper autofocus, tap-to-focus, - * and high-res photos. Then send to Gemini Vision for barcode extraction. + * Three modes, all writing into the same `barcodes` array (max 3): * - * Resilience: if Gemini doesn't answer within SCAN_TIMEOUT_MS (weak LTE), - * the photo is queued in IndexedDB via the offline store and retried when - * the signal comes back. The tech gets a "scan en attente" indicator and - * can keep working; late results are delivered via onNewCode(). + * 1. `startLive(elId)` — continuous camera stream via html5-qrcode. + * Instant, offline-capable, works for standard QR / Code128 / EAN. + * This is the DEFAULT and fastest path. + * + * 2. `processPhoto(file)` — take a picture, scan it for codes. + * Tries html5-qrcode locally first (full image + 3 horizontal strips, + * catches up to 3 barcodes per photo, offline). If nothing is found + * AND we're online, falls back to Gemini Vision which can read weird + * stuff html5-qrcode misses: damaged stickers, multi-line serials + * printed as text, unusual barcode symbologies. + * If Gemini times out on weak LTE, the photo is queued in IndexedDB + * and retried when signal returns — late results come back through + * `onNewCode` via the watcher on offline.scanResults. + * + * 3. Manual entry — handled by the caller, just appends via addCode(). + * + * This was rewritten in 2026-04-22 after an earlier refactor replaced all + * local scanning with Gemini-only. Live camera matters for field techs: + * point-and-scan is 10× faster than take-photo-wait-for-AI on a normal QR. + * Gemini is kept as a second chance for the hard cases. * * @param {object} options - * @param {(code: string) => void} [options.onNewCode] — called for each - * newly detected code, whether the scan was synchronous or delivered - * later from the offline queue. Typically used to trigger lookup + notify. + * @param {(code: string) => void} [options.onNewCode] — fires for each + * newly added code whether it came from live, photo, queue, or manual. + * Typically used to trigger lookup + toast. */ export function useScanner (options = {}) { const onNewCode = options.onNewCode || (() => {}) - const barcodes = ref([]) // Array of { value, region } — max 3 - const scanning = ref(false) // true while Gemini is processing + const barcodes = ref([]) // [{ value, region }], max MAX_CODES + const scanning = ref(false) // true while a photo/AI call is in-flight + const live = ref(false) // true while the live camera is running const error = ref(null) - const lastPhoto = ref(null) // data URI of last captured photo (thumbnail) - const photos = ref([]) // all captured photo thumbnails + const lastPhoto = ref(null) // thumbnail data URI + const photos = ref([]) // [{ url, ts, codes, queued }] const offline = useOfflineStore() + let _html5 = null // Html5Qrcode instance for live mode - // Pick up any scans that completed while the page was unmounted (e.g. tech - // queued a photo, locked phone, walked out of the basement, signal returns). + // Pick up any Gemini scans that completed while the page was unmounted + // (tech queued a photo, locked phone, walked out of the basement, signal + // returned, hub replied, we stored the result in IndexedDB). for (const result of offline.scanResults) { mergeCodes(result.barcodes || [], 'queued') offline.consumeScanResult(result.id) } - // Watch for sync completions during the lifetime of this scanner. + // Watch for sync completions during this scanner's lifetime. // Vue auto-disposes the watcher when the host component unmounts. watch( () => offline.scanResults.length, @@ -51,10 +69,12 @@ export function useScanner (options = {}) { ) function addCode (code, region) { - if (barcodes.value.length >= 3) return false - if (barcodes.value.find(b => b.value === code)) return false - barcodes.value.push({ value: code, region }) - onNewCode(code) + const trimmed = String(code || '').trim() + if (!trimmed) return false + if (barcodes.value.length >= MAX_CODES) return false + if (barcodes.value.find(b => b.value === trimmed)) return false + barcodes.value.push({ value: trimmed, region }) + onNewCode(trimmed) return true } @@ -66,44 +86,109 @@ export function useScanner (options = {}) { return added } + // ------------------------------------------------------------------------- + // 1. Live camera — html5-qrcode continuous stream + // ------------------------------------------------------------------------- + + /** + * Start the live scanner in the given DOM element. + * Safe to call again after stopLive() — recreates the instance. + * + * @param {string} elementId — id of a div to mount the camera view into. + * Must already be in the DOM when this is called. + */ + async function startLive (elementId) { + if (live.value) return + error.value = null + try { + const { Html5Qrcode } = await import('html5-qrcode') + _html5 = new Html5Qrcode(elementId, { verbose: false }) + await _html5.start( + { facingMode: 'environment' }, + { + fps: 10, + // Wider aimbox so tech doesn't have to line up perfectly. + qrbox: (w, h) => { + const side = Math.floor(Math.min(w, h) * 0.7) + return { width: side, height: Math.floor(side * 0.6) } + }, + }, + (decoded) => addCode(decoded, 'caméra'), + () => { /* per-frame decode miss — ignore, this is noisy by design */ }, + ) + live.value = true + } catch (e) { + error.value = e?.message || 'Caméra non disponible' + live.value = false + _html5 = null + } + } + + async function stopLive () { + try { + if (_html5?.isScanning) await _html5.stop() + _html5?.clear() + } catch { /* ignore — teardown races are fine */ } + _html5 = null + live.value = false + } + + // ------------------------------------------------------------------------- + // 2. Photo scan — html5-qrcode multi-strip, Gemini fallback + // ------------------------------------------------------------------------- + /** * Process a photo file from camera input. - * Resizes for AI, keeps thumbnail, sends to Gemini with an 8s timeout. - * On timeout/failure, the photo is queued for background retry. + * + * Pass 1: html5-qrcode on the full image (cheap, offline). + * Pass 2: split into 3 horizontal strips, scan each — catches stickers + * that contain multiple barcodes stacked vertically (common on + * modems: SN, MAC, GPON SN each on their own line). + * Pass 3: if still empty AND online, send to Gemini for OCR-based read. + * If Gemini times out (weak signal), queue for background retry. */ async function processPhoto (file) { if (!file) return [] error.value = null scanning.value = true - let aiImage = null + const found = [] const photoIdx = photos.value.length - let found = [] + let aiImage = null try { - // Create thumbnail for display (small) + // Thumbnail for the UI const thumbUrl = await resizeImage(file, 400) lastPhoto.value = thumbUrl photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false }) - // Create optimized image for AI — keep high res for text readability - aiImage = await resizeImage(file, 1600, 0.92) - - const result = await scanBarcodesWithTimeout(aiImage, SCAN_TIMEOUT_MS) - found = mergeCodes(result.barcodes || [], 'photo') - photos.value[photoIdx].codes = found + // --- Pass 1 + 2: local html5-qrcode on full image + strips --- + const localCodes = await scanPhotoLocally(file) + found.push(...mergeCodes(localCodes, 'photo')) + // --- Pass 3: Gemini fallback if local scan came up empty --- if (found.length === 0) { + aiImage = await resizeImage(file, 1600, 0.92) + try { + const result = await scanBarcodesWithTimeout(aiImage, GEMINI_TIMEOUT_MS) + found.push(...mergeCodes(result.barcodes || [], 'IA')) + } catch (e) { + if (isRetryable(e)) { + await offline.enqueueVisionScan({ image: aiImage }) + if (photos.value[photoIdx]) photos.value[photoIdx].queued = true + error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.' + } else { + throw e + } + } + } + + if (photos.value[photoIdx]) photos.value[photoIdx].codes = found + if (found.length === 0 && !error.value) { error.value = 'Aucun code détecté — rapprochez-vous ou améliorez la mise au point' } } catch (e) { - if (aiImage && isRetryable(e)) { - await offline.enqueueVisionScan({ image: aiImage }) - if (photos.value[photoIdx]) photos.value[photoIdx].queued = true - error.value = 'Réseau faible — scan en attente. Reprise automatique au retour du signal.' - } else { - error.value = e.message || 'Erreur' - } + error.value = e?.message || 'Erreur' } finally { scanning.value = false } @@ -111,6 +196,69 @@ export function useScanner (options = {}) { return found } + /** + * Local pass: html5-qrcode on full image + 3 horizontal strips. + * Returns an array of unique decoded strings. Always safe to call — + * errors at any stage are swallowed and produce an empty result. + */ + async function scanPhotoLocally (file) { + const found = new Set() + let scratch = document.getElementById('scanner-scratch') + if (!scratch) { + scratch = document.createElement('div') + scratch.id = 'scanner-scratch' + scratch.style.display = 'none' + document.body.appendChild(scratch) + } + + let scanner + try { + const { Html5Qrcode } = await import('html5-qrcode') + scanner = new Html5Qrcode('scanner-scratch', { verbose: false }) + + // Full image first + try { + const r = await scanner.scanFileV2(file, false) + if (r?.decodedText) found.add(r.decodedText) + } catch { /* no code on full image — try strips */ } + + // Split into 3 strips and scan each — only if we still have room + if (found.size < MAX_CODES) { + const img = await createImageBitmap(file) + try { + const { width, height } = img + const strips = [ + { y: 0, h: Math.floor(height / 3), label: 'haut' }, + { y: Math.floor(height / 3), h: Math.floor(height / 3), label: 'milieu' }, + { y: Math.floor(height * 2 / 3), h: height - Math.floor(height * 2 / 3), label: 'bas' }, + ] + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + for (const strip of strips) { + if (found.size >= MAX_CODES) break + canvas.width = width + canvas.height = strip.h + ctx.drawImage(img, 0, strip.y, width, strip.h, 0, 0, width, strip.h) + try { + const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.9)) + const stripFile = new File([blob], 'strip.jpg', { type: 'image/jpeg' }) + const r = await scanner.scanFileV2(stripFile, false) + if (r?.decodedText) found.add(r.decodedText) + } catch { /* strip miss — continue */ } + } + } finally { + img.close() + } + } + } catch { /* library load / instantiation failure — caller falls back to Gemini */ } + finally { + try { scanner?.clear() } catch { /* ignore */ } + } + + return Array.from(found) + } + async function scanBarcodesWithTimeout (image, ms) { return await Promise.race([ scanBarcodes(image), @@ -130,9 +278,11 @@ export function useScanner (options = {}) { || e?.name === 'TypeError' // fetch throws TypeError on network error } - /** - * Resize an image file to a max dimension, return as base64 data URI. - */ + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + /** Resize an image file to a max dimension, return as base64 data URI. */ function resizeImage (file, maxDim, quality = 0.85) { return new Promise((resolve, reject) => { const img = new Image() @@ -166,7 +316,15 @@ export function useScanner (options = {}) { } return { - barcodes, scanning, error, lastPhoto, photos, - processPhoto, removeBarcode, clearBarcodes, + // state + barcodes, scanning, live, error, lastPhoto, photos, + // live camera + startLive, stopLive, + // photo scan + processPhoto, + // shared + addCode, removeBarcode, clearBarcodes, + // constants + MAX_CODES, } } diff --git a/apps/field/src/pages/ScanPage.vue b/apps/field/src/pages/ScanPage.vue index 107ba65..1092b0c 100644 --- a/apps/field/src/pages/ScanPage.vue +++ b/apps/field/src/pages/ScanPage.vue @@ -17,55 +17,99 @@ - -