Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6cee498ad | |||
| 828471d27a | |||
| cf64a247be | |||
| 813b66d0c5 | |||
| 8c6d6c1630 | |||
| 1afaaa0bae | |||
| cb66371a5b | |||
| 390d6210d0 | |||
| 2760623b53 | |||
| 9eb5942459 |
@@ -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
|
||||
+354
-98
@@ -8,6 +8,10 @@ Parla con le rotte dello stesso servizio: /camera/sources, /camera/anteprima,
|
||||
/camera/parametri, /camera/parametri/salva, /camera/calibration, /camera/calibrate.
|
||||
#84: calibrazione densa, un solo dato dell'operatore (lato dello scacco in mm).
|
||||
#87: anteprima, parametri camera, stile TMF.
|
||||
#91: due tab, «Acquisizione» e «Calibrazione», nella stessa pagina; l'anteprima resta
|
||||
visibile in entrambe. Calibrazioni salvate con nome in un archivio unico
|
||||
(/camera/calibrazioni, /camera/calibrazione-attiva); il calcolo non salva piu':
|
||||
si salva dal calcolo_id con un nome.
|
||||
Dipende da: niente. E' una stringa, cosi' il pacchetto compilato non ha file dati.
|
||||
"""
|
||||
|
||||
@@ -75,16 +79,45 @@ PAGINA = r"""<!doctype html>
|
||||
.parametro .campo input[type=text] { width: 110px; text-align: right; flex: none; }
|
||||
.parametro .unita { font-size: .82rem; color: var(--secondario); min-width: 2.2em; }
|
||||
.parametro .limiti { font-size: .75rem; color: var(--secondario); }
|
||||
/* #91: tab Acquisizione | Calibrazione */
|
||||
.tabs { display: flex; gap: 6px; margin: 0 0 14px; border-bottom: 1px solid var(--bordo); }
|
||||
.tabs button { background: transparent; color: var(--secondario); border-radius: 6px 6px 0 0; border-bottom: 3px solid transparent;
|
||||
flex: 0 1 auto; padding: 8px 18px; }
|
||||
.tabs button[aria-selected=true] { color: var(--testo); background: var(--pannello); border-bottom-color: var(--accento); font-weight: 600; }
|
||||
/* #91: calibrazione con nome, elenco delle salvate */
|
||||
.scorri { overflow-x: auto; margin-top: 8px; }
|
||||
.elenco { width: 100%; }
|
||||
.elenco th { text-align: left; font-size: .78rem; font-weight: 600; color: var(--secondario); padding: 2px 12px 4px 0; white-space: nowrap; }
|
||||
.elenco td { padding: 5px 12px 5px 0; border-top: 1px solid var(--bordo); vertical-align: top; }
|
||||
.elenco td:first-child { color: var(--testo); }
|
||||
.elenco .azioni { white-space: nowrap; }
|
||||
.elenco button { padding: 3px 9px; font-size: .82rem; margin-right: 4px; }
|
||||
.segno { display: inline-block; font-size: .72rem; padding: 0 6px; border-radius: 9px; background: var(--ok); color: #052e16; margin-left: 6px; }
|
||||
.segno.diversa { background: var(--avviso); color: #422006; }
|
||||
.origine label { display: inline-block; margin: 0 14px 4px 0; color: var(--testo); font-size: .92rem; }
|
||||
input[type=radio] { accent-color: var(--accento); }
|
||||
@media (max-width: 900px) {
|
||||
.griglia { grid-template-columns: minmax(0, 1fr); }
|
||||
.cornice img { max-height: 60vh; }
|
||||
}
|
||||
@media (max-width: 500px) { /* #91: utilizzabile anche stretta (400 px) */
|
||||
main { padding: 8px; }
|
||||
.scheda { padding: 10px; }
|
||||
.tabs button { flex: 1 1 0; padding: 8px 6px; }
|
||||
.parametro .campo input[type=text] { width: 88px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Acquisizione camera</h1>
|
||||
|
||||
<!-- #91: due tab; la colonna dell'anteprima resta visibile in entrambe -->
|
||||
<div class="tabs" role="tablist" aria-label="Sezioni della pagina">
|
||||
<button id="tabAcq" type="button" role="tab" aria-selected="true" aria-controls="pannelloAcq">Acquisizione</button>
|
||||
<button id="tabCal" type="button" role="tab" aria-selected="false" aria-controls="pannelloCal" tabindex="-1">Calibrazione</button>
|
||||
</div>
|
||||
|
||||
<div class="griglia">
|
||||
<div class="colonna">
|
||||
<!-- #87: sezione Anteprima -->
|
||||
@@ -132,7 +165,7 @@ PAGINA = r"""<!doctype html>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="colonna">
|
||||
<div class="colonna" id="pannelloAcq" role="tabpanel" aria-labelledby="tabAcq">
|
||||
<!-- #87: sezione Sorgente -->
|
||||
<section class="scheda" id="sezioneSorgente">
|
||||
<h2>Sorgente</h2>
|
||||
@@ -151,43 +184,51 @@ PAGINA = r"""<!doctype html>
|
||||
</div>
|
||||
<div id="msgParametri" class="messaggio"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- #91: tab Calibrazione (colonna destra al posto di Sorgente e Parametri) -->
|
||||
<div class="colonna" id="pannelloCal" role="tabpanel" aria-labelledby="tabCal" hidden>
|
||||
<section class="scheda" id="sezioneCalibrazione">
|
||||
<h2>Calibrazione</h2>
|
||||
<div class="riga">
|
||||
<div class="stato" style="margin:0 0 10px">Sorgente: <strong id="calSorgente">-</strong> (si cambia nella tab Acquisizione)</div>
|
||||
<div class="origine">
|
||||
<label for="origSorgente"><input id="origSorgente" name="origine" type="radio" value="sorgente" checked> Sorgente selezionata</label>
|
||||
<label for="origFile"><input id="origFile" name="origine" type="radio" value="file"> Immagine da file</label>
|
||||
</div>
|
||||
<div class="riga" style="margin-top:4px">
|
||||
<button id="scegliFile" class="secondario">Scegli immagine...</button>
|
||||
<input id="file" type="file" accept="image/*" hidden>
|
||||
<span id="nomeFile" class="nota" style="margin:0 0 6px">nessuna immagine scelta</span>
|
||||
</div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<div><label for="quadrato">Lato dello scacco (mm)</label><input id="quadrato" type="text" inputmode="decimal" value="2" size="8"></div>
|
||||
<button id="acquisisci" title="Acquisisci e calibra">Calcola</button>
|
||||
</div>
|
||||
<p class="nota">
|
||||
Servono una scacchiera che riempie tutto il campo della camera, piana, ferma e ben illuminata, e il lato di uno scacco.
|
||||
La griglia si trova da sola; il risultato corregge anche la distorsione dell'ottica.
|
||||
Durante il calcolo l'anteprima si sospende.</p>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<button id="acquisisci">Acquisisci e calibra</button>
|
||||
<button id="scegliFile" class="secondario">Scegli immagine...</button>
|
||||
<input id="file" type="file" accept="image/*" hidden>
|
||||
<span id="nomeFile" class="nota" style="margin:0 0 6px">nessuna immagine scelta</span>
|
||||
<button id="carica" class="secondario">Carica immagine e calibra</button>
|
||||
</div>
|
||||
Calcolando dalla sorgente l'anteprima si sospende e poi riprende.</p>
|
||||
<div id="messaggio" class="messaggio"></div>
|
||||
|
||||
<div id="risultato" hidden style="margin-top:10px">
|
||||
<strong>Risultato del calcolo</strong>
|
||||
<div id="origineCalcolo" class="nota" style="margin:2px 0 6px"></div>
|
||||
<table id="tabella"></table>
|
||||
<div id="avviso" class="avviso"></div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<div><label for="nomeCal">Nome</label><input id="nomeCal" type="text" maxlength="60" size="22" autocomplete="off"></div>
|
||||
<button id="salva">Salva</button>
|
||||
<span class="nota" style="margin:0 0 6px">Da camera, salvando si acquisisce e si calcola di nuovo: tenere la scacchiera ferma.</span>
|
||||
</div>
|
||||
<div id="msgSalva" class="messaggio"></div>
|
||||
<img id="overlay" alt="Angoli trovati sulla scacchiera" style="max-width:100%;margin-top:8px;border:1px solid var(--bordo);border-radius:6px">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="margin-top:14px">
|
||||
<strong>Calibrazione salvata</strong>
|
||||
<div id="stato" class="stato">-</div>
|
||||
<div class="riga" style="margin-top:10px">
|
||||
<button id="elimina" class="pericolo" disabled>Elimina calibrazione</button>
|
||||
</div>
|
||||
</div>
|
||||
<section class="scheda" id="sezioneSalvate">
|
||||
<h2>Calibrazioni salvate</h2>
|
||||
<div id="stato" class="stato">-</div>
|
||||
<div class="scorri"><table id="calibrazioni" class="elenco"></table></div>
|
||||
<div id="msgElenco" class="messaggio"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,7 +249,8 @@ function fmt(v, cifre) {
|
||||
return n.toLocaleString("it-IT", o);
|
||||
}
|
||||
// numero senza separatore delle migliaia, per le caselle di input
|
||||
function fmtCasella(v) { return Number(v).toLocaleString("it-IT", { maximumFractionDigits: 6, useGrouping: false }); }
|
||||
// #87: cifre = decimali massimi; i parametri camera ne usano 1 (la camera rilegge 11999,993164 per 12000)
|
||||
function fmtCasella(v, cifre) { return Number(v).toLocaleString("it-IT", { maximumFractionDigits: cifre === undefined ? 6 : cifre, useGrouping: false }); }
|
||||
// legge un numero scritto con virgola o con punto
|
||||
function leggiNumero(testo) {
|
||||
var n = Number(String(testo).split(" ").join("").replace(",", "."));
|
||||
@@ -274,6 +316,7 @@ function cambioSorgente(salva) {
|
||||
fermaAnteprima();
|
||||
azzeraIndicatori();
|
||||
dire("msgAnteprima", "");
|
||||
dire("msgElenco", ""); // #91
|
||||
aggiornaStato();
|
||||
caricaParametri();
|
||||
if (era) avviaAnteprima();
|
||||
@@ -281,7 +324,7 @@ function cambioSorgente(salva) {
|
||||
|
||||
// ---- Anteprima (#87)
|
||||
|
||||
var A = { attiva: false, gen: 0, inVolo: false, url: null, nitMax: 0 };
|
||||
var A = { attiva: false, gen: 0, inVolo: false, url: null, nitMax: 0, ris: null }; // #91: ris = {w, h} dell'ultimo frame
|
||||
|
||||
function azzeraIndicatori() {
|
||||
A.nitMax = 0;
|
||||
@@ -289,6 +332,8 @@ function azzeraIndicatori() {
|
||||
$("saturati").className = "valore";
|
||||
$("barraNitidezza").style.width = "0";
|
||||
$("raddrizzato").textContent = " ";
|
||||
A.ris = null; // #91: risoluzione del frame non piu' nota
|
||||
segnaRisoluzioni();
|
||||
}
|
||||
|
||||
function pulsantiAnteprima() {
|
||||
@@ -321,7 +366,12 @@ function aggiornaIndicatori(h) {
|
||||
}
|
||||
if (isFinite(media)) $("media").textContent = fmt(media, 1);
|
||||
var w = h.get("X-Larghezza"), a = h.get("X-Altezza");
|
||||
if (w && a) $("dimensioni").textContent = fmt(w, 0) + " x " + fmt(a, 0);
|
||||
if (w && a) {
|
||||
$("dimensioni").textContent = fmt(w, 0) + " x " + fmt(a, 0);
|
||||
// #91: risoluzione nota del frame, per segnare le calibrazioni che non combaciano
|
||||
var nw = parseInt(w, 10), na = parseInt(a, 10);
|
||||
if (!A.ris || A.ris.w !== nw || A.ris.h !== na) { A.ris = { w: nw, h: na }; segnaRisoluzioni(); }
|
||||
}
|
||||
var r = h.get("X-Raddrizzato");
|
||||
$("raddrizzato").textContent = r === "si" ? "raddrizzata dalla calibrazione" : r === "no" ? "non raddrizzata" : " ";
|
||||
}
|
||||
@@ -357,7 +407,7 @@ async function unaAnteprima(lato) {
|
||||
}
|
||||
}
|
||||
|
||||
// ciclo sequenziale: la richiesta dopo parte solo a risposta arrivata, almeno 300 ms dopo la precedente
|
||||
// ciclo sequenziale: la richiesta dopo parte solo a risposta arrivata, almeno 100 ms dopo la precedente (#90: ~10 fps)
|
||||
async function ciclo(g) {
|
||||
while (A.inVolo) await pausa(50);
|
||||
while (A.attiva && g === A.gen) {
|
||||
@@ -365,7 +415,7 @@ async function ciclo(g) {
|
||||
var esito = await unaAnteprima(1280);
|
||||
if (g !== A.gen) return;
|
||||
if (esito === "fatale") { fermaAnteprima(); return; }
|
||||
await pausa(esito === "errore" ? 1500 : Math.max(300 - (Date.now() - t0), 0));
|
||||
await pausa(esito === "errore" ? 1500 : Math.max(100 - (Date.now() - t0), 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,20 +554,20 @@ function creaParametro(nome, d) {
|
||||
cur.disabled = bloccato;
|
||||
cur.oninput = function () { // il cursore aggiorna la casella subito, la scrittura arriva dopo 300 ms
|
||||
var v = adatta(d, daPosizione(d, Number(cur.value)));
|
||||
casella.value = fmtCasella(v);
|
||||
casella.value = fmtCasella(v, 1);
|
||||
modifica(nome, v);
|
||||
};
|
||||
campo.appendChild(cur);
|
||||
}
|
||||
casella.type = "text";
|
||||
casella.inputMode = "decimal";
|
||||
casella.value = fmtCasella(d.valore);
|
||||
casella.value = fmtCasella(d.valore, 1);
|
||||
casella.disabled = bloccato;
|
||||
casella.onchange = function () {
|
||||
var v = leggiNumero(casella.value);
|
||||
if (isNaN(v)) { dire("msgParametri", "Valore non valido per " + ETICHETTE[nome].toLowerCase() + ".", "errore"); casella.value = fmtCasella(P.dati[nome].valore); return; }
|
||||
if (isNaN(v)) { dire("msgParametri", "Valore non valido per " + ETICHETTE[nome].toLowerCase() + ".", "errore"); casella.value = fmtCasella(P.dati[nome].valore, 1); return; }
|
||||
v = adatta(d, v);
|
||||
casella.value = fmtCasella(v);
|
||||
casella.value = fmtCasella(v, 1);
|
||||
if (cur) cur.value = aPosizione(d, v);
|
||||
modifica(nome, v);
|
||||
};
|
||||
@@ -588,7 +638,7 @@ function aggiornaValori(j) {
|
||||
if (!el || P.pendenti.hasOwnProperty(nome)) return;
|
||||
P.dati[nome].valore = d.valore;
|
||||
if (el.select) el.select.value = d.valore;
|
||||
if (el.casella && document.activeElement !== el.casella) el.casella.value = fmtCasella(d.valore);
|
||||
if (el.casella && document.activeElement !== el.casella) el.casella.value = fmtCasella(d.valore, 1);
|
||||
if (el.cursore && document.activeElement !== el.cursore) el.cursore.value = aPosizione(P.dati[nome], d.valore);
|
||||
});
|
||||
}
|
||||
@@ -610,6 +660,29 @@ $("salvaParametri").onclick = async function () {
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Tab (#91): «Acquisizione» | «Calibrazione»; l'anteprima sta fuori dai pannelli e resta sempre visibile
|
||||
|
||||
function scegliTab(nome, salva) {
|
||||
var cal = nome === "cal";
|
||||
$("tabAcq").setAttribute("aria-selected", cal ? "false" : "true");
|
||||
$("tabCal").setAttribute("aria-selected", cal ? "true" : "false");
|
||||
$("tabAcq").tabIndex = cal ? -1 : 0;
|
||||
$("tabCal").tabIndex = cal ? 0 : -1;
|
||||
$("pannelloAcq").hidden = cal;
|
||||
$("pannelloCal").hidden = !cal;
|
||||
if (salva) { memoria("tmf.acq.tab", nome); if (cal) caricaCalibrazioni(); } // l'elenco si rilegge entrando
|
||||
}
|
||||
$("tabAcq").onclick = function () { scegliTab("acq", true); };
|
||||
$("tabCal").onclick = function () { scegliTab("cal", true); };
|
||||
[$("tabAcq"), $("tabCal")].forEach(function (b) { // frecce sinistra/destra fra le tab
|
||||
b.onkeydown = function (e) {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
var cal = b.id === "tabAcq";
|
||||
scegliTab(cal ? "cal" : "acq", true);
|
||||
$(cal ? "tabCal" : "tabAcq").focus();
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Calibrazione (logica di #83 e #84; #87: lato precompilato, data italiana, conferma in pagina)
|
||||
|
||||
function righe(d) {
|
||||
@@ -643,37 +716,6 @@ function tabella(el, d) {
|
||||
});
|
||||
}
|
||||
|
||||
async function aggiornaStato() {
|
||||
var sorgente = $("sorgente").value;
|
||||
var stato = $("stato");
|
||||
annullaConferma();
|
||||
$("elimina").disabled = true;
|
||||
if (!sorgente) { stato.textContent = "-"; $("statoSorgente").textContent = "-"; return; }
|
||||
try {
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
|
||||
if (sorgente !== $("sorgente").value) return; // nel frattempo si e' cambiata sorgente
|
||||
if (r.status === 404) {
|
||||
stato.textContent = "Sorgente non calibrata.";
|
||||
$("statoSorgente").textContent = "non calibrata";
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { stato.textContent = await dettaglio(r); return; }
|
||||
var d = await r.json();
|
||||
stato.textContent = "";
|
||||
var t = document.createElement("table");
|
||||
tabella(t, d);
|
||||
var quando = document.createElement("div");
|
||||
quando.textContent = "Calibrata il " + dataItaliana(d.calibrated_at);
|
||||
stato.appendChild(t);
|
||||
stato.appendChild(quando);
|
||||
$("elimina").disabled = false;
|
||||
$("statoSorgente").textContent = "calibrata: " + fmt(d.px_per_mm, 2) + " px/mm";
|
||||
if (d.square_mm) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato
|
||||
} catch (e) {
|
||||
stato.textContent = "Servizio non raggiungibile.";
|
||||
}
|
||||
}
|
||||
|
||||
// avvisi sulla qualita' (solo calibrazione densa): copertura bassa o residuo alto
|
||||
function avvisi(d) {
|
||||
var a = [];
|
||||
@@ -686,78 +728,292 @@ function avvisi(d) {
|
||||
return a.join(" ");
|
||||
}
|
||||
|
||||
async function calcola(conFile, salva) {
|
||||
// ---- Calibrazioni con nome (#91): archivio unico, una attiva per sorgente
|
||||
|
||||
var C = { elenco: [], attiva: null, celle: [], gen: 0, confermaNome: null, confermaBtn: null, confermaTimer: null };
|
||||
var S = { nome: null, timer: null }; // secondo clic su «Sovrascrivi» entro 4 s
|
||||
var JSON_H = { "Content-Type": "application/json" };
|
||||
|
||||
function descriviOrigine(o) { return String(o || "-").replace(":", " "); }
|
||||
|
||||
// stato della calibrazione attiva sulla sorgente selezionata (come prima di #91, piu' il nome)
|
||||
async function aggiornaStato() {
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente) { messaggio("Scegliere una sorgente.", "errore"); return; }
|
||||
var stato = $("stato");
|
||||
annullaConferma();
|
||||
$("calSorgente").textContent = sorgente ? $("sorgente").options[$("sorgente").selectedIndex].textContent : "-"; // #91
|
||||
if (!sorgente) { stato.textContent = "-"; $("statoSorgente").textContent = "-"; caricaCalibrazioni(); return; }
|
||||
try {
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente));
|
||||
if (sorgente !== $("sorgente").value) return; // nel frattempo si e' cambiata sorgente
|
||||
if (r.status === 404) {
|
||||
stato.textContent = "Sorgente non calibrata.";
|
||||
$("statoSorgente").textContent = "non calibrata";
|
||||
} else if (!r.ok) {
|
||||
stato.textContent = await dettaglio(r);
|
||||
} else {
|
||||
var d = await r.json();
|
||||
stato.textContent = "";
|
||||
var titolo = document.createElement("div");
|
||||
titolo.textContent = "Calibrazione attiva: " + (d.nome ? "«" + d.nome + "»" : "(senza nome)");
|
||||
titolo.style.fontWeight = "600";
|
||||
var t = document.createElement("table");
|
||||
tabella(t, d);
|
||||
var quando = document.createElement("div");
|
||||
quando.textContent = "Calibrata il " + dataItaliana(d.calibrated_at);
|
||||
stato.appendChild(titolo);
|
||||
stato.appendChild(t);
|
||||
stato.appendChild(quando);
|
||||
$("statoSorgente").textContent = "calibrata: " + fmt(d.px_per_mm, 2) + " px/mm";
|
||||
if (d.square_mm) $("quadrato").value = fmtCasella(d.square_mm); // #87: parte dal lato gia' usato
|
||||
}
|
||||
} catch (e) {
|
||||
stato.textContent = "Servizio non raggiungibile.";
|
||||
}
|
||||
caricaCalibrazioni();
|
||||
}
|
||||
|
||||
// #91: elenco unico delle calibrazioni salvate; "attiva" e' quella della sorgente selezionata
|
||||
async function caricaCalibrazioni() {
|
||||
var sorgente = $("sorgente").value;
|
||||
var g = ++C.gen;
|
||||
try {
|
||||
var r = await fetch("/camera/calibrazioni" + (sorgente ? "?source=" + encodeURIComponent(sorgente) : ""));
|
||||
if (g !== C.gen) return;
|
||||
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; }
|
||||
var j = await r.json();
|
||||
if (g !== C.gen) return;
|
||||
C.elenco = j.calibrazioni || [];
|
||||
C.attiva = j.attiva || null;
|
||||
disegnaElenco();
|
||||
} catch (e) {
|
||||
if (g === C.gen) dire("msgElenco", "Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
}
|
||||
|
||||
function cella(tr, testo) {
|
||||
var td = document.createElement("td");
|
||||
td.textContent = testo;
|
||||
tr.appendChild(td);
|
||||
return td;
|
||||
}
|
||||
|
||||
function bottone(testo, classe, fn) {
|
||||
var b = document.createElement("button");
|
||||
b.textContent = testo;
|
||||
b.className = classe || "";
|
||||
b.onclick = fn;
|
||||
return b;
|
||||
}
|
||||
|
||||
function disegnaElenco() {
|
||||
var t = $("calibrazioni");
|
||||
annullaConferma();
|
||||
t.textContent = "";
|
||||
C.celle = [];
|
||||
if (!C.elenco.length) {
|
||||
var trVuota = document.createElement("tr");
|
||||
cella(trVuota, "Nessuna calibrazione salvata.");
|
||||
t.appendChild(trVuota);
|
||||
return;
|
||||
}
|
||||
var testa = document.createElement("tr");
|
||||
["Nome", "Risoluzione", "px/mm", "Metodo", "Data", "Origine", ""].forEach(function (h) {
|
||||
var th = document.createElement("th");
|
||||
th.textContent = h;
|
||||
testa.appendChild(th);
|
||||
});
|
||||
t.appendChild(testa);
|
||||
C.elenco.forEach(function (c) {
|
||||
var tr = document.createElement("tr");
|
||||
var tdNome = cella(tr, c.nome);
|
||||
var attiva = C.attiva !== null && c.nome === C.attiva;
|
||||
if (attiva) {
|
||||
var s1 = document.createElement("span");
|
||||
s1.className = "segno";
|
||||
s1.textContent = "attiva";
|
||||
tdNome.appendChild(s1);
|
||||
}
|
||||
var s2 = document.createElement("span"); // «risoluzione diversa» dal frame della sorgente (si accende in segnaRisoluzioni)
|
||||
s2.className = "segno diversa";
|
||||
s2.textContent = "risoluzione diversa";
|
||||
s2.hidden = true;
|
||||
tdNome.appendChild(s2);
|
||||
C.celle.push({ w: Number(c.image_width), h: Number(c.image_height), span: s2 });
|
||||
cella(tr, c.image_width && c.image_height ? fmt(c.image_width, 0) + "×" + fmt(c.image_height, 0) : "-");
|
||||
cella(tr, fmt(c.px_per_mm, 3));
|
||||
cella(tr, c.metodo === "densa" ? "densa" : "planare");
|
||||
cella(tr, c.calibrated_at ? dataItaliana(c.calibrated_at) : "-");
|
||||
cella(tr, descriviOrigine(c.origine));
|
||||
var az = document.createElement("td");
|
||||
az.className = "azioni";
|
||||
var bc = bottone("Carica", "", function () { caricaNome(c.nome); });
|
||||
bc.disabled = !$("sorgente").value;
|
||||
az.appendChild(bc);
|
||||
if (attiva) az.appendChild(bottone("Scollega", "secondario", scollega));
|
||||
az.appendChild(bottone("Elimina", "secondario", function (e) { eliminaNome(c.nome, e.currentTarget); }));
|
||||
tr.appendChild(az);
|
||||
t.appendChild(tr);
|
||||
});
|
||||
segnaRisoluzioni();
|
||||
}
|
||||
|
||||
// #91: «risoluzione diversa» se il frame noto della sorgente non combacia con quello della calibrazione
|
||||
function segnaRisoluzioni() {
|
||||
(C.celle || []).forEach(function (c) {
|
||||
c.span.hidden = !(A.ris && c.w > 0 && c.h > 0 && (A.ris.w !== c.w || A.ris.h !== c.h));
|
||||
});
|
||||
}
|
||||
|
||||
// «Carica»: la calibrazione diventa quella attiva della sorgente. Un errore (es. risoluzione diversa) non ferma l'anteprima.
|
||||
async function caricaNome(nome) {
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente) { dire("msgElenco", "Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
|
||||
dire("msgElenco", "Caricamento di «" + nome + "»...", "");
|
||||
try {
|
||||
var r = await fetch("/camera/calibrazione-attiva", { method: "PUT", headers: JSON_H, body: JSON.stringify({ source: sorgente, nome: nome }) });
|
||||
if (sorgente !== $("sorgente").value) return;
|
||||
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; }
|
||||
dire("msgElenco", "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
|
||||
$("raddrizzato").textContent = $("grezzo").checked ? "non raddrizzata" : "raddrizzata dalla calibrazione"; // il prossimo frame conferma
|
||||
aggiornaStato();
|
||||
} catch (e) {
|
||||
dire("msgElenco", "Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
}
|
||||
|
||||
// «Scollega»: la sorgente torna senza calibrazione, la libreria resta
|
||||
async function scollega() {
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!sorgente) return;
|
||||
try {
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
|
||||
dire("msgElenco", r.ok ? "Calibrazione scollegata dalla sorgente (resta fra le salvate)." : await dettaglio(r), r.ok ? "ok" : "errore");
|
||||
if (r.ok) $("raddrizzato").textContent = "non raddrizzata";
|
||||
} catch (e) {
|
||||
dire("msgElenco", "Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
aggiornaStato();
|
||||
}
|
||||
|
||||
// «Elimina» di una riga: conferma in pagina con un secondo clic entro 4 s (niente finestre del browser)
|
||||
function annullaConferma() {
|
||||
clearTimeout(C.confermaTimer);
|
||||
C.confermaTimer = null;
|
||||
if (C.confermaBtn) C.confermaBtn.textContent = "Elimina";
|
||||
C.confermaBtn = null;
|
||||
C.confermaNome = null;
|
||||
}
|
||||
async function eliminaNome(nome, btn) {
|
||||
if (C.confermaNome !== nome) {
|
||||
annullaConferma();
|
||||
C.confermaNome = nome;
|
||||
C.confermaBtn = btn;
|
||||
btn.textContent = "Confermi? Clic ancora";
|
||||
C.confermaTimer = setTimeout(annullaConferma, 4000);
|
||||
return;
|
||||
}
|
||||
annullaConferma();
|
||||
try {
|
||||
var r = await fetch("/camera/calibrazioni?nome=" + encodeURIComponent(nome), { method: "DELETE" });
|
||||
dire("msgElenco", r.ok ? "Calibrazione «" + nome + "» eliminata." : await dettaglio(r), r.ok ? "ok" : "errore");
|
||||
} catch (e) {
|
||||
dire("msgElenco", "Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
aggiornaStato();
|
||||
}
|
||||
|
||||
// #91: il calcolo non salva mai; il servizio tiene il risultato e ne da' il calcolo_id per «Salva»
|
||||
async function calcola() {
|
||||
var daFile = $("origFile").checked;
|
||||
var sorgente = $("sorgente").value;
|
||||
if (!daFile && !sorgente) { messaggio("Scegliere una sorgente nella tab Acquisizione.", "errore"); return; }
|
||||
var lato = leggiNumero($("quadrato").value);
|
||||
if (!(lato > 0)) { messaggio("Il lato dello scacco deve essere un numero maggiore di zero.", "errore"); return; }
|
||||
var f = new FormData();
|
||||
f.append("source", sorgente);
|
||||
f.append("square_mm", String(lato)); // al servizio sempre con il punto
|
||||
f.append("save", salva ? "true" : "false");
|
||||
if (conFile) {
|
||||
if (daFile) {
|
||||
var file = $("file").files[0];
|
||||
if (!file) { messaggio("Scegliere un file immagine.", "errore"); return; }
|
||||
f.append("image", file);
|
||||
} else {
|
||||
f.append("source", sorgente);
|
||||
}
|
||||
var eraAttiva = A.attiva; // #87: l'anteprima si sospende e poi riprende
|
||||
var eraAttiva = !daFile && A.attiva; // #87: da sorgente l'anteprima si sospende e poi riprende
|
||||
if (eraAttiva) fermaAnteprima();
|
||||
messaggio("Calcolo in corso...", "");
|
||||
["acquisisci", "carica", "salva"].forEach(function (i) { $(i).disabled = true; });
|
||||
["acquisisci", "salva"].forEach(function (i) { $(i).disabled = true; });
|
||||
try {
|
||||
var r = await fetch("/camera/calibrate", { method: "POST", body: f });
|
||||
if (!r.ok) { messaggio(await dettaglio(r), "errore"); return; }
|
||||
var d = await r.json();
|
||||
ultimo = { conFile: conFile, dati: d };
|
||||
ultimo = { dati: d };
|
||||
tabella($("tabella"), d);
|
||||
$("overlay").src = "data:image/png;base64," + d.overlay_png;
|
||||
$("avviso").textContent = avvisi(d);
|
||||
$("origineCalcolo").textContent = "Origine: " + descriviOrigine(d.origine);
|
||||
$("risultato").hidden = false;
|
||||
messaggio(d.saved ? "Calibrazione salvata." : "Calibrazione riuscita: controllare e salvare.", "ok");
|
||||
if (d.saved) aggiornaStato();
|
||||
annullaSovrascrivi();
|
||||
dire("msgSalva", "");
|
||||
messaggio("Calcolo riuscito: dare un nome e salvare.", "ok");
|
||||
} catch (e) {
|
||||
messaggio("Servizio non raggiungibile.", "errore");
|
||||
} finally {
|
||||
["acquisisci", "carica", "salva"].forEach(function (i) { $(i).disabled = false; });
|
||||
$("acquisisci").disabled = false;
|
||||
$("salva").disabled = false;
|
||||
if (eraAttiva) avviaAnteprima();
|
||||
}
|
||||
}
|
||||
|
||||
$("acquisisci").onclick = function () { calcola(false, false); };
|
||||
$("carica").onclick = function () { calcola(true, false); };
|
||||
// «Salva» rifa il calcolo con save=true sulla stessa origine (altra acquisizione se da camera)
|
||||
$("salva").onclick = function () { calcola(ultimo ? ultimo.conFile : false, true); };
|
||||
// «Salva» con nome; se il nome c'e' gia' il pulsante chiede un secondo clic per sovrascrivere
|
||||
function annullaSovrascrivi() {
|
||||
clearTimeout(S.timer);
|
||||
S.timer = null;
|
||||
S.nome = null;
|
||||
$("salva").textContent = "Salva";
|
||||
}
|
||||
async function salvaNome() {
|
||||
if (!ultimo || !ultimo.dati.calcolo_id) { dire("msgSalva", "Nessun calcolo da salvare: calcolare prima.", "errore"); return; }
|
||||
var nome = $("nomeCal").value.trim();
|
||||
if (!nome) { dire("msgSalva", "Scrivere un nome per la calibrazione.", "errore"); return; }
|
||||
if (nome.length > 60) { dire("msgSalva", "Il nome può avere al massimo 60 caratteri.", "errore"); return; }
|
||||
var sovrascrivi = S.timer !== null && S.nome === nome.toLowerCase(); // secondo clic sullo stesso nome
|
||||
annullaSovrascrivi();
|
||||
$("salva").disabled = true;
|
||||
try {
|
||||
var r = await fetch("/camera/calibrazioni", {
|
||||
method: "POST", headers: JSON_H,
|
||||
body: JSON.stringify({ calcolo_id: ultimo.dati.calcolo_id, nome: nome, sovrascrivi: sovrascrivi })
|
||||
});
|
||||
if (r.status === 409) {
|
||||
S.nome = nome.toLowerCase();
|
||||
$("salva").textContent = "Sovrascrivi «" + nome + "»? Clic ancora";
|
||||
S.timer = setTimeout(annullaSovrascrivi, 4000);
|
||||
dire("msgSalva", await dettaglio(r), "errore");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { dire("msgSalva", await dettaglio(r), "errore"); return; }
|
||||
dire("msgSalva", "Calibrazione «" + nome + "» salvata. Per usarla su una sorgente: «Carica» nell'elenco.", "ok");
|
||||
caricaCalibrazioni();
|
||||
} catch (e) {
|
||||
dire("msgSalva", "Servizio non raggiungibile.", "errore");
|
||||
} finally {
|
||||
$("salva").disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
$("acquisisci").onclick = calcola;
|
||||
$("salva").onclick = salvaNome;
|
||||
$("nomeCal").onkeydown = function (e) { if (e.key === "Enter") salvaNome(); };
|
||||
// #87: il pulsante del file e' in italiano, l'input nativo resta nascosto
|
||||
$("scegliFile").onclick = function () { $("file").click(); };
|
||||
$("file").onchange = function () {
|
||||
$("nomeFile").textContent = $("file").files[0] ? $("file").files[0].name : "nessuna immagine scelta";
|
||||
};
|
||||
|
||||
// #87: «Elimina calibrazione» chiede conferma in pagina con un secondo clic (niente finestre del browser)
|
||||
var confermaTimer = null;
|
||||
function annullaConferma() {
|
||||
clearTimeout(confermaTimer);
|
||||
confermaTimer = null;
|
||||
$("elimina").textContent = "Elimina calibrazione";
|
||||
}
|
||||
$("elimina").onclick = async function () {
|
||||
if (!confermaTimer) {
|
||||
$("elimina").textContent = "Confermi? Clic ancora per eliminare";
|
||||
confermaTimer = setTimeout(annullaConferma, 4000);
|
||||
return;
|
||||
}
|
||||
annullaConferma();
|
||||
var sorgente = $("sorgente").value;
|
||||
try {
|
||||
var r = await fetch("/camera/calibration?source=" + encodeURIComponent(sorgente), { method: "DELETE" });
|
||||
messaggio(r.ok ? "Calibrazione eliminata." : await dettaglio(r), r.ok ? "ok" : "errore");
|
||||
} catch (e) {
|
||||
messaggio("Servizio non raggiungibile.", "errore");
|
||||
}
|
||||
aggiornaStato();
|
||||
if ($("file").files[0]) $("origFile").checked = true; // #91: scelto un file, l'origine diventa il file
|
||||
};
|
||||
|
||||
$("sorgente").onchange = function () { cambioSorgente(true); };
|
||||
scegliTab(memoria("tmf.acq.tab") === "cal" ? "cal" : "acq", false); // #91: tab ricordata
|
||||
caricaSorgenti();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+177
-10
@@ -26,6 +26,23 @@ Calibrazione a scacchiera per sorgente (#83, logica in scripts/calibrazione.py):
|
||||
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).
|
||||
|
||||
@@ -49,6 +66,7 @@ camera a chiunque raggiunga la porta: solo su rete isolata.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
@@ -61,7 +79,7 @@ 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
|
||||
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)
|
||||
@@ -70,6 +88,11 @@ 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 = {
|
||||
@@ -140,17 +163,41 @@ def create_app(folder: Path | str | None = None,
|
||||
return {"sources": [{"name": v.name, "kind": v.kind, "label": v.label}
|
||||
for v in voci if v.kind in ("camera", "virtual")]}
|
||||
|
||||
def _grab(nome: str, timeout: int):
|
||||
"""Un frame dalla sorgente (apre e tiene aperta); errori camera -> HTTP."""
|
||||
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
|
||||
with lock:
|
||||
sorgente = aperte.get(nome)
|
||||
if sorgente is None:
|
||||
sorgente = open_source(nome, None, config=_config())
|
||||
with lock:
|
||||
aperte[nome] = sorgente
|
||||
return sorgente.grab(timeout)
|
||||
return fn(_apri(nome))
|
||||
except SourceNotFound as e:
|
||||
raise HTTPException(404, _traduci(str(e)))
|
||||
except TimeoutError as e:
|
||||
@@ -158,6 +205,10 @@ def create_app(folder: Path | str | None = None,
|
||||
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
|
||||
@@ -241,7 +292,123 @@ def create_app(folder: Path | str | None = None,
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -32,7 +32,7 @@ def test_id_principali():
|
||||
attesi = {"sorgente", "avvia", "ferma", "scatta", "grezzo", "mirinoCasella", "immagine",
|
||||
"nitidezza", "nitidezzaMax", "saturati", "media", "dimensioni",
|
||||
"parametri", "salvaParametri", "quadrato", "acquisisci", "scegliFile", "file",
|
||||
"carica", "salva", "elimina", "stato", "statoSorgente"}
|
||||
"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))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -598,7 +598,7 @@ def api_station_camera_calibration(station_id: int):
|
||||
}), 409
|
||||
camera_url, camera_source = camera
|
||||
base = camera_url.rstrip("/")
|
||||
link = f"{base}/calibrazione" # pagina di calibrazione dell'acquisitore (C4)
|
||||
link = f"{base}/acquisizione" # pagina di acquisizione dell'acquisitore (#88; era /calibrazione, C4)
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{base}/camera/calibration",
|
||||
|
||||
@@ -40,8 +40,8 @@ function stationCalibrationCell(station, msgs) {
|
||||
if (!this.hasCamera) return;
|
||||
const r = await fetchStationCalibration(station.id);
|
||||
this.state = r.state || 'unreachable';
|
||||
// Link «Calibra»: dal proxy, altrimenti costruito dall'indirizzo della camera
|
||||
this.calibrationUrl = r.calibration_url || (this.cameraUrl.replace(/\/+$/, '') + '/calibrazione');
|
||||
// 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';
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
</span>
|
||||
<a :href="calibrationUrl" target="_blank" rel="noopener"
|
||||
x-show="calibrationUrl"
|
||||
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
|
||||
class="font-medium text-primary hover:underline">{{ _('Acquisizione') }}</a> {# #88: era «Calibra» #}
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
@@ -238,21 +238,42 @@
|
||||
{# 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"
|
||||
<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>
|
||||
<input type="text" x-model="form.camera_source"
|
||||
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="camera:folder"
|
||||
maxlength="200">
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Es. camera:<seriale> oppure camera:folder') }}</p>
|
||||
<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>
|
||||
@@ -509,6 +530,12 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
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]');
|
||||
@@ -556,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: '', vision_worker_url: '', camera_url: '', camera_source: '', active: true };
|
||||
this.loadCameraSources(); // #88: svuota lo stato delle sorgenti
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
@@ -578,6 +640,7 @@ function stationManagement(initialStations, initialRecipes) {
|
||||
camera_source: station.camera_source || '', // #72
|
||||
active: station.active,
|
||||
};
|
||||
this.loadCameraSources(); // #88: se la camera e' valorizzata legge subito le sorgenti
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
|
||||
@@ -254,3 +254,74 @@ def test_create_station_proxy_forwards_the_camera_fields(logged_in_client, mock_
|
||||
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
|
||||
|
||||
@@ -120,7 +120,7 @@ class TestStationCameraCalibration:
|
||||
assert resp.status_code == 200
|
||||
assert body["state"] == "calibrated"
|
||||
assert body["calibration"]["px_per_mm"] == 20.03
|
||||
assert body["calibration_url"] == "http://acq:8765/calibrazione"
|
||||
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"}
|
||||
@@ -130,7 +130,7 @@ class TestStationCameraCalibration:
|
||||
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/calibrazione"
|
||||
assert resp.get_json()["calibration_url"] == "http://acq:8765/acquisizione"
|
||||
|
||||
def test_unreachable(self, logged_in_client, mock_api_client):
|
||||
_stations(mock_api_client)
|
||||
|
||||
@@ -3452,3 +3452,36 @@ 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"
|
||||
|
||||
@@ -3440,3 +3440,36 @@ 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"
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/visionsuite updated: e0d19dee7e...06b549a25d
Reference in New Issue
Block a user