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:
+46
-7
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user