Novo módulo vida.py: energia, alegria, companhia e diversão caem com o tempo e sobem com presença (PIR), movimento, conversa e carinho. Delas sai o humor e, do humor, os atos: gestos, cochilo quando cansado, dormir à noite sem ninguém por perto, acenar pedindo atenção quando carente com gente perto, tocar reações da biblioteca e inventar figurinhas novas com o Gemini (no máximo 3 por dia, 90 min entre elas). - O celular recebe "humor x" e escolhe os gestos soltos por ele. - Toque na tela do celular conta como carinho/cutucada (/vida/cuidar). - Respeita E-Stop e o modo presença (ninguém em casa = quieto). - Painel: cartão Vida no Ao vivo com humor, barras, cuidados, ritmo e diário; liga por padrão. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
7.7 KiB
TypeScript
212 lines
7.7 KiB
TypeScript
/**
|
|
* 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<Conexao>('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<string[]>([]);
|
|
gestos = $state<string[]>([]);
|
|
rostos = $state<string[]>([]);
|
|
app = $state<Telemetria | null>(null);
|
|
appRecebidoEm = $state(0);
|
|
appConfig = $state<AppConfig | null>(null);
|
|
motores = $state<Motores | null>(null);
|
|
conversa = $state<Conversa | null>(null);
|
|
ia = $state<IA | null>(null);
|
|
reacao = $state<Reacao | null>(null);
|
|
treinamento = $state({ ocupado: false, erro: '' });
|
|
api = $state<EstadoServidor['api'] | null>(null);
|
|
vida = $state<Vida | null>(null);
|
|
feed = $state<ItemFeed[]>([]);
|
|
/** 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<string, Set<Ouvinte>>();
|
|
|
|
/** 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<string, (d: any) => 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<EstadoServidor>('/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);
|