feat(presenca): sensibilidade do PIR por confirmação (padrão média)

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 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-10 19:05:20 -03:00
co-authored by Claude Opus 5.5
parent 6e80332a8d
commit 7b0a05435f
5 changed files with 126 additions and 8 deletions
+46 -7
View File
@@ -21,7 +21,11 @@ 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, dias=3, 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 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
@@ -51,6 +55,8 @@ class Historico:
self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None, 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) ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma)
self.processo_pir = None 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: 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() self.limpar_antigos()
@@ -82,6 +88,9 @@ class Historico:
elif chave == 'dias': elif chave == 'dias':
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30: if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30:
raise ValueError('Guardar de 1 a 30 dias') 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': 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')
@@ -134,21 +143,51 @@ class Historico:
self.publicar('pir', dict(ultimo=self.pir['ultimo'], ativo=self.pir['ativo'], presenca=dict(self.presenca))) 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): 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() agora = agora if agora is not None else time.time()
self.pir['ativo'] = alto self.pir['ativo'] = alto
if alto: if alto:
self.pir['ultimo'] = agora self.pir['ultimo'] = agora
self.marcar_presenca(agora)
self.avisar_pir() self.avisar_pir()
if alto and registrar: if not alto:
self.registrar('pir', 'presença') 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): def vigiar_ausencia(self):
passo = 0
while True: while True:
time.sleep(10) time.sleep(1)
passo += 1
try: try:
self.checar_ausencia() self.checar_pir()
if passo % 10 == 0:
self.checar_ausencia()
except Exception: except Exception:
pass pass
+33
View File
@@ -97,6 +97,7 @@ class HistoricoTest(unittest.TestCase):
avisos = [] avisos = []
self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados)) self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados))
self.h.pir['disponivel'] = True 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.h.nivel_pir(True, agora=1000) # subida: evento + bolinha acesa
self.assertTrue(self.h.pir['ativo']) self.assertTrue(self.h.pir['ativo'])
self.assertEqual(self.h.status()['eventos'][0]['tipo'], 'pir') 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.assertTrue(self.h.checar_ausencia(1000 + 15 * 60 + 1))
self.assertIn(('pir', True), [(t, d.get('ativo')) for t, d in avisos]) 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): 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)
+4
View File
@@ -279,6 +279,10 @@ export const TOPICOS: Record<string, TopicoGuia> = {
onde: 'Eventos › Modo presença', onde: 'Eventos › Modo presença',
resumo: 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.', '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: { tela: {
titulo: 'Tela e personalidade', titulo: 'Tela e personalidade',
+1 -1
View File
@@ -13,7 +13,7 @@ export interface EventoHistorico {
} }
export interface StatusHistorico { 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 }; 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[];
+42
View File
@@ -56,6 +56,12 @@
aberta = ev; aberta = ev;
modal = true; 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;
</script> </script>
<div class="eventos"> <div class="eventos">
@@ -132,6 +138,21 @@
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por"> <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 })} /> <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> </Linha>
<Linha icone={Footprints} cor="#ff9500" rotulo="Sensibilidade do sensor" detalhe={SENS_PIR.find((s) => s.valor === (st?.config.sensibilidade_pir ?? 'media'))?.descricao ?? ''}>
{#snippet acessorio()}
<span class="segmentos" role="radiogroup" aria-label="Sensibilidade do sensor de presença">
{#each SENS_PIR as s (s.valor)}
<button
role="radio"
aria-checked={(st?.config.sensibilidade_pir ?? 'media') === s.valor}
class:ativo={(st?.config.sensibilidade_pir ?? 'media') === s.valor}
disabled={!st}
onclick={() => historico.configurar({ sensibilidade_pir: s.valor })}>{s.nome}</button
>
{/each}
</span>
{/snippet}
</Linha>
</Grupo> </Grupo>
<Grupo titulo="O que registrar" ajuda="eventos"> <Grupo titulo="O que registrar" ajuda="eventos">
@@ -306,4 +327,25 @@
top: calc(var(--topo-altura) + var(--e-5)); 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);
}
</style> </style>