From 192d4b590cebfdea3f95485fa130efa664fa5f0a Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 15 Oct 2026 09:51:07 +0200 Subject: [PATCH] Camera di stazione: acquisizione dal frontend e campi admin (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route POST /measure/api/vision/grab (proxy verso il servizio camera), get_station_by_code, pulsante «Acquisisci dalla camera», grab() in vision_execute.js, campi camera_url e camera_source nell'admin stazioni, traduzioni it/en, extra_hosts host.docker.internal nel compose di sviluppo. Co-Authored-By: Claude Sonnet 5.5 --- docker-compose.dev.yml | 3 + src/frontend/flask_app/blueprints/measure.py | 59 +++++++ src/frontend/flask_app/services/api_client.py | 4 + .../flask_app/static/js/vision_execute.js | 29 ++++ .../flask_app/templates/admin/stations.html | 30 +++- .../templates/measure/task_execute.html | 10 ++ .../flask_app/tests/test_admin_stations.py | 39 +++++ .../tests/test_api_client_stations.py | 9 + .../tests/test_measure_camera_grab.py | 161 ++++++++++++++++++ .../translations/en/LC_MESSAGES/messages.po | 37 ++++ .../translations/it/LC_MESSAGES/messages.po | 37 ++++ 11 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 src/frontend/flask_app/tests/test_measure_camera_grab.py diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d91046d..495d9e0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -88,6 +88,9 @@ services: # on-premise su LAN in HTTP semplice ha bisogno del cookie non-secure, # altrimenti il browser non lo rimanda mai indietro. SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} + # #72: da container si raggiunge il servizio camera (vs-gui) sull'host Linux + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: server: condition: service_healthy diff --git a/src/frontend/flask_app/blueprints/measure.py b/src/frontend/flask_app/blueprints/measure.py index 749d2bb..d5acbd7 100644 --- a/src/frontend/flask_app/blueprints/measure.py +++ b/src/frontend/flask_app/blueprints/measure.py @@ -345,6 +345,7 @@ def task_execute(task_id: int): all_task_ids=all_task_ids, measurement_interval_minutes=measurement_interval_minutes, allow_manual_input=allow_manual_input, + camera_enabled=_station_camera(_current_station()[0]) is not None, ) @@ -1000,6 +1001,64 @@ def api_vision_execute(): return jsonify(resp), 200 +def _station_camera(station_code: str | None) -> tuple[str, str] | None: + """(camera_url, camera_source) of the station, None when it has no camera. + + A station that cannot be read counts as "no camera": the grab button simply + does not show, the file input keeps working. + """ + if not station_code: + return None + station = api_client.get_station_by_code(station_code) + if not isinstance(station, dict) or station.get("error"): + return None + url = station.get("camera_url") + source = station.get("camera_source") + if not url or not source: + return None + return str(url), str(source) + + +# #72: the browser asks the station's camera service (vs-gui) for a frame; the +# service address is a server-side matter, so Flask relays and the PNG goes back as is. +@measure_bp.route("/api/vision/grab", methods=["POST"]) +@login_required +@role_required("MeasurementTec") +def api_vision_grab(): + """Proxy: grab one frame from the current station's camera, answer a PNG.""" + camera = _station_camera(_current_station()[0]) + if camera is None: + return jsonify({ + "error": True, + "detail": _("Camera non configurata per questa stazione"), + }), 409 + camera_url, camera_source = camera + try: + resp = http_requests.post( + f"{camera_url.rstrip('/')}/camera/grab", + json={"source": camera_source, "trigger": "continuo", "timeout_ms": 5000}, + timeout=15, + ) + except http_requests.RequestException as exc: + return jsonify({ + "error": True, + "detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)), + }), 502 + if resp.status_code != 200: + detail = "" + try: + body = resp.json() + detail = body.get("detail", "") if isinstance(body, dict) else "" + except ValueError: + detail = (resp.text or "")[:200] + return jsonify({ + "error": True, + "detail": _("Acquisizione non riuscita (%(status)s): %(detail)s", + status=resp.status_code, detail=detail), + }), 502 + return Response(resp.content, content_type="image/png") + + @measure_bp.route("/api/vision/results//image", methods=["GET"]) @login_required @role_required("MeasurementTec") diff --git a/src/frontend/flask_app/services/api_client.py b/src/frontend/flask_app/services/api_client.py index a1dd429..b83de02 100644 --- a/src/frontend/flask_app/services/api_client.py +++ b/src/frontend/flask_app/services/api_client.py @@ -193,5 +193,9 @@ class APIClient: """Return the list of active recipes assigned to the given station.""" return self.get(f"/api/stations/by-code/{station_code}/recipes") + def get_station_by_code(self, station_code: str) -> dict[str, Any]: + """Return the station with the given code (camera_url / camera_source included).""" + return self.get(f"/api/stations/by-code/{station_code}") + api_client = APIClient() diff --git a/src/frontend/flask_app/static/js/vision_execute.js b/src/frontend/flask_app/static/js/vision_execute.js index 981c567..3e22aac 100644 --- a/src/frontend/flask_app/static/js/vision_execute.js +++ b/src/frontend/flask_app/static/js/vision_execute.js @@ -18,6 +18,7 @@ function visionExecute() { return { busy: false, + grabbing: false, // frame requested from the station camera (#72) error: '', result: null, // last execute response imageSrc: '', // object URL of the acquired image (shown under the overlay) @@ -61,6 +62,34 @@ function visionExecute() { } }, + // Station camera (#72): the Flask proxy answers a PNG, which goes through + // acquire() like a picked file. Errors show as the execute ones do. + async grab() { + const cfg = window.__visionCfg || {}; + if (this.busy || this.grabbing || !cfg.grabUrl) return; + this.grabbing = true; + this.error = ''; + let blob = null; + try { + const csrf = document.querySelector('meta[name=csrf-token]')?.content || ''; + const resp = await fetch(cfg.grabUrl, { + method: 'POST', headers: { 'X-CSRFToken': csrf }, + }); + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + this.error = body.detail || (cfg.labels && cfg.labels.requestFailed) || 'Error'; + return; + } + blob = await resp.blob(); + } catch (e) { + this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error'; + return; + } finally { + this.grabbing = false; + } + if (blob) await this.acquire(blob); + }, + // Input change: take the file and allow picking the same one again. onPick(ev) { const f = ev.target.files && ev.target.files[0]; diff --git a/src/frontend/flask_app/templates/admin/stations.html b/src/frontend/flask_app/templates/admin/stations.html index be9e765..21a42d6 100644 --- a/src/frontend/flask_app/templates/admin/stations.html +++ b/src/frontend/flask_app/templates/admin/stations.html @@ -215,6 +215,26 @@

{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}

+ {# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #} +
+ + +

{{ _('Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale') }}

+
+
+ + +

{{ _('Es. camera: oppure camera:folder') }}

+
+