acquisitore: elenco sorgenti in cache, calibrata chiesta solo se serve (Refs #92)

Servizio: la ricerca camere di /camera/sources resta 60 s in cache (lock,
riscaldamento all'avvio, ?aggiorna=true la forza); le immagini caricate si
leggono sempre fresche. Pagina: l'immagine caricata entra nella select senza
rileggere l'elenco, pulsante Aggiorna, nessuna richiesta calibrata se la
sorgente non ha calibrazione attiva o dopo un 409.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 16:41:18 +02:00
parent 4a48af2ae3
commit 9c45a51770
4 changed files with 121 additions and 17 deletions
+52 -11
View File
@@ -140,6 +140,7 @@ PAGINA = r"""<!doctype html>
<label for="sorgente">Camera o immagine</label> <label for="sorgente">Camera o immagine</label>
<select id="sorgente"></select> <select id="sorgente"></select>
</div> </div>
<button id="aggiornaSorgenti" class="secondario" type="button" title="Cerca di nuovo le camere collegate">Aggiorna</button>
<button id="caricaImmagine" class="secondario" type="button">Carica immagine da file...</button> <button id="caricaImmagine" class="secondario" type="button">Carica immagine da file...</button>
<input id="fileImmagine" type="file" accept="image/*" hidden> <input id="fileImmagine" type="file" accept="image/*" hidden>
<button id="rimuoviImmagine" class="secondario" type="button" hidden>Rimuovi immagine</button> <button id="rimuoviImmagine" class="secondario" type="button" hidden>Rimuovi immagine</button>
@@ -312,12 +313,13 @@ async function dettaglio(risposta) {
// ---- Sorgente // ---- Sorgente
// #92: scegli = nome da selezionare (immagine appena caricata); senza, l'ultima scelta ricordata // #92: scegli = nome da selezionare (immagine appena caricata); senza, l'ultima scelta ricordata
async function caricaSorgenti(scegli) { // #92: aggiorna = true forza la ricerca camere del servizio (camera appena collegata); di norma e' in cache
async function caricaSorgenti(scegli, aggiorna) {
var sel = $("sorgente"); var sel = $("sorgente");
var prima = sel.value; var prima = sel.value;
sel.textContent = ""; sel.textContent = "";
try { try {
var r = await fetch("/camera/sources"); var r = await fetch("/camera/sources" + (aggiorna ? "?aggiorna=true" : ""));
var j = await r.json(); var j = await r.json();
(j.sources || []).forEach(function (s) { (j.sources || []).forEach(function (s) {
var o = document.createElement("option"); var o = document.createElement("option");
@@ -341,6 +343,27 @@ function sorgenteImmagine() {
return sel.selectedIndex >= 0 && sel.options[sel.selectedIndex].dataset.kind === "image"; return sel.selectedIndex >= 0 && sel.options[sel.selectedIndex].dataset.kind === "image";
} }
// #92: aggiunge (o aggiorna) la voce dell'immagine {source, label, kind} nella select e la seleziona
function aggiungiSorgente(j) {
var sel = $("sorgente");
var o = null;
for (var i = 0; i < sel.options.length; i++) if (sel.options[i].value === j.source) o = sel.options[i];
if (!o) { o = document.createElement("option"); o.value = j.source; sel.appendChild(o); }
o.textContent = j.label || j.source;
o.dataset.kind = j.kind || "image";
sel.value = j.source;
cambioSorgente(true);
}
// #92: «Aggiorna» = ricerca camere nuova (sul servizio puo' durare ~10 s)
$("aggiornaSorgenti").onclick = async function () {
$("aggiornaSorgenti").disabled = true;
dire("msgSorgente", "Ricerca delle camere in corso...", "");
await caricaSorgenti(undefined, true);
dire("msgSorgente", "Elenco aggiornato.", "ok");
$("aggiornaSorgenti").disabled = false;
};
// #92: «Carica immagine da file...» -> POST /camera/immagini, poi la nuova immagine diventa la sorgente // #92: «Carica immagine da file...» -> POST /camera/immagini, poi la nuova immagine diventa la sorgente
$("caricaImmagine").onclick = function () { $("fileImmagine").click(); }; $("caricaImmagine").onclick = function () { $("fileImmagine").click(); };
$("fileImmagine").onchange = async function () { $("fileImmagine").onchange = async function () {
@@ -355,7 +378,7 @@ $("fileImmagine").onchange = async function () {
var r = await fetch("/camera/immagini", { method: "POST", body: f }); var r = await fetch("/camera/immagini", { method: "POST", body: f });
if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; } if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; }
var j = await r.json(); var j = await r.json();
await caricaSorgenti(j.source); aggiungiSorgente(j); // #92: la ricerca camere puo' durare ~10 s: la voce nuova si aggiunge senza rileggere l'elenco
dire("msgSorgente", "Immagine «" + j.source + "» caricata: premere «Scatta» per vederla.", "ok"); dire("msgSorgente", "Immagine «" + j.source + "» caricata: premere «Scatta» per vederla.", "ok");
} catch (e) { } catch (e) {
dire("msgSorgente", "Servizio non raggiungibile.", "errore"); dire("msgSorgente", "Servizio non raggiungibile.", "errore");
@@ -385,7 +408,10 @@ $("rimuoviImmagine").onclick = async function () {
try { try {
var r = await fetch("/camera/immagini?nome=" + encodeURIComponent(nome), { method: "DELETE" }); var r = await fetch("/camera/immagini?nome=" + encodeURIComponent(nome), { method: "DELETE" });
if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; } if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; }
await caricaSorgenti(""); var sel = $("sorgente"); // #92: voce tolta in locale, senza rileggere l'elenco dal servizio
sel.removeChild(sel.options[sel.selectedIndex]);
if (sel.options.length) sel.selectedIndex = 0;
cambioSorgente(true);
dire("msgSorgente", "Immagine «" + nome + "» rimossa.", "ok"); dire("msgSorgente", "Immagine «" + nome + "» rimossa.", "ok");
} catch (e) { } catch (e) {
dire("msgSorgente", "Servizio non raggiungibile.", "errore"); dire("msgSorgente", "Servizio non raggiungibile.", "errore");
@@ -399,6 +425,7 @@ function cambioSorgente(salva) {
fermaAnteprima(); fermaAnteprima();
azzeraIndicatori(); azzeraIndicatori();
svuotaRiquadri(); // #92: il frame vecchio non e' della nuova sorgente svuotaRiquadri(); // #92: il frame vecchio non e' della nuova sorgente
A.calAttiva = null; A.bloccoCal = false; // #92: stato calibrazione della nuova sorgente ancora da leggere
annullaRimuovi(); annullaRimuovi();
$("rimuoviImmagine").hidden = !sorgenteImmagine(); $("rimuoviImmagine").hidden = !sorgenteImmagine();
dire("msgAnteprima", ""); dire("msgAnteprima", "");
@@ -410,8 +437,8 @@ function cambioSorgente(salva) {
// ---- Anteprima (#87) // ---- Anteprima (#87)
// #91: ris = {w, h} dell'ultimo frame; #92: urlCal/seqCal = riquadro calibrata, riprendi = ciclo sospeso dalla tab Calibrazione, lato = ultimo lato_max // #91: ris = {w, h} dell'ultimo frame; #92: calAttiva = la sorgente ha una calibrazione attiva (null = non nota), bloccoCal = un 409 ha fermato le richieste calibrata; urlCal/seqCal = riquadro calibrata, riprendi = ciclo sospeso dalla tab Calibrazione, lato = ultimo lato_max
var A = { attiva: false, gen: 0, inVolo: false, url: null, urlCal: null, seqCal: 0, riprendi: false, lato: 1280, nitMax: 0, ris: null }; var A = { attiva: false, gen: 0, inVolo: false, url: null, urlCal: null, seqCal: 0, riprendi: false, lato: 1280, calAttiva: null, bloccoCal: false, nitMax: 0, ris: null };
function azzeraIndicatori() { function azzeraIndicatori() {
A.nitMax = 0; A.nitMax = 0;
@@ -464,6 +491,12 @@ function svuotaRiquadri() {
async function aggiornaCalibrata(lato) { async function aggiornaCalibrata(lato) {
var sorgente = $("sorgente").value; var sorgente = $("sorgente").value;
if (!$("mostraCal").checked || !sorgente) return; if (!$("mostraCal").checked || !sorgente) return;
if (A.calAttiva === false) { // #92: lo stato dice che non c'e' calibrazione: niente richiesta al servizio
A.seqCal++;
messaggioCalibrata("nessuna calibrazione attiva su questa sorgente");
return;
}
if (A.bloccoCal) return; // #92: dopo un 409 (risoluzione diversa, planare) si richiede solo dopo Carica/Scollega o cambio sorgente
var n = ++A.seqCal; var n = ++A.seqCal;
try { try {
var r = await fetch("/camera/anteprima/calibrata", { var r = await fetch("/camera/anteprima/calibrata", {
@@ -472,7 +505,11 @@ async function aggiornaCalibrata(lato) {
body: JSON.stringify({ source: sorgente, lato_max: lato || A.lato }) body: JSON.stringify({ source: sorgente, lato_max: lato || A.lato })
}); });
if (n !== A.seqCal) return; // risposta superata da una piu' recente o da un cambio sorgente if (n !== A.seqCal) return; // risposta superata da una piu' recente o da un cambio sorgente
if (!r.ok) { messaggioCalibrata(await dettaglio(r)); return; } // l'anteprima grezza continua if (!r.ok) { // l'anteprima grezza continua
if (r.status === 409) A.bloccoCal = true; // #92: inutile richiedere a ogni frame
messaggioCalibrata(await dettaglio(r));
return;
}
var nome = ""; var nome = "";
try { nome = decodeURIComponent(r.headers.get("X-Calibrazione") || ""); } catch (e) { /* intestazione non decodificabile: nessun nome */ } try { nome = decodeURIComponent(r.headers.get("X-Calibrazione") || ""); } catch (e) { /* intestazione non decodificabile: nessun nome */ }
var blob = await r.blob(); var blob = await r.blob();
@@ -901,10 +938,12 @@ async function aggiornaStato() {
if (r.status === 404) { if (r.status === 404) {
stato.textContent = "Sorgente non calibrata."; stato.textContent = "Sorgente non calibrata.";
$("statoSorgente").textContent = "non calibrata"; $("statoSorgente").textContent = "non calibrata";
A.calAttiva = false; // #92
} else if (!r.ok) { } else if (!r.ok) {
stato.textContent = await dettaglio(r); stato.textContent = await dettaglio(r);
} else { } else {
var d = await r.json(); var d = await r.json();
A.calAttiva = true; // #92
stato.textContent = ""; stato.textContent = "";
var titolo = document.createElement("div"); var titolo = document.createElement("div");
titolo.textContent = "Calibrazione attiva: " + (d.nome ? "«" + d.nome + "»" : "(senza nome)"); titolo.textContent = "Calibrazione attiva: " + (d.nome ? "«" + d.nome + "»" : "(senza nome)");
@@ -1023,8 +1062,9 @@ async function caricaNome(nome) {
if (sorgente !== $("sorgente").value) return; if (sorgente !== $("sorgente").value) return;
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; } if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; }
dire("msgElenco", "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok"); dire("msgElenco", "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
aggiornaStato(); A.bloccoCal = false; // #92
aggiornaCalibrata(); // #92: il riquadro calibrata usa subito la nuova calibrazione await aggiornaStato(); // prima lo stato nuovo, poi il riquadro calibrata con la nuova calibrazione
aggiornaCalibrata();
} catch (e) { } catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore"); dire("msgElenco", "Servizio non raggiungibile.", "errore");
} }
@@ -1040,8 +1080,9 @@ async function scollega() {
} catch (e) { } catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore"); dire("msgElenco", "Servizio non raggiungibile.", "errore");
} }
aggiornaStato(); A.bloccoCal = false; // #92
aggiornaCalibrata(); // #92: senza calibrazione il riquadro mostra il 409 del servizio await aggiornaStato();
aggiornaCalibrata(); // #92: senza calibrazione il riquadro dice che non c'e'
} }
// «Elimina» di una riga: conferma in pagina con un secondo clic entro 4 s (niente finestre del browser) // «Elimina» di una riga: conferma in pagina con un secondo clic entro 4 s (niente finestre del browser)
+29 -6
View File
@@ -88,6 +88,7 @@ import os
import re # #92: nome sicuro per le immagini caricate import re # #92: nome sicuro per le immagini caricate
import sys import sys
import threading import threading
import time # #92: scadenza della cache delle camere
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
import uuid # #91 id dei calcoli in attesa di nome import uuid # #91 id dei calcoli in attesa di nome
from collections import OrderedDict from collections import OrderedDict
@@ -110,6 +111,7 @@ from calibrazione_pagina import PAGINA # noqa: E402
_TRIGGER = ("continuo",) _TRIGGER = ("continuo",)
_MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome _MAX_CALCOLI = 8 # #91: calcoli tenuti in memoria in attesa di un nome
_CACHE_CAMERE_S = 60.0 # #92: durata della cache della ricerca camere
_log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi) _log = logging.getLogger("camera_service") # #87: errori non bloccanti (settaggi)
# #87: i quattro parametri regolabili, nell'ordine di presentazione # #87: i quattro parametri regolabili, nell'ordine di presentazione
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto") _PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
@@ -169,8 +171,32 @@ def create_app(folder: Path | str | None = None,
with lock: with lock:
return lock_sorgenti.setdefault(nome, threading.Lock()) return lock_sorgenti.setdefault(nome, threading.Lock())
# #92: la ricerca camere di list_sources dura ~10 s: il risultato (gia' filtrato) resta 60 s in cache
cache_camere: dict = {"voci": None, "t": 0.0}
lock_ricerca = threading.Lock() # due richieste insieme non lanciano due ricerche
def _camere(aggiorna: bool = False) -> list:
with lock_ricerca:
fresca = cache_camere["voci"] is not None and time.monotonic() - cache_camere["t"] < _CACHE_CAMERE_S
if fresca and not aggiorna:
return cache_camere["voci"]
voci = list_sources(None, cameras=True, config=_config())
# camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
cache_camere["voci"] = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
cache_camere["t"] = time.monotonic()
return cache_camere["voci"]
def _prima_ricerca() -> None:
try:
_camere()
except Exception: # noqa: BLE001 - e' solo un riscaldamento: la prima richiesta riprova
_log.exception("prima ricerca camere fallita")
@asynccontextmanager @asynccontextmanager
async def _vita(_app): async def _vita(_app):
# #92: la prima ricerca parte all'avvio, cosi' la prima pagina non aspetta
threading.Thread(target=_prima_ricerca, daemon=True, name="ricerca-camere").start()
yield yield
with lock: # alla chiusura rilascia le camere aperte with lock: # alla chiusura rilascia le camere aperte
sorgenti = list(aperte.values()) sorgenti = list(aperte.values())
@@ -184,12 +210,9 @@ def create_app(folder: Path | str | None = None,
app = FastAPI(title="TMF camera service", lifespan=_vita) app = FastAPI(title="TMF camera service", lifespan=_vita)
@app.get("/camera/sources") @app.get("/camera/sources")
def camera_sources(): def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
voci = list_sources(None, cameras=True, config=_config()) elenco = list(_camere(aggiorna))
# #92: camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco # #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
elenco = [{"name": v.name, "kind": v.kind, "label": v.label}
for v in voci if v.kind in ("camera", "virtual") and v.name != "camera:file"]
# #92: in coda le immagini caricate, che sono sorgenti a pieno titolo
for v in list_sources(cartella_immagini, cameras=False): for v in list_sources(cartella_immagini, cameras=False):
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"}) elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
return {"sources": elenco} return {"sources": elenco}
+28
View File
@@ -106,6 +106,34 @@ def test_sources_con_immagini_e_senza_camera_file(client):
assert nomi.index("camera:folder") < nomi.index("a.png") assert nomi.index("camera:folder") < nomi.index("a.png")
def test_sources_camere_in_cache_e_aggiorna(client):
# #92: la ricerca camere (~10 s) si fa una volta e resta 60 s; aggiorna=true la rilancia; le immagini sono sempre fresche
g = _modulo(client)
originale = g["list_sources"]
chiamate = []
def finto(cartella, cameras=True, **kw):
if cameras:
chiamate.append(1)
return originale(cartella, cameras=cameras, **kw)
g["list_sources"] = finto
try:
client.get("/camera/sources") # se la ricerca d'avvio e' ancora in corso aspetta il suo lock
base = len(chiamate)
_carica(client, "nuova.png", una_scacchiera())
voci = client.get("/camera/sources").json()["sources"]
client.get("/camera/sources")
assert len(chiamate) == base # seconda e terza chiamata: nessuna nuova ricerca
assert voci[-1]["name"] == "nuova.png" # ma l'immagine appena caricata c'e'
client.get("/camera/sources", params={"aggiorna": "true"})
assert len(chiamate) == base + 1 # aggiorna=true la rilancia
client.get("/camera/sources")
assert len(chiamate) == base + 1 # e il risultato nuovo torna in cache
finally:
g["list_sources"] = originale
def test_sources_senza_immagini_non_crea_la_cartella(client): def test_sources_senza_immagini_non_crea_la_cartella(client):
voci = client.get("/camera/sources").json()["sources"] voci = client.get("/camera/sources").json()["sources"]
assert all(v["kind"] != "image" for v in voci) assert all(v["kind"] != "image" for v in voci)
+12
View File
@@ -112,3 +112,15 @@ def test_calibrata_dopo_anteprima_e_dopo_carica():
assert PAGINA.count("aggiornaCalibrata();") >= 3 assert PAGINA.count("aggiornaCalibrata();") >= 3
assert "messaggioCalibrata(await dettaglio(r))" in PAGINA assert "messaggioCalibrata(await dettaglio(r))" in PAGINA
assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA assert 'decodeURIComponent(r.headers.get("X-Calibrazione")' in PAGINA
def test_elenco_sorgenti_senza_attese_e_calibrata_solo_se_serve():
# #92: la voce dell'immagine caricata si aggiunge in pagina (niente rilettura dell'elenco); «Aggiorna» forza la ricerca camere
assert "aggiungiSorgente(j);" in PAGINA and "caricaSorgenti(j.source)" not in PAGINA
assert 'id="aggiornaSorgenti"' in PAGINA and ">Aggiorna</button>" in PAGINA
assert '"?aggiorna=true"' in PAGINA
# nessuna calibrazione attiva: il riquadro lo dice senza chiamare il servizio; dopo un 409 non si richiede piu'
assert "if (A.calAttiva === false)" in PAGINA
assert "nessuna calibrazione attiva su questa sorgente" in PAGINA
assert "if (r.status === 409) A.bloccoCal = true;" in PAGINA and "if (A.bloccoCal) return;" in PAGINA
assert PAGINA.count("A.bloccoCal = false;") >= 3 # cambio sorgente, Carica, Scollega