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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user