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