Compare commits

..

82 Commits

Author SHA1 Message Date
Adriano d6cee498ad Acquisizione: pagina a due tab, calibrazioni salvate con nome e carica per sorgente (Refs #91)
Tab Acquisizione | Calibrazione con anteprima sempre visibile; il calcolo non salva piu',
Salva usa calcolo_id e un nome (conferma di sovrascrittura al secondo clic);
elenco unico con Carica / Scollega / Elimina e segno di risoluzione diversa.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:08:02 +02:00
Adriano 828471d27a Acquisizione: anteprima a pausa minima 100 ms (Refs #90)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:49:17 +02:00
Adriano cf64a247be acquisitore: frame sempre piu' recente (Refs #89)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 15:44:38 +02:00
Adriano 813b66d0c5 Acquisizione: parametri camera con un decimale nelle caselle (#87)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:28:59 +02:00
Adriano 8c6d6c1630 sviluppo.ps1: -EscludiCamere; bump visionsuite VS_CAMERA_ESCLUDI (TieVision-Core#339)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:25:13 +02:00
Adriano 1afaaa0bae packaging: sviluppo.ps1, TMF da sorgente senza build Nuitka (#87 #88)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 15:09:33 +02:00
Adriano cb66371a5b Stazioni: x-cloak rimesso sulle modali, tolto per sbaglio (#88)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:58:23 +02:00
Adriano 390d6210d0 Stazioni: sorgenti camera lette dall'acquisitore, link Acquisizione (#88)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:51:22 +02:00
Adriano 2760623b53 Merge remote-tracking branch 'origin/lavoro/daitarn-tmf-acq-pagina' into lavoro/daitarn-tmf-dxfcal 2026-10-24 14:41:55 +02:00
Adriano 13eeec7119 Acquisizione: pagina, attributo hidden rispettato, indicatori su una riga, mirino a 2 px (#87)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:41:25 +02:00
Adriano 0629f1b5ec Acquisizione: pagina nuova con anteprima, parametri camera e stile TMF (#87)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:38:39 +02:00
Adriano 9eb5942459 Acquisitore: anteprima, parametri camera e settaggi di default (#87, piano A)
Rotte nuove nel servizio camera: POST /camera/anteprima (JPEG ridotto con
nitidezza, saturati e media sul frame pieno), GET/PUT /camera/parametri
(esposizione e guadagno con i relativi auto), POST /camera/parametri/salva.
I settaggi salvati vengono ricaricati alla prima apertura della camera.
GET /acquisizione serve la stessa pagina di /calibrazione.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 14:36:18 +02:00
Adriano 11c7d2e98b DXF compare: ancore ICP sul solo pezzo staccato dal bordo, errore ICP con motivo (#86)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 13:54:25 +02:00
Adriano 88bba213e2 bump vendor/visionsuite: guardia overlay fuori immagine (TieVision-Core#337)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 13:53:01 +02:00
Adriano 8ce7b489d6 Merge branch 'lavoro/daitarn-tmf-seedfiera' into lavoro/daitarn-tmf-dxfcal 2026-10-24 10:17:47 +02:00
Adriano bc90d327f1 seed fiera: password 123 per tutti, chiesta da Adriano (#85)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 10:17:45 +02:00
Adriano e46965cb51 Merge branch 'lavoro/daitarn-tmf-seedfiera' into lavoro/daitarn-tmf-dxfcal 2026-10-24 00:55:21 +02:00
Adriano c554fcfda7 seed fiera: commento sulle password corretto (#85)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 00:55:20 +02:00
Adriano 08e46e335b Merge branch 'lavoro/daitarn-tmf-calibdensa' into lavoro/daitarn-tmf-dxfcal 2026-10-24 00:49:52 +02:00
Adriano e38e1a2eaf Calibrazione densa: finestra sub-pixel ristretta al bordo, test su scacchiera continua (#84)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-24 00:00:36 +02:00
Adriano 7fa5bacdfe Acquisitore: calibrazione densa su C2/grab/pagina, test (#84)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-23 23:56:15 +02:00
Adriano 896a30b376 Calibrazione densa da una foto: griglia, mappa di distorsione, archivio .npz (#84)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-23 23:53:48 +02:00
Adriano 13dbc8f13b Merge branch 'lavoro/daitarn-tmf-dxfcal-frontend' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 8925968f34 Merge branch 'lavoro/daitarn-tmf-dxfcal-backend' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 2959b79fa2 Merge branch 'lavoro/daitarn-tmf-dxfcal-worker' into lavoro/daitarn-tmf-dxfcal 2026-10-23 23:52:00 +02:00
Adriano 990b5d0db8 Seed fiera: utenti, stazione, ricette e storico misure (#85)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2026-10-23 23:51:00 +02:00
Adriano 1bfcfaa5f2 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>
2026-10-23 22:51:11 +02:00
Adriano ad6ce5d11d Backend: ricetta DXF compare da camera e calibrazione letta dall'acquisitore (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:44:15 +02:00
Adriano 351739ec36 Acquisitore: calibrazione a scacchiera per sorgente, rotte e pagina /calibrazione (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:43:32 +02:00
Adriano 9f2d624b4f Worker: endpoint /recipes/dxf-compare con test (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:40:08 +02:00
Adriano 9fe82d7cef Costruttore ricetta DXF compare dal campo, con test (#83)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 22:39:21 +02:00
Adriano ae2353f7f8 Pacchetto Windows: registra gli strumenti senza entry point e controlla il catalogo nel build (#82)
rilascio-windows / setup-windows (push) Successful in 32m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMYrdz6uBbuwbaQ9MqDmMc
2026-10-19 23:03:48 +02:00
Adriano d652891c1c Merge lavoro/daitarn-tmf-81 (TieMeasureFlow#81)
rilascio-windows / setup-windows (push) Successful in 34m11s
2026-10-17 14:25:56 +02:00
Adriano 0b22643d69 Merge lavoro/daitarn-tmf-80 (TieMeasureFlow#80) 2026-10-17 14:25:56 +02:00
Adriano 15e31e2b8a Merge lavoro/daitarn-tmf-79 (TieMeasureFlow#79) 2026-10-17 14:25:55 +02:00
Adriano f8f5f248ff setup: init-db e reset-db stampano head in un thread (TieMeasureFlow#80)
_stamp_head esegue env.py di alembic, che fa asyncio.run() e dentro il loop
di FastAPI dava RuntimeError (500). Ora gira con asyncio.to_thread. Nuovo
test_setup_stamp.py senza monkeypatch di _stamp_head, su file SQLite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:25:24 +02:00
Adriano f33415df6a Test frontend: niente piu' dipendenza dall'ordine sulla stazione (TieMeasureFlow#79)
test_measure.py non ricarica piu' config/blueprints.measure (monkeypatch.setattr,
annullato al teardown); TestTaskComplete e test_recipe_rules_ui.py fissano la
stazione esplicitamente con una fixture autouse.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 14:13:15 +02:00
Adriano 62b5e412ca static del frontend sempre LF (.gitattributes): impronte di VERSIONS.md e pacchetto Windows identici (TieMeasureFlow#81)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:10:39 +02:00
Adriano 0e92959022 Merge piano C: setup Windows di TieMeasureFlow (TieMeasureFlow#76)
rilascio-windows / setup-windows (push) Successful in 32m46s
2026-10-17 12:39:35 +02:00
Adriano f23e87e585 Merge piano B: report PDF su Windows offline (TieMeasureFlow#78) 2026-10-17 12:39:35 +02:00
Adriano 114e4a10ae Merge piano A: backend su SQLite (TieMeasureFlow#77) 2026-10-17 12:39:34 +02:00
Adriano 3aac8cc490 CLAUDE.md: trappole del pacchetto Windows (stazione, alembic, init-db) (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 11:28:12 +02:00
Adriano 8b3e897668 Workflow rilascio-windows: corregge il percorso di Node nel PATH (YAML valido) (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 11:25:22 +02:00
Adriano eea3e319fb Pacchetto Windows: STATION_CODE predefinito e chiavi extra da config.env, alembic.ini sourceless, fileConfig neutralizzato (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 10:55:48 +02:00
Adriano bfc7c391e8 build.ps1: niente metadati vs-* (Nuitka li rifiuta per i pacchetti del namespace visionsuite) (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:29:45 +02:00
Adriano 8d4c4020f6 Pacchetto Windows: tzdata nel gruppo windows, flag Nuitka dal LEGGIMI del piano PDF, trappole in CLAUDE.md (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:20:58 +02:00
Adriano 0a68a9995e Pacchetto Windows: LEGGIMI dei report PDF (DLL, variabili, flag Nuitka, licenze) (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:18:21 +02:00
Adriano 959e92552b Pacchetto Windows: test dell'avvio, workflow rilascio-windows, sezione README (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:17:18 +02:00
Adriano 8431aa7095 Report PDF su Windows: MathJax locale per Kaleido (niente CDN offline) e test con WeasyPrint/Edge veri (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:16:29 +02:00
Adriano 69cbf53060 Pacchetto Windows: build.ps1 (Nuitka standalone + migrazioni .pyc + Inno Setup), installer .iss, icona (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:14:01 +02:00
Adriano 23012fdfbb Pacchetto Windows: prendi-gtk.ps1 scarica le DLL Pango/GTK pinnate con sha256 (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:10:11 +02:00
Adriano d89a2e6c8d Pacchetto Windows: avvio.py (licenza, config.env, migrazioni, quattro server in thread), vs-camera nel gruppo windows (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:10:05 +02:00
Adriano 242add047b Backend su file SQLite locale: DB_URL, PRAGMA FK/WAL, UPLOAD_DIR assoluto (TieMeasureFlow#77)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:07:45 +02:00
Adriano 09c4029978 Gruppo uv windows per il pacchetto Windows: aiosqlite, waitress, vs-license, nuitka (TieMeasureFlow#76)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 09:07:22 +02:00
Adriano cd40b81f1e Merge lavoro/daitarn-tmf-core2e7: vendor TieVision-Core 2e73691 (Fixes #75) 2026-10-17 00:39:43 +02:00
Adriano 6e60e0b1a9 vendor TieVision-Core 4c178cf -> 2e73691: CompareDxf dxf_path su recipe_dir, catalogo EdgesBetweenSegments (#75)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8FDdHxKu17fSf9T6aZETK
2026-10-17 00:39:36 +02:00
Adriano 6bc6c0c0d5 Submodule vendor/visionsuite: da visionsuite a TieVision-Core, pin 4c178cf (#74)
Il repo e' stato rinominato il 15/10/2026 (TieVision-Core#329): .gitmodules punta
al nuovo URL, il pin passa da cdc7a14 a 4c178cf, CLAUDE.md e il commento di
scripts/camera_service.py seguono la divisione (vs-gui e vs-assistant in
TieVision-Studio e TieVision-Assistant). Test visione: 29 passed prima e dopo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 17:49:13 +02:00
Adriano e5989c9d23 Merge lavoro/jeeg-tmf72: camera di stazione per la demo in fiera (#72)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 10:02:50 +02:00
Adriano 0af6692786 Servizio camera minimo di stazione con test e README (#72)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 10:01:46 +02:00
Adriano 36074ab2f7 Merge frontend camera di stazione (#72) 2026-10-15 09:53:41 +02:00
Adriano fd1bbce8aa Merge backend camera di stazione (#72) 2026-10-15 09:53:41 +02:00
Adriano 1453b31cee by-code: auth da utente loggato qualunque, come /recipes (#72)
Lo chiama il proxy Flask con la chiave dell'operatore MeasurementTec, quindi
get_current_user al posto di require_admin_user. Test: MeasurementTec 200,
senza chiave 401.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:53:33 +02:00
Adriano 192d4b590c Camera di stazione: acquisizione dal frontend e campi admin (#72)
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 <noreply@anthropic.com>
2026-10-15 09:51:07 +02:00
Adriano b9f43a0ede Stazioni: camera_url e camera_source, GET /api/stations/by-code/{code} (#72)
Migrazione 020 con le due colonne su stations, campi in modello, schemi e
servizio (validatore http(s) come vision_worker_url), nuova route by-code
dichiarata prima di /{station_id}. Test di migrazione su e giu e di API.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:50:14 +02:00
Adriano 5bf374aadd Merge lavoro/jeeg-tmf71: 015_api_key_hash idempotente (Fixes #71)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 09:28:40 +02:00
Adriano 74fe5665b5 015_api_key_hash idempotente: DB da create_all e 015 a metà su MySQL (#71)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 09:25:07 +02:00
Adriano be904bac07 Merge lavoro/daitarn-tmf73: vendor visionsuite cdc7a14 (Fixes #73)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcYsFQDaTPnDVjrQkWj8R
2026-10-13 09:26:04 +02:00
Adriano 011c08c9d8 vendor visionsuite adf7a26 -> cdc7a14: revisione GUI e assistente (visionsuite#305-#316) (#73)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JzcYsFQDaTPnDVjrQkWj8R
2026-10-13 09:26:00 +02:00
Adriano 82ad88538d Merge lavoro/daitarn-70: versione e commit nel piè di pagina (Fixes #70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019u7uKckaZ7DBe4oGbiwuHa
2026-10-12 14:25:04 +02:00
Adriano bafc776ef7 Versione e commit nel piè di pagina (#70)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:22:02 +02:00
Adriano 8fcb4796d5 Merge lavoro/daitarn-58: esiti, note, campi e allegati dei task nella statistica per sessione (Fixes #58)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:22:39 +00:00
Adriano 8d7fa9a12a Statistica: pannello esiti, note, campi e allegati dei task per sessione, proxy e traduzioni (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:08:18 +00:00
Adriano c911449b7c Statistica: endpoint esiti, note, campi e allegati dei task di una sessione (#58)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:07:06 +00:00
Adriano ac8604eff5 Merge lavoro/daitarn-45: worker di visione configurabile per stazione (Fixes #45)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 18:03:57 +00:00
Adriano 631ba4ee05 Stazioni: placeholder senza URL letterale (test_offline), test form indipendente dalla lingua (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:49:46 +00:00
Adriano 40eed5c2af Admin stazioni: campo Worker visione (URL), traduzioni e test (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:39:22 +00:00
Adriano 275bbd1a5f Test worker di visione per stazione, API stazioni, migrazione 019 (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:38:03 +00:00
Adriano 95d643d6d8 Worker di visione per stazione: modello, API, migrazione 019, execute_task (#45)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NABedeMX43pyXZbDNUgA5B
2026-10-11 17:37:08 +00:00
Adriano 9306fbf653 Merge lavoro/daitarn-tmf69: vendor visionsuite adf7a26 (Fixes #69)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 16:44:06 +02:00
Adriano 1a5e98f072 vendor visionsuite adf7a26: ricette robuste ai PermissionError di Windows (#69)
Da dbd1a55 a adf7a26 (visionsuite#279): vs-task server/recipes.py ritenta lettura e scrittura su PermissionError. uv.lock invariato (uv lock --check ok). Suite visione 3.13: 34 passati.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 16:44:03 +02:00
Adriano fdfcf05951 Merge lavoro/daitarn-tmf68: vendor visionsuite riallineato a main dbd1a55 (Fixes #68)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 14:07:03 +02:00
Adriano 26884ebf4f vendor visionsuite dbd1a55: riallineato a main (#68)
Da 1e45b33 a dbd1a55 (87 commit): vs-dxf, vs-pm2d, vs-tools, vs-task aggiornati; uv.lock invariato (uv lock --check ok).
Suite visione 3.13: prima 29 passati, dopo 34 passati (con test_vision_boundary).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQqGYniFfkpHEfZiSqBfnW
2026-10-10 14:06:50 +02:00
84 changed files with 10033 additions and 86 deletions
+3
View File
@@ -0,0 +1,3 @@
# TieMeasureFlow#81: static del frontend sempre con LF, anche con core.autocrlf=true su Windows:
# le impronte sha256 di static/vendor/VERSIONS.md (test_offline.py) e il pacchetto Windows le vogliono identiche.
src/frontend/flask_app/static/** text=auto eol=lf
+119
View File
@@ -0,0 +1,119 @@
# TieMeasureFlow#76: setup.exe di TieMeasureFlow per Windows, generato al tag v*.
# Cosa fa: submodule vendor/visionsuite (deploy key), build.ps1 (uv sync Python 3.13, Tailwind,
# Nuitka --standalone, migrazioni .pyc, DLL di WeasyPrint, Inno Setup), poi il setup come
# artifact e come asset della release Gitea del tag.
# Da cosa dipende: un runner Gitea con label `windows` che giri su un host Windows (non in
# container) con Visual Studio/Build Tools (MSVC), git e node (serve a actions/checkout e a
# Tailwind); uv e Inno Setup li installa il workflow se mancano. Secret SUBMODULI_SSH_KEY
# (deploy key di sola lettura di TieVision-Core, impostato sul repo il 17/10/2026).
# Il runner `windows` e' act_runner in modalita' host su daitarn (capacity 1).
# ⚠️ upload-artifact@v3: e' la versione che usa gia' vs-native.yml in TieVision-Core su questo
# Gitea; la v4 usa un protocollo che act_runner puo' non supportare.
# ⚠️ L'asset della release usa il token del job (secrets.GITHUB_TOKEN): se la chiamata risponde
# 403 serve un token personale in un secret (es. RILASCI_TOKEN) al posto di GITHUB_TOKEN.
# ⚠️ La build da freddo e' lunga (Nuitka compila backend, visione, plotly, opencv...): timeout 180 min.
name: rilascio-windows
on:
push:
tags: ['v*']
workflow_dispatch: {}
jobs:
setup-windows:
runs-on: windows
timeout-minutes: 180
defaults:
run:
shell: powershell
steps:
# Checkout senza ssh-key (come ci.yml): i submodule si inizializzano a parte, con la deploy key.
- uses: actions/checkout@v4
- name: Submodule privati (deploy key in sola lettura)
env:
SUBMODULI_SSH_KEY: ${{ secrets.SUBMODULI_SSH_KEY }}
run: |
$ErrorActionPreference = 'Stop'
# cartella temporanea del job, non ~/.ssh: su un runner host e' l'account dell'utente
$ssh = Join-Path $env:TEMP ("submoduli-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force $ssh | Out-Null
$chiave = Join-Path $ssh 'chiave'
# LF e newline finale: OpenSSH rifiuta una chiave con CRLF
[System.IO.File]::WriteAllText($chiave, ($env:SUBMODULI_SSH_KEY.Trim() + "`n"), (New-Object System.Text.UTF8Encoding $false))
# OpenSSH su Windows rifiuta una chiave leggibile da altri utenti; (F) e non (R):
# con la sola lettura Remove-Item finale non puo' cancellarla
icacls $chiave /inheritance:r | Out-Null
icacls $chiave /grant:r "$($env:USERNAME):(F)" | Out-Null
$known = Join-Path $ssh 'known_hosts'
[System.IO.File]::WriteAllText($known, "[git.tielogic.xyz]:222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDvABW6Tvruqkf7KJhJZUiAX6NeCx0enwcNXzZbECQ8J`n", (New-Object System.Text.UTF8Encoding $false))
$env:GIT_SSH_COMMAND = "ssh -i $($chiave -replace '\\','/') -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$($known -replace '\\','/')"
try {
git submodule update --init --recursive
if ($LASTEXITCODE -ne 0) { throw "git submodule update fallito" }
} finally {
# la deploy key non resta mai sul disco del runner
Remove-Item $ssh -Recurse -Force -ErrorAction SilentlyContinue
}
- name: uv (se manca)
run: |
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
irm https://astral.sh/uv/install.ps1 | iex
}
# il PATH del passo successivo
"$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
- name: Node (se manca)
run: |
# serve a Tailwind (npx) in build.ps1
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
winget install --id OpenJS.NodeJS.LTS -e --silent --accept-package-agreements --accept-source-agreements
}
"$env:ProgramFiles\nodejs" | Out-File -Append -Encoding utf8 $env:GITHUB_PATH
- name: Inno Setup (se manca)
run: |
$trovato = (Get-Command ISCC.exe -ErrorAction SilentlyContinue) -or
(Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe") -or
(Test-Path "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")
if (-not $trovato) {
winget install --id JRSoftware.InnoSetup -e --silent --accept-package-agreements --accept-source-agreements
}
- name: Build (Nuitka + Inno Setup)
# GITHUB_REF_NAME e' il tag (v0.1.0); con workflow_dispatch e' il ramo: build.ps1 rifiuta
# un nome senza numeri, quindi in quel caso si passa 0.0.0
run: |
$v = '${{ github.ref_name }}'
if ($v -notmatch '^v?\d+\.\d+\.\d+') { $v = '0.0.0' }
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione $v
if ($LASTEXITCODE -ne 0) { throw "build.ps1 fallito" }
- name: Setup come artifact
uses: actions/upload-artifact@v3
with:
name: TieMeasureFlow-setup
path: dist/TieMeasureFlow-setup-*.exe
if-no-files-found: error
- name: Asset della release Gitea (solo ai tag)
if: startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
$ErrorActionPreference = 'Stop'
$h = @{ Authorization = "token $env:TOKEN" }
# la release del tag: la crea il primo job che arriva, poi si riusa
try {
$rel = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG"
} catch {
$corpo = @{ tag_name = $env:TAG; name = "TieMeasureFlow $env:TAG" } | ConvertTo-Json
$rel = Invoke-RestMethod -Method Post -Headers $h -ContentType 'application/json' -Body $corpo "$env:API/releases"
}
$setup = Get-ChildItem dist\TieMeasureFlow-setup-*.exe | Select-Object -First 1
# curl.exe (incluso in Windows 10+) fa il multipart senza codice a mano
curl.exe --fail -sS -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@$($setup.FullName)" "$env:API/releases/$($rel.id)/assets?name=$($setup.Name)"
if ($LASTEXITCODE -ne 0) { throw "caricamento dell'asset fallito" }
+3
View File
@@ -73,3 +73,6 @@ docker-compose.override.yml
# Subagent-driven development scratch: ledger, briefs, review packages
.superpowers/
# Generato da packaging/windows/build.ps1 (TieMeasureFlow#82): moduli-strumento dagli entry point
packaging/windows/_strumenti.py
+1 -1
View File
@@ -1,3 +1,3 @@
[submodule "vendor/visionsuite"]
path = vendor/visionsuite
url = ssh://git@git.tielogic.xyz:222/Adriano/visionsuite.git
url = ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git
+24 -2
View File
@@ -287,9 +287,9 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
## Visione (V3.0.0)
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** su `ssh://git.tielogic.xyz:222`: un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** che punta al repo TieVision-Core (`ssh://git@git.tielogic.xyz:222/Adriano/TieVision-Core.git`, ex visionsuite, rinominato il 15/10/2026; `vs-gui` e `vs-assistant` sono ora in TieVision-Studio e TieVision-Assistant, che TieMeasureFlow non usa; il path resta `vendor/visionsuite`): un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. Pin attuale: bc0df16.
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. La regola vale ora per `origin/main` di TieVision-Core. Pin attuale: 2e73691.
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
@@ -335,3 +335,25 @@ Variabili d'ambiente in `.env` (copiare da `.env.example`):
- Font UI: Inter
- Font numeri: JetBrains Mono
- Dark mode: `darkMode: 'class'` in tailwind.config.js, toggle via `Alpine.store('theme')` con localStorage key `tmf-theme`
## Pacchetto Windows (TieMeasureFlow#76)
- File: `packaging/windows/avvio.py` (entry point: licenza, `config.env`, variabili d'ambiente, migrazioni, quattro server in thread), `build.ps1` (uv sync 3.13, Tailwind, Nuitka `--standalone`, migrazioni come `.pyc`, Inno Setup), `TieMeasureFlow.iss`, `TieMeasureFlow.ico`; CI `.gitea/workflows/rilascio-windows.yml` (tag `v*`, runner con etichetta `windows`); test in `packaging/windows/tests/` (licenza firmata con la chiave di prova del contratto, server e migrazioni sostituiti). Piani collegati: SQLite su file (#77) e PDF/DLL (#78). Modello: TieVision-Studio#1.
- Un solo processo `TieMeasureFlow.exe`: thread `web` (Flask + waitress, `--host`, 8780), `backend` (8000), `vision` (8100), `camera` (8765) con uvicorn. Dati in `%PROGRAMDATA%\Tielogic\TieMeasureFlow` (`tmf.db`, `uploads`, `camera`, `log`, `config.env`); licenza in `%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json` (mai nel repo).
- ⚠️ Python del pacchetto: 3.13 in un ambiente separato (`build\venv313`, `UV_PROJECT_ENVIRONMENT`); il repo resta su `.python-version` 3.11 per lo sviluppo. `vs-license` e `vs-camera` hanno il marker `python_version>='3.13'` nel gruppo `windows`, altrimenti `uv lock` fallisce sul `requires-python >=3.11`.
- ⚠️ Le variabili d'ambiente si impostano PRIMA di importare backend e frontend (leggono la configurazione all'import): in `avvio.py` gli import pesanti stanno dentro le funzioni. `APP_ENV=production` rifiuta i segreti di default, quindi `config.env` genera anche `DB_PASSWORD` (inutilizzata con SQLite).
- ⚠️ Il frontend Flask non è un pacchetto (`from config import Config`, `blueprints`, `services`, `app`) e `scripts/camera_service.py` neppure: da sorgente `avvio.py` aggiunge `flask_app` e `scripts` al `sys.path`; per Nuitka `build.ps1` li mette su `PYTHONPATH` e li include con `--include-module/--include-package`. I dati di Flask (`templates`, `static`, `translations`) vanno nella radice della cartella standalone, `src\backend\templates` sotto `src\backend`.
- ⚠️ Il browser parla con Flask (8780); la pagina di setup `/api/setup` invece è del backend: si apre su `http://127.0.0.1:8000/api/setup` (in Docker la serve nginx). `avvio.py` la apre da sola se il database non ha utenti.
- ⚠️ Alembic carica `env.py` e `versions\` dal disco, quindi Nuitka non può compilarli: `build.ps1` li copia come soli `.pyc` (`compileall -b`) e `avvio.py` esegue `upgrade head` con `sourceless=true`. È l'unica eccezione al «nessun `.py`/`.pyc`»: il controllo finale di `build.ps1` ammette `.pyc` solo sotto `src\backend\migrations`. `env.py` chiama `fileConfig`, che azzera i gestori del log: `avvio.py` li ricrea dopo le migrazioni.
- ⚠️ `numba` è escluso dal pacchetto (`--nofollow-import-to=numba`): le funzioni compilate da Nuitka non hanno bytecode e numba non può farne il JIT. Core e pm2d hanno il ripiego in Python puro (`HAS_NUMBA=False`): funziona ma il matching è più lento. Da misurare sul TC bench.
- ⚠️ L'exe è senza console: `sys.stdout`/`sys.stderr` possono essere `None` (`avvio.py` li sostituisce con il device nullo), uvicorn gira con `log_config=None` e il log va su file (`...\log\tiemeasureflow.log`). I server uvicorn girano in thread (nessun signal handler) e waitress si crea con `create_server`.
- ⚠️ Strumenti di visione (TieMeasureFlow#82): nel binario non ci sono i metadati di `vs-tools`, quindi `load_tools()` (entry point `visionsuite.task.tools`) non trova nulla e ogni ricetta fallisce con tipo sconosciuto. `build.ps1` genera `packaging/windows/_strumenti.py` (`MODULI`, gitignorato, letto dal venv del build), `avvio.py` `registra_strumenti()` lo importa prima dei server, e prima di Inno Setup `build.ps1` lancia l'exe con `--controlla-strumenti` (senza licenza né server; JSON in `...\log\controllo-strumenti.json`, exit 3 se il catalogo è vuoto o un modulo non si importa).
- ⚠️ `visionsuite` è un namespace condiviso da più distribuzioni: `build.ps1` elenca i sottopacchetti dagli `__path__` e i metadati con `--include-distribution-metadata`. Un nuovo pacchetto di Core si prende da solo se è in `[tool.uv.sources]` e nel gruppo.
- ⚠️ `tailwind.css` non è nel repo: `build.ps1` lo genera con `npx tailwindcss@3.4.19` (stessa versione di `Dockerfile.frontend`); serve Internet solo a chi costruisce. Il setup finale non ha bisogno di Internet.
- ⚠️ Chrome o Edge servono a kaleido per i grafici dei report: `avvio.py` imposta `BROWSER_PATH` al primo trovato. Le DLL di WeasyPrint le scarica `packaging/windows/pdf/prendi-gtk.ps1` (piano #78) in `gtk\` accanto all'exe.
- ⚠️ `SETUP_PASSWORD` e le chiavi stanno in `config.env`, in una cartella scrivibile da tutti gli utenti locali (serve all'exe, che gira con l'utente che lo lancia): non è un luogo per segreti ad alto valore.
- ⚠️ CI di rilascio: il runner `windows` è su daitarn (act_runner, modalità host, capacity 1); `upload-artifact` è alla v3. Non committare `build/` e `dist/`.
- ⚠️ `STATION_CODE`: senza una stazione l'interfaccia risponde 503 («Stazione non configurata»). `config.env` nasce con `STATION_CODE=ST-DEFAULT` (la stazione creata dal seed della pagina di setup); le chiavi extra di `config.env` (`STATION_CODE`, `STATION_SWITCH_ENABLED`, ...) passano all'ambiente ma non sovrascrivono le variabili fisse del contratto.
- ⚠️ Alembic anche fuori da `avvio.py`: `setup.py` (`_stamp_head`) apre `alembic.ini` con un `Config` suo, quindi `build.ps1` scrive `sourceless = true` nell'`alembic.ini` del pacchetto; e `env.py` chiama `logging.config.fileConfig`, che azzererebbe il log su file, quindi `avvio.py` lo sostituisce con un no-op.
- ⚠️ Bug noto, non del pacchetto: `POST /api/setup/init-db` risponde 500 (`asyncio.run() cannot be called from a running event loop`, perché `_stamp_head` esegue `env.py` dentro il loop). Nel pacchetto non serve: le migrazioni creano già le tabelle; la pagina di setup si usa per «Seed» o per creare l'amministratore.
- ⚠️ Le richieste di login all'API sono limitate (5 al minuto): negli script di prova distanziarle.
+7
View File
@@ -47,6 +47,13 @@ WORKDIR /app/flask_app
RUN uv run --project /app pybabel compile -d translations && \
chown -R app:app /app /tmp/uv-cache
# Commit mostrato nel piede di pagina (#70). .git e' escluso dal contesto di
# build (.dockerignore), quindi lo passa chi costruisce; facoltativo, senza il
# piede mostra solo la versione. Uso:
# APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client
ARG APP_COMMIT=""
ENV APP_COMMIT=${APP_COMMIT}
USER app
EXPOSE 5000
+65
View File
@@ -333,6 +333,7 @@ TieMeasureFlow/
|---|---|
| `docker compose -f docker-compose.dev.yml up -d --build` | Avvia servizi in sviluppo (build incluso) |
| `docker compose -f docker-compose.dev.yml down` | Ferma e rimuove i container |
| `APP_COMMIT=$(git rev-parse --short HEAD) docker compose build client` | Ricostruisci il client mostrando nel piè di pagina il commit (oltre alla versione di `pyproject.toml`); senza `APP_COMMIT` compare solo la versione |
| `docker compose logs -f server` | Segui log server in tempo reale |
| `docker compose logs -f client` | Segui log client in tempo reale |
| `docker compose ps` | Stato di tutti i servizi |
@@ -515,6 +516,70 @@ 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 <cartella immagini>]
```
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:<seriale>` oppure `camera:folder`). Per avere due postazioni sullo stesso PC si abilita `STATION_SWITCH_ENABLED`.
---
## Pacchetto Windows
Per i PC senza Docker (per esempio il PC della stazione in fiera) TieMeasureFlow si distribuisce come programma di installazione per Windows, `TieMeasureFlow-setup-<versione>.exe`. Funziona completamente offline, non richiede Docker né MySQL, e non contiene codice sorgente Python: il codice del prodotto è compilato con Nuitka. Il programma parte solo con una licenza valida, legata al computer.
### Installazione
1. Eseguire `TieMeasureFlow-setup-<versione>.exe` (serve un account amministratore; per una prova senza amministratore si può lanciare il setup con `/CURRENTUSER`). Per un'installazione silenziosa: `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART`.
2. Dal menu Start aprire «Impronta di questo computer» e comunicare l'impronta a Tielogic. L'impronta viene salvata anche in `C:\ProgramData\Tielogic\licenze\impronta-tiemeasureflow.txt`.
3. Copiare il file di licenza ricevuto in `C:\ProgramData\Tielogic\licenze\tiemeasureflow.json`. La licenza non è inclusa nel setup e non va mai inserita nel repository.
4. Avviare «TieMeasureFlow» dal menu Start o dal desktop. Senza una licenza valida (mancante, scaduta, di un altro computer o di un altro prodotto) il programma mostra il motivo e l'impronta ed esce.
### Primo avvio
Il programma gira in un solo processo, senza finestra, e apre il browser. Ascolta solo sul computer locale (`127.0.0.1`); per raggiungere l'interfaccia da altri dispositivi della rete si avvia l'exe con `--host 0.0.0.0` (per esempio da un collegamento personalizzato).
| Componente | Indirizzo |
|---|---|
| Interfaccia web (Flask) | http://127.0.0.1:8780/ |
| Backend API (FastAPI) | http://127.0.0.1:8000/ |
| Worker di visione | http://127.0.0.1:8100/ |
| Camera della stazione | http://127.0.0.1:8765/ (`/camera/sources`) |
Al primo avvio vengono create la cartella dati `C:\ProgramData\Tielogic\TieMeasureFlow` e il file `config.env` con le chiavi segrete generate (`SERVER_SECRET_KEY`, `CLIENT_SECRET_KEY`, `SETUP_PASSWORD`, `DB_PASSWORD`); al riavvio vengono rilette, quindi le sessioni e le password restano valide. Il database è un file SQLite (`tmf.db`); le migrazioni si applicano da sole a ogni avvio.
Se il database non contiene ancora utenti, il browser si apre direttamente sulla pagina di setup `http://127.0.0.1:8000/api/setup`. La pagina chiede la `SETUP_PASSWORD`, che si legge in `config.env`, e permette di inizializzare il database e creare il primo amministratore (vedi «Setup Iniziale»). Terminato il setup si accede all'interfaccia su `http://127.0.0.1:8780/`.
Per le camere della stazione si compila nella schermata Stazioni `camera_url` = `http://127.0.0.1:8765` e `camera_source` (`camera:<seriale>` oppure `camera:folder`). Con `camera:folder` il servizio restituisce a rotazione le immagini che si copiano in `C:\ProgramData\Tielogic\TieMeasureFlow\camera`. Per le camere GigE/GenTL il driver (producer `.cti`) è quello installato dal produttore della camera.
Altre informazioni utili:
- I log sono in `C:\ProgramData\Tielogic\TieMeasureFlow\log\tiemeasureflow.log`.
- Una seconda esecuzione mentre il programma è già attivo apre soltanto il browser.
- Per fermare il programma si usa «Arresta TieMeasureFlow» nel menu Start (chiude il processo).
- La disinstallazione non cancella licenze, database, file caricati e `config.env`: sono dati del cliente.
- I report PDF usano le librerie grafiche incluse nel pacchetto e un browser Chrome o Edge già installato sul computer per i grafici.
### Come si costruisce
Il setup lo produce la CI Gitea (`.gitea/workflows/rilascio-windows.yml`) quando si crea un tag `v*`, su un runner con etichetta `windows`; il file compare come allegato della release. In locale, dalla radice del repository con il sottomodulo `vendor/visionsuite` inizializzato:
```powershell
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 -Versione 3.0.0
```
Servono `uv`, Node.js, Visual Studio con il carico C++ e Inno Setup 6. Il risultato è `dist\TieMeasureFlow-setup-3.0.0.exe`. La prima compilazione richiede decine di minuti. I test dell'avvio si lanciano con l'ambiente Python 3.13 del pacchetto: `uv run --python 3.13 --extra server --extra client --extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q`.
---
## Documentazione
Indice completo: [`docs/README.md`](docs/README.md).
+6
View File
@@ -75,6 +75,9 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client
restart: unless-stopped
env_file:
@@ -85,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
+3
View File
@@ -89,6 +89,9 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
args:
# Commit nel piede di pagina (#70); facoltativo, vuoto = solo versione
APP_COMMIT: ${APP_COMMIT:-}
container_name: tmflow-client
restart: unless-stopped
env_file:
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+81
View File
@@ -0,0 +1,81 @@
; Installer di TieMeasureFlow (TieMeasureFlow#76).
; Cosa fa: copia la cartella standalone di Nuitka in Program Files\Tielogic\TieMeasureFlow,
; crea in ProgramData le cartelle scrivibili da tutti gli utenti (licenze, uploads, camera, log)
; e i collegamenti in menu Start e sul desktop.
; Come si usa: lo lancia build.ps1 con /DVersione /DVersione4 /DOrigine /DUscita; a mano:
; ISCC /DVersione=3.0.0 /DVersione4=3.0.0.0 /DOrigine=build\nuitka\avvio.dist /DUscita=dist TieMeasureFlow.iss
; Setup silenzioso: TieMeasureFlow-setup-X.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
; (/CURRENTUSER per installare senza amministratore, in %LOCALAPPDATA%\Programs).
; ATTENZIONE: la licenza NON e' nel setup: va copiata in {commonappdata}\Tielogic\licenze\tiemeasureflow.json
; (la cartella e' scrivibile dagli utenti). La disinstallazione lascia licenze, database (tmf.db),
; uploads e config.env: sono dati del cliente.
#ifndef Versione
#define Versione "0.0.0"
#endif
#ifndef Versione4
#define Versione4 "0.0.0.0"
#endif
#ifndef Origine
#define Origine "..\..\build\nuitka\avvio.dist"
#endif
#ifndef Uscita
#define Uscita "..\..\dist"
#endif
[Setup]
; AppId proprio di TieMeasureFlow (diverso da quello di TieVision Studio)
AppId={{B7D4E2A1-5C93-4F6E-8A0B-3D1F7E9C2A64}
AppName=TieMeasureFlow
AppVersion={#Versione}
AppVerName=TieMeasureFlow {#Versione}
AppPublisher=Tielogic
DefaultDirName={autopf}\Tielogic\TieMeasureFlow
DefaultGroupName=Tielogic\TieMeasureFlow
PrivilegesRequired=admin
; /CURRENTUSER solo da riga di comando: serve alle prove senza amministratore
PrivilegesRequiredOverridesAllowed=commandline
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir={#Uscita}
OutputBaseFilename=TieMeasureFlow-setup-{#Versione}
SetupIconFile=TieMeasureFlow.ico
UninstallDisplayIcon={app}\TieMeasureFlow.exe
VersionInfoVersion={#Versione4}
VersionInfoCompany=Tielogic
VersionInfoProductName=TieMeasureFlow
Compression=lzma2/max
SolidCompression=yes
WizardStyle=modern
DisableProgramGroupPage=yes
; il programma in esecuzione tiene aperti i file: Inno chiede di chiuderlo (in silenzioso lo chiude)
CloseApplications=yes
[Languages]
Name: "italiano"; MessagesFile: "compiler:Languages\Italian.isl"
[Tasks]
Name: "desktopicon"; Description: "Crea un collegamento sul desktop"; GroupDescription: "Collegamenti:"
[Dirs]
; scrivibili da tutti gli utenti: l'exe gira con l'utente che lo lancia, non con admin
Name: "{commonappdata}\Tielogic\licenze"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow\uploads"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow\camera"; Permissions: users-modify
Name: "{commonappdata}\Tielogic\TieMeasureFlow\log"; Permissions: users-modify
[Files]
Source: "{#Origine}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
[Icons]
Name: "{group}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"
Name: "{group}\Impronta di questo computer"; Filename: "{app}\TieMeasureFlow.exe"; Parameters: "--impronta"
; l'exe non ha finestra: per fermarlo si chiude il processo
Name: "{group}\Arresta TieMeasureFlow"; Filename: "{sys}\taskkill.exe"; Parameters: "/IM TieMeasureFlow.exe /F"; Flags: runminimized
Name: "{group}\Cartella dati e configurazione"; Filename: "{commonappdata}\Tielogic\TieMeasureFlow"
Name: "{group}\Disinstalla TieMeasureFlow"; Filename: "{uninstallexe}"
Name: "{autodesktop}\TieMeasureFlow"; Filename: "{app}\TieMeasureFlow.exe"; Tasks: desktopicon
[Run]
Filename: "{app}\TieMeasureFlow.exe"; Description: "Avvia TieMeasureFlow"; Flags: nowait postinstall skipifsilent
+536
View File
@@ -0,0 +1,536 @@
"""Entry point del pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
Cosa fa: controlla la licenza (prodotto "tiemeasureflow"); se non e' valida mostra il
motivo e l'impronta del computer, scrive l'impronta in
%PROGRAMDATA%\\Tielogic\\licenze\\impronta-tiemeasureflow.txt ed esce con 1.
Se e' valida: prepara la cartella dati (%PROGRAMDATA%\\Tielogic\\TieMeasureFlow, con
config.env dei segreti generati al primo avvio), imposta le variabili d'ambiente del
contratto, esegue le migrazioni Alembic (SQLite su file) e avvia in un solo processo
quattro server, ciascuno nel suo thread:
web Flask (src/frontend/flask_app) servito da waitress, --host (default 127.0.0.1) :8780
backend FastAPI src.backend.main:app, 127.0.0.1:8000
vision FastAPI src.vision_worker.main:app, 127.0.0.1:8100
camera scripts/camera_service.py (camere della stazione), 127.0.0.1:8765
Poi apre il browser. Se un'altra copia e' gia' in esecuzione apre solo il browser.
Come si usa: e' l'exe compilato con Nuitka (build.ps1); da sorgente, dalla radice del repo
con l'ambiente 3.13 del pacchetto: `python packaging/windows/avvio.py [--impronta] [--host H]`.
Opzioni di servizio: `--senza-finestre` (niente MessageBox, solo stderr: serve alle prove
automatiche; NON salta il controllo di licenza) e `--senza-browser`.
`--controlla-strumenti` (TieMeasureFlow#82): NON richiede la licenza, non avvia server, migrazioni
ne' database; registra gli strumenti, scrive su stdout e in
%PROGRAMDATA%\\Tielogic\\TieMeasureFlow\\log\\controllo-strumenti.json un JSON
{strumenti, kind, falliti, unavailable} ed esce con 0 (strumenti > 0 e nessun fallito) o 3.
La usa build.ps1 prima di Inno Setup.
Dipende da: visionsuite.license (vs-license, contratto licenza v1), uvicorn, waitress, alembic.
⚠️ L'exe e' senza console: stdout/stderr possono essere None, quindi il log va su file
(<dati>\\log\\tiemeasureflow.log) e uvicorn gira con log_config=None.
⚠️ Le variabili d'ambiente vanno impostate PRIMA di importare backend e frontend (leggono
la configurazione all'import): per questo gli import pesanti stanno dentro le funzioni.
⚠️ La cartella ProgramData la rende scrivibile agli utenti l'installer (.iss).
⚠️ Nel binario Nuitka mancano i metadati di vs-tools, quindi `load_tools()` (che cerca gli
strumenti come entry point) non ne trova nessuno: `build.ps1` genera `_strumenti.py`
(MODULI, dagli entry point del venv) e `registra_strumenti()` li importa per nome (#82).
"""
from __future__ import annotations
import argparse
import ctypes
import importlib
import json
import logging
import os
import secrets
import socket
import sqlite3
import sys
import threading
import time
import urllib.request
import webbrowser
from pathlib import Path
from visionsuite import license as lic # contratto licenza v1: impronta, cerca_licenza, verifica
PRODOTTO = "tiemeasureflow"
TITOLO = "TieMeasureFlow"
PORTA_WEB = 8780
PORTA_BACKEND = 8000
PORTA_VISION = 8100
PORTA_CAMERA = 8765
log = logging.getLogger("tmf.avvio")
# Nuitka definisce __compiled__ nel modulo compilato; da sorgente non esiste
COMPILATO = "__compiled__" in globals()
# Da sorgente (python packaging/windows/avvio.py) la radice e' due livelli sopra questo file
RADICE_SORGENTI = Path(__file__).resolve().parents[2]
# Segreti generati al primo avvio e riletti dopo (nome -> generatore)
SEGRETI = ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD")
# STATION_CODE: senza una stazione l'interfaccia risponde 503; ST-DEFAULT e' quella creata dal seed della pagina di setup
PREDEFINITI = {"STATION_CODE": "ST-DEFAULT"}
def _stream_sicuri() -> None:
# Exe senza console: stdout/stderr sono None e print/logging/fileConfig di alembic
# andrebbero in errore; li si sostituisce con il device nullo
if sys.stdout is None:
sys.stdout = open(os.devnull, "w", encoding="utf-8")
if sys.stderr is None:
sys.stderr = open(os.devnull, "w", encoding="utf-8")
def cartella_tielogic() -> Path:
# %PROGRAMDATA% e' sempre impostata su Windows; il ripiego serve solo a sviluppo/test su altri sistemi
base = os.environ.get("PROGRAMDATA") or str(Path.home() / ".tielogic-dati")
return Path(base) / "Tielogic"
def cartella_dati() -> Path:
return cartella_tielogic() / "TieMeasureFlow"
def cartella_programma() -> Path:
# Cartella dell'exe (P); da sorgente, la radice del repo
if COMPILATO:
return Path(sys.argv[0]).resolve().parent
return RADICE_SORGENTI
def percorso_impronta() -> Path:
return cartella_tielogic() / "licenze" / f"impronta-{PRODOTTO}.txt"
def messaggio(testo: str, titolo: str = TITOLO, errore: bool = True, senza_finestre: bool = False) -> None:
# Sempre su stderr (se esiste); la MessageBox solo su Windows e se non soppressa
if sys.stderr is not None:
print(testo, file=sys.stderr)
if senza_finestre or sys.platform != "win32":
return
# MB_OK | MB_ICONERROR (0x10) oppure MB_ICONINFORMATION (0x40) | MB_TOPMOST (0x40000)
ctypes.windll.user32.MessageBoxW(None, testo, titolo, 0x40000 | (0x10 if errore else 0x40))
# ---------------------------------------------------------------- licenza
def _scrivi_impronta(impronta: str) -> None:
try:
p = percorso_impronta()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(impronta + "\n", encoding="utf-8")
except OSError as e:
log.warning("impronta non scritta: %s", e)
def controlla_licenza(senza_finestre: bool = False) -> bool:
"""True se la licenza e' valida; altrimenti mostra il motivo e l'impronta e torna False."""
try:
percorso = lic.cerca_licenza(PRODOTTO)
lic.verifica(percorso, PRODOTTO)
return True
except lic.LicenzaNonValida as e:
motivo = str(e)
except Exception as e: # impronta non calcolabile, file illeggibile: mai un'eccezione muta nell'exe
motivo = f"Controllo licenza non riuscito: {e}"
try:
impronta = lic.impronta()
except Exception as e:
impronta = None
motivo += f"\nImpronta macchina non disponibile: {e}"
testo = f"{motivo}\n\n"
if impronta:
_scrivi_impronta(impronta)
testo += (f"Impronta di questo computer:\n{impronta}\n\n"
f"Comunicala a Tielogic per ottenere la licenza (salvata anche in {percorso_impronta()}). "
f"La licenza va copiata in {lic.cerca_licenza(PRODOTTO)}.")
messaggio(testo, f"{TITOLO} - licenza", senza_finestre=senza_finestre)
return False
# ---------------------------------------------------------------- configurazione
def _leggi_env(percorso: Path) -> dict[str, str]:
valori: dict[str, str] = {}
for riga in percorso.read_text(encoding="utf-8").splitlines():
riga = riga.strip()
if riga and not riga.startswith("#") and "=" in riga:
k, v = riga.split("=", 1)
valori[k.strip()] = v.strip()
return valori
def carica_segreti(dati: Path) -> dict[str, str]:
"""Legge <dati>\\config.env; i segreti mancanti si generano e si riscrivono (una volta sola)."""
dati.mkdir(parents=True, exist_ok=True)
percorso = dati / "config.env"
valori = _leggi_env(percorso) if percorso.exists() else {}
nuovi = [k for k in SEGRETI if not valori.get(k)]
# valori di partenza modificabili dall'utente in config.env (scritti una volta sola)
mancanti = [k for k in PREDEFINITI if k not in valori]
if nuovi or mancanti:
for k in nuovi:
valori[k] = secrets.token_urlsafe(32)
for k in mancanti:
valori[k] = PREDEFINITI[k]
righe = ["# Generato da TieMeasureFlow al primo avvio. Non condividere questo file.",
"# SETUP_PASSWORD serve per la pagina http://127.0.0.1:8000/api/setup (primo amministratore)."]
righe += [f"{k}={v}" for k, v in valori.items()]
percorso.write_text("\n".join(righe) + "\n", encoding="utf-8")
log.info("segreti generati in %s", percorso)
return valori
def trova_browser() -> str | None:
"""Chrome o Edge per kaleido (grafici dei report PDF, offline)."""
pf = os.environ.get("ProgramFiles", r"C:\Program Files")
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
local = os.environ.get("LOCALAPPDATA", "")
candidati = [
Path(pf) / "Google/Chrome/Application/chrome.exe",
Path(pf86) / "Google/Chrome/Application/chrome.exe",
Path(local) / "Google/Chrome/Application/chrome.exe" if local else None,
Path(pf86) / "Microsoft/Edge/Application/msedge.exe",
Path(pf) / "Microsoft/Edge/Application/msedge.exe",
]
for c in candidati:
if c is not None and c.is_file():
return str(c)
return None
def imposta_ambiente(dati: Path, host: str, valori: dict[str, str]) -> None:
"""Variabili d'ambiente del contratto v1; da chiamare PRIMA di importare backend e frontend."""
uploads = dati / "uploads"
env = {
"APP_ENV": "production",
# barre "/" nel percorso: l'URL SQLAlchemy non vuole le barre rovesciate
"DB_URL": f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}",
"UPLOAD_DIR": str(uploads),
"VISION_WORKER_URL": f"http://127.0.0.1:{PORTA_VISION}",
"VISION_RECIPES_ROOT": str(uploads),
"API_SERVER_URL": f"http://127.0.0.1:{PORTA_BACKEND}",
"SESSION_COOKIE_SECURE": "0", # niente HTTPS in locale: un cookie Secure verrebbe scartato
"SERVER_CORS_ORIGINS": f"http://127.0.0.1:{PORTA_WEB},http://localhost:{PORTA_WEB}",
**{k: valori[k] for k in SEGRETI},
}
# le altre chiavi di config.env (STATION_CODE, STATION_SWITCH_ENABLED, ...) passano cosi' come sono,
# ma non sovrascrivono le variabili fisse del contratto
for k, v in valori.items():
env.setdefault(k, v)
programma = cartella_programma()
versione = programma / "vision-engine-version.txt" # scritto da build.ps1
if versione.is_file():
env["VISION_ENGINE_VERSION"] = versione.read_text(encoding="utf-8").strip()
gtk = programma / "gtk" # DLL di WeasyPrint (piano B)
if gtk.is_dir():
env["WEASYPRINT_DLL_DIRECTORIES"] = str(gtk)
if hasattr(os, "add_dll_directory"):
# cintura e bretelle: WeasyPrint salta add_dll_directory se trova sys.frozen
try:
os.add_dll_directory(str(gtk))
except OSError:
pass
browser = trova_browser()
if browser:
env["BROWSER_PATH"] = browser
os.environ.update(env)
for cartella in (uploads, dati / "camera", dati / "log"):
cartella.mkdir(parents=True, exist_ok=True)
def _prepara_percorsi_sorgenti() -> None:
# Da sorgente i moduli del frontend (config, services, blueprints, app) e lo script camera
# non sono pacchetti: si aggiungono al path. Compilati, Nuitka li ha gia' incorporati.
if COMPILATO:
return
for p in (RADICE_SORGENTI / "scripts", RADICE_SORGENTI / "src" / "frontend" / "flask_app", RADICE_SORGENTI):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
# ---------------------------------------------------------------- log
def _prepara_log(dati: Path | None = None) -> None:
# Exe senza console: il log va su file; se la cartella non e' scrivibile si prosegue senza.
# Idempotente: alembic (fileConfig in env.py) azzera i gestori del root, quindi si richiama dopo.
try:
d = (dati or cartella_dati()) / "log"
d.mkdir(parents=True, exist_ok=True)
percorso = str(d / "tiemeasureflow.log")
root = logging.getLogger()
for h in root.handlers:
if isinstance(h, logging.FileHandler) and h.baseFilename.lower() == os.path.abspath(percorso).lower():
break
else:
h = logging.FileHandler(percorso, encoding="utf-8")
h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
root.addHandler(h)
root.setLevel(logging.INFO)
for nome in ("tmf.avvio",):
logging.getLogger(nome).disabled = False # fileConfig disattiva i logger esistenti
except OSError:
pass
# ---------------------------------------------------------------- strumenti di visione
def registra_strumenti() -> list[str]:
"""Registra gli strumenti di Core; torna i moduli che non si sono importati (#82).
Nel pacchetto Nuitka gli entry point non esistono: `load_tools()` non trova nulla e
l'elenco dei moduli arriva da `_strumenti.py`, generato da build.ps1. Da sorgente
`_strumenti` non c'e' e bastano gli entry point. Il worker di visione gira in un thread
di questo processo: registrare una volta sola qui vale per tutti."""
from visionsuite.task.registry import load_tools
load_tools()
try:
from _strumenti import MODULI # generato da build.ps1, accanto ad avvio.py
except ImportError:
MODULI = () # da sorgente: gli entry point di load_tools() bastano
falliti: list[str] = []
for modulo in MODULI:
try:
importlib.import_module(modulo) # eseguire il modulo registra i suoi @tool
except Exception: # un modulo rotto non ferma l'app: si logga e si raccoglie
log.error("strumenti %s non importati", modulo, exc_info=True)
falliti.append(modulo)
return falliti
def controlla_strumenti() -> int:
"""Senza licenza, server ne' DB: scrive il JSON del catalogo strumenti. Exit 0 se completo, 3 altrimenti."""
from visionsuite.task.graph import registry
from visionsuite.task.registry import unavailable
falliti = registra_strumenti()
kind = sorted(registry())
esito = {"strumenti": len(kind), "kind": kind, "falliti": falliti, "unavailable": unavailable()}
riga = json.dumps(esito, ensure_ascii=False)
if sys.stdout is not None:
print(riga)
try:
f = cartella_dati() / "log" / "controllo-strumenti.json"
f.parent.mkdir(parents=True, exist_ok=True)
f.write_text(riga + "\n", encoding="utf-8") # l'exe e' senza console: il file e' l'unica uscita sicura
except OSError as e:
log.warning("controllo-strumenti.json non scritto: %s", e)
return 0 if kind and not falliti else 3
# ---------------------------------------------------------------- rete
def _porta_aperta(porta: int) -> bool:
try:
with socket.create_connection(("127.0.0.1", porta), timeout=0.5):
return True
except OSError:
return False
def _e_tmf() -> bool:
# Una copia gia' in esecuzione: il backend risponde su /api/health con il nome del servizio
try:
with urllib.request.urlopen(f"http://127.0.0.1:{PORTA_BACKEND}/api/health", timeout=2) as r:
return "TieMeasureFlow" in str(json.load(r).get("service", ""))
except Exception:
return False
def _nessun_utente(db: Path) -> bool:
"""True se il database esiste ed e' senza utenti (primo avvio: serve la pagina di setup)."""
try:
con = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True, timeout=2)
try:
return con.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 0
finally:
con.close()
except Exception:
return False
def _apri_browser_quando_pronto(url: str, porte: tuple[int, ...]) -> None:
# Aspetta che i server ascoltino (max ~60 s), poi apre il browser predefinito
for _ in range(300):
if all(_porta_aperta(p) for p in porte):
break
time.sleep(0.2)
webbrowser.open(url)
# ---------------------------------------------------------------- migrazioni
def _neutralizza_fileconfig() -> None:
# env.py di Alembic chiama logging.config.fileConfig (anche la pagina di setup, con command.stamp):
# azzererebbe i gestori del log su file. Nel pacchetto il log lo decide avvio.py.
import logging.config
logging.config.fileConfig = lambda *a, **k: None
def esegui_migrazioni() -> None:
"""alembic upgrade head. Compilato: env.py e versions\\ sono soli .pyc, quindi sourceless."""
_neutralizza_fileconfig()
from alembic import command
from alembic.config import Config
import src.backend as pacchetto
migrazioni = Path(pacchetto.__file__).resolve().parent / "migrations"
cfg = Config(str(migrazioni / "alembic.ini"))
if COMPILATO:
cfg.set_main_option("sourceless", "true")
command.upgrade(cfg, "head")
# ---------------------------------------------------------------- server
class ServerThread(threading.Thread):
"""Un server (uvicorn o waitress) in un thread; `errore` resta valorizzato se muore."""
def __init__(self, nome: str, costruisci):
super().__init__(name=nome, daemon=True)
self._costruisci = costruisci
self.server = None
self.errore: BaseException | None = None
def run(self) -> None:
try:
self.server = self._costruisci()
self.server.run()
except BaseException as e: # un server morto deve comparire nel log e fermare l'insieme
self.errore = e
log.exception("server %s terminato con errore", self.name)
def ferma(self) -> None:
s = self.server
try:
if hasattr(s, "should_exit"):
s.should_exit = True # uvicorn
elif s is not None:
s.close() # waitress
except Exception:
pass
def _uvicorn(app, porta: int):
import uvicorn
# log_config=None: niente console; i thread non toccano i signal handler (uvicorn lo gestisce)
return uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=porta, log_config=None, workers=1))
def costruisci_web(host: str):
import waitress
from app import create_app # src/frontend/flask_app/app.py
return waitress.create_server(create_app(), host=host, port=PORTA_WEB, threads=8)
def costruisci_backend():
from src.backend.main import app
return _uvicorn(app, PORTA_BACKEND)
def costruisci_vision():
from src.vision_worker.main import app
return _uvicorn(app, PORTA_VISION)
def costruisci_camera(cartella: Path):
from camera_service import create_app # scripts/camera_service.py (non e' un pacchetto)
return _uvicorn(create_app(cartella), PORTA_CAMERA)
def avvia_server(host: str, dati: Path) -> list[ServerThread]:
thread = [
ServerThread("backend", costruisci_backend),
ServerThread("vision", costruisci_vision),
ServerThread("camera", lambda: costruisci_camera(dati / "camera")),
ServerThread("web", lambda: costruisci_web(host)),
]
for t in thread:
t.start()
return thread
def main(argv=None) -> int:
_stream_sicuri()
p = argparse.ArgumentParser(prog="TieMeasureFlow", description=TITOLO)
p.add_argument("--impronta", action="store_true", help="mostra l'impronta di questo computer")
p.add_argument("--host", default="127.0.0.1", help="indirizzo di ascolto dell'interfaccia web (default 127.0.0.1)")
p.add_argument("--senza-finestre", action="store_true", help="niente MessageBox (prove automatiche)")
p.add_argument("--senza-browser", action="store_true", help="non aprire il browser (prove automatiche)")
p.add_argument("--controlla-strumenti", action="store_true",
help="registra gli strumenti, scrive il JSON ed esce (senza licenza ne' server; usata da build.ps1)")
args = p.parse_args(argv)
_prepara_log()
if args.impronta:
try:
impronta = lic.impronta()
except Exception as e:
messaggio(f"Impronta non disponibile: {e}", senza_finestre=args.senza_finestre)
return 1
print(impronta) if sys.stdout is not None else None
messaggio(f"Impronta di questo computer:\n{impronta}", errore=False, senza_finestre=args.senza_finestre)
return 0
if args.controlla_strumenti:
return controlla_strumenti() # prima della licenza: la build non ha una licenza
if not controlla_licenza(args.senza_finestre):
return 1
if _porta_aperta(PORTA_WEB):
if _e_tmf():
# TieMeasureFlow e' gia' in esecuzione: basta riaprire la pagina
if not args.senza_browser:
webbrowser.open(f"http://127.0.0.1:{PORTA_WEB}/")
return 0
messaggio(f"La porta {PORTA_WEB} e' occupata da un altro programma: chiuderlo e riprovare.",
senza_finestre=args.senza_finestre)
return 2
occupate = [str(x) for x in (PORTA_BACKEND, PORTA_VISION, PORTA_CAMERA) if _porta_aperta(x)]
if occupate:
messaggio(f"Porte occupate da altri programmi: {', '.join(occupate)}. Chiuderli e riprovare.",
senza_finestre=args.senza_finestre)
return 2
dati = cartella_dati()
try:
valori = carica_segreti(dati)
imposta_ambiente(dati, args.host, valori)
_prepara_percorsi_sorgenti()
primo_avvio = not (dati / "tmf.db").exists()
esegui_migrazioni()
except Exception as e:
_prepara_log(dati)
log.exception("avvio non riuscito")
messaggio(f"Avvio non riuscito: {e}\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}",
senza_finestre=args.senza_finestre)
return 3
_prepara_log(dati) # alembic ha riconfigurato il logging
log.info("migrazioni applicate (primo avvio: %s)", primo_avvio)
registra_strumenti() # prima del server: il worker di visione legge registry() gia' popolato (#82)
thread = avvia_server(args.host, dati)
if not args.senza_browser:
# database senza utenti: si apre la pagina di setup del backend (SETUP_PASSWORD in config.env)
url = (f"http://127.0.0.1:{PORTA_BACKEND}/api/setup" if _nessun_utente(dati / "tmf.db")
else f"http://127.0.0.1:{PORTA_WEB}/")
threading.Thread(target=_apri_browser_quando_pronto,
args=(url, (PORTA_WEB, PORTA_BACKEND)), daemon=True).start()
# Si resta vivi finche' tutti i server girano; se uno muore si ferma tutto
try:
while thread and all(t.is_alive() for t in thread):
time.sleep(1)
except KeyboardInterrupt:
pass
morti = [t for t in thread if not t.is_alive()]
for t in thread:
t.ferma()
if morti:
messaggio("Un componente si e' fermato: " + ", ".join(f"{t.name} ({t.errore})" for t in morti)
+ f"\nDettagli in {dati / 'log' / 'tiemeasureflow.log'}", senza_finestre=args.senza_finestre)
return 4
return 0
if __name__ == "__main__":
sys.exit(main())
+247
View File
@@ -0,0 +1,247 @@
<#
Pacchetto Windows di TieMeasureFlow (TieMeasureFlow#76).
Cosa fa: uv sync in un ambiente Python 3.13 separato (build\venv313), frontend (Tailwind +
traduzioni), compila avvio.py con Nuitka in modalita' --standalone (cartella, NON onefile:
l'onefile si decomprime in %TEMP% ad ogni avvio), aggiunge le migrazioni Alembic come soli
.pyc, le DLL di WeasyPrint (piano B) e il commit del motore di visione, poi Inno Setup
produce dist\TieMeasureFlow-setup-<versione>.exe. Sul PC del cliente non finisce nessun
.py; i .pyc ci sono solo in src\backend\migrations (Alembic le carica dal disco).
Come si usa (dalla radice del repo, con il submodule vendor/visionsuite inizializzato):
powershell -ExecutionPolicy Bypass -File packaging\windows\build.ps1 [-Versione 3.0.0]
-Versione accetta anche un tag (v3.0.0, v3.0.0-rc1): si usano i numeri iniziali.
Senza -Versione si usa la variabile GITHUB_REF_NAME (tag nella CI) o 0.0.0.
-SenzaSetup si ferma dopo Nuitka (cartella build\nuitka\avvio.dist).
Dipende da: uv, node/npx (Tailwind 3.4.19 da npm: serve Internet SOLO a chi costruisce),
Visual Studio con carico C++ (MSVC; Nuitka lo trova da solo), Inno Setup 6
(`winget install --id JRSoftware.InnoSetup -e`; ISCC.exe nel PATH, in Program Files o in
%LOCALAPPDATA%\Programs). Nuitka e' nel gruppo uv "windows" (pyproject.toml).
⚠️ La prima compilazione e' lunga (decine di minuti: numpy/opencv/scipy/plotly); le successive
riusano la cache di Nuitka (%LOCALAPPDATA%\Nuitka) e la cartella build\nuitka.
⚠️ I pacchetti di Core condividono il namespace visionsuite (extend_path): Nuitka non lo
segue da solo, quindi i sottopacchetti si elencano uno per uno dagli __path__ installati.
⚠️ numba e' escluso di proposito: le funzioni compilate da Nuitka non hanno bytecode e numba
non puo' farne il JIT. Core e pm2d hanno gia' il ripiego in Python puro (HAS_NUMBA=False):
funziona ma e' piu' lento nel matching.
⚠️ Gli strumenti di vs-tools si registrano solo come entry point (visionsuite.task.tools) e i
metadati di vs-tools non stanno nel binario (TieMeasureFlow#82): il build genera
packaging\windows\_strumenti.py (MODULI, gitignorato) che avvio.py importa, e prima di
Inno Setup esegue l'exe con --controlla-strumenti: se il catalogo e' vuoto il build fallisce.
⚠️ Il frontend Flask non e' un pacchetto (`from config import Config`, `blueprints`, `services`):
si compila mettendo flask_app e scripts su PYTHONPATH, come fa avvio.py in sviluppo.
#>
param(
[string]$Versione = $env:GITHUB_REF_NAME,
[switch]$SenzaSetup
)
$ErrorActionPreference = 'Stop'
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
Set-Location $radice
# Versione numerica a 4 parti per i metadati dell'exe (Nuitka la vuole cosi')
if (-not $Versione) { $Versione = '0.0.0' }
if ($Versione -notmatch '^v?(\d+)\.(\d+)\.(\d+)') { throw "Versione non valida: '$Versione' (atteso es. v3.0.0)" }
$ver3 = "$($Matches[1]).$($Matches[2]).$($Matches[3])"
$ver4 = "$ver3.0"
Write-Host "== TieMeasureFlow $ver3 =="
# Controlli: se manca qualcosa il messaggio dice come installarlo
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
throw "uv non trovato. Installarlo: https://docs.astral.sh/uv/getting-started/installation/"
}
if (-not (Get-Command npx -ErrorAction SilentlyContinue)) {
throw "node/npx non trovato (serve a compilare Tailwind). Installarlo da https://nodejs.org"
}
if (-not (Test-Path (Join-Path $radice 'vendor\visionsuite\packages\vs-license'))) {
throw "Submodule vendor/visionsuite mancante o vecchio: git submodule update --init --recursive"
}
$iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source
if (-not $iscc) {
foreach ($c in @("${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe")) {
if (Test-Path $c) { $iscc = $c; break }
}
}
if (-not $iscc -and -not $SenzaSetup) { throw "Inno Setup 6 non trovato. Installarlo: winget install --id JRSoftware.InnoSetup -e" }
# Ambiente separato da quello di sviluppo (3.11): extra server + client + vision-worker + gruppo windows
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313'
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
$vn = & $py -m nuitka --version # niente Select-Object -First: chiude la pipe e falsa l'exit code
if ($LASTEXITCODE -ne 0) { throw "Nuitka non disponibile (serve anche un compilatore C: Visual Studio con 'Sviluppo di applicazioni desktop con C++')" }
# Frontend: tailwind.css non e' nel repo (stessa versione esatta di Dockerfile.frontend) e traduzioni .mo
$flask = Join-Path $radice 'src\frontend\flask_app'
Push-Location $flask
try {
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') compile -d translations
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
} finally { Pop-Location }
# Sottopacchetti del namespace visionsuite (core, camera, task, tools, license, ...)
$pacchetti = & $py -c "import visionsuite, pkgutil; print('\n'.join('visionsuite.' + m.name for m in pkgutil.iter_modules(visionsuite.__path__) if m.ispkg))"
if ($LASTEXITCODE -ne 0 -or -not $pacchetti) { throw "Elenco dei pacchetti visionsuite non riuscito" }
$includi = @($pacchetti -split "\r?\n" | Where-Object { $_ } | ForEach-Object { "--include-package=$_" })
# Nessun --include-distribution-metadata per vs-*: Nuitka rifiuta i metadati di una distribuzione il cui
# pacchetto (visionsuite.camera, namespace) non e' di primo livello. Gli entry point di vs-tools, che
# load_tools() cercherebbe nei metadati, si rimpiazzano con _strumenti.py (sotto, TieMeasureFlow#82).
Write-Host "Pacchetti visionsuite: $($includi -join ' ')"
# Flag Nuitka del piano PDF (WeasyPrint, kaleido, pyphen, plotly, tzdata...): le righe "--include-..."
# del LEGGIMI.md, con l'eventuale commento "# ..." in coda. I dati di src/backend/templates sono gia' sopra.
$flagPdf = @()
$leggimi = Join-Path $PSScriptRoot 'pdf\LEGGIMI.md'
if (Test-Path $leggimi) {
$flagPdf = @(Get-Content $leggimi | ForEach-Object {
if ($_ -match '^\s*(--(?:include|nofollow|noinclude|enable)[^\s`#]*)\s*(?:#.*)?$' -and $Matches[1] -notlike '--include-data-dir=src/backend/templates*') { $Matches[1] }
})
Write-Host "Flag Nuitka da pdf\LEGGIMI.md: $($flagPdf -join ' ')"
}
# Nuitka cerca i moduli non-pacchetto (config, services, blueprints, app, camera_service) su PYTHONPATH
$env:PYTHONPATH = (@($radice, $flask, (Join-Path $radice 'scripts')) -join ';')
# TieMeasureFlow#82: i moduli-strumento dagli entry point del venv del build (nel binario non ci sono i
# metadati di vs-tools, quindi load_tools() non li troverebbe). Si usa $py, non `uv run`.
$strumenti = Join-Path $radice 'packaging\windows\_strumenti.py'
$genera = @'
import sys
from importlib.metadata import entry_points
moduli = sorted({ep.value for ep in entry_points(group="visionsuite.task.tools")})
if not moduli:
sys.exit(1)
righe = ["# Generato da build.ps1 (TieMeasureFlow#82): non modificare, non committare.", "MODULI = ("]
righe += [f" {m!r}," for m in moduli] + [")"]
with open(sys.argv[1], "w", encoding="utf-8", newline="") as f:
print(*righe, sep=chr(10), file=f)
print(len(moduli))
'@
$nModuli = $genera | & $py - $strumenti
if ($LASTEXITCODE -ne 0) { throw "Entry point visionsuite.task.tools non trovati nel venv: nessuno strumento da includere" }
Write-Host "Moduli-strumento ($nModuli) in $strumenti"
$out = Join-Path $radice 'build\nuitka'
New-Item -ItemType Directory -Force $out | Out-Null
$nuitka = @(
'--standalone',
'--assume-yes-for-downloads',
'--msvc=latest',
'--windows-console-mode=disable', # nessuna finestra nera: gli errori escono in MessageBox e nel log
"--output-dir=$out",
'--output-filename=TieMeasureFlow.exe',
'--windows-icon-from-ico=packaging\windows\TieMeasureFlow.ico',
'--company-name=Tielogic',
'--product-name=TieMeasureFlow',
"--file-version=$ver4",
"--product-version=$ver4",
'--file-description=TieMeasureFlow',
'--copyright=Tielogic',
# codice del prodotto: backend, worker di visione, runner (src e' un pacchetto, flask_app no)
'--include-package=src.backend',
'--include-package=src.vision',
'--include-package=src.vision_worker',
# le migrazioni NON si compilano: Alembic le carica dal disco, vanno come .pyc (sotto)
'--nofollow-import-to=src.backend.migrations',
'--nofollow-import-to=src.backend.tests',
'--nofollow-import-to=src.vision.tests',
'--nofollow-import-to=src.vision_worker.tests',
# frontend Flask e servizio camera: moduli di primo livello trovati via PYTHONPATH
'--include-module=app',
'--include-module=config',
'--include-package=blueprints',
'--include-package=services',
'--include-module=camera_service',
'--nofollow-import-to=blueprints.tests',
'--nofollow-import-to=tests',
'--include-package=uvicorn', # loop e protocolli di uvicorn si caricano per nome
'--include-package=waitress',
'--include-package=alembic',
'--include-module=aiosqlite',
'--include-module=sqlalchemy.dialects.sqlite.aiosqlite',
'--include-package=harvesters', # camere GenTL (GigE): il producer .cti e' del vendor
'--include-package=genicam',
'--include-package-data=genicam',
'--include-package-data=babel', # locale-data di Babel (flask-babel)
'--nofollow-import-to=numba', # vedi nota in testa: il ripiego in Python puro
'--nofollow-import-to=llvmlite',
# dati letti dal disco con __file__: setup page, report, Flask (templates/static/translations alla radice)
'--include-data-dir=src\backend\templates=src\backend\templates',
'--include-data-files=src\backend\migrations\alembic.ini=src\backend\migrations\alembic.ini',
'--include-data-dir=src\frontend\flask_app\templates=templates',
'--include-data-dir=src\frontend\flask_app\static=static',
'--include-data-dir=src\frontend\flask_app\translations=translations',
'--include-data-files=pyproject.toml=pyproject.toml' # config.py ne legge la versione per il piede di pagina
) + $includi + $flagPdf + @('packaging\windows\avvio.py')
$t0 = Get-Date
& $py -m nuitka @nuitka
if ($LASTEXITCODE -ne 0) { throw "Nuitka fallito (codice $LASTEXITCODE)" }
Write-Host ("Nuitka: {0:N0} minuti" -f ((Get-Date) - $t0).TotalMinutes)
$sorgente = Join-Path $out 'avvio.dist'
if (-not (Test-Path (Join-Path $sorgente 'TieMeasureFlow.exe'))) { throw "exe non trovato in $sorgente" }
# Migrazioni Alembic: env.py e versions\ come soli .pyc (compileall -b: .pyc accanto al sorgente, che si cancella)
$mig = Join-Path $sorgente 'src\backend\migrations'
New-Item -ItemType Directory -Force (Join-Path $mig 'versions') | Out-Null
Copy-Item 'src\backend\migrations\env.py' $mig -Force
Copy-Item 'src\backend\migrations\versions\*.py' (Join-Path $mig 'versions') -Force
& $py -m compileall -b -q $mig
if ($LASTEXITCODE -ne 0) { throw "compileall delle migrazioni fallito" }
Get-ChildItem $mig -Recurse -Filter *.py -File | Remove-Item -Force
Get-ChildItem $mig -Recurse -Directory -Filter __pycache__ | Remove-Item -Recurse -Force
# alembic.ini del pacchetto: sourceless, perche' anche la pagina di setup (command.stamp) apre
# alembic.ini con un Config suo e non troverebbe env.py/versions (solo .pyc)
$ini = Join-Path $mig 'alembic.ini'
$testoIni = [System.IO.File]::ReadAllText($ini)
if ($testoIni -notmatch '(?m)^script_location') { throw "alembic.ini senza script_location" }
$testoIni = $testoIni -replace '(?m)^(script_location[^\r\n]*)', "`$1`nsourceless = true"
[System.IO.File]::WriteAllText($ini, $testoIni, (New-Object System.Text.UTF8Encoding $false))
# Commit del motore di visione: avvio.py lo legge in VISION_ENGINE_VERSION
$motore = (git -C vendor/visionsuite rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or -not $motore) { throw "Commit di vendor/visionsuite non leggibile" }
[System.IO.File]::WriteAllText((Join-Path $sorgente 'vision-engine-version.txt'), $motore, (New-Object System.Text.UTF8Encoding $false))
# DLL di WeasyPrint (piano PDF): lo script le scarica con sha256 pinnato
$gtk = Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1'
if (Test-Path $gtk) {
powershell -NoProfile -ExecutionPolicy Bypass -File $gtk -Destinazione (Join-Path $sorgente 'gtk')
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
} else {
Write-Warning "packaging\windows\pdf\prendi-gtk.ps1 assente: il pacchetto non avra' le DLL di WeasyPrint (niente report PDF)"
}
# Il pacchetto non deve contenere codice sorgente o bytecode, tranne i .pyc delle migrazioni
$sporchi = Get-ChildItem $sorgente -Recurse -Include *.py, *.pyc -File |
Where-Object { $_.FullName -notlike "$mig\*" -or $_.Extension -eq '.py' }
if ($sporchi) { throw "Il pacchetto contiene sorgenti/bytecode:`n$($sporchi.FullName -join "`n")" }
# TieMeasureFlow#82: l'exe deve vedere gli strumenti (--controlla-strumenti non vuole licenza ne' apre server/DB)
$exe = Join-Path $sorgente 'TieMeasureFlow.exe'
$jsonControllo = Join-Path $env:PROGRAMDATA 'Tielogic\TieMeasureFlow\log\controllo-strumenti.json'
Remove-Item $jsonControllo -ErrorAction SilentlyContinue # un JSON vecchio non deve far passare il controllo
# l'exe e' GUI-subsystem: con & non si aspetta, serve Start-Process -Wait
$proc = Start-Process -FilePath $exe -ArgumentList '--controlla-strumenti' -Wait -PassThru
$esito = if (Test-Path $jsonControllo) { Get-Content $jsonControllo -Raw } else { '(controllo-strumenti.json non scritto)' }
if ($proc.ExitCode -ne 0) { throw "Controllo strumenti fallito (codice $($proc.ExitCode)): $esito" }
$nStrumenti = ($esito | ConvertFrom-Json).strumenti
Write-Host "Controllo strumenti: $nStrumenti tipi registrati nell'exe"
if ($SenzaSetup) { Write-Host "Fermo qui (-SenzaSetup): $sorgente"; return }
$dist = Join-Path $radice 'dist'
New-Item -ItemType Directory -Force $dist | Out-Null
& $iscc "/DVersione=$ver3" "/DVersione4=$ver4" "/DOrigine=$sorgente" "/DUscita=$dist" (Join-Path $radice 'packaging\windows\TieMeasureFlow.iss')
if ($LASTEXITCODE -ne 0) { throw "Inno Setup fallito" }
$setup = Join-Path $dist "TieMeasureFlow-setup-$ver3.exe"
Write-Host ("Pronto: {0} ({1:N1} MB)" -f $setup, ((Get-Item $setup).Length / 1MB))
+3
View File
@@ -0,0 +1,3 @@
# Le DLL scaricate da prendi-gtk.ps1 non si committano: solo lo script (TieMeasureFlow#78).
gtk/
cache/
+149
View File
@@ -0,0 +1,149 @@
# Report PDF nel pacchetto Windows
Questa cartella contiene quello che serve perché i report PDF di TieMeasureFlow
(`src/backend/services/report_service.py`: Jinja2, Plotly con Kaleido 1.2.0, WeasyPrint 68.1)
funzionino su Windows, dentro il pacchetto, **senza Internet**. Riferimento: TieMeasureFlow#78
(parte del pacchetto Windows, madre #76).
## Come funziona
Un report SPC passa da due componenti nativi che su Windows non arrivano con `pip`:
1. **WeasyPrint** disegna il PDF e carica a runtime le DLL di Pango/GTK
(`libgobject-2.0-0`, `libpango-1.0-0`, `libharfbuzz-0`, `libharfbuzz-subset-0`,
`libfontconfig-1`, `libpangoft2-1.0-0` e le loro dipendenze).
2. **Kaleido 1.x** trasforma i grafici Plotly in SVG pilotando un browser Chromium già installato
(non porta più Chromium con sé). Su ogni Windows 10/11 c'è Edge.
### Le DLL: `prendi-gtk.ps1`
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione <cartella>
```
- Scarica da `repo.msys2.org` (ucrt64) 21 pacchetti **pinnati con sha256** (nome file e hash stanno nello
script), verifica l'hash, estrae solo le 27 DLL necessarie, `etc\fonts` di fontconfig e le licenze.
- Idempotente: se la cartella è già pronta (file `.gtk-pin` uguale ai pin dello script) esce subito.
- Se un hash non coincide fallisce e non lascia mezza cartella.
- I pacchetti scaricati stanno in `%LOCALAPPDATA%\Tielogic\tmf-gtk-cache` (`-Cache` per cambiarla).
- Serve Internet **solo alla build**. Estrae `.zst` con `tar.exe` di Windows (verificato: bsdtar 3.8.9 con
zstd); se `tar.exe` non sa di zstd ripiega su `uv run --with zstandard`.
- Gira da utente non admin con PowerShell 5.1.
- ⚠️ `repo.msys2.org` tiene solo le versioni recenti: se un file pinnato sparisce (404) vanno aggiornati nome
e sha256 dal db `https://repo.msys2.org/mingw/ucrt64/ucrt64.db` e rifatto il test. Conservare la cache
(Wasabi o NAS) evita la corsa contro il repository.
Il piano C copia `<cartella>` in `P\gtk\` (P = cartella programma).
### Variabili d'ambiente (le imposta `avvio.py`, piano C)
| Variabile | Valore | Perché |
|---|---|---|
| `WEASYPRINT_DLL_DIRECTORIES` | `<P>\gtk` | WeasyPrint chiama `os.add_dll_directory` su ogni cartella (separatore `;`) **prima** di caricare le DLL. Va impostata prima di importare `weasyprint` (lo fa `report_service` all'import). |
| `BROWSER_PATH` | `msedge.exe` o `chrome.exe` | Choreographer (sotto Kaleido) la legge da sé e ha la precedenza su ogni ricerca (verificato: un percorso inesistente dà `FileNotFoundError`). Senza la variabile Kaleido cerca Edge/Chrome da solo (registro e percorsi tipici). Nessun codice in `report_service.py` per questo. |
Fontconfig non richiede variabili: trova `etc\fonts` accanto alle DLL (verificato). Se manca `etc\fonts`
WeasyPrint stampa "Fontconfig error: Cannot load default config file". I font dei report vengono da
`C:\Windows\Fonts`.
### MathJax: la modifica a `report_service.py`
Kaleido 1.x inserisce nella pagina di render uno `<script>` MathJax da `cdnjs.cloudflare.com` anche quando i
grafici non hanno formule. Misurato su daitarn con Edge: con rete che scarta i pacchetti ogni grafico aspetta
~19 s (due grafici per report SPC); con rete che rifiuta subito ~2 s. `report_service.py` imposta
`pio.defaults.mathjax` su un file `.js` vuoto in `%TEMP%` (`tmf-mathjax-vuoto.js`): nessuna richiesta di rete,
report SPC in ~7 s anche a rete bloccata. Se un giorno i grafici useranno LaTeX, qui serve un MathJax locale.
`plotly.min.js` invece è già locale (dentro il pacchetto `plotly`).
## Flag Nuitka e dati da includere (per `build.ps1`, piano C)
⚠️ Stato: ricavati leggendo i sorgenti installati (versioni del `uv.lock`); **non provati con una build
Nuitka**: la verifica è la prima build di C. I dati e i metadati sono quelli che il codice legge davvero da disco.
Dati dei pacchetti (file non `.py` letti a runtime):
```text
--include-package-data=weasyprint # css/html5_ua.css, html5_ph.css, html5_ua_form.css, pdf/sRGB2014.icc
--include-package-data=pyphen # dictionaries/*.dic (84 file, sillabazione)
--include-package-data=kaleido # vendor/kaleido_scopes.js (pagina di render)
--include-package-data=plotly # package_data/plotly.min.js (Kaleido lo cerca come file accanto a plotly/__init__), templates/*.json, validators/_validators.json
--include-package-data=tzdata # zoneinfo: Windows non ha il database IANA (vedi sotto)
--include-data-dir=src/backend/templates=src/backend/templates # Jinja2: report_service legge i .html dal disco
```
`choreographer/resources/last_known_good_chrome.json` serve solo al comando che scarica Chrome: non serve.
Metadati delle distribuzioni (`importlib.metadata.version` al momento dell'import): senza, `import plotly` e
Kaleido falliscono con `PackageNotFoundError`:
```text
--include-distribution-metadata=plotly
--include-distribution-metadata=kaleido
```
Import dinamici che l'analisi statica di Nuitka può perdere:
```text
--include-package=fontTools # ttLib/ttFont.py importa le tabelle con __import__("fontTools.ttLib.tables." + tag)
--include-package=plotly # _plotly_utils/importers.py: importlib.import_module lazy dei moduli graph_objs
--include-package=_plotly_utils
--include-module=_cffi_backend # WeasyPrint usa cffi in modalita' ABI (dlopen), serve il modulo C di cffi
--include-package=weasyprint
--include-package=kaleido
--include-package=choreographer
```
Altre note per C:
- WeasyPrint salta `os.add_dll_directory` se `sys.frozen` esiste (`weasyprint/text/ffi.py`). Nuitka di norma
non lo imposta (PyInstaller sì): se in `TieMeasureFlow.exe` le DLL non si trovassero, chiamare
`os.add_dll_directory(<P>\gtk)` da `avvio.py` prima dell'import.
- Le DLL **non** vanno compilate né incluse da Nuitka: restano file in `P\gtk\`.
- Il controllo "nessun `.py`" di `build.ps1` non deve scandire `gtk\` (non ci sono `.py`, ma è meglio
saperlo) né `licenze\`.
- **`tzdata` manca da `pyproject.toml`**: su Windows `ZoneInfo("Europe/Rome")` (usato da
`src/backend/utils/tempo.py`, quindi da ogni report) dà `ZoneInfoNotFoundError`. Va aggiunto
`tzdata; sys_platform == 'win32'` agli extra server e client (o al gruppo `windows`) e il frontend
(`src/frontend/flask_app/app.py` usa `ZoneInfo`) ne ha bisogno allo stesso modo.
- `pdfplumber` è già nell'extra `server` (il test lo usa per leggere i PDF).
## Licenze delle DLL
Le DLL sono binari MSYS2/MinGW (ucrt64) non modificati, collegati dinamicamente. `prendi-gtk.ps1` mette in
`<cartella>\licenze\` i testi di licenza di ogni pacchetto più un `<pacchetto>.txt` con nome del file e licenza.
**L'installer deve includere `gtk\licenze\`.**
| Libreria | Licenza |
|---|---|
| Pango, GLib/GObject/GIO, fribidi, libthai, libdatrie, graphite2, libintl (gettext-runtime), libiconv | LGPL 2.1 o successive |
| FreeType | FTL oppure GPL 2+ (si sceglie FTL) |
| HarfBuzz, expat, libffi, brotli, fontconfig | MIT / permissiva |
| libpng, zlib-ng, bzip2, pcre2 | permissive (libpng, Zlib, bzip2, BSD-3) |
| libgcc, libstdc++ | GPL 3 con GCC Runtime Library Exception (ridistribuzione libera) |
| libwinpthread | MIT e BSD-3 |
Per le librerie LGPL, chi riceve il pacchetto può sostituire le DLL (sono file separati in `gtk\`); i sorgenti
sono pubblici su MSYS2 (`https://packages.msys2.org/`), e l'elenco esatto delle versioni è nello script.
## Test
`src/backend/tests/test_report_windows.py` (saltato fuori da Windows, senza DLL, senza Edge/Chrome o senza
`tzdata`): in un sottoprocesso con WeasyPrint **vero** (il `conftest.py` altrimenti lo sostituisce con un
mock) genera su SQLite in memoria il report SPC (carta di controllo e istogramma) e quello a tabella, e
controlla con `pdfplumber` byte `%PDF-`, almeno una pagina, titoli dei grafici e segni vettoriali.
```powershell
# DLL (una volta) e test
powershell -NoProfile -ExecutionPolicy Bypass -File packaging\windows\pdf\prendi-gtk.ps1 -Destinazione packaging\windows\pdf\gtk
uv run --python 3.13 --extra server --extra dev pytest src/backend/tests/test_report_windows.py -q
```
`packaging/windows/pdf/gtk/` e `cache/` sono in `.gitignore`: le DLL non si committano.
### Prova a rete bloccata
`HTTP_PROXY`/`HTTPS_PROXY` non bastano: Edge e Chrome usano il **proxy di sistema** di Windows. Per provare davvero
il PC senza Internet: impostare temporaneamente il proxy di sistema (Impostazioni, Rete, Proxy) su un indirizzo
morto (`127.0.0.1:9` rifiuta subito; `10.255.255.1:8080` scarta i pacchetti, il caso peggiore), generare i
report e **ripristinare** il proxy. Esito del 2026-10-17 con proxy che scarta i pacchetti: report SPC e report a
tabella completi in ~7 s.
+143
View File
@@ -0,0 +1,143 @@
<#
prendi-gtk.ps1 - prepara le DLL di Pango/GTK che WeasyPrint 68.1 vuole su Windows.
COSA FA: scarica da repo.msys2.org (ucrt64) un elenco FISSO di pacchetti, ne verifica lo sha256,
estrae solo le DLL che servono a WeasyPrint (+ etc\fonts di fontconfig e le licenze) in -Destinazione.
COME SI USA: powershell -NoProfile -ExecutionPolicy Bypass -File prendi-gtk.ps1 -Destinazione <cartella>
Opzionali: -Cache <cartella> (pacchetti scaricati, default %LOCALAPPDATA%\Tielogic\tmf-gtk-cache),
-Forza (rifa' anche se la cartella e' gia' pronta).
DA COSA DIPENDE: Windows 10/11 con tar.exe (bsdtar con zstd) oppure uv + Python (modulo zstandard);
Internet solo al momento della build, mai a runtime. PowerShell 5.1 va bene, non serve admin.
IDEMPOTENTE: se la cartella ha gia' le DLL e il file .gtk-pin coincide con questi pin, esce senza scaricare.
FALLISCE se lo sha256 di un pacchetto non coincide (nessun file parziale resta in -Destinazione).
TRAPPOLA: repo.msys2.org tiene solo le versioni recenti. Se un file pinnato sparisce (404) serve aggiornare
i pin qui sotto (nome file + sha256 dal db https://repo.msys2.org/mingw/ucrt64/ucrt64.db) e riprovare i test.
La -Cache conserva i pacchetti gia' scaricati: copiarla su Wasabi/NAS evita il problema.
#>
param(
[Parameter(Mandatory = $true)][string]$Destinazione,
[string]$Cache = (Join-Path $env:LOCALAPPDATA 'Tielogic\tmf-gtk-cache'),
[switch]$Forza
)
$ErrorActionPreference = 'Stop'
# PS 5.1 usa TLS 1.0 di default: repo.msys2.org vuole 1.2 (cosa/perche')
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$Base = 'https://repo.msys2.org/mingw/ucrt64/'
# Pin: pacchetto, file, sha256 (dal db ucrt64 di MSYS2 del 2026-10-17), licenza dichiarata.
$Pacchetti = @(
@{ N='brotli'; F='mingw-w64-ucrt-x86_64-brotli-1.2.0-1-any.pkg.tar.zst'; H='9cc89665496ea504751476eafba15c71c554aaf3004babc2004f7c07ffd60514'; L='MIT' }
@{ N='bzip2'; F='mingw-w64-ucrt-x86_64-bzip2-1.0.8-4-any.pkg.tar.zst'; H='f03a2174034ddd2d96cecd34f617c5f8e2ef86c812b8d2bb3b8875257f2c8bfa'; L='bzip2 (BSD)' }
@{ N='expat'; F='mingw-w64-ucrt-x86_64-expat-2.9.0-1-any.pkg.tar.zst'; H='f52364dc874009316e1ee1fe46ee89784594b80d10cbcc1cc856945d575308e0'; L='MIT' }
@{ N='fontconfig'; F='mingw-w64-ucrt-x86_64-fontconfig-2.18.3-1-any.pkg.tar.zst'; H='d0b364f3ee37e3b6f6c136688ce8fa909bb6d8a69df7e08de1aa40870d9e1d60'; L='fontconfig (MIT-like)' }
@{ N='freetype'; F='mingw-w64-ucrt-x86_64-freetype-2.15.0-1-any.pkg.tar.zst'; H='a9a691f71d1a553099c54ca666e611abfb2b4ff08dae24f2a5af72e22a7e5feb'; L='GPL-2.0+ OR FTL' }
@{ N='fribidi'; F='mingw-w64-ucrt-x86_64-fribidi-1.0.17-1-any.pkg.tar.zst'; H='3581ee03cf83a9bb0e98bf688c4f532a29553500e317532d90746de295b0f8a8'; L='LGPL-2.1+' }
@{ N='gettext-runtime'; F='mingw-w64-ucrt-x86_64-gettext-runtime-1.0-1-any.pkg.tar.zst'; H='ba693dda4ac375af76ce481ff3a6e7481286546cc7dc6d56c7021dae34084157'; L='LGPL-2.1+ (libintl)' }
@{ N='glib2'; F='mingw-w64-ucrt-x86_64-glib2-2.90.1-1-any.pkg.tar.zst'; H='9a7826349279bcca0c935b01880d812be9de9609aef2205be68fd7a6128c1695'; L='LGPL-2.1+' }
@{ N='graphite2'; F='mingw-w64-ucrt-x86_64-graphite2-1.3.15-1-any.pkg.tar.zst'; H='34ca1f88bff10ef182a6b1c8e5622d5029f13b05748e361af13b552b422ea849'; L='LGPL-2.1+' }
@{ N='harfbuzz'; F='mingw-w64-ucrt-x86_64-harfbuzz-14.6.0-1-any.pkg.tar.zst'; H='8781d486c864bf91f482768cc5bd7267e49331636583d9f855af3974e6378e69'; L='MIT' }
@{ N='libdatrie'; F='mingw-w64-ucrt-x86_64-libdatrie-0.2.14-1-any.pkg.tar.zst'; H='ea92777801ad325278193a362967540752ae37035016df2f6bcee2d89928a195'; L='LGPL' }
@{ N='libffi'; F='mingw-w64-ucrt-x86_64-libffi-3.8.0-1-any.pkg.tar.zst'; H='99ad12f4ecfa00a889ef9c5c0188592368a5bac109d0c508ce4e04f29ea95a76'; L='MIT' }
@{ N='libgcc'; F='mingw-w64-ucrt-x86_64-libgcc-16.2.0-6-any.pkg.tar.zst'; H='80b2497f019977dd9528b1abc8621b1328a0115ab1f724047ccbb56bf323f11d'; L='GPL-3.0+ con GCC runtime exception' }
@{ N='libiconv'; F='mingw-w64-ucrt-x86_64-libiconv-1.19-1-any.pkg.tar.zst'; H='9a500f38c2b91808741c62fae746b3e9110b33a1ecf5c30fa0c66dbedddf7e16'; L='LGPL-2.1+' }
@{ N='libpng'; F='mingw-w64-ucrt-x86_64-libpng-1.6.59-1-any.pkg.tar.zst'; H='7f8448e06eceba8f8ce130390addea49f767bdf8f6b7e7eede8ca6d595e3d3a8'; L='libpng (permissiva)' }
@{ N='libstdc++'; F='mingw-w64-ucrt-x86_64-libstdc++-16.2.0-6-any.pkg.tar.zst'; H='33c5e58a85a538cf843a8672991cce591f0231d66da3b66a57629712d64be17f'; L='GPL-3.0+ con GCC runtime exception' }
@{ N='libthai'; F='mingw-w64-ucrt-x86_64-libthai-0.1.30-1-any.pkg.tar.zst'; H='c90ebbca3e7b4c34f4b3ffa501ba8d00cdaf6340cff7db3c46d79d5e01ef207c'; L='LGPL-2.1+' }
@{ N='libwinpthread'; F='mingw-w64-ucrt-x86_64-libwinpthread-14.0.0.r426.g4564ee4b5-2-any.pkg.tar.zst'; H='a02f5e09d666d3be657c78c86fe67c975d04fe33b5211cd5ff1b23a91c118524'; L='MIT AND BSD-3-Clause' }
@{ N='pcre2'; F='mingw-w64-ucrt-x86_64-pcre2-10.49-1-any.pkg.tar.zst'; H='63172179db724ac71e2439a7981134c1bcab8049bdddfd349b32b8f76ed52d1e'; L='BSD-3-Clause' }
@{ N='pango'; F='mingw-w64-ucrt-x86_64-pango-1.58.2-1-any.pkg.tar.zst'; H='8d86e4a018ff139b010bf357fa1c9f9030fa32ee195c54ed7e89d8ea701851c8'; L='LGPL-2.1' }
@{ N='zlib-ng-compat'; F='mingw-w64-ucrt-x86_64-zlib-ng-compat-2.3.3-2-any.pkg.tar.zst'; H='b3ca6b437fb154a0fa26652002cdda591498ee4a0e08d91cc653c3702d090f25'; L='Zlib' }
)
# DLL che WeasyPrint carica (6 radici) piu' la loro chiusura di dipendenze (calcolata con pefile il 2026-10-17).
$Dll = @(
'libbrotlicommon.dll','libbrotlidec.dll','libbz2-1.dll','libdatrie-1.dll','libexpat-1.dll','libffi-8.dll',
'libfontconfig-1.dll','libfreetype-6.dll','libfribidi-0.dll','libgcc_s_seh-1.dll','libgio-2.0-0.dll',
'libglib-2.0-0.dll','libgmodule-2.0-0.dll','libgobject-2.0-0.dll','libgraphite2.dll','libharfbuzz-0.dll',
'libharfbuzz-subset-0.dll','libiconv-2.dll','libintl-8.dll','libpango-1.0-0.dll','libpangoft2-1.0-0.dll',
'libpcre2-8-0.dll','libpng16-16.dll','libstdc++-6.dll','libthai-0.dll','libwinpthread-1.dll','zlib1.dll'
)
function Scrivi([string]$m) { Write-Host ("[prendi-gtk] " + $m) }
# Impronta dei pin: se cambia, la cartella gia' pronta non e' piu' valida.
$pinTesto = (($Pacchetti | ForEach-Object { $_.F + ':' + $_.H }) -join "`n") + "`n" + ($Dll -join ',')
$sha = [Security.Cryptography.SHA256]::Create()
$impronta = -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($pinTesto)) | ForEach-Object { $_.ToString('x2') })
$marker = Join-Path $Destinazione '.gtk-pin'
$dllMancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $Destinazione $_)) })
if (-not $Forza -and (Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $impronta) -and $dllMancanti.Count -eq 0) {
Scrivi "gia' pronto in $Destinazione (pin $($impronta.Substring(0,12)))"
exit 0
}
# --- estrazione .zst: tar.exe di Windows se sa di zstd, altrimenti Python zstandard via uv ---
$tar = Join-Path $env:SystemRoot 'System32\tar.exe'
$tarZstd = $false
if (Test-Path $tar) { $tarZstd = ((& $tar --version) -join ' ') -match 'zstd' }
function Estrai([string]$pkg, [string]$dest) {
if ($tarZstd) {
& $tar -xf $pkg -C $dest
if ($LASTEXITCODE -ne 0) { throw "tar.exe ha fallito su $pkg" }
} else {
$py = "import sys,tarfile,zstandard`nd=zstandard.ZstdDecompressor()`nwith open(sys.argv[1],'rb') as f, d.stream_reader(f) as r:`n tarfile.open(fileobj=r,mode='r|').extractall(sys.argv[2])"
$tmpPy = Join-Path ([IO.Path]::GetTempPath()) ('estrai-zst-' + [guid]::NewGuid().ToString('N') + '.py')
Set-Content -Path $tmpPy -Value $py -Encoding ASCII
try {
& uv run --quiet --with zstandard python -I $tmpPy $pkg $dest
if ($LASTEXITCODE -ne 0) { throw "estrazione zstd via uv fallita su $pkg" }
}
finally { Remove-Item $tmpPy -ErrorAction SilentlyContinue }
}
}
New-Item -ItemType Directory -Force -Path $Cache | Out-Null
# Lavoro in una cartella temporanea, poi sposto: un errore a meta' non lascia mezza cartella in -Destinazione.
$tmp = Join-Path ([IO.Path]::GetTempPath()) ('tmf-gtk-' + [guid]::NewGuid().ToString('N'))
$stage = Join-Path $tmp 'stage'
New-Item -ItemType Directory -Force -Path $stage | Out-Null
try {
foreach ($p in $Pacchetti) {
$file = Join-Path $Cache $p.F
$ok = $false
if (Test-Path $file) {
$ok = ((Get-FileHash $file -Algorithm SHA256).Hash.ToLower() -eq $p.H)
if (-not $ok) { Scrivi "cache corrotta, riscarico $($p.F)"; Remove-Item $file -Force }
}
if (-not $ok) {
Scrivi "scarico $($p.F)"
$parz = $file + '.parz'
# il '+' di libstdc++ va codificato nell'URL
Invoke-WebRequest -UseBasicParsing -Uri ($Base + [Uri]::EscapeDataString($p.F)) -OutFile $parz
$h = (Get-FileHash $parz -Algorithm SHA256).Hash.ToLower()
if ($h -ne $p.H) { Remove-Item $parz -Force; throw "sha256 diverso per $($p.F): atteso $($p.H), trovato $h" }
Move-Item $parz $file -Force
}
$ex = Join-Path $tmp $p.N
New-Item -ItemType Directory -Force -Path $ex | Out-Null
Estrai $file $ex
$bin = Join-Path $ex 'ucrt64\bin'
if (Test-Path $bin) { Get-ChildItem $bin -Filter *.dll | Copy-Item -Destination $stage -Force }
# fontconfig: senza etc\fonts WeasyPrint non trova i font (errore "Cannot load default config file")
if ($p.N -eq 'fontconfig') { Copy-Item (Join-Path $ex 'ucrt64\etc\fonts') (Join-Path $stage 'etc\fonts') -Recurse -Force }
# licenze: le DLL LGPL si ridistribuiscono con il testo della licenza
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'licenze') | Out-Null
$lic = Join-Path $ex 'ucrt64\share\licenses'
if (Test-Path $lic) { Copy-Item $lic (Join-Path $stage ('licenze\' + $p.N)) -Recurse -Force }
Set-Content -Path (Join-Path $stage ('licenze\' + $p.N + '.txt')) -Value ($p.F + "`nlicenza: " + $p.L + "`nsorgente: https://packages.msys2.org/") -Encoding UTF8
}
# Tengo solo le DLL in whitelist: le altre DLL dei pacchetti non servono.
Get-ChildItem $stage -Filter *.dll | Where-Object { $Dll -notcontains $_.Name } | Remove-Item -Force
$mancanti = @($Dll | Where-Object { -not (Test-Path (Join-Path $stage $_)) })
if ($mancanti.Count -gt 0) { throw "DLL attese non trovate: $($mancanti -join ', ')" }
Set-Content -Path (Join-Path $stage '.gtk-pin') -Value $impronta -Encoding ASCII
if (Test-Path $Destinazione) { Get-ChildItem $Destinazione -Force | Remove-Item -Recurse -Force }
New-Item -ItemType Directory -Force -Path $Destinazione | Out-Null
Get-ChildItem $stage -Force | Move-Item -Destination $Destinazione -Force
Scrivi "pronto: $($Dll.Count) DLL in $Destinazione"
}
finally {
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
+53
View File
@@ -0,0 +1,53 @@
<#
Cosa fa: avvia TieMeasureFlow DA SORGENTE su una macchina di sviluppo Windows, senza la build
Nuitka (che richiede ~20 minuti): stessi quattro server e stesse porte dell'exe (8780 web,
8000 backend, 8100 visione, 8765 camera), stessa cartella dati %PROGRAMDATA%\Tielogic\TieMeasureFlow.
Prima dell'avvio: ambiente Python 3.13 in build\venv313 (uv sync, solo con -Sincronizza o se manca),
tailwind.css e traduzioni .mo ricompilati, DLL di WeasyPrint in build\gtk (scaricate con
pdf\prendi-gtk.ps1 se mancano).
Come si usa (dalla radice del repo):
powershell -ExecutionPolicy Bypass -File packaging\windows\sviluppo.ps1 [-Sincronizza] [-Ascolto 0.0.0.0] [-EscludiCamere OECS]
Dopo una modifica: Ctrl+C e rilancio (pochi secondi). Template e JS non richiedono altro;
classi Tailwind nuove e msgid nuovi vengono ricompilati a ogni avvio.
Dipende da: uv, npx (Node), la licenza tiemeasureflow di QUESTA macchina in
%PROGRAMDATA%\Tielogic\licenze\tiemeasureflow.json (impronta: avvio.py --impronta).
#>
param(
[switch]$Sincronizza, # forza uv sync (dipendenze cambiate)
[string]$Ascolto = '127.0.0.1', # indirizzo di ascolto della UI web ($Host e' riservato in PowerShell)
[string]$EscludiCamere = '' # backend nativi da saltare (TieVision-Core#339): OECS se c'e' il runtime OECS ma in fiera si usa GenTL
)
$ErrorActionPreference = 'Stop'
$radice = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$env:UV_PROJECT_ENVIRONMENT = Join-Path $radice 'build\venv313' # stesso ambiente della build
$py = Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\python.exe'
if ($Sincronizza -or -not (Test-Path $py)) {
uv sync --python 3.13 --frozen --extra server --extra client --extra vision-worker --group windows
if ($LASTEXITCODE -ne 0) { throw "uv sync fallito" }
}
# CSS e traduzioni: non sono nel repo, come in build.ps1
$flask = Join-Path $radice 'src\frontend\flask_app'
Push-Location $flask
try {
npx --yes tailwindcss@3.4.19 -i static/css/input.css -o static/css/tailwind.css --minify
if ($LASTEXITCODE -ne 0) { throw "Tailwind fallito" }
& (Join-Path $env:UV_PROJECT_ENVIRONMENT 'Scripts\pybabel.exe') -q compile -d translations
if ($LASTEXITCODE -ne 0) { throw "pybabel compile fallito" }
} finally { Pop-Location }
# DLL di WeasyPrint fuori dalla radice (gtk\ nella radice non e' ignorata da git): build\gtk
$gtk = Join-Path $radice 'build\gtk'
if (-not (Test-Path (Join-Path $gtk '*.dll'))) {
powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'pdf\prendi-gtk.ps1') -Destinazione $gtk
if ($LASTEXITCODE -ne 0) { throw "prendi-gtk.ps1 fallito" }
}
$env:WEASYPRINT_DLL_DIRECTORIES = $gtk
if ($EscludiCamere) { $env:VS_CAMERA_ESCLUDI = $EscludiCamere } # #339: stesso percorso camera del PC fiera
Set-Location $radice
& $py (Join-Path $PSScriptRoot 'avvio.py') --senza-browser --senza-finestre --host $Ascolto # errori in console, niente MessageBox
exit $LASTEXITCODE
+273
View File
@@ -0,0 +1,273 @@
"""Test di packaging/windows/avvio.py (TieMeasureFlow#76): licenza firmata con la chiave di prova
del contratto v1, nessun server vero (avvia_server e migrazioni sono sostituiti).
Si lanciano con l'ambiente 3.13 del pacchetto (serve visionsuite.license):
UV_PROJECT_ENVIRONMENT=build/venv313 uv run --python 3.13 --frozen --extra server --extra client \
--extra vision-worker --group windows --with pytest pytest packaging/windows/tests -q
"""
from __future__ import annotations
import base64
import functools
import importlib.util
import json
import os
import sqlite3
import sys
import types
from datetime import date, timedelta
from pathlib import Path
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
RADICE = Path(__file__).resolve().parents[3]
# vettore di prova del contratto licenza v1 (chiave finta, solo test)
SEED_TEST = base64.b64decode("AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=")
PUBBLICA_TEST = base64.b64decode("A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=")
IMPRONTA = "ab" * 32
def _firma(dati: dict) -> str:
# stessa canonicalizzazione del contratto (byte firmati)
byte = json.dumps(dati, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
return base64.b64encode(Ed25519PrivateKey.from_private_bytes(SEED_TEST).sign(byte)).decode()
@pytest.fixture
def avvio(monkeypatch, tmp_path):
# avvio.py non e' in un pacchetto: lo si carica dal percorso
spec = importlib.util.spec_from_file_location("avvio_tmf", RADICE / "packaging/windows/avvio.py")
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
monkeypatch.setenv("PROGRAMDATA", str(tmp_path)) # ProgramData finta
monkeypatch.delenv("TIELOGIC_LICENZA", raising=False)
monkeypatch.setattr(os, "environ", os.environ.copy()) # le variabili impostate da avvio non escono dal test
monkeypatch.setattr(m.lic, "impronta", lambda: IMPRONTA)
# verifica reale (formato, firma, prodotto, macchina, scadenza) ma con la chiave di prova
verifica_reale = m.lic.verifica
monkeypatch.setattr(m.lic, "verifica", functools.partial(
verifica_reale, chiave_pubblica=PUBBLICA_TEST, impronta_attesa=IMPRONTA))
m.chiamate = []
monkeypatch.setattr(m, "avvia_server", lambda host, dati: m.chiamate.append((host, dati)) or [])
monkeypatch.setattr(m, "esegui_migrazioni", lambda: m.chiamate.append("migrazioni"))
m.aperti = []
monkeypatch.setattr(m.webbrowser, "open", lambda url: m.aperti.append(url))
monkeypatch.setattr(m, "_prepara_log", lambda dati=None: None)
monkeypatch.setattr(m, "_porta_aperta", lambda porta: False)
return m
def _scrivi_licenza(tmp_path: Path, **modifiche) -> Path:
dati = {"versione": 1, "prodotto": "tiemeasureflow", "cliente": "Prova Srl", "impronta": IMPRONTA,
"emessa": date.today().isoformat(), "scadenza": (date.today() + timedelta(days=30)).isoformat()}
dati.update(modifiche)
p = tmp_path / "Tielogic" / "licenze" / "tiemeasureflow.json"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps({"dati": dati, "firma": _firma(dati)}), encoding="utf-8")
return p
def test_licenza_mancante_non_avvia_e_scrive_impronta(avvio, capsys):
assert avvio.main(["--senza-finestre"]) == 1
assert avvio.chiamate == [] and avvio.aperti == []
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
err = capsys.readouterr().err
assert "Licenza non trovata" in err and IMPRONTA in err and "tiemeasureflow.json" in err
@pytest.mark.parametrize("modifica,testo", [
({"scadenza": "2020-01-01"}, "scaduta"),
({"impronta": "cd" * 32}, "altra macchina"),
({"prodotto": "tievision-studio"}, "altro prodotto"),
])
def test_licenza_non_valida_non_avvia(avvio, tmp_path, capsys, modifica, testo):
_scrivi_licenza(tmp_path, **modifica)
assert avvio.main(["--senza-finestre"]) == 1
assert avvio.chiamate == []
assert testo in capsys.readouterr().err
assert avvio.percorso_impronta().read_text(encoding="utf-8").strip() == IMPRONTA
def test_licenza_manomessa_non_avvia(avvio, tmp_path, capsys):
p = _scrivi_licenza(tmp_path)
doc = json.loads(p.read_text(encoding="utf-8"))
doc["dati"]["cliente"] = "Altro Cliente" # dati cambiati dopo la firma
p.write_text(json.dumps(doc), encoding="utf-8")
assert avvio.main(["--senza-finestre"]) == 1
assert "Firma" in capsys.readouterr().err
def test_licenza_valida_prepara_dati_e_avvia(avvio, tmp_path):
_scrivi_licenza(tmp_path)
assert avvio.main(["--senza-finestre", "--senza-browser", "--host", "0.0.0.0"]) == 0
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
# prima le migrazioni, poi i server
assert avvio.chiamate == ["migrazioni", ("0.0.0.0", dati)]
assert (dati / "config.env").is_file() and (dati / "uploads").is_dir() and (dati / "camera").is_dir()
assert not avvio.percorso_impronta().exists() # con licenza valida non si scrive nulla
assert avvio.aperti == []
def test_variabili_del_contratto(avvio, tmp_path):
_scrivi_licenza(tmp_path)
avvio.main(["--senza-finestre", "--senza-browser"])
e = os.environ
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
assert e["APP_ENV"] == "production"
assert e["DB_URL"] == f"sqlite+aiosqlite:///{(dati / 'tmf.db').as_posix()}" and "\\" not in e["DB_URL"]
assert e["UPLOAD_DIR"] == str(dati / "uploads") == e["VISION_RECIPES_ROOT"]
assert e["VISION_WORKER_URL"] == "http://127.0.0.1:8100"
assert e["API_SERVER_URL"] == "http://127.0.0.1:8000"
assert e["SESSION_COOKIE_SECURE"] == "0"
for k in ("SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD"):
assert len(e[k]) >= 32
def test_config_env_generato_una_volta_e_riletto(avvio, tmp_path):
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
primo = avvio.carica_segreti(dati)
testo = (dati / "config.env").read_text(encoding="utf-8")
assert set(primo) == {"SERVER_SECRET_KEY", "CLIENT_SECRET_KEY", "SETUP_PASSWORD", "DB_PASSWORD", "STATION_CODE"}
assert len({primo[k] for k in avvio.SEGRETI}) == 4 # segreti distinti
assert primo["STATION_CODE"] == "ST-DEFAULT"
secondo = avvio.carica_segreti(dati)
assert secondo == primo and (dati / "config.env").read_text(encoding="utf-8") == testo
def test_config_env_completa_i_segreti_mancanti(avvio, tmp_path):
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
dati.mkdir(parents=True)
(dati / "config.env").write_text("# nota\nSETUP_PASSWORD=scelta-da-me\n", encoding="utf-8")
v = avvio.carica_segreti(dati)
assert v["SETUP_PASSWORD"] == "scelta-da-me" and len(v["SERVER_SECRET_KEY"]) >= 32
assert avvio.carica_segreti(dati) == v
def test_seconda_istanza_apre_solo_il_browser(avvio, tmp_path, monkeypatch):
_scrivi_licenza(tmp_path)
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
monkeypatch.setattr(avvio, "_e_tmf", lambda: True)
assert avvio.main(["--senza-finestre"]) == 0
assert avvio.aperti == ["http://127.0.0.1:8780/"] and avvio.chiamate == []
def test_porta_occupata_da_altro_programma(avvio, tmp_path, monkeypatch, capsys):
_scrivi_licenza(tmp_path)
monkeypatch.setattr(avvio, "_porta_aperta", lambda porta: True)
monkeypatch.setattr(avvio, "_e_tmf", lambda: False)
assert avvio.main(["--senza-finestre"]) == 2
assert "8780" in capsys.readouterr().err and avvio.chiamate == []
def test_migrazione_fallita_esce_con_3(avvio, tmp_path, monkeypatch, capsys):
_scrivi_licenza(tmp_path)
def rotta():
raise RuntimeError("db bloccato")
monkeypatch.setattr(avvio, "esegui_migrazioni", rotta)
assert avvio.main(["--senza-finestre"]) == 3
assert "db bloccato" in capsys.readouterr().err
def test_impronta_si_stampa_senza_licenza(avvio, capsys):
assert avvio.main(["--impronta", "--senza-finestre"]) == 0
assert IMPRONTA in capsys.readouterr().out
def test_nessun_utente_rileva_il_primo_avvio(avvio, tmp_path):
db = tmp_path / "t.db"
assert avvio._nessun_utente(db) is False # file assente: nessuna conclusione
con = sqlite3.connect(db)
con.execute("CREATE TABLE users (id INTEGER)")
con.commit()
assert avvio._nessun_utente(db) is True
con.execute("INSERT INTO users VALUES (1)")
con.commit()
con.close()
assert avvio._nessun_utente(db) is False
def test_chiavi_extra_di_config_env_passano_ma_non_sovrascrivono_il_contratto(avvio, tmp_path):
_scrivi_licenza(tmp_path)
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
dati.mkdir(parents=True)
(dati / "config.env").write_text("STATION_CODE=ST-FIERA\nSTATION_SWITCH_ENABLED=1\nAPP_ENV=dev\n", encoding="utf-8")
avvio.main(["--senza-finestre", "--senza-browser"])
assert os.environ["STATION_CODE"] == "ST-FIERA" and os.environ["STATION_SWITCH_ENABLED"] == "1"
assert os.environ["APP_ENV"] == "production" # le variabili fisse vincono
# --- TieMeasureFlow#82: strumenti nel pacchetto (nel binario non ci sono gli entry point) ---
def _modulo_strumenti(monkeypatch, moduli):
# finto _strumenti.py generato da build.ps1
finto = types.ModuleType("_strumenti")
finto.MODULI = tuple(moduli)
monkeypatch.setitem(sys.modules, "_strumenti", finto)
def test_registra_strumenti_da_sorgente_senza_modulo_generato(avvio, monkeypatch):
# _strumenti.py esiste solo dopo build.ps1: se manca (sorgente) bastano gli entry point
monkeypatch.setitem(sys.modules, "_strumenti", None) # None in sys.modules = ImportError all'import
from visionsuite.task.graph import registry
assert avvio.registra_strumenti() == []
assert len(registry()) > 0
def test_registra_strumenti_modulo_inesistente_non_solleva(avvio, monkeypatch):
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image", "visionsuite.tools.non_esiste"))
assert avvio.registra_strumenti() == ["visionsuite.tools.non_esiste"] # raccolto, non sollevato
def test_registra_strumenti_importa_i_moduli_elencati(avvio, monkeypatch):
# senza entry point (come nell'exe) i moduli di MODULI sono l'unica via: li si importa per nome
_modulo_strumenti(monkeypatch, ("visionsuite.tools.image",))
visti = []
monkeypatch.setattr(avvio.importlib, "import_module", lambda nome: visti.append(nome))
assert avvio.registra_strumenti() == [] and visti == ["visionsuite.tools.image"]
def test_controlla_strumenti_senza_licenza_scrive_json(avvio, monkeypatch, capsys, tmp_path):
monkeypatch.setitem(sys.modules, "_strumenti", None)
# la licenza non deve essere nemmeno chiesta, e server e migrazioni non devono partire
def mai(*a, **k):
raise AssertionError("licenza, server o migrazioni chiamati da --controlla-strumenti")
monkeypatch.setattr(avvio.lic, "verifica", mai)
monkeypatch.setattr(avvio.lic, "cerca_licenza", mai)
monkeypatch.setattr(avvio, "avvia_server", mai)
monkeypatch.setattr(avvio, "esegui_migrazioni", mai)
assert avvio.main(["--controlla-strumenti", "--senza-finestre"]) == 0
dati = json.loads(capsys.readouterr().out)
assert dati["strumenti"] > 0 and dati["strumenti"] == len(dati["kind"])
assert dati["falliti"] == []
scritto = tmp_path / "Tielogic" / "TieMeasureFlow" / "log" / "controllo-strumenti.json"
assert json.loads(scritto.read_text(encoding="utf-8")) == dati
assert avvio.aperti == [] and not (tmp_path / "Tielogic" / "TieMeasureFlow" / "tmf.db").exists() # nessun DB
def test_controlla_strumenti_exit_3_se_un_modulo_fallisce(avvio, monkeypatch, capsys):
_modulo_strumenti(monkeypatch, ("visionsuite.tools.non_esiste",))
assert avvio.main(["--controlla-strumenti"]) == 3
assert json.loads(capsys.readouterr().out)["falliti"] == ["visionsuite.tools.non_esiste"]
def test_controlla_strumenti_exit_3_se_il_catalogo_e_vuoto(avvio, monkeypatch, capsys):
# niente strumenti registrati (es. entry point assenti e _strumenti vuoto): il controllo deve fallire
import visionsuite.task.graph as graph
monkeypatch.setattr(avvio, "registra_strumenti", lambda: [])
monkeypatch.setattr(graph, "registry", lambda: {})
assert avvio.main(["--controlla-strumenti"]) == 3
assert json.loads(capsys.readouterr().out)["strumenti"] == 0
def test_avvio_normale_registra_gli_strumenti_prima_del_server(avvio, tmp_path, monkeypatch):
# main() chiama registra_strumenti() dopo licenza e migrazioni e prima di avvia_server
_scrivi_licenza(tmp_path)
monkeypatch.setattr(avvio, "registra_strumenti", lambda: avvio.chiamate.append("strumenti") or [])
assert avvio.main(["--senza-finestre", "--senza-browser"]) == 0
dati = tmp_path / "Tielogic" / "TieMeasureFlow"
assert avvio.chiamate == ["migrazioni", "strumenti", ("127.0.0.1", dati)]
+16
View File
@@ -81,6 +81,20 @@ vision-worker = [
"python-multipart>=0.0.6; python_version>='3.13'",
]
# Pacchetto Windows (packaging/windows, TieMeasureFlow#76): solo per build.ps1, non per
# i container. Si usa con --python 3.13 insieme agli extra server, client e vision-worker.
[dependency-groups]
windows = [
"tzdata; sys_platform == 'win32'", # ZoneInfo("Europe/Rome") (backend e frontend): Windows non ha il database IANA
"aiosqlite>=0.20.0", # database SQLite su file (DB_URL, piano A)
"waitress>=3.0.0", # server WSGI del frontend Flask (gunicorn non gira su Windows)
"vs-camera[genicam]; python_version>='3.13'", # scripts/camera_service.py: camere GigE/GenTL (producer .cti del vendor)
"vs-license; python_version>='3.13'", # licenza Tielogic v1 (impronta macchina, firma Ed25519)
"nuitka>=2.5", # compilazione standalone
"ordered-set>=4.1", # richiesto da Nuitka per la modalita' standalone
"zstandard>=0.22", # compressione dei dati incorporati di Nuitka
]
[project.scripts]
# Backend
server = "uvicorn:run" # placeholder, real CMD lives in Dockerfile
@@ -105,6 +119,8 @@ vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
vs-camera = { path = "vendor/visionsuite/packages/vs-camera", editable = true } # #76 camera di stazione
vs-license = { path = "vendor/visionsuite/packages/vs-license", editable = true } # #76 licenza del pacchetto Windows
[tool.pytest.ini_options]
asyncio_mode = "auto"
+566
View File
@@ -0,0 +1,566 @@
"""Calibrazione a scacchiera dell'acquisitore (TieMeasureFlow, #83).
Cosa fa: da una foto di scacchiera piana calcola la scala (px/mm) con
TieVision-Core (visionsuite.calibration), e conserva un risultato per sorgente
camera in un JSON (scrittura atomica).
#84: `calibra_scacchiera_densa(pixels, square_mm)` calibra da UNA foto di
scacchiera che riempie tutto il campo (ottica telecentrica): trova da sola la
griglia, costruisce la mappa di distorsione (DistortionMap) e il mm/px
dell'immagine raddrizzata. L'Archivio salva la mappa accanto al JSON (.npz) e
`raddrizza()` la applica ai frame.
Uso: importato da scripts/camera_service.py (rotte /camera/calibrate e
/camera/calibration); `calibra_scacchiera(gray, cols, rows, square_mm)` e
`calibra_scacchiera_densa(gray, square_mm)` sono logica pura, senza FastAPI,
e si provano da sole.
Dipende da: numpy, opencv, visionsuite (vs-core). Niente altro (no scipy).
"""
from __future__ import annotations
import base64
import hashlib
import json
import math
import os
import re
import tempfile
import threading
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import NamedTuple
import cv2
import numpy as np
from visionsuite.calibration import calibrate_planar, find_chessboard
from visionsuite.calibration.distortion_map import DistortionMap # #84 mappa densa
from visionsuite.core.image import Image
class CalibrazioneError(ValueError):
"""Parametri invalidi o scacchiera non trovata: input sbagliato, non un guasto (422)."""
def _gray_u8(pixels: np.ndarray) -> np.ndarray:
"""Immagine -> grigio uint8 2D."""
px = np.asarray(pixels)
if px.ndim == 3:
px = cv2.cvtColor(px, cv2.COLOR_BGRA2GRAY if px.shape[2] == 4 else cv2.COLOR_BGR2GRAY)
if px.dtype != np.uint8:
# float/uint16 -> scala sul massimo, come fa vs-core
m = float(px.max()) if px.size else 1.0
px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8)
return np.ascontiguousarray(px)
def calibra_scacchiera(pixels, cols: int, rows: int, square_mm: float) -> dict:
"""Scala (px/mm) da una foto di scacchiera piana. cols/rows = angoli INTERNI.
Ritorna {px_per_mm, mm_per_px, rms_px, n_corners, cols, rows, square_mm,
image_width, image_height, scale_spread_pct, metodo, distorsione,
overlay_png (b64)}.
"""
if cols < 2 or rows < 2:
raise CalibrazioneError("cols e rows (angoli interni) devono essere almeno 2")
if not (square_mm > 0 and math.isfinite(square_mm)):
raise CalibrazioneError("square_mm deve essere > 0")
frame = _gray_u8(pixels)
image = Image(frame)
try:
image_pts, world_pts = find_chessboard(image, (cols, rows), square_mm)
except RuntimeError as exc:
# find_chessboard solleva RuntimeError quando la scacchiera non c'e'
raise CalibrazioneError(
f"scacchiera {cols}x{rows} non trovata nell'immagine: {exc}") from exc
model = calibrate_planar(image, image_pts, world_pts) # omografia pura
# Scala al centro della scacchiera (puo' non stare al centro immagine)
x0, y0 = image_pts.min(axis=0)
x1, y1 = image_pts.max(axis=0)
mm_per_px = model.pixel_size((x0 + x1) / 2.0, (y0 + y1) / 2.0)
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
raise CalibrazioneError("calibrazione degenere: scala non valida")
# Scarto massimo % della scala ai quattro angoli dell'area della scacchiera
# rispetto al centro: ottica telecentrica/piana ~ 0, prospettiva = grande.
spread = max(abs(model.pixel_size(x, y) - mm_per_px) / mm_per_px * 100.0
for x in (x0, x1) for y in (y0, y1))
# Overlay: angoli disegnati sull'immagine (PNG base64)
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
cv2.drawChessboardCorners(
bgr, (cols, rows), image_pts.reshape(-1, 1, 2).astype(np.float32), True)
ok, png = cv2.imencode(".png", bgr)
if not ok:
raise RuntimeError("PNG dell'overlay non codificabile")
h, w = frame.shape
return {
"px_per_mm": 1.0 / mm_per_px,
"mm_per_px": mm_per_px,
"rms_px": float(model.rms_reprojection),
"n_corners": int(len(image_pts)),
"cols": int(cols),
"rows": int(rows),
"square_mm": float(square_mm),
"image_width": int(w),
"image_height": int(h),
"scale_spread_pct": float(spread),
"metodo": "planare", # #84: il metodo con cols/rows resta quello di prima
"distorsione": False,
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
}
# ---- #84: calibrazione densa (scacchiera che riempie il campo, griglia non nota)
# #84: findChessboardCornersSBWithMeta (verificato su opencv 5.0.0) su una scacchiera
# grande quanto l'immagine trova solo frammenti (2064x1544 con quadrati da 40 px:
# 5x5, 28x6...). Per questo la griglia si cerca qui: sellini della Hessiana a piu'
# scale + crescita del reticolo; poi cornerSubPix.
_SIGME = (1.5, 2.2, 3.2, 4.5, 6.5, 9.0, 13.0) # scale dei sellini (passo ~ 5..120 px)
_MAX_CANDIDATI = 6000
def _candidati(gray: np.ndarray, sigma: float) -> np.ndarray:
"""Massimi locali del 'selliness' (-det Hessiana) = angoli di scacchiera, (n,2) x,y."""
b = cv2.GaussianBlur(gray.astype(np.float32), (0, 0), sigma)
ixx = cv2.Sobel(b, cv2.CV_32F, 2, 0, ksize=3)
iyy = cv2.Sobel(b, cv2.CV_32F, 0, 2, ksize=3)
ixy = cv2.Sobel(b, cv2.CV_32F, 1, 1, ksize=3)
r = ixy * ixy - ixx * iyy # >0 solo dove l'immagine ha un sellino
massimo = float(r.max())
if massimo <= 0:
return np.zeros((0, 2))
k = 2 * int(math.ceil(1.5 * sigma)) + 1
picchi = (r >= cv2.dilate(r, np.ones((k, k), np.uint8))) & (r > 0.1 * massimo)
ys, xs = np.nonzero(picchi)
if len(xs) > _MAX_CANDIDATI: # tiene i piu' forti
forti = np.argsort(r[ys, xs])[::-1][:_MAX_CANDIDATI]
ys, xs = ys[forti], xs[forti]
return np.stack([xs, ys], axis=1).astype(np.float64)
def _e_angolo_x(lisc: np.ndarray, delta: float, p: np.ndarray, u: np.ndarray, v: np.ndarray) -> bool:
"""True se attorno a p i quattro quadranti alternano chiaro/scuro (vero angolo X).
Scarta le giunzioni a L/T al bordo della scacchiera (3 quadranti uguali), che
il selliness da solo accetta e che spostano la griglia.
"""
h, w = lisc.shape
campioni = []
for q in (0.25 * (u + v), -0.25 * (u + v), 0.25 * (u - v), -0.25 * (u - v)):
x, y = int(round(p[0] + q[0])), int(round(p[1] + q[1]))
if not (0 <= x < w and 0 <= y < h):
return False
campioni.append(float(lisc[y, x]))
s1, s2, t1, t2 = campioni
return (min(s1, s2) > max(t1, t2) + delta) or (max(s1, s2) < min(t1, t2) - delta)
def _cresci_reticolo(pts: np.ndarray, centro: tuple[float, float],
lisc: np.ndarray, delta: float) -> dict | None:
"""Assegna un indice (riga, colonna) ai candidati che stanno su un reticolo.
Parte dal candidato piu' vicino al centro, ricava i due vettori di passo dai
vicini e cresce a macchia d'olio: la base si aggiorna a ogni passo, quindi
segue una distorsione lenta. Ritorna {(i, j): indice candidato} o None.
"""
if len(pts) < 9:
return None
seme = int(np.argmin(np.hypot(pts[:, 0] - centro[0], pts[:, 1] - centro[1])))
d = np.hypot(pts[:, 0] - pts[seme, 0], pts[:, 1] - pts[seme, 1])
vicini = np.argsort(d)[1:9]
u = pts[vicini[0]] - pts[seme]
lu = float(np.hypot(*u))
v = None
for k in vicini[1:]:
w = pts[k] - pts[seme]
lw = float(np.hypot(*w))
# il secondo vettore: quasi ortogonale al primo e di lunghezza simile
if lu > 0 and lw > 0 and abs(float(u @ w)) / (lu * lw) < 0.4 and lw < 1.5 * lu:
v = w
break
if v is None or lu < 3:
return None
posto = {(0, 0): seme}
indice = {seme: (0, 0)}
basi = {seme: (u, v)}
coda = deque([seme])
while coda:
a = coda.popleft()
i, j = indice[a]
u, v = basi[a]
tol = 0.3 * min(float(np.hypot(*u)), float(np.hypot(*v)))
for di, dj, vec in ((0, 1, u), (0, -1, -u), (1, 0, v), (-1, 0, -v)):
if (i + di, j + dj) in posto:
continue
prev = pts[a] + vec
dist = np.hypot(pts[:, 0] - prev[0], pts[:, 1] - prev[1])
b = int(np.argmin(dist))
if dist[b] > tol or b in indice:
continue
if not _e_angolo_x(lisc, delta, pts[b], u, v): # niente L/T del bordo
continue
posto[(i + di, j + dj)] = b
indice[b] = (i + di, j + dj)
reale = pts[b] - pts[a]
basi[b] = (reale * dj, v) if dj else (u, reale * di) # base locale aggiornata
coda.append(b)
return posto
def _rettangolo_pieno(posto: dict) -> tuple[int, int, int, int] | None:
"""Il rettangolo di indici tutto occupato di area massima: (i0, j0, righe, colonne)."""
if not posto:
return None
i_min = min(i for i, _ in posto)
j_min = min(j for _, j in posto)
nr = max(i for i, _ in posto) - i_min + 1
nc = max(j for _, j in posto) - j_min + 1
m = np.zeros((nr, nc), bool)
for (i, j) in posto:
m[i - i_min, j - j_min] = True
migliore = (0, 0, 0, 0)
alt = np.zeros(nc, int)
for r in range(nr): # istogramma delle altezze + pila, O(righe*colonne)
alt = np.where(m[r], alt + 1, 0)
pila: list[int] = []
for c in range(nc + 1):
h = int(alt[c]) if c < nc else 0
while pila and alt[pila[-1]] >= h:
altezza = int(alt[pila.pop()])
sx = pila[-1] + 1 if pila else 0
larg = c - sx
if altezza * larg > migliore[2] * migliore[3]:
migliore = (r - altezza + 1 + i_min, sx + j_min, altezza, larg)
pila.append(c)
return migliore if migliore[2] * migliore[3] else None
def _orienta(g: np.ndarray) -> np.ndarray:
"""Riordina (nr,nc,2) con colonne verso destra e righe verso il basso."""
a = (g[:, -1] - g[:, 0]).mean(axis=0) # direzione dell'indice di colonna
b = (g[-1, :] - g[0, :]).mean(axis=0) # direzione dell'indice di riga
if abs(b[0]) > abs(a[0]): # assi scambiati (scacchiera ruotata di ~90 gradi)
g = g.transpose(1, 0, 2)
a, b = b, a
if a[0] < 0:
g = g[:, ::-1]
if b[1] < 0:
g = g[::-1]
return np.ascontiguousarray(g)
def _passo_mediano(g: np.ndarray) -> float:
"""Distanza mediana (px) fra corner adiacenti."""
passi = np.concatenate([np.diff(g, axis=1).reshape(-1, 2), np.diff(g, axis=0).reshape(-1, 2)])
return float(np.median(np.hypot(passi[:, 0], passi[:, 1])))
def _raffina(img: np.ndarray, pts: np.ndarray, passo: float) -> np.ndarray:
"""cornerSubPix su (n,2) con finestra ~0.3 passo, ristretta per chi sta vicino al bordo.
Una finestra che esce dall'immagine vede il bordo replicato e sposta il corner
di ~1 px (visto sull'ultima colonna di una scacchiera a 14 px dal bordo): per
questi punti si usa la finestra piu' grande che sta dentro; sotto 3 px resta
la posizione grezza.
"""
h, w = img.shape
out = pts.astype(np.float32).copy()
win = int(min(20, max(3, 0.3 * passo)))
dentro = np.minimum.reduce([out[:, 0], w - 1 - out[:, 0], out[:, 1], h - 1 - out[:, 1]])
per_punto = np.minimum(win, np.floor(dentro).astype(int) - 2)
crit = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 40, 0.001)
for wi in np.unique(per_punto[per_punto >= 3]):
sel = per_punto == wi
out[sel] = cv2.cornerSubPix(img, out[sel].reshape(-1, 1, 2).copy(),
(int(wi), int(wi)), (-1, -1), crit).reshape(-1, 2)
return out
def _trova_griglia(frame: np.ndarray) -> np.ndarray:
"""Griglia di angoli (n_righe, n_colonne, 2) x,y, in ordine di riga dall'alto."""
h, w = frame.shape
migliore = None
lisc = cv2.GaussianBlur(frame.astype(np.float32), (0, 0), 1.0)
p5, p95 = np.percentile(frame, (5, 95))
delta = 0.25 * float(p95 - p5) # contrasto minimo fra quadranti opposti
for sigma in _SIGME:
pts = _candidati(frame, sigma)
posto = _cresci_reticolo(pts, (w / 2.0, h / 2.0), lisc, delta)
rett = _rettangolo_pieno(posto) if posto else None
if rett is None:
continue
i0, j0, nr, nc = rett
if nr < 3 or nc < 3:
continue
if migliore is not None and nr * nc < 0.5 * migliore[0] * migliore[1]:
break # oltre il picco: le scale piu' grosse fondono gli angoli
if migliore is None or nr * nc > migliore[0] * migliore[1]:
g = np.array([[pts[posto[(i0 + i, j0 + j)]] for j in range(nc)]
for i in range(nr)])
migliore = (nr, nc, g)
if migliore is None:
raise CalibrazioneError(
"griglia della scacchiera non trovata: serve una scacchiera nitida "
"che riempia il campo")
g = _orienta(migliore[2])
nr, nc, _ = g.shape
passo = _passo_mediano(g)
# sub-pixel: finestra proporzionale al passo, abbastanza piccola da non vedere i vicini
grezzi = g.reshape(-1, 2).astype(np.float32)
raff = _raffina(frame, grezzi, passo)
spostati = np.hypot(*(raff - grezzi).T) > 0.3 * passo
raff[spostati] = grezzi[spostati] # un raffinamento che scappa e' peggio del grezzo
return raff.reshape(nr, nc, 2).astype(np.float64)
def _estrapola(g: np.ndarray, k: int, prima: bool) -> np.ndarray:
"""Aggiunge k colonne (asse 1) per estrapolazione lineare, prima o dopo."""
if k <= 0:
return g
if prima:
passo = g[:, 0] - g[:, 1]
extra = np.stack([g[:, 0] + m * passo for m in range(k, 0, -1)], axis=1)
return np.concatenate([extra, g], axis=1)
passo = g[:, -1] - g[:, -2]
extra = np.stack([g[:, -1] + m * passo for m in range(1, k + 1)], axis=1)
return np.concatenate([g, extra], axis=1)
def _estendi_asse(g: np.ndarray, lungh: int, c: int, passo: float,
grid_step: int) -> tuple[np.ndarray, int]:
"""Estende la griglia (asse 1) fino a coprire l'immagine; ritorna (griglia, k_prima).
`c` e' la coordinata (0 = x, 1 = y) e `lungh` la dimensione dell'immagine su
quell'asse. `from_grid` (crop_domain=False) tiene costante la mappa fuori
dalla griglia: senza anelli in piu' i bordi dell'immagine raddrizzata
sarebbero sbavati.
"""
margine = max(0.0, float(g[:, 0, c].min()))
k_pre = int(math.ceil(margine / passo)) + 1
g = _estrapola(g, k_pre, True)
offset = int(g[0, 0, c]) # lo stesso int() che usa from_grid
# colonne totali perche' l'ultimo nodo, a passo grid_step, superi il bordo
servono = int(math.ceil((lungh - 1 - offset) / grid_step)) + 2
g = _estrapola(g, max(0, servono - g.shape[1]), False)
return g, k_pre
class MappaPronta(NamedTuple):
"""Mappa gia' convertita in virgola fissa per un cv2.remap veloce (#84)."""
map1: np.ndarray
map2: np.ndarray
pixel_size: float
shape: tuple # (altezza, larghezza) dei frame per cui vale
def _prepara_mappa(mappa: DistortionMap) -> MappaPronta:
# CV_16SC2: remap bilineare in virgola fissa, piu' veloce di float32 su 5 MP
m1, m2 = cv2.convertMaps(mappa.map_col, mappa.map_row, cv2.CV_16SC2)
return MappaPronta(m1, m2, float(mappa.pixel_size), mappa.shape)
def raddrizza(frame: np.ndarray, mappa: MappaPronta) -> np.ndarray | None:
"""Frame raddrizzato, o None se le dimensioni non sono quelle della calibrazione."""
if frame.shape[:2] != tuple(mappa.shape):
return None
return cv2.remap(frame, mappa.map1, mappa.map2, cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
def calibra_scacchiera_densa(pixels, square_mm: float) -> tuple[dict, DistortionMap]:
"""Calibra da UNA foto di scacchiera densa (riempie il campo). Input: solo square_mm.
Ritorna (risultato, mappa). Risultato: {px_per_mm, mm_per_px (dell'immagine
RADDRIZZATA), rms_px, residuo_rms_px, residuo_max_px, n_corners, cols, rows,
square_mm, image_width, image_height, scale_spread_pct (0: la mappa uniforma
la scala), copertura_pct, metodo, distorsione, overlay_png (b64)}.
"""
if not (square_mm > 0 and math.isfinite(square_mm)):
raise CalibrazioneError("square_mm deve essere > 0")
frame = _gray_u8(pixels)
h, w = frame.shape
g = _trova_griglia(frame)
nr, nc, _ = g.shape
passo = _passo_mediano(g)
grid_step = int(round(passo))
if grid_step < 2:
raise CalibrazioneError("calibrazione degenere: passo della griglia troppo piccolo")
# anelli in piu' ai bordi (x e poi y), poi la mappa dai punti estesi
e, kl = _estendi_asse(g, w, 0, passo, grid_step)
et, kt = _estendi_asse(e.transpose(1, 0, 2), h, 1, passo, grid_step)
e = et.transpose(1, 0, 2)
try:
mappa = DistortionMap.from_grid(
e[..., 0].ravel(), e[..., 1].ravel(), e.shape[1], e.shape[0],
image_size=(w, h), grid_step=grid_step, crop_domain=False,
grid_step_measure=square_mm)
except ValueError as exc:
raise CalibrazioneError(f"mappa di distorsione non costruibile: {exc}") from exc
mm_per_px = float(mappa.pixel_size)
if not (mm_per_px > 0 and math.isfinite(mm_per_px)):
raise CalibrazioneError("calibrazione degenere: scala non valida")
# qualita': si raddrizza la stessa foto e si rilegge ogni corner nel punto ideale
off_c, off_r = int(e[0, 0, 0]), int(e[0, 0, 1])
ideali = np.array([[(off_c + (kl + j) * grid_step, off_r + (kt + i) * grid_step)
for j in range(nc)] for i in range(nr)], np.float32).reshape(-1, 1, 2)
rett = cv2.remap(frame, mappa.map_col, mappa.map_row, cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
# solo i corner ideali dentro l'immagine raddrizzata (cornerSubPix rifiuta quelli fuori)
xy = ideali.reshape(-1, 2)
dentro = (xy[:, 0] >= 1) & (xy[:, 0] <= w - 2) & (xy[:, 1] >= 1) & (xy[:, 1] <= h - 2)
if int(dentro.sum()) < 4:
raise CalibrazioneError("griglia raddrizzata fuori dall'immagine: calibrazione non verificabile")
ideali = ideali[dentro].reshape(-1, 2)
letti = _raffina(rett, ideali, grid_step)
scarto = (letti - ideali).astype(np.float64)
scarto -= np.median(scarto, axis=0) # int() dell'offset sposta tutto di una traslazione
dist = np.hypot(scarto[:, 0], scarto[:, 1])
residuo_max = float(dist.max())
residuo_rms = float(math.sqrt(float((dist ** 2).mean())))
# copertura: area del quadrilatero dei quattro angoli esterni / area immagine
quad = np.array([g[0, 0], g[0, -1], g[-1, -1], g[-1, 0]], np.float32)
copertura = float(cv2.contourArea(quad)) / float(w * h) * 100.0
# overlay: corner trovati sull'originale (ridotto a max 1600 px: 5 MP in PNG pesano)
f = min(1.0, 1600.0 / w)
bgr = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
pts = g.reshape(-1, 1, 2).astype(np.float32)
if f < 1.0:
bgr = cv2.resize(bgr, None, fx=f, fy=f, interpolation=cv2.INTER_AREA)
pts = pts * np.float32(f)
cv2.drawChessboardCorners(bgr, (nc, nr), pts, True)
ok, png = cv2.imencode(".png", bgr)
if not ok:
raise RuntimeError("PNG dell'overlay non codificabile")
return {
"px_per_mm": 1.0 / mm_per_px,
"mm_per_px": mm_per_px,
"rms_px": residuo_rms,
"residuo_rms_px": residuo_rms,
"residuo_max_px": residuo_max,
"n_corners": int(nr * nc),
"cols": int(nc),
"rows": int(nr),
"square_mm": float(square_mm),
"image_width": int(w),
"image_height": int(h),
"scale_spread_pct": 0.0,
"copertura_pct": copertura,
"metodo": "densa",
"distorsione": True,
"overlay_png": base64.b64encode(png.tobytes()).decode("ascii"),
}, mappa
def ora_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class Archivio:
"""Un JSON per sorgente in `cartella`; nome file dalla sorgente ripulito.
#84: se la calibrazione e' densa, accanto al JSON sta la mappa (stesso nome,
.npz); `leggi_mappa` la tiene in memoria finche' il file non cambia.
"""
def __init__(self, cartella: Path | str):
self.cartella = Path(cartella)
self._cache: dict = {} # percorso .npz -> (mtime_ns, MappaPronta)
self._lock = threading.Lock()
def _file(self, sorgente: str) -> Path:
# caratteri sicuri + hash dell'originale: due sorgenti diverse non si pestano
pulito = re.sub(r"[^A-Za-z0-9._-]+", "_", sorgente).strip("._") or "sorgente"
sigla = hashlib.sha1(sorgente.encode("utf-8")).hexdigest()[:8]
return self.cartella / f"{pulito[:60]}-{sigla}.json"
def _file_mappa(self, sorgente: str) -> Path:
return self._file(sorgente).with_suffix(".npz") # #84 stesso nome del JSON
def leggi(self, sorgente: str) -> dict | None:
try:
return json.loads(self._file(sorgente).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None # assente o illeggibile = non calibrata
def salva(self, sorgente: str, dati: dict, mappa: DistortionMap | None = None) -> None:
destinazione = self._file(sorgente)
self.cartella.mkdir(parents=True, exist_ok=True)
# #84: prima la mappa (o la rimozione di una vecchia), poi il JSON che la dichiara
if mappa is not None:
self._salva_mappa(sorgente, mappa)
else:
self._togli_mappa(sorgente) # ricalibrazione planare: la vecchia mappa non vale piu'
# scrittura atomica: file temporaneo nella stessa cartella, poi replace
fd, tmp = tempfile.mkstemp(dir=self.cartella, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(dati, f, ensure_ascii=False, indent=2)
os.replace(tmp, destinazione)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _salva_mappa(self, sorgente: str, mappa: DistortionMap) -> None:
destinazione = self._file_mappa(sorgente)
fd, tmp = tempfile.mkstemp(dir=self.cartella, suffix=".tmp.npz") # .npz: numpy non aggiunge nulla
os.close(fd)
try:
mappa.save(tmp)
os.replace(tmp, destinazione) # atomica come il JSON
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
with self._lock:
self._cache.pop(str(destinazione), None)
def _togli_mappa(self, sorgente: str) -> None:
destinazione = self._file_mappa(sorgente)
try:
destinazione.unlink()
except FileNotFoundError:
pass
with self._lock:
self._cache.pop(str(destinazione), None)
def leggi_mappa(self, sorgente: str) -> MappaPronta | None:
"""La mappa salvata, pronta per `raddrizza`; None se la sorgente non ne ha una."""
percorso = self._file_mappa(sorgente)
chiave = str(percorso)
try:
mtime = percorso.stat().st_mtime_ns
except OSError:
with self._lock:
self._cache.pop(chiave, None)
return None
with self._lock:
voce = self._cache.get(chiave)
if voce is not None and voce[0] == mtime:
return voce[1]
try:
pronta = _prepara_mappa(DistortionMap.load(percorso))
except (OSError, ValueError, KeyError):
return None # illeggibile = nessuna correzione
with self._lock:
self._cache[chiave] = (mtime, pronta)
return pronta
def elimina(self, sorgente: str) -> None:
try:
self._file(sorgente).unlink()
except FileNotFoundError:
pass
self._togli_mappa(sorgente) # #84 toglie anche la mappa
File diff suppressed because it is too large Load Diff
+431
View File
@@ -0,0 +1,431 @@
"""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 TieVision-Studio, src/visionsuite/gui/server.py):
GET /camera/sources -> {"sources": [{name, kind, label}]}
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000,
grezzo=false} -> PNG
#84: se la sorgente ha una mappa di distorsione salvata il frame esce
raddrizzato (header X-Raddrizzato: si|no); "grezzo": true lo dà com'è.
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py):
GET /camera/calibration?source=<src> -> 200 {source, px_per_mm, mm_per_px,
rms_px, n_corners, cols, rows, square_mm, image_width, image_height,
scale_spread_pct, calibrated_at} | 404 "sorgente non calibrata"
POST /camera/calibrate multipart: source, square_mm, image (file, opzionale:
senza, grab GREZZO dalla sorgente), save=false, cols/rows (opzionali).
#84: SENZA cols/rows = calibrazione densa (default): una foto di
scacchiera che riempie il campo, griglia trovata da sola, mappa di
distorsione salvata accanto al JSON (.npz); mm/px e px/mm sono quelli
dell'immagine RADDRIZZATA. CON cols/rows (angoli interni) = metodo
planare di prima. Campi: quelli di C1 + overlay_png (b64) + saved +
metodo ("densa"|"planare"), distorsione (bool) e, per la densa,
residuo_rms_px, residuo_max_px, copertura_pct;
422 scacchiera non trovata o parametri invalidi
DELETE /camera/calibration?source=<src> -> 204 (toglie JSON e mappa)
GET /calibrazione -> pagina HTML autonoma (offline) per fare tutto a mano
GET /acquisizione -> stessa pagina (#87)
Acquisizione (#87): anteprima, esposizione/guadagno, settaggi di default:
POST /camera/anteprima json {source, grezzo=false, lato_max=1280 (320..4096),
timeout_ms=5000} -> JPEG ridotto (lato lungo <= lato_max, mai ingrandito).
Header: X-Raddrizzato si|no, X-Larghezza/X-Altezza (frame PIENO),
X-Nitidezza (varianza del Laplaciano), X-Saturati-Pct (% pixel >= 250),
X-Media (livello medio 0-255), tutti sul frame pieno in grigio.
GET /camera/parametri?source=<src> -> {source, regolabile, parametri:
{esposizione, esposizione_auto, guadagno, guadagno_auto: dominio}};
dominio = GenicamSource.dominio (valore, tipo, min/max/passo o scelte).
Esposizione in us, guadagno in dB. Sorgente senza capacita': regolabile=false.
PUT /camera/parametri json {source, valori: {nome: valore}} -> come GET.
Scrive prima gli *_auto, poi esposizione/guadagno (assesta=3).
400 nome ignoto o corpo errato, 409 non regolabile, 422 camera rifiuta
POST /camera/parametri/salva json {source} -> {source, salvati, file}
scrive <config-dir>/settaggi/<nome>-<sha1>.json; alla prima apertura
della camera nel servizio il file, se c'e', viene ricaricato.
Il risultato salvato sta in un JSON per sorgente in --config-dir (default
%ProgramData%/Tielogic/TieMeasureFlow/acquisitore su Windows, ./acquisitore altrove).
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 <cartella immagini>] \
[--config-dir <cartella calibrazioni>]
--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, python-multipart (rotta /camera/calibrate),
visionsuite.calibration (vs-core).
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 logging
import os
import sys
import threading
from contextlib import asynccontextmanager
from pathlib import Path
import cv2
import numpy as np
from fastapi import Body, FastAPI, File, Form, HTTPException, UploadFile
from fastapi.concurrency import run_in_threadpool # #83: il grab bloccante fuori dall'event loop
from fastapi.responses import HTMLResponse, Response
from visionsuite.camera.camera_interface import CameraError, CameraGrabError, CameraStateError
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
# #83: calibrazione e pagina stanno accanto a questo file (che non e' un pacchetto)
sys.path.insert(0, str(Path(__file__).resolve().parent))
import calibrazione # noqa: E402
from calibrazione_pagina import PAGINA # noqa: E402
_TRIGGER = ("continuo",)
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
# #87: i quattro parametri regolabili, nell'ordine di presentazione
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
# #87: in scrittura prima gli automatismi (un valore manuale con auto acceso viene riscritto)
_ORDINE_SCRITTURA = ("esposizione_auto", "guadagno_auto", "esposizione", "guadagno")
# 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 _config_dir_default() -> Path:
"""#83: cartella delle calibrazioni salvate."""
if os.name == "nt":
base = os.environ.get("ProgramData") or "C:/ProgramData"
return Path(base) / "Tielogic" / "TieMeasureFlow" / "acquisitore"
return Path("acquisitore")
def create_app(folder: Path | str | None = None,
config_dir: Path | str | None = None) -> FastAPI:
archivio = calibrazione.Archivio(config_dir if config_dir is not None
else _config_dir_default()) # #83
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")]}
def _apri(nome: str):
"""#87: sorgente aperta (o da aprire); da chiamare SOLO sotto _lock_di(nome)."""
with lock:
sorgente = aperte.get(nome)
if sorgente is None:
sorgente = open_source(nome, None, config=_config())
# TieVision-Core#341 / #89: free-run, serve la scena di adesso, non il frame vecchio in coda
if hasattr(sorgente, "solo_ultimo_frame"):
try:
sorgente.solo_ultimo_frame()
except Exception as e: # noqa: BLE001 - producer senza il nodo: si apre lo stesso
_log.warning("frame piu' recente non impostabile su %s: %s", nome, e)
_carica_settaggi(nome, sorgente) # #87: predefiniti salvati, se ci sono
with lock:
aperte[nome] = sorgente
return sorgente
def _file_settaggi(nome: str) -> Path:
# #87: stesso schema di nome della calibrazione, in una sottocartella
return archivio.cartella / "settaggi" / archivio._file(nome).name
def _carica_settaggi(nome: str, sorgente) -> None:
percorso = _file_settaggi(nome)
if not percorso.is_file() or not hasattr(sorgente, "carica_settaggi"):
return
try:
sorgente.carica_settaggi(percorso)
except Exception as e: # noqa: BLE001 - un file rotto non blocca l'apertura
_log.warning("settaggi di %s non caricati (%s): %s", nome, percorso, e)
def _con_sorgente(nome: str, fn):
"""#87: fn(sorgente) sotto il lock della sorgente; errori camera -> HTTP."""
try:
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
return fn(_apri(nome))
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)))
def _grab(nome: str, timeout: int):
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
return _con_sorgente(nome, lambda s: s.grab(timeout)) # #87: corpo in _con_sorgente
@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")
grezzo = corpo.get("grezzo", False) # #84: true = frame non raddrizzato
if not isinstance(grezzo, bool):
raise HTTPException(400, "«grezzo» deve essere true o false")
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")
frame = _grab(nome, timeout) # #83: corpo estratto in _grab, invariato
raddrizzato = False
if not grezzo: # #84: sorgente calibrata (densa) -> frame raddrizzato dalla mappa in cache
mappa = archivio.leggi_mappa(nome)
if mappa is not None:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is not None: # None = dimensioni diverse da quelle calibrate
frame, raddrizzato = corretto, True
ok, buf = cv2.imencode(".png", frame)
if not ok:
raise HTTPException(500, "codifica PNG fallita")
return Response(buf.tobytes(), media_type="image/png",
headers={"X-Raddrizzato": "si" if raddrizzato else "no"})
# ---- #83: calibrazione a scacchiera per sorgente
def _sorgente(valore) -> str:
if not isinstance(valore, str) or not valore.strip():
raise HTTPException(400, "manca «source»")
return valore.strip()
@app.get("/camera/calibration")
def calibrazione_leggi(source: str = ""):
dati = archivio.leggi(_sorgente(source))
if dati is None:
raise HTTPException(404, "sorgente non calibrata")
return dati
@app.delete("/camera/calibration", status_code=204)
def calibrazione_elimina(source: str = ""):
archivio.elimina(_sorgente(source))
return Response(status_code=204)
@app.post("/camera/calibrate")
async def calibrazione_calcola(
source: str = Form(...),
square_mm: float = Form(...),
cols: int | None = Form(None), # #84: opzionali, senza = calibrazione densa
rows: int | None = Form(None),
image: UploadFile | None = File(None),
save: bool = Form(False),
):
nome = _sorgente(source)
contenuto = await image.read() if image is not None else b""
if contenuto:
frame = cv2.imdecode(np.frombuffer(contenuto, np.uint8), cv2.IMREAD_UNCHANGED)
if frame is None:
raise HTTPException(422, "l'immagine caricata non è leggibile")
else:
frame = await run_in_threadpool(_grab, nome, 5000) # come /camera/grab (404/503/504)
if (cols is None) != (rows is None):
raise HTTPException(422, "cols e rows vanno indicati insieme (o nessuno dei due)")
mappa = None
try:
if cols is None: # #84: densa, nessuna griglia da dichiarare
risultato, mappa = await run_in_threadpool(
calibrazione.calibra_scacchiera_densa, frame, square_mm)
else: # metodo planare di #83, invariato
risultato = calibrazione.calibra_scacchiera(frame, cols, rows, square_mm)
except calibrazione.CalibrazioneError as e:
raise HTTPException(422, str(e))
corpo = {"source": nome,
**{k: v for k, v in risultato.items() if k != "overlay_png"},
"calibrated_at": calibrazione.ora_iso()}
if save:
archivio.salva(nome, corpo, mappa) # #84: la mappa (se densa) va in .npz accanto
return {**corpo, "overlay_png": risultato["overlay_png"], "saved": bool(save)}
# ---- #87: acquisizione (anteprima, parametri, settaggi)
def _regolabile(s) -> bool:
# #87: ha il vocabolario delle capacita' e ne supporta almeno una delle quattro
if not (hasattr(s, "supporta") and hasattr(s, "dominio")):
return False
return any(s.supporta(n) for n in _PARAMETRI)
def _parametri(nome: str, s) -> dict:
"""#87: forma di GET /camera/parametri; da chiamare sotto lock."""
parametri: dict = {}
if _regolabile(s):
for n in _PARAMETRI:
if not s.supporta(n):
continue
try:
parametri[n] = s.dominio(n)
except CameraStateError as e: # capacita' dichiarata ma non leggibile: si salta
_log.warning("dominio di %s su %s non letto: %s", n, nome, e)
return {"source": nome, "regolabile": bool(parametri), "parametri": parametri}
def _grigio8(frame):
g = frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if g.dtype == np.uint16: # #87: 16 bit -> 8 bit, per soglia e media coerenti
g = (g >> 8).astype(np.uint8)
elif g.dtype != np.uint8:
g = cv2.convertScaleAbs(g, alpha=255.0 / max(float(g.max()), 1.0))
return g
@app.post("/camera/anteprima")
def camera_anteprima(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
grezzo = corpo.get("grezzo", False)
if not isinstance(grezzo, bool):
raise HTTPException(400, "«grezzo» deve essere true o false")
lato = corpo.get("lato_max", 1280)
if isinstance(lato, bool) or not isinstance(lato, int) or not 320 <= lato <= 4096:
raise HTTPException(400, "«lato_max» deve essere un intero fra 320 e 4096")
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")
frame = _grab(nome, timeout)
raddrizzato = False
if not grezzo: # come /camera/grab
mappa = archivio.leggi_mappa(nome)
if mappa is not None:
corretto = calibrazione.raddrizza(frame, mappa)
if corretto is not None:
frame, raddrizzato = corretto, True
alt, larg = frame.shape[:2]
grigio = _grigio8(frame) # misure sul frame PIENO, non sul ridotto
nitidezza = float(cv2.Laplacian(grigio, cv2.CV_64F).var())
saturati = float((grigio >= 250).mean() * 100.0)
media = float(grigio.mean())
if frame.dtype != np.uint8: # il JPEG vuole 8 bit
frame = grigio
scala = lato / max(alt, larg)
if scala < 1.0: # mai ingrandita
frame = cv2.resize(frame, (max(1, round(larg * scala)), max(1, round(alt * scala))),
interpolation=cv2.INTER_AREA)
ok, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
if not ok:
raise HTTPException(500, "codifica JPEG fallita")
return Response(buf.tobytes(), media_type="image/jpeg", headers={
"X-Raddrizzato": "si" if raddrizzato else "no",
"X-Larghezza": str(larg), "X-Altezza": str(alt),
"X-Nitidezza": f"{nitidezza:.1f}", "X-Saturati-Pct": f"{saturati:.2f}",
"X-Media": f"{media:.1f}"})
@app.get("/camera/parametri")
def parametri_leggi(source: str = ""):
nome = _sorgente(source)
return _con_sorgente(nome, lambda s: _parametri(nome, s))
@app.put("/camera/parametri")
def parametri_scrivi(corpo: dict = Body(default=None)):
corpo = corpo if isinstance(corpo, dict) else {}
nome = _sorgente(corpo.get("source"))
valori = corpo.get("valori")
if not isinstance(valori, dict) or not valori:
raise HTTPException(400, "manca «valori»")
for n, v in valori.items():
if n not in _PARAMETRI:
raise HTTPException(400, f"parametro non riconosciuto: «{n}»")
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
raise HTTPException(400, f"valore non valido per «{n}»")
def scrivi(s):
if not _regolabile(s):
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
for n in _ORDINE_SCRITTURA: # prima gli *_auto
if n in valori:
try:
s.scrivi(n, valori[n], assesta=3)
except CameraStateError as e:
raise HTTPException(422, _traduci(str(e)))
return _parametri(nome, s) # valori riletti dalla camera
return _con_sorgente(nome, scrivi)
@app.post("/camera/parametri/salva")
def parametri_salva(corpo: dict = Body(default=None)):
nome = _sorgente(corpo.get("source") if isinstance(corpo, dict) else None)
def salva(s):
if not _regolabile(s) or not hasattr(s, "salva_settaggi"):
raise HTTPException(409, "questa sorgente non ha parametri regolabili")
percorso = _file_settaggi(nome)
percorso.parent.mkdir(parents=True, exist_ok=True)
s.salva_settaggi(percorso)
return {"source": nome, "salvati": True, "file": percorso.name}
return _con_sorgente(nome, salva)
@app.get("/calibrazione", response_class=HTMLResponse)
@app.get("/acquisizione", response_class=HTMLResponse) # #87: stessa pagina
def calibrazione_pagina():
return HTMLResponse(PAGINA)
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")
p.add_argument("--config-dir", default=None,
help="cartella delle calibrazioni salvate (default: ProgramData su Windows)") # #83
a = p.parse_args()
uvicorn.run(create_app(a.folder, a.config_dir), host=a.host, port=a.port)
if __name__ == "__main__":
main()
+458
View File
@@ -0,0 +1,458 @@
"""Seed per il PC della fiera: utenti, stazione, tre ricette e quattro settimane di misure.
Cosa fa
Popola un database TieMeasureFlow (SQLite del pacchetto Windows) cosi' che al primo
avvio NON compaia la procedura di setup e la demo abbia gia' qualcosa da mostrare:
- utenti adriano (admin), maker, capoturno, operatore (password nella costante
UTENTI qui sotto, decise da Adriano per la fiera);
- la stazione ST-DEFAULT (quella del pacchetto, vedi packaging/windows/avvio.py)
con camera_url http://127.0.0.1:8765 e camera_source camera:601020 (Itala);
- FIERA-FLANGIA «Flangia Ø80» (5 quote) e FIERA-ALBERO «Albero rettificato»
(4 quote), con circa 4 settimane di storico fino a ieri (lun-ven 8-17, una
produzione chiusa per giorno, lotti e seriali coerenti, misure con Cpk diversi
per quota, una deriva lenta nell'ultima settimana, una quota con 1-3% fuori
tolleranza);
- FIERA-DXF «Profilo DXF (visione)»: un task dxf_compare senza ricetta di visione
(si crea in fiera dal pannello «Crea ricetta da DXF»).
«Setup fatto» per il backend vuol dire: esiste almeno un utente admin
(api/routers/setup.py::_admin_exists), quindi basta creare adriano.
Come si usa (da daitarn, TMF fermo sul PC, su una COPIA del file SQLite)
uv run python scripts/seed_fiera.py --db C:/percorso/tmf.db [--replace] [--dry-run]
[--fine AAAA-MM-GG]
Il file deve avere gia' lo schema (alembic upgrade head: lo fa l'exe al primo
avvio). --replace toglie solo i dati FIERA-* (ricette, misure, produzioni) e li
rifa'; senza, un secondo giro non duplica le ricette. Gli utenti e la stazione
sono idempotenti: se esistono si aggiornano password, ruoli e camera.
--dry-run fa tutto e annulla (stampa i numeri). Il seme casuale e' fisso: stesso
comando e stessa data di fine, stessi dati.
Da cosa dipende
src.backend (modelli ORM, auth_service, measurement_service), SQLAlchemy +
aiosqlite, tzdata su Windows (gia' in pyproject). Non usa MySQL.
"""
import argparse
import asyncio
import random
import sys
from datetime import date, datetime, time, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from sqlalchemy import delete, event, select
from sqlalchemy.ext.asyncio import (
AsyncSession, async_sessionmaker, create_async_engine,
)
# La radice del repo nel path, cosi' "src.backend" si importa lanciando lo script da ovunque
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.backend.database import async_session_factory # noqa: E402
from src.backend.models.orm.measurement import Measurement # noqa: E402
from src.backend.models.orm.production import ( # noqa: E402
ProductionEvent, ProductionRun,
)
from src.backend.models.orm.recipe import Recipe, RecipeVersion # noqa: E402
from src.backend.models.orm.setting import RecipeVersionAudit # noqa: E402
from src.backend.models.orm.station import ( # noqa: E402
Station, StationRecipeAssignment,
)
from src.backend.models.orm.task import RecipeSubtask, RecipeTask # noqa: E402
from src.backend.models.orm.task_result import TaskResult # noqa: E402
from src.backend.models.orm.user import User # noqa: E402
from src.backend.services import auth_service # noqa: E402
from src.backend.services.measurement_service import calculate_pass_fail # noqa: E402
CODES = ("FIERA-FLANGIA", "FIERA-ALBERO", "FIERA-DXF")
STATION_CODE = "ST-DEFAULT" # STATION_CODE del pacchetto Windows (avvio.py PREDEFINITI)
CAMERA_URL = "http://127.0.0.1:8765"
CAMERA_SOURCE = "camera:601020" # Itala sul PC fiera
SEED = 20261026 # seme fisso: dati ripetibili
FUSO = ZoneInfo("Europe/Rome") # le misure sono salvate in UTC naive, l'orario di turno e' locale
GIORNI_STORICO = 28 # circa 4 settimane
# (username, password, nome, ruoli, admin). Password "123" per tutti, chiesta da Adriano per la fiera (#85); il login non controlla la lunghezza.
UTENTI = [
("adriano", "123", "Adriano Dal Pastro",
["Maker", "MeasurementTec", "Metrologist", "Supervisor"], True),
("maker", "123", "Marco Ferrari (Maker)", ["Maker"], False),
("capoturno", "123", "Luca Bianchi (Capoturno)",
["Supervisor", "MeasurementTec"], False),
("operatore", "123", "Giulia Rossi (Operatore)", ["MeasurementTec"], False),
]
def _quota(marker, descrizione, nominale, tol, unita="mm", dec=3):
"""Quota simmetrica: tolleranza = nominale +/- tol, warning al 70% della tolleranza."""
return dict(
marker_number=marker, description=descrizione, nominal=nominale,
ltl=round(nominale - tol, dec), lwl=round(nominale - 0.7 * tol, dec),
uwl=round(nominale + 0.7 * tol, dec), utl=round(nominale + tol, dec),
unit=unita,
)
# Profilo statistico di una quota: scostamento medio (in frazioni di tol), sigma (in
# frazioni di tol) e deriva (frazione di tol aggiunta linearmente nell'ultima settimana).
# Cpk ~ (1 - |scostamento|) / (3 * sigma): sigma 0.2 -> 1,67; 0.33 -> 1,0.
FLANGIA = [
# (task, quota, scostamento, sigma, deriva)
(0, _quota(1, "Diametro esterno Ø80", 80.0, 0.05), 0.0, 0.20, 0.0), # Cpk ~1,67 capace
(0, _quota(2, "Diametro foro centrale Ø25", 25.0, 0.02), 0.0, 0.33, 0.0), # Cpk ~1,0 al limite
(1, _quota(3, "Spessore flangia", 12.0, 0.10), 0.0, 0.22, 0.55), # deriva ultima settimana
(1, _quota(4, "Interasse fori Ø60", 60.0, 0.10), 0.10, 0.25, 0.0), # Cpk ~1,2
(1, _quota(5, "Profondita' lamatura", 8.0, 0.15), 0.50, 0.244, 0.0), # ~2% fuori tolleranza
]
ALBERO = [
(0, _quota(1, "Diametro cuscinetto Ø20 h6", 20.0, 0.010), 0.0, 0.20, 0.0), # Cpk ~1,67
(0, _quota(2, "Diametro sede Ø25 j5", 25.0, 0.008), 0.0, 0.25, 0.0), # Cpk ~1,33
(1, _quota(3, "Lunghezza totale", 150.0, 0.10), 0.0, 0.33, 0.0), # Cpk ~1,0
(1, _quota(4, "Rugosita' Ra", 0.40, 0.20, unita="µm"), 0.20, 0.25, 0.0), # Cpk ~1,07
]
def _ora_utc(giorno: date, minuti_da_mezzanotte: int) -> datetime:
"""Ora locale di officina -> datetime UTC naive, come le salva il backend."""
locale = datetime.combine(giorno, time(0, 0), tzinfo=FUSO) + timedelta(minutes=minuti_da_mezzanotte)
return locale.astimezone(timezone.utc).replace(tzinfo=None)
def _giorni_lavorativi(fine: date) -> list[date]:
"""Lun-ven degli ultimi GIORNI_STORICO giorni, fino a `fine` compreso."""
giorni = [fine - timedelta(days=i) for i in range(GIORNI_STORICO)]
return sorted(g for g in giorni if g.weekday() < 5)
def _slot_giornalieri(rng: random.Random, n: int) -> list[int]:
"""n orari (minuti dalla mezzanotte) in 8-12 e 13-17, ogni ~30 min con un po' di scarto."""
mezz_ore = [8 * 60 + 30 * i for i in range(8)] + [13 * 60 + 30 * i for i in range(8)]
scelti = sorted(rng.sample(mezz_ore, n))
return [m + rng.randint(0, 9) for m in scelti]
async def _upsert_utenti(session: AsyncSession) -> dict[str, User]:
"""Crea gli utenti o, se esistono, ne aggiorna password, nome, ruoli e flag admin."""
utenti = {}
for username, password, nome, ruoli, admin in UTENTI:
esistente = (await session.execute(
select(User).where(User.username == username)
)).scalar_one_or_none()
if esistente is None:
utenti[username] = await auth_service.create_user(
session, username=username, password=password, display_name=nome,
roles=ruoli, is_admin=admin,
)
else:
esistente.password_hash = auth_service.hash_password(password)
esistente.display_name = nome
esistente.roles = ruoli
esistente.is_admin = admin
esistente.active = True
utenti[username] = esistente
await session.flush()
return utenti
async def _upsert_stazione(session: AsyncSession, owner: User) -> Station:
"""La stazione del pacchetto, con la camera Itala; se c'e' gia' ne aggiorna la camera."""
stazione = (await session.execute(
select(Station).where(Station.code == STATION_CODE)
)).scalar_one_or_none()
if stazione is None:
stazione = Station(
code=STATION_CODE, name="Postazione fiera", location="Stand",
created_by=owner.id,
)
session.add(stazione)
stazione.camera_url = CAMERA_URL
stazione.camera_source = CAMERA_SOURCE
await session.flush()
return stazione
async def _conta_e_cancella(session: AsyncSession, ricette: list[Recipe]) -> dict:
"""Toglie le ricette FIERA-* e tutto cio' che e' misurato contro di esse. Ritorna i conteggi."""
conti = {"misure": 0, "produzioni": 0, "eventi": 0}
for ricetta in ricette:
versioni = (await session.execute(
select(RecipeVersion.id).where(RecipeVersion.recipe_id == ricetta.id)
)).scalars().all()
runs = (await session.execute(
select(ProductionRun.id).where(ProductionRun.recipe_id == ricetta.id)
)).scalars().all()
if versioni:
conti["misure"] += (await session.execute(
delete(Measurement).where(Measurement.version_id.in_(versioni))
)).rowcount
await session.execute( # esiti dei task di queste versioni (FK sulle versioni)
delete(TaskResult).where(TaskResult.version_id.in_(versioni))
)
if runs:
conti["eventi"] += (await session.execute(
delete(ProductionEvent).where(ProductionEvent.run_id.in_(runs))
)).rowcount
conti["produzioni"] += (await session.execute(
delete(ProductionRun).where(ProductionRun.id.in_(runs))
)).rowcount
await session.execute(
delete(StationRecipeAssignment).where(StationRecipeAssignment.recipe_id == ricetta.id)
)
await session.execute(
delete(RecipeVersionAudit).where(RecipeVersionAudit.recipe_id == ricetta.id)
)
# Versioni, task e quote se ne vanno con la cascade ORM della ricetta.
await session.delete(ricetta)
await session.flush()
return conti
async def _nuova_ricetta(session, owner, stazione, *, code, name, description,
interval, requires_serial, note_versione):
"""Ricetta + versione 1 corrente + assegnazione alla stazione."""
ricetta = Recipe(
code=code, name=name, description=description, created_by=owner.id, active=True,
measurement_interval_minutes=interval, requires_lot=True,
requires_serial=requires_serial, allow_manual_input=True,
)
session.add(ricetta)
await session.flush()
versione = RecipeVersion(
recipe_id=ricetta.id, version_number=1, is_current=True,
created_by=owner.id, change_notes=note_versione,
)
session.add(versione)
session.add(StationRecipeAssignment(
station_id=stazione.id, recipe_id=ricetta.id, assigned_by=owner.id,
))
await session.flush()
return ricetta, versione
async def _task(session, versione, ordine, titolo, tipo, direttiva=None,
descrizione=None, quote=()):
task = RecipeTask(
version_id=versione.id, order_index=ordine, title=titolo, task_type=tipo,
directive=direttiva, description=descrizione,
)
session.add(task)
await session.flush()
sottotask = []
for q in quote:
st = RecipeSubtask(task_id=task.id, **q)
session.add(st)
sottotask.append(st)
await session.flush()
return task, sottotask
async def _ricetta_meccanica(session, owner, stazione, *, code, name, description,
interval, requires_serial, profilo, titoli_task):
"""Ricetta con una nota iniziale e un task di misura per ogni gruppo di quote del profilo."""
ricetta, versione = await _nuova_ricetta(
session, owner, stazione, code=code, name=name, description=description,
interval=interval, requires_serial=requires_serial,
note_versione="Ricetta dimostrativa per la fiera",
)
await _task(session, versione, 0, "Preparazione del pezzo", "note",
direttiva="Prima di misurare",
descrizione="Pulire il pezzo e controllare che sia a **temperatura ambiente**.")
sottotask_per_quota = {}
for indice, titolo in enumerate(titoli_task):
quote = [q for (t, q, *_r) in profilo if t == indice]
_t, sottotask = await _task(
session, versione, indice + 1, titolo, "measure",
direttiva="Misurare le quote in sequenza", quote=quote,
)
for st in sottotask:
sottotask_per_quota[st.marker_number] = st
return ricetta, versione, sottotask_per_quota
def _valore(rng, quota, scost, sigma, deriva, frazione_tempo):
"""Un valore: normale attorno a nominale + scostamento (+ deriva nell'ultima settimana)."""
tol = quota["utl"] - quota["nominal"]
media = quota["nominal"] + tol * (scost + deriva * max(0.0, (frazione_tempo - 0.75) / 0.25))
return round(rng.gauss(media, tol * sigma), 6)
async def _storico(session, rng, fine, owner_ricetta, stazione, utenti, ricetta, versione,
sottotask, profilo, pezzi_giorno, prefisso_lotto, prefisso_seriale):
"""Una produzione chiusa per giorno lavorativo, con i suoi pezzi, eventi e misure."""
giorni = _giorni_lavorativi(fine)
operatore, capoturno = utenti["operatore"], utenti["capoturno"]
task_ids = {} # marker -> id del task di misura
for marker, st in sottotask.items():
task_ids[marker] = st.task_id
ordine_task = sorted(set(task_ids.values()))
ultimo_task = ordine_task[-1]
contatore = 0
totale = 0
for indice_giorno, giorno in enumerate(giorni):
frazione = indice_giorno / max(1, len(giorni) - 1) # 0 = primo giorno, 1 = ieri
n = rng.randint(*pezzi_giorno)
slot = _slot_giornalieri(rng, n)
lotto = f"{prefisso_lotto}{giorno:%y%m%d}"
inizio, fine_run = _ora_utc(giorno, slot[0] - 5), _ora_utc(giorno, slot[-1] + 10)
run = ProductionRun(
station_id=stazione.id, recipe_id=ricetta.id, version_id=versione.id,
operator_id=operatore.id, lot_number=lotto, status="closed",
measurement_interval_minutes=ricetta.measurement_interval_minutes,
cycle_count=n, started_at=inizio, closed_at=fine_run, closed_by=capoturno.id,
active_station_id=None,
)
session.add(run)
await session.flush()
session.add(ProductionEvent(run_id=run.id, event_type="start", user_id=operatore.id,
created_at=inizio))
for numero, minuti in enumerate(slot, start=1):
contatore += 1
seriale = f"{prefisso_seriale}{contatore:05d}"
istante = _ora_utc(giorno, minuti)
for marker in sorted(sottotask):
st = sottotask[marker]
_t, quota, scost, sigma, deriva = next(p for p in profilo if p[1]["marker_number"] == marker)
valore = _valore(rng, quota, scost, sigma, deriva, frazione)
esito, scarto = calculate_pass_fail(valore, st)
m = Measurement(
subtask_id=st.id, version_id=versione.id, measured_by=operatore.id,
value=valore, pass_fail=esito, deviation=scarto, lot_number=lotto,
serial_number=seriale,
input_method="usb_caliper" if rng.random() < 0.75 else "manual",
input_duration_ms=rng.randint(2500, 9000),
production_run_id=run.id,
# un fuori tolleranza passa solo con l'ok del capoturno (come in produzione)
supervisor_id=capoturno.id if esito == "fail" else None,
authorised_at=istante + timedelta(minutes=1) if esito == "fail" else None,
measured_at=istante + timedelta(seconds=20 * marker),
)
session.add(m)
totale += 1
# un task_measured per ogni task di misura non finale, poi il ciclo completato
for tid in ordine_task:
tipo = "cycle_completed" if tid == ultimo_task else "task_measured"
session.add(ProductionEvent(
run_id=run.id, event_type=tipo, user_id=operatore.id, task_id=tid,
created_at=istante + timedelta(minutes=1),
))
session.add(ProductionEvent(run_id=run.id, event_type="close", user_id=capoturno.id,
supervisor_id=capoturno.id, created_at=fine_run))
await session.flush()
return contatore, totale
async def _ricetta_dxf(session, owner, stazione):
"""Demo visione: un task dxf_compare, senza grafo (si crea in fiera dal pannello DXF)."""
ricetta, versione = await _nuova_ricetta(
session, owner, stazione, code="FIERA-DXF", name="Profilo DXF (visione)",
description="Confronto di un profilo con il disegno DXF tramite la camera. "
"La ricetta di visione si crea in fiera dal pannello «Crea ricetta da DXF».",
interval=None, requires_serial=False, note_versione="Ricetta dimostrativa per la fiera",
)
await _task(session, versione, 0, "Preparazione del pezzo", "note",
direttiva="Prima di misurare",
descrizione="Appoggiare il pezzo sul piano della camera, **in piano**.")
await _task(
session, versione, 1, "Confronto profilo con DXF", "dxf_compare",
direttiva="Acquisire e confrontare",
descrizione="Lo scostamento massimo del profilo rispetto al DXF deve restare entro la tolleranza.",
quote=[_quota(1, "Scostamento massimo profilo", 0.0, 0.15)],
)
return ricetta
async def seed(replace: bool, dry_run: bool, fine: date) -> int:
"""Esegue il seed in una sola transazione; con dry_run annulla tutto alla fine."""
rng = random.Random(SEED)
async with async_session_factory() as session:
utenti = await _upsert_utenti(session)
owner = utenti["adriano"]
stazione = await _upsert_stazione(session, owner)
esistenti = list((await session.execute(
select(Recipe).where(Recipe.code.in_(CODES))
)).scalars().all())
if esistenti and not replace:
print(f"Ricette FIERA gia' presenti ({', '.join(r.code for r in esistenti)}): "
"non duplico. Usa --replace per rifarle.")
else:
if esistenti:
conti = await _conta_e_cancella(session, esistenti)
print(f"--replace: rimosse {', '.join(r.code for r in esistenti)} "
f"({conti['misure']} misure, {conti['produzioni']} produzioni, "
f"{conti['eventi']} eventi)")
r1, v1, sub1 = await _ricetta_meccanica(
session, owner, stazione, code="FIERA-FLANGIA", name="Flangia Ø80",
description="Flangia tornita in alluminio: diametri, spessore, interasse fori "
"e profondita' della lamatura. Lotto e seriale obbligatori.",
interval=30, requires_serial=True, profilo=FLANGIA,
titoli_task=["Misure diametrali", "Spessore e posizione fori"],
)
n1, m1 = await _storico(session, rng, fine, owner, stazione, utenti, r1, v1, sub1,
FLANGIA, (10, 14), "LF", "FL")
r2, v2, sub2 = await _ricetta_meccanica(
session, owner, stazione, code="FIERA-ALBERO", name="Albero rettificato",
description="Albero in acciaio rettificato: sedi dei cuscinetti, lunghezza e "
"rugosita'. Tolleranze strette.",
interval=30, requires_serial=False, profilo=ALBERO,
titoli_task=["Diametri rettificati", "Lunghezza e rugosita'"],
)
n2, m2 = await _storico(session, rng, fine, owner, stazione, utenti, r2, v2, sub2,
ALBERO, (8, 11), "LA", "AL")
await _ricetta_dxf(session, owner, stazione)
print(f"FIERA-FLANGIA: {n1} pezzi, {m1} misure; FIERA-ALBERO: {n2} pezzi, {m2} misure")
if dry_run:
await session.rollback()
print("--dry-run: nessuna modifica salvata.")
else:
await session.commit()
print(f"Seed fiera completato (stazione {stazione.code}, utenti: "
f"{', '.join(u[0] for u in UTENTI)}).")
return 0
def _factory_per_file(db: Path):
"""Sessioni async su un file SQLite qualsiasi, con le stesse PRAGMA dell'app (FK attive)."""
engine = create_async_engine(f"sqlite+aiosqlite:///{db.resolve().as_posix()}", echo=False)
@event.listens_for(engine.sync_engine, "connect")
def _pragma(dbapi_connection, _record) -> None:
cur = dbapi_connection.cursor()
cur.execute("PRAGMA foreign_keys=ON")
cur.execute("PRAGMA busy_timeout=5000")
cur.close()
return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def _main_async(args) -> int:
global async_session_factory
engine, async_session_factory = _factory_per_file(args.db)
try:
fine = date.fromisoformat(args.fine) if args.fine else date.today() - timedelta(days=1)
return await seed(args.replace, args.dry_run, fine)
finally:
await engine.dispose()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--db", required=True, type=Path, help="file SQLite con lo schema gia' creato")
parser.add_argument("--replace", action="store_true",
help="rimuove i dati FIERA-* (ricette, misure, produzioni) e li rifa'")
parser.add_argument("--dry-run", action="store_true", help="esegue tutto e annulla")
parser.add_argument("--fine", default=None, help="ultimo giorno dello storico AAAA-MM-GG (default: ieri)")
args = parser.parse_args()
if not args.db.is_file():
print(f"File non trovato: {args.db}")
return 2
return asyncio.run(_main_async(args))
if __name__ == "__main__":
raise SystemExit(main())
+376
View File
@@ -0,0 +1,376 @@
"""Test della calibrazione dell'acquisitore (#83): logica pura e rotte di camera_service.
Uso: dalla radice, `uv run --python 3.13 --extra vision-worker --extra dev
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q`
(o il venv di VisionSuite come per test_camera_service.py).
"""
import base64
import importlib.util
import json
from pathlib import Path
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
SCRIPTS = Path(__file__).resolve().parents[1]
MODULO = SCRIPTS / "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
def una_scacchiera(square_px: int = 40, nx: int = 10, ny: int = 7, margin: int = 60) -> np.ndarray:
"""nx x ny quadrati => (nx-1) x (ny-1) angoli interni, su fondo bianco."""
img = np.full((ny * square_px + 2 * margin, nx * square_px + 2 * margin), 255, np.uint8)
for j in range(ny):
for i in range(nx):
if (i + j) % 2 == 0:
y, x = margin + j * square_px, margin + i * square_px
img[y:y + square_px, x:x + square_px] = 0
return img
def una_scacchiera_densa(w: int = 640, h: int = 480, square_px: int = 40, k: float = 1e-8) -> np.ndarray:
"""#84: scacchiera che riempie tutto il campo, con distorsione a barilotto (k sui px dal centro)."""
m = 3 * square_px # la scacchiera continua oltre il campo: nessun bordo finto nell'immagine
yy, xx = np.mgrid[0:h + 2 * m, 0:w + 2 * m]
piana = (((xx // square_px) + (yy // square_px)) % 2 * 255).astype(np.uint8)
cx, cy = w / 2.0, h / 2.0
dx, dy = (xx[m:m + h, m:m + w] - m - cx).astype(np.float32), (yy[m:m + h, m:m + w] - m - cy).astype(np.float32)
f = 1 + k * (dx * dx + dy * dy)
img = cv2.remap(piana, cx + m + dx * f, cy + m + dy * f, cv2.INTER_LINEAR)
return cv2.GaussianBlur(img, (3, 3), 0.8) # bordi non perfettamente netti, come in camera
def residuo_reticolo(griglia: np.ndarray) -> float:
"""Residuo massimo (px) della griglia (nr,nc,2) da un reticolo affine regolare."""
nr, nc, _ = griglia.shape
ii, jj = np.mgrid[0:nr, 0:nc]
A = np.stack([np.ones(nr * nc), ii.ravel(), jj.ravel()], axis=1)
P = griglia.reshape(-1, 2)
coef, *_ = np.linalg.lstsq(A, P, rcond=None)
return float(np.hypot(*(A @ coef - P).T).max())
def _png(img: np.ndarray) -> bytes:
ok, buf = cv2.imencode(".png", img)
assert ok
return buf.tobytes()
@pytest.fixture
def cartella(tmp_path):
# la sorgente camera:folder fa il loop su una scacchiera: il grab la trova
cartella = tmp_path / "immagini"
cartella.mkdir()
cv2.imwrite(str(cartella / "scacchiera.png"), una_scacchiera())
return cartella
@pytest.fixture
def client(cartella, tmp_path):
mod = _carica()
with TestClient(mod.create_app(cartella, config_dir=tmp_path / "config")) as c:
c.config = tmp_path / "config"
yield c
CAMPI = {"cols": "9", "rows": "6", "square_mm": "2"}
CHIAVI_C1 = {"source", "px_per_mm", "mm_per_px", "rms_px", "n_corners", "cols", "rows",
"square_mm", "image_width", "image_height", "scale_spread_pct", "calibrated_at"}
# #84: campi in piu' per metodo planare (C2 con cols/rows) e per la densa
CHIAVI_PLANARE = CHIAVI_C1 | {"metodo", "distorsione"}
CHIAVI_DENSA = CHIAVI_PLANARE | {"residuo_rms_px", "residuo_max_px", "copertura_pct"}
SORGENTE = "camera:folder"
@pytest.fixture
def cartella_densa(tmp_path):
# #84: la sorgente camera:folder fa il loop su una scacchiera densa distorta
cartella = tmp_path / "immagini_dense"
cartella.mkdir()
cv2.imwrite(str(cartella / "densa.png"), una_scacchiera_densa(k=1e-7))
return cartella
@pytest.fixture
def client_denso(cartella_densa, tmp_path):
mod = _carica()
with TestClient(mod.create_app(cartella_densa, config_dir=tmp_path / "config_densa")) as c:
c.config = tmp_path / "config_densa"
yield c
NON_IMMAGINE = b"questo non e una immagine"
# ---- logica pura
def test_scacchiera_sintetica_da_la_scala():
_carica() # mette scripts/ in sys.path
import calibrazione
r = calibrazione.calibra_scacchiera(una_scacchiera(), 9, 6, 2.0)
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r["mm_per_px"] == pytest.approx(0.05, rel=0.005)
assert r["rms_px"] < 0.5
assert r["n_corners"] == 54
assert (r["image_width"], r["image_height"]) == (520, 400)
assert r["scale_spread_pct"] < 1.0
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
def test_immagine_bianca_solleva():
_carica()
import calibrazione
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
calibrazione.calibra_scacchiera(np.full((300, 300), 255, np.uint8), 9, 6, 2.0)
@pytest.mark.parametrize("cols,rows,quadrato", [(1, 6, 2.0), (9, 6, 0.0), (9, 6, -1.0)])
def test_parametri_invalidi_sollevano(cols, rows, quadrato):
_carica()
import calibrazione
with pytest.raises(calibrazione.CalibrazioneError):
calibrazione.calibra_scacchiera(una_scacchiera(), cols, rows, quadrato)
# ---- rotte
def test_c1_non_calibrata_404(client):
r = client.get("/camera/calibration", params={"source": "camera:folder"})
assert r.status_code == 404
assert r.json()["detail"] == "sorgente non calibrata"
def test_c1_senza_source_400(client):
assert client.get("/camera/calibration").status_code == 400
def test_c2_immagine_caricata_senza_salvare(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200, r.text
corpo = r.json()
assert CHIAVI_PLANARE | {"overlay_png", "saved"} == set(corpo)
assert corpo["metodo"] == "planare" and corpo["distorsione"] is False # #84: con cols/rows nulla cambia
assert corpo["saved"] is False
assert corpo["px_per_mm"] == pytest.approx(20.0, rel=0.005)
# senza save non si scrive nulla
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
assert not client.config.exists() or not list(client.config.glob("*.json"))
def test_c2_c1_c3_ciclo_con_grab_dalla_sorgente(client):
# nessun file: grab da camera:folder (la scacchiera in cartella), con save
r = client.post("/camera/calibrate",
data={"source": "camera:folder", "save": "true", **CAMPI})
assert r.status_code == 200, r.text
assert r.json()["saved"] is True
r1 = client.get("/camera/calibration", params={"source": "camera:folder"})
assert r1.status_code == 200
assert set(r1.json()) == CHIAVI_PLANARE # nessun overlay nel file salvato
assert r1.json()["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r1.json()["source"] == "camera:folder"
file_salvati = list(client.config.glob("*.json"))
assert len(file_salvati) == 1
assert json.loads(file_salvati[0].read_text(encoding="utf-8"))["cols"] == 9
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
assert client.get("/camera/calibration", params={"source": "camera:folder"}).status_code == 404
# cancellare di nuovo e' idempotente
assert client.delete("/camera/calibration", params={"source": "camera:folder"}).status_code == 204
def test_c2_scacchiera_non_trovata_422(client):
bianca = np.full((300, 300), 255, np.uint8)
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("b.png", _png(bianca), "image/png")})
assert r.status_code == 422
assert "non trovata" in r.json()["detail"]
def test_c2_parametri_invalidi_422(client):
r = client.post("/camera/calibrate",
data={"source": "camera:folder", "cols": "1", "rows": "6", "square_mm": "2"},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 422
def test_c2_immagine_illeggibile_422(client):
r = client.post("/camera/calibrate", data={"source": "camera:folder", **CAMPI},
files={"image": ("x.png", NON_IMMAGINE, "image/png")})
assert r.status_code == 422
def test_c2_errori_camera_come_grab(client):
r = client.post("/camera/calibrate", data={"source": "camera:non-esiste", **CAMPI})
assert r.status_code == 404
def test_due_sorgenti_hanno_file_diversi(client):
for nome in ("camera:a b", "camera:a_b"):
r = client.post("/camera/calibrate", data={"source": nome, "save": "true", **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert r.status_code == 200
assert len(list(client.config.glob("*.json"))) == 2
def test_c4_pagina_html(client):
r = client.get("/calibrazione")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
assert "Acquisisci e calibra" in r.text
assert "Lato dello scacco" in r.text and 'id="cols"' not in r.text # #84: un solo campo
assert "http://" not in r.text and "https://" not in r.text # offline, niente CDN
# ---- #84: calibrazione densa (logica pura)
def test_densa_trova_la_griglia_da_sola_e_la_scala():
_carica()
import calibrazione
r, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(k=1e-8), 2.0)
assert (r["cols"], r["rows"]) == (15, 11) # 640x480 con quadrati da 40 px
assert r["n_corners"] == 15 * 11
assert r["mm_per_px"] == pytest.approx(2.0 / 40, rel=0.005)
assert r["px_per_mm"] == pytest.approx(20.0, rel=0.005)
assert r["metodo"] == "densa" and r["distorsione"] is True
assert r["residuo_max_px"] < 0.3 and r["residuo_rms_px"] <= r["residuo_max_px"]
assert 60 < r["copertura_pct"] <= 100
assert (r["image_width"], r["image_height"]) == (640, 480)
assert base64.b64decode(r["overlay_png"])[:8] == b"\x89PNG\r\n\x1a\n"
assert mappa.shape == (480, 640) and mappa.pixel_size == pytest.approx(0.05, rel=0.005)
def test_densa_raddrizza_su_griglia_regolare():
_carica()
import calibrazione
img = una_scacchiera_densa(k=1e-7) # distorsione marcata: ai bordi ~5 %
r, mappa = calibrazione.calibra_scacchiera_densa(img, 2.0)
assert (r["cols"], r["rows"]) == (15, 11)
# prima la griglia e' curva, dopo e' un reticolo regolare
assert residuo_reticolo(calibrazione._trova_griglia(img)) > 1.0
pronta = calibrazione._prepara_mappa(mappa)
dritta = calibrazione.raddrizza(img, pronta)
assert dritta.shape == img.shape
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3
@pytest.mark.parametrize("cosa", ["bianca", "scacchiera_piccola"])
def test_densa_senza_griglia_solleva(cosa):
_carica()
import calibrazione
img = (np.full((300, 300), 255, np.uint8) if cosa == "bianca"
else una_scacchiera_densa(80, 60, 20)) # 3x2 angoli: meno di 3x3
with pytest.raises(calibrazione.CalibrazioneError, match="non trovata"):
calibrazione.calibra_scacchiera_densa(img, 2.0)
@pytest.mark.parametrize("quadrato", [0.0, -1.0, float("nan")])
def test_densa_quadrato_invalido_solleva(quadrato):
_carica()
import calibrazione
with pytest.raises(calibrazione.CalibrazioneError):
calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), quadrato)
def test_archivio_mappa_cache_e_invalidazione(tmp_path):
_carica()
import calibrazione
_, mappa = calibrazione.calibra_scacchiera_densa(una_scacchiera_densa(), 2.0)
arch = calibrazione.Archivio(tmp_path)
assert arch.leggi_mappa("s") is None
arch.salva("s", {"a": 1}, mappa)
prima = arch.leggi_mappa("s")
assert prima is not None and arch.leggi_mappa("s") is prima # cache in memoria
assert not list(tmp_path.glob("*.tmp*")) # niente temporanei rimasti
arch.salva("s", {"a": 2}, mappa) # file riscritto -> cache invalidata
assert arch.leggi_mappa("s") is not prima
arch.salva("s", {"a": 3}) # salvataggio senza mappa (planare): la vecchia sparisce
assert arch.leggi_mappa("s") is None and not list(tmp_path.glob("*.npz"))
# ---- #84: rotte con la calibrazione densa
def _grigio(r) -> np.ndarray:
return cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_GRAYSCALE)
def test_c2_senza_cols_rows_e_densa(client_denso):
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert r.status_code == 200, r.text
corpo = r.json()
assert CHIAVI_DENSA | {"overlay_png", "saved"} == set(corpo)
assert corpo["metodo"] == "densa" and corpo["distorsione"] is True
assert (corpo["cols"], corpo["rows"]) == (15, 11)
assert corpo["px_per_mm"] == pytest.approx(1.0 / corpo["mm_per_px"])
assert corpo["saved"] is False
assert not client_denso.config.exists() or not list(client_denso.config.iterdir())
def test_c2_solo_cols_o_solo_rows_422(client_denso):
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "cols": "9"})
assert r.status_code == 422
def test_grab_raddrizzato_se_calibrata_e_grezzo_a_richiesta(client_denso, cartella_densa):
originale = cv2.imread(str(cartella_densa / "densa.png"), cv2.IMREAD_GRAYSCALE)
prima = client_denso.post("/camera/grab", json={"source": SORGENTE})
assert prima.headers["x-raddrizzato"] == "no" # non calibrata: frame com'e'
assert np.array_equal(_grigio(prima), originale)
r = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"})
assert r.status_code == 200, r.text
assert r.json()["saved"] is True
json_salvati, mappe = list(client_denso.config.glob("*.json")), list(client_denso.config.glob("*.npz"))
assert len(json_salvati) == 1 and len(mappe) == 1
assert mappe[0].stem == json_salvati[0].stem # stesso nome del JSON
saved = client_denso.get("/camera/calibration", params={"source": SORGENTE}).json()
assert set(saved) == CHIAVI_DENSA and saved["metodo"] == "densa"
dopo = client_denso.post("/camera/grab", json={"source": SORGENTE})
assert dopo.status_code == 200 and dopo.headers["x-raddrizzato"] == "si"
import calibrazione
dritta = _grigio(dopo)
assert dritta.shape == originale.shape
assert not np.array_equal(dritta, originale)
assert residuo_reticolo(calibrazione._trova_griglia(dritta)) < 0.3 # griglia regolare
grezzo = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": True})
assert grezzo.headers["x-raddrizzato"] == "no"
assert np.array_equal(_grigio(grezzo), originale)
# la calibrazione parte sempre dal frame grezzo: ricalibrare dalla sorgente da' lo stesso mm/px
di_nuovo = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2"})
assert di_nuovo.json()["mm_per_px"] == pytest.approx(r.json()["mm_per_px"])
assert di_nuovo.json()["residuo_max_px"] < 0.3
def test_grab_grezzo_non_booleano_400(client_denso):
r = client_denso.post("/camera/grab", json={"source": SORGENTE, "grezzo": "si"})
assert r.status_code == 400
def test_delete_toglie_anche_la_mappa(client_denso):
client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"})
assert list(client_denso.config.glob("*.npz"))
assert client_denso.delete("/camera/calibration", params={"source": SORGENTE}).status_code == 204
assert not list(client_denso.config.glob("*.npz")) and not list(client_denso.config.glob("*.json"))
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
def test_ricalibrare_planare_toglie_la_mappa_densa(client_denso):
client_denso.post("/camera/calibrate", data={"source": SORGENTE, "square_mm": "2", "save": "true"})
assert list(client_denso.config.glob("*.npz"))
planare = client_denso.post("/camera/calibrate", data={"source": SORGENTE, "save": "true", **CAMPI},
files={"image": ("s.png", _png(una_scacchiera()), "image/png")})
assert planare.status_code == 200 and planare.json()["metodo"] == "planare"
assert not list(client_denso.config.glob("*.npz")) # niente mappa vecchia che raddrizza a vuoto
assert client_denso.post("/camera/grab", json={"source": SORGENTE}).headers["x-raddrizzato"] == "no"
+320
View File
@@ -0,0 +1,320 @@
"""Test delle rotte di acquisizione di scripts/camera_service.py (#87).
Uso: uv run --python 3.13 --extra vision-worker --extra dev \
--with-editable vendor/visionsuite/packages/vs-camera pytest scripts/tests -q
Copre: POST /camera/anteprima, GET/PUT /camera/parametri, POST /camera/parametri/salva,
caricamento dei settaggi alla riapertura, alias GET /acquisizione.
Sorgente regolabile finta (iniettata su open_source) e FolderCamera su PNG temporanei.
"""
import importlib.util
import json
from pathlib import Path
import cv2
import numpy as np
import pytest
from fastapi.testclient import TestClient
from visionsuite.camera.camera_interface import CameraStateError
MODULO = Path(__file__).resolve().parents[1] / "camera_service.py"
SORGENTE = "camera:finta"
def _carica():
spec = importlib.util.spec_from_file_location("camera_service_acq", MODULO)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
class Finta:
"""Camera regolabile finta: stesso vocabolario di GenicamSource."""
DOMINI = {
"esposizione": {"tipo": "numero", "min": 10.0, "max": 100000.0, "passo": 1.0},
"esposizione_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
"guadagno": {"tipo": "numero", "min": 0.0, "max": 24.0},
"guadagno_auto": {"tipo": "scelta", "scelte": ["Off", "Once", "Continuous"]},
}
def __init__(self, supportati=None, rifiuta=()):
self.valori = {"esposizione": 5000.0, "esposizione_auto": "Continuous",
"guadagno": 1.0, "guadagno_auto": "Off"}
self.supportati = set(supportati if supportati is not None else self.DOMINI)
self.rifiuta = set(rifiuta)
self.scritture = []
self.caricato = None
self.chiusa = False
# metà bianca (255) e metà a 100: saturati 50%, media 177,5
frame = np.full((1000, 2000, 3), 100, np.uint8)
frame[:, :1000] = 255
self.frame = frame
def supporta(self, nome):
return nome in self.supportati
def dominio(self, nome):
return {"capacita": nome, "valore": self.valori[nome], **self.DOMINI[nome]}
def leggi(self, nome):
return self.valori[nome]
def scrivi(self, nome, valore, assesta=0):
if nome in self.rifiuta:
raise CameraStateError(f"non posso impostare {nome}: nodo in sola lettura")
self.scritture.append((nome, valore, assesta))
self.valori[nome] = valore
def salva_settaggi(self, percorso):
Path(percorso).write_text(json.dumps(self.valori), encoding="utf-8")
return dict(self.valori)
def carica_settaggi(self, percorso, forza=False):
self.caricato = Path(percorso)
self.valori.update(json.loads(self.caricato.read_text(encoding="utf-8")))
return []
def grab(self, timeout=0):
return self.frame.copy()
def close(self):
self.chiusa = True
@pytest.fixture
def cartella(tmp_path):
img = tmp_path / "foto"
img.mkdir()
for nome in ("a.png", "b.png"):
cv2.imwrite(str(img / nome), np.full((48, 64, 3), 128, np.uint8))
return img
def _client(cartella, config, monkeypatch, finta):
mod = _carica()
monkeypatch.setattr(mod, "open_source", lambda *a, **k: finta)
c = TestClient(mod.create_app(cartella, config))
c.mod = mod
return c
@pytest.fixture
def finta():
return Finta()
@pytest.fixture
def client(cartella, tmp_path, monkeypatch, finta):
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
c.finta = finta
c.config = tmp_path / "cfg"
yield c
@pytest.fixture
def client_cartella(cartella, tmp_path):
mod = _carica()
with TestClient(mod.create_app(cartella, tmp_path / "cfg")) as c:
yield c
# ---- anteprima
def test_anteprima_jpeg_ridotta_e_header(client):
r = client.post("/camera/anteprima", json={"source": SORGENTE, "lato_max": 500})
assert r.status_code == 200
assert r.headers["content-type"] == "image/jpeg"
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
assert img.shape[:2] == (250, 500) # lato lungo 2000 -> 500
assert r.headers["x-raddrizzato"] == "no"
assert (r.headers["x-larghezza"], r.headers["x-altezza"]) == ("2000", "1000") # frame pieno
assert r.headers["x-saturati-pct"] == "50.00"
assert r.headers["x-media"] == "177.5"
assert float(r.headers["x-nitidezza"]) > 0 # bordo netto fra le due metà
def test_anteprima_mai_ingrandita(client_cartella):
r = client_cartella.post("/camera/anteprima", json={"source": "camera:folder"})
assert r.status_code == 200
img = cv2.imdecode(np.frombuffer(r.content, np.uint8), cv2.IMREAD_COLOR)
assert img.shape[:2] == (48, 64) # lato_max 1280 > 64: resta com'è
assert r.headers["x-nitidezza"] == "0.0" # grigio uniforme
assert r.headers["x-saturati-pct"] == "0.00"
assert r.headers["x-media"] == "128.0"
def test_anteprima_parametri_errati_400(client):
for corpo in ({}, {"source": SORGENTE, "lato_max": 100}, {"source": SORGENTE, "lato_max": 5000},
{"source": SORGENTE, "timeout_ms": 0}, {"source": SORGENTE, "grezzo": "si"}):
assert client.post("/camera/anteprima", json=corpo).status_code == 400, corpo
def test_anteprima_sorgente_inesistente_404(client_cartella):
assert client_cartella.post("/camera/anteprima",
json={"source": "camera:non-esiste"}).status_code == 404
def test_anteprima_errore_camera_503_e_timeout_504(client):
from visionsuite.camera.camera_interface import CameraGrabError
def rotta(timeout=0):
raise CameraGrabError("Camera is not open")
client.finta.grab = rotta
r = client.post("/camera/anteprima", json={"source": SORGENTE})
assert r.status_code == 503 and r.json()["detail"] == "La camera non è aperta"
def lenta(timeout=0):
raise TimeoutError("scaduto")
client.finta.grab = lenta
assert client.post("/camera/anteprima", json={"source": SORGENTE}).status_code == 504
# ---- parametri
def test_parametri_regolabile(client):
r = client.get("/camera/parametri", params={"source": SORGENTE})
assert r.status_code == 200
j = r.json()
assert j["source"] == SORGENTE and j["regolabile"] is True
assert list(j["parametri"]) == ["esposizione", "esposizione_auto", "guadagno", "guadagno_auto"]
assert j["parametri"]["esposizione"]["valore"] == 5000.0
assert j["parametri"]["esposizione_auto"]["scelte"] == ["Off", "Once", "Continuous"]
def test_parametri_solo_quelli_supportati(cartella, tmp_path, monkeypatch):
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(supportati=["esposizione"])) as c:
j = c.get("/camera/parametri", params={"source": SORGENTE}).json()
assert j["regolabile"] is True and list(j["parametri"]) == ["esposizione"]
def test_parametri_sorgente_non_regolabile(client_cartella):
j = client_cartella.get("/camera/parametri", params={"source": "camera:folder"}).json()
assert j == {"source": "camera:folder", "regolabile": False, "parametri": {}}
def test_parametri_senza_source_400(client):
assert client.get("/camera/parametri").status_code == 400
def test_put_scrive_prima_gli_auto(client):
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {
"esposizione": 8000, "guadagno": 3.5, "esposizione_auto": "Off", "guadagno_auto": "Off"}})
assert r.status_code == 200
assert client.finta.scritture == [
("esposizione_auto", "Off", 3), ("guadagno_auto", "Off", 3),
("esposizione", 8000, 3), ("guadagno", 3.5, 3)]
j = r.json() # valori riletti dalla camera
assert j["parametri"]["esposizione"]["valore"] == 8000
assert j["parametri"]["esposizione_auto"]["valore"] == "Off"
def test_put_nome_ignoto_400(client):
r = client.put("/camera/parametri", json={"source": SORGENTE, "valori": {"frame_rate": 10}})
assert r.status_code == 400
assert client.finta.scritture == []
for corpo in ({"source": SORGENTE}, {"source": SORGENTE, "valori": {}},
{"source": SORGENTE, "valori": {"guadagno": True}}):
assert client.put("/camera/parametri", json=corpo).status_code == 400, corpo
def test_put_non_regolabile_409(client_cartella):
r = client_cartella.put("/camera/parametri",
json={"source": "camera:folder", "valori": {"esposizione": 1000}})
assert r.status_code == 409
assert r.json()["detail"] == "questa sorgente non ha parametri regolabili"
def test_put_rifiuto_della_camera_422(cartella, tmp_path, monkeypatch):
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta(rifiuta=["guadagno"])) as c:
r = c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"guadagno": 5}})
assert r.status_code == 422
assert "guadagno" in r.json()["detail"]
# ---- salvataggio e riapertura
def test_salva_crea_il_file(client):
r = client.post("/camera/parametri/salva", json={"source": SORGENTE})
assert r.status_code == 200
j = r.json()
assert j["source"] == SORGENTE and j["salvati"] is True
atteso = client.config / "settaggi" / j["file"]
assert atteso.is_file()
assert j["file"].startswith("camera_finta-") and j["file"].endswith(".json")
assert json.loads(atteso.read_text(encoding="utf-8"))["esposizione"] == 5000.0
def test_salva_non_regolabile_409(client_cartella):
r = client_cartella.post("/camera/parametri/salva", json={"source": "camera:folder"})
assert r.status_code == 409
def test_riapertura_carica_i_settaggi(cartella, tmp_path, monkeypatch):
config = tmp_path / "cfg"
prima = Finta()
with _client(cartella, config, monkeypatch, prima) as c:
c.put("/camera/parametri", json={"source": SORGENTE, "valori": {"esposizione": 7777}})
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
seconda = Finta() # servizio riavviato: camera nuova, valori di fabbrica
with _client(cartella, config, monkeypatch, seconda) as c:
assert seconda.caricato is None # niente apertura finché nessuno la chiede
c.get("/camera/parametri", params={"source": SORGENTE})
assert seconda.caricato == config / "settaggi" / file
assert seconda.valori["esposizione"] == 7777
def test_settaggi_rotti_non_bloccano_l_apertura(cartella, tmp_path, monkeypatch):
class Rotta(Finta):
def carica_settaggi(self, percorso, forza=False):
raise ValueError("file illeggibile")
config = tmp_path / "cfg"
with _client(cartella, config, monkeypatch, Finta()) as c:
file = c.post("/camera/parametri/salva", json={"source": SORGENTE}).json()["file"]
assert (config / "settaggi" / file).is_file()
with _client(cartella, config, monkeypatch, Rotta()) as c:
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
def test_apertura_chiede_il_frame_piu_recente(cartella, tmp_path, monkeypatch):
# #89: solo_ultimo_frame() una volta sola all'apertura, non a ogni richiesta
class ConUltimoFrame(Finta):
chiamate = 0
def solo_ultimo_frame(self, attivo=True):
self.chiamate += 1
finta = ConUltimoFrame()
with _client(cartella, tmp_path / "cfg", monkeypatch, finta) as c:
for _ in range(3):
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
assert finta.chiamate == 1
def test_frame_piu_recente_non_impostabile_non_blocca_l_apertura(cartella, tmp_path, monkeypatch, caplog):
# #89: producer senza il nodo -> avviso nel log, apertura riuscita
class SenzaNodo(Finta):
def solo_ultimo_frame(self, attivo=True):
raise CameraStateError("questo producer non espone StreamBufferHandlingMode")
with caplog.at_level("WARNING"):
with _client(cartella, tmp_path / "cfg", monkeypatch, SenzaNodo()) as c:
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
assert "frame piu' recente non impostabile" in caplog.text
def test_sorgente_senza_solo_ultimo_frame_si_apre(cartella, tmp_path, monkeypatch):
# #89: FolderCamera e simili non hanno il metodo: nessun errore
assert not hasattr(Finta(), "solo_ultimo_frame")
with _client(cartella, tmp_path / "cfg", monkeypatch, Finta()) as c:
assert c.get("/camera/parametri", params={"source": SORGENTE}).status_code == 200
# ---- pagina
def test_acquisizione_alias_della_pagina(client):
a = client.get("/acquisizione")
c = client.get("/calibrazione")
assert a.status_code == 200 and a.text == c.text
+116
View File
@@ -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"]
+54
View File
@@ -0,0 +1,54 @@
"""Test della pagina Acquisizione camera (#87): contenuto statico di PAGINA, senza servizio."""
import importlib.util
import re
from pathlib import Path
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
_spec = importlib.util.spec_from_file_location("calibrazione_pagina", _FILE)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
PAGINA = _mod.PAGINA
def test_titolo_e_stile_scuro():
assert "<title>Acquisizione camera</title>" in PAGINA
assert "#0f172a" in PAGINA and "#1e293b" in PAGINA and "#3b82f6" in PAGINA
def test_rotte_usate():
for rotta in ("/camera/sources", "/camera/anteprima", "/camera/parametri",
"/camera/parametri/salva", "/camera/calibrate", "/camera/calibration"):
assert '"' + rotta in PAGINA, rotta
def test_offline_e_senza_finestre_del_browser():
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
assert vietato not in PAGINA, vietato
def test_id_principali():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"sorgente", "avvia", "ferma", "scatta", "grezzo", "mirinoCasella", "immagine",
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
"parametri", "salvaParametri", "quadrato", "acquisisci", "scegliFile", "file",
"salva", "stato", "statoSorgente"} # #91: carica/elimina sono ora per riga, nell'elenco
assert attesi <= ids, attesi - ids
# ogni id usato dal JS con $("...") esiste nel markup
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
def test_sezioni_e_testi_italiani():
for testo in ("Sorgente", "Anteprima", "Parametri camera", "Calibrazione",
"Salva come predefiniti", "Scegli immagine...", "Questa sorgente non ha parametri regolabili",
"Immagine grezza (senza correzione)"):
assert testo in PAGINA, testo
assert "it-IT" in PAGINA # numeri con la virgola
assert "Choose File" not in PAGINA
def test_localstorage_protetto():
# ogni uso di localStorage sta dentro un try
for m in re.finditer(r"localStorage[.]", PAGINA):
assert "try {" in PAGINA[max(0, m.start() - 200):m.start()]
+75
View File
@@ -0,0 +1,75 @@
"""Test della pagina a due tab (#91): contenuto statico di PAGINA, senza servizio."""
import importlib.util
import re
from pathlib import Path
_FILE = Path(__file__).resolve().parents[1] / "calibrazione_pagina.py"
_spec = importlib.util.spec_from_file_location("calibrazione_pagina_tab", _FILE)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
PAGINA = _mod.PAGINA
def test_due_tab_con_ruolo_e_selezione():
assert re.search(r'<button id="tabAcq"[^>]*role="tab"[^>]*aria-selected="true"', PAGINA)
assert re.search(r'<button id="tabCal"[^>]*role="tab"[^>]*aria-selected="false"', PAGINA)
assert ">Acquisizione</button>" in PAGINA and ">Calibrazione</button>" in PAGINA
assert 'role="tablist"' in PAGINA and 'role="tabpanel"' in PAGINA
assert 'aria-selected", cal ?' in PAGINA # la selezione cambia da JS
assert '"tmf.acq.tab"' in PAGINA # tab scelta ricordata (via memoria(), protetta)
def test_anteprima_fuori_dai_pannelli():
# l'anteprima sta nella colonna sinistra, prima dei due pannelli: visibile in entrambe le tab
i_ant = PAGINA.index('id="sezioneAnteprima"')
i_acq = PAGINA.index('id="pannelloAcq"')
i_cal = PAGINA.index('id="pannelloCal"')
assert i_ant < i_acq < i_cal
# Sorgente e Parametri nel pannello Acquisizione, la calibrazione in quello Calibrazione
assert i_acq < PAGINA.index('id="sezioneSorgente"') < PAGINA.index('id="sezioneParametri"') < i_cal
assert PAGINA.index('id="sezioneCalibrazione"') > i_cal
def test_id_del_pannello_calibrazione():
ids = set(re.findall(r'id="([^"]+)"', PAGINA))
attesi = {"calSorgente", "origSorgente", "origFile", "scegliFile", "file", "quadrato", "acquisisci",
"nomeCal", "salva", "msgSalva", "calibrazioni", "msgElenco", "stato", "risultato", "overlay"}
assert attesi <= ids, attesi - ids
usati = set(re.findall(r'\$\("([^"]+)"\)', PAGINA))
assert usati <= ids, usati - ids
assert len(re.findall(r'id="([^"]+)"', PAGINA)) == len(ids) # nessun id doppio
def test_testi_del_pannello():
for testo in ("Sorgente selezionata", "Immagine da file", "Scegli immagine...", "Calcola",
"Calibrazioni salvate", "si cambia nella tab Acquisizione", "risoluzione diversa",
"Carica", "Elimina", "Scollega", "Sovrascrivi «", "Clic ancora", "Nome"):
assert testo in PAGINA, testo
def test_rotte_del_contratto():
for rotta in ("/camera/calibrazioni", "/camera/calibrazione-attiva", "/camera/calibrate",
"/camera/calibration?source="):
assert '"' + rotta in PAGINA, rotta
for parola in ("calcolo_id", "sovrascrivi", "method: \"PUT\"", "method: \"DELETE\"", "X-Larghezza", "X-Altezza"):
assert parola in PAGINA, parola
# il calcolo non salva piu': il campo save sparisce
assert 'append("save"' not in PAGINA
def test_nessuna_finestra_del_browser_e_offline():
for vietato in ("alert(", "confirm(", "prompt(", "http://", "https://"):
assert vietato not in PAGINA, vietato
def test_acquisizione_di_87_90_conservata():
# anteprima, indicatori, mirino, grezzo, parametri camera e ciclo a 100 ms restano
for parola in ("Parametri camera", "Immagine grezza (senza correzione)", "Mirino centrale",
"Math.max(100 - (Date.now() - t0), 0)", "/camera/anteprima", "/camera/parametri"):
assert parola in PAGINA, parola
def test_pagina_stretta():
assert "@media (max-width: 500px)" in PAGINA
assert "overflow-x: auto" in PAGINA # l'elenco scorre di lato invece di rompere la pagina
+7 -2
View File
@@ -3,6 +3,7 @@
Protected by SETUP_PASSWORD env var. When empty/None, all endpoints return 404.
NOT protected by API Key auth (standalone access).
"""
import asyncio
import hmac
import random
from datetime import datetime, timedelta
@@ -312,7 +313,9 @@ async def init_db(body: PasswordBody):
await _require_not_initialised()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
_stamp_head()
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database tables created successfully"}
@@ -748,6 +751,8 @@ async def reset_db(body: ResetDbBody):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
_stamp_head()
# #80: env.py di alembic fa asyncio.run(), vietato dentro il loop di FastAPI;
# in un thread non c'e' un loop attivo e funziona.
await asyncio.to_thread(_stamp_head)
return {"status": "ok", "message": "Database reset successfully"}
+15
View File
@@ -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,
+74
View File
@@ -12,6 +12,7 @@ from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.task_result import TaskResult # #58: esiti dei task della sessione
from src.backend.models.orm.user import User
from src.backend.models.api.statistics import (
CapabilityData,
@@ -294,3 +295,76 @@ async def get_recipe_runs(
}
for r in runs
]
@router.get("/runs/{run_id}/task-results")
async def get_run_task_results(
run_id: int,
user: User = Depends(require_metrologist),
db: AsyncSession = Depends(get_db),
) -> dict:
"""#58: esiti, note, campi e allegati dei task di una sessione di produzione.
Endpoint di statistica (require_metrologist): GET /api/task-results vuole
il ruolo MeasurementTec e non si puo' riusare qui.
"""
run = (await db.execute(
select(ProductionRun.id).where(ProductionRun.id == run_id)
)).scalar_one_or_none()
if run is None:
raise HTTPException(status_code=404, detail="Sessione non trovata")
# #58: una query per i risultati della run, una per i task, una per gli utenti.
results = (await db.execute(
select(TaskResult)
.where(TaskResult.production_run_id == run_id)
.order_by(TaskResult.created_at.asc(), TaskResult.id.asc())
)).scalars().all()
task_ids = {r.task_id for r in results}
tasks = {}
if task_ids:
tasks = {t.id: t for t in (await db.execute(
select(RecipeTask).where(RecipeTask.id.in_(task_ids))
)).scalars().all()}
user_ids = {r.user_id for r in results}
names = {}
if user_ids:
names = dict((await db.execute(
select(User.id, User.display_name).where(User.id.in_(user_ids))
)).all())
counts = {"ok": 0, "nok": 0, "done": 0}
out = []
for r in results:
counts[r.outcome] = counts.get(r.outcome, 0) + 1
task = tasks.get(r.task_id)
# #58: etichette dai campi del task; se il campo e' stato tolto vale la chiave.
labels = {
f.get("key"): f.get("label") or f.get("key")
for f in (task.fields or []) if isinstance(f, dict)
} if task else {}
out.append({
"id": r.id,
"task_id": r.task_id,
"task_title": task.title if task else None,
"task_type": task.task_type if task else None,
"outcome": r.outcome,
"note": r.note,
"created_at": r.created_at.isoformat() if r.created_at else None,
"user_name": names.get(r.user_id),
"values": [
{"key": k, "label": labels.get(k, k), "value": v}
for k, v in (r.values or {}).items()
],
"attachments": [
{
"key": a.get("key"),
"label": labels.get(a.get("key"), a.get("key")),
"name": a.get("name"),
"path": a.get("path"),
}
for a in (r.attachments or []) if isinstance(a, dict)
],
})
return {"run_id": run_id, "counts": counts, "results": out}
+103 -14
View File
@@ -4,8 +4,11 @@ It does not know, and must not know, whether the server or the station computed
the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
import base64
import binascii
import io
import json
from typing import Literal
from fastapi.responses import FileResponse
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
@@ -26,7 +29,7 @@ from src.backend.models.api.vision import (
from src.backend.models.orm.vision import VisionResult
from src.backend.models.orm.task import RecipeTask
from src.backend.models.orm.user import User
from src.backend.services import vision_service
from src.backend.services import station_service, vision_service
router = APIRouter(prefix="/api/vision", tags=["vision"])
@@ -185,6 +188,30 @@ def _422(errors: list[str]) -> HTTPException:
)
async def _store_validate_assign(
task: RecipeTask, graph: dict, placed: dict[str, bytes],
) -> tuple[str, dict]:
"""Scrive la cartella ricetta, la fa validare dal worker, la assegna al task.
Condiviso da upload_recipe e dxf-compare con save (#83): stesso
comportamento, un solo posto. Non committa. Ritorna (recipe_dir, check).
"""
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
return recipe_dir, check
@router.post("/tasks/{task_id}/recipe", response_model=VisionRecipeUploadResponse)
async def upload_recipe(
task_id: int,
@@ -218,19 +245,7 @@ async def upload_recipe(
if errors:
raise _422(errors)
# The folder is content-addressed and immutable; writing it before the
# validation is harmless (an orphan folder is never referenced) and the
# worker needs it on disk to validate meta files.
try:
recipe_dir = vision_service.store_recipe_folder(task.id, graph, placed)
except ValueError as exc:
raise _422([str(exc)])
check = await vision_service.validate_recipe(graph, recipe_dir)
if not check["ok"]:
raise _422([str(e) for e in check["errors"]] or ["the recipe is not valid"])
task.vision_json = graph
task.vision_recipe_dir = recipe_dir
recipe_dir, check = await _store_validate_assign(task, graph, placed)
await db.commit()
return VisionRecipeUploadResponse(
task_id=task.id,
@@ -325,3 +340,77 @@ async def preview(
) -> dict:
task = await _task_or_404(db, task_id)
return await vision_service.preview(db, task, await _read_image(image))
@router.post("/tasks/{task_id}/recipe/dxf-compare")
async def create_dxf_compare_recipe(
task_id: int,
image: UploadFile = File(...),
dxf: UploadFile = File(...),
station_id: int | None = Form(None, gt=0),
px_per_mm: float | None = Form(None, gt=0),
band_mm: float = Form(0.1, gt=0),
min_points_pct: float = Form(95, ge=0, le=100),
polarity: Literal["dark", "light"] = Form("dark"),
save: bool = Form(False),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_maker),
) -> dict:
"""Ricetta DXF compare da un'immagine e un DXF, senza Studio (#83).
La scala e' `px_per_mm`, o quella della stazione (letta dall'acquisitore).
Con save=false si vede il risultato e non si scrive nulla; con save=true la
ricetta va sul task come con l'upload, e se il task non ha nessuna quota di
visione nasce la quota «Punti in tolleranza».
"""
task = await _task_or_404(db, task_id)
if task.task_type not in vision_service.VISION_TASK_TYPES:
raise _422([f"task type {task.task_type!r} does not run a vision recipe"])
station = None
if station_id is not None:
station = await station_service.get_station(db, station_id) # 404 se non esiste
if px_per_mm is None:
calibration, reason = await vision_service.fetch_station_calibration(station)
if calibration is None:
# irraggiungibile e non calibrata sono due guasti diversi per chi lo legge
raise _422([
"acquisitore della stazione non raggiungibile"
if reason == vision_service.CAL_UNREACHABLE else "stazione non calibrata"
])
px_per_mm = float(calibration["px_per_mm"])
image_bytes = await _read_image(image)
dxf_bytes = await dxf.read()
if not validate_file_size(len(dxf_bytes)):
raise _422([f"the DXF exceeds {settings.max_upload_size_mb}MB"])
body = await vision_service.dxf_compare_recipe(
image_bytes, dxf_bytes,
px_per_mm=px_per_mm, band_mm=band_mm, min_points_pct=min_points_pct,
polarity=polarity,
worker_url=(station.vision_worker_url or None) if station is not None else None,
)
# i file stanno nella ricetta salvata, non nella risposta al client
files = body.pop("files")
response = {"result": body, "saved": False, "recipe_dir": None, "outputs": None}
if not save:
return response
uploads: list[tuple[str, bytes]] = []
for name, encoded in files.items():
try:
uploads.append((name, base64.b64decode(encoded, validate=True)))
except (binascii.Error, ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker's answer was malformed: file {name!r} is not base64",
)
placed, errors = vision_service.match_recipe_files(body["graph"], uploads)
if errors:
raise _422(errors)
recipe_dir, check = await _store_validate_assign(task, body["graph"], placed)
await vision_service.ensure_points_quote(db, task, min_points_pct)
await db.commit()
response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"])
return response
+17 -2
View File
@@ -27,6 +27,10 @@ class Settings(BaseSettings):
db_name: str = "tiemeasureflow"
db_user: str = "tmflow"
db_password: str = _DEFAULT_DB_PASSWORD
# Full SQLAlchemy async URL (env DB_URL). When set it wins over the
# DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD parts. Used by the Windows
# package: sqlite+aiosqlite:///C:/ProgramData/.../tmf.db (forward slashes).
db_url: str | None = None
# Presentation time zone (DB is UTC, #42)
display_timezone: str = "Europe/Rome"
@@ -78,7 +82,9 @@ class Settings(BaseSettings):
@property
def database_url(self) -> str:
"""Async MySQL connection string."""
"""Async connection string: DB_URL if set, else the MySQL one."""
if self.db_url:
return self.db_url
return (
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}"
@@ -97,7 +103,12 @@ class Settings(BaseSettings):
(mounted as a Docker volume), not inside the backend tree.
"""
# Path(__file__) = src/backend/config.py → parents[2] = project root
return Path(__file__).resolve().parents[2] / self.upload_dir
# UPLOAD_DIR may be absolute (Windows package: C:/ProgramData/...):
# in that case it is used as is instead of being joined to the root.
configured = Path(self.upload_dir)
if configured.is_absolute():
return configured
return Path(__file__).resolve().parents[2] / configured
# Always resolve .env against the project root regardless of cwd
# (pydantic-settings would otherwise treat the path as cwd-relative).
@@ -122,6 +133,10 @@ class Settings(BaseSettings):
"SERVER_SECRET_KEY is still the committed default value; "
"set it before running with APP_ENV=" + self.app_env
)
# A SQLite file has no DB password: the default-password guard only
# makes sense for the MySQL connection built from the DB_* parts.
if self.db_url and self.db_url.startswith("sqlite"):
return self
if self.db_password == _DEFAULT_DB_PASSWORD:
raise RuntimeError(
"DB_PASSWORD is still the committed default value; "
+25 -8
View File
@@ -1,6 +1,7 @@
"""Async SQLAlchemy database engine and session management."""
from collections.abc import AsyncGenerator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
@@ -10,14 +11,30 @@ from sqlalchemy.orm import DeclarativeBase
from src.backend.config import settings
# Create async engine
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
)
_IS_SQLITE = settings.database_url.startswith("sqlite")
# Create async engine. MySQL gets a connection pool; SQLite (local file of the
# Windows package) does not accept pool_size/max_overflow/pool_recycle.
if _IS_SQLITE:
engine = create_async_engine(settings.database_url, echo=False)
@event.listens_for(engine.sync_engine, "connect")
def _sqlite_pragmas(dbapi_connection, _connection_record) -> None:
"""Per-connection PRAGMAs: SQLite ignores FKs unless asked, and WAL +
busy_timeout let the web, vision and backend threads share the file."""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
else:
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_recycle=3600,
)
# Session factory
async_session_factory = async_sessionmaker(
@@ -22,24 +22,44 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'api_keys',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('key_hash', sa.String(64), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
),
)
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
# Idempotent on purpose (#71): a database born from create_all has no
# `uq_users_api_key` (only the unique index `ix_users_api_key`), and on
# MySQL a run that dies half way (no transactional DDL) leaves `api_keys`
# created and `alembic_version` still at 014. Every step checks first.
insp = sa.inspect(op.get_bind())
if 'api_keys' not in insp.get_table_names():
op.create_table(
'api_keys',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('key_hash', sa.String(64), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
),
)
existing_idx: set[str] = set()
else:
existing_idx = {i['name'] for i in insp.get_indexes('api_keys')}
if 'ix_api_keys_key_hash' not in existing_idx:
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
if 'ix_api_keys_user_id' not in existing_idx:
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
if 'api_key' not in {c['name'] for c in insp.get_columns('users')}:
return # column already gone: nothing else to do
user_idx = {i['name'] for i in insp.get_indexes('users')}
user_uq = {u['name'] for u in insp.get_unique_constraints('users')}
with op.batch_alter_table('users') as batch:
batch.drop_index('ix_users_api_key')
batch.drop_constraint('uq_users_api_key', type_='unique')
if 'ix_users_api_key' in user_idx:
batch.drop_index('ix_users_api_key')
if 'uq_users_api_key' in user_uq:
batch.drop_constraint('uq_users_api_key', type_='unique')
batch.drop_column('api_key')
@@ -0,0 +1,34 @@
"""stations.vision_worker_url e vision_results.worker_url (#45)
stations.vision_worker_url: worker di visione della stazione (NULL = centrale).
vision_results.worker_url: worker effettivamente chiamato per quel risultato.
Revision ID: 019_station_vision_worker
Revises: 018_task_loop_results
Create Date: 2026-10-11
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '019_station_vision_worker'
down_revision: Union[str, None] = '018_task_loop_results'
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('vision_worker_url', sa.String(500), nullable=True))
with op.batch_alter_table('vision_results') as batch:
batch.add_column(sa.Column('worker_url', sa.String(500), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('vision_results') as batch:
batch.drop_column('worker_url')
with op.batch_alter_table('stations') as batch:
batch.drop_column('vision_worker_url')
@@ -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:<seriale>
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')
+51 -1
View File
@@ -2,7 +2,36 @@
from src.backend.models.api.tipi import UtcDateTime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
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(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):
@@ -11,6 +40,15 @@ class StationCreate(BaseModel):
location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None
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):
@@ -18,6 +56,15 @@ class StationUpdate(BaseModel):
location: Optional[str] = Field(default=None, max_length=255)
notes: Optional[str] = None
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):
@@ -28,6 +75,9 @@ class StationResponse(BaseModel):
location: Optional[str]
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
+5
View File
@@ -25,6 +25,11 @@ class Station(Base):
location: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
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()
+2
View File
@@ -26,6 +26,8 @@ class VisionResult(Base):
Integer, ForeignKey("stations.id"), nullable=True
)
device_code: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# URL del worker effettivamente chiamato (#45), copiato: la stazione puo' cambiarlo dopo
worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Copied, not referenced: a calibration redone six months from now must not
# rewrite what a measurement already taken means. Same for the graph.
calibration_snapshot: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
+18
View File
@@ -1,6 +1,7 @@
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
import asyncio
import base64
import tempfile
from datetime import datetime
from src.backend.utils.tempo import to_local, utcnow
from pathlib import Path
@@ -25,6 +26,23 @@ from src.backend.services.spc_service import (
compute_summary,
)
# Kaleido 1.x aggiunge alla pagina di render uno <script> MathJax dal CDN (cdnjs) anche se i grafici
# non usano LaTeX: senza Internet (stand, PC di produzione) ogni grafico aspetta il timeout di rete
# (~19 s con rete che scarta i pacchetti, TieMeasureFlow#78). I grafici qui sotto non hanno formule:
# si passa a Kaleido un file locale vuoto al posto del CDN. Il file sta nella cartella temporanea.
def _mathjax_vuoto() -> str:
"""Percorso di un .js vuoto da usare come MathJax: nessuna richiesta di rete per i grafici."""
path = Path(tempfile.gettempdir()) / "tmf-mathjax-vuoto.js"
if not path.exists():
path.write_text("/* MathJax non usato: grafici senza LaTeX */", encoding="utf-8")
return str(path)
try:
pio.defaults.mathjax = _mathjax_vuoto()
except (AttributeError, OSError): # plotly vecchio senza pio.defaults o temp non scrivibile: resta il CDN
pass
# Jinja2 environment for report templates
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
_jinja_env = Environment(
+3
View File
@@ -31,6 +31,9 @@ async def create_station(
location=data.location,
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)
+204 -7
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import math
from pathlib import Path
from uuid import uuid4
@@ -19,10 +20,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
from src.backend.services import measurement_service
logger = logging.getLogger(__name__)
# Worker responses whose status and reason are forwarded as-is rather than
# flattened into a generic 502 - see `_call_worker`.
_FORWARDED_WORKER_STATUSES = frozenset({
@@ -53,6 +57,7 @@ async def _call_worker(
*,
recipe_dir: str | None = None,
calibration: dict | None = None,
worker_url: str | None = None,
) -> dict:
"""The one place that talks to the worker; the tests replace it.
@@ -70,6 +75,8 @@ async def _call_worker(
# recipe_dir (relativo, #46) e calibration sono opzionali: se assenti non
# si mandano, il worker usa i suoi default.
data = {"graph": json.dumps(graph)}
# worker della stazione (#45) o, se manca, il centrale; mai un ripiego dall'uno all'altro
base = (worker_url or settings.vision_worker_url).rstrip("/")
if recipe_dir:
data["recipe_dir"] = recipe_dir
if calibration:
@@ -77,19 +84,19 @@ async def _call_worker(
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{settings.vision_worker_url}/run",
f"{base}/run",
files={"image": ("frame.png", image_bytes, "image/png")},
data=data,
)
except httpx.TimeoutException as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"the vision worker did not answer in time: {exc}",
detail=f"the vision worker did not answer in time: {exc} ({base})", # #45
) from exc
except (httpx.TransportError, httpx.HTTPError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc}",
detail=f"the vision worker is unreachable: {exc} ({base})", # #45
) from exc
# The worker already gave this fault a name - forward its status and
@@ -136,6 +143,136 @@ async def _call_worker(
return report
async def call_worker_multipart(
path: str,
*,
files: dict,
data: dict,
worker_url: str | None = None,
timeout: float = 120.0,
) -> dict:
"""POST multipart generico al worker (#83): /recipes/dxf-compare.
Stessa traduzione degli errori di `_call_worker`: 504 sul timeout, 502 sul
trasporto o su una risposta non JSON-oggetto; 422/503/504 del worker si
inoltrano cosi' come sono. Worker della stazione o, se manca, il centrale.
"""
base = (worker_url or settings.vision_worker_url).rstrip("/")
try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(f"{base}{path}", files=files, data=data)
except httpx.TimeoutException as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"the vision worker did not answer in time: {exc} ({base})",
) from exc
except (httpx.TransportError, httpx.HTTPError) as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker is unreachable: {exc} ({base})",
) from exc
if response.status_code in _FORWARDED_WORKER_STATUSES:
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
raise HTTPException(status_code=response.status_code, detail=detail)
if response.status_code != 200:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker refused the job: {response.text}",
)
try:
body = response.json()
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the vision worker's answer was malformed: not valid JSON ({exc})",
) from exc
if not isinstance(body, dict):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: expected a JSON object",
)
return body
async def dxf_compare_recipe(
image_bytes: bytes, dxf_bytes: bytes, *, px_per_mm: float, band_mm: float,
min_points_pct: float, polarity: str, worker_url: str | None = None,
) -> dict:
"""W2: ricetta dxf-compare da immagine + DXF (#83). Sostituita dai test."""
body = await call_worker_multipart(
"/recipes/dxf-compare",
files={
"image": ("frame.png", image_bytes, "image/png"),
"dxf": ("disegno.dxf", dxf_bytes, "application/octet-stream"),
},
data={
"px_per_mm": str(px_per_mm), "band_mm": str(band_mm),
"min_points_pct": str(min_points_pct), "polarity": polarity,
},
worker_url=worker_url,
)
# forma minima del contratto W2: senza graph e files non c'e' nulla da salvare
if not isinstance(body.get("graph"), dict) or not isinstance(body.get("files"), dict):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="the vision worker's answer was malformed: missing graph or files",
)
return body
# Stati di fetch_station_calibration (#83)
CAL_OK = "ok"
CAL_NO_CAMERA = "no_camera" # la stazione non ha camera_url/camera_source
CAL_NOT_CALIBRATED = "not_calibrated" # l'acquisitore risponde 404 (o un valore inutilizzabile)
CAL_UNREACHABLE = "unreachable" # l'acquisitore non risponde
async def fetch_station_calibration(station: Station | None) -> tuple[dict | None, str]:
"""Legge la calibrazione dall'acquisitore della stazione (C1, #83).
GET {camera_url}/camera/calibration?source={camera_source}. La calibrazione
vive nell'acquisitore: TMF la legge, non la tiene. Ritorna (dict, "ok")
oppure (None, motivo) per distinguere «non calibrata» da «irraggiungibile».
Un px_per_mm non finito o <= 0 conta come non calibrata.
"""
if station is None or not station.camera_url or not station.camera_source:
return None, CAL_NO_CAMERA
url = f"{station.camera_url.rstrip('/')}/camera/calibration"
try:
# timeout breve: e' una lettura di servizio, non deve bloccare l'execute
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(url, params={"source": station.camera_source})
except httpx.HTTPError as exc:
logger.warning("acquisitore della stazione %s non raggiungibile: %s", station.code, exc)
return None, CAL_UNREACHABLE
if response.status_code == status.HTTP_404_NOT_FOUND:
return None, CAL_NOT_CALIBRATED
if response.status_code != 200:
logger.warning(
"acquisitore della stazione %s: HTTP %s", station.code, response.status_code,
)
return None, CAL_UNREACHABLE
try:
body = response.json()
except ValueError:
logger.warning("acquisitore della stazione %s: risposta non JSON", station.code)
return None, CAL_UNREACHABLE
px_per_mm = body.get("px_per_mm") if isinstance(body, dict) else None
if not (_is_measurable(px_per_mm) and px_per_mm > 0):
return None, CAL_NOT_CALIBRATED
return body, CAL_OK
async def get_station_calibration(station: Station | None) -> dict | None:
"""La calibrazione C1 della stazione, o None in ogni altro caso (#83)."""
calibration, _ = await fetch_station_calibration(station)
return calibration
async def validate_recipe(graph: dict, recipe_dir: str | None) -> dict:
"""Ask the worker's /validate whether a v2 recipe is loadable (#46).
@@ -352,12 +489,23 @@ async def execute_task(
detail="this recipe is not active",
)
run = None
if production_run_id is not None:
# 404 if the run does not exist, 422 if it belongs to another recipe,
# 409 if it is closed or paused - same rule a manual reading follows.
await measurement_service.validate_run_for_version(
run = await measurement_service.validate_run_for_version(
db, production_run_id, version_id,
)
# Worker della stazione della sessione di produzione (#45); senza run o senza URL: centrale
station_url = None
# Calibrazione letta dall'acquisitore della stazione (#83); None = non c'e'
calibration = None
if run is not None:
station = (await db.execute(
select(Station).where(Station.id == run.station_id)
)).scalar_one_or_none()
station_url = (station.vision_worker_url or None) if station is not None else None
calibration = await get_station_calibration(station)
if not task.vision_json:
raise HTTPException(
@@ -407,10 +555,15 @@ async def execute_task(
),
)
# Nessuna fonte di calibrazione esiste oggi (ne' task ne' stazione): non si
# passa `calibration`, la ricetta porta la sua (#46).
# La calibrazione viene dall'acquisitore della stazione (#83); se non c'e',
# non si passa `calibration` e la ricetta porta la sua (#46).
worker_kwargs: dict = {}
if calibration is not None:
worker_kwargs["calibration"] = {"px_per_mm": float(calibration["px_per_mm"])}
report = await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
worker_url=station_url, # #45
**worker_kwargs,
)
outputs = report["outputs"]
@@ -439,8 +592,13 @@ async def execute_task(
image_path=image_path,
overlay_path=overlay_path,
engine_version=report["engine_version"],
executed_on="server",
# #45: dove e' girato davvero, e quale worker (copiato come lo snapshot)
executed_on="station" if station_url else "server",
station_id=run.station_id if run is not None else None,
worker_url=(station_url or settings.vision_worker_url).rstrip("/"),
graph_snapshot=task.vision_json,
# copiata come il grafo (#83): ricalibrare dopo non cambia cio' che e' stato misurato
calibration_snapshot=calibration,
duration_ms=report.get("duration_ms"),
)
db.add(result)
@@ -585,3 +743,42 @@ async def preview(db: AsyncSession, task: RecipeTask, image_bytes: bytes) -> dic
return await _call_worker(
image_bytes, task.vision_json, recipe_dir=task.vision_recipe_dir,
)
async def ensure_points_quote(
db: AsyncSession, task: RecipeTask, min_points_pct: float,
) -> bool:
"""Crea la quota «Punti in tolleranza» se il task non ha nessuna quota di visione (#83).
Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca.
Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router
dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata.
"""
from src.backend.services import recipe_service # import locale: evita cicli
existing = (await db.execute(
select(RecipeSubtask.id)
.where(RecipeSubtask.task_id == task.id)
.where(RecipeSubtask.vision_output.is_not(None))
.limit(1)
)).scalar_one_or_none()
if existing is not None:
return False
if await recipe_service.version_has_measurements(db, task.version_id):
return False
markers = (await db.execute(
select(RecipeSubtask.marker_number).where(RecipeSubtask.task_id == task.id)
)).scalars().all()
db.add(RecipeSubtask(
task_id=task.id,
marker_number=max(markers, default=0) + 1, # unico per task (uq_task_marker)
description="Punti in tolleranza",
nominal=100,
# limiti assoluti come nel resto delle quote: passa se punti% >= min_points_pct
ltl=min_points_pct,
utl=100,
unit="%",
vision_output="dxf.points_in_pct",
))
await db.flush()
return True
+108
View File
@@ -13,7 +13,10 @@ from pathlib import Path
import pytest
from alembic import command
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from alembic.config import Config
import sqlalchemy as sa
from sqlalchemy import create_engine, inspect as sa_inspect, text
from src.backend.config import Settings
@@ -132,6 +135,70 @@ class TestApiKeyHashMigration:
command.upgrade(cfg, "head") # and up again
assert "api_keys" in _inspect(db_path).get_table_names()
def test_015_on_create_all_schema(self, sqlite_database_url):
"""DB nato da create_all: users.api_key ha solo l'indice unique
ix_users_api_key, nessun vincolo uq_users_api_key (#71)."""
db_path, _ = sqlite_database_url
cfg = _alembic_config()
command.upgrade(cfg, "014_vision_fk_ondelete")
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
with ops.batch_alter_table("users") as batch:
batch.drop_index("ix_users_api_key")
batch.drop_constraint("uq_users_api_key", type_="unique")
ops.create_index("ix_users_api_key", "users", ["api_key"], unique=True)
engine.dispose()
insp = _inspect(db_path)
assert not insp.get_unique_constraints("users") or "uq_users_api_key" not in {
u["name"] for u in insp.get_unique_constraints("users")
}
command.upgrade(cfg, "head") # non deve sollevare
insp = _inspect(db_path)
assert "api_keys" in insp.get_table_names()
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
def test_015_resumes_after_partial_run(self, sqlite_database_url):
"""015 a meta' (MySQL, DDL non transazionale): api_keys creata e
ix_users_api_key tolto, alembic_version ancora 014 (#71)."""
db_path, _ = sqlite_database_url
cfg = _alembic_config()
command.upgrade(cfg, "014_vision_fk_ondelete")
engine = create_engine(f"sqlite:///{db_path}")
with engine.begin() as conn:
ops = Operations(MigrationContext.configure(conn))
ops.create_table(
"api_keys",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("key_hash", sa.String(64), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"],
name="fk_api_keys_user_id", ondelete="CASCADE",
),
)
ops.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"], unique=True)
ops.drop_index("ix_users_api_key", table_name="users")
engine.dispose()
command.upgrade(cfg, "head") # non deve sollevare
insp = _inspect(db_path)
assert "api_keys" in insp.get_table_names()
assert {i["name"] for i in insp.get_indexes("api_keys")} >= {
"ix_api_keys_key_hash", "ix_api_keys_user_id",
}
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
class TestVisionRecipeDirMigration:
"""016_vision_recipe_dir: recipe_tasks.vision_recipe_dir, su e giu (#46)."""
@@ -233,3 +300,44 @@ class TestTaskLoopResultsMigration:
with pytest.raises(RuntimeError, match="check"):
command.downgrade(cfg, "017_user_pin")
class TestStationVisionWorkerMigration:
"""019_station_vision_worker: stations.vision_worker_url e vision_results.worker_url, su e giu (#45)."""
def test_019_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 "vision_worker_url" in cols("stations")
assert "worker_url" in cols("vision_results")
command.downgrade(cfg, "018_task_loop_results")
assert "vision_worker_url" not in cols("stations")
assert "worker_url" not in cols("vision_results")
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")
+164
View File
@@ -0,0 +1,164 @@
"""Report PDF veri su Windows (WeasyPrint + DLL GTK + Kaleido con Edge/Chrome), offline.
Pacchetto Windows (TieMeasureFlow#78): senza Internet i report devono uscire lo stesso.
Il test gira solo su Windows, con le DLL di packaging/windows/pdf/prendi-gtk.ps1 e un browser
Chromium (Edge o Chrome) installati; altrimenti viene saltato.
Il conftest dei test sostituisce WeasyPrint con un mock: qui serve quello vero, quindi la
generazione gira in un sottoprocesso che importa weasyprint PRIMA del conftest (il mock scatta
solo se weasyprint non e' gia' in sys.modules).
Come preparare le DLL: powershell -File packaging/windows/pdf/prendi-gtk.ps1 -Destinazione packaging/windows/pdf/gtk
(oppure WEASYPRINT_DLL_DIRECTORIES gia' impostata su una cartella con le DLL).
"""
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
GTK_DEFAULT = REPO / "packaging" / "windows" / "pdf" / "gtk"
# Browser Chromium cercati in ordine (Edge e' presente su ogni Windows 10/11).
_BROWSER_CANDIDATI = [
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
]
def _cartella_gtk() -> Path | None:
"""Cartella DLL: WEASYPRINT_DLL_DIRECTORIES (primo elemento) o quella di prendi-gtk.ps1."""
env = os.environ.get("WEASYPRINT_DLL_DIRECTORIES", "").split(";")[0]
for c in (Path(env) if env else None, GTK_DEFAULT):
if c and (c / "libpango-1.0-0.dll").exists():
return c
return None
def _browser() -> str | None:
env = os.environ.get("BROWSER_PATH")
if env and Path(env).exists():
return env
return next((b for b in _BROWSER_CANDIDATI if Path(b).exists()), None)
def _tz_ok() -> bool:
"""Windows non ha il database IANA: serve il pacchetto tzdata (to_local usa ZoneInfo)."""
try:
from zoneinfo import ZoneInfo
ZoneInfo("Europe/Rome")
return True
except Exception:
return False
pytestmark = [
pytest.mark.skipif(sys.platform != "win32", reason="solo Windows"),
pytest.mark.skipif(_cartella_gtk() is None, reason="DLL GTK assenti: lancia prendi-gtk.ps1"),
pytest.mark.skipif(_browser() is None, reason="nessun Edge/Chrome installato"),
# Senza tzdata i report falliscono su Windows (ZoneInfoNotFoundError): il pacchetto deve dichiararlo.
pytest.mark.skipif(not _tz_ok(), reason="manca tzdata: aggiungere 'tzdata; sys_platform == \"win32\"' a pyproject"),
]
# Sottoprocesso: dati veri nel DB SQLite in memoria del conftest, poi i due report reali.
_WORKER = r"""
import asyncio, json, sys
sys.path.insert(0, sys.argv[1])
import weasyprint # VERO, prima del conftest (che altrimenti lo sostituisce con un mock)
assert not type(weasyprint.HTML).__module__.startswith("unittest"), "WeasyPrint mockato"
from datetime import datetime, timedelta
from src.backend.tests.conftest import Base, TestSessionFactory, test_engine, _create_user, create_test_recipe
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.task import RecipeSubtask
from src.backend.services import report_service
from sqlalchemy import select
VALORI = [10.02, 9.98, 10.05, 10.11, 9.94, 10.0, 10.07, 9.96, 10.03, 10.09, 9.99, 10.04, 10.38, 10.01, 9.97]
async def main():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestSessionFactory() as db:
user = await _create_user(db, "metrologo", roles=["Metrologist"])
recipe = await create_test_recipe(db, user.id)
version = recipe.versions[0]
sub = (await db.execute(select(RecipeSubtask))).scalars().first()
t0 = datetime.utcnow() - timedelta(hours=1)
for i, v in enumerate(VALORI):
pf = "warning" if v > 10.3 else "pass"
db.add(Measurement(subtask_id=sub.id, version_id=version.id, measured_by=user.id,
value=v, pass_fail=pf, measured_at=t0 + timedelta(minutes=i)))
await db.commit()
spc = await report_service.generate_spc_report(db, recipe.id, sub.id)
mis = await report_service.generate_measurement_report(db, recipe.id)
open(sys.argv[2] + "/spc.pdf", "wb").write(spc)
open(sys.argv[2] + "/misure.pdf", "wb").write(mis)
# MathJax locale: senza, Kaleido chiede lo script al CDN e senza Internet ogni grafico attende ~19 s.
from pathlib import Path
mj = report_service.pio.defaults.mathjax
print(json.dumps({"ok": True, "mathjax_locale": bool(mj) and Path(mj).is_file()}))
asyncio.run(main())
"""
@pytest.fixture(scope="module")
def pdf_generati(tmp_path_factory):
"""Genera i due PDF in un sottoprocesso con proxy finto: se serve Internet, fallisce."""
out = tmp_path_factory.mktemp("report_win")
env = dict(os.environ)
env.update(
WEASYPRINT_DLL_DIRECTORIES=str(_cartella_gtk()),
BROWSER_PATH=_browser(),
# Proxy morto per la parte Python (Chrome/Edge usano invece il proxy di sistema: vedi LEGGIMI.md).
HTTP_PROXY="http://127.0.0.1:9", HTTPS_PROXY="http://127.0.0.1:9",
PYTHONIOENCODING="utf-8",
)
# Il sottoprocesso non deve leggere il .env di sviluppo ne' un DB reale.
env.pop("DB_URL", None)
res = subprocess.run(
[sys.executable, "-W", "ignore", "-c", _WORKER, str(REPO), str(out)],
cwd=REPO, env=env, capture_output=True, text=True, timeout=180,
)
assert res.returncode == 0, f"sottoprocesso fallito:\n{res.stdout}\n{res.stderr}"
esito = json.loads(res.stdout.strip().splitlines()[-1])
assert esito["ok"] is True
assert esito["mathjax_locale"], "Kaleido userebbe MathJax dal CDN: niente rete = report lenti"
return out
def _pdf(path: Path):
import pdfplumber
return pdfplumber.open(path)
def test_spc_ha_carta_di_controllo_e_istogramma(pdf_generati):
"""Il report SPC e' un PDF vero con una pagina almeno e i due grafici disegnati."""
path = pdf_generati / "spc.pdf"
assert path.read_bytes()[:5] == b"%PDF-"
with _pdf(path) as pdf:
assert len(pdf.pages) >= 1
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
# I titoli stanno dentro l'SVG di Plotly: se ci sono, Kaleido ha reso e WeasyPrint ha disegnato.
assert "Control Chart" in testo
assert "Histogram" in testo
# Grafico presente = segni vettoriali (tracce, assi, barre) oltre al testo.
segni = sum(len(p.curves) + len(p.lines) + len(p.rects) for p in pdf.pages)
assert segni > 40, f"troppo pochi segni vettoriali ({segni}): grafico assente?"
def test_report_misure_e_pdf_vero(pdf_generati):
"""Anche il report a tabella (solo WeasyPrint, niente Kaleido) esce con le righe misurate."""
path = pdf_generati / "misure.pdf"
assert path.read_bytes()[:5] == b"%PDF-"
with _pdf(path) as pdf:
assert len(pdf.pages) >= 1
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
assert "10.3800" in testo # il valore anomalo e' in tabella
+194
View File
@@ -0,0 +1,194 @@
"""Test di scripts/seed_fiera.py (issue #85): il seed del PC della fiera.
Lo script non e' un modulo del pacchetto: si carica da file con importlib, come
test_seed_collaudo. La parte "dato" gira sul DB in memoria dei test; l'ultimo test
percorre invece la strada vera (file SQLite con `alembic upgrade head` + --db).
"""
import importlib.util
import sqlite3
from argparse import Namespace
from datetime import date
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import func, select
from src.backend.config import Settings, settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import Recipe
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.models.orm.user import User
from src.backend.services.auth_service import authenticate_user
from src.backend.tests.conftest import TestSessionFactory, test_engine
_SCRIPT_PATH = Path(__file__).resolve().parents[3] / "scripts" / "seed_fiera.py"
_ALEMBIC_INI = Path(__file__).resolve().parents[1] / "migrations" / "alembic.ini"
FINE = date(2026, 10, 22) # data fissa: il test non dipende dal giorno in cui gira
@pytest.fixture
def seed_fiera(monkeypatch):
spec = importlib.util.spec_from_file_location("seed_fiera", _SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
monkeypatch.setattr(module, "async_session_factory", TestSessionFactory)
return module
async def _utente(db_session, username) -> User:
return (await db_session.execute(
select(User).where(User.username == username)
)).scalar_one()
class TestSeedFiera:
async def test_utenti_ruoli_e_login(self, seed_fiera, db_session):
assert await seed_fiera.seed(False, False, FINE) == 0
attesi = {
"adriano": ("123", True),
"maker": ("123", False),
"capoturno": ("123", False),
"operatore": ("123", False),
}
for username, (password, admin) in attesi.items():
utente = await authenticate_user(db_session, username, password)
assert utente is not None, username
assert utente.is_admin is admin
assert (await _utente(db_session, "maker")).roles == ["Maker"]
assert "Supervisor" in (await _utente(db_session, "capoturno")).roles
assert (await _utente(db_session, "operatore")).roles == ["MeasurementTec"]
assert {"Maker", "Metrologist"} <= set((await _utente(db_session, "adriano")).roles)
async def test_setup_risulta_completato(self, seed_fiera, client, monkeypatch):
import src.backend.api.routers.setup as setup_mod
monkeypatch.setattr(settings, "setup_password", "test-setup-password")
monkeypatch.setattr(setup_mod, "engine", test_engine)
monkeypatch.setattr(setup_mod, "async_session_factory", TestSessionFactory)
# prima del seed il frontend vede il setup da fare, dopo no
assert (await client.get("/api/setup/status")).json() == {"initialised": False}
await seed_fiera.seed(False, False, FINE)
assert (await client.get("/api/setup/status")).json() == {"initialised": True}
async def test_stazione_e_ricette(self, seed_fiera, db_session):
await seed_fiera.seed(False, False, FINE)
stazione = (await db_session.execute(
select(Station).where(Station.code == "ST-DEFAULT")
)).scalar_one()
assert stazione.camera_url == "http://127.0.0.1:8765"
assert stazione.camera_source == "camera:601020"
assegnate = (await db_session.execute(
select(Recipe.code)
.join(StationRecipeAssignment, StationRecipeAssignment.recipe_id == Recipe.id)
.where(StationRecipeAssignment.station_id == stazione.id)
)).scalars().all()
assert sorted(assegnate) == ["FIERA-ALBERO", "FIERA-DXF", "FIERA-FLANGIA"]
# la ricetta DXF ha un task dxf_compare e nessun grafo di visione
from src.backend.models.orm.task import RecipeTask
dxf = (await db_session.execute(
select(RecipeTask).where(RecipeTask.task_type == "dxf_compare")
)).scalars().all()
assert len(dxf) == 1 and dxf[0].vision_json is None
async def test_misure_nel_numero_atteso(self, seed_fiera, db_session):
await seed_fiera.seed(False, False, FINE)
# ogni pezzo ha una misura per quota: pezzi 150-300 per ricetta
for codice, quote in (("FIERA-FLANGIA", 5), ("FIERA-ALBERO", 4)):
misure = (await db_session.execute(
select(func.count(Measurement.id))
.where(Measurement.version_id.in_(
select(seed_fiera.RecipeVersion.id)
.join(Recipe, Recipe.id == seed_fiera.RecipeVersion.recipe_id)
.where(Recipe.code == codice)
))
)).scalar_one()
assert misure % quote == 0
assert 150 <= misure // quote <= 300, (codice, misure // quote)
# lun-ven soltanto, e le misure compaiono nelle produzioni
giorni = (await db_session.execute(
select(seed_fiera.ProductionRun.started_at)
)).scalars().all()
assert giorni and all(g.weekday() < 5 for g in giorni)
senza_run = (await db_session.execute(
select(func.count(Measurement.id)).where(Measurement.production_run_id.is_(None))
)).scalar_one()
assert senza_run == 0
async def test_statistiche_numeriche(self, seed_fiera, client, db_session):
await seed_fiera.seed(False, False, FINE)
adriano = await _utente(db_session, "adriano")
from src.backend.services.auth_service import issue_api_key
chiave = await issue_api_key(db_session, adriano)
intestazioni = {"X-API-Key": chiave}
flangia = (await db_session.execute(
select(Recipe).where(Recipe.code == "FIERA-FLANGIA")
)).scalar_one()
quote = (await client.get(
"/api/statistics/subtasks", headers=intestazioni,
params={"recipe_id": flangia.id},
)).json()
assert len(quote) == 5
cpk = []
for quota in quote:
risposta = await client.get(
"/api/statistics/capability", headers=intestazioni,
params={"recipe_id": flangia.id, "subtask_id": quota["id"]},
)
assert risposta.status_code == 200
d = risposta.json()
for campo in ("mean", "std_dev", "cp", "cpk"):
assert isinstance(d[campo], (int, float)), (quota["id"], campo)
cpk.append(d["cpk"])
# Cpk diversi per quota: una capace, una al limite o sotto
assert max(cpk) > 1.4
assert min(cpk) < 1.1
async def test_secondo_giro_non_duplica_e_replace_rifa(self, seed_fiera, db_session):
async def conta():
return [
(await db_session.execute(select(func.count(t.id)))).scalar_one()
for t in (Recipe, Measurement, User)
]
await seed_fiera.seed(False, False, FINE)
primo = await conta()
await seed_fiera.seed(False, False, FINE)
assert await conta() == primo # utenti aggiornati, ricette e misure non duplicate
await seed_fiera.seed(True, False, FINE)
assert await conta() == primo # stessi numeri: il seme e' fisso
async def test_dry_run_non_salva(self, seed_fiera, db_session):
await seed_fiera.seed(False, True, FINE)
assert (await db_session.execute(select(func.count(User.id)))).scalar_one() == 0
assert (await db_session.execute(select(func.count(Measurement.id)))).scalar_one() == 0
async def test_file_sqlite_con_alembic(seed_fiera, tmp_path, monkeypatch):
"""La strada vera: schema da `alembic upgrade head`, poi il seed con --db."""
db = tmp_path / "tmf.db"
url = f"sqlite+aiosqlite:///{db.as_posix()}"
monkeypatch.setattr(Settings, "database_url", property(lambda self: url))
# alembic fa asyncio.run(): fuori dal loop di pytest-asyncio, in un thread
import asyncio
await asyncio.to_thread(command.upgrade, Config(str(_ALEMBIC_INI)), "head")
argomenti = Namespace(db=db, replace=False, dry_run=False, fine=FINE.isoformat())
assert await seed_fiera._main_async(argomenti) == 0
con = sqlite3.connect(db)
try:
assert con.execute("SELECT COUNT(*) FROM users WHERE is_admin=1").fetchone()[0] == 1
assert con.execute("SELECT COUNT(*) FROM recipes").fetchone()[0] == 3
assert con.execute("SELECT COUNT(*) FROM measurements").fetchone()[0] > 1000
finally:
con.close()
+81
View File
@@ -0,0 +1,81 @@
"""init-db / reset-db con _stamp_head REALE su un file SQLite (TieMeasureFlow#80).
_stamp_head esegue migrations/env.py, che chiama asyncio.run(): dentro il loop
gia' attivo di FastAPI dava RuntimeError -> 500. test_setup.py e
test_station_seed.py sostituiscono _stamp_head con un no-op e non lo vedevano:
qui NON si fa monkeypatch di _stamp_head.
"""
import logging.config
import sqlite3
import pytest
from alembic.config import Config
from alembic.script import ScriptDirectory
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from src.backend.config import settings
from src.backend.main import app
SETUP_PWD = "test-setup-password"
@pytest.fixture
def file_db(tmp_path, monkeypatch):
"""Punta settings, engine e session factory del router setup a un file SQLite."""
import src.backend.api.routers.setup as setup_mod
db_file = tmp_path / "t.db"
url = f"sqlite+aiosqlite:///{db_file.as_posix()}"
# env.py ricava sqlalchemy.url da settings.database_url (property su db_url)
monkeypatch.setattr(settings, "db_url", url)
monkeypatch.setattr(settings, "setup_password", SETUP_PWD)
file_engine = create_async_engine(url, echo=False)
monkeypatch.setattr(setup_mod, "engine", file_engine)
monkeypatch.setattr(
setup_mod, "async_session_factory",
async_sessionmaker(file_engine, expire_on_commit=False),
)
# env.py chiama fileConfig, che disattiverebbe i logger degli altri test
monkeypatch.setattr(logging.config, "fileConfig", lambda *a, **k: None)
return db_file, setup_mod, file_engine
def _head() -> str:
from src.backend.api.routers.setup import _alembic_ini
cfg = Config(str(_alembic_ini))
return ScriptDirectory.from_config(cfg).get_current_head()
def _versions(db_file) -> list[str]:
con = sqlite3.connect(db_file)
try:
return [r[0] for r in con.execute("SELECT version_num FROM alembic_version")]
finally:
con.close()
@pytest.fixture
async def http():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac:
yield ac
@pytest.mark.asyncio
async def test_init_db_stamps_head(file_db, http):
db_file, _, file_engine = file_db
resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200, resp.text
assert _versions(db_file) == [_head()]
await file_engine.dispose()
@pytest.mark.asyncio
async def test_reset_db_stamps_head(file_db, http):
db_file, _, file_engine = file_db
resp = await http.post("/api/setup/init-db", json={"password": SETUP_PWD})
assert resp.status_code == 200, resp.text
resp = await http.post("/api/setup/reset-db", json={"password": SETUP_PWD, "confirm": True})
assert resp.status_code == 200, resp.text
assert _versions(db_file) == [_head()]
await file_engine.dispose()
+144
View File
@@ -0,0 +1,144 @@
"""Backend su un file SQLite locale (pacchetto Windows, TieMeasureFlow#77).
Il modulo `database` costruisce l'engine all'import leggendo l'ambiente, quindi
i test che riguardano DB_URL girano in un sottoprocesso con DB_URL/UPLOAD_DIR
impostati: e' lo stesso percorso di avvio.py nel pacchetto, senza toccare
l'engine in memoria usato dal resto della suite.
"""
import json
import os
import subprocess
import sys
import textwrap
from pathlib import Path
from src.backend.config import Settings
_ROOT = Path(__file__).resolve().parents[3]
# Script eseguito nel sottoprocesso: upgrade head da DB vuoto, poi app reale
# (health, login con utente creato via ORM), PRAGMA e vincolo FK.
_SCRIPT = textwrap.dedent(
"""
import asyncio, json, sys
from pathlib import Path
from unittest.mock import MagicMock
# WeasyPrint senza GTK: stesso mock di conftest.py (le DLL sono affare del piano B)
sys.modules.setdefault("weasyprint", MagicMock())
from alembic import command
from alembic.config import Config
from src.backend.config import settings
ini = Path(r"{ini}")
command.upgrade(Config(str(ini)), "head")
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select, text
from sqlalchemy.exc import IntegrityError
from src.backend.database import async_session_factory, engine
from src.backend.main import app
from src.backend.models.orm.user import User
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.services.auth_service import hash_password
async def main():
out = {{}}
async with async_session_factory() as s:
s.add(User(username="fiera", password_hash=hash_password("Pass12345"),
display_name="Fiera", roles=["Maker"], is_admin=True))
await s.commit()
async with async_session_factory() as s:
out["utente"] = (await s.execute(select(User.username))).scalars().all()
out["fk"] = (await s.execute(text("PRAGMA foreign_keys"))).scalar()
out["journal"] = (await s.execute(text("PRAGMA journal_mode"))).scalar()
out["busy"] = (await s.execute(text("PRAGMA busy_timeout"))).scalar()
# FK attive: una versione con recipe_id inesistente deve essere rifiutata
try:
async with async_session_factory() as s:
s.add(RecipeVersion(recipe_id=99999, version_number=1, created_by=1))
await s.commit()
out["fk_violation"] = False
except IntegrityError:
out["fk_violation"] = True
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
out["health"] = (await c.get("/api/health")).status_code
r = await c.post("/api/auth/login", json={{"username": "fiera", "password": "Pass12345"}})
out["login"] = r.status_code
out["upload_path"] = str(settings.upload_path)
out["database_url"] = settings.database_url
await engine.dispose()
print("RISULTATO=" + json.dumps(out))
asyncio.run(main())
"""
)
def _run(tmp_path: Path, extra_env: dict[str, str]) -> dict:
ini = _ROOT / "src" / "backend" / "migrations" / "alembic.ini"
env = {k: v for k, v in os.environ.items() if k not in ("DB_URL", "UPLOAD_DIR")}
env.update(extra_env)
env["PYTHONPATH"] = str(_ROOT)
env["PYTHONIOENCODING"] = "utf-8"
proc = subprocess.run(
[sys.executable, "-c", _SCRIPT.format(ini=str(ini))],
cwd=str(_ROOT), env=env, capture_output=True, text=True, timeout=180,
)
assert proc.returncode == 0, proc.stderr[-3000:]
line = [l for l in proc.stdout.splitlines() if l.startswith("RISULTATO=")][-1]
return json.loads(line[len("RISULTATO="):])
def test_file_sqlite_upgrade_app_pragma_e_upload_assoluto(tmp_path):
db = tmp_path / "tmf.db"
uploads = tmp_path / "uploads"
# barre "/" nell'URL come nel pacchetto Windows (D:/... non va in escape)
url = f"sqlite+aiosqlite:///{db.as_posix()}"
out = _run(tmp_path, {"DB_URL": url, "UPLOAD_DIR": str(uploads)})
assert out["database_url"] == url
assert db.exists() # il file e' stato creato da upgrade head
assert out["utente"] == ["fiera"]
assert out["health"] == 200
assert out["login"] == 200
assert out["fk"] == 1 # PRAGMA foreign_keys attivo
assert out["fk_violation"] is True
assert out["journal"] == "wal"
assert out["busy"] == 5000
# UPLOAD_DIR assoluto rispettato (non accodato alla radice del progetto)
assert Path(out["upload_path"]) == uploads
def test_database_url_senza_db_url_resta_mysql(monkeypatch):
monkeypatch.delenv("DB_URL", raising=False)
s = Settings(_env_file=None, db_url=None)
assert s.database_url.startswith("mysql+asyncmy://")
def test_db_url_vince_sulle_parti_db(monkeypatch):
s = Settings(_env_file=None, db_url="sqlite+aiosqlite:///C:/x/tmf.db", db_host="altro")
assert s.database_url == "sqlite+aiosqlite:///C:/x/tmf.db"
def test_produzione_con_sqlite_non_richiede_db_password():
# niente RuntimeError: con SQLite la password DB di default non conta
Settings(
_env_file=None, app_env="production", server_secret_key="x" * 40,
db_url="sqlite+aiosqlite:///C:/x/tmf.db",
)
def test_produzione_mysql_rifiuta_ancora_password_di_default():
import pytest
with pytest.raises(Exception, match="DB_PASSWORD"):
Settings(_env_file=None, app_env="production", server_secret_key="x" * 40)
def test_upload_dir_relativo_resta_sotto_la_radice():
s = Settings(_env_file=None, upload_dir="uploads")
assert s.upload_path == _ROOT / "uploads"
def test_upload_dir_assoluto(tmp_path):
s = Settings(_env_file=None, upload_dir=str(tmp_path / "u"))
assert s.upload_path == tmp_path / "u"
+156
View File
@@ -374,3 +374,159 @@ async def test_reset_unknown_station_is_404(client: AsyncClient, admin_user):
"/api/stations/999999/recipes", headers=auth_headers(admin_user),
)
assert resp.status_code == 404
# --- vision_worker_url (#45) ---
async def test_station_vision_worker_url_roundtrip(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations",
headers=auth_headers(admin_user),
json={"code": "ST-VW", "name": "VW", "vision_worker_url": " http://10.0.0.7:8100/ "},
)
assert created.status_code == 201, created.text
# spazi tolti e "/" finale tolto
assert created.json()["vision_worker_url"] == "http://10.0.0.7:8100"
sid = created.json()["id"]
updated = await client.put(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
json={"vision_worker_url": "https://w.example:9000"},
)
assert updated.status_code == 200
assert updated.json()["vision_worker_url"] == "https://w.example:9000"
# stringa vuota = torna al worker centrale
cleared = await client.put(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
json={"vision_worker_url": ""},
)
assert cleared.status_code == 200
assert cleared.json()["vision_worker_url"] is None
async def test_station_vision_worker_url_defaults_to_null(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-VN", "name": "VN"},
)
assert created.status_code == 201
assert created.json()["vision_worker_url"] is None
async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin_user):
resp = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-VF", "name": "VF", "vision_worker_url": "ftp://x"},
)
assert resp.status_code == 422
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-VG", "name": "VG"},
)
resp = await client.put(
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
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
+113
View File
@@ -287,3 +287,116 @@ class TestRunFilter:
headers=auth_headers(measurement_tec_user),
params={"recipe_id": 1})
assert resp.status_code == 403
class TestRunTaskResults:
"""#58: GET /api/statistics/runs/{id}/task-results."""
FIELDS = [
{"key": "peso", "label": "Peso", "type": "number", "default": None, "attachment": False},
{"key": "foto", "label": "Foto", "type": "text", "default": None, "attachment": True},
]
async def _setup(self, client, db_session, maker_user, tec_user):
from datetime import datetime
from sqlalchemy import select
from src.backend.models.orm.production import ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task_result import TaskResult
recipe = await create_test_recipe(db_session, maker_user.id, code="TR58")
version = (await db_session.execute(
select(RecipeVersion).where(RecipeVersion.recipe_id == recipe.id)
)).scalars().first()
db_session.add(Station(code="ST-TR58", name="S", active=True, created_by=maker_user.id))
await db_session.commit()
station = (await db_session.execute(select(Station))).scalars().first()
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=tec_user.id, status="open", started_at=datetime(2026, 1, 1),
)
db_session.add(run)
await db_session.commit()
h = auth_headers(maker_user)
check = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h,
json={"title": "Controllo", "task_type": "check"},
)).json()
note = (await client.post(
f"/api/recipes/{recipe.id}/tasks", headers=h,
json={"title": "Nota", "task_type": "note", "fields": self.FIELDS},
)).json()
# Inseriti fuori ordine: l'API deve ordinare per created_at.
rows = [
TaskResult(task_id=note["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="done", note="ok finito",
values={"peso": "12", "foto": "x.jpg"},
attachments=[{"key": "foto", "path": "task_results/a.jpg", "name": "a.jpg"}],
created_at=datetime(2026, 1, 1, 10, 3)),
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="nok", note="graffio",
created_at=datetime(2026, 1, 1, 10, 2)),
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=run.id,
user_id=tec_user.id, outcome="ok",
created_at=datetime(2026, 1, 1, 10, 1)),
# Un esito di un'altra sessione (nessuna) non deve comparire.
TaskResult(task_id=check["id"], version_id=version.id, production_run_id=None,
user_id=tec_user.id, outcome="ok",
created_at=datetime(2026, 1, 1, 9, 0)),
]
db_session.add_all(rows)
await db_session.commit()
return recipe, run, check, note
async def test_counts_order_labels_attachments(
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
):
_, run, check, note = await self._setup(
client, db_session, maker_user, measurement_tec_user)
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
headers=auth_headers(metrologist_user))
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["run_id"] == run.id
assert data["counts"] == {"ok": 1, "nok": 1, "done": 1}
res = data["results"]
assert [r["outcome"] for r in res] == ["ok", "nok", "done"] # per ora crescente
assert res[1]["note"] == "graffio"
assert res[1]["task_title"] == "Controllo"
assert res[1]["task_type"] == "check"
assert res[1]["user_name"] == measurement_tec_user.display_name
assert res[1]["values"] == [] and res[1]["attachments"] == []
n = res[2]
assert n["task_title"] == "Nota" and n["note"] == "ok finito"
assert n["values"] == [
{"key": "peso", "label": "Peso", "value": "12"},
{"key": "foto", "label": "Foto", "value": "x.jpg"},
]
assert n["attachments"] == [
{"key": "foto", "label": "Foto", "name": "a.jpg", "path": "task_results/a.jpg"},
]
async def test_label_falls_back_to_key_when_field_removed(
self, client, maker_user, measurement_tec_user, metrologist_user, db_session,
):
from src.backend.models.orm.task import RecipeTask
_, run, _, note = await self._setup(
client, db_session, maker_user, measurement_tec_user)
task = await db_session.get(RecipeTask, note["id"])
task.fields = [self.FIELDS[0]] # il campo «foto» viene tolto dal task
await db_session.commit()
resp = await client.get(f"/api/statistics/runs/{run.id}/task-results",
headers=auth_headers(metrologist_user))
n = resp.json()["results"][2]
assert {v["key"]: v["label"] for v in n["values"]} == {"peso": "Peso", "foto": "foto"}
assert n["attachments"][0]["label"] == "foto"
async def test_unknown_run_is_404(self, client, metrologist_user):
resp = await client.get("/api/statistics/runs/99999/task-results",
headers=auth_headers(metrologist_user))
assert resp.status_code == 404
async def test_measurement_tec_is_403(self, client, measurement_tec_user):
resp = await client.get("/api/statistics/runs/1/task-results",
headers=auth_headers(measurement_tec_user))
assert resp.status_code == 403
@@ -0,0 +1,338 @@
"""#83: ricetta DXF compare da camera e calibrazione letta dall'acquisitore.
Il worker (W2) e l'acquisitore (C1) non girano nei test: si fingono, come
fanno gli altri test di visione. La calibrazione vive nell'acquisitore esterno,
TMF la legge via GET {camera_url}/camera/calibration.
"""
import base64
import json
import httpx
import pytest
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask
from src.backend.models.orm.vision import VisionResult
from src.backend.services import vision_service
from src.backend.tests.conftest import auth_headers
from src.backend.tests.test_vision_recipe_upload import an_image, make_task
from src.backend.tests.test_vision_station_worker import ( # noqa: F401 (fixture worker_calls)
_camera_task, _execute, _station_with_run, worker_calls,
)
CAMERA_URL = "http://10.0.0.9:8200"
CAL = {
"source": "camera:A1", "px_per_mm": 12.5, "mm_per_px": 0.08, "rms_px": 0.2,
"n_corners": 40, "cols": 8, "rows": 5, "square_mm": 5.0, "image_width": 640,
"image_height": 480, "scale_spread_pct": 0.1, "calibrated_at": "2026-10-23T10:00:00",
}
GRAPH = {
"schema_version": 2, "name": "dxf-compare", "tools": [],
"meta": {"dxf": {"disegno": {"path": "disegno.dxf"}}, "calibration": {"path": "calibrazione.json"}},
}
OUTPUTS = [{"key": "dxf.points_in_pct", "quantity": "ratio", "unit": "%"}]
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode()
@pytest.fixture(autouse=True)
def redirect_uploads(monkeypatch, tmp_path):
# upload_path = parents[2] / upload_dir: un assoluto sostituisce la base
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
@pytest.fixture
def w2(monkeypatch):
"""Finto worker W2: ricorda le chiamate e risponde col corpo del contratto."""
calls: list[dict] = []
async def fake(image_bytes, dxf_bytes, **kwargs):
calls.append({"dxf": dxf_bytes, **kwargs})
return {
"graph": GRAPH,
"files": {
"disegno.dxf": _b64(dxf_bytes),
"calibrazione.json": _b64(json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()),
},
"outputs": {"dxf.points_in_pct": 99.0},
"overlay": [], "outcome": "pass",
}
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake)
return calls
@pytest.fixture
def validate_ok(monkeypatch):
async def fake(graph, recipe_dir):
return {"ok": True, "errors": [], "outputs": OUTPUTS}
monkeypatch.setattr("src.backend.services.vision_service.validate_recipe", fake)
@pytest.fixture
def acquisitore(monkeypatch):
"""Finto C1: `state['cal']` = dict (200), None (404), 'down' (irraggiungibile)."""
state: dict = {"cal": CAL, "urls": []}
class _Resp:
def __init__(self, code, body=None):
self.status_code, self._body, self.text = code, body, ""
def json(self):
return self._body
class _Client:
def __init__(self, *a, **k):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def get(self, url, params=None):
state["urls"].append((url, params))
if state["cal"] == "down":
raise httpx.ConnectError("giu'")
if state["cal"] is None:
return _Resp(404)
return _Resp(200, state["cal"])
monkeypatch.setattr(httpx, "AsyncClient", _Client)
return state
async def _station(db_session, user, *, camera=True, worker_url=None, code="ST-C"):
station = Station(
code=code, name="Stazione C", active=True, created_by=user.id,
vision_worker_url=worker_url,
camera_url=CAMERA_URL if camera else None,
camera_source="camera:A1" if camera else None,
)
db_session.add(station)
await db_session.commit()
await db_session.refresh(station)
return station
def _post(client, user, task, *, save=False, **data):
return client.post(
f"/api/vision/tasks/{task.id}/recipe/dxf-compare",
headers=auth_headers(user),
files={
"image": ("p.png", an_image(), "image/png"),
"dxf": ("d.dxf", b"0\nEOF", "application/octet-stream"),
},
data={"save": str(save).lower(), **{k: str(v) for k, v in data.items() if v is not None}},
)
# --- lettura della calibrazione dall'acquisitore (C1) -------------------------
@pytest.mark.asyncio
async def test_fetch_calibration_ok(db_session, maker_user, acquisitore):
station = await _station(db_session, maker_user)
cal, reason = await vision_service.fetch_station_calibration(station)
assert (cal, reason) == (CAL, "ok")
assert acquisitore["urls"] == [(f"{CAMERA_URL}/camera/calibration", {"source": "camera:A1"})]
@pytest.mark.asyncio
async def test_fetch_calibration_reasons(db_session, maker_user, acquisitore):
station = await _station(db_session, maker_user)
acquisitore["cal"] = None
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
acquisitore["cal"] = "down"
assert await vision_service.fetch_station_calibration(station) == (None, "unreachable")
assert await vision_service.get_station_calibration(station) is None
acquisitore["cal"] = {**CAL, "px_per_mm": 0} # valore inutilizzabile = non calibrata
assert await vision_service.fetch_station_calibration(station) == (None, "not_calibrated")
no_camera = await _station(db_session, maker_user, camera=False, code="ST-N")
assert await vision_service.fetch_station_calibration(no_camera) == (None, "no_camera")
assert await vision_service.fetch_station_calibration(None) == (None, "no_camera")
# --- B3: POST /api/vision/tasks/{id}/recipe/dxf-compare -----------------------
@pytest.mark.asyncio
async def test_dxf_compare_preview_writes_nothing(
client, db_session, maker_user, w2, acquisitore,
):
task, subtask = await make_task(db_session, maker_user)
station = await _station(db_session, maker_user, worker_url="http://10.0.0.7:8100")
resp = await _post(client, maker_user, task, station_id=station.id, band_mm=0.2,
min_points_pct=90, polarity="light")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["saved"] is False and body["recipe_dir"] is None and body["outputs"] is None
assert "files" not in body["result"] and body["result"]["graph"] == GRAPH
# px_per_mm dall'acquisitore, parametri e worker della stazione
assert w2[0]["px_per_mm"] == 12.5 and w2[0]["band_mm"] == 0.2
assert w2[0]["min_points_pct"] == 90 and w2[0]["polarity"] == "light"
assert w2[0]["worker_url"] == "http://10.0.0.7:8100"
await db_session.refresh(task)
assert task.vision_json is None and task.vision_recipe_dir is None
quotes = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
assert [q.id for q in quotes] == [subtask.id] # nessuna quota nuova
assert not (settings.upload_path / "vision" / "recipes").exists()
@pytest.mark.asyncio
async def test_dxf_compare_save_stores_recipe_and_quote_once(
client, db_session, maker_user, w2, validate_ok,
):
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, maker_user, task, save=True, px_per_mm=10, min_points_pct=95)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["saved"] is True and body["outputs"] == OUTPUTS
assert "files" not in body["result"]
folder = settings.upload_path / body["recipe_dir"]
assert (folder / "disegno.dxf").read_bytes() == b"0\nEOF"
assert json.loads((folder / "calibrazione.json").read_text()) == {"px_per_mm": 10.0}
await db_session.refresh(task)
assert task.vision_json == GRAPH and task.vision_recipe_dir == body["recipe_dir"]
quotes = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct")
.where(RecipeSubtask.task_id == task.id))).scalars().all()
assert len(quotes) == 1
q = quotes[0]
assert q.description == "Punti in tolleranza"
assert (float(q.nominal), float(q.ltl), float(q.utl)) == (100.0, 95.0, 100.0)
# secondo salvataggio: la quota non si duplica
task_id = task.id # dopo expire_all l'attributo scaduto non si rilegge in async
again = await _post(client, maker_user, task, save=True, px_per_mm=10)
assert again.status_code == 200
db_session.expire_all()
count = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.vision_output.is_not(None))
.where(RecipeSubtask.task_id == task_id))).scalars().all()
assert len(count) == 1
@pytest.mark.asyncio
async def test_dxf_compare_keeps_an_existing_vision_quote(
client, db_session, maker_user, w2, validate_ok,
):
task, subtask = await make_task(db_session, maker_user)
subtask.vision_output = "t1.diameter"
await db_session.commit()
resp = await _post(client, maker_user, task, save=True, px_per_mm=10)
assert resp.status_code == 200, resp.text
quotes = (await db_session.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id))).scalars().all()
assert len(quotes) == 1 # niente quota in piu'
@pytest.mark.asyncio
async def test_dxf_compare_uncalibrated_station_is_422(
client, db_session, maker_user, w2, acquisitore,
):
task, _ = await make_task(db_session, maker_user)
station = await _station(db_session, maker_user)
acquisitore["cal"] = None
resp = await _post(client, maker_user, task, station_id=station.id)
assert resp.status_code == 422
assert resp.json()["detail"] == ["stazione non calibrata"]
assert w2 == []
@pytest.mark.asyncio
async def test_dxf_compare_unreachable_acquirer_is_422_with_its_own_message(
client, db_session, maker_user, w2, acquisitore,
):
task, _ = await make_task(db_session, maker_user)
station = await _station(db_session, maker_user)
acquisitore["cal"] = "down"
resp = await _post(client, maker_user, task, station_id=station.id)
assert resp.status_code == 422
assert resp.json()["detail"] == ["acquisitore della stazione non raggiungibile"]
@pytest.mark.asyncio
async def test_dxf_compare_without_station_nor_scale_is_422(client, db_session, maker_user, w2):
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, maker_user, task)
assert resp.status_code == 422 and resp.json()["detail"] == ["stazione non calibrata"]
@pytest.mark.asyncio
async def test_dxf_compare_needs_maker(client, db_session, maker_user, measurement_tec_user, w2):
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, measurement_tec_user, task, px_per_mm=10)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_dxf_compare_worker_422_is_forwarded(
client, db_session, maker_user, monkeypatch,
):
from fastapi import HTTPException
async def refuse(*a, **k):
raise HTTPException(status_code=422, detail="pezzo non trovato")
monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", refuse)
task, _ = await make_task(db_session, maker_user)
resp = await _post(client, maker_user, task, px_per_mm=10)
assert resp.status_code == 422 and resp.json()["detail"] == "pezzo non trovato"
# --- execute: calibrazione della stazione -------------------------------------
@pytest.mark.asyncio
async def test_execute_passes_station_calibration_and_snapshots_it(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
await _execute(client, measurement_tec_user, task, run)
assert worker_calls[0]["calibration"] == {"px_per_mm": 12.5}
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.calibration_snapshot == CAL
@pytest.mark.asyncio
async def test_execute_without_calibration_is_unchanged(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
station.camera_url, station.camera_source = CAMERA_URL, "camera:A1"
await db_session.commit()
acquisitore["cal"] = "down" # acquisitore giu': si misura come prima, senza calibrazione
await _execute(client, measurement_tec_user, task, run)
assert "calibration" not in worker_calls[0]
result = (await db_session.execute(select(VisionResult))).scalars().one()
assert result.calibration_snapshot is None
@pytest.mark.asyncio
async def test_execute_station_without_camera_never_asks(
client, db_session, measurement_tec_user, worker_calls, acquisitore,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
_, run = await _station_with_run(db_session, measurement_tec_user, recipe, None)
await _execute(client, measurement_tec_user, task, run)
assert acquisitore["urls"] == [] and "calibration" not in worker_calls[0]
@@ -0,0 +1,199 @@
"""#45: il worker di visione si sceglie per stazione.
La stazione si legge dalla sessione di produzione (`production_run_id`): con un
URL sulla stazione `_call_worker` lo riceve e il VisionResult lo registra;
senza URL o senza run si resta sul worker centrale, senza ripiego fra i due.
"""
import io
import httpx
import pytest
from fastapi import HTTPException
from PIL import Image
from src.backend.config import settings
from src.backend.models.api.production import ProductionRunCreate
from src.backend.models.orm.station import Station
from src.backend.models.orm.vision import VisionResult
from src.backend.services import production_service
from src.backend.services.vision_service import _call_worker
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_execute import recipe_parts
from sqlalchemy import select
STATION_URL = "http://10.0.0.7:8100"
def an_image() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="PNG")
return buffer.getvalue()
@pytest.fixture
def worker_calls(monkeypatch):
"""Fake `_call_worker` che ricorda i kwargs con cui e' stato chiamato."""
calls: list[dict] = []
async def fake_call(image_bytes: bytes, graph: dict, **kwargs) -> dict:
calls.append(kwargs)
return {"outputs": {"t1.diameter": 10.0}, "failures": [],
"engine_version": "e" * 40, "duration_ms": 3}
monkeypatch.setattr("src.backend.services.vision_service._call_worker", fake_call)
return calls
async def _camera_task(db_session, user_id: int):
recipe = await create_test_recipe(db_session, user_id)
version, task, subtask = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
subtask.vision_output = "t1.diameter"
await db_session.commit()
return recipe, task
async def _station_with_run(db_session, user, recipe, worker_url):
station = Station(
code="ST-W", name="Stazione W", active=True, created_by=user.id,
vision_worker_url=worker_url,
)
db_session.add(station)
await db_session.commit()
await db_session.refresh(station)
run = await production_service.open_run(
db_session,
ProductionRunCreate(station_code=station.code, recipe_id=recipe.id),
user,
)
await db_session.commit()
return station, run
async def _execute(client, user, task, run=None):
data = {"task_id": str(task.id)}
if run is not None:
data["production_run_id"] = str(run.id)
response = await client.post(
"/api/vision/execute",
headers=auth_headers(user),
files={"image": ("p.png", an_image(), "image/png")},
data=data,
)
assert response.status_code == 200, response.text
async def _the_result(db_session) -> VisionResult:
return (await db_session.execute(select(VisionResult))).scalars().one()
@pytest.mark.asyncio
async def test_station_with_url_gets_the_call_and_the_record(
client, db_session, measurement_tec_user, worker_calls,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(
db_session, measurement_tec_user, recipe, STATION_URL)
await _execute(client, measurement_tec_user, task, run)
assert worker_calls[0]["worker_url"] == STATION_URL
result = await _the_result(db_session)
assert result.executed_on == "station"
assert result.worker_url == STATION_URL
assert result.station_id == station.id
@pytest.mark.asyncio
async def test_station_without_url_uses_the_central_worker(
client, db_session, measurement_tec_user, worker_calls,
):
recipe, task = await _camera_task(db_session, measurement_tec_user.id)
station, run = await _station_with_run(
db_session, measurement_tec_user, recipe, None)
await _execute(client, measurement_tec_user, task, run)
assert worker_calls[0]["worker_url"] is None
result = await _the_result(db_session)
assert result.executed_on == "server"
assert result.worker_url == settings.vision_worker_url.rstrip("/")
assert result.station_id == station.id
@pytest.mark.asyncio
async def test_without_a_run_the_central_worker_is_used(
client, db_session, measurement_tec_user, worker_calls,
):
_, task = await _camera_task(db_session, measurement_tec_user.id)
await _execute(client, measurement_tec_user, task)
assert worker_calls[0]["worker_url"] is None
result = await _the_result(db_session)
assert result.executed_on == "server"
assert result.worker_url == settings.vision_worker_url.rstrip("/")
assert result.station_id is None
class _Response:
status_code = 200
def json(self):
return {"outputs": {}, "failures": [], "engine_version": "e" * 40}
class _RecordingClient:
"""httpx.AsyncClient finto: registra gli URL postati, opzionalmente fallisce."""
def __init__(self, posts: list, raises=None):
self._posts = posts
self._raises = raises
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def post(self, url, *args, **kwargs):
self._posts.append(url)
if self._raises is not None:
raise self._raises
return _Response()
def _install(monkeypatch, raises=None) -> list:
posts: list = []
monkeypatch.setattr(
"src.backend.services.vision_service.httpx.AsyncClient",
lambda *a, **k: _RecordingClient(posts, raises),
)
return posts
@pytest.mark.asyncio
async def test_call_worker_posts_to_the_given_url(monkeypatch):
posts = _install(monkeypatch)
# lo "/" finale non deve produrre "//run"
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL + "/")
assert posts == [f"{STATION_URL}/run"]
@pytest.mark.asyncio
async def test_call_worker_defaults_to_the_central_url(monkeypatch):
posts = _install(monkeypatch)
await _call_worker(b"img", {"tools": []})
assert posts == [f"{settings.vision_worker_url.rstrip('/')}/run"]
@pytest.mark.asyncio
async def test_station_worker_down_is_502_with_its_url_and_no_fallback(monkeypatch):
posts = _install(monkeypatch, raises=httpx.ConnectError("refused"))
with pytest.raises(HTTPException) as exc:
await _call_worker(b"img", {"tools": []}, worker_url=STATION_URL)
assert exc.value.status_code == 502
assert STATION_URL in exc.value.detail
# una sola POST: il centrale non viene tentato
assert posts == [f"{STATION_URL}/run"]
+3
View File
@@ -242,6 +242,9 @@ def create_app() -> Flask:
"company_logo": session.get("company_logo"),
"auto_logout_minutes": session.get("auto_logout_minutes"),
"current_year": date.today().year,
# Versione/commit nel piede di pagina (#70)
"app_version": Config.APP_VERSION,
"app_commit": Config.APP_COMMIT,
}
return app
@@ -1,4 +1,6 @@
"""Admin blueprint - user management."""
from urllib.parse import urlparse # #88: validazione url camera
import requests as http_requests # #88: lettura sorgenti dall'acquisitore (come maker.py)
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
@@ -170,6 +172,39 @@ def api_toggle_active(user_id: int):
# --- Stations ---
@admin_bp.route("/api/camera/sources", methods=["GET"])
@login_required
@admin_required
def api_camera_sources():
"""Proxy (#88): elenco sorgenti di un acquisitore, GET <url>/camera/sources."""
url = (request.args.get("url") or "").strip()
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return jsonify({"error": True, "detail": _("Indirizzo della camera non valido")}), 400
try:
resp = http_requests.get(f"{url.rstrip('/')}/camera/sources", timeout=5)
except http_requests.RequestException as exc:
return jsonify({
"error": True,
"detail": _("Servizio camera non raggiungibile: %(detail)s", detail=str(exc)),
}), 502
invalid = (jsonify({"error": True, "detail": _("Risposta non valida dal servizio camera")}), 502)
if resp.status_code != 200:
return invalid
try:
sources = resp.json().get("sources")
except (ValueError, AttributeError):
return invalid
if not isinstance(sources, list):
return invalid
# solo i tre campi che la UI usa, sempre stringhe
clean = [
{k: str(s.get(k, "")) for k in ("name", "label", "kind")}
for s in sources if isinstance(s, dict)
]
return jsonify({"sources": clean}), 200
@admin_bp.route("/api/stations", methods=["POST"])
@login_required
@admin_required
+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}/acquisizione" # pagina di acquisizione dell'acquisitore (#88; era /calibrazione, 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):
@@ -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/<int:result_id>/image", methods=["GET"])
@login_required
@role_required("MeasurementTec")
@@ -1,12 +1,13 @@
"""Metrologist blueprint - SPC statistics dashboard and API proxies."""
import requests as http_requests
from flask import Blueprint, jsonify, render_template, request, session
from flask import Blueprint, abort, jsonify, render_template, request, session
from flask_babel import gettext as _
from blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client
from services.file_proxy import proxy_file # #58: allegati dei task
statistics_bp = Blueprint("statistics", __name__)
@@ -148,6 +149,33 @@ def api_runs():
return jsonify(resp)
@statistics_bp.route("/api/runs/<int:run_id>/task-results")
@login_required
@role_required("Metrologist")
def api_run_task_results(run_id: int):
"""Proxy: esiti, note, campi e allegati dei task di una sessione (#58)."""
resp = api_client.get(f"/api/statistics/runs/{run_id}/task-results")
if isinstance(resp, dict) and resp.get("error"):
error_body = {"error": True, "detail": resp.get("detail", "")}
if resp.get("session_expired"):
error_body["session_expired"] = True
return jsonify(error_body), resp.get("status_code", 500)
return jsonify(resp)
@statistics_bp.route("/api/files/<path:file_path>")
@login_required
@role_required("Metrologist")
def api_task_result_file(file_path: str):
"""Proxy: allegati dei task (#58). Solo task_results/, mai altri file."""
# #58: il Metrologist non deve poter leggere altro dell'upload dir (es. statistics/).
if not file_path.startswith("task_results/") or ".." in file_path.split("/"):
abort(404)
return proxy_file(file_path)
# ============================================================================
# PROXY REPORT PDF → FastAPI
# ============================================================================
+27
View File
@@ -1,5 +1,7 @@
"""TieMeasureFlow Client Configuration."""
import os
import tomllib
from pathlib import Path
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
@@ -10,6 +12,25 @@ load_dotenv(os.path.join(os.path.dirname(__file__), '..', '.env'))
DEFAULT_SECRET_KEY = "change-this-to-another-random-secret-key"
def _read_app_version() -> str:
"""Versione del prodotto = `[project] version` di pyproject.toml (#70).
Si risale dalla cartella del modulo: nel container il file sta in
/app/pyproject.toml (Dockerfile.frontend), nel repo alla radice. Se non
si trova o e' illeggibile torna "dev": il piede di pagina non deve mai
rompere l'avvio.
"""
for parent in Path(__file__).resolve().parents:
candidate = parent / "pyproject.toml"
if candidate.is_file():
try:
with candidate.open("rb") as fh:
return str(tomllib.load(fh)["project"]["version"])
except (OSError, tomllib.TOMLDecodeError, KeyError):
return "dev"
return "dev"
class Config:
"""Flask client configuration."""
@@ -17,6 +38,12 @@ class Config:
SECRET_KEY = os.getenv("CLIENT_SECRET_KEY", DEFAULT_SECRET_KEY)
DEBUG = os.getenv("FLASK_DEBUG", "0") == "1"
# Versione e commit mostrati nel piede di pagina (#70). Letti una volta
# all'import. Il commit arriva dal build arg APP_COMMIT (.git non e' nel
# contesto di build, vedi .dockerignore); facoltativo: vuoto = non mostrato.
APP_VERSION: str = _read_app_version()
APP_COMMIT: str = os.environ.get("APP_COMMIT", "")[:7]
# API Server connection
API_SERVER_URL = os.getenv("API_SERVER_URL", "http://localhost:8000")
@@ -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()
@@ -0,0 +1,181 @@
// «Crea ricetta da DXF» dell'editor task (#83): da un'immagine della camera e un DXF
// genera la ricetta dxf_compare (POST /maker/api/vision/tasks/<id>/recipe/dxf-compare).
// «Prova» (save=false) mostra overlay e quattro valori, «Salva ricetta» (save=true)
// la scrive e avvisa il componente visionRecipe con l'evento 'dxf-recipe-saved'.
// Uso: x-data="dxfRecipeWizard(task.id, csrfFn, msgs)". Dipende da Alpine.js e station_calibration.js.
function dxfRecipeWizard(taskId, csrf, msgs) {
// Colori overlay per ruolo, uguali a vision_execute.js (reference, roi, found)
const ROLE_COLORS = { reference: '#2563eb', found: '#16a34a', roi: '#f59e0b' };
const VALUE_KEYS = ['dxf.points_in_pct', 'dxf.distance_avg', 'dxf.distance_min', 'dxf.distance_max'];
return {
taskId: taskId,
msgs: msgs || {},
open: false,
stations: [],
stationId: '',
calState: '', // '' | calibrated | uncalibrated | unreachable
calLabel: '',
calUrl: '',
imageFile: null, // immagine caricata o acquisita
imageName: '',
imageSrc: '',
dxfFile: null,
bandMm: 0.1,
minPointsPct: 95,
dark: true, // spuntato = pezzo scuro su fondo chiaro (controluce)
busy: false,
grabbing: false,
error: '',
notice: '',
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
async toggle() {
this.open = !this.open;
if (this.open && !this.stations.length) await this.loadStations();
},
async loadStations() {
try {
const resp = await fetch('/maker/api/stations');
if (resp.ok) this.stations = await resp.json();
} catch (e) { console.error('dxfRecipeWizard stations', e); }
// Una sola stazione con camera: preselezionata
if (this.stations.length === 1) {
this.stationId = String(this.stations[0].id);
await this.onStation();
}
},
// Cambio stazione: rilegge lo stato di calibrazione dall'acquisitore
async onStation() {
this.calState = ''; this.calLabel = ''; this.calUrl = '';
this.result = null;
if (!this.stationId) return;
const r = await fetchStationCalibration(this.stationId);
this.calState = r.state || 'unreachable';
this.calUrl = r.calibration_url || '';
if (this.calState === 'calibrated') this.calLabel = formatCalibration(r.calibration || {});
},
get calibrated() { return this.calState === 'calibrated'; },
get canRun() {
return !!(this.stationId && this.calibrated && this.imageFile && this.dxfFile) && !this.busy;
},
_setImage(file) {
if (this.imageSrc) URL.revokeObjectURL(this.imageSrc);
this.imageFile = file;
this.imageName = file.name || '';
this.imageSrc = URL.createObjectURL(file);
this.result = null;
},
onImage(ev) {
const f = ev.target.files && ev.target.files[0];
if (f) this._setImage(f);
},
onDxf(ev) { this.dxfFile = (ev.target.files && ev.target.files[0]) || null; this.result = null; },
// Frame dalla camera della stazione scelta
async grab() {
if (!this.stationId || this.grabbing) return;
this.grabbing = true;
this.error = '';
try {
const resp = await fetch(`/maker/api/stations/${this.stationId}/camera/grab`, {
method: 'POST', headers: { 'X-CSRFToken': this._csrf() },
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
this.error = body.detail || this.msgs.requestFailed || 'Error';
} else {
const blob = await resp.blob();
this._setImage(new File([blob], 'camera.png', { type: blob.type || 'image/png' }));
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
}
this.grabbing = false;
},
_csrf() { return typeof csrf === 'function' ? csrf() : csrf; },
// Il 422 porta `detail` (stringa o lista) oppure `errors`
_errorText(body) {
if (Array.isArray(body.errors) && body.errors.length) return body.errors.map(String).join('; ');
const d = body.detail;
if (Array.isArray(d)) return d.map(x => (typeof x === 'string' ? x : (x.msg || JSON.stringify(x)))).join('; ');
if (d && typeof d === 'object') return JSON.stringify(d);
return d || this.msgs.requestFailed || 'Error';
},
async run(save) {
if (!this.canRun) return;
this.busy = true;
this.error = '';
this.notice = '';
try {
const fd = new FormData();
fd.append('image', this.imageFile, this.imageFile.name || 'capture.png');
fd.append('dxf', this.dxfFile, this.dxfFile.name);
fd.append('station_id', this.stationId);
fd.append('band_mm', String(this.bandMm));
fd.append('min_points_pct', String(this.minPointsPct));
fd.append('polarity', this.dark ? 'dark' : 'light');
fd.append('save', save ? 'true' : 'false');
const resp = await fetch(`/maker/api/vision/tasks/${this.taskId}/recipe/dxf-compare`, {
method: 'POST', headers: { 'X-CSRFToken': this._csrf() }, body: fd,
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok || body.error) {
this.error = this._errorText(body);
} else {
this.result = body;
this.$nextTick(() => this.drawOverlay());
if (save && body.saved) {
this.notice = this.msgs.saved || 'Saved';
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
this.$dispatch('dxf-recipe-saved', { taskId: this.taskId });
}
}
} catch (e) {
this.error = this.msgs.requestFailed || String(e);
}
this.busy = false;
},
// Le quattro uscite del confronto, nell'ordine fisso
get values() {
const out = (this.result && this.result.result && this.result.result.outputs) || {};
return VALUE_KEYS.map(k => ({ key: k, value: out[k] }));
},
fmt(v) {
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
{ maximumFractionDigits: 3 }) : '-';
},
// Come vision_execute.js: canvas della dimensione dell'immagine, coordinate in pixel immagine
drawOverlay() {
const img = this.$refs.img, cv = this.$refs.overlay;
if (!img || !cv || !img.naturalWidth) return;
cv.width = img.naturalWidth;
cv.height = img.naturalHeight;
const g = cv.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.lineWidth = Math.max(2, cv.width / 400);
g.lineJoin = 'round';
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
for (const item of shapes) {
const rows = item.rows || [], cols = item.cols || [];
if (!rows.length || rows.length !== cols.length) continue;
g.strokeStyle = ROLE_COLORS[item.role] || '#ef4444';
g.beginPath();
g.moveTo(cols[0], rows[0]);
for (let i = 1; i < rows.length; i++) g.lineTo(cols[i], rows[i]);
g.stroke();
}
},
};
}
@@ -0,0 +1,50 @@
// Stato di calibrazione della camera di una stazione (#83).
// La calibrazione vive nell'acquisitore (camera_service): qui si legge solo lo stato
// tramite il proxy Flask e si offre il link alla sua pagina «Calibra».
// Uso: x-data="stationCalibrationCell(station, msgs)" (msgs: stringhe tradotte). Dipende da Alpine.js.
// Formatta px/mm e data nella lingua della pagina (es. «20,03 px/mm · 23/10/2026»)
function formatCalibration(cal) {
const lang = document.documentElement.lang || 'it';
let text = new Intl.NumberFormat(lang, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
.format(Number(cal.px_per_mm)) + ' px/mm';
if (cal.calibrated_at) {
const d = new Date(cal.calibrated_at);
if (!isNaN(d)) text += ' · ' + d.toLocaleDateString(lang);
}
return text;
}
// Legge lo stato dal proxy: ritorna {state, calibration, calibration_url, detail}
async function fetchStationCalibration(stationId) {
try {
const resp = await fetch(`/maker/api/stations/${stationId}/camera/calibration`);
const body = await resp.json().catch(() => ({}));
if (!resp.ok) return { state: 'unreachable', detail: body.detail || '' };
return body;
} catch (e) {
return { state: 'unreachable', detail: String(e) };
}
}
function stationCalibrationCell(station, msgs) {
return {
hasCamera: !!(station.camera_url && station.camera_source),
cameraUrl: station.camera_url || '',
state: '', // '' = in lettura | calibrated | uncalibrated | unreachable
label: '',
calibrationUrl: '',
msgs: msgs || {},
async init() {
if (!this.hasCamera) return;
const r = await fetchStationCalibration(station.id);
this.state = r.state || 'unreachable';
// Link «Acquisizione» (#88, era «Calibra»): dal proxy, altrimenti costruito dall'indirizzo della camera
this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/acquisizione');
if (this.state === 'calibrated') this.label = formatCalibration(r.calibration || {});
else if (this.state === 'uncalibrated') this.label = this.msgs.uncalibrated || 'not calibrated';
else this.label = this.msgs.unreachable || 'camera service unreachable';
},
};
}
@@ -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];
@@ -50,6 +50,7 @@
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden lg:table-cell">{{ _('Ricette collegate') }}</th>
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Calibrazione') }}</th>
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
</tr>
@@ -81,6 +82,25 @@
</div>
</template>
</td>
{# Calibrazione (#83): stato letto dall'acquisitore della camera, link alla sua pagina «Calibra» #}
<td class="px-4 py-3 hidden md:table-cell" @click.stop
x-data="stationCalibrationCell(station, {{ {'uncalibrated': _('non calibrata'), 'unreachable': _('acquisitore non raggiungibile')}|tojson_attr }})">
<template x-if="!hasCamera">
<span class="text-xs italic text-[var(--text-secondary)]">{{ _('Nessuna camera') }}</span>
</template>
<template x-if="hasCamera">
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span :class="state === 'calibrated' ? 'text-emerald-700 dark:text-emerald-300'
: (state === 'uncalibrated' || state === 'unreachable') ? 'text-amber-700 dark:text-amber-300'
: 'text-[var(--text-secondary)]'">
<span x-show="state === 'calibrated'">{{ _('Calibrazione') }}: </span><span x-text="state ? label : '...'"></span>
</span>
<a :href="calibrationUrl" target="_blank" rel="noopener"
x-show="calibrationUrl"
class="font-medium text-primary hover:underline">{{ _('Acquisizione') }}</a> {# #88: era «Calibra» #}
</div>
</template>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
:class="station.active
@@ -204,6 +224,58 @@
maxlength="255">
</div>
{# Worker visione (#45): vuoto = worker centrale #}
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Worker visione (URL)') }}</label>
<input type="text" x-model="form.vision_worker_url"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="host:8100"
maxlength="500">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = worker centrale. Indirizzo completo, con http o https') }}</p>
</div>
{# Camera della stazione (#72): servizio camera (vs-gui) e sorgente da cui prendere il frame #}
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Camera (URL)') }}</label>
<input type="text" x-model="form.camera_url" @change="loadCameraSources()"
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="host:8765"
maxlength="500">
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale') }}</p>
</div>
{# Sorgente camera (#88): testo libero con suggerimenti letti dall'acquisitore (datalist) #}
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Sorgente camera') }}</label>
<div class="flex gap-2">
<input type="text" x-model="form.camera_source" list="cameraSourcesList"
class="flex-1 min-w-0 px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
text-sm font-mono text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
placeholder="camera:folder"
maxlength="200">
<datalist id="cameraSourcesList">
<template x-for="s in cameraSources" :key="s.name">
<option :value="s.name" :label="s.label"></option>
</template>
</datalist>
<button type="button" @click="loadCameraSources()"
:disabled="!(form.camera_url || '').trim() || cameraSourcesLoading"
class="px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)] text-sm font-medium
text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap">
{{ _('Leggi sorgenti') }}
</button>
</div>
<p class="mt-1 text-xs"
:class="cameraSourcesError ? 'text-red-600 dark:text-red-400' : 'text-[var(--text-secondary)]'"
x-text="cameraSourcesStatus || {{ _('Es. camera:<seriale> oppure camera:folder')|tojson_attr }}"></p>
<p x-show="cameraSourceMissing" class="mt-1 text-xs text-amber-700 dark:text-amber-300">{{ _("Sorgente non presente nell'acquisitore") }}</p>
<a x-show="(form.camera_url || '').trim()"
:href="(form.camera_url || '').trim().replace(/\/+$/, '') + '/acquisizione'"
target="_blank" rel="noopener"
class="mt-1 inline-block text-xs font-medium text-primary hover:underline">{{ _('Apri pagina acquisizione') }}</a>
</div>
<div>
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
<textarea x-model="form.notes" rows="3"
@@ -428,6 +500,7 @@
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script>
function stationManagement(initialStations, initialRecipes) {
return {
@@ -452,8 +525,17 @@ function stationManagement(initialStations, initialRecipes) {
name: '',
location: '',
notes: '',
vision_worker_url: '', // #45
camera_url: '', // #72
camera_source: '', // #72
active: true,
},
// #88: sorgenti lette dall'acquisitore per il datalist della sorgente camera
cameraSources: [],
cameraSourcesLoaded: false,
cameraSourcesLoading: false,
cameraSourcesStatus: '',
cameraSourcesError: false,
get csrfToken() {
const meta = document.querySelector('meta[name=csrf-token]');
@@ -501,11 +583,46 @@ function stationManagement(initialStations, initialRecipes) {
return '{{ _("Nessuna ricetta assegnata") }}';
},
// #88: legge le sorgenti dall'acquisitore (proxy admin) e riempie il datalist
async loadCameraSources() {
const url = (this.form.camera_url || '').trim();
this.cameraSources = [];
this.cameraSourcesLoaded = false;
this.cameraSourcesError = false;
if (!url) { this.cameraSourcesStatus = ''; return; }
this.cameraSourcesLoading = true;
this.cameraSourcesStatus = '{{ _("Lettura...") }}';
try {
const resp = await fetch('/admin/api/camera/sources?url=' + encodeURIComponent(url));
const result = await resp.json();
if (!resp.ok) {
this.cameraSourcesError = true;
this.cameraSourcesStatus = result.detail || '{{ _("Risposta non valida dal servizio camera") }}';
return;
}
this.cameraSources = result.sources || [];
this.cameraSourcesLoaded = true;
this.cameraSourcesStatus = '{{ _("%(n)s sorgenti trovate", n="__N__") }}'.replace('__N__', this.cameraSources.length);
} catch (e) {
this.cameraSourcesError = true;
this.cameraSourcesStatus = '{{ _("Risposta non valida dal servizio camera") }}';
} finally {
this.cameraSourcesLoading = false;
}
},
// #88: avviso se la sorgente scritta non e' fra quelle lette
get cameraSourceMissing() {
const src = (this.form.camera_source || '').trim();
return this.cameraSourcesLoaded && src !== '' && !this.cameraSources.some(s => s.name === src);
},
openCreateModal() {
this.isEditing = false;
this.editingId = null;
this.errorMsg = '';
this.form = { code: '', name: '', location: '', notes: '', active: true };
this.form = { code: '', name: '', location: '', notes: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true };
this.loadCameraSources(); // #88: svuota lo stato delle sorgenti
this.showModal = true;
},
@@ -518,8 +635,12 @@ function stationManagement(initialStations, initialRecipes) {
name: station.name,
location: station.location || '',
notes: station.notes || '',
vision_worker_url: station.vision_worker_url || '', // #45
camera_url: station.camera_url || '', // #72
camera_source: station.camera_source || '', // #72
active: station.active,
};
this.loadCameraSources(); // #88: se la camera e' valorizzata legge subito le sorgenti
this.showModal = true;
},
@@ -537,6 +658,9 @@ function stationManagement(initialStations, initialRecipes) {
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
camera_url: (this.form.camera_url || '').trim() || null, // #72
camera_source: (this.form.camera_source || '').trim() || null, // #72
active: this.form.active,
};
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
@@ -563,6 +687,9 @@ function stationManagement(initialStations, initialRecipes) {
name: this.form.name,
location: this.form.location || null,
notes: this.form.notes || null,
vision_worker_url: (this.form.vision_worker_url || '').trim() || null, // #45
camera_url: (this.form.camera_url || '').trim() || null, // #72
camera_source: (this.form.camera_source || '').trim() || null, // #72
active: this.form.active,
};
const resp = await fetch('/admin/api/stations', {
@@ -157,7 +157,7 @@
<!-- Footer -->
<div class="text-center text-xs text-[var(--text-muted)]">
<p>TieMeasureFlow &copy; {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
<p>TieMeasureFlow &copy; {{ current_year }} - {{ _('Sistema di misurazione industriale') }}{# versione e commit (#70) #} &middot; v{{ app_version }}{% if app_commit %} &middot; {{ app_commit }}{% endif %}</p>
</div>
</div>
</div>
+1 -1
View File
@@ -107,7 +107,7 @@
<!-- Footer -->
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic
Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic{# versione e commit (#70) #} &middot; v{{ app_version }}{% if app_commit %} &middot; {{ app_commit }}{% endif %}
</p>
</footer>
{% endblock %}
@@ -550,7 +550,8 @@
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})">
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})"
@dxf-recipe-saved="init()">{# #83: dopo «Salva ricetta» da DXF si rilegge la ricetta #}
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Ricetta visione') }}
</p>
@@ -593,6 +594,93 @@
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
</ul>
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
<template x-if="task.task_type === 'dxf_compare'">
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata')}|tojson_attr }})">
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
{{ _('Crea ricetta da DXF') }}
</button>
<div x-show="open" x-cloak class="mt-3 space-y-3">
<!-- Stazione -->
<div>
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
<select class="tmf-input text-xs" x-model="stationId" @change="onStation()">
<option value="">{{ _('Scegli la stazione') }}</option>
<template x-for="st in stations" :key="st.id">
<option :value="String(st.id)" x-text="st.code + ' - ' + st.name"></option>
</template>
</select>
<p x-show="!stations.length" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Nessuna stazione con camera configurata') }}</p>
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
</p>
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600">
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
</p>
</div>
<!-- Immagine: camera o file -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
<div class="flex flex-wrap items-center gap-2">
<button type="button" class="btn btn-secondary text-xs" @click="grab()"
:disabled="!stationId || grabbing">{{ _('Acquisisci dalla camera') }}</button>
<label class="btn btn-secondary text-xs cursor-pointer">
{{ _('Carica immagine') }}
<input type="file" accept="image/*" class="hidden" @change="onImage($event)">
</label>
</div>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
</div>
<div>
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
</div>
</div>
<!-- Pochi parametri -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="tmf-label text-xs">{{ _('Banda ± mm') }}</label>
<input type="number" step="0.01" min="0.001" class="tmf-input text-xs" x-model.number="bandMm">
</div>
<div>
<label class="tmf-label text-xs">{{ _('Soglia punti in banda %%') }}</label>
<input type="number" step="1" min="1" max="100" class="tmf-input text-xs" x-model.number="minPointsPct">
</div>
</div>
<label class="flex items-center gap-2 text-xs">
<input type="checkbox" x-model="dark">
{{ _('Pezzo scuro su fondo chiaro (controluce)') }}
</label>
<!-- Azioni -->
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-secondary text-xs" @click="run(false)"
:disabled="!canRun">{{ _('Prova') }}</button>
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
</div>
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
<!-- Anteprima con overlay e quattro valori -->
<div x-show="imageSrc" class="relative">
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
@load="drawOverlay()">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
</div>
<table x-show="result" class="tmf-table text-xs">
<tbody>
<template x-for="v in values" :key="v.key">
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
@@ -1245,6 +1333,8 @@
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
@@ -544,6 +544,15 @@
<input type="file" accept="image/*" capture="environment" class="hidden"
@change="onPick($event)" :disabled="busy">
</label>
{% if camera_enabled %}
{# #72: frame dalla camera della stazione, stessa strada di acquire() #}
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
:disabled="busy || grabbing">
<span>{{ _('Acquisisci dalla camera') }}</span>
</button>
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
{% endif %}
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
</div>
@@ -1136,6 +1145,7 @@
<script>
window.__visionCfg = {
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|tojson }},
@@ -129,6 +129,74 @@
</div>
</div>
<!-- #58: esiti, note, campi e allegati dei task della sessione scelta -->
<div id="run-task-results" x-show="selectedRunId" x-cloak
class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 mb-6 shadow-sm">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Esiti dei task della sessione") }}</h2>
<p x-show="taskResultsLoading" class="text-sm text-steel">{{ _("Caricamento...") }}</p>
<p x-show="taskResultsError" x-text="taskResultsError" class="text-sm text-red-600"></p>
<template x-if="taskResults && !taskResultsLoading">
<div>
<div class="grid grid-cols-3 gap-3 mb-4 max-w-md">
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="taskResults.counts.ok"></div>
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("OK") }}</div>
</div>
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
<div class="text-2xl font-bold font-mono text-red-600" x-text="taskResults.counts.nok"></div>
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("NOK") }}</div>
</div>
<div class="text-center p-3 rounded-lg bg-[var(--bg-primary)]">
<div class="text-2xl font-bold font-mono text-[var(--text-primary)]" x-text="taskResults.counts.done"></div>
<div class="text-xs text-steel mt-1">{{ _("Fatto") }}</div>
</div>
</div>
<p x-show="taskResults.results.length === 0" class="text-sm text-steel">{{ _("Nessun esito registrato in questa sessione") }}</p>
<div x-show="taskResults.results.length > 0" class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-steel uppercase border-b border-[var(--border-color)]">
<th class="py-2 pr-3">{{ _("Ora") }}</th>
<th class="py-2 pr-3">{{ _("Task") }}</th>
<th class="py-2 pr-3">{{ _("Esito") }}</th>
<th class="py-2 pr-3">{{ _("Note") }}</th>
<th class="py-2 pr-3">{{ _("Campi") }}</th>
<th class="py-2 pr-3">{{ _("Allegati") }}</th>
<th class="py-2">{{ _("Operatore") }}</th>
</tr>
</thead>
<tbody>
<template x-for="r in taskResults.results" :key="r.id">
<tr class="border-b border-[var(--border-color)] align-top">
<td class="py-2 pr-3 font-mono whitespace-nowrap" x-text="resultTime(r.created_at)"></td>
<td class="py-2 pr-3" x-text="r.task_title"></td>
<td class="py-2 pr-3">
<span class="px-2 py-0.5 rounded text-xs font-semibold"
:class="r.outcome === 'nok' ? 'bg-red-600 text-white' : (r.outcome === 'ok' ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-700')"
x-text="outcomeLabel(r.outcome)"></span>
</td>
<td class="py-2 pr-3 whitespace-pre-line" x-text="r.note"></td>
<td class="py-2 pr-3">
<template x-for="v in r.values" :key="v.key">
<div><span class="text-steel" x-text="v.label + ': '"></span><span x-text="v.value"></span></div>
</template>
</td>
<td class="py-2 pr-3">
<template x-for="a in r.attachments" :key="a.path">
<div><a :href="attachmentUrl(a.path)" target="_blank" rel="noopener"
class="text-primary hover:underline" x-text="a.name || a.label"></a></div>
</template>
</td>
<td class="py-2" x-text="r.user_name"></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
</div>
<!-- Nessuna ricetta selezionata -->
<template x-if="!selectedRecipeId && !loading">
<div class="text-center py-16 text-steel">
@@ -290,8 +358,52 @@ function spcDashboard() {
reportError: '',
downloadingReport: false,
// #58: esiti dei task della sessione scelta
taskResults: null,
taskResultsLoading: false,
taskResultsError: '',
init() {
// Nothing to pre-load
// #58: il pannello esiti si ricarica a ogni cambio di sessione
this.$watch('selectedRunId', () => this.loadTaskResults());
},
async loadTaskResults() {
this.taskResults = null;
this.taskResultsError = '';
const runId = this.selectedRunId;
if (!runId) return;
this.taskResultsLoading = true;
try {
const resp = await fetch('/statistics/api/runs/' + runId + '/task-results');
const data = await resp.json();
// #58: se nel frattempo la sessione e' cambiata, il risultato e' vecchio
if (runId !== this.selectedRunId) return;
if (!resp.ok || data.error) {
this.taskResultsError = data.detail || {{ _("Errore nel caricamento dei dati")|tojson }};
} else {
this.taskResults = data;
}
} catch (e) {
console.error('Error loading task results:', e);
this.taskResultsError = {{ _("Errore di connessione al server")|tojson }};
} finally {
if (runId === this.selectedRunId) this.taskResultsLoading = false;
}
},
// #58: ora locale dell'esito (created_at e' UTC senza suffisso)
resultTime(iso) {
return iso ? new Date(iso + (iso.endsWith('Z') ? '' : 'Z')).toLocaleString() : '';
},
outcomeLabel(o) {
return o === 'ok' ? {{ _("OK")|tojson }} : (o === 'nok' ? {{ _("NOK")|tojson }} : {{ _("Fatto")|tojson }});
},
// #58: gli allegati passano dal proxy del blueprint, il browser non manda X-API-Key
attachmentUrl(path) {
return '/statistics/api/files/' + String(path).split('/').map(encodeURIComponent).join('/');
},
async onRecipeChange() {
@@ -176,3 +176,152 @@ def test_station_list_page_renders_assigned_recipes(logged_in_client, mock_admin
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200
assert b"REC-ABC" in resp.data
# --- Worker visione per stazione (#45) ---
def test_station_form_has_the_vision_worker_field(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = []
resp = logged_in_client.get("/admin/stations")
assert resp.status_code == 200
assert b"form.vision_worker_url" in resp.data
# etichetta in italiano o in inglese, secondo il catalogo compilato
assert b"Worker visione (URL)" in resp.data or b"Vision worker (URL)" in resp.data
def test_station_list_exposes_the_vision_worker_url(logged_in_client, mock_admin_api):
mock_admin_api.get.side_effect = [
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
"notes": None, "active": True, "vision_worker_url": "http://10.0.0.7:8100",
"created_by": 1, "created_at": "2026-04-25T10:00:00"}],
[],
]
resp = logged_in_client.get("/admin/stations")
assert b"http://10.0.0.7:8100" in resp.data # nel JSON iniziale della pagina
def test_station_proxies_pass_the_vision_worker_url_untouched(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {"id": 7, "code": "ST-7", "name": "n"}
mock_admin_api.put.return_value = {"id": 7, "code": "ST-7", "name": "n"}
logged_in_client.post(
"/admin/api/stations",
json={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
)
mock_admin_api.post.assert_called_once_with(
"/api/stations",
data={"code": "ST-7", "name": "n", "vision_worker_url": "http://w:8100"},
)
logged_in_client.put("/admin/api/stations/7", json={"vision_worker_url": None})
mock_admin_api.put.assert_called_once_with(
"/api/stations/7", data={"vision_worker_url": None},
)
# --- Camera per stazione (#72) ---
def test_station_form_has_the_camera_fields(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = []
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
assert "form.camera_url" in html
assert "form.camera_source" in html
def test_station_list_exposes_the_camera_fields(logged_in_client, mock_admin_api):
mock_admin_api.get.side_effect = [
[{"id": 1, "code": "ST-001", "name": "Linea A", "location": None,
"notes": None, "active": True,
"camera_url": "http://10.0.0.9:8765", "camera_source": "camera:folder",
"created_by": 1, "created_at": "2026-04-25T10:00:00"}],
[],
]
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
assert "http://10.0.0.9:8765" in html
assert "camera:folder" in html
def test_station_payload_script_sends_the_camera_fields(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = []
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
# default, reset, carica, crea, aggiorna: ogni punto che già porta vision_worker_url
assert html.count("camera_url") >= html.count("vision_worker_url")
assert html.count("camera_source") >= html.count("vision_worker_url")
def test_create_station_proxy_forwards_the_camera_fields(logged_in_client, mock_admin_api):
mock_admin_api.post.return_value = {"id": 5, "code": "ST-005", "name": "N"}
body = {"code": "ST-005", "name": "N",
"camera_url": "http://h:8765", "camera_source": "camera:folder"}
resp = logged_in_client.post("/admin/api/stations", json=body)
assert resp.status_code == 201
mock_admin_api.post.assert_called_once_with("/api/stations", data=body)
# --- Sorgenti camera lette dall'acquisitore (#88) ---
def _fake_sources_response(status=200, payload=None):
resp = MagicMock()
resp.status_code = status
resp.json.return_value = payload
return resp
def test_camera_sources_proxy_keeps_only_the_three_fields(logged_in_client, mock_admin_api):
payload = {"sources": [
{"name": "camera:folder", "label": "Cartella", "kind": "folder", "extra": 1},
{"name": "camera:123", "label": "Basler", "kind": "gige"},
]}
with patch("blueprints.admin.http_requests.get",
return_value=_fake_sources_response(200, payload)) as get:
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765/")
assert resp.status_code == 200
assert resp.get_json()["sources"][0] == {
"name": "camera:folder", "label": "Cartella", "kind": "folder"}
assert len(resp.get_json()["sources"]) == 2
assert get.call_args.args[0] == "http://acq:8765/camera/sources"
assert get.call_args.kwargs["timeout"] == 5
@pytest.mark.parametrize("url", ["ftp://x", "", "http://", "acq:8765"])
def test_camera_sources_proxy_rejects_invalid_url(logged_in_client, mock_admin_api, url):
with patch("blueprints.admin.http_requests.get") as get:
resp = logged_in_client.get("/admin/api/camera/sources", query_string={"url": url})
assert resp.status_code == 400
assert resp.get_json()["error"] is True
get.assert_not_called()
def test_camera_sources_proxy_unreachable_is_502(logged_in_client, mock_admin_api):
import requests
with patch("blueprints.admin.http_requests.get",
side_effect=requests.ConnectionError("rifiutata")):
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765")
assert resp.status_code == 502
assert "rifiutata" in resp.get_json()["detail"]
@pytest.mark.parametrize("status,payload", [(200, {"altro": 1}), (200, {"sources": "x"}), (500, {"sources": []})])
def test_camera_sources_proxy_invalid_response_is_502(logged_in_client, mock_admin_api, status, payload):
with patch("blueprints.admin.http_requests.get",
return_value=_fake_sources_response(status, payload)):
resp = logged_in_client.get("/admin/api/camera/sources?url=http://acq:8765")
assert resp.status_code == 502
def test_camera_sources_proxy_non_admin_is_redirected(client, mock_admin_api):
with client.session_transaction() as sess:
sess["api_key"] = "test-key"
sess["user"] = {"id": 2, "username": "operator", "roles": ["MeasurementTec"],
"is_admin": False, "active": True}
sess["user_id"] = 2
with patch("blueprints.admin.http_requests.get") as get:
resp = client.get("/admin/api/camera/sources?url=http://acq:8765")
assert resp.status_code in (301, 302)
get.assert_not_called()
def test_station_form_has_the_camera_sources_picker(logged_in_client, mock_admin_api):
mock_admin_api.get.return_value = []
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
assert "cameraSourcesList" in html
assert "loadCameraSources" in html
assert "/acquisizione" in html
@@ -13,3 +13,12 @@ def test_get_station_recipes_calls_correct_endpoint():
called_args, called_kwargs = mock_get.call_args
assert called_args[0] == "/api/stations/by-code/ST-001/recipes"
assert result == [{"id": 1, "code": "R1", "name": "R1", "active": True}]
def test_get_station_by_code_calls_correct_endpoint():
client = APIClient()
with patch.object(client, "get") as mock_get:
mock_get.return_value = {"id": 1, "code": "ST-001", "camera_url": None}
result = client.get_station_by_code("ST-001")
assert mock_get.call_args[0][0] == "/api/stations/by-code/ST-001"
assert result["code"] == "ST-001"
@@ -0,0 +1,232 @@
"""gitea:Adriano/TieMeasureFlow#83 - ricetta DXF compare da camera: rotte Flask nuove.
api_client e requests sono finti: backend (B3) e acquisitore (C1) rispondono
come da contratto del piano.
"""
import io
from unittest.mock import MagicMock, patch
import requests
from tests.conftest import api_get_router
STATION = {
"id": 4, "code": "ST-4", "name": "Banco",
"camera_url": "http://acq:8765/", "camera_source": "camera:folder",
}
NO_CAMERA = {"id": 5, "code": "ST-5", "name": "Senza", "camera_url": None, "camera_source": None}
PNG = b"\x89PNG\r\n\x1a\n fake"
CAL = {
"source": "camera:folder", "px_per_mm": 20.03, "mm_per_px": 0.0499, "rms_px": 0.2,
"n_corners": 54, "cols": 9, "rows": 6, "square_mm": 5.0,
"image_width": 2448, "image_height": 2048, "scale_spread_pct": 0.3,
"calibrated_at": "2026-10-23T10:00:00",
}
def _fake(status=200, content=PNG, json_body=None):
resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1"))
if json_body is None:
resp.json.side_effect = ValueError("no json")
else:
resp.json.return_value = json_body
return resp
def _stations(mock_api_client, stations=(STATION, NO_CAMERA)):
mock_api_client.get.side_effect = api_get_router({"/api/stations": list(stations)})
class TestStationList:
def test_only_stations_with_camera(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp = logged_in_client.get("/maker/api/stations")
assert resp.status_code == 200
assert [s["id"] for s in resp.get_json()] == [4]
def test_non_admin_list_falls_back_to_current_station(
self, logged_in_client, mock_api_client, monkeypatch):
# /api/stations e' solo admin: il Maker vede la stazione corrente (by-code)
monkeypatch.setattr("blueprints.maker.Config.STATION_CODE", "ST-4")
mock_api_client.get.side_effect = api_get_router(
{"/api/stations": {"error": True, "status_code": 403, "detail": "no"}})
mock_api_client.get_station_by_code.return_value = STATION
resp = logged_in_client.get("/maker/api/stations")
assert [s["id"] for s in resp.get_json()] == [4]
mock_api_client.get_station_by_code.assert_called_once_with("ST-4")
def test_requires_maker_or_admin(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"], "is_admin": False}
assert logged_in_client.get("/maker/api/stations").status_code == 403
class TestStationCameraGrab:
def test_png_pass_through(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 200
assert resp.mimetype == "image/png"
assert resp.data == PNG
args, kwargs = post.call_args
assert args[0] == "http://acq:8765/camera/grab" # slash finale tolto
assert kwargs["json"] == {"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000}
def test_get_also_works(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post", return_value=_fake()):
assert logged_in_client.get("/maker/api/stations/4/camera/grab").status_code == 200
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post") as post:
resp = logged_in_client.post("/maker/api/stations/5/camera/grab")
assert resp.status_code == 409
post.assert_not_called()
def test_unknown_station_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
assert logged_in_client.post("/maker/api/stations/99/camera/grab").status_code == 409
def test_service_down_is_502(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.post",
side_effect=requests.ConnectionError("refused")):
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 502
assert resp.get_json()["error"]
def test_service_503_is_502_with_detail(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
fake = _fake(503, b"{}", {"detail": "camera occupata"})
with patch("blueprints.maker.http_requests.post", return_value=fake):
resp = logged_in_client.post("/maker/api/stations/4/camera/grab")
assert resp.status_code == 502
assert "camera occupata" in resp.get_json()["detail"]
class TestStationCameraCalibration:
def _get(self, client, fake=None, exc=None):
with patch("blueprints.maker.http_requests.get", return_value=fake,
side_effect=exc) as get:
resp = client.get("/maker/api/stations/4/camera/calibration")
return resp, get
def test_calibrated(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, get = self._get(logged_in_client, _fake(200, b"{}", CAL))
body = resp.get_json()
assert resp.status_code == 200
assert body["state"] == "calibrated"
assert body["calibration"]["px_per_mm"] == 20.03
assert body["calibration_url"] == "http://acq:8765/acquisizione"
args, kwargs = get.call_args
assert args[0] == "http://acq:8765/camera/calibration"
assert kwargs["params"] == {"source": "camera:folder"}
def test_404_is_uncalibrated(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, _fake(404, b"{}", {"detail": "no"}))
assert resp.status_code == 200
assert resp.get_json()["state"] == "uncalibrated"
assert resp.get_json()["calibration_url"] == "http://acq:8765/acquisizione"
def test_unreachable(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, exc=requests.ConnectionError("down"))
assert resp.status_code == 200
assert resp.get_json()["state"] == "unreachable"
def test_server_error_is_unreachable(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
resp, _ = self._get(logged_in_client, _fake(500, b"boom"))
assert resp.get_json()["state"] == "unreachable"
def test_station_without_camera_is_409(self, logged_in_client, mock_api_client):
_stations(mock_api_client)
with patch("blueprints.maker.http_requests.get") as get:
resp = logged_in_client.get("/maker/api/stations/5/camera/calibration")
assert resp.status_code == 409
get.assert_not_called()
class TestDxfCompareProxy:
URL = "/maker/api/vision/tasks/7/recipe/dxf-compare"
OK = {
"result": {"outputs": {"dxf.points_in_pct": 99.1}, "overlay": [], "outcome": "pass"},
"saved": False, "recipe_dir": None, "outputs": None,
}
def _post(self, client, **form):
data = {"image": (io.BytesIO(PNG), "p.png"), "dxf": (io.BytesIO(b"0\nEOF"), "d.dxf"), **form}
return client.post(self.URL, data=data, content_type="multipart/form-data")
def test_forwards_files_and_fields(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = self.OK
resp = self._post(logged_in_client, station_id="4", band_mm="0.1",
min_points_pct="95", polarity="dark", save="false", px_per_mm="")
assert resp.status_code == 200
assert resp.get_json()["result"]["outputs"]["dxf.points_in_pct"] == 99.1
args, kwargs = mock_api_client.post.call_args
assert args[0] == "/api/vision/tasks/7/recipe/dxf-compare"
# campi del contratto B3; px_per_mm vuoto non si inoltra
assert kwargs["data"] == {
"station_id": "4", "band_mm": "0.1", "min_points_pct": "95",
"polarity": "dark", "save": "false",
}
assert set(kwargs["files"]) == {"image", "dxf"}
assert kwargs["files"]["image"][1] == PNG
def test_save_true_forwarded(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {**self.OK, "saved": True, "recipe_dir": "vision/recipes/7/x"}
resp = self._post(logged_in_client, save="true")
assert resp.get_json()["saved"] is True
assert mock_api_client.post.call_args.kwargs["data"]["save"] == "true"
def test_missing_files_is_400(self, logged_in_client, mock_api_client):
resp = logged_in_client.post(self.URL, data={"station_id": "4"},
content_type="multipart/form-data")
assert resp.status_code == 400
mock_api_client.post.assert_not_called()
def test_backend_422_propagates(self, logged_in_client, mock_api_client):
mock_api_client.post.return_value = {
"error": True, "status_code": 422, "detail": "stazione non calibrata"}
resp = self._post(logged_in_client)
assert resp.status_code == 422
assert resp.get_json()["detail"] == "stazione non calibrata"
def test_requires_maker_role(self, logged_in_client, mock_api_client):
with logged_in_client.session_transaction() as sess:
sess["user"] = {**sess["user"], "roles": ["MeasurementTec"]}
assert self._post(logged_in_client).status_code == 403
mock_api_client.post.assert_not_called()
class TestPages:
def test_stations_page_has_calibration_cell(self, logged_in_client):
with patch("blueprints.admin.api_client") as admin_api:
admin_api.get.side_effect = api_get_router({"/api/stations": [STATION], "/api/recipes": []})
html = logged_in_client.get("/admin/stations").get_data(as_text=True)
assert "js/station_calibration.js" in html
assert "stationCalibrationCell(" in html
# niente piu' pannello di calibrazione nella pagina: solo il link all'acquisitore
assert "calibration-panel" not in html
def _editor(self, client, mock_api_client, task_type):
mock_api_client.get.side_effect = api_get_router({
"/api/recipes/3": {"id": 3, "code": "R", "name": "R"},
"/api/recipes/3/tasks": [{
"id": 11, "title": "T", "task_type": task_type, "subtasks": [],
"file_path": None, "order_index": 1,
}],
})
return client.get("/maker/recipes/3/tasks").get_data(as_text=True)
def test_editor_has_dxf_wizard(self, logged_in_client, mock_api_client):
html = self._editor(logged_in_client, mock_api_client, "dxf_compare")
assert "js/dxf_recipe_wizard.js" in html
assert "dxfRecipeWizard(" in html
assert "@dxf-recipe-saved" in html
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
@@ -0,0 +1,37 @@
"""Versione e commit nel piede di pagina (#70)."""
import tomllib
from pathlib import Path
from config import Config
_PYPROJECT = Path(__file__).resolve().parents[4] / "pyproject.toml"
def _pyproject_version() -> str:
with _PYPROJECT.open("rb") as fh:
return tomllib.load(fh)["project"]["version"]
def test_app_version_matches_pyproject():
# La versione mostrata e' quella di pyproject.toml, non un valore a mano
assert Config.APP_VERSION == _pyproject_version()
def test_login_page_shows_version(client):
resp = client.get("/auth/login")
assert resp.status_code == 200
assert f"v{_pyproject_version()}" in resp.get_data(as_text=True)
def test_login_page_shows_commit_when_set(client, monkeypatch):
# Commit valorizzato (gia' troncato a 7 da Config): compare nel footer
monkeypatch.setattr(Config, "APP_COMMIT", "abc1234")
assert "abc1234" in client.get("/auth/login").get_data(as_text=True)
def test_login_page_hides_commit_when_empty(client, monkeypatch):
# Build senza APP_COMMIT: solo versione, nessun separatore orfano
monkeypatch.setattr(Config, "APP_COMMIT", "")
html = client.get("/auth/login").get_data(as_text=True)
assert f"v{_pyproject_version()}" in html
assert f"v{_pyproject_version()} &middot;" not in html
+16 -18
View File
@@ -12,12 +12,9 @@ class TestSelectRecipe:
def test_select_recipe_renders(self, logged_in_client, mock_api_client, monkeypatch):
"""Recipe selection page renders for MeasurementTec role."""
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
# Patch the class attribute instead of reload()ing config/blueprints.measure:
# monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "REC-001", "name": "Test Recipe"},
@@ -47,12 +44,9 @@ class TestSelectRecipe:
import re
from html.parser import HTMLParser
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
# Patch the class attribute instead of reload()ing config/blueprints.measure:
# monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = []
@@ -79,12 +73,9 @@ class TestSelectRecipe:
self, logged_in_client, mock_api_client, monkeypatch
):
"""#10: a lot number like ``L'23`` must not break the page."""
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import config
import importlib
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
# Patch the class attribute instead of reload()ing config/blueprints.measure:
# monkeypatch undoes it at teardown, so no ST-TEST leaks into later tests (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = []
@@ -393,6 +384,13 @@ class TestStartSequence:
class TestTaskComplete:
"""GET /measure/complete/<recipe_id> tests."""
@pytest.fixture(autouse=True)
def _station(self, monkeypatch):
# The side_effect lists below include the /production-runs/current reply,
# which is only requested when a station is configured: pin it explicitly
# instead of relying on whatever an earlier test left behind (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
def test_task_complete_renders(self, logged_in_client, mock_api_client):
"""The summary carries the measurement clock, so it has to render with it."""
mock_api_client.get.side_effect = [
@@ -0,0 +1,161 @@
"""gitea:Adriano/TieMeasureFlow#72 - frame grab from the station camera.
The backend (station.camera_url / camera_source, GET /api/stations/by-code/{code})
is built elsewhere: api_client and requests are mocked here.
"""
from unittest.mock import MagicMock, patch
import requests
from tests.conftest import api_get_router
STATION = {
"id": 1, "code": "ST-TEST", "name": "Linea",
"camera_url": "http://host.docker.internal:8765",
"camera_source": "camera:folder",
}
PNG = b"\x89PNG\r\n\x1a\n fake"
def _fake(status=200, content=PNG, ctype="image/png", json_body=None):
resp = MagicMock(status_code=status, content=content, text=content.decode("latin-1"))
resp.headers = {"content-type": ctype}
if json_body is None:
resp.json.side_effect = ValueError("no json")
else:
resp.json.return_value = json_body
return resp
def _station(mock_api_client, monkeypatch, station):
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_by_code.return_value = station
def test_grab_without_camera_is_409(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, {**STATION, "camera_url": None})
with patch("blueprints.measure.http_requests.post") as post:
resp = logged_in_client.post("/measure/api/vision/grab")
assert resp.status_code == 409
assert resp.get_json()["error"]
post.assert_not_called()
def test_grab_without_source_is_409(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, {**STATION, "camera_source": None})
with patch("blueprints.measure.http_requests.post") as post:
resp = logged_in_client.post("/measure/api/vision/grab")
assert resp.status_code == 409
post.assert_not_called()
def test_grab_station_lookup_error_is_409(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, {"error": True, "status_code": 404, "detail": "x"})
resp = logged_in_client.post("/measure/api/vision/grab")
assert resp.status_code == 409
def test_grab_passes_png_through(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
with patch("blueprints.measure.http_requests.post", return_value=_fake()) as post:
resp = logged_in_client.post("/measure/api/vision/grab")
assert resp.status_code == 200
assert resp.mimetype == "image/png"
assert resp.data == PNG
mock_api_client.get_station_by_code.assert_called_once_with("ST-TEST")
args, kwargs = post.call_args
assert args[0] == "http://host.docker.internal:8765/camera/grab"
assert kwargs["json"] == {
"source": "camera:folder", "trigger": "continuo", "timeout_ms": 5000,
}
assert kwargs["timeout"] == 15
def test_grab_service_503_is_502(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
fake = _fake(503, b"{}", "application/json", {"detail": "camera occupata"})
with patch("blueprints.measure.http_requests.post", return_value=fake):
resp = logged_in_client.post("/measure/api/vision/grab")
assert resp.status_code == 502
body = resp.get_json()
assert body["error"] is True
assert "camera occupata" in body["detail"]
def test_grab_service_504_and_404_are_502(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
for status in (404, 504):
with patch("blueprints.measure.http_requests.post",
return_value=_fake(status, b"nope", "text/plain")):
resp = logged_in_client.post("/measure/api/vision/grab")
assert resp.status_code == 502
assert resp.get_json()["error"] is True
def test_grab_connection_error_is_502(logged_in_client, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
with patch("blueprints.measure.http_requests.post",
side_effect=requests.ConnectionError("refused")):
resp = logged_in_client.post("/measure/api/vision/grab")
assert resp.status_code == 502
assert resp.get_json()["error"] is True
def test_grab_requires_measurement_tec(flask_app, mock_api_client, monkeypatch):
_station(mock_api_client, monkeypatch, STATION)
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["api_key"] = "k"
sess["user"] = {"id": 2, "username": "m", "roles": ["Maker"],
"is_admin": False, "active": True}
sess["user_id"] = 2
with patch("blueprints.measure.http_requests.post") as post:
resp = c.post("/measure/api/vision/grab")
assert resp.status_code == 403
post.assert_not_called()
def test_grab_requires_login(client):
resp = client.post("/measure/api/vision/grab")
assert resp.status_code in (302, 401)
# --- execute page: button only when the station has a camera -----------------
TASK = {
"id": 12, "recipe_id": 3, "version_id": 5, "task_type": "camera_measure",
"title": "Foto", "directive": None, "description": None, "file_path": None,
"file_type": None, "annotations_json": None, "subtasks": [],
}
RECIPE = {
"id": 3, "code": "REC-1", "name": "Ricetta",
"measurement_interval_minutes": 30, "allow_manual_input": True,
"current_version": {"id": 5, "version_number": 1},
}
def _page(logged_in_client, mock_api_client, monkeypatch, station):
_station(mock_api_client, monkeypatch, station)
mock_api_client.get_station_recipes.return_value = {"items": [{"id": 3}]}
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/12": TASK,
"/api/recipes/3/tasks": [{"id": 12, "order_index": 0}],
"/api/recipes/3": RECIPE,
})
return logged_in_client.get("/measure/execute/12").get_data(as_text=True)
def test_execute_page_shows_grab_button_with_camera(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch, STATION)
assert "/measure/api/vision/grab" in html
assert "grab()" in html
def test_execute_page_hides_grab_button_without_camera(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch, {**STATION, "camera_url": None})
assert "grab()" not in html
def test_execute_page_hides_grab_button_when_lookup_fails(logged_in_client, mock_api_client, monkeypatch):
html = _page(logged_in_client, mock_api_client, monkeypatch,
{"error": True, "status_code": 502, "detail": "down"})
assert "grab()" not in html
@@ -6,6 +6,17 @@ a start button that will not start, a description that reads the way it was
written.
"""
import pytest
@pytest.fixture(autouse=True)
def _no_station(monkeypatch):
# These tests assume no station is configured (e.g. _task_list feeds the mock a
# fixed number of replies, with no /production-runs/current among them); pin it
# so a STATION_CODE left behind by an earlier test cannot change the call count (#79).
# Tests that need a station set it themselves, after this fixture has run.
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", None)
# ---------------------------------------------------------------------------
# Point 11 - line breaks and bold, and nothing else
@@ -197,13 +208,9 @@ def test_avvia_is_untouched_when_nothing_is_required(
def test_selection_screen_carries_the_traceability_rules(
logged_in_client, mock_api_client, monkeypatch,
):
monkeypatch.setenv("STATION_CODE", "ST-TEST")
import importlib
import config
importlib.reload(config)
import blueprints.measure
importlib.reload(blueprints.measure)
# Patch instead of reload()ing config/blueprints.measure: the patch is undone at
# teardown, a reload would leave ST-TEST behind for later tests (#79).
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [
{
@@ -130,3 +130,40 @@ class TestRunsProxy:
mock_api_client.get.return_value = {"total": 1}
logged_in_client.get("/statistics/api/summary?recipe_id=1&production_run_id=5")
assert mock_api_client.get.call_args.kwargs["params"]["production_run_id"] == "5"
class TestRunTaskResultsProxy:
"""#58: esiti dei task e allegati della sessione."""
def test_task_results_proxy(self, logged_in_client, mock_api_client):
payload = {"run_id": 5, "counts": {"ok": 1, "nok": 0, "done": 0}, "results": []}
mock_api_client.get.return_value = payload
resp = logged_in_client.get("/statistics/api/runs/5/task-results")
assert resp.status_code == 200
assert resp.get_json() == payload
assert mock_api_client.get.call_args.args[0] == "/api/statistics/runs/5/task-results"
def test_task_results_proxy_relays_error(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"error": True, "detail": "x", "status_code": 404}
resp = logged_in_client.get("/statistics/api/runs/9/task-results")
assert resp.status_code == 404
assert resp.get_json()["error"] is True
def test_file_proxy_serves_task_results_only(self, logged_in_client):
from unittest.mock import patch
from flask import Response
with patch("blueprints.statistics.proxy_file",
return_value=Response("img", status=200)) as pf:
ok = logged_in_client.get("/statistics/api/files/task_results/a.jpg")
assert ok.status_code == 200
pf.assert_called_once_with("task_results/a.jpg")
for bad in ("statistics/x", "task_results/../statistics/x", "../etc/passwd"):
assert logged_in_client.get(f"/statistics/api/files/{bad}").status_code == 404
assert pf.call_count == 1 # i percorsi rifiutati non arrivano al proxy
def test_dashboard_has_task_results_panel(self, logged_in_client, mock_api_client):
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
assert 'id="run-task-results"' in html
assert "loadTaskResults" in html
assert "/statistics/api/runs/" in html
@@ -754,6 +754,14 @@ msgstr "Code cannot be modified"
msgid "Identificativo univoco usato dal client tramite STATION_CODE"
msgstr "Unique identifier used by client via STATION_CODE"
#: templates/admin/stations.html
msgid "Worker visione (URL)"
msgstr "Vision worker (URL)"
#: templates/admin/stations.html
msgid "Vuoto = worker centrale. Indirizzo completo, con http o https"
msgstr "Empty = central worker. Full address, with http or https"
#: templates/admin/stations.html:194
msgid "Nome descrittivo della stazione"
msgstr "Descriptive name of the station"
@@ -2969,6 +2977,36 @@ msgstr "Select a measurement point for the histogram"
msgid "Errore nella generazione del report"
msgstr "Error generating report"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Esiti dei task della sessione"
msgstr "Task outcomes of the session"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Nessun esito registrato in questa sessione"
msgstr "No outcome recorded in this session"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Fatto"
msgstr "Done"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Ora"
msgstr "Time"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Campi"
msgstr "Fields"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Allegati"
msgstr "Attachments"
#~ msgid "Esci"
#~ msgstr "Logout"
@@ -3284,3 +3322,166 @@ msgstr "Error generating report"
#~ msgid "Crea, modifica e gestisci gli utenti del sistema"
#~ msgstr "Create, edit and manage system users"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Camera (URL)"
msgstr "Camera (URL)"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale"
msgstr "Empty = no camera. Camera service address, with http or https, no trailing /"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Sorgente camera"
msgstr "Camera source"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Es. camera:<seriale> oppure camera:folder"
msgstr "E.g. camera:<serial> or camera:folder"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Acquisisci dalla camera"
msgstr "Capture from camera"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Acquisizione in corso..."
msgstr "Capturing..."
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Camera non configurata per questa stazione"
msgstr "Camera not configured for this station"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
#, python-format
msgid "Servizio camera non raggiungibile: %(detail)s"
msgstr "Camera service unreachable: %(detail)s"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
#, python-format
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
msgstr "Capture failed (%(status)s): %(detail)s"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
#, python-format
msgid "Risposta %(status)s dall'acquisitore"
msgstr "Response %(status)s from the camera service"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Risposta non valida dall'acquisitore"
msgstr "Invalid response from the camera service"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Servono l'immagine e il file DXF"
msgstr "Both the image and the DXF file are required"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibrazione"
msgstr "Calibration"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "non calibrata"
msgstr "not calibrated"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "acquisitore non raggiungibile"
msgstr "camera service unreachable"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna camera"
msgstr "No camera"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibra"
msgstr "Calibrate"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Ricetta salvata"
msgstr "Recipe saved"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Crea ricetta da DXF"
msgstr "Create recipe from DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Scegli la stazione"
msgstr "Choose the station"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna stazione con camera configurata"
msgstr "No station with a camera configured"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
msgstr "Station not calibrated: calibrate it before creating the recipe."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
msgstr "Camera service unreachable: cannot read the calibration."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "oppure vedi la pagina Stazioni"
msgstr "or see the Stations page"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Immagine del pezzo"
msgstr "Part image"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Carica immagine"
msgstr "Upload image"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Disegno (.dxf)"
msgstr "Drawing (.dxf)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Banda ± mm"
msgstr "Band ± mm"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Soglia punti in banda %%"
msgstr "Points-in-band threshold %%"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Pezzo scuro su fondo chiaro (controluce)"
msgstr "Dark part on light background (backlight)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Prova"
msgstr "Try"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta"
msgstr "Save recipe"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Acquisizione"
msgstr "Acquisition"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Leggi sorgenti"
msgstr "Read sources"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Lettura..."
msgstr "Reading..."
#: templates/admin/stations.html blueprints/admin.py (#88)
#, python-format
msgid "%(n)s sorgenti trovate"
msgstr "%(n)s sources found"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Sorgente non presente nell'acquisitore"
msgstr "Source not present in the acquisition service"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Apri pagina acquisizione"
msgstr "Open acquisition page"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Indirizzo della camera non valido"
msgstr "Invalid camera address"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera"
msgstr "Invalid response from the camera service"
@@ -756,6 +756,14 @@ msgstr "Il codice non può essere modificato"
msgid "Identificativo univoco usato dal client tramite STATION_CODE"
msgstr "Identificativo univoco usato dal client tramite STATION_CODE"
#: templates/admin/stations.html
msgid "Worker visione (URL)"
msgstr "Worker visione (URL)"
#: templates/admin/stations.html
msgid "Vuoto = worker centrale. Indirizzo completo, con http o https"
msgstr "Vuoto = worker centrale. Indirizzo completo, con http o https"
#: templates/admin/stations.html:194
msgid "Nome descrittivo della stazione"
msgstr "Nome descrittivo della stazione"
@@ -2973,6 +2981,36 @@ msgstr "Seleziona un punto di misura per l'istogramma"
msgid "Errore nella generazione del report"
msgstr "Errore nella generazione del report"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Esiti dei task della sessione"
msgstr "Esiti dei task della sessione"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Nessun esito registrato in questa sessione"
msgstr "Nessun esito registrato in questa sessione"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Fatto"
msgstr "Fatto"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Ora"
msgstr "Ora"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Campi"
msgstr "Campi"
#: templates/statistics/dashboard.html
# #58: pannello esiti dei task della sessione
msgid "Allegati"
msgstr "Allegati"
#~ msgid "Esci"
#~ msgstr "Esci"
@@ -3272,3 +3310,166 @@ msgstr "Errore nella generazione del report"
#~ msgid "Crea, modifica e gestisci gli utenti del sistema"
#~ msgstr "Crea, modifica e gestisci gli utenti del sistema"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Camera (URL)"
msgstr "Camera (URL)"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale"
msgstr "Vuoto = nessuna camera. Indirizzo del servizio camera, con http o https, senza / finale"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Sorgente camera"
msgstr "Sorgente camera"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Es. camera:<seriale> oppure camera:folder"
msgstr "Es. camera:<seriale> oppure camera:folder"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Acquisisci dalla camera"
msgstr "Acquisisci dalla camera"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Acquisizione in corso..."
msgstr "Acquisizione in corso..."
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
msgid "Camera non configurata per questa stazione"
msgstr "Camera non configurata per questa stazione"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
#, python-format
msgid "Servizio camera non raggiungibile: %(detail)s"
msgstr "Servizio camera non raggiungibile: %(detail)s"
#: templates/admin/stations.html templates/measure/task_execute.html blueprints/measure.py
#, python-format
msgid "Acquisizione non riuscita (%(status)s): %(detail)s"
msgstr "Acquisizione non riuscita (%(status)s): %(detail)s"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
#, python-format
msgid "Risposta %(status)s dall'acquisitore"
msgstr "Risposta %(status)s dall'acquisitore"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Risposta non valida dall'acquisitore"
msgstr "Risposta non valida dall'acquisitore"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Servono l'immagine e il file DXF"
msgstr "Servono l'immagine e il file DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibrazione"
msgstr "Calibrazione"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "non calibrata"
msgstr "non calibrata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "acquisitore non raggiungibile"
msgstr "acquisitore non raggiungibile"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna camera"
msgstr "Nessuna camera"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Calibra"
msgstr "Calibra"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Ricetta salvata"
msgstr "Ricetta salvata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Crea ricetta da DXF"
msgstr "Crea ricetta da DXF"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Scegli la stazione"
msgstr "Scegli la stazione"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Nessuna stazione con camera configurata"
msgstr "Nessuna stazione con camera configurata"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Stazione non calibrata: calibrala prima di creare la ricetta."
msgstr "Stazione non calibrata: calibrala prima di creare la ricetta."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
msgstr "Acquisitore non raggiungibile: impossibile leggere la calibrazione."
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "oppure vedi la pagina Stazioni"
msgstr "oppure vedi la pagina Stazioni"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Immagine del pezzo"
msgstr "Immagine del pezzo"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Carica immagine"
msgstr "Carica immagine"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Disegno (.dxf)"
msgstr "Disegno (.dxf)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Banda ± mm"
msgstr "Banda ± mm"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Soglia punti in banda %%"
msgstr "Soglia punti in banda %%"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Pezzo scuro su fondo chiaro (controluce)"
msgstr "Pezzo scuro su fondo chiaro (controluce)"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Prova"
msgstr "Prova"
#: templates/maker/task_editor.html templates/admin/stations.html blueprints/maker.py
msgid "Salva ricetta"
msgstr "Salva ricetta"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Acquisizione"
msgstr "Acquisizione"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Leggi sorgenti"
msgstr "Leggi sorgenti"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Lettura..."
msgstr "Lettura..."
#: templates/admin/stations.html blueprints/admin.py (#88)
#, python-format
msgid "%(n)s sorgenti trovate"
msgstr "%(n)s sorgenti trovate"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Sorgente non presente nell'acquisitore"
msgstr "Sorgente non presente nell'acquisitore"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Apri pagina acquisizione"
msgstr "Apri pagina acquisizione"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Indirizzo della camera non valido"
msgstr "Indirizzo della camera non valido"
#: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera"
msgstr "Risposta non valida dal servizio camera"
+263
View File
@@ -0,0 +1,263 @@
"""Costruttore di ricette DXF compare dal campo (immagine + DXF + scala -> ricetta v2).
Logica pura, niente FastAPI ne' database: il worker (src/vision_worker/main.py)
lo espone come /recipes/dxf-compare, i test lo chiamano direttamente. Tutta la
visione sta in TieVision-Core (visionsuite.*). La calibrazione a scacchiera NON
sta qui: vive nell'acquisitore (scripts/calibrazione.py, #83).
Errori di input (pezzo non trovato, DXF illeggibile, parametri fuori range)
sono `CostruttoreError`: il worker li mappa a 422.
"""
from __future__ import annotations
import base64
import hashlib
import json
import math
import tempfile
from pathlib import Path
import cv2 # #86: soglia/componenti connesse per la maschera del pezzo
import numpy as np
from visionsuite.core.image import Image
from src.vision.runner import RunOutcome, run_graph
class CostruttoreError(ValueError):
"""Input che non permette di costruire (422 per il worker), non un guasto."""
# Nomi/id fissi della ricetta (contratto #83 con backend e frontend).
DXF_FILE = "disegno.dxf"
CALIBRATION_FILE = "calibrazione.json"
_POLARITIES = ("dark", "light")
# Pose iniziali del disegno provate (gradi): il verso del pezzo non e' noto.
_OUTPUT_KEYS = ("dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max")
_START_ANGLES = (0.0, 90.0, 180.0, 270.0)
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def _gray_u8(pixels: np.ndarray) -> np.ndarray:
"""Immagine -> grigio uint8 2D (LocateBlob accetta solo uint8)."""
px = np.asarray(pixels)
if px.ndim == 3:
px = px[..., 0] # multicanale: il primo, come fa LocateBlob
if px.dtype != np.uint8:
# float/uint16 -> scala sul massimo, come _to_gray_uint8 di vs-core
m = float(px.max()) if px.size else 1.0
px = np.clip(px.astype(np.float32) / max(m, 1e-9) * 255, 0, 255).astype(np.uint8)
return np.ascontiguousarray(px)
# ------------------------------------------------------------------ W2
def _graph(dxf_sha: str, cal_sha: str, band_mm: float, min_points_pct: float, polarity: str,
reference_pose: dict, anchor: tuple[float, float, float] | None) -> dict:
"""Ricetta v2 fissa: LocateBlob "pezzo" + CompareDxf "dxf" agganciato a `align`.
`anchor` = (riga, colonna, angolo) dell'origine del disegno nell'immagine di
riferimento; None = solo il localizzatore (passo intermedio).
"""
tools: list[dict] = [{
"id": "pezzo", "name": "Pezzo", "kind": "LocateBlob",
"values": {"polarity": polarity, "reference_pose": reference_pose},
"links": {},
}]
if anchor is not None:
row, col, angle = anchor
tools.append({
"id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf",
"values": {
"dxf": "disegno",
"anchor": "align",
"anchor_offset_row_px": float(row),
"anchor_offset_col_px": float(col),
"offset_angle_deg": float(angle),
"band_plus": float(band_mm),
"band_minus": float(band_mm),
"nominal_unit": "mm",
# tolleranza di points_in_pct: nominale = limite, plus = 100 - limite
"points_in_pct_nominal": float(min_points_pct),
"points_in_pct_plus": float(100.0 - min_points_pct),
"points_in_pct_minus": 0.0,
},
"links": {"align": {"tool": "pezzo", "property": "poses"}},
})
return {
"schema_version": 2,
"name": "Confronto DXF",
# sha256 obbligatorio per vs-task (verifica i file della cartella ricetta)
"meta": {"dxf": {"disegno": {"path": DXF_FILE, "sha256": dxf_sha}},
"calibration": {"path": CALIBRATION_FILE, "sha256": cal_sha}},
"tools": tools,
}
def _locate_reference(gray: np.ndarray, polarity: str) -> dict:
"""Posa del pezzo sull'immagine di riferimento (LocateBlob, soglia Otsu)."""
from visionsuite.task import Task, load_tools
load_tools()
graph = _graph('', '', 1.0, 95.0, polarity, {"row": 0.0, "col": 0.0, "angle_deg": 0.0}, None)
task = Task.from_dict(graph)
task.run(context={"image": Image(gray)})
pezzo = task["Pezzo"]
if not pezzo.found_poses:
raise CostruttoreError(
"pezzo non trovato nell'immagine di riferimento "
f"(polarity={polarity}): controllare luce, sfondo e polarita'"
)
return pezzo.found_poses[0].to_dict()
def _maschera_pezzo(gray: np.ndarray, polarity: str, px_per_mm: float = 71.0) -> np.ndarray:
"""Immagine con solo il pezzo: fuori dal blob staccato dal bordo e' sfondo (#86).
Otsu (INV se dark), componenti connesse, la piu' grande che NON tocca il
bordo immagine (piastre del prisma, ombre e angoli neri della foto
raddrizzata ci sono attaccati), dilatata di ~0,3 mm (minimo 15 px).
"""
flag = cv2.THRESH_BINARY_INV if polarity == "dark" else cv2.THRESH_BINARY
_, binary = cv2.threshold(gray, 0, 255, flag | cv2.THRESH_OTSU)
n, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8)
h, w = gray.shape[:2]
best, best_area = 0, 0
for i in range(1, n): # 0 = sfondo
x, y, cw, ch, area = (int(v) for v in stats[i])
if x == 0 or y == 0 or x + cw >= w or y + ch >= h:
continue # tocca il bordo immagine: non e' il pezzo
if area > best_area:
best, best_area = i, area
if best == 0:
raise CostruttoreError("pezzo non trovato: nessun blob staccato dal bordo")
raggio = max(15, int(round(0.3 * px_per_mm))) # margine in funzione della scala
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2 * raggio + 1, 2 * raggio + 1))
zona = cv2.dilate((labels == best).astype(np.uint8), kernel) > 0
sfondo = 255 if polarity == "dark" else 0
return np.where(zona, gray, np.uint8(sfondo)).astype(np.uint8)
def _candidate_anchors(gray: np.ndarray, dxf_path: Path, px_per_mm: float,
polarity: str = "dark") -> list[tuple[float, float, float]]:
"""Pose (riga, col, angolo) del disegno sull'immagine: ICP da 4 orientamenti.
Il disegno e' in px (origine DXF, y capovolta); si parte da ognuno dei 4
angoli con i centroidi sovrapposti e l'ICP rifinisce. Ritorna le posa trovate
(anche per orientamenti che non convergono: le giudica la ricetta eseguita).
Centroide e ICP lavorano sull'immagine mascherata sul solo pezzo (#86).
"""
from visionsuite.calibration import ScaleCalibration
from visionsuite.dxf.compare import DXFCompare, _stack
from visionsuite.task.pose import Pose
cmp_ = DXFCompare(dxf_path, ScaleCalibration(px_per_mm=px_per_mm))
drawing = _stack(cmp_.drawing_contours())
gray = _maschera_pezzo(gray, polarity, px_per_mm) # #86: via piastre, ombre, angoli neri
measured = _stack(cmp_.measured_contours(Image(gray)))
if len(drawing) < 3:
raise CostruttoreError("DXF senza geometria utilizzabile")
if len(measured) < 3:
raise CostruttoreError("pezzo non trovato: nessun bordo nell'immagine")
dc, mc = drawing.mean(axis=0), measured.mean(axis=0)
anchors = []
errori = [] # #86: motivo di ogni orientamento caduto, per la diagnosi in campo
for theta in _START_ANGLES:
# traslazione che porta il centroide del disegno ruotato sul misurato
rr, cc = Pose(0.0, 0.0, theta).apply(dc[0], dc[1])
start = Pose(float(mc[0] - rr), float(mc[1] - cc), theta)
try:
result = cmp_.compare(Image(gray), initial_pose=start)
except Exception as exc: # noqa: BLE001 - un orientamento che cade non ferma gli altri
errori.append(f"{theta:g}gradi: {type(exc).__name__}: {exc}") # #86
continue
# transform: misurato -> disegno; la posa del disegno e' l'inversa
m = np.linalg.inv(result.transform)
angle = math.degrees(math.atan2(m[1, 0], m[0, 0]))
anchors.append((float(m[0, 2]), float(m[1, 2]), angle))
if not anchors and errori: # #86: nessuna ancora -> il motivo risale al chiamante
raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito): " + "; ".join(errori))
return anchors
def _run(gray, graph, px_per_mm, folder) -> RunOutcome:
return run_graph(gray, graph, calibration={"px_per_mm": px_per_mm}, recipe_dir=folder)
def build_dxf_compare(
gray,
dxf_bytes: bytes,
px_per_mm: float,
band_mm: float = 0.1,
min_points_pct: float = 95.0,
polarity: str = "dark",
) -> dict:
"""Ricetta DXF compare dall'immagine di riferimento + disegno + scala.
Ritorna {graph, files, outputs, overlay, outcome}. `files` sono i due file
della cartella ricetta (b64): disegno.dxf e calibrazione.json.
"""
if not (px_per_mm > 0 and math.isfinite(px_per_mm)):
raise CostruttoreError("px_per_mm deve essere > 0")
if not (band_mm > 0 and math.isfinite(band_mm)):
raise CostruttoreError("band_mm deve essere > 0")
if not (0 < min_points_pct <= 100):
raise CostruttoreError("min_points_pct deve stare in (0, 100]")
if polarity not in _POLARITIES:
raise CostruttoreError(f"polarity deve essere una di {list(_POLARITIES)}")
if not dxf_bytes:
raise CostruttoreError("DXF vuoto")
frame = _gray_u8(gray)
calibration_bytes = json.dumps({"px_per_mm": px_per_mm}).encode("utf-8")
dxf_sha = hashlib.sha256(dxf_bytes).hexdigest()
cal_sha = hashlib.sha256(calibration_bytes).hexdigest()
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
dxf_path = folder / DXF_FILE
dxf_path.write_bytes(dxf_bytes)
(folder / CALIBRATION_FILE).write_bytes(calibration_bytes)
# DXF leggibile? (ezdxf solleva su file corrotto/non DXF)
from visionsuite.io.dxf import read_xld_dxf
try:
drawing = list(read_xld_dxf(str(dxf_path), sampling_distance=0.1,
scale=px_per_mm, flip_y=True))
except Exception as exc: # noqa: BLE001 - qualunque errore di lettura = DXF illeggibile
raise CostruttoreError(f"DXF illeggibile: {type(exc).__name__}: {exc}") from exc
if not drawing:
raise CostruttoreError("DXF illeggibile: nessuna entita' geometrica")
reference_pose = _locate_reference(frame, polarity)
anchors = _candidate_anchors(frame, dxf_path, px_per_mm, polarity) # #86
if not anchors:
raise CostruttoreError("disegno non sovrapponibile al pezzo (ICP fallito)")
# Verita' = eseguire davvero la ricetta sul riferimento: vince il maggior points_in_pct
best: tuple[float, dict, RunOutcome] | None = None
for anchor in anchors:
graph = _graph(dxf_sha, cal_sha, band_mm, min_points_pct, polarity, reference_pose, anchor)
outcome = _run(frame, graph, px_per_mm, folder)
pct = outcome.outputs.get("dxf.points_in_pct")
if pct is not None and (best is None or pct > best[0]):
best = (pct, graph, outcome)
if best is None:
raise CostruttoreError(
"disegno non sovrapponibile al pezzo: il confronto non ha prodotto punti"
)
_, graph, outcome = best
# solo le quattro uscite del contratto (n_points resta fuori)
outputs = {k: v for k, v in outcome.outputs.items() if k in _OUTPUT_KEYS}
return {
"graph": graph,
"files": {DXF_FILE: _b64(dxf_bytes), CALIBRATION_FILE: _b64(calibration_bytes)},
"outputs": outputs,
"overlay": outcome.overlay,
"outcome": outcome.outcome,
}
+150
View File
@@ -0,0 +1,150 @@
"""Costruttore di ricette dal campo: immagine + DXF + scala -> ricetta v2."""
import pytest
# Come test_runner.py: l'albero vision richiede Python >=3.13; su 3.11 e' skipped.
pytest.importorskip(
"visionsuite.task",
reason=(
"the vision tree requires Python >=3.13; run it with "
"`uv run --python 3.13 --extra vision --extra dev pytest src/vision/tests`"
),
)
import base64
import json
from pathlib import Path
import cv2
import numpy as np
from src.vision.costruttori import (
CALIBRATION_FILE,
DXF_FILE,
CostruttoreError,
_candidate_anchors,
_maschera_pezzo,
build_dxf_compare,
)
from src.vision.runner import run_graph
SAMPLE_DXF = (
Path(__file__).resolve().parents[3]
/ "vendor/visionsuite/packages/vs-dxf/tests/fixtures/sample.dxf"
)
DXF_WIDTH_MM, DXF_HEIGHT_MM, DXF_HOLE_R_MM = 200.0, 100.0, 10.0
PX_PER_MM = 4.0
def a_scene(angle_deg: float = 0.0, shift_px: tuple[float, float] = (0.0, 0.0),
margin_mm: float = 60.0) -> np.ndarray:
"""Il pezzo del sample.dxf (scuro su bianco) a 4 px/mm, ruotato e traslato."""
w = int((DXF_WIDTH_MM + 2 * margin_mm) * PX_PER_MM)
h = int((DXF_HEIGHT_MM + 2 * margin_mm) * PX_PER_MM)
img = np.full((h, w), 255, np.uint8)
cx, cy = w // 2, h // 2
hw, hh = int(DXF_WIDTH_MM * PX_PER_MM / 2), int(DXF_HEIGHT_MM * PX_PER_MM / 2)
cv2.rectangle(img, (cx - hw, cy - hh), (cx + hw, cy + hh), 0, -1)
cv2.circle(img, (cx, cy), int(DXF_HOLE_R_MM * PX_PER_MM), 255, -1)
if angle_deg or any(shift_px):
m = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0) # >0 = antiorario a video
m[0, 2] += shift_px[0]
m[1, 2] += shift_px[1]
img = cv2.warpAffine(img, m, (w, h), flags=cv2.INTER_LINEAR, borderValue=255)
return img
# ------------------------------------------------------------------ ricetta DXF
def test_ricetta_dxf_gira_su_riferimento_e_su_scena_ruotata(tmp_path):
dxf = SAMPLE_DXF.read_bytes()
risultato = build_dxf_compare(a_scene(0.0), dxf, px_per_mm=PX_PER_MM, band_mm=2.0)
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0 # sul riferimento
assert set(risultato["outputs"]) == {
"dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max"}
assert set(risultato["files"]) == {DXF_FILE, CALIBRATION_FILE}
assert json.loads(base64.b64decode(risultato["files"][CALIBRATION_FILE])) == {
"px_per_mm": PX_PER_MM}
assert risultato["overlay"] and risultato["outcome"] is not None
graph = risultato["graph"]
assert graph["schema_version"] == 2
# path come da contratto; lo sha256 lo esige vs-task per ogni file in meta
assert graph["meta"]["dxf"]["disegno"]["path"] == "disegno.dxf"
assert graph["meta"]["calibration"]["path"] == "calibrazione.json"
assert len(graph["meta"]["dxf"]["disegno"]["sha256"]) == 64
pezzo, dxf_tool = graph["tools"]
assert (pezzo["id"], pezzo["kind"], pezzo["name"]) == ("pezzo", "LocateBlob", "Pezzo")
assert (dxf_tool["id"], dxf_tool["kind"], dxf_tool["name"]) == ("dxf", "CompareDxf", "Confronto DXF")
assert dxf_tool["links"] == {"align": {"tool": "pezzo", "property": "poses"}}
# La cartella ricetta come la scriverebbe il backend, poi la scena ruotata di 5 gradi
for nome, b64 in risultato["files"].items():
(tmp_path / nome).write_bytes(base64.b64decode(b64))
ruotata = a_scene(5.0, shift_px=(30.0, 20.0))
esito = run_graph(ruotata, graph, calibration={"px_per_mm": PX_PER_MM}, recipe_dir=tmp_path)
assert not esito.failures, esito.failures
assert esito.outputs["dxf.points_in_pct"] > 95.0
def test_ricetta_dxf_con_riferimento_gia_ruotato(tmp_path):
# il riferimento non e' a 0 gradi: la ricetta si costruisce lo stesso
risultato = build_dxf_compare(a_scene(20.0, shift_px=(-15.0, 10.0)),
SAMPLE_DXF.read_bytes(), PX_PER_MM, band_mm=2.0)
assert risultato["outputs"]["dxf.points_in_pct"] > 95.0
def test_immagine_senza_pezzo_solleva():
bianca = np.full((600, 800), 255, np.uint8)
with pytest.raises(CostruttoreError, match="pezzo"):
build_dxf_compare(bianca, SAMPLE_DXF.read_bytes(), PX_PER_MM)
def test_dxf_illeggibile_solleva():
with pytest.raises(CostruttoreError, match="DXF"):
build_dxf_compare(a_scene(), b"questo non e' un dxf", PX_PER_MM)
@pytest.mark.parametrize("kwargs", [
{"px_per_mm": 0.0}, {"px_per_mm": PX_PER_MM, "band_mm": 0.0},
{"px_per_mm": PX_PER_MM, "min_points_pct": 0.0},
{"px_per_mm": PX_PER_MM, "min_points_pct": 101.0},
{"px_per_mm": PX_PER_MM, "polarity": "grigio"},
])
def test_parametri_ricetta_invalidi(kwargs):
with pytest.raises(CostruttoreError):
build_dxf_compare(a_scene(), SAMPLE_DXF.read_bytes(), **kwargs)
def test_maschera_pezzo_tiene_solo_il_blob_staccato_dal_bordo():
# #86: pezzo scuro staccato + rettangoli scuri attaccati al bordo + angolo nero
img = np.full((500, 600), 230, np.uint8)
img[200:300, 250:400] = 20 # il pezzo
img[0:60, 100:220] = 15 # attaccato al bordo alto
img[420:500, 300:420] = 15 # attaccato al bordo basso
img[0:80, 0:80] = 0 # angolo nero (borderValue=0 del raddrizzamento)
out = _maschera_pezzo(img, "dark", px_per_mm=4.0)
assert (out[200:300, 250:400] == 20).all() # il pezzo resta
assert (out[0:60, 100:220] == 255).all() # i rettangoli sono sfondo
assert (out[420:500, 300:420] == 255).all()
assert (out[0:80, 0:80] == 255).all() # angolo nero cancellato
def test_maschera_pezzo_senza_blob_staccato_solleva():
img = np.full((200, 200), 230, np.uint8)
img[:, 0:50] = 10 # solo una fascia attaccata al bordo
with pytest.raises(CostruttoreError, match="staccato dal bordo"):
_maschera_pezzo(img, "dark")
def test_ricetta_dxf_icp_fallito_riporta_il_motivo(monkeypatch, tmp_path):
# #86: l'errore "ICP fallito" porta il messaggio degli orientamenti caduti
from visionsuite.dxf.compare import DXFCompare
def boom(self, *a, **k):
raise ValueError("negative dimensions are not allowed")
monkeypatch.setattr(DXFCompare, "compare", boom)
with pytest.raises(CostruttoreError, match="ICP fallito.*negative dimensions"):
_candidate_anchors(a_scene(), SAMPLE_DXF, PX_PER_MM)
+65
View File
@@ -17,6 +17,7 @@ import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError
from src.vision.costruttori import CostruttoreError, build_dxf_compare # #83
from src.vision.runner import (
GraphBuildError,
RunOutcome,
@@ -223,3 +224,67 @@ async def validate(
validate_graph, parsed_graph, resolved_dir
)
return {"ok": ok, "errors": errors, "outputs": outputs}
def _decode_and_build_dxf(content: bytes, dxf: bytes, px_per_mm: float,
band_mm: float, min_points_pct: float,
polarity: str) -> dict:
"""#83: meta' sincrona di /recipes/dxf-compare (decodifica + costruzione),
in un thread come `_decode_and_run`."""
try:
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
except (UnidentifiedImageError, OSError) as exc:
raise _ImageDecodeError(str(exc)) from exc
return build_dxf_compare(frame, dxf, px_per_mm, band_mm, min_points_pct, polarity)
@app.post("/recipes/dxf-compare")
async def recipes_dxf_compare(
image: UploadFile = File(...),
dxf: UploadFile = File(...),
px_per_mm: float = Form(...),
band_mm: float = Form(0.1),
min_points_pct: float = Form(95.0),
polarity: str = Form("dark"),
) -> dict:
"""#83: costruisce una ricetta DXF compare dall'immagine di riferimento, il
disegno e la scala. Stessi semaforo, timeout e mappa errori di /run."""
content = await image.read()
dxf_bytes = await dxf.read()
if _run_slots.locked():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"worker busy: already running {_MAX_CONCURRENCY} job(s)",
)
async with _run_slots:
try:
return await asyncio.wait_for(
asyncio.to_thread(
_decode_and_build_dxf, content, dxf_bytes, px_per_mm,
band_mm, min_points_pct, polarity,
),
timeout=_RUN_TIMEOUT_S,
)
except asyncio.TimeoutError as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"vision run exceeded {_RUN_TIMEOUT_S}s",
) from exc
except _ImageDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"image is not decodable: {exc}",
) from exc
except (CostruttoreError, GraphBuildError) as exc:
# pezzo non trovato, DXF illeggibile, parametri invalidi: input, non guasto
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"recipe build failed: {type(exc).__name__}: {exc}",
) from exc
@@ -0,0 +1,82 @@
"""POST /recipes/dxf-compare del worker (#83): forma della risposta ed errori."""
import pytest
# Come test_worker.py: serve l'albero vision (Python >=3.13), altrimenti skipped.
pytest.importorskip(
"visionsuite.task",
reason="the vision tree requires Python >=3.13; see test_worker.py",
)
import base64
import io
import time
import numpy as np
from httpx import ASGITransport, AsyncClient
from PIL import Image
from src.vision.tests.test_costruttori import PX_PER_MM, SAMPLE_DXF, a_scene
from src.vision_worker.main import app
def _png(array: np.ndarray) -> bytes:
buffer = io.BytesIO()
Image.fromarray(array).save(buffer, format="PNG")
return buffer.getvalue()
async def _post(image: bytes, dxf: bytes, **data):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
return await client.post(
"/recipes/dxf-compare",
files={"image": ("riferimento.png", image, "image/png"),
"dxf": ("disegno.dxf", dxf, "application/octet-stream")},
data={"px_per_mm": str(PX_PER_MM), "band_mm": "2.0", **data},
)
@pytest.mark.asyncio
async def test_dxf_compare_200_con_le_chiavi_del_contratto():
inizio = time.monotonic()
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes())
durata = time.monotonic() - inizio
assert response.status_code == 200, response.text
body = response.json()
assert {"graph", "files", "outputs", "overlay", "outcome"} <= set(body)
assert set(body["files"]) == {"disegno.dxf", "calibrazione.json"}
assert base64.b64decode(body["files"]["disegno.dxf"]) == SAMPLE_DXF.read_bytes()
assert set(body["outputs"]) == {
"dxf.points_in_pct", "dxf.distance_avg", "dxf.distance_min", "dxf.distance_max"}
assert body["outputs"]["dxf.points_in_pct"] > 95.0
assert body["graph"]["schema_version"] == 2
print(f"\nW2 sulla scena di prova: {durata:.2f}s") # riportato nel rapporto
@pytest.mark.asyncio
async def test_dxf_compare_immagine_senza_pezzo_e_422():
bianca = np.full((600, 800), 255, np.uint8)
response = await _post(_png(bianca), SAMPLE_DXF.read_bytes())
assert response.status_code == 422
assert "pezzo" in response.json()["detail"]
@pytest.mark.asyncio
async def test_dxf_compare_dxf_illeggibile_e_422():
response = await _post(_png(a_scene(0.0)), b"non e' un dxf")
assert response.status_code == 422
assert "DXF" in response.json()["detail"]
@pytest.mark.asyncio
async def test_dxf_compare_immagine_non_decodificabile_e_422():
response = await _post(b"non e' un'immagine", SAMPLE_DXF.read_bytes())
assert response.status_code == 422
assert "not decodable" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_dxf_compare_parametro_fuori_range_e_422():
response = await _post(_png(a_scene(0.0)), SAMPLE_DXF.read_bytes(), polarity="grigio")
assert response.status_code == 422
Generated
+211
View File
@@ -783,6 +783,33 @@ woff = [
{ name = "zopfli" },
]
[[package]]
name = "genicam"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/39/97c9a39fde61441731a383bde6a86b079a5a9d1d22c6d9eb16556b77016e/genicam-1.6.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:2a5bfeb3b9796cf6667192c2957f8ea7ec240ff42e76cc6e3e05d1d0230bf68a", size = 2556729, upload-time = "2026-09-01T08:26:57.558Z" },
{ url = "https://files.pythonhosted.org/packages/bd/1b/bf993221a155d2fd35c8201be97cd0376e005bf1cc82dec27105a712d3b1/genicam-1.6.0-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:4207d7ccee9deb755ad372c0b473fd2f23e4e42da61e06e4a7873d199b883a0f", size = 2604157, upload-time = "2026-09-01T08:26:58.847Z" },
{ url = "https://files.pythonhosted.org/packages/38/92/68c0033f076ad1defed2e6bf1cec8fc720a24622fb0389013b058e841234/genicam-1.6.0-cp311-cp311-manylinux2014_armv7l.whl", hash = "sha256:a750ffb11194f02740531bc0f62bf7013d39f2eea65f5e283c7811ab757fc987", size = 2190252, upload-time = "2026-09-01T08:27:00.126Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b3/252e5d64022984de4a78220c456f1f91742dbd7bb02d5b3926ed4c228015/genicam-1.6.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:589b1152f22ca766f8b5e1dff9c1b4006c2eb7f5ee33152619ef6ece8a5a3f47", size = 2800290, upload-time = "2026-09-01T08:27:01.364Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ec/19092034d0f66646d97b85e1549b6b4e2448f5287bb3a76b5a8cd677ba4a/genicam-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:ef1d25c7e9823689dbfb9e08d8fdb46e0f05982ca9c27d1eeacc1aa8fcd69f6e", size = 1802065, upload-time = "2026-09-01T08:27:02.555Z" },
{ url = "https://files.pythonhosted.org/packages/b7/84/0dc939a435deef267d82bc6aaa4b7ed9c89510766454640622608d0e7ce5/genicam-1.6.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:d0becabb469d0f51f9d4820d655d2788c620a8ee1c8abcfa4703cac87ba27f53", size = 2557117, upload-time = "2026-09-01T08:27:03.87Z" },
{ url = "https://files.pythonhosted.org/packages/07/68/499657bf3e5111fe1c87e3364c2ae6b59fe912d7184fccf591eafd762ceb/genicam-1.6.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:3a1a16b89fec69d52142781f280ce2c01a4c1422da1cd08893ed1cd630d11c30", size = 2607960, upload-time = "2026-09-01T08:27:05.149Z" },
{ url = "https://files.pythonhosted.org/packages/8f/ac/3b8496c446715008e77dda7439735c428f163aab0a3f643352407a259a52/genicam-1.6.0-cp312-cp312-manylinux2014_armv7l.whl", hash = "sha256:b65beca4b425442922a2f2beefae1e3109ce166cfd726fcd9d2ac59ff1d57e24", size = 2189851, upload-time = "2026-09-01T08:27:06.516Z" },
{ url = "https://files.pythonhosted.org/packages/30/9c/49d68f5055e970e4e4d834bc3df686a01f7bd82e070888fc7ad2c86e2eaf/genicam-1.6.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:83da90404da189a87273864f2c97c761c1807fc6fc532fabd3bf50c0ae28f8ae", size = 2799503, upload-time = "2026-09-01T08:27:07.963Z" },
{ url = "https://files.pythonhosted.org/packages/a3/4a/e643cac4031ad7b1780ab19bcdeb6b0545e829b2ee10ea37ca1185d9417d/genicam-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d2bb3f39443c5bc248fcbde83a3a973370ca43588a7c1ded7d7324d7d9427d3", size = 1802200, upload-time = "2026-09-01T08:27:09.766Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f1/b5f641ad64c296462ab2ca314b8a471827142b949606ebe8b69bff7e6a08/genicam-1.6.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:f5c35275014f4793b34a21ebe8bbcb269a87b5d1bf129daf2818c6160f8758c9", size = 2557108, upload-time = "2026-09-01T08:27:11.211Z" },
{ url = "https://files.pythonhosted.org/packages/10/59/86ae5cbc1a89f70c2b3b906e692a46b977e9230664781cd5a304012f6b20/genicam-1.6.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:f4d6496660507b32a7f6ef05f5344bcd305ea3e2158a41bf53169663d7a72e70", size = 2608137, upload-time = "2026-09-01T08:27:12.405Z" },
{ url = "https://files.pythonhosted.org/packages/41/37/25a84938b5ec34fc6123006a3f4173fead4fde3b460b516749a8ae8c1b02/genicam-1.6.0-cp313-cp313-manylinux2014_armv7l.whl", hash = "sha256:efe646df5e8ca76208c6621e8a5dbea3e9909641801787993ea2d7ec4a704c09", size = 2189839, upload-time = "2026-09-01T08:27:13.834Z" },
{ url = "https://files.pythonhosted.org/packages/75/eb/37363beb1880674ff0976ff7607e2e045933c6388d08d98bc0b512df941c/genicam-1.6.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:a342980e80735b7b1e4f18f6c33d64eccc3e95887dd72f302409f1296bef3dac", size = 2799503, upload-time = "2026-09-01T08:27:15.253Z" },
{ url = "https://files.pythonhosted.org/packages/ae/38/895e9effffc858ff06996a9eed28decdc9b5b2176b2cfd82df8c4f65bdec/genicam-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:740156c5fa8d82330d36314112f4b755e8333c16f82792174f1a2a2e150519ae", size = 1802243, upload-time = "2026-09-01T08:27:16.576Z" },
{ url = "https://files.pythonhosted.org/packages/50/26/98a864061d25e78b96b7d621814b399a78fb7603ae6a6ac871648587bc49/genicam-1.6.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:064a87188b6102bac03a8ab1ccff5280e8fa4cf6378448e6286ed6f4cc9f7a64", size = 2557508, upload-time = "2026-09-01T08:27:17.763Z" },
{ url = "https://files.pythonhosted.org/packages/f1/32/2ef1193d27bb375e3d9822be883e987d75203fcb417bafa96fd77fe8be18/genicam-1.6.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:3dcf4af1fabb1cee06cac0bed6560f0e49180dab1e078501949eaf07596736c3", size = 2609251, upload-time = "2026-09-01T08:27:19.161Z" },
{ url = "https://files.pythonhosted.org/packages/45/06/ea8b6c2f4536cd8f54961040edae1ad9ff8a3dced2081a2377dac600bb73/genicam-1.6.0-cp314-cp314-manylinux2014_armv7l.whl", hash = "sha256:e3f9ff542bbd2fe389e1a94e4a694ec34f94ab378247c5f7e761b78ff5891d24", size = 2189734, upload-time = "2026-09-01T08:27:20.541Z" },
{ url = "https://files.pythonhosted.org/packages/38/ff/a8c90b9a7d99fa6008437491f1198264c22d49572a0013a05bc1c579e034/genicam-1.6.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:d1e12e276eab6dbe8dec37e060ea5a8cc5758cc8c3502a048782e38a0451517d", size = 2799526, upload-time = "2026-09-01T08:27:21.77Z" },
{ url = "https://files.pythonhosted.org/packages/24/18/c6f0b7f3da480aac0c86d104664e9cf144675986cc4e3f108c909bec0ddf/genicam-1.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:7630b7844b273080f21f01881c56667cc68521186b75dd15798f5dee1f8f1037", size = 1846842, upload-time = "2026-09-01T08:27:23.18Z" },
]
[[package]]
name = "greenlet"
version = "3.4.0"
@@ -861,6 +888,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "harvesters"
version = "1.4.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "genicam" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/18/ee45d896fee51dee2e35df9d371e2d213ed4488ddea4dd4bd1df6f92a5fb/harvesters-1.4.3.tar.gz", hash = "sha256:c237dd402735a66ebd570865113a3e80ae19161a21706aed80691627a8cb7da2", size = 104391, upload-time = "2024-05-16T17:09:41.805Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/ab/295e5ec52cc2859ad8fa0d56f953cefa26f3e1eb28cc937162d7df7df872/harvesters-1.4.3-py3-none-any.whl", hash = "sha256:74994cedc941051b9ce9527c5b336e611589a1bc209bc03186c49102a55f1f1f", size = 100253, upload-time = "2024-05-16T17:09:27.933Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -1113,6 +1153,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
]
[[package]]
name = "nuitka"
version = "4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/5f/ba7cb858da0c98c24f85c54454a5ea18f2b784f658a91531fa051b2f2ee9/nuitka-4.3.tar.gz", hash = "sha256:8b102c6bf30d9504e82e69674d396972729092b25cefa680e8fd05678fdfa30b", size = 4766934, upload-time = "2026-10-10T11:13:15.577Z" }
[[package]]
name = "numba"
version = "0.67.0"
@@ -1238,6 +1284,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/21/f0/9fa6e85cb10c8eb36a0222d27e50fe381b86ce49a55446bf39f491727564/opencv_python-5.0.0.93-cp37-abi3-win_amd64.whl", hash = "sha256:f90ba04b8f73bc5c3814037699739f0156f597338a98f05956c684e7c3ca10d2", size = 44000345, upload-time = "2026-07-02T05:49:54.971Z" },
]
[[package]]
name = "ordered-set"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" },
]
[[package]]
name = "orjson"
version = "3.11.8"
@@ -2043,6 +2098,18 @@ vision-worker = [
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
]
[package.dev-dependencies]
windows = [
{ name = "aiosqlite" },
{ name = "nuitka" },
{ name = "ordered-set" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "vs-camera", extra = ["genicam"], marker = "python_full_version >= '3.13'" },
{ name = "vs-license", marker = "python_full_version >= '3.13'" },
{ name = "waitress" },
{ name = "zstandard" },
]
[package.metadata]
requires-dist = [
{ name = "aiosqlite", marker = "extra == 'dev'", specifier = ">=0.20.0" },
@@ -2087,6 +2154,18 @@ requires-dist = [
]
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
[package.metadata.requires-dev]
windows = [
{ name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "nuitka", specifier = ">=2.5" },
{ name = "ordered-set", specifier = ">=4.1" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
{ name = "vs-camera", extras = ["genicam"], marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-camera" },
{ name = "vs-license", marker = "python_full_version >= '3.13'", editable = "vendor/visionsuite/packages/vs-license" },
{ name = "waitress", specifier = ">=3.0.0" },
{ name = "zstandard", specifier = ">=0.22" },
]
[[package]]
name = "tinycss2"
version = "1.5.1"
@@ -2132,6 +2211,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2026.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404, upload-time = "2026-10-03T09:23:14.143Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996, upload-time = "2026-10-03T09:23:12.535Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
@@ -2230,6 +2318,31 @@ requires-dist = [
]
provides-extras = ["dev", "fast", "dxf"]
[[package]]
name = "vs-camera"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-camera" }
dependencies = [
{ name = "numpy" },
{ name = "opencv-python" },
]
[package.optional-dependencies]
genicam = [
{ name = "harvesters" },
]
[package.metadata]
requires-dist = [
{ name = "flask", marker = "extra == 'dev'", specifier = ">=3.0" },
{ name = "harvesters", marker = "extra == 'genicam'", specifier = ">=1.4" },
{ name = "netifaces", marker = "extra == 'rete'", specifier = ">=0.11" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "opencv-python", specifier = ">=4.8" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
]
provides-extras = ["genicam", "rete", "dev"]
[[package]]
name = "vs-dxf"
version = "0.1.0"
@@ -2256,6 +2369,21 @@ requires-dist = [
]
provides-extras = ["dev"]
[[package]]
name = "vs-license"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-license" }
dependencies = [
{ name = "cryptography" },
]
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=42" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
]
provides-extras = ["dev"]
[[package]]
name = "vs-measure"
version = "0.1.0"
@@ -2360,6 +2488,15 @@ requires-dist = [
]
provides-extras = ["locate", "dxf", "dev"]
[[package]]
name = "waitress"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
@@ -2576,3 +2713,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/23/f0/ad6e26aa06943ce9f1be4ae6738513a7b69d8ea1f3b13e46009a249a3f73/zopfli-0.4.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7", size = 124371, upload-time = "2026-02-13T14:17:24.976Z" },
{ url = "https://files.pythonhosted.org/packages/7b/36/3c15d564db6dfdd740919b205bdb69be75113e9919c422cde658e6d013c0/zopfli-0.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707", size = 102199, upload-time = "2026-02-13T14:17:26.246Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
{ url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
{ url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
{ url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
{ url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
{ url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
{ url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
{ url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
{ url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
{ url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
{ url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
{ url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
{ url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
{ url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
{ url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
{ url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
{ url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
{ url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
{ url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
{ url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
{ url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
]