From 7b0a05435f1dafb51d007c39437b17515dee4c31 Mon Sep 17 00:00:00 2001 From: julio Date: Sat, 10 Oct 2026 19:05:20 -0300 Subject: [PATCH] =?UTF-8?q?feat(presenca):=20sensibilidade=20do=20PIR=20po?= =?UTF-8?q?r=20confirma=C3=A7=C3=A3o=20(padr=C3=A3o=20m=C3=A9dia)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O HC-SR505 não tem ajuste físico e disparava por pouco (57 disparos numa hora de madrugada). Agora só conta como presença o disparo confirmado: média = 2 em 1 min ou ligado direto 12 s; baixa = 3 em 90 s ou 20 s; alta = qualquer um. A bolinha do Ao vivo segue o pino cru. Ajuste em Eventos › Modo presença. Co-Authored-By: Claude Opus 5.5 --- historico.py | 53 ++++++++++++++++++++++++++++----- tests/test_historico.py | 33 ++++++++++++++++++++ web/src/lib/guia.ts | 4 +++ web/src/lib/historico.svelte.ts | 2 +- web/src/telas/Eventos.svelte | 42 ++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/historico.py b/historico.py index 9ff8362..84e6e05 100644 --- a/historico.py +++ b/historico.py @@ -21,7 +21,11 @@ from comum import gravar_atomico, ler_json TIPOS = ('pir', 'movimento', 'som') 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 + presenca=True, ausencia=5, # modo presença: o PIR liga a câmera; sem ninguém, ela desliga + sensibilidade_pir='media') +# O HC-SR505 não tem ajuste de sensibilidade e segura ~8 s a cada disparo. Para separar alguém de +# verdade de um disparo à toa: (disparos que bastam, dentro de quantos s, ou ligado direto por quantos s). +REGRAS_PIR = {'alta': (1, 0, 0), 'media': (2, 60, 12), 'baixa': (3, 90, 20)} 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 @@ -51,6 +55,8 @@ class Historico: self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None, ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma) self.processo_pir = None + self.subidas_pir = [] # disparos ainda não confirmados (horários) + self.subiu_em = None # quando o pino subiu da última vez (ligado direto desde então) 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() @@ -82,6 +88,9 @@ class Historico: 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 == 'sensibilidade_pir': + if valor not in REGRAS_PIR: + raise ValueError('Sensibilidade do sensor: alta, media ou baixa') 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') @@ -134,21 +143,51 @@ class Historico: self.publicar('pir', dict(ultimo=self.pir['ultimo'], ativo=self.pir['ativo'], presenca=dict(self.presenca))) def nivel_pir(self, alto, agora=None, registrar=True): - """Mudança no pino do PIR. Subida = presença (evento + foto); enquanto alto, a bolinha fica acesa.""" + """Mudança no pino do PIR. A bolinha acompanha o pino; presença (evento, foto, acordar) só + quando o disparo se confirma pela sensibilidade escolhida.""" agora = agora if agora is not None else time.time() self.pir['ativo'] = alto if alto: self.pir['ultimo'] = agora - self.marcar_presenca(agora) self.avisar_pir() - if alto and registrar: - self.registrar('pir', 'presença') + if not alto: + self.checar_pir(agora) # ligado direto até agora pode já ter confirmado + self.subiu_em = None + return + if not registrar: # pino assentando ao ligar: só a bolinha + return + quantos, janela, _ = REGRAS_PIR[self.config['sensibilidade_pir']] + with self.lock: + self.subidas_pir = [t for t in self.subidas_pir if agora - t < janela] + [agora] + self.subiu_em = agora + confirmou = len(self.subidas_pir) >= quantos + if confirmou: + self.confirmar_pir(agora) + + def checar_pir(self, agora=None): + """Ligado direto por bastante tempo = alguém se mexendo na frente (o sensor foi redisparando).""" + agora = agora if agora is not None else time.time() + _, _, segurar = REGRAS_PIR[self.config['sensibilidade_pir']] + with self.lock: + pronto = segurar and self.subiu_em is not None and self.subidas_pir and agora - self.subiu_em >= segurar + if pronto: + self.confirmar_pir(agora) + + def confirmar_pir(self, agora): + with self.lock: + self.subidas_pir = [] + self.marcar_presenca(agora) + self.registrar('pir', 'presença', agora=agora) def vigiar_ausencia(self): + passo = 0 while True: - time.sleep(10) + time.sleep(1) + passo += 1 try: - self.checar_ausencia() + self.checar_pir() + if passo % 10 == 0: + self.checar_ausencia() except Exception: pass diff --git a/tests/test_historico.py b/tests/test_historico.py index 8bd36ee..2bcd61c 100644 --- a/tests/test_historico.py +++ b/tests/test_historico.py @@ -97,6 +97,7 @@ class HistoricoTest(unittest.TestCase): avisos = [] self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados)) self.h.pir['disponivel'] = True + self.h.config['sensibilidade_pir'] = 'alta' # qualquer disparo conta self.h.nivel_pir(True, agora=1000) # subida: evento + bolinha acesa self.assertTrue(self.h.pir['ativo']) self.assertEqual(self.h.status()['eventos'][0]['tipo'], 'pir') @@ -107,6 +108,38 @@ class HistoricoTest(unittest.TestCase): self.assertTrue(self.h.checar_ausencia(1000 + 15 * 60 + 1)) self.assertIn(('pir', True), [(t, d.get('ativo')) for t, d in avisos]) + def test_sensibilidade_do_pir_confirma_antes_de_contar(self): + detectados = [] + self.h.ao_detectar = detectados.append + self.h.config['intervalo'] = 0 + # média (padrão): um disparo solto só acende a bolinha + self.h.nivel_pir(True, agora=1000) + self.assertTrue(self.h.pir['ativo']) + self.h.nivel_pir(False, agora=1008) # o HC-SR505 segura ~8 s + self.assertEqual((self.h.status()['total'], detectados), (0, [])) + self.h.nivel_pir(True, agora=1040) # segundo disparo em menos de 1 min + self.assertEqual(detectados, ['pir']) + self.h.nivel_pir(False, agora=1048) + self.h.nivel_pir(True, agora=1200) # 2 min depois: solto de novo + self.h.nivel_pir(False, agora=1208) + self.assertEqual(len(detectados), 1) + # alguém se mexendo na frente: o sensor fica ligado direto e confirma + self.h.nivel_pir(True, agora=2000) + self.h.checar_pir(2010) + self.assertEqual(len(detectados), 1) + self.h.checar_pir(2012) + self.assertEqual(len(detectados), 2) + self.h.nivel_pir(False, agora=2030) + # baixa: precisa de 3 disparos em 90 s + self.h.config['sensibilidade_pir'] = 'baixa' + for t in (3000, 3030): + self.h.nivel_pir(True, agora=t); self.h.nivel_pir(False, agora=t + 8) + self.assertEqual(len(detectados), 2) + self.h.nivel_pir(True, agora=3060) + self.assertEqual(len(detectados), 3) + with self.assertRaises(ValueError): + Historico.validar(dict(sensibilidade_pir='maxima')) + 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/lib/guia.ts b/web/src/lib/guia.ts index 32b87dc..06269b3 100644 --- a/web/src/lib/guia.ts +++ b/web/src/lib/guia.ts @@ -279,6 +279,10 @@ export const TOPICOS: Record = { onde: 'Eventos › Modo presença', resumo: 'O PIR liga a câmera do celular quando alguém aparece e acorda o Carinha. Sem ninguém pelo tempo escolhido, a câmera desliga, ele dorme e fica em silêncio. Na madrugada silenciosa o PIR não acorda.', + itens: [ + { emoji: '🎚️', nome: 'Sensibilidade do sensor', texto: 'O HC-SR505 não tem botão de ajuste e dispara por pouca coisa. A bolinha do Ao vivo sempre mostra o sensor, mas só conta como presença (evento, foto, acordar, câmera) o que se confirma. Baixa: 3 disparos em 90 s ou alguém se mexendo por 20 s. Média (padrão): 2 disparos em 1 min ou 12 s. Alta: qualquer disparo.' }, + { emoji: '🩹', nome: 'Ajuste físico', texto: 'Para cortar ainda mais, cubra parte da lente branca com fita (diminui o campo de visão) ou vire o sensor para longe de janelas, ar-condicionado e da cama.' }, + ], }, tela: { titulo: 'Tela e personalidade', diff --git a/web/src/lib/historico.svelte.ts b/web/src/lib/historico.svelte.ts index fc803b6..c38229c 100644 --- a/web/src/lib/historico.svelte.ts +++ b/web/src/lib/historico.svelte.ts @@ -13,7 +13,7 @@ export interface EventoHistorico { } export interface StatusHistorico { - config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number }; + config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number; sensibilidade_pir?: 'alta' | 'media' | 'baixa' }; presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number }; pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null }; eventos: EventoHistorico[]; diff --git a/web/src/telas/Eventos.svelte b/web/src/telas/Eventos.svelte index fb4c313..ebb0a52 100644 --- a/web/src/telas/Eventos.svelte +++ b/web/src/telas/Eventos.svelte @@ -56,6 +56,12 @@ aberta = ev; modal = true; } + // O HC-SR505 não tem ajuste físico: a sensibilidade é quantos disparos confirmam alguém de verdade + const SENS_PIR = [ + { valor: 'baixa', nome: 'Baixa', descricao: '3 disparos em 90 s ou alguém se mexendo por 20 s' }, + { valor: 'media', nome: 'Média', descricao: '2 disparos em 1 min ou alguém se mexendo por 12 s' }, + { valor: 'alta', nome: 'Alta', descricao: 'qualquer disparo conta como presença' }, + ] as const;
@@ -132,6 +138,21 @@ historico.configurar({ ausencia: v })} /> + s.valor === (st?.config.sensibilidade_pir ?? 'media'))?.descricao ?? ''}> + {#snippet acessorio()} + + {#each SENS_PIR as s (s.valor)} + + {/each} + + {/snippet} + @@ -306,4 +327,25 @@ top: calc(var(--topo-altura) + var(--e-5)); } } + .segmentos { + display: inline-flex; + padding: 2px; + border-radius: var(--raio-sm); + background: var(--superficie-3); + } + .segmentos button { + min-height: 28px; + padding: 0 var(--e-2); + border: 0; + border-radius: calc(var(--raio-sm) - 2px); + background: transparent; + color: var(--texto-2); + font: 500 var(--t-xs) var(--fonte); + cursor: pointer; + } + .segmentos button.ativo { + background: var(--superficie); + color: var(--texto); + box-shadow: var(--sombra); + }