From 788215b49efe7e72b38e30f84c15de23fb471ce7 Mon Sep 17 00:00:00 2001 From: julio Date: Tue, 6 Oct 2026 03:40:13 -0300 Subject: [PATCH] feat(historico): deteccoes com foto, sensor PIR na placa e tela apagando no sono - historico.py: eventos de PIR (gpiomon no pino 11, gpiochip1 linha 70), movimento e som do celular em SQLite; ate 200 eventos e 100 fotos; cada sensor e a foto ligam/desligam; intervalo minimo por tipo - PIR acorda o Carinha quando ele esta dormindo - APK 0.8.3: o app consulta o pedido de foto e envia uma foto da camera frontal (quadro da camera de movimento ou do video ao vivo) - tela apaga depois de X minutos dormindo (preto total + brilho minimo no AMOLED); acende com som, movimento, toque, comando ou PIR - painel: tela Historico (sensores, fotos, filtros, apagar), atalho em Ao vivo e no Ctrl+K, ajustes "Apagar a tela quando dormir" - testes do historico (servico e rotas HTTP) Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + android/app/AndroidManifest.xml | 4 +- android/app/assets/carinha.html | 30 ++ android/app/assets/transmissao.html | 9 + .../java/br/carinha/rosto/ControleRemoto.java | 55 +++- .../java/br/carinha/rosto/MainActivity.java | 13 +- carinha.html | 30 ++ controle_pc.py | 3 +- deploy.ps1 | 2 +- historico.py | 270 ++++++++++++++++ kiosk-server.py | 17 +- tests/test_historico.py | 119 +++++++ web/src/App.svelte | 2 + web/src/lib/ajustes.svelte.ts | 2 + web/src/lib/estado.svelte.ts | 6 +- web/src/lib/historico.svelte.ts | 84 +++++ web/src/lib/paleta.svelte.ts | 1 + web/src/lib/rotas.svelte.ts | 7 +- web/src/telas/AoVivo.svelte | 3 +- web/src/telas/Historico.svelte | 290 ++++++++++++++++++ 20 files changed, 934 insertions(+), 16 deletions(-) create mode 100644 historico.py create mode 100644 tests/test_historico.py create mode 100644 web/src/lib/historico.svelte.ts create mode 100644 web/src/telas/Historico.svelte diff --git a/.gitignore b/.gitignore index 71afbcc..e49a8c9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ api-privada.tmp ia-config.json seguranca.json +historico.db +historico.json +fotos/ diff --git a/android/app/AndroidManifest.xml b/android/app/AndroidManifest.xml index 2488da6..8a1e9cf 100644 --- a/android/app/AndroidManifest.xml +++ b/android/app/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="11" + android:versionName="0.8.3"> diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html index 37c1b08..9cf8d7c 100644 --- a/android/app/assets/carinha.html +++ b/android/app/assets/carinha.html @@ -93,6 +93,22 @@ addEventListener("resize", ajustar); const PRETO = [6, 8, 14]; const CIANO = [92, 214, 255]; const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 }; +const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false }; +/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo. + Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */ +function cuidarDaTela() { + const agora = performance.now(); + if (!r.dormindo) SONO.desde = 0; + else if (!SONO.desde) SONO.desde = agora; + const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000; + if (apagar !== SONO.apagada) { + SONO.apagada = apagar; + window.carinhaTelaApagada = apagar; + document.body.style.visibility = apagar ? "hidden" : ""; + console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa"); + } +} +setInterval(cuidarDaTela, 1000); const CIANO_E = [150, 232, 255]; const VERM = [255, 78, 78]; const ROSA = [255, 112, 176]; @@ -716,6 +732,16 @@ function checarSilencio(agora) { journal do carinha-web (so quando o microfone esta ativo). */ function relatarSom(evento, n) { try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} + if (evento !== "adormeceu") registrarEvento("som", evento, n); +} + +/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */ +function registrarEvento(tipo, evento, nivel) { + if (new URLSearchParams(location.search).get("aparelho") !== "1") return; + try { + fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {}); + } catch (e) {} } // ------------------------------------------------------ presenca (movimento) @@ -804,6 +830,7 @@ function movimento(vel, cx, cy) { /* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */ function relatarMov(evento, n) { try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} + registrarEvento("movimento", evento, n); } // ------------------------------------------------- camera oculta (sensor) @@ -936,6 +963,8 @@ let configRecebida = null; function configurarApp(c) { EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true; if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca; + SONO.apagar = c.apagarTela !== false; + if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin; if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha; AUTO = c.auto; AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume; @@ -966,6 +995,7 @@ async function sincronizarApp() { movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2, centro: [MOV.cx, MOV.cy], config: configRecebida, brilho: window.carinhaBrilho, + tela_apagada: !!window.carinhaTelaApagada, bateria: window.carinhaBateria, aparelho: window.carinhaAparelho, video: window.carinhaVideo, diff --git a/android/app/assets/transmissao.html b/android/app/assets/transmissao.html index f355d8d..105931b 100644 --- a/android/app/assets/transmissao.html +++ b/android/app/assets/transmissao.html @@ -152,6 +152,15 @@ window.rtcReceber = packet => { }); }; window.rtcParar = () => { allowed = false; parar(); sid = null; }; +/* Foto do evento para o histórico, tirada do vídeo ao vivo (câmera de movimento parada). */ +window.rtcFoto = () => { + if (!stream || video.readyState < 2 || !video.videoWidth) return; + const escala = Math.min(1, 1280 / video.videoWidth); + const tela = document.createElement('canvas'); + tela.width = Math.round(video.videoWidth * escala); tela.height = Math.round(video.videoHeight * escala); + tela.getContext('2d').drawImage(video, 0, 0, tela.width, tela.height); + CarinhaRTC.foto(tela.toDataURL('image/jpeg', 0.82)); +}; setInterval(() => { if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return; lastMotionFrame=video.currentTime; diff --git a/android/app/java/br/carinha/rosto/ControleRemoto.java b/android/app/java/br/carinha/rosto/ControleRemoto.java index c6c73b8..90e6a08 100644 --- a/android/app/java/br/carinha/rosto/ControleRemoto.java +++ b/android/app/java/br/carinha/rosto/ControleRemoto.java @@ -28,6 +28,15 @@ public final class ControleRemoto { private volatile ScheduledExecutorService worker; private long consultarEm; private long quadroEm; + // Foto do evento (histórico): o servidor pede, o app tira e envia. + private volatile long fotoPedida = 0, fotoFeita = 0, fotoId = 0, pedidoEm = 0; + private volatile byte[] fotoJpeg = null; + /** Id do evento que está esperando foto (0 = nenhum). */ + public long fotoPendente() { return fotoJpeg == null ? fotoPedida : 0; } + /** Foto vinda do vídeo ao vivo (quando a câmera de movimento está desligada). */ + public void fotoRTC(byte[] jpeg) { + if (fotoPedida > 0 && fotoJpeg == null && jpeg != null && jpeg.length > 0) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; } + } private long logEm; private final Runnable mudanca; public interface RTCListener { boolean receber(JSONObject pacote); } @@ -118,6 +127,7 @@ public final class ControleRemoto { mudanca.run(); } if (videoRTC()) rtcTick(base); + fotoTick(base); byte[] jpeg = pendente; pendente = null; if (jpeg != null && preview()) { @@ -147,10 +157,13 @@ public final class ControleRemoto { return c; } public void quadro(Image image, int rotacao) { - if (videoRTC()) return; + boolean querFoto = fotoPedida > 0 && fotoJpeg == null; long agora = System.currentTimeMillis(); - if (!preview() || pendente != null || agora < quadroEm) return; - quadroEm = agora + 200; + if (!querFoto) { + if (videoRTC()) return; + if (!preview() || pendente != null || agora < quadroEm) return; + quadroEm = agora + 200; + } try { Image.Plane[] p = image.getPlanes(); ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()}; @@ -171,10 +184,44 @@ public final class ControleRemoto { if (rotated != b) rotated.recycle(); b.recycle(); } - if (preview()) pendente = jpeg; + if (querFoto) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; } + else if (preview()) pendente = jpeg; } catch (Exception e) { Log.w("CarinhaPC", "quadro descartado: " + e); } } + /** Consulta o pedido de foto (1x/s) e envia a foto pronta para o histórico. */ + private void fotoTick(String base) throws Exception { + byte[] jpeg = fotoJpeg; + if (jpeg != null) { + long id = fotoId; + fotoJpeg = null; + HttpURLConnection c = abrir(base + "/historico/foto?id=" + id); + try { + c.setRequestMethod("POST"); c.setDoOutput(true); + c.setRequestProperty("Content-Type", "image/jpeg"); + c.setFixedLengthStreamingMode(jpeg.length); + try (java.io.OutputStream out = c.getOutputStream()) { out.write(jpeg); } + int code = c.getResponseCode(); + Log.i("CarinhaPC", "foto do evento " + id + ": HTTP " + code); + fotoFeita = id; + } finally { c.disconnect(); } + } + long agora = System.currentTimeMillis(); + if (agora < pedidoEm) return; + pedidoEm = agora + 1000; + HttpURLConnection c = abrir(base + "/historico/pedido"); + try { + if (c.getResponseCode() != 200) return; + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (InputStream in = c.getInputStream()) { + byte[] buffer = new byte[512]; int n; + while ((n = in.read(buffer)) != -1 && bytes.size() < 4096) bytes.write(buffer, 0, n); + } + long id = new JSONObject(bytes.toString("UTF-8")).optLong("id", 0); + if (id > 0 && id != fotoFeita && id != fotoId && fotoJpeg == null) fotoPedida = id; + } finally { c.disconnect(); } + } + private void rtcTick(String base) throws Exception { String json; while ((json = sinais.poll()) != null) { diff --git a/android/app/java/br/carinha/rosto/MainActivity.java b/android/app/java/br/carinha/rosto/MainActivity.java index a0f4bb1..deba015 100644 --- a/android/app/java/br/carinha/rosto/MainActivity.java +++ b/android/app/java/br/carinha/rosto/MainActivity.java @@ -198,6 +198,8 @@ public class MainActivity extends Activity { } atualizarSensores(); atualizarBrilho(); + if (remoto.fotoPendente() > 0 && rtcAtivo) + rtcWeb.evaluateJavascript("window.rtcFoto&&window.rtcFoto()", null); } } }); @@ -1079,7 +1081,7 @@ public class MainActivity extends Activity { web.evaluateJavascript("window.carinhaBateria={nivel:" + percent + ",carregando:" + charging + "}", null); } } - web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length)}):null", + web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length),apagada:!!window.carinhaTelaApagada}):null", new ValueCallback() { @Override public void onReceiveValue(String valor) { if (!emPrimeiroPlano) return; @@ -1091,6 +1093,7 @@ public class MainActivity extends Activity { float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(), System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(), estado.optBoolean("ocupado"), estado.optBoolean("dormindo")); + if (estado.optBoolean("apagada")) nivel = 0f; // tela preta + brilho mínimo WindowManager.LayoutParams attr = getWindow().getAttributes(); if (Math.abs(attr.screenBrightness - nivel) > 0.005f) { attr.screenBrightness = nivel; @@ -1104,6 +1107,14 @@ public class MainActivity extends Activity { } private final class PonteRTC { + /** Foto do vídeo ao vivo (data:image/jpeg;base64,...) para o histórico. */ + @JavascriptInterface public void foto(final String dataUrl) { + try { + int virgula = dataUrl == null ? -1 : dataUrl.indexOf(','); + if (virgula < 0 || dataUrl.length() > 4_000_000) return; + remoto.fotoRTC(android.util.Base64.decode(dataUrl.substring(virgula + 1), android.util.Base64.DEFAULT)); + } catch (Exception e) { Log.w("CarinhaPC", "foto do video falhou: " + e); } + } @JavascriptInterface public void rastrear(final String json) { if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return; try { diff --git a/carinha.html b/carinha.html index 37c1b08..9cf8d7c 100644 --- a/carinha.html +++ b/carinha.html @@ -93,6 +93,22 @@ addEventListener("resize", ajustar); const PRETO = [6, 8, 14]; const CIANO = [92, 214, 255]; const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 }; +const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false }; +/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo. + Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */ +function cuidarDaTela() { + const agora = performance.now(); + if (!r.dormindo) SONO.desde = 0; + else if (!SONO.desde) SONO.desde = agora; + const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000; + if (apagar !== SONO.apagada) { + SONO.apagada = apagar; + window.carinhaTelaApagada = apagar; + document.body.style.visibility = apagar ? "hidden" : ""; + console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa"); + } +} +setInterval(cuidarDaTela, 1000); const CIANO_E = [150, 232, 255]; const VERM = [255, 78, 78]; const ROSA = [255, 112, 176]; @@ -716,6 +732,16 @@ function checarSilencio(agora) { journal do carinha-web (so quando o microfone esta ativo). */ function relatarSom(evento, n) { try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} + if (evento !== "adormeceu") registrarEvento("som", evento, n); +} + +/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */ +function registrarEvento(tipo, evento, nivel) { + if (new URLSearchParams(location.search).get("aparelho") !== "1") return; + try { + fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {}); + } catch (e) {} } // ------------------------------------------------------ presenca (movimento) @@ -804,6 +830,7 @@ function movimento(vel, cx, cy) { /* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */ function relatarMov(evento, n) { try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} + registrarEvento("movimento", evento, n); } // ------------------------------------------------- camera oculta (sensor) @@ -936,6 +963,8 @@ let configRecebida = null; function configurarApp(c) { EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true; if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca; + SONO.apagar = c.apagarTela !== false; + if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin; if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha; AUTO = c.auto; AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume; @@ -966,6 +995,7 @@ async function sincronizarApp() { movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2, centro: [MOV.cx, MOV.cy], config: configRecebida, brilho: window.carinhaBrilho, + tela_apagada: !!window.carinhaTelaApagada, bateria: window.carinhaBateria, aparelho: window.carinhaAparelho, video: window.carinhaVideo, diff --git a/controle_pc.py b/controle_pc.py index 82e687b..df58f5c 100644 --- a/controle_pc.py +++ b/controle_pc.py @@ -21,6 +21,7 @@ PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35, movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40, economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30, boca=True, sobrancelhas=False, iris=False, subirBoca=0, distSobrancelha=16, videoRTC=True, + apagarTela=True, apagarTelaMin=5, sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False, cameraLado='user', videoResolucao='auto', videoFPS=0, rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True) @@ -31,7 +32,7 @@ LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1), somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120), movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100), brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600), - subirBoca=(-20, 60), distSobrancelha=(0, 60)) + subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120)) def validar(dados): diff --git a/deploy.ps1 b/deploy.ps1 index c1e1ba3..7a6d5d8 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop # chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai). $arquivos = @( 'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py', - 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', + 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json', 'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js' ) diff --git a/historico.py b/historico.py new file mode 100644 index 0000000..14764bd --- /dev/null +++ b/historico.py @@ -0,0 +1,270 @@ +"""Histórico de detecções (PIR da placa, movimento e som do celular) com uma foto por evento. + +- PIR: lê o pino com `gpiomon` (libgpiod). Padrão: gpiochip1 linha 70 = pino 11 (PC6) do + Orange Pi Zero 3; outro pino com CARINHA_PIR=gpiochip1:79. +- Foto: ao registrar um evento, o servidor deixa um "pedido de foto"; o app do S10 consulta + /historico/pedido, tira a foto com a câmera frontal e envia em /historico/foto?id=N. +- Guarda os 200 eventos e as 100 fotos mais recentes. Cada sensor e a foto podem ser desligados. +""" +import json +import os +from pathlib import Path +import shutil +import sqlite3 +import subprocess +import threading +import time +from contextlib import contextmanager +from urllib.parse import parse_qs, urlsplit +from comum import gravar_atomico, ler_json + +TIPOS = ('pir', 'movimento', 'som') +PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15) +MAX_EVENTOS, MAX_FOTOS = 200, 100 +VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto + + +class Historico: + def __init__(self, pasta, iniciar_pir=True): + self.pasta = Path(pasta) + self.fotos = self.pasta / 'fotos' + self.fotos.mkdir(exist_ok=True) + self.caminho_db = str(self.pasta / 'historico.db') + self.arquivo_config = self.pasta / 'historico.json' + self.lock = threading.RLock() + self.publicar = lambda tipo, dados: None + self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto + self.config = dict(PADRAO) + try: + salvo = json.loads(self.arquivo_config.read_text(encoding='utf-8')) + self.config.update(self.validar(salvo)) + except (OSError, ValueError, TypeError): + pass + self.ultimo = {} # tipo -> horário do último evento (intervalo mínimo) + self.pedido = None # (id do evento, monotonic) aguardando foto do app + self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None) + self.processo_pir = None + with self.db() as db: + db.execute('CREATE TABLE IF NOT EXISTS eventos(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, evento TEXT, nivel REAL, foto TEXT)') + if iniciar_pir: + threading.Thread(target=self.vigiar_pir, daemon=True).start() + + @contextmanager + def db(self): + con = sqlite3.connect(self.caminho_db) + try: + with con: + yield con + finally: + con.close() + + @staticmethod + def validar(dados): + if not isinstance(dados, dict): + raise ValueError('Esperava um objeto') + limpo = {} + for chave, valor in dados.items(): + if chave not in PADRAO: + raise ValueError('Ajuste desconhecido: ' + chave) + if chave == 'intervalo': + if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600: + raise ValueError('Intervalo entre 0 e 600 s') + elif not isinstance(valor, bool): + raise ValueError(chave + ' precisa ser verdadeiro ou falso') + limpo[chave] = valor + return limpo + + # ------------------------------------------------------------------ eventos + def registrar(self, tipo, evento='', nivel=None, agora=None): + """Grava o evento se o sensor estiver ligado e fora do intervalo mínimo; devolve o id ou None.""" + if tipo not in TIPOS: + raise ValueError('Tipo de evento inválido') + agora = agora if agora is not None else time.time() + with self.lock: + if not self.config.get(tipo, False): + return None + if tipo in self.ultimo and agora - self.ultimo[tipo] < self.config['intervalo']: + return None + self.ultimo[tipo] = agora + evento = str(evento or '')[:40] + nivel = float(nivel) if isinstance(nivel, (int, float)) and not isinstance(nivel, bool) else None + with self.db() as db: + ident = db.execute('INSERT INTO eventos(horario, tipo, evento, nivel) VALUES (?,?,?,?)', + (agora, tipo, evento, nivel)).lastrowid + velhos = db.execute('SELECT id, foto FROM eventos ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_EVENTOS,)).fetchall() + for vid, foto in velhos: + self.apagar_arquivo(foto) + db.execute('DELETE FROM eventos WHERE id=?', (vid,)) + if self.config['foto']: + self.pedido = (ident, time.monotonic()) + self.publicar('historico', dict(novo=self.evento(ident))) + try: + self.ao_detectar(tipo) + except Exception: + pass + return ident + + def evento(self, ident): + with self.db() as db: + linha = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos WHERE id=?', (ident,)).fetchone() + return self.como_dict(linha) if linha else None + + @staticmethod + def como_dict(linha): + i, h, t, e, n, f = linha + return dict(id=i, horario=h, tipo=t, evento=e, nivel=n, foto=bool(f)) + + def listar(self, limite=100): + with self.db() as db: + linhas = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos ORDER BY id DESC LIMIT ?', (limite,)).fetchall() + total = db.execute('SELECT count(*) FROM eventos').fetchone()[0] + return [self.como_dict(l) for l in linhas], total + + def apagar(self, ident): + with self.lock, self.db() as db: + linha = db.execute('SELECT foto FROM eventos WHERE id=?', (ident,)).fetchone() + if linha: + self.apagar_arquivo(linha[0]) + db.execute('DELETE FROM eventos WHERE id=?', (ident,)) + self.publicar('historico', dict(apagado=ident)) + + def limpar(self): + with self.lock, self.db() as db: + db.execute('DELETE FROM eventos') + for f in self.fotos.glob('evento-*.jpg'): + f.unlink(missing_ok=True) + self.pedido = None + self.publicar('historico', dict(limpo=True)) + + def apagar_arquivo(self, nome): + if nome: + (self.fotos / nome).unlink(missing_ok=True) + + # ------------------------------------------------------------------ foto + def pedido_atual(self): + with self.lock: + if self.pedido and time.monotonic() - self.pedido[1] < VALIDADE_PEDIDO: + return self.pedido[0] + self.pedido = None + return None + + def guardar_foto(self, ident, jpeg): + if not (jpeg.startswith(b'\xff\xd8') and jpeg.endswith(b'\xff\xd9')) or len(jpeg) > 3_000_000: + raise ValueError('Esperava uma foto JPEG de até 3 MB') + with self.lock: + if not self.config['foto']: + raise ValueError('Fotos desligadas') + if self.pedido and self.pedido[0] == ident: + self.pedido = None + nome = f'evento-{ident}.jpg' + with self.db() as db: + if not db.execute('SELECT 1 FROM eventos WHERE id=?', (ident,)).fetchone(): + raise ValueError('Evento não existe mais') + tmp = self.fotos / (nome + '.tmp') + tmp.write_bytes(jpeg); tmp.replace(self.fotos / nome) + db.execute('UPDATE eventos SET foto=? WHERE id=?', (nome, ident)) + antigas = db.execute('SELECT id, foto FROM eventos WHERE foto IS NOT NULL ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_FOTOS,)).fetchall() + for vid, foto in antigas: + self.apagar_arquivo(foto) + db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,)) + self.publicar('historico', dict(foto=ident)) + + # ------------------------------------------------------------------ PIR + def vigiar_pir(self): + """Mantém um `gpiomon` rodando enquanto o PIR estiver ligado; cada borda de subida = presença.""" + while True: + if not self.config['pir'] or os.name != 'posix' or not shutil.which('gpiomon'): + self.pir.update(disponivel=False, erro='' if self.config['pir'] else 'desligado') + if os.name == 'posix' and not shutil.which('gpiomon'): + self.pir['erro'] = 'gpiomon não instalado (apt install gpiod)' + time.sleep(2) + continue + chip, _, linha = self.pir['linha'].partition(':') + try: + self.processo_pir = subprocess.Popen(['gpiomon', '-c', chip, '-e', 'rising', linha], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + self.pir.update(disponivel=True, erro='') + for _ in self.processo_pir.stdout: + if not self.config['pir']: + break + self.pir['ultimo'] = time.time() + self.registrar('pir', 'presença') + erro = (self.processo_pir.stderr.read() or '').strip()[:200] + except OSError as e: + erro = str(e) + finally: + self.parar_pir() + self.pir.update(disponivel=False, erro=erro if self.config['pir'] else 'desligado') + time.sleep(3) + + def parar_pir(self): + p, self.processo_pir = self.processo_pir, None + if p and p.poll() is None: + p.terminate() + try: + p.wait(timeout=2) + except subprocess.TimeoutExpired: + p.kill() + + def status(self): + eventos, total = self.listar() + with self.lock: + return dict(config=dict(self.config), pir=dict(self.pir), eventos=eventos, total=total, + max_eventos=MAX_EVENTOS, max_fotos=MAX_FOTOS) + + # ------------------------------------------------------------------ HTTP + def rotas(self, h, metodo): + url = urlsplit(h.path) + rota = url.path + if not rota.startswith('/historico/'): + return False + try: + if metodo == 'GET' and rota == '/historico/status': + return h.json(self.status()) or True + if metodo == 'GET' and rota == '/historico/pedido': + return h.json(dict(id=self.pedido_atual())) or True + if metodo == 'GET' and rota.startswith('/historico/foto/'): + nome = rota.rsplit('/', 1)[-1] + if not nome.startswith('evento-') or not nome.endswith('.jpg') or '/' in nome or '..' in nome: + raise ValueError('Foto inválida') + arquivo = self.fotos / nome + if not arquivo.is_file(): + h.json(dict(erro='Foto não encontrada'), 404) + return True + h.enviar(arquivo.read_bytes(), 'image/jpeg') + return True + if metodo == 'POST' and rota == '/historico/foto': + ident = int(parse_qs(url.query).get('id', ['0'])[0]) + if h.headers.get('Content-Type', '').split(';')[0] != 'image/jpeg': + raise ValueError('Use image/jpeg') + tamanho = int(h.headers.get('Content-Length', '0')) + if not 0 < tamanho <= 3_000_000: + raise ValueError('Foto vazia ou grande demais') + self.guardar_foto(ident, h.rfile.read(tamanho)) + return h.json(dict(ok=True)) or True + if metodo == 'POST': + dados = ler_json(h, 4096) + if rota == '/historico/evento': + ident = self.registrar(dados.get('tipo'), dados.get('evento'), dados.get('nivel')) + return h.json(dict(ok=True, id=ident)) or True + if rota == '/historico/config': + limpo = self.validar(dados) + with self.lock: + self.config.update(limpo) + gravar_atomico(self.arquivo_config, self.config) + if 'pir' in limpo and not limpo['pir']: + self.parar_pir() + self.publicar('historico', dict(config=dict(self.config))) + return h.json(self.status()) or True + if rota == '/historico/apagar': + if type(dados.get('id')) is not int: + raise ValueError('ID inválido') + self.apagar(dados['id']) + return h.json(dict(ok=True)) or True + if rota == '/historico/limpar': + self.limpar() + return h.json(dict(ok=True)) or True + h.json(dict(erro='Rota do histórico desconhecida'), 404) + except (ValueError, TypeError) as e: + h.json(dict(erro=str(e)), 400) + return True diff --git a/kiosk-server.py b/kiosk-server.py index 2fb2aa9..3584e10 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -40,6 +40,7 @@ from cerebro import Cerebro from conversa import Conversa from treinamento import Treinamento from api_config import ConfigAPI +from historico import Historico TRAVA = threading.Lock() @@ -69,7 +70,7 @@ ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador # Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar. PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"} -PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", +PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") @@ -236,7 +237,7 @@ class Handler(SimpleHTTPRequestHandler): def modulos(self): s = self.server - return [getattr(s, nome) for nome in ("api_config", "treinamento", "conversa", "cerebro", + return [getattr(s, nome) for nome in ("api_config", "historico", "treinamento", "conversa", "cerebro", "braco", "servos", "controle") if hasattr(s, nome)] def estado(self): @@ -451,7 +452,7 @@ class Servidor(ThreadingHTTPServer): def ligar_eventos(servidor): - for nome in ("controle", "servos", "cerebro", "conversa", "treinamento"): + for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico"): if hasattr(servidor, nome): getattr(servidor, nome).publicar = EVENTOS.publicar @@ -477,6 +478,16 @@ def main(): servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro) servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db') servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar + servidor.historico = Historico(Path.cwd()) + + def presenca(tipo): + # PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda + if tipo == "pir": + with servidor.controle.lock: + dormindo = bool(servidor.controle.estado.get("dormindo")) + if dormindo: + enfileirar("acordar") + servidor.historico.ao_detectar = presenca servidor.conversa.treinamento = servidor.treinamento ligar_eventos(servidor) servidor.registrar = args.log diff --git a/tests/test_historico.py b/tests/test_historico.py new file mode 100644 index 0000000..d316417 --- /dev/null +++ b/tests/test_historico.py @@ -0,0 +1,119 @@ +"""Histórico de detecções: sensores liga/desliga, intervalo, pedido de foto e rotas HTTP.""" +import http.client +import importlib.util +import json +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from historico import Historico, MAX_FOTOS + +spec = importlib.util.spec_from_file_location('kiosk_hist', ROOT / 'kiosk-server.py') +kiosk = importlib.util.module_from_spec(spec) +spec.loader.exec_module(kiosk) + +JPEG = b'\xff\xd8' + b'foto' * 10 + b'\xff\xd9' + + +class HistoricoTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.h = Historico(self.tmp.name, iniciar_pir=False) + self.publicados = [] + self.h.publicar = lambda t, d: self.publicados.append((t, d)) + + def tearDown(self): + self.tmp.cleanup() + + def test_intervalo_e_sensor_desligado(self): + self.assertIsNotNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=100)) + self.assertIsNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=105)) # dentro dos 15 s + self.assertIsNotNone(self.h.registrar('som', 'surpreso', 0.9, agora=105)) # outro tipo vale + self.h.config['pir'] = False + self.assertIsNone(self.h.registrar('pir', agora=200)) + with self.assertRaises(ValueError): + self.h.registrar('fumaça') + self.assertEqual(self.h.listar()[1], 2) + self.assertEqual(self.publicados[0][0], 'historico') + + def test_pedido_e_foto(self): + ident = self.h.registrar('pir', 'presença', agora=10) + self.assertEqual(self.h.pedido_atual(), ident) + with self.assertRaises(ValueError): + self.h.guardar_foto(ident, b'nao e jpeg') + self.h.guardar_foto(ident, JPEG) + self.assertIsNone(self.h.pedido_atual()) + self.assertTrue(self.h.evento(ident)['foto']) + self.assertTrue((Path(self.tmp.name) / 'fotos' / f'evento-{ident}.jpg').is_file()) + + def test_sem_foto_quando_desligado(self): + self.h.config['foto'] = False + self.h.registrar('pir', agora=10) + self.assertIsNone(self.h.pedido_atual()) + + def test_guarda_no_maximo_as_fotos_recentes(self): + self.h.config['intervalo'] = 0 + ids = [] + for i in range(MAX_FOTOS + 3): + ids.append(self.h.registrar('pir', agora=i)) + self.h.guardar_foto(ids[-1], JPEG) + com_foto = [e for e in self.h.listar(500)[0] if e['foto']] + self.assertEqual(len(com_foto), MAX_FOTOS) + self.assertFalse(self.h.evento(ids[0])['foto']) + self.assertEqual(len(list((Path(self.tmp.name) / 'fotos').glob('*.jpg'))), MAX_FOTOS) + + def test_apagar_e_limpar(self): + a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG) + b = self.h.registrar('som', agora=1) + self.h.apagar(a) + self.assertIsNone(self.h.evento(a)) + self.h.limpar() + self.assertEqual(self.h.listar()[1], 0) + self.assertEqual(list((Path(self.tmp.name) / 'fotos').glob('*.jpg')), []) + + +class HistoricoHttpTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler) + self.server.token = 'token-historico' + self.server.historico = Historico(self.tmp.name, iniciar_pir=False) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + def tearDown(self): + self.server.shutdown(); self.server.server_close(); self.tmp.cleanup() + + def pedir(self, metodo, rota, corpo=None, tipo='application/json', token='token-historico'): + c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5) + h = {'Content-Type': tipo} + if token: + h['Authorization'] = 'Bearer ' + token + if isinstance(corpo, dict): + corpo = json.dumps(corpo) + c.request(metodo, rota, body=corpo, headers=h) + r = c.getresponse(); dados = r.read(); c.close() + return r.status, dados + + def test_fluxo_completo(self): + self.assertEqual(self.pedir('POST', '/historico/evento', {'tipo': 'pir'}, token=None)[0], 401) + codigo, corpo = self.pedir('POST', '/historico/evento', {'tipo': 'movimento', 'evento': 'surpreso', 'nivel': 0.3}) + ident = json.loads(corpo)['id'] + self.assertEqual(json.loads(self.pedir('GET', '/historico/pedido')[1])['id'], ident) + self.assertEqual(self.pedir('POST', f'/historico/foto?id={ident}', JPEG, 'image/jpeg')[0], 200) + codigo, foto = self.pedir('GET', f'/historico/foto/evento-{ident}.jpg?t=token-historico', token=None) + self.assertEqual((codigo, foto), (200, JPEG)) + self.assertEqual(self.pedir('GET', '/historico/foto/../historico.db')[0], 400) + status = json.loads(self.pedir('GET', '/historico/status')[1]) + self.assertEqual(status['eventos'][0]['id'], ident) + self.assertTrue(status['eventos'][0]['foto']) + self.assertEqual(self.pedir('POST', '/historico/config', {'foto': False, 'intervalo': 30})[0], 200) + self.assertEqual(self.pedir('POST', '/historico/config', {'intervalo': 9999})[0], 400) + self.assertFalse(self.server.historico.config['foto']) + + +if __name__ == '__main__': + unittest.main() diff --git a/web/src/App.svelte b/web/src/App.svelte index bb120f0..2af4ad5 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -17,6 +17,7 @@ import Movimento from './telas/Movimento.svelte'; import Reacoes from './telas/Reacoes.svelte'; import Rosto from './telas/Rosto.svelte'; + import Historico from './telas/Historico.svelte'; const TELAS: Record = { 'ao-vivo': AoVivo, @@ -25,6 +26,7 @@ movimento: Movimento, ajustes: Ajustes, rosto: Rosto, + historico: Historico, }; const ICONES: Record> = { 'ao-vivo': Video, diff --git a/web/src/lib/ajustes.svelte.ts b/web/src/lib/ajustes.svelte.ts index b695729..a579cec 100644 --- a/web/src/lib/ajustes.svelte.ts +++ b/web/src/lib/ajustes.svelte.ts @@ -55,6 +55,8 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] = { chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' }, { chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 }, { chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' }, + { chave: 'apagarTela', rotulo: 'Apagar a tela quando dormir', ajuda: 'Tela preta e brilho mínimo; acorda com som, movimento, toque ou PIR', tipo: 'toggle' }, + { chave: 'apagarTelaMin', rotulo: 'Dormindo há quanto tempo para apagar', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' min' }, ], }, { diff --git a/web/src/lib/estado.svelte.ts b/web/src/lib/estado.svelte.ts index e1ab0cf..35915b7 100644 --- a/web/src/lib/estado.svelte.ts +++ b/web/src/lib/estado.svelte.ts @@ -139,6 +139,10 @@ const tratadores: Record void> = { if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome); }, treinamento: (d) => (estado.treinamento = d), + historico: (d) => { + const n = d?.novo; + if (n) noFeed('historico', { pir: '🚶 Presença no PIR', movimento: '👀 Movimento na câmera', som: '🔊 Som alto' }[n.tipo as string] ?? 'Detecção'); + }, }; let fonte: EventSource | null = null; @@ -161,7 +165,7 @@ function abrirEventos() { if (estado.conexao !== 'token') estado.conexao = 'caiu'; }; for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia', - 'reacao', 'treinamento']) { + 'reacao', 'treinamento', 'historico']) { fonte.addEventListener(tipo, (e) => { const dados = JSON.parse((e as MessageEvent).data); tratadores[tipo]?.(dados); diff --git a/web/src/lib/historico.svelte.ts b/web/src/lib/historico.svelte.ts new file mode 100644 index 0000000..5d2c3b9 --- /dev/null +++ b/web/src/lib/historico.svelte.ts @@ -0,0 +1,84 @@ +/** Histórico de detecções (/historico/*): recarrega ao abrir e quando o servidor avisa por SSE. */ +import { lerToken, pedir, postar } from './api'; +import { avisos } from './avisos.svelte'; +import { ouvir } from './estado.svelte'; + +export interface EventoHistorico { + id: number; + horario: number; + tipo: 'pir' | 'movimento' | 'som'; + evento: string; + nivel: number | null; + foto: boolean; +} + +export interface StatusHistorico { + config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number }; + pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null }; + eventos: EventoHistorico[]; + total: number; + max_eventos: number; + max_fotos: number; +} + +export const TIPOS_EVENTO: Record = { + pir: { nome: 'Presença (PIR)', emoji: '🚶', cor: '#34c759' }, + movimento: { nome: 'Movimento na câmera', emoji: '👀', cor: '#0a84ff' }, + som: { nome: 'Som alto', emoji: '🔊', cor: '#ff9500' }, +}; + +/** URL da foto com o token na query (a tag não manda cabeçalho). */ +export function urlFoto(id: number) { + const t = lerToken(); + return `/historico/foto/evento-${id}.jpg` + (t ? '?t=' + encodeURIComponent(t) : ''); +} + +class Historico { + status = $state(null); + private espera = 0; + + async carregar() { + try { + this.status = await pedir('/historico/status'); + } catch (e) { + avisos.erro(e); + } + } + + async configurar(parcial: Partial) { + const antes = this.status ? { ...this.status.config } : null; + if (this.status) this.status = { ...this.status, config: { ...this.status.config, ...parcial } }; + try { + this.status = await postar('/historico/config', parcial); + } catch (e) { + if (this.status && antes) this.status = { ...this.status, config: antes }; + avisos.erro(e); + } + } + + async apagar(id: number) { + try { + await postar('/historico/apagar', { id }); + } catch (e) { + avisos.erro(e); + } + } + + async limpar() { + try { + await postar('/historico/limpar'); + avisos.ok('Histórico apagado.'); + } catch (e) { + avisos.erro(e); + } + } + + /** Vários avisos seguidos (evento + foto) viram uma recarga só. */ + agendar() { + clearTimeout(this.espera); + this.espera = window.setTimeout(() => this.carregar(), 300); + } +} + +export const historico = new Historico(); +ouvir('historico', () => historico.status && historico.agendar()); diff --git a/web/src/lib/paleta.svelte.ts b/web/src/lib/paleta.svelte.ts index 200f9ee..b1476e1 100644 --- a/web/src/lib/paleta.svelte.ts +++ b/web/src/lib/paleta.svelte.ts @@ -36,6 +36,7 @@ export function itensDaPaleta(): ItemPaleta[] { registrarNaPaleta(() => [ ...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })), { id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') }, + { id: 'ir-historico', grupo: 'Ir para', titulo: 'Histórico de detecções', fazer: () => nav.ir('historico') }, ...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })), estado.estop.ativo ? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar } diff --git a/web/src/lib/rotas.svelte.ts b/web/src/lib/rotas.svelte.ts index c835061..ca526a7 100644 --- a/web/src/lib/rotas.svelte.ts +++ b/web/src/lib/rotas.svelte.ts @@ -1,6 +1,6 @@ /** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */ -export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto'; +export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'historico'; export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [ { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' }, @@ -11,7 +11,10 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [ ]; /** Páginas abertas a partir de outra tela (não aparecem na navegação). */ -export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }]; +export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [ + { id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }, + { id: 'historico', titulo: 'Histórico', volta: 'ao-vivo' }, +]; export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? ''; diff --git a/web/src/telas/AoVivo.svelte b/web/src/telas/AoVivo.svelte index c148bec..da782ea 100644 --- a/web/src/telas/AoVivo.svelte +++ b/web/src/telas/AoVivo.svelte @@ -1,6 +1,6 @@ + +
+ Ao vivo +
+

Histórico de detecções

+

+ Presença do sensor PIR, movimento e som percebidos pelo celular, com uma foto da câmera frontal em cada evento. + {#if st}Guarda os {st.max_eventos} eventos e as {st.max_fotos} fotos mais recentes.{/if} +

+
+ +
+
+ + + {#snippet acessorio()} + historico.configurar({ pir: v })} /> + {/snippet} + + + {#snippet acessorio()} + historico.configurar({ movimento: v })} /> + {/snippet} + + + {#snippet acessorio()} + historico.configurar({ som: v })} /> + {/snippet} + + + + + {#snippet acessorio()} + historico.configurar({ foto: v })} /> + {/snippet} + + + historico.configurar({ intervalo: v })} + /> + + +
+ {#if confirmar} +

Apagar todos os eventos e fotos?

+
+ + +
+ {:else} + + {/if} +
+
+ +
+
+ {#each [['todos', 'Todos'], ['pir', '🚶 PIR'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['foto', '📷 Com foto']] as [id, nome] (id)} + + {/each} + {#if st}{st.total} evento(s){/if} +
+ + {#if !st} +

Carregando histórico…

+ {:else if !eventos.length} +
+

Nenhum evento {filtro === 'todos' ? 'ainda' : 'com esse filtro'}.

+

Quando o PIR ou o celular perceber algo, o evento aparece aqui na hora, com a foto.

+
+ {:else} +
    + {#each eventos as ev (ev.id)} + {@const info = TIPOS_EVENTO[ev.tipo]} +
  • + +
    + {NOME_EVENTO[ev.evento] ?? ev.evento} + {quando(ev.horario)}{ev.nivel !== null ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''} +
    + +
  • + {/each} +
+ {/if} +
+
+
+ + + {#if aberta}Foto do evento{/if} + + +