Compare commits

...

12 Commits

Author SHA1 Message Date
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 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 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 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 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
18 changed files with 962 additions and 7 deletions
+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
+2 -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:
+16
View File
@@ -516,6 +516,22 @@ Copia `.env.example` in `.env` e configura:
---
## Camera di stazione (demo fiera, #72)
Per acquisire il fotogramma da una camera locale (per esempio quella del TC bench) senza passare dall'upload, sul PC della stazione gira un piccolo servizio nativo, fuori da Docker, perché la camera GigE non si raggiunge dal container. Si avvia dalla radice del repository con l'ambiente di VisionSuite (`uv sync --all-packages` in `vendor/visionsuite`):
```bash
vendor/visionsuite/.venv/bin/python scripts/camera_service.py [--host 127.0.0.1] [--port 8765] [--folder <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`.
---
## Documentazione
Indice completo: [`docs/README.md`](docs/README.md).
+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
}
+144
View File
@@ -0,0 +1,144 @@
"""Servizio camera minimo di stazione (TieMeasureFlow, #72).
Cosa fa: espone via HTTP l'acquisizione di una camera (vs-camera: GigE/GenTL,
OpenCV, cartella di immagini) con lo stesso contratto di `vs-gui`, ma solo
`grab` (copiato da TieVision-Studio, src/visionsuite/gui/server.py):
GET /camera/sources -> {"sources": [{name, kind, label}]}
POST /camera/grab json {source, trigger="continuo", timeout_ms=5000} -> PNG
Errori: 400 parametri, 404 sorgente inesistente, 503 guasto camera, 504 timeout.
Serve perche' vs-gui ascolta solo su 127.0.0.1 e il container `client` non lo
raggiunge; questo servizio permette di scegliere l'host di ascolto.
Uso (dalla radice del repo):
vendor/visionsuite/.venv/bin/python scripts/camera_service.py \
[--host 127.0.0.1] [--port 8765] [--folder <cartella immagini>]
--folder abilita la sorgente finta `camera:folder` (loop sulle immagini).
Da Docker su Linux: --host 172.17.0.1 (gateway di docker0).
Dipende da: venv di VisionSuite (`uv sync --all-packages` in vendor/visionsuite):
vs-camera, fastapi, uvicorn, opencv.
AVVERTENZA: nessuna autenticazione. Un --host diverso dal loopback espone la
camera a chiunque raggiunga la porta: solo su rete isolata.
"""
from __future__ import annotations
import argparse
import threading
from contextlib import asynccontextmanager
from pathlib import Path
import cv2
from fastapi import Body, FastAPI, HTTPException
from fastapi.responses import Response
from visionsuite.camera.camera_interface import CameraError, CameraGrabError
from visionsuite.camera.sources import SourceNotFound, list_sources, open_source
_TRIGGER = ("continuo",)
# errori noti di vs-camera (inglese) -> italiano; esatti, poi per prefisso
_MESSAGGI = {
"Camera is not open": "La camera non è aperta",
"Camera is not grabbing": "La camera non sta acquisendo",
"Grab cancelled (grabbing stopped)": "Acquisizione annullata (acquisizione fermata)",
"Failed to read frame from RTSP stream": "Lettura del fotogramma dallo stream RTSP non riuscita",
}
_PREFISSI = (
("Failed to open OpenCV device", "Apertura del dispositivo OpenCV non riuscita"),
("Failed to open RTSP stream", "Apertura dello stream RTSP non riuscita"),
("Failed to reconnect to RTSP stream", "Riconnessione allo stream RTSP non riuscita"),
("Failed to read frame after", "Lettura del fotogramma non riuscita dopo"),
)
def _traduci(messaggio: str) -> str:
if messaggio in _MESSAGGI:
return _MESSAGGI[messaggio]
for inglese, italiano in _PREFISSI:
if messaggio.startswith(inglese):
return italiano + messaggio[len(inglese):]
return messaggio
def create_app(folder: Path | str | None = None) -> FastAPI:
aperte: dict = {}
lock = threading.Lock() # protegge SOLO i dizionari, mai un'apertura
lock_sorgenti: dict = {} # nome sorgente -> Lock che serializza apertura e grab
def _config() -> dict:
if folder is None:
return {}
return {"folder": {"folder": str(folder), "loop": True}}
def _lock_di(nome: str) -> threading.Lock:
with lock:
return lock_sorgenti.setdefault(nome, threading.Lock())
@asynccontextmanager
async def _vita(_app):
yield
with lock: # alla chiusura rilascia le camere aperte
sorgenti = list(aperte.values())
aperte.clear()
for s in sorgenti:
try:
s.close()
except Exception:
pass
app = FastAPI(title="TMF camera service", lifespan=_vita)
@app.get("/camera/sources")
def camera_sources():
voci = list_sources(None, cameras=True, config=_config())
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual")]}
@app.post("/camera/grab")
def camera_grab(corpo: dict = Body(default=None)):
nome = corpo.get("source") if isinstance(corpo, dict) else None
if not isinstance(nome, str) or not nome.strip():
raise HTTPException(400, "manca «source»")
nome = nome.strip()
trigger = corpo.get("trigger", "continuo")
if trigger not in _TRIGGER:
raise HTTPException(400, "«trigger» supportato: solo continuo")
timeout = corpo.get("timeout_ms", 5000)
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 10000:
raise HTTPException(400, "«timeout_ms» deve essere un intero fra 1 e 10000")
try:
with _lock_di(nome): # solo questa sorgente; le altre non aspettano
with lock:
sorgente = aperte.get(nome)
if sorgente is None:
sorgente = open_source(nome, None, config=_config())
with lock:
aperte[nome] = sorgente
frame = sorgente.grab(timeout)
except SourceNotFound as e:
raise HTTPException(404, _traduci(str(e)))
except TimeoutError as e:
raise HTTPException(504, _traduci(str(e)) or "timeout")
except (CameraGrabError, CameraError) as e:
raise HTTPException(503, _traduci(str(e)))
ok, buf = cv2.imencode(".png", frame)
if not ok:
raise HTTPException(500, "codifica PNG fallita")
return Response(buf.tobytes(), media_type="image/png")
return app
def main() -> None:
import uvicorn
p = argparse.ArgumentParser(description="Servizio camera minimo di stazione (#72)")
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=8765)
p.add_argument("--folder", default=None, help="cartella immagini per camera:folder")
a = p.parse_args()
uvicorn.run(create_app(a.folder), host=a.host, port=a.port)
if __name__ == "__main__":
main()
+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"]
+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,
@@ -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')
+32 -3
View File
@@ -5,18 +5,35 @@ from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
def _normalize_http_url(value: Optional[str], field: str) -> Optional[str]:
"""Vuoto/spazi -> None; altrimenti http(s) senza '/' finale."""
if value is None:
return None
value = value.strip()
if not value:
return None
if not value.lower().startswith(("http://", "https://")):
raise ValueError("vision_worker_url must start with http:// or https://")
raise ValueError(f"{field} must start with http:// or https://")
return value.rstrip("/")
def _normalize_worker_url(value: Optional[str]) -> Optional[str]:
"""Vuoto/spazi -> None (worker centrale); altrimenti http(s) senza '/' finale (#45)."""
return _normalize_http_url(value, "vision_worker_url")
def _normalize_camera_url(value: Optional[str]) -> Optional[str]:
"""Vuoto/spazi -> None (nessuna camera); altrimenti http(s) senza '/' finale (#72)."""
return _normalize_http_url(value, "camera_url")
def _normalize_camera_source(value: Optional[str]) -> Optional[str]:
"""Strip; vuoto -> None (#72)."""
if value is None:
return None
return value.strip() or None
class StationCreate(BaseModel):
code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255)
@@ -25,8 +42,13 @@ class StationCreate(BaseModel):
active: bool = True
# worker di visione della stazione (#45); vuoto = centrale
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
# camera della stazione (#72); vuoto = nessuna camera
camera_url: Optional[str] = Field(default=None, max_length=500)
camera_source: Optional[str] = Field(default=None, max_length=200)
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
class StationUpdate(BaseModel):
@@ -36,8 +58,13 @@ class StationUpdate(BaseModel):
active: Optional[bool] = None
# worker di visione della stazione (#45); "" -> None riporta al centrale
vision_worker_url: Optional[str] = Field(default=None, max_length=500)
# camera della stazione (#72); vuoto = nessuna camera
camera_url: Optional[str] = Field(default=None, max_length=500)
camera_source: Optional[str] = Field(default=None, max_length=200)
_check_worker_url = field_validator("vision_worker_url")(_normalize_worker_url)
_check_camera_url = field_validator("camera_url")(_normalize_camera_url)
_check_camera_source = field_validator("camera_source")(_normalize_camera_source)
class StationResponse(BaseModel):
@@ -49,6 +76,8 @@ class StationResponse(BaseModel):
notes: Optional[str]
active: bool
vision_worker_url: Optional[str] = None # #45
camera_url: Optional[str] = None # #72
camera_source: Optional[str] = None # #72
created_by: int
created_at: UtcDateTime
+3
View File
@@ -27,6 +27,9 @@ class Station(Base):
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
# URL del worker di visione della stazione (#45); NULL = worker centrale
vision_worker_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# camera della stazione (#72): URL del servizio camera e sorgente da chiedergli
camera_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
camera_source: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
created_by: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now()
+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(
+2
View File
@@ -32,6 +32,8 @@ async def create_station(
notes=data.notes,
active=data.active,
vision_worker_url=data.vision_worker_url, # #45
camera_url=data.camera_url, # #72
camera_source=data.camera_source, # #72
created_by=creator.id,
)
db.add(station)
+20
View File
@@ -321,3 +321,23 @@ class TestStationVisionWorkerMigration:
command.upgrade(cfg, "head") # e di nuovo su
assert "vision_worker_url" in cols("stations")
assert "worker_url" in cols("vision_results")
class TestStationCameraMigration:
"""020_station_camera: stations.camera_url e stations.camera_source, su e giu (#72)."""
def test_020_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
def cols(table):
return {c["name"] for c in _inspect(db_path).get_columns(table)}
command.upgrade(cfg, "head")
assert {"camera_url", "camera_source"} <= cols("stations")
command.downgrade(cfg, "019_station_vision_worker")
assert "camera_url" not in cols("stations")
assert "camera_source" not in cols("stations")
assert "vision_worker_url" in cols("stations") # la 019 resta
command.upgrade(cfg, "head") # e di nuovo su
assert {"camera_url", "camera_source"} <= cols("stations")
+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
+100
View File
@@ -430,3 +430,103 @@ async def test_station_vision_worker_url_must_be_http(client: AsyncClient, admin
json={"vision_worker_url": "ftp://x"},
)
assert resp.status_code == 422
# --- camera_url / camera_source (#72) ---
async def test_station_camera_fields_roundtrip(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={
"code": "ST-CAM", "name": "CAM",
"camera_url": " http://127.0.0.1:8765/ ",
"camera_source": " camera:folder ",
},
)
assert created.status_code == 201, created.text
assert created.json()["camera_url"] == "http://127.0.0.1:8765"
assert created.json()["camera_source"] == "camera:folder"
sid = created.json()["id"]
read = await client.get(f"/api/stations/{sid}", headers=auth_headers(admin_user))
assert read.status_code == 200
assert read.json()["camera_url"] == "http://127.0.0.1:8765"
assert read.json()["camera_source"] == "camera:folder"
updated = await client.put(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
json={"camera_url": "https://cam.example:9000", "camera_source": "camera:ABC123"},
)
assert updated.status_code == 200
assert updated.json()["camera_url"] == "https://cam.example:9000"
assert updated.json()["camera_source"] == "camera:ABC123"
cleared = await client.put(
f"/api/stations/{sid}", headers=auth_headers(admin_user),
json={"camera_url": "", "camera_source": " "},
)
assert cleared.status_code == 200
assert cleared.json()["camera_url"] is None
assert cleared.json()["camera_source"] is None
async def test_station_camera_fields_default_to_null(client: AsyncClient, admin_user):
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-CN", "name": "CN"},
)
assert created.status_code == 201
assert created.json()["camera_url"] is None
assert created.json()["camera_source"] is None
async def test_station_camera_url_must_be_http(client: AsyncClient, admin_user):
resp = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-CF", "name": "CF", "camera_url": "ftp://x"},
)
assert resp.status_code == 422
created = await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-CG", "name": "CG"},
)
resp = await client.put(
f"/api/stations/{created.json()['id']}", headers=auth_headers(admin_user),
json={"camera_url": "ftp://x"},
)
assert resp.status_code == 422
async def test_station_by_code_returns_station(client: AsyncClient, admin_user):
await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-BC", "name": "BC", "camera_url": "http://h:1", "camera_source": "camera:folder"},
)
resp = await client.get("/api/stations/by-code/ST-BC", headers=auth_headers(admin_user))
assert resp.status_code == 200, resp.text
assert resp.json()["code"] == "ST-BC"
assert resp.json()["camera_url"] == "http://h:1"
assert resp.json()["camera_source"] == "camera:folder"
async def test_station_by_code_unknown_is_404(client: AsyncClient, admin_user):
resp = await client.get("/api/stations/by-code/NOPE", headers=auth_headers(admin_user))
assert resp.status_code == 404
async def test_station_by_code_any_authenticated_user(
client: AsyncClient, measurement_tec_user, admin_user,
):
await client.post(
"/api/stations", headers=auth_headers(admin_user),
json={"code": "ST-BA", "name": "BA"},
)
resp = await client.get("/api/stations/by-code/ST-BA", headers=auth_headers(measurement_tec_user))
assert resp.status_code == 200, resp.text
assert resp.json()["code"] == "ST-BA"
async def test_station_by_code_requires_auth(client: AsyncClient):
resp = await client.get("/api/stations/by-code/ST-BA")
assert resp.status_code == 401