diff --git a/README.md b/README.md index c57b662..3d54059 100644 --- a/README.md +++ b/README.md @@ -516,6 +516,22 @@ Copia `.env.example` in `.env` e configura: --- +## Camera di stazione (demo fiera, #72) + +Per acquisire il fotogramma da una camera locale (per esempio quella del TC bench) senza passare dall'upload, sul PC della stazione gira un piccolo servizio nativo, fuori da Docker, perché la camera GigE non si raggiunge dal container. Si avvia dalla radice del repository con l'ambiente di VisionSuite (`uv sync --all-packages` in `vendor/visionsuite`): + +```bash +vendor/visionsuite/.venv/bin/python scripts/camera_service.py [--host 127.0.0.1] [--port 8765] [--folder ] +``` + +Con `--folder` la sorgente `camera:folder` restituisce a rotazione le immagini della cartella, utile per provare senza hardware. Su Linux, perché il container `client` lo raggiunga, si usa `--host 172.17.0.1` (gateway di docker0; il compose di sviluppo mappa `host.docker.internal` su quell'host). Su Windows con Docker Desktop si presume che `host.docker.internal` raggiunga il loopback dell'host, ma va verificato sul PC della demo. + +⚠️ Il servizio non ha autenticazione: un `--host` diverso dal loopback va usato solo su una rete isolata. + +Nella schermata Stazioni si compilano `camera_url` (per esempio `http://host.docker.internal:8765`) e `camera_source` (`camera:` oppure `camera:folder`). Per avere due postazioni sullo stesso PC si abilita `STATION_SWITCH_ENABLED`. + +--- + ## Documentazione Indice completo: [`docs/README.md`](docs/README.md). 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/scripts/camera_service.py b/scripts/camera_service.py new file mode 100644 index 0000000..4a61105 --- /dev/null +++ b/scripts/camera_service.py @@ -0,0 +1,144 @@ +"""Servizio camera minimo di stazione (TieMeasureFlow, #72). + +Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL, +OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo +`grab` (copiato da vendor/visionsuite/packages/vs-gui/.../server.py): + GET /camera/sources -> {"sources": [{name, kind, label}]} + POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG +Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout. +Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo +raggiunge; questo servizio permette di scegliere l'host di ascolto. + +Uso (dalla radice del repo): + vendor/visionsuite/.venv/bin/python scripts/camera_service.py \ + [--host 127.0.0.1] [--port 8765] [--folder ] + --folder abilita la sorgente finta `camera:folder` (loop sulle immagini). + Da Docker su Linux: --host 172.17.0.1 (gateway di docker0). + +Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite): +vs-camera, fastapi, uvicorn, opencv. + +AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la +camera a chiunque raggiunga la porta: solo su rete isolata. +""" +from __future__ import annotations + +import argparse +import threading +from contextlib import asynccontextmanager +from pathlib import Path + +import cv2 +from fastapi import Body, FastAPI, HTTPException +from fastapi.responses import Response + +from visionsuite.camera.camera_interface import CameraError, CameraGrabError +from visionsuite.camera.sources import SourceNotFound, list_sources, open_source + +_TRIGGER = ("continuo",) + +# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso +_MESSAGGI = { + "Camera is not open": "La camera non è aperta", + "Camera is not grabbing": "La camera non sta acquisendo", + "Grab cancelled (grabbing stopped)": "Acquisizione annullata (acquisizione fermata)", + "Failed to read frame from RTSP stream": "Lettura del fotogramma dallo stream RTSP non riuscita", +} +_PREFISSI = ( + ("Failed to open OpenCV device", "Apertura del dispositivo OpenCV non riuscita"), + ("Failed to open RTSP stream", "Apertura dello stream RTSP non riuscita"), + ("Failed to reconnect to RTSP stream", "Riconnessione allo stream RTSP non riuscita"), + ("Failed to read frame after", "Lettura del fotogramma non riuscita dopo"), +) + + +def _traduci(messaggio: str) -> str: + if messaggio in _MESSAGGI: + return _MESSAGGI[messaggio] + for inglese, italiano in _PREFISSI: + if messaggio.startswith(inglese): + return italiano + messaggio[len(inglese):] + return messaggio + + +def create_app(folder: Path | str | None = None) -> FastAPI: + aperte: dict = {} + lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura + lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab + + def _config() -> dict: + if folder is None: + return {} + return {"folder": {"folder": str(folder), "loop": True}} + + def _lock_di(nome: str) -> threading.Lock: + with lock: + return lock_sorgenti.setdefault(nome, threading.Lock()) + + @asynccontextmanager + async def _vita(_app): + yield + with lock: # alla chiusura rilascia le camere aperte + sorgenti = list(aperte.values()) + aperte.clear() + for s in sorgenti: + try: + s.close() + except Exception: + pass + + app = FastAPI(title="TMF camera service", lifespan=_vita) + + @app.get("/camera/sources") + def camera_sources(): + voci = list_sources(None, cameras=True, config=_config()) + return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label} + for v in voci if v.kind in ("camera", "virtual")]} + + @app.post("/camera/grab") + def camera_grab(corpo: dict = Body(default=None)): + nome = corpo.get("source") if isinstance(corpo, dict) else None + if not isinstance(nome, str) or not nome.strip(): + raise HTTPException(400, "manca «source»") + nome = nome.strip() + trigger = corpo.get("trigger", "continuo") + if trigger not in _TRIGGER: + raise HTTPException(400, "«trigger» supportato: solo continuo") + timeout = corpo.get("timeout_ms", 5000) + if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000: + raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000") + try: + with _lock_di(nome): # solo questa sorgente; le altre non aspettano + with lock: + sorgente = aperte.get(nome) + if sorgente is None: + sorgente = open_source(nome, None, config=_config()) + with lock: + aperte[nome] = sorgente + frame = sorgente.grab(timeout) + except SourceNotFound as e: + raise HTTPException(404, _traduci(str(e))) + except TimeoutError as e: + raise HTTPException(504, _traduci(str(e)) or "timeout") + except (CameraGrabError, CameraError) as e: + raise HTTPException(503, _traduci(str(e))) + ok, buf = cv2.imencode(".png", frame) + if not ok: + raise HTTPException(500, "codifica PNG fallita") + return Response(buf.tobytes(), media_type="image/png") + + return app + + +def main() -> None: + import uvicorn + p = argparse.ArgumentParser(description="Servizio camera minimo di stazione (#72)") + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--port", type=int, default=8765) + p.add_argument("--folder", default=None, help="cartella immagini per camera:folder") + a = p.parse_args() + uvicorn.run(create_app(a.folder), host=a.host, port=a.port) + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_camera_service.py b/scripts/tests/test_camera_service.py new file mode 100644 index 0000000..c66bf33 --- /dev/null +++ b/scripts/tests/test_camera_service.py @@ -0,0 +1,116 @@ +"""Test di scripts/camera_service.py (#72). + +Uso: vendor/visionsuite/.venv/bin/python -m pytest scripts/tests/test_camera_service.py -q +Dipende da: venv di VisionSuite (vs-camera, fastapi, httpx, opencv, pytest). +""" +import importlib.util +from pathlib import Path + +import cv2 +import numpy as np +import pytest +from fastapi.testclient import TestClient + +MODULO = Path(__file__).resolve().parents[1] / "camera_service.py" + + +def _carica(): + spec = importlib.util.spec_from_file_location("camera_service", MODULO) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def cartella(tmp_path): + for nome, (w, h) in {"a.png": (64, 48), "b.png": (64, 48)}.items(): + cv2.imwrite(str(tmp_path / nome), np.full((h, w, 3), 128, np.uint8)) + return tmp_path + + +@pytest.fixture +def client(cartella): + mod = _carica() + with TestClient(mod.create_app(cartella)) as c: + c.mod = mod + yield c + + +def test_sources_contiene_folder(client): + r = client.get("/camera/sources") + assert r.status_code == 200 + nomi = [s["name"] for s in r.json()["sources"]] + assert "camera:folder" in nomi + voce = next(s for s in r.json()["sources"] if s["name"] == "camera:folder") + assert {"name", "kind", "label"} <= set(voce) + + +def test_grab_folder_restituisce_png(client): + r = client.post("/camera/grab", json={"source": "camera:folder"}) + assert r.status_code == 200 + assert r.headers["content-type"] == "image/png" + img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR) + assert img.shape[:2] == (48, 64) + + +def test_grab_ripetuto_in_loop(client): + # due immagini in cartella, tre grab: il loop non deve esaurirsi + for _ in range(3): + assert client.post("/camera/grab", json={"source": "camera:folder"}).status_code == 200 + + +def test_source_mancante_400(client): + assert client.post("/camera/grab", json={}).status_code == 400 + assert client.post("/camera/grab", json={"source": " "}).status_code == 400 + + +def test_source_inesistente_404(client): + assert client.post("/camera/grab", json={"source": "camera:non-esiste"}).status_code == 404 + + +def test_timeout_non_valido_400(client): + r = client.post("/camera/grab", json={"source": "camera:folder", "timeout_ms": 0}) + assert r.status_code == 400 + + +def test_errore_camera_503_tradotto(client, monkeypatch): + from visionsuite.camera.camera_interface import CameraGrabError + + class Rotta: + def grab(self, timeout): + raise CameraGrabError("Camera is not open") + + def close(self): + pass + + monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Rotta()) + r = client.post("/camera/grab", json={"source": "camera:finta"}) + assert r.status_code == 503 + assert r.json()["detail"] == "La camera non è aperta" + + +def test_timeout_504(client, monkeypatch): + class Lenta: + def grab(self, timeout): + raise TimeoutError("scaduto") + + def close(self): + pass + + monkeypatch.setattr(client.mod, "open_source", lambda *a, **k: Lenta()) + r = client.post("/camera/grab", json={"source": "camera:lenta"}) + assert r.status_code == 504 + + +def test_sorgente_aperta_una_volta(client, monkeypatch): + aperture = [] + reale = client.mod.open_source + + def conta(*a, **k): + aperture.append(a[0]) + return reale(*a, **k) + + monkeypatch.setattr(client.mod, "open_source", conta) + for _ in range(3): + client.post("/camera/grab", json={"source": "camera:folder"}) + assert aperture == ["camera:folder"] diff --git a/src/backend/api/routers/stations.py b/src/backend/api/routers/stations.py index 3935a16..4f2f577 100644 --- a/src/backend/api/routers/stations.py +++ b/src/backend/api/routers/stations.py @@ -67,6 +67,21 @@ async def create_new_station( # The int-typed station_id param already guards against "by-code" being # matched as a station id, but keeping the explicit order avoids surprises # during refactors (e.g. if someone regroups handlers by HTTP method). +@router.get("/by-code/{code}", response_model=StationResponse) +async def get_station_by_code( + code: str, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Get a station by its code (any authenticated user, like /by-code/{code}/recipes) (#72).""" + station = await station_service.get_station_by_code(db, code) + if station is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Station not found", + ) + return StationResponse.model_validate(station) + + @router.get("/by-code/{code}/recipes", response_model=list[RecipeSummary]) async def list_recipes_by_station_code( code: str, diff --git a/src/backend/migrations/versions/020_station_camera.py b/src/backend/migrations/versions/020_station_camera.py new file mode 100644 index 0000000..13f7c32 --- /dev/null +++ b/src/backend/migrations/versions/020_station_camera.py @@ -0,0 +1,33 @@ +"""stations.camera_url e stations.camera_source (#72) + +stations.camera_url: servizio camera della stazione (vs-gui o equivalente). +stations.camera_source: sorgente da chiedere al servizio, es. camera: +o camera:folder. + +Revision ID: 020_station_camera +Revises: 019_station_vision_worker +Create Date: 2026-10-15 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '020_station_camera' +down_revision: Union[str, None] = '019_station_vision_worker' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # batch_alter_table come nelle migrazioni vicine: serve a SQLite + with op.batch_alter_table('stations') as batch: + batch.add_column(sa.Column('camera_url', sa.String(500), nullable=True)) + batch.add_column(sa.Column('camera_source', sa.String(200), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('stations') as batch: + batch.drop_column('camera_source') + batch.drop_column('camera_url') diff --git a/src/backend/models/api/station.py b/src/backend/models/api/station.py index 6fb10bc..3024704 100644 --- a/src/backend/models/api/station.py +++ b/src/backend/models/api/station.py @@ -5,18 +5,35 @@ from typing import Optional from pydantic import BaseModel, ConfigDict, Field, field_validator -def _normalize_worker_url(value: Optional[str]) -> Optional[str]: - """Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45).""" +def _normalize_http_url(value: Optional[str], field: str) -> Optional[str]: + """Vuoto/spazi -> None; altrimenti http(s) senza '/' finale.""" if value is None: return None value = value.strip() if not value: return None if not value.lower().startswith(("http://", "https://")): - raise ValueError("vision_worker_url must start with http:// or https://") + raise ValueError(f"{field} must start with http:// or https://") return value.rstrip("/") +def _normalize_worker_url(value: Optional[str]) -> Optional[str]: + """Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45).""" + return _normalize_http_url(value, "vision_worker_url") + + +def _normalize_camera_url(value: Optional[str]) -> Optional[str]: + """Vuoto/spazi -> None (nessuna camera); altrimenti http(s) senza '/' finale (#72).""" + return _normalize_http_url(value, "camera_url") + + +def _normalize_camera_source(value: Optional[str]) -> Optional[str]: + """Strip; vuoto -> None (#72).""" + if value is None: + return None + return value.strip() or None + + class StationCreate(BaseModel): code: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=255) @@ -25,8 +42,13 @@ class StationCreate(BaseModel): active: bool = True # worker di visione della stazione (#45); vuoto = centrale vision_worker_url: Optional[str] = Field(default=None, max_length=500) + # camera della stazione (#72); vuoto = nessuna camera + camera_url: Optional[str] = Field(default=None, max_length=500) + camera_source: Optional[str] = Field(default=None, max_length=200) _check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url) + _check_camera_url = field_validator("camera_url")(_normalize_camera_url) + _check_camera_source = field_validator("camera_source")(_normalize_camera_source) class StationUpdate(BaseModel): @@ -36,8 +58,13 @@ class StationUpdate(BaseModel): active: Optional[bool] = None # worker di visione della stazione (#45); "" -> None riporta al centrale vision_worker_url: Optional[str] = Field(default=None, max_length=500) + # camera della stazione (#72); vuoto = nessuna camera + camera_url: Optional[str] = Field(default=None, max_length=500) + camera_source: Optional[str] = Field(default=None, max_length=200) _check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url) + _check_camera_url = field_validator("camera_url")(_normalize_camera_url) + _check_camera_source = field_validator("camera_source")(_normalize_camera_source) class StationResponse(BaseModel): @@ -49,6 +76,8 @@ class StationResponse(BaseModel): notes: Optional[str] active: bool vision_worker_url: Optional[str] = None # #45 + camera_url: Optional[str] = None # #72 + camera_source: Optional[str] = None # #72 created_by: int created_at: UtcDateTime diff --git a/src/backend/models/orm/station.py b/src/backend/models/orm/station.py index 973bcaa..4d2cf32 100644 --- a/src/backend/models/orm/station.py +++ b/src/backend/models/orm/station.py @@ -27,6 +27,9 @@ class Station(Base): active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) # URL del worker di visione della stazione (#45); NULL = worker centrale vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + # camera della stazione (#72): URL del servizio camera e sorgente da chiedergli + camera_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + camera_source: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.now() diff --git a/src/backend/services/station_service.py b/src/backend/services/station_service.py index eb35dfa..14c4a31 100644 --- a/src/backend/services/station_service.py +++ b/src/backend/services/station_service.py @@ -32,6 +32,8 @@ async def create_station( notes=data.notes, active=data.active, vision_worker_url=data.vision_worker_url, # #45 + camera_url=data.camera_url, # #72 + camera_source=data.camera_source, # #72 created_by=creator.id, ) db.add(station) diff --git a/src/backend/tests/test_migrations.py b/src/backend/tests/test_migrations.py index 613e426..5d4baf4 100644 --- a/src/backend/tests/test_migrations.py +++ b/src/backend/tests/test_migrations.py @@ -321,3 +321,23 @@ class TestStationVisionWorkerMigration: command.upgrade(cfg, "head") # e di nuovo su assert "vision_worker_url" in cols("stations") assert "worker_url" in cols("vision_results") + + +class TestStationCameraMigration: + """020_station_camera: stations.camera_url e stations.camera_source, su e giu (#72).""" + + def test_020_upgrade_and_downgrade(self, sqlite_database_url): + db_path, _ = sqlite_database_url + cfg = _alembic_config() + + def cols(table): + return {c["name"] for c in _inspect(db_path).get_columns(table)} + + command.upgrade(cfg, "head") + assert {"camera_url", "camera_source"} <= cols("stations") + command.downgrade(cfg, "019_station_vision_worker") + assert "camera_url" not in cols("stations") + assert "camera_source" not in cols("stations") + assert "vision_worker_url" in cols("stations") # la 019 resta + command.upgrade(cfg, "head") # e di nuovo su + assert {"camera_url", "camera_source"} <= cols("stations") diff --git a/src/backend/tests/test_stations_api.py b/src/backend/tests/test_stations_api.py index 87551d2..0fbcd0b 100644 --- a/src/backend/tests/test_stations_api.py +++ b/src/backend/tests/test_stations_api.py @@ -430,3 +430,103 @@ async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin json={"vision_worker_url": "ftp://x"}, ) assert resp.status_code == 422 + + +# --- camera_url / camera_source (#72) --- + +async def test_station_camera_fields_roundtrip(client: AsyncClient, admin_user): + created = await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={ + "code": "ST-CAM", "name": "CAM", + "camera_url": " http://127.0.0.1:8765/ ", + "camera_source": " camera:folder ", + }, + ) + assert created.status_code == 201, created.text + assert created.json()["camera_url"] == "http://127.0.0.1:8765" + assert created.json()["camera_source"] == "camera:folder" + + sid = created.json()["id"] + read = await client.get(f"/api/stations/{sid}", headers=auth_headers(admin_user)) + assert read.status_code == 200 + assert read.json()["camera_url"] == "http://127.0.0.1:8765" + assert read.json()["camera_source"] == "camera:folder" + + updated = await client.put( + f"/api/stations/{sid}", headers=auth_headers(admin_user), + json={"camera_url": "https://cam.example:9000", "camera_source": "camera:ABC123"}, + ) + assert updated.status_code == 200 + assert updated.json()["camera_url"] == "https://cam.example:9000" + assert updated.json()["camera_source"] == "camera:ABC123" + + cleared = await client.put( + f"/api/stations/{sid}", headers=auth_headers(admin_user), + json={"camera_url": "", "camera_source": " "}, + ) + assert cleared.status_code == 200 + assert cleared.json()["camera_url"] is None + assert cleared.json()["camera_source"] is None + + +async def test_station_camera_fields_default_to_null(client: AsyncClient, admin_user): + created = await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={"code": "ST-CN", "name": "CN"}, + ) + assert created.status_code == 201 + assert created.json()["camera_url"] is None + assert created.json()["camera_source"] is None + + +async def test_station_camera_url_must_be_http(client: AsyncClient, admin_user): + resp = await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={"code": "ST-CF", "name": "CF", "camera_url": "ftp://x"}, + ) + assert resp.status_code == 422 + + created = await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={"code": "ST-CG", "name": "CG"}, + ) + resp = await client.put( + f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user), + json={"camera_url": "ftp://x"}, + ) + assert resp.status_code == 422 + + +async def test_station_by_code_returns_station(client: AsyncClient, admin_user): + await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={"code": "ST-BC", "name": "BC", "camera_url": "http://h:1", "camera_source": "camera:folder"}, + ) + resp = await client.get("/api/stations/by-code/ST-BC", headers=auth_headers(admin_user)) + assert resp.status_code == 200, resp.text + assert resp.json()["code"] == "ST-BC" + assert resp.json()["camera_url"] == "http://h:1" + assert resp.json()["camera_source"] == "camera:folder" + + +async def test_station_by_code_unknown_is_404(client: AsyncClient, admin_user): + resp = await client.get("/api/stations/by-code/NOPE", headers=auth_headers(admin_user)) + assert resp.status_code == 404 + + +async def test_station_by_code_any_authenticated_user( + client: AsyncClient, measurement_tec_user, admin_user, +): + await client.post( + "/api/stations", headers=auth_headers(admin_user), + json={"code": "ST-BA", "name": "BA"}, + ) + resp = await client.get("/api/stations/by-code/ST-BA", headers=auth_headers(measurement_tec_user)) + assert resp.status_code == 200, resp.text + assert resp.json()["code"] == "ST-BA" + + +async def test_station_by_code_requires_auth(client: AsyncClient): + resp = await client.get("/api/stations/by-code/ST-BA") + assert resp.status_code == 401 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') }}

+
+