diff --git a/apps/field/package-lock.json b/apps/field/package-lock.json index b0c5757..3875eb5 100644 --- a/apps/field/package-lock.json +++ b/apps/field/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@quasar/cli": "^3.0.0", "@quasar/extras": "^1.16.12", - "html5-qrcode": "^2.3.8", "idb-keyval": "^6.2.1", "pinia": "^2.1.7", "quasar": "^2.16.10", @@ -6580,12 +6579,6 @@ "node": "^14.13.1 || >=16.0.0" } }, - "node_modules/html5-qrcode": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", - "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", - "license": "Apache-2.0" - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/apps/field/package.json b/apps/field/package.json index 10b73e6..e210bbe 100644 --- a/apps/field/package.json +++ b/apps/field/package.json @@ -12,7 +12,6 @@ "dependencies": { "@quasar/cli": "^3.0.0", "@quasar/extras": "^1.16.12", - "html5-qrcode": "^2.3.8", "idb-keyval": "^6.2.1", "pinia": "^2.1.7", "quasar": "^2.16.10", diff --git a/apps/field/src/composables/useScanner.js b/apps/field/src/composables/useScanner.js index 5f79bf9..d2c1ab8 100644 --- a/apps/field/src/composables/useScanner.js +++ b/apps/field/src/composables/useScanner.js @@ -2,61 +2,43 @@ import { ref, watch } from 'vue' import { scanBarcodes } from 'src/api/ocr' import { useOfflineStore } from 'src/stores/offline' -const GEMINI_TIMEOUT_MS = 8000 -const MAX_CODES = 3 +const SCAN_TIMEOUT_MS = 8000 /** - * Hybrid barcode scanner for field techs. + * Barcode scanner using device camera photo capture + Gemini Vision AI. * - * Three modes, all writing into the same `barcodes` array (max 3): + * 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. * - * 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. + * 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(). * * @param {object} options - * @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. + * @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. */ export function useScanner (options = {}) { const onNewCode = options.onNewCode || (() => {}) - 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 barcodes = ref([]) // Array of { value, region } — max 3 + const scanning = ref(false) // true while Gemini is processing const error = ref(null) - const lastPhoto = ref(null) // thumbnail data URI - const photos = ref([]) // [{ url, ts, codes, queued }] + const lastPhoto = ref(null) // data URI of last captured photo (thumbnail) + const photos = ref([]) // all captured photo thumbnails const offline = useOfflineStore() - let _html5 = null // Html5Qrcode instance for live mode - // 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). + // 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). for (const result of offline.scanResults) { mergeCodes(result.barcodes || [], 'queued') offline.consumeScanResult(result.id) } - // Watch for sync completions during this scanner's lifetime. + // Watch for sync completions during the lifetime of this scanner. // Vue auto-disposes the watcher when the host component unmounts. watch( () => offline.scanResults.length, @@ -69,12 +51,10 @@ export function useScanner (options = {}) { ) function addCode (code, region) { - 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) + 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) return true } @@ -86,109 +66,44 @@ 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. - * - * 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. + * Resizes for AI, keeps thumbnail, sends to Gemini with an 8s timeout. + * On timeout/failure, the photo is queued for background retry. */ async function processPhoto (file) { if (!file) return [] error.value = null scanning.value = true - const found = [] - const photoIdx = photos.value.length let aiImage = null + const photoIdx = photos.value.length + let found = [] try { - // Thumbnail for the UI + // Create thumbnail for display (small) const thumbUrl = await resizeImage(file, 400) lastPhoto.value = thumbUrl photos.value.push({ url: thumbUrl, ts: Date.now(), codes: [], queued: false }) - // --- Pass 1 + 2: local html5-qrcode on full image + strips --- - const localCodes = await scanPhotoLocally(file) - found.push(...mergeCodes(localCodes, 'photo')) + // 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 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) { - error.value = e?.message || 'Erreur' + 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' + } } finally { scanning.value = false } @@ -196,69 +111,6 @@ 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), @@ -278,11 +130,9 @@ export function useScanner (options = {}) { || e?.name === 'TypeError' // fetch throws TypeError on network error } - // ------------------------------------------------------------------------- - // Utilities - // ------------------------------------------------------------------------- - - /** Resize an image file to a max dimension, return as base64 data URI. */ + /** + * 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() @@ -316,15 +166,7 @@ export function useScanner (options = {}) { } return { - // state - barcodes, scanning, live, error, lastPhoto, photos, - // live camera - startLive, stopLive, - // photo scan - processPhoto, - // shared - addCode, removeBarcode, clearBarcodes, - // constants - MAX_CODES, + barcodes, scanning, error, lastPhoto, photos, + processPhoto, removeBarcode, clearBarcodes, } } diff --git a/apps/field/src/pages/ScanPage.vue b/apps/field/src/pages/ScanPage.vue index 1092b0c..107ba65 100644 --- a/apps/field/src/pages/ScanPage.vue +++ b/apps/field/src/pages/ScanPage.vue @@ -17,99 +17,55 @@ - - - - - - - - -
-
-
- - -
-
- Cadrez un code-barres — détection automatique -
-
- - -
+ +
-
- Jusqu'à 3 codes par photo · IA activée si la lecture locale échoue -
+
- -
- - {{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} - en attente · toucher pour réessayer - -
+ +
+ + {{ offline.pendingVisionCount }} scan{{ offline.pendingVisionCount > 1 ? 's' : '' }} en attente · toucher pour réessayer + +
- -
- -
- -
Analyse...
-
-
- - -
-
Photos capturées
-
-
- - -
-
+ +
+ +
+ +
Analyse Gemini...
- -
- - - - -
- - +
{{ scanner.error.value }}
- + + + + + +
-
-
Codes détectés ({{ scanner.barcodes.value.length }}/{{ scanner.MAX_CODES }})
- -
+
Codes détectés ({{ scanner.barcodes.value.length }}/3)
{{ bc.value }}
-
{{ bc.region }}
@@ -130,7 +86,7 @@
-
+
{{ lookupResults[bc.value].equipment.service_location }}
@@ -146,6 +102,17 @@
+ +
+
Photos capturées
+
+
+ + +
+
+
+