Ricetta DXF da camera: pannello editor, stato calibrazione stazioni, proxy camera (#83)

Rotte maker: elenco stazioni con camera, grab per stazione, stato calibrazione
dall'acquisitore (C1), inoltro a B3. Pagina stazioni: stato e link «Calibra».
Test con api_client e requests finti; traduzioni IT/EN.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 22:51:11 +02:00
parent ae2353f7f8
commit 1bfcfaa5f2
8 changed files with 931 additions and 2 deletions
+170 -1
View File
@@ -4,7 +4,9 @@ import math
import requests as http_requests
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from functools import wraps
from flask import Blueprint, Response, abort, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
@@ -483,6 +485,173 @@ def api_upload_vision_recipe(task_id: int):
return jsonify(resp), 200
# ============================================================================
# RICETTA DXF DA CAMERA (#83): stazioni, camera, calibrazione, creazione ricetta
# ============================================================================
def maker_or_admin_required(f):
"""Maker dell'editor oppure admin della pagina stazioni (#83: stesse rotte camera)."""
@wraps(f)
def decorated(*args, **kwargs):
user = session.get("user", {})
if not (user.get("is_admin") or "Maker" in user.get("roles", [])):
abort(403)
return f(*args, **kwargs)
return decorated
def _stations_for_maker() -> list[dict]:
"""Stazioni visibili al chiamante.
GET /api/stations e' solo admin: un Maker non admin ripiega sulla stazione
corrente (by-code, aperta a tutti), cosi' l'editor funziona comunque.
"""
resp = api_client.get("/api/stations")
if isinstance(resp, list):
return resp
if Config.STATION_CODE:
one = api_client.get_station_by_code(Config.STATION_CODE)
if isinstance(one, dict) and not one.get("error"):
return [one]
return []
def _camera_of_station(station_id: int) -> tuple[str, str] | None:
"""(camera_url, camera_source) della stazione con questo id, None se senza camera."""
for st in _stations_for_maker():
if st.get("id") == station_id:
if st.get("camera_url") and st.get("camera_source"):
return str(st["camera_url"]), str(st["camera_source"])
return None
return None
@maker_bp.route("/api/stations", methods=["GET"])
@login_required
@maker_or_admin_required
def api_list_stations_for_maker():
"""Elenco stazioni con camera per il pannello «Crea ricetta da DXF»."""
out = []
for st in _stations_for_maker():
if st.get("camera_url") and st.get("camera_source"):
out.append({
"id": st.get("id"), "code": st.get("code"), "name": st.get("name"),
"camera_url": st.get("camera_url"),
})
return jsonify(out), 200
# Copia minima della logica di measure.api_vision_grab, ma per una stazione scelta per id
@maker_bp.route("/api/stations/<int:station_id>/camera/grab", methods=["GET", "POST"])
@login_required
@maker_or_admin_required
def api_station_camera_grab(station_id: int):
"""Proxy: un frame dalla camera della stazione, risposta PNG."""
camera = _camera_of_station(station_id)
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")
@maker_bp.route("/api/stations/<int:station_id>/camera/calibration", methods=["GET"])
@login_required
@maker_or_admin_required
def api_station_camera_calibration(station_id: int):
"""Proxy: calibrazione della camera letta dall'acquisitore (C1).
Risponde sempre 200 con `state`: calibrated | uncalibrated | unreachable,
cosi' la UI mostra lo stato senza trattare «non calibrata» come errore.
409 solo se la stazione non ha camera.
"""
camera = _camera_of_station(station_id)
if camera is None:
return jsonify({
"error": True,
"detail": _("Camera non configurata per questa stazione"),
}), 409
camera_url, camera_source = camera
base = camera_url.rstrip("/")
link = f"{base}/calibrazione" # pagina di calibrazione dell'acquisitore (C4)
try:
resp = http_requests.get(
f"{base}/camera/calibration",
params={"source": camera_source},
timeout=10,
)
except http_requests.RequestException as exc:
return jsonify({"state": "unreachable", "detail": str(exc), "calibration_url": link}), 200
if resp.status_code == 404:
return jsonify({"state": "uncalibrated", "calibration_url": link}), 200
if resp.status_code != 200:
return jsonify({
"state": "unreachable", "calibration_url": link,
"detail": _("Risposta %(status)s dall'acquisitore", status=resp.status_code),
}), 200
try:
calibration = resp.json()
except ValueError:
return jsonify({"state": "unreachable", "calibration_url": link,
"detail": _("Risposta non valida dall'acquisitore")}), 200
return jsonify({"state": "calibrated", "calibration": calibration, "calibration_url": link}), 200
@maker_bp.route("/api/vision/tasks/<int:task_id>/recipe/dxf-compare", methods=["POST"])
@login_required
@role_required("Maker")
def api_dxf_compare_recipe(task_id: int):
"""Proxy: crea (o prova, save=false) la ricetta DXF compare da camera (B3)."""
image = request.files.get("image")
dxf = request.files.get("dxf")
if image is None or not image.filename or dxf is None or not dxf.filename:
return jsonify({"error": True, "detail": _("Servono l'immagine e il file DXF")}), 400
# Solo i campi del contratto B3; i vuoti non si inoltrano (il backend ha i default)
data = {}
for key in ("station_id", "px_per_mm", "band_mm", "min_points_pct", "polarity", "save"):
value = request.form.get(key)
if value not in (None, ""):
data[key] = value
resp = api_client.post(
f"/api/vision/tasks/{task_id}/recipe/dxf-compare",
data=data,
files={
"image": (image.filename, image.read(), image.mimetype or "application/octet-stream"),
"dxf": (dxf.filename, dxf.read(), dxf.mimetype or "application/octet-stream"),
},
)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required
def api_get_file(file_path: str):