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;
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 === '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 (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
+1
View File
@@ -613,6 +613,7 @@ if (MODO_PAINEL) {
const m = e.data;
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 === '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 (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"
"@
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)' }
Write-Host '== Conferindo'
+33 -12
View File
@@ -48,7 +48,8 @@ class Historico:
pass
self.ultimo = {} # tipo -> horário do último evento (intervalo mínimo)
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
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)')
@@ -119,13 +120,30 @@ class Historico:
if not (self.config['presenca'] and self.config['pir'] and self.pir['disponivel']):
return False
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:
return False
self.presenca.update(estado='ausente', desde=agora)
self.ao_ausencia()
self.publicar('pir', dict(ultimo=self.pir['ultimo'], presenca=dict(self.presenca)))
self.avisar_pir()
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):
while True:
time.sleep(10)
@@ -240,7 +258,7 @@ class Historico:
# ------------------------------------------------------------------ PIR
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:
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')
@@ -251,25 +269,28 @@ class Historico:
chip, _, linha = self.pir['linha'].partition(':')
try:
# 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)
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()
for _ in self.processo_pir.stdout:
for linha_evento in self.processo_pir.stdout:
if not self.config['pir']:
break
if time.monotonic() - inicio < 2: # o pino "assenta" ao ligar o pull-down
continue
self.pir['ultimo'] = time.time()
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')
alto = linha_evento.strip() == 'rising'
# o pino "assenta" ao ligar o pull-down: acompanha o nível, mas não registra evento
self.nivel_pir(alto, registrar=time.monotonic() - inicio >= 2)
erro = (self.processo_pir.stderr.read() or '').strip()[:200]
except OSError as e:
erro = str(e)
except subprocess.TimeoutExpired:
erro = 'gpioget não respondeu'
finally:
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)
def parar_pir(self):
+14
View File
@@ -93,6 +93,20 @@ class HistoricoTest(unittest.TestCase):
self.h.marcar_presenca(3000)
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):
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
b = self.h.registrar('som', agora=1)
+9 -1
View File
@@ -7,7 +7,8 @@
import { onDestroy } from '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 pronto = $state(false);
@@ -20,6 +21,13 @@
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;
const cancelar = [
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);
feed = $state<ItemFeed[]>([]);
/** 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. */
agora = $state(Date.now());
@@ -108,7 +108,7 @@ function aplicarFoto(f: EstadoServidor) {
estado.reacao = f.reacao ?? null;
if (f.treinamento) estado.treinamento = f.treinamento;
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> = {
@@ -142,7 +142,7 @@ const tratadores: Record<string, (d: any) => void> = {
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
},
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) => {
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');
+46
View File
@@ -48,6 +48,11 @@
});
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 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">
<!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
<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}>
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
@@ -300,6 +311,41 @@
.espaco {
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 {
display: grid;
+1 -1
View File
@@ -249,7 +249,7 @@
</div>
<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>
</aside>
</div>
+1 -1
View File
@@ -70,7 +70,7 @@
<section class="bancada" aria-label="Prévia">
<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="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span>
</div>