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.
+
+
+
+
+
+
+
+
+
+
+
+
📸
+
Tap to start camera
+
+
+
+
+
+
+
+
+
+
+
+
📋 Scan Results
+
![Scanned photo]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Picked photo]()
@@ -1434,6 +1491,16 @@
+
+
+
+
+ 🏷️ Recently Scanned Labels
+
+
+
+
+
@@ -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
// ═══════════════════════════════════════════════════════════════════════