feat(eventos): menu Eventos, modo presenca e 3 dias de historico
- "Eventos" vira item do menu principal: lista por dia (Hoje, Ontem...), presenca e movimento em destaque, botao "Ver foto" com miniatura - modo presenca: o PIR liga a camera do celular e acorda o Carinha; sem ninguem por N minutos (5 por padrao) a camera desliga, ele dorme e fica em silencio (sons e fala desligados enquanto dorme) - historico guarda so os ultimos 3 dias (ajustavel de 1 a 30), com faxina automatica de eventos e fotos antigos - testes da retencao e do modo presenca Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1045,7 +1045,7 @@ const SONS = {
|
|||||||
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
||||||
os sons dos gestos ficam quietos para não embolar. */
|
os sons dos gestos ficam quietos para não embolar. */
|
||||||
function falar(texto) {
|
function falar(texto) {
|
||||||
if (!AUDIO.ligado || !window.VozRobo) return;
|
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
|
||||||
const c = contextoAudio();
|
const c = contextoAudio();
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||||
@@ -1057,6 +1057,7 @@ function falar(texto) {
|
|||||||
|
|
||||||
function tocarSom(gesto) {
|
function tocarSom(gesto) {
|
||||||
if (!AUDIO.ligado) return false;
|
if (!AUDIO.ligado) return false;
|
||||||
|
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
|
||||||
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
||||||
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
||||||
const f = SONS[gesto];
|
const f = SONS[gesto];
|
||||||
|
|||||||
+2
-1
@@ -1045,7 +1045,7 @@ const SONS = {
|
|||||||
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
||||||
os sons dos gestos ficam quietos para não embolar. */
|
os sons dos gestos ficam quietos para não embolar. */
|
||||||
function falar(texto) {
|
function falar(texto) {
|
||||||
if (!AUDIO.ligado || !window.VozRobo) return;
|
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
|
||||||
const c = contextoAudio();
|
const c = contextoAudio();
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||||
@@ -1057,6 +1057,7 @@ function falar(texto) {
|
|||||||
|
|
||||||
function tocarSom(gesto) {
|
function tocarSom(gesto) {
|
||||||
if (!AUDIO.ligado) return false;
|
if (!AUDIO.ligado) return false;
|
||||||
|
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
|
||||||
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
||||||
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
||||||
const f = SONS[gesto];
|
const f = SONS[gesto];
|
||||||
|
|||||||
+79
-7
@@ -4,7 +4,8 @@
|
|||||||
Orange Pi Zero 3; outro pino com CARINHA_PIR=gpiochip1:79.
|
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
|
- 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.
|
/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 json
|
||||||
import os
|
import os
|
||||||
@@ -19,8 +20,9 @@ from urllib.parse import parse_qs, urlsplit
|
|||||||
from comum import gravar_atomico, ler_json
|
from comum import gravar_atomico, ler_json
|
||||||
|
|
||||||
TIPOS = ('pir', 'movimento', 'som')
|
TIPOS = ('pir', 'movimento', 'som')
|
||||||
PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15)
|
PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15, dias=3,
|
||||||
MAX_EVENTOS, MAX_FOTOS = 200, 100
|
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
|
VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +36,10 @@ class Historico:
|
|||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.publicar = lambda tipo, dados: None
|
self.publicar = lambda tipo, dados: None
|
||||||
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
|
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)
|
self.config = dict(PADRAO)
|
||||||
try:
|
try:
|
||||||
salvo = json.loads(self.arquivo_config.read_text(encoding='utf-8'))
|
salvo = json.loads(self.arquivo_config.read_text(encoding='utf-8'))
|
||||||
@@ -46,8 +52,11 @@ class Historico:
|
|||||||
self.processo_pir = None
|
self.processo_pir = None
|
||||||
with self.db() as db:
|
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)')
|
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:
|
if iniciar_pir:
|
||||||
threading.Thread(target=self.vigiar_pir, daemon=True).start()
|
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
|
@contextmanager
|
||||||
def db(self):
|
def db(self):
|
||||||
@@ -66,7 +75,13 @@ class Historico:
|
|||||||
for chave, valor in dados.items():
|
for chave, valor in dados.items():
|
||||||
if chave not in PADRAO:
|
if chave not in PADRAO:
|
||||||
raise ValueError('Ajuste desconhecido: ' + chave)
|
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:
|
if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600:
|
||||||
raise ValueError('Intervalo entre 0 e 600 s')
|
raise ValueError('Intervalo entre 0 e 600 s')
|
||||||
elif not isinstance(valor, bool):
|
elif not isinstance(valor, bool):
|
||||||
@@ -74,6 +89,59 @@ class Historico:
|
|||||||
limpo[chave] = valor
|
limpo[chave] = valor
|
||||||
return limpo
|
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
|
# ------------------------------------------------------------------ eventos
|
||||||
def registrar(self, tipo, evento='', nivel=None, agora=None):
|
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."""
|
"""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,))
|
db.execute('DELETE FROM eventos WHERE id=?', (vid,))
|
||||||
if self.config['foto']:
|
if self.config['foto']:
|
||||||
self.pedido = (ident, time.monotonic())
|
self.pedido = (ident, time.monotonic())
|
||||||
|
self.limpar_antigos(agora)
|
||||||
self.publicar('historico', dict(novo=self.evento(ident)))
|
self.publicar('historico', dict(novo=self.evento(ident)))
|
||||||
try:
|
try:
|
||||||
self.ao_detectar(tipo)
|
self.ao_detectar(tipo)
|
||||||
@@ -114,7 +183,7 @@ class Historico:
|
|||||||
i, h, t, e, n, f = linha
|
i, h, t, e, n, f = linha
|
||||||
return dict(id=i, horario=h, tipo=t, evento=e, nivel=n, foto=bool(f))
|
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:
|
with self.db() as db:
|
||||||
linhas = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos ORDER BY id DESC LIMIT ?', (limite,)).fetchall()
|
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]
|
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
|
if time.monotonic() - inicio < 2: # o pino "assenta" ao ligar o pull-down
|
||||||
continue
|
continue
|
||||||
self.pir['ultimo'] = time.time()
|
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')
|
self.registrar('pir', 'presença')
|
||||||
erro = (self.processo_pir.stderr.read() or '').strip()[:200]
|
erro = (self.processo_pir.stderr.read() or '').strip()[:200]
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -214,7 +284,7 @@ class Historico:
|
|||||||
def status(self):
|
def status(self):
|
||||||
eventos, total = self.listar()
|
eventos, total = self.listar()
|
||||||
with self.lock:
|
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)
|
max_eventos=MAX_EVENTOS, max_fotos=MAX_FOTOS)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ HTTP
|
# ------------------------------------------------------------------ HTTP
|
||||||
@@ -259,6 +329,8 @@ class Historico:
|
|||||||
gravar_atomico(self.arquivo_config, self.config)
|
gravar_atomico(self.arquivo_config, self.config)
|
||||||
if 'pir' in limpo and not limpo['pir']:
|
if 'pir' in limpo and not limpo['pir']:
|
||||||
self.parar_pir()
|
self.parar_pir()
|
||||||
|
if 'dias' in limpo:
|
||||||
|
self.limpar_antigos()
|
||||||
self.publicar('historico', dict(config=dict(self.config)))
|
self.publicar('historico', dict(config=dict(self.config)))
|
||||||
return h.json(self.status()) or True
|
return h.json(self.status()) or True
|
||||||
if rota == '/historico/apagar':
|
if rota == '/historico/apagar':
|
||||||
|
|||||||
+16
-1
@@ -261,7 +261,7 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
if hasattr(s, "api_config"):
|
if hasattr(s, "api_config"):
|
||||||
dados["api"] = s.api_config.status()
|
dados["api"] = s.api_config.status()
|
||||||
if hasattr(s, "historico"):
|
if hasattr(s, "historico"):
|
||||||
dados["pir"] = dict(s.historico.pir)
|
dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca))
|
||||||
return dados
|
return dados
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
@@ -489,7 +489,22 @@ def main():
|
|||||||
dormindo = bool(servidor.controle.estado.get("dormindo"))
|
dormindo = bool(servidor.controle.estado.get("dormindo"))
|
||||||
if dormindo:
|
if dormindo:
|
||||||
enfileirar("acordar")
|
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_detectar = presenca
|
||||||
|
servidor.historico.ao_presenca = alguem_chegou
|
||||||
|
servidor.historico.ao_ausencia = ninguem_por_perto
|
||||||
servidor.conversa.treinamento = servidor.treinamento
|
servidor.conversa.treinamento = servidor.treinamento
|
||||||
ligar_eventos(servidor)
|
ligar_eventos(servidor)
|
||||||
servidor.registrar = args.log
|
servidor.registrar = args.log
|
||||||
|
|||||||
@@ -66,6 +66,33 @@ class HistoricoTest(unittest.TestCase):
|
|||||||
self.assertFalse(self.h.evento(ids[0])['foto'])
|
self.assertFalse(self.h.evento(ids[0])['foto'])
|
||||||
self.assertEqual(len(list((Path(self.tmp.name) / 'fotos').glob('*.jpg'))), MAX_FOTOS)
|
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):
|
def test_apagar_e_limpar(self):
|
||||||
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
|
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
|
||||||
b = self.h.registrar('som', agora=1)
|
b = self.h.registrar('som', agora=1)
|
||||||
|
|||||||
+5
-4
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
||||||
import { onMount, type Component } from 'svelte';
|
import { onMount, type Component } from 'svelte';
|
||||||
import Avisos from './lib/componentes/Avisos.svelte';
|
import Avisos from './lib/componentes/Avisos.svelte';
|
||||||
import EStop from './lib/componentes/EStop.svelte';
|
import EStop from './lib/componentes/EStop.svelte';
|
||||||
@@ -17,19 +17,20 @@
|
|||||||
import Movimento from './telas/Movimento.svelte';
|
import Movimento from './telas/Movimento.svelte';
|
||||||
import Reacoes from './telas/Reacoes.svelte';
|
import Reacoes from './telas/Reacoes.svelte';
|
||||||
import Rosto from './telas/Rosto.svelte';
|
import Rosto from './telas/Rosto.svelte';
|
||||||
import Historico from './telas/Historico.svelte';
|
import Eventos from './telas/Eventos.svelte';
|
||||||
|
|
||||||
const TELAS: Record<Rota, Component> = {
|
const TELAS: Record<Rota, Component> = {
|
||||||
'ao-vivo': AoVivo,
|
'ao-vivo': AoVivo,
|
||||||
|
eventos: Eventos,
|
||||||
conversa: Conversa,
|
conversa: Conversa,
|
||||||
reacoes: Reacoes,
|
reacoes: Reacoes,
|
||||||
movimento: Movimento,
|
movimento: Movimento,
|
||||||
ajustes: Ajustes,
|
ajustes: Ajustes,
|
||||||
rosto: Rosto,
|
rosto: Rosto,
|
||||||
historico: Historico,
|
|
||||||
};
|
};
|
||||||
const ICONES: Record<string, Component<any>> = {
|
const ICONES: Record<string, Component<any>> = {
|
||||||
'ao-vivo': Video,
|
'ao-vivo': Video,
|
||||||
|
eventos: Bell,
|
||||||
conversa: MessageCircle,
|
conversa: MessageCircle,
|
||||||
reacoes: Smile,
|
reacoes: Smile,
|
||||||
movimento: Hand,
|
movimento: Hand,
|
||||||
@@ -208,7 +209,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--e-1);
|
padding: var(--e-1);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
}
|
}
|
||||||
li a {
|
li a {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class Estado {
|
|||||||
api = $state<EstadoServidor['api'] | null>(null);
|
api = $state<EstadoServidor['api'] | null>(null);
|
||||||
feed = $state<ItemFeed[]>([]);
|
feed = $state<ItemFeed[]>([]);
|
||||||
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
|
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
|
||||||
pir = $state<{ disponivel: boolean; ultimo: number | null } | null>(null);
|
pir = $state<{ disponivel: boolean; ultimo: number | null; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
|
||||||
/** Relógio de 1 s para derivar "online" sem esperar evento novo. */
|
/** Relógio de 1 s para derivar "online" sem esperar evento novo. */
|
||||||
agora = $state(Date.now());
|
agora = $state(Date.now());
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ function aplicarFoto(f: EstadoServidor) {
|
|||||||
estado.reacao = f.reacao ?? null;
|
estado.reacao = f.reacao ?? null;
|
||||||
if (f.treinamento) estado.treinamento = f.treinamento;
|
if (f.treinamento) estado.treinamento = f.treinamento;
|
||||||
if (f.api) estado.api = f.api;
|
if (f.api) estado.api = f.api;
|
||||||
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo };
|
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, presenca: (f.pir as any).presenca };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tratadores: Record<string, (d: any) => void> = {
|
const tratadores: Record<string, (d: any) => void> = {
|
||||||
@@ -142,7 +142,7 @@ const tratadores: Record<string, (d: any) => void> = {
|
|||||||
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
|
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
|
||||||
},
|
},
|
||||||
treinamento: (d) => (estado.treinamento = d),
|
treinamento: (d) => (estado.treinamento = d),
|
||||||
pir: (d: { ultimo: number }) => (estado.pir = { disponivel: true, ultimo: d.ultimo }),
|
pir: (d: { ultimo: number; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, presenca: d.presenca }),
|
||||||
historico: (d) => {
|
historico: (d) => {
|
||||||
const n = d?.novo;
|
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');
|
if (n) noFeed('historico', { pir: '🚶 Presença no PIR', movimento: '👀 Movimento na câmera', som: '🔊 Som alto' }[n.tipo as string] ?? 'Detecção');
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export interface EventoHistorico {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusHistorico {
|
export interface StatusHistorico {
|
||||||
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number };
|
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number };
|
||||||
|
presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number };
|
||||||
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||||
eventos: EventoHistorico[];
|
eventos: EventoHistorico[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export function itensDaPaleta(): ItemPaleta[] {
|
|||||||
registrarNaPaleta(() => [
|
registrarNaPaleta(() => [
|
||||||
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
|
...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-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.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
|
||||||
estado.estop.ativo
|
estado.estop.ativo
|
||||||
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
|
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
|
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
|
||||||
|
|
||||||
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'historico';
|
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
|
||||||
|
|
||||||
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||||
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
|
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
|
||||||
|
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
|
||||||
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
|
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
|
||||||
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
|
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
|
||||||
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
|
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
|
||||||
@@ -13,13 +14,13 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
|||||||
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */
|
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */
|
||||||
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
|
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
|
||||||
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' },
|
{ 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 ?? '';
|
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
|
||||||
|
|
||||||
function lerHash(): Rota {
|
function lerHash(): Rota {
|
||||||
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
|
let id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
|
||||||
|
if ((id as string) === 'historico') id = 'eventos'; // endereço antigo
|
||||||
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
|
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
|
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
|
||||||
{#snippet acessorio()}<PresencaPir />{/snippet}
|
{#snippet acessorio()}<PresencaPir />{/snippet}
|
||||||
</Linha>
|
</Linha>
|
||||||
<Linha icone={History} cor="#30b0c7" rotulo="Histórico de detecções" detalhe="PIR, movimento e som, com foto" href="#/historico" />
|
<Linha icone={History} cor="#30b0c7" rotulo="Eventos" detalhe="Presença, movimento e som, com foto" href="#/eventos" />
|
||||||
{#if estado.feed.length}
|
{#if estado.feed.length}
|
||||||
<ol class="feed">
|
<ol class="feed">
|
||||||
{#each estado.feed.slice(0, 12) as item (item.id)}
|
{#each estado.feed.slice(0, 12) as item (item.id)}
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Camera, Ear, Footprints, Image as ImageIcon, Moon, Timer, Trash2, Video } from '@lucide/svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Grupo from '../lib/componentes/Grupo.svelte';
|
||||||
|
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
||||||
|
import Linha from '../lib/componentes/Linha.svelte';
|
||||||
|
import Modal from '../lib/componentes/Modal.svelte';
|
||||||
|
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||||
|
import Slider from '../lib/componentes/Slider.svelte';
|
||||||
|
import { estado } from '../lib/estado.svelte';
|
||||||
|
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
historico.carregar();
|
||||||
|
});
|
||||||
|
|
||||||
|
const st = $derived(historico.status);
|
||||||
|
// Presença e movimento em destaque; som é opcional no filtro.
|
||||||
|
type Filtro = 'principais' | 'pir' | 'movimento' | 'som' | 'todos';
|
||||||
|
let filtro = $state<Filtro>('principais');
|
||||||
|
const FILTROS: [Filtro, string][] = [
|
||||||
|
['principais', 'Presença e movimento'], ['pir', '🚶 Presença'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['todos', 'Todos'],
|
||||||
|
];
|
||||||
|
const eventos = $derived(
|
||||||
|
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'principais' ? e.tipo !== 'som' : e.tipo === filtro)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Agrupa por dia: Hoje, Ontem, depois a data.
|
||||||
|
const dias = $derived.by(() => {
|
||||||
|
const grupos: { titulo: string; itens: EventoHistorico[] }[] = [];
|
||||||
|
const hoje = new Date(estado.agora); hoje.setHours(0, 0, 0, 0);
|
||||||
|
for (const ev of eventos) {
|
||||||
|
const d = new Date(ev.horario * 1000); d.setHours(0, 0, 0, 0);
|
||||||
|
const diff = Math.round((hoje.getTime() - d.getTime()) / 86400000);
|
||||||
|
const titulo = diff === 0 ? 'Hoje' : diff === 1 ? 'Ontem' : d.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' });
|
||||||
|
if (!grupos.length || grupos[grupos.length - 1].titulo !== titulo) grupos.push({ titulo, itens: [] });
|
||||||
|
grupos[grupos.length - 1].itens.push(ev);
|
||||||
|
}
|
||||||
|
return grupos;
|
||||||
|
});
|
||||||
|
|
||||||
|
const TITULO: Record<string, string> = { pir: 'Presença detectada', movimento: 'Movimento na câmera', som: 'Som alto' };
|
||||||
|
const DETALHE: Record<string, string> = { surpreso: 'deu um susto', acordou: 'acordou o Carinha', presença: 'sensor PIR' };
|
||||||
|
const horaCurta = (t: number) => new Date(t * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
|
||||||
|
const desde = (t: number) => {
|
||||||
|
const min = Math.max(0, Math.round((estado.agora - t * 1000) / 60000));
|
||||||
|
return min < 1 ? 'agora' : min < 60 ? `há ${min} min` : `há ${Math.round(min / 60)} h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const presenca = $derived(st?.presenca);
|
||||||
|
let aberta = $state<EventoHistorico | null>(null);
|
||||||
|
let modal = $state(false);
|
||||||
|
let confirmar = $state(false);
|
||||||
|
function verFoto(ev: EventoHistorico) {
|
||||||
|
aberta = ev;
|
||||||
|
modal = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="eventos">
|
||||||
|
<header>
|
||||||
|
<div class="linha entre">
|
||||||
|
<h1>Eventos</h1>
|
||||||
|
{#if st?.config.pir}<PresencaPir />{/if}
|
||||||
|
</div>
|
||||||
|
<p class="fraco">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="colunas">
|
||||||
|
<section class="lista" aria-label="Lista de eventos">
|
||||||
|
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
|
||||||
|
{#each FILTROS as [id, nome] (id)}
|
||||||
|
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id)}>{nome}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !st}
|
||||||
|
<p class="fraco">Carregando eventos…</p>
|
||||||
|
{:else if !eventos.length}
|
||||||
|
<div class="vazio">
|
||||||
|
<p>Nenhum evento {filtro === 'todos' ? 'nos últimos dias' : 'com esse filtro'}.</p>
|
||||||
|
<p class="fraco pequeno-texto">Quando alguém passar na frente do sensor ou a câmera perceber movimento, aparece aqui na hora.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each dias as dia (dia.titulo)}
|
||||||
|
<Grupo titulo={`${dia.titulo} · ${dia.itens.length}`}>
|
||||||
|
{#each dia.itens as ev (ev.id)}
|
||||||
|
{@const info = TIPOS_EVENTO[ev.tipo]}
|
||||||
|
<div class="evento">
|
||||||
|
<span class="icone" style="--c: {info.cor}" aria-hidden="true">{info.emoji}</span>
|
||||||
|
<div class="texto">
|
||||||
|
<strong>{TITULO[ev.tipo] ?? ev.tipo}</strong>
|
||||||
|
<span class="fraco pequeno-texto">
|
||||||
|
{horaCurta(ev.horario)} · {DETALHE[ev.evento] ?? ev.evento}{ev.nivel !== null && ev.tipo !== 'pir' ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if ev.foto}
|
||||||
|
<button class="ver-foto" onclick={() => verFoto(ev)} aria-label="Ver foto de {horaCurta(ev.horario)}">
|
||||||
|
<img src={urlFoto(ev.id)} alt="" loading="lazy" />
|
||||||
|
<span>Ver foto</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<span class="sem-foto fraco pequeno-texto">sem foto</span>
|
||||||
|
{/if}
|
||||||
|
<button class="btn pequeno icone fantasma" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</Grupo>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="lado">
|
||||||
|
<Grupo
|
||||||
|
titulo="Modo presença"
|
||||||
|
rodape="Alguém aparece: o PIR liga a câmera do celular e acorda o Carinha. Ninguém pelo tempo escolhido: a câmera desliga, ele dorme e fica em silêncio."
|
||||||
|
>
|
||||||
|
<Linha
|
||||||
|
icone={Footprints}
|
||||||
|
cor="#34c759"
|
||||||
|
rotulo="Câmera ligada pela presença"
|
||||||
|
detalhe={presenca ? (presenca.estado === 'presente' ? `alguém por perto · último movimento ${desde(presenca.ultimo)}` : `ninguém por perto ${desde(presenca.desde)}`) : ''}
|
||||||
|
>
|
||||||
|
{#snippet acessorio()}
|
||||||
|
<Interruptor rotulo="Câmera ligada pela presença" checked={!!st?.config.presenca} disabled={!st} onchange={(v) => historico.configurar({ presenca: v })} />
|
||||||
|
{/snippet}
|
||||||
|
</Linha>
|
||||||
|
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por">
|
||||||
|
<Slider rotulo="Desligar câmera e dormir depois de" valor={st?.config.ausencia ?? 5} min={1} max={60} passo={1} unidade=" min" onchange={(v) => historico.configurar({ ausencia: v })} />
|
||||||
|
</Linha>
|
||||||
|
</Grupo>
|
||||||
|
|
||||||
|
<Grupo titulo="O que registrar">
|
||||||
|
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)" detalhe={st?.pir.disponivel ? 'Pino 11 · HC-SR505' : st?.pir.erro || 'indisponível'}>
|
||||||
|
{#snippet acessorio()}
|
||||||
|
<Interruptor rotulo="Sensor de presença (PIR)" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
|
||||||
|
{/snippet}
|
||||||
|
</Linha>
|
||||||
|
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera">
|
||||||
|
{#snippet acessorio()}
|
||||||
|
<Interruptor rotulo="Movimento na câmera" checked={!!st?.config.movimento} disabled={!st} onchange={(v) => historico.configurar({ movimento: v })} />
|
||||||
|
{/snippet}
|
||||||
|
</Linha>
|
||||||
|
<Linha icone={Ear} cor="#ff9500" rotulo="Som alto">
|
||||||
|
{#snippet acessorio()}
|
||||||
|
<Interruptor rotulo="Som alto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
|
||||||
|
{/snippet}
|
||||||
|
</Linha>
|
||||||
|
<Linha icone={Camera} cor="#af52de" rotulo="Tirar uma foto por evento" detalhe="Câmera frontal do S10">
|
||||||
|
{#snippet acessorio()}
|
||||||
|
<Interruptor rotulo="Tirar uma foto por evento" checked={!!st?.config.foto} disabled={!st} onchange={(v) => historico.configurar({ foto: v })} />
|
||||||
|
{/snippet}
|
||||||
|
</Linha>
|
||||||
|
<Linha icone={Timer} cor="#8e8e93" rotulo="Intervalo entre eventos do mesmo tipo">
|
||||||
|
<Slider rotulo="Intervalo" valor={st?.config.intervalo ?? 15} min={0} max={120} passo={5} unidade=" s" onchange={(v) => historico.configurar({ intervalo: v })} />
|
||||||
|
</Linha>
|
||||||
|
<Linha icone={ImageIcon} cor="#30b0c7" rotulo="Guardar">
|
||||||
|
<Slider rotulo="Dias de histórico" valor={st?.config.dias ?? 3} min={1} max={30} passo={1} unidade=" dias" onchange={(v) => historico.configurar({ dias: v })} />
|
||||||
|
</Linha>
|
||||||
|
</Grupo>
|
||||||
|
|
||||||
|
<div class="limpar">
|
||||||
|
{#if confirmar}
|
||||||
|
<p class="pequeno-texto">Apagar todos os eventos e fotos?</p>
|
||||||
|
<div class="linha">
|
||||||
|
<button class="btn perigo pequeno" onclick={() => { confirmar = false; historico.limpar(); }}>Apagar tudo</button>
|
||||||
|
<button class="btn pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!st?.total}><Trash2 size={14} /> Limpar eventos</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal bind:aberto={modal} titulo={aberta ? `${TITULO[aberta.tipo]} · ${hora(aberta.horario)}` : 'Foto'}>
|
||||||
|
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.eventos {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-5);
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-2);
|
||||||
|
}
|
||||||
|
header p {
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
.colunas {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-5);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.lista,
|
||||||
|
.lado {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-5);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.filtros {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--e-2);
|
||||||
|
}
|
||||||
|
.filtros button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 var(--e-3);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: var(--raio-pill);
|
||||||
|
background: var(--superficie);
|
||||||
|
color: var(--texto-2);
|
||||||
|
font: 500 var(--t-sm) var(--fonte);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.filtros button[aria-checked='true'] {
|
||||||
|
background: var(--destaque);
|
||||||
|
border-color: var(--destaque);
|
||||||
|
color: var(--destaque-texto);
|
||||||
|
}
|
||||||
|
.vazio {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-1);
|
||||||
|
padding: var(--e-6);
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--borda-forte);
|
||||||
|
border-radius: var(--raio-lg);
|
||||||
|
}
|
||||||
|
.evento {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-3);
|
||||||
|
padding: var(--e-2) var(--e-3) var(--e-2) var(--e-4);
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
.evento + .evento {
|
||||||
|
border-top: 1px solid var(--borda);
|
||||||
|
}
|
||||||
|
.icone {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--c) 18%, transparent);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.texto {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ver-foto {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: 3px var(--e-3) 3px 3px;
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: var(--raio);
|
||||||
|
background: var(--superficie-2);
|
||||||
|
color: var(--destaque);
|
||||||
|
font: 600 var(--t-xs) var(--fonte);
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.ver-foto img {
|
||||||
|
width: 56px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.sem-foto {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.limpar {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: 0 var(--e-2);
|
||||||
|
}
|
||||||
|
.grande {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--raio);
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.ver-foto span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ver-foto {
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.colunas {
|
||||||
|
grid-template-columns: 1fr 380px;
|
||||||
|
}
|
||||||
|
.lado {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--topo-altura) + var(--e-5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { ArrowLeft, Camera, Ear, Footprints, ImageOff, Timer, Trash2, Video } from '@lucide/svelte';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import Grupo from '../lib/componentes/Grupo.svelte';
|
|
||||||
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
|
||||||
import Linha from '../lib/componentes/Linha.svelte';
|
|
||||||
import Modal from '../lib/componentes/Modal.svelte';
|
|
||||||
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
|
||||||
import Slider from '../lib/componentes/Slider.svelte';
|
|
||||||
import { estado } from '../lib/estado.svelte';
|
|
||||||
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
historico.carregar();
|
|
||||||
});
|
|
||||||
|
|
||||||
const st = $derived(historico.status);
|
|
||||||
type Filtro = 'todos' | 'pir' | 'movimento' | 'som' | 'foto';
|
|
||||||
let filtro = $state<Filtro>('todos');
|
|
||||||
const eventos = $derived(
|
|
||||||
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'foto' ? e.foto : e.tipo === filtro)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const quando = (t: number) => {
|
|
||||||
const seg = Math.max(0, Math.round((estado.agora - t * 1000) / 1000));
|
|
||||||
if (seg < 60) return `há ${seg} s`;
|
|
||||||
if (seg < 3600) return `há ${Math.round(seg / 60)} min`;
|
|
||||||
if (seg < 86400) return `há ${Math.round(seg / 3600)} h`;
|
|
||||||
return new Date(t * 1000).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' });
|
|
||||||
};
|
|
||||||
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
|
|
||||||
const NOME_EVENTO: Record<string, string> = { surpreso: 'susto', acordou: 'acordou o Carinha', presença: 'presença' };
|
|
||||||
|
|
||||||
const pirTexto = $derived.by(() => {
|
|
||||||
if (!st) return '';
|
|
||||||
if (!st.config.pir) return 'Desligado';
|
|
||||||
if (st.pir.disponivel) return 'Pino 11 · HC-SR505';
|
|
||||||
return st.pir.erro || 'Indisponível neste servidor';
|
|
||||||
});
|
|
||||||
|
|
||||||
let aberta = $state<EventoHistorico | null>(null);
|
|
||||||
let modal = $state(false);
|
|
||||||
let confirmar = $state(false);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="historico">
|
|
||||||
<a class="voltar" href="#/ao-vivo"><ArrowLeft size={16} /> Ao vivo</a>
|
|
||||||
<header>
|
|
||||||
<h1>Histórico de detecções</h1>
|
|
||||||
<p class="fraco">
|
|
||||||
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}
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="colunas">
|
|
||||||
<div class="lado">
|
|
||||||
<Grupo titulo="Sensores">
|
|
||||||
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor PIR da placa" detalhe={pirTexto}>
|
|
||||||
{#snippet acessorio()}
|
|
||||||
{#if st?.config.pir}<PresencaPir />{/if}
|
|
||||||
<Interruptor rotulo="Sensor PIR da placa" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
|
|
||||||
{/snippet}
|
|
||||||
</Linha>
|
|
||||||
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera" detalhe="Câmera do S10 como sensor">
|
|
||||||
{#snippet acessorio()}
|
|
||||||
<Interruptor rotulo="Movimento na câmera" checked={!!st?.config.movimento} disabled={!st} onchange={(v) => historico.configurar({ movimento: v })} />
|
|
||||||
{/snippet}
|
|
||||||
</Linha>
|
|
||||||
<Linha icone={Ear} cor="#ff9500" rotulo="Som alto e susto" detalhe="Microfone do S10 (só o nível, nunca o áudio)">
|
|
||||||
{#snippet acessorio()}
|
|
||||||
<Interruptor rotulo="Som alto e susto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
|
|
||||||
{/snippet}
|
|
||||||
</Linha>
|
|
||||||
</Grupo>
|
|
||||||
<Grupo titulo="Fotos" rodape="A foto sai da câmera de movimento do S10 ou, com o vídeo ao vivo no ar, do próprio vídeo.">
|
|
||||||
<Linha icone={Camera} cor="#af52de" rotulo="Guardar uma foto por evento">
|
|
||||||
{#snippet acessorio()}
|
|
||||||
<Interruptor rotulo="Guardar uma foto por evento" checked={!!st?.config.foto} disabled={!st} onchange={(v) => historico.configurar({ foto: v })} />
|
|
||||||
{/snippet}
|
|
||||||
</Linha>
|
|
||||||
<Linha icone={Timer} cor="#8e8e93" rotulo="Intervalo mínimo entre eventos">
|
|
||||||
<Slider
|
|
||||||
rotulo="Do mesmo tipo"
|
|
||||||
valor={st?.config.intervalo ?? 15}
|
|
||||||
min={0}
|
|
||||||
max={120}
|
|
||||||
passo={5}
|
|
||||||
unidade=" s"
|
|
||||||
onchange={(v) => historico.configurar({ intervalo: v })}
|
|
||||||
/>
|
|
||||||
</Linha>
|
|
||||||
</Grupo>
|
|
||||||
<div class="limpar">
|
|
||||||
{#if confirmar}
|
|
||||||
<p class="pequeno-texto">Apagar todos os eventos e fotos?</p>
|
|
||||||
<div class="linha">
|
|
||||||
<button class="btn perigo pequeno" onclick={() => { confirmar = false; historico.limpar(); }}>Apagar tudo</button>
|
|
||||||
<button class="btn pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!st?.total}><Trash2 size={14} /> Limpar histórico</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="lista" aria-label="Eventos">
|
|
||||||
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
|
|
||||||
{#each [['todos', 'Todos'], ['pir', '🚶 PIR'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['foto', '📷 Com foto']] as [id, nome] (id)}
|
|
||||||
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id as Filtro)}>{nome}</button>
|
|
||||||
{/each}
|
|
||||||
{#if st}<span class="fraco pequeno-texto">{st.total} evento(s)</span>{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !st}
|
|
||||||
<p class="fraco">Carregando histórico…</p>
|
|
||||||
{:else if !eventos.length}
|
|
||||||
<div class="vazio">
|
|
||||||
<p>Nenhum evento {filtro === 'todos' ? 'ainda' : 'com esse filtro'}.</p>
|
|
||||||
<p class="fraco pequeno-texto">Quando o PIR ou o celular perceber algo, o evento aparece aqui na hora, com a foto.</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<ul class="grade">
|
|
||||||
{#each eventos as ev (ev.id)}
|
|
||||||
{@const info = TIPOS_EVENTO[ev.tipo]}
|
|
||||||
<li class="cartao">
|
|
||||||
<button class="foto" onclick={() => ev.foto && ((aberta = ev), (modal = true))} disabled={!ev.foto} aria-label={ev.foto ? 'Ver foto' : 'Sem foto'}>
|
|
||||||
{#if ev.foto}
|
|
||||||
<img src={urlFoto(ev.id)} alt="Foto do evento de {hora(ev.horario)}" loading="lazy" />
|
|
||||||
{:else}
|
|
||||||
<ImageOff size={22} />
|
|
||||||
{/if}
|
|
||||||
<span class="selo" style="--c: {info.cor}">{info.emoji} {info.nome}</span>
|
|
||||||
</button>
|
|
||||||
<div class="dados">
|
|
||||||
<strong>{NOME_EVENTO[ev.evento] ?? ev.evento}</strong>
|
|
||||||
<span class="fraco pequeno-texto" title={hora(ev.horario)}>{quando(ev.horario)}{ev.nivel !== null ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn pequeno icone fantasma apagar" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal bind:aberto={modal} titulo={aberta ? `${TIPOS_EVENTO[aberta.tipo].nome} · ${hora(aberta.horario)}` : 'Foto'}>
|
|
||||||
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.historico {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--e-5);
|
|
||||||
}
|
|
||||||
.voltar {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--e-1);
|
|
||||||
width: fit-content;
|
|
||||||
color: var(--texto-2);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: var(--t-sm);
|
|
||||||
}
|
|
||||||
header {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--e-2);
|
|
||||||
}
|
|
||||||
header p {
|
|
||||||
max-width: 70ch;
|
|
||||||
}
|
|
||||||
.colunas {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--e-5);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.lado {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--e-5);
|
|
||||||
}
|
|
||||||
.limpar {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--e-2);
|
|
||||||
padding: 0 var(--e-2);
|
|
||||||
}
|
|
||||||
.lista {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--e-4);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.filtros {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--e-2);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.filtros button {
|
|
||||||
min-height: 34px;
|
|
||||||
padding: 0 var(--e-3);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
border-radius: var(--raio-pill);
|
|
||||||
background: var(--superficie);
|
|
||||||
color: var(--texto-2);
|
|
||||||
font: 500 var(--t-sm) var(--fonte);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.filtros button[aria-checked='true'] {
|
|
||||||
background: var(--destaque);
|
|
||||||
border-color: var(--destaque);
|
|
||||||
color: var(--destaque-texto);
|
|
||||||
}
|
|
||||||
.vazio {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--e-1);
|
|
||||||
padding: var(--e-6);
|
|
||||||
text-align: center;
|
|
||||||
border: 1px dashed var(--borda-forte);
|
|
||||||
border-radius: var(--raio-lg);
|
|
||||||
}
|
|
||||||
.grade {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: var(--e-3);
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
|
|
||||||
}
|
|
||||||
.cartao {
|
|
||||||
position: relative;
|
|
||||||
display: grid;
|
|
||||||
gap: var(--e-2);
|
|
||||||
padding: var(--e-2);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
border-radius: var(--raio-lg);
|
|
||||||
background: var(--superficie);
|
|
||||||
}
|
|
||||||
.foto {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 4 / 3;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--raio);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--fundo-2);
|
|
||||||
color: var(--texto-3);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
cursor: zoom-in;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.foto:disabled {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.foto img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.selo {
|
|
||||||
position: absolute;
|
|
||||||
left: 6px;
|
|
||||||
bottom: 6px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: var(--raio-pill);
|
|
||||||
background: rgb(0 0 0 / 0.6);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 11px;
|
|
||||||
border-left: 3px solid var(--c);
|
|
||||||
}
|
|
||||||
.dados {
|
|
||||||
display: grid;
|
|
||||||
padding: 0 var(--e-1) var(--e-1);
|
|
||||||
}
|
|
||||||
.apagar {
|
|
||||||
position: absolute;
|
|
||||||
right: 6px;
|
|
||||||
bottom: 6px;
|
|
||||||
}
|
|
||||||
.grande {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: var(--raio);
|
|
||||||
}
|
|
||||||
@media (min-width: 1100px) {
|
|
||||||
.colunas {
|
|
||||||
grid-template-columns: 360px 1fr;
|
|
||||||
}
|
|
||||||
.lado {
|
|
||||||
position: sticky;
|
|
||||||
top: calc(var(--topo-altura) + var(--e-5));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Reference in New Issue
Block a user