diff --git a/scripts/calibrazione_pagina.py b/scripts/calibrazione_pagina.py
index 3a67e06..4cdb13a 100644
--- a/scripts/calibrazione_pagina.py
+++ b/scripts/calibrazione_pagina.py
@@ -140,6 +140,7 @@ PAGINA = r"""
+
@@ -312,12 +313,13 @@ async function dettaglio(risposta) {
// ---- Sorgente
// #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 prima = sel.value;
sel.textContent = "";
try {
- var r = await fetch("/camera/sources");
+ var r = await fetch("/camera/sources" + (aggiorna ? "?aggiorna=true" : ""));
var j = await r.json();
(j.sources || []).forEach(function (s) {
var o = document.createElement("option");
@@ -341,6 +343,27 @@ function sorgenteImmagine() {
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
$("caricaImmagine").onclick = function () { $("fileImmagine").click(); };
$("fileImmagine").onchange = async function () {
@@ -355,7 +378,7 @@ $("fileImmagine").onchange = async function () {
var r = await fetch("/camera/immagini", { method: "POST", body: f });
if (!r.ok) { dire("msgSorgente", await dettaglio(r), "errore"); return; }
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");
} catch (e) {
dire("msgSorgente", "Servizio non raggiungibile.", "errore");
@@ -385,7 +408,10 @@ $("rimuoviImmagine").onclick = async function () {
try {
var r = await fetch("/camera/immagini?nome=" + encodeURIComponent(nome), { method: "DELETE" });
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");
} catch (e) {
dire("msgSorgente", "Servizio non raggiungibile.", "errore");
@@ -399,6 +425,7 @@ function cambioSorgente(salva) {
fermaAnteprima();
azzeraIndicatori();
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();
$("rimuoviImmagine").hidden = !sorgenteImmagine();
dire("msgAnteprima", "");
@@ -410,8 +437,8 @@ function cambioSorgente(salva) {
// ---- Anteprima (#87)
-// #91: ris = {w, h} dell'ultimo frame; #92: 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 };
+// #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, calAttiva: null, bloccoCal: false, nitMax: 0, ris: null };
function azzeraIndicatori() {
A.nitMax = 0;
@@ -464,6 +491,12 @@ function svuotaRiquadri() {
async function aggiornaCalibrata(lato) {
var sorgente = $("sorgente").value;
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;
try {
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 })
});
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 = "";
try { nome = decodeURIComponent(r.headers.get("X-Calibrazione") || ""); } catch (e) { /* intestazione non decodificabile: nessun nome */ }
var blob = await r.blob();
@@ -901,10 +938,12 @@ async function aggiornaStato() {
if (r.status === 404) {
stato.textContent = "Sorgente non calibrata.";
$("statoSorgente").textContent = "non calibrata";
+ A.calAttiva = false; // #92
} else if (!r.ok) {
stato.textContent = await dettaglio(r);
} else {
var d = await r.json();
+ A.calAttiva = true; // #92
stato.textContent = "";
var titolo = document.createElement("div");
titolo.textContent = "Calibrazione attiva: " + (d.nome ? "«" + d.nome + "»" : "(senza nome)");
@@ -1023,8 +1062,9 @@ async function caricaNome(nome) {
if (sorgente !== $("sorgente").value) return;
if (!r.ok) { dire("msgElenco", await dettaglio(r), "errore"); return; }
dire("msgElenco", "Calibrazione «" + nome + "» caricata sulla sorgente.", "ok");
- aggiornaStato();
- aggiornaCalibrata(); // #92: il riquadro calibrata usa subito la nuova calibrazione
+ A.bloccoCal = false; // #92
+ await aggiornaStato(); // prima lo stato nuovo, poi il riquadro calibrata con la nuova calibrazione
+ aggiornaCalibrata();
} catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore");
}
@@ -1040,8 +1080,9 @@ async function scollega() {
} catch (e) {
dire("msgElenco", "Servizio non raggiungibile.", "errore");
}
- aggiornaStato();
- aggiornaCalibrata(); // #92: senza calibrazione il riquadro mostra il 409 del servizio
+ A.bloccoCal = false; // #92
+ 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)
diff --git a/scripts/camera_service.py b/scripts/camera_service.py
index 3a55fad..6bfc364 100644
--- a/scripts/camera_service.py
+++ b/scripts/camera_service.py
@@ -88,6 +88,7 @@ import os
import re # #92: nome sicuro per le immagini caricate
import sys
import threading
+import time # #92: scadenza della cache delle camere
import urllib.parse # #92: nome calibrazione nell'header X-Calibrazione
import uuid # #91 id dei calcoli in attesa di nome
from collections import OrderedDict
@@ -110,6 +111,7 @@ from calibrazione_pagina import PAGINA # noqa: E402
_TRIGGER = ("continuo",)
_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)
# #87: i quattro parametri regolabili, nell'ordine di presentazione
_PARAMETRI = ("esposizione", "esposizione_auto", "guadagno", "guadagno_auto")
@@ -169,8 +171,32 @@ def create_app(folder: Path | str | None = None,
with 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
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
with lock: # alla chiusura rilascia le camere aperte
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.get("/camera/sources")
- def camera_sources():
- voci = list_sources(None, cameras=True, config=_config())
- # #92: camera:file (replay) non e' mai configurato in TMF -> fuori dall'elenco
- 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
+ def camera_sources(aggiorna: bool = False): # #92: aggiorna=true forza la ricerca (camera appena collegata)
+ elenco = list(_camere(aggiorna))
+ # #92: in coda le immagini caricate, sempre fresche dalla cartella (costano nulla)
for v in list_sources(cartella_immagini, cameras=False):
elenco.append({"name": v.name, "kind": "image", "label": f"immagine — {v.name}"})
return {"sources": elenco}
diff --git a/scripts/tests/test_immagini_affiancate.py b/scripts/tests/test_immagini_affiancate.py
index 01410fd..2aa79c8 100644
--- a/scripts/tests/test_immagini_affiancate.py
+++ b/scripts/tests/test_immagini_affiancate.py
@@ -106,6 +106,34 @@ def test_sources_con_immagini_e_senza_camera_file(client):
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):
voci = client.get("/camera/sources").json()["sources"]
assert all(v["kind"] != "image" for v in voci)
diff --git a/scripts/tests/test_pagina_tab.py b/scripts/tests/test_pagina_tab.py
index 14d062a..b74d70c 100644
--- a/scripts/tests/test_pagina_tab.py
+++ b/scripts/tests/test_pagina_tab.py
@@ -112,3 +112,15 @@ def test_calibrata_dopo_anteprima_e_dopo_carica():
assert PAGINA.count("aggiornaCalibrata();") >= 3
assert "messaggioCalibrata(await dettaglio(r))" 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" 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