diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html index 49be5e5..3b2acaa 100644 --- a/android/app/assets/carinha.html +++ b/android/app/assets/carinha.html @@ -359,6 +359,20 @@ const r = { const SEQS = sequencias(); const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo", "surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"]; +/* Modo vida (servidor): o humor do bichinho escolhe os gestos soltos entre um ato e outro. */ +const LIVRES_HUMOR = { + feliz: ["feliz", "alegre", "piscadinha", "coracao", "olhar_esq", "olhar_dir"], + tranquilo: ["olhar_esq", "olhar_dir", "feliz", "piscadinha", "olhar_baixo"], + entediado: ["olhar_esq", "olhar_dir", "olhar_baixo", "pensar"], + carente: ["olhar_baixo", "triste", "olhar_esq", "olhar_dir"], + triste: ["triste", "olhar_baixo", "olhar_baixo"], + cansado: ["olhar_baixo", "piscar2"], +}; +let HUMOR = "normal"; +function gestoSolto() { + const lista = LIVRES_HUMOR[HUMOR] || LIVRES; + return lista[Math.floor(Math.random() * lista.length)]; +} function proximo() { if (!r.fila.length) { r.kf = null; return; } @@ -527,6 +541,8 @@ function trata(ordem) { if (v.length === 3 && v.every((n) => !isNaN(n))) { CIANO.splice(0, 3, ...v); r.cor = v; } + } else if (ordem.indexOf("humor ") === 0) { + HUMOR = ordem.slice(6).trim(); } else if (ordem === "dormir" || ordem === "sono") { r.dormindo = true; } else if (ordem === "acordar") { @@ -627,9 +643,10 @@ if (MODO_PAINEL) { if (!inicio) return; const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t; inicio = null; - if (r.dormindo) { trata('acordar'); return; } - if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; } + if (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; } + if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); cuidar('carinho'); return; } if (dur < 350 && Math.hypot(dx, dy) < 15) { + cuidar('cutucar'); animar('surpreso'); setTimeout(() => animar('feliz'), 700); } @@ -736,6 +753,15 @@ function relatarSom(evento, n) { if (evento !== "adormeceu") registrarEvento("som", evento, n); } +/* Toque na tela vira cuidado no modo vida (carinho/cutucada). Só o celular conta. */ +function cuidar(acao) { + if (new URLSearchParams(location.search).get("aparelho") !== "1") return; + try { + fetch("/vida/cuidar", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ acao }) }).catch(() => {}); + } catch (e) {} +} + /* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */ function registrarEvento(tipo, evento, nivel) { if (new URLSearchParams(location.search).get("aparelho") !== "1") return; @@ -1105,8 +1131,10 @@ function laco(t) { animar("piscar"); proxPiscar = t + 1600 + Math.random() * 3500; } else if (AUTO && t >= proxGesto) { - animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); - proxGesto = t + 7000 + Math.random() * 8000; + animar(gestoSolto()); + // cansado e triste mexem menos; feliz, mais + const ritmo = { cansado: 1.8, triste: 1.5, feliz: 0.8 }[HUMOR] || 1; + proxGesto = t + (7000 + Math.random() * 8000) * ritmo; } } else if (t >= proxGesto) { proxGesto = t + 7000 + Math.random() * 8000; diff --git a/carinha.html b/carinha.html index 49be5e5..3b2acaa 100644 --- a/carinha.html +++ b/carinha.html @@ -359,6 +359,20 @@ const r = { const SEQS = sequencias(); const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo", "surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"]; +/* Modo vida (servidor): o humor do bichinho escolhe os gestos soltos entre um ato e outro. */ +const LIVRES_HUMOR = { + feliz: ["feliz", "alegre", "piscadinha", "coracao", "olhar_esq", "olhar_dir"], + tranquilo: ["olhar_esq", "olhar_dir", "feliz", "piscadinha", "olhar_baixo"], + entediado: ["olhar_esq", "olhar_dir", "olhar_baixo", "pensar"], + carente: ["olhar_baixo", "triste", "olhar_esq", "olhar_dir"], + triste: ["triste", "olhar_baixo", "olhar_baixo"], + cansado: ["olhar_baixo", "piscar2"], +}; +let HUMOR = "normal"; +function gestoSolto() { + const lista = LIVRES_HUMOR[HUMOR] || LIVRES; + return lista[Math.floor(Math.random() * lista.length)]; +} function proximo() { if (!r.fila.length) { r.kf = null; return; } @@ -527,6 +541,8 @@ function trata(ordem) { if (v.length === 3 && v.every((n) => !isNaN(n))) { CIANO.splice(0, 3, ...v); r.cor = v; } + } else if (ordem.indexOf("humor ") === 0) { + HUMOR = ordem.slice(6).trim(); } else if (ordem === "dormir" || ordem === "sono") { r.dormindo = true; } else if (ordem === "acordar") { @@ -627,9 +643,10 @@ if (MODO_PAINEL) { if (!inicio) return; const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t; inicio = null; - if (r.dormindo) { trata('acordar'); return; } - if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; } + if (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; } + if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); cuidar('carinho'); return; } if (dur < 350 && Math.hypot(dx, dy) < 15) { + cuidar('cutucar'); animar('surpreso'); setTimeout(() => animar('feliz'), 700); } @@ -736,6 +753,15 @@ function relatarSom(evento, n) { if (evento !== "adormeceu") registrarEvento("som", evento, n); } +/* Toque na tela vira cuidado no modo vida (carinho/cutucada). Só o celular conta. */ +function cuidar(acao) { + if (new URLSearchParams(location.search).get("aparelho") !== "1") return; + try { + fetch("/vida/cuidar", { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ acao }) }).catch(() => {}); + } catch (e) {} +} + /* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */ function registrarEvento(tipo, evento, nivel) { if (new URLSearchParams(location.search).get("aparelho") !== "1") return; @@ -1105,8 +1131,10 @@ function laco(t) { animar("piscar"); proxPiscar = t + 1600 + Math.random() * 3500; } else if (AUTO && t >= proxGesto) { - animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); - proxGesto = t + 7000 + Math.random() * 8000; + animar(gestoSolto()); + // cansado e triste mexem menos; feliz, mais + const ritmo = { cansado: 1.8, triste: 1.5, feliz: 0.8 }[HUMOR] || 1; + proxGesto = t + (7000 + Math.random() * 8000) * ritmo; } } else if (t >= proxGesto) { proxGesto = t + 7000 + Math.random() * 8000; diff --git a/deploy.ps1 b/deploy.ps1 index cde24c9..8c24a6c 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop # chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai). $arquivos = @( 'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py', - 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', + 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json', 'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js' ) diff --git a/kiosk-server.py b/kiosk-server.py index 74fd192..f42a086 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -41,6 +41,7 @@ from conversa import Conversa from treinamento import Treinamento from api_config import ConfigAPI from historico import Historico +from vida import Vida TRAVA = threading.Lock() @@ -70,7 +71,7 @@ ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador # Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar. PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"} -PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", +PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") @@ -237,7 +238,7 @@ class Handler(SimpleHTTPRequestHandler): def modulos(self): s = self.server - return [getattr(s, nome) for nome in ("api_config", "historico", "treinamento", "conversa", "cerebro", + return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "treinamento", "conversa", "cerebro", "braco", "servos", "controle") if hasattr(s, nome)] def estado(self): @@ -262,6 +263,8 @@ class Handler(SimpleHTTPRequestHandler): dados["api"] = s.api_config.status() if hasattr(s, "historico"): dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca)) + if hasattr(s, "vida"): + dados["vida"] = s.vida.status() return dados def do_GET(self): @@ -454,7 +457,7 @@ class Servidor(ThreadingHTTPServer): def ligar_eventos(servidor): - for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico"): + for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida"): if hasattr(servidor, nome): getattr(servidor, nome).publicar = EVENTOS.publicar @@ -479,10 +482,34 @@ def main(): servidor.cerebro.chave = servidor.token servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro) servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db') - servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar servidor.historico = Historico(Path.cwd()) + servidor.vida = vida = Vida(Path.cwd() / 'vida.json') + + def registrar_conversa(papel, texto): + servidor.treinamento.registrar(papel, texto) + if papel == 'user': + vida.estimulo('conversa') # alguém conversou com ele: companhia + servidor.conversa.memoria.on_registrar = registrar_conversa + + def celular_dormindo(): + resumo = servidor.controle.resumo() + return bool(resumo['estado'].get('dormindo')) if resumo['online'] else None + + def alguem_perto(): + h = servidor.historico + recente = max(h.pir.get('ultimo') or 0, h.ultimo.get('movimento', 0)) + return bool(h.pir.get('ativo')) or time.time() - recente < 120 + + vida.enfileirar = enfileirar + vida.treinamento = servidor.treinamento + vida.celular_dormindo = celular_dormindo + vida.celular_online = lambda: servidor.controle.resumo()['online'] + vida.alguem_perto = alguem_perto + # E-Stop parado, ou modo presença com ninguém em casa (dormindo em silêncio): não faz nada + vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente" def presenca(tipo): + vida.estimulo(tipo) # PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda if tipo == "pir": with servidor.controle.lock: diff --git a/tests/test_vida.py b/tests/test_vida.py new file mode 100644 index 0000000..5264a07 --- /dev/null +++ b/tests/test_vida.py @@ -0,0 +1,167 @@ +"""Modo vida (bichinho virtual): necessidades, sono, atos, figurinhas com limite e rotas HTTP.""" +import http.client +import importlib.util +import json +import random +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from vida import Vida # noqa: E402 + +spec = importlib.util.spec_from_file_location('kiosk_vida', ROOT / 'kiosk-server.py') +kiosk = importlib.util.module_from_spec(spec) +spec.loader.exec_module(kiosk) + +DIA = time.mktime((2026, 10, 6, 15, 0, 0, 0, 0, -1)) # 15h: longe da noite + + +class TreinoFalso: + def __init__(self): + self.pedidos, self.tocadas = [], [] + + def gerar_figurinha(self, assunto): + self.pedidos.append(assunto) + + def status(self): + return dict(reacoes=[dict(id=7, receita=dict(nome='Dança'), feedback=0, ativa=True)]) + + def testar(self, ident): + self.tocadas.append(ident) + + +class VidaTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.v = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False, rng=random.Random(1)) + self.ordens = [] + self.v.enfileirar = self.ordens.append + self.v.ultimo_tick = DIA + + def tearDown(self): + self.tmp.cleanup() + + def test_necessidades_caem_e_sono_recupera(self): + self.v.necessidades.update(energia=50, social=50, diversao=50) + self.v.passar_tempo(DIA + 600) # 10 min acordado + self.assertLess(self.v.necessidades['energia'], 50) + self.assertLess(self.v.necessidades['diversao'], 50) + self.v.dormindo_proprio = True + antes = self.v.necessidades['energia'] + self.v.passar_tempo(DIA + 1200) # 10 min dormindo + self.assertGreater(self.v.necessidades['energia'], antes) + + def test_pausa_longa_nao_zera(self): + self.v.passar_tempo(DIA + 86400) # placa desligada um dia + self.assertGreater(self.v.necessidades['energia'], 60) + + def test_cansado_vai_dormir_e_acorda_descansado(self): + self.v.necessidades['energia'] = 10 + self.assertEqual(self.v.agir(DIA), 'dormir') + self.assertIn('dormir', self.ordens) + self.assertTrue(self.v.dormindo()) + self.assertIsNone(self.v.agir(DIA + 60)) # ainda sem energia: continua dormindo + self.v.necessidades['energia'] = 100 + self.assertEqual(self.v.agir(DIA + 120), 'acordar') + + def test_noite_dorme_sem_ninguem(self): + noite = time.mktime((2026, 10, 6, 23, 30, 0, 0, 0, -1)) + self.assertEqual(self.v.agir(noite), 'dormir') + self.v.dormindo_proprio = False + self.v.alguem_perto = lambda: True + self.assertNotEqual(self.v.agir(noite), 'dormir') + + def test_humor_e_gestos_de_carente_com_gente_perto(self): + self.v.necessidades.update(energia=80, social=10) + self.assertEqual(self.v.humor(), 'carente') + self.v.alguem_perto = lambda: True + gesto = self.v.agir(DIA) + self.assertIn(gesto, ('acenar', 'espiar', 'coracao', 'surpreso')) + self.assertEqual(self.v.diario[0]['tipo'], 'acao') + + def test_cuidados_sobem_necessidades_e_mandam_gesto(self): + self.v.necessidades['social'] = 20 + st = self.v.estimulo('carinho', DIA) + self.assertEqual(st['necessidades']['social'], 35) + self.assertEqual(self.ordens[-1], 'coracao') + self.v.estimulo('ninar', DIA) + self.assertTrue(self.v.dormindo()) + with self.assertRaises(ValueError): + self.v.estimulo('voar', DIA) + + def test_figurinha_com_limite(self): + treino = TreinoFalso() + self.v.treinamento = treino + self.v.pode_gemini = lambda: True + self.assertEqual(self.v.motivo_sem_emoji(DIA), '') + self.assertTrue(self.v.criar_figurinha(DIA, 'feliz')) + self.assertIn('à tarde', treino.pedidos[0]) + self.assertEqual(self.v.motivo_sem_emoji(DIA + 60), 'descansando a criatividade') + self.v.config['max_emojis_dia'] = 1 + self.assertEqual(self.v.motivo_sem_emoji(DIA + 4 * 3600), 'limite do dia') + self.v.pode_gemini = lambda: False + self.assertEqual(self.v.motivo_sem_emoji(DIA + 4 * 3600), 'sem chave Gemini') + + def test_tick_manda_humor_e_respeita_bloqueio(self): + self.v.proximo_ato = DIA + 999 + self.v.tick(DIA + 5) + self.assertTrue(any(o.startswith('humor ') for o in self.ordens)) + self.ordens.clear() + self.v.bloqueado = lambda: True + self.v.proximo_ato = DIA + self.assertIsNone(self.v.tick(DIA + 70)) + self.assertEqual(self.ordens, []) + + def test_desligado_nao_age_e_config_persiste(self): + self.v.configurar({'ligado': False, 'ritmo': 'calmo'}) + self.assertIn('humor normal', self.ordens) + self.v.proximo_ato = DIA + self.assertIsNone(self.v.tick(DIA + 10)) + outra = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False) + self.assertFalse(outra.config['ligado']) + self.assertEqual(outra.config['ritmo'], 'calmo') + for ruim in ({'ritmo': 'turbo'}, {'max_emojis_dia': 50}, {'ligado': 1}, {'x': True}): + with self.assertRaises(ValueError): + self.v.configurar(ruim) + + +class VidaHttpTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler) + self.server.token = 'token-vida' + self.server.vida = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + + def tearDown(self): + self.server.shutdown(); self.server.server_close(); self.tmp.cleanup() + + def pedir(self, metodo, rota, corpo=None, token='token-vida'): + c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5) + h = {'Content-Type': 'application/json'} + if token: + h['Authorization'] = 'Bearer ' + token + c.request(metodo, rota, body=json.dumps(corpo) if corpo is not None else None, headers=h) + r = c.getresponse(); dados = r.read(); c.close() + return r.status, json.loads(dados) + + def test_rotas(self): + self.assertEqual(self.pedir('GET', '/vida/status', token=None)[0], 401) + self.assertEqual(self.pedir('POST', '/vida/cuidar', {'acao': 'carinho'}, token=None)[0], 401) + codigo, st = self.pedir('GET', '/vida/status') + self.assertEqual(codigo, 200) + self.assertIn(st['humor'], ('feliz', 'tranquilo', 'carente', 'entediado', 'triste', 'cansado', 'dormindo')) + codigo, st = self.pedir('POST', '/vida/cuidar', {'acao': 'brincar'}) + self.assertEqual((codigo, st['diario'][0]['texto']), (200, 'Brincou')) + self.assertEqual(self.pedir('POST', '/vida/cuidar', {'acao': 'voar'})[0], 400) + self.assertFalse(self.pedir('POST', '/vida/config', {'ligado': False})[1]['config']['ligado']) + self.assertIn('vida', self.pedir('GET', '/estado')[1]) + + +if __name__ == '__main__': + unittest.main() diff --git a/vida.py b/vida.py new file mode 100644 index 0000000..9d51a31 --- /dev/null +++ b/vida.py @@ -0,0 +1,421 @@ +"""Modo vida: o Carinha como um bichinho virtual (estilo Tamagotchi). + +Quatro necessidades (0 a 100, 100 = satisfeito) caem com o tempo e sobem com o que acontece em volta +(presença, conversa, carinho). Delas sai o humor, e do humor saem os atos: gestos, cochilos, chamar +atenção quando tem gente perto, tocar uma reação da biblioteca e, de vez em quando, desenhar uma +figurinha nova com o Gemini. O celular recebe o humor ("humor feliz") e escolhe os gestos soltos dele +a partir disso, então o rosto parece ter vida mesmo entre um ato e outro. +""" +import json +import os +import random +import threading +import time +from pathlib import Path + +from comum import gravar_atomico, ler_json + +NECESSIDADES = ('energia', 'alegria', 'social', 'diversao') + +# Queda por minuto acordado; dormindo, as outras caem bem mais devagar e a energia volta. +QUEDA = dict(energia=0.5, alegria=0.0, social=0.8, diversao=1.0) +RECUPERA_SONO = 2.5 # energia por minuto dormindo (25 -> 100 em ~30 min) +SONO_LENTO = 0.25 + +RITMOS = {'calmo': (60, 150), 'normal': (25, 75), 'agitado': (12, 35)} # segundos entre atos + +PADRAO = dict(ligado=True, criar_emojis=True, max_emojis_dia=3, ritmo='normal', noite=True) +NOITE = (23, 7) # dorme das 23h às 7h, se ninguém estiver por perto +INTERVALO_EMOJI = 90 * 60 # no mínimo 90 min entre duas figurinhas criadas sozinho + +# O que cada estímulo faz com as necessidades (e o gesto que sai na hora, se houver). +ESTIMULOS = { + 'pir': (dict(social=4, diversao=2), None), + 'movimento': (dict(diversao=3, social=1), None), + 'som': (dict(diversao=2), None), + 'conversa': (dict(social=12, alegria=6, diversao=4), None), + 'carinho': (dict(social=15, alegria=10), 'coracao'), + 'cutucar': (dict(social=5, alegria=2, diversao=4), None), + 'brincar': (dict(diversao=25, alegria=8, energia=-5), 'alegre'), + 'alimentar': (dict(energia=30, alegria=4), 'feliz'), +} +CUIDADOS = {'carinho': 'Recebeu carinho', 'cutucar': 'Levou uma cutucada', 'brincar': 'Brincou', + 'alimentar': 'Ganhou energia', 'ninar': 'Foi posto para dormir', 'acordar': 'Foi acordado'} + +HUMORES = { + 'dormindo': ('😴', 'Dormindo'), 'cansado': ('🥱', 'Cansado'), 'carente': ('🥺', 'Carente'), + 'entediado': ('😐', 'Entediado'), 'triste': ('😢', 'Triste'), 'feliz': ('😄', 'Feliz'), + 'tranquilo': ('🙂', 'Tranquilo'), +} + +# Gestos de cada humor: (gesto, peso, o que vai no diário) +ATOS = { + 'feliz': [('alegre', 3, 'Saltitou de alegria'), ('musica', 2, 'Cantarolou'), ('feliz', 3, 'Sorriu à toa'), + ('piscadinha', 2, 'Deu uma piscadinha'), ('coracao', 1, 'Ficou apaixonado pelo dia')], + 'tranquilo': [('olhar_esq', 2, 'Olhou em volta'), ('olhar_dir', 2, 'Olhou em volta'), ('piscar2', 2, 'Piscou devagar'), + ('feliz', 1, 'Sorriu'), ('pensar', 1, 'Ficou pensativo')], + 'entediado': [('pensar', 3, 'Ficou pensando na vida'), ('olhar_esq', 2, 'Procurou algo para fazer'), + ('olhar_dir', 2, 'Procurou algo para fazer'), ('espiar', 2, 'Espiou por trás das mãos'), + ('musica', 1, 'Cantou para passar o tempo')], + 'carente': [('triste', 2, 'Sentiu falta de alguém'), ('olhar_baixo', 2, 'Ficou cabisbaixo'), + ('esconder', 1, 'Escondeu o rosto')], + 'carente_perto': [('acenar', 3, 'Acenou chamando atenção'), ('espiar', 2, 'Espiou quem está aí'), + ('coracao', 2, 'Pediu carinho'), ('surpreso', 1, 'Fez cara de "oi!"')], + 'triste': [('triste', 3, 'Ficou tristinho'), ('olhar_baixo', 2, 'Olhou para baixo'), ('pensar', 1, 'Ficou pensativo')], + 'cansado': [('piscar2', 3, 'Bocejou'), ('olhar_baixo', 2, 'Cochilou de olho aberto')], +} + +# Assuntos das figurinhas que ele inventa sozinho, por humor e por hora do dia. +TEMAS = { + 'feliz': ['um sol sorridente', 'um balão colorido', 'um arco-íris', 'um sorvete de casquinha', 'confete de festa'], + 'tranquilo': ['uma folha de árvore', 'uma flor', 'um gatinho dormindo', 'uma xícara de chá', 'um vaso com planta'], + 'entediado': ['um foguete', 'um planeta com anel', 'uma pipa', 'um dado', 'um controle de videogame', 'um pião'], + 'carente': ['um coração com curativo', 'um ursinho de pelúcia', 'uma carta de amor', 'dois corações juntos'], + 'triste': ['uma nuvem de chuva', 'um guarda-chuva', 'uma gota d\'água'], + 'cansado': ['uma lua com touca de dormir', 'uma xícara de café', 'um travesseiro', 'uma bateria fraca'], +} +PERIODOS = [(5, 12, 'de manhã', ['um sol nascendo', 'um pãozinho', 'uma xícara de café']), + (12, 18, 'à tarde', ['um picolé', 'um avião de papel', 'uma bola']), + (18, 24, 'à noite', ['uma lua', 'estrelas', 'uma coruja']), + (0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])] + +MAX_DIARIO = 60 + + +def _limitar(v): + return max(0.0, min(100.0, v)) + + +class Vida: + def __init__(self, caminho, iniciar=True, rng=None): + self.caminho = Path(caminho) + self.lock = threading.RLock() + self.rng = rng or random.Random() + self.publicar = lambda tipo, dados: None + self.enfileirar = lambda ordem: None + # Ligações com o resto do servidor (o main preenche; nos testes ficam neutras). + self.treinamento = None + self.celular_dormindo = lambda: None # True/False com o celular online, None sem ele + self.celular_online = lambda: False + self.alguem_perto = lambda: False # PIR/movimento recente + self.bloqueado = lambda: False # E-Stop ou modo presença com ninguém em casa + self.pode_gemini = lambda: bool(os.environ.get('GEMINI_API_KEY')) + + agora = time.time() + self.config = dict(PADRAO) + self.necessidades = dict(energia=80.0, alegria=70.0, social=60.0, diversao=60.0) + self.nascimento = agora + self.diario = [] + self.emojis = dict(dia='', total=0, ultimo=0.0) + self.dormindo_proprio = False + try: + salvo = json.loads(self.caminho.read_text(encoding='utf-8')) + self.config.update(self.validar(salvo.get('config', {}))) + for k in NECESSIDADES: + if isinstance(salvo.get('necessidades', {}).get(k), (int, float)): + self.necessidades[k] = _limitar(float(salvo['necessidades'][k])) + self.nascimento = float(salvo.get('nascimento', agora)) + self.diario = [d for d in salvo.get('diario', []) if isinstance(d, dict)][:MAX_DIARIO] + if isinstance(salvo.get('emojis'), dict): + self.emojis.update({k: salvo['emojis'][k] for k in ('dia', 'total', 'ultimo') if k in salvo['emojis']}) + except (OSError, ValueError, TypeError, AttributeError): + pass + self.ultimo_tick = agora + self.proximo_ato = agora + 10 + self.humor_enviado = (None, 0.0) + self.publicado_em = 0.0 + self.salvo_em = agora + if iniciar: + threading.Thread(target=self.loop, daemon=True).start() + + # ------------------------------------------------------------------ config + @staticmethod + def validar(dados): + if not isinstance(dados, dict): + raise ValueError('Esperava um objeto') + limpo = {} + for chave, valor in dados.items(): + if chave not in PADRAO: + raise ValueError('Ajuste desconhecido: ' + chave) + if chave == 'ritmo': + if valor not in RITMOS: + raise ValueError('Ritmo: calmo, normal ou agitado') + elif chave == 'max_emojis_dia': + if isinstance(valor, bool) or not isinstance(valor, int) or not 0 <= valor <= 10: + raise ValueError('Figurinhas por dia: de 0 a 10') + elif not isinstance(valor, bool): + raise ValueError(chave + ' precisa ser verdadeiro ou falso') + limpo[chave] = valor + return limpo + + def configurar(self, dados): + limpo = self.validar(dados) + with self.lock: + antes = self.config['ligado'] + self.config.update(limpo) + if 'ligado' in limpo and limpo['ligado'] != antes: + self.anotar('vida', 'Modo autônomo ligado' if limpo['ligado'] else 'Modo autônomo desligado') + if not limpo['ligado']: + self.enfileirar('humor normal') # o celular volta aos gestos soltos de sempre + self.humor_enviado = (None, 0.0) + self.proximo_ato = time.time() + 5 + self.salvar() + self.avisar() + return self.status() + + def salvar(self): + with self.lock: + dados = dict(config=self.config, necessidades=self.necessidades, nascimento=self.nascimento, + diario=self.diario, emojis=self.emojis) + self.salvo_em = time.time() + try: + gravar_atomico(self.caminho, dados) + except OSError: + pass + + # ------------------------------------------------------------------ estado + def dormindo(self): + cel = self.celular_dormindo() + return self.dormindo_proprio if cel is None else bool(cel) + + def humor(self): + if self.dormindo(): + return 'dormindo' + n = self.necessidades + if n['energia'] < 25: + return 'cansado' + if n['social'] < 25: + return 'carente' + if n['diversao'] < 25: + return 'entediado' + if n['alegria'] < 30: + return 'triste' + if n['alegria'] >= 65 and n['social'] >= 40 and n['diversao'] >= 40: + return 'feliz' + return 'tranquilo' + + def anotar(self, tipo, texto, agora=None): + with self.lock: + self.diario.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto)) + del self.diario[MAX_DIARIO:] + + def emojis_hoje(self, agora=None): + dia = time.strftime('%Y-%m-%d', time.localtime(agora or time.time())) + return self.emojis['total'] if self.emojis.get('dia') == dia else 0 + + def status(self, agora=None): + agora = agora or time.time() + h = self.humor() + with self.lock: + return dict(config=dict(self.config), necessidades={k: round(v) for k, v in self.necessidades.items()}, + humor=h, emoji=HUMORES[h][0], nome_humor=HUMORES[h][1], dormindo=self.dormindo(), + nascimento=self.nascimento, idade_dias=int((agora - self.nascimento) // 86400), + emojis_hoje=self.emojis_hoje(agora), ultimo_emoji=self.emojis.get('ultimo') or None, + pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30]) + + def avisar(self): + self.publicado_em = time.time() + self.publicar('vida', self.status()) + + # ------------------------------------------------------------------ tempo passando + def passar_tempo(self, agora): + minutos = max(0.0, min(10.0, (agora - self.ultimo_tick) / 60)) # pausa longa (placa desligada) não zera tudo + self.ultimo_tick = agora + dormindo = self.dormindo() + with self.lock: + n = self.necessidades + for k, q in QUEDA.items(): + n[k] = _limitar(n[k] - q * minutos * (SONO_LENTO if dormindo else 1)) + if dormindo: + n['energia'] = _limitar(n['energia'] + RECUPERA_SONO * minutos) + # alegria acompanha devagar a média de companhia e diversão + alvo = (n['social'] + n['diversao']) / 2 + 10 + n['alegria'] = _limitar(n['alegria'] + (alvo - n['alegria']) * min(1.0, 0.05 * minutos)) + + def estimulo(self, tipo, agora=None): + """Algo aconteceu em volta (sensor, conversa, toque, botão do painel).""" + if tipo not in ESTIMULOS and tipo not in ('ninar', 'acordar'): + raise ValueError('Cuidado desconhecido') + agora = agora or time.time() + if not self.config['ligado']: + return self.status(agora) + gesto = None + if tipo == 'ninar': + self.dormir(agora, 'Foi posto para dormir') + elif tipo == 'acordar': + self.acordar(agora, 'Foi acordado') + else: + efeito, gesto = ESTIMULOS[tipo] + with self.lock: + for k, v in efeito.items(): + self.necessidades[k] = _limitar(self.necessidades[k] + v) + if tipo in CUIDADOS: + self.anotar('cuidado', CUIDADOS[tipo], agora) + if gesto and not self.bloqueado(): + if self.dormindo(): + self.acordar(agora, None) + self.enfileirar(gesto) + if tipo in CUIDADOS: + self.avisar() + return self.status(agora) + + def dormir(self, agora, motivo): + self.dormindo_proprio = True + self.enfileirar('dormir') + if motivo: + self.anotar('sono', motivo, agora) + + def acordar(self, agora, motivo): + self.dormindo_proprio = False + self.enfileirar('acordar') + if motivo: + self.anotar('sono', motivo, agora) + + def noite(self, agora): + hora = time.localtime(agora).tm_hour + ini, fim = NOITE + return hora >= ini or hora < fim + + # ------------------------------------------------------------------ decidir o que fazer + def escolher(self, opcoes): + total = sum(p for _, p, _ in opcoes) + x = self.rng.uniform(0, total) + for gesto, peso, texto in opcoes: + x -= peso + if x <= 0: + return gesto, texto + return opcoes[-1][0], opcoes[-1][2] + + def motivo_sem_emoji(self, agora): + """'' quando pode criar uma figurinha agora; senão, o porquê (aparece no painel).""" + if not self.config['criar_emojis']: + return 'desligado' + if self.treinamento is None: + return 'biblioteca indisponível' + if not self.pode_gemini(): + return 'sem chave Gemini' + if self.emojis_hoje(agora) >= self.config['max_emojis_dia']: + return 'limite do dia' + if agora - float(self.emojis.get('ultimo') or 0) < INTERVALO_EMOJI: + return 'descansando a criatividade' + return '' + + def criar_figurinha(self, agora, humor): + h = time.localtime(agora).tm_hour + periodo, extras = next((p, e) for ini, fim, p, e in PERIODOS if ini <= h < fim) + tema = self.rng.choice(TEMAS.get(humor, TEMAS['tranquilo']) + extras) + assunto = (f'Ideia que o próprio Carinha teve sozinho, {periodo}, se sentindo {HUMORES[humor][1].lower()}: ' + f'{tema}. Figurinha fofa e simples.') + try: + self.treinamento.gerar_figurinha(assunto) + except ValueError as e: + self.anotar('emoji', 'Quis desenhar ' + tema + ', mas não deu: ' + str(e)[:80], agora) + return False + dia = time.strftime('%Y-%m-%d', time.localtime(agora)) + with self.lock: + total = self.emojis['total'] + 1 if self.emojis.get('dia') == dia else 1 + self.emojis.update(dia=dia, total=total, ultimo=agora) + self.necessidades['diversao'] = _limitar(self.necessidades['diversao'] + 30) + self.necessidades['alegria'] = _limitar(self.necessidades['alegria'] + 10) + self.anotar('emoji', 'Teve uma ideia e desenhou ' + tema, agora) + return True + + def tocar_reacao(self, agora): + try: + reacoes = [r for r in self.treinamento.status()['reacoes'] if r.get('ativa', True) and r['feedback'] >= 0] + except Exception: + return False + if not reacoes: + return False + r = self.rng.choice(reacoes[:40]) + try: + self.treinamento.testar(r['id']) + except ValueError: + return False + self.anotar('acao', 'Lembrou da reação "' + r['receita'].get('nome', '?') + '"', agora) + return True + + def agir(self, agora): + """Um ato: o que um bichinho com essas necessidades faria agora.""" + n = self.necessidades + perto = self.alguem_perto() + if self.dormindo(): + # acorda descansado de dia, ou sozinho quando a energia enche e tem alguém por perto + if n['energia'] >= 95 and (not self.noite(agora) or perto): + self.acordar(agora, 'Acordou descansado') + return 'acordar' + return None + if self.config['noite'] and self.noite(agora) and not perto: + self.dormir(agora, 'Ficou tarde, foi dormir') + return 'dormir' + if n['energia'] < 20: + self.dormir(agora, 'Ficou cansado e foi tirar um cochilo') + return 'dormir' + humor = self.humor() + # habilidade: inventar uma figurinha nova (raro, com limite por dia e intervalo) + if not self.motivo_sem_emoji(agora) and self.celular_online() and self.rng.random() < 0.08: + if self.criar_figurinha(agora, humor): + return 'figurinha' + # às vezes lembra de uma reação da biblioteca + if self.treinamento is not None and humor in ('feliz', 'tranquilo', 'entediado') and self.rng.random() < 0.15: + if self.tocar_reacao(agora): + with self.lock: + n['diversao'] = _limitar(n['diversao'] + 8) + return 'reacao' + lista = ATOS['carente_perto'] if humor == 'carente' and perto else ATOS.get(humor, ATOS['tranquilo']) + gesto, texto = self.escolher(lista) + self.enfileirar(gesto) + if humor == 'entediado': + with self.lock: + n['diversao'] = _limitar(n['diversao'] + 3) # fazer algo distrai um pouquinho + self.anotar('acao', texto, agora) + return gesto + + def tick(self, agora=None): + agora = agora or time.time() + self.passar_tempo(agora) + if not self.config['ligado']: + return None + ato = None + humor = self.humor() + if not self.bloqueado(): + # o celular escolhe os gestos soltos pelo humor; reenvia de tempos em tempos (pode ter reiniciado) + if humor != self.humor_enviado[0] or agora - self.humor_enviado[1] > 60: + self.enfileirar('humor ' + humor) + self.humor_enviado = (humor, agora) + if agora >= self.proximo_ato: + ato = self.agir(agora) + lo, hi = RITMOS[self.config['ritmo']] + self.proximo_ato = agora + self.rng.uniform(lo, hi) + if ato or agora - self.publicado_em > 30 or humor != self.humor(): + self.avisar() + if agora - self.salvo_em > 60: + self.salvar() + return ato + + def loop(self): + while True: + time.sleep(5) + try: + self.tick() + except Exception as e: # nunca derruba a thread + print('vida: erro no tick:', e, flush=True) + + # ------------------------------------------------------------------ HTTP + def rotas(self, h, metodo): + rota = h.path.split('?', 1)[0] + if not rota.startswith('/vida/'): + return False + try: + if metodo == 'GET' and rota == '/vida/status': + resultado = self.status() + elif metodo == 'POST' and rota == '/vida/config': + resultado = self.configurar(ler_json(h, 2048)) + elif metodo == 'POST' and rota == '/vida/cuidar': + resultado = self.estimulo(ler_json(h, 2048).get('acao')) + else: + raise ValueError('Rota inválida') + h.enviar(json.dumps(resultado, ensure_ascii=False)) + except (ValueError, TypeError) as e: + h.enviar(json.dumps(dict(erro=str(e)), ensure_ascii=False), codigo=400) + return True diff --git a/web/src/lib/componentes/VidaCartao.svelte b/web/src/lib/componentes/VidaCartao.svelte new file mode 100644 index 0000000..4740069 --- /dev/null +++ b/web/src/lib/componentes/VidaCartao.svelte @@ -0,0 +1,283 @@ + + + + {#if !v} +

Carregando…

+ {:else} +
+ +
+ {v.config.ligado ? v.nome_humor : 'Modo autônomo desligado'} + {idade(v.idade_dias)}{v.config.ligado && v.diario[0] ? ' · ' + v.diario[0].texto : ''} +
+ configurarVida({ ligado: l })} /> +
+ + {#if v.config.ligado} +
+ {#each NECESSIDADES as n (n.chave)} + {@const valor = v.necessidades[n.chave]} +
+ {n.nome} + + + + {valor} +
+ {/each} +
+ +
+ {#each CUIDADOS as c (c.acao)} + + {/each} + {#if v.dormindo} + + {:else} + + {/if} +
+ + + {#snippet acessorio()} + configurarVida({ criar_emojis: l })} /> + {/snippet} + + + {#snippet acessorio()} + + {#each RITMOS as r (r.valor)} + + {/each} + + {/snippet} + + + {#snippet acessorio()} + configurarVida({ noite: l })} /> + {/snippet} + + + {#if v.diario.length} +
    + {#each v.diario.slice(0, diarioAberto ? 30 : 5) as d (d.horario + d.texto)} +
  1. {d.texto}
  2. + {/each} +
+ {#if v.diario.length > 5} + + {/if} + {/if} + {/if} + {/if} +
+ + diff --git a/web/src/lib/estado.svelte.ts b/web/src/lib/estado.svelte.ts index 531a09a..35958e1 100644 --- a/web/src/lib/estado.svelte.ts +++ b/web/src/lib/estado.svelte.ts @@ -4,7 +4,7 @@ */ import { ErroApi, pedir, quandoNegado, urlEventos } from './api'; import { avisos } from './avisos.svelte'; -import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos'; +import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria, Vida } from './tipos'; export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token'; @@ -33,6 +33,7 @@ class Estado { 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); @@ -108,12 +109,14 @@ 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.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 }) => { - if (d.ordem !== 'calma') noFeed('expressao', 'Expressão: ' + d.ordem); + // '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; @@ -142,6 +145,11 @@ const tratadores: Record void> = { 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; @@ -169,7 +177,7 @@ function abrirEventos() { if (estado.conexao !== 'token') estado.conexao = 'caiu'; }; for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia', - 'reacao', 'treinamento', 'historico', 'pir']) { + 'reacao', 'treinamento', 'historico', 'pir', 'vida']) { fonte.addEventListener(tipo, (e) => { const dados = JSON.parse((e as MessageEvent).data); tratadores[tipo]?.(dados); diff --git a/web/src/lib/tipos.ts b/web/src/lib/tipos.ts index 77aac28..511fafb 100644 --- a/web/src/lib/tipos.ts +++ b/web/src/lib/tipos.ts @@ -82,4 +82,22 @@ export interface EstadoServidor { treinamento?: { ocupado: boolean; erro: string }; api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown }; pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null }; + vida?: Vida; +} + +/** Modo vida (/vida/status e SSE 'vida'): necessidades de 0 a 100, 100 = satisfeito. */ +export interface Vida { + config: { ligado: boolean; criar_emojis: boolean; max_emojis_dia: number; ritmo: 'calmo' | 'normal' | 'agitado'; noite: boolean }; + necessidades: { energia: number; alegria: number; social: number; diversao: number }; + humor: string; + emoji: string; + nome_humor: string; + dormindo: boolean; + nascimento: number; + idade_dias: number; + emojis_hoje: number; + ultimo_emoji: number | null; + /** '' quando pode inventar figurinha agora; senão, o motivo. */ + pode_criar: string; + diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida'; texto: string }[]; } diff --git a/web/src/lib/vida.svelte.ts b/web/src/lib/vida.svelte.ts new file mode 100644 index 0000000..3899559 --- /dev/null +++ b/web/src/lib/vida.svelte.ts @@ -0,0 +1,37 @@ +/** Modo vida: ajustes e cuidados. O estado chega pelo SSE 'vida' em estado.vida. */ +import { postar } from './api'; +import { avisos } from './avisos.svelte'; +import { estado } from './estado.svelte'; +import type { Vida } from './tipos'; + +export const NECESSIDADES: { chave: keyof Vida['necessidades']; nome: string; emoji: string; cor: string }[] = [ + { chave: 'energia', nome: 'Energia', emoji: '⚡', cor: '#ffcc00' }, + { chave: 'alegria', nome: 'Alegria', emoji: '😊', cor: '#ff2d55' }, + { chave: 'social', nome: 'Companhia', emoji: '💬', cor: '#0a84ff' }, + { chave: 'diversao', nome: 'Diversão', emoji: '🎈', cor: '#34c759' }, +]; + +export const CUIDADOS = [ + { acao: 'carinho', nome: 'Carinho', emoji: '❤️' }, + { acao: 'brincar', nome: 'Brincar', emoji: '🎈' }, + { acao: 'alimentar', nome: 'Energia', emoji: '⚡' }, +] as const; + +export async function configurarVida(parcial: Partial) { + const antes = estado.vida; + if (antes) estado.vida = { ...antes, config: { ...antes.config, ...parcial } }; + try { + estado.vida = await postar('/vida/config', parcial); + } catch (e) { + estado.vida = antes; + avisos.erro(e); + } +} + +export async function cuidar(acao: string) { + try { + estado.vida = await postar('/vida/cuidar', { acao }); + } catch (e) { + avisos.erro(e); + } +} diff --git a/web/src/telas/AoVivo.svelte b/web/src/telas/AoVivo.svelte index ae384d2..8d0b948 100644 --- a/web/src/telas/AoVivo.svelte +++ b/web/src/telas/AoVivo.svelte @@ -14,6 +14,7 @@ import Linha from '../lib/componentes/Linha.svelte'; import PresencaPir from '../lib/componentes/PresencaPir.svelte'; import RostoPrevia from '../lib/componentes/RostoPrevia.svelte'; + import VidaCartao from '../lib/componentes/VidaCartao.svelte'; import { estado } from '../lib/estado.svelte'; import { Rastreamento } from '../lib/rastreamento.svelte'; import { VideoAoVivo } from '../lib/video.svelte'; @@ -122,6 +123,8 @@ const pirAtivo = $derived(
+ +