/** * Estado do painel: uma foto inicial (/estado) + um único fluxo SSE (/eventos). * Nenhuma tela faz polling; todas leem daqui. */ import { ErroApi, pedir, quandoNegado, urlEventos } from './api'; import { avisos } from './avisos.svelte'; import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria, Vida } from './tipos'; export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token'; export interface ItemFeed { id: number; quando: number; tipo: string; texto: string; } class Estado { conexao = $state('conectando'); versao = $state(''); autenticacao = $state(false); estop = $state({ ativo: false, desde: null as number | null }); aparencia = $state<{ rosto: string; cor: [number, number, number]; formato?: string }>({ rosto: 'carinha', cor: [92, 214, 255], formato: 'padrao' }); formatos = $state([]); gestos = $state([]); rostos = $state([]); app = $state(null); appRecebidoEm = $state(0); appConfig = $state(null); motores = $state(null); conversa = $state(null); ia = $state(null); reacao = $state(null); treinamento = $state({ ocupado: false, erro: '' }); api = $state(null); vida = $state(null); feed = $state([]); /** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */ 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()); celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000); /** Quando o celular mandou o último sinal (0 = nunca, nesta sessão nem no servidor). */ ultimoSinalEm = $state(0); /** Bateria do último sinal recebido, mesmo com o celular offline. */ bateria = $derived.by(() => { const b = this.app?.estado?.bateria; const n = typeof b === 'number' ? b : b?.nivel; return { nivel: typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null, carregando: typeof b === 'object' && !!b?.carregando, }; }); bateriaBaixa = $derived(this.bateria.nivel !== null && this.bateria.nivel <= 15 && !this.bateria.carregando); } export const estado = new Estado(); type Ouvinte = (dados: any) => void; const ouvintes = new Map>(); /** Assina um tipo de evento do servidor; devolve a função para cancelar. */ export function ouvir(tipo: string, fn: Ouvinte): () => void { if (!ouvintes.has(tipo)) ouvintes.set(tipo, new Set()); ouvintes.get(tipo)!.add(fn); return () => ouvintes.get(tipo)!.delete(fn); } // Avisa uma vez em 15% e outra em 5% (sem carregador); zera quando volta a carregar. let avisouEm: number | null = null; function avisarBateria() { const { nivel, carregando } = estado.bateria; if (nivel === null) return; if (carregando || nivel > 20) { avisouEm = null; return; } const limite = nivel <= 5 ? 5 : nivel <= 15 ? 15 : null; if (limite !== null && avisouEm !== limite) { avisouEm = limite; avisos.mostrar(`Bateria do celular em ${nivel}%. Ligue o carregador ou o Carinha vai desligar.`, 'erro'); noFeed('estop', `Bateria do celular em ${nivel}%`); } } let seqFeed = 0; function noFeed(tipo: string, texto: string) { estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40); } function aplicarFoto(f: EstadoServidor) { estado.versao = f.versao; estado.autenticacao = f.autenticacao; estado.estop = f.estop; estado.aparencia = f.aparencia; estado.gestos = f.gestos; estado.rostos = f.rostos; estado.formatos = f.formatos ?? []; if (f.app) { estado.app = f.app; estado.appRecebidoEm = Date.now(); estado.ultimoSinalEm = typeof f.app.idade === 'number' ? Date.now() - f.app.idade * 1000 : 0; } if (f.app_config) estado.appConfig = f.app_config; if (f.motores) estado.motores = f.motores; if (f.conversa) estado.conversa = f.conversa; if (f.ia) estado.ia = f.ia; estado.reacao = f.reacao ?? null; if (f.treinamento) estado.treinamento = f.treinamento; if (f.api) estado.api = f.api; if (f.vida) estado.vida = f.vida; 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 void> = { expressao: (d: { ordem: string }) => { // 'humor x' é o modo vida avisando o celular; aparece no cartão Vida, não no feed if (d.ordem !== 'calma' && !d.ordem.startsWith('humor ')) noFeed('expressao', 'Expressão: ' + d.ordem); }, telemetria: (d: Telemetria) => { estado.app = d; estado.appRecebidoEm = estado.ultimoSinalEm = Date.now(); avisarBateria(); }, app_config: (d: AppConfig) => (estado.appConfig = d), aparencia: (d) => (estado.aparencia = d), motores: (d: Motores) => (estado.motores = d), estop: (d) => { estado.estop = d; noFeed('estop', d.ativo ? 'E-Stop acionado' : 'Motores rearmados'); }, conversa: (d: Conversa) => { const nova = d.ultima && d.ultima.id !== estado.conversa?.ultima?.id; estado.conversa = d; if (nova) noFeed('conversa', 'Carinha: ' + d.ultima!.texto); }, ia: (d: IA) => { const nova = d.ultima && d.ultima.horario !== estado.ia?.ultima?.horario; estado.ia = d; if (nova) noFeed('ia', `IA local: ${d.ultima!.evento} → ${d.ultima!.gesto}`); }, reacao: (d: Reacao | null) => { estado.reacao = d; if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome); }, treinamento: (d) => (estado.treinamento = d), vida: (d: Vida) => { const novo = d.diario[0]; if (novo && novo.horario !== estado.vida?.diario[0]?.horario && novo.tipo !== 'acao') noFeed('vida', d.emoji + ' ' + novo.texto); estado.vida = d; }, 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'); }, }; let fonte: EventSource | null = null; let primeiraAbertura = true; export async function carregarFoto() { aplicarFoto(await pedir('/estado')); } function abrirEventos() { fonte?.close(); fonte = new EventSource(urlEventos()); fonte.addEventListener('pronto', async () => { estado.conexao = 'aberta'; // Ao reconectar, ressincroniza: pode ter perdido eventos além do buffer do servidor. if (!primeiraAbertura) carregarFoto().catch(() => {}); primeiraAbertura = false; }); fonte.onerror = () => { if (estado.conexao !== 'token') estado.conexao = 'caiu'; }; for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia', 'reacao', 'treinamento', 'historico', 'pir', 'vida']) { fonte.addEventListener(tipo, (e) => { const dados = JSON.parse((e as MessageEvent).data); tratadores[tipo]?.(dados); ouvintes.get(tipo)?.forEach((fn) => fn(dados)); }); } } export async function conectar() { estado.conexao = 'conectando'; try { await carregarFoto(); } catch (e) { if (e instanceof ErroApi && e.status === 401) { estado.conexao = 'token'; return; } estado.conexao = 'caiu'; setTimeout(conectar, 3000); return; } abrirEventos(); } quandoNegado(() => { estado.conexao = 'token'; fonte?.close(); fonte = null; }); setInterval(() => (estado.agora = Date.now()), 1000);