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:
@@ -140,6 +140,7 @@ PAGINA = r"""<!doctype html>
|
||||
<label for="sorgente">Camera o immagine</label>
|
||||
<select id="sorgente"></select>
|
||||
</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>
|
||||
<input id="fileImmagine" type="file" accept="image/*" hidden>
|
||||
<button id="rimuoviImmagine" class="secondario" type="button" hidden>Rimuovi immagine</button>
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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</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
|
||||
|
||||
Reference in New Issue
Block a user