Compare commits

..
Author SHA1 Message Date
julioandClaude Opus 5.5 8d410dbf24 fix(ao-vivo): rosto do painel dorme e acorda junto com o celular
O celular adormece sozinho (silêncio sem movimento) e acorda com som,
movimento ou toque; isso vem na telemetria, não no SSE de ordens, e a
prévia do painel continuava acordada. Ao vivo e mini-rosto agora seguem
o `dormindo` da telemetria; as prévias de edição (Rosto, Reações) não.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:08:25 -03:00
julioandClaude Opus 5.5 fd6a14054d 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>
2026-10-06 04:04:41 -03:00
julioandClaude Opus 5.5 784042335c fix(deploy): tira o BOM do script enviado à placa
O PowerShell 5.1 manda BOM no stdin do ssh e o bash lia "<BOM>set -e"
como comando inexistente, rodando a instalação sem parar no primeiro erro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:57:53 -03:00
julioandClaude Opus 5.5 cf54a93b53 feat(ao-vivo): pontinho do PIR sobre a imagem do palco
Verde pulsando por 8 s depois de cada disparo, cinza sem presença;
some quando o sensor não está disponível.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:56:48 -03:00
10 changed files with 111 additions and 19 deletions
+1
View File
@@ -613,6 +613,7 @@ if (MODO_PAINEL) {
const m = e.data; const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem); if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config); else if (m.tipo === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto }); else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
}); });
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000); } else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
+1
View File
@@ -613,6 +613,7 @@ if (MODO_PAINEL) {
const m = e.data; const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem); if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config); else if (m.tipo === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto }); else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
}); });
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000); } else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
+2 -1
View File
@@ -92,7 +92,8 @@ systemctl restart carinha-pc
echo "backup em `$backup" echo "backup em `$backup"
"@ "@
Write-Host '== Instalando na placa' Write-Host '== Instalando na placa'
$script -replace "`r", '' | & ssh $alvo 'bash -s' # O PowerShell 5.1 põe BOM no stdin do ssh; sem tirar, o bash lê "<BOM>set -e" e não para no primeiro erro.
$script -replace "`r", '' | & ssh $alvo "sed '1s/^\xEF\xBB\xBF//' | bash -s"
if ($LASTEXITCODE) { throw 'instalação na placa falhou (o backup foi feito antes de qualquer remoção)' } if ($LASTEXITCODE) { throw 'instalação na placa falhou (o backup foi feito antes de qualquer remoção)' }
Write-Host '== Conferindo' Write-Host '== Conferindo'
+33 -12
View File
@@ -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):
+14
View File
@@ -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)
+9 -1
View File
@@ -7,7 +7,8 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { estado, ouvir } from '../estado.svelte'; import { estado, ouvir } from '../estado.svelte';
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props(); /** espelharSono: dorme/acorda junto com o celular (ele adormece sozinho no silêncio). */
let { titulo = 'Prévia do rosto', espelharSono = true }: { titulo?: string; espelharSono?: boolean } = $props();
let quadro: HTMLIFrameElement; let quadro: HTMLIFrameElement;
let pronto = $state(false); let pronto = $state(false);
@@ -20,6 +21,13 @@
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) }); if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
} }
// Sono que o celular decidiu sozinho (silêncio, toque, barulho) não passa pelo SSE de ordens:
// vem na telemetria. Sem celular online, a prévia fica como está.
const dormindoNoCelular = $derived(estado.celularOnline ? !!estado.app?.estado?.dormindo : null);
$effect(() => {
if (espelharSono && pronto && dormindoNoCelular !== null) enviar({ tipo: 'sono', dormindo: dormindoNoCelular });
});
let ultimaConversa = estado.conversa?.ultima?.id; let ultimaConversa = estado.conversa?.ultima?.id;
const cancelar = [ const cancelar = [
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })), ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
+3 -3
View File
@@ -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');
+46
View File
@@ -48,6 +48,11 @@
}); });
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.
// 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' });
@@ -70,6 +75,12 @@
<div class="ao-vivo"> <div class="ao-vivo">
<!-- Palco fixo: a câmera continua visível enquanto a lista rola --> <!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
<section class="palco" aria-label="Câmera e rosto"> <section class="palco" aria-label="Câmera e rosto">
{#if estado.pir?.disponivel}
<span class="ponto-pir" class:ativo={pirAtivo} title="Sensor de presença (PIR)" aria-live="polite">
<span class="luz" aria-hidden="true"></span>
PIR{pirAtivo ? ' · presença' : ''}
</span>
{/if}
<div class="quadro camera" class:mini={rostoGrande}> <div class="quadro camera" class:mini={rostoGrande}>
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video> <video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
@@ -300,6 +311,41 @@
.espaco { .espaco {
flex: 1; flex: 1;
} }
.ponto-pir {
position: absolute;
z-index: 3;
left: var(--e-3);
top: var(--e-3);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 7px;
border-radius: var(--raio-pill);
background: rgb(0 0 0 / 0.55);
color: #c7cdd4;
font: 600 11px var(--fonte);
letter-spacing: 0.04em;
pointer-events: none;
transition: color var(--transicao);
}
.ponto-pir .luz {
width: 10px;
height: 10px;
border-radius: 50%;
background: #6b7280;
transition: background var(--transicao);
}
.ponto-pir.ativo {
color: #b8f5c9;
}
.ponto-pir.ativo .luz {
background: #34c759;
animation: pulsoPir 1s ease-in-out infinite;
}
@keyframes pulsoPir {
0%, 100% { box-shadow: 0 0 0 0 rgb(52 199 89 / 0.7); }
50% { box-shadow: 0 0 0 7px rgb(52 199 89 / 0); }
}
/* ---------- lista */ /* ---------- lista */
.lista { .lista {
display: grid; display: grid;
+1 -1
View File
@@ -249,7 +249,7 @@
</div> </div>
<aside class="lateral"> <aside class="lateral">
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" /></div> <div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" espelharSono={false} /></div>
<p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p> <p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p>
</aside> </aside>
</div> </div>
+1 -1
View File
@@ -70,7 +70,7 @@
<section class="bancada" aria-label="Prévia"> <section class="bancada" aria-label="Prévia">
<div class="celular"> <div class="celular">
<div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" /></div> <div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" espelharSono={false} /></div>
<span class="camera-frontal" aria-hidden="true"></span> <span class="camera-frontal" aria-hidden="true"></span>
<span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span> <span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span>
</div> </div>