fix(pir): acompanha o nível do pino em vez de só a borda de subida
O HC-SR505 segura a saída em alto ~8 s e rearma a cada movimento; com alguém na frente o pino ficava alto direto e passar a mão não gerava borda nova, então o painel parecia não detectar nada. Agora o gpiomon escuta as duas bordas, o nível inicial vem do gpioget e o status/SSE levam `ativo`. A bolinha do Ao vivo fica acesa enquanto o pino está alto, e o modo presença não manda dormir com o sensor ainda segurando. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+33
-12
@@ -48,7 +48,8 @@ class Historico:
|
|||||||
pass
|
pass
|
||||||
self.ultimo = {} # tipo -> horário do último evento (intervalo mínimo)
|
self.ultimo = {} # tipo -> horário do último evento (intervalo mínimo)
|
||||||
self.pedido = None # (id do evento, monotonic) aguardando foto do app
|
self.pedido = None # (id do evento, monotonic) aguardando foto do app
|
||||||
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)
|
||||||
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)')
|
||||||
@@ -119,13 +120,30 @@ class Historico:
|
|||||||
if not (self.config['presenca'] and self.config['pir'] and self.pir['disponivel']):
|
if not (self.config['presenca'] and self.config['pir'] and self.pir['disponivel']):
|
||||||
return False
|
return False
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
if self.pir['ativo']: # sensor ainda segurando: a pessoa continua ali
|
||||||
|
self.presenca['ultimo'] = agora
|
||||||
|
return False
|
||||||
if self.presenca['estado'] != 'presente' or agora - self.presenca['ultimo'] < self.config['ausencia'] * 60:
|
if self.presenca['estado'] != 'presente' or agora - self.presenca['ultimo'] < self.config['ausencia'] * 60:
|
||||||
return False
|
return False
|
||||||
self.presenca.update(estado='ausente', desde=agora)
|
self.presenca.update(estado='ausente', desde=agora)
|
||||||
self.ao_ausencia()
|
self.ao_ausencia()
|
||||||
self.publicar('pir', dict(ultimo=self.pir['ultimo'], presenca=dict(self.presenca)))
|
self.avisar_pir()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def avisar_pir(self):
|
||||||
|
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."""
|
||||||
|
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')
|
||||||
|
|
||||||
def vigiar_ausencia(self):
|
def vigiar_ausencia(self):
|
||||||
while True:
|
while True:
|
||||||
time.sleep(10)
|
time.sleep(10)
|
||||||
@@ -240,7 +258,7 @@ class Historico:
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ PIR
|
# ------------------------------------------------------------------ PIR
|
||||||
def vigiar_pir(self):
|
def vigiar_pir(self):
|
||||||
"""Mantém um `gpiomon` rodando enquanto o PIR estiver ligado; cada borda de subida = presença."""
|
"""Mantém um `gpiomon` rodando enquanto o PIR estiver ligado e acompanha o nível do pino."""
|
||||||
while True:
|
while True:
|
||||||
if not self.config['pir'] or os.name != 'posix' or not shutil.which('gpiomon'):
|
if not self.config['pir'] or os.name != 'posix' or not shutil.which('gpiomon'):
|
||||||
self.pir.update(disponivel=False, erro='' if self.config['pir'] else 'desligado')
|
self.pir.update(disponivel=False, erro='' if self.config['pir'] else 'desligado')
|
||||||
@@ -251,25 +269,28 @@ class Historico:
|
|||||||
chip, _, linha = self.pir['linha'].partition(':')
|
chip, _, linha = self.pir['linha'].partition(':')
|
||||||
try:
|
try:
|
||||||
# pull-down: com o sensor desligado o pino não fica "flutuando" (disparos falsos)
|
# pull-down: com o sensor desligado o pino não fica "flutuando" (disparos falsos)
|
||||||
self.processo_pir = subprocess.Popen(['gpiomon', '-c', chip, '-b', 'pull-down', '-e', 'rising', linha],
|
lido = subprocess.run(['gpioget', '-c', chip, '-b', 'pull-down', '--numeric', linha],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
self.processo_pir = subprocess.Popen(['gpiomon', '-c', chip, '-b', 'pull-down', '-e', 'both', '-F', '%E', linha],
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
self.pir.update(disponivel=True, erro='')
|
self.pir.update(disponivel=True, erro='')
|
||||||
|
# nível de partida: alguém já na frente não gera borda de subida, mas acende a bolinha
|
||||||
|
self.nivel_pir(lido.stdout.strip() == '1', registrar=False)
|
||||||
inicio = time.monotonic()
|
inicio = time.monotonic()
|
||||||
for _ in self.processo_pir.stdout:
|
for linha_evento in self.processo_pir.stdout:
|
||||||
if not self.config['pir']:
|
if not self.config['pir']:
|
||||||
break
|
break
|
||||||
if time.monotonic() - inicio < 2: # o pino "assenta" ao ligar o pull-down
|
alto = linha_evento.strip() == 'rising'
|
||||||
continue
|
# o pino "assenta" ao ligar o pull-down: acompanha o nível, mas não registra evento
|
||||||
self.pir['ultimo'] = time.time()
|
self.nivel_pir(alto, registrar=time.monotonic() - inicio >= 2)
|
||||||
self.marcar_presenca(self.pir['ultimo'])
|
|
||||||
self.publicar('pir', dict(ultimo=self.pir['ultimo'], presenca=dict(self.presenca))) # todo disparo, ao vivo
|
|
||||||
self.registrar('pir', 'presença')
|
|
||||||
erro = (self.processo_pir.stderr.read() or '').strip()[:200]
|
erro = (self.processo_pir.stderr.read() or '').strip()[:200]
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
erro = str(e)
|
erro = str(e)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
erro = 'gpioget não respondeu'
|
||||||
finally:
|
finally:
|
||||||
self.parar_pir()
|
self.parar_pir()
|
||||||
self.pir.update(disponivel=False, erro=erro if self.config['pir'] else 'desligado')
|
self.pir.update(disponivel=False, ativo=False, erro=erro if self.config['pir'] else 'desligado')
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
def parar_pir(self):
|
def parar_pir(self):
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ class HistoricoTest(unittest.TestCase):
|
|||||||
self.h.marcar_presenca(3000)
|
self.h.marcar_presenca(3000)
|
||||||
self.assertFalse(self.h.checar_ausencia(9999))
|
self.assertFalse(self.h.checar_ausencia(9999))
|
||||||
|
|
||||||
|
def test_pir_segurando_conta_como_presenca(self):
|
||||||
|
avisos = []
|
||||||
|
self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados))
|
||||||
|
self.h.pir['disponivel'] = True
|
||||||
|
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')
|
||||||
|
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # pino ainda alto: não dorme
|
||||||
|
self.h.nivel_pir(False, agora=1000 + 10 * 60) # descida: só apaga a bolinha
|
||||||
|
self.assertFalse(self.h.pir['ativo'])
|
||||||
|
self.assertEqual(self.h.status()['total'], 1)
|
||||||
|
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_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)
|
||||||
|
|||||||
@@ -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; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
|
pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; 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, presenca: (f.pir as any).presenca };
|
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, 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; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, presenca: d.presenca }),
|
pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, 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');
|
||||||
|
|||||||
@@ -49,7 +49,10 @@
|
|||||||
|
|
||||||
const cfg = $derived(estado.appConfig);
|
const cfg = $derived(estado.appConfig);
|
||||||
// Pontinho do PIR sobre a imagem: verde pulsando nos 8 s depois de cada disparo.
|
// Pontinho do PIR sobre a imagem: verde pulsando nos 8 s depois de cada disparo.
|
||||||
const pirAtivo = $derived(!!estado.pir?.ultimo && estado.agora - estado.pir.ultimo * 1000 < 8000);
|
// aceso enquanto o pino do sensor está alto; servidor antigo (sem `ativo`) cai nos 8 s após o disparo
|
||||||
|
const pirAtivo = $derived(
|
||||||
|
estado.pir?.ativo ?? (!!estado.pir?.ultimo && estado.agora - estado.pir.ultimo * 1000 < 8000),
|
||||||
|
);
|
||||||
const transmitindo = $derived(!!cfg?.preview);
|
const transmitindo = $derived(!!cfg?.preview);
|
||||||
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user