diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html index 9cf8d7c..b4c3c12 100644 --- a/android/app/assets/carinha.html +++ b/android/app/assets/carinha.html @@ -1045,7 +1045,7 @@ const SONS = { /* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala", os sons dos gestos ficam quietos para não embolar. */ function falar(texto) { - if (!AUDIO.ligado || !window.VozRobo) return; + if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return; const c = contextoAudio(); if (!c) return; if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } } @@ -1057,6 +1057,7 @@ function falar(texto) { function tocarSom(gesto) { if (!AUDIO.ligado) return false; + if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false; const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0; const f = SONS[gesto]; diff --git a/carinha.html b/carinha.html index 9cf8d7c..b4c3c12 100644 --- a/carinha.html +++ b/carinha.html @@ -1045,7 +1045,7 @@ const SONS = { /* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala", os sons dos gestos ficam quietos para não embolar. */ function falar(texto) { - if (!AUDIO.ligado || !window.VozRobo) return; + if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return; const c = contextoAudio(); if (!c) return; if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } } @@ -1057,6 +1057,7 @@ function falar(texto) { function tocarSom(gesto) { if (!AUDIO.ligado) return false; + if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false; const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0; const f = SONS[gesto]; diff --git a/historico.py b/historico.py index 3bc5554..c86e336 100644 --- a/historico.py +++ b/historico.py @@ -4,7 +4,8 @@ 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. +- Guarda só os últimos 3 dias (eventos e fotos mais antigos são apagados sozinhos), com teto de + segurança de 2000 eventos e 500 fotos. Cada sensor e a foto podem ser desligados. """ import json import os @@ -19,8 +20,9 @@ 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 +PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15, dias=3, + presenca=True, ausencia=5) # modo presença: o PIR liga a câmera; sem ninguém, ela desliga +MAX_EVENTOS, MAX_FOTOS = 2000, 500 # teto de segurança; quem manda é a retenção em dias VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto @@ -34,6 +36,10 @@ class Historico: 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 + # Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia). + self.ao_presenca = lambda: None + self.ao_ausencia = lambda: None + self.presenca = dict(estado='presente', desde=time.time(), ultimo=time.time()) self.config = dict(PADRAO) try: salvo = json.loads(self.arquivo_config.read_text(encoding='utf-8')) @@ -46,8 +52,11 @@ class Historico: 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)') + self.limpar_antigos() if iniciar_pir: threading.Thread(target=self.vigiar_pir, daemon=True).start() + threading.Thread(target=self.faxina, daemon=True).start() + threading.Thread(target=self.vigiar_ausencia, daemon=True).start() @contextmanager def db(self): @@ -66,7 +75,13 @@ class Historico: for chave, valor in dados.items(): if chave not in PADRAO: raise ValueError('Ajuste desconhecido: ' + chave) - if chave == 'intervalo': + if chave == 'ausencia': + if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 120: + raise ValueError('Ausência de 1 a 120 minutos') + elif chave == 'dias': + if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30: + raise ValueError('Guardar de 1 a 30 dias') + elif 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): @@ -74,6 +89,59 @@ class Historico: limpo[chave] = valor return limpo + # ------------------------------------------------------------------ retenção + def limpar_antigos(self, agora=None): + """Apaga eventos (e fotos) mais velhos que a retenção em dias.""" + limite = (agora if agora is not None else time.time()) - self.config['dias'] * 86400 + with self.lock, self.db() as db: + velhos = db.execute('SELECT id, foto FROM eventos WHERE horario < ?', (limite,)).fetchall() + for vid, foto in velhos: + self.apagar_arquivo(foto) + db.execute('DELETE FROM eventos WHERE horario < ?', (limite,)) + if velhos: + self.publicar('historico', dict(expirados=len(velhos))) + return len(velhos) + + # ------------------------------------------------------------------ modo presença + def marcar_presenca(self, agora=None): + agora = agora if agora is not None else time.time() + with self.lock: + self.presenca['ultimo'] = agora + voltou = self.presenca['estado'] != 'presente' + if voltou: + self.presenca.update(estado='presente', desde=agora) + if voltou and self.config['presenca']: + self.ao_presenca() + + def checar_ausencia(self, agora=None): + """Sem presença no PIR por `ausencia` minutos: desliga câmera, dorme e fica quieto.""" + agora = agora if agora is not None else time.time() + if not (self.config['presenca'] and self.config['pir'] and self.pir['disponivel']): + return False + with self.lock: + if self.presenca['estado'] != 'presente' or agora - self.presenca['ultimo'] < self.config['ausencia'] * 60: + return False + self.presenca.update(estado='ausente', desde=agora) + self.ao_ausencia() + self.publicar('pir', dict(ultimo=self.pir['ultimo'], presenca=dict(self.presenca))) + return True + + def vigiar_ausencia(self): + while True: + time.sleep(10) + try: + self.checar_ausencia() + except Exception: + pass + + def faxina(self): + while True: + time.sleep(600) + try: + self.limpar_antigos() + except Exception: + pass + # ------------------------------------------------------------------ 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.""" @@ -97,6 +165,7 @@ class Historico: db.execute('DELETE FROM eventos WHERE id=?', (vid,)) if self.config['foto']: self.pedido = (ident, time.monotonic()) + self.limpar_antigos(agora) self.publicar('historico', dict(novo=self.evento(ident))) try: self.ao_detectar(tipo) @@ -114,7 +183,7 @@ class Historico: 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): + def listar(self, limite=500): 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] @@ -192,7 +261,8 @@ class Historico: if time.monotonic() - inicio < 2: # o pino "assenta" ao ligar o pull-down continue self.pir['ultimo'] = time.time() - self.publicar('pir', dict(ultimo=self.pir['ultimo'])) # todo disparo, ao vivo + self.marcar_presenca(self.pir['ultimo']) + self.publicar('pir', dict(ultimo=self.pir['ultimo'], presenca=dict(self.presenca))) # todo disparo, ao vivo self.registrar('pir', 'presença') erro = (self.processo_pir.stderr.read() or '').strip()[:200] except OSError as e: @@ -214,7 +284,7 @@ class Historico: def status(self): eventos, total = self.listar() with self.lock: - return dict(config=dict(self.config), pir=dict(self.pir), eventos=eventos, total=total, + return dict(config=dict(self.config), pir=dict(self.pir), presenca=dict(self.presenca), eventos=eventos, total=total, max_eventos=MAX_EVENTOS, max_fotos=MAX_FOTOS) # ------------------------------------------------------------------ HTTP @@ -259,6 +329,8 @@ class Historico: gravar_atomico(self.arquivo_config, self.config) if 'pir' in limpo and not limpo['pir']: self.parar_pir() + if 'dias' in limpo: + self.limpar_antigos() self.publicar('historico', dict(config=dict(self.config))) return h.json(self.status()) or True if rota == '/historico/apagar': diff --git a/kiosk-server.py b/kiosk-server.py index 20ac048..74fd192 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -261,7 +261,7 @@ class Handler(SimpleHTTPRequestHandler): if hasattr(s, "api_config"): dados["api"] = s.api_config.status() if hasattr(s, "historico"): - dados["pir"] = dict(s.historico.pir) + dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca)) return dados def do_GET(self): @@ -489,7 +489,22 @@ def main(): dormindo = bool(servidor.controle.estado.get("dormindo")) if dormindo: enfileirar("acordar") + + def camera(ligada): + if servidor.controle.config.get("sensorCamera") != ligada: + servidor.controle.publicar("app_config", servidor.controle.atualizar({"sensorCamera": ligada})) + + def alguem_chegou(): + camera(True) # modo presença: o PIR liga a câmera do celular + enfileirar("acordar") + + def ninguem_por_perto(): + camera(False) # sem ninguém: câmera desligada, dorme e fica em silêncio + enfileirar("dormir") + servidor.historico.ao_detectar = presenca + servidor.historico.ao_presenca = alguem_chegou + servidor.historico.ao_ausencia = ninguem_por_perto servidor.conversa.treinamento = servidor.treinamento ligar_eventos(servidor) servidor.registrar = args.log diff --git a/tests/test_historico.py b/tests/test_historico.py index d316417..8bd2366 100644 --- a/tests/test_historico.py +++ b/tests/test_historico.py @@ -66,6 +66,33 @@ class HistoricoTest(unittest.TestCase): self.assertFalse(self.h.evento(ids[0])['foto']) self.assertEqual(len(list((Path(self.tmp.name) / 'fotos').glob('*.jpg'))), MAX_FOTOS) + def test_guarda_so_os_ultimos_dias(self): + agora = 10 * 86400 + velho = self.h.registrar('pir', agora=agora - 4 * 86400) + self.h.guardar_foto(velho, JPEG) + novo = self.h.registrar('pir', agora=agora - 86400) + self.assertEqual(self.h.limpar_antigos(agora), 1) # 3 dias por padrão + self.assertIsNone(self.h.evento(velho)) + self.assertIsNotNone(self.h.evento(novo)) + self.assertFalse((Path(self.tmp.name) / 'fotos' / f'evento-{velho}.jpg').exists()) + with self.assertRaises(ValueError): + self.h.validar({'dias': 0}) + + def test_modo_presenca(self): + chamadas = [] + self.h.ao_presenca = lambda: chamadas.append('chegou') + self.h.ao_ausencia = lambda: chamadas.append('saiu') + self.h.pir['disponivel'] = True + self.h.marcar_presenca(1000) + self.assertFalse(self.h.checar_ausencia(1000 + 4 * 60)) # 4 min: ainda presente + self.assertTrue(self.h.checar_ausencia(1000 + 5 * 60 + 1)) # 5 min: ausente + self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # não repete + self.h.marcar_presenca(2000) # voltou alguém + self.assertEqual(chamadas, ['saiu', 'chegou']) + self.h.config['presenca'] = False + self.h.marcar_presenca(3000) + self.assertFalse(self.h.checar_ausencia(9999)) + 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) diff --git a/web/src/App.svelte b/web/src/App.svelte index 2af4ad5..a0f3900 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,5 +1,5 @@ + +
+ O que o sensor de presença e a câmera do celular perceberam, com uma foto de cada momento. Guardamos {st?.config.dias ?? 3} dias. +
+Carregando eventos…
+ {:else if !eventos.length} +Nenhum evento {filtro === 'todos' ? 'nos últimos dias' : 'com esse filtro'}.
+Quando alguém passar na frente do sensor ou a câmera perceber movimento, aparece aqui na hora.
+- 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} -
-Apagar todos os eventos e fotos?
-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.
-