feat(ao-vivo): indicador ao vivo do sensor PIR
Cada disparo do PIR vira um evento SSE 'pir' (mesmo dentro do intervalo do historico) e o painel mostra 'presenca agora' por 8 s em Ao vivo e no Historico; /estado inclui o estado do PIR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -192,6 +192,7 @@ 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.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:
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro)
|
dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro)
|
||||||
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"):
|
||||||
|
dados["pir"] = dict(s.historico.pir)
|
||||||
return dados
|
return dados
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { estado } from '../estado.svelte';
|
||||||
|
// Selo ao vivo do PIR: "Presença agora" por 8 s depois de cada disparo (o HC-SR505 segura ~8 s).
|
||||||
|
const seg = $derived(estado.pir?.ultimo ? Math.max(0, Math.round((estado.agora - estado.pir.ultimo * 1000) / 1000)) : null);
|
||||||
|
const agora = $derived(seg !== null && seg < 8);
|
||||||
|
const texto = $derived(
|
||||||
|
!estado.pir?.disponivel ? 'sensor indisponível'
|
||||||
|
: agora ? 'presença agora'
|
||||||
|
: seg === null ? 'aguardando presença'
|
||||||
|
: seg < 60 ? `último há ${seg} s` : seg < 3600 ? `último há ${Math.round(seg / 60)} min` : `último há ${Math.round(seg / 3600)} h`,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="pir" class:agora class:off={!estado.pir?.disponivel} aria-live="polite">
|
||||||
|
<span class="ponto" aria-hidden="true"></span>{texto}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pir {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--raio-pill);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--superficie-2);
|
||||||
|
color: var(--texto-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ponto {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
.agora {
|
||||||
|
background: var(--ok-suave);
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.agora .ponto {
|
||||||
|
animation: pulso 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.off {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
@keyframes pulso {
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 5px rgb(52 199 89 / 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -34,6 +34,8 @@ class Estado {
|
|||||||
treinamento = $state({ ocupado: false, erro: '' });
|
treinamento = $state({ ocupado: false, erro: '' });
|
||||||
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. */
|
||||||
|
pir = $state<{ disponivel: boolean; ultimo: number | null } | 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());
|
||||||
|
|
||||||
@@ -106,6 +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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tratadores: Record<string, (d: any) => void> = {
|
const tratadores: Record<string, (d: any) => void> = {
|
||||||
@@ -139,6 +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 }),
|
||||||
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');
|
||||||
@@ -165,7 +169,7 @@ function abrirEventos() {
|
|||||||
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
||||||
};
|
};
|
||||||
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
||||||
'reacao', 'treinamento', 'historico']) {
|
'reacao', 'treinamento', 'historico', 'pir']) {
|
||||||
fonte.addEventListener(tipo, (e) => {
|
fonte.addEventListener(tipo, (e) => {
|
||||||
const dados = JSON.parse((e as MessageEvent).data);
|
const dados = JSON.parse((e as MessageEvent).data);
|
||||||
tratadores[tipo]?.(dados);
|
tratadores[tipo]?.(dados);
|
||||||
|
|||||||
@@ -81,4 +81,5 @@ export interface EstadoServidor {
|
|||||||
reacao?: Reacao | null;
|
reacao?: Reacao | null;
|
||||||
treinamento?: { ocupado: boolean; erro: string };
|
treinamento?: { ocupado: boolean; erro: string };
|
||||||
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
|
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
|
||||||
|
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
Brain, Camera, Download, Eye, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
|
Brain, Camera, Download, Eye, Footprints, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { postar } from '../lib/api';
|
import { postar } from '../lib/api';
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
import Grupo from '../lib/componentes/Grupo.svelte';
|
import Grupo from '../lib/componentes/Grupo.svelte';
|
||||||
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
||||||
import Linha from '../lib/componentes/Linha.svelte';
|
import Linha from '../lib/componentes/Linha.svelte';
|
||||||
|
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||||
import { estado } from '../lib/estado.svelte';
|
import { estado } from '../lib/estado.svelte';
|
||||||
import { Rastreamento } from '../lib/rastreamento.svelte';
|
import { Rastreamento } from '../lib/rastreamento.svelte';
|
||||||
@@ -181,7 +182,10 @@
|
|||||||
</Grupo>
|
</Grupo>
|
||||||
|
|
||||||
<Grupo titulo="Acontecimentos">
|
<Grupo titulo="Acontecimentos">
|
||||||
<Linha icone={History} cor="#34c759" rotulo="Histórico de detecções" detalhe="PIR, movimento e som, com foto" href="#/historico" />
|
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
|
||||||
|
{#snippet acessorio()}<PresencaPir />{/snippet}
|
||||||
|
</Linha>
|
||||||
|
<Linha icone={History} cor="#30b0c7" rotulo="Histórico de detecções" detalhe="PIR, movimento e som, com foto" href="#/historico" />
|
||||||
{#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)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
||||||
import Linha from '../lib/componentes/Linha.svelte';
|
import Linha from '../lib/componentes/Linha.svelte';
|
||||||
import Modal from '../lib/componentes/Modal.svelte';
|
import Modal from '../lib/componentes/Modal.svelte';
|
||||||
|
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||||
import Slider from '../lib/componentes/Slider.svelte';
|
import Slider from '../lib/componentes/Slider.svelte';
|
||||||
import { estado } from '../lib/estado.svelte';
|
import { estado } from '../lib/estado.svelte';
|
||||||
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
|
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
const pirTexto = $derived.by(() => {
|
const pirTexto = $derived.by(() => {
|
||||||
if (!st) return '';
|
if (!st) return '';
|
||||||
if (!st.config.pir) return 'Desligado';
|
if (!st.config.pir) return 'Desligado';
|
||||||
if (st.pir.disponivel) return `Pino 11 · ${st.pir.ultimo ? 'último disparo ' + quando(st.pir.ultimo) : 'aguardando presença'}`;
|
if (st.pir.disponivel) return 'Pino 11 · HC-SR505';
|
||||||
return st.pir.erro || 'Indisponível neste servidor';
|
return st.pir.erro || 'Indisponível neste servidor';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
<Grupo titulo="Sensores">
|
<Grupo titulo="Sensores">
|
||||||
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor PIR da placa" detalhe={pirTexto}>
|
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor PIR da placa" detalhe={pirTexto}>
|
||||||
{#snippet acessorio()}
|
{#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 })} />
|
<Interruptor rotulo="Sensor PIR da placa" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Linha>
|
</Linha>
|
||||||
|
|||||||
Reference in New Issue
Block a user