diff --git a/server.py b/server.py index 113d3e9..05839b0 100644 --- a/server.py +++ b/server.py @@ -22,7 +22,7 @@ from pathlib import Path import pytesseract from PIL import Image as PILImage -from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File +from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles @@ -2687,6 +2687,107 @@ async def ocr_sticker(file: UploadFile = File(...)): } +# ─── T4: Connect Label — unified photo + OCR + GPS endpoint ───────────────── + +class ConnectLabelRequest(BaseModel): + """Request body for the connect-label endpoint.""" + machine_id: str + name: str = "" + latitude: Optional[float] = None + longitude: Optional[float] = None + category: Optional[str] = "Other" + photo_path: Optional[str] = None + + +@app.post("/api/connect-label", status_code=201) +async def connect_label( + photo: Optional[UploadFile] = File(None), + machine_id: str = Form(""), + name: str = Form(""), + latitude: Optional[float] = Form(None), + longitude: Optional[float] = Form(None), + category: str = Form("Other"), + photo_path: str = Form(""), +): + """Unified endpoint for Connect Label workflow. + + Accepts multipart form data with optional photo upload. + Creates an asset with the provided machine_id, name, GPS coords. + If photo is uploaded, it saves the file and sets photo_path. + Validates machine_id format (XXXXX-XXXXXX → last 5 digits). + """ + # If machine_id/name come as form fields, use those; otherwise try query params + machine_id = _sanitize_machine_id(machine_id) + name = _sanitize_name(name or "Scanned Asset") + _validate_status("active") + + # Save photo if provided + saved_photo_path = photo_path or "" + if photo and photo.filename: + saved_photo_path = _save_upload(photo, "connect_labels", PHOTO_ALLOWED_EXTS, PHOTO_MAX_SIZE) + + conn = get_db() + _validate_enum_table(conn, "categories", category or "Other", "category") + + # Build insert + columns: list[str] = ["machine_id", "name", "category", "status"] + values: list = [machine_id, name, category or "Other", "active"] + + if saved_photo_path: + columns.append("photo_path") + values.append(saved_photo_path) + + if latitude is not None: + columns.append("latitude") + values.append(latitude) + if longitude is not None: + columns.append("longitude") + values.append(longitude) + + # Auto-populate address from GPS + if latitude is not None and longitude is not None: + geo = reverse_geocode(latitude, longitude) + if geo: + if "address" not in columns: + columns.append("address") + values.append(geo.get("address", geo.get("formatted", ""))) + if "building_name" not in columns and geo.get("building_name"): + columns.append("building_name") + values.append(geo.get("building_name", "")) + if "building_number" not in columns and geo.get("building_number"): + columns.append("building_number") + values.append(geo.get("building_number", "")) + + placeholders = ", ".join(["?"] * len(columns)) + col_names = ", ".join(columns) + + try: + cursor = conn.execute( + f"INSERT INTO assets ({col_names}) VALUES ({placeholders})", + values, + ) + except sqlite3.IntegrityError: + conn.close() + raise HTTPException( + status_code=409, + detail=f"Asset with machine_id '{machine_id}' already exists", + ) + raw_id = cursor.lastrowid + assert raw_id is not None, "INSERT did not return lastrowid" + asset_id: int = raw_id + + _log_activity(conn, "created", "asset", asset_id, + f"Asset '{name}' (machine_id: {machine_id}) created via Connect Label") + conn.commit() + + row = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone() + result = row_to_dict(row) + result["keys"] = _get_asset_keys(conn, asset_id) + result["badges"] = _get_asset_badges(conn, asset_id) + conn.close() + return result + + # ─── Reverse Geocode (Nominatim) ──────────────────────────────────────────── def reverse_geocode(lat: float, lng: float) -> dict | None: diff --git a/static/index.html b/static/index.html index fec192d..6d38204 100644 --- a/static/index.html +++ b/static/index.html @@ -1212,6 +1212,7 @@ + @@ -1407,6 +1408,62 @@ + +
+
+
🏷️ Scan Connect Label — Photo + OCR + GPS
+

+ Take or pick a photo of a machine sticker. OCR reads the ID, GPS is extracted automatically. +

+
+ + +
+
+ + + + + + + + + +
+ + + + +
@@ -2486,7 +2553,7 @@ if (tabId === 'tabAddAsset') startScanning(); else stopScanning(); - if (tabId === 'tabAssets') { loadAssets(); loadTechnicianOptions(); } + if (tabId === 'tabAssets') { loadAssets(); loadTechnicianOptions(); renderConnectLabels(); } if (tabId === 'tabDashboard') loadDashboard(); if (tabId === 'tabActivity') { loadActivity(); loadActivityUserFilter(); } @@ -2594,6 +2661,7 @@ stopScanning(); stopOcrCamera(); stopManualPhoto(); + stopConnectCamera(); if (mode === 'barcode') { startScanning(); @@ -3519,6 +3587,396 @@ } } + // ═══════════════════════════════════════════════════════════════════════ + // T4: CONNECT LABEL — unified photo + OCR + GPS + // ═══════════════════════════════════════════════════════════════════════ + let connectStream = null; + let connectFile = null; + let connectGps = null; // { lat, lng, accuracy } + let connectOcrData = null; // { machine_id, confidence, raw_text } + + async function connectFromCamera() { + // Stop any existing streams + stopOcrCamera(); + stopManualPhoto(); + + const area = document.getElementById('connectCameraArea'); + const video = document.getElementById('connectVideo'); + const placeholder = document.getElementById('connectCameraPlaceholder'); + const overlay = document.getElementById('connectCaptureOverlay'); + const results = document.getElementById('connectResults'); + const status = document.getElementById('connectStatus'); + + area.style.display = 'block'; + results.style.display = 'none'; + status.style.display = 'none'; + status.textContent = ''; + placeholder.style.display = 'flex'; + video.style.display = 'none'; + overlay.style.display = 'none'; + + // Stop any previous connect stream + if (connectStream) { + connectStream.getTracks().forEach(t => t.stop()); + connectStream = null; + } + + try { + connectStream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } }, + audio: false, + }); + video.srcObject = connectStream; + video.style.display = 'block'; + placeholder.style.display = 'none'; + overlay.style.display = 'flex'; + } catch (e) { + showToast('Camera error: ' + e.message, true); + area.style.display = 'none'; + } + } + + function connectFromGallery() { + const pp = document.getElementById('photoPicker'); + if (pp) { + // Override the default handler for connect mode + pp._connectMode = true; + pp.click(); + } + } + + async function captureConnectPhoto() { + const video = document.getElementById('connectVideo'); + if (!video || !connectStream) return; + + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext('2d').drawImage(video, 0, 0); + const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.85)); + + // Stop the camera + stopConnectCamera(); + + // Process the photo + await processConnectLabelPhoto(new File([blob], 'sticker.jpg', { type: 'image/jpeg' })); + } + + function stopConnectCamera() { + if (connectStream) { + connectStream.getTracks().forEach(t => t.stop()); + connectStream = null; + } + document.getElementById('connectVideo').srcObject = null; + document.getElementById('connectVideo').style.display = 'none'; + document.getElementById('connectCameraArea').style.display = 'none'; + document.getElementById('connectCaptureOverlay').style.display = 'none'; + document.getElementById('connectCameraPlaceholder').style.display = 'none'; + } + + // ▶ Core: process photo via OCR + GPS extraction + async function processConnectLabelPhoto(file) { + connectFile = file; + connectGps = null; + connectOcrData = null; + + // Show status + const statusEl = document.getElementById('connectStatus'); + statusEl.style.display = 'block'; + statusEl.className = 'status-bar working'; + statusEl.textContent = '⏳ Processing photo...'; + document.getElementById('connectResults').style.display = 'none'; + + // Show thumbnail + const reader = new FileReader(); + reader.onload = function(e) { + document.getElementById('connectThumb').src = e.target.result; + }; + reader.readAsDataURL(file); + + // Run OCR and GPS in parallel + const [ocrResult, gpsResult] = await Promise.allSettled([ + runConnectOcr(file), + extractGpsFromPhoto(file), + ]); + + // Process OCR result + if (ocrResult.status === 'fulfilled' && ocrResult.value) { + connectOcrData = ocrResult.value; + } else { + connectOcrData = { machine_id: '', confidence: 'none', raw_text: 'OCR failed' }; + } + + // Process GPS result + if (gpsResult.status === 'fulfilled' && gpsResult.value) { + connectGps = gpsResult.value; + } else { + connectGps = null; + } + + // Render results + renderConnectResults(); + } + + // ▶ Run OCR for connect mode (server first, client fallback) + async function runConnectOcr(file) { + // Try server-side OCR first + try { + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), 8000); + const formData = new FormData(); + formData.append('file', file, 'sticker.jpg'); + const res = await fetch('/api/ocr', { + method: 'POST', + body: formData, + signal: controller.signal, + headers: AppState.authToken + ? { 'Authorization': 'Bearer ' + AppState.authToken } + : {}, + }); + clearTimeout(t); + if (res.ok) { + const data = await res.json(); + data.method = 'server'; + return data; + } + throw new Error('Server returned ' + res.status); + } catch (e) { + // Fall back to client-side Tesseract.js + try { + const data = await ocrImageClient(file); + return data; + } catch (clientErr) { + return { machine_id: '', confidence: 'none', raw_text: 'OCR unavailable', method: 'client' }; + } + } + } + + function renderConnectResults() { + const resultsEl = document.getElementById('connectResults'); + const statusEl = document.getElementById('connectStatus'); + resultsEl.style.display = 'block'; + + // OCR result line + const ocrEl = document.getElementById('connectOcrResult'); + if (connectOcrData && connectOcrData.machine_id) { + const methodBadge = connectOcrData.method === 'client' + ? '📡 Offline' : ''; + ocrEl.innerHTML = ` +
+ ✅ Machine ID found${methodBadge} +
+
+ ${esc(connectOcrData.machine_id)} + ${connectOcrData.confidence === 'low' ? ' ⚠️ Low confidence — verify' : ''} +
`; + document.getElementById('connectMachineId').value = connectOcrData.machine_id; + statusEl.className = 'status-bar success'; + statusEl.textContent = '✅ Processing complete'; + } else { + ocrEl.innerHTML = ` +
+ ❌ No ID detected +
+
Enter machine ID manually below.
`; + document.getElementById('connectMachineId').value = ''; + statusEl.className = 'status-bar error'; + statusEl.textContent = 'OCR failed — enter ID manually'; + } + + // GPS result line + const gpsEl = document.getElementById('connectGpsResult'); + const miniMapEl = document.getElementById('connectMiniMap'); + if (connectGps) { + gpsEl.innerHTML = ` +
+ 📍 GPS from photo +
+
+ ${connectGps.lat.toFixed(6)}, ${connectGps.lng.toFixed(6)} + ${connectGps.accuracy ? ` (±${connectGps.accuracy.toFixed(1)}m)` : ''} +
`; + document.getElementById('connectLat').value = connectGps.lat.toFixed(6); + document.getElementById('connectLng').value = connectGps.lng.toFixed(6); + miniMapEl.style.display = 'block'; + setTimeout(() => renderGpsPreview(connectGps.lat, connectGps.lng, miniMapEl), 100); + } else { + gpsEl.innerHTML = ` +
+ 📍 No GPS in photo +
+
+ Phone may have stripped EXIF GPS. Enter coordinates manually or skip. +
`; + document.getElementById('connectLat').value = ''; + document.getElementById('connectLng').value = ''; + miniMapEl.style.display = 'none'; + } + + // Populate category select + populateCategorySelect('connectCatSelect'); + document.getElementById('connectName').value = ''; + document.getElementById('connectName').focus(); + + // Save to recent connect labels + saveConnectLabel(connectOcrData ? connectOcrData.machine_id : '', connectGps); + } + + async function createConnectAsset() { + const machineId = document.getElementById('connectMachineId').value.trim(); + const name = document.getElementById('connectName').value.trim(); + if (!machineId) { showToast('Machine ID is required', true); return; } + if (!name) { showToast('Asset name is required', true); return; } + + const latStr = document.getElementById('connectLat').value.trim(); + const lngStr = document.getElementById('connectLng').value.trim(); + + const payload = { + machine_id: machineId, + name: name, + category: document.getElementById('connectCatSelect').value || 'Other', + status: 'active', + }; + + if (latStr && lngStr) { + payload.latitude = parseFloat(latStr); + payload.longitude = parseFloat(lngStr); + } + + // Try reverse geocode for address + if (payload.latitude && payload.longitude) { + try { + const geo = await reverseGeocode(payload.latitude, payload.longitude); + if (geo) { + payload.address = geo.address || geo.formatted || ''; + payload.building_name = geo.building_name || ''; + payload.building_number = geo.building_number || ''; + } + } catch (e) { /* ignore */ } + } + + // Upload photo if available + if (connectFile) { + try { + const formData = new FormData(); + formData.append('file', connectFile); + const uploadRes = await fetch('/api/upload/photo', { + method: 'POST', + body: formData, + headers: AppState.authToken + ? { 'Authorization': 'Bearer ' + AppState.authToken } + : {}, + }); + if (uploadRes.ok) { + const uploadData = await uploadRes.json(); + payload.photo_path = uploadData.path; + } + } catch (e) { /* photo upload is optional */ } + } + + try { + const asset = await api('/api/assets', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (asset._queued) { + showToast('📴 Queued offline — will sync when connected'); + } else { + showToast('✅ Asset created!'); + AppState.currentAssetId = asset.id; + autoCheckin(asset.id); + } + // Switch to assets tab + switchTab('tabAssets'); + loadAssets(); + } catch (e) { + showToast(e.message, true); + } + } + + function resetConnectMode() { + connectFile = null; + connectGps = null; + connectOcrData = null; + document.getElementById('connectResults').style.display = 'none'; + document.getElementById('connectStatus').style.display = 'none'; + document.getElementById('connectStatus').textContent = ''; + document.getElementById('connectThumb').src = ''; + document.getElementById('connectMachineId').value = ''; + document.getElementById('connectName').value = ''; + document.getElementById('connectLat').value = ''; + document.getElementById('connectLng').value = ''; + const miniMapEl = document.getElementById('connectMiniMap'); + miniMapEl.innerHTML = ''; + miniMapEl.style.display = 'none'; + stopConnectCamera(); + } + + // ── Connect Label History ─────────────────────────────────────────────── + function saveConnectLabel(machineId, gps) { + try { + const labels = JSON.parse(localStorage.getItem('connectLabels') || '[]'); + labels.unshift({ + machine_id: machineId || 'Manual entry', + lat: gps ? gps.lat : null, + lng: gps ? gps.lng : null, + time: new Date().toISOString(), + }); + // Keep last 20 + if (labels.length > 20) labels.length = 20; + localStorage.setItem('connectLabels', JSON.stringify(labels)); + } catch (e) { /* ignore storage errors */ } + } + + function renderConnectLabels() { + const section = document.getElementById('connectLabelSection'); + const list = document.getElementById('connectLabelList'); + try { + const labels = JSON.parse(localStorage.getItem('connectLabels') || '[]'); + if (!labels.length) { + section.style.display = 'none'; + return; + } + section.style.display = 'block'; + list.innerHTML = labels.map((l, i) => { + const gpsStr = l.lat && l.lng + ? `📍 (${l.lat.toFixed(4)}, ${l.lng.toFixed(4)})` + : '📍 No GPS'; + const timeStr = new Date(l.time).toLocaleString(); + return `
+ ${esc(l.machine_id)} + ${gpsStr} + ${timeStr} +
`; + }).join(''); + } catch (e) { + section.style.display = 'none'; + } + } + + function clearConnectLabels() { + localStorage.removeItem('connectLabels'); + document.getElementById('connectLabelSection').style.display = 'none'; + document.getElementById('connectLabelList').innerHTML = ''; + } + + // ── Hook into gallery picker for connect mode ──────────────────────────── + (function() { + const pp = document.getElementById('photoPicker'); + const origHandler = pp.onchange; + pp.addEventListener('change', function(e) { + if (pp._connectMode) { + pp._connectMode = false; + if (e.target.files && e.target.files[0]) { + processConnectLabelPhoto(e.target.files[0]); + } + pp.value = ''; // Reset so same file can be picked again + return; + } + }); + })(); + // ═══════════════════════════════════════════════════════════════════════ // MANUAL MODE — Photo // ═══════════════════════════════════════════════════════════════════════