Compare commits

..
Author SHA1 Message Date
julioandClaude Opus 5.5 319e63cc42 feat(rosto): esconder e espiar com maos inclinadas e movimento em arco
As maos sobem em arco a partir dos cantos de baixo, girando no caminho,
com um leve passa-e-volta e uma chegando um instante depois da outra.
Ficam inclinadas para dentro (~24 graus) mostrando o dorso (nos dos
dedos, polegar para fora) e sem tapar o olho inteiro. Espiar gira a mao
pelo punho como uma porta; acenar tambem balanca pelo punho.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:52:39 -03:00
julioandClaude Opus 5.5 f550930f56 feat(gestos): gestos com maos nos favoritos e ate 12 favoritos
Acenar, esconder, espiar, pensar e dancar entram nos favoritos padrao
(logo apos feliz e coracao). Quem ja tinha favoritos salvos recebe esses
gestos uma vez, sem perder os seus. Teclas 1 a 9 para os nove primeiros.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:48:21 -03:00
julioandClaude Opus 5.5 230ca991a2 feat(rosto): mao com dedos, polegar e punho no lugar da luva
Quatro dedos separados (medio maior, levemente abertos), polegar para o
lado do rosto, punho de robo e contorno so na borda externa. Ao acenar os
dedos balancam; ao pensar o punho fecha com o indicador no rosto; para
esconder e espiar as maos cobrem o olho inteiro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:45:17 -03:00
julioandClaude Opus 5.5 628db8492f feat(rosto): maos (esconder, espiar, acenar, pensar) e faixa de baixo
- rosto-motor.js desenha luvinhas na cor dos olhos que sobem da borda da
  tela, sincronizadas com as sequencias de rosto de mesmo nome
- faixa de baixo: legenda da resposta da conversa com efeito de digitacao,
  tres pontinhos enquanto a IA pensa, equalizador ao dancar e indicador de
  carga (raio e porcentagem) quando o celular esta no carregador
- gestos novos no servidor, no painel e na tela Rosto; a IA (Qwen/Gemini)
  pode responder acenando, se escondendo ou pensando
- a previa do painel tambem mostra a legenda da conversa

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:39:00 -03:00
julioandClaude Opus 5.5 f45e05ab81 feat(ao-vivo): avisa bateria baixa e mostra o ultimo sinal do celular
- aviso no painel quando a bateria do celular chega a 15% e a 5% sem
  carregador (uma vez cada; volta a valer depois de carregar)
- selo do celular fica vermelho com bateria baixa
- celular offline mostra ha quanto tempo foi o ultimo sinal e com quanta
  bateria; abaixo de 10% sugere que a bateria acabou

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:24:33 -03:00
julioandClaude Opus 5.5 69018c8b36 feat(reacoes): emojis e figurinhas no mesmo padrao do rosto novo
- rosto-motor.js desenha emoji e figurinha dentro dos olhos no estilo e
  formato escolhidos (brilho, piscada; na Lente no lugar da iris; na
  Matriz os LEDs usam as cores do desenho)
- MotorRosto.retrato desenha um retrato parado em qualquer canvas
- miniaturas da biblioteca no painel usam o mesmo motor, no rosto, formato
  e cor atuais do robo (3:2, com o simbolo "ao redor" no canto)
- emoji e figurinha "ao redor" ganham o brilho na cor da reacao
- cartoes da biblioteca com as acoes numa linha so

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:19:19 -03:00
julioandClaude Opus 5.5 972dd8dd66 feat(rosto): motor de palpebras, 4 rostos novos, formato do olho e tela Rosto
- rosto-motor.js: emocoes como posicao de palpebra (cima, inclinacao, baixo)
  com transicao suave; respiracao, sacadas e piscada que achata o olho
- rostos novos Carinha Viva, Bloco, Matriz (LEDs) e Lente (iris/pupila);
  os classicos continuam; emoji/figurinha nos olhos seguem no SVG
- formato do olho (padrao, redondo, pilula, quadrado, largo) para Viva,
  Bloco e Matriz; salvo na aparencia e enviado como ordem "formato X"
- toque na tela do S10: cutucar = susto e risada, deslizar = carinho,
  tocar dormindo acorda
- painel: tela Rosto (celular em destaque, cartoes de rosto, formatos,
  cores, testar emocoes so na previa ou no celular, lista do que ja esta
  pronto); Ao vivo leva a ela; paleta ganha rostos e formatos
- corrige /comando rosto que nao salvava a aparencia

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 01:33:56 -03:00
julioandClaude Opus 5.5 b6b8e2efd5 fix(android): le o sinal do Wi-Fi sem depender do ID da rede
No Android 10+ sem localizacao o networkId vem -1, mas o RSSI e valido.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:57:56 -03:00
julioandClaude Opus 5.5 69d04d4972 chore(deploy): aceita alias SSH (-Ssh) do ~/.ssh/config
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:43:55 -03:00
julioandClaude Opus 5.5 a058c6f453 feat(ao-vivo): camera sempre visivel, lista estilo iOS e dados do aparelho
- palco fixo no topo: camera grande com o rosto num quadrinho no canto
  (toque troca os dois); botao "Ligar camera" direto no palco
- controles em lista agrupada estilo Ajustes do iPhone (Grupo, Linha,
  Interruptor): camera e rastreamento, gestos, aparencia, personalidade
- cartao do celular com modelo, Android, versao do app, bateria, Wi-Fi,
  IP e estado do rosto
- APK 0.8.1 informa ip/modelo/android/app/wifi (ACCESS_WIFI_STATE, so o
  nivel do sinal); servidor usa o IP informado pelo app em vez do
  endereco da conexao

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:07:52 -03:00
julioandClaude Opus 5.5 3e51266644 chore(deploy): adiciona deploy.ps1 para publicar no Orange Pi
- compila e verifica o painel (check, testes, build), empacota so o codigo
  e web/dist; configuracoes, bancos, chaves e vendor/ da placa ficam intactos
- na placa: backup datado do codigo atual, confere SHA256, remove telas
  antigas, extrai, reinicia carinha-pc e confere a versao em /app/health
- -SoPacote gera o pacote sem tocar na placa
- iniciar.ps1 abre o painel novo em "/" e aceita outro IP; scripts .ps1
  salvos com BOM para o PowerShell 5.1 ler os acentos

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:13:37 -03:00
julioandClaude Opus 5.5 c5fd54c1d9 feat(celular): rosto e app usam SSE e token; remove telas legadas
- carinha.html assina /eventos (expressao, app_config, conversa, reacao,
  recarregar) e volta ao polling de /fila sozinho se o SSE cair; foto dos
  ajustes a cada (re)conexao; token (?t=) vai em todo fetch ao servidor
- APK 0.8-token: token nas preferencias e no menu de toque longo, enviado
  na pagina, no HTTP do Java e ao llama-server (--api-key); trocar servidor
  por Intent exige o token salvo; camera/microfone so para o servidor
  configurado ou a copia local
- cerebro/conversa mandam a chave ao llama-server do S10
- servidor: evento SSE 'recarregar'; remove /camera/frame e /app/connect;
  enderecos antigos redirecionam para a secao do painel novo
- remove dashboard, painel, conversa, emojis, treinamento, braco, servos,
  api, scripts e CSS antigos, audio-robot.js e servir-local.bat
- logica pura do painel em web/src/lib/nucleo.ts com testes node --test
  (substitui test_movimentos.js e test_rastreamento.js)
- testes de navegador portados para o painel novo (rtc, rosto, rastreamento)
- README, docs/PAINEL.md e docs/SEGURANCA.md reescritos; versao 0.8.0;
  carinha-pc.service le /etc/carinha.env

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:11:58 -03:00
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
96 changed files with 4307 additions and 6191 deletions
+65 -42
View File
@@ -1,60 +1,83 @@
# Carinha PC
# Carinha Control Panel
Servidor no Orange Pi Zero 3, acessado pelo PC, para controlar o Carinha no Galaxy S10 e testar servos. Dashboard: http://192.168.1.35:8100/dashboard.
Robô de mesa: o **rosto** roda num Galaxy S10 (app Android), o **servidor** num Orange Pi Zero 3
(Python, só biblioteca padrão) e o **painel** em qualquer navegador da rede. O mesmo servidor
comanda um braço de 7 articulações por um PCA9685 (I²C) e conversa por Gemini ou Qwen local.
## Desenvolvimento
Painel: **http://192.168.1.35:8100/**
Pasta criada em 04/10/2026 a pedido do usuário. Código inicial copiado do projeto anterior; o original não será modificado. Design aprovado: controlar rosto, gestos, configurações e visualizar a câmera frontal pelo PC enquanto o celular mantém a carinha na tela.
```
PC / celular (navegador) Orange Pi :8100 Galaxy S10 (app)
┌──────────────────────┐ HTTP ┌──────────────────────┐ HTTP ┌──────────────────┐
│ painel Svelte (web/) │ ───────► │ kiosk-server.py │ ◄─────── │ carinha.html │
│ 1 EventSource │ ◄─SSE─── │ /eventos /estado │ ──SSE──► │ (rosto) │
│ WebRTC ◄────────────┼──────────┼── sinalização /rtc ──┼──────────┼─► câmera │
└──────────────────────┘ │ braço · servos (I²C) │ └──────────────────┘
│ conversa · reações │
└──────────────────────┘
```
- [Design](docs/DESIGN.md)
- [Plano e verificações](docs/PLANO.md)
- [Registro de alterações](docs/REGISTRO.md)
## O painel
Quatro áreas + ajustes, todas alimentadas por **um** fluxo SSE (`/eventos`), sem polling:
| Área | O que tem |
|---|---|
| **Ao vivo** | Câmera do celular (WebRTC) e rosto lado a lado, gestos favoritos, aparência, sensores, IA local, acontecimentos |
| **Conversa** | Chat com voz, motor de IA, memória (lembranças + rotina) |
| **Reações** | Biblioteca (criar, editar, desfazer), criar com Gemini, todos os gestos, diário |
| **Movimento** | Braço com homem-morto, sequências, posturas, calibração e diagnóstico do PCA9685 |
| **Ajustes** | Celular (agrupado, com busca), IA e chave Gemini, token, sistema |
Controles que valem em qualquer tela:
- **Parar tudo** (botão vermelho, `Shift+Espaço` ou `Esc` duas vezes): desliga todos os servos,
para reações e acalma o rosto. Os motores ficam travados até **Rearmar**.
- **Ctrl+K**: paleta com gestos, rostos, cores, telas, ações e posturas.
- **1–9**: gestos favoritos (fixe com a estrela em Reações › Gestos). **g** + letra navega
(`g v` Ao vivo, `g c` Conversa, `g r` Reações, `g m` Movimento, `g a` Ajustes).
- O braço **só se move enquanto você segura** o botão ou o slider; ao soltar, os motores desligam
em menos de meio segundo. Detalhes em [docs/PAINEL.md](docs/PAINEL.md).
## Usar
1. Execute **iniciar.bat**. Verifica e abre **http://192.168.1.35:8100/dashboard**, sem iniciar servidor no PC. O serviço carinha-pc inicia automaticamente na placa.
2. No celular, segure a tela do Carinha e altere o servidor para **http://192.168.1.35:8100**. O botão ADB do antigo servidor Windows não é usado nesta instalação.
3. Troque rosto, cor e gestos. Use **Mostrar boca** e **Mostrar sobrancelhas** para ativar cada uma independentemente. Clique **Ligar câmera** para vídeo ao vivo com qualidade automática, sem trocar a tela do celular.
4. Em **Comportamento**, ajuste som, movimento, volume, sono e economia de tela. **Configurações aplicadas no celular** depende da resposta do aparelho.
1. `iniciar.bat` confere o Orange Pi e abre o painel.
2. No celular, toque longo › **Mudar o servidor** › `http://192.168.1.35:8100`.
3. Se a placa tiver token (recomendado, veja [docs/SEGURANCA.md](docs/SEGURANCA.md)):
toque longo › **Token do servidor** no celular, e o painel pede o token uma vez.
Brilho padrão: **70% ativo / 8% em repouso**, com **30 segundos** de espera. Retorna com som, movimento ou comando. O brilho do sistema é restaurado ao sair do app.
## Desenvolver
Configurações ficam em `app-config.json` e `aparencia.json`. O vídeo não é gravado: WebRTC transmite diretamente do app ao navegador. O servidor troca apenas sinalização. Logs em `runtime/`. Qualidade automática busca 30 fps, adaptando resolução e compressão. O painel mostra os fps efetivos. [Detalhes do vídeo](docs/VIDEO-AUTO.md).
```powershell
# servidor local (sem I²C ele avisa "PCA9685 indisponível")
python kiosk-server.py --porta 8100
APK: `android/carinha-rosto.apk`. Guia: [android/APP.md](android/APP.md).
# painel com recarga automática, falando com o servidor local ou com a placa
cd web; npm install
npm run dev # usa http://127.0.0.1:8100
$env:CARINHA_SERVIDOR='http://192.168.1.35:8100'; npm run dev
```
Publicar na placa: `.\deploy.ps1` (compila o painel, faz backup e reinicia o serviço).
## Verificações
```powershell
python -m unittest discover -s tests -p test_controle_pc.py
python tests/test_browser.py
python tests/test_rtc_browser.py
node tests/test_qualidade.js
python android/build-apk.py
python -m pytest tests -q --ignore-glob='*browser*' # servidor: token, SSE, E-Stop, trava, módulos
cd web; npm run check; npm test; npm run build; cd .. # tipos, lógica pura e build do painel
python tests/test_painel_web_browser.py # painel de ponta a ponta (Playwright)
python tests/test_rtc_browser.py # WebRTC real com câmera sintética
python tests/test_browser.py # rosto (carinha.html)
node tests/test_qualidade.js # qualidade automática do vídeo
python android/build-apk.py # APK (precisa de keystore e libs nativas)
```
O teste de navegador usa Playwright e um servidor efêmero próprio, sem alterar o servidor real. Testes Java em `tests/` verificam conversão de imagem e brilho; compile-os com o JDK junto das classes correspondentes. O servidor em produção exige somente Python 3.9+.
`tests/test_tracking_browser.py` precisa dos modelos (`python obter-rastreamento.py`) e das
imagens de teste em `../work/tracking-fixtures`.
**Validação física em 05/10/2026:** S10 conectado à porta 8100, telemetria de câmera/microfone ativa e JPEG recente recebido pelo PC. Configurações confirmadas pelo app e brilho ativo reportado em 70%. Ainda falta observar a orientação da imagem e a transição física do brilho para repouso.
## Documentos
**Atualização 0.3 instalada:** vídeo contínuo WebRTC automático e boca/sobrancelhas independentes. Teste com câmera simulada recebeu 30 fps. Após atualizar o APK, reabra o Carinha e recarregue o painel; os fps no S10 ainda precisam ser confirmados.
Ferramentas de compilação reutilizadas de `C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools` (JDK 17 e Android SDK). Não é necessário rodar servidor na placa.
# Atualização 0.4 — sensores e câmeras
Controle microfone e câmera separadamente, com permissões específicas durante o sono (padrão: só microfone). Escolha frontal/traseira e qualidade automática, manual ou máxima disponível no painel ou no menu de toque longo do app. Veja [detalhes e limites](docs/SENSORES-CAMERAS.md).
# Atualização 0.5 — rastreamento de rosto e mãos
O painel agora detecta rosto e mãos no PC, mostra pontos sobre o vídeo e pode fazer os olhos acompanharem o alvo por WebRTC. Os controles estão sob o vídeo. Só funciona com a transmissão ligada e painel aberto; os sensores e regras de sono continuam valendo. Veja [uso e validação](docs/RASTREAMENTO.md).
## Dashboard e organização
A navegação lateral reúne Visão geral, Câmera e rosto, Conversar, Emojis, Treinamento, Servos, Braço e API. O tema escuro/ciano e os controles são compartilhados em dashboard.css/dashboard.js; CSS próprio de cada página guarda apenas seu layout específico. No celular, o rosto continua em tela cheia.
A biblioteca /emojis permite criar emojis prontos, editar nome e contexto, substituir por emoji, duplicar, ativar/desativar, excluir, dar feedback, desfazer e exportar. Desfazer preserva o desenho original e funciona após reiniciar o serviço. Parar expressão não movimenta motores. Reações desativadas não são escolhidas automaticamente na conversa.
/api gerencia o modelo Gemini e a chave. Campos de chave ficam vazios após salvar e as respostas não retornam credenciais. Alterações são guardadas em api-privada.json (permissão 600 no Linux), ignorado pelo Git e bloqueado no servidor HTTP. A configuração antiga de ambiente continua válida enquanto não houver override.
[Revisão, critérios e validação do dashboard](docs/DASHBOARD.md).
[Painel](docs/PAINEL.md) · [Segurança](docs/SEGURANCA.md) · [Orange Pi](docs/ORANGEPI.md) ·
[Braço](docs/BRACO.md) · [Conversa](docs/CONVERSA.md) · [IA local](docs/IA-LOCAL.md) ·
[Vídeo](docs/VIDEO-AUTO.md) · [Rastreamento](docs/RASTREAMENTO.md) · [Repositório](docs/GITEA.md) ·
[Registro](docs/REGISTRO.md)
+4 -2
View File
@@ -1,14 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto"
android:versionCode="6"
android:versionName="0.6-ia-local">
android:versionCode="9"
android:versionName="0.8.1">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
<!-- precisa de rede para falar com o servidor da carinha (troca de rosto) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- só o nível do sinal do Wi-Fi, para o painel (sem localização nem nome da rede) -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- escutar o ambiente: reage a barulho (surpreso) e a silencio (dormir) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- camera: pagina de visualizacao e, depois, seguir o movimento -->
+151 -11
View File
@@ -25,7 +25,27 @@
<path id="sobrancelha2" class="traco" style="display:none"/>
</svg>
<div id="fps"></div>
<script src="rosto-motor.js"></script>
<script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
(function () {
const token = new URLSearchParams(location.search).get('t');
window.carinhaToken = token || '';
if (!token || !window.fetch) return;
const original = window.fetch.bind(window);
window.fetch = (recurso, opcoes = {}) => {
try {
const url = new URL(typeof recurso === 'string' ? recurso : recurso.url, location.href);
if (url.origin === location.origin) {
const cabecalhos = new Headers(opcoes.headers || {});
cabecalhos.set('Authorization', 'Bearer ' + token);
opcoes = Object.assign({}, opcoes, { headers: cabecalhos });
}
} catch (e) { /* URL estranha: segue sem token */ }
return original(recurso, opcoes);
};
})();
"use strict";
/* -------------------------------------------------------------------------
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
@@ -84,10 +104,17 @@ const BRANCO = [245, 250, 255];
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = {
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
};
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
let FORMATO = "padrao";
let ROSTO_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME];
@@ -277,9 +304,17 @@ function sequencias() {
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
// Gestos com mãos: o motor desenha as mãos com a mesma duração (rosto-motor.js, DUR_MAOS).
const esconder = [[{ forma: "aberto", esc: 0.95, dy: 4 }, 0.3], [{}, 1.6], [{ forma: "feliz", boca: "sorriso", esc: 1.05, dy: 0 }, 0.3], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const espiar = [[{ esc: 0.96 }, 0.4], [{}, 0.6], [{ dx: -14, forma: "aberto", ab: 1.08 }, 0.25], [{}, 0.9], [{ dx: 0, ab: 1.0 }, 0.35], [{ forma: "feliz", boca: "sorriso" }, 0.4], [{ forma: "aberto" }, 0.3]];
const acenar = [[{ forma: "feliz", boca: "sorriso", esc: 1.05 }, 0.3], [{}, 1.7], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const pensar = [[{ dy: -10, dx: 14, boca: "neutra" }, 0.4], [{}, 2.0], [{ dy: 0, dx: 0 }, 0.4]];
const musica = [[{ forma: "feliz", boca: "sorriso", dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25],
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
return { piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
return { esconder, espiar, acenar, pensar, musica,
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre };
}
@@ -361,12 +396,28 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
for (let i = 0; i < 2; i++) {
const comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) {
window.MotorRosto.desenhar({
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null,
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
})),
});
} else if (window.MotorRosto) window.MotorRosto.esconder();
for (let i = 0; i < 2 && !comMotor; i++) {
const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6);
}
desenharBoca();
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
else desenharBoca();
for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
@@ -411,6 +462,9 @@ function trata(ordem) {
trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo"
} else if (ordem.indexOf("formato ") === 0) {
const f = ordem.slice(8).trim();
if (FORMATOS.includes(f)) FORMATO = f;
} else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) {
@@ -423,7 +477,14 @@ function trata(ordem) {
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
} else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; }
}
@@ -460,7 +521,7 @@ window.eilik = {
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
// do PC com um curl, igual ao FIFO de antes
async function buscarOrdens() {
if (new URLSearchParams(location.search).get('espelho') === '1') return;
if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
@@ -479,12 +540,43 @@ async function aplicarAparencia() {
if (!resp.ok) return;
const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ }
}
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 (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
(function () {
let inicio = null;
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
addEventListener('pointerup', (e) => {
if (!inicio) return;
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
inicio = null;
if (r.dormindo) { trata('acordar'); return; }
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
if (dur < 350 && Math.hypot(dx, dy) < 15) {
animar('surpreso');
setTimeout(() => animar('feliz'), 700);
}
});
})();
// teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
@@ -818,8 +910,10 @@ async function sincronizarApp() {
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
sincronizando = true;
try {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json());
if (!window.carinhaSSE) {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json());
}
if (par.get('aparelho') === '1') {
const agora = performance.now() / 1000;
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
@@ -830,6 +924,7 @@ async function sincronizarApp() {
centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho,
bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo,
}) });
}
@@ -965,11 +1060,12 @@ setTimeout(relatar, 1500);
if(location.protocol==='file:')return;
let seen=null,fetching=false;
async function poll(){
if(fetching||window.carinhaAtiva===false)return;fetching=true;
if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
}catch(e){}finally{fetching=false;}
}
setInterval(poll,1000);poll();
@@ -977,11 +1073,55 @@ setTimeout(relatar, 1500);
</script>
<script src="/reacoes-render.js"></script>
</body>
</html>
<script>
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
}
/* Tempo real: um EventSource em /eventos substitui os pollings de /fila, /app/config,
/conversa/status e /treinamento/expressao. Se cair, os pollings voltam sozinhos
(window.carinhaSSE=false) até o EventSource reconectar. */
(function () {
const p = new URLSearchParams(location.search);
if (location.protocol === 'file:' || p.get('espelho') === '1' || !window.EventSource) return;
const aparelho = p.get('aparelho') === '1';
const t = window.carinhaToken;
const fonte = new EventSource('/eventos' + (t ? '?t=' + encodeURIComponent(t) : ''));
let ultimaConversa = null, ultimaReacao = null, primeira = true;
const ouvir = (tipo, fn) => fonte.addEventListener(tipo, (e) => { try { fn(JSON.parse(e.data)); } catch (err) {} });
fonte.addEventListener('pronto', () => {
// Ordens que chegaram antes da conexão ficam na fila: executa uma vez e passa ao SSE.
if (primeira) { primeira = false; buscarOrdens(); }
window.carinhaSSE = true;
// Foto dos ajustes a cada (re)conexão; depois só mudanças chegam por 'app_config'.
fetch('/app/config', { cache: 'no-store' }).then((r) => r.ok && r.json()).then((c) => c && configurarApp(c)).catch(() => {});
});
fonte.onerror = () => {
if (!window.carinhaSSE) return;
window.carinhaSSE = false;
// Volta ao polling sem repetir o que o SSE já entregou.
fetch('fila' + (aparelho ? '?aparelho=1' : ''), { cache: 'no-store' }).catch(() => {});
};
ouvir('expressao', (d) => trata(d.ordem));
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
ouvir('app_config', (c) => configurarApp(c));
ouvir('conversa', (s) => {
if (window.MotorRosto) {
if (s.ocupado) window.MotorRosto.faixa('digitando');
else window.MotorRosto.faixa(null, { so: 'digitando' });
}
const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id;
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
ouvir('reacao', (d) => {
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
ultimaReacao = d.id;
if (window.eilik && window.eilik.receita) window.eilik.receita(d.receita);
});
})();
</script>
</body>
</html>
+476
View File
@@ -0,0 +1,476 @@
/* rosto-motor.js — motor de pálpebras dos rostos novos (Carinha Viva, Bloco, Matriz, Lente).
*
* O carinha.html continua decidindo O QUE o rosto faz (gestos, reações, sensores, rastreamento);
* aqui só se desenha. Cada "forma" vira posição de pálpebra (cima, inclinação, baixo) e o motor
* suaviza a passagem entre elas, então feliz → triste não salta de um desenho para outro.
* Também acrescenta vida: respiração, sacadas e achatamento na piscada.
*/
(function () {
'use strict';
// forma → pálpebras. top: pálpebra de cima (0 aberto, 1 fechado); slant: + = canto interno sobe;
// bot: pálpebra de baixo sobe ("olho sorrindo").
const PALPEBRAS = {
aberto: { top: 0, slant: 0, bot: 0 },
surpreso: { top: 0, slant: 0, bot: 0 },
feliz: { top: 0, slant: 0, bot: 0.46 },
coracao: { top: 0, slant: 0, bot: 0.42 },
triste: { top: 0.3, slant: 0.32, bot: 0 },
bravo: { top: 0.34, slant: -0.42, bot: 0.06 },
dormir: { top: 0.9, slant: 0.04, bot: 0.08 },
};
// Medidas em frações do raio vertical do olho clássico (RY), para casar com o layout atual.
const ESTILOS = {
vivo: { w: 1.0, h: 1.45, r: 0.5, brilho: true },
bloco: { w: 1.35, h: 1.25, r: 0.22, brilho: true },
matriz: { w: 1.0, h: 1.45, r: 0.5, brilho: false, pontos: true },
lente: { w: 1.15, h: 1.15, r: 0.58, lente: true },
};
// Formato do olho, escolhido no painel e combinável com Viva, Bloco e Matriz.
const FORMATOS = {
padrao: null,
redondo: { w: 1.15, h: 1.15, r: 0.58 },
pilula: { w: 0.8, h: 1.55, r: 0.4 },
quadrado: { w: 1.3, h: 1.3, r: 0.12 },
largo: { w: 1.65, h: 0.9, r: 0.3 },
};
let tela = null, ctx = null, camada = null, cctx = null, baixa = null, bctx = null;
const estado = [novo(), novo()];
const sacada = { x: 0, y: 0, alvoX: 0, alvoY: 0, proxima: 0 };
let antes = 0;
function novo() { return { top: 0, slant: 0, bot: 0, coracao: 0 }; }
const css = (c, a) => 'rgba(' + c.map((v) => Math.round(v)).join(',') + ',' + (a === undefined ? 1 : a) + ')';
function preparar() {
if (tela) return;
tela = document.createElement('canvas');
tela.id = 'rostoMotor';
tela.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:2;display:none';
document.body.append(tela);
ctx = tela.getContext('2d');
camada = document.createElement('canvas'); cctx = camada.getContext('2d');
baixa = document.createElement('canvas'); bctx = baixa.getContext('2d', { willReadFrequently: true });
}
function medir() {
// Até 1,5x a densidade: a tela do S10 é enorme e o brilho custa caro.
const d = Math.min(1.5, window.devicePixelRatio || 1);
const w = Math.round(innerWidth * d), h = Math.round(innerHeight * d);
if (tela.width !== w || tela.height !== h) { tela.width = camada.width = w; tela.height = camada.height = h; }
return d;
}
function caixa(c, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w / 2, h / 2));
c.beginPath();
if (c.roundRect) c.roundRect(x, y, w, h, r);
else { // WebViews antigos
c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath();
}
}
function coracao(c, cx, cy, s) {
c.beginPath();
c.moveTo(cx, cy + s * 0.72);
c.bezierCurveTo(cx - s * 1.3, cy - s * 0.3, cx - s * 0.8, cy - s * 1.18, cx, cy - s * 0.3);
c.bezierCurveTo(cx + s * 0.8, cy - s * 1.18, cx + s * 1.3, cy - s * 0.3, cx, cy + s * 0.72);
c.fill();
}
const FONTE_EMOJI = '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif';
/** Figurinha (viewBox 100x100, só M L C Q Z) ou emoji, centrado no olho. */
function conteudo(c, o, cx, cy, tam) {
if (o.desenho && Array.isArray(o.desenho.formas)) {
c.save();
c.translate(cx - tam / 2, cy - tam / 2);
c.scale(tam / 100, tam / 100);
for (const f of o.desenho.formas) {
if (typeof f.caminho !== 'string' || !/^[MLCQZmlcqz0-9.,\s+-]+$/.test(f.caminho) || !/^#[0-9a-f]{6}$/i.test(f.cor)) continue;
c.fillStyle = f.cor;
c.fill(new Path2D(f.caminho));
}
c.restore();
} else if (o.icone) {
c.save();
c.font = Math.round(tam * 0.8) + 'px ' + FONTE_EMOJI;
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(o.icone, cx, cy + tam * 0.04);
c.restore();
}
}
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes).
function olhoPlano(c, o, p, est, e) {
const respira = 1 + 0.012 * Math.sin(o.t * 1.4);
const temConteudo = !!(o.icone || o.desenho);
if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 };
const fecha = Math.min(1, Math.max(e.top, 1 - Math.min(1, p.ab)));
const abre = Math.max(1, p.ab); // surpreso abre além de 1
const amassa = fecha > 0.5 && e.top < 0.5 ? (fecha - 0.5) * 0.2 : 0; // piscada achata e alarga
const w = est.w * p.ry * respira * (1 + amassa);
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
const x = p.cx - w / 2, y = p.cy - h / 2;
c.fillStyle = css(o.cor);
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
caixa(c, x, y, w, h, est.r * p.ry * respira);
c.fill();
c.save();
caixa(c, x - 2, y - 2, w + 4, h + 4, est.r * p.ry);
c.clip();
c.globalCompositeOperation = 'destination-out';
c.fillStyle = '#000';
const sl = p.lado > 0 ? e.slant : -e.slant; // lado +1 = olho esquerdo; canto interno = lado do nariz
const yEsq = y + h * (fecha + sl * 0.5), yDir = y + h * (fecha - sl * 0.5);
c.beginPath(); c.moveTo(x - 4, y - 4); c.lineTo(x + w + 4, y - 4); c.lineTo(x + w + 4, yDir); c.lineTo(x - 4, yEsq); c.closePath(); c.fill();
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over';
if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74);
else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012));
const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012));
c.fillStyle = 'rgba(245,250,255,0.9)';
c.beginPath(); c.ellipse(hx, hy, w * 0.1, h * 0.08, 0, 0, Math.PI * 2); c.fill();
}
c.restore();
}
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) {
const R = est.w * p.ry * 0.5 * Math.max(1, p.ab) * (1 + 0.01 * Math.sin(o.t * 1.4));
const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
c.save();
c.beginPath(); c.arc(p.cx, p.cy, R * 1.12, 0, Math.PI * 2); c.fillStyle = '#1b2029'; c.fill();
c.beginPath(); c.arc(p.cx, p.cy, R, 0, Math.PI * 2); c.clip();
c.fillStyle = '#e9eef3'; c.fillRect(p.cx - R, p.cy - R, 2 * R, 2 * R);
if (o.icone || o.desenho) {
conteudo(c, o, p.cx, p.cy, R * 1.3);
e = { top: 0, slant: 0, bot: 0 };
} else {
const ix = p.cx + Math.max(-R * 0.4, Math.min(R * 0.4, (p.lx + sacada.x) * R * 0.02));
const iy = p.cy + Math.max(-R * 0.35, Math.min(R * 0.35, (p.ly + sacada.y) * R * 0.02));
const g = c.createRadialGradient(ix, iy, R * 0.05, ix, iy, R * 0.55);
g.addColorStop(0, css(o.cor.map((v) => v * 0.5))); g.addColorStop(1, css(o.cor));
c.beginPath(); c.arc(ix, iy, R * 0.55, 0, Math.PI * 2); c.fillStyle = g; c.fill();
const pupila = p.ab > 1.05 ? 0.12 : e.bot > 0.3 ? 0.3 : 0.2; // susto contrai, alegria dilata
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fillStyle = '#05070a'; c.fill();
c.beginPath(); c.arc(ix - R * 0.2, iy - R * 0.22, R * 0.09, 0, Math.PI * 2); c.fillStyle = 'rgba(255,255,255,0.9)'; c.fill();
}
const metal = c.createLinearGradient(0, p.cy - R, 0, p.cy + R);
metal.addColorStop(0, '#5b6675'); metal.addColorStop(1, '#2b323d');
c.fillStyle = metal;
const sl = p.lado > 0 ? e.slant : -e.slant;
const yEsq = p.cy - R + 2 * R * (fecha + sl * 0.5), yDir = p.cy - R + 2 * R * (fecha - sl * 0.5);
c.beginPath(); c.moveTo(p.cx - R, p.cy - R); c.lineTo(p.cx + R, p.cy - R); c.lineTo(p.cx + R, yDir); c.lineTo(p.cx - R, yEsq); c.closePath(); c.fill();
c.strokeStyle = '#8994a3'; c.lineWidth = Math.max(1, R * 0.03);
c.beginPath(); c.moveTo(p.cx - R, yEsq); c.lineTo(p.cx + R, yDir); c.stroke();
c.beginPath(); c.ellipse(p.cx, p.cy + R * (2.3 - 2 * e.bot), R * 1.3, R * 1.3, 0, 0, Math.PI * 2); c.fill();
c.restore();
}
function atualizarPalpebras(dt, olhos, lento) {
const k = 1 - Math.exp(-dt * (lento ? 4 : 11));
olhos.forEach((p, i) => {
const alvo = PALPEBRAS[p.forma] || PALPEBRAS.aberto, e = estado[i];
e.top += (alvo.top - e.top) * k;
e.slant += (alvo.slant - e.slant) * k;
e.bot += (alvo.bot - e.bot) * k;
e.coracao += ((p.forma === 'coracao' ? 1 : 0) - e.coracao) * Math.min(1, k * 2);
});
}
function atualizarSacada(t, dt, parado) {
// Pequenos saltos do olhar quando nada está acontecendo; olhos de verdade nunca ficam parados.
if (parado && t > sacada.proxima) {
sacada.alvoX = (Math.random() - 0.5) * 14;
sacada.alvoY = (Math.random() - 0.5) * 6;
sacada.proxima = t + 0.8 + Math.random() * 2.4;
} else if (!parado) { sacada.alvoX = sacada.alvoY = 0; }
const k = 1 - Math.exp(-dt * 22);
sacada.x += (sacada.alvoX - sacada.x) * k;
sacada.y += (sacada.alvoY - sacada.y) * k;
}
function desenharPontos(estilo, o, olhos, d) {
// Matriz: desenha os olhos numa tela pequena e acende um LED por pixel.
const linhas = 38, colunas = Math.round(linhas * tela.width / tela.height);
if (baixa.width !== colunas || baixa.height !== linhas) { baixa.width = colunas; baixa.height = linhas; }
const f = linhas / tela.height * d;
bctx.clearRect(0, 0, colunas, linhas);
bctx.save(); bctx.scale(f, f);
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
desenharMaos(bctx, o, olhos, tela.height / d);
bctx.restore();
const px = bctx.getImageData(0, 0, colunas, linhas).data;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
const passo = tela.width / colunas, raio = passo * 0.36;
for (let j = 0; j < linhas; j++) for (let i = 0; i < colunas; i++) {
const k = (j * colunas + i) * 4, a = px[k + 3] / 255;
ctx.beginPath(); ctx.arc((i + 0.5) * passo, (j + 0.5) * passo, raio, 0, Math.PI * 2);
ctx.fillStyle = a > 0.25 ? css([px[k], px[k + 1], px[k + 2]], 0.35 + a * 0.65) : 'rgba(255,255,255,0.045)';
ctx.fill();
}
}
/**
* Desenha um quadro. o = { estilo, formato, t, cor:[r,g,b], dormindo, parado,
* olhos: [{ cx, cy, ry, ab, forma, lado, lx, ly }, ...] } em pixels CSS.
*/
function desenhar(o) {
preparar();
const base = ESTILOS[o.estilo];
if (!base) return esconder();
const formato = !base.lente && FORMATOS[o.formato];
const estilo = formato ? Object.assign({}, base, formato) : base;
tela.style.display = '';
const d = medir();
const dt = antes ? Math.min(0.1, o.t - antes) : 0.033;
antes = o.t;
atualizarPalpebras(dt, o.olhos, o.dormindo);
atualizarSacada(o.t, dt, o.parado);
const olhos = o.olhos.map((p) => Object.assign({}, p, {
cx: (p.cx + (o.parado ? sacada.x * 0.35 : 0)) * d,
cy: (p.cy + (o.parado ? sacada.y * 0.35 : 0)) * d,
ry: p.ry * d,
}));
ctx.clearRect(0, 0, tela.width, tela.height);
if (estilo.pontos) {
desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
} else if (estilo.lente) {
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
desenharMaos(ctx, o, olhos, tela.height);
} else {
cctx.clearRect(0, 0, camada.width, camada.height);
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
desenharMaos(cctx, o, olhos, tela.height);
ctx.save();
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
ctx.drawImage(camada, 0, 0);
ctx.restore();
}
desenharFaixa(ctx, o, tela.width, tela.height);
}
// ------------------------------------------------------------------ mãos
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências
// de rosto de mesmo nome no carinha.html.
const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 };
const maos = { modo: null, t0: null };
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); };
const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
// Mão de desenho animado: palma, quatro dedos (o do meio maior), polegar para o lado do rosto
// e um punho de robô. dobra (0–1) fecha os dedos; mexe (0–1) agita os dedos ao acenar.
// O contorno é traçado antes do preenchimento: assim só a borda externa aparece.
function capsula(c, x0, y0, x1, y1, r) {
const a = Math.atan2(y1 - y0, x1 - x0) + Math.PI / 2;
c.moveTo(x0 + Math.cos(a) * r, y0 + Math.sin(a) * r);
c.arc(x0, y0, r, a, a + Math.PI);
c.arc(x1, y1, r, a + Math.PI, a + 2 * Math.PI);
c.closePath();
}
function formaMao(c, s, dobra, mexe, t, indicador) {
c.beginPath();
// punho (manga do robô)
caixa(c, -0.42 * s, 0.5 * s, 0.84 * s, 0.38 * s, 0.12 * s);
// palma
caixa(c, -0.5 * s, -0.36 * s, 1.0 * s, 0.95 * s, 0.34 * s);
// dedos: x da base, comprimento e abertura
// mindinho, anelar, médio e indicador (o indicador fica do lado do polegar)
const dedos = [[-0.355, 0.5, -0.16], [-0.12, 0.68, -0.05], [0.12, 0.74, 0.05], [0.355, 0.66, 0.14]];
dedos.forEach(([x, comp, abre], i) => {
const onda = mexe ? Math.sin(t * 14 + i * 0.9) * 0.06 * mexe : 0;
const fecha = indicador && i === 3 ? 0 : dobra; // "pensando": só o indicador esticado
const ang = abre + onda, l = comp * (1 - 0.7 * fecha) * s;
const bx = x * s, by = -0.26 * s;
capsula(c, bx, by, bx + Math.sin(ang) * l, by - Math.cos(ang) * l, 0.115 * s);
});
// polegar, saindo para o lado
capsula(c, 0.38 * s, 0.2 * s, (0.38 + 0.42 * (1 - 0.4 * dobra)) * s, (0.2 - 0.34) * s, 0.13 * s);
}
function mao(c, o, x, y, s, ang, espelho, dobra, mexe, indicador, pulso, costas) {
dobra = dobra || 0; mexe = mexe || 0;
s *= 0.82;
const escura = o.cor.map((v) => v * 0.34);
c.save();
c.translate(x, y); c.rotate(ang); c.scale(espelho, 1);
if (pulso) c.translate(0, -0.7 * s); // (x, y) é o punho: gira como uma mão de verdade
c.lineJoin = 'round'; c.lineCap = 'round';
formaMao(c, s, dobra, mexe, o.t || 0, indicador);
c.strokeStyle = css(o.cor); c.lineWidth = s * 0.17; c.stroke(); // contorno (metade some sob o preenchimento)
c.fillStyle = css(escura); c.fill();
// detalhes por dentro, finos: linha da palma, vincos dos dedos e a costura do punho
c.strokeStyle = css(o.cor, 0.55); c.lineWidth = s * 0.045;
if (costas) { // dorso da mão: nós dos dedos em vez da linha da palma
for (const nx of [-0.355, -0.12, 0.12, 0.355]) { c.beginPath(); c.arc(nx * s, -0.2 * s, 0.075 * s, Math.PI * 1.15, Math.PI * 1.85); c.stroke(); }
} else {
c.beginPath(); c.moveTo(-0.3 * s, 0.12 * s); c.quadraticCurveTo(0.02 * s, 0.26 * s, 0.3 * s, 0.06 * s); c.stroke();
}
c.beginPath(); c.moveTo(-0.36 * s, 0.6 * s); c.lineTo(0.36 * s, 0.6 * s); c.stroke();
c.restore();
}
// Sobe com um leve "passa e volta" no fim (como um braço que freia).
const voltinha = (x) => { x = Math.max(0, Math.min(1, x)); const k = 1.6; return 1 + (k + 1) * Math.pow(x - 1, 3) + k * Math.pow(x - 1, 2); };
function desenharMaos(c, o, olhos, H) {
if (!maos.modo) return;
if (maos.t0 === null) maos.t0 = o.t;
const k = (o.t - maos.t0) / DUR_MAOS[maos.modo];
if (k >= 1) { maos.modo = null; return; }
const [e, d] = olhos.slice().sort((a, b) => a.cx - b.cx); // olho da esquerda e da direita na tela
const s = e.ry * 0.95, fora = H + s * 1.8;
if (maos.modo === 'esconder' || maos.modo === 'espiar') {
// Cada mão vem do canto de baixo, por fora, num arco, girando até ficar inclinada para dentro.
// A da direita chega um pouco depois: duas mãos nunca se mexem igualzinho.
const cobrir = (olho, lado, atraso) => {
const ida = voltinha((k - atraso) / 0.24), volta = faixaDe(k, 0.8 + atraso * 0.5, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta); // 0 = fora da tela, 1 = cobrindo
const alvoX = olho.cx - lado * olho.ry * 0.05, alvoY = olho.cy + olho.ry * 0.38;
const x = alvoX - lado * olho.ry * 1.6 * (1 - Math.min(1, v));
const y = fora + (alvoY - fora) * v;
const respira = Math.sin(o.t * 3 + lado) * s * 0.03 * Math.min(1, v);
const ang = lado * (0.42 + 0.55 * (1 - Math.min(1, v))); // gira enquanto sobe, termina a ~24°
return { x, y: y + respira, ang };
};
const pe = cobrir(e, 1, 0), pd = cobrir(d, -1, 0.05);
if (maos.modo === 'espiar') {
// a mão da esquerda gira pelo punho para fora, como uma porta, e o olho aparece
const abre = faixaDe(k, 0.3, 0.44) - faixaDe(k, 0.64, 0.76);
pe.ang -= abre * 1.05; pe.x -= abre * e.ry * 0.35; pe.y += abre * e.ry * 0.2;
}
const treme = Math.sin(o.t * 22) * s * 0.012; // tímido: tremidinha
// dorso virado para quem olha: polegar para fora, perto da bochecha
mao(c, o, pe.x + treme, pe.y, s * 1.1, pe.ang, -1, 0, 0, false, true, true);
mao(c, o, pd.x - treme, pd.y, s * 1.1, pd.ang, 1, 0, 0, false, true, true);
} else if (maos.modo === 'acenar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
const x = d.cx + d.ry * 2.2 + d.ry * 0.6 * (1 - Math.min(1, v)), y0 = d.cy + d.ry * 1.55;
const y = fora + (y0 - fora) * v;
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0;
mao(c, o, x, y, s * 0.95, -0.18 + balanco, -1, 0, 1, false, true);
} else if (maos.modo === 'pensar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
const x = (e.cx + d.cx) / 2 + e.ry * 1.05, y0 = e.cy + e.ry * 1.4;
const y = fora + (y0 - fora) * v + Math.sin(o.t * 9) * s * 0.03;
mao(c, o, x, y, s * 0.85, -0.5, -1, 0.95, 0, true);
}
}
// --------------------------------------------------------- faixa de baixo
// A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga.
const faixa = { modo: null, texto: '', t0: null, dur: 0 };
function definirFaixa(modo, opcoes) {
opcoes = opcoes || {};
if (!modo) {
if (!opcoes.so || faixa.modo === opcoes.so) faixa.modo = null; // so: só apaga esse modo
return;
}
faixa.modo = modo; faixa.t0 = performance.now() / 1000; faixa.texto = String(opcoes.texto || '').replace(/\s+/g, ' ').trim().slice(0, 400);
faixa.dur = opcoes.segundos || (modo === 'legenda' ? Math.max(3, Math.min(14, faixa.texto.length / 14 + 2.5)) : modo === 'digitando' ? 20 : 2.6);
}
function desenharFaixa(c, o, W, H) {
const y = H * 0.955, fonte = Math.round(H * 0.042);
c.save();
c.shadowColor = css(o.cor, 0.6); c.shadowBlur = H * 0.02;
if (o.carga && o.carga.carregando && typeof o.carga.nivel === 'number') { // carregando: canto direito
const n = Math.max(0, Math.min(100, o.carga.nivel)), bw = H * 0.075, bh = H * 0.036, bx = W - H * 0.13, by = y - bh / 2;
c.globalAlpha = 0.55 + 0.25 * Math.sin(o.t * 2.5);
c.strokeStyle = css(o.cor); c.lineWidth = Math.max(1, H * 0.005);
caixa(c, bx, by, bw, bh, bh * 0.25); c.stroke();
c.fillStyle = css(o.cor); c.fillRect(bx + bw, y - bh * 0.2, bh * 0.15, bh * 0.4);
c.fillRect(bx + bh * 0.12, by + bh * 0.15, (bw - bh * 0.24) * n / 100, bh * 0.7);
c.font = '600 ' + Math.round(fonte * 0.8) + 'px system-ui,sans-serif'; c.textAlign = 'right'; c.textBaseline = 'middle';
const tx = bx - H * 0.012;
c.fillText(Math.round(n) + '%', tx, y);
const rx = tx - c.measureText(Math.round(n) + '%').width - H * 0.022, r = H * 0.016; // raio desenhado na cor dos olhos
c.beginPath(); c.moveTo(rx + r * 0.3, y - r); c.lineTo(rx - r * 0.5, y + r * 0.15); c.lineTo(rx, y + r * 0.15);
c.lineTo(rx - r * 0.3, y + r); c.lineTo(rx + r * 0.5, y - r * 0.15); c.lineTo(rx, y - r * 0.15); c.closePath(); c.fill();
c.globalAlpha = 1;
}
if (faixa.modo) {
if (faixa.t0 === null) faixa.t0 = o.t;
const k = (o.t - faixa.t0) / faixa.dur;
if (k >= 1) faixa.modo = null;
else {
const some = Math.min(1, (faixa.dur - (o.t - faixa.t0)) / 0.4);
c.globalAlpha = Math.max(0, some);
c.fillStyle = css(o.cor);
if (faixa.modo === 'legenda') {
// efeito de digitação; se não couber, mostra o final do que já foi "dito"
let txt = faixa.texto.slice(0, Math.floor((o.t - faixa.t0) * 30));
c.font = '500 ' + fonte + 'px system-ui,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
const max = W * 0.8;
while (txt.length > 1 && c.measureText(txt).width > max) txt = txt.slice(1);
c.fillText(txt, W / 2, y);
} else if (faixa.modo === 'digitando') {
for (let i = 0; i < 3; i++) {
const pulo = Math.max(0, Math.sin(o.t * 7 - i * 0.7)) * H * 0.012;
c.beginPath(); c.arc(W / 2 + (i - 1) * H * 0.035, y - pulo, H * 0.011, 0, Math.PI * 2); c.fill();
}
} else if (faixa.modo === 'equalizador') {
const n = 15, larg = H * 0.016, passo = H * 0.026;
for (let i = 0; i < n; i++) {
const a = 0.25 + 0.75 * Math.abs(Math.sin(o.t * (5 + i % 4) + i * 1.3)) * Math.sin(Math.PI * (i + 0.5) / n);
const h = H * 0.04 * a;
caixa(c, W / 2 + (i - (n - 1) / 2) * passo - larg / 2, H * 0.988 - h, larg, h, larg / 2); c.fill();
}
}
}
}
c.restore();
}
/**
* Retrato parado num canvas qualquer (miniaturas da biblioteca no painel).
* o = { estilo, formato, cor, forma, icone, desenho }.
*/
function retrato(alvo, o) {
const base = ESTILOS[o.estilo] || ESTILOS.vivo;
const formato = !base.lente && FORMATOS[o.formato];
const est = formato ? Object.assign({}, base, formato) : base;
const W = alvo.width, H = alvo.height, c = alvo.getContext('2d');
const pal = Object.assign({ coracao: o.forma === 'coracao' ? 1 : 0 }, PALPEBRAS[o.forma] || PALPEBRAS.aberto);
const op = { t: 0, cor: o.cor || [92, 214, 255], icone: o.icone || null, desenho: o.desenho || null };
const ry = H * 0.36, sep = Math.min(W * 0.23, H * 0.62);
const olhos = [-1, 1].map((s) => ({ cx: W / 2 + s * sep, cy: H / 2, ry, ab: o.forma === 'surpreso' ? 1.12 : 1, forma: o.forma, lado: -s, lx: 0, ly: 0 }));
c.clearRect(0, 0, W, H);
if (est.lente) { olhos.forEach((p) => olhoLente(c, op, p, est, pal)); return; }
const tmp = document.createElement('canvas'); tmp.width = W; tmp.height = H;
const t = tmp.getContext('2d');
olhos.forEach((p) => olhoPlano(t, op, p, est, Object.assign({}, pal)));
if (est.pontos) {
const passo = Math.max(3, Math.round(H / 16)), d = t.getImageData(0, 0, W, H).data;
for (let y = passo / 2; y < H; y += passo) for (let x = passo / 2; x < W; x += passo) {
const k = (Math.floor(y) * W + Math.floor(x)) * 4, a = d[k + 3] / 255;
c.beginPath(); c.arc(x, y, passo * 0.38, 0, Math.PI * 2);
c.fillStyle = a > 0.25 ? css([d[k], d[k + 1], d[k + 2]], 0.4 + a * 0.6) : 'rgba(255,255,255,0.05)';
c.fill();
}
return;
}
c.save();
if (est.brilho) { c.shadowColor = css(op.cor, 0.7); c.shadowBlur = H * 0.08; }
c.drawImage(tmp, 0, 0);
c.restore();
}
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = {
desenhar, retrato, esconder,
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
})();
@@ -10,6 +10,8 @@ import java.security.MessageDigest;
public final class CerebroLocal {
private volatile boolean fechado;
private Process processo;
/** Com token, o llama-server (aberto na rede para a placa) exige a mesma chave. */
public volatile String chave = "";
public void iniciar(final Context contexto, final String servidor) {
new Thread(() -> {
try {
@@ -43,7 +45,9 @@ public final class CerebroLocal {
try { if (health.getResponseCode() == 200) { Log.i("CarinhaIA", "Executor existente pronto"); return; } }
finally { health.disconnect(); }
} catch (IOException ignored) { }
ProcessBuilder builder = new ProcessBuilder(executavel.getAbsolutePath(), "-m", modelo.getAbsolutePath(), "--host", "0.0.0.0", "--port", "8101", "-c", "1024", "-t", "2", "-np", "1", "--jinja");
java.util.List<String> comando = new java.util.ArrayList<>(java.util.Arrays.asList(executavel.getAbsolutePath(), "-m", modelo.getAbsolutePath(), "--host", "0.0.0.0", "--port", "8101", "-c", "1024", "-t", "2", "-np", "1", "--jinja"));
if (chave != null && !chave.isEmpty()) { comando.add("--api-key"); comando.add(chave); }
ProcessBuilder builder = new ProcessBuilder(comando);
builder.environment().put("LD_LIBRARY_PATH", libs.getAbsolutePath());
builder.environment().put("GGML_BACKEND_DL_PATH", libs.getAbsolutePath());
builder.redirectErrorStream(true);
@@ -46,6 +46,9 @@ public final class ControleRemoto {
public void sinalRTC(String json) {
if (sinais.size() < 64) sinais.offer(json);
}
private volatile String token = "";
/** Token do servidor (CARINHA_TOKEN); vai como Authorization em todo pedido. */
public void token(String valor) { token = valor == null ? "" : valor.trim(); }
public void servidor(String valor) {
servidor = valor;
pendente = null;
@@ -140,6 +143,7 @@ public final class ControleRemoto {
HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
c.setConnectTimeout(1500); c.setReadTimeout(1500);
c.setUseCaches(false);
if (!token.isEmpty()) c.setRequestProperty("Authorization", "Bearer " + token);
return c;
}
public void quadro(Image image, int rotacao) {
@@ -8,6 +8,7 @@ import android.content.Intent;
import android.content.IntentFilter;
import android.os.BatteryManager;
import android.content.SharedPreferences;
import android.net.Uri;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.hardware.camera2.CameraAccessException;
@@ -83,6 +84,7 @@ public class MainActivity extends Activity {
private static final String PREFS = "carinha";
private static final String CHAVE = "servidor";
private static final String CHAVE_TOKEN = "token";
private static final String PADRAO = "http://192.168.1.15:8100";
private static final String TAG = "CarinhaMic";
@@ -100,6 +102,7 @@ public class MainActivity extends Activity {
private WebView web;
private String servidor;
private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto
private final CerebroLocal cerebroLocal = new CerebroLocal();
private boolean jaCaiuParaLocal = false;
private boolean jaRecarregou = false;
@@ -179,8 +182,10 @@ public class MainActivity extends Activity {
SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
servidor = p.getString(CHAVE, PADRAO);
token = p.getString(CHAVE_TOKEN, "");
movimentoLigado = p.getBoolean(CHAVE_MOV, true);
usarServidorDoPedido(getIntent()); // permite apontar o servidor ao lancar
cerebroLocal.chave = token;
cerebroLocal.iniciar(this, servidor);
remoto = new ControleRemoto(new Runnable() {
@Override public void run() {
@@ -272,7 +277,7 @@ public class MainActivity extends Activity {
runOnUiThread(new Runnable() {
@Override
public void run() {
boolean pode = true;
boolean pode = origemConfiavel(pedido.getOrigin());
for (String recurso : pedido.getResources()) {
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(recurso)
&& checkSelfPermission(Manifest.permission.CAMERA)
@@ -348,6 +353,15 @@ public class MainActivity extends Activity {
if (it == null) return;
String novo = it.getStringExtra("servidor");
if (novo == null) return;
String tokenPedido = it.getStringExtra("token");
if (!token.isEmpty() && !token.equals(tokenPedido)) {
Log.w(TAG, "pedido para trocar o servidor sem o token certo: ignorado");
return;
}
if (token.isEmpty() && tokenPedido != null && !tokenPedido.trim().isEmpty()) {
token = tokenPedido.trim();
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE_TOKEN, token).apply();
}
novo = novo.trim();
while (novo.endsWith("/")) novo = novo.substring(0, novo.length() - 1);
if (novo.length() == 0) return;
@@ -374,8 +388,9 @@ public class MainActivity extends Activity {
private void abrirRosto() {
naCamera = false;
remoto.servidor(servidor);
remoto.token(token);
if (usandoLocal) web.loadUrl("file:///android_asset/carinha.html");
else web.loadUrl(servidor + "/carinha.html?aparelho=1");
else web.loadUrl(servidor + "/carinha.html?aparelho=1" + (token.isEmpty() ? "" : "&t=" + Uri.encode(token)));
iniciarMovimento(); // volta a observar quem passa na frente
}
@@ -397,7 +412,7 @@ public class MainActivity extends Activity {
private void menu() {
final String[] opcoes = {"Ver a camera", "Voltar para a carinha",
(movimentoLigado ? "Desligar reacao ao movimento" : "Ligar reacao ao movimento"),
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video"};
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video", "Token do servidor"};
new AlertDialog.Builder(this)
.setTitle("Carinha")
.setItems(opcoes, new DialogInterface.OnClickListener() {
@@ -408,7 +423,8 @@ public class MainActivity extends Activity {
else if (i == 2) alternarMovimento();
else if (i == 3) pedirServidor();
else if (i == 4) menuSensores();
else menuVideo();
else if (i == 5) menuVideo();
else pedirToken();
}
})
.show();
@@ -520,6 +536,37 @@ public class MainActivity extends Activity {
.show();
}
/** Token do servidor (CARINHA_TOKEN da placa). Vazio = servidor sem autenticacao. */
private void pedirToken() {
final EditText campo = new EditText(this);
campo.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
campo.setText(token);
new AlertDialog.Builder(this)
.setTitle("Token do servidor")
.setMessage("O mesmo CARINHA_TOKEN configurado na placa")
.setView(campo)
.setPositiveButton("Salvar", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface d, int w) {
token = campo.getText().toString().trim();
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE_TOKEN, token).apply();
cerebroLocal.chave = token;
aviso(token.isEmpty() ? "token removido" : "token salvo");
abrirRosto();
}
})
.setNegativeButton("Cancelar", null)
.show();
}
/** So o servidor configurado (ou a copia local do APK) pode usar camera/microfone. */
private boolean origemConfiavel(Uri origem) {
if (origem == null) return false;
String o = origem.toString();
while (o.endsWith("/")) o = o.substring(0, o.length() - 1);
return o.startsWith("file:") || o.equals(servidor);
}
/** Voltar nao sai do app (num enfeite de mesa isso seria chato): volta a carinha. */
@Override
public void onBackPressed() {
@@ -974,8 +1021,53 @@ public class MainActivity extends Activity {
return String.format(java.util.Locale.US, "%.3f", v);
}
private String aparelhoJson = "null";
private long aparelhoEm = 0;
/** IP, modelo, Android, versão do app e sinal do Wi-Fi; recalculado a cada 10 s. */
private String infoAparelho() {
long agora = System.currentTimeMillis();
if (agora - aparelhoEm < 10000) return aparelhoJson;
aparelhoEm = agora;
try {
JSONObject a = new JSONObject();
a.put("ip", ipLocal());
a.put("modelo", (Build.MANUFACTURER + " " + Build.MODEL).trim());
a.put("android", Build.VERSION.RELEASE);
try { a.put("app", getPackageManager().getPackageInfo(getPackageName(), 0).versionName); }
catch (Exception e) { a.put("app", JSONObject.NULL); }
try {
android.net.wifi.WifiManager wifi = (android.net.wifi.WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
android.net.wifi.WifiInfo info = wifi == null ? null : wifi.getConnectionInfo();
// Sem localização o Android 10+ esconde o ID da rede, mas o RSSI continua valendo.
if (info != null && info.getRssi() > -127 && info.getRssi() < 0) {
a.put("wifi", android.net.wifi.WifiManager.calculateSignalLevel(info.getRssi(), 5));
a.put("rssi", info.getRssi());
}
} catch (Exception e) { /* sem permissão ou sem Wi-Fi */ }
aparelhoJson = a.toString();
} catch (Exception e) {
aparelhoJson = "null";
}
return aparelhoJson;
}
/** Primeiro IPv4 privado da rede (Wi-Fi), sem precisar de permissão extra. */
private static String ipLocal() {
try {
for (java.net.NetworkInterface n : java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) {
if (!n.isUp() || n.isLoopback()) continue;
for (java.net.InetAddress e : java.util.Collections.list(n.getInetAddresses())) {
if (e instanceof java.net.Inet4Address && e.isSiteLocalAddress()) return e.getHostAddress();
}
}
} catch (Exception e) { /* sem rede */ }
return null;
}
private void atualizarBrilho() {
if (naCamera) return;
web.evaluateJavascript("window.carinhaAparelho=" + infoAparelho(), null);
Intent bateria = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
if (bateria != null) {
int level = bateria.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
+1 -1
View File
@@ -30,7 +30,7 @@ def main():
def run(*command):
subprocess.run([str(x) for x in command], check=True)
for name in ('carinha.html', 'camera.html'):
for name in ('carinha.html', 'camera.html', 'rosto-motor.js'):
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
tmp = Path(folder)
-1
View File
@@ -1 +0,0 @@
(()=>{const el=id=>document.getElementById(id);let busy=false;async function call(path,data){const r=await fetch('/api/configuracao/'+path,{cache:'no-store',signal:AbortSignal.timeout(55000),...(data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})});const d=await r.json();if(!r.ok)throw Error(d.erro);return d;}function lock(v){busy=v;el('apiSave').disabled=v;el('apiTest').disabled=v;}async function load(){try{const s=await call('status');el('apiModel').value=s.modelo;el('apiBadge').textContent=s.configurada?'Chave configurada':'Chave não configurada';el('apiBadge').classList.toggle('ativo',s.configurada);if(s.ultimo_teste)el('apiState').textContent=s.ultimo_teste.mensagem+(s.ultimo_teste.segundos?' ('+s.ultimo_teste.segundos+' s)':'');}catch(e){el('apiState').textContent=e.message;}}el('apiServer').textContent=location.origin;el('apiForm').onsubmit=async e=>{e.preventDefault();if(busy)return;lock(true);try{const d={modelo:el('apiModel').value.trim()};if(el('apiKey').value.trim())d.chave=el('apiKey').value.trim();await call('salvar',d);el('apiKey').value='';await load();el('apiState').textContent='Configuração salva. As próximas chamadas usarão este modelo.';}catch(e){el('apiState').textContent=e.message;}finally{lock(false);}};el('apiTest').onclick=async()=>{if(busy)return;lock(true);el('apiState').textContent='Testando o modelo salvo…';try{const s=await call('testar',{});el('apiState').textContent=s.mensagem+(s.segundos?' ('+s.segundos+' s)':'');}catch(e){el('apiState').textContent=e.message;}finally{lock(false);}};load();})();
-117
View File
@@ -1,117 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API Gemini & Conexão · Carinha</title>
<link rel="stylesheet" href="/dashboard.css">
<script src="/dashboard.js" defer></script>
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
<div>
<h1>API Gemini & Conexão IA</h1>
<p style="margin:4px 0 0">Configure o modelo do Google Gemini que dá inteligência ao diálogo e cria novas expressões.</p>
</div>
<div>
<span class="badge" id="apiBadge">Verificando…</span>
</div>
</div>
<div class="split" style="margin-top:16px">
<!-- Formulário do Gemini -->
<section class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
Google Gemini Cloud
</h2>
<span class="badge" style="font-size:11px">Seguro · api-privada.json</span>
</div>
<form id="apiForm">
<!-- Model Presets -->
<label for="apiModelSelect">Modelos recomendados:</label>
<select id="apiModelSelect" style="margin-bottom:12px">
<option value="gemini-2.5-flash">gemini-2.5-flash · Rápido, expressivo & moderno (Recomendado)</option>
<option value="gemini-1.5-flash">gemini-1.5-flash · Alta velocidade e respostas curtas</option>
<option value="gemini-2.0-flash">gemini-2.0-flash · Nova arquitetura multimodal</option>
<option value="gemini-1.5-pro">gemini-1.5-pro · Raciocínio profundo e criatividade</option>
<option value="custom">Outro (digitar manualmente abaixo)</option>
</select>
<label for="apiModel">Identificador do Modelo</label>
<input id="apiModel" required maxlength="100" placeholder="gemini-flash-lite-latest">
<label for="apiKey">Chave de API Gemini (Google AI Studio)</label>
<input id="apiKey" type="password" autocomplete="new-password" maxlength="250" placeholder="Insira nova chave ou deixe vazio para manter a atual">
<p style="font-size:12.5px;color:var(--fraco);margin-top:-6px">
🔒 Por segurança, o campo de chave nunca exibe o valor salvo no servidor. Ele é mantido exclusivamente no arquivo privado local do Orange Pi.
</p>
<div class="actions" style="margin-top:20px">
<button class="primary" id="apiSave" type="submit">💾 Salvar Configuração</button>
<button type="button" id="apiTest">⚡ Testar Conexão Agora</button>
</div>
</form>
<p id="apiState" role="status" aria-live="polite" style="margin-top:16px;font-size:13.5px;color:var(--ciano);min-height:24px"></p>
</section>
<!-- Instruções e Informações do Sistema -->
<section class="card" style="display:flex;flex-direction:column;justify-content:space-between">
<div>
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
Arquitetura Híbrida de IA
</h2>
<p>O Carinha combina processamento em nuvem com inteligência embarcada no robô:</p>
<div style="display:flex;flex-direction:column;gap:12px;margin:16px 0">
<div style="background:rgba(12,22,36,0.6);border:1px solid var(--borda);border-radius:var(--raio-md);padding:12px">
<strong style="color:var(--ciano);display:block;margin-bottom:4px">☁️ Google Gemini (Nuvem)</strong>
<small style="color:var(--texto-medio);font-size:13px">Gera as respostas de conversação natural, recupera lembranças e desenha novas figurinhas SVG em tempo real.</small>
</div>
<div style="background:rgba(12,22,36,0.6);border:1px solid var(--borda);border-radius:var(--raio-md);padding:12px">
<strong style="color:#34d399;display:block;margin-bottom:4px">📱 Qwen / Local (Galaxy S10)</strong>
<small style="color:var(--texto-medio);font-size:13px">Disponível no app do celular para quando estiver offline, garantindo que o Carinha continue reagindo aos sensores.</small>
</div>
</div>
<p style="font-size:13px">Você pode alternar entre os motores a qualquer momento na tela <a href="/conversa">Conversar</a>.</p>
</div>
<details style="margin-top:16px">
<summary>Informações do Servidor Local</summary>
<p id="apiServer" style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--ciano);margin-top:8px"></p>
<p style="font-size:12px;color:var(--fraco)">Orange Pi Zero 3 operando na rede interna.</p>
</details>
</section>
</div>
<script src="/api-painel.js" defer></script>
<script>
// Sincroniza o select de modelos com o input de modelo
document.addEventListener('DOMContentLoaded', () => {
const sel = document.getElementById('apiModelSelect');
const input = document.getElementById('apiModel');
if (sel && input) {
sel.onchange = () => {
if (sel.value !== 'custom') {
input.value = sel.value;
}
};
input.addEventListener('input', () => {
let matched = false;
for (let opt of sel.options) {
if (opt.value === input.value) { opt.selected = true; matched = true; break; }
}
if (!matched) sel.value = 'custom';
});
}
});
</script>
</body>
</html>
-248
View File
@@ -1,248 +0,0 @@
/**
* audio-robot.js — Sintetizador procedural de efeitos sonoros robóticos (Web Audio API)
* Gera bips, assobios, trinados alegres e sons estilo WALL-E / Eilik diretamente no navegador.
* Sem arquivos de áudio externos, sem dependências, 100% autônomo e instantâneo.
*/
(function(window) {
'use strict';
let audioCtx = null;
let enabled = localStorage.getItem('carinha_sfx') !== 'false';
let volume = parseFloat(localStorage.getItem('carinha_volume') || '0.7');
function getContext() {
if (!audioCtx) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (AudioContext) {
audioCtx = new AudioContext();
}
}
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume();
}
return audioCtx;
}
const RobotAudio = {
get enabled() { return enabled; },
set enabled(val) {
enabled = Boolean(val);
localStorage.setItem('carinha_sfx', enabled ? 'true' : 'false');
window.dispatchEvent(new CustomEvent('carinha:audio-toggle', { detail: { enabled } }));
},
get volume() { return volume; },
set volume(val) {
volume = Math.max(0, Math.min(1, parseFloat(val) || 0.7));
localStorage.setItem('carinha_volume', volume.toString());
},
toggle() {
this.enabled = !this.enabled;
if (this.enabled) this.bleep();
return this.enabled;
},
// Bip simples de confirmação / clique
click() {
if (!enabled) return;
const ctx = getContext();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const now = ctx.currentTime;
osc.type = 'sine';
osc.frequency.setValueAtTime(800, now);
osc.frequency.exponentialRampToValueAtTime(1400, now + 0.04);
gain.gain.setValueAtTime(volume * 0.15, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.05);
},
// Bip robótico positivo
bleep() {
if (!enabled) return;
const ctx = getContext();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const now = ctx.currentTime;
osc.type = 'sine';
osc.frequency.setValueAtTime(659.25, now); // E5
osc.frequency.setValueAtTime(880, now + 0.06); // A5
gain.gain.setValueAtTime(volume * 0.25, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.16);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.16);
},
// Trinado alegre (estilo WALL-E / Eilik feliz)
happy() {
if (!enabled) return;
const ctx = getContext();
if (!ctx) return;
const notes = [523.25, 659.25, 783.99, 1046.50]; // C5, E5, G5, C6
const now = ctx.currentTime;
notes.forEach((freq, idx) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const start = now + idx * 0.07;
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, start);
osc.frequency.exponentialRampToValueAtTime(freq * 1.08, start + 0.06);
gain.gain.setValueAtTime(0.001, start);
gain.gain.linearRampToValueAtTime(volume * 0.3, start + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.1);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(start);
osc.stop(start + 0.1);
});
},
// Curiosidade / interrogação
curious() {
if (!enabled) return;
const ctx = getContext();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const now = ctx.currentTime;
osc.type = 'sine';
osc.frequency.setValueAtTime(440, now);
osc.frequency.exponentialRampToValueAtTime(950, now + 0.22);
gain.gain.setValueAtTime(volume * 0.22, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.25);
},
// Surpreso / Alerta
surprise() {
if (!enabled) return;
const ctx = getContext();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const now = ctx.currentTime;
osc.type = 'square';
osc.frequency.setValueAtTime(800, now);
osc.frequency.linearRampToValueAtTime(1400, now + 0.08);
osc.frequency.linearRampToValueAtTime(1800, now + 0.15);
gain.gain.setValueAtTime(volume * 0.2, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.18);
},
// Triste / Melancólico
sad() {
if (!enabled) return;
const ctx = getContext();
if (!ctx) return;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const now = ctx.currentTime;
osc.type = 'sine';
osc.frequency.setValueAtTime(600, now);
osc.frequency.linearRampToValueAtTime(320, now + 0.35);
gain.gain.setValueAtTime(volume * 0.2, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.4);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(now);
osc.stop(now + 0.4);
},
// Acordando (Power on)
wake() {
if (!enabled) return;
const ctx = getContext();
if (!ctx) return;
const freqs = [350, 440, 587.33, 880];
const now = ctx.currentTime;
freqs.forEach((f, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const t = now + i * 0.06;
osc.type = 'triangle';
osc.frequency.setValueAtTime(f, t);
gain.gain.setValueAtTime(volume * 0.25, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + 0.12);
});
},
// Dormindo (Power down)
sleep() {
if (!enabled) return;
const ctx = getContext();
if (!ctx) return;
const freqs = [700, 520, 380, 220];
const now = ctx.currentTime;
freqs.forEach((f, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const t = now + i * 0.08;
osc.type = 'sine';
osc.frequency.setValueAtTime(f, t);
gain.gain.setValueAtTime(volume * 0.2, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.14);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t);
osc.stop(t + 0.14);
});
},
// Tocar som de acordo com o gesto/emoção
tocarGesto(gesto) {
if (!enabled) return;
switch ((gesto || '').toLowerCase()) {
case 'feliz':
case 'alegre':
case 'coracao':
this.happy();
break;
case 'surpreso':
this.surprise();
break;
case 'triste':
this.sad();
break;
case 'dormir':
this.sleep();
break;
case 'acordar':
this.wake();
break;
case 'olhar_esq':
case 'olhar_dir':
case 'olhar_baixo':
this.curious();
break;
case 'piscar':
case 'piscar2':
case 'piscadinha':
this.click();
break;
default:
this.bleep();
}
}
};
window.RobotAudio = RobotAudio;
})(window);
-13
View File
@@ -1,13 +0,0 @@
(()=>{const el=id=>document.getElementById(id);let state=null,signature='',editing=null,lastFocus=null,busy=false;
async function api(path,data){const r=await fetch('/treinamento/'+path,{cache:'no-store',...(data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})});const d=await r.json();if(!r.ok)throw Error(d.erro);return d;}
function text(tag,t){const n=document.createElement(tag);n.textContent=t;return n;}function button(t,f){const b=text('button',t);b.onclick=f;return b;}
function preview(recipe){el('emojiPreview').contentWindow.postMessage({tipo:'previa-reacao',receita:recipe},location.origin);}
async function action(path,data){if(busy)return false;busy=true;try{await api(path,data);await refresh();el('emojiState').textContent=path==='testar'?'Expressão enviada ao celular.':path==='parar'?'Pedido para parar enviado ao celular.':'Biblioteca atualizada.';return true;}catch(e){el('emojiState').textContent=e.message;return false;}finally{busy=false;}}
function render(){const q=el('emojiSearch').value.toLocaleLowerCase('pt-BR');const recipes=state.reacoes.filter(r=>(r.receita.nome+' '+r.receita.motivo).toLocaleLowerCase('pt-BR').includes(q));el('emojiLibrary').replaceChildren();el('emojiCount').textContent=recipes.length+' reações';for(const r of recipes){const recipe=r.receita;const card=document.createElement('article');if(!r.ativa)card.classList.add('muted-card');const icon=text('div',recipe.emoji?.caractere||({ideia:'💡',cafe:'☕',musica:'🎵',coracao:'❤️',estrela:'⭐'}[recipe.simbolo]||'◈'));icon.className='emoji-swatch';if(recipe.figurinha){icon.textContent='';const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 100 100');svg.setAttribute('width','64');svg.setAttribute('height','64');for(const shape of recipe.figurinha.formas){const path=document.createElementNS(ns,'path');path.setAttribute('d',shape.caminho);path.setAttribute('fill',shape.cor);svg.append(path);}icon.append(svg);}card.append(icon,text('h3',recipe.nome),text('p',recipe.motivo),text('small',(recipe.figurinha?'Desenho SVG':recipe.emoji?'Emoji pronto':'Animação do rosto')+' · '+(r.ativa?'Ativa':'Desativada')));const actions=document.createElement('div');actions.className='actions';actions.append(button('Prévia',()=>preview(recipe)),button('Mostrar no celular',()=>action('testar',{id:r.id})),button('Editar',()=>edit(r)),button('Duplicar',()=>action('biblioteca',{acao:'duplicar',id:r.id})),button(r.ativa?'Desativar':'Ativar',()=>action('biblioteca',{acao:'ativar',id:r.id,ativa:!r.ativa})),button('Excluir',()=>action('biblioteca',{acao:'excluir',id:r.id})),button(r.feedback===1?'Gostei ✓':'Gostei',()=>action('feedback',{id:r.id,valor:r.feedback===1?0:1})),button(r.feedback===-1?'Não gostei ✓':'Não gostei',()=>action('feedback',{id:r.id,valor:r.feedback===-1?0:-1})));card.append(actions);el('emojiLibrary').append(card);}if(!recipes.length)el('emojiLibrary').append(text('p',q?'Nenhuma reação encontrada.':'Crie seu primeiro emoji ou peça um desenho ao Gemini.'));}
async function refresh(){try{state=await api('status');el('emojiUndo').disabled=!state.desfazer||state.ocupado;el('emojiNew').disabled=state.ocupado;if(el('emojiCharacter').options.length===1)for(const e of state.emojis)el('emojiCharacter').add(new Option(e,e));const key=JSON.stringify(state.reacoes);if(key!==signature){signature=key;render();}}catch(e){el('emojiState').textContent=e.message;}}
function edit(r){editing=r?.id??null;lastFocus=document.activeElement;el('emojiEditorTitle').textContent=r?'Editar reação':'Criar emoji';el('emojiName').value=r?.receita.nome||'';el('emojiReason').value=r?.receita.motivo||'';el('emojiCharacter').value=r?.receita.emoji?.caractere||(r?'':'💡');el('emojiLocation').value=r?.receita.emoji?.local||r?.receita.figurinha?.local||'olhos';el('emojiEditState').textContent='';el('emojiDialog').hidden=false;el('emojiName').focus();}
function close(){el('emojiDialog').hidden=true;lastFocus?.focus();}
el('emojiCancel').onclick=close;el('emojiDialog').onclick=e=>{if(e.target===el('emojiDialog'))close();};document.addEventListener('keydown',e=>{if(el('emojiDialog').hidden)return;if(e.key==='Escape')close();if(e.key==='Tab'){const nodes=[...el('emojiDialog').querySelectorAll('input,textarea,select,button')];if(e.shiftKey&&document.activeElement===nodes[0]){e.preventDefault();nodes.at(-1).focus();}else if(!e.shiftKey&&document.activeElement===nodes.at(-1)){e.preventDefault();nodes[0].focus();}}});
el('emojiEditForm').onsubmit=async e=>{e.preventDefault();const data={acao:editing===null?'criar':'editar',id:editing,nome:el('emojiName').value.trim(),motivo:el('emojiReason').value.trim(),caractere:el('emojiCharacter').value,local:el('emojiLocation').value};if(editing===null&&!data.caractere){el('emojiEditState').textContent='Escolha um emoji para criar a reação.';return;}if(await action('biblioteca',data))close();else el('emojiEditState').textContent=el('emojiState').textContent;};
el('emojiSearch').oninput=()=>{if(state)render();};el('emojiNew').onclick=()=>edit(null);el('emojiUndo').onclick=()=>action('biblioteca',{acao:'desfazer'});el('emojiStop').onclick=async()=>{preview({limpar:true});await action('parar',{});};el('emojiExport').onclick=()=>{if(!state)return;const url=URL.createObjectURL(new Blob([JSON.stringify(state.reacoes.map(r=>r.receita),null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='carinha-emojis.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};refresh();setInterval(refresh,3000);
})();
-17
View File
@@ -1,17 +0,0 @@
'use strict';
const calibrationPanel=document.createElement('section');
calibrationPanel.style.cssText='border:1px solid #ddd;border-radius:14px;padding:16px;margin:16px 0';
calibrationPanel.innerHTML='<h3 style="font-size:16px;margin:0 0 12px">Testar fim de curso · passos de 100 µs</h3><p class="notice">Observe o motor e recue ao atingir o limite. Não há detecção automática. Canais 5 e 6 acompanham juntos. Cada teste dura até 2 segundos.</p><label>Canal<select id="calChannel" style="max-width:220px">'+Array.from({length:8},(_,i)=>`<option value="${i}">Canal ${i}${i===5||i===6?' (par 5/6)':''}</option>`).join('')+'</select></label><p><output id="calPulse" style="font-size:24px">1500 µs</output></p><label><input id="calEnable" type="checkbox"> Habilitar testes de calibração</label><div style="display:flex;flex-wrap:wrap;gap:8px;margin:14px 0"><button id="calDown">−100 µs</button><button id="calTest">Testar posição</button><button id="calUp">+100 µs</button><button id="calStop">Parar sinais</button></div><div style="display:flex;flex-wrap:wrap;gap:8px"><button id="calMin">Marcar mínimo</button><button id="calCenter">Marcar centro</button><button id="calMax">Marcar máximo</button></div><p id="calMessage" role="status" class="notice">Escolha um canal. Marcar limites não envia movimento.</p>';
document.querySelector('details').insertBefore(calibrationPanel,document.getElementById('calibration'));
calibrationPanel.querySelectorAll('button').forEach(b=>b.style.cssText='font-size:13px;padding:11px 16px');
let calibrationPulse=1500, calibrationBusy=false;
const el=id=>document.getElementById(id);
function calButtons(){for(const id of ['calDown','calUp','calTest'])el(id).disabled=calibrationBusy||!el('calEnable').checked;}
async function calPost(route,data){const r=await fetch('/braco/'+route,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const d=await r.json();if(!r.ok)throw Error(d.erro);return d;}
async function testCalibration(delta){if(calibrationBusy||!el('calEnable').checked)return;calibrationBusy=true;calButtons();try{const next=calibrationPulse+delta;if(next<500||next>2500)throw Error('Faixa absoluta: 500–2500 µs');await BracoUI.stop();const result=await calPost('calibrar',{canal:Number(el('calChannel').value),pulso:next});calibrationPulse=next;el('calPulse').textContent=next+' µs';el('calMessage').textContent='Teste enviado: '+JSON.stringify(result.pulsos)+'. Sinal desliga em 2 s.';}catch(e){el('calMessage').textContent=e.message;}finally{calibrationBusy=false;calButtons();}}
el('calEnable').onchange=()=>{calButtons();if(!el('calEnable').checked)BracoUI.stop().catch(e=>el('calMessage').textContent=e.message);};
el('calChannel').onchange=()=>{el('calEnable').checked=false;calibrationPulse=BracoUI.calibration()?.[Number(el('calChannel').value)].centro??1500;el('calPulse').textContent=calibrationPulse+' µs';calButtons();BracoUI.stop().catch(e=>el('calMessage').textContent=e.message);};
el('calDown').onclick=()=>testCalibration(-100);el('calUp').onclick=()=>testCalibration(100);el('calTest').onclick=()=>testCalibration(0);
el('calStop').onclick=()=>{el('calEnable').checked=false;calButtons();BracoUI.stop().catch(e=>el('calMessage').textContent=e.message);};
for(const [id,key] of [['calMin','minimo'],['calCenter','centro'],['calMax','maximo']])el(id).onclick=async()=>{if(calibrationBusy)return;try{el('calEnable').checked=false;calButtons();await BracoUI.stop();BracoUI.markCalibration(Number(el('calChannel').value),key,calibrationPulse);el('calMessage').textContent='Marcado '+key+' = '+calibrationPulse+' µs. Clique Salvar calibração abaixo.';}catch(e){el('calMessage').textContent=e.message;}};
calButtons();
-41
View File
@@ -1,41 +0,0 @@
'use strict';
let preview=null, previewPose=Array(7).fill(90), previewAt=0, previous=performance.now();
const armDrawing=document.querySelector('.arm');
const svgNS='http://www.w3.org/2000/svg';
const shell=armDrawing.querySelector('g[stroke]');
const parts=Array.from(shell.children);
function group(id){const g=document.createElementNS(svgNS,'g');g.id=id;return g;}
const baseVisual=group('armBase'),lowerVisual=group('armLower'),upperVisual=group('armUpper'),neckVisual=group('armNeck'),handVisual=group('armHand');
parts.forEach((part,i)=>{(i<2?baseVisual:i<8?lowerVisual:i<13?upperVisual:i<17?neckVisual:handVisual).append(part);});
neckVisual.append(handVisual);upperVisual.append(neckVisual);lowerVisual.append(upperVisual);baseVisual.append(lowerVisual);shell.append(baseVisual);
const bolts=armDrawing.querySelector(':scope > g:not([stroke]):not([fill])');
if(bolts){Array.from(bolts.children).forEach(bolt=>{const y=Number(bolt.getAttribute('y'));(y>420?baseVisual:y>300?lowerVisual:y>170?upperVisual:neckVisual).append(bolt);});bolts.remove();}
const guideLines=armDrawing.querySelector('g[fill="none"]');if(guideLines)guideLines.remove();
function drawPose(p){
baseVisual.removeAttribute('transform');
baseVisual.style.transformOrigin='122px 450px';
baseVisual.style.transform=`perspective(650px) rotateY(${p[6]-90}deg)`;
baseVisual.style.transformBox='view-box';
lowerVisual.setAttribute('transform',`rotate(${p[4]-90},122,339)`);
upperVisual.setAttribute('transform',`rotate(${p[3]-90},122,211)`);
neckVisual.setAttribute('transform',`rotate(${p[5]-90},122,125)`);
handVisual.setAttribute('transform',`rotate(${p[1]-90},122,90)`);
const fingers=handVisual.querySelectorAll('path')[2];
handVisual.style.transformOrigin='122px 78px';
handVisual.style.transformBox='view-box';
handVisual.style.transform=`perspective(450px) rotateX(${p[2]-90}deg)`;
if(fingers){fingers.setAttribute('transform',`translate(0,${(p[0]-90)*-.06}) scale(1,${1+(p[0]-90)*.003})`);}
}
function stopPreview(){preview=null;document.getElementById('animationStatus').textContent='Prévia parada · posição nominal';}
document.getElementById('stopPreview').onclick=()=>{stopPreview();BracoUI.stop().catch(e=>document.getElementById('animationStatus').textContent=e.message);};
const init=setInterval(()=>{if(!BracoUI.ready())return;clearInterval(init);
Movimentos.sequencias(BracoUI.bounds()).forEach((move,i)=>{const card=document.createElement('article');card.style.cssText='border:1px solid var(--borda);border-radius:12px;padding:16px;flex:1;min-width:170px';const title=document.createElement('h3');title.textContent=move.nome;title.style.cssText='font-size:14px;margin:0 0 12px';card.append(title);
for(const [text,physical] of [['Prévia',false],['Executar',true]]){const button=document.createElement('button');button.textContent=text;if(physical){button.dataset.execute='1';button.disabled=!BracoUI.canExecute();button.title='Marque Ativar movimento para executar';}button.style.cssText='font-size:12px;padding:10px 14px;margin:3px';button.onclick=async()=>{try{const steps=Movimentos.sequencias(BracoUI.bounds())[i].passos;if(physical){stopPreview();BracoUI.execute(steps);}else{await BracoUI.stop();preview={steps,index:0,hold:0};previewPose=BracoUI.pose();previewAt=performance.now();document.getElementById('animationStatus').textContent='Prévia: '+move.nome+' · sem movimento físico';}}catch(e){document.getElementById('animationStatus').textContent=e.message;}};card.append(button);}
document.getElementById('movimentos').append(card);
});},100);
function animate(now){const dt=Math.min(.1,(now-previous)/1000);previous=now;
if(preview){const goal=preview.steps[preview.index],step=Number(document.getElementById('speed').value)*dt;const distance=Math.max(...previewPose.map((v,i)=>Math.abs(goal[i]-v))),fraction=distance?Math.min(1,step/distance):1;previewPose=previewPose.map((v,i)=>v+(goal[i]-v)*fraction);if(previewPose.every((v,i)=>Math.abs(v-goal[i])<.01)){if(!preview.hold)preview.hold=now+700;if(now>=preview.hold){preview.index++;preview.hold=0;if(preview.index===preview.steps.length){preview=null;document.getElementById('animationStatus').textContent='Prévia concluída · sem movimento físico';}}}drawPose(previewPose);}else if(BracoUI.ready())drawPose(BracoUI.pose());requestAnimationFrame(animate);}
document.getElementById('stop').addEventListener('click',stopPreview);
document.addEventListener('visibilitychange',()=>{if(document.hidden)stopPreview();});
requestAnimationFrame(animate);
-213
View File
@@ -1,213 +0,0 @@
/* ==========================================================================
BRAÇO ROBÓTICO · WORKSPACE & CINEMÁTICA
========================================================================== */
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
h1 small {
display: block;
font-size: 13px;
font-weight: 500;
letter-spacing: 0;
color: var(--fraco);
margin-top: 6px;
}
.workspace {
display: grid;
grid-template-columns: 160px 220px minmax(280px, 1fr) 150px;
gap: 20px;
margin-top: 24px;
align-items: center;
background: rgba(10, 18, 30, 0.7);
padding: 24px;
border-radius: var(--raio-lg);
border: 1px solid var(--borda);
box-shadow: var(--sombra-card);
}
.side {
display: flex;
flex-direction: column;
gap: 10px;
}
.side button {
padding: 10px 14px;
font-size: 13.5px;
justify-content: center;
}
.arm {
width: 100%;
height: 480px;
overflow: visible;
filter: drop-shadow(0 12px 20px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 16px rgba(92, 214, 255, 0.15));
transition: transform 0.2s ease;
}
.counter {
text-align: center;
color: var(--ciano);
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
background: rgba(92, 214, 255, 0.1);
padding: 6px 12px;
border-radius: 20px;
border: 1px solid rgba(92, 214, 255, 0.25);
}
.sliders {
display: grid;
gap: 14px;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px;
gap: 14px;
align-items: center;
background: rgba(16, 28, 44, 0.5);
padding: 8px 14px;
border-radius: var(--raio-sm);
border: 1px solid rgba(56, 82, 110, 0.2);
}
.row label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--texto-medio);
}
.row output {
background: rgba(28, 48, 70, 0.8);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
padding: 8px;
text-align: center;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 14px;
color: var(--ciano);
box-shadow: 0 0 10px rgba(92, 214, 255, 0.15);
}
#console {
background: rgba(5, 10, 18, 0.9);
color: #a5c7e5;
padding: 16px;
margin-top: 20px;
border: 1px solid var(--borda);
border-radius: var(--raio-md);
min-height: 64px;
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
line-height: 1.5;
}
#enableLabel {
display: inline-flex;
align-items: center;
gap: 10px;
margin: 18px 0 10px;
padding: 10px 16px;
background: rgba(20, 36, 56, 0.6);
border: 1px solid var(--borda);
border-radius: var(--raio-md);
font-weight: 600;
color: var(--texto);
}
.calibration {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin: 20px 0;
}
.calibration article {
padding: 16px;
background: rgba(14, 24, 38, 0.7);
}
.calibration label {
display: block;
font-size: 12px;
margin-bottom: 4px;
}
#importFile {
display: none;
}
.notice {
max-width: 100ch;
line-height: 1.6;
}
#poses {
font-size: 13px;
background: rgba(12, 20, 32, 0.9);
}
#movimentos {
margin: 16px 0;
}
#movimentos button {
padding: 10px 16px;
font-size: 13px;
border-radius: var(--raio-sm);
background: rgba(20, 38, 60, 0.7);
border: 1px solid var(--borda);
}
#movimentos button:hover {
border-color: var(--ciano);
color: #ffffff;
}
@media (max-width: 1200px) {
.workspace {
grid-template-columns: 140px 180px minmax(0, 1fr);
}
.right {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
flex-direction: row;
}
}
@media (max-width: 768px) {
header {
align-items: flex-start;
flex-direction: column;
}
.workspace {
grid-template-columns: 1fr;
padding: 16px;
}
.arm {
height: 280px;
grid-row: 1;
}
.sliders {
grid-row: 2;
}
.side {
flex-direction: row;
flex-wrap: wrap;
}
.calibration {
grid-template-columns: 1fr;
}
}
-158
View File
@@ -1,158 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Braço Robótico · Carinha</title>
<meta name="description" content="Controle os ângulos, crie coreografias articuladas e coordene 8 canais de movimento do braço robótico.">
<link rel="stylesheet" href="/dashboard.css">
<link rel="stylesheet" href="/braco.css">
<script src="/dashboard.js" defer></script>
</head>
<body>
<!-- Cabeçalho -->
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
<div>
<h1>Braço Robótico</h1>
<p style="margin:4px 0 0">Posturas e movimentos coordenados para os 8 canais de servo.</p>
</div>
<div style="display:flex;gap:10px;align-items:center">
<span id="connection" class="connection">Verificando conexão…</span>
</div>
</div>
<!-- Aviso de Segurança -->
<div class="card" style="margin-bottom:18px;border-color:rgba(245,158,11,0.35);background:rgba(45,30,6,0.5)">
<div style="display:flex;align-items:center;gap:12px">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--amarelo)" stroke-width="2" style="flex-shrink:0"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<div>
<p style="margin:0;color:var(--texto-medio);font-weight:600;font-size:14px">Atenção ao usar o braço</p>
<p style="margin:2px 0 0;font-size:13px">Ângulos são comandos nominais, sem medição do eixo. Canais 5 e 6 movem juntos e invertidos. Comece com o braço apoiado e limites calibrados. Ao parar ou perder a conexão, o sinal desliga e o braço pode perder sustentação.</p>
</div>
</div>
<div style="margin-top:12px">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;margin:0">
<input id="enable" type="checkbox" disabled>
<span style="font-size:14px;font-weight:600">Ativar movimento das articulações — canais 0 a 7</span>
</label>
</div>
</div>
<!-- Workspace Principal: SVG + Sliders + Controles -->
<div class="workspace">
<!-- Painel Esquerdo: Posturas Salvas -->
<aside class="side">
<div class="card" style="padding:18px">
<h2 style="font-size:16px;margin-bottom:14px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
Posturas Salvas
</h2>
<span id="count" class="counter" style="display:block;margin-bottom:12px">0 posturas</span>
<div style="display:flex;flex-direction:column;gap:8px">
<button id="savePose" style="width:100%">💾 Salvar postura atual</button>
<select id="poses" aria-label="Postura salva" style="margin:0">
<option>Sem posturas</option>
</select>
<button id="loadPose" disabled style="width:100%">📥 Carregar postura</button>
<button id="deletePose" disabled style="width:100%;background:rgba(60,10,18,0.5);border-color:rgba(244,63,94,0.3)">🗑️ Excluir postura</button>
<hr style="border:none;border-top:1px solid var(--borda);margin:4px 0">
<button id="play" disabled style="width:100%" class="primary">▶️ Reproduzir sequência</button>
<button id="stop" class="danger" style="width:100%">⛔ Parar sinais</button>
</div>
</div>
</aside>
<!-- Diagrama SVG do Braço -->
<svg class="arm" viewBox="0 0 240 530" role="img" aria-label="Ilustração detalhada do braço robótico">
<defs>
<linearGradient id="shell"><stop stop-color="#17191b"/><stop offset=".25" stop-color="#54585b"/><stop offset=".48" stop-color="#303437"/><stop offset=".8" stop-color="#151719"/><stop offset="1" stop-color="#3b3f41"/></linearGradient>
<linearGradient id="edge" x2="0" y2="1"><stop stop-color="#707579"/><stop offset=".18" stop-color="#34383b"/><stop offset=".8" stop-color="#191c1e"/><stop offset="1" stop-color="#464c50"/></linearGradient>
<radialGradient id="hub"><stop stop-color="#646b70"/><stop offset=".4" stop-color="#30363a"/><stop offset=".85" stop-color="#171a1c"/><stop offset="1" stop-color="#5c6267"/></radialGradient>
<g id="bolt"><circle r="3.2" fill="#777e83" stroke="#111" stroke-width="1"/><path d="m-1.5-1.5 3 3m0-3-3 3" stroke="#222" stroke-width=".7"/></g>
</defs>
<g stroke="#151719" stroke-width="2.5">
<path d="M25 512 46 490 73 467h100l18 23 29 22Z" fill="url(#shell)"/><path d="M74 478v-33q0-15 19-22h50q26 6 27 22v33q-47 16-96 0Z" fill="url(#edge)"/>
<rect x="87" y="343" width="70" height="103" rx="23" fill="url(#shell)"/><rect x="96" y="356" width="51" height="76" rx="13" fill="#202426"/>
<rect x="79" y="306" width="87" height="67" rx="24" fill="url(#edge)"/><circle cx="122" cy="339" r="24" fill="url(#hub)"/><circle cx="122" cy="339" r="10" fill="#30383d"/><circle cx="122" cy="339" r="3" fill="#949ba0"/>
<rect x="87" y="213" width="69" height="106" rx="20" fill="url(#shell)"/><path d="M97 224v74M146 224v74" stroke="#686e72" stroke-width="1.2"/>
<rect x="80" y="179" width="85" height="64" rx="20" fill="url(#edge)"/><circle cx="122" cy="211" r="24" fill="url(#hub)"/><circle cx="122" cy="211" r="8" fill="#5b6368"/>
<rect x="94" y="130" width="57" height="67" rx="17" fill="url(#shell)"/><rect x="77" y="96" width="91" height="59" rx="21" fill="url(#edge)"/><circle cx="122" cy="125" r="23" fill="url(#hub)"/><circle cx="122" cy="125" r="7" fill="#656c71"/>
<path d="M50 48h63q12 0 14-13h28q18 0 19 21v37q-3 13-18 14H65q-15 0-15-13Z" fill="url(#shell)"/><path d="M71 99h83q11-1 12-12V55" fill="none" stroke="#73797e" stroke-width="3"/><rect x="43" y="44" width="16" height="64" rx="4" fill="url(#edge)"/>
<path d="M45 57 13 61 2 72l43-6Z M45 92 13 88 2 77l43 6Z" fill="url(#edge)"/><path d="M50 46h7v59" stroke="#92989d" stroke-width="1"/>
</g>
<g><use href="#bolt" x="88" y="192"/><use href="#bolt" x="158" y="192"/><use href="#bolt" x="88" y="234"/><use href="#bolt" x="158" y="234"/><use href="#bolt" x="90" y="316"/><use href="#bolt" x="155" y="316"/><use href="#bolt" x="90" y="362"/><use href="#bolt" x="155" y="362"/><use href="#bolt" x="88" y="110"/><use href="#bolt" x="157" y="110"/><use href="#bolt" x="100" y="449"/><use href="#bolt" x="144" y="449"/></g>
<g fill="none" stroke="#2f3335" stroke-width="2" opacity=".8"><path d="M174 63q25 0 33 16t24 18M164 123q30 0 44 24t25 9M165 188q30 0 43 27t25 10M164 239q24 0 42 28t28 10M160 309q25 0 49 20t24 3M168 364q24 0 40 10t25 8M171 456q20-52 38-41t24 21"/></g>
</svg>
<!-- Coluna de Sliders + Console -->
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:16px">
<div class="card" style="padding:20px">
<h2 style="font-size:16px;margin-bottom:14px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
Controle de Canais
</h2>
<div id="sliders" class="sliders"></div>
<div style="display:flex;gap:16px;align-items:center;margin-top:18px;padding-top:14px;border-top:1px solid var(--borda)">
<div style="flex:1">
<label for="speed">Velocidade nominal (graus/s)</label>
<input id="speed" type="range" min="5" max="90" value="25">
</div>
<output id="speedValue" style="font-size:22px;font-weight:700;color:var(--ciano);font-family:'JetBrains Mono',monospace;min-width:36px">25</output>
</div>
<div id="console" role="status" aria-live="polite" style="margin-top:14px;padding:10px 14px;background:rgba(5,9,16,0.6);border:1px solid var(--borda);border-radius:var(--raio-sm);font-family:'JetBrains Mono',monospace;font-size:12.5px;color:var(--ciano)">Carregando configurações…</div>
</div>
</div>
<!-- Painel Direito: Ferramentas -->
<aside class="side right">
<div class="card" style="padding:18px">
<h2 style="font-size:16px;margin-bottom:14px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9z"/></svg>
Ferramentas
</h2>
<div style="display:flex;flex-direction:column;gap:8px">
<button id="export" style="width:100%">📤 Exportar JSON</button>
<button id="import" style="width:100%">📥 Importar JSON</button>
<button id="reset" style="width:100%">🎯 Centralizar na tela</button>
<input id="importFile" type="file" accept=".json,application/json">
</div>
</div>
</aside>
</div>
<!-- Calibração de Limites -->
<details style="margin-top:24px">
<summary>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Calibrar Limites por Canal
</summary>
<div class="card" style="margin-top:14px">
<p style="margin:0 0 14px">O projeto original usa 500–2500 µs para 0–180°. Isso não confirma o fim de curso do seu servo. Amplie gradualmente os limites com o eixo livre. Os limites desta aba são independentes dos testes individuais.</p>
<div id="calibration" class="calibration"></div>
<button id="saveCalibration" style="margin-top:16px">💾 Salvar calibração</button>
</div>
</details>
<!-- Movimentos Coordenados -->
<section style="margin-top:24px" class="card">
<h2 style="font-size:18px;margin-bottom:8px">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Movimentos Coordenados
</h2>
<p style="margin:0 0 14px">Prévia é uma simulação dos comandos, sem sensores de posição. Executar envia a sequência aos oito canais dentro dos limites salvos.</p>
<div id="movimentos" style="display:flex;gap:12px;flex-wrap:wrap"></div>
<div style="margin-top:16px;padding-top:14px;border-top:1px solid var(--borda);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px">
<p id="animationStatus" style="margin:0;font-size:13px;color:var(--fraco)">Representação esquemática · posição nominal</p>
<button id="stopPreview" style="font-size:13px">⛔ Parar prévia e sinais</button>
</div>
</section>
<script src="/braco-calibracao.js" defer></script>
<script src="/braco.js"></script>
<script src="/movimentos-core.js"></script>
<script src="/braco-movimentos.js"></script>
</body>
</html>
-49
View File
@@ -1,49 +0,0 @@
'use strict';
const $=id=>document.getElementById(id);
let config, target=Array(7).fill(90), current=target.slice(), enabled=false, dirty=false, inflight=null, needed=false, playIndex=-1, pauseUntil=0, lastTick=performance.now();
let sequence=null;
async function api(path,data){const r=await fetch('/braco/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const d=await r.json();if(!r.ok)throw Error(d.erro||'Falha');return d;}
function message(text){$('console').textContent=text;}
function pulseAngle(p,c){const center=c.centro??1500;return p<=center?90*(p-500)/(center-500):90+90*(p-center)/(2500-center);}
function bounds(i){const ch=config.articulacoes[i].canal,c=config.calibracao[ch];let lo=pulseAngle(c.minimo,c),hi=pulseAngle(c.maximo,c);if(ch===5){const pair=config.calibracao[6];lo=Math.max(lo,180-pulseAngle(pair.maximo,pair));hi=Math.min(hi,180-pulseAngle(pair.minimo,pair));}return [Math.ceil(lo),Math.floor(hi)];}
function controls(){ $('enable').disabled=!config?.disponivel||dirty; document.querySelectorAll('[data-execute]').forEach(b=>{b.disabled=!enabled||dirty;b.title=b.disabled?'Marque Ativar movimento e salve a calibração':'Executar nos oito canais';}); $('play').disabled=!enabled||!config.posturas.length; $('loadPose').disabled=!config.posturas.length; $('deletePose').disabled=!config.posturas.length; }
function render(){
$('sliders').replaceChildren();
config.articulacoes.forEach((a,i)=>{const [lo,hi]=bounds(i);target[i]=Math.max(lo,Math.min(hi,target[i]));const row=document.createElement('div');row.className='row';const wrap=document.createElement('div'),label=document.createElement('label'),range=document.createElement('input'),out=document.createElement('output');label.textContent=`${a.nome} · canal ${a.canal}${a.canal===5?' + 6 invertido':''} · limite ${lo}–${hi}°`;range.type='range';range.min=lo;range.max=hi;range.step=1;range.value=target[i];range.id='joint'+i;label.htmlFor=range.id;out.id='value'+i;out.textContent=String(Math.round(target[i])).padStart(3,'0');range.oninput=()=>{playIndex=-1;target[i]=Number(range.value);out.textContent=String(target[i]).padStart(3,'0');if(enabled)needed=true;else message('Posição escolhida na tela. Movimento desligado.');};wrap.append(label,range);row.append(wrap,out);$('sliders').append(row);});
$('calibration').replaceChildren();config.calibracao.forEach((c,i)=>{const card=document.createElement('article');card.textContent=`Canal ${i}`;for(const [key,name] of [['minimo','Mínimo (µs)'],['centro','Centro (µs)'],['maximo','Máximo (µs)']]){const label=document.createElement('label');label.textContent=name;const input=document.createElement('input');input.type='number';input.min=500;input.max=2500;input.step=10;input.value=c[key]??1500;input.oninput=()=>{c[key]=Number(input.value);dirty=true;stop().catch(e=>message(e.message));controls();message('Calibração alterada. Salve para habilitar movimento.');};label.append(input);card.append(label);}$('calibration').append(card);});
$('count').textContent=config.posturas.length+' posturas';$('poses').replaceChildren();config.posturas.forEach((pose,i)=>{const option=document.createElement('option');option.value=i;option.textContent=pose.nome;$('poses').append(option);});if(!config.posturas.length){const o=document.createElement('option');o.textContent='Sem posturas';$('poses').append(o);}controls();
}
function showTarget(){target.forEach((v,i)=>{$('joint'+i).value=v;$('value'+i).textContent=String(Math.round(v)).padStart(3,'0');});}
async function stop(){enabled=false;needed=false;playIndex=-1;$('enable').checked=false;controls();if(inflight)await inflight;await api('parar',{});message('Sinais desligados. Movimento desativado.');}
async function persist(){await stop();config=await api('config',{calibracao:config.calibracao,posturas:config.posturas});dirty=false;render();}
$('enable').onchange=()=>{if($('enable').checked){enabled=true;needed=false;current=target.slice();lastTick=performance.now();controls();message('Ativado. Arraste uma articulação para enviar a postura aos oito canais.');}else stop().catch(e=>message(e.message));};
$('stop').onclick=()=>stop().catch(e=>message(e.message));
$('speed').oninput=()=>$('speedValue').textContent=$('speed').value;
$('saveCalibration').onclick=async()=>{try{await persist();message('Calibração salva. Movimento desativado.');}catch(e){message(e.message);}};
$('savePose').onclick=async()=>{try{const name=prompt('Nome da postura:',`Postura ${config.posturas.length+1}`);if(!name)return;config.posturas.push({nome:name,angulos:target.slice()});await persist();message('Postura salva, sem movimento.');}catch(e){config=await api('status');render();message(e.message);}};
$('deletePose').onclick=async()=>{try{config.posturas.splice(Number($('poses').value),1);await persist();message('Postura excluída.');}catch(e){message(e.message);}};
$('loadPose').onclick=()=>{playIndex=-1;target=config.posturas[Number($('poses').value)].angulos.slice();showTarget();if(enabled)needed=true;message(enabled?'Movendo para a postura escolhida.':'Postura carregada apenas na tela.');};
$('play').onclick=()=>{if(!enabled||!config.posturas.length)return;sequence=config.posturas.map(p=>p.angulos.slice());playIndex=0;target=sequence[0].slice();needed=true;pauseUntil=0;showTarget();message('Reproduzindo posturas salvas uma vez.');};
$('reset').onclick=async()=>{try{await stop();target=Array(7).fill(90);current=target.slice();showTarget();message('Centro selecionado apenas na tela.');}catch(e){message(e.message);}};
$('export').onclick=()=>{const blob=new Blob([JSON.stringify({formato:'carinha-braco-v1',calibracao:config.calibracao,posturas:config.posturas},null,2)],{type:'application/json'});const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='braco-posturas.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
$('import').onclick=()=>$('importFile').click();
$('importFile').onchange=async()=>{try{const f=$('importFile').files[0];if(!f)return;if(f.size>65536)throw Error('Arquivo maior que 64 KB');const data=JSON.parse(await f.text());if(data.formato!=='carinha-braco-v1')throw Error('Use arquivo exportado por esta aba');await stop();config=await api('config',{calibracao:data.calibracao,posturas:data.posturas});dirty=false;target=Array(7).fill(90);render();message('Importado, sem movimento.');}catch(e){message(e.message);}finally{$('importFile').value='';}};
setInterval(()=>{
const now=performance.now(),dt=Math.min(.2,(now-lastTick)/1000);lastTick=now;
if(!enabled||dirty||inflight||!needed)return;
const step=Number($('speed').value)*dt;
const distance=Math.max(...current.map((v,i)=>Math.abs(target[i]-v)));
const fraction=distance?Math.min(1,step/distance):1;
current=current.map((v,i)=>v+(target[i]-v)*fraction);
const pose=current.map(v=>Math.round(v*100)/100);
inflight=api('mover',{angulos:pose}).then(()=>{
if(!enabled)return;
const reached=current.every((v,i)=>Math.abs(v-target[i])<.01);
if(reached&&playIndex>=0){if(!pauseUntil)pauseUntil=performance.now()+700;if(performance.now()>=pauseUntil){playIndex++;pauseUntil=0;if(playIndex>=sequence.length){playIndex=-1;needed=false;message('Sequência concluída. Sinais desligam após 2 s.');}else{target=sequence[playIndex].slice();showTarget();}}}
else if(reached){needed=false;message('Comando enviado. Sinais desligam após 2 s.');}
}).catch(async e=>{enabled=false;needed=false;playIndex=-1;$('enable').checked=false;controls();message(e.message);try{await api('parar',{});}catch{}}).finally(()=>{inflight=null;});
},100);
document.addEventListener('visibilitychange',()=>{if(document.hidden&&enabled)stop().catch(()=>{});});
api('status').then(data=>{config=data;render();$('connection').textContent=data.disponivel?'PCA9685 · conectado':'PCA9685 indisponível';message('Pronto. Movimento desativado. Calibre antes de ampliar o curso.');}).catch(e=>message(e.message));
window.BracoUI={calibration:()=>config?.calibracao,markCalibration(ch,key,us){config.calibracao[ch][key]=us;if(ch===5||ch===6){const other=11-ch,sum=(config.calibracao[5].centro??1500)+(config.calibracao[6].centro??1500);if(key!=='centro')config.calibracao[other][key==='minimo'?'maximo':'minimo']=sum-us;}dirty=true;render();message('Limite marcado. Clique Salvar calibração para gravar.');},canExecute:()=>enabled&&!dirty,ready:()=>!!config,bounds:()=>config.articulacoes.map((a,i)=>bounds(i)),pose:()=>enabled?current.slice():target.slice(),stop,
execute(steps){if(!enabled||dirty)throw Error('Ative o movimento e salve a calibração primeiro');sequence=steps.map(s=>s.slice());playIndex=0;target=sequence[0].slice();needed=true;pauseUntil=0;showTarget();message('Executando movimento sincronizado.');}};
+9 -3
View File
@@ -84,7 +84,7 @@ class Braco:
try:
for channel,pulse in pulses.items():
self.servos.hw.pulse(channel,pulse)
self.servos.active[channel]=prazo
self.servos.ligar([(channel,prazo) for channel in pulses])
except OSError:
self.alvo=None; self.servos.stop()
raise
@@ -102,7 +102,13 @@ class Braco:
self.escrever(pulses,prazo)
self.atual=[float(a) for a in pose]
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
return dict(ok=True,pulsos=pulses,movendo=True)
def cancelar(self):
@@ -142,7 +148,7 @@ class Braco:
self.servos.reservar('braco'); self.alvo=None; self.atual=None
for ch,us in outputs.items():
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)
def rotas(self,h,method):
route=h.path.split('?',1)[0]
+136 -10
View File
@@ -25,7 +25,27 @@
<path id="sobrancelha2" class="traco" style="display:none"/>
</svg>
<div id="fps"></div>
<script src="rosto-motor.js"></script>
<script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
(function () {
const token = new URLSearchParams(location.search).get('t');
window.carinhaToken = token || '';
if (!token || !window.fetch) return;
const original = window.fetch.bind(window);
window.fetch = (recurso, opcoes = {}) => {
try {
const url = new URL(typeof recurso === 'string' ? recurso : recurso.url, location.href);
if (url.origin === location.origin) {
const cabecalhos = new Headers(opcoes.headers || {});
cabecalhos.set('Authorization', 'Bearer ' + token);
opcoes = Object.assign({}, opcoes, { headers: cabecalhos });
}
} catch (e) { /* URL estranha: segue sem token */ }
return original(recurso, opcoes);
};
})();
"use strict";
/* -------------------------------------------------------------------------
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
@@ -84,10 +104,17 @@ const BRANCO = [245, 250, 255];
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = {
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
};
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
let FORMATO = "padrao";
let ROSTO_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME];
@@ -277,9 +304,17 @@ function sequencias() {
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
// Gestos com mãos: o motor desenha as mãos com a mesma duração (rosto-motor.js, DUR_MAOS).
const esconder = [[{ forma: "aberto", esc: 0.95, dy: 4 }, 0.3], [{}, 1.6], [{ forma: "feliz", boca: "sorriso", esc: 1.05, dy: 0 }, 0.3], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const espiar = [[{ esc: 0.96 }, 0.4], [{}, 0.6], [{ dx: -14, forma: "aberto", ab: 1.08 }, 0.25], [{}, 0.9], [{ dx: 0, ab: 1.0 }, 0.35], [{ forma: "feliz", boca: "sorriso" }, 0.4], [{ forma: "aberto" }, 0.3]];
const acenar = [[{ forma: "feliz", boca: "sorriso", esc: 1.05 }, 0.3], [{}, 1.7], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const pensar = [[{ dy: -10, dx: 14, boca: "neutra" }, 0.4], [{}, 2.0], [{ dy: 0, dx: 0 }, 0.4]];
const musica = [[{ forma: "feliz", boca: "sorriso", dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25],
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
return { piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
return { esconder, espiar, acenar, pensar, musica,
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre };
}
@@ -361,12 +396,28 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
for (let i = 0; i < 2; i++) {
const comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) {
window.MotorRosto.desenhar({
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null,
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
})),
});
} else if (window.MotorRosto) window.MotorRosto.esconder();
for (let i = 0; i < 2 && !comMotor; i++) {
const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6);
}
desenharBoca();
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
else desenharBoca();
for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
@@ -411,6 +462,9 @@ function trata(ordem) {
trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo"
} else if (ordem.indexOf("formato ") === 0) {
const f = ordem.slice(8).trim();
if (FORMATOS.includes(f)) FORMATO = f;
} else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) {
@@ -423,6 +477,10 @@ function trata(ordem) {
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
@@ -463,7 +521,7 @@ window.eilik = {
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
// do PC com um curl, igual ao FIFO de antes
async function buscarOrdens() {
if (new URLSearchParams(location.search).get('espelho') === '1') return;
if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
@@ -482,6 +540,7 @@ async function aplicarAparencia() {
if (!resp.ok) return;
const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ }
@@ -497,9 +556,28 @@ if (MODO_PAINEL) {
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
(function () {
let inicio = null;
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
addEventListener('pointerup', (e) => {
if (!inicio) return;
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
inicio = null;
if (r.dormindo) { trata('acordar'); return; }
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
if (dur < 350 && Math.hypot(dx, dy) < 15) {
animar('surpreso');
setTimeout(() => animar('feliz'), 700);
}
});
})();
// teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
@@ -832,8 +910,10 @@ async function sincronizarApp() {
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
sincronizando = true;
try {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json());
if (!window.carinhaSSE) {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json());
}
if (par.get('aparelho') === '1') {
const agora = performance.now() / 1000;
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
@@ -844,6 +924,7 @@ async function sincronizarApp() {
centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho,
bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo,
}) });
}
@@ -979,11 +1060,12 @@ setTimeout(relatar, 1500);
if(location.protocol==='file:')return;
let seen=null,fetching=false;
async function poll(){
if(fetching||window.carinhaAtiva===false)return;fetching=true;
if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
}catch(e){}finally{fetching=false;}
}
setInterval(poll,1000);poll();
@@ -991,11 +1073,55 @@ setTimeout(relatar, 1500);
</script>
<script src="/reacoes-render.js"></script>
</body>
</html>
<script>
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
}
/* Tempo real: um EventSource em /eventos substitui os pollings de /fila, /app/config,
/conversa/status e /treinamento/expressao. Se cair, os pollings voltam sozinhos
(window.carinhaSSE=false) até o EventSource reconectar. */
(function () {
const p = new URLSearchParams(location.search);
if (location.protocol === 'file:' || p.get('espelho') === '1' || !window.EventSource) return;
const aparelho = p.get('aparelho') === '1';
const t = window.carinhaToken;
const fonte = new EventSource('/eventos' + (t ? '?t=' + encodeURIComponent(t) : ''));
let ultimaConversa = null, ultimaReacao = null, primeira = true;
const ouvir = (tipo, fn) => fonte.addEventListener(tipo, (e) => { try { fn(JSON.parse(e.data)); } catch (err) {} });
fonte.addEventListener('pronto', () => {
// Ordens que chegaram antes da conexão ficam na fila: executa uma vez e passa ao SSE.
if (primeira) { primeira = false; buscarOrdens(); }
window.carinhaSSE = true;
// Foto dos ajustes a cada (re)conexão; depois só mudanças chegam por 'app_config'.
fetch('/app/config', { cache: 'no-store' }).then((r) => r.ok && r.json()).then((c) => c && configurarApp(c)).catch(() => {});
});
fonte.onerror = () => {
if (!window.carinhaSSE) return;
window.carinhaSSE = false;
// Volta ao polling sem repetir o que o SSE já entregou.
fetch('fila' + (aparelho ? '?aparelho=1' : ''), { cache: 'no-store' }).catch(() => {});
};
ouvir('expressao', (d) => trata(d.ordem));
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
ouvir('app_config', (c) => configurarApp(c));
ouvir('conversa', (s) => {
if (window.MotorRosto) {
if (s.ocupado) window.MotorRosto.faixa('digitando');
else window.MotorRosto.faixa(null, { so: 'digitando' });
}
const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id;
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
ouvir('reacao', (d) => {
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
ultimaReacao = d.id;
if (window.eilik && window.eilik.receita) window.eilik.receita(d.receita);
});
})();
</script>
</body>
</html>
+8 -2
View File
@@ -7,7 +7,7 @@ import time
from urllib.request import Request,urlopen
from comum import gravar_atomico, ler_json
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo')
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo','acenar','esconder','pensar')
EVENTOS=('som','movimento','saudacao','sozinho')
def validar_intencao(data):
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS:
@@ -18,6 +18,8 @@ class Cerebro:
def __init__(self,path,controle,enviar):
self.path=Path(path);self.controle=controle;self.enviar=enviar
self.endpoint=os.environ.get('CARINHA_IA_URL','http://127.0.0.1:8101')
# O llama-server do S10 fica na rede: com token, ele exige a mesma chave (--api-key).
self.chave=os.environ.get('CARINHA_IA_CHAVE') or os.environ.get('CARINHA_TOKEN','')
self.executor='S10' if '192.168.1.16' in self.endpoint else 'Orange Pi'
self.lock=threading.Lock();self.ativo=False;self.pending=None;self.busy=False
self.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
@@ -25,6 +27,10 @@ class Cerebro:
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
except (OSError,ValueError,KeyError,TypeError):pass
threading.Thread(target=self.loop,daemon=True).start()
def cabecalhos(self):
h={'Content-Type':'application/json'}
if self.chave:h['Authorization']='Bearer '+self.chave
return h
def config(self,ativo):
if type(ativo) is not bool:raise ValueError('ativo deve ser booleano')
with self.lock:
@@ -43,7 +49,7 @@ class Cerebro:
return dict(ok=True)
def decide(self,event):
payload=dict(model='local',messages=[dict(role='system',content='Você é Carinha, um pequeno robô curioso, gentil e expressivo, inspirado em WALL-E. Escolha uma expressão adequada ao evento. Varie os gestos recentes. Responda somente JSON.'),dict(role='user',content=json.dumps(dict(evento=event,recentes=self.history[-3:]),ensure_ascii=False)+' /no_think')],max_tokens=32,temperature=.7,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='intencao',strict=True,schema=dict(type='object',properties=dict(gesto=dict(type='string',enum=list(GESTOS))),required=['gesto'],additionalProperties=False))))
request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers={'Content-Type':'application/json'})
request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cabecalhos())
start=time.monotonic()
with urlopen(request,timeout=90) as response:data=json.load(response)
content=data['choices'][0]['message']['content']
+16 -66
View File
@@ -1,10 +1,8 @@
"""Configuração persistente, telemetria e prévia da câmera do celular."""
"""Configuração persistente e telemetria do celular; sinalização do vídeo WebRTC."""
import ipaddress
import json
import math
import os
from pathlib import Path
import subprocess
import threading
import time
from comum import origem_valida
@@ -33,8 +31,6 @@ LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600))
TOOLS = Path(os.environ.get('CARINHA_TOOLS',
'C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools'))
def validar(dados):
@@ -56,6 +52,16 @@ def validar(dados):
return dados
def ip_informado(dados):
"""IP que o próprio app informa (aparelho.ip); o endereço da conexão pode ser de um proxy."""
aparelho = dados.get('aparelho')
ip = aparelho.get('ip') if isinstance(aparelho, dict) else None
try:
return str(ipaddress.IPv4Address(ip)) if isinstance(ip, str) else None
except ValueError:
return None
class ControlePC:
def __init__(self, arquivo):
self.arquivo = Path(arquivo)
@@ -65,9 +71,6 @@ class ControlePC:
self.status_at = 0
self.celular_ip = None
self.recarga = {"id": None, "confirmada": False}
self.frame = b''
self.frame_at = 0
self.frame_seq = 0
self.rtc = Sinalizacao()
self.publicar = lambda tipo, dados: None
try:
@@ -85,8 +88,6 @@ class ControlePC:
self.config = novo
if not novo['preview']:
self.rtc.encerrar()
self.frame = b''
self.frame_at = 0
return novo.copy()
def resumo(self, host=''):
@@ -96,15 +97,13 @@ class ControlePC:
idade = agora - self.status_at
return dict(online=idade < 5, idade=round(idade, 1) if self.status_at else None,
celular_ip=self.celular_ip, servidor_ip=host, recarga=self.recarga.copy(),
estado=self.estado.copy(), preview=self.config['preview'],
camera_recente=bool(self.frame) and agora - self.frame_at < 3 and self.config['preview'],
frame_seq=self.frame_seq)
estado=self.estado.copy(), preview=self.config['preview'])
def rotas(self, h, metodo):
if self.rtc.atender(h, metodo, self.config['preview']):
return True
rota = h.path.split('?', 1)[0].rstrip('/')
if rota not in ('/app/config', '/app/padrao', '/app/status', '/camera/frame', '/app/connect', '/app/health'):
if rota not in ('/app/config', '/app/padrao', '/app/status', '/app/health'):
return False
def enviar(dados, codigo=200):
@@ -117,14 +116,13 @@ class ControlePC:
if rota == '/app/config' and h.headers.get('Content-Type', '').split(';')[0] != 'application/json':
raise ValueError('Use JSON')
tamanho = int(h.headers.get('Content-Length', '0'))
limite = 1_000_000 if rota == '/camera/frame' else 16384
if tamanho <= 0 or tamanho > limite:
if tamanho <= 0 or tamanho > 16384:
enviar({'erro': 'Corpo vazio ou grande demais'}, 413)
return True
corpo = h.rfile.read(tamanho)
if len(corpo) != tamanho:
raise ValueError('Corpo incompleto')
dados = None if rota == '/camera/frame' else json.loads(corpo)
dados = json.loads(corpo)
else:
dados = None
@@ -147,7 +145,7 @@ class ControlePC:
raise ValueError('Estado deve ser objeto JSON')
with self.lock:
self.estado = dados
self.celular_ip = h.client_address[0]
self.celular_ip = ip_informado(dados) or h.client_address[0]
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
self.recarga["confirmada"] = True
self.status_at = time.monotonic()
@@ -155,56 +153,8 @@ class ControlePC:
enviar({'ok': True})
else:
enviar(self.resumo(h.headers.get("Host", "").split(":")[0]))
elif rota == '/camera/frame':
with self.lock:
if metodo == 'POST':
if not self.config['preview']:
enviar({'erro': 'Transmissão desligada'}, 409)
elif h.headers.get('Content-Type', '').split(';')[0] != 'image/jpeg' or not corpo.startswith(b'\xff\xd8') or not corpo.endswith(b'\xff\xd9'):
enviar({'erro': 'Esperava uma imagem JPEG'}, 400)
else:
self.frame = corpo
self.frame_at = time.monotonic()
self.frame_seq += 1
enviar({'ok': True})
else:
recente = self.config['preview'] and self.frame and time.monotonic() - self.frame_at < 3
h.enviar(self.frame if recente else b'', 'image/jpeg', 200 if recente else 204)
elif rota == '/app/connect':
if metodo != 'POST':
enviar({'erro': 'Use POST'}, 405)
elif h.client_address[0] not in ('127.0.0.1', '::1'):
enviar({'erro': 'Abra o painel em localhost no PC para conectar'}, 403)
else:
self.conectar(h, dados, enviar)
except (ValueError, TypeError) as e:
enviar({'erro': str(e)}, 400)
except OSError as e:
enviar({'erro': str(e)}, 503)
return True
def conectar(self, h, dados, enviar):
ip = str(ipaddress.IPv4Address(dados.get('ip', '192.168.1.16')))
porta = dados.get('porta', 5555)
if isinstance(porta, bool) or not isinstance(porta, int) or not 1 <= porta <= 65535:
raise ValueError('Porta ADB inválida')
pc = str(ipaddress.IPv4Address(dados.get('pc', '192.168.1.15')))
adb = str(TOOLS / 'android-sdk/platform-tools/adb.exe')
serial = '%s:%s' % (ip, porta)
url = 'http://%s:%s' % (pc, h.server.server_port)
comandos = [[adb, 'connect', serial], [adb, '-s', serial, 'shell', 'input', 'keyevent', 'KEYCODE_WAKEUP'],
[adb, '-s', serial, 'shell', 'wm', 'dismiss-keyguard'],
[adb, '-s', serial, 'shell', 'am', 'start', '-n', 'br.carinha.rosto/.MainActivity', '-e', 'servidor', url]]
saidas = []
for cmd in comandos:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
except subprocess.TimeoutExpired:
enviar({'erro': 'ADB não respondeu. Confira a depuração Wi-Fi no celular.'}, 504)
return
saidas.append((r.stdout + r.stderr).strip())
if r.returncode or 'cannot connect' in r.stdout.lower() or 'failed' in r.stdout.lower() or 'error:' in r.stdout.lower():
enviar({'erro': '\n'.join(saidas)}, 502)
return
enviar({'ok': True, 'servidor': url, 'mensagem': 'App aberto. Aguarde a confirmação de conexão no painel.'})
-327
View File
@@ -1,327 +0,0 @@
/* ==========================================================================
CONVERSA COM O CARINHA · CHAT MODERNO IA
========================================================================== */
/* Workspace sticky de 2 colunas: rosto fixo + chat rolável */
.conversa-workspace {
display: grid;
grid-template-columns: 310px minmax(0, 1fr);
gap: 24px;
align-items: start;
margin-top: 8px;
}
.conversa-sticky-col {
position: sticky;
top: 84px;
z-index: 40;
display: flex;
flex-direction: column;
gap: 18px;
max-height: calc(100vh - 104px);
overflow-y: auto;
padding-right: 4px;
}
.conversa-sticky-col::-webkit-scrollbar {
width: 4px;
}
.conversa-sticky-col::-webkit-scrollbar-thumb {
background: rgba(92,214,255,0.3);
border-radius: 4px;
}
.conversa-chat-col {
min-width: 0;
display: flex;
flex-direction: column;
}
.btn-gesto-rapido {
flex: 1 1 calc(50% - 4px);
min-width: 0;
}
@media (max-width: 900px) {
.conversa-workspace {
grid-template-columns: 1fr;
}
.conversa-sticky-col {
position: static;
}
}
.chat-card {
margin: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.chat-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
border-bottom: 1px solid var(--borda);
padding-bottom: 14px;
}
.chat-box {
min-height: 380px;
max-height: 520px;
overflow-y: auto;
padding: 20px;
background: rgba(6, 12, 22, 0.75);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
display: flex;
flex-direction: column;
gap: 16px;
scroll-behavior: smooth;
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.4);
}
.chat-row {
display: flex;
gap: 12px;
max-width: 82%;
animation: chatMsgAnim 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes chatMsgAnim {
from { opacity: 0; transform: translateY(10px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.chat-row.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.chat-row.assistant {
align-self: flex-start;
}
.chat-avatar {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 18px;
flex-shrink: 0;
}
.chat-row.assistant .chat-avatar {
background: linear-gradient(135deg, #10263f 0%, #1e4b78 100%);
border: 1px solid var(--ciano-brilho, #5cd6ff);
box-shadow: 0 0 12px var(--ciano-glow);
}
.chat-row.user .chat-avatar {
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.chat-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-balloon {
padding: 12px 18px;
border-radius: 16px;
font-size: 14.5px;
line-height: 1.55;
word-break: break-word;
}
.chat-row.assistant .chat-balloon {
background: rgba(16, 28, 46, 0.9);
border: 1px solid rgba(92, 214, 255, 0.3);
color: #f1f7fd;
border-top-left-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
.chat-row.user .chat-balloon {
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
color: #ffffff;
border-top-right-radius: 4px;
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.35);
}
.chat-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.chat-gesto-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11.5px;
font-weight: 600;
padding: 3px 9px;
border-radius: 12px;
background: rgba(92, 214, 255, 0.12);
color: var(--ciano);
border: 1px solid rgba(92, 214, 255, 0.3);
cursor: pointer;
transition: all 0.16s ease;
}
.chat-gesto-badge:hover {
background: rgba(92, 214, 255, 0.25);
transform: scale(1.05);
}
.chat-speak-btn {
background: transparent;
border: none;
padding: 2px 6px;
font-size: 13px;
cursor: pointer;
opacity: 0.7;
color: var(--fraco);
box-shadow: none;
}
.chat-speak-btn:hover {
opacity: 1;
color: var(--ciano);
background: transparent;
transform: none;
}
/* Digitando / Pensando */
.chat-typing {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
background: rgba(16, 28, 46, 0.8);
border: 1px solid rgba(92, 214, 255, 0.2);
border-radius: 16px;
width: fit-content;
color: var(--ciano);
font-size: 13px;
font-style: italic;
}
.dot-pulse {
width: 6px;
height: 6px;
background: var(--ciano);
border-radius: 50%;
animation: dotPulseAnim 1.2s infinite ease-in-out;
}
.dot-pulse:nth-child(2) { animation-delay: 0.2s; }
.dot-pulse:nth-child(3) { animation-delay: 0.4s; }
@keyframes dotPulseAnim {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1.1); opacity: 1; }
}
/* Sugestões de Conversa */
.chat-suggestions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
}
.suggestion-chip {
padding: 6px 14px;
border-radius: 20px;
background: rgba(18, 34, 54, 0.6);
border: 1px solid var(--borda);
color: var(--texto-medio);
font-size: 12.5px;
cursor: pointer;
transition: all 0.16s ease;
}
.suggestion-chip:hover {
border-color: var(--ciano);
color: #ffffff;
background: rgba(30, 56, 88, 0.85);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(92, 214, 255, 0.2);
}
/* Barra de Entrada */
.chat-input-container {
display: flex;
gap: 10px;
align-items: center;
position: relative;
}
.chat-input-container input {
margin: 0 !important;
padding-right: 48px;
border-radius: var(--raio-md);
font-size: 15px;
}
.btn-mic {
width: 44px;
height: 44px;
padding: 0;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 18px;
flex-shrink: 0;
background: rgba(22, 38, 60, 0.8);
border: 1px solid var(--borda);
color: var(--texto-medio);
}
.btn-mic:hover {
border-color: var(--ciano);
color: var(--ciano);
}
.btn-mic.recording {
background: var(--vermelho);
border-color: var(--vermelho);
color: #ffffff;
animation: micPulse 1s infinite alternate;
box-shadow: 0 0 16px rgba(244, 63, 94, 0.6);
}
@keyframes micPulse {
from { transform: scale(1); }
to { transform: scale(1.08); }
}
/* Lembranças / Memória */
.memory-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: rgba(12, 22, 36, 0.6);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
margin-bottom: 8px;
transition: border-color 0.16s ease;
}
.memory-card:hover {
border-color: rgba(92, 214, 255, 0.4);
}
-164
View File
@@ -1,164 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Conversar com IA · Carinha</title>
<meta name="description" content="Converse por voz ou texto com o Carinha usando Google Gemini com memória de longo prazo.">
<link rel="stylesheet" href="/dashboard.css">
<link rel="stylesheet" href="/conversa.css">
<script src="/dashboard.js" defer></script>
</head>
<body>
<!-- Cabeçalho da Página -->
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
<div>
<h1>Conversar com o Carinha</h1>
<p style="margin:4px 0 0">Converse por voz ou texto. As expressões do rosto mudam em tempo real conforme a conversa.</p>
</div>
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin:0;cursor:pointer">
<input type="checkbox" id="conversaVozAuto" checked style="width:16px;height:16px">
<span>Falar respostas (TTS)</span>
</label>
<span class="badge ativo"><span class="badge-dot"></span> Gemini Online</span>
</div>
</div>
<!-- WORKSPACE STICKY: Rosto fica fixado enquanto a conversa rola -->
<div class="conversa-workspace">
<!-- Coluna Fixa (Sticky: Rosto + Status) -->
<aside class="conversa-sticky-col">
<!-- Preview do Rosto ao Vivo -->
<section class="card sticky-preview-card" style="margin-bottom:18px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
Rosto ao Vivo
</h2>
<span class="badge ativo" style="font-size:11px">📌 Sempre Visível</span>
</div>
<iframe id="conversaCarinhaPreview"
src="/carinha.html?espelho=1&audio=0"
title="Expressão atual do Carinha"
style="width:100%;height:220px;border-radius:var(--raio-md);border:1px solid var(--borda-brilho);background:#000;box-shadow:inset 0 0 30px rgba(0,0,0,0.8),0 0 20px var(--ciano-glow)">
</iframe>
<!-- Status da Expressão Atual -->
<div id="conversaExpressaoAtual" style="margin-top:10px;font-size:13px;color:var(--ciano);min-height:22px;text-align:center">
Aguardando conversa…
</div>
<!-- Motor de IA selecionado -->
<div style="margin-top:12px">
<label for="conversaProvedor" style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:var(--muito-fraco);margin-bottom:6px">Motor de IA</label>
<select id="conversaProvedor" style="width:100%;margin:0">
<option value="gemini">✨ Google Gemini · Nuvem</option>
<option value="local">📱 Qwen · Local no Celular</option>
</select>
</div>
</section>
<!-- Gestos Rápidos -->
<section class="card">
<h2 style="font-size:15px;margin-bottom:12px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
Gestos Rápidos
</h2>
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin:0">
<button type="button" class="btn-gesto-rapido" data-gesto="feliz" style="padding:7px 12px;font-size:13px">😊 Feliz</button>
<button type="button" class="btn-gesto-rapido" data-gesto="surpreso" style="padding:7px 12px;font-size:13px">😲 Surpreso</button>
<button type="button" class="btn-gesto-rapido" data-gesto="piscar" style="padding:7px 12px;font-size:13px">😉 Piscar</button>
<button type="button" class="btn-gesto-rapido" data-gesto="coracao" style="padding:7px 12px;font-size:13px">💖 Amor</button>
<button type="button" class="btn-gesto-rapido" data-gesto="dormir" style="padding:7px 12px;font-size:13px">🌙 Dormir</button>
<button type="button" class="btn-gesto-rapido" data-gesto="acordar" style="padding:7px 12px;font-size:13px">☀️ Acordar</button>
</div>
<p id="conversaGestoEstado" role="status" style="margin:10px 0 0;font-size:12.5px;color:var(--fraco);min-height:18px"></p>
</section>
</aside>
<!-- Coluna Rolável (Chat) -->
<main class="conversa-chat-col">
<section class="card chat-card" style="margin:0">
<div class="chat-header-bar">
<div style="display:flex;align-items:center;gap:12px">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span style="font-weight:700;font-size:16px">Histórico</span>
</div>
<p id="conversaEstado" role="status" style="margin:0;font-size:12.5px;color:var(--fraco)">Carregando conversa…</p>
</div>
<!-- Caixa de Histórico de Mensagens -->
<div id="conversaHistorico" class="chat-box" role="log" aria-label="Histórico da conversa"></div>
<!-- Indicador de Digitando / Pensando -->
<div id="conversaTyping" class="chat-typing" style="display:none">
<span>Carinha está pensando</span>
<span class="dot-pulse"></span>
<span class="dot-pulse"></span>
<span class="dot-pulse"></span>
</div>
<!-- Sugestões Rápidas de Diálogo -->
<div class="chat-suggestions">
<button type="button" class="suggestion-chip" data-msg="Oi, Carinha! Como você está?">👋 Como você está?</button>
<button type="button" class="suggestion-chip" data-msg="Me conte uma curiosidade sobre robôs!">🤖 Curiosidade robótica</button>
<button type="button" class="suggestion-chip" data-msg="Faça uma cara bem engraçada e feliz!">😄 Faça uma cara feliz</button>
<button type="button" class="suggestion-chip" data-msg="Você gosta de música? Qual é seu som favorito?">🎶 Gosta de música?</button>
</div>
<!-- Formulário de Envio de Mensagem com Microfone -->
<form id="conversaForm" style="margin:0">
<div class="chat-input-container">
<button id="conversaMicBtn" class="btn-mic" type="button" title="Falar no microfone (Reconhecimento de Voz)">🎤</button>
<input id="conversaTexto" maxlength="600" required placeholder="Digite uma mensagem ou clique no microfone para falar..." autocomplete="off">
<button id="conversaEnviar" class="primary" type="submit" style="padding:10px 22px;border-radius:var(--raio-md)">
<span>Enviar</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</form>
<!-- Gerenciador de Lembranças e Memória Persistente -->
<details style="margin-top:10px">
<summary>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Lembranças &amp; Memória de Longo Prazo
</summary>
<p style="margin:8px 0 12px">Fatos e preferências ensinados ao Carinha que enriquecem o prompt do Gemini sem alterar os pesos do modelo.</p>
<form id="memoriaForm" style="display:flex;gap:10px;align-items:center;margin-bottom:14px">
<input id="memoriaTexto" maxlength="300" required placeholder="Ex.: Meu nome é Jullio e prefiro explicações diretas" style="margin:0">
<button type="submit" style="white-space:nowrap">Salvar Memória</button>
</form>
<div id="conversaMemorias" style="display:flex;flex-direction:column;gap:8px"></div>
<div style="margin-top:16px;border-top:1px solid var(--borda);padding-top:12px;display:flex;justify-content:flex-end">
<button id="conversaLimpar" type="button" class="danger" style="font-size:12px">🗑️ Apagar histórico e memórias</button>
</div>
</details>
</section>
</main>
</div>
<script src="/painel-conversa.js"></script>
<script>
// Gestos rápidos no painel lateral
document.querySelectorAll('.btn-gesto-rapido').forEach(btn => {
btn.addEventListener('click', async () => {
const gesto = btn.dataset.gesto;
try {
const r = await fetch('/gesto/' + encodeURIComponent(gesto));
if (r.ok) {
const el = document.getElementById('conversaGestoEstado');
if (el) { el.textContent = 'Gesto: ' + gesto + ' ✓'; setTimeout(() => { el.textContent = ''; }, 2500); }
}
} catch (e) { console.log('gesto rápido:', e); }
});
});
</script>
</body>
</html>
+1 -1
View File
@@ -145,7 +145,7 @@ class Conversa:
messages=mensagens_modelo(history,self.memoria.listar())
schema=dict(type='object',properties=dict(texto=dict(type='string'),gesto=dict(type='string',enum=list(GESTOS))),required=['texto','gesto'],additionalProperties=False)
payload=dict(model='local',messages=messages,max_tokens=120,temperature=.2,repeat_penalty=1.1,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='conversa',strict=True,schema=schema)))
req=Request(self.cerebro.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers={'Content-Type':'application/json'})
req=Request(self.cerebro.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cerebro.cabecalhos())
with urlopen(req,timeout=90) as response:data=json.load(response)
result=validar_resposta(json.loads(data['choices'][0]['message']['content']))
with self.lock:
-1353
View File
File diff suppressed because it is too large Load Diff
-153
View File
@@ -1,153 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Visão Geral · Central Carinha IA</title>
<link rel="stylesheet" href="/dashboard.css">
<script src="/dashboard.js" defer></script>
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
<div>
<h1>Central do Carinha</h1>
<p style="margin:4px 0 0;font-size:15px">Supervisão em tempo real do Galaxy S10, telemetria de sensores, cérebro Gemini e robótica.</p>
</div>
<div style="display:flex;gap:10px;align-items:center">
<span class="badge ativo" id="overviewSystemBadge"><span class="badge-dot"></span> Sistema Ativo</span>
</div>
</div>
<div class="overview-grid">
<!-- Card do Rosto do Robô com Visualizador Interativo -->
<section class="card overview-face-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h2>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
Expressão do Personagem
</h2>
<span class="badge" style="font-size:11.5px">Prévia Sincronizada</span>
</div>
<iframe class="overview-face" id="overviewFace" title="Prévia do rosto do robô" src="/carinha.html?espelho=1&audio=0"></iframe>
<!-- Mood Switches Rápidos -->
<div style="margin:8px 0 16px">
<label style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:var(--muito-fraco);margin-bottom:8px;display:block">Expressão Rápida</label>
<div class="toolbar" style="margin:0;gap:8px">
<button type="button" class="btn-mood" data-gesto="feliz" style="padding:7px 12px;font-size:13px">😊 Feliz</button>
<button type="button" class="btn-mood" data-gesto="surpreso" style="padding:7px 12px;font-size:13px">😲 Surpreso</button>
<button type="button" class="btn-mood" data-gesto="coracao" style="padding:7px 12px;font-size:13px">💖 Amor</button>
<button type="button" class="btn-mood" data-gesto="piscar" style="padding:7px 12px;font-size:13px">😉 Piscar</button>
<button type="button" class="btn-mood" data-gesto="dormir" style="padding:7px 12px;font-size:13px">🌙 Dormir</button>
<button type="button" class="btn-mood" data-gesto="acordar" style="padding:7px 12px;font-size:13px;border-color:var(--ciano)">☀️ Acordar</button>
</div>
</div>
<div class="actions" style="margin-top:auto;border-top:1px solid var(--borda);padding-top:16px">
<a href="/painel" class="primary" style="display:inline-flex;align-items:center;gap:6px;padding:9px 16px;border-radius:var(--raio-sm);font-weight:700">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
Abrir Câmera & Rosto
</a>
<button id="overviewReload" type="button">🔄 Recarregar Tela Celular</button>
<button id="overviewSfxHappy" type="button" title="Testar som procedural alegre">🎵 Tocar Bip</button>
</div>
<p id="overviewMessage" role="status" style="margin-top:10px;font-size:13px;min-height:20px;color:var(--ciano)"></p>
</section>
<!-- Grade de Métricas & Telemetria -->
<div style="display:flex;flex-direction:column;gap:18px">
<div class="metrics">
<section class="card metric">
<div style="display:flex;justify-content:space-between;align-items:center">
<p>Galaxy S10</p>
<span style="font-size:18px">📱</span>
</div>
<strong id="overviewPhone">—</strong>
<small id="overviewIP">Aguardando conexão</small>
</section>
<section class="card metric">
<div style="display:flex;justify-content:space-between;align-items:center">
<p>Bateria</p>
<span style="font-size:18px">🔋</span>
</div>
<strong id="overviewBattery">—</strong>
<small id="overviewCharge">Aguardando dados</small>
</section>
<section class="card metric">
<div style="display:flex;justify-content:space-between;align-items:center">
<p>Inteligência IA</p>
<span style="font-size:18px">🧠</span>
</div>
<strong id="overviewAI" style="font-size:22px">—</strong>
<small id="overviewModel">Verificando API</small>
</section>
<section class="card metric">
<div style="display:flex;justify-content:space-between;align-items:center">
<p>Expressões</p>
<span style="font-size:18px">🎨</span>
</div>
<strong id="overviewReactions">—</strong>
<small>Reações na biblioteca</small>
</section>
</div>
<!-- Feed de Atividades Recentes -->
<section class="card" style="flex:1">
<h2>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Telemetria & Eventos
</h2>
<div id="overviewFeed" style="display:flex;flex-direction:column;gap:10px;margin-top:12px;font-size:13px;max-height:165px;overflow-y:auto">
<div style="display:flex;align-items:center;gap:10px;color:var(--fraco)">
<span class="badge-dot" style="background:var(--ciano)"></span>
<span>Central inicializada no Orange Pi Zero 3</span>
<span style="margin-left:auto;font-size:11px;font-family:monospace">agora</span>
</div>
</div>
</section>
</div>
</div>
<!-- Links Rápidos de Acesso com Visual Moderno -->
<h2 style="margin:36px 0 16px">Navegação Rápida</h2>
<div class="quick-links">
<a class="card" href="/conversa">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2>💬 Conversar</h2>
<span class="badge" style="font-size:11px">Voz & Texto</span>
</div>
<p>Interaja por voz e texto com o Carinha usando Gemini e memória de longo prazo.</p>
</a>
<a class="card" href="/emojis">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2>✨ Emojis & Reações</h2>
<span class="badge" style="font-size:11px">Estúdio SVG</span>
</div>
<p>Desenhe novas reações com inteligência artificial e teste diretamente nos olhos do robô.</p>
</a>
<a class="card" href="/braco">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2>🦾 Braço Robótico</h2>
<span class="badge" style="font-size:11px">Cinemática</span>
</div>
<p>Controle ângulos, crie coreografias articuladas e coordene 8 canais de movimento.</p>
</a>
<a class="card" href="/treinamento">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2>📚 Treinamento</h2>
<span class="badge" style="font-size:11px">Personalidade</span>
</div>
<p>Defina rotinas, hábitos, prompts e consulte o diário de interações do robô.</p>
</a>
</div>
<script src="/overview.js" defer></script>
</body>
</html>
-459
View File
@@ -1,459 +0,0 @@
/**
* dashboard.js — Shell global da Central de Controle do Carinha
* Injeta barra lateral unificada, telemetria em tempo real no topo,
* controle de áudio robótico e dock de comandos rápidos.
*/
(() => {
if (window.location.search.includes('clean=1')) {
document.documentElement.classList.add('is-clean-embed');
return;
}
// Carrega dinamicamente o sintetizador de áudio caso não esteja presente
if (!window.RobotAudio) {
const audioScript = document.createElement('script');
audioScript.src = '/audio-robot.js';
audioScript.defer = true;
document.head.appendChild(audioScript);
}
const sections = [
{
title: 'Principal',
items: [
['/dashboard', 'Visão geral', 'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z'],
['/painel', 'Câmera e rosto', 'M3 6h12v12H3z M15 10l6-4v12l-6-4']
]
},
{
title: 'Inteligência & IA',
items: [
['/conversa', 'Conversar com IA', 'M3 4h18v13H9l-6 4z'],
['/emojis', 'Emojis & Reações', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18 M8 9h1 M15 9h1 M7 14q5 5 10 0'],
['/treinamento', 'Treinamento', 'M3 8l9-5 9 5-9 5z M6 11v6q6 5 12 0v-6']
]
},
{
title: 'Robótica & Hardware',
items: [
['/braco', 'Braço robótico', 'M3 21h18 M8 21v-5l7-5-3-6 M9 4h6v4H9z M14 10h4v4h-4z'],
['/servos', 'Servos PCA9685', 'M5 3v18 M12 3v18 M19 3v18 M2 8h6 M9 16h6 M16 7h6']
]
},
{
title: 'Configurações',
items: [
['/api', 'API Gemini & Modelo', 'M8 5L2 12l6 7 M16 5l6 7-6 7 M14 3l-4 18']
]
}
];
const pathname = location.pathname === '/' ? '/dashboard' : location.pathname;
document.body.classList.add('dashboard');
const frame = document.createElement('div');
frame.className = 'app-frame';
const main = document.createElement('main');
main.className = 'app-content';
main.id = 'appContent';
// Move elementos existentes do body para o container principal
for (const child of [...document.body.childNodes]) {
if (!(child.nodeType === 1 && ['SCRIPT', 'STYLE', 'LINK'].includes(child.tagName))) {
main.append(child);
}
}
// Sidebar moderna e estilizada
const sidebar = document.createElement('aside');
sidebar.className = 'app-sidebar';
sidebar.innerHTML = `
<a href="/dashboard" class="brand" title="Central Carinha">
<span class="brand-face" aria-hidden="true"><i></i><i></i></span>
<span>Carinha <span style="font-size:12px;color:var(--ciano);font-weight:600;letter-spacing:0.05em;vertical-align:middle;background:rgba(92,214,255,0.12);padding:2px 6px;border-radius:6px;border:1px solid rgba(92,214,255,0.25)">IA</span></span>
</a>
<nav aria-label="Navegação principal" id="sidebarNav"></nav>
<div class="sidebar-footer">
<div><strong>Orange Pi Zero 3</strong></div>
<div style="font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--muito-fraco)" id="shellServer">${location.host}</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:6px">
<span class="badge-dot" style="background:#10b981;color:#10b981"></span>
<span style="font-size:11px;color:var(--fraco)">Serviço operacional</span>
</div>
</div>
`;
const nav = sidebar.querySelector('#sidebarNav');
for (const sec of sections) {
const title = document.createElement('div');
title.className = 'nav-section-title';
title.textContent = sec.title;
nav.append(title);
for (const [url, label, path] of sec.items) {
const a = document.createElement('a');
a.href = url;
a.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${path}"/></svg><span>${label}</span>`;
if (url === pathname) {
a.setAttribute('aria-current', 'page');
}
a.addEventListener('click', () => {
if (window.RobotAudio) window.RobotAudio.click();
});
nav.append(a);
}
}
// Barra de ferramentas superior (Topbar)
const top = document.createElement('header');
top.className = 'app-topbar';
top.innerHTML = `
<div class="topbar-left">
<button class="menu-button" type="button" aria-label="Abrir menu" aria-expanded="false">☰</button>
<span class="server-label" style="font-size:14px;font-weight:600;color:var(--texto);display:flex;align-items:center;gap:8px">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--ciano);box-shadow:0 0 8px var(--ciano)"></span>
Central de Controle
</span>
</div>
<div class="topbar-telemetry">
<button id="pipToggleBtn" type="button" title="Mini-monitor flutuante (Picture-in-Picture) que fica fixo em qualquer página" style="padding:6px 12px;font-size:12.5px;border-radius:20px;border-color:var(--borda)">
📺 Monitor PiP
</button>
<button id="quickRemoteBtn" type="button" title="Atalhos rápidos de expressão" style="padding:6px 12px;font-size:12.5px;border-radius:20px;border-color:var(--borda)">
⚡ Gestos
</button>
<button id="sfxToggleBtn" type="button" title="Ativar/Desativar efeitos sonoros robóticos" style="padding:6px 12px;font-size:12.5px;border-radius:20px">
🔊 Som: Ativo
</button>
<span class="badge" id="shellConnection">
<span class="badge-dot"></span>
<span id="shellConnectionText">Verificando celular…</span>
</span>
</div>
`;
// Mini-Monitor Flutuante (Picture-in-Picture)
const pipWidget = document.createElement('div');
pipWidget.id = 'floatingPipWidget';
pipWidget.className = 'floating-pip-widget';
pipWidget.style.display = 'none';
pipWidget.innerHTML = `
<div class="floating-pip-header">
<div class="pip-title">
<span class="badge-dot" style="background:var(--ciano)"></span>
<span>Carinha Monitor</span>
</div>
<div class="floating-pip-actions">
<button id="pipMinimizeBtn" type="button" title="Minimizar ou Expandir">_</button>
<button id="pipCloseBtn" type="button" title="Fechar">✕</button>
</div>
</div>
<div class="floating-pip-body" id="pipBody">
<iframe src="/carinha.html?espelho=1&audio=0" title="Monitor Flutuante do Carinha"></iframe>
</div>
`;
document.body.appendChild(pipWidget);
const pipToggleBtn = top.querySelector('#pipToggleBtn');
const pipMinBtn = pipWidget.querySelector('#pipMinimizeBtn');
const pipCloseBtn = pipWidget.querySelector('#pipCloseBtn');
function togglePip(force) {
const isVisible = pipWidget.style.display === 'flex';
const next = force !== undefined ? force : !isVisible;
pipWidget.style.display = next ? 'flex' : 'none';
pipToggleBtn.style.color = next ? 'var(--ciano)' : 'var(--texto)';
pipToggleBtn.style.borderColor = next ? 'var(--ciano)' : 'var(--borda)';
localStorage.setItem('carinha_pip', next ? 'true' : 'false');
if (next && window.RobotAudio) window.RobotAudio.click();
}
pipToggleBtn.onclick = () => togglePip();
pipCloseBtn.onclick = () => togglePip(false);
pipMinBtn.onclick = () => {
const isMin = pipWidget.classList.toggle('minimized');
pipMinBtn.textContent = isMin ? '□' : '_';
};
// Se o usuário já deixou o PiP ativo anteriormente
if (localStorage.getItem('carinha_pip') === 'true') {
pipWidget.style.display = 'flex';
pipToggleBtn.style.color = 'var(--ciano)';
pipToggleBtn.style.borderColor = 'var(--ciano)';
}
// Menu móvel
const menuBtn = top.querySelector('.menu-button');
menuBtn.onclick = () => {
const open = document.body.classList.toggle('menu-open');
menuBtn.setAttribute('aria-expanded', String(open));
};
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
document.body.classList.remove('menu-open');
menuBtn.setAttribute('aria-expanded', 'false');
fecharQuickRemote();
}
});
main.addEventListener('click', () => {
document.body.classList.remove('menu-open');
menuBtn.setAttribute('aria-expanded', 'false');
});
// Modal / Dropdown de Gestos Rápidos
const quickRemote = document.createElement('div');
quickRemote.id = 'quickRemoteBar';
quickRemote.style.cssText = `
position: fixed; top: 72px; right: 28px; z-index: 90;
background: rgba(14, 24, 38, 0.95); backdrop-filter: blur(16px);
border: 1px solid var(--borda-brilho); border-radius: 14px;
padding: 16px; box-shadow: var(--sombra); display: none;
flex-direction: column; gap: 10px; width: 290px;
`;
quickRemote.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--borda);padding-bottom:8px">
<strong style="font-size:14px;color:var(--texto)">⚡ Disparo Rápido</strong>
<button id="closeQuickRemote" type="button" style="padding:3px 8px;font-size:12px;border:none;background:transparent;color:var(--fraco)">✕</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:4px">
<button data-gesto="feliz" style="padding:8px;font-size:12px">😊 Feliz</button>
<button data-gesto="surpreso" style="padding:8px;font-size:12px">😲 Surpreso</button>
<button data-gesto="coracao" style="padding:8px;font-size:12px">❤️ Coração</button>
<button data-gesto="bravo" style="padding:8px;font-size:12px">😠 Bravo</button>
<button data-gesto="piscar" style="padding:8px;font-size:12px">😉 Piscar</button>
<button data-gesto="piscadinha" style="padding:8px;font-size:12px">✨ Piscar 2</button>
<button data-gesto="acordar" style="padding:8px;font-size:12px" class="primary">☀️ Acordar</button>
<button data-gesto="dormir" style="padding:8px;font-size:12px">🌙 Dormir</button>
</div>
<button data-gesto="parar" class="danger" style="width:100%;font-size:12px;padding:8px;margin-top:4px">🛑 Parar Tudo (E-Stop)</button>
`;
document.body.appendChild(quickRemote);
function toggleQuickRemote() {
const isVisible = quickRemote.style.display === 'flex';
quickRemote.style.display = isVisible ? 'none' : 'flex';
if (!isVisible && window.RobotAudio) window.RobotAudio.click();
}
function fecharQuickRemote() {
quickRemote.style.display = 'none';
}
top.querySelector('#quickRemoteBtn').onclick = toggleQuickRemote;
quickRemote.querySelector('#closeQuickRemote').onclick = fecharQuickRemote;
quickRemote.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-gesto]');
if (!btn) return;
const gesto = btn.getAttribute('data-gesto');
try {
if (window.RobotAudio) window.RobotAudio.tocarGesto(gesto);
await fetch('/gesto/' + encodeURIComponent(gesto));
btn.style.transform = 'scale(0.95)';
setTimeout(() => btn.style.transform = '', 150);
} catch (err) {
console.error('Falha ao enviar gesto:', err);
}
});
// Botão de Áudio
const sfxBtn = top.querySelector('#sfxToggleBtn');
function atualizarBotaoSfx() {
if (!window.RobotAudio) return;
sfxBtn.textContent = window.RobotAudio.enabled ? '🔊 Som: Ativo' : '🔇 Som: Mudo';
sfxBtn.style.color = window.RobotAudio.enabled ? 'var(--ciano)' : 'var(--fraco)';
}
sfxBtn.onclick = () => {
if (window.RobotAudio) {
window.RobotAudio.toggle();
atualizarBotaoSfx();
}
};
setTimeout(atualizarBotaoSfx, 400);
frame.append(top, main);
document.body.prepend(sidebar, frame);
// Monitoramento periódico de status do celular e servidor
async function checkStatus() {
try {
const r = await fetch('/app/status', { cache: 'no-store', signal: AbortSignal.timeout(3000) });
if (!r.ok) throw Error();
const s = await r.json();
const badge = document.getElementById('shellConnection');
const text = document.getElementById('shellConnectionText') || badge;
const bat = s.estado?.bateria?.nivel;
const carregando = s.estado?.bateria?.carregando;
let batInfo = '';
if (bat != null) {
batInfo = ` · ${bat}%${carregando ? ' ⚡' : ''}`;
}
if (s.online) {
text.textContent = `S10 Online${batInfo}`;
badge.className = 'badge ativo';
} else {
text.textContent = 'S10 Desconectado';
badge.className = 'badge offline';
}
} catch (e) {
const badge = document.getElementById('shellConnection');
if (badge) {
badge.className = 'badge danger';
const text = document.getElementById('shellConnectionText') || badge;
text.textContent = 'Servidor Offline';
}
}
}
checkStatus();
setInterval(checkStatus, 4000);
// =========================================================================
// BARRA DE MONITORAMENTO NO TOPO — câmera e rosto sempre visíveis
// Com botão de expandir/recolher cada painel individualmente
// =========================================================================
const watchBar = document.createElement('div');
watchBar.id = 'watchBar';
watchBar.className = 'watch-bar';
// Lê estado salvo (expandido ou não) e tamanho
const watchState = (() => {
try { return JSON.parse(localStorage.getItem('carinha_watch') || '{}'); }
catch { return {}; }
})();
function saveWatchState() {
localStorage.setItem('carinha_watch', JSON.stringify(watchState));
}
// Altura dos painéis: compacto ou expandido
const HEIGHTS = { compact: 90, medium: 200, full: 340 };
function makePanelHTML(id, icon, label, src) {
const h = watchState[id + '_size'] || 'compact';
const hidden = watchState[id + '_hidden'] === true;
return `
<div class="watch-panel ${hidden ? 'watch-panel-hidden' : ''}" id="wp_${id}" data-id="${id}" data-size="${h}">
<div class="watch-panel-header">
<span class="watch-panel-label">${icon} ${label}</span>
<div class="watch-panel-actions">
<button class="wp-btn wp-shrink" title="Recolher" data-id="${id}">−</button>
<button class="wp-btn wp-expand" title="Expandir" data-id="${id}">+</button>
<button class="wp-btn wp-hide" title="Ocultar painel" data-id="${id}">✕</button>
</div>
</div>
<div class="watch-panel-body" style="height:${HEIGHTS[h]}px">
<iframe src="${src}" title="${label}" loading="lazy" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
</div>
`;
}
watchBar.innerHTML = `
<div class="watch-bar-inner">
<div class="watch-bar-grip" title="Arrastar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/></svg>
<span style="font-size:11px;font-weight:700;letter-spacing:0.06em;color:var(--ciano)">MONITOR</span>
</div>
<div class="watch-panels" id="watchPanels">
${makePanelHTML('camera', '📷', 'Câmera ao Vivo', '/painel?clean=1')}
${makePanelHTML('rosto', '🤖', 'Rosto do Carinha', '/carinha.html?espelho=1&audio=0&clean=1')}
</div>
<div class="watch-bar-tools">
<button id="watchAddCamera" class="wp-tool-btn" title="Mostrar/ocultar Câmera">📷</button>
<button id="watchAddRosto" class="wp-tool-btn" title="Mostrar/ocultar Rosto">🤖</button>
<button id="watchBarToggle" class="wp-tool-btn" title="Ocultar barra inteira" style="margin-top:auto">☰</button>
</div>
</div>
`;
document.body.appendChild(watchBar);
// Aplica estado inicial de visibilidade dos painéis
['camera', 'rosto'].forEach(id => {
const panel = watchBar.querySelector('#wp_' + id);
if (!panel) return;
if (watchState[id + '_hidden'] === true) panel.classList.add('watch-panel-hidden');
});
// Oculta a barra inteira se o usuário fechou antes
if (watchState['bar_hidden'] === true) {
watchBar.classList.add('watch-bar-collapsed');
}
// Toggle da barra inteira
watchBar.querySelector('#watchBarToggle').addEventListener('click', () => {
const collapsed = watchBar.classList.toggle('watch-bar-collapsed');
watchState['bar_hidden'] = collapsed;
saveWatchState();
if (window.RobotAudio) window.RobotAudio.click();
});
// Toggle de visibilidade dos painéis individuais
watchBar.querySelector('#watchAddCamera').addEventListener('click', () => {
const panel = watchBar.querySelector('#wp_camera');
const hidden = panel.classList.toggle('watch-panel-hidden');
watchState['camera_hidden'] = hidden;
saveWatchState();
});
watchBar.querySelector('#watchAddRosto').addEventListener('click', () => {
const panel = watchBar.querySelector('#wp_rosto');
const hidden = panel.classList.toggle('watch-panel-hidden');
watchState['rosto_hidden'] = hidden;
saveWatchState();
});
// Expandir / Recolher tamanho dos painéis
watchBar.addEventListener('click', e => {
const btn = e.target.closest('.wp-expand, .wp-shrink, .wp-hide');
if (!btn) return;
const id = btn.dataset.id;
const panel = watchBar.querySelector('#wp_' + id);
const body = panel.querySelector('.watch-panel-body');
const sizes = Object.keys(HEIGHTS);
if (btn.classList.contains('wp-expand')) {
const cur = sizes.indexOf(panel.dataset.size);
const next = sizes[Math.min(cur + 1, sizes.length - 1)];
panel.dataset.size = next;
body.style.height = HEIGHTS[next] + 'px';
watchState[id + '_size'] = next;
} else if (btn.classList.contains('wp-shrink')) {
const cur = sizes.indexOf(panel.dataset.size);
const next = sizes[Math.max(cur - 1, 0)];
panel.dataset.size = next;
body.style.height = HEIGHTS[next] + 'px';
watchState[id + '_size'] = next;
} else if (btn.classList.contains('wp-hide')) {
const hidden = panel.classList.toggle('watch-panel-hidden');
watchState[id + '_hidden'] = hidden;
}
saveWatchState();
if (window.RobotAudio) window.RobotAudio.click();
});
// Ajusta padding do conteúdo principal para não sobrepor a watch-bar
const appContent = document.getElementById('appContent');
function syncWatchBarOffset() {
const collapsed = watchBar.classList.contains('watch-bar-collapsed');
const h = collapsed ? 0 : watchBar.offsetHeight;
if (appContent) appContent.style.paddingTop = h > 0 ? (h + 12) + 'px' : '';
document.body.classList.toggle('watch-bar-active', !collapsed);
}
const ro = new ResizeObserver(syncWatchBarOffset);
ro.observe(watchBar);
syncWatchBarOffset();
// Também atualiza o offset ao colapsar/expandir a barra
const origToggle = watchBar.querySelector('#watchBarToggle');
if (origToggle) {
const prevHandler = origToggle.onclick;
origToggle.addEventListener('click', () => {
setTimeout(syncWatchBarOffset, 320); // após animação
});
}
})();
+107
View File
@@ -0,0 +1,107 @@
<#
.SYNOPSIS
Publica o Carinha no Orange Pi: compila o painel, empacota o código, faz backup na placa,
instala e reinicia o serviço. Configurações e bancos da placa nunca são sobrescritos.
.EXAMPLE
.\deploy.ps1 # compila, publica em root@192.168.1.35:/opt/carinha-pc e reinicia
.\deploy.ps1 -SoPacote # só gera o pacote local (nada vai para a placa)
.\deploy.ps1 -Placa 192.168.1.40 -Usuario carinha
.\deploy.ps1 -Ssh orangepi # usa um Host do ~/.ssh/config (chave própria)
#>
param(
[string]$Placa = '192.168.1.35',
[string]$Usuario = 'root',
[string]$Destino = '/opt/carinha-pc',
[int]$Porta = 8100,
[string]$Ssh = '',
[switch]$SemCompilar,
[switch]$SoPacote
)
# Comandos nativos (npm, tar, scp, ssh) escrevem avisos no stderr; o PowerShell 5.1 trataria isso
# como erro fatal. Por isso o sucesso de cada um é decidido pelo $LASTEXITCODE.
$ErrorActionPreference = 'Continue'
Set-Location $PSScriptRoot -ErrorAction Stop
# Código que vai para a placa. Ficam de fora: configurações (*.json da placa), bancos (*.db),
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
$arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py',
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'version.json',
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
)
if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' }
# Telas antigas (até 0.7) que ainda podem estar na placa; vão para o backup e saem da pasta.
$legado = 'dashboard.html dashboard.css dashboard.js overview.js painel.html painel.css painel-controle.js ' +
'painel-video.js painel-rastreamento.js painel-conversa.js conversa.html conversa.css emojis.html biblioteca.js ' +
'treinamento.html treinamento.js treinamento.css braco.html braco.css braco.js braco-calibracao.js ' +
'braco-movimentos.js movimentos-core.js servos.html servos.css servos.js api.html api-painel.js ' +
'rastreamento-core.js audio-robot.js'
if (-not $SemCompilar) {
Write-Host '== Compilando o painel (web/)'
Push-Location web
try {
if (-not (Test-Path node_modules)) { npm ci; if ($LASTEXITCODE) { throw 'npm ci falhou' } }
npm run check; if ($LASTEXITCODE) { throw 'svelte-check encontrou erros' }
npm test; if ($LASTEXITCODE) { throw 'testes do painel falharam' }
npm run build; if ($LASTEXITCODE) { throw 'build do painel falhou' }
} finally { Pop-Location }
}
if (-not (Test-Path 'web/dist/index.html')) { throw 'web/dist não existe: rode sem -SemCompilar' }
$versao = (Get-Content version.json -Raw -ErrorAction Stop | ConvertFrom-Json).versao
$carimbo = Get-Date -Format 'yyyyMMdd-HHmmss'
$pacote = Join-Path $env:TEMP "carinha-$versao-$carimbo.tar.gz"
Write-Host "== Empacotando $versao em $pacote"
$faltando = $arquivos | Where-Object { -not (Test-Path $_) }
if ($faltando) { throw "Arquivos ausentes: $($faltando -join ', ')" }
& tar -czf $pacote @arquivos web/dist
if ($LASTEXITCODE) { throw 'tar falhou' }
$hash = (Get-FileHash $pacote -Algorithm SHA256 -ErrorAction Stop).Hash.ToLower()
Write-Host " SHA256 $hash"
if ($SoPacote) {
& tar -tzf $pacote | Measure-Object | ForEach-Object { Write-Host " $($_.Count) arquivos no pacote" }
Write-Host 'Pacote pronto (nada enviado à placa).'
return
}
$alvo = if ($Ssh) { $Ssh } else { "$Usuario@$Placa" }
$remoto = "/tmp/carinha-$versao-$carimbo.tar.gz"
Write-Host "== Enviando para $alvo"
& scp -q $pacote "${alvo}:$remoto"
if ($LASTEXITCODE) { throw 'scp falhou' }
# Na placa: backup do código atual, remove telas antigas, extrai o novo e reinicia.
$script = @"
set -e
cd '$Destino'
backup='$Destino/backup-$carimbo'
mkdir -p "`$backup"
for f in $($arquivos -join ' ') $legado; do [ -e "`$f" ] && cp -a --parents "`$f" "`$backup/" || true; done
[ -d web/dist ] && mkdir -p "`$backup/web" && cp -a web/dist "`$backup/web/" || true
echo "$hash $remoto" | sha256sum -c --quiet
rm -f $legado
rm -rf web/dist
tar -xzf '$remoto' -C '$Destino'
rm -f '$remoto'
systemctl restart carinha-pc
echo "backup em `$backup"
"@
Write-Host '== Instalando na placa'
$script -replace "`r", '' | & ssh $alvo 'bash -s'
if ($LASTEXITCODE) { throw 'instalação na placa falhou (o backup foi feito antes de qualquer remoção)' }
Write-Host '== Conferindo'
$url = "http://${Placa}:$Porta"
for ($i = 0; $i -lt 10; $i++) {
try {
$saude = Invoke-RestMethod "$url/app/health" -TimeoutSec 3
if ($saude.versao -eq $versao) { Write-Host "OK: $url/ responde versão $versao"; return }
} catch { }
Start-Sleep -Seconds 1
}
throw "O serviço não respondeu a versão $versao em $url. Veja: ssh $alvo journalctl -u carinha-pc -n 50"
+2
View File
@@ -1,3 +1,5 @@
> **Histórico.** Este dashboard foi substituído pelo painel 0.8 em Svelte: veja [PAINEL.md](PAINEL.md).
# Dashboard Carinha — 05/10/2026
Objetivo: unificar todas as páginas de operação em tema escuro/ciano com navegação lateral. A tela do celular continua em tela cheia.
+1 -1
View File
@@ -2,7 +2,7 @@
Implantação em 2026-10-05. IP atual: 192.168.1.35.
- Painel: http://192.168.1.35:8100/painel
- Painel: http://192.168.1.35:8100/ (publicar com `deploy.ps1`)
- Diretório: /opt/carinha-pc
- Serviço: carinha-pc.service, habilitado para iniciar no boot.
- Sistema: Armbian Debian 13, Python 3.13.5, arquitetura aarch64.
+57
View File
@@ -0,0 +1,57 @@
# Painel 0.8 — Svelte + SSE
Substitui as oito páginas antigas (dashboard, painel, conversa, emojis, treinamento, braço,
servos, api). Os endereços antigos redirecionam para a seção equivalente (`/braco` → `/#/movimento`).
## Arquitetura
- `web/`: Vite + Svelte 5 + TypeScript. `npm run build` gera `web/dist/`, que o servidor entrega em `/`.
`web/dist` não vai para o git; o `deploy.ps1` compila e copia.
- **Estado único** (`web/src/lib/estado.svelte.ts`): uma foto inicial (`GET /estado`) e um `EventSource`
em `/eventos`. Eventos: `expressao`, `telemetria`, `app_config`, `aparencia`, `motores`, `estop`,
`conversa`, `ia`, `reacao`, `treinamento`, `recarregar`. Ao reconectar, o navegador manda
`Last-Event-ID` e o servidor reenvia o que estiver no buffer; o painel ainda recarrega a foto.
- **Comandos**: `POST /comando {tipo, valor}` com `tipo` = `gesto` | `rosto` | `cor` | `reacao` |
`parar_reacao` | `recarregar`. Gestos e rostos são validados no servidor.
- **Prévia do rosto**: um único iframe de `carinha.html?espelho=1&painel=1`, sem polling; o painel repassa
os eventos por `postMessage`. A câmera (WebRTC) só existe na tela Ao vivo, então não há disputa pela sessão.
- **Celular**: `carinha.html` também assina `/eventos` (com `?t=<token>`) e volta ao polling de `/fila`
sozinho se o SSE cair. O batimento `POST /app/status` (1/s) continua.
Carga típica: antes 15–20 pedidos/s ao Orange Pi com o dashboard aberto; agora 1 conexão SSE por
painel + o batimento do celular + a sinalização do vídeo enquanto ele conecta.
## Segurança dos controles
| Controle | Comportamento |
|---|---|
| Parar tudo | `POST /parar-tudo`: desliga as 16 saídas, cancela interpolação, para reações, rosto `calma`. Motores travados (409) até `POST /rearmar`. |
| Homem-morto | O painel renova `POST /braco/mover {angulos, velocidade, ttl_ms: 450}` a cada 150 ms enquanto o botão/slider está pressionado. Sem renovação o watchdog desliga os canais. |
| Velocidade | Interpolação a 50 Hz no servidor (`braco.py`), 5–360 °/s. Sem `velocidade` o comando é direto (compatível com scripts antigos). |
| Dono do PCA9685 | Braço e diagnóstico não disputam o hardware: o segundo recebe 409 enquanto o primeiro tem canais ligados. |
| Aba escondida / E-Stop | O painel solta o braço e desativa o movimento físico. |
| Token | Opcional, recomendado: veja [SEGURANCA.md](SEGURANCA.md). |
## Atalhos
`Ctrl+K` paleta · `1`–`9` gestos favoritos · `Shift+Espaço` ou `Esc Esc` parar tudo ·
`g v/c/r/m/a` navegar · `Espaço` segurado no botão do braço = segurar.
## Design
Tokens em `web/src/lib/tema.css`: tema escuro (padrão) e claro, contraste AA, fontes locais
(Outfit e JetBrains Mono, sem Google Fonts). Vidro só em superfícies flutuantes (barra superior,
paleta, mini-rosto); cards sólidos. Estado nunca só por cor: selos com texto. Navegação lateral a
partir de 1024 px, barra inferior abaixo disso; sem rolagem horizontal a 400 px.
Componentes: `Card`, `Selo`, `Toggle`, `Slider`, `Abas`, `Modal` (`<dialog>` nativo), `Avisos`
(toasts com Desfazer, no lugar de alert/confirm/prompt), `Paleta`, `EStop`, `Gestos`,
`RostoPrevia`, `MiniRosto`, `Miniatura`, `BracoDesenho`.
## Testes
- `tests/test_servidor.py`: token, SSE, `/estado`, `/comando`, fila, E-Stop, trava, interpolação e prazo.
- `web/tests/nucleo.test.ts` (`npm test`): escolha de alvo, encaixe, faixas e sequências do braço.
- `tests/test_painel_web_browser.py`: token, gestos (botão, 1–9, paleta), E-Stop, ajustes, conversa,
memória, reações com desfazer, braço com homem-morto, 400 px e tema.
- `tests/test_rtc_browser.py`: vídeo WebRTC real no painel novo, canal de rastreamento e ajustes por SSE.
+17 -4
View File
@@ -25,10 +25,23 @@ ou `seguranca.json` na pasta do servidor (fora do git):
Sem token configurado o servidor fica aberto na rede local, como antes.
> **Ordem de ativação.** O painel antigo e o `carinha.html`/APK atuais ainda não enviam token.
> Ative o token na placa só depois de publicar o painel novo e o rosto/APK com suporte a token
> (fases 2–5 do plano de modernização). Até lá o servidor já aplica as demais proteções:
> checagem de Origin em todos os POST, comandos validados e E-Stop.
## Como ativar (ordem importa)
1. Publique servidor e painel 0.8 (`deploy.ps1`) e instale o APK `0.8-token` no S10.
2. No S10: toque longo › **Token do servidor** › digite o token. O app passa a mandar o token
na página do rosto (`?t=`), nos pedidos Java e no llama-server local (`--api-key`).
3. Na placa: `echo 'CARINHA_TOKEN=...' | sudo tee /etc/carinha.env && sudo chmod 600 /etc/carinha.env`
e `sudo systemctl restart carinha-pc`. O serviço lê o arquivo (`EnvironmentFile=`).
4. Abra o painel: ele pede o token uma vez e guarda só naquele navegador.
O cérebro da placa usa o mesmo token para falar com o llama-server do S10
(ou `CARINHA_IA_CHAVE`, se forem diferentes).
## App Android
- Com token salvo, trocar o servidor por Intent (`adb shell am start ... -e servidor URL`) exige
`-e token <token>`; outro app não consegue redirecionar o rosto.
- Câmera e microfone só são liberados para a página do servidor configurado ou para a cópia local.
## E-Stop
-14
View File
@@ -1,14 +0,0 @@
[Unit]
Description=Cerebro local Qwen3 Carinha
After=network.target
[Service]
Environment=LD_LIBRARY_PATH=/opt/carinha-ai/llama-b11404
ExecStart=/opt/carinha-ai/llama-b11404/llama-server -m /opt/carinha-ai/models/qwen3-0.6b-q4.gguf --host 127.0.0.1 --port 8101 -c 1024 -t 2 -np 1 --jinja --chat-template-kwargs '{"enable_thinking":false}'
Restart=on-failure
RestartSec=5
Nice=10
[Install]
WantedBy=multi-user.target
+2
View File
@@ -5,6 +5,8 @@ After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/carinha-pc
# CARINHA_TOKEN e GEMINI_API_KEY (opcionais); o '-' ignora o arquivo se não existir
EnvironmentFile=-/etc/carinha.env
ExecStart=/usr/bin/python3 -u /opt/carinha-pc/kiosk-server.py --porta 8100
Restart=on-failure
RestartSec=3
-148
View File
@@ -1,148 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Emojis & Reações · Carinha</title>
<link rel="stylesheet" href="/dashboard.css">
<script src="/dashboard.js" defer></script>
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:14px">
<div>
<h1>Biblioteca de Emojis & Reações</h1>
<p style="margin:4px 0 0">Gerencie expressões visuais, desenhos vetoriais e reações inteligentes para o Carinha.</p>
</div>
<div class="actions">
<button class="primary" id="emojiNew">✨ Criar Nova Reação</button>
<button id="emojiUndo" disabled>↩️ Desfazer</button>
<button id="emojiExport">📦 Exportar JSON</button>
</div>
</div>
<!-- WORKSPACE STICKY: O simulador fica fixado na tela enquanto a biblioteca rola ao lado -->
<div class="emojis-workspace" style="margin-top:16px">
<!-- Coluna Fixa (Sticky Preview) -->
<aside class="sticky-preview-col">
<section class="card sticky-preview-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
Simulador ao Vivo
</h2>
<span class="badge ativo" style="font-size:11px">📌 Sempre Visível</span>
</div>
<iframe id="emojiPreview" title="Prévia de emoji no rosto" style="height:250px;border-radius:var(--raio-md);box-shadow:inset 0 0 30px rgba(0,0,0,0.8),0 0 15px var(--ciano-glow)" src="/carinha.html?espelho=1&audio=0"></iframe>
<div class="actions" style="margin-top:14px">
<button id="emojiStop" class="danger" style="flex:1">🛑 Parar no Celular</button>
<a href="/treinamento" class="primary" style="display:inline-flex;align-items:center;gap:6px;padding:8px 12px;border-radius:var(--raio-sm);font-size:13px">
<span>🎨 Gemini SVG</span>
</a>
</div>
<p class="code-label" style="margin-top:10px;font-size:11.5px;color:var(--muito-fraco);line-height:1.4">
Dica: Ao clicar em "Prévia" em qualquer cartão, a animação acontece aqui mesmo sem você perder a posição de rolagem!
</p>
<p id="emojiState" role="status" aria-live="polite" style="font-size:13px;color:var(--ciano);min-height:22px;margin:8px 0 0"></p>
</section>
</aside>
<!-- Coluna Rolável (Biblioteca de Emojis) -->
<main class="emojis-library-col" style="min-width:0">
<section class="card" style="margin-bottom:20px">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px">
<h2 style="margin:0">Catálogo de Reações do Robô</h2>
<span class="badge ativo" id="emojiCount">—</span>
</div>
<p style="margin:6px 0 12px;font-size:13px">Reações salvas entram no repertório da conversa com o Gemini ou podem ser chamadas manualmente.</p>
<label for="emojiSearch">Filtrar por nome ou situação:</label>
<input id="emojiSearch" placeholder="Ex.: café, feliz, ideias, surpresa, música...">
</section>
<!-- Grade de Cartões de Emojis -->
<div id="emojiLibrary" class="library-grid"></div>
</main>
</div>
<!-- Modal / Dialog de Criação e Edição -->
<div id="emojiDialog" class="dialog-overlay" hidden role="dialog" aria-modal="true" aria-labelledby="emojiEditorTitle">
<form id="emojiEditForm" class="dialog-card">
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--borda);padding-bottom:12px;margin-bottom:18px">
<h2 id="emojiEditorTitle" style="margin:0">Criar Emoji</h2>
<button type="button" id="emojiCancel" style="border:none;background:transparent;font-size:18px;cursor:pointer">✕</button>
</div>
<label for="emojiName">Nome da Reação</label>
<input id="emojiName" required maxlength="50" placeholder="Ex.: Café Matinal, Ideia Brilhante">
<label for="emojiReason">Quando Usar (Situação para o Gemini)</label>
<textarea id="emojiReason" required maxlength="200" rows="3" placeholder="Ex.: Quando o usuário falar sobre café, cansaço ou manhã"></textarea>
<label for="emojiCharacter">Caractere ou Desenho</label>
<select id="emojiCharacter">
<option value="">Manter desenho original</option>
</select>
<!-- Grade de Atalhos Rápidos de Emojis -->
<div style="margin:6px 0 16px">
<span style="font-size:12px;color:var(--fraco)">Atalhos rápidos:</span>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px" id="quickEmojiBar">
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="💡">💡</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="☕">☕</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="❤️">❤️</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="⭐">⭐</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🚀">🚀</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🤖">🤖</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🕶️">🕶️</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🔥">🔥</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🐱">🐱</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="😴">😴</button>
</div>
</div>
<label for="emojiLocation">Posição na Tela do Carinha</label>
<select id="emojiLocation">
<option value="olhos">Dentro dos dois olhos</option>
<option value="ao_redor">Ao lado do rosto</option>
</select>
<p class="code-label" style="font-size:12px;margin:12px 0 18px">
Escolher um emoji substitui o desenho SVG. Você poderá desfazer a qualquer momento.
</p>
<div class="actions" style="justify-content:flex-end">
<button type="button" onclick="document.getElementById('emojiCancel').click()">Cancelar</button>
<button class="primary" type="submit">Salvar Reação</button>
</div>
<p id="emojiEditState" role="status" style="margin-top:10px;font-size:13px;color:var(--vermelho)"></p>
</form>
</div>
<script src="/biblioteca.js" defer></script>
<script>
// Atalho para preencher emoji clicando na barra de atalhos rápidos
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#quickEmojiBar button').forEach(btn => {
btn.onclick = () => {
const val = btn.getAttribute('data-val');
const sel = document.getElementById('emojiCharacter');
if (sel && val) {
let found = false;
for (let opt of sel.options) {
if (opt.value === val) { opt.selected = true; found = true; break; }
}
if (!found) {
sel.add(new Option(val, val, true, true));
}
}
};
});
});
</script>
</body>
</html>
+4 -4
View File
@@ -1,11 +1,11 @@
param([int]$Porta = 8100, [switch]$SemAbrir)
param([string]$Placa = '192.168.1.35', [int]$Porta = 8100, [switch]$SemAbrir)
$ErrorActionPreference = 'Stop'
$url = "http://192.168.1.35:$Porta"
$url = "http://${Placa}:$Porta"
try {
$health = Invoke-RestMethod "$url/app/health" -TimeoutSec 5
if ($health.projeto -ne 'carinha-pc') { throw 'Servidor inesperado' }
} catch {
throw "Orange Pi indisponível em $url. Confira alimentação, rede e o serviço carinha-pc. Nenhum servidor será iniciado no PC."
}
Write-Host "Painel no Orange Pi: $url/painel"
if (-not $SemAbrir) { Start-Process "$url/painel" }
Write-Host "Painel Carinha $($health.versao): $url/"
if (-not $SemAbrir) { Start-Process "$url/" }
+46 -37
View File
@@ -44,23 +44,32 @@ from api_config import ConfigAPI
TRAVA = threading.Lock()
# Espelho da biblioteca de rostos do carinha.html (para /faces funcionar)
ROSTOS = ["eilik", "carinha", "miudo"]
ROSTOS = ["vivo", "bloco", "matriz", "lente", "eilik", "carinha", "miudo"]
# Formato do olho nos rostos novos (rosto-motor.js)
FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"]
# Espelho dos gestos do carinha.html (para o painel montar os botoes)
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
"dormir", "acordar", "aleatorio", "parar"]
"dormir", "acordar", "aleatorio", "parar",
"esconder", "espiar", "acenar", "pensar", "musica"]
# Ordens internas que o rosto entende mas não viram botão.
ORDENS_INTERNAS = {"calma"}
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
# arquivo ao lado do servidor, para sobreviver a reinicios.
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255]}
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255], "formato": "padrao"}
ARQUIVO_APARENCIA = "aparencia.json"
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js",
"/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task",
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm",
"/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"}
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/camera/", "/conversa/", "/treinamento/",
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -117,6 +126,8 @@ def carregar_aparencia():
if isinstance(dados, dict):
if dados.get("rosto") in ROSTOS:
APARENCIA["rosto"] = dados["rosto"]
if dados.get("formato") in FORMATOS:
APARENCIA["formato"] = dados["formato"]
cor = dados.get("cor")
if isinstance(cor, list) and len(cor) == 3:
APARENCIA["cor"] = [max(0, min(255, int(v))) for v in cor]
@@ -147,6 +158,11 @@ def aplicar_aparencia(dados):
if nome in ROSTOS:
APARENCIA["rosto"] = nome
ordens.append("rosto " + nome)
if "formato" in dados:
nome = str(dados["formato"]).strip().lower()
if nome in FORMATOS:
APARENCIA["formato"] = nome
ordens.append("formato " + nome)
if "cor" in dados:
try:
APARENCIA["cor"] = validar_cor(dados["cor"])
@@ -229,7 +245,7 @@ class Handler(SimpleHTTPRequestHandler):
with TRAVA:
aparencia = dict(APARENCIA)
dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)),
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS)
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS, formatos=FORMATOS)
if hasattr(s, "controle"):
dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy())
if hasattr(s, "servos"):
@@ -290,45 +306,31 @@ class Handler(SimpleHTTPRequestHandler):
enfileirar(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 (Vite/Svelte compilado em web/dist) em "/"; o resto é lista branca.
painel = Path("web/dist")
if caminho in ("/", "") and (painel / "index.html").is_file():
if caminho in ("/", "", "/index.html"):
if not (painel / "index.html").is_file():
return self.json({"erro": "Painel não compilado: rode 'npm run build' em web/ (ou use deploy.ps1)"}, 503)
self.path = "/web/dist/index.html"
return super().do_GET()
if caminho.startswith("/assets/"):
elif 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"):
self.path = caminho + ".html"
elif caminho == "/painel":
self.path = "/painel.html" # painel de controle
elif caminho == '/servos':
self.path = '/servos.html'
elif caminho == '/conversa':
self.path = '/conversa.html'
elif caminho == '/treinamento':
self.path = '/treinamento.html'
elif caminho == '/braco':
self.path = '/braco.html'
elif caminho in ("/", ""):
self.path = "/dashboard.html"
elif caminho in ("/dashboard", "/painel", "/conversa", "/emojis", "/treinamento", "/braco", "/servos", "/api"):
# Endereços das telas antigas levam à seção equivalente do painel novo.
secao = {"/conversa": "conversa", "/emojis": "reacoes", "/treinamento": "reacoes", "/braco": "movimento",
"/servos": "movimento", "/api": "ajustes"}.get(caminho, "ao-vivo")
self.send_response(302)
self.send_header("Location", "/#/" + secao)
self.send_header("Content-Length", "0")
self.end_headers()
return
elif caminho == '/qualidade-auto.js':
self.path = '/android/app/assets/qualidade-auto.js'
elif caminho == '/app.apk':
self.path = '/android/carinha-rosto.apk'
elif caminho in (
'/vendor/mediapipe/vision_bundle.mjs', '/vendor/mediapipe/face.tflite', '/vendor/mediapipe/hands.task',
'/vendor/mediapipe/wasm/vision_wasm_internal.js', '/vendor/mediapipe/wasm/vision_wasm_internal.wasm',
'/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js', '/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm'):
pass
elif caminho in ('/dashboard.html','/emojis.html','/api.html','/dashboard.css','/dashboard.js','/overview.js','/biblioteca.js','/api-painel.js','/painel.css','/conversa.css','/treinamento.css','/servos.css','/braco.css','/audio-robot.js'):
pass
elif caminho not in ('/conversa.html', '/treinamento.html', '/treinamento.js', '/reacoes-render.js', '/painel-conversa.js', '/braco-calibracao.js', '/movimentos-core.js', '/braco-movimentos.js', '/braco.html', '/braco.js', '/servos.html', '/servos.js', '/carinha.html', '/camera.html', '/painel.html', '/painel-controle.js', '/painel-video.js',
'/rastreamento-core.js', '/rastreador-worker.js', '/painel-rastreamento.js'):
elif caminho not in ESTATICOS:
return self.enviar(json.dumps({'erro': 'Arquivo não disponível'}), codigo=404)
return super().do_GET()
@@ -368,9 +370,13 @@ class Handler(SimpleHTTPRequestHandler):
def comando(self, dados):
tipo, valor = dados.get("tipo"), dados.get("valor")
s = self.server
if tipo == "cor":
if not aplicar_aparencia({"cor": valor}):
raise ValueError("Cor inválida")
if tipo == "rosto":
ordem_de_comando("rosto", valor) # valida e dá erro legível
aplicar_aparencia({"rosto": valor}) # salva, avisa os painéis e manda ao celular
return self.json({"ok": True})
if tipo in ("cor", "formato"):
if not aplicar_aparencia({tipo: valor}):
raise ValueError("Cor inválida" if tipo == "cor" else "Formato inválido")
return self.json({"ok": True})
if tipo == "recarregar":
return self.recarregar()
@@ -424,6 +430,7 @@ class Handler(SimpleHTTPRequestHandler):
if not online:
return self.json({"erro": "Abra o Carinha e conecte ao servidor primeiro"}, 409)
FILA.adicionar("recarregar")
EVENTOS.publicar("recarregar", {"id": controle.recarga["id"]})
return self.json({"ok": True, "mensagem": "Solicitação enviada. Aguardando o celular recarregar a tela."})
def log_message(self, formato, *args):
@@ -465,6 +472,8 @@ def main():
servidor.servos = ControleServos(Path.cwd() / 'servos.json')
servidor.braco = Braco(Path.cwd() / 'braco.json', servidor.servos)
servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, enfileirar)
if servidor.token and not servidor.cerebro.chave:
servidor.cerebro.chave = servidor.token
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
-13
View File
@@ -1,13 +0,0 @@
(function(root){
function sequencias(ranges){
const neutral=[0,0,0,0,0,0,0];
const sets=[
['Acenar',[[0,-.7,.5,.2,0,.1,0],[0,.7,-.5,.2,0,.1,0],[0,-.7,.5,.2,0,.1,0],[0,.7,-.5,.2,0,.1,0],neutral]],
['Olhar para os lados',[[0,.2,-.4,0,0,0,-.7],[0,-.2,.4,0,0,0,.7],neutral]],
['Alcançar e recolher',[[0,0,.2,.6,-.5,.4,0],[0,0,-.2,-.4,.5,-.4,0],neutral]],
['Garra e punho',[[-.7,.3,0,.2,0,.1,0],[.7,-.3,0,.2,0,.1,0],[-.7,.3,0,0,0,0,0],neutral]],
['Apresentação',[[.3,.3,-.3,.4,-.3,.3,-.6],[-.3,-.3,.3,-.3,.4,-.3,.6],[.5,.4,.2,.3,-.3,.2,0],neutral]]];
return sets.map(([nome,steps])=>({nome,passos:steps.map(s=>s.map((v,i)=>{const [lo,hi]=ranges[i];return Math.round(90+v*(v<0?90-lo:hi-90));}))}));
}
const api={sequencias};if(typeof module==='object')module.exports=api;else root.Movimentos=api;
})(globalThis);
-159
View File
@@ -1,159 +0,0 @@
/**
* overview.js — Lógica de supervisão da Visão Geral
* Monitora métricas do robô, bateria, IA Gemini, reações e gerencia ações rápidas.
*/
(() => {
const el = id => document.getElementById(id);
let reload = null;
let start = 0;
let ultimoEstadoOnline = null;
function adicionarEvento(texto, tipo = 'info') {
const feed = el('overviewFeed');
if (!feed) return;
const item = document.createElement('div');
item.style.cssText = 'display:flex;align-items:center;gap:10px;animation:fadeInMsg .2s ease-out';
const cor = tipo === 'online' ? '#10b981' : tipo === 'alerta' ? '#f59e0b' : 'var(--ciano)';
const hora = new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
item.innerHTML = `
<span class="badge-dot" style="background:${cor};box-shadow:0 0 8px ${cor}"></span>
<span style="color:var(--texto)">${texto}</span>
<span style="margin-left:auto;font-size:11px;font-family:'JetBrains Mono',monospace;color:var(--fraco)">${hora}</span>
`;
feed.prepend(item);
while (feed.children.length > 7) {
feed.removeChild(feed.lastChild);
}
}
async function refresh() {
try {
const [s, c, t] = await Promise.all(['/app/status', '/conversa/status', '/treinamento/status'].map(async p => {
const r = await fetch(p, { cache: 'no-store', signal: AbortSignal.timeout(3000) });
if (!r.ok) throw Error('Servidor indisponível');
return r.json();
}));
// Estado do Celular
if (el('overviewPhone')) {
el('overviewPhone').textContent = s.online ? 'Conectado' : 'Desconectado';
el('overviewPhone').style.color = s.online ? '#34d399' : 'var(--fraco)';
}
if (el('overviewIP')) {
el('overviewIP').textContent = s.celular_ip || (s.online ? 'IP Detectado' : 'Abra o Carinha no celular');
}
// Registra evento de conexão/desconexão
if (ultimoEstadoOnline !== null && ultimoEstadoOnline !== s.online) {
if (s.online) {
adicionarEvento('Galaxy S10 conectado e sincronizado', 'online');
if (window.RobotAudio) window.RobotAudio.happy();
} else {
adicionarEvento('Galaxy S10 desconectou da Central', 'alerta');
}
}
ultimoEstadoOnline = s.online;
// Bateria
if (el('overviewBattery')) {
const nivel = s.online && s.estado?.bateria?.nivel != null ? s.estado.bateria.nivel + '%' : '—';
el('overviewBattery').textContent = nivel;
if (s.estado?.bateria?.nivel != null) {
if (s.estado.bateria.nivel <= 20) {
el('overviewBattery').style.color = '#fb7185';
} else {
el('overviewBattery').style.color = '#f0f6fc';
}
}
}
if (el('overviewCharge')) {
const carregando = s.estado?.bateria?.carregando;
el('overviewCharge').textContent = carregando ? '⚡ Carregando na tomada' : 'Operando na bateria';
el('overviewCharge').style.color = carregando ? 'var(--ciano)' : 'var(--fraco)';
}
// Inteligência IA
if (el('overviewAI')) {
el('overviewAI').textContent = c.provedor === 'gemini' ? 'Google Gemini' : 'Qwen Local';
}
if (el('overviewModel')) {
el('overviewModel').textContent = c.modelo || 'IA Pronta';
}
// Biblioteca de reações
if (el('overviewReactions')) {
el('overviewReactions').textContent = Array.isArray(t.reacoes) ? t.reacoes.length : '0';
}
// Botão de recarga
if (el('overviewReload')) {
el('overviewReload').disabled = !s.online;
}
// Confirmação de recarga remota
if (reload && s.recarga?.id === reload && s.recarga.confirmada) {
el('overviewMessage').textContent = '✅ Tela atualizada: Galaxy S10 confirmou o recarregamento.';
adicionarEvento('Tela do Galaxy S10 recarregada com sucesso', 'online');
reload = null;
} else if (reload && Date.now() - start > 12000) {
el('overviewMessage').textContent = '⚠️ Sem confirmação. Reabra o app Carinha no celular.';
reload = null;
}
} catch (e) {
if (el('overviewMessage')) el('overviewMessage').textContent = e.message;
}
}
// Ação de recarregar a tela do celular
if (el('overviewReload')) {
el('overviewReload').onclick = async () => {
try {
if (window.RobotAudio) window.RobotAudio.click();
const r = await fetch('/app/recarregar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
const d = await r.json();
if (!r.ok) throw Error(d.erro);
el('overviewMessage').textContent = d.mensagem;
adicionarEvento('Solicitação de recarga enviada ao celular...');
const s = await (await fetch('/app/status')).json();
reload = s.recarga?.id;
start = Date.now();
} catch (e) {
el('overviewMessage').textContent = 'Erro: ' + e.message;
}
};
}
// Botões de Expressão Rápida (Mood Buttons)
document.querySelectorAll('.btn-mood').forEach(btn => {
btn.onclick = async () => {
const gesto = btn.getAttribute('data-gesto');
if (!gesto) return;
try {
if (window.RobotAudio) window.RobotAudio.tocarGesto(gesto);
await fetch('/gesto/' + encodeURIComponent(gesto));
adicionarEvento(`Expressão disparada: ${gesto}`);
btn.style.transform = 'scale(0.94)';
setTimeout(() => btn.style.transform = '', 160);
} catch (err) {
console.error('Falha ao acionar gesto:', err);
}
};
});
// Botão de Teste Sonoro
if (el('overviewSfxHappy')) {
el('overviewSfxHappy').onclick = () => {
if (window.RobotAudio) {
window.RobotAudio.happy();
adicionarEvento('Sintetizador gerou trinado alegre');
}
};
}
refresh();
setInterval(refresh, 3500);
})();
-215
View File
@@ -1,215 +0,0 @@
"use strict";
const $ = (id) => document.getElementById(id);
let recargaPendente = null;
let recargaInicio = 0;
let appConfig = null;
let imagemURL = null;
let recebendoImagem = false;
let salvandoConfig = false;
let sequencia = -1;
let ultimoStatus = null;
let consultandoStatus = false;
async function api(rota, dados) {
const r = await fetch(rota, {cache: 'no-store', signal: AbortSignal.timeout(rota === '/app/connect' ? 65000 : 3500),
...(dados === undefined ? {} : {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(dados)})});
const body = await r.json();
if (!r.ok) throw new Error(body.erro || 'Servidor respondeu HTTP ' + r.status);
return body;
}
function ajustarCamera() {
const ligada = appConfig && appConfig.preview;
$('toggleCamera').textContent = ligada ? 'Desligar câmera' : 'Ligar câmera';
if (!ligada) esconderImagem('Transmissão desligada. O rosto e os sensores continuam funcionando.');
if (window.CarinhaVideo && !ligada) window.CarinhaVideo.parar();
}
function esconderImagem(texto) {
$('cameraImagem').hidden = true;
$('cameraImagem').removeAttribute('src');
$('cameraMensagem').hidden = false;
$('cameraMensagem').textContent = texto;
if (imagemURL) URL.revokeObjectURL(imagemURL);
imagemURL = null;
sequencia = -1;
}
async function salvarApp(parte) {
if (salvandoConfig) return;
salvandoConfig = true;
CAMPOS.forEach(([chave]) => { $('ajuste-' + chave).disabled = true; });
$('configEstado').textContent = 'Salvando…';
$('toggleCamera').disabled = true;
try {
appConfig = (await api('/app/config', parte)).config;
atualizarCampos();
ajustarCamera();
$('configEstado').textContent = 'Salvo no servidor. Aguardando o celular aplicar.';
} catch (e) {
$('configEstado').textContent = 'Não foi possível salvar: ' + e.message;
atualizarCampos();
} finally {
salvandoConfig = false; $('toggleCamera').disabled = false;
CAMPOS.forEach(([chave]) => { $('ajuste-' + chave).disabled = false; });
}
}
const CAMPOS = [
['rastrearRosto', 'Rastrear rosto'], ['rastrearMaos', 'Rastrear mãos'],
['seguirRastreamento', 'Olhos acompanharem o alvo'], ['mostrarRastreamento', 'Mostrar rastreamento no vídeo'],
['sensorMic', 'Microfone ligado'], ['sensorCamera', 'Câmera ligada'],
['micSono', 'Permitir microfone durante o sono'], ['cameraSono', 'Permitir câmera durante o sono'],
['cameraLado', 'Câmera', [['user','Frontal'],['environment','Traseira']]],
['videoResolucao', 'Resolução do vídeo', [['auto','Automática (padrão)'],['max','Máxima disponível'],['480','480p'],['720','720p'],['1080','1080p'],['2160','4K'],['4320','8K']]],
['videoFPS', 'FPS do vídeo', [[0,'Automático (padrão)'],[-1,'Máximo disponível'],[15,'15'],[24,'24'],[30,'30'],[60,'60'],[120,'120'],[240,'240']]],
['boca', 'Mostrar boca'], ['sobrancelhas', 'Mostrar sobrancelhas'],
['auto', 'Gestos espontâneos'], ['audio', 'Sons da Carinha'],
['ouvir', 'Reagir aos sons'], ['reagir', 'Reagir ao movimento'],
['volume', 'Volume dos sons', 0, 1, 0.05],
['sompausa', 'Silêncio para dormir (s)', 1, 120, 1],
['economia', 'Reduzir brilho em repouso'],
['brilho', 'Brilho normal', 0.01, 1, 0.01],
['brilhoRepouso', 'Brilho em repouso', 0.01, 1, 0.01],
['repousoTela', 'Espera para reduzir brilho (s)', 5, 300, 5],
['somlim', 'Nível para perceber som', 0, 1, 0.01],
['somganho', 'Aumento de som para susto', 0, 1, 0.01],
['somaltolim', 'Som mínimo para susto', 0, 1, 0.01],
['somcooldown', 'Intervalo entre sustos por som (s)', 0, 120, 1],
['movlim', 'Movimento mínimo', 0, 1, 0.01],
['movforte', 'Movimento para susto', 0, 1, 0.01],
['movcooldown', 'Intervalo entre sustos por movimento (s)', 0, 120, 1],
['movolhar', 'Quanto os olhos acompanham', 0, 100, 1],
];
function montarCampos() {
CAMPOS.forEach(([chave, texto, min, max, passo], i) => {
const area = document.createElement('div');
const label = document.createElement('label');
const select = Array.isArray(min);
const input = document.createElement(select ? 'select' : 'input');
input.id = 'ajuste-' + chave; label.htmlFor = input.id;
if (!select) input.type = min === undefined ? 'checkbox' : 'range';
label.textContent = texto;
if (select) {
min.forEach(([v,t]) => input.add(new Option(t, String(v))));
area.className = 'ajuste'; area.append(label, input);
} else if (input.type === 'range') {
input.min = min; input.max = max; input.step = passo;
area.className = 'ajuste-range';
const output = document.createElement('output'); output.id = 'valor-' + chave;
label.appendChild(output); area.append(label, input);
input.oninput = () => { output.textContent = input.value; };
} else { area.className = 'ajuste'; area.append(label, input); }
input.onchange = () => salvarApp({[chave]: input.type === 'checkbox' ? input.checked : select && chave !== 'videoFPS' ? input.value : Number(input.value)});
$(i < 4 ? 'ajustesRastreamento' : i < 23 ? 'ajustesBasicos' : 'ajustesAvancados').appendChild(area);
});
}
function atualizarCampos() {
if (!appConfig) return;
CAMPOS.forEach(([chave]) => {
const input = $('ajuste-' + chave);
if (input.type === 'checkbox') input.checked = appConfig[chave];
else if (document.activeElement !== input) {
input.value = appConfig[chave]; if ($('valor-' + chave)) $('valor-' + chave).textContent = appConfig[chave];
}
});
}
async function statusApp() {
if (consultandoStatus) return;
consultandoStatus = true;
try {
const s = await api('/app/status'); ultimoStatus = s;
if (!salvandoConfig) { appConfig = await api('/app/config'); atualizarCampos(); ajustarCamera(); }
$('conexaoEstado').textContent = s.online ? 'Conectado' : 'Sem conexão';
$('conexaoEstado').classList.toggle('ativo', s.online);
if(s.online && s.celular_ip && document.activeElement !== $('celularIP')) $('celularIP').value=s.celular_ip;
if(s.servidor_ip && document.activeElement !== $('pcIP')) $('pcIP').value=s.servidor_ip;
if(recargaPendente && s.recarga?.id===recargaPendente && s.recarga.confirmada){$('conectarEstado').textContent='Tela atualizada: confirmação recebida do celular.';recargaPendente=null;}
else if(recargaPendente && Date.now()-recargaInicio>12000){$('conectarEstado').textContent='O celular não confirmou a recarga. Feche e reabra o Carinha uma vez para carregar o suporte atualizado.';recargaPendente=null;}
const e = s.estado || {};
const bateria = e.bateria;
const nivel = bateria?.nivel;
$('bateriaCelular').textContent = s.online && typeof nivel === 'number' && nivel >= 0 && nivel <= 100 ? `Bateria do celular: ${Math.round(nivel)}%${bateria.carregando ? ' · carregando' : ''}` : 'Bateria do celular: aguardando dados do app.';
$('rostoConfirmado').textContent = s.online ? 'No celular: ' + (e.rosto || '?') + (e.dormindo ? ', dormindo.' : ', acordado.') : 'Aguardando confirmação do celular.';
['mic', 'mov'].forEach((nome) => {
const chave = nome === 'mic' ? 'microfone' : 'movimento';
const ativo = s.online && e[nome === 'mic' ? 'microfone_ativo' : 'camera_ativa'];
const v = ativo ? Number(e[chave] || 0) : 0;
$(nome + 'Nivel').value = v;
$(nome + 'Valor').textContent = ativo ? Math.round(v * 100) + '%' : '—';
});
$('sensorEstado').textContent = s.online ? (e.camera_ativa ? ('Câmera ' + (appConfig?.cameraLado === 'environment' ? 'traseira' : 'frontal')) : 'Câmera desativada') + (e.centro ? ' · centro do movimento: ' + e.centro.map(v => Math.round(v * 100) + '%').join(', ') : '') + (typeof e.brilho === 'number' ? ' · brilho: ' + Math.round(e.brilho * 100) + '%' : '') : 'Abra o app no celular ou use Conectar e abrir o app.';
if (appConfig && !salvandoConfig && e.config && Object.keys(appConfig).every(k => e.config[k] === appConfig[k]))
$('configEstado').textContent = s.online ? 'Configurações aplicadas no celular.' : 'Configurações salvas no servidor. Celular sem conexão.';
if (appConfig && appConfig.videoRTC && window.CarinhaVideo) window.CarinhaVideo.atualizar(s, appConfig);
if (appConfig && window.CarinhaRastreamento) window.CarinhaRastreamento.atualizar(s, appConfig);
if (e.video && e.video.aviso) $('sensorEstado').textContent += ' · ' + e.video.aviso;
if (appConfig && !appConfig.videoRTC && appConfig.preview && (!s.online || !s.camera_recente))
esconderImagem(s.online ? 'Aguardando imagem da câmera. Confira a permissão de câmera no celular.' : 'Celular sem conexão. A imagem antiga foi removida.');
} catch (e) {
ultimoStatus = null;
$('bateriaCelular').textContent = 'Bateria do celular: sem conexão.';
$('conexaoEstado').textContent = 'Servidor indisponível';
$('conexaoEstado').classList.remove('ativo');
esconderImagem('O servidor não respondeu. Confira o serviço no Orange Pi.');
if (window.CarinhaVideo) window.CarinhaVideo.parar();
if (window.CarinhaRastreamento) window.CarinhaRastreamento.parar();
} finally { consultandoStatus = false; }
}
async function imagem() {
if (appConfig && appConfig.videoRTC) return;
if (recebendoImagem || !appConfig || !appConfig.preview || !ultimoStatus || !ultimoStatus.camera_recente) return;
recebendoImagem = true;
try {
const r = await fetch('/camera/frame?t=' + Date.now(), {cache:'no-store', signal:AbortSignal.timeout(2500)});
if (!r.ok || r.status === 204) return;
const blob = await r.blob();
if (!appConfig.preview) return;
const nova = URL.createObjectURL(blob);
const anterior = imagemURL; imagemURL = nova;
$('cameraImagem').src = nova;
$('cameraImagem').hidden = false; $('cameraMensagem').hidden = true;
if (anterior) URL.revokeObjectURL(anterior);
sequencia = ultimoStatus.frame_seq;
} catch (e) { esconderImagem('Imagem indisponível. Aguardando o próximo quadro.'); }
finally { recebendoImagem = false; }
}
$('toggleCamera').onclick = () => salvarApp({preview: !appConfig.preview});
$('conectarForm').onsubmit = async (ev) => {
ev.preventDefault(); $('conectarBotao').disabled = true;
$('conectarEstado').textContent = 'Conectando ao celular…';
try {
const r = await api('/app/connect', {ip: $('celularIP').value.trim(), pc: $('pcIP').value.trim(), porta:5555});
$('conectarEstado').textContent = r.mensagem;
} catch (e) { $('conectarEstado').textContent = e.message; }
finally { $('conectarBotao').disabled = false; }
};
montarCampos();
(async () => {
try { appConfig = await api('/app/config'); atualizarCampos(); ajustarCamera(); $('toggleCamera').disabled = false; await statusApp(); }
catch (e) { $('configEstado').textContent = 'Falha ao carregar: ' + e.message; }
})();
setInterval(statusApp, 1000);
setInterval(imagem, 250);
(function(){
const iaActive=document.getElementById('iaAtiva'),iaState=document.getElementById('iaEstado'),iaTest=document.getElementById('iaTestar');
let updating=false;
async function request(path,data){const r=await fetch('/ia/'+path,data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const d=await r.json();if(!r.ok)throw Error(d.erro);return d;}
async function refresh(){if(updating)return;try{const s=await request('status');iaActive.checked=s.ativo;iaTest.disabled=!s.ativo||s.ocupado;iaState.textContent=s.erro?'IA: '+s.erro:s.ocupado?'IA local pensando…':s.ultima?`${s.modelo} · ${s.executor} · última expressão: ${s.ultima.gesto} (${s.ultima.segundos} s)`:`${s.modelo} · ${s.executor} · ${s.ativo?'aguardando eventos':'desativado'}`;}catch(e){iaState.textContent=e.message;}}
iaActive.onchange=async()=>{updating=true;try{await request('config',{ativo:iaActive.checked});}catch(e){iaState.textContent=e.message;}finally{updating=false;refresh();}};
iaTest.onclick=async()=>{iaTest.disabled=true;try{await request('evento',{evento:document.getElementById('iaEvento').value});iaState.textContent='Evento enviado para a IA local…';}catch(e){iaState.textContent=e.message;}};
refresh();setInterval(refresh,2000);
})();
document.getElementById('recarregarTela').onclick=async()=>{
const button=document.getElementById('recarregarTela');button.disabled=true;
try{const r=await api('/app/recarregar',{});document.getElementById('conectarEstado').textContent=r.mensagem;recargaPendente=(await api('/app/status')).recarga.id;recargaInicio=Date.now();}
catch(e){document.getElementById('conectarEstado').textContent=e.message;}
finally{button.disabled=false;}
};
-328
View File
@@ -1,328 +0,0 @@
/**
* painel-conversa.js — Interface interativa de chat com o Carinha (IA Gemini / Qwen)
* Suporte a Reconhecimento de Voz (Microfone), Síntese de Voz Robótica (TTS),
* balões modernos, chips de sugestão e acionamento de expressões.
*/
(function() {
'use strict';
const el = id => document.getElementById(id);
let sending = false;
let signature = '';
let recognition = null;
let isRecording = false;
let lastSpokenMessage = null;
// Configuração de síntese de voz robótica
function falarTexto(texto) {
if (!('speechSynthesis' in window) || !texto) return;
window.speechSynthesis.cancel(); // Para falas anteriores
const u = new SpeechSynthesisUtterance(texto);
u.lang = 'pt-BR';
u.pitch = 1.35; // Voz aguda e robótica / simpática estilo robô
u.rate = 1.05; // Ritmo levemente ágil
// Tenta encontrar uma voz em português brasileiro
const voices = window.speechSynthesis.getVoices();
const ptVoice = voices.find(v => v.lang.startsWith('pt'));
if (ptVoice) u.voice = ptVoice;
window.speechSynthesis.speak(u);
}
// Configuração do microfone (Speech Recognition)
function setupSpeechRecognition() {
const SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition;
const micBtn = el('conversaMicBtn');
if (!SpeechRec || !micBtn) {
if (micBtn) {
micBtn.title = 'Reconhecimento de voz não suportado pelo navegador atual';
micBtn.style.opacity = '0.4';
}
return;
}
recognition = new SpeechRec();
recognition.lang = 'pt-BR';
recognition.continuous = false;
recognition.interimResults = false;
recognition.onstart = () => {
isRecording = true;
micBtn.classList.add('recording');
micBtn.textContent = '⏹️';
if (window.RobotAudio) window.RobotAudio.click();
};
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
const input = el('conversaTexto');
if (input && transcript) {
input.value = transcript;
input.focus();
if (window.RobotAudio) window.RobotAudio.bleep();
}
};
recognition.onerror = (e) => {
console.warn('Erro no microfone:', e.error);
pararGravacao();
};
recognition.onend = () => {
pararGravacao();
};
function pararGravacao() {
isRecording = false;
micBtn.classList.remove('recording');
micBtn.textContent = '🎤';
}
micBtn.onclick = () => {
if (isRecording) {
recognition.stop();
} else {
try {
recognition.start();
} catch (err) {
console.error('Falha ao iniciar reconhecimento:', err);
}
}
};
}
async function api(path, data) {
const r = await fetch('/conversa/' + path, data === undefined ? { cache: 'no-store' } : {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await r.json();
if (!r.ok) throw Error(result.erro);
return result;
}
async function refresh() {
try {
const s = await api('status');
if (el('conversaProvedor')) {
el('conversaProvedor').value = s.provedor || 'local';
el('conversaProvedor').disabled = s.ocupado;
}
if (el('conversaEnviar')) {
el('conversaEnviar').disabled = sending || s.ocupado;
}
// Indicador de "Pensando..."
const typingEl = el('conversaTyping');
if (typingEl) {
typingEl.style.display = s.ocupado ? 'flex' : 'none';
}
// Texto de status do motor
if (el('conversaEstado')) {
el('conversaEstado').textContent = s.erro ? 'Falha: ' + s.erro :
s.ocupado ? 'Pensando com ' + (s.modelo || 'IA') + '…' :
s.ultima ? (s.modelo || 'IA') + ' · ' + s.ultima.segundos + 's' :
'Pronto para conversar.';
}
const next = JSON.stringify([s.historico, s.lembrancas, s.ultima]);
if (signature === next) return;
signature = next;
// Renderiza Histórico de Mensagens como Balões de Chat
const hist = el('conversaHistorico');
if (hist) {
hist.replaceChildren();
if (!s.historico || s.historico.length === 0) {
const vazio = document.createElement('div');
vazio.style.cssText = 'text-align:center;color:var(--fraco);padding:40px 20px;font-size:14px';
vazio.innerHTML = `
<div style="font-size:36px;margin-bottom:10px">🤖</div>
<strong>O Carinha está pronto para conversar!</strong>
<p style="margin-top:6px;font-size:13px">Envie uma mensagem abaixo ou use os atalhos de sugestão.</p>
`;
hist.append(vazio);
} else {
for (const m of s.historico) {
const row = document.createElement('div');
row.className = 'chat-row ' + (m.role === 'user' ? 'user' : 'assistant');
const avatar = document.createElement('div');
avatar.className = 'chat-avatar';
avatar.textContent = m.role === 'user' ? '👤' : '🤖';
const content = document.createElement('div');
content.className = 'chat-content';
const balloon = document.createElement('div');
balloon.className = 'chat-balloon';
balloon.textContent = m.content;
content.append(balloon);
// Se for mensagem do robô, adiciona botão de áudio e gesto
if (m.role === 'assistant') {
const meta = document.createElement('div');
meta.className = 'chat-meta';
const speakBtn = document.createElement('button');
speakBtn.className = 'chat-speak-btn';
speakBtn.type = 'button';
speakBtn.title = 'Ouvir resposta (Voz)';
speakBtn.innerHTML = '🔊 Falar';
speakBtn.onclick = () => {
falarTexto(m.content);
if (window.RobotAudio) window.RobotAudio.click();
};
meta.append(speakBtn);
if (s.ultima && s.ultima.texto === m.content && s.ultima.gesto) {
const gestoBadge = document.createElement('span');
gestoBadge.className = 'chat-gesto-badge';
gestoBadge.title = 'Clique para ver a carinha fazer este gesto';
gestoBadge.innerHTML = `<span>✨</span><span>${s.ultima.gesto}</span>`;
gestoBadge.onclick = async () => {
try {
if (window.RobotAudio) window.RobotAudio.tocarGesto(s.ultima.gesto);
await fetch('/gesto/' + encodeURIComponent(s.ultima.gesto));
} catch (e) {
console.error(e);
}
};
meta.append(gestoBadge);
}
content.append(meta);
}
row.append(avatar, content);
hist.append(row);
}
hist.scrollTop = hist.scrollHeight;
}
// Fala automática da nova resposta da IA se a opção estiver ativada
const autoVoz = el('conversaVozAuto')?.checked;
if (autoVoz && s.ultima && s.ultima.texto && s.ultima.texto !== lastSpokenMessage) {
lastSpokenMessage = s.ultima.texto;
falarTexto(s.ultima.texto);
if (s.ultima.gesto && window.RobotAudio) {
window.RobotAudio.tocarGesto(s.ultima.gesto);
}
}
}
// Renderiza Lista de Lembranças / Memória
const memorias = el('conversaMemorias');
if (memorias) {
memorias.replaceChildren();
for (const m of s.lembrancas || []) {
const row = document.createElement('div');
row.className = 'memory-card';
const label = document.createElement('span');
label.style.cssText = 'color:var(--texto);font-size:13.5px;word-break:break-word';
label.textContent = m.texto;
const button = document.createElement('button');
button.type = 'button';
button.className = 'danger';
button.style.cssText = 'padding:4px 10px;font-size:12px;flex-shrink:0';
button.textContent = 'Apagar';
button.onclick = () => action('apagar', { id: m.id });
row.append(label, button);
memorias.append(row);
}
if (!s.lembrancas || !s.lembrancas.length) {
memorias.innerHTML = '<span style="color:var(--fraco);font-size:13px;padding:6px 0">Nenhuma lembrança salva no banco SQLite.</span>';
}
}
} catch (e) {
if (el('conversaEstado')) el('conversaEstado').textContent = e.message;
}
}
async function action(path, data) {
try {
await api(path, data);
await refresh();
return true;
} catch (e) {
if (el('conversaEstado')) el('conversaEstado').textContent = e.message;
return false;
}
}
// Troca de Provedor (Gemini / Qwen)
if (el('conversaProvedor')) {
el('conversaProvedor').onchange = () => action('config', { provedor: el('conversaProvedor').value });
}
// Envio de Mensagem
if (el('conversaForm')) {
el('conversaForm').onsubmit = async (e) => {
e.preventDefault();
const input = el('conversaTexto');
const val = input.value.trim();
if (!val) return;
sending = true;
if (el('conversaEnviar')) el('conversaEnviar').disabled = true;
if (window.RobotAudio) window.RobotAudio.click();
try {
if (await action('enviar', { texto: val })) {
input.value = '';
}
} finally {
sending = false;
await refresh();
}
};
}
// Chips de Sugestão de Diálogo
document.querySelectorAll('.suggestion-chip').forEach(chip => {
chip.onclick = async () => {
const msg = chip.getAttribute('data-msg');
const input = el('conversaTexto');
if (input && msg) {
input.value = msg;
if (el('conversaForm')) {
el('conversaForm').requestSubmit();
}
}
};
});
// Salvar Lembrança
if (el('memoriaForm')) {
el('memoriaForm').onsubmit = async (e) => {
e.preventDefault();
const input = el('memoriaTexto');
if (await action('lembrar', { texto: input.value })) {
input.value = '';
if (window.RobotAudio) window.RobotAudio.bleep();
}
};
}
// Limpar Histórico
if (el('conversaLimpar')) {
el('conversaLimpar').onclick = () => {
if (confirm('Apagar todo o histórico de conversas e lembranças do Carinha?')) {
action('limpar', {});
}
};
}
setupSpeechRecognition();
refresh();
setInterval(refresh, 1800);
})();
-98
View File
@@ -1,98 +0,0 @@
(function() {
'use strict';
const video=document.getElementById('cameraVideo'), canvas=document.getElementById('trackingOverlay');
const ctx=canvas.getContext('2d'), label=document.getElementById('trackingEstado');
const R=window.Rastreamento;
const fresh=new R.FreshTarget();
const connections=[[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]];
let worker=null, ready=false, pending=false, generation=0, active=false, config={}, session=null;
let result=null, target=null, at=0, interval=50, nextFrame=0, lastVideo=-1, samples=0, retryAt=0, previousSample=0;
const scratch=document.createElement('canvas'), sc=scratch.getContext('2d');
function clear() { ctx.clearRect(0,0,canvas.width,canvas.height); }
function stop(text) {
if (active && window.CarinhaVideo) window.CarinhaVideo.rastrear(null);
active=false; generation++; if(worker) worker.terminate(); worker=null;
pending=false; ready=false; result=null; target=null; lastVideo=-1; fresh.clear(); previousSample=0; clear(); label.textContent=text;
}
function draw() {
const rect=canvas.parentElement.getBoundingClientRect();
if(canvas.width!==Math.round(rect.width) || canvas.height!==Math.round(rect.height)) {
canvas.width=Math.round(rect.width); canvas.height=Math.round(rect.height);
}
clear(); canvas.hidden=!config.mostrarRastreamento;
if(canvas.hidden || !result || performance.now()-at>350 || !video.videoWidth) return;
const fit=R.fit(canvas.width,canvas.height,video.videoWidth,video.videoHeight);
const point=p=>R.point(p,fit);
ctx.lineWidth=2; ctx.font='13px Segoe UI';
for(const f of result.faces) {
const p=point(f); ctx.strokeStyle='#5cd6ff'; ctx.fillStyle='#5cd6ff';
ctx.strokeRect(p.x,p.y,f.w*fit.w,f.h*fit.h);
ctx.fillText('Rosto '+Math.round(f.score*100)+'%',p.x,Math.max(15,p.y-6));
for(const q of f.pontos || []) { const v=point(q); ctx.beginPath();ctx.arc(v.x,v.y,3,0,Math.PI*2);ctx.fill(); }
}
for(const h of result.hands) {
ctx.strokeStyle='#78ffa0'; ctx.fillStyle='#78ffa0';
for(const [a,b] of connections) { const p=point(h[a]),q=point(h[b]); ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.lineTo(q.x,q.y);ctx.stroke(); }
for(const q of h) { const p=point(q);ctx.beginPath();ctx.arc(p.x,p.y,3,0,Math.PI*2);ctx.fill(); }
const p=point(h[0]);ctx.fillText('Mão',p.x+6,p.y+16);
}
if(target) { const p=point(target);ctx.strokeStyle='#ffd860';ctx.beginPath();ctx.arc(p.x,p.y,12,0,Math.PI*2);ctx.moveTo(p.x-18,p.y);ctx.lineTo(p.x+18,p.y);ctx.moveTo(p.x,p.y-18);ctx.lineTo(p.x,p.y+18);ctx.stroke(); }
}
function start() {
active=true; const gen=++generation; worker=new Worker('/rastreador-worker.js');
label.textContent='Carregando rastreadores locais…';
worker.onerror=e=>{ retryAt=Date.now()+10000; stop('Falha no rastreador: '+e.message); };
worker.onmessage=({data})=>{
if(gen!==generation) return;
pending=false;
if(data.tipo==='erro') { retryAt=Date.now()+10000; stop('Rastreamento indisponível: '+data.erro); return; }
ready=true;
if(data.tipo==='pronto') { label.textContent='Rastreadores prontos. Aguardando imagem…'; return; }
result=data; at=data.timestamp; samples++;
const prior=target;
target=R.target(data.faces,data.hands,prior);
if(target) target.atividade=!!prior && target.tipo===prior.tipo && Math.hypot(target.x-prior.x,target.y-prior.y)>.008;
if(target) fresh.accept(target,at); else fresh.clear();
interval=Math.max(50,Math.min(200,data.ms*1.2));
const hz=previousSample?Math.round(1000/(data.timestamp-previousSample)):0; previousSample=data.timestamp;
label.textContent=data.faces.length+' rosto(s) · '+data.hands.length+' mão(s) · '+hz+' análises/s · '+Math.round(data.ms)+' ms' +
(target?' · alvo: '+(target.tipo==='mao'?'mão':'rosto'):' · procurando…');
if(config.seguirRastreamento && window.CarinhaVideo) window.CarinhaVideo.rastrear(target);
draw();
};
pending=true; worker.postMessage({config:{rosto:config.rastrearRosto,maos:config.rastrearMaos}});
}
async function frame(now) {
if(!active || !ready || pending || now<nextFrame || video.readyState<2 || video.hidden || video.currentTime===lastVideo) return;
pending=true; nextFrame=now+interval; lastVideo=video.currentTime;
const gen=generation;
try {
const scale=Math.min(1,640/video.videoWidth);scratch.width=Math.round(video.videoWidth*scale);scratch.height=Math.round(video.videoHeight*scale);
sc.drawImage(video,0,0,scratch.width,scratch.height);
const bitmap=await createImageBitmap(scratch);
if(gen!==generation) { bitmap.close(); return; }
worker.postMessage({bitmap,timestamp:now,config:{rosto:config.rastrearRosto,maos:config.rastrearMaos}},[bitmap]);
} catch(e) { if(gen===generation) stop('Falha ao analisar vídeo: '+e.message); }
}
setInterval(()=>{const now=performance.now();frame(now);
if(fresh.value && !fresh.get(now)) { fresh.clear(); target=null; if(config.seguirRastreamento && window.CarinhaVideo) window.CarinhaVideo.rastrear(null); }
if(result && now-at>350) { result=null;target=null;clear(); }
},20);
window.addEventListener('resize',draw);
window.CarinhaRastreamento={
atualizar(status,c) {
const changed=config.rastrearRosto!==c.rastrearRosto || config.rastrearMaos!==c.rastrearMaos;
if(config.seguirRastreamento && !c.seguirRastreamento && window.CarinhaVideo) window.CarinhaVideo.rastrear(null);
config=c;
const allowed=status.online && c.preview && c.sensorCamera!==false && (!(status.estado || {}).dormindo || c.cameraSono);
if(!allowed || (!c.rastrearRosto && !c.rastrearMaos)) {
stop(!allowed?'Ligue o vídeo para rastrear no PC.':'Rastreamento desligado.'); return;
}
if(changed && active) stop('Atualizando rastreadores…');
if(!active && Date.now()>=retryAt) start(); draw();
},
parar() { stop('Vídeo desligado.'); },
estado() { return {active,ready,pending,samples,result,target}; }
};
window.addEventListener('pagehide',()=>stop('Painel fechado.'));
})();
-138
View File
@@ -1,138 +0,0 @@
(function() {
'use strict';
const video = document.getElementById('cameraVideo');
const mensagem = document.getElementById('cameraMensagem');
const qualidade = document.getElementById('videoQualidade');
let pc = null, id = null, desde = 0, ice = [], busy = false, wanted = false;
let opening = false, polling = false, generation = 0, retryAt = 0, last = null, outroPainel = false;
let trackingChannel = null;
let trackingSequence = 0;
let effectiveQuality = '';
async function req(path, data) {
const r = await fetch(path, {cache:'no-store',signal:AbortSignal.timeout(3500),
...(data === undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})});
const body = await r.json();
if (!r.ok) throw new Error(body.erro || 'HTTP ' + r.status);
return body;
}
function texto(t) {
mensagem.textContent = t;
mensagem.hidden = false; video.hidden = true;
}
function parar(fechar = true) {
generation++;
const antiga = id; id = null;
if (pc) pc.close(); pc = null; trackingChannel=null; ice = []; desde = 0; last = null;
if (window.CarinhaRastreamento) window.CarinhaRastreamento.parar();
video.pause(); video.srcObject = null; video.hidden = true;
if (antiga && fechar) req('/rtc/session',{acao:'fechar',id:antiga}).catch(() => {});
}
async function enviar(msg, sessao) {
await req('/rtc/send',{id:sessao || id,de:'pc',mensagem:msg});
}
async function iniciar() {
if (!wanted || pc || opening || outroPainel || Date.now() < retryAt) return;
opening = true;
const gen = generation;
texto('Conectando vídeo ao vivo… O rosto continua no celular.');
try {
const session = await req('/rtc/session',{acao:'abrir'});
if (gen !== generation || !wanted) { req('/rtc/session',{acao:'fechar',id:session.id}).catch(()=>{}); return; }
id = session.id;
const currentId = id;
pc = new RTCPeerConnection({iceServers:[]});
const mine = pc;
trackingChannel=pc.createDataChannel('rastreamento',{ordered:false,maxRetransmits:0});
pc.addTransceiver('video',{direction:'recvonly'});
pc.onicecandidate = event => {
if (pc === mine && event.candidate) enviar({tipo:'candidate',candidate:event.candidate.toJSON()}, currentId).catch(()=>{});
};
pc.ontrack = event => {
if (pc !== mine) return;
video.srcObject = event.streams[0] || new MediaStream([event.track]);
video.play().catch(e => texto('Clique em Ligar câmera para permitir o vídeo: ' + e.message));
};
pc.onconnectionstatechange = () => {
if (pc !== mine) return;
if (pc.connectionState === 'failed') {
parar(); retryAt = Date.now()+2000; texto('Reconectando o vídeo…');
}
};
await pc.setLocalDescription(await pc.createOffer());
if (pc !== mine) return;
await enviar({tipo:'offer',sdp:pc.localDescription.sdp}, currentId);
} catch (e) { parar(); retryAt = Date.now()+2500; texto('Falha ao iniciar vídeo: ' + e.message); }
finally { opening = false; }
}
async function poll() {
if (!id || !pc || polling) return;
polling = true;
const mine = pc, sid = id;
try {
const packet = await req('/rtc/poll?lado=pc&desde='+desde+'&id='+sid);
if (pc !== mine) return;
if (packet.id !== sid) {
outroPainel = !!packet.id; parar(false);
texto(outroPainel ? 'Vídeo aberto em outro painel. Recarregue para assumir o controle.' : 'Aguardando reconexão do vídeo…');
return;
}
for (const envelope of packet.mensagens) {
const msg = envelope.mensagem;
if (msg.tipo === 'answer') {
await pc.setRemoteDescription({type:'answer',sdp:msg.sdp});
for (const c of ice) await pc.addIceCandidate(c);
ice = [];
} else if (msg.tipo === 'candidate') {
if (pc.remoteDescription) await pc.addIceCandidate(msg.candidate); else ice.push(msg.candidate);
} else if (msg.tipo === 'erro') {
parar(); retryAt = Date.now()+3500;
texto('Câmera no celular: ' + msg.erro); return;
}
desde = Math.max(desde,envelope.seq);
}
} catch (e) { if (pc === mine) { parar(); retryAt = Date.now()+2500; texto('Sinalização interrompida. Reconectando…'); } }
finally { polling = false; }
}
video.onloadeddata = () => { video.hidden = false; mensagem.hidden = true; };
video.onplaying = () => { video.hidden = false; mensagem.hidden = true; };
let statsBusy = false;
setInterval(async () => {
if (!pc || pc.connectionState !== 'connected' || statsBusy) return;
statsBusy = true;
const mine = pc;
try {
const report = await pc.getStats(); if (pc !== mine) return;
report.forEach(s => {
if (s.type !== 'inbound-rtp' || (s.kind !== 'video' && s.mediaType !== 'video')) return;
const fps = last ? (s.framesDecoded-last.framesDecoded)*1000/(s.timestamp-last.timestamp) : s.framesPerSecond || 0;
const kbps = last ? (s.bytesReceived-last.bytesReceived)*8/(s.timestamp-last.timestamp) : 0;
last = s;
qualidade.textContent = (effectiveQuality==='auto' || (!effectiveQuality && appConfig?.videoResolucao === 'auto' && !appConfig?.videoFPS) ? 'Automática' : 'Manual') + ' · '+(s.frameWidth||video.videoWidth)+' × '+(s.frameHeight||video.videoHeight)+' · '+Math.round(fps)+' fps · '+Math.round(kbps)+' kb/s';
if (fps > 0) { video.hidden = false; mensagem.hidden = true; }
});
} catch (e) { /* conexão em transição */ }
finally { statsBusy = false; }
},1000);
setInterval(() => { iniciar(); poll(); },400);
window.CarinhaVideo = {
rastrear(alvo) {
if (trackingChannel && trackingChannel.readyState==='open' && trackingChannel.bufferedAmount<4096)
trackingChannel.send(JSON.stringify({tipo:'alvo',seq:++trackingSequence,alvo}));
},
rastreamentoConectado() { return !!trackingChannel && trackingChannel.readyState==='open'; },
parar() { wanted = false; outroPainel = false; parar(); },
atualizar(status, config) {
effectiveQuality=(status.estado || {}).video?.qualidade || '';
const liberada = config.sensorCamera !== false && (!(status.estado || {}).dormindo || config.cameraSono === true);
const nova = !!config.preview && !!status.online && liberada;
if (!nova) {
if (wanted || pc) parar(); wanted = false;
texto(!liberada ? 'Câmera desativada pelos ajustes de sensores.' : config.preview ? 'Celular sem conexão. Abra o Carinha.' : 'Transmissão desligada. O rosto e os sensores continuam funcionando.');
} else { wanted = true; iniciar(); }
}
};
window.addEventListener('pagehide', () => {
if (id) navigator.sendBeacon('/rtc/session',new Blob([JSON.stringify({acao:'fechar',id})],{type:'application/json'}));
wanted = false; parar(false);
});
})();
-247
View File
@@ -1,247 +0,0 @@
/* ==========================================================================
PAINEL DE CÂMERA & ROSTO · CARINHA HUD STYLE
========================================================================== */
/* Se o painel for carregado como iframe limpo (no topo), exibe APENAS a câmera */
html.is-clean-embed body {
margin: 0 !important;
padding: 0 !important;
overflow: hidden !important;
background: transparent !important;
}
html.is-clean-embed .painel-workspace {
display: block;
margin: 0;
}
html.is-clean-embed .painel-monitor-sticky {
width: 100% !important;
top: 0 !important;
position: static !important;
}
html.is-clean-embed .cartao {
margin: 0 !important;
padding: 0 !important;
border: none !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
}
/* Oculta quase tudo, deixa só a câmera (div.visao) */
html.is-clean-embed .cabecalho,
html.is-clean-embed .texto-fraco,
html.is-clean-embed .ajustes-grade,
html.is-clean-embed .sensores,
html.is-clean-embed .painel-controls-scroll,
html.is-clean-embed .rodape,
html.is-clean-embed #ajustesRastreamento,
html.is-clean-embed #trackingEstado,
html.is-clean-embed #sensorEstado {
display: none !important;
}
html.is-clean-embed h1,
html.is-clean-embed .sub,
html.is-clean-embed > div:first-of-type {
display: none !important;
}
/* Oculta o segundo cartão inteiro (Prévia do Celular) */
html.is-clean-embed .painel-monitor-sticky > section:not(:first-child) {
display: none !important;
}
/* Centraliza e expande o vídeo/imagem */
html.is-clean-embed .visao {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0;
border: none;
background: transparent;
min-height: unset;
aspect-ratio: unset;
}
html.is-clean-embed .visao video,
html.is-clean-embed .visao img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.supervisao {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr);
gap: 22px;
margin: 18px 0 24px;
}
.visao {
position: relative;
background: #020509;
border: 1px solid rgba(92, 214, 255, 0.35);
border-radius: var(--raio-lg);
min-height: 320px;
aspect-ratio: 16 / 9;
overflow: hidden;
display: grid;
place-items: center;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.7), inset 0 0 60px rgba(0, 0, 0, 0.9);
}
.visao::after {
content: "";
position: absolute;
inset: 12px;
border: 1px dashed rgba(92, 214, 255, 0.2);
border-radius: var(--raio-md);
pointer-events: none;
}
.visao img, .visao video {
width: 100%;
height: 100%;
object-fit: contain;
position: absolute;
inset: 0;
z-index: 2;
}
.visao p {
max-width: 44ch;
text-align: center;
color: #9bb7cf;
padding: 24px;
font-size: 14.5px;
line-height: 1.6;
z-index: 1;
}
.cabecalho {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.cabecalho h2 {
margin: 0;
font-size: 18px;
}
.sensores {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-top: 18px;
background: rgba(10, 18, 30, 0.6);
padding: 14px 18px;
border-radius: var(--raio-md);
border: 1px solid var(--borda);
}
meter {
display: block;
width: 100%;
height: 12px;
margin-top: 8px;
border-radius: 6px;
overflow: hidden;
background: rgba(20, 35, 55, 0.6);
}
meter::-webkit-meter-bar {
background: rgba(20, 35, 55, 0.6);
border-radius: 6px;
}
meter::-webkit-meter-optimum-value {
background: linear-gradient(90deg, #38bdf8, #5cd6ff);
box-shadow: 0 0 10px rgba(92, 214, 255, 0.5);
}
.rosto-preview {
width: 100%;
height: 190px;
border: 1px solid var(--borda);
border-radius: var(--raio-md);
background: #000000;
margin-top: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), inset 0 0 20px rgba(0, 0, 0, 0.8);
}
.ajustes {
margin-top: 22px;
}
.ajustes-grade {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px 28px;
margin-top: 12px;
}
.ajuste {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
padding: 8px 12px;
background: rgba(12, 21, 35, 0.5);
border: 1px solid rgba(56, 82, 110, 0.25);
border-radius: var(--raio-sm);
}
.ajuste-range {
margin-top: 10px;
padding: 10px 14px;
background: rgba(12, 21, 35, 0.5);
border: 1px solid rgba(56, 82, 110, 0.25);
border-radius: var(--raio-sm);
}
.ajuste-range label {
display: flex;
justify-content: space-between;
gap: 16px;
font-weight: 500;
}
#gestos button {
padding: 8px 14px;
font-size: 13px;
border-radius: var(--raio-sm);
background: rgba(22, 38, 60, 0.65);
border: 1px solid var(--borda);
transition: all 0.16s ease;
}
#gestos button:hover {
background: rgba(35, 62, 95, 0.85);
border-color: var(--ciano);
color: #ffffff;
transform: translateY(-2px);
box-shadow: 0 4px 14px rgba(92, 214, 255, 0.25);
}
#rostos button {
padding: 8px 16px;
font-weight: 600;
border-radius: var(--raio-sm);
}
#rostos button.on {
background: var(--ciano);
border-color: var(--ciano);
color: #041420;
font-weight: 700;
box-shadow: 0 0 16px var(--ciano-glow);
}
@media (max-width: 820px) {
.supervisao {
grid-template-columns: 1fr;
}
.visao {
min-height: 220px;
}
}
-379
View File
@@ -1,379 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>Câmera & Rosto · Central Carinha</title>
<script>
if (window.location.search.includes('clean=1')) {
document.documentElement.classList.add('is-clean-embed');
}
</script>
<link rel="stylesheet" href="/dashboard.css">
<link rel="stylesheet" href="/painel.css">
<script src="/dashboard.js" defer></script>
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
<div>
<h1>Câmera & Rosto</h1>
<p id="bateriaCelular" class="texto-fraco" role="status" style="margin:4px 0 0">Bateria do celular: aguardando dados.</p>
</div>
<div style="display:flex;gap:10px;align-items:center">
<span class="badge ativo"><span class="badge-dot"></span> Monitoramento Contínuo</span>
</div>
</div>
<div class="sub" style="margin-bottom:18px">Controle o rostinho no celular e acompanhe o que ele vê. O monitor da câmera e do rosto fica fixado na tela enquanto você ajusta gestos e comportamentos.</div>
<!-- WORKSPACE STICKY: Câmera e Rosto fixados na tela durante a rolagem de configurações -->
<div class="painel-workspace">
<!-- Coluna Fixa (Sticky Monitor) -->
<aside class="painel-monitor-sticky">
<!-- Câmera ao Vivo -->
<section class="cartao" style="margin-bottom:18px">
<div class="cabecalho">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
Olhar da Carinha
</h2>
<div style="display:flex;gap:8px;align-items:center">
<button id="bSnapshot" type="button" title="Capturar imagem da câmera">📸 Foto</button>
<button id="toggleCamera" class="principal" disabled>Ligar câmera</button>
</div>
</div>
<div class="visao">
<video id="cameraVideo" autoplay muted playsinline hidden aria-label="Vídeo ao vivo da câmera do celular"></video>
<img id="cameraImagem" alt="Imagem ao vivo da câmera do celular" hidden>
<canvas id="trackingOverlay" aria-label="Marcações do rastreamento" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none" hidden></canvas>
<p id="cameraMensagem">Ligue a câmera para acompanhar o ambiente. A imagem aparece aqui; o celular continua mostrando o rosto.</p>
</div>
<p class="texto-fraco" id="videoQualidade" style="margin:8px 0 4px">Qualidade automática · buscando 30 fps</p>
<div id="ajustesRastreamento" class="ajustes-grade" style="margin-top:8px"></div>
<p id="trackingEstado" class="texto-fraco" role="status" style="margin:4px 0">Aguardando vídeo para rastrear no PC.</p>
<div class="sensores">
<label>Microfone <span id="micValor">—</span><meter id="micNivel" min="0" max="1" value="0"></meter></label>
<label>Movimento <span id="movValor">—</span><meter id="movNivel" min="0" max="1" value="0"></meter></label>
</div>
<p class="texto-fraco" id="sensorEstado" style="margin:4px 0">Aguardando o app no celular.</p>
</section>
<!-- Prévia da Tela do Celular -->
<section class="cartao">
<div class="cabecalho">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/></svg>
Tela do Galaxy S10
</h2>
<span id="conexaoEstado" class="online">Aguardando</span>
</div>
<iframe class="rosto-preview" style="height:165px;margin-top:4px" src="/carinha.html?espelho=1&amp;camera=0&amp;audio=0" title="Prévia da aparência escolhida" tabindex="-1"></iframe>
<p class="texto-fraco" style="margin:6px 0 10px">Prévia da aparência. <span id="rostoConfirmado">Aguardando confirmação do celular.</span></p>
<button id="recarregarTela" type="button" style="width:100%;margin-bottom:10px">🔄 Recarregar tela do celular</button>
<details>
<summary>Conexão ADB e Rede</summary>
<form id="conectarForm" style="margin-top:10px">
<label for="celularIP">IP do celular</label>
<input id="celularIP" type="text" value="192.168.1.16" required>
<label for="pcIP">IP do servidor na rede</label>
<input id="pcIP" type="text" value="192.168.1.35" required>
<button id="conectarBotao" type="submit" style="width:100%">Conectar e abrir o app</button>
</form>
<p id="conectarEstado" class="texto-fraco" role="status" style="margin-top:6px">O celular e o PC precisam estar na mesma rede, com ADB Wi-Fi ativado.</p>
</details>
</section>
</aside>
<!-- Coluna Rolável (Controles & Configurações) -->
<main class="painel-controls-scroll">
<!-- Gestos -->
<section class="cartao">
<div class="cabecalho">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M12 2v4"/><path d="M12 18v4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg>
Disparo de Gestos
</h2>
<span class="badge" style="font-size:11px">Fila de Ações</span>
</div>
<p style="font-size:13px;margin:4px 0 12px">Clique para acionar a expressão física no robô com feedback sonoro instantâneo:</p>
<div class="linha" id="gestos" style="gap:10px"></div>
</section>
<!-- Rosto e Cor dos Olhos -->
<div class="grade" style="grid-template-columns:1fr 1fr">
<div class="cartao">
<h2>Rosto</h2>
<p style="font-size:13px;margin:4px 0 10px">Selecione a proporção e formato:</p>
<div class="linha" id="rostos"></div>
<div class="estado" id="estadoRosto" style="margin-top:12px;font-size:13.5px">—</div>
</div>
<div class="cartao">
<h2>Cor dos olhos</h2>
<p style="font-size:13px;margin:4px 0 10px">Paleta com brilho neon:</p>
<div class="linha" id="cores"></div>
<div class="olho">
<input type="color" id="corLivre" value="#5cd6ff">
<span style="font-size:13px">escolher outra cor</span>
</div>
<div class="previa" id="previa" style="margin-top:12px">
<div class="bolha" id="bolha1"></div>
<div class="bolha" id="bolha2"></div>
</div>
</div>
</div>
<!-- Aparência Atual -->
<div class="cartao">
<h2>Aparência Atual & Demos</h2>
<div class="estado" id="estadoAparencia">—</div>
<div class="linha" style="margin-top:14px">
<button id="bPadrao">Voltar ao padrão</button>
<button id="bAbracar" class="primary">Demo: acordar + feliz + coração</button>
</div>
</div>
<!-- Personalidade e IA local -->
<section class="cartao">
<details style="margin:0;border:0;padding:0">
<summary style="font-size:16px">Personalidade e IA Local (Qwen embarcado)</summary>
<div style="margin-top:12px">
<label style="display:flex;align-items:center;gap:10px">
<input id="iaAtiva" type="checkbox">
<span>Ativar cérebro autônomo do personagem</span>
</label>
<p class="texto-fraco">Qwen3 decide expressões a partir de sons e movimento. Processamento no celular; sem comandos de motores.</p>
<div style="display:flex;gap:10px;align-items:center;margin-top:10px">
<select id="iaEvento" aria-label="Evento para testar IA" style="margin:0">
<option value="saudacao">Alguém me cumprimentou</option>
<option value="som">Ouvi um som</option>
<option value="movimento">Vi movimento</option>
<option value="sozinho">Estou sozinho</option>
</select>
<button id="iaTestar" type="button">Testar reação</button>
</div>
<p id="iaEstado" role="status" style="margin-top:8px;font-size:13px">Carregando IA…</p>
</div>
</details>
</section>
<!-- Comportamento e Ajustes -->
<section class="cartao ajustes">
<div class="cabecalho">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Comportamento & Sensores
</h2>
<span class="badge" style="font-size:11px">Tempo Real</span>
</div>
<div id="ajustesBasicos" class="ajustes-grade"></div>
<details>
<summary>Sensibilidade e tempos de reação</summary>
<div id="ajustesAvancados" class="ajustes-grade"></div>
</details>
<div id="configEstado" role="status" style="margin-top:14px;color:var(--ciano)">Carregando configurações…</div>
</section>
</main>
</div>
<div class="rodape" style="margin-top:32px">
Servidor no Orange Pi. O vídeo é transmitido ao navegador e não é gravado.
</div>
<script>
"use strict";
/* -------------------------------------------------------------------------
Painel de controle da carinha (servido pelo kiosk-server.py em /painel).
Ele conversa com as MESMAS rotas que a pagina da cara usa:
GET /gestos lista de gestos
GET /faces lista de rostos
GET /config aparencia guardada (rosto + cor)
GET /gesto/<nome> enfileira um gesto
POST /config guarda e aplica rosto/cor
------------------------------------------------------------------------- */
const elGestos = document.getElementById("gestos");
const elRostos = document.getElementById("rostos");
const elCores = document.getElementById("cores");
const elCorLivre = document.getElementById("corLivre");
const elEstadoRosto = document.getElementById("estadoRosto");
const elEstadoAparencia = document.getElementById("estadoAparencia");
const bPadrao = document.getElementById("bPadrao");
const bAbracar = document.getElementById("bAbracar");
const bolha1 = document.getElementById("bolha1");
const bolha2 = document.getElementById("bolha2");
const PADRAO = { rosto: "carinha", cor: [92, 214, 255] };
const PALETA = ["#5CD6FF", "#96E8FF", "#FF7070", "#FFB070", "#FFD860", "#78FFA0",
"#82AAFF", "#FF70B0", "#F5FAFF"];
let aparencia = Object.assign({}, PADRAO);
const paraHex = (c) => "#" + c.map((v) => Math.max(0, Math.min(255, Math.round(v)))
.toString(16).padStart(2, "0")).join("");
const deHex = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16),
parseInt(h.slice(5, 7), 16)];
const mesmoTom = (a, b) => a && b && a.every((v, i) => Math.abs(v - b[i]) < 6);
async function pedir(rota, opcoes) {
try {
const r = await fetch(rota, opcoes);
if (!r.ok) throw new Error("HTTP " + r.status);
return await r.json();
} catch (e) {
estado("Falha ao falar com o servidor (" + e.message + ")");
return null;
}
}
function estado(texto) {
elEstadoAparencia.innerHTML = texto;
}
function pintarPrevia() {
const c = paraHex(aparencia.cor || PADRAO.cor);
bolha1.style.background = c;
bolha2.style.background = c;
const hex = paraHex(aparencia.cor || PADRAO.cor);
elCorLivre.value = hex;
for (const b of elCores.children) b.classList.toggle("on", b.dataset.hex === hex);
for (const b of elRostos.children)
b.classList.toggle("on", b.dataset.rosto === aparencia.rosto);
elEstadoRosto.innerHTML = "Em uso: <b>" + (aparencia.rosto || "?") + "</b>";
estado("Rosto <b>" + (aparencia.rosto || "?") + "</b> · cor dos olhos <b>" + hex +
"</b><br><span style='color:#7f97ab'>configuração guardada no PC</span>");
}
/* Manda a aparencia para o servidor: ele guarda E enfileira a ordem certa. */
async function salvarAparencia(parte) {
const dados = {};
if (parte === "rosto") dados.rosto = aparencia.rosto;
if (parte === "cor") dados.cor = aparencia.cor;
const r = await pedir("/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dados),
});
if (r && r.aparencia) {
aparencia = Object.assign(aparencia, r.aparencia);
pintarPrevia();
}
}
function montarGestos(lista) {
elGestos.innerHTML = "";
for (const nome of lista) {
const b = document.createElement("button");
b.textContent = nome.replace(/_/g, " ");
b.onclick = async () => {
if (window.RobotAudio) window.RobotAudio.tocarGesto(nome);
const resposta = await pedir("/gesto/" + encodeURIComponent(nome));
if (resposta) estado("Gesto enviado: <b>" + nome + "</b>");
};
elGestos.appendChild(b);
}
}
function montarRostos(lista) {
elRostos.innerHTML = "";
for (const nome of lista) {
const b = document.createElement("button");
b.textContent = nome;
b.dataset.rosto = nome;
b.onclick = () => {
if (window.RobotAudio) window.RobotAudio.click();
aparencia.rosto = nome; pintarPrevia(); salvarAparencia("rosto");
};
elRostos.appendChild(b);
}
}
function montarCores() {
elCores.innerHTML = "";
for (const hex of PALETA) {
const b = document.createElement("button");
b.className = "swatch";
b.dataset.hex = hex;
b.style.background = hex;
b.title = hex;
b.onclick = () => {
if (window.RobotAudio) window.RobotAudio.click();
aparencia.cor = deHex(hex); pintarPrevia(); salvarAparencia("cor");
};
elCores.appendChild(b);
}
}
// Botão de Snapshot / Foto
const elSnapshot = document.getElementById("bSnapshot");
if (elSnapshot) {
elSnapshot.onclick = () => {
const video = document.getElementById("cameraVideo");
const img = document.getElementById("cameraImagem");
const canvas = document.createElement("canvas");
let ok = false;
if (video && !video.hidden && video.videoWidth > 0) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d").drawImage(video, 0, 0);
ok = true;
} else if (img && !img.hidden && img.naturalWidth > 0) {
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
canvas.getContext("2d").drawImage(img, 0, 0);
ok = true;
}
if (ok) {
if (window.RobotAudio) window.RobotAudio.click();
const a = document.createElement("a");
a.download = "carinha-foto-" + Date.now() + ".png";
a.href = canvas.toDataURL("image/png");
a.click();
estado("Foto salva da câmera!");
} else {
alert("Ligue a câmera e aguarde o vídeo para tirar uma foto.");
}
};
}
elCorLivre.oninput = () => { aparencia.cor = deHex(elCorLivre.value); pintarPrevia();
salvarAparencia("cor"); };
bPadrao.onclick = () => {
aparencia = { rosto: PADRAO.rosto, cor: PADRAO.cor.slice() };
pintarPrevia();
salvarAparencia("rosto").then(() => salvarAparencia("cor"));
};
bAbracar.onclick = async () => {
await pedir("/gesto/acordar");
setTimeout(() => pedir("/gesto/feliz"), 900);
setTimeout(() => pedir("/gesto/coracao"), 2200);
estado("Demo enviada: <b>acordar → feliz → coração</b>");
};
/* Monta as listas a partir do servidor (rostos, gestos, aparencia guardada). */
async function iniciar() {
const cfg = await pedir("/config");
if (cfg) aparencia = Object.assign(PADRAO, cfg);
const rostos = (await pedir("/faces")) || { rostos: ["eilik", "carinha", "miudo"] };
montarRostos(rostos.rostos || []);
const gestos = (await pedir("/gestos")) || { gestos: [] };
montarGestos(gestos.gestos || []);
montarCores();
pintarPrevia();
}
iniciar();
</script>
<script src="/painel-controle.js"></script>
<script src="/painel-video.js"></script>
<script src="/rastreamento-core.js"></script>
<script src="/painel-rastreamento.js"></script>
</body>
</html>
-33
View File
@@ -1,33 +0,0 @@
(function(root) {
'use strict';
const valid = p => p && Number.isFinite(p.x) && Number.isFinite(p.y);
const clamp = v => Math.max(0,Math.min(1,v));
function target(faces, hands, previous) {
let candidates = hands.filter(h=>h.length===21 && h.every(valid)).map(h=>{
const palm = [0,5,9,13,17].map(i=>h[i]);
return {tipo:'mao',x:palm.reduce((s,p)=>s+p.x,0)/5,y:palm.reduce((s,p)=>s+p.y,0)/5};
});
if (!candidates.length) candidates = faces.filter(f=>valid(f) && Number.isFinite(f.w) && Number.isFinite(f.h) && 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 (!candidates.length) return null;
if (previous && previous.tipo === candidates[0].tipo) candidates.sort((a,b)=>
Math.hypot(a.x-previous.x,a.y-previous.y)-Math.hypot(b.x-previous.x,b.y-previous.y));
const t = candidates[0]; t.x=clamp(t.x); t.y=clamp(t.y); return t;
}
function fit(w,h,vw,vh) {
const scale = Math.min(w/vw,h/vh);
return {x:Math.max(0,(w-vw*scale)/2),y:Math.max(0,(h-vh*scale)/2),w:vw*scale,h:vh*scale};
}
function point(p,r) { return {x:r.x+p.x*r.w,y:r.y+p.y*r.h}; }
class FreshTarget {
constructor() { this.value=null; this.at=-Infinity; }
accept(p,now) {
if (!valid(p) || !['mao','rosto'].includes(p.tipo) || p.x<0 || p.x>1 || p.y<0 || p.y>1) return false;
this.value={tipo:p.tipo,x:p.x,y:p.y,atividade:p.atividade===true}; this.at=now; return true;
}
get(now) { return now-this.at <= 350 ? this.value : null; }
clear() { this.value=null; this.at=-Infinity; }
}
const api={target,fit,point,FreshTarget}; root.Rastreamento=api;
if(typeof module!=='undefined') module.exports=api;
})(typeof globalThis!=='undefined'?globalThis:window);
+3 -3
View File
@@ -15,13 +15,13 @@
function visual(recipe){
if(recipe.emoji){
const e=recipe.emoji;if(e.local==='olhos'){window.carinhaOlhosIcone=e.caractere;return;}
badge.style.cssText='position:fixed;right:5%;top:8%;z-index:15;font:clamp(42px,10vw,90px) sans-serif;pointer-events:none';badge.textContent=e.caractere;
badge.style.cssText='position:fixed;right:5%;top:8%;z-index:15;font:clamp(42px,10vw,90px) sans-serif;pointer-events:none;filter:drop-shadow(0 0 14px '+(recipe.passos[0].cor||'#5cd6ff')+')';badge.textContent=e.caractere;
animation=badge.animate([{transform:'scale(.92)'},{transform:'scale(1.06)'},{transform:'scale(.92)'}],{duration:1400,iterations:Infinity});return;
}
if(recipe.figurinha){
const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)return;
if(recipe.figurinha.local==='olhos'){window.carinhaOlhosDesenho=recipe.figurinha;return;}
badge.style.cssText='position:fixed;right:5%;top:8%;width:24%;height:30%;z-index:15;pointer-events:none';svg.style.cssText='width:100%;height:100%';badge.append(svg);
badge.style.cssText='position:fixed;right:5%;top:8%;width:24%;height:30%;z-index:15;pointer-events:none;filter:drop-shadow(0 0 14px '+(recipe.passos[0].cor||'#5cd6ff')+')';svg.style.cssText='width:100%;height:100%';badge.append(svg);
animation=svg.animate([{transform:'scale(.92) rotate(-3deg)'},{transform:'scale(1.05) rotate(3deg)'},{transform:'scale(.92) rotate(-3deg)'}],{duration:1500,iterations:Infinity});return;
}
if(['ideia','cafe'].includes(recipe.simbolo)){window.carinhaOlhosIcone=icons[recipe.simbolo];return;}
@@ -49,6 +49,6 @@
window.eilik.receita=render;
addEventListener('message',e=>{if(e.origin===location.origin&&e.source===parent&&e.data&&e.data.tipo==='previa-reacao')render(e.data.receita);});
if(new URLSearchParams(location.search).get('espelho')==='1'||location.protocol==='file:')return;
async function poll(){if(fetching||window.carinhaAtiva===false)return;fetching=true;try{const response=await fetch('/treinamento/expressao',{cache:'no-store',signal:AbortSignal.timeout(2000)});if(response.ok){const d=await response.json();if(d&&d.id!==last&&Date.now()/1000-d.horario<60){last=d.id;render(d.receita);}}}catch(e){}finally{fetching=false;}}
async function poll(){if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;try{const response=await fetch('/treinamento/expressao',{cache:'no-store',signal:AbortSignal.timeout(2000)});if(response.ok){const d=await response.json();if(d&&d.id!==last&&Date.now()/1000-d.horario<60){last=d.id;render(d.receita);}}}catch(e){}finally{fetching=false;}}
setInterval(poll,1000);
})();
+476
View File
@@ -0,0 +1,476 @@
/* rosto-motor.js — motor de pálpebras dos rostos novos (Carinha Viva, Bloco, Matriz, Lente).
*
* O carinha.html continua decidindo O QUE o rosto faz (gestos, reações, sensores, rastreamento);
* aqui só se desenha. Cada "forma" vira posição de pálpebra (cima, inclinação, baixo) e o motor
* suaviza a passagem entre elas, então feliz → triste não salta de um desenho para outro.
* Também acrescenta vida: respiração, sacadas e achatamento na piscada.
*/
(function () {
'use strict';
// forma → pálpebras. top: pálpebra de cima (0 aberto, 1 fechado); slant: + = canto interno sobe;
// bot: pálpebra de baixo sobe ("olho sorrindo").
const PALPEBRAS = {
aberto: { top: 0, slant: 0, bot: 0 },
surpreso: { top: 0, slant: 0, bot: 0 },
feliz: { top: 0, slant: 0, bot: 0.46 },
coracao: { top: 0, slant: 0, bot: 0.42 },
triste: { top: 0.3, slant: 0.32, bot: 0 },
bravo: { top: 0.34, slant: -0.42, bot: 0.06 },
dormir: { top: 0.9, slant: 0.04, bot: 0.08 },
};
// Medidas em frações do raio vertical do olho clássico (RY), para casar com o layout atual.
const ESTILOS = {
vivo: { w: 1.0, h: 1.45, r: 0.5, brilho: true },
bloco: { w: 1.35, h: 1.25, r: 0.22, brilho: true },
matriz: { w: 1.0, h: 1.45, r: 0.5, brilho: false, pontos: true },
lente: { w: 1.15, h: 1.15, r: 0.58, lente: true },
};
// Formato do olho, escolhido no painel e combinável com Viva, Bloco e Matriz.
const FORMATOS = {
padrao: null,
redondo: { w: 1.15, h: 1.15, r: 0.58 },
pilula: { w: 0.8, h: 1.55, r: 0.4 },
quadrado: { w: 1.3, h: 1.3, r: 0.12 },
largo: { w: 1.65, h: 0.9, r: 0.3 },
};
let tela = null, ctx = null, camada = null, cctx = null, baixa = null, bctx = null;
const estado = [novo(), novo()];
const sacada = { x: 0, y: 0, alvoX: 0, alvoY: 0, proxima: 0 };
let antes = 0;
function novo() { return { top: 0, slant: 0, bot: 0, coracao: 0 }; }
const css = (c, a) => 'rgba(' + c.map((v) => Math.round(v)).join(',') + ',' + (a === undefined ? 1 : a) + ')';
function preparar() {
if (tela) return;
tela = document.createElement('canvas');
tela.id = 'rostoMotor';
tela.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:2;display:none';
document.body.append(tela);
ctx = tela.getContext('2d');
camada = document.createElement('canvas'); cctx = camada.getContext('2d');
baixa = document.createElement('canvas'); bctx = baixa.getContext('2d', { willReadFrequently: true });
}
function medir() {
// Até 1,5x a densidade: a tela do S10 é enorme e o brilho custa caro.
const d = Math.min(1.5, window.devicePixelRatio || 1);
const w = Math.round(innerWidth * d), h = Math.round(innerHeight * d);
if (tela.width !== w || tela.height !== h) { tela.width = camada.width = w; tela.height = camada.height = h; }
return d;
}
function caixa(c, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w / 2, h / 2));
c.beginPath();
if (c.roundRect) c.roundRect(x, y, w, h, r);
else { // WebViews antigos
c.moveTo(x + r, y); c.arcTo(x + w, y, x + w, y + h, r); c.arcTo(x + w, y + h, x, y + h, r);
c.arcTo(x, y + h, x, y, r); c.arcTo(x, y, x + w, y, r); c.closePath();
}
}
function coracao(c, cx, cy, s) {
c.beginPath();
c.moveTo(cx, cy + s * 0.72);
c.bezierCurveTo(cx - s * 1.3, cy - s * 0.3, cx - s * 0.8, cy - s * 1.18, cx, cy - s * 0.3);
c.bezierCurveTo(cx + s * 0.8, cy - s * 1.18, cx + s * 1.3, cy - s * 0.3, cx, cy + s * 0.72);
c.fill();
}
const FONTE_EMOJI = '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif';
/** Figurinha (viewBox 100x100, só M L C Q Z) ou emoji, centrado no olho. */
function conteudo(c, o, cx, cy, tam) {
if (o.desenho && Array.isArray(o.desenho.formas)) {
c.save();
c.translate(cx - tam / 2, cy - tam / 2);
c.scale(tam / 100, tam / 100);
for (const f of o.desenho.formas) {
if (typeof f.caminho !== 'string' || !/^[MLCQZmlcqz0-9.,\s+-]+$/.test(f.caminho) || !/^#[0-9a-f]{6}$/i.test(f.cor)) continue;
c.fillStyle = f.cor;
c.fill(new Path2D(f.caminho));
}
c.restore();
} else if (o.icone) {
c.save();
c.font = Math.round(tam * 0.8) + 'px ' + FONTE_EMOJI;
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(o.icone, cx, cy + tam * 0.04);
c.restore();
}
}
// Olho plano (vivo, bloco, matriz): corpo + pálpebras recortadas (transparentes).
function olhoPlano(c, o, p, est, e) {
const respira = 1 + 0.012 * Math.sin(o.t * 1.4);
const temConteudo = !!(o.icone || o.desenho);
if (temConteudo) e = { top: 0, slant: 0, bot: 0, coracao: 0 };
const fecha = Math.min(1, Math.max(e.top, 1 - Math.min(1, p.ab)));
const abre = Math.max(1, p.ab); // surpreso abre além de 1
const amassa = fecha > 0.5 && e.top < 0.5 ? (fecha - 0.5) * 0.2 : 0; // piscada achata e alarga
const w = est.w * p.ry * respira * (1 + amassa);
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
const x = p.cx - w / 2, y = p.cy - h / 2;
c.fillStyle = css(o.cor);
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
caixa(c, x, y, w, h, est.r * p.ry * respira);
c.fill();
c.save();
caixa(c, x - 2, y - 2, w + 4, h + 4, est.r * p.ry);
c.clip();
c.globalCompositeOperation = 'destination-out';
c.fillStyle = '#000';
const sl = p.lado > 0 ? e.slant : -e.slant; // lado +1 = olho esquerdo; canto interno = lado do nariz
const yEsq = y + h * (fecha + sl * 0.5), yDir = y + h * (fecha - sl * 0.5);
c.beginPath(); c.moveTo(x - 4, y - 4); c.lineTo(x + w + 4, y - 4); c.lineTo(x + w + 4, yDir); c.lineTo(x - 4, yEsq); c.closePath(); c.fill();
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over';
if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74);
else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012));
const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012));
c.fillStyle = 'rgba(245,250,255,0.9)';
c.beginPath(); c.ellipse(hx, hy, w * 0.1, h * 0.08, 0, 0, Math.PI * 2); c.fill();
}
c.restore();
}
// Olho lente: esclera, íris, pupila que dilata e pálpebras de metal.
function olhoLente(c, o, p, est, e) {
const R = est.w * p.ry * 0.5 * Math.max(1, p.ab) * (1 + 0.01 * Math.sin(o.t * 1.4));
const fecha = Math.min(1, Math.max(o.icone || o.desenho ? 0 : e.top, 1 - Math.min(1, p.ab)));
c.save();
c.beginPath(); c.arc(p.cx, p.cy, R * 1.12, 0, Math.PI * 2); c.fillStyle = '#1b2029'; c.fill();
c.beginPath(); c.arc(p.cx, p.cy, R, 0, Math.PI * 2); c.clip();
c.fillStyle = '#e9eef3'; c.fillRect(p.cx - R, p.cy - R, 2 * R, 2 * R);
if (o.icone || o.desenho) {
conteudo(c, o, p.cx, p.cy, R * 1.3);
e = { top: 0, slant: 0, bot: 0 };
} else {
const ix = p.cx + Math.max(-R * 0.4, Math.min(R * 0.4, (p.lx + sacada.x) * R * 0.02));
const iy = p.cy + Math.max(-R * 0.35, Math.min(R * 0.35, (p.ly + sacada.y) * R * 0.02));
const g = c.createRadialGradient(ix, iy, R * 0.05, ix, iy, R * 0.55);
g.addColorStop(0, css(o.cor.map((v) => v * 0.5))); g.addColorStop(1, css(o.cor));
c.beginPath(); c.arc(ix, iy, R * 0.55, 0, Math.PI * 2); c.fillStyle = g; c.fill();
const pupila = p.ab > 1.05 ? 0.12 : e.bot > 0.3 ? 0.3 : 0.2; // susto contrai, alegria dilata
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fillStyle = '#05070a'; c.fill();
c.beginPath(); c.arc(ix - R * 0.2, iy - R * 0.22, R * 0.09, 0, Math.PI * 2); c.fillStyle = 'rgba(255,255,255,0.9)'; c.fill();
}
const metal = c.createLinearGradient(0, p.cy - R, 0, p.cy + R);
metal.addColorStop(0, '#5b6675'); metal.addColorStop(1, '#2b323d');
c.fillStyle = metal;
const sl = p.lado > 0 ? e.slant : -e.slant;
const yEsq = p.cy - R + 2 * R * (fecha + sl * 0.5), yDir = p.cy - R + 2 * R * (fecha - sl * 0.5);
c.beginPath(); c.moveTo(p.cx - R, p.cy - R); c.lineTo(p.cx + R, p.cy - R); c.lineTo(p.cx + R, yDir); c.lineTo(p.cx - R, yEsq); c.closePath(); c.fill();
c.strokeStyle = '#8994a3'; c.lineWidth = Math.max(1, R * 0.03);
c.beginPath(); c.moveTo(p.cx - R, yEsq); c.lineTo(p.cx + R, yDir); c.stroke();
c.beginPath(); c.ellipse(p.cx, p.cy + R * (2.3 - 2 * e.bot), R * 1.3, R * 1.3, 0, 0, Math.PI * 2); c.fill();
c.restore();
}
function atualizarPalpebras(dt, olhos, lento) {
const k = 1 - Math.exp(-dt * (lento ? 4 : 11));
olhos.forEach((p, i) => {
const alvo = PALPEBRAS[p.forma] || PALPEBRAS.aberto, e = estado[i];
e.top += (alvo.top - e.top) * k;
e.slant += (alvo.slant - e.slant) * k;
e.bot += (alvo.bot - e.bot) * k;
e.coracao += ((p.forma === 'coracao' ? 1 : 0) - e.coracao) * Math.min(1, k * 2);
});
}
function atualizarSacada(t, dt, parado) {
// Pequenos saltos do olhar quando nada está acontecendo; olhos de verdade nunca ficam parados.
if (parado && t > sacada.proxima) {
sacada.alvoX = (Math.random() - 0.5) * 14;
sacada.alvoY = (Math.random() - 0.5) * 6;
sacada.proxima = t + 0.8 + Math.random() * 2.4;
} else if (!parado) { sacada.alvoX = sacada.alvoY = 0; }
const k = 1 - Math.exp(-dt * 22);
sacada.x += (sacada.alvoX - sacada.x) * k;
sacada.y += (sacada.alvoY - sacada.y) * k;
}
function desenharPontos(estilo, o, olhos, d) {
// Matriz: desenha os olhos numa tela pequena e acende um LED por pixel.
const linhas = 38, colunas = Math.round(linhas * tela.width / tela.height);
if (baixa.width !== colunas || baixa.height !== linhas) { baixa.width = colunas; baixa.height = linhas; }
const f = linhas / tela.height * d;
bctx.clearRect(0, 0, colunas, linhas);
bctx.save(); bctx.scale(f, f);
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
desenharMaos(bctx, o, olhos, tela.height / d);
bctx.restore();
const px = bctx.getImageData(0, 0, colunas, linhas).data;
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
const passo = tela.width / colunas, raio = passo * 0.36;
for (let j = 0; j < linhas; j++) for (let i = 0; i < colunas; i++) {
const k = (j * colunas + i) * 4, a = px[k + 3] / 255;
ctx.beginPath(); ctx.arc((i + 0.5) * passo, (j + 0.5) * passo, raio, 0, Math.PI * 2);
ctx.fillStyle = a > 0.25 ? css([px[k], px[k + 1], px[k + 2]], 0.35 + a * 0.65) : 'rgba(255,255,255,0.045)';
ctx.fill();
}
}
/**
* Desenha um quadro. o = { estilo, formato, t, cor:[r,g,b], dormindo, parado,
* olhos: [{ cx, cy, ry, ab, forma, lado, lx, ly }, ...] } em pixels CSS.
*/
function desenhar(o) {
preparar();
const base = ESTILOS[o.estilo];
if (!base) return esconder();
const formato = !base.lente && FORMATOS[o.formato];
const estilo = formato ? Object.assign({}, base, formato) : base;
tela.style.display = '';
const d = medir();
const dt = antes ? Math.min(0.1, o.t - antes) : 0.033;
antes = o.t;
atualizarPalpebras(dt, o.olhos, o.dormindo);
atualizarSacada(o.t, dt, o.parado);
const olhos = o.olhos.map((p) => Object.assign({}, p, {
cx: (p.cx + (o.parado ? sacada.x * 0.35 : 0)) * d,
cy: (p.cy + (o.parado ? sacada.y * 0.35 : 0)) * d,
ry: p.ry * d,
}));
ctx.clearRect(0, 0, tela.width, tela.height);
if (estilo.pontos) {
desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
} else if (estilo.lente) {
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
desenharMaos(ctx, o, olhos, tela.height);
} else {
cctx.clearRect(0, 0, camada.width, camada.height);
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
desenharMaos(cctx, o, olhos, tela.height);
ctx.save();
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
ctx.drawImage(camada, 0, 0);
ctx.restore();
}
desenharFaixa(ctx, o, tela.width, tela.height);
}
// ------------------------------------------------------------------ mãos
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências
// de rosto de mesmo nome no carinha.html.
const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 };
const maos = { modo: null, t0: null };
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); };
const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
// Mão de desenho animado: palma, quatro dedos (o do meio maior), polegar para o lado do rosto
// e um punho de robô. dobra (0–1) fecha os dedos; mexe (0–1) agita os dedos ao acenar.
// O contorno é traçado antes do preenchimento: assim só a borda externa aparece.
function capsula(c, x0, y0, x1, y1, r) {
const a = Math.atan2(y1 - y0, x1 - x0) + Math.PI / 2;
c.moveTo(x0 + Math.cos(a) * r, y0 + Math.sin(a) * r);
c.arc(x0, y0, r, a, a + Math.PI);
c.arc(x1, y1, r, a + Math.PI, a + 2 * Math.PI);
c.closePath();
}
function formaMao(c, s, dobra, mexe, t, indicador) {
c.beginPath();
// punho (manga do robô)
caixa(c, -0.42 * s, 0.5 * s, 0.84 * s, 0.38 * s, 0.12 * s);
// palma
caixa(c, -0.5 * s, -0.36 * s, 1.0 * s, 0.95 * s, 0.34 * s);
// dedos: x da base, comprimento e abertura
// mindinho, anelar, médio e indicador (o indicador fica do lado do polegar)
const dedos = [[-0.355, 0.5, -0.16], [-0.12, 0.68, -0.05], [0.12, 0.74, 0.05], [0.355, 0.66, 0.14]];
dedos.forEach(([x, comp, abre], i) => {
const onda = mexe ? Math.sin(t * 14 + i * 0.9) * 0.06 * mexe : 0;
const fecha = indicador && i === 3 ? 0 : dobra; // "pensando": só o indicador esticado
const ang = abre + onda, l = comp * (1 - 0.7 * fecha) * s;
const bx = x * s, by = -0.26 * s;
capsula(c, bx, by, bx + Math.sin(ang) * l, by - Math.cos(ang) * l, 0.115 * s);
});
// polegar, saindo para o lado
capsula(c, 0.38 * s, 0.2 * s, (0.38 + 0.42 * (1 - 0.4 * dobra)) * s, (0.2 - 0.34) * s, 0.13 * s);
}
function mao(c, o, x, y, s, ang, espelho, dobra, mexe, indicador, pulso, costas) {
dobra = dobra || 0; mexe = mexe || 0;
s *= 0.82;
const escura = o.cor.map((v) => v * 0.34);
c.save();
c.translate(x, y); c.rotate(ang); c.scale(espelho, 1);
if (pulso) c.translate(0, -0.7 * s); // (x, y) é o punho: gira como uma mão de verdade
c.lineJoin = 'round'; c.lineCap = 'round';
formaMao(c, s, dobra, mexe, o.t || 0, indicador);
c.strokeStyle = css(o.cor); c.lineWidth = s * 0.17; c.stroke(); // contorno (metade some sob o preenchimento)
c.fillStyle = css(escura); c.fill();
// detalhes por dentro, finos: linha da palma, vincos dos dedos e a costura do punho
c.strokeStyle = css(o.cor, 0.55); c.lineWidth = s * 0.045;
if (costas) { // dorso da mão: nós dos dedos em vez da linha da palma
for (const nx of [-0.355, -0.12, 0.12, 0.355]) { c.beginPath(); c.arc(nx * s, -0.2 * s, 0.075 * s, Math.PI * 1.15, Math.PI * 1.85); c.stroke(); }
} else {
c.beginPath(); c.moveTo(-0.3 * s, 0.12 * s); c.quadraticCurveTo(0.02 * s, 0.26 * s, 0.3 * s, 0.06 * s); c.stroke();
}
c.beginPath(); c.moveTo(-0.36 * s, 0.6 * s); c.lineTo(0.36 * s, 0.6 * s); c.stroke();
c.restore();
}
// Sobe com um leve "passa e volta" no fim (como um braço que freia).
const voltinha = (x) => { x = Math.max(0, Math.min(1, x)); const k = 1.6; return 1 + (k + 1) * Math.pow(x - 1, 3) + k * Math.pow(x - 1, 2); };
function desenharMaos(c, o, olhos, H) {
if (!maos.modo) return;
if (maos.t0 === null) maos.t0 = o.t;
const k = (o.t - maos.t0) / DUR_MAOS[maos.modo];
if (k >= 1) { maos.modo = null; return; }
const [e, d] = olhos.slice().sort((a, b) => a.cx - b.cx); // olho da esquerda e da direita na tela
const s = e.ry * 0.95, fora = H + s * 1.8;
if (maos.modo === 'esconder' || maos.modo === 'espiar') {
// Cada mão vem do canto de baixo, por fora, num arco, girando até ficar inclinada para dentro.
// A da direita chega um pouco depois: duas mãos nunca se mexem igualzinho.
const cobrir = (olho, lado, atraso) => {
const ida = voltinha((k - atraso) / 0.24), volta = faixaDe(k, 0.8 + atraso * 0.5, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta); // 0 = fora da tela, 1 = cobrindo
const alvoX = olho.cx - lado * olho.ry * 0.05, alvoY = olho.cy + olho.ry * 0.38;
const x = alvoX - lado * olho.ry * 1.6 * (1 - Math.min(1, v));
const y = fora + (alvoY - fora) * v;
const respira = Math.sin(o.t * 3 + lado) * s * 0.03 * Math.min(1, v);
const ang = lado * (0.42 + 0.55 * (1 - Math.min(1, v))); // gira enquanto sobe, termina a ~24°
return { x, y: y + respira, ang };
};
const pe = cobrir(e, 1, 0), pd = cobrir(d, -1, 0.05);
if (maos.modo === 'espiar') {
// a mão da esquerda gira pelo punho para fora, como uma porta, e o olho aparece
const abre = faixaDe(k, 0.3, 0.44) - faixaDe(k, 0.64, 0.76);
pe.ang -= abre * 1.05; pe.x -= abre * e.ry * 0.35; pe.y += abre * e.ry * 0.2;
}
const treme = Math.sin(o.t * 22) * s * 0.012; // tímido: tremidinha
// dorso virado para quem olha: polegar para fora, perto da bochecha
mao(c, o, pe.x + treme, pe.y, s * 1.1, pe.ang, -1, 0, 0, false, true, true);
mao(c, o, pd.x - treme, pd.y, s * 1.1, pd.ang, 1, 0, 0, false, true, true);
} else if (maos.modo === 'acenar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
const x = d.cx + d.ry * 2.2 + d.ry * 0.6 * (1 - Math.min(1, v)), y0 = d.cy + d.ry * 1.55;
const y = fora + (y0 - fora) * v;
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0;
mao(c, o, x, y, s * 0.95, -0.18 + balanco, -1, 0, 1, false, true);
} else if (maos.modo === 'pensar') {
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
const x = (e.cx + d.cx) / 2 + e.ry * 1.05, y0 = e.cy + e.ry * 1.4;
const y = fora + (y0 - fora) * v + Math.sin(o.t * 9) * s * 0.03;
mao(c, o, x, y, s * 0.85, -0.5, -1, 0.95, 0, true);
}
}
// --------------------------------------------------------- faixa de baixo
// A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga.
const faixa = { modo: null, texto: '', t0: null, dur: 0 };
function definirFaixa(modo, opcoes) {
opcoes = opcoes || {};
if (!modo) {
if (!opcoes.so || faixa.modo === opcoes.so) faixa.modo = null; // so: só apaga esse modo
return;
}
faixa.modo = modo; faixa.t0 = performance.now() / 1000; faixa.texto = String(opcoes.texto || '').replace(/\s+/g, ' ').trim().slice(0, 400);
faixa.dur = opcoes.segundos || (modo === 'legenda' ? Math.max(3, Math.min(14, faixa.texto.length / 14 + 2.5)) : modo === 'digitando' ? 20 : 2.6);
}
function desenharFaixa(c, o, W, H) {
const y = H * 0.955, fonte = Math.round(H * 0.042);
c.save();
c.shadowColor = css(o.cor, 0.6); c.shadowBlur = H * 0.02;
if (o.carga && o.carga.carregando && typeof o.carga.nivel === 'number') { // carregando: canto direito
const n = Math.max(0, Math.min(100, o.carga.nivel)), bw = H * 0.075, bh = H * 0.036, bx = W - H * 0.13, by = y - bh / 2;
c.globalAlpha = 0.55 + 0.25 * Math.sin(o.t * 2.5);
c.strokeStyle = css(o.cor); c.lineWidth = Math.max(1, H * 0.005);
caixa(c, bx, by, bw, bh, bh * 0.25); c.stroke();
c.fillStyle = css(o.cor); c.fillRect(bx + bw, y - bh * 0.2, bh * 0.15, bh * 0.4);
c.fillRect(bx + bh * 0.12, by + bh * 0.15, (bw - bh * 0.24) * n / 100, bh * 0.7);
c.font = '600 ' + Math.round(fonte * 0.8) + 'px system-ui,sans-serif'; c.textAlign = 'right'; c.textBaseline = 'middle';
const tx = bx - H * 0.012;
c.fillText(Math.round(n) + '%', tx, y);
const rx = tx - c.measureText(Math.round(n) + '%').width - H * 0.022, r = H * 0.016; // raio desenhado na cor dos olhos
c.beginPath(); c.moveTo(rx + r * 0.3, y - r); c.lineTo(rx - r * 0.5, y + r * 0.15); c.lineTo(rx, y + r * 0.15);
c.lineTo(rx - r * 0.3, y + r); c.lineTo(rx + r * 0.5, y - r * 0.15); c.lineTo(rx, y - r * 0.15); c.closePath(); c.fill();
c.globalAlpha = 1;
}
if (faixa.modo) {
if (faixa.t0 === null) faixa.t0 = o.t;
const k = (o.t - faixa.t0) / faixa.dur;
if (k >= 1) faixa.modo = null;
else {
const some = Math.min(1, (faixa.dur - (o.t - faixa.t0)) / 0.4);
c.globalAlpha = Math.max(0, some);
c.fillStyle = css(o.cor);
if (faixa.modo === 'legenda') {
// efeito de digitação; se não couber, mostra o final do que já foi "dito"
let txt = faixa.texto.slice(0, Math.floor((o.t - faixa.t0) * 30));
c.font = '500 ' + fonte + 'px system-ui,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
const max = W * 0.8;
while (txt.length > 1 && c.measureText(txt).width > max) txt = txt.slice(1);
c.fillText(txt, W / 2, y);
} else if (faixa.modo === 'digitando') {
for (let i = 0; i < 3; i++) {
const pulo = Math.max(0, Math.sin(o.t * 7 - i * 0.7)) * H * 0.012;
c.beginPath(); c.arc(W / 2 + (i - 1) * H * 0.035, y - pulo, H * 0.011, 0, Math.PI * 2); c.fill();
}
} else if (faixa.modo === 'equalizador') {
const n = 15, larg = H * 0.016, passo = H * 0.026;
for (let i = 0; i < n; i++) {
const a = 0.25 + 0.75 * Math.abs(Math.sin(o.t * (5 + i % 4) + i * 1.3)) * Math.sin(Math.PI * (i + 0.5) / n);
const h = H * 0.04 * a;
caixa(c, W / 2 + (i - (n - 1) / 2) * passo - larg / 2, H * 0.988 - h, larg, h, larg / 2); c.fill();
}
}
}
}
c.restore();
}
/**
* Retrato parado num canvas qualquer (miniaturas da biblioteca no painel).
* o = { estilo, formato, cor, forma, icone, desenho }.
*/
function retrato(alvo, o) {
const base = ESTILOS[o.estilo] || ESTILOS.vivo;
const formato = !base.lente && FORMATOS[o.formato];
const est = formato ? Object.assign({}, base, formato) : base;
const W = alvo.width, H = alvo.height, c = alvo.getContext('2d');
const pal = Object.assign({ coracao: o.forma === 'coracao' ? 1 : 0 }, PALPEBRAS[o.forma] || PALPEBRAS.aberto);
const op = { t: 0, cor: o.cor || [92, 214, 255], icone: o.icone || null, desenho: o.desenho || null };
const ry = H * 0.36, sep = Math.min(W * 0.23, H * 0.62);
const olhos = [-1, 1].map((s) => ({ cx: W / 2 + s * sep, cy: H / 2, ry, ab: o.forma === 'surpreso' ? 1.12 : 1, forma: o.forma, lado: -s, lx: 0, ly: 0 }));
c.clearRect(0, 0, W, H);
if (est.lente) { olhos.forEach((p) => olhoLente(c, op, p, est, pal)); return; }
const tmp = document.createElement('canvas'); tmp.width = W; tmp.height = H;
const t = tmp.getContext('2d');
olhos.forEach((p) => olhoPlano(t, op, p, est, Object.assign({}, pal)));
if (est.pontos) {
const passo = Math.max(3, Math.round(H / 16)), d = t.getImageData(0, 0, W, H).data;
for (let y = passo / 2; y < H; y += passo) for (let x = passo / 2; x < W; x += passo) {
const k = (Math.floor(y) * W + Math.floor(x)) * 4, a = d[k + 3] / 255;
c.beginPath(); c.arc(x, y, passo * 0.38, 0, Math.PI * 2);
c.fillStyle = a > 0.25 ? css([d[k], d[k + 1], d[k + 2]], 0.4 + a * 0.6) : 'rgba(255,255,255,0.05)';
c.fill();
}
return;
}
c.save();
if (est.brilho) { c.shadowColor = css(op.cor, 0.7); c.shadowBlur = H * 0.08; }
c.drawImage(tmp, 0, 0);
c.restore();
}
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = {
desenhar, retrato, esconder,
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
})();
-8
View File
@@ -1,8 +0,0 @@
@echo off
cd /d "%~dp0"
set PORTA=%1
if "%PORTA%"=="" set PORTA=8100
echo Painel: http://localhost:%PORTA%/painel
echo Ctrl+C para parar.
python kiosk-server.py --porta %PORTA%
pause
-63
View File
@@ -1,63 +0,0 @@
/* ==========================================================================
SERVOS PCA9685 · ESTÚDIO DE CALIBRAÇÃO & CONTROLE
========================================================================== */
#cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
gap: 20px;
margin-top: 18px;
}
#cards article {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 12px;
}
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
background: rgba(10, 18, 30, 0.5);
padding: 12px;
border-radius: var(--raio-sm);
border: 1px solid rgba(56, 82, 110, 0.2);
}
.fields label {
font-size: 12.5px;
font-weight: 500;
color: var(--texto-medio);
}
input[type=range] {
margin: 14px 0 8px;
}
output {
font-size: 22px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
color: var(--ciano);
text-shadow: 0 0 10px rgba(92, 214, 255, 0.3);
}
#status {
min-height: 24px;
font-size: 13.5px;
color: var(--ciano);
}
.dirty {
color: #fbbf24;
font-size: 13px;
font-weight: 600;
}
@media (max-width: 680px) {
#cards {
grid-template-columns: 1fr;
}
}
-15
View File
@@ -1,15 +0,0 @@
<!doctype html>
<html lang="pt-BR"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Servos · Carinha</title><link rel="stylesheet" href="/dashboard.css"><link rel="stylesheet" href="/servos.css"><script src="/dashboard.js" defer></script>
<body>
<h1>Teste dos servos</h1>
<p class="intro">Escolha o canal impresso na placa (0 a 15). Ajuste o pulso e clique em Testar: o sinal dura até 2 segundos. Os limites iniciais são estreitos para começar a calibração. A posição mostrada é o comando, sem medição da posição real.</p>
<p class="intro">Ao desligar o sinal, o servo pode perder a força de sustentação. Nesta etapa, teste com o braço apoiado. Não há movimento automático ao abrir esta página.</p>
<label style="display:flex;gap:10px;align-items:center;font-size:16px"><input id="live" type="checkbox" style="width:22px;margin:0" disabled>Controle ao vivo — acompanhar o deslizador</label>
<p class="intro">No modo ao vivo, arrastar envia posições. O sinal desliga até 2 segundos após o último movimento. Desmarcar o modo ou desligar todos cancela os comandos pendentes.</p>
<div class="toolbar"><button id="stop" class="stop">Desligar todos os sinais</button><button id="save" class="primary">Salvar configurações</button><button id="add">Adicionar servo</button><span id="dirty"></span></div>
<p id="status" role="status" aria-live="polite">Verificando PCA9685…</p>
<div id="cards"></div>
<script src="/servos.js"></script>
</body></html>
-98
View File
@@ -1,98 +0,0 @@
'use strict';
const cards = document.querySelector('#cards'), statusEl = document.querySelector('#status');
let rows = [], available = false, dirty = false, busy = false;
const live = document.querySelector('#live');
let pending = null, liveRequest = null, liveTimer = null, lastSent = 0;
function cancelLive() {
live.checked = false; pending = null;
clearTimeout(liveTimer); liveTimer = null;
}
function queueLive(index, pulse) {
if (!live.checked || !available || dirty || busy) return;
pending = {indice:index,pulso:pulse};
pumpLive();
}
function pumpLive() {
if (!pending || liveRequest || liveTimer || !live.checked) return;
const wait = 100 - (performance.now() - lastSent);
if (wait > 0) {
liveTimer = setTimeout(() => {liveTimer = null; pumpLive();}, wait);
return;
}
const command = pending; pending = null; lastSent = performance.now();
liveRequest = api('mover', command).then(() => {
statusEl.textContent = `Ao vivo: canal ${rows[command.indice].canal}, ${command.pulso} µs. Desligamento 2 s após o último comando.`;
}).catch(e => { cancelLive(); statusEl.textContent = e.message; }).finally(() => {
liveRequest = null; pumpLive();
});
}
async function stopLive() {
cancelLive();
if (liveRequest) await liveRequest;
await api('parar',{});
}
async function api(path, data) {
const response = await fetch('/servos/' + path, data === undefined ? {} : {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const result = await response.json();
if (!response.ok) throw Error(result.erro || 'Falha no servidor');
return result;
}
function mark() { cancelLive(); dirty = true; document.querySelector('#dirty').textContent = 'Alterações não salvas'; updateButtons(); }
function updateButtons() {
cards.querySelectorAll('[data-test]').forEach(b => b.disabled = !available || dirty || busy);
document.querySelector('#add').disabled = rows.length >= 16 || busy;
document.querySelector('#save').disabled = busy;
live.disabled = !available || dirty || busy;
}
function render() {
cards.replaceChildren();
rows.forEach((row, index) => {
const section = document.createElement('section'); section.className = 'card';
const h = document.createElement('h2'); h.textContent = row.nome; section.append(h);
const fields = document.createElement('div'); fields.className = 'fields';
for (const [key, title, min, max] of [['nome','Nome'],['canal','Canal',0,15],['minimo','Mínimo (µs)',1000,2000],['maximo','Máximo (µs)',1000,2000],['centro','Centro (µs)',1000,2000]]) {
const label = document.createElement('label'); label.textContent = title;
const input = document.createElement('input'); input.type = key === 'nome' ? 'text' : 'number'; input.value = row[key];
if (key === 'nome') input.maxLength = 40; else { input.min = min; input.max = max; input.step = 1; }
input.addEventListener('input', () => { row[key] = key === 'nome' ? input.value : Number(input.value); mark(); if (key === 'nome') h.textContent = input.value; });
label.append(input); fields.append(label);
}
section.append(fields);
const label = document.createElement('label'); label.textContent = 'Pulso de teste';
const range = document.createElement('input'); range.type = 'range'; range.min = row.minimo; range.max = row.maximo; range.step = 5; range.value = row.centro;
const out = document.createElement('output'); out.textContent = range.value + ' µs';
range.addEventListener('input', () => {out.textContent = range.value + ' µs'; queueLive(index, Number(range.value));}); label.append(range, out); section.append(label);
const actions = document.createElement('div'); actions.className = 'actions';
for (const [text, center] of [['Testar por 2 s',false],['Testar centro',true]]) {
const button = document.createElement('button'); button.textContent = text; button.dataset.test = '1';
button.addEventListener('click', () => run(async () => { await api('mover',{indice:index,pulso:center ? row.centro : Number(range.value)}); statusEl.textContent = `${row.nome}: comando enviado ao canal ${row.canal}. Sinal será desligado em até 2 s.`; }));
actions.append(button);
}
section.append(actions); cards.append(section);
});
updateButtons();
}
async function run(action) {
busy = true; updateButtons();
try { await action(); } catch (e) { statusEl.textContent = e.message; }
finally { busy = false; updateButtons(); }
}
document.querySelector('#stop').onclick = () => run(async () => { await stopLive(); statusEl.textContent = 'Sinais desligados em todos os canais.'; });
live.onchange = () => {
if (!live.checked) run(async () => {await stopLive(); statusEl.textContent = 'Controle ao vivo desligado. Sinais desligados.';});
else statusEl.textContent = 'Controle ao vivo ativado. Arraste um deslizador para mover.';
};
document.addEventListener('visibilitychange', () => {
if (document.hidden && live.checked) run(stopLive);
});
document.querySelector('#save').onclick = () => run(async () => {
cancelLive(); if (liveRequest) await liveRequest;
const data = await api('config',{servos:rows}); rows = data.servos; dirty = false;
document.querySelector('#dirty').textContent = ''; render(); statusEl.textContent = 'Configurações salvas. Nenhum movimento enviado.';
});
document.querySelector('#add').onclick = () => {
const channel = Array.from({length:16},(_,i)=>i).find(i=>!rows.some(r=>r.canal===i));
if (channel === undefined) return;
rows.push({nome:`Servo ${rows.length+1}`,canal:channel,minimo:1400,maximo:1600,centro:1500}); mark(); render();
};
run(async () => { const data = await api('status'); rows = data.servos; available = data.disponivel; render(); statusEl.textContent = available ? `PCA9685 disponível. ${data.ativos.length ? 'Teste em andamento; desligamento automático em até 2 s.' : 'Sem testes em andamento.'}` : 'Controle indisponível neste servidor. Abra a aba no Orange Pi. ' + data.erro; });
+10 -1
View File
@@ -88,6 +88,15 @@ class ControleServos:
with self.lock:
return dict(disponivel=self.hw is not None, erro=self.error, ativos=sorted(self.active),
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):
"""Braço e diagnóstico usam o mesmo PCA9685 com limites diferentes: um de cada vez."""
with self.lock:
@@ -110,7 +119,7 @@ class ControleServos:
if self.hw is None: raise OSError('PCA9685 indisponível neste servidor')
self.reservar('diagnostico')
self.hw.pulse(row['canal'], pulse)
self.active[row['canal']] = time.monotonic() + 2
self.ligar([(row['canal'], time.monotonic() + 2)])
return self.status()
def expire(self, now):
with self.lock:
-37
View File
@@ -1,37 +0,0 @@
from playwright.sync_api import sync_playwright
import json
with sync_playwright() as p:
b=p.chromium.launch(headless=True); page=b.new_page(viewport={'width':1400,'height':1000})
cfg=dict(disponivel=True,erro='',calibracao=[dict(canal=i,minimo=1400,maximo=1600) for i in range(8)],posturas=[],articulacoes=[dict(nome=n,canal=c) for n,c in zip(['Garra','Punho 1','Punho 2','Braço superior','Braço inferior','Antebraço','Base'],[0,1,2,3,4,5,7])])
calls=[]; errors=[]
def handle(route):
if route.request.method=='POST':
data=route.request.post_data_json; calls.append((route.request.url,data))
if route.request.url.endswith('/config'): cfg.update(data)
route.fulfill(content_type='application/json',body=json.dumps(cfg))
page.route('**/braco/status',handle); page.route('**/braco/config',handle); page.route('**/braco/mover',handle); page.route('**/braco/parar',handle)
page.on('pageerror',lambda e:errors.append(str(e)))
page.goto('http://192.168.1.35:8100/braco');page.wait_for_selector('#joint6')
assert page.locator('#sliders input').count()==7
assert not page.locator('#enable').is_checked()
page.locator('#joint1').evaluate("e=>{e.value=95;e.dispatchEvent(new Event('input'))}");page.wait_for_timeout(200)
assert calls==[]
page.locator('#enable').check();assert calls==[]
page.locator('#joint1').evaluate("e=>{e.value=85;e.dispatchEvent(new Event('input'))}");page.wait_for_timeout(650)
moves=[d for u,d in calls if u.endswith('/mover')]
assert moves and moves[-1]['angulos'][1]==85,moves
assert len(moves[0]['angulos'])==7
page.locator('#stop').click();page.wait_for_timeout(200)
assert calls[-1][0].endswith('/parar');assert not page.locator('#enable').is_checked()
page.on('dialog',lambda d:d.accept('Teste centro'))
page.locator('#savePose').click();page.wait_for_timeout(250)
assert '1 posturas' in page.locator('#count').inner_text()
page.locator('#enable').check();page.locator('#play').click();page.wait_for_timeout(1100)
assert 'Sequência concluída' in page.locator('#console').inner_text()
page.locator('#stop').click();page.wait_for_timeout(200)
page.screenshot(path='C:/Users/JullioPC/Documents/Codex/2026-10-04/o/outputs/carinha-pc/docs/braco-painel.png',full_page=True)
page.set_viewport_size({'width':390,'height':844})
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth')
assert not errors,errors
print('OK: sete sliders, opt-in, velocidade, parar, salvar/reproduzir, mobile; comandos simulados')
b.close()
+10 -7
View File
@@ -18,6 +18,7 @@ def main():
kiosk.ARQUIVO_APARENCIA = str(Path(tmp) / 'aparencia.json')
server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
server.controle = kiosk.ControlePC(Path(tmp) / 'app.json')
kiosk.ligar_eventos(server) # o rosto recebe ajustes e ordens por SSE
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
@@ -63,21 +64,23 @@ def browser_test(base):
panel = browser.new_page(viewport={'width': 1440, 'height': 1000})
errors = []
panel.on('pageerror', lambda error: errors.append(str(error) + '\n' + error.stack))
panel.goto(base + '/painel')
panel.wait_for_selector('#gestos button')
panel.locator('#ajuste-sensorMic').uncheck()
# Painel novo (web/dist): ajustes do celular e transmissão.
panel.goto(base + '/#/ajustes')
alternar = lambda nome: panel.get_by_role('switch', name=nome).locator('xpath=..').click()
alternar('Microfone Só o nível')
panel.wait_for_timeout(400)
assert panel.request.get(base + '/app/config').json()['sensorMic'] is False
panel.locator('#ajuste-cameraSono').check()
alternar('Câmera durante o sono')
panel.wait_for_timeout(400)
panel.locator('#ajuste-cameraLado').select_option('environment')
panel.get_by_role('combobox', name='Câmera').select_option('environment')
panel.wait_for_timeout(400)
c = panel.request.get(base + '/app/config').json()
assert c['cameraSono'] is True and c['cameraLado'] == 'environment'
panel.get_by_role('button', name='Ligar câmera').click()
panel.get_by_role('link', name='Ao vivo').first.click()
alternar('Transmitir câmera do celular')
panel.wait_for_timeout(500)
assert panel.request.get(base + '/app/config').json()['preview'] is True
panel.get_by_role('button', name='Desligar câmera').click()
alternar('Transmitir câmera do celular')
panel.wait_for_timeout(500)
assert panel.request.get(base + '/app/config').json()['preview'] is False
assert not errors, errors
+10 -12
View File
@@ -86,21 +86,19 @@ class ControleTest(unittest.TestCase):
self.assertTrue(json.loads(body)['online'])
self.assertEqual(json.loads(body)['estado']['rosto'], 'miudo')
def test_frame_is_hidden_when_disabled_and_after_expiry(self):
jpeg = b'\xff\xd8fixture\xff\xd9'
self.assertEqual(self.request('/camera/frame', jpeg, 'image/jpeg')[0], 409)
def test_phone_reports_its_own_ip(self):
self.request('/app/status', {'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F'}})
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '192.168.1.16')
self.request('/app/status', {'aparelho': {'ip': 'não é ip'}})
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '127.0.0.1')
def test_legacy_camera_and_adb_routes_are_gone(self):
self.request('/app/config', {'preview': True})
self.assertEqual(self.request('/camera/frame', jpeg, 'image/jpeg')[0], 200)
self.assertEqual(self.request('/camera/frame'), (200, jpeg))
self.server.controle.frame_at -= 4
self.assertEqual(self.request('/camera/frame')[0], 204)
self.request('/camera/frame', jpeg, 'image/jpeg')
self.request('/app/config', {'preview': False})
self.assertEqual(self.request('/camera/frame')[0], 204)
self.assertEqual(self.request('/camera/frame', b'\xff\xd8x\xff\xd9', 'image/jpeg')[0], 404)
self.assertEqual(self.request('/camera/frame')[0], 404)
self.assertEqual(self.request('/app/connect', {'ip': '127.0.0.1'})[0], 404)
def test_rejects_non_jpeg_and_unknown_settings(self):
self.request('/app/config', {'preview': True})
self.assertEqual(self.request('/camera/frame', b'hello', 'image/jpeg')[0], 400)
self.assertEqual(self.request('/app/config', {'preview': 'false'})[0], 400)
self.assertEqual(self.request('/app/config', {'extra': 1})[0], 400)
-41
View File
@@ -1,41 +0,0 @@
from playwright.sync_api import sync_playwright
import json
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls = []
rows = [dict(nome=f'Servo {i+1}',canal=i,minimo=1400,maximo=1600,centro=1500) for i in range(6)]
def handle(route):
if route.request.method == 'POST': calls.append((route.request.url, route.request.post_data_json))
route.fulfill(content_type='application/json',body=json.dumps(dict(disponivel=True,erro='',servos=rows,ativos=[])))
page.route('**/servos/status', handle)
page.route('**/servos/mover', handle)
page.route('**/servos/parar', handle)
page.goto('http://192.168.1.35:8100/servos')
page.wait_for_selector('.card')
live = page.locator('#live')
assert live.count() == 1, 'Modo ao vivo ausente'
assert not live.is_checked()
slider = page.locator('input[type=range]').first
slider.evaluate("e=>{e.value=1450;e.dispatchEvent(new Event('input'))}")
page.wait_for_timeout(200)
assert calls == []
live.check()
assert calls == [], 'Ativar modo não deve mover'
slider.evaluate("e=>{for(let i=0;i<30;i++){e.value=1400+(i%20)*5;e.dispatchEvent(new Event('input'))}e.value=1600;e.dispatchEvent(new Event('input'))}")
page.wait_for_timeout(500)
moves = [data for url,data in calls if url.endswith('/mover')]
assert 1 <= len(moves) <= 6, len(moves)
assert moves[-1] == dict(indice=0,pulso=1600)
page.locator('#stop').click()
page.wait_for_timeout(200)
assert not live.is_checked()
assert calls[-1][0].endswith('/parar')
count = len(calls)
slider.evaluate("e=>{e.value=1400;e.dispatchEvent(new Event('input'))}")
page.wait_for_timeout(200)
assert len(calls) == count
page.locator('input[type=text]').first.fill('Teste')
assert live.is_disabled()
print('OK: live opt-in, limite de envio, último valor, parada cancela fila, alterações bloqueiam; hardware simulado')
browser.close()
-9
View File
@@ -1,9 +0,0 @@
const assert=require('node:assert/strict');
const {sequencias}=require('../movimentos-core.js');
const ranges=Array.from({length:7},()=>[81,99]);
const moves=sequencias(ranges);
assert.equal(moves.length,5);
for(const move of moves){assert.ok(move.passos.length>=3);for(const step of move.passos){assert.equal(step.length,7);step.forEach(v=>assert.ok(v>=81&&v<=99));}}
assert.ok(moves.some(m=>m.passos.some(s=>s.filter(v=>v!==90).length>=3)));
assert.deepEqual(moves[0].passos.at(-1),Array(7).fill(90));
console.log('OK: cinco sequências, limites e coordenação');
+50 -2
View File
@@ -74,8 +74,20 @@ def testar(base, server, capturas):
# 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})
'microfone_ativo': True, 'microfone': 0.3,
'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F', 'android': '12',
'app': '0.8.1', 'wifi': 3, 'rssi': -58}})
expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000)
# Dados que o próprio APK informa aparecem no cartão do celular.
expect(page.get_by_text('samsung SM-G973F')).to_be_visible()
expect(page.get_by_text('192.168.1.16')).to_be_visible()
# 2b. bateria baixa sem carregador vira aviso na hora
page.request.post(base + '/app/status', headers=auth, data={'bateria': {'nivel': 12, 'carregando': False}})
expect(page.get_by_text('Bateria do celular em 12%. Ligue o carregador')).to_be_visible(timeout=5000)
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F', 'android': '12',
'app': '0.8.1', 'wifi': 3, 'rssi': -58}})
# 3. gesto pelo dock
kiosk.FILA.limpar()
@@ -116,6 +128,16 @@ def testar(base, server, capturas):
page.get_by_label('Buscar ajuste').fill('')
page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True)
# 6a. rosto: escolher estilo e formato do olho
page.goto(base + '/#/rosto')
page.get_by_role('radio', name='Bloco').click()
page.get_by_role('radio', name='Quadrado', exact=True).click()
page.wait_for_timeout(500)
assert kiosk.APARENCIA['rosto'] == 'bloco' and kiosk.APARENCIA['formato'] == 'quadrado', kiosk.APARENCIA
expect(page.get_by_text('BLOCO · QUADRADO')).to_be_visible()
if capturas:
page.screenshot(path=str(Path(capturas) / 'rosto.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?')
@@ -150,6 +172,32 @@ def testar(base, server, capturas):
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()
@@ -170,7 +218,7 @@ def testar(base, server, capturas):
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, celular 400px, tema')
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__':
-19
View File
@@ -1,19 +0,0 @@
const assert = require('node:assert/strict');
let R;
try { R = require('../rastreamento-core.js'); } catch(e) { assert.fail('Rastreamento ausente'); }
const faces = [{x:.2,y:.1,w:.3,h:.4,score:.9}];
const hands = [Array.from({length:21},()=>({x:.8,y:.6}))];
assert.equal(R.target(faces,hands).tipo,'mao');
assert.equal(R.target(faces,[]).tipo,'rosto');
assert.equal(R.target([],[]),null);
assert.equal(R.target([{x:NaN,y:0,w:1,h:1}],[]),null);
const rect = R.fit(1000,500,640,480);
assert.ok(Math.abs(rect.x - (1000/2 - (500*640/480)/2)) < 1e-9);
assert.equal(rect.y,0);
assert.equal(R.point({x:0,y:0},rect).x,rect.x);
const gate = new R.FreshTarget();
gate.accept({tipo:'mao',x:.8,y:.5,atividade:true},100);
assert.equal(gate.get(200).x,.8);
assert.equal(gate.get(451),null,'Alvo antigo precisa expirar');
assert.equal(gate.accept({x:Infinity,y:0},500),false);
console.log('Rastreamento: prioridade, coordenadas, validação e expiração OK');
+31 -19
View File
@@ -1,4 +1,6 @@
"""Real WebRTC sender and panel in Chromium, using a synthetic camera."""
"""WebRTC real entre o transmissor do app e o painel novo (web/dist), com câmera sintética.
Requer `npm run build` em web/."""
import importlib.util
from pathlib import Path
import sys
@@ -27,6 +29,7 @@ def main():
server = kiosk.Servidor(('127.0.0.1', 0), Handler)
server.controle = kiosk.ControlePC(Path(tmp) / 'config.json')
server.controle.atualizar({'preview': True,'rastrearRosto':False,'rastrearMaos':False})
kiosk.ligar_eventos(server) # telemetria e ajustes chegam ao painel por SSE
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base = 'http://127.0.0.1:' + str(server.server_port)
@@ -62,40 +65,49 @@ def main():
},200);
}""")
sender.request.post(base + '/app/status', data={'rosto':'carinha'})
# Como o celular real, manda batimento a cada 1 s (senão o painel o considera offline).
sender.evaluate("""() => setInterval(() => fetch('/app/status',{method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify({rosto:'carinha'})}), 1000)""")
panel = context.new_page()
panel.on('pageerror', lambda e: errors.append(str(e)))
panel.goto(base + '/painel')
panel.wait_for_function("document.getElementById('cameraVideo').videoWidth > 0", timeout=20000)
first = panel.evaluate("document.getElementById('cameraVideo').getVideoPlaybackQuality().totalVideoFrames")
panel.goto(base + '/')
video = "document.querySelector('.camera video')"
panel.wait_for_function(video + ".videoWidth > 0", timeout=20000)
first = panel.evaluate(video + ".getVideoPlaybackQuality().totalVideoFrames")
panel.wait_for_timeout(2200)
count = panel.evaluate("document.getElementById('cameraVideo').getVideoPlaybackQuality().totalVideoFrames") - first
count = panel.evaluate(video + ".getVideoPlaybackQuality().totalVideoFrames") - first
assert count >= 45, 'Vídeo não está fluido: %s quadros em 2.2 s' % count
assert panel.locator('#cameraVideo').is_visible()
assert 'fps' in panel.locator('#videoQualidade').inner_text()
assert panel.locator('.camera video').is_visible()
qualidade = panel.locator('.camera .rodape .mono').inner_text()
assert 'fps' in qualidade, qualidade
assert not errors, errors
print('Vídeo WebRTC real: %s quadros em 2.2 s; %s' % (count, panel.locator('#videoQualidade').inner_text()))
print('Vídeo WebRTC real: %s quadros em 2.2 s; %s' % (count, qualidade))
# Canal de rastreamento (DataChannel) chega ao transmissor.
sender.request.post(base+'/app/config',data={'rastrearMaos':True})
sender.wait_for_function('config.rastrearMaos === true')
panel.wait_for_function('CarinhaVideo.rastreamentoConectado()')
panel.evaluate("CarinhaVideo.rastrear({tipo:'mao',x:.8,y:.3,atividade:true})")
panel.wait_for_function('window.carinhaVideo?.rastreamentoConectado')
panel.wait_for_timeout(1000) # sem vendor/ o rastreador falha e manda null uma vez
panel.evaluate("carinhaVideo.rastrear({tipo:'mao',x:.8,y:.3,atividade:true})")
sender.wait_for_function("window.testAlvo?.tipo === 'mao' && testAlvo.x === .8")
panel.evaluate('CarinhaVideo.rastrear(null)')
panel.evaluate('carinhaVideo.rastrear(null)')
sender.wait_for_function('window.testAlvo === null')
sender.request.post(base+'/app/config',data={'rastrearMaos':False})
panel.locator('#ajuste-videoResolucao').select_option('480')
# Ajustes chegam ao celular; o painel acompanha por SSE.
sender.request.post(base+'/app/config',data={'videoResolucao':'480'})
sender.wait_for_function("stream && stream.getVideoTracks()[0].getSettings().height === 480", timeout=10000)
panel.locator('#ajuste-videoFPS').select_option('240')
sender.request.post(base+'/app/config',data={'videoFPS':240})
sender.wait_for_function("aviso.includes('indisponível')", timeout=10000)
assert sender.evaluate('stream.getVideoTracks()[0].readyState') == 'live'
panel.locator('#ajuste-sensorCamera').uncheck()
panel.wait_for_function("document.getElementById('cameraVideo').srcObject === null", timeout=10000)
sender.request.post(base+'/app/config',data={'sensorCamera':False})
panel.wait_for_function(video + ".srcObject === null", timeout=10000)
sender.wait_for_function('stream === null', timeout=10000)
panel.locator('#ajuste-sensorCamera').check()
panel.wait_for_function("document.getElementById('cameraVideo').videoWidth > 0 && document.getElementById('cameraVideo').srcObject !== null", timeout=15000)
sender.request.post(base+'/app/config',data={'sensorCamera':True})
panel.wait_for_function(video + ".videoWidth > 0 && " + video + ".srcObject !== null", timeout=15000)
assert not errors, errors
panel.get_by_role('button', name='Desligar câmera').click()
# Desligar pela chave da tela Ao vivo.
panel.get_by_role('switch', name='Transmitir câmera do celular').locator('xpath=..').click()
panel.wait_for_timeout(1400)
assert panel.evaluate("document.getElementById('cameraVideo').srcObject === null")
assert panel.evaluate(video + ".srcObject === null")
preview = context.new_page()
preview.on('pageerror', lambda e: errors.append(str(e)))
preview.goto(base + '/camera.html?lado=user&resolucao=480&fps=30')
+23
View File
@@ -106,9 +106,24 @@ class ServidorTest(unittest.TestCase):
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo'])
for novo in ('acenar', 'esconder', 'espiar', 'pensar', 'musica'): # gestos com mãos e faixa
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': novo})[0], 200)
kiosk.FILA.limpar()
kiosk.FILA.adicionar('feliz'); kiosk.FILA.adicionar('rosto miudo')
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo'])
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
def test_rostos_novos_e_formato_do_olho(self):
kiosk.ARQUIVO_APARENCIA = str(Path(self.tmp.name) / 'aparencia.json')
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'bloco'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'formato', 'valor': 'quadrado'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'formato', 'valor': 'triangulo'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['rosto bloco', 'formato quadrado'])
estado = self.pedir('GET', '/estado')[1]
self.assertEqual(estado['aparencia']['formato'], 'quadrado')
self.assertEqual(estado['aparencia']['rosto'], 'bloco') # a escolha fica salva
self.assertIn('pilula', estado['formatos'])
def test_post_face_usa_prefixo_rosto(self):
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
conn.request('POST', '/face', body='miudo', headers={'Authorization': 'Bearer ' + TOKEN})
@@ -215,6 +230,14 @@ class BracoMovimentoTest(unittest.TestCase):
self.assertEqual(self.servos.active, {})
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):
for kwargs in (dict(velocidade=1000), dict(velocidade=True), dict(ttl_ms=50), dict(ttl_ms=5000)):
with self.assertRaises(ValueError):
+2 -28
View File
@@ -35,7 +35,7 @@ def main():
if not r.request.url.startswith(base): external.append(r.request.url);r.abort()
else:r.continue_()
page.route('**/*',route)
page.goto(base+'/painel')
page.goto(base+'/') # painel novo: nada pode sair para a internet
results=page.evaluate("""async () => {
const worker=new Worker('/rastreador-worker.js');
const send=msg=>new Promise((resolve,reject)=>{
@@ -56,36 +56,10 @@ def main():
assert len(results['hand']['hands'][0])==21
print('Inferencia real: %s rosto(s), %s mao(s); %.1f/%.1f ms' % (
len(results['face']['faces']),len(results['hand']['hands']),results['face']['ms'],results['hand']['ms']))
# Feed the same real worker into the panel, using a local canvas stream.
page.request.post(base+'/app/config',data={'preview':True})
page.request.post(base+'/app/status',data={'rosto':'carinha','dormindo':False})
page.evaluate("""async () => {
const image=await createImageBitmap(await (await fetch('/test-hands')).blob());
const c=document.createElement('canvas');c.width=640;c.height=Math.round(640*image.height/image.width);
const ctx=c.getContext('2d');ctx.drawImage(image,0,0,c.width,c.height);image.close();
window.testStream=c.captureStream(30);
window.testDraw=setInterval(()=>ctx.drawImage(c,0,0),33);
const v=document.getElementById('cameraVideo');v.srcObject=testStream;v.hidden=false;await v.play();
window.testTargets=[];
window.CarinhaVideo={rastrear(t){testTargets.push(t)},parar(){},atualizar(){}};
window.CarinhaRastreamento.atualizar({online:true,estado:{}}, {preview:true,sensorCamera:true,
rastrearRosto:true,rastrearMaos:true,mostrarRastreamento:true,seguirRastreamento:true});
}""")
page.wait_for_function("window.CarinhaRastreamento.estado().result?.hands.length > 0",timeout=15000)
assert page.locator('#trackingOverlay').is_visible()
assert page.evaluate("testTargets.some(t=>t?.tipo==='mao')")
work=ROOT.parents[1]/'work';page.screenshot(path=str(work/'rastreamento-teste.png'),full_page=True)
page.locator('#ajuste-mostrarRastreamento').uncheck()
page.wait_for_function("document.getElementById('trackingOverlay').hidden",timeout=5000)
page.locator('#ajuste-seguirRastreamento').uncheck()
page.wait_for_timeout(1200)
assert page.evaluate('testTargets[testTargets.length-1] === null')
page.evaluate("CarinhaRastreamento.atualizar({online:true,estado:{dormindo:true}},{preview:true,sensorCamera:true,cameraSono:false,rastrearRosto:true,rastrearMaos:true})")
assert not page.evaluate('CarinhaRastreamento.estado().active')
assert not external,external
assert not errors,errors
browser.close()
print('Overlay, alvo, controles, sono e inferencia sem acesso externo: OK')
print('Inferencia real no worker e painel sem acesso externo: OK')
finally:
server.shutdown();server.server_close();thread.join()
-60
View File
@@ -1,60 +0,0 @@
/* ==========================================================================
TREINAMENTO & PERSONALIDADE · ESTILOS
========================================================================== */
.treinamento-grid {
display: grid;
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
gap: 24px;
margin-top: 20px;
}
.rotina {
display: flex;
flex-direction: column;
}
.laboratorio {
display: flex;
flex-direction: column;
}
.laboratorio iframe {
height: 340px;
border-radius: var(--raio-md);
margin: 12px 0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6), inset 0 0 30px rgba(0, 0, 0, 0.8);
}
#diario {
max-height: 420px;
overflow-y: auto;
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
#rotinas {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 380px;
overflow-y: auto;
}
#estado {
font-size: 13.5px;
color: var(--ciano);
min-height: 22px;
}
@media (max-width: 1050px) {
.treinamento-grid {
grid-template-columns: 1fr;
}
.laboratorio {
order: -1;
}
}
-116
View File
@@ -1,116 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Treinamento & Personalidade · Carinha</title>
<link rel="stylesheet" href="/dashboard.css">
<link rel="stylesheet" href="/treinamento.css">
<script src="/dashboard.js" defer></script>
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
<div>
<h1>Treinamento & Personalidade</h1>
<p style="margin:4px 0 0">Ensine novos hábitos, registre preferências e crie desenhos dinâmicos com o Gemini.</p>
</div>
<div>
<a href="/emojis" class="primary" style="display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:var(--raio-sm)">
<span>✨ Ver Biblioteca de Emojis</span>
</a>
</div>
</div>
<div class="treinamento-grid">
<!-- Seção de Rotina e Preferências -->
<section class="card rotina">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Rotina & Preferências
</h2>
<p>Hábitos e gostos que moldam a convivência com o Carinha.</p>
<form id="rotinaForm" style="margin-top:10px">
<label for="rotinaTexto">O que o Carinha deve saber?</label>
<input id="rotinaTexto" type="text" maxlength="300" required placeholder="Ex.: De manhã gosto de café preto e notícias">
<button class="primary" style="width:100%">Registrar Preferência</button>
</form>
<div id="rotinas"></div>
</section>
<!-- Laboratório de Criação de Desenhos SVG com Gemini -->
<section class="card laboratorio">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="12 8 8 12 12 16 16 12 12 8"/></svg>
Laboratório de Expressão IA
</h2>
<span class="badge" style="font-size:11px">Gemini SVG Studio</span>
</div>
<iframe id="previa" title="Prévia animada do Carinha" src="/carinha.html?espelho=1&audio=0"></iframe>
<p style="font-size:13px;color:var(--fraco)">
Descreva uma ideia visual para o Gemini sintetizar em formas vetoriais no rosto do robô.
</p>
<form id="figurinhaForm" style="margin:8px 0 14px">
<label for="figurinhaTexto">O que você quer desenhar?</label>
<input id="figurinhaTexto" type="text" maxlength="1000" required placeholder="Ex.: Uma lâmpada acendendo com brilhos ou um coração pulsando nos olhos">
<div class="actions" style="margin-top:6px">
<button class="primary" id="criarFigurinha" type="submit">✨ Desenhar e Mostrar</button>
<button id="gerar" type="button">Gerar Reações Automáticas</button>
</div>
</form>
<details class="opcoes" style="margin-top:4px">
<summary>Criação Automática Periódica</summary>
<label style="display:flex;align-items:center;gap:10px;margin-top:10px">
<input id="automatico" type="checkbox">
<span>Criar novas reações a cada hora quando houver conversas ativas</span>
</label>
</details>
<details style="margin-top:12px">
<summary>Prompt enviado ao Gemini (Inspeção Técnica)</summary>
<p style="font-size:12.5px">Orientação de sistema para os desenhos vetoriais:</p>
<pre id="promptOrientacao"></pre>
<h3 style="margin-top:14px">Último Prompt Enviado</h3>
<pre id="ultimoPrompt">Nenhum pedido enviado ainda.</pre>
</details>
<p id="estado" role="status" style="margin-top:12px">Carregando…</p>
</section>
</div>
<!-- Diário & Resumo de Sessões -->
<div class="grid" style="margin-top:24px">
<section class="card">
<details>
<summary>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Resumo das Sessões de Treino
</summary>
<div id="resumos" style="margin-top:12px"></div>
</details>
</section>
<section class="card">
<details>
<summary>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
Diário de Conversas do Robô
</summary>
<p style="font-size:13px;margin:8px 0 12px">Registros gerados a partir dos diálogos anteriores.</p>
<div class="actions" style="margin-bottom:12px">
<button id="limparDiario" type="button" class="danger" style="font-size:12px">🗑️ Apagar Diário e Resumos</button>
</div>
<div id="diario"></div>
</details>
</section>
</div>
<script src="/treinamento.js"></script>
</body>
</html>
-19
View File
@@ -1,19 +0,0 @@
(function(){
const el=id=>document.getElementById(id);let signature='',sending=false;
async function api(path,data){const r=await fetch('/treinamento/'+path,data===undefined?{cache:'no-store'}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const d=await r.json();if(!r.ok)throw Error(d.erro);return d;}
const date=t=>new Date(t*1000).toLocaleString('pt-BR');
function text(tag,value){const n=document.createElement(tag);n.textContent=value;return n;}
function button(label,action){const b=text('button',label);b.onclick=action;return b;}
async function action(path,data){try{await api(path,data);await refresh();return true;}catch(e){el('estado').textContent=e.message;return false;}}
async function refresh(){try{const s=await api('status');el('gerar').disabled=s.ocupado||sending;el('criarFigurinha').disabled=s.ocupado;el('automatico').checked=s.automatico;el('estado').textContent=s.erro?s.erro:s.ocupado?'Gemini está criando novas reações…':s.automatico?'Geração por hora ativada.':'Crie agora ou ative a geração por hora.';
el('promptOrientacao').textContent=s.prompt_orientacao||'';el('ultimoPrompt').textContent=s.ultimo_prompt_figurinha||'Nenhum pedido enviado ainda.';
const key=JSON.stringify([s.rotina,s.reacoes,s.diario,s.resumos]);if(key===signature)return;signature=key;
el('rotinas').replaceChildren();for(const r of s.rotina){const row=text('p',r.texto+' ');row.append(button('Apagar',()=>action('rotina-apagar',{id:r.id})));el('rotinas').append(row);}
el('resumos').replaceChildren();for(const r of s.resumos)el('resumos').append(text('small',date(r.horario)),text('p',r.texto));
el('diario').replaceChildren();for(const m of s.diario)el('diario').append(text('small',date(m.horario)+' · '+(m.papel==='user'?'Você':'Carinha')),text('p',m.texto));
}catch(e){el('estado').textContent=e.message;}}
el('limparDiario').onclick=()=>{if(confirm('Apagar o diario e os resumos? As rotinas e reacoes serao mantidas.'))action('limpar-diario',{});};
el('figurinhaForm').onsubmit=async e=>{e.preventDefault();await action('figurinha',{texto:el('figurinhaTexto').value});};
el('rotinaForm').onsubmit=async e=>{e.preventDefault();if(await action('rotina',{texto:el('rotinaTexto').value}))el('rotinaTexto').value='';};
el('gerar').onclick=async()=>{sending=true;el('gerar').disabled=true;await action('gerar',{});sending=false;};el('automatico').onchange=()=>action('config',{automatico:el('automatico').checked});refresh();setInterval(refresh,2000);
})();
+7 -6
View File
@@ -1,10 +1,11 @@
{
"versao": "0.7.0",
"versao": "0.8.0",
"data": "2026-10-05",
"alteracoes": [
"Dashboard escuro com sidebar e oito telas",
"Biblioteca de emojis com edição e desfazer persistente",
"Configuração privada e teste de API Gemini",
"Estilos e navegação compartilhados"
"Painel novo em Svelte: Ao vivo, Conversa, Reações, Movimento e Ajustes",
"Estado em tempo real por SSE (/eventos) no painel e no rosto, sem polling",
"E-Stop real (/parar-tudo) e braço com homem-morto e interpolação no servidor",
"Token opcional para todas as rotas de controle; app Android envia o token",
"Telas antigas, /camera/frame e /app/connect removidos"
]
}
}
+2
View File
@@ -9,6 +9,8 @@
</head>
<body>
<div id="app"></div>
<!-- mesmo motor de rosto do celular, usado nas miniaturas das reações -->
<script src="/rosto-motor.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "node --test \"tests/**/*.test.ts\""
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
+11 -11
View File
@@ -10,12 +10,13 @@
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 { nav, ROTAS, SECUNDARIAS, tituloDa, 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';
import Rosto from './telas/Rosto.svelte';
const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo,
@@ -23,8 +24,9 @@
reacoes: Reacoes,
movimento: Movimento,
ajustes: Ajustes,
rosto: Rosto,
};
const ICONES: Record<Rota, Component<any>> = {
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
conversa: MessageCircle,
reacoes: Smile,
@@ -32,6 +34,8 @@
ajustes: Settings,
};
const Tela = $derived(TELAS[nav.atual]);
// Páginas secundárias acendem a seção de onde vieram.
const secao = $derived(SECUNDARIAS.find((r) => r.id === nav.atual)?.volta ?? nav.atual);
// ---------- tema
let tema = $state<'dark' | 'light'>('dark');
@@ -100,11 +104,7 @@
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;
});
const bateria = $derived(estado.bateria.nivel);
</script>
<svelte:window onkeydown={teclado} />
@@ -126,7 +126,7 @@
{#each ROTAS as r (r.id)}
{@const Icone = ICONES[r.id]}
<li>
<a href={'#/' + r.id} aria-current={nav.atual === r.id ? 'page' : undefined}>
<a href={'#/' + r.id} aria-current={secao === r.id ? 'page' : undefined}>
<Icone size={20} /> <span>{r.titulo}</span>
</a>
</li>
@@ -138,12 +138,12 @@
</nav>
<header class="topo">
<h1 class="titulo">{ROTAS.find((r) => r.id === nav.atual)?.titulo}</h1>
<h1 class="titulo">{tituloDa(nav.atual)}</h1>
<div class="status linha">
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto}
</Selo>
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>
<Selo tom={!estado.celularOnline ? 'neutro' : estado.bateriaBaixa ? 'perigo' : 'ok'}>
Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
</Selo>
{#if estado.conversa}
@@ -169,7 +169,7 @@
<Tela />
</main>
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes'}
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto'}
<MiniRosto />
{/if}
</div>
+174
View File
@@ -0,0 +1,174 @@
/**
* 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';
import { limites } from './nucleo';
export { sequencias } from './nucleo';
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;
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.calibracao, this.status.articulacoes) : []);
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;
}
});
});
+37 -3
View File
@@ -20,17 +20,34 @@ export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
dormir: { nome: 'Dormir', emoji: '😴' },
acordar: { nome: 'Acordar', emoji: '🌅' },
aleatorio: { nome: 'Aleatório', emoji: '🎲' },
acenar: { nome: 'Acenar', emoji: '👋' },
esconder: { nome: 'Esconder o rosto', emoji: '🙈' },
espiar: { nome: 'Espiar', emoji: '🫣' },
pensar: { nome: 'Pensar', emoji: '🤔' },
musica: { nome: 'Dançar', 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'];
const CHAVE_MAOS = 'carinha_favoritos_maos'; // marca que os gestos com mãos já entraram
/** Até 12 favoritos; as teclas 1 a 9 valem para os nove primeiros. */
export const MAX_FAVORITOS = 12;
const GESTOS_MAOS = ['acenar', 'esconder', 'espiar', 'pensar', 'musica'];
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'piscadinha', 'surpreso', 'triste', '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);
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) {
if (localStorage.getItem(CHAVE_MAOS)) return v.slice(0, MAX_FAVORITOS);
// Quem já tinha favoritos salvos ganha os gestos com mãos logo depois dos dois primeiros.
const novos = GESTOS_MAOS.filter((g) => !v.includes(g));
const lista = [...v.slice(0, 2), ...novos, ...v.slice(2)].slice(0, MAX_FAVORITOS);
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(lista));
localStorage.setItem(CHAVE_MAOS, '1');
return lista;
}
} catch {
/* sem armazenamento */
}
@@ -40,9 +57,10 @@ function lerFavoritos(): string[] {
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);
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, MAX_FAVORITOS);
try {
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
localStorage.setItem(CHAVE_MAOS, '1');
} catch {
/* ok */
}
@@ -53,6 +71,22 @@ 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 formato = (nome: string) => tentar(() => postar('/comando', { tipo: 'formato', valor: nome }));
/** Rostos: os novos usam o motor de pálpebras (rosto-motor.js) e aceitam formato de olho. */
export const ROSTOS_INFO: Record<string, { nome: string; descricao: string; novo: boolean; formato: boolean }> = {
vivo: { nome: 'Carinha Viva', descricao: 'O rosto de sempre com pálpebras, brilho e micro-movimentos.', novo: true, formato: true },
bloco: { nome: 'Bloco', descricao: 'Olhos quadrados que se recortam, estilo robô de mesa.', novo: true, formato: true },
matriz: { nome: 'Matriz', descricao: 'Painel de LEDs retrô; cada olho é feito de pontos.', novo: true, formato: true },
lente: { nome: 'Lente', descricao: 'Íris e pupila que dilata, pálpebras de metal.', novo: true, formato: false },
carinha: { nome: 'Clássico', descricao: 'O desenho original, com boca.', novo: false, formato: false },
miudo: { nome: 'Miúdo', descricao: 'Clássico com olhos menores.', novo: false, formato: false },
eilik: { nome: 'Eilik', descricao: 'Clássico sem boca.', novo: false, formato: false },
};
export const FORMATOS_INFO: Record<string, string> = {
padrao: 'Padrão', redondo: 'Redondo', pilula: 'Pílula', quadrado: 'Quadrado', largo: 'Largo',
};
export const nomeRosto = (id: string) => ROSTOS_INFO[id]?.nome ?? id;
export const recarregarCelular = () =>
tentar(async () => {
const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
@@ -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>
+202
View File
@@ -0,0 +1,202 @@
<script lang="ts">
import { BatteryCharging, BatteryFull, BatteryLow, BatteryMedium, Moon, Smartphone, Sun, WifiOff } from '@lucide/svelte';
import { estado } from '../estado.svelte';
// Cartão do aparelho com o que o próprio app informa (IP, modelo, Android, versão, Wi-Fi, bateria).
const s = $derived(estado.app?.estado ?? {});
const ap = $derived(s.aparelho ?? null);
const bateria = $derived(estado.bateria.nivel);
const carregando = $derived(estado.bateria.carregando);
// Tempo desde o último sinal, em texto curto ("há 3 min").
const semSinal = $derived.by(() => {
if (!estado.ultimoSinalEm) return '';
const seg = Math.max(0, Math.round((estado.agora - estado.ultimoSinalEm) / 1000));
if (seg < 60) return `há ${seg} s`;
if (seg < 3600) return `há ${Math.round(seg / 60)} min`;
if (seg < 86400) return `há ${Math.round(seg / 3600)} h`;
return `há ${Math.round(seg / 86400)} dia(s)`;
});
const IconeBateria = $derived(carregando ? BatteryCharging : bateria === null ? BatteryMedium : bateria < 20 ? BatteryLow : bateria < 70 ? BatteryMedium : BatteryFull);
const ip = $derived(ap?.ip || estado.app?.celular_ip || '—');
</script>
<div class="celular" class:offline={!estado.celularOnline}>
<div class="cabeca">
<span class="aparelho" aria-hidden="true"><Smartphone size={22} /></span>
<div class="nome">
<strong>{ap?.modelo || 'Celular do rosto'}</strong>
<span>
{#if estado.celularOnline}
{[ap?.android && 'Android ' + ap.android, ap?.app && 'app ' + ap.app].filter(Boolean).join(' · ') || 'online'}
{:else}
offline{semSinal ? ' · último sinal ' + semSinal : ''}
{/if}
</span>
</div>
<span class="estado" class:on={estado.celularOnline}>{estado.celularOnline ? 'Online' : 'Offline'}</span>
</div>
{#if estado.celularOnline}
<dl class="dados">
<div>
<dt>Bateria</dt>
<dd class:baixa={bateria !== null && bateria < 20 && !carregando}>
<IconeBateria size={18} />
{bateria !== null ? bateria + '%' : '—'}
{#if carregando}<small>carregando</small>{/if}
</dd>
</div>
<div>
<dt>Wi-Fi</dt>
<dd>
{#if typeof ap?.wifi === 'number'}
<svg viewBox="0 0 20 16" width="20" height="16" aria-hidden="true">
{#each [0, 1, 2, 3] as i (i)}
<rect x={i * 5} y={12 - i * 4} width="3.5" height={4 + i * 4} rx="1" class:cheia={ap.wifi > i} />
{/each}
</svg>
<span class="sr-only">{ap.wifi} de 4</span>
{#if ap.rssi}<small>{ap.rssi} dBm</small>{/if}
{:else}
<WifiOff size={16} /> <small>sem dado</small>
{/if}
</dd>
</div>
<div>
<dt>IP</dt>
<dd class="mono">{ip}</dd>
</div>
<div>
<dt>Rosto</dt>
<dd>
{#if s.dormindo}<Moon size={16} /> Dormindo{:else}<Sun size={16} /> Acordado{/if}
</dd>
</div>
</dl>
{:else}
{#if bateria !== null && estado.ultimoSinalEm}
<p class="ultimo" class:baixa={bateria <= 15 && !carregando}>
<IconeBateria size={18} /> Último sinal com {bateria}%{carregando ? ', carregando' : ''}.
{#if bateria <= 10 && !carregando}Provavelmente a bateria acabou: ligue o carregador e abra o app.{/if}
</p>
{/if}
<p class="ajuda">Abra o app Carinha no celular e confira se ele aponta para este servidor (toque longo › Mudar o servidor).</p>
{/if}
</div>
<style>
.celular {
display: grid;
gap: var(--e-4);
padding: var(--e-4);
}
.cabeca {
display: flex;
align-items: center;
gap: var(--e-3);
}
.aparelho {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, #5e5ce6, #0a84ff);
color: #fff;
flex: none;
}
.offline .aparelho {
background: var(--superficie-3);
color: var(--texto-3);
}
.nome {
flex: 1;
display: grid;
min-width: 0;
}
.nome strong {
font-size: var(--t-md);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nome span {
font-size: var(--t-xs);
color: var(--texto-3);
}
.estado {
font-size: var(--t-xs);
font-weight: 600;
padding: 2px 10px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
color: var(--texto-3);
}
.estado.on {
background: var(--ok-suave);
color: var(--ok);
}
.dados {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--e-2);
margin: 0;
}
.dados div {
display: grid;
gap: 2px;
padding: var(--e-2) var(--e-3);
border-radius: var(--raio);
background: var(--superficie-2);
min-width: 0;
}
dt {
font-size: 11px;
color: var(--texto-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
dd {
margin: 0;
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
font-size: var(--t-sm);
flex-wrap: wrap;
overflow-wrap: anywhere;
}
dd small {
font-weight: 400;
font-size: 11px;
color: var(--texto-3);
}
.baixa {
color: var(--perigo);
}
rect {
fill: var(--superficie-3);
}
rect.cheia {
fill: var(--texto);
}
.ultimo {
display: flex;
align-items: center;
gap: var(--e-2);
flex-wrap: wrap;
font-size: var(--t-sm);
font-weight: 500;
}
.ultimo.baixa {
color: var(--perigo);
}
.ajuda {
font-size: var(--t-sm);
color: var(--texto-3);
}
dd.mono {
white-space: nowrap;
overflow-wrap: normal;
}
</style>
+2 -2
View File
@@ -11,10 +11,10 @@
{#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})`}>
<button class="gesto" onclick={() => gesto(g)} title={completo || i > 8 ? 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}
{#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if}
</button>
{#if completo}
<button
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// Lista agrupada no estilo dos Ajustes do iPhone: título pequeno, cartão arredondado, rodapé de ajuda.
let { titulo = '', rodape = '', children }: { titulo?: string; rodape?: string; children: Snippet } = $props();
</script>
<section class="grupo">
{#if titulo}<h2>{titulo}</h2>{/if}
<div class="itens">{@render children()}</div>
{#if rodape}<p class="rodape">{rodape}</p>{/if}
</section>
<style>
.grupo {
display: grid;
gap: 6px;
min-width: 0;
}
h2 {
padding: 0 var(--e-4);
font-size: var(--t-xs);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--texto-3);
}
.itens {
background: var(--superficie);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
overflow: hidden;
}
.rodape {
padding: 0 var(--e-4);
font-size: var(--t-xs);
color: var(--texto-3);
}
</style>
@@ -0,0 +1,62 @@
<script lang="ts">
// Interruptor no estilo iOS (verde quando ligado). O rótulo vira o nome acessível.
let { rotulo, checked = false, disabled = false, onchange }: {
rotulo: string;
checked?: boolean;
disabled?: boolean;
onchange: (v: boolean) => void;
} = $props();
</script>
<label class="interruptor">
<input type="checkbox" role="switch" aria-label={rotulo} {checked} {disabled} onchange={(e) => onchange(e.currentTarget.checked)} />
<span class="trilho" aria-hidden="true"></span>
</label>
<style>
.interruptor {
position: relative;
display: inline-flex;
cursor: pointer;
}
input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.trilho {
width: 51px;
height: 31px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
position: relative;
transition: background var(--transicao);
}
.trilho::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 27px;
height: 27px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
transition: transform var(--transicao);
}
input:checked + .trilho {
background: #34c759;
}
input:checked + .trilho::after {
transform: translateX(20px);
}
input:focus-visible + .trilho {
outline: 2px solid var(--destaque);
outline-offset: 2px;
}
input:disabled + .trilho {
opacity: 0.45;
cursor: not-allowed;
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import { ChevronRight } from '@lucide/svelte';
import type { Component, Snippet } from 'svelte';
// Uma linha da lista: ícone colorido, rótulo, detalhe e um acessório (interruptor, valor ou seta).
// Com href ou onclick a linha inteira é clicável e mostra a seta.
let {
icone,
cor = 'var(--destaque)',
rotulo,
detalhe = '',
href,
onclick,
disabled = false,
acessorio,
children,
}: {
icone?: Component<any>;
cor?: string;
rotulo: string;
detalhe?: string;
href?: string;
onclick?: () => void;
disabled?: boolean;
acessorio?: Snippet;
children?: Snippet;
} = $props();
const clicavel = $derived(!!href || !!onclick);
</script>
{#snippet corpo()}
{#if icone}
{@const Icone = icone}
<span class="icone" style="--cor: {cor}" aria-hidden="true"><Icone size={16} strokeWidth={2.4} /></span>
{/if}
<span class="texto">
<span class="rotulo">{rotulo}</span>
{#if detalhe}<span class="detalhe">{detalhe}</span>{/if}
</span>
{#if acessorio}<span class="acessorio">{@render acessorio()}</span>{/if}
{#if clicavel}<ChevronRight size={18} class="seta" aria-hidden="true" />{/if}
{/snippet}
<div class="linha-ios" class:com-icone={!!icone} class:desativada={disabled}>
{#if href}
<a class="conteudo" {href}>{@render corpo()}</a>
{:else if onclick}
<button class="conteudo" {onclick} {disabled}>{@render corpo()}</button>
{:else}
<div class="conteudo">{@render corpo()}</div>
{/if}
{#if children}<div class="extra">{@render children()}</div>{/if}
</div>
<style>
.linha-ios {
position: relative;
}
/* separador fino que começa depois do ícone, como no iOS */
.linha-ios:not(:first-child)::before {
content: '';
position: absolute;
top: 0;
right: 0;
left: var(--e-4);
border-top: 1px solid var(--borda);
}
.linha-ios.com-icone:not(:first-child)::before {
left: calc(var(--e-4) + 30px + var(--e-3));
}
.conteudo {
display: flex;
align-items: center;
gap: var(--e-3);
width: 100%;
min-height: 52px;
padding: var(--e-2) var(--e-4);
border: 0;
background: transparent;
color: var(--texto);
font: inherit;
text-align: left;
text-decoration: none;
}
a.conteudo,
button.conteudo {
cursor: pointer;
}
a.conteudo:hover,
button.conteudo:hover:not(:disabled) {
background: var(--superficie-2);
}
a.conteudo:active,
button.conteudo:active:not(:disabled) {
background: var(--superficie-3);
}
.icone {
flex: none;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--cor);
color: #fff;
}
.texto {
flex: 1;
display: grid;
min-width: 0;
}
.rotulo {
font-size: var(--t-sm);
font-weight: 500;
}
.detalhe {
font-size: var(--t-xs);
color: var(--texto-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acessorio {
flex: none;
display: flex;
align-items: center;
gap: var(--e-2);
color: var(--texto-3);
font-size: var(--t-sm);
}
.conteudo :global(.seta) {
flex: none;
color: var(--texto-3);
}
.extra {
padding: 0 var(--e-4) var(--e-4);
}
.desativada {
opacity: 0.5;
}
</style>
+61 -33
View File
@@ -1,56 +1,84 @@
<script lang="ts">
import { rgb, ROSTOS_INFO } from '../comandos.svelte';
import { estado } from '../estado.svelte';
import { SIMBOLO_EMOJI } from '../treinamento.svelte';
// Prévia estática de uma reação (sem iframe): figurinha SVG, emoji ou olhos do 1º passo.
// Retrato parado da reação, desenhado pelo mesmo motor do celular (rosto-motor.js),
// no rosto, formato e cor que o robô está usando agora.
let { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
let tela: HTMLCanvasElement;
const passo = $derived(receita?.passos?.[0]);
const simbolo = $derived(receita?.emoji?.caractere || SIMBOLO_EMOJI[receita?.simbolo] || '');
const nosOlhos = $derived({
icone:
receita?.emoji?.local === 'olhos'
? receita.emoji.caractere
: ['ideia', 'cafe'].includes(receita?.simbolo) && !receita?.emoji && !receita?.figurinha
? SIMBOLO_EMOJI[receita.simbolo]
: null,
desenho: receita?.figurinha?.local === 'olhos' ? receita.figurinha : null,
});
// O que aparece fora dos olhos (emoji/figurinha "ao redor", notas, estrelas, corações).
const aoRedor = $derived.by(() => {
if (receita?.emoji && receita.emoji.local !== 'olhos') return { emoji: receita.emoji.caractere };
if (receita?.figurinha && receita.figurinha.local !== 'olhos') return { figurinha: receita.figurinha };
if (['musica', 'estrela', 'coracao'].includes(receita?.simbolo)) return { emoji: SIMBOLO_EMOJI[receita.simbolo] };
return null;
});
const estilo = $derived(ROSTOS_INFO[estado.aparencia.rosto]?.novo ? estado.aparencia.rosto : 'vivo');
$effect(() => {
const motor = (window as any).MotorRosto;
if (!motor || !tela) return;
const d = Math.min(2, devicePixelRatio || 1);
tela.width = Math.round(tamanho * 1.5 * d); // 3:2, cabe os dois olhos com folga
tela.height = Math.round(tamanho * d);
motor.retrato(tela, {
estilo,
formato: estado.aparencia.formato ?? 'padrao',
cor: /^#[0-9a-f]{6}$/i.test(passo?.cor ?? '') ? rgb(passo.cor) : estado.aparencia.cor,
forma: passo?.olhos ?? 'aberto',
icone: nosOlhos.icone,
desenho: nosOlhos.desenho ? $state.snapshot(nosOlhos.desenho) : null,
});
});
</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}
<canvas bind:this={tela}></canvas>
{#if aoRedor?.emoji}
<span class="extra">{aoRedor.emoji}</span>
{:else if aoRedor?.figurinha}
<svg class="extra" viewBox="0 0 100 100">
{#each aoRedor.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);
width: calc(var(--t) * 1.5);
height: var(--t);
display: grid;
place-items: center;
border-radius: var(--raio);
background: #05080c;
background: #000;
flex: none;
overflow: hidden;
}
.emoji {
font-size: calc(var(--t) * 0.55);
line-height: 1;
canvas {
width: 100%;
height: 100%;
display: block;
}
.simbolo {
.extra {
position: absolute;
right: 2px;
bottom: 0;
font-size: calc(var(--t) * 0.3);
right: 4px;
top: 3px;
width: calc(var(--t) * 0.34);
height: calc(var(--t) * 0.34);
font-size: calc(var(--t) * 0.26);
line-height: calc(var(--t) * 0.34);
text-align: center;
filter: drop-shadow(0 0 4px rgb(92 214 255 / 0.6));
}
</style>
@@ -31,11 +31,17 @@
ultimaConversa = c.ultima.id;
enviar({ tipo: 'ordem', ordem: 'acordar' });
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
enviar({ tipo: 'legenda', texto: c.ultima.texto });
}
}),
];
onDestroy(() => cancelar.forEach((c) => c()));
/** Mostra uma ordem (gesto, rosto, formato) só na prévia, sem mandar ao celular. */
export function ordem(texto: string) {
enviar({ tipo: 'ordem', ordem: texto });
}
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
export function previa(receita: unknown) {
enviar({ tipo: 'previa-reacao', receita });
+36 -2
View File
@@ -3,6 +3,7 @@
* Nenhuma tela faz polling; todas leem daqui.
*/
import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
import { avisos } from './avisos.svelte';
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
@@ -19,7 +20,8 @@ class Estado {
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] });
aparencia = $state<{ rosto: string; cor: [number, number, number]; formato?: string }>({ rosto: 'carinha', cor: [92, 214, 255], formato: 'padrao' });
formatos = $state<string[]>([]);
gestos = $state<string[]>([]);
rostos = $state<string[]>([]);
app = $state<Telemetria | null>(null);
@@ -36,6 +38,18 @@ class Estado {
agora = $state(Date.now());
celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000);
/** Quando o celular mandou o último sinal (0 = nunca, nesta sessão nem no servidor). */
ultimoSinalEm = $state(0);
/** Bateria do último sinal recebido, mesmo com o celular offline. */
bateria = $derived.by(() => {
const b = this.app?.estado?.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return {
nivel: typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null,
carregando: typeof b === 'object' && !!b?.carregando,
};
});
bateriaBaixa = $derived(this.bateria.nivel !== null && this.bateria.nivel <= 15 && !this.bateria.carregando);
}
export const estado = new Estado();
@@ -50,6 +64,23 @@ export function ouvir(tipo: string, fn: Ouvinte): () => void {
return () => ouvintes.get(tipo)!.delete(fn);
}
// Avisa uma vez em 15% e outra em 5% (sem carregador); zera quando volta a carregar.
let avisouEm: number | null = null;
function avisarBateria() {
const { nivel, carregando } = estado.bateria;
if (nivel === null) return;
if (carregando || nivel > 20) {
avisouEm = null;
return;
}
const limite = nivel <= 5 ? 5 : nivel <= 15 ? 15 : null;
if (limite !== null && avisouEm !== limite) {
avisouEm = limite;
avisos.mostrar(`Bateria do celular em ${nivel}%. Ligue o carregador ou o Carinha vai desligar.`, 'erro');
noFeed('estop', `Bateria do celular em ${nivel}%`);
}
}
let seqFeed = 0;
function noFeed(tipo: string, texto: string) {
estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40);
@@ -62,9 +93,11 @@ function aplicarFoto(f: EstadoServidor) {
estado.aparencia = f.aparencia;
estado.gestos = f.gestos;
estado.rostos = f.rostos;
estado.formatos = f.formatos ?? [];
if (f.app) {
estado.app = f.app;
estado.appRecebidoEm = Date.now();
estado.ultimoSinalEm = typeof f.app.idade === 'number' ? Date.now() - f.app.idade * 1000 : 0;
}
if (f.app_config) estado.appConfig = f.app_config;
if (f.motores) estado.motores = f.motores;
@@ -81,7 +114,8 @@ const tratadores: Record<string, (d: any) => void> = {
},
telemetria: (d: Telemetria) => {
estado.app = d;
estado.appRecebidoEm = Date.now();
estado.appRecebidoEm = estado.ultimoSinalEm = Date.now();
avisarBateria();
},
app_config: (d: AppConfig) => (estado.appConfig = d),
aparencia: (d) => (estado.aparencia = d),
+99
View File
@@ -0,0 +1,99 @@
/**
* Lógica pura do painel (sem Svelte nem DOM), testada com `npm test` (node --test).
* Rastreamento: escolha do alvo e encaixe do vídeo. Braço: faixas calibradas e sequências.
*/
export interface Ponto {
x: number;
y: number;
}
export interface Face extends Ponto {
w: number;
h: number;
score?: number;
pontos?: Ponto[];
}
export interface AlvoRastreio {
tipo: 'mao' | 'rosto';
x: number;
y: number;
atividade?: boolean;
}
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
const limitar01 = (v: number) => Math.max(0, Math.min(1, v));
/** Mão tem prioridade sobre rosto; entre candidatos do mesmo tipo, o mais perto do alvo anterior. */
export function escolherAlvo(faces: Face[], maos: Ponto[][], anterior: AlvoRastreio | null = 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) && Number.isFinite(f.w) && Number.isFinite(f.h) && 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: limitar01(t.x), y: limitar01(t.y) };
}
/** Retângulo do vídeo (vw×vh) encaixado em w×h sem cortar (object-fit: contain). */
export 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 interface Calibracao {
canal: number;
minimo: number;
maximo: number;
centro?: number;
}
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; o canal 5 respeita o par 6 invertido. */
export function limites(calibracao: Calibracao[], articulacoes: { canal: number }[]): [number, number][] {
return articulacoes.map(({ canal }) => {
const c = calibracao[canal];
let lo = anguloDoPulso(c.minimo, c);
let hi = anguloDoPulso(c.maximo, c);
if (canal === 5) {
const par = 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 de cada articulação. */
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];
const meio = Math.max(lo, Math.min(hi, 90));
return Math.round(meio + v * (v < 0 ? meio - lo : hi - meio));
}),
),
}));
}
+6 -2
View File
@@ -1,5 +1,7 @@
/** 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 {
cor, CORES, formato, FORMATOS_INFO, gesto, GESTOS_INFO, gestosDisponiveis, nomeRosto, pararTudo, rearmar, recarregarCelular, rosto,
} from './comandos.svelte';
import { estado } from './estado.svelte';
import { nav, ROTAS } from './rotas.svelte';
@@ -33,6 +35,8 @@ export function itensDaPaleta(): ItemPaleta[] {
// 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) })),
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
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 },
@@ -43,6 +47,6 @@ registrarNaPaleta(() => [
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) })),
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + nomeRosto(r), fazer: () => rosto(r) })),
...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })),
]);
+3 -38
View File
@@ -3,18 +3,9 @@
* 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';
import { encaixe, escolherAlvo, type AlvoRastreio, type Face, type Ponto } from './nucleo';
import type { 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[][];
@@ -22,35 +13,9 @@ interface Resultado {
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.');
@@ -125,7 +90,7 @@ export class Rastreamento {
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));
c.fillText('Rosto ' + Math.round((rosto.score ?? 0) * 100) + '%', p.x, Math.max(15, p.y - 6));
for (const q of rosto.pontos || []) bola(pt(q));
}
for (const mao of r.hands) {
+7 -2
View File
@@ -1,6 +1,6 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes';
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
@@ -10,9 +10,14 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' },
];
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
function lerHash(): Rota {
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo';
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
}
class Navegacao {
+4 -1
View File
@@ -18,6 +18,8 @@ export interface Telemetria {
bateria?: { nivel?: number; carregando?: boolean } | number | null;
brilho?: number;
video?: { qualidade?: string } | null;
/** Informado pelo próprio app (APK 0.8.1+). */
aparelho?: { ip?: string | null; modelo?: string; android?: string; app?: string | null; wifi?: number; rssi?: number } | null;
[k: string]: unknown;
};
preview: boolean;
@@ -67,7 +69,8 @@ export interface EstadoServidor {
seq: number;
autenticacao: boolean;
estop: { ativo: boolean; desde: number | null };
aparencia: { rosto: string; cor: [number, number, number] };
aparencia: { rosto: string; cor: [number, number, number]; formato?: string };
formatos?: string[];
gestos: string[];
rostos: string[];
app?: Telemetria;
+6 -6
View File
@@ -4,14 +4,10 @@
* 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 { AlvoRastreio } from './nucleo';
import type { AppConfig, Telemetria } from './tipos';
export interface AlvoRastreio {
tipo: 'mao' | 'rosto';
x: number;
y: number;
atividade?: boolean;
}
export type { AlvoRastreio } from './nucleo';
export class VideoAoVivo {
mensagem = $state('Transmissão desligada.');
@@ -186,6 +182,10 @@ export class VideoAoVivo {
}
}
get rastreamentoConectado() {
return this.canal?.readyState === 'open';
}
rastrear(alvo: AlvoRastreio | null) {
if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096)
this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo }));
+212 -247
View File
@@ -1,15 +1,18 @@
<script lang="ts">
import { Battery, Brain, Camera, Download, Ear, Moon, RefreshCw, ScanFace } from '@lucide/svelte';
import {
Brain, Camera, Download, Eye, Hand, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
} 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 { FORMATOS_INFO, hex, nomeRosto, recarregarCelular, ROSTOS_INFO } from '../lib/comandos.svelte';
import Celular from '../lib/componentes/Celular.svelte';
import Gestos from '../lib/componentes/Gestos.svelte';
import Grupo from '../lib/componentes/Grupo.svelte';
import Interruptor from '../lib/componentes/Interruptor.svelte';
import Linha from '../lib/componentes/Linha.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';
@@ -19,13 +22,18 @@
let video = $state<VideoAoVivo>();
let rastreio = $state<Rastreamento>();
// Palco: câmera grande e rosto no quadrinho do canto; tocar no quadrinho troca os dois.
let rostoGrande = $state(false);
onMount(() => {
video = new VideoAoVivo(elVideo);
rastreio = new Rastreamento(elVideo, elTela, video);
(window as any).carinhaVideo = video; // depuração e testes de ponta a ponta
});
onDestroy(() => {
rastreio?.destruir();
video?.destruir();
delete (window as any).carinhaVideo;
});
// Vídeo e rastreamento seguem o estado do servidor (SSE), sem polling próprio.
@@ -38,16 +46,11 @@
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 cfg = $derived(estado.appConfig);
const transmitindo = $derived(!!cfg?.preview);
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
async function foto() {
function foto() {
if (!elVideo.videoWidth) return;
const c = document.createElement('canvas');
c.width = elVideo.videoWidth;
@@ -59,156 +62,135 @@
a.click();
}
const iaLocal = (ativo: boolean) => tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.');
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">
<!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
<section class="palco" aria-label="Câmera e rosto">
<div class="quadro camera" class:mini={rostoGrande}>
<!-- 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>
<Camera size={rostoGrande ? 18 : 32} />
{#if !rostoGrande}
<p>{video?.mensagem ?? 'Carregando…'}</p>
{#if !transmitindo && cfg}
<button class="btn primario" onclick={() => salvarApp({ preview: true })}><Video size={16} /> Ligar câmera</button>
{/if}
{/if}
</div>
{/if}
{#if rostoGrande}
<button class="trocar" onclick={() => (rostoGrande = false)} aria-label="Mostrar a câmera em destaque"></button>
{:else}
<div class="rodape">
<span class="ponto" class:ao-vivo-on={video?.exibindo}></span>
<span>{video?.exibindo ? 'Ao vivo' : 'Câmera'}</span>
{#if video?.qualidade && video.exibindo}<span class="mono">{video.qualidade}</span>{/if}
<span class="espaco"></span>
{#if video?.exibindo}
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
{/if}
</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">
<div class="quadro rosto" class:mini={!rostoGrande}>
<RostoPrevia titulo="Rosto ao vivo" />
<div class="rodape">
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>Rosto {estado.celularOnline ? 'no celular' : '· prévia'}</Selo>
</div>
{#if !rostoGrande}
<button class="trocar" onclick={() => (rostoGrande = true)} aria-label="Mostrar o rosto em destaque">
<Repeat size={14} />
</button>
{/if}
</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>
<div class="lista">
<Grupo titulo="Celular">
<Celular />
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
</Grupo>
<Grupo titulo="Câmera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
<Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}>
{#snippet acessorio()}
<Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} />
{/snippet}
</Linha>
{#if transmitindo}
<Linha icone={ScanFace} cor="#af52de" rotulo="Rastrear rosto">
{#snippet acessorio()}
<Interruptor rotulo="Rastrear rosto" checked={!!cfg?.rastrearRosto} onchange={(v) => salvarApp({ rastrearRosto: v })} />
{/snippet}
</Linha>
<Linha icone={Hand} cor="#ff9500" rotulo="Rastrear mãos">
{#snippet acessorio()}
<Interruptor rotulo="Rastrear mãos" checked={!!cfg?.rastrearMaos} onchange={(v) => salvarApp({ rastrearMaos: v })} />
{/snippet}
</Linha>
<Linha icone={Eye} cor="#007aff" rotulo="Olhos seguem o alvo">
{#snippet acessorio()}
<Interruptor rotulo="Olhos seguem o alvo" checked={!!cfg?.seguirRastreamento} onchange={(v) => salvarApp({ seguirRastreamento: v })} />
{/snippet}
</Linha>
<Linha icone={PenLine} cor="#8e8e93" rotulo="Desenhar rastreamento no vídeo">
{#snippet acessorio()}
<Interruptor rotulo="Desenhar rastreamento no vídeo" checked={!!cfg?.mostrarRastreamento} onchange={(v) => salvarApp({ mostrarRastreamento: v })} />
{/snippet}
</Linha>
{/if}
</div>
</div>
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
</Grupo>
<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>
<Grupo titulo="Gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos />
</Linha>
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
</Grupo>
<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>
<Grupo titulo="Aparência">
<Linha
icone={Palette}
cor="#ff9500"
rotulo="Rosto, formato e cor"
detalhe={nomeRosto(estado.aparencia.rosto) + (ROSTOS_INFO[estado.aparencia.rosto]?.formato ? ' · ' + (FORMATOS_INFO[estado.aparencia.formato ?? 'padrao'] ?? '') : '')}
href="#/rosto"
>
{#snippet acessorio()}
<span class="amostra-cor" style="--c: {hex(estado.aparencia.cor)}" aria-hidden="true"></span>
{/snippet}
</Linha>
</Grupo>
<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
<Grupo titulo="Personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}>
<Linha
icone={Brain}
cor="#5e5ce6"
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>
detalhe={estado.ia?.ultima ? `Último: ${estado.ia.ultima.evento} → ${estado.ia.ultima.gesto} (${estado.ia.ultima.segundos}s)` : estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''}
>
{#snippet acessorio()}
<Interruptor rotulo="Reagir sozinho com IA local" checked={!!estado.ia?.ativo} disabled={!estado.ia} onchange={iaLocal} />
{/snippet}
</Linha>
</Grupo>
<Card titulo="Acontecimentos" class="feed">
<Grupo titulo="Acontecimentos">
{#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>
<ol class="feed">
{#each estado.feed.slice(0, 12) as item (item.id)}
<li class={item.tipo}><time class="mono">{hora(item.quando)}</time> <span>{item.texto}</span></li>
{/each}
</ol>
{:else}
<p class="fraco pequeno-texto">Gestos, respostas e reações aparecem aqui assim que acontecerem.</p>
<p class="vazio-feed">Gestos, respostas e reações aparecem aqui na hora.</p>
{/if}
</Card>
</Grupo>
</div>
</div>
@@ -216,19 +198,35 @@
.ao-vivo {
display: grid;
gap: var(--e-4);
align-items: start;
}
/* ---------- palco: sempre visível */
.palco {
display: grid;
gap: var(--e-3);
grid-template-columns: 1fr;
position: sticky;
top: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
z-index: 20;
aspect-ratio: 16 / 9;
margin: calc(-1 * var(--e-4)) calc(-1 * var(--e-4)) 0;
background: #000;
}
.quadro {
position: relative;
aspect-ratio: 16 / 9;
border-radius: var(--raio-lg);
position: absolute;
overflow: hidden;
background: #000;
border: 1px solid var(--borda);
transition: all 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.quadro:not(.mini) {
inset: 0;
}
.quadro.mini {
right: var(--e-3);
top: var(--e-3);
width: 30%;
aspect-ratio: 16 / 9;
border-radius: var(--raio);
border: 2px solid rgb(255 255 255 / 0.85);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
z-index: 2;
}
.camera video,
.sobreposicao {
@@ -247,12 +245,23 @@
display: grid;
place-content: center;
justify-items: center;
gap: var(--e-2);
gap: var(--e-3);
padding: var(--e-5);
text-align: center;
color: #8396ab;
color: #a9b8c9;
font-size: var(--t-sm);
}
.trocar {
position: absolute;
inset: 0;
display: grid;
place-items: start end;
padding: 6px;
border: 0;
background: transparent;
color: #fff;
cursor: pointer;
}
.rodape {
position: absolute;
left: 0;
@@ -262,127 +271,83 @@
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
background: linear-gradient(transparent, rgb(0 0 0 / 0.7));
background: linear-gradient(transparent, rgb(0 0 0 / 0.75));
color: #e8eef6;
font-size: var(--t-xs);
pointer-events: none;
}
.rodape button {
pointer-events: auto;
.rodape .mono {
opacity: 0.8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ponto {
width: 8px;
height: 8px;
border-radius: 50%;
background: #8e8e93;
flex: none;
}
.ponto.ao-vivo-on {
background: #ff3b30;
box-shadow: 0 0 0 3px rgb(255 59 48 / 0.3);
}
.espaco {
flex: 1;
}
.controles {
display: flex;
flex-wrap: wrap;
gap: var(--e-4);
align-items: center;
}
.transmissao {
/* ---------- lista */
.lista {
display: grid;
gap: var(--e-1);
min-width: min(100%, 420px);
gap: var(--e-5);
min-width: 0;
}
.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;
.amostra-cor {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--borda-forte);
background: var(--c);
cursor: pointer;
position: relative;
border: 2px solid var(--borda-forte);
}
.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 {
.feed {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-2);
max-height: 260px;
padding: var(--e-2) 0;
max-height: 280px;
overflow: auto;
font-size: var(--t-sm);
}
:global(.feed) li.estop {
.feed li {
display: flex;
gap: var(--e-3);
padding: 6px var(--e-4);
}
.feed time {
color: var(--texto-3);
flex: none;
}
.feed li.estop {
color: var(--aviso);
}
@media (min-width: 900px) {
.vazio-feed {
padding: var(--e-4);
font-size: var(--t-sm);
color: var(--texto-3);
}
@media (min-width: 640px) {
.palco {
grid-template-columns: 1fr 1fr;
margin: 0;
border-radius: var(--raio-lg);
overflow: hidden;
border: 1px solid var(--borda);
}
}
@media (min-width: 1024px) {
.ao-vivo {
grid-template-columns: minmax(0, 1.5fr) minmax(340px, 1fr);
gap: var(--e-5);
}
.palco {
top: calc(var(--topo-altura) + var(--e-5));
}
}
</style>
+85 -4
View File
@@ -1,7 +1,88 @@
<script lang="ts">
import Card from '../lib/componentes/Card.svelte';
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>
<Card titulo="Movimento" descricao="Braço, calibração e diagnóstico migram na fase 4.">
<p class="pequeno-texto">Enquanto isso, use a tela antiga: <a href="/braco">abrir Movimento (versão anterior)</a>.</p>
</Card>
<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>
+9 -1
View File
@@ -204,7 +204,7 @@
</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).">
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (até 12; as teclas 1 a 9 valem para os nove primeiros).">
<Gestos completo />
</Card>
{:else}
@@ -364,6 +364,14 @@
.espaco {
flex: 1;
}
/* seis ações pequenas + Mostrar cabem numa linha num cartão de 300 px */
.acoes :global(.btn.pequeno.icone) {
width: 28px;
}
.acoes {
flex-wrap: nowrap;
gap: 2px;
}
.marcado {
color: var(--ok);
border-color: var(--ok);
+414
View File
@@ -0,0 +1,414 @@
<script lang="ts">
import { ArrowLeft, Check } from '@lucide/svelte';
import {
cor, CORES, FORMATOS_INFO, formato, gesto, GESTOS_INFO, hex, nomeRosto, rgb, rosto, ROSTOS_INFO,
} from '../lib/comandos.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { estado } from '../lib/estado.svelte';
let previa = $state<{ ordem: (t: string) => void }>();
// Por padrão as emoções só aparecem aqui na prévia; ligado, vão também para o celular.
let noCelular = $state(false);
const atual = $derived(estado.aparencia.rosto);
const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao');
const aceitaFormato = $derived(!!ROSTOS_INFO[atual]?.formato);
const novos = $derived(estado.rostos.filter((r) => ROSTOS_INFO[r]?.novo));
const classicos = $derived(estado.rostos.filter((r) => !ROSTOS_INFO[r]?.novo));
const formatos = $derived(estado.formatos.length ? estado.formatos : Object.keys(FORMATOS_INFO));
const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'dormir', 'acordar'];
function emocao(g: string) {
if (noCelular) gesto(g);
else previa?.ordem(g);
}
const IDEIAS: { titulo: string; texto: string; feito: boolean; onde: string }[] = [
{ titulo: 'Pálpebras no lugar de formas prontas', texto: 'Cada emoção é posição de pálpebra; as trocas ficam suaves em vez de saltar.', feito: true, onde: 'rosto' },
{ titulo: 'Nunca parado', texto: 'Respiração, sacadas e piscada que achata o olho. Também protege a tela AMOLED.', feito: true, onde: 'rosto' },
{ titulo: 'Toque na tela', texto: 'Cutucar dá susto e risada; deslizar o dedo é carinho; tocar acorda.', feito: true, onde: 'rosto' },
{ titulo: 'Mãos', texto: 'Mãozinhas na cor dos olhos: esconde o rosto, espia, acena e pensa com a mão no queixo.', feito: true, onde: 'rosto' },
{ titulo: 'Faixa de baixo', texto: 'Legenda da conversa sendo digitada, pontinhos enquanto pensa, equalizador ao dançar e carga quando está no carregador.', feito: true, onde: 'rosto' },
{ titulo: 'Fala pelo próprio celular', texto: 'A resposta da conversa sai no alto-falante do S10, com os olhos pulsando na voz.', feito: false, onde: 'app' },
{ titulo: 'Ritmo do dia', texto: 'Boceja à noite, fica sonolento com bateria baixa, mostra um raio quando carrega.', feito: false, onde: 'rosto e servidor' },
{ titulo: 'Contato visual', texto: 'Sustenta o olhar de quem a câmera vê e desvia quando fica tímido.', feito: false, onde: 'rosto' },
{ titulo: 'Humor que dura', texto: 'Energia e alegria sobem com conversa e carinho e caem devagar.', feito: false, onde: 'servidor e IA' },
{ titulo: 'Hora nos olhos', texto: 'Ao perguntar a hora, ela aparece dentro dos olhos por alguns segundos.', feito: false, onde: 'rosto' },
];
</script>
<div class="rosto-pagina">
<a class="voltar" href="#/ao-vivo"><ArrowLeft size={16} /> Ao vivo</a>
<header>
<span class="rotulo">Galaxy S10 · 19:9 · AMOLED</span>
<h1>Rosto do Carinha</h1>
<p>Escolha o rosto, o formato dos olhos e a cor. A prévia abaixo é o mesmo rosto que roda no celular; clique nela para cutucar.</p>
</header>
<section class="bancada" aria-label="Prévia">
<div class="celular">
<div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" /></div>
<span class="camera-frontal" aria-hidden="true"></span>
<span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span>
</div>
<div class="emocoes" role="group" aria-label="Testar emoção">
{#each EMOCOES.filter((g) => GESTOS_INFO[g]) as g (g)}
<button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button>
{/each}
</div>
<Toggle
rotulo="Mandar as emoções também para o celular"
ajuda="Desligado, elas aparecem só na prévia"
checked={noCelular}
onchange={(v) => (noCelular = v)}
/>
</section>
<section class="bloco-escolha" aria-labelledby="t-rostos">
<h2 id="t-rostos">Rosto</h2>
<div class="cartoes" role="radiogroup" aria-label="Rosto">
{#each novos as r (r)}
<button class="cartao" role="radio" aria-checked={atual === r} onclick={() => rosto(r)}>
<span class="nome">{ROSTOS_INFO[r].nome} {#if atual === r}<Check size={16} />{/if}</span>
<span class="desc">{ROSTOS_INFO[r].descricao}</span>
</button>
{/each}
</div>
{#if classicos.length}
<div class="classicos" role="radiogroup" aria-label="Rostos clássicos">
<span class="rotulo">Clássicos</span>
{#each classicos as r (r)}
<button class="tecla" role="radio" aria-checked={atual === r} onclick={() => rosto(r)}>{nomeRosto(r)}</button>
{/each}
</div>
{/if}
</section>
<section class="bloco-escolha" aria-labelledby="t-formato">
<h2 id="t-formato">Formato dos olhos</h2>
<div class="formatos" role="radiogroup" aria-label="Formato dos olhos">
{#each formatos as f (f)}
<button class="formato" role="radio" aria-checked={formatoAtual === f} disabled={!aceitaFormato} onclick={() => formato(f)}>
<span class="amostra {f}" aria-hidden="true"></span>
{FORMATOS_INFO[f] ?? f}
</button>
{/each}
</div>
{#if !aceitaFormato}
<p class="fraco pequeno-texto">O formato vale para Carinha Viva, Bloco e Matriz.</p>
{/if}
</section>
<section class="bloco-escolha" aria-labelledby="t-cor">
<h2 id="t-cor">Cor dos olhos</h2>
<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" 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>
</section>
<section class="bloco-escolha" aria-labelledby="t-vivo">
<h2 id="t-vivo">O que deixa o Carinha vivo</h2>
<ul class="ideias">
{#each IDEIAS as i (i.titulo)}
<li>
<div class="linha entre">
<h3>{i.titulo}</h3>
<span class="status" class:feito={i.feito}>{i.feito ? 'Pronto' : 'Próximo'}</span>
</div>
<p>{i.texto}</p>
<span class="onde">{i.onde}</span>
</li>
{/each}
</ul>
</section>
</div>
<style>
.rosto-pagina {
display: grid;
gap: var(--e-6);
max-width: 1120px;
}
.voltar {
display: inline-flex;
align-items: center;
gap: var(--e-1);
width: fit-content;
color: var(--texto-2);
text-decoration: none;
font-size: var(--t-sm);
}
header {
display: grid;
gap: var(--e-2);
}
header h1 {
font-size: clamp(1.6rem, 3.5vw, 2.3rem);
letter-spacing: -0.01em;
}
header p {
color: var(--texto-2);
max-width: 65ch;
}
.rotulo {
font: 500 11px var(--fonte-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--texto-3);
}
.bancada {
display: grid;
gap: var(--e-4);
}
.celular {
position: relative;
aspect-ratio: 19 / 9;
max-width: 100%;
border-radius: clamp(18px, 3.4vw, 40px);
padding: clamp(6px, 0.9vw, 12px);
background: linear-gradient(145deg, #2a313b, #11161d 45%, #222932);
box-shadow: 0 30px 80px rgb(0 0 0 / 0.45), inset 0 1px 0 rgb(255 255 255 / 0.08);
}
.tela {
width: 100%;
height: 100%;
border-radius: clamp(13px, 2.6vw, 30px);
overflow: hidden;
background: #000;
}
.camera-frontal {
position: absolute;
left: clamp(14px, 2.2vw, 26px);
top: 50%;
translate: 0 -50%;
width: clamp(6px, 0.8vw, 10px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #2b3a55, #05070a 70%);
}
.etiqueta {
position: absolute;
right: clamp(20px, 3vw, 36px);
top: clamp(14px, 2vw, 24px);
font: 500 11px var(--fonte-mono);
color: rgb(255 255 255 / 0.4);
letter-spacing: 0.08em;
pointer-events: none;
}
.emocoes,
.classicos {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
align-items: center;
}
.tecla {
min-height: 40px;
padding: 0 var(--e-4);
border: 1px solid var(--borda);
border-radius: var(--raio-pill);
background: var(--superficie);
color: var(--texto);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.tecla:hover {
border-color: var(--destaque);
}
.tecla[aria-checked='true'] {
background: var(--destaque);
border-color: var(--destaque);
color: var(--destaque-texto);
}
.bloco-escolha {
display: grid;
gap: var(--e-3);
}
.bloco-escolha h2 {
font-size: var(--t-lg);
}
.cartoes {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--e-3);
}
.cartao {
display: grid;
gap: var(--e-1);
align-content: start;
padding: var(--e-4);
text-align: left;
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie);
color: var(--texto);
font: inherit;
cursor: pointer;
}
.cartao:hover {
border-color: var(--borda-forte);
}
.cartao[aria-checked='true'] {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.nome {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.nome :global(svg) {
color: var(--destaque);
}
.desc {
font-size: var(--t-sm);
color: var(--texto-2);
line-height: 1.4;
}
.formatos {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--e-2);
}
.formato {
display: grid;
justify-items: center;
gap: var(--e-2);
padding: var(--e-3) var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie);
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.formato[aria-checked='true'] {
border-color: var(--destaque);
color: var(--texto);
}
.formato:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* amostras com as mesmas proporções do rosto-motor.js */
.amostra {
display: block;
width: 20px;
height: 29px;
border-radius: 10px;
background: var(--destaque);
}
.amostra.redondo {
width: 24px;
height: 24px;
border-radius: 50%;
}
.amostra.pilula {
width: 16px;
height: 31px;
border-radius: 8px;
}
.amostra.quadrado {
width: 26px;
height: 26px;
border-radius: 4px;
}
.amostra.largo {
width: 33px;
height: 18px;
border-radius: 6px;
}
.cores {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.cor {
width: 36px;
height: 36px;
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;
}
.ideias {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
background: var(--borda);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
overflow: hidden;
}
.ideias li {
display: grid;
gap: var(--e-2);
align-content: start;
padding: var(--e-4) var(--e-5);
background: var(--superficie);
}
.ideias h3 {
font-size: var(--t-md);
}
.ideias p {
font-size: var(--t-sm);
color: var(--texto-2);
}
.status {
flex: none;
font: 500 11px var(--fonte-mono);
padding: 2px 8px;
border-radius: var(--raio-pill);
border: 1px solid var(--borda-forte);
color: var(--texto-3);
}
.status.feito {
color: var(--ok);
border-color: transparent;
background: var(--ok-suave);
}
.onde {
font: 500 11px var(--fonte-mono);
color: var(--texto-3);
}
@media (max-width: 900px) {
.cartoes {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ideias {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.formatos {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</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>
+57
View File
@@ -0,0 +1,57 @@
// node --test (Node 22.6+ remove os tipos sozinho). Substitui tests/test_movimentos.js e test_rastreamento.js.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { encaixe, escolherAlvo, limites, sequencias } from '../src/lib/nucleo.ts';
test('mão tem prioridade sobre rosto e entradas inválidas são ignoradas', () => {
const faces = [{ x: 0.2, y: 0.1, w: 0.3, h: 0.4, score: 0.9 }];
const maos = [Array.from({ length: 21 }, () => ({ x: 0.8, y: 0.6 }))];
assert.equal(escolherAlvo(faces, maos)?.tipo, 'mao');
const rosto = escolherAlvo(faces, [])!;
assert.equal(rosto.tipo, 'rosto');
assert.ok(Math.abs(rosto.x - 0.35) < 1e-9 && Math.abs(rosto.y - 0.3) < 1e-9);
assert.equal(escolherAlvo([], []), null);
assert.equal(escolherAlvo([{ x: NaN, y: 0, w: 1, h: 1 }], []), null);
});
test('entre rostos, segue o mais perto do alvo anterior', () => {
const faces = [
{ x: 0.0, y: 0.0, w: 0.4, h: 0.4 },
{ x: 0.7, y: 0.7, w: 0.2, h: 0.2 },
];
assert.equal(escolherAlvo(faces, [])?.x, 0.2);
assert.ok(Math.abs(escolherAlvo(faces, [], { tipo: 'rosto', x: 0.8, y: 0.8 })!.x - 0.8) < 1e-9);
});
test('encaixe centraliza sem cortar', () => {
const r = encaixe(1000, 500, 640, 480);
assert.ok(Math.abs(r.x - (500 - (500 * 640) / 480 / 2)) < 1e-9);
assert.equal(r.y, 0);
assert.ok(Math.abs(r.h - 500) < 1e-9);
});
test('sequências respeitam a faixa calibrada e voltam ao neutro', () => {
const faixas = Array.from({ length: 7 }, (): [number, number] => [81, 99]);
const seqs = sequencias(faixas);
assert.equal(seqs.length, 5);
for (const s of seqs) {
assert.ok(s.passos.length >= 3);
for (const passo of s.passos) {
assert.equal(passo.length, 7);
passo.forEach((v) => assert.ok(v >= 81 && v <= 99, `${s.nome}: ${v}`));
}
}
assert.deepEqual(seqs[0].passos.at(-1), Array(7).fill(90));
// Faixa que não contém 90°: o neutro vira o ponto mais próximo dentro dela.
const deslocada = sequencias(Array.from({ length: 7 }, (): [number, number] => [100, 140]));
assert.deepEqual(deslocada[0].passos.at(-1), Array(7).fill(100));
});
test('limites seguem a calibração e o par 5/6 invertido', () => {
const cal = Array.from({ length: 8 }, (_, canal) => ({ canal, minimo: 1400, maximo: 1600, centro: 1500 }));
const art = [0, 1, 2, 3, 4, 5, 7].map((canal) => ({ canal }));
const f = limites(cal, art);
assert.deepEqual(f[0], [81, 99]);
cal[6] = { canal: 6, minimo: 1450, maximo: 1600, centro: 1500 };
assert.deepEqual(limites(cal, art)[5], [81, 94]);
});
+1 -1
View File
@@ -6,7 +6,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
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'];
'/carinha.html', '/reacoes-render.js', '/rosto-motor.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
export default defineConfig({
plugins: [svelte()],