Compare commits

..
Author SHA1 Message Date
julioandClaude Opus 5.5 673dea62e8 feat(web): migra Movimento com homem-morto e trava de hardware
- Controle: opt-in de movimento fisico, botao "segure para mover"
  (mouse, toque e teclado), sliders das articulacoes tambem so movem
  enquanto pressionados; velocidade e interpolacao ficam no servidor
- sequencias prontas e posturas salvas: previa so na tela ou execucao
  segurando o botao ate o fim; soltar interrompe na hora
- posturas com nome inline (sem prompt), exclusao confirmada inline,
  exportar/importar; posturas aparecem na paleta (Ctrl+K)
- Calibracao: testes de fim de curso por canal, marcar limites e tabela
  editavel com validacao antes de salvar
- Diagnostico (antigo Servos): ao vivo limitado a 10/s, bloqueado quando
  o braco esta com os motores
- desenho esquematico do braco em SVG com previa animada
- servidor: evento 'motores' tambem ao ligar canais; renovar no alvo
  devolve movendo=false para a sequencia avancar
- aba escondida ou E-Stop soltam o braco e desativam o movimento

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:56:48 -03:00
julioandClaude Opus 5.5 947c0a3127 feat(web): migra Conversa e Reacoes para o painel novo
- Conversa: chat empurrado por SSE, indicador de "pensando", gesto da
  resposta com repetir, voz com aviso claro quando o contexto nao e seguro
  e leitura em voz alta so de respostas novas
- Memoria unificada: lembrancas da conversa + rotina/preferencias em abas;
  apagar tudo com confirmacao inline (sem confirm nativo)
- Reacoes: biblioteca com miniaturas estaticas (sem iframe por cartao),
  busca, mostrar no celular, feedback, editar em <dialog>, duplicar,
  ativar e excluir com Desfazer no toast; criar com IA; diario; grade de
  gestos com estrela para favoritos; uma unica previa lateral
- treinamento recarrega por evento SSE ao terminar, sem polling
- corrige icones encolhidos em botoes .pequeno.icone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:51:45 -03:00
julioandClaude Opus 5.5 c4b53a8976 feat(web): cria painel Svelte com shell, Ao vivo e Ajustes
- web/ com Vite + Svelte 5 + TypeScript; servidor entrega web/dist em "/"
  (telas antigas seguem em /dashboard, /painel, /braco...)
- design system em tokens CSS, tema escuro e claro, fontes locais
- estado unico: foto de /estado + um EventSource em /eventos, sem polling
- shell com navegacao lateral/inferior, selos de status reais e E-Stop fixo
- command palette (Ctrl+K), gestos favoritos com atalhos 1-9, g+letra navega
- Ao vivo: camera WebRTC e rastreamento portados para TS, rosto, aparencia,
  sensores do celular, IA local e feed de acontecimentos
- Ajustes: campos do celular agrupados com busca, salvamento otimista e
  restaurar padrao (GET /app/padrao); motor da conversa, Gemini e token
- carinha.html ganha modo ?painel=1 dirigido por postMessage (sem polling)
- teste Playwright de ponta a ponta do painel novo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:47:18 -03:00
52 changed files with 6986 additions and 7 deletions
+9 -3
View File
@@ -84,7 +84,7 @@ class Braco:
try: try:
for channel,pulse in pulses.items(): for channel,pulse in pulses.items():
self.servos.hw.pulse(channel,pulse) self.servos.hw.pulse(channel,pulse)
self.servos.active[channel]=prazo self.servos.ligar([(channel,prazo) for channel in pulses])
except OSError: except OSError:
self.alvo=None; self.servos.stop() self.alvo=None; self.servos.stop()
raise raise
@@ -102,7 +102,13 @@ class Braco:
self.escrever(pulses,prazo) self.escrever(pulses,prazo)
self.atual=[float(a) for a in pose] self.atual=[float(a) for a in pose]
return dict(ok=True,pulsos=pulses,movendo=False) return dict(ok=True,pulsos=pulses,movendo=False)
self.alvo=[float(a) for a in pose]; self.velocidade=velocidade; self.lease=prazo alvo=[float(a) for a in pose]
if all(abs(a-t)<.01 for a,t in zip(self.atual,alvo)):
# Já no alvo: só renova o prazo (e reenergiza canais que tenham desligado).
self.alvo=None
self.escrever(pulses,prazo)
return dict(ok=True,pulsos=pulses,movendo=False)
self.alvo=alvo; self.velocidade=velocidade; self.lease=prazo
for channel in list(self.servos.active): self.servos.active[channel]=prazo for channel in list(self.servos.active): self.servos.active[channel]=prazo
return dict(ok=True,pulsos=pulses,movendo=True) return dict(ok=True,pulsos=pulses,movendo=True)
def cancelar(self): def cancelar(self):
@@ -142,7 +148,7 @@ class Braco:
self.servos.reservar('braco'); self.alvo=None; self.atual=None self.servos.reservar('braco'); self.alvo=None; self.atual=None
for ch,us in outputs.items(): for ch,us in outputs.items():
self.servos.hw.pulse(ch,us) self.servos.hw.pulse(ch,us)
self.servos.active[ch]=time.monotonic()+2 self.servos.ligar([(ch,time.monotonic()+2) for ch in outputs])
return dict(ok=True,pulsos=outputs) return dict(ok=True,pulsos=outputs)
def rotas(self,h,method): def rotas(self,h,method):
route=h.path.split('?',1)[0] route=h.path.split('?',1)[0]
+12 -1
View File
@@ -487,7 +487,18 @@ async function aplicarAparencia() {
} catch (e) { /* sem servidor — segue com o padrao */ } } catch (e) { /* sem servidor — segue com o padrao */ }
} }
setTimeout(aplicarAparencia, 200); setTimeout(aplicarAparencia, 200);
if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000); // ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
if (MODO_PAINEL) {
window.carinhaAtiva = false;
addEventListener('message', (e) => {
if (e.origin !== location.origin || e.source !== parent || !e.data) return;
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 (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// teclado (util para testar no PC) // teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo", const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
+4 -2
View File
@@ -104,7 +104,7 @@ class ControlePC:
if self.rtc.atender(h, metodo, self.config['preview']): if self.rtc.atender(h, metodo, self.config['preview']):
return True return True
rota = h.path.split('?', 1)[0].rstrip('/') rota = h.path.split('?', 1)[0].rstrip('/')
if rota not in ('/app/config', '/app/status', '/camera/frame', '/app/connect', '/app/health'): if rota not in ('/app/config', '/app/padrao', '/app/status', '/camera/frame', '/app/connect', '/app/health'):
return False return False
def enviar(dados, codigo=200): def enviar(dados, codigo=200):
@@ -128,7 +128,9 @@ class ControlePC:
else: else:
dados = None dados = None
if rota == '/app/health': if rota == '/app/padrao':
enviar(PADRAO)
elif rota == '/app/health':
enviar({'projeto': 'carinha-pc', 'versao': versao()}) enviar({'projeto': 'carinha-pc', 'versao': versao()})
elif rota == '/app/config': elif rota == '/app/config':
if metodo == 'POST': if metodo == 'POST':
+12
View File
@@ -290,6 +290,18 @@ class Handler(SimpleHTTPRequestHandler):
enfileirar(ordem) enfileirar(ordem)
return self.json({"ok": True, partes[0]: ordem}) return self.json({"ok": True, partes[0]: ordem})
# Painel novo (Vite/Svelte em web/dist); as telas antigas seguem nos caminhos próprios.
painel = Path("web/dist")
if caminho in ("/", "") and (painel / "index.html").is_file():
self.path = "/web/dist/index.html"
return super().do_GET()
if caminho.startswith("/assets/"):
nome = caminho[len("/assets/"):]
if "/" in nome or "\\" in nome or nome.startswith(".") or not (painel / "assets" / nome).is_file():
return self.json({"erro": "Arquivo não disponível"}, 404)
self.path = "/web/dist/assets/" + nome
return super().do_GET()
if caminho in ("/dashboard", "/emojis", "/api"): if caminho in ("/dashboard", "/emojis", "/api"):
self.path = caminho + ".html" self.path = caminho + ".html"
elif caminho == "/painel": elif caminho == "/painel":
+10 -1
View File
@@ -88,6 +88,15 @@ class ControleServos:
with self.lock: with self.lock:
return dict(disponivel=self.hw is not None, erro=self.error, ativos=sorted(self.active), return dict(disponivel=self.hw is not None, erro=self.error, ativos=sorted(self.active),
dono=self.dono, travado=self.travado) dono=self.dono, travado=self.travado)
def ligar(self, canais_prazos):
"""Registra canais energizados até o prazo; avisa o painel quando algum canal liga."""
with self.lock:
novos = False
for canal, prazo in canais_prazos:
novos = novos or canal not in self.active
self.active[canal] = prazo
if novos:
self.publicar('motores', self.motores())
def reservar(self, nome): def reservar(self, nome):
"""Braço e diagnóstico usam o mesmo PCA9685 com limites diferentes: um de cada vez.""" """Braço e diagnóstico usam o mesmo PCA9685 com limites diferentes: um de cada vez."""
with self.lock: with self.lock:
@@ -110,7 +119,7 @@ class ControleServos:
if self.hw is None: raise OSError('PCA9685 indisponível neste servidor') if self.hw is None: raise OSError('PCA9685 indisponível neste servidor')
self.reservar('diagnostico') self.reservar('diagnostico')
self.hw.pulse(row['canal'], pulse) self.hw.pulse(row['canal'], pulse)
self.active[row['canal']] = time.monotonic() + 2 self.ligar([(row['canal'], time.monotonic() + 2)])
return self.status() return self.status()
def expire(self, now): def expire(self, now):
with self.lock: with self.lock:
+203
View File
@@ -0,0 +1,203 @@
"""Painel novo (web/dist) contra o servidor real: token, SSE, gestos, paleta, E-Stop, ajustes e celular.
Requer `npm run build` em web/. Uso: python tests/test_painel_web_browser.py [pasta-para-capturas]
"""
from playwright.sync_api import sync_playwright, expect
from pathlib import Path
import importlib.util
import os
import sys
import tempfile
import threading
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.chdir(ROOT)
spec = importlib.util.spec_from_file_location('kiosk_web', ROOT / 'kiosk-server.py')
kiosk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kiosk)
from braco import Braco
from cerebro import Cerebro
from conversa import Conversa
from servos import ControleServos
from treinamento import Treinamento
TOKEN = 'token-de-teste-123'
class HW:
def __init__(self): self.calls = []
def pulse(self, ch, us): self.calls.append((ch, us))
def off(self, ch): self.calls.append((ch, None))
def main(capturas=None):
assert (ROOT / 'web/dist/index.html').is_file(), 'Rode npm run build em web/ primeiro'
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
kiosk.ARQUIVO_APARENCIA = str(tmp / 'aparencia.json')
server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
server.token = TOKEN
server.controle = kiosk.ControlePC(tmp / 'app.json')
server.servos = ControleServos(tmp / 'servos.json', HW())
server.braco = Braco(tmp / 'braco.json', server.servos)
server.cerebro = Cerebro(tmp / 'ia.json', server.controle, kiosk.enfileirar)
server.conversa = Conversa(tmp / 'memoria.db', server.cerebro)
server.treinamento = Treinamento(tmp / 'treinamento.db', iniciar=False)
server.conversa.treinamento = server.treinamento
kiosk.ligar_eventos(server)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
testar('http://127.0.0.1:%d' % server.server_port, server, capturas)
finally:
kiosk.EVENTOS.fechar()
server.shutdown()
server.server_close()
server.servos.close()
def testar(base, server, capturas):
auth = {'Authorization': 'Bearer ' + TOKEN}
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={'width': 1440, 'height': 960})
erros = []
page.on('pageerror', lambda e: erros.append(str(e)))
page.goto(base + '/')
# 1. token
expect(page.get_by_role('heading', name='Token do Carinha')).to_be_visible()
page.get_by_label('Token').fill(TOKEN)
page.get_by_role('button', name='Entrar').click()
expect(page.get_by_text('Servidor', exact=True)).to_be_visible(timeout=5000)
# 2. telemetria do celular chega por SSE
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
'microfone_ativo': True, 'microfone': 0.3})
expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000)
# 3. gesto pelo dock
kiosk.FILA.limpar()
page.get_by_role('button', name='Feliz').first.click()
expect(page.get_by_text('Expressão: feliz')).to_be_visible(timeout=5000)
assert kiosk.FILA.ordens() == ['feliz'], kiosk.FILA.ordens()
# 4. atalho numérico e paleta
page.locator('body').click(position={'x': 5, 'y': 900})
page.keyboard.press('2')
page.wait_for_timeout(300)
assert kiosk.FILA.ordens()[-1] == 'coracao', kiosk.FILA.ordens()
page.keyboard.press('Control+k')
page.get_by_label('Buscar comando').fill('surpreso')
page.keyboard.press('Enter')
page.wait_for_timeout(300)
assert kiosk.FILA.ordens()[-1] == 'surpreso', kiosk.FILA.ordens()
if capturas:
page.screenshot(path=str(Path(capturas) / 'ao-vivo.png'), full_page=True)
# 5. E-Stop real
page.get_by_role('button', name='Parar tudo').click()
expect(page.get_by_role('button', name='PARADO · Rearmar')).to_be_visible(timeout=5000)
assert kiosk.ESTOP['ativo'] and server.servos.travado
page.get_by_role('button', name='PARADO · Rearmar').click()
expect(page.get_by_role('button', name='Parar tudo')).to_be_visible(timeout=5000)
assert not server.servos.travado
# 6. ajustes salvam sem travar
page.get_by_role('link', name='Ajustes').first.click()
page.get_by_label('Buscar ajuste').fill('boca')
boca = page.get_by_role('switch', name='Mostrar boca')
assert boca.is_checked()
boca.locator('xpath=..').click()
page.wait_for_timeout(500)
assert server.controle.config['boca'] is False
if capturas:
page.get_by_label('Buscar ajuste').fill('')
page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True)
# 6b. conversa: pergunta de hora é respondida pelo relógio (sem IA) e chega por SSE
page.get_by_role('link', name='Conversa').first.click()
page.get_by_label('Mensagem').fill('Que horas são?')
page.get_by_role('button', name='Enviar').click()
expect(page.get_by_text('no horário de São Paulo')).to_be_visible(timeout=5000)
page.get_by_label('Nova lembrança').fill('Meu nome é Júlio')
page.get_by_role('button', name='Adicionar').click()
expect(page.get_by_text('Meu nome é Júlio')).to_be_visible(timeout=5000)
page.get_by_role('tab', name='Rotina e preferências').click()
page.get_by_label('Nova rotina').fill('Treino às 7h')
page.get_by_role('button', name='Adicionar').click()
expect(page.get_by_text('Treino às 7h')).to_be_visible(timeout=5000)
if capturas:
page.screenshot(path=str(Path(capturas) / 'conversa.png'), full_page=True)
# 6c. reações: criar emoji, mostrar no celular, excluir e desfazer pelo toast
page.get_by_role('link', name='Reações').first.click()
page.get_by_role('button', name='Novo emoji').click()
page.get_by_label('Nome').fill('Ideia brilhante')
page.get_by_label('Quando usar').fill('Quando tiver uma ideia')
page.get_by_role('radio', name='🚀').click()
page.get_by_role('button', name='Salvar').click()
cartao = page.locator('li.reacao', has_text='Ideia brilhante')
expect(cartao).to_be_visible(timeout=5000)
cartao.get_by_role('button', name='Mostrar').click()
page.wait_for_timeout(400)
assert server.treinamento.expressao['receita']['nome'] == 'Ideia brilhante'
cartao.get_by_role('button', name='Excluir').click()
expect(cartao).to_have_count(0, timeout=5000)
page.get_by_role('button', name='Desfazer').last.click()
expect(page.locator('li.reacao', has_text='Ideia brilhante')).to_be_visible(timeout=5000)
if capturas:
page.screenshot(path=str(Path(capturas) / 'reacoes.png'), full_page=True)
# 6d. braço: opt-in, homem-morto e desligamento pelo prazo ao soltar
hw = server.servos.hw
page.get_by_role('link', name='Movimento').first.click()
segurar = page.get_by_role('button', name='Segure para ir ao alvo')
expect(segurar).to_be_disabled()
page.get_by_role('switch', name='Ativar movimento físico').locator('xpath=..').click()
expect(segurar).to_be_enabled()
hw.calls.clear()
segurar.hover()
page.mouse.down()
page.wait_for_timeout(700)
assert server.servos.active, 'motores deveriam estar ligados enquanto segura'
expect(page.get_by_text('Motores ligados')).to_be_visible(timeout=3000)
page.mouse.up()
page.wait_for_timeout(900)
assert not server.servos.active, 'motores deveriam desligar sozinhos após soltar'
assert any(us for _, us in hw.calls), hw.calls
if capturas:
page.screenshot(path=str(Path(capturas) / 'movimento.png'), full_page=True)
# E-Stop desativa o movimento físico na hora
page.get_by_role('button', name='Parar tudo').click()
expect(page.get_by_role('switch', name='Ativar movimento físico')).to_be_disabled(timeout=3000)
expect(segurar).to_be_disabled()
page.get_by_role('button', name='PARADO · Rearmar').click()
expect(page.get_by_role('button', name='Parar tudo')).to_be_visible(timeout=3000)
# 7. celular: barra inferior e nada de rolagem horizontal
page.set_viewport_size({'width': 400, 'height': 860})
page.get_by_role('link', name='Ao vivo').click()
page.wait_for_timeout(300)
largura = page.evaluate('document.scrollingElement.scrollWidth')
assert largura <= 400, 'rolagem horizontal: %s' % largura
if capturas:
page.screenshot(path=str(Path(capturas) / 'celular.png'), full_page=True)
# 8. tema claro
page.set_viewport_size({'width': 1280, 'height': 900})
inicial = page.evaluate("document.documentElement.dataset.theme")
outro = 'light' if inicial == 'dark' else 'dark'
page.get_by_role('button', name='Usar tema ' + ('claro' if outro == 'light' else 'escuro')).click()
assert page.evaluate("document.documentElement.dataset.theme") == outro
if capturas:
page.screenshot(path=str(Path(capturas) / ('tema-' + outro + '.png')))
assert not erros, erros
browser.close()
print('OK: token, SSE, dock 1-9, paleta, E-Stop, ajustes otimistas, conversa, memória, reações com desfazer, braço com homem-morto, celular 400px, tema')
if __name__ == '__main__':
main(sys.argv[1] if len(sys.argv) > 1 else None)
+8
View File
@@ -215,6 +215,14 @@ class BracoMovimentoTest(unittest.TestCase):
self.assertEqual(self.servos.active, {}) self.assertEqual(self.servos.active, {})
self.assertIsNone(self.servos.dono) self.assertIsNone(self.servos.dono)
def test_renovar_no_alvo_informa_que_chegou(self):
self.arm.move([90] * 7)
self.hw.calls.clear()
r = self.arm.move([90] * 7, velocidade=60, ttl_ms=400)
self.assertFalse(r['movendo'])
self.assertIsNone(self.arm.alvo)
self.assertEqual(len(self.hw.calls), 8) # reenergiza os 8 canais com o novo prazo
def test_parametros_invalidos(self): def test_parametros_invalidos(self):
for kwargs in (dict(velocidade=1000), dict(velocidade=True), dict(ttl_ms=50), dict(ttl_ms=5000)): for kwargs in (dict(velocidade=1000), dict(velocidade=True), dict(ttl_ms=50), dict(ttl_ms=5000)):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b1017" />
<title>Carinha</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230b1017'/%3E%3Crect x='14' y='20' width='12' height='22' rx='6' fill='%235cd6ff'/%3E%3Crect x='38' y='20' width='12' height='22' rx='6' fill='%235cd6ff'/%3E%3C/svg%3E" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1250
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "carinha-painel",
"private": true,
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^26.6.4",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^8.3.2"
},
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@lucide/svelte": "^1.52.0"
}
}
+364
View File
@@ -0,0 +1,364 @@
<script lang="ts">
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
import { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte';
import MiniRosto from './lib/componentes/MiniRosto.svelte';
import Paleta from './lib/componentes/Paleta.svelte';
import Selo from './lib/componentes/Selo.svelte';
import Token from './lib/componentes/Token.svelte';
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
import { conectar, estado } from './lib/estado.svelte';
import { paleta } from './lib/paleta.svelte';
import { nav, ROTAS, type Rota } from './lib/rotas.svelte';
import AoVivo from './telas/AoVivo.svelte';
import Ajustes from './telas/Ajustes.svelte';
import Conversa from './telas/Conversa.svelte';
import Movimento from './telas/Movimento.svelte';
import Reacoes from './telas/Reacoes.svelte';
const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo,
conversa: Conversa,
reacoes: Reacoes,
movimento: Movimento,
ajustes: Ajustes,
};
const ICONES: Record<Rota, Component<any>> = {
'ao-vivo': Video,
conversa: MessageCircle,
reacoes: Smile,
movimento: Hand,
ajustes: Settings,
};
const Tela = $derived(TELAS[nav.atual]);
// ---------- tema
let tema = $state<'dark' | 'light'>('dark');
function aplicarTema(t: 'dark' | 'light') {
tema = t;
document.documentElement.dataset.theme = t;
try {
localStorage.setItem('carinha_tema', t);
} catch {
/* ok */
}
}
// ---------- atalhos: Ctrl+K paleta · 1–9 gestos favoritos · Shift+Espaço ou Esc Esc = E-Stop · g+letra navega
let ultimoEsc = 0;
let prefixoG = 0;
function editando(e: KeyboardEvent) {
const alvo = e.target as HTMLElement;
return alvo.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(alvo.tagName);
}
function teclado(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
paleta.aberta ? paleta.fechar() : paleta.abrir();
return;
}
if (e.shiftKey && e.code === 'Space' && !editando(e)) {
e.preventDefault();
pararTudo();
return;
}
if (e.key === 'Escape' && !paleta.aberta) {
const agora = Date.now();
if (agora - ultimoEsc < 600) pararTudo();
ultimoEsc = agora;
return;
}
if (editando(e) || paleta.aberta || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key === 'g') {
prefixoG = Date.now();
return;
}
if (Date.now() - prefixoG < 800) {
const rota = ROTAS.find((r) => r.atalho === 'g ' + e.key);
prefixoG = 0;
if (rota) nav.ir(rota.id);
return;
}
if (/^[1-9]$/.test(e.key)) {
const g = favoritos.lista[Number(e.key) - 1];
if (g) gesto(g);
}
}
onMount(() => {
let salvo: string | null = null;
try {
salvo = localStorage.getItem('carinha_tema');
} catch {
/* ok */
}
aplicarTema(salvo === 'light' || (!salvo && matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark');
conectar();
});
const conexaoTexto = $derived(
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
);
const bateria = $derived.by(() => {
const b = estado.app?.estado?.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
});
</script>
<svelte:window onkeydown={teclado} />
{#if estado.conexao === 'token'}
<Token />
{/if}
<div class="app">
<nav class="lateral" aria-label="Seções">
<div class="marca">
<span class="logo" aria-hidden="true"><Bot size={20} /></span>
<div>
<strong>Carinha</strong>
<small class="fraco mono">{estado.versao ? 'v' + estado.versao : ''}</small>
</div>
</div>
<ul>
{#each ROTAS as r (r.id)}
{@const Icone = ICONES[r.id]}
<li>
<a href={'#/' + r.id} aria-current={nav.atual === r.id ? 'page' : undefined}>
<Icone size={20} /> <span>{r.titulo}</span>
</a>
</li>
{/each}
</ul>
<button class="btn fantasma paleta-btn" onclick={paleta.abrir}>
<Command size={16} /> Comandos <kbd>Ctrl K</kbd>
</button>
</nav>
<header class="topo">
<h1 class="titulo">{ROTAS.find((r) => r.id === nav.atual)?.titulo}</h1>
<div class="status linha">
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto}
</Selo>
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>
Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
</Selo>
{#if estado.conversa}
<Selo tom="destaque">IA: {estado.conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'}</Selo>
{/if}
</div>
<div class="acoes linha">
<button class="btn fantasma icone so-celular" onclick={paleta.abrir} aria-label="Comandos">
<Command size={18} />
</button>
<button
class="btn fantasma icone"
onclick={() => aplicarTema(tema === 'dark' ? 'light' : 'dark')}
aria-label={tema === 'dark' ? 'Usar tema claro' : 'Usar tema escuro'}
>
{#if tema === 'dark'}<Sun size={18} />{:else}<Moon size={18} />{/if}
</button>
<EStop />
</div>
</header>
<main class="conteudo">
<Tela />
</main>
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes'}
<MiniRosto />
{/if}
</div>
<Paleta />
<Avisos />
<style>
.app {
min-height: 100dvh;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: var(--topo-altura) 1fr;
grid-template-areas: 'topo' 'conteudo';
}
/* ---------- navegação: barra inferior no celular, lateral a partir de 1024px */
.lateral {
position: fixed;
z-index: 60;
left: 0;
right: 0;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
background: var(--vidro);
backdrop-filter: blur(16px);
border-top: 1px solid var(--borda);
}
.marca,
.paleta-btn {
display: none;
}
ul {
list-style: none;
margin: 0;
padding: var(--e-1);
display: grid;
grid-template-columns: repeat(5, 1fr);
}
li a {
display: grid;
justify-items: center;
gap: 2px;
padding: var(--e-2) 0;
border-radius: var(--raio);
color: var(--texto-3);
text-decoration: none;
font-size: 11px;
font-weight: 500;
}
li a[aria-current='page'] {
color: var(--destaque);
}
.topo {
grid-area: topo;
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: var(--e-3);
padding: 0 var(--e-4);
padding-top: env(safe-area-inset-top, 0px);
height: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
background: var(--vidro);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--borda);
}
.titulo {
font-size: var(--t-lg);
flex: none;
}
.status {
flex: 1;
min-width: 0;
overflow: hidden;
flex-wrap: nowrap;
}
.status :global(.selo:nth-child(n + 2)) {
display: none;
}
.acoes {
flex-wrap: nowrap;
}
.conteudo {
grid-area: conteudo;
padding: var(--e-4);
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
min-width: 0;
width: 100%;
max-width: 1440px;
margin: 0 auto;
}
kbd {
margin-left: auto;
font: 500 11px var(--fonte-mono);
color: var(--texto-3);
border: 1px solid var(--borda);
border-radius: 4px;
padding: 1px 5px;
}
@media (min-width: 640px) {
.status :global(.selo:nth-child(n + 2)) {
display: inline-flex;
}
.conteudo {
padding: var(--e-5);
padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px));
}
}
@media (min-width: 1024px) {
.app {
grid-template-columns: var(--nav-largura) 1fr;
grid-template-areas: 'nav topo' 'nav conteudo';
/* a coluna da navegação continua pintada quando a página rola além da viewport */
background: linear-gradient(
to right,
var(--fundo-2) calc(var(--nav-largura) - 1px),
var(--borda) 0 var(--nav-largura),
transparent 0
);
}
.lateral {
grid-area: nav;
position: sticky;
top: 0;
bottom: auto;
height: 100dvh;
display: flex;
flex-direction: column;
gap: var(--e-4);
padding: var(--e-4) var(--e-3);
border-top: 0;
border-right: 1px solid var(--borda);
background: var(--fundo-2);
backdrop-filter: none;
}
.marca {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-2);
}
.marca div {
display: grid;
line-height: 1.2;
}
.logo {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: var(--raio);
background: var(--destaque-suave);
color: var(--destaque);
}
ul {
grid-template-columns: 1fr;
gap: 2px;
padding: 0;
}
li a {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-2) var(--e-3);
font-size: var(--t-sm);
color: var(--texto-2);
}
li a:hover {
background: var(--superficie);
color: var(--texto);
}
li a[aria-current='page'] {
background: var(--destaque-suave);
color: var(--destaque);
}
.paleta-btn {
display: flex;
margin-top: auto;
justify-content: flex-start;
color: var(--texto-2);
}
.so-celular {
display: none;
}
.conteudo {
padding-bottom: var(--e-6);
}
}
</style>
+107
View File
@@ -0,0 +1,107 @@
/** Ajustes do app do celular (/app/config): salvamento otimista e campos agrupados. */
import { pedir, postar } from './api';
import { avisos } from './avisos.svelte';
import { estado } from './estado.svelte';
import type { AppConfig } from './tipos';
/** Atualiza na hora; se o servidor recusar, volta ao valor anterior e avisa. Não trava o formulário. */
export async function salvarApp(parcial: AppConfig) {
const antes = estado.appConfig ? { ...estado.appConfig } : null;
if (estado.appConfig) estado.appConfig = { ...estado.appConfig, ...parcial };
try {
const r = await postar<{ config: AppConfig }>('/app/config', parcial);
estado.appConfig = r.config;
} catch (e) {
estado.appConfig = antes;
avisos.erro(e);
}
}
export async function restaurarPadrao() {
const padrao = await pedir<AppConfig>('/app/padrao');
// A transmissão não é "ajuste": continua como está.
delete padrao.preview;
await salvarApp(padrao);
avisos.ok('Ajustes do celular restaurados para o padrão.');
}
type Opcoes = [string | number, string][];
export interface Campo {
chave: string;
rotulo: string;
ajuda?: string;
tipo: 'toggle' | 'slider' | 'escolha';
min?: number;
max?: number;
passo?: number;
unidade?: string;
opcoes?: Opcoes;
}
export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] = [
{
titulo: 'Tela e personalidade',
descricao: 'Como o rosto aparece e se comporta sozinho.',
campos: [
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
{ chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
{ chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
{ chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' },
{ chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
{ chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' },
],
},
{
titulo: 'Sensores e reações',
descricao: 'O que o celular percebe e como reage.',
campos: [
{ chave: 'sensorMic', rotulo: 'Microfone', ajuda: 'Só o nível de som é usado, nunca o áudio', tipo: 'toggle' },
{ chave: 'ouvir', rotulo: 'Reagir a sons', tipo: 'toggle' },
{ chave: 'micSono', rotulo: 'Microfone durante o sono', tipo: 'toggle' },
{ chave: 'sompausa', rotulo: 'Silêncio para dormir', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' s' },
{ chave: 'sensorCamera', rotulo: 'Câmera como sensor', ajuda: 'Detecta movimento e permite o vídeo', tipo: 'toggle' },
{ chave: 'reagir', rotulo: 'Reagir a movimento', tipo: 'toggle' },
{ chave: 'cameraSono', rotulo: 'Câmera durante o sono', tipo: 'toggle' },
{ chave: 'movolhar', rotulo: 'Quanto os olhos acompanham', tipo: 'slider', min: 0, max: 100, passo: 1, unidade: '%' },
],
},
{
titulo: 'Vídeo e rastreamento',
descricao: 'Qualidade da transmissão e rastreamento feito neste PC.',
campos: [
{ chave: 'cameraLado', rotulo: 'Câmera', tipo: 'escolha', opcoes: [['user', 'Frontal'], ['environment', 'Traseira']] },
{
chave: 'videoResolucao',
rotulo: 'Resolução',
tipo: 'escolha',
opcoes: [['auto', 'Automática'], ['max', 'Máxima'], ['480', '480p'], ['720', '720p'], ['1080', '1080p'], ['2160', '4K'], ['4320', '8K']],
},
{
chave: 'videoFPS',
rotulo: 'Quadros por segundo',
tipo: 'escolha',
opcoes: [[0, 'Automático'], [-1, 'Máximo'], [15, '15'], [24, '24'], [30, '30'], [60, '60'], [120, '120'], [240, '240']],
},
{ chave: 'rastrearRosto', rotulo: 'Rastrear rosto', tipo: 'toggle' },
{ chave: 'rastrearMaos', rotulo: 'Rastrear mãos', tipo: 'toggle' },
{ chave: 'seguirRastreamento', rotulo: 'Olhos seguem o alvo', tipo: 'toggle' },
{ chave: 'mostrarRastreamento', rotulo: 'Desenhar rastreamento no vídeo', tipo: 'toggle' },
],
},
{
titulo: 'Sensibilidade (avançado)',
descricao: 'Limiares dos sustos e intervalos entre reações.',
campos: [
{ chave: 'somlim', rotulo: 'Nível para perceber som', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'somaltolim', rotulo: 'Som mínimo para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'somganho', rotulo: 'Aumento súbito para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'somcooldown', rotulo: 'Intervalo entre sustos por som', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
{ chave: 'movlim', rotulo: 'Movimento mínimo', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'movforte', rotulo: 'Movimento para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'movcooldown', rotulo: 'Intervalo entre sustos por movimento', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
],
},
];
+70
View File
@@ -0,0 +1,70 @@
/** Cliente HTTP único do painel: token, JSON, tempo limite e erro legível. */
const CHAVE_TOKEN = 'carinha_token';
export class ErroApi extends Error {
constructor(public status: number, mensagem: string) {
super(mensagem);
}
}
export function lerToken(): string {
try {
return localStorage.getItem(CHAVE_TOKEN) || '';
} catch {
return '';
}
}
export function gravarToken(token: string) {
try {
if (token) localStorage.setItem(CHAVE_TOKEN, token);
else localStorage.removeItem(CHAVE_TOKEN);
} catch {
/* navegação privada: vale só nesta aba */
}
}
/** Avisado quando o servidor recusa o token (abre o diálogo de token). */
let aoNegar: () => void = () => {};
export function quandoNegado(fn: () => void) {
aoNegar = fn;
}
export async function pedir<T = any>(caminho: string, dados?: unknown, tempo = 8000): Promise<T> {
const cabecalhos: Record<string, string> = {};
const token = lerToken();
if (token) cabecalhos.Authorization = 'Bearer ' + token;
const opcoes: RequestInit = { cache: 'no-store', headers: cabecalhos, signal: AbortSignal.timeout(tempo) };
if (dados !== undefined) {
opcoes.method = 'POST';
cabecalhos['Content-Type'] = 'application/json';
opcoes.body = JSON.stringify(dados);
}
let resposta: Response;
try {
resposta = await fetch(caminho, opcoes);
} catch (e) {
const nome = (e as Error).name;
throw new ErroApi(0, nome === 'TimeoutError' ? 'O servidor demorou a responder' : 'Sem conexão com o servidor');
}
const texto = await resposta.text();
let corpo: any = null;
try {
corpo = texto ? JSON.parse(texto) : null;
} catch {
corpo = null;
}
if (!resposta.ok) {
if (resposta.status === 401) aoNegar();
throw new ErroApi(resposta.status, corpo?.erro || `Erro ${resposta.status}`);
}
return corpo as T;
}
export const postar = <T = any>(caminho: string, dados: unknown = {}) => pedir<T>(caminho, dados);
export function urlEventos(): string {
const token = lerToken();
return token ? '/eventos?t=' + encodeURIComponent(token) : '/eventos';
}
+41
View File
@@ -0,0 +1,41 @@
/** Toasts no lugar de alert/confirm: mensagem curta, com ação opcional (ex.: Desfazer). */
export type TipoAviso = 'info' | 'ok' | 'erro';
export interface Aviso {
id: number;
texto: string;
tipo: TipoAviso;
acao?: { rotulo: string; fazer: () => void };
}
class Avisos {
lista = $state<Aviso[]>([]);
private seq = 0;
mostrar(texto: string, tipo: TipoAviso = 'info', acao?: Aviso['acao'], ms = tipo === 'erro' ? 6000 : 3500) {
const id = ++this.seq;
this.lista = [...this.lista, { id, texto, tipo, acao }].slice(-4);
setTimeout(() => this.fechar(id), acao ? Math.max(ms, 6000) : ms);
return id;
}
fechar(id: number) {
this.lista = this.lista.filter((a) => a.id !== id);
}
ok = (t: string, acao?: Aviso['acao']) => this.mostrar(t, 'ok', acao);
erro = (e: unknown) => this.mostrar(e instanceof Error ? e.message : String(e), 'erro');
}
export const avisos = new Avisos();
/** Executa uma ação do painel mostrando o erro num toast em vez de engolir. */
export async function tentar<T>(fn: () => Promise<T>, sucesso?: string): Promise<T | undefined> {
try {
const r = await fn();
if (sucesso) avisos.ok(sucesso);
return r;
} catch (e) {
avisos.erro(e);
return undefined;
}
}
+214
View File
@@ -0,0 +1,214 @@
/**
* Braço (/braco/*) e diagnóstico (/servos/*). Segurança:
* - homem-morto: só há movimento enquanto o botão/slider está pressionado; cada pedido leva
* um prazo curto (ttl_ms) e o servidor desliga os motores se ele vencer sem renovação;
* - a interpolação e o limite de velocidade são do servidor, não deste navegador.
*/
import { pedir, postar } from './api';
import { avisos } from './avisos.svelte';
import { estado } from './estado.svelte';
export interface Calibracao {
canal: number;
minimo: number;
maximo: number;
centro: number;
}
export interface Postura {
nome: string;
angulos: number[];
}
export interface StatusBraco {
calibracao: Calibracao[];
posturas: Postura[];
disponivel: boolean;
erro: string;
dono: string | null;
travado: boolean;
atual: number[] | null;
movendo: boolean;
articulacoes: { nome: string; canal: number }[];
}
export const PRAZO_MS = 450;
const RENOVAR_MS = 150;
function anguloDoPulso(p: number, c: Calibracao) {
const centro = c.centro ?? 1500;
return p <= centro ? (90 * (p - 500)) / (centro - 500) : 90 + (90 * (p - centro)) / (2500 - centro);
}
/** Faixa segura (graus) de cada articulação a partir da calibração; o canal 5 respeita o par 6 invertido. */
export function limites(st: StatusBraco): [number, number][] {
return st.articulacoes.map(({ canal }) => {
const c = st.calibracao[canal];
let lo = anguloDoPulso(c.minimo, c);
let hi = anguloDoPulso(c.maximo, c);
if (canal === 5) {
const par = st.calibracao[6];
lo = Math.max(lo, 180 - anguloDoPulso(par.maximo, par));
hi = Math.min(hi, 180 - anguloDoPulso(par.minimo, par));
}
return [Math.ceil(lo), Math.floor(hi)];
});
}
/** As 5 sequências prontas, escaladas para a faixa calibrada (porta de movimentos-core.js). */
export function sequencias(faixas: [number, number][]): { nome: string; passos: number[][] }[] {
const neutro = [0, 0, 0, 0, 0, 0, 0];
const base: [string, number[][]][] = [
['Acenar', [[0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], [0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], neutro]],
['Olhar para os lados', [[0, 0.2, -0.4, 0, 0, 0, -0.7], [0, -0.2, 0.4, 0, 0, 0, 0.7], neutro]],
['Alcançar e recolher', [[0, 0, 0.2, 0.6, -0.5, 0.4, 0], [0, 0, -0.2, -0.4, 0.5, -0.4, 0], neutro]],
['Garra e punho', [[-0.7, 0.3, 0, 0.2, 0, 0.1, 0], [0.7, -0.3, 0, 0.2, 0, 0.1, 0], [-0.7, 0.3, 0, 0, 0, 0, 0], neutro]],
['Apresentação', [[0.3, 0.3, -0.3, 0.4, -0.3, 0.3, -0.6], [-0.3, -0.3, 0.3, -0.3, 0.4, -0.3, 0.6], [0.5, 0.4, 0.2, 0.3, -0.3, 0.2, 0], neutro]],
];
return base.map(([nome, passos]) => ({
nome,
passos: passos.map((s) =>
s.map((v, i) => {
const [lo, hi] = faixas[i];
// Centro 90°, mas sempre dentro da faixa calibrada.
const meio = Math.max(lo, Math.min(hi, 90));
return Math.round(meio + v * (v < 0 ? meio - lo : hi - meio));
}),
),
}));
}
class Braco {
status = $state<StatusBraco | null>(null);
alvo = $state<number[]>(Array(7).fill(90));
velocidade = $state(60);
/** Opt-in explícito antes de qualquer movimento físico. */
ativado = $state(false);
segurando = $state(false);
passoSequencia = $state<{ nome: string; indice: number; total: number } | null>(null);
private relogio = 0;
private emVoo = false;
private sequencia: number[][] | null = null;
private pausaAte = 0;
faixas = $derived(this.status ? limites(this.status) : []);
async carregar() {
try {
this.status = await pedir<StatusBraco>('/braco/status');
this.limitarAlvo();
} catch (e) {
avisos.erro(e);
}
}
limitarAlvo() {
if (!this.faixas.length) return;
this.alvo = this.alvo.map((v, i) => Math.max(this.faixas[i][0], Math.min(this.faixas[i][1], v)));
}
/** Começa a segurar: renova o movimento a cada 150 ms até soltar. */
segurar(sequencia?: { nome: string; passos: number[][] }) {
if (!this.ativado || this.segurando) return;
if (sequencia) {
this.sequencia = sequencia.passos;
this.passoSequencia = { nome: sequencia.nome, indice: 0, total: sequencia.passos.length };
this.alvo = [...sequencia.passos[0]];
this.pausaAte = 0;
}
this.segurando = true;
this.renovar();
this.relogio = window.setInterval(() => this.renovar(), RENOVAR_MS);
}
/** Soltou: para de renovar. O servidor desliga os motores quando o prazo vence (≤ 450 ms). */
soltar() {
if (!this.segurando) return;
this.segurando = false;
clearInterval(this.relogio);
if (this.passoSequencia) avisos.mostrar(`${this.passoSequencia.nome} interrompida ao soltar.`);
this.sequencia = null;
this.passoSequencia = null;
}
private async renovar() {
if (this.emVoo || !this.segurando) return;
this.emVoo = true;
try {
const r = await postar<{ movendo: boolean }>('/braco/mover', {
angulos: $state.snapshot(this.alvo),
velocidade: this.velocidade,
ttl_ms: PRAZO_MS,
});
if (!r.movendo && this.sequencia && this.passoSequencia) this.avancarSequencia();
} catch (e) {
this.segurando = false;
clearInterval(this.relogio);
this.sequencia = null;
this.passoSequencia = null;
avisos.erro(e);
} finally {
this.emVoo = false;
}
}
private avancarSequencia() {
if (!this.pausaAte) {
this.pausaAte = performance.now() + 700;
return;
}
if (performance.now() < this.pausaAte) return;
this.pausaAte = 0;
const p = this.passoSequencia!;
if (p.indice + 1 >= p.total) {
avisos.ok(`${p.nome} concluída.`);
this.sequencia = null;
this.passoSequencia = null;
this.segurando = false;
clearInterval(this.relogio);
return;
}
this.passoSequencia = { ...p, indice: p.indice + 1 };
this.alvo = [...this.sequencia![p.indice + 1]];
}
async pararMotores() {
this.soltar();
try {
await postar('/braco/parar');
} catch (e) {
avisos.erro(e);
}
}
async desativar() {
this.ativado = false;
await this.pararMotores();
}
async salvar(dados: { calibracao: Calibracao[]; posturas: Postura[] }, sucesso: string) {
this.soltar();
try {
await postar('/braco/parar');
this.status = await postar<StatusBraco>('/braco/config', dados);
this.limitarAlvo();
avisos.ok(sucesso);
return true;
} catch (e) {
avisos.erro(e);
return false;
}
}
}
export const braco = new Braco();
// Segurança: aba escondida ou E-Stop param de segurar na hora.
document.addEventListener('visibilitychange', () => document.hidden && braco.soltar());
$effect.root(() => {
$effect(() => {
if (estado.estop.ativo) {
braco.soltar();
braco.ativado = false;
}
});
});
+89
View File
@@ -0,0 +1,89 @@
/** Ações que qualquer tela pode disparar: gestos, rosto, cor, E-Stop. */
import { postar } from './api';
import { avisos, tentar } from './avisos.svelte';
import { estado } from './estado.svelte';
/** Nome amigável e emoji de cada gesto do rosto ('parar' fecha o kiosk: não vira botão). */
export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
feliz: { nome: 'Feliz', emoji: '😊' },
alegre: { nome: 'Alegre', emoji: '😄' },
coracao: { nome: 'Coração', emoji: '❤️' },
piscar: { nome: 'Piscar', emoji: '😌' },
piscar2: { nome: 'Piscar duplo', emoji: '😑' },
piscadinha: { nome: 'Piscadinha', emoji: '😉' },
surpreso: { nome: 'Surpreso', emoji: '😮' },
triste: { nome: 'Triste', emoji: '😢' },
bravo: { nome: 'Bravo', emoji: '😠' },
olhar_esq: { nome: 'Olhar à esquerda', emoji: '👈' },
olhar_dir: { nome: 'Olhar à direita', emoji: '👉' },
olhar_baixo: { nome: 'Olhar para baixo', emoji: '👇' },
dormir: { nome: 'Dormir', emoji: '😴' },
acordar: { nome: 'Acordar', emoji: '🌅' },
aleatorio: { nome: 'Aleatório', emoji: '🎲' },
};
export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
const CHAVE_FAVORITOS = 'carinha_favoritos';
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'piscadinha', 'surpreso', 'triste', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
function lerFavoritos(): string[] {
try {
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return v.slice(0, 9);
} catch {
/* sem armazenamento */
}
return PADRAO_FAVORITOS;
}
class Favoritos {
lista = $state<string[]>(lerFavoritos());
alternar(g: string) {
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, 9);
try {
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
} catch {
/* ok */
}
}
}
export const favoritos = new Favoritos();
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome }));
export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb }));
export const recarregarCelular = () =>
tentar(async () => {
const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
avisos.ok(r.mensagem);
});
export async function pararTudo() {
try {
await postar('/parar-tudo');
avisos.mostrar('E-Stop acionado: motores desligados e travados.', 'erro');
} catch (e) {
avisos.erro(e);
}
}
export const rearmar = () => tentar(() => postar('/rearmar'), 'Motores rearmados.');
export function hex(rgb: number[]) {
return '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join('');
}
export function rgb(hexCor: string): [number, number, number] {
const n = parseInt(hexCor.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export const CORES: { nome: string; rgb: [number, number, number] }[] = [
{ nome: 'Ciano', rgb: [92, 214, 255] },
{ nome: 'Verde', rgb: [74, 222, 128] },
{ nome: 'Amarelo', rgb: [251, 191, 36] },
{ nome: 'Laranja', rgb: [255, 140, 50] },
{ nome: 'Rosa', rgb: [255, 110, 190] },
{ nome: 'Roxo', rgb: [167, 139, 250] },
{ nome: 'Vermelho', rgb: [248, 90, 90] },
{ nome: 'Branco', rgb: [235, 240, 245] },
];
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts" generics="T extends string">
let { abas, ativa = $bindable(), rotulo }: { abas: { id: T; titulo: string; contador?: number }[]; ativa: T; rotulo: string } =
$props();
function tecla(e: KeyboardEvent, i: number) {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
const prox = abas[(i + d + abas.length) % abas.length];
ativa = prox.id;
(e.currentTarget as HTMLElement).parentElement?.querySelector<HTMLElement>(`[data-aba="${prox.id}"]`)?.focus();
}
</script>
<div class="abas" role="tablist" aria-label={rotulo}>
{#each abas as aba, i (aba.id)}
<button
role="tab"
data-aba={aba.id}
aria-selected={ativa === aba.id}
tabindex={ativa === aba.id ? 0 : -1}
onclick={() => (ativa = aba.id)}
onkeydown={(e) => tecla(e, i)}
>
{aba.titulo}
{#if aba.contador !== undefined}<span class="contador">{aba.contador}</span>{/if}
</button>
{/each}
</div>
<style>
.abas {
display: flex;
gap: var(--e-1);
border-bottom: 1px solid var(--borda);
overflow-x: auto;
scrollbar-width: none;
}
button {
display: inline-flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
min-height: 40px;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
color: var(--texto-3);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
white-space: nowrap;
}
button[aria-selected='true'] {
color: var(--texto);
border-bottom-color: var(--destaque);
}
.contador {
font-size: var(--t-xs);
padding: 0 6px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
color: var(--texto-2);
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import { avisos } from '../avisos.svelte';
</script>
<div class="avisos" role="status" aria-live="polite">
{#each avisos.lista as a (a.id)}
<div class="aviso {a.tipo}">
<span>{a.texto}</span>
{#if a.acao}
<button
class="btn pequeno"
onclick={() => {
a.acao!.fazer();
avisos.fechar(a.id);
}}>{a.acao.rotulo}</button
>
{/if}
<button class="btn fantasma pequeno icone" aria-label="Fechar aviso" onclick={() => avisos.fechar(a.id)}>
<X size={16} />
</button>
</div>
{/each}
</div>
<style>
.avisos {
position: fixed;
z-index: 90;
right: var(--e-4);
bottom: calc(var(--e-4) + env(safe-area-inset-bottom, 0px));
display: grid;
gap: var(--e-2);
width: min(420px, calc(100vw - 2 * var(--e-4)));
}
.aviso {
display: flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-2) var(--e-2) var(--e-4);
border-radius: var(--raio);
background: var(--superficie-2);
border: 1px solid var(--borda-forte);
border-left: 4px solid var(--destaque);
box-shadow: var(--sombra-flutuante);
font-size: var(--t-sm);
}
.aviso span {
flex: 1;
}
.ok {
border-left-color: var(--ok);
}
.erro {
border-left-color: var(--perigo);
}
@media (max-width: 1023px) {
.avisos {
bottom: calc(76px + env(safe-area-inset-bottom, 0px));
}
}
</style>
@@ -0,0 +1,96 @@
<script lang="ts">
// Vista lateral esquemática: cada articulação é um <g> aninhado que gira no próprio eixo.
// Ordem dos ângulos: 0 garra, 1 punho 1, 2 punho 2, 3 braço superior, 4 braço inferior, 5 antebraço, 6 base.
let { pose, fantasma = null }: { pose: number[]; fantasma?: number[] | null } = $props();
const r = (v: number) => v - 90;
</script>
{#snippet braco(p: number[], classe: string)}
<g class={classe}>
<g transform="translate(120 236) rotate({r(p[4])})">
<line x1="0" y1="0" x2="0" y2="-78" class="seg" />
<g transform="translate(0 -78) rotate({r(p[3])})">
<line x1="0" y1="0" x2="0" y2="-66" class="seg" />
<g transform="translate(0 -66) rotate({r(p[5])})">
<line x1="0" y1="0" x2="0" y2="-40" class="seg fino" />
<g transform="translate(0 -40) rotate({r(p[1])})">
<line x1="0" y1="0" x2="0" y2="-18" class="seg fino" />
<ellipse cx="0" cy="-18" rx={7 * Math.abs(Math.cos(((p[2] - 90) * Math.PI) / 180)) + 2} ry="4" class="punho" />
<g transform="translate(0 -20)">
<path d="M0 0 L-4 -16 L-1 -24" class="dedo" transform="rotate({-8 - (p[0] - 90) * 0.25})" />
<path d="M0 0 L4 -16 L1 -24" class="dedo" transform="rotate({8 + (p[0] - 90) * 0.25})" />
</g>
<circle r="4" class="junta" />
</g>
<circle r="5" class="junta" />
</g>
<circle r="6" class="junta" />
</g>
<circle r="7" class="junta" />
</g>
</g>
{/snippet}
<svg viewBox="0 0 240 290" role="img" aria-label="Postura do braço">
<!-- base: o arco mostra a rotação (vista de cima) -->
<rect x="80" y="240" width="80" height="16" rx="5" class="base" />
<path d="M85 274 A40 14 0 0 0 155 274" class="trilho" />
<circle
cx={120 + 35 * Math.cos(Math.PI - (pose[6] * Math.PI) / 180)}
cy={274 + 12 * Math.sin((pose[6] * Math.PI) / 180) * 0.4}
r="4"
class="marcador"
/>
{#if fantasma}{@render braco(fantasma, 'fantasma')}{/if}
{@render braco(pose, 'real')}
</svg>
<style>
svg {
width: 100%;
height: 100%;
display: block;
}
.seg {
stroke: var(--destaque);
stroke-width: 12;
stroke-linecap: round;
}
.fino {
stroke-width: 8;
}
.junta {
fill: var(--superficie);
stroke: var(--texto-2);
stroke-width: 2;
}
.punho {
fill: var(--texto-2);
}
.dedo {
fill: none;
stroke: var(--texto);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.base {
fill: var(--superficie-3);
stroke: var(--borda-forte);
}
.trilho {
fill: none;
stroke: var(--borda-forte);
stroke-width: 2;
stroke-dasharray: 3 4;
}
.marcador {
fill: var(--aviso);
}
.fantasma {
opacity: 0.25;
}
.fantasma .seg {
stroke: var(--texto-3);
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { titulo = '', descricao = '', acoes, children, class: classe = '' }: {
titulo?: string;
descricao?: string;
acoes?: Snippet;
children: Snippet;
class?: string;
} = $props();
</script>
<section class="card {classe}">
{#if titulo || acoes}
<header>
<div>
{#if titulo}<h2>{titulo}</h2>{/if}
{#if descricao}<p class="fraco pequeno-texto">{descricao}</p>{/if}
</div>
{#if acoes}<div class="linha">{@render acoes()}</div>{/if}
</header>
{/if}
{@render children()}
</section>
<style>
.card {
background: var(--superficie);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
padding: var(--e-4);
display: grid;
gap: var(--e-4);
align-content: start;
min-width: 0;
}
header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--e-3);
flex-wrap: wrap;
}
h2 {
font-size: var(--t-md);
}
@media (min-width: 640px) {
.card {
padding: var(--e-5);
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { OctagonX, RotateCcw } from '@lucide/svelte';
import { pararTudo, rearmar } from '../comandos.svelte';
import { estado } from '../estado.svelte';
</script>
{#if estado.estop.ativo}
<button class="btn estop parado" onclick={rearmar} title="Motores travados pelo E-Stop. Clique para rearmar.">
<RotateCcw size={18} /> <span>PARADO · Rearmar</span>
</button>
{:else}
<button class="btn perigo estop" onclick={pararTudo} title="Parar tudo (Shift+Espaço ou Esc duas vezes)">
<OctagonX size={18} /> <span>Parar tudo</span>
</button>
{/if}
<style>
.estop {
font-weight: 700;
letter-spacing: 0.02em;
}
.parado {
background: var(--aviso-suave);
border-color: var(--aviso);
color: var(--aviso);
animation: pulso 1.6s ease-in-out infinite;
}
@keyframes pulso {
50% {
box-shadow: 0 0 0 4px var(--aviso-suave);
}
}
@media (max-width: 480px) {
span {
display: none;
}
.estop {
width: 44px;
padding: 0;
}
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import { Star } from '@lucide/svelte';
import { favoritos, gesto, GESTOS_INFO, gestosDisponiveis } from '../comandos.svelte';
// compacto: só os favoritos com atalho 1–9 (dock). Completo: todos, com estrela para fixar.
let { completo = false }: { completo?: boolean } = $props();
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => g in GESTOS_INFO));
</script>
<div class="gestos" class:completo>
{#each lista as g, i (g)}
{@const info = GESTOS_INFO[g]}
<div class="item">
<button class="gesto" onclick={() => gesto(g)} title={completo ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<span class="emoji" aria-hidden="true">{info.emoji}</span>
<span class="nome">{info.nome}</span>
{#if !completo}<kbd>{i + 1}</kbd>{/if}
</button>
{#if completo}
<button
class="estrela"
class:ativa={favoritos.lista.includes(g)}
aria-label={favoritos.lista.includes(g) ? `Tirar ${info.nome} dos favoritos` : `Fixar ${info.nome} nos favoritos`}
aria-pressed={favoritos.lista.includes(g)}
onclick={() => favoritos.alternar(g)}
>
<Star size={14} />
</button>
{/if}
</div>
{/each}
</div>
<style>
.gestos {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: var(--e-2);
}
.item {
position: relative;
}
.gesto {
width: 100%;
display: grid;
justify-items: center;
gap: 2px;
padding: var(--e-3) var(--e-1) var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--texto);
font: inherit;
cursor: pointer;
position: relative;
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
}
.gesto:hover {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.gesto:active {
transform: scale(0.97);
}
.emoji {
font-size: 1.6rem;
line-height: 1.2;
}
.nome {
font-size: var(--t-xs);
color: var(--texto-2);
text-align: center;
}
kbd {
position: absolute;
top: 4px;
right: 6px;
font: 500 10px var(--fonte-mono);
color: var(--texto-3);
}
.estrela {
position: absolute;
top: 2px;
right: 2px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--texto-3);
cursor: pointer;
}
.estrela.ativa {
color: var(--aviso);
}
.estrela.ativa :global(svg) {
fill: currentColor;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import { ChevronDown, ChevronUp } from '@lucide/svelte';
import { estado } from '../estado.svelte';
import RostoPrevia from './RostoPrevia.svelte';
// Um só mini-rosto em todas as telas (exceto Ao vivo, que já tem o rosto grande).
let aberto = $state(true);
try {
aberto = localStorage.getItem('carinha_mini') !== '0';
} catch {
/* ok */
}
function alternar() {
aberto = !aberto;
try {
localStorage.setItem('carinha_mini', aberto ? '1' : '0');
} catch {
/* ok */
}
}
</script>
<aside class="mini" class:aberto aria-label="Rosto ao vivo">
<button class="barra" onclick={alternar} aria-expanded={aberto}>
<span class="ponto" class:online={estado.celularOnline} aria-hidden="true"></span>
<span>Rosto {estado.celularOnline ? 'ao vivo' : '· celular offline'}</span>
{#if aberto}<ChevronDown size={16} />{:else}<ChevronUp size={16} />{/if}
</button>
{#if aberto}
<div class="tela"><RostoPrevia /></div>
{/if}
</aside>
<style>
.mini {
position: fixed;
z-index: 55;
right: var(--e-4);
bottom: calc(84px + env(safe-area-inset-bottom, 0px));
width: 220px;
border-radius: var(--raio);
overflow: hidden;
background: var(--vidro);
backdrop-filter: blur(16px);
border: 1px solid var(--borda-forte);
box-shadow: var(--sombra-flutuante);
}
.barra {
width: 100%;
display: flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
border: 0;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-xs) var(--fonte);
cursor: pointer;
}
.barra span:nth-child(2) {
flex: 1;
text-align: left;
}
.ponto {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--texto-3);
}
.ponto.online {
background: var(--ok);
}
.tela {
aspect-ratio: 16 / 9;
}
@media (max-width: 639px) {
.mini {
width: 160px;
}
}
@media (min-width: 1024px) {
.mini {
bottom: var(--e-4);
width: 260px;
}
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { SIMBOLO_EMOJI } from '../treinamento.svelte';
// Prévia estática de uma reação (sem iframe): figurinha SVG, emoji ou olhos do 1º passo.
let { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
const passo = $derived(receita?.passos?.[0]);
const simbolo = $derived(receita?.emoji?.caractere || SIMBOLO_EMOJI[receita?.simbolo] || '');
</script>
<div class="miniatura" style="--t: {tamanho}px" aria-hidden="true">
{#if receita?.figurinha?.formas}
<svg viewBox="0 0 100 100" width={tamanho} height={tamanho}>
{#each receita.figurinha.formas as forma, i (i)}
<path d={forma.caminho} fill={forma.cor} />
{/each}
</svg>
{:else if receita?.emoji}
<span class="emoji">{receita.emoji.caractere}</span>
{:else}
<svg viewBox="0 0 100 60" width={tamanho} height={tamanho * 0.6}>
{#if passo?.olhos === 'feliz'}
<path d="M18 38 Q30 18 42 38 M58 38 Q70 18 82 38" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
{:else if passo?.olhos === 'triste'}
<path d="M18 26 Q30 44 42 26 M58 26 Q70 44 82 26" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
{:else}
{@const ry = passo?.olhos === 'surpreso' ? 20 : 16}
<ellipse cx="30" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
<ellipse cx="70" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
{/if}
</svg>
{#if simbolo}<span class="simbolo">{simbolo}</span>{/if}
{/if}
</div>
<style>
.miniatura {
position: relative;
width: var(--t);
height: var(--t);
display: grid;
place-items: center;
border-radius: var(--raio);
background: #05080c;
flex: none;
}
.emoji {
font-size: calc(var(--t) * 0.55);
line-height: 1;
}
.simbolo {
position: absolute;
right: 2px;
bottom: 0;
font-size: calc(var(--t) * 0.3);
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import type { Snippet } from 'svelte';
// <dialog> nativo: foco preso, Esc e fundo vêm de graça.
let { aberto = $bindable(false), titulo, children }: { aberto: boolean; titulo: string; children: Snippet } = $props();
let dialogo: HTMLDialogElement;
$effect(() => {
if (aberto && !dialogo.open) dialogo.showModal();
else if (!aberto && dialogo.open) dialogo.close();
});
</script>
<dialog
bind:this={dialogo}
onclose={() => (aberto = false)}
onclick={(e) => e.target === dialogo && (aberto = false)}
aria-label={titulo}
>
<div class="conteudo">
<header>
<h2>{titulo}</h2>
<button class="btn fantasma icone pequeno" aria-label="Fechar" onclick={() => (aberto = false)}><X size={18} /></button>
</header>
{#if aberto}{@render children()}{/if}
</div>
</dialog>
<style>
dialog {
width: min(480px, calc(100vw - 2 * var(--e-4)));
padding: 0;
border: 1px solid var(--borda-forte);
border-radius: var(--raio-lg);
background: var(--superficie);
color: var(--texto);
box-shadow: var(--sombra-flutuante);
}
dialog::backdrop {
background: rgb(0 0 0 / 0.5);
}
.conteudo {
display: grid;
gap: var(--e-4);
padding: var(--e-5);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
h2 {
font-size: var(--t-lg);
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import { Search } from '@lucide/svelte';
import { itensDaPaleta, paleta, type ItemPaleta } from '../paleta.svelte';
let busca = $state('');
let ativo = $state(0);
let campo = $state<HTMLInputElement>();
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const itens = $derived.by(() => {
const termos = normal(busca).split(/\s+/).filter(Boolean);
return itensDaPaleta()
.filter((i) => termos.every((t) => normal(i.grupo + ' ' + i.titulo).includes(t)))
.slice(0, 50);
});
$effect(() => {
if (paleta.aberta) {
busca = '';
ativo = 0;
queueMicrotask(() => campo?.focus());
}
});
$effect(() => {
void busca;
ativo = 0;
});
function executar(i: ItemPaleta | undefined) {
if (!i) return;
paleta.fechar();
i.fazer();
}
function tecla(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
ativo = Math.min(itens.length - 1, ativo + 1);
e.preventDefault();
} else if (e.key === 'ArrowUp') {
ativo = Math.max(0, ativo - 1);
e.preventDefault();
} else if (e.key === 'Enter') {
executar(itens[ativo]);
e.preventDefault();
} else if (e.key === 'Escape') {
paleta.fechar();
e.stopPropagation();
}
}
</script>
{#if paleta.aberta}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="fundo" onclick={paleta.fechar}>
<div class="caixa" role="dialog" tabindex="-1" aria-modal="true" aria-label="Comandos" onclick={(e) => e.stopPropagation()}>
<div class="busca">
<Search size={18} />
<input
bind:this={campo}
bind:value={busca}
onkeydown={tecla}
placeholder="Gesto, rosto, tela ou ação…"
aria-label="Buscar comando"
role="combobox"
aria-expanded="true"
aria-controls="paleta-lista"
aria-activedescendant={itens[ativo] ? 'pal-' + itens[ativo].id : undefined}
/>
<kbd>Esc</kbd>
</div>
<ul id="paleta-lista" role="listbox">
{#each itens as item, i (item.id)}
{#if i === 0 || itens[i - 1].grupo !== item.grupo}
<li class="grupo" role="presentation">{item.grupo}</li>
{/if}
<li
id={'pal-' + item.id}
role="option"
aria-selected={i === ativo}
class:ativo={i === ativo}
onmousemove={() => (ativo = i)}
onclick={() => executar(item)}
>
<span>{item.titulo}</span>
{#if item.dica}<kbd>{item.dica}</kbd>{/if}
</li>
{:else}
<li class="vazio">Nada encontrado.</li>
{/each}
</ul>
</div>
</div>
{/if}
<style>
.fundo {
position: fixed;
inset: 0;
z-index: 95;
background: rgb(0 0 0 / 0.45);
display: grid;
justify-items: center;
align-items: start;
padding: 12vh var(--e-4) var(--e-4);
}
.caixa {
width: min(560px, 100%);
max-height: 70vh;
display: grid;
grid-template-rows: auto 1fr;
background: var(--vidro);
backdrop-filter: blur(18px);
border: 1px solid var(--borda-forte);
border-radius: var(--raio-lg);
box-shadow: var(--sombra-flutuante);
overflow: hidden;
}
.busca {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-3) var(--e-4);
border-bottom: 1px solid var(--borda);
color: var(--texto-3);
}
.busca input {
border: 0;
background: transparent;
min-height: 36px;
font-size: var(--t-md);
padding: 0;
}
.busca input:focus-visible {
outline: none;
}
ul {
list-style: none;
margin: 0;
padding: var(--e-2);
overflow: auto;
}
li[role='option'] {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
cursor: pointer;
font-size: var(--t-sm);
}
li.ativo {
background: var(--destaque-suave);
color: var(--destaque);
}
.grupo {
padding: var(--e-3) var(--e-3) var(--e-1);
font-size: var(--t-xs);
color: var(--texto-3);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.vazio {
padding: var(--e-4);
color: var(--texto-3);
font-size: var(--t-sm);
}
kbd {
font: 500 11px var(--fonte-mono);
color: var(--texto-3);
border: 1px solid var(--borda);
border-radius: 4px;
padding: 1px 5px;
}
</style>
@@ -0,0 +1,62 @@
<script lang="ts">
/**
* Prévia do rosto: o próprio carinha.html em modo ?painel=1 (sem polling).
* O painel repassa por postMessage o que chega no SSE, então a prévia mostra
* exatamente as mesmas ordens que o celular recebe.
*/
import { onDestroy } from 'svelte';
import { estado, ouvir } from '../estado.svelte';
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props();
let quadro: HTMLIFrameElement;
let pronto = $state(false);
function enviar(msg: unknown) {
if (pronto) quadro?.contentWindow?.postMessage(msg, location.origin);
}
function aoCarregar() {
pronto = true;
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
}
let ultimaConversa = estado.conversa?.ultima?.id;
const cancelar = [
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
ouvir('app_config', (c) => enviar({ tipo: 'config', config: c })),
ouvir('reacao', (r) => r?.receita && enviar({ tipo: 'previa-reacao', receita: r.receita })),
// O celular reage à resposta da conversa com acordar + gesto; a prévia imita.
ouvir('conversa', (c) => {
if (c.ultima && c.ultima.id !== ultimaConversa) {
ultimaConversa = c.ultima.id;
enviar({ tipo: 'ordem', ordem: 'acordar' });
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
}
}),
];
onDestroy(() => cancelar.forEach((c) => c()));
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
export function previa(receita: unknown) {
enviar({ tipo: 'previa-reacao', receita });
}
</script>
<iframe
bind:this={quadro}
src="/carinha.html?espelho=1&painel=1&auto=0&camera=0&audio=0"
title={titulo}
tabindex="-1"
onload={aoCarregar}
></iframe>
<style>
iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #000;
border-radius: inherit;
}
</style>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// Estado nunca só por cor: o selo sempre tem texto (e o ponto é decorativo).
let { tom = 'neutro', children }: { tom?: 'ok' | 'aviso' | 'perigo' | 'neutro' | 'destaque'; children: Snippet } =
$props();
</script>
<span class="selo {tom}"><span class="ponto" aria-hidden="true"></span>{@render children()}</span>
<style>
.selo {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: var(--raio-pill);
font-size: var(--t-xs);
font-weight: 500;
background: var(--superficie-2);
color: var(--texto-2);
border: 1px solid var(--borda);
white-space: nowrap;
}
.ponto {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.ok {
color: var(--ok);
background: var(--ok-suave);
border-color: transparent;
}
.aviso {
color: var(--aviso);
background: var(--aviso-suave);
border-color: transparent;
}
.perigo {
color: var(--perigo);
background: var(--perigo-suave);
border-color: transparent;
}
.destaque {
color: var(--destaque);
background: var(--destaque-suave);
border-color: transparent;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
let { rotulo, valor, min, max, passo = 1, unidade = '', disabled = false, oninput, onchange }: {
rotulo: string;
valor: number;
min: number;
max: number;
passo?: number;
unidade?: string;
disabled?: boolean;
oninput?: (v: number) => void;
onchange?: (v: number) => void;
} = $props();
const id = 's' + Math.random().toString(36).slice(2, 9);
let local = $state<number | null>(null);
const mostrado = $derived(local ?? valor);
const casas = $derived(passo < 1 ? Math.min(2, String(passo).split('.')[1]?.length ?? 2) : 0);
</script>
<div class="slider">
<label for={id}>
<span>{rotulo}</span>
<output for={id} class="mono">{mostrado.toFixed(casas)}{unidade}</output>
</label>
<input
{id}
type="range"
{min}
{max}
step={passo}
value={mostrado}
{disabled}
style="--p: {((mostrado - min) / (max - min)) * 100}%"
oninput={(e) => {
local = Number(e.currentTarget.value);
oninput?.(local);
}}
onchange={(e) => {
const v = Number(e.currentTarget.value);
local = null;
onchange?.(v);
}}
/>
</div>
<style>
.slider {
display: grid;
gap: var(--e-1);
}
label {
display: flex;
justify-content: space-between;
font-size: var(--t-sm);
gap: var(--e-2);
}
output {
color: var(--texto-2);
}
input {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 28px;
background: transparent;
cursor: pointer;
}
input::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
}
input::-moz-range-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
}
input::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
margin-top: -7px;
border-radius: 50%;
background: var(--texto);
border: 3px solid var(--destaque);
}
input::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--texto);
border: 3px solid var(--destaque);
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
let { rotulo, ajuda = '', checked = false, disabled = false, onchange }: {
rotulo: string;
ajuda?: string;
checked?: boolean;
disabled?: boolean;
onchange: (v: boolean) => void;
} = $props();
const id = 't' + Math.random().toString(36).slice(2, 9);
</script>
<label class="toggle" for={id}>
<span class="texto">
<span>{rotulo}</span>
{#if ajuda}<small class="fraco">{ajuda}</small>{/if}
</span>
<input {id} type="checkbox" role="switch" {checked} {disabled} onchange={(e) => onchange(e.currentTarget.checked)} />
<span class="trilho" aria-hidden="true"></span>
</label>
<style>
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--e-3);
min-height: 44px;
cursor: pointer;
position: relative;
}
.texto {
display: grid;
font-size: var(--t-sm);
}
small {
font-size: var(--t-xs);
}
input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.trilho {
flex: none;
width: 42px;
height: 24px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
border: 1px solid var(--borda-forte);
position: relative;
transition: background var(--transicao);
}
.trilho::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--texto-2);
transition: transform var(--transicao), background var(--transicao);
}
input:checked + .trilho {
background: var(--destaque);
border-color: var(--destaque);
}
input:checked + .trilho::after {
transform: translateX(18px);
background: var(--destaque-texto);
}
input:focus-visible + .trilho {
outline: 2px solid var(--destaque);
outline-offset: 2px;
}
input:disabled + .trilho {
opacity: 0.5;
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { KeyRound } from '@lucide/svelte';
import { gravarToken } from '../api';
import { conectar } from '../estado.svelte';
let token = $state('');
async function entrar(e: SubmitEvent) {
e.preventDefault();
gravarToken(token.trim());
await conectar();
}
</script>
<div class="fundo">
<form class="caixa" onsubmit={entrar}>
<KeyRound size={28} />
<h1>Token do Carinha</h1>
<p class="fraco pequeno-texto">
Este servidor exige um token para controlar o robô. Ele fica em <span class="mono">CARINHA_TOKEN</span> ou
<span class="mono">seguranca.json</span> na placa e é guardado só neste navegador.
</p>
<label class="campo">
<span>Token</span>
<!-- svelte-ignore a11y_autofocus -->
<input type="password" bind:value={token} autocomplete="current-password" required minlength="8" autofocus />
</label>
<button class="btn primario" type="submit">Entrar</button>
</form>
</div>
<style>
.fundo {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: var(--e-4);
background: var(--fundo);
}
.caixa {
width: min(400px, 100%);
display: grid;
gap: var(--e-4);
padding: var(--e-6);
border-radius: var(--raio-lg);
background: var(--superficie);
border: 1px solid var(--borda);
color: var(--destaque);
}
h1,
p,
label {
color: var(--texto);
}
</style>
+161
View File
@@ -0,0 +1,161 @@
/**
* 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 type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } 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: 'carinha', cor: [92, 214, 255] as [number, number, number] });
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);
feed = $state<ItemFeed[]>([]);
/** 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);
}
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);
}
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;
if (f.app) {
estado.app = f.app;
estado.appRecebidoEm = Date.now();
}
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;
}
const tratadores: Record<string, (d: any) => void> = {
expressao: (d: { ordem: string }) => {
if (d.ordem !== 'calma') noFeed('expressao', 'Expressão: ' + d.ordem);
},
telemetria: (d: Telemetria) => {
estado.app = d;
estado.appRecebidoEm = Date.now();
},
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),
};
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']) {
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);
+48
View File
@@ -0,0 +1,48 @@
/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */
import { cor, CORES, gesto, GESTOS_INFO, gestosDisponiveis, pararTudo, rearmar, recarregarCelular, rosto } from './comandos.svelte';
import { estado } from './estado.svelte';
import { nav, ROTAS } from './rotas.svelte';
export interface ItemPaleta {
id: string;
grupo: string;
titulo: string;
dica?: string;
fazer: () => unknown;
}
type Fonte = () => ItemPaleta[];
const fontes = new Set<Fonte>();
export function registrarNaPaleta(fonte: Fonte): () => void {
fontes.add(fonte);
return () => fontes.delete(fonte);
}
class Paleta {
aberta = $state(false);
abrir = () => (this.aberta = true);
fechar = () => (this.aberta = false);
}
export const paleta = new Paleta();
export function itensDaPaleta(): ItemPaleta[] {
return [...fontes].flatMap((f) => f());
}
// Itens base: navegação, ações gerais, gestos, rostos e cores.
registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
estado.estop.ativo
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
: { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo },
{ id: 'recarregar', grupo: 'Ações', titulo: 'Recarregar tela do celular', fazer: recarregarCelular },
...gestosDisponiveis().map((g) => ({
id: 'gesto-' + g,
grupo: 'Gestos',
titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`,
fazer: () => gesto(g),
})),
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + r, fazer: () => rosto(r) })),
...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })),
]);
+251
View File
@@ -0,0 +1,251 @@
/**
* Rastreamento de rosto/mãos no navegador do PC (MediaPipe num Worker servido pelo
* servidor em /rastreador-worker.js). Porta de painel-rastreamento.js + rastreamento-core.js.
*/
import type { AppConfig } from './tipos';
import type { AlvoRastreio, VideoAoVivo } from './video.svelte';
interface Ponto {
x: number;
y: number;
}
interface Face extends Ponto {
w: number;
h: number;
score: number;
pontos?: Ponto[];
}
interface Resultado {
faces: Face[];
hands: Ponto[][];
ms: number;
timestamp: number;
}
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
const limitar = (v: number) => Math.max(0, Math.min(1, v));
const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12],
[9, 13], [13, 14], [14, 15], [15, 16], [13, 17], [0, 17], [17, 18], [18, 19], [19, 20]];
export function escolherAlvo(faces: Face[], maos: Ponto[][], anterior: AlvoRastreio | null): AlvoRastreio | null {
let candidatos: AlvoRastreio[] = maos
.filter((h) => h.length === 21 && h.every(valido))
.map((h) => {
const palma = [0, 5, 9, 13, 17].map((i) => h[i]);
return { tipo: 'mao', x: palma.reduce((s, p) => s + p.x, 0) / 5, y: palma.reduce((s, p) => s + p.y, 0) / 5 };
});
if (!candidatos.length)
candidatos = faces
.filter((f) => valido(f) && f.w > 0 && f.h > 0)
.sort((a, b) => b.w * b.h - a.w * a.h)
.map((f) => ({ tipo: 'rosto', x: f.x + f.w / 2, y: f.y + f.h / 2 }));
if (!candidatos.length) return null;
if (anterior && anterior.tipo === candidatos[0].tipo)
candidatos.sort((a, b) => Math.hypot(a.x - anterior.x, a.y - anterior.y) - Math.hypot(b.x - anterior.x, b.y - anterior.y));
const t = candidatos[0];
return { ...t, x: limitar(t.x), y: limitar(t.y) };
}
function encaixe(w: number, h: number, vw: number, vh: number) {
const escala = Math.min(w / vw, h / vh);
return { x: Math.max(0, (w - vw * escala) / 2), y: Math.max(0, (h - vh * escala) / 2), w: vw * escala, h: vh * escala };
}
export class Rastreamento {
texto = $state('Rastreamento desligado.');
private worker: Worker | null = null;
private pronto = false;
private pendente = false;
private geracao = 0;
private ativo = false;
private config: AppConfig = {};
private resultado: Resultado | null = null;
private alvo: AlvoRastreio | null = null;
private alvoEm = 0;
private em = 0;
private intervalo = 50;
private proximo = 0;
private ultimoVideo = -1;
private tentarEm = 0;
private amostraAnterior = 0;
private rascunho = document.createElement('canvas');
private relogio: number;
private ctx: CanvasRenderingContext2D;
constructor(
private video: HTMLVideoElement,
private tela: HTMLCanvasElement,
private saida: VideoAoVivo,
) {
this.ctx = tela.getContext('2d')!;
this.relogio = window.setInterval(() => this.passo(), 20);
addEventListener('resize', this.desenhar);
}
private limpar() {
this.ctx.clearRect(0, 0, this.tela.width, this.tela.height);
}
private parar(texto: string) {
if (this.ativo) this.saida.rastrear(null);
this.ativo = false;
this.geracao++;
this.worker?.terminate();
this.worker = null;
this.pendente = this.pronto = false;
this.resultado = this.alvo = null;
this.ultimoVideo = -1;
this.amostraAnterior = 0;
this.limpar();
this.texto = texto;
}
private desenhar = () => {
const caixa = this.tela.parentElement!.getBoundingClientRect();
if (this.tela.width !== Math.round(caixa.width) || this.tela.height !== Math.round(caixa.height)) {
this.tela.width = Math.round(caixa.width);
this.tela.height = Math.round(caixa.height);
}
this.limpar();
this.tela.hidden = !this.config.mostrarRastreamento;
const r = this.resultado;
if (this.tela.hidden || !r || performance.now() - this.em > 350 || !this.video.videoWidth) return;
const f = encaixe(this.tela.width, this.tela.height, this.video.videoWidth, this.video.videoHeight);
const pt = (p: Ponto) => ({ x: f.x + p.x * f.w, y: f.y + p.y * f.h });
const c = this.ctx;
c.lineWidth = 2;
c.font = '13px system-ui';
const bola = (p: Ponto, raio = 3) => {
c.beginPath();
c.arc(p.x, p.y, raio, 0, Math.PI * 2);
c.fill();
};
for (const rosto of r.faces) {
const p = pt(rosto);
c.strokeStyle = c.fillStyle = '#5cd6ff';
c.strokeRect(p.x, p.y, rosto.w * f.w, rosto.h * f.h);
c.fillText('Rosto ' + Math.round(rosto.score * 100) + '%', p.x, Math.max(15, p.y - 6));
for (const q of rosto.pontos || []) bola(pt(q));
}
for (const mao of r.hands) {
c.strokeStyle = c.fillStyle = '#4ade80';
for (const [a, b] of LIGACOES) {
const p = pt(mao[a]);
const q = pt(mao[b]);
c.beginPath();
c.moveTo(p.x, p.y);
c.lineTo(q.x, q.y);
c.stroke();
}
for (const q of mao) bola(pt(q));
}
if (this.alvo) {
const p = pt(this.alvo);
c.strokeStyle = '#fbbf24';
c.beginPath();
c.arc(p.x, p.y, 12, 0, Math.PI * 2);
c.moveTo(p.x - 18, p.y);
c.lineTo(p.x + 18, p.y);
c.moveTo(p.x, p.y - 18);
c.lineTo(p.x, p.y + 18);
c.stroke();
}
};
private iniciar() {
this.ativo = true;
const geracao = ++this.geracao;
this.worker = new Worker('/rastreador-worker.js');
this.texto = 'Carregando rastreadores locais…';
this.worker.onerror = (e) => {
this.tentarEm = Date.now() + 10000;
this.parar('Falha no rastreador: ' + e.message);
};
this.worker.onmessage = ({ data }) => {
if (geracao !== this.geracao) return;
this.pendente = false;
if (data.tipo === 'erro') {
this.tentarEm = Date.now() + 10000;
this.parar('Rastreamento indisponível: ' + data.erro);
return;
}
this.pronto = true;
if (data.tipo === 'pronto') {
this.texto = 'Rastreadores prontos. Aguardando imagem…';
return;
}
const r = data as Resultado;
this.resultado = r;
this.em = r.timestamp;
const anterior = this.alvo;
const alvo = escolherAlvo(r.faces, r.hands, anterior);
if (alvo) alvo.atividade = !!anterior && alvo.tipo === anterior.tipo && Math.hypot(alvo.x - anterior.x, alvo.y - anterior.y) > 0.008;
this.alvo = alvo;
this.alvoEm = r.timestamp;
this.intervalo = Math.max(50, Math.min(200, r.ms * 1.2));
const hz = this.amostraAnterior ? Math.round(1000 / (r.timestamp - this.amostraAnterior)) : 0;
this.amostraAnterior = r.timestamp;
this.texto = `${r.faces.length} rosto(s) · ${r.hands.length} mão(s) · ${hz} análises/s · ${Math.round(r.ms)} ms${
alvo ? ' · alvo: ' + (alvo.tipo === 'mao' ? 'mão' : 'rosto') : ' · procurando…'
}`;
if (this.config.seguirRastreamento) this.saida.rastrear(alvo);
this.desenhar();
};
this.pendente = true;
this.worker.postMessage({ config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } });
}
private async passo() {
const agora = performance.now();
if (this.alvo && agora - this.alvoEm > 350) {
this.alvo = null;
if (this.config.seguirRastreamento) this.saida.rastrear(null);
}
if (this.resultado && agora - this.em > 350) {
this.resultado = null;
this.limpar();
}
const v = this.video;
if (!this.ativo || !this.pronto || this.pendente || agora < this.proximo || v.readyState < 2 || v.currentTime === this.ultimoVideo)
return;
this.pendente = true;
this.proximo = agora + this.intervalo;
this.ultimoVideo = v.currentTime;
const geracao = this.geracao;
try {
const escala = Math.min(1, 640 / v.videoWidth);
this.rascunho.width = Math.round(v.videoWidth * escala);
this.rascunho.height = Math.round(v.videoHeight * escala);
this.rascunho.getContext('2d')!.drawImage(v, 0, 0, this.rascunho.width, this.rascunho.height);
const bitmap = await createImageBitmap(this.rascunho);
if (geracao !== this.geracao) return bitmap.close();
this.worker!.postMessage(
{ bitmap, timestamp: agora, config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } },
[bitmap],
);
} catch (e) {
if (geracao === this.geracao) this.parar('Falha ao analisar vídeo: ' + (e as Error).message);
}
}
atualizar(config: AppConfig | null, permitido: boolean) {
if (!config) return;
const mudou = this.config.rastrearRosto !== config.rastrearRosto || this.config.rastrearMaos !== config.rastrearMaos;
if (this.config.seguirRastreamento && !config.seguirRastreamento) this.saida.rastrear(null);
this.config = config;
if (!permitido || (!config.rastrearRosto && !config.rastrearMaos)) {
this.parar(!permitido ? 'Ligue a transmissão para rastrear no PC.' : 'Rastreamento desligado.');
return;
}
if (mudou && this.ativo) this.parar('Atualizando rastreadores…');
if (!this.ativo && Date.now() >= this.tentarEm) this.iniciar();
this.desenhar();
}
destruir() {
clearInterval(this.relogio);
removeEventListener('resize', this.desenhar);
this.parar('Painel fechado.');
}
}
+26
View File
@@ -0,0 +1,26 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
{ id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' },
];
function lerHash(): Rota {
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo';
}
class Navegacao {
atual = $state<Rota>(lerHash());
ir(rota: Rota) {
location.hash = '/' + rota;
}
}
export const nav = new Navegacao();
addEventListener('hashchange', () => (nav.atual = lerHash()));
+128
View File
@@ -0,0 +1,128 @@
/* Design system do painel Carinha: tokens + poucos utilitários. Escuro por padrão, claro opcional. */
:root {
color-scheme: dark;
--fundo: #0b1017;
--fundo-2: #0f1620;
--superficie: #141c27;
--superficie-2: #1a2431;
--superficie-3: #223041;
--borda: #263447;
--borda-forte: #34475f;
--texto: #e8eef6;
--texto-2: #a9b8c9;
--texto-3: #8396ab; /* AA sobre --superficie */
--destaque: #5cd6ff;
--destaque-forte: #2fbff0;
--destaque-texto: #04202b;
--destaque-suave: rgb(92 214 255 / 0.12);
--ok: #4ade80;
--ok-suave: rgb(74 222 128 / 0.12);
--aviso: #fbbf24;
--aviso-suave: rgb(251 191 36 / 0.12);
--perigo: #f87171;
--perigo-forte: #ef4444;
--perigo-suave: rgb(248 113 113 / 0.14);
--vidro: rgb(15 22 32 / 0.72);
--fonte: 'Outfit Variable', 'Segoe UI', system-ui, sans-serif;
--fonte-mono: 'JetBrains Mono Variable', ui-monospace, Consolas, monospace;
--t-xs: 0.75rem; --t-sm: 0.875rem; --t-md: 1rem; --t-lg: 1.25rem; --t-xl: 1.6rem;
--e-1: 4px; --e-2: 8px; --e-3: 12px; --e-4: 16px; --e-5: 24px; --e-6: 32px;
--raio-sm: 8px; --raio: 12px; --raio-lg: 18px; --raio-pill: 999px;
--sombra: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 24px rgb(0 0 0 / 0.25);
--sombra-flutuante: 0 12px 40px rgb(0 0 0 / 0.45);
--transicao: 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
--nav-largura: 232px;
--topo-altura: 60px;
}
:root[data-theme='light'] {
color-scheme: light;
--fundo: #f4f6f9;
--fundo-2: #eef2f6;
--superficie: #ffffff;
--superficie-2: #f3f6fa;
--superficie-3: #e7edf4;
--borda: #dbe3ec;
--borda-forte: #c3cfdc;
--texto: #0f1722;
--texto-2: #3d4d60;
--texto-3: #5a6b7f;
--destaque: #0284c7;
--destaque-forte: #0369a1;
--destaque-texto: #ffffff;
--destaque-suave: rgb(2 132 199 / 0.1);
--ok: #15803d; --ok-suave: rgb(21 128 61 / 0.1);
--aviso: #b45309; --aviso-suave: rgb(180 83 9 / 0.1);
--perigo: #dc2626; --perigo-forte: #b91c1c; --perigo-suave: rgb(220 38 38 / 0.1);
--vidro: rgb(255 255 255 / 0.78);
--sombra: 0 1px 2px rgb(15 23 34 / 0.06), 0 6px 20px rgb(15 23 34 / 0.08);
--sombra-flutuante: 0 12px 40px rgb(15 23 34 / 0.18);
}
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--fundo);
color: var(--texto);
font: 400 var(--t-md)/1.5 var(--fonte);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 { margin: 0; line-height: 1.2; font-weight: 600; }
h1 { font-size: var(--t-xl); }
h2 { font-size: var(--t-lg); }
h3 { font-size: var(--t-md); }
p { margin: 0; }
a { color: var(--destaque); }
img, video, canvas, iframe { max-width: 100%; }
[hidden] { display: none !important; }
:focus-visible { outline: 2px solid var(--destaque); outline-offset: 2px; }
/* ---------- botões ---------- */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: var(--e-2);
min-height: 40px; padding: 0 var(--e-4);
border: 1px solid var(--borda-forte); border-radius: var(--raio-sm);
background: var(--superficie-2); color: var(--texto);
font: 500 var(--t-sm) var(--fonte); cursor: pointer; white-space: nowrap;
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
touch-action: manipulation; user-select: none;
}
.btn:hover:not(:disabled) { background: var(--superficie-3); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn.primario { background: var(--destaque); border-color: var(--destaque); color: var(--destaque-texto); }
.btn.primario:hover:not(:disabled) { background: var(--destaque-forte); }
.btn.perigo { background: var(--perigo-forte); border-color: var(--perigo-forte); color: #fff; }
.btn.perigo:hover:not(:disabled) { background: var(--perigo); }
.btn.fantasma { background: transparent; border-color: transparent; }
.btn.fantasma:hover:not(:disabled) { background: var(--superficie-2); }
.btn.icone { padding: 0; width: 40px; }
.btn.pequeno { min-height: 32px; padding: 0 var(--e-3); font-size: var(--t-xs); }
.btn.pequeno.icone { width: 32px; padding: 0; }
/* ---------- formulários ---------- */
.campo { display: grid; gap: var(--e-1); }
.campo > span, .rotulo { font-size: var(--t-sm); color: var(--texto-2); }
input[type='text'], input[type='password'], input[type='search'], input[type='number'], select, textarea {
width: 100%; min-height: 40px; padding: var(--e-2) var(--e-3);
border: 1px solid var(--borda-forte); border-radius: var(--raio-sm);
background: var(--fundo-2); color: var(--texto); font: inherit; font-size: var(--t-sm);
}
textarea { resize: vertical; min-height: 80px; }
input::placeholder, textarea::placeholder { color: var(--texto-3); }
/* ---------- utilitários ---------- */
.grade { display: grid; gap: var(--e-4); }
.linha { display: flex; gap: var(--e-2); align-items: center; flex-wrap: wrap; }
.entre { justify-content: space-between; }
.mono { font-family: var(--fonte-mono); font-size: 0.92em; }
.fraco { color: var(--texto-3); }
.pequeno-texto { font-size: var(--t-sm); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0ms !important; animation-duration: 0ms !important; }
}
+81
View File
@@ -0,0 +1,81 @@
/** Formato do que o servidor manda em /estado e nos eventos SSE. */
export type AppConfig = Record<string, boolean | number | string>;
export interface Telemetria {
online: boolean;
idade: number | null;
celular_ip: string | null;
servidor_ip: string;
recarga: { id: string | null; confirmada: boolean };
estado: {
rosto?: string;
dormindo?: boolean;
microfone?: number;
microfone_ativo?: boolean;
movimento?: number;
camera_ativa?: boolean;
bateria?: { nivel?: number; carregando?: boolean } | number | null;
brilho?: number;
video?: { qualidade?: string } | null;
[k: string]: unknown;
};
preview: boolean;
}
export interface Motores {
disponivel: boolean;
erro: string;
ativos: number[];
dono: 'braco' | 'diagnostico' | null;
travado: boolean;
}
export interface Mensagem {
role: 'user' | 'assistant';
content: string;
}
export interface Conversa {
provedor: 'local' | 'gemini';
modelo: string;
gemini_configurado: boolean;
ocupado: boolean;
ultima: { id: number; texto: string; gesto: string; horario: number; segundos: number; fonte?: string } | null;
erro: string;
historico: Mensagem[];
lembrancas: { id: number; texto: string }[];
}
export interface IA {
ativo: boolean;
ocupado: boolean;
modelo: string;
executor: string;
ultima: { evento: string; gesto: string; segundos: number; horario: number } | null;
erro: string;
}
export interface Reacao {
id: number;
horario: number;
receita: any;
}
export interface EstadoServidor {
versao: string;
seq: number;
autenticacao: boolean;
estop: { ativo: boolean; desde: number | null };
aparencia: { rosto: string; cor: [number, number, number] };
gestos: string[];
rostos: string[];
app?: Telemetria;
app_config?: AppConfig;
motores?: Motores;
conversa?: Conversa;
ia?: IA;
reacao?: Reacao | null;
treinamento?: { ocupado: boolean; erro: string };
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Biblioteca de reações, rotinas e diário (/treinamento/*). Sem polling: recarrega ao abrir a
* tela, depois de cada ação e quando o servidor avisa (SSE 'treinamento') que terminou de criar.
*/
import { pedir, postar } from './api';
import { avisos } from './avisos.svelte';
import { ouvir } from './estado.svelte';
export interface ReacaoBiblioteca {
id: number;
horario: number;
receita: any;
feedback: -1 | 0 | 1;
ativa: boolean;
}
export interface StatusTreinamento {
ocupado: boolean;
erro: string;
automatico: boolean;
rotina: { id: number; texto: string }[];
diario: { id: number; horario: number; papel: string; texto: string }[];
resumos: { id: number; horario: number; texto: string }[];
emojis: string[];
desfazer: [string] | null;
reacoes: ReacaoBiblioteca[];
prompt_orientacao: string;
ultimo_prompt_figurinha: string;
}
class Treinamento {
status = $state<StatusTreinamento | null>(null);
carregando = $state(false);
async carregar() {
this.carregando = true;
try {
this.status = await pedir<StatusTreinamento>('/treinamento/status');
} catch (e) {
avisos.erro(e);
} finally {
this.carregando = false;
}
}
/** Executa uma ação e recarrega; devolve true se deu certo (erro vira toast). */
async acao(rota: string, dados: object = {}, sucesso?: string) {
try {
await postar('/treinamento/' + rota, dados);
if (sucesso) avisos.ok(sucesso);
await this.carregar();
return true;
} catch (e) {
avisos.erro(e);
return false;
}
}
}
export const treinamento = new Treinamento();
let estavaOcupado = false;
ouvir('treinamento', (d: { ocupado: boolean; erro: string }) => {
if (treinamento.status) treinamento.status = { ...treinamento.status, ocupado: d.ocupado, erro: d.erro };
if (estavaOcupado && !d.ocupado) {
if (d.erro) avisos.mostrar('Gemini: ' + d.erro, 'erro');
else avisos.ok('Novas reações prontas.');
treinamento.carregar();
}
estavaOcupado = d.ocupado;
});
export const SIMBOLO_EMOJI: Record<string, string> = { ideia: '💡', cafe: '☕', musica: '🎵', coracao: '❤️', estrela: '⭐' };
+236
View File
@@ -0,0 +1,236 @@
/**
* Vídeo ao vivo do celular por WebRTC P2P. A sinalização passa pelo servidor (/rtc/*);
* o vídeo vai direto do celular para este navegador. Porta de painel-video.js.
* Só a tela Ao vivo cria uma instância: assim não há disputa pela única sessão.
*/
import { lerToken, pedir } from './api';
import type { AppConfig, Telemetria } from './tipos';
export interface AlvoRastreio {
tipo: 'mao' | 'rosto';
x: number;
y: number;
atividade?: boolean;
}
export class VideoAoVivo {
mensagem = $state('Transmissão desligada.');
exibindo = $state(false);
qualidade = $state('');
private pc: RTCPeerConnection | null = null;
private id: string | null = null;
private desde = 0;
private ice: RTCIceCandidateInit[] = [];
private querido = false;
private abrindo = false;
private consultando = false;
private geracao = 0;
private tentarEm = 0;
private ultimoStats: any = null;
private outroPainel = false;
private canal: RTCDataChannel | null = null;
private seqRastreio = 0;
private qualidadeEfetiva = '';
private config: AppConfig = {};
private relogios: number[] = [];
constructor(private video: HTMLVideoElement) {
video.onloadeddata = video.onplaying = () => this.mostrar();
this.relogios.push(
window.setInterval(() => {
this.iniciar();
this.consultar();
}, 400),
window.setInterval(() => this.estatisticas(), 1000),
);
addEventListener('pagehide', this.aoSair);
}
private mostrar() {
this.exibindo = true;
}
private texto(t: string) {
this.mensagem = t;
this.exibindo = false;
}
private parar(fechar = true) {
this.geracao++;
const antiga = this.id;
this.id = null;
this.pc?.close();
this.pc = null;
this.canal = null;
this.ice = [];
this.desde = 0;
this.ultimoStats = null;
this.video.pause();
this.video.srcObject = null;
this.exibindo = false;
if (antiga && fechar) pedir('/rtc/session', { acao: 'fechar', id: antiga }).catch(() => {});
}
private enviar(mensagem: unknown, sessao?: string) {
return pedir('/rtc/send', { id: sessao || this.id, de: 'pc', mensagem }, 3500);
}
private async iniciar() {
if (!this.querido || this.pc || this.abrindo || this.outroPainel || Date.now() < this.tentarEm) return;
this.abrindo = true;
const geracao = this.geracao;
this.texto('Conectando vídeo ao vivo… O rosto continua no celular.');
try {
const sessao = await pedir<{ id: string }>('/rtc/session', { acao: 'abrir' }, 3500);
if (geracao !== this.geracao || !this.querido) {
pedir('/rtc/session', { acao: 'fechar', id: sessao.id }).catch(() => {});
return;
}
this.id = sessao.id;
const id = sessao.id;
const pc = new RTCPeerConnection({ iceServers: [] });
this.pc = pc;
this.canal = pc.createDataChannel('rastreamento', { ordered: false, maxRetransmits: 0 });
pc.addTransceiver('video', { direction: 'recvonly' });
pc.onicecandidate = (e) => {
if (this.pc === pc && e.candidate) this.enviar({ tipo: 'candidate', candidate: e.candidate.toJSON() }, id).catch(() => {});
};
pc.ontrack = (e) => {
if (this.pc !== pc) return;
this.video.srcObject = e.streams[0] || new MediaStream([e.track]);
this.video.play().catch((err) => this.texto('Clique no vídeo para permitir a reprodução: ' + err.message));
};
pc.onconnectionstatechange = () => {
if (this.pc === pc && pc.connectionState === 'failed') {
this.parar();
this.tentarEm = Date.now() + 2000;
this.texto('Reconectando o vídeo…');
}
};
await pc.setLocalDescription(await pc.createOffer());
if (this.pc !== pc) return;
await this.enviar({ tipo: 'offer', sdp: pc.localDescription!.sdp }, id);
} catch (e) {
this.parar();
this.tentarEm = Date.now() + 2500;
this.texto('Falha ao iniciar vídeo: ' + (e as Error).message);
} finally {
this.abrindo = false;
}
}
private async consultar() {
if (!this.id || !this.pc || this.consultando) return;
this.consultando = true;
const pc = this.pc;
const sid = this.id;
try {
const pacote = await pedir<{ id: string | null; mensagens: { seq: number; mensagem: any }[] }>(
`/rtc/poll?lado=pc&desde=${this.desde}&id=${sid}`,
undefined,
3500,
);
if (this.pc !== pc) return;
if (pacote.id !== sid) {
this.outroPainel = !!pacote.id;
this.parar(false);
this.texto(this.outroPainel ? 'Vídeo aberto em outro painel. Recarregue para assumir.' : 'Aguardando reconexão do vídeo…');
return;
}
for (const { seq, mensagem: m } of pacote.mensagens) {
if (m.tipo === 'answer') {
await pc.setRemoteDescription({ type: 'answer', sdp: m.sdp });
for (const c of this.ice) await pc.addIceCandidate(c);
this.ice = [];
} else if (m.tipo === 'candidate') {
if (pc.remoteDescription) await pc.addIceCandidate(m.candidate);
else this.ice.push(m.candidate);
} else if (m.tipo === 'erro') {
this.parar();
this.tentarEm = Date.now() + 3500;
this.texto('Câmera no celular: ' + m.erro);
return;
}
this.desde = Math.max(this.desde, seq);
}
} catch {
if (this.pc === pc) {
this.parar();
this.tentarEm = Date.now() + 2500;
this.texto('Sinalização interrompida. Reconectando…');
}
} finally {
this.consultando = false;
}
}
private async estatisticas() {
const pc = this.pc;
if (!pc || pc.connectionState !== 'connected') return;
try {
const relatorio = await pc.getStats();
relatorio.forEach((s: any) => {
if (s.type !== 'inbound-rtp' || (s.kind !== 'video' && s.mediaType !== 'video')) return;
const u = this.ultimoStats;
const fps = u ? ((s.framesDecoded - u.framesDecoded) * 1000) / (s.timestamp - u.timestamp) : s.framesPerSecond || 0;
const kbps = u ? ((s.bytesReceived - u.bytesReceived) * 8) / (s.timestamp - u.timestamp) : 0;
this.ultimoStats = s;
const auto =
this.qualidadeEfetiva === 'auto' || (!this.qualidadeEfetiva && this.config.videoResolucao === 'auto' && !this.config.videoFPS);
this.qualidade = `${auto ? 'Automática' : 'Manual'} · ${s.frameWidth || this.video.videoWidth}×${s.frameHeight || this.video.videoHeight} · ${Math.round(fps)} fps · ${Math.round(kbps)} kb/s`;
if (fps > 0) this.mostrar();
});
} catch {
/* conexão em transição */
}
}
rastrear(alvo: AlvoRastreio | null) {
if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096)
this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo }));
}
atualizar(status: Telemetria | null, config: AppConfig | null, online: boolean) {
if (!config) return;
this.config = config;
this.qualidadeEfetiva = (status?.estado?.video as any)?.qualidade || '';
const dormindo = !!status?.estado?.dormindo;
const liberada = config.sensorCamera !== false && (!dormindo || config.cameraSono === true);
const quer = !!config.preview && online && liberada;
if (!quer) {
if (this.querido || this.pc) this.parar();
this.querido = false;
this.texto(
!liberada
? 'Câmera desativada pelos ajustes de sensores.'
: config.preview
? 'Celular sem conexão. Abra o app Carinha.'
: 'Transmissão desligada. O rosto e os sensores continuam funcionando.',
);
} else {
this.querido = true;
this.iniciar();
}
}
private aoSair = () => {
if (this.id) {
const t = lerToken();
navigator.sendBeacon(
'/rtc/session' + (t ? '?t=' + encodeURIComponent(t) : ''),
new Blob([JSON.stringify({ acao: 'fechar', id: this.id })], { type: 'application/json' }),
);
}
this.querido = false;
this.parar(false);
};
destruir() {
this.relogios.forEach(clearInterval);
removeEventListener('pagehide', this.aoSair);
this.querido = false;
this.outroPainel = false;
this.parar();
}
}
+7
View File
@@ -0,0 +1,7 @@
import '@fontsource-variable/outfit';
import '@fontsource-variable/jetbrains-mono';
import './lib/tema.css';
import { mount } from 'svelte';
import App from './App.svelte';
mount(App, { target: document.getElementById('app')! });
+341
View File
@@ -0,0 +1,341 @@
<script lang="ts">
import { Download, KeyRound, RefreshCw, RotateCcw, Search, ShieldCheck, ShieldOff } from '@lucide/svelte';
import { gravarToken, lerToken, postar } from '../lib/api';
import { GRUPOS, restaurarPadrao, salvarApp, type Campo } from '../lib/ajustes.svelte';
import { avisos, tentar } from '../lib/avisos.svelte';
import { recarregarCelular } from '../lib/comandos.svelte';
import Card from '../lib/componentes/Card.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Slider from '../lib/componentes/Slider.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { conectar, estado } from '../lib/estado.svelte';
// ---------- busca nos ajustes do celular
let busca = $state('');
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const grupos = $derived(
GRUPOS.map((g) => ({
...g,
campos: g.campos.filter((c) => !busca || normal(c.rotulo + ' ' + (c.ajuda ?? '')).includes(normal(busca))),
})).filter((g) => g.campos.length),
);
const valor = (c: Campo) => estado.appConfig?.[c.chave];
let confirmarPadrao = $state(false);
// ---------- IA
let modelo = $state('');
let chave = $state('');
let testando = $state(false);
let resultadoTeste = $state<{ ok: boolean; mensagem: string; segundos?: number } | null>(null);
$effect(() => {
if (estado.api && !modelo) modelo = estado.api.modelo;
});
async function trocarProvedor(provedor: string) {
await tentar(async () => {
estado.conversa = await postar('/conversa/config', { provedor });
}, provedor === 'gemini' ? 'Conversa usando Gemini.' : 'Conversa usando Qwen local.');
}
async function salvarGemini(e: SubmitEvent) {
e.preventDefault();
const dados: Record<string, string> = { modelo: modelo.trim() };
if (chave.trim()) dados.chave = chave.trim();
const r = await tentar(() => postar('/api/configuracao/salvar', dados), 'Configuração do Gemini salva.');
if (r) {
estado.api = r;
chave = '';
resultadoTeste = null;
}
}
async function testarGemini() {
testando = true;
resultadoTeste = (await tentar(() => postar('/api/configuracao/testar'))) ?? null;
testando = false;
}
// ---------- segurança
let tokenLocal = $state(!!lerToken());
function esquecerToken() {
gravarToken('');
tokenLocal = false;
avisos.ok('Token removido deste navegador.');
if (estado.autenticacao) conectar();
}
</script>
<div class="ajustes">
<Card titulo="Celular" descricao="Mudanças valem na hora no aparelho. Nada trava enquanto salva.">
{#snippet acoes()}
<label class="busca">
<Search size={16} />
<input type="search" bind:value={busca} placeholder="Buscar ajuste" aria-label="Buscar ajuste" />
</label>
{#if confirmarPadrao}
<span class="pequeno-texto">Restaurar todos?</span>
<button
class="btn pequeno perigo"
onclick={async () => {
confirmarPadrao = false;
await tentar(restaurarPadrao);
}}>Sim, restaurar</button
>
<button class="btn pequeno" onclick={() => (confirmarPadrao = false)}>Cancelar</button>
{:else}
<button class="btn pequeno" onclick={() => (confirmarPadrao = true)} disabled={!estado.appConfig}>
<RotateCcw size={14} /> Restaurar padrão
</button>
{/if}
{/snippet}
{#if !estado.appConfig}
<p class="fraco">Carregando ajustes…</p>
{:else}
<div class="grupos">
{#each grupos as g (g.titulo)}
<fieldset>
<legend>
<strong>{g.titulo}</strong>
<span class="fraco pequeno-texto">{g.descricao}</span>
</legend>
{#each g.campos as c (c.chave)}
{#if c.tipo === 'toggle'}
<Toggle rotulo={c.rotulo} ajuda={c.ajuda} checked={!!valor(c)} onchange={(v) => salvarApp({ [c.chave]: v })} />
{:else if c.tipo === 'slider'}
<Slider
rotulo={c.rotulo}
valor={Number(valor(c) ?? 0)}
min={c.min!}
max={c.max!}
passo={c.passo}
unidade={c.unidade}
onchange={(v) => salvarApp({ [c.chave]: v })}
/>
{:else}
<label class="campo escolha">
<span>{c.rotulo}</span>
<select
value={String(valor(c))}
onchange={(e) => {
const v = e.currentTarget.value;
salvarApp({ [c.chave]: typeof c.opcoes![0][0] === 'number' ? Number(v) : v });
}}
>
{#each c.opcoes! as [v, t] (v)}<option value={String(v)}>{t}</option>{/each}
</select>
</label>
{/if}
{/each}
</fieldset>
{:else}
<p class="fraco">Nenhum ajuste com “{busca}”.</p>
{/each}
</div>
{/if}
</Card>
<div class="coluna">
<Card titulo="Inteligência artificial">
<div class="campo">
<span>Motor da conversa</span>
<div class="segmentado" role="radiogroup" aria-label="Motor da conversa">
<button role="radio" aria-checked={estado.conversa?.provedor === 'local'} onclick={() => trocarProvedor('local')}>
Qwen local
</button>
<button
role="radio"
aria-checked={estado.conversa?.provedor === 'gemini'}
disabled={!estado.api?.configurada}
title={estado.api?.configurada ? '' : 'Configure a chave do Gemini abaixo'}
onclick={() => trocarProvedor('gemini')}
>
Gemini
</button>
</div>
<small class="fraco">Local roda no celular/placa, sem custo. Gemini responde melhor e cria reações.</small>
</div>
<form class="gemini" onsubmit={salvarGemini}>
<div class="linha entre">
<strong class="pequeno-texto">Gemini</strong>
<Selo tom={estado.api?.configurada ? 'ok' : 'aviso'}>{estado.api?.configurada ? 'Chave configurada' : 'Sem chave'}</Selo>
</div>
<label class="campo">
<span>Modelo</span>
<input type="text" bind:value={modelo} list="modelos-gemini" required pattern="[a-zA-Z0-9._\-]{'{'}3,100{'}'}" />
<datalist id="modelos-gemini">
<option value="gemini-flash-lite-latest"></option>
<option value="gemini-flash-latest"></option>
<option value="gemini-2.5-flash-lite"></option>
<option value="gemini-2.5-flash"></option>
<option value="gemini-2.5-pro"></option>
</datalist>
</label>
<label class="campo">
<span>Chave de API {estado.api?.configurada ? '(deixe vazio para manter)' : ''}</span>
<input type="password" bind:value={chave} autocomplete="off" placeholder="AIza…" />
</label>
<div class="linha">
<button class="btn primario" type="submit"><KeyRound size={16} /> Salvar</button>
<button class="btn" type="button" onclick={testarGemini} disabled={testando || !estado.api?.configurada}>
{testando ? 'Testando…' : 'Testar conexão'}
</button>
</div>
{#if resultadoTeste}
<p class="pequeno-texto" class:ok={resultadoTeste.ok} class:erro={!resultadoTeste.ok}>
{resultadoTeste.mensagem}{resultadoTeste.segundos ? ` (${resultadoTeste.segundos}s)` : ''}
</p>
{/if}
</form>
</Card>
<Card titulo="Segurança">
<div class="linha">
{#if estado.autenticacao}
<Selo tom="ok"><ShieldCheck size={12} /> Servidor exige token</Selo>
{:else}
<Selo tom="aviso"><ShieldOff size={12} /> Servidor aberto na rede</Selo>
{/if}
</div>
<p class="fraco pequeno-texto">
{#if estado.autenticacao}
Só quem tem o token controla o robô. Ele fica guardado apenas neste navegador.
{:else}
Qualquer aparelho da rede local pode controlar o robô. Defina <span class="mono">CARINHA_TOKEN</span> na placa
(veja docs/SEGURANCA.md) depois de atualizar o app do celular.
{/if}
</p>
{#if tokenLocal}
<button class="btn pequeno" onclick={esquecerToken}>Esquecer token neste navegador</button>
{/if}
</Card>
<Card titulo="Sistema">
<dl class="info">
<div><dt>Versão do servidor</dt><dd class="mono">{estado.versao || '—'}</dd></div>
<div><dt>Celular</dt><dd class="mono">{estado.app?.celular_ip ?? 'offline'}</dd></div>
<div><dt>Servidor</dt><dd class="mono">{location.host}</dd></div>
</dl>
<div class="linha">
<button class="btn pequeno" onclick={recarregarCelular} disabled={!estado.celularOnline}>
<RefreshCw size={14} /> Recarregar tela do celular
</button>
<a class="btn pequeno" href="/app.apk" download><Download size={14} /> Baixar app Android</a>
</div>
</Card>
</div>
</div>
<style>
.ajustes {
display: grid;
gap: var(--e-4);
align-items: start;
}
.coluna {
display: grid;
gap: var(--e-4);
}
.busca {
display: flex;
align-items: center;
gap: var(--e-2);
padding-left: var(--e-3);
border: 1px solid var(--borda-forte);
border-radius: var(--raio-sm);
background: var(--fundo-2);
color: var(--texto-3);
}
.busca input {
border: 0;
background: transparent;
min-height: 32px;
width: 160px;
}
.grupos {
display: grid;
gap: var(--e-5);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
}
fieldset {
border: 0;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-2);
align-content: start;
}
legend {
display: grid;
gap: 2px;
padding: 0 0 var(--e-2);
margin-bottom: var(--e-2);
border-bottom: 1px solid var(--borda);
width: 100%;
}
.escolha {
min-height: 44px;
}
.segmentado {
display: flex;
background: var(--fundo-2);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
padding: 3px;
gap: 3px;
}
.segmentado button {
flex: 1;
min-height: 34px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.segmentado button[aria-checked='true'] {
background: var(--superficie-3);
color: var(--texto);
}
.segmentado button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.gemini {
display: grid;
gap: var(--e-3);
padding-top: var(--e-3);
border-top: 1px solid var(--borda);
}
.ok {
color: var(--ok);
}
.erro {
color: var(--perigo);
}
.info {
display: grid;
gap: var(--e-2);
margin: 0;
}
.info div {
display: flex;
justify-content: space-between;
gap: var(--e-3);
font-size: var(--t-sm);
}
dt {
color: var(--texto-3);
}
dd {
margin: 0;
}
@media (min-width: 1200px) {
.ajustes {
grid-template-columns: 2fr 1fr;
}
}
</style>
+388
View File
@@ -0,0 +1,388 @@
<script lang="ts">
import { Battery, Brain, Camera, Download, Ear, Moon, RefreshCw, ScanFace } from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api';
import { salvarApp } from '../lib/ajustes.svelte';
import { tentar } from '../lib/avisos.svelte';
import { cor, CORES, hex, recarregarCelular, rgb, rosto } from '../lib/comandos.svelte';
import Card from '../lib/componentes/Card.svelte';
import Gestos from '../lib/componentes/Gestos.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte';
import { VideoAoVivo } from '../lib/video.svelte';
let elVideo: HTMLVideoElement;
let elTela: HTMLCanvasElement;
let video = $state<VideoAoVivo>();
let rastreio = $state<Rastreamento>();
onMount(() => {
video = new VideoAoVivo(elVideo);
rastreio = new Rastreamento(elVideo, elTela, video);
});
onDestroy(() => {
rastreio?.destruir();
video?.destruir();
});
// Vídeo e rastreamento seguem o estado do servidor (SSE), sem polling próprio.
$effect(() => {
const cfg = estado.appConfig ? { ...estado.appConfig } : null;
const online = estado.celularOnline;
video?.atualizar(estado.app, cfg, online);
const dormindo = !!estado.app?.estado?.dormindo;
const permitido = !!cfg?.preview && online && cfg.sensorCamera !== false && (!dormindo || !!cfg.cameraSono);
rastreio?.atualizar(cfg, permitido);
});
const sensores = $derived(estado.app?.estado ?? {});
const bateria = $derived.by(() => {
const b = sensores.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
});
const pct = (v: unknown) => (typeof v === 'number' ? Math.round(Math.min(1, v) * 100) : 0);
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
async function foto() {
if (!elVideo.videoWidth) return;
const c = document.createElement('canvas');
c.width = elVideo.videoWidth;
c.height = elVideo.videoHeight;
c.getContext('2d')!.drawImage(elVideo, 0, 0);
const a = document.createElement('a');
a.href = c.toDataURL('image/png');
a.download = `carinha-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
a.click();
}
const iaLocal = (ativo: boolean) => tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.');
</script>
<div class="ao-vivo">
<section class="palco">
<div class="quadro camera">
<!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
<canvas bind:this={elTela} class="sobreposicao" aria-hidden="true"></canvas>
{#if !video?.exibindo}
<div class="vazio">
<Camera size={32} />
<p>{video?.mensagem ?? 'Carregando…'}</p>
</div>
{/if}
<div class="rodape">
<Selo tom={video?.exibindo ? 'ok' : 'neutro'}>Câmera</Selo>
{#if video?.qualidade && video.exibindo}<span class="mono fraco">{video.qualidade}</span>{/if}
<span class="espaco"></span>
{#if video?.exibindo}
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
{/if}
</div>
</div>
<div class="quadro rosto">
<RostoPrevia titulo="Rosto ao vivo" />
<div class="rodape">
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>Rosto {estado.celularOnline ? 'no celular' : '· prévia'}</Selo>
</div>
</div>
</section>
<div class="controles">
<div class="transmissao">
<Toggle
rotulo="Transmitir câmera do celular"
ajuda="Vídeo direto do celular para este PC (WebRTC)"
checked={!!estado.appConfig?.preview}
disabled={!estado.appConfig}
onchange={(v) => salvarApp({ preview: v })}
/>
{#if rastreio && estado.appConfig?.preview}
<p class="fraco pequeno-texto linha"><ScanFace size={14} /> {rastreio.texto}</p>
{/if}
</div>
</div>
<div class="grade-cards">
<Card titulo="Gestos" descricao="Favoritos com atalho de 1 a 9. Fixe outros na tela de Reações ou pelo Ctrl+K.">
<Gestos />
</Card>
<Card titulo="Aparência">
<div class="campo">
<span>Rosto</span>
<div class="segmentado" role="radiogroup" aria-label="Rosto">
{#each estado.rostos as r (r)}
<button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button>
{/each}
</div>
</div>
<div class="campo">
<span>Cor dos olhos</span>
<div class="cores">
{#each CORES as c (c.nome)}
<button
class="cor"
style="--c: {hex(c.rgb)}"
aria-label={c.nome}
aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)}
onclick={() => cor(c.rgb)}
></button>
{/each}
<label class="cor livre" style="--c: {hex(estado.aparencia.cor)}" title="Outra cor">
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
<span class="sr-only">Escolher outra cor</span>
</label>
</div>
</div>
</Card>
<Card titulo="Celular">
{#snippet acoes()}
<button class="btn pequeno" onclick={recarregarCelular} disabled={!estado.celularOnline}>
<RefreshCw size={14} /> Recarregar tela
</button>
{/snippet}
{#if estado.celularOnline}
<dl class="sensores">
<div>
<dt><Battery size={16} /> Bateria</dt>
<dd>{bateria !== null ? bateria + '%' : '—'}</dd>
</div>
<div>
<dt><Moon size={16} /> Estado</dt>
<dd>{sensores.dormindo ? 'Dormindo' : 'Acordado'}</dd>
</div>
<div>
<dt><Ear size={16} /> Som</dt>
<dd>
{#if sensores.microfone_ativo}
<meter min="0" max="100" value={pct(sensores.microfone)} aria-label="Nível de som"></meter>
{:else}<span class="fraco">desligado</span>{/if}
</dd>
</div>
<div>
<dt><Camera size={16} /> Movimento</dt>
<dd>
{#if sensores.camera_ativa}
<meter min="0" max="100" value={pct((sensores.movimento as number) * 5)} aria-label="Movimento"></meter>
{:else}<span class="fraco">desligado</span>{/if}
</dd>
</div>
</dl>
<p class="fraco pequeno-texto mono">{estado.app?.celular_ip ?? ''}</p>
{:else}
<p class="fraco pequeno-texto">
O celular não está enviando sinais. Abra o app Carinha no aparelho e confira se ele aponta para este servidor.
</p>
{/if}
</Card>
<Card titulo="Personalidade local" descricao="Qwen escolhe expressões a partir de sons e movimentos.">
<Toggle
rotulo="Reagir sozinho com IA local"
ajuda={estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''}
checked={!!estado.ia?.ativo}
disabled={!estado.ia}
onchange={iaLocal}
/>
{#if estado.ia?.ultima}
<p class="pequeno-texto linha">
<Brain size={14} /> Último: {estado.ia.ultima.evento} → <strong>{estado.ia.ultima.gesto}</strong>
<span class="fraco">({estado.ia.ultima.segundos}s)</span>
</p>
{/if}
{#if estado.ia?.erro}<p class="erro pequeno-texto">{estado.ia.erro}</p>{/if}
</Card>
<Card titulo="Acontecimentos" class="feed">
{#if estado.feed.length}
<ol>
{#each estado.feed as item (item.id)}
<li class={item.tipo}><time class="mono fraco">{hora(item.quando)}</time> {item.texto}</li>
{/each}
</ol>
{:else}
<p class="fraco pequeno-texto">Gestos, respostas e reações aparecem aqui assim que acontecerem.</p>
{/if}
</Card>
</div>
</div>
<style>
.ao-vivo {
display: grid;
gap: var(--e-4);
}
.palco {
display: grid;
gap: var(--e-3);
grid-template-columns: 1fr;
}
.quadro {
position: relative;
aspect-ratio: 16 / 9;
border-radius: var(--raio-lg);
overflow: hidden;
background: #000;
border: 1px solid var(--borda);
}
.camera video,
.sobreposicao {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.sobreposicao {
pointer-events: none;
}
.vazio {
position: absolute;
inset: 0;
display: grid;
place-content: center;
justify-items: center;
gap: var(--e-2);
padding: var(--e-5);
text-align: center;
color: #8396ab;
font-size: var(--t-sm);
}
.rodape {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
background: linear-gradient(transparent, rgb(0 0 0 / 0.7));
color: #e8eef6;
font-size: var(--t-xs);
pointer-events: none;
}
.rodape button {
pointer-events: auto;
}
.espaco {
flex: 1;
}
.controles {
display: flex;
flex-wrap: wrap;
gap: var(--e-4);
align-items: center;
}
.transmissao {
display: grid;
gap: var(--e-1);
min-width: min(100%, 420px);
}
.grade-cards {
display: grid;
gap: var(--e-4);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
}
.segmentado {
display: flex;
background: var(--fundo-2);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
padding: 3px;
gap: 3px;
}
.segmentado button {
flex: 1;
min-height: 34px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
text-transform: capitalize;
cursor: pointer;
}
.segmentado button[aria-checked='true'] {
background: var(--superficie-3);
color: var(--texto);
}
.cores {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.cor {
width: 34px;
height: 34px;
border-radius: 50%;
border: 2px solid var(--borda-forte);
background: var(--c);
cursor: pointer;
position: relative;
}
.cor[aria-pressed='true'] {
outline: 2px solid var(--texto);
outline-offset: 2px;
}
.livre {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
}
.livre input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.sensores {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--e-3);
margin: 0;
}
.sensores div {
display: grid;
gap: 2px;
}
dt {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--t-xs);
color: var(--texto-3);
}
dd {
margin: 0;
font-weight: 500;
}
meter {
width: 100%;
height: 8px;
}
.erro {
color: var(--perigo);
}
:global(.feed) ol {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-2);
max-height: 260px;
overflow: auto;
font-size: var(--t-sm);
}
:global(.feed) li.estop {
color: var(--aviso);
}
@media (min-width: 900px) {
.palco {
grid-template-columns: 1fr 1fr;
}
}
</style>
+403
View File
@@ -0,0 +1,403 @@
<script lang="ts">
import { Mic, MicOff, Plus, Send, Trash2, Volume2, X } from '@lucide/svelte';
import { onMount, tick } from 'svelte';
import { postar } from '../lib/api';
import { avisos, tentar } from '../lib/avisos.svelte';
import { gesto, GESTOS_INFO } from '../lib/comandos.svelte';
import Abas from '../lib/componentes/Abas.svelte';
import Card from '../lib/componentes/Card.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { estado, ouvir } from '../lib/estado.svelte';
import { treinamento } from '../lib/treinamento.svelte';
const SUGESTOES = ['Oi, Carinha! Tudo bem?', 'Que horas são?', 'Me conta uma curiosidade', 'Como você está se sentindo?'];
let texto = $state('');
let enviando = $state(false);
let lista = $state<HTMLElement>();
const conversa = $derived(estado.conversa);
const ocupado = $derived(enviando || !!conversa?.ocupado);
async function enviar(mensagem = texto) {
const t = mensagem.trim();
if (!t || ocupado) return;
enviando = true;
try {
await postar('/conversa/enviar', { texto: t });
texto = '';
} catch (e) {
avisos.erro(e);
} finally {
enviando = false;
}
}
// Rola para o fim quando chegam mensagens.
$effect(() => {
void conversa?.historico.length;
void conversa?.ocupado;
tick().then(() => lista?.scrollTo({ top: lista.scrollHeight, behavior: 'smooth' }));
});
// ---------- voz (Web Speech API): fala só respostas novas, nunca a antiga ao abrir
let lerEmVoz = $state(false);
try {
lerEmVoz = localStorage.getItem('carinha_voz') === '1';
} catch {
/* ok */
}
function falar(t: string) {
if (!('speechSynthesis' in window) || !t) return;
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(t);
u.lang = 'pt-BR';
u.pitch = 1.35;
u.rate = 1.05;
const voz = speechSynthesis.getVoices().find((v) => v.lang.startsWith('pt'));
if (voz) u.voice = voz;
speechSynthesis.speak(u);
}
let ultimaFalada = estado.conversa?.ultima?.id;
onMount(() =>
ouvir('conversa', (c) => {
if (c.ultima && c.ultima.id !== ultimaFalada) {
ultimaFalada = c.ultima.id;
if (lerEmVoz) falar(c.ultima.texto);
}
}),
);
const Reconhecimento = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const vozDisponivel = !!Reconhecimento && window.isSecureContext;
const motivoSemVoz = !Reconhecimento
? 'Este navegador não reconhece voz.'
: 'O microfone do navegador exige HTTPS ou localhost.';
let gravando = $state(false);
let reconhecimento: any = null;
function microfone() {
if (gravando) return reconhecimento?.stop();
reconhecimento = new Reconhecimento();
reconhecimento.lang = 'pt-BR';
reconhecimento.interimResults = false;
reconhecimento.onstart = () => (gravando = true);
reconhecimento.onend = () => (gravando = false);
reconhecimento.onerror = (e: any) => {
gravando = false;
avisos.mostrar(e.error === 'not-allowed' ? 'Permissão do microfone negada.' : 'Microfone: ' + e.error, 'erro');
};
reconhecimento.onresult = (e: any) => {
texto = e.results[0][0].transcript;
enviar();
};
reconhecimento.start();
}
// ---------- memória: lembranças da conversa + rotinas do treinamento
type AbaMemoria = 'lembrancas' | 'rotinas';
let aba = $state<AbaMemoria>('lembrancas');
let nova = $state('');
const itensMemoria = $derived(aba === 'lembrancas' ? (conversa?.lembrancas ?? []) : (treinamento.status?.rotina ?? []));
onMount(() => {
treinamento.carregar();
});
async function adicionar(e: SubmitEvent) {
e.preventDefault();
const t = nova.trim();
if (!t) return;
const ok =
aba === 'lembrancas'
? await tentar(() => postar('/conversa/lembrar', { texto: t }))
: await treinamento.acao('rotina', { texto: t });
if (ok) nova = '';
}
const apagarLembranca = (id: number) => tentar(() => postar('/conversa/apagar', { id }));
const apagarRotina = (id: number) => treinamento.acao('rotina-apagar', { id });
let confirmarLimpar = $state(false);
async function limparTudo() {
confirmarLimpar = false;
await tentar(() => postar('/conversa/limpar'), 'Conversa, lembranças e diário apagados.');
treinamento.carregar();
}
</script>
<div class="conversa">
<Card class="chat">
{#snippet acoes()}
{#if conversa}
<Selo tom="destaque">{conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'} · {conversa.modelo}</Selo>
<a class="pequeno-texto" href="#/ajustes">trocar</a>
{/if}
{/snippet}
<div class="mensagens" bind:this={lista} aria-live="polite">
{#if !conversa?.historico.length}
<div class="boas-vindas">
<p>Converse com o Carinha. Ele responde aqui e reage com o rosto no celular.</p>
<div class="sugestoes">
{#each SUGESTOES as s (s)}
<button class="btn pequeno" onclick={() => enviar(s)} disabled={ocupado}>{s}</button>
{/each}
</div>
</div>
{/if}
{#each conversa?.historico ?? [] as m, i (i)}
<div class="msg {m.role}">
<p>{m.content}</p>
{#if m.role === 'assistant'}
<button class="btn fantasma pequeno icone" aria-label="Ler em voz alta" onclick={() => falar(m.content)}>
<Volume2 size={14} />
</button>
{/if}
</div>
{/each}
{#if conversa?.ocupado}
<div class="msg assistant pensando" aria-label="Carinha está pensando"><span></span><span></span><span></span></div>
{/if}
{#if conversa?.ultima && !conversa.ocupado && GESTOS_INFO[conversa.ultima.gesto]}
<button class="gesto-resposta" onclick={() => gesto(conversa.ultima!.gesto)}>
{GESTOS_INFO[conversa.ultima.gesto].emoji} reagiu com {GESTOS_INFO[conversa.ultima.gesto].nome.toLowerCase()} · repetir
</button>
{/if}
</div>
{#if conversa?.erro}<p class="erro pequeno-texto">{conversa.erro}</p>{/if}
<form
class="entrada"
onsubmit={(e) => {
e.preventDefault();
enviar();
}}
>
<button
type="button"
class="btn icone"
class:gravando
onclick={microfone}
disabled={!vozDisponivel || ocupado}
title={vozDisponivel ? (gravando ? 'Parar de ouvir' : 'Falar') : motivoSemVoz}
aria-label={gravando ? 'Parar de ouvir' : 'Falar'}
>
{#if vozDisponivel}<Mic size={18} />{:else}<MicOff size={18} />{/if}
</button>
<input type="text" bind:value={texto} placeholder="Escreva para o Carinha…" maxlength="600" aria-label="Mensagem" />
<button class="btn primario" type="submit" disabled={ocupado || !texto.trim()}>
<Send size={16} /> <span class="rotulo-enviar">Enviar</span>
</button>
</form>
<div class="linha entre">
<Toggle
rotulo="Ler respostas em voz alta neste PC"
checked={lerEmVoz}
onchange={(v) => {
lerEmVoz = v;
try {
localStorage.setItem('carinha_voz', v ? '1' : '0');
} catch {
/* ok */
}
}}
/>
</div>
</Card>
<Card titulo="Memória" descricao="O que o Carinha leva em conta ao responder. São dados, nunca instruções.">
<Abas
rotulo="Memória"
bind:ativa={aba}
abas={[
{ id: 'lembrancas', titulo: 'Lembranças', contador: conversa?.lembrancas.length ?? 0 },
{ id: 'rotinas', titulo: 'Rotina e preferências', contador: treinamento.status?.rotina.length ?? 0 },
]}
/>
<form class="linha nova" onsubmit={adicionar}>
<input
type="text"
bind:value={nova}
maxlength="300"
placeholder={aba === 'lembrancas' ? 'Ex.: Meu nome é Júlio' : 'Ex.: Treino às 7h de segunda a sexta'}
aria-label={aba === 'lembrancas' ? 'Nova lembrança' : 'Nova rotina'}
/>
<button class="btn" type="submit" disabled={!nova.trim()}><Plus size={16} /> Adicionar</button>
</form>
<ul class="itens">
{#each itensMemoria as item (item.id)}
<li>
<span>{item.texto}</span>
<button
class="btn fantasma pequeno icone"
aria-label="Apagar"
onclick={() => (aba === 'lembrancas' ? apagarLembranca(item.id) : apagarRotina(item.id))}
>
<X size={14} />
</button>
</li>
{:else}
<li class="fraco pequeno-texto">Nada guardado ainda.</li>
{/each}
</ul>
<div class="perigo-zona">
{#if confirmarLimpar}
<p class="pequeno-texto">Apagar conversa, lembranças e diário? Rotinas e reações ficam.</p>
<div class="linha">
<button class="btn perigo pequeno" onclick={limparTudo}>Sim, apagar</button>
<button class="btn pequeno" onclick={() => (confirmarLimpar = false)}>Cancelar</button>
</div>
{:else}
<button class="btn fantasma pequeno" onclick={() => (confirmarLimpar = true)}>
<Trash2 size={14} /> Apagar conversa e lembranças
</button>
{/if}
</div>
</Card>
</div>
<style>
.conversa {
display: grid;
gap: var(--e-4);
align-items: start;
}
:global(.chat) {
grid-template-rows: auto 1fr auto auto;
}
.mensagens {
display: flex;
flex-direction: column;
gap: var(--e-3);
min-height: 320px;
max-height: min(60vh, 640px);
overflow-y: auto;
padding: var(--e-1);
}
.boas-vindas {
margin: auto;
display: grid;
gap: var(--e-4);
justify-items: center;
text-align: center;
color: var(--texto-2);
max-width: 420px;
}
.sugestoes {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
justify-content: center;
}
.msg {
display: flex;
align-items: flex-end;
gap: var(--e-1);
max-width: 82%;
}
.msg p {
padding: var(--e-2) var(--e-3);
border-radius: var(--raio);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.msg.user {
align-self: flex-end;
}
.msg.user p {
background: var(--destaque);
color: var(--destaque-texto);
border-bottom-right-radius: 4px;
}
.msg.assistant p {
background: var(--superficie-2);
border: 1px solid var(--borda);
border-bottom-left-radius: 4px;
}
.pensando {
gap: 4px;
padding: var(--e-3);
border-radius: var(--raio);
background: var(--superficie-2);
width: fit-content;
}
.pensando span {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--texto-3);
animation: pulo 1s infinite ease-in-out;
}
.pensando span:nth-child(2) {
animation-delay: 0.15s;
}
.pensando span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes pulo {
50% {
transform: translateY(-4px);
opacity: 0.5;
}
}
.gesto-resposta {
align-self: flex-start;
border: 1px dashed var(--borda-forte);
border-radius: var(--raio-pill);
background: transparent;
color: var(--texto-2);
font: var(--t-xs) var(--fonte);
padding: 4px 12px;
cursor: pointer;
}
.entrada {
display: flex;
gap: var(--e-2);
}
.gravando {
background: var(--perigo-suave);
border-color: var(--perigo);
color: var(--perigo);
}
.erro {
color: var(--perigo);
}
.nova input {
flex: 1;
min-width: 180px;
}
.itens {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-1);
max-height: 320px;
overflow: auto;
}
.itens li {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
background: var(--superficie-2);
font-size: var(--t-sm);
}
.perigo-zona {
border-top: 1px solid var(--borda);
padding-top: var(--e-3);
display: grid;
gap: var(--e-2);
}
@media (max-width: 480px) {
.rotulo-enviar {
display: none;
}
}
@media (min-width: 1100px) {
.conversa {
grid-template-columns: 3fr 2fr;
}
}
</style>
+88
View File
@@ -0,0 +1,88 @@
<script lang="ts">
import { Power } from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte';
import { braco } from '../lib/braco.svelte';
import Abas from '../lib/componentes/Abas.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import { estado } from '../lib/estado.svelte';
import { registrarNaPaleta } from '../lib/paleta.svelte';
import Calibracao from './movimento/Calibracao.svelte';
import Controle from './movimento/Controle.svelte';
import Diagnostico from './movimento/Diagnostico.svelte';
type Aba = 'controle' | 'calibracao' | 'diagnostico';
let aba = $state<Aba>('controle');
const motores = $derived(estado.motores);
onMount(() => {
braco.carregar();
});
// Posturas na paleta: carregar como alvo (mover exige segurar o botão).
const sair = registrarNaPaleta(() =>
(braco.status?.posturas ?? []).map((p, i) => ({
id: 'postura-' + i,
grupo: 'Posturas do braço',
titulo: 'Carregar ' + p.nome,
fazer: () => (braco.alvo = [...p.angulos]),
})),
);
onDestroy(() => {
sair();
braco.soltar();
});
</script>
<div class="movimento">
<div class="estado">
<div class="linha">
{#if !motores?.disponivel}
<Selo tom="aviso">PCA9685 indisponível neste servidor</Selo>
{:else if estado.estop.ativo || motores.travado}
<Selo tom="perigo">Travado pelo E-Stop</Selo>
{:else if motores.ativos.length}
<Selo tom="ok">Motores ligados · {motores.dono === 'braco' ? 'braço' : 'diagnóstico'} · canais {motores.ativos.join(', ')}</Selo>
{:else}
<Selo>Motores desligados</Selo>
{/if}
{#if motores?.erro}<span class="erro pequeno-texto">{motores.erro}</span>{/if}
</div>
<button class="btn" onclick={() => braco.pararMotores()} disabled={!motores?.ativos.length}>
<Power size={16} /> Desligar motores
</button>
</div>
<Abas
rotulo="Movimento"
bind:ativa={aba}
abas={[
{ id: 'controle', titulo: 'Controle' },
{ id: 'calibracao', titulo: 'Calibração' },
{ id: 'diagnostico', titulo: 'Diagnóstico' },
]}
/>
{#if aba === 'controle'}
<Controle />
{:else if aba === 'calibracao'}
<Calibracao />
{:else}
<Diagnostico />
{/if}
</div>
<style>
.movimento {
display: grid;
gap: var(--e-4);
}
.estado {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--e-3);
flex-wrap: wrap;
}
.erro {
color: var(--perigo);
}
</style>
+449
View File
@@ -0,0 +1,449 @@
<script lang="ts">
import {
Copy, Download, Eye, EyeOff, Pencil, Plus, Search, Send, Sparkles, Square, ThumbsDown, ThumbsUp, Trash2, Undo2,
} from '@lucide/svelte';
import { onMount } from 'svelte';
import { postar } from '../lib/api';
import { avisos, tentar } from '../lib/avisos.svelte';
import Abas from '../lib/componentes/Abas.svelte';
import Card from '../lib/componentes/Card.svelte';
import Gestos from '../lib/componentes/Gestos.svelte';
import Miniatura from '../lib/componentes/Miniatura.svelte';
import Modal from '../lib/componentes/Modal.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { treinamento, type ReacaoBiblioteca } from '../lib/treinamento.svelte';
type Aba = 'biblioteca' | 'criar' | 'gestos' | 'diario';
let aba = $state<Aba>('biblioteca');
let previa = $state<{ previa: (receita: unknown) => void }>();
let busca = $state('');
const st = $derived(treinamento.status);
onMount(() => {
treinamento.carregar();
});
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const reacoes = $derived(
(st?.reacoes ?? []).filter((r) => !busca || normal(r.receita.nome + ' ' + r.receita.motivo).includes(normal(busca))),
);
const tipo = (r: ReacaoBiblioteca) => (r.receita.figurinha ? 'Desenho' : r.receita.emoji ? 'Emoji' : 'Animação');
// ---------- ações da biblioteca
const mostrar = (r: ReacaoBiblioteca) => tentar(() => postar('/comando', { tipo: 'reacao', valor: r.id }), 'Enviada ao celular.');
const pararNoCelular = () => {
previa?.previa({ limpar: true });
tentar(() => postar('/comando', { tipo: 'parar_reacao' }));
};
const biblioteca = (dados: object, sucesso?: string) => treinamento.acao('biblioteca', dados, sucesso);
const desfazer = () => biblioteca({ acao: 'desfazer' }, 'Alteração desfeita.');
async function excluir(r: ReacaoBiblioteca) {
if (await biblioteca({ acao: 'excluir', id: r.id }))
avisos.ok(`“${r.receita.nome}” excluída.`, { rotulo: 'Desfazer', fazer: desfazer });
}
const feedback = (r: ReacaoBiblioteca, valor: -1 | 1) =>
treinamento.acao('feedback', { id: r.id, valor: r.feedback === valor ? 0 : valor });
function exportar() {
if (!st) return;
const url = URL.createObjectURL(
new Blob([JSON.stringify(st.reacoes.map((r) => r.receita), null, 2)], { type: 'application/json' }),
);
const a = document.createElement('a');
a.href = url;
a.download = 'carinha-reacoes.json';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// ---------- editor
let editorAberto = $state(false);
let editando = $state<ReacaoBiblioteca | null>(null);
let form = $state({ nome: '', motivo: '', caractere: '💡', local: 'olhos' });
function abrirEditor(r: ReacaoBiblioteca | null) {
editando = r;
form = {
nome: r?.receita.nome ?? '',
motivo: r?.receita.motivo ?? '',
caractere: r?.receita.emoji?.caractere ?? (r ? '' : '💡'),
local: r?.receita.emoji?.local ?? r?.receita.figurinha?.local ?? 'olhos',
};
editorAberto = true;
}
async function salvarEditor(e: SubmitEvent) {
e.preventDefault();
const dados = { acao: editando ? 'editar' : 'criar', id: editando?.id ?? null, ...form, nome: form.nome.trim(), motivo: form.motivo.trim() };
if (await biblioteca(dados, editando ? 'Reação atualizada.' : 'Emoji criado.')) editorAberto = false;
}
// ---------- criar com IA
let pedido = $state('');
async function desenhar(e: SubmitEvent) {
e.preventDefault();
if (await treinamento.acao('figurinha', { texto: pedido.trim() }, 'Pedido enviado ao Gemini. A figurinha aparece no celular quando ficar pronta.'))
pedido = '';
}
let confirmarDiario = $state(false);
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
</script>
<div class="reacoes">
<div class="principal">
<Abas
rotulo="Reações"
bind:ativa={aba}
abas={[
{ id: 'biblioteca', titulo: 'Biblioteca', contador: st?.reacoes.length },
{ id: 'criar', titulo: 'Criar com IA' },
{ id: 'gestos', titulo: 'Gestos do rosto' },
{ id: 'diario', titulo: 'Diário' },
]}
/>
{#if aba === 'biblioteca'}
<div class="barra">
<label class="busca">
<Search size={16} />
<input type="search" bind:value={busca} placeholder="Buscar reação" aria-label="Buscar reação" />
</label>
<button class="btn primario" onclick={() => abrirEditor(null)} disabled={st?.ocupado}><Plus size={16} /> Novo emoji</button>
<button class="btn" onclick={desfazer} disabled={!st?.desfazer || st?.ocupado}><Undo2 size={16} /> Desfazer</button>
<button class="btn" onclick={pararNoCelular}><Square size={16} /> Parar no celular</button>
<button class="btn fantasma" onclick={exportar} disabled={!st?.reacoes.length}><Download size={16} /> Exportar</button>
</div>
{#if !st}
<p class="fraco">Carregando biblioteca…</p>
{:else}
<ul class="grade-reacoes">
{#each reacoes as r (r.id)}
<li class="reacao" class:inativa={!r.ativa}>
<button class="topo" onclick={() => previa?.previa(r.receita)} title="Ver na prévia">
<Miniatura receita={r.receita} />
<div class="info">
<strong>{r.receita.nome}</strong>
<span class="fraco pequeno-texto">{r.receita.motivo}</span>
<span class="linha">
<Selo>{tipo(r)}</Selo>
{#if !r.ativa}<Selo tom="aviso">Desativada</Selo>{/if}
</span>
</div>
</button>
<div class="acoes">
<button class="btn pequeno primario" onclick={() => mostrar(r)}><Send size={14} /> Mostrar</button>
<button
class="btn pequeno icone"
class:marcado={r.feedback === 1}
aria-pressed={r.feedback === 1}
aria-label="Gostei"
onclick={() => feedback(r, 1)}><ThumbsUp size={14} /></button
>
<button
class="btn pequeno icone"
class:marcado-ruim={r.feedback === -1}
aria-pressed={r.feedback === -1}
aria-label="Não gostei"
onclick={() => feedback(r, -1)}><ThumbsDown size={14} /></button
>
<span class="espaco"></span>
<button class="btn pequeno icone fantasma" aria-label="Editar" title="Editar" onclick={() => abrirEditor(r)}><Pencil size={14} /></button>
<button class="btn pequeno icone fantasma" aria-label="Duplicar" title="Duplicar" onclick={() => biblioteca({ acao: 'duplicar', id: r.id }, 'Reação duplicada.')}
><Copy size={14} /></button
>
<button
class="btn pequeno icone fantasma"
aria-label={r.ativa ? 'Desativar' : 'Ativar'}
title={r.ativa ? 'Desativar (a IA deixa de usar)' : 'Ativar'}
onclick={() => biblioteca({ acao: 'ativar', id: r.id, ativa: !r.ativa })}
>
{#if r.ativa}<EyeOff size={14} />{:else}<Eye size={14} />{/if}
</button>
<button class="btn pequeno icone fantasma perigo-texto" aria-label="Excluir" title="Excluir" onclick={() => excluir(r)}><Trash2 size={14} /></button>
</div>
</li>
{:else}
<li class="fraco">{busca ? 'Nenhuma reação encontrada.' : 'Crie um emoji ou peça um desenho ao Gemini.'}</li>
{/each}
</ul>
{/if}
{:else if aba === 'criar'}
<div class="grade-criar">
<Card titulo="Desenhar figurinha" descricao="O Gemini desenha um SVG e o celular mostra assim que ficar pronto.">
<form class="grade" onsubmit={desenhar}>
<label class="campo">
<span>O que desenhar</span>
<textarea bind:value={pedido} maxlength="1000" placeholder="Ex.: um sol de óculos escuros sorrindo" required></textarea>
</label>
<button class="btn primario" type="submit" disabled={st?.ocupado || !pedido.trim()}>
<Sparkles size={16} /> {st?.ocupado ? 'Gemini trabalhando…' : 'Desenhar e mostrar'}
</button>
</form>
{#if st?.ultimo_prompt_figurinha}
<details>
<summary class="pequeno-texto fraco">Último pedido enviado</summary>
<pre class="mono">{st.ultimo_prompt_figurinha}</pre>
</details>
{/if}
</Card>
<Card titulo="Reações automáticas" descricao="A partir da conversa e da rotina, o Gemini inventa 3 animações novas.">
<button class="btn" onclick={() => treinamento.acao('gerar', {}, 'Gemini está criando 3 reações…')} disabled={st?.ocupado}>
<Sparkles size={16} /> Gerar 3 agora
</button>
<Toggle
rotulo="Gerar sozinho a cada hora"
ajuda="Cada geração é uma chamada paga ao Gemini"
checked={!!st?.automatico}
disabled={!st}
onchange={(v) => treinamento.acao('config', { automatico: v })}
/>
{#if st?.ocupado}<Selo tom="aviso">Gemini trabalhando…</Selo>{/if}
{#if st?.erro}<p class="erro pequeno-texto">{st.erro}</p>{/if}
</Card>
</div>
{:else if aba === 'gestos'}
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (atalhos 1 a 9).">
<Gestos completo />
</Card>
{:else}
<div class="grade-criar">
<Card titulo="Resumos">
{#each st?.resumos ?? [] as r (r.id)}
<article class="entrada"><time class="fraco pequeno-texto">{hora(r.horario)}</time><p>{r.texto}</p></article>
{:else}
<p class="fraco pequeno-texto">Os resumos aparecem quando o Gemini gera reações.</p>
{/each}
</Card>
<Card titulo="Diário">
{#snippet acoes()}
{#if confirmarDiario}
<button
class="btn perigo pequeno"
onclick={async () => {
confirmarDiario = false;
await treinamento.acao('limpar-diario', {}, 'Diário e resumos apagados.');
}}>Apagar diário</button
>
<button class="btn pequeno" onclick={() => (confirmarDiario = false)}>Cancelar</button>
{:else}
<button class="btn pequeno fantasma" onclick={() => (confirmarDiario = true)} disabled={!st?.diario.length}>
<Trash2 size={14} /> Limpar
</button>
{/if}
{/snippet}
<div class="diario">
{#each st?.diario ?? [] as m (m.id)}
<article class="entrada">
<time class="fraco pequeno-texto">{hora(m.horario)} · {m.papel === 'user' ? 'Você' : 'Carinha'}</time>
<p>{m.texto}</p>
</article>
{:else}
<p class="fraco pequeno-texto">Vazio.</p>
{/each}
</div>
</Card>
</div>
{/if}
</div>
<aside class="lateral">
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" /></div>
<p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p>
</aside>
</div>
<Modal bind:aberto={editorAberto} titulo={editando ? 'Editar reação' : 'Novo emoji'}>
<form class="grade" onsubmit={salvarEditor}>
<label class="campo"><span>Nome</span><input type="text" bind:value={form.nome} maxlength="50" required /></label>
<label class="campo"><span>Quando usar</span><input type="text" bind:value={form.motivo} maxlength="200" required /></label>
{#if !editando?.receita.figurinha}
<div class="campo">
<span>Emoji</span>
<div class="emojis" role="radiogroup" aria-label="Emoji">
{#each st?.emojis ?? [] as e (e)}
<button type="button" role="radio" aria-checked={form.caractere === e} onclick={() => (form.caractere = e)}>{e}</button>
{/each}
</div>
</div>
<label class="campo">
<span>Onde aparece</span>
<select bind:value={form.local}>
<option value="olhos">Nos olhos</option>
<option value="ao_redor">Ao redor do rosto</option>
</select>
</label>
{/if}
<div class="linha">
<button class="btn primario" type="submit" disabled={!editando && !form.caractere}>Salvar</button>
<button class="btn" type="button" onclick={() => (editorAberto = false)}>Cancelar</button>
</div>
</form>
</Modal>
<style>
.reacoes {
display: grid;
gap: var(--e-4);
align-items: start;
}
.principal {
display: grid;
gap: var(--e-4);
min-width: 0;
}
.barra {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.busca {
flex: 1;
min-width: 200px;
display: flex;
align-items: center;
gap: var(--e-2);
padding-left: var(--e-3);
border: 1px solid var(--borda-forte);
border-radius: var(--raio-sm);
background: var(--fundo-2);
color: var(--texto-3);
}
.busca input {
border: 0;
background: transparent;
}
.grade-reacoes {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-3);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
}
.reacao {
display: grid;
gap: var(--e-3);
padding: var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
background: var(--superficie);
}
.reacao.inativa {
opacity: 0.6;
}
.topo {
display: flex;
gap: var(--e-3);
text-align: left;
border: 0;
background: transparent;
color: inherit;
font: inherit;
padding: 0;
cursor: pointer;
}
.info {
display: grid;
gap: 4px;
min-width: 0;
align-content: start;
}
.info strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acoes {
display: flex;
gap: var(--e-1);
align-items: center;
flex-wrap: wrap;
}
.espaco {
flex: 1;
}
.marcado {
color: var(--ok);
border-color: var(--ok);
}
.marcado-ruim {
color: var(--perigo);
border-color: var(--perigo);
}
.perigo-texto:hover {
color: var(--perigo);
}
.grade-criar {
display: grid;
gap: var(--e-4);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
align-items: start;
}
pre {
white-space: pre-wrap;
font-size: var(--t-xs);
color: var(--texto-2);
max-height: 200px;
overflow: auto;
}
.erro {
color: var(--perigo);
}
.entrada {
display: grid;
gap: 2px;
font-size: var(--t-sm);
}
.diario {
display: grid;
gap: var(--e-3);
max-height: 480px;
overflow: auto;
}
.lateral {
display: grid;
gap: var(--e-2);
}
.quadro {
aspect-ratio: 16 / 9;
border-radius: var(--raio-lg);
overflow: hidden;
border: 1px solid var(--borda);
background: #000;
}
.emojis {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: 4px;
max-height: 180px;
overflow: auto;
}
.emojis button {
height: 40px;
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
background: var(--superficie-2);
font-size: 1.3rem;
cursor: pointer;
}
.emojis button[aria-checked='true'] {
border-color: var(--destaque);
background: var(--destaque-suave);
}
@media (max-width: 1099px) {
.lateral {
order: -1;
}
}
@media (min-width: 1100px) {
.reacoes {
grid-template-columns: 1fr 340px;
}
.lateral {
position: sticky;
top: calc(var(--topo-altura) + var(--e-4));
}
}
</style>
+195
View File
@@ -0,0 +1,195 @@
<script lang="ts">
import { Minus, Plus, Save, Target } from '@lucide/svelte';
import { postar } from '../../lib/api';
import { avisos } from '../../lib/avisos.svelte';
import { braco, type Calibracao } from '../../lib/braco.svelte';
import Card from '../../lib/componentes/Card.svelte';
import Selo from '../../lib/componentes/Selo.svelte';
import Toggle from '../../lib/componentes/Toggle.svelte';
import { estado } from '../../lib/estado.svelte';
const st = $derived(braco.status);
// Cópia local editável; só vai ao servidor em "Salvar calibração".
let cal = $state<Calibracao[]>([]);
let sujo = $state(false);
$effect(() => {
if (st && !sujo) cal = st.calibracao.map((c) => ({ ...c, centro: c.centro ?? 1500 }));
});
let habilitado = $state(false);
let canal = $state(0);
let pulso = $state(1500);
let ocupado = $state(false);
const nomeCanal = (i: number) => st?.articulacoes.find((a) => a.canal === i)?.nome ?? (i === 6 ? 'Antebraço (par invertido)' : `Canal ${i}`);
function trocarCanal(i: number) {
canal = i;
pulso = cal[i]?.centro ?? 1500;
habilitado = false;
braco.pararMotores();
}
async function testar(delta: number) {
if (!habilitado || ocupado) return;
const prox = pulso + delta;
if (prox < 500 || prox > 2500) return avisos.mostrar('Faixa absoluta: 500–2500 µs', 'erro');
ocupado = true;
try {
braco.soltar();
const r = await postar<{ pulsos: Record<string, number> }>('/braco/calibrar', { canal, pulso: prox });
pulso = prox;
avisos.mostrar(`Teste: ${Object.entries(r.pulsos).map(([c, us]) => `canal ${c} = ${us} µs`).join(', ')}. Desliga em 2 s.`);
} catch (e) {
avisos.erro(e);
} finally {
ocupado = false;
}
}
function marcar(campo: 'minimo' | 'centro' | 'maximo') {
cal[canal] = { ...cal[canal], [campo]: pulso };
sujo = true;
}
function editar(i: number, campo: 'minimo' | 'centro' | 'maximo', v: number) {
cal[i] = { ...cal[i], [campo]: v };
sujo = true;
}
const invalido = $derived(cal.find((c) => !(c.minimo < c.centro && c.centro < c.maximo)));
async function salvar() {
if (!st) return;
habilitado = false;
if (await braco.salvar({ calibracao: $state.snapshot(cal), posturas: st.posturas }, 'Calibração salva. Movimento desativado.')) {
sujo = false;
braco.ativado = false;
}
}
</script>
<div class="calibracao">
<Card titulo="Testar fim de curso" descricao="Passos de 100 µs. Observe o motor e recue ao atingir o limite: não há detecção automática.">
<Toggle
rotulo="Habilitar testes de calibração"
ajuda="Cada teste mantém o sinal por até 2 s"
checked={habilitado}
disabled={!st?.disponivel || estado.estop.ativo}
onchange={(v) => {
habilitado = v;
if (!v) braco.pararMotores();
}}
/>
<label class="campo">
<span>Canal</span>
<select value={canal} onchange={(e) => trocarCanal(Number(e.currentTarget.value))}>
{#each Array.from({ length: 8 }, (_, i) => i) as i (i)}
<option value={i}>Canal {i} · {nomeCanal(i)}{i === 5 || i === 6 ? ' (par 5/6)' : ''}</option>
{/each}
</select>
</label>
<div class="pulso">
<button class="btn icone" onclick={() => testar(-100)} disabled={!habilitado || ocupado} aria-label="Menos 100 µs"><Minus size={18} /></button>
<output class="mono" aria-live="polite">{pulso} µs</output>
<button class="btn icone" onclick={() => testar(100)} disabled={!habilitado || ocupado} aria-label="Mais 100 µs"><Plus size={18} /></button>
</div>
<button class="btn" onclick={() => testar(0)} disabled={!habilitado || ocupado}><Target size={16} /> Testar posição atual</button>
<div class="linha">
<button class="btn pequeno" onclick={() => marcar('minimo')}>Marcar mínimo</button>
<button class="btn pequeno" onclick={() => marcar('centro')}>Marcar centro</button>
<button class="btn pequeno" onclick={() => marcar('maximo')}>Marcar máximo</button>
</div>
<p class="fraco pequeno-texto">Marcar só altera a tabela; nada se move. Canais 5 e 6 andam juntos, espelhados.</p>
</Card>
<Card titulo="Limites por canal">
{#snippet acoes()}
{#if sujo}<Selo tom="aviso">Alterações não salvas</Selo>{/if}
<button class="btn pequeno primario" onclick={salvar} disabled={!sujo || !!invalido}><Save size={14} /> Salvar calibração</button>
{/snippet}
<div class="tabela">
<table>
<thead>
<tr><th>Canal</th><th>Mínimo</th><th>Centro</th><th>Máximo</th></tr>
</thead>
<tbody>
{#each cal as c, i (i)}
<tr class:atual={i === canal} class:erro={!(c.minimo < c.centro && c.centro < c.maximo)}>
<th scope="row">{i} <small class="fraco">{nomeCanal(i)}</small></th>
{#each ['minimo', 'centro', 'maximo'] as const as campo (campo)}
<td>
<input
type="number"
min="500"
max="2500"
step="10"
value={c[campo]}
aria-label="{campo} do canal {i}"
onchange={(e) => editar(i, campo, Number(e.currentTarget.value))}
/>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{#if invalido}<p class="erro pequeno-texto">Canal {invalido.canal}: o centro precisa ficar entre mínimo e máximo.</p>{/if}
</Card>
</div>
<style>
.calibracao {
display: grid;
gap: var(--e-4);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
align-items: start;
}
.pulso {
display: flex;
align-items: center;
justify-content: center;
gap: var(--e-4);
}
output {
font-size: var(--t-xl);
min-width: 7ch;
text-align: center;
}
.tabela {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--t-sm);
}
th,
td {
padding: var(--e-1) var(--e-2);
text-align: left;
}
thead th {
color: var(--texto-3);
font-weight: 500;
font-size: var(--t-xs);
}
tbody th {
font-weight: 500;
white-space: nowrap;
}
tbody th small {
display: block;
}
td input {
min-width: 84px;
min-height: 34px;
}
tr.atual {
background: var(--destaque-suave);
}
tr.erro input {
border-color: var(--perigo);
}
.erro {
color: var(--perigo);
}
</style>
+325
View File
@@ -0,0 +1,325 @@
<script lang="ts">
import { Crosshair, Download, Eye, Hand, Play, Save, Trash2, Upload } from '@lucide/svelte';
import { onDestroy } from 'svelte';
import { avisos } from '../../lib/avisos.svelte';
import { braco, sequencias } from '../../lib/braco.svelte';
import BracoDesenho from '../../lib/componentes/BracoDesenho.svelte';
import Card from '../../lib/componentes/Card.svelte';
import Slider from '../../lib/componentes/Slider.svelte';
import Toggle from '../../lib/componentes/Toggle.svelte';
import { estado } from '../../lib/estado.svelte';
const st = $derived(braco.status);
const podeMover = $derived(!!st?.disponivel && !estado.estop.ativo);
const seqs = $derived(st ? sequencias(braco.faixas) : []);
// ---------- botão de homem-morto (mouse, toque e teclado)
function segurarBotao(e: PointerEvent, seq?: { nome: string; passos: number[][] }) {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
braco.segurar(seq);
}
function teclaSegurar(e: KeyboardEvent, seq?: { nome: string; passos: number[][] }) {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) {
e.preventDefault();
braco.segurar(seq);
}
}
function teclaSoltar(e: KeyboardEvent) {
if (e.key === ' ' || e.key === 'Enter') braco.soltar();
}
// Arrastar um slider com o movimento ativado também é "segurar".
function teclaSlider(e: KeyboardEvent) {
if (braco.ativado && /^Arrow|Page|Home|End/.test(e.key)) braco.segurar();
}
// ---------- prévia animada (só na tela)
let previa = $state<number[] | null>(null);
let quadro = 0;
function animar(passos: number[][]) {
cancelAnimationFrame(quadro);
let pose = [...braco.alvo];
let i = 0;
let pausa = 0;
let antes = performance.now();
const passo = (agora: number) => {
const dt = Math.min(0.1, (agora - antes) / 1000);
antes = agora;
const meta = passos[i];
const dist = Math.max(...pose.map((v, k) => Math.abs(meta[k] - v)));
const frac = dist ? Math.min(1, (braco.velocidade * dt) / dist) : 1;
pose = pose.map((v, k) => v + (meta[k] - v) * frac);
previa = pose;
if (dist < 0.01) {
if (!pausa) pausa = agora + 700;
if (agora >= pausa) {
pausa = 0;
if (++i >= passos.length) {
previa = null;
return;
}
}
}
quadro = requestAnimationFrame(passo);
};
quadro = requestAnimationFrame(passo);
}
onDestroy(() => {
cancelAnimationFrame(quadro);
braco.soltar();
});
// ---------- posturas
let nomePostura = $state('');
let excluindo = $state<number | null>(null);
async function salvarPostura(e: SubmitEvent) {
e.preventDefault();
if (!st) return;
const nome = nomePostura.trim() || `Postura ${st.posturas.length + 1}`;
const posturas = [...st.posturas, { nome, angulos: $state.snapshot(braco.alvo) }];
if (await braco.salvar({ calibracao: st.calibracao, posturas }, `“${nome}” salva, sem movimento.`)) nomePostura = '';
}
async function excluirPostura(i: number) {
if (!st) return;
excluindo = null;
await braco.salvar({ calibracao: st.calibracao, posturas: st.posturas.filter((_, k) => k !== i) }, 'Postura excluída.');
}
const salvas = $derived(st?.posturas.length ? { nome: 'Posturas salvas em ordem', passos: st.posturas.map((p) => p.angulos) } : null);
function exportar() {
if (!st) return;
const url = URL.createObjectURL(
new Blob([JSON.stringify({ formato: 'carinha-braco-v1', calibracao: st.calibracao, posturas: st.posturas }, null, 2)], {
type: 'application/json',
}),
);
const a = document.createElement('a');
a.href = url;
a.download = 'braco-posturas.json';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
let arquivo: HTMLInputElement;
async function importar() {
const f = arquivo.files?.[0];
arquivo.value = '';
if (!f) return;
try {
if (f.size > 65536) throw new Error('Arquivo maior que 64 KB');
const dados = JSON.parse(await f.text());
if (dados.formato !== 'carinha-braco-v1') throw new Error('Use um arquivo exportado por este painel');
await braco.salvar({ calibracao: dados.calibracao, posturas: dados.posturas }, 'Calibração e posturas importadas, sem movimento.');
} catch (e) {
avisos.erro(e);
}
}
</script>
<svelte:window onpointerup={() => braco.soltar()} />
<div class="controle">
<Card class="desenho">
<div class="tela"><BracoDesenho pose={previa ?? braco.alvo} fantasma={previa ? null : st?.atual} /></div>
<p class="fraco pequeno-texto">
{#if previa}Prévia na tela · sem movimento físico{:else if st?.atual}Contorno claro: última posição enviada aos motores{:else}Alvo na tela{/if}
</p>
</Card>
<div class="coluna">
<Card titulo="Mover">
<Toggle
rotulo="Ativar movimento físico"
ajuda={!st?.disponivel ? 'PCA9685 indisponível neste servidor' : estado.estop.ativo ? 'Rearme o E-Stop primeiro' : 'Com isso desligado, tudo acontece só na tela'}
checked={braco.ativado}
disabled={!podeMover}
onchange={(v) => (v ? (braco.ativado = true) : braco.desativar())}
/>
<button
class="segurar"
class:ativo={braco.segurando}
disabled={!braco.ativado}
onpointerdown={(e) => segurarBotao(e)}
onpointerup={() => braco.soltar()}
onpointercancel={() => braco.soltar()}
onlostpointercapture={() => braco.soltar()}
onkeydown={(e) => teclaSegurar(e)}
onkeyup={teclaSoltar}
onblur={() => braco.soltar()}
oncontextmenu={(e) => e.preventDefault()}
>
<Hand size={22} />
{#if braco.passoSequencia}
{braco.passoSequencia.nome} · passo {braco.passoSequencia.indice + 1}/{braco.passoSequencia.total}
{:else if braco.segurando}Movendo… solte para parar{:else}Segure para ir ao alvo{/if}
</button>
<p class="fraco pequeno-texto">
O braço só se move enquanto você segura (botão, slider ou Espaço). Ao soltar, os motores desligam em menos de meio segundo.
</p>
<Slider rotulo="Velocidade" valor={braco.velocidade} min={5} max={180} passo={5} unidade=" °/s" oninput={(v) => (braco.velocidade = v)} />
</Card>
<Card titulo="Articulações">
{#snippet acoes()}
<button class="btn pequeno" onclick={() => ((braco.alvo = Array(7).fill(90)), braco.limitarAlvo())}>
<Crosshair size={14} /> Centralizar
</button>
{/snippet}
{#if st}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="articulacoes"
onpointerdown={(e) => braco.ativado && (e.target as HTMLElement).tagName === 'INPUT' && braco.segurar()}
onkeydown={teclaSlider}
onkeyup={() => braco.soltar()}
>
{#each st.articulacoes as a, i (a.canal)}
<Slider
rotulo={`${a.nome} · canal ${a.canal}${a.canal === 5 ? ' + 6' : ''}`}
valor={braco.alvo[i]}
min={braco.faixas[i][0]}
max={braco.faixas[i][1]}
unidade="°"
oninput={(v) => (braco.alvo[i] = v)}
/>
{/each}
</div>
{/if}
</Card>
</div>
<Card titulo="Sequências" descricao="Prévia anima só a tela. Para executar, segure o botão até o fim.">
<ul class="lista">
{#each salvas ? [...seqs, salvas] : seqs as s (s.nome)}
<li>
<span>{s.nome}</span>
<span class="linha">
<button class="btn pequeno" onclick={() => animar(s.passos)}><Eye size={14} /> Prévia</button>
<button
class="btn pequeno primario"
disabled={!braco.ativado || (braco.segurando && braco.passoSequencia?.nome !== s.nome)}
onpointerdown={(e) => segurarBotao(e, s)}
onpointerup={() => braco.soltar()}
onpointercancel={() => braco.soltar()}
onkeydown={(e) => teclaSegurar(e, s)}
onkeyup={teclaSoltar}
oncontextmenu={(e) => e.preventDefault()}
>
<Play size={14} /> Segure
</button>
</span>
</li>
{/each}
</ul>
</Card>
<Card titulo="Posturas">
{#snippet acoes()}
<button class="btn pequeno fantasma" onclick={exportar} disabled={!st}><Download size={14} /> Exportar</button>
<button class="btn pequeno fantasma" onclick={() => arquivo.click()}><Upload size={14} /> Importar</button>
<input bind:this={arquivo} type="file" accept="application/json" hidden onchange={importar} />
{/snippet}
<form class="linha" onsubmit={salvarPostura}>
<input type="text" bind:value={nomePostura} maxlength="40" placeholder="Nome da postura atual" aria-label="Nome da postura" />
<button class="btn" type="submit" disabled={!st}><Save size={16} /> Salvar atual</button>
</form>
<ul class="lista">
{#each st?.posturas ?? [] as p, i (i)}
<li>
<button class="nome" onclick={() => (braco.alvo = [...p.angulos])} title="Carregar como alvo (sem mover)">{p.nome}</button>
<span class="linha">
{#if excluindo === i}
<button class="btn pequeno perigo" onclick={() => excluirPostura(i)}>Excluir</button>
<button class="btn pequeno" onclick={() => (excluindo = null)}>Cancelar</button>
{:else}
<button class="btn pequeno" onclick={() => (braco.alvo = [...p.angulos])}>Carregar</button>
<button class="btn pequeno icone fantasma" aria-label="Excluir {p.nome}" onclick={() => (excluindo = i)}><Trash2 size={14} /></button>
{/if}
</span>
</li>
{:else}
<li class="fraco pequeno-texto">Nenhuma postura salva. Ajuste as articulações e salve.</li>
{/each}
</ul>
</Card>
</div>
<style>
.controle {
display: grid;
gap: var(--e-4);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
align-items: start;
}
.coluna {
display: grid;
gap: var(--e-4);
}
.tela {
aspect-ratio: 4 / 5;
max-height: 520px;
margin: 0 auto;
width: 100%;
background: var(--fundo-2);
border-radius: var(--raio);
}
.segurar {
display: flex;
align-items: center;
justify-content: center;
gap: var(--e-3);
min-height: 64px;
padding: var(--e-3);
border: 2px dashed var(--destaque);
border-radius: var(--raio);
background: var(--destaque-suave);
color: var(--destaque);
font: 600 var(--t-md) var(--fonte);
cursor: pointer;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.segurar.ativo {
border-style: solid;
background: var(--destaque);
color: var(--destaque-texto);
}
.segurar:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.articulacoes {
display: grid;
gap: var(--e-3);
}
.lista {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-2);
}
.lista li {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
background: var(--superficie-2);
font-size: var(--t-sm);
flex-wrap: wrap;
}
.nome {
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
text-align: left;
}
form input {
flex: 1;
min-width: 160px;
}
</style>
+184
View File
@@ -0,0 +1,184 @@
<script lang="ts">
import { Plus, Save, Trash2, Zap } from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte';
import { pedir, postar } from '../../lib/api';
import { avisos, tentar } from '../../lib/avisos.svelte';
import Card from '../../lib/componentes/Card.svelte';
import Selo from '../../lib/componentes/Selo.svelte';
import Slider from '../../lib/componentes/Slider.svelte';
import Toggle from '../../lib/componentes/Toggle.svelte';
import { estado } from '../../lib/estado.svelte';
interface Servo {
nome: string;
canal: number;
minimo: number;
maximo: number;
centro: number;
}
let servos = $state<Servo[]>([]);
let pulsos = $state<number[]>([]);
let disponivel = $state(false);
let sujo = $state(false);
let aoVivo = $state(false);
async function carregar() {
const st = await tentar(() => pedir<{ servos: Servo[]; disponivel: boolean }>('/servos/status'));
if (!st) return;
servos = st.servos;
pulsos = st.servos.map((s) => s.centro);
disponivel = st.disponivel;
sujo = false;
}
onMount(carregar);
// Ao vivo: no máximo 10 comandos/s, sempre o último valor.
let pendente: { indice: number; pulso: number } | null = null;
let emVoo = false;
let ultimo = 0;
let espera = 0;
function enfileirar(indice: number, pulso: number) {
pulsos[indice] = pulso;
if (!aoVivo || sujo) return;
pendente = { indice, pulso };
bombear();
}
async function bombear() {
if (!pendente || emVoo || espera) return;
const falta = 100 - (performance.now() - ultimo);
if (falta > 0) {
espera = window.setTimeout(() => {
espera = 0;
bombear();
}, falta);
return;
}
const cmd = pendente;
pendente = null;
ultimo = performance.now();
emVoo = true;
try {
await postar('/servos/mover', cmd);
} catch (e) {
aoVivo = false;
avisos.erro(e);
} finally {
emVoo = false;
bombear();
}
}
async function parar() {
aoVivo = false;
pendente = null;
clearTimeout(espera);
espera = 0;
await tentar(() => postar('/servos/parar'));
}
onDestroy(() => {
if (aoVivo) parar();
});
const testar = (i: number, pulso: number) =>
tentar(() => postar('/servos/mover', { indice: i, pulso }), `${servos[i].nome}: ${pulso} µs por 2 s.`);
function editar(i: number, campo: keyof Servo, v: string) {
servos[i] = { ...servos[i], [campo]: campo === 'nome' ? v : Number(v) };
sujo = true;
aoVivo = false;
}
function adicionar() {
const canal = Array.from({ length: 16 }, (_, i) => i).find((c) => !servos.some((s) => s.canal === c));
if (canal === undefined) return;
servos = [...servos, { nome: `Servo ${servos.length + 1}`, canal, minimo: 1400, maximo: 1600, centro: 1500 }];
pulsos = [...pulsos, 1500];
sujo = true;
}
function remover(i: number) {
servos = servos.filter((_, k) => k !== i);
pulsos = pulsos.filter((_, k) => k !== i);
sujo = true;
}
async function salvar() {
await parar();
const r = await tentar(() => postar<{ servos: Servo[] }>('/servos/config', { servos: $state.snapshot(servos) }), 'Servos salvos. Nenhum movimento enviado.');
if (r) {
servos = r.servos;
pulsos = r.servos.map((s) => s.centro);
sujo = false;
}
}
const ocupadoPeloBraco = $derived(estado.motores?.dono === 'braco' && !!estado.motores.ativos.length);
</script>
<div class="diagnostico">
<Card titulo="Diagnóstico do PCA9685" descricao="Teste canal a canal, com limites conservadores (1000–2000 µs). Use com o braço parado.">
{#snippet acoes()}
{#if sujo}<Selo tom="aviso">Alterações não salvas</Selo>{/if}
<button class="btn pequeno" onclick={adicionar} disabled={servos.length >= 16}><Plus size={14} /> Servo</button>
<button class="btn pequeno primario" onclick={salvar} disabled={!sujo}><Save size={14} /> Salvar</button>
{/snippet}
{#if ocupadoPeloBraco}
<p class="aviso pequeno-texto">O braço está usando os motores. Pare o braço antes de testar aqui.</p>
{/if}
<Toggle
rotulo="Controle ao vivo"
ajuda="Arrastar o pulso envia ao motor (até 10 vezes por segundo)"
checked={aoVivo}
disabled={!disponivel || sujo || estado.estop.ativo || ocupadoPeloBraco}
onchange={(v) => (v ? (aoVivo = true) : parar())}
/>
</Card>
<div class="servos">
{#each servos as s, i (i)}
<Card>
<div class="campos">
<label class="campo nome"><span>Nome</span><input type="text" maxlength="40" value={s.nome} onchange={(e) => editar(i, 'nome', e.currentTarget.value)} /></label>
<label class="campo"><span>Canal</span><input type="number" min="0" max="15" value={s.canal} onchange={(e) => editar(i, 'canal', e.currentTarget.value)} /></label>
<label class="campo"><span>Mín. µs</span><input type="number" min="1000" max="2000" value={s.minimo} onchange={(e) => editar(i, 'minimo', e.currentTarget.value)} /></label>
<label class="campo"><span>Centro</span><input type="number" min="1000" max="2000" value={s.centro} onchange={(e) => editar(i, 'centro', e.currentTarget.value)} /></label>
<label class="campo"><span>Máx. µs</span><input type="number" min="1000" max="2000" value={s.maximo} onchange={(e) => editar(i, 'maximo', e.currentTarget.value)} /></label>
</div>
<Slider rotulo="Pulso" valor={pulsos[i] ?? s.centro} min={s.minimo} max={s.maximo} passo={5} unidade=" µs" oninput={(v) => enfileirar(i, v)} />
<div class="linha">
<button class="btn pequeno" disabled={!disponivel || sujo} onclick={() => testar(i, pulsos[i])}><Zap size={14} /> Testar 2 s</button>
<button class="btn pequeno" disabled={!disponivel || sujo} onclick={() => testar(i, s.centro)}>Centro</button>
<span class="espaco"></span>
<button class="btn pequeno icone fantasma" aria-label="Remover {s.nome}" onclick={() => remover(i)}><Trash2 size={14} /></button>
</div>
</Card>
{/each}
</div>
</div>
<style>
.diagnostico {
display: grid;
gap: var(--e-4);
}
.servos {
display: grid;
gap: var(--e-4);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
}
.campos {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--e-2);
}
.campos .nome {
grid-column: 1 / -1;
}
.campos input {
min-height: 34px;
padding: var(--e-1) var(--e-2);
}
.espaco {
flex: 1;
}
.aviso {
color: var(--aviso);
}
</style>
+2
View File
@@ -0,0 +1,2 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default { preprocess: vitePreprocess() };
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
// Em desenvolvimento, a API vem do servidor Python (local ou a placa):
// CARINHA_SERVIDOR=http://192.168.1.35:8100 npm run dev
const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face',
'/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/audio-robot.js', '/qualidade-auto.js'];
export default defineConfig({
plugins: [svelte()],
base: '/',
build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' },
server: { proxy: Object.fromEntries(api.map((p) => [p, { target: alvo, changeOrigin: false }])) },
});