Compare commits

..
Author SHA1 Message Date
julioandClaude Opus 5.5 8d410dbf24 fix(ao-vivo): rosto do painel dorme e acorda junto com o celular
O celular adormece sozinho (silêncio sem movimento) e acorda com som,
movimento ou toque; isso vem na telemetria, não no SSE de ordens, e a
prévia do painel continuava acordada. Ao vivo e mini-rosto agora seguem
o `dormindo` da telemetria; as prévias de edição (Rosto, Reações) não.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:08:25 -03:00
julioandClaude Opus 5.5 fd6a14054d fix(pir): acompanha o nível do pino em vez de só a borda de subida
O HC-SR505 segura a saída em alto ~8 s e rearma a cada movimento; com
alguém na frente o pino ficava alto direto e passar a mão não gerava
borda nova, então o painel parecia não detectar nada. Agora o gpiomon
escuta as duas bordas, o nível inicial vem do gpioget e o status/SSE
levam `ativo`. A bolinha do Ao vivo fica acesa enquanto o pino está
alto, e o modo presença não manda dormir com o sensor ainda segurando.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:04:41 -03:00
julioandClaude Opus 5.5 784042335c fix(deploy): tira o BOM do script enviado à placa
O PowerShell 5.1 manda BOM no stdin do ssh e o bash lia "<BOM>set -e"
como comando inexistente, rodando a instalação sem parar no primeiro erro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:57:53 -03:00
julioandClaude Opus 5.5 cf54a93b53 feat(ao-vivo): pontinho do PIR sobre a imagem do palco
Verde pulsando por 8 s depois de cada disparo, cinza sem presença;
some quando o sensor não está disponível.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:56:48 -03:00
julioandClaude Opus 5.5 499a4ae479 feat(eventos): menu Eventos, modo presenca e 3 dias de historico
- "Eventos" vira item do menu principal: lista por dia (Hoje, Ontem...),
  presenca e movimento em destaque, botao "Ver foto" com miniatura
- modo presenca: o PIR liga a camera do celular e acorda o Carinha; sem
  ninguem por N minutos (5 por padrao) a camera desliga, ele dorme e fica
  em silencio (sons e fala desligados enquanto dorme)
- historico guarda so os ultimos 3 dias (ajustavel de 1 a 30), com faxina
  automatica de eventos e fotos antigos
- testes da retencao e do modo presenca

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:51:49 -03:00
julioandClaude Opus 5.5 6029c79645 feat(ao-vivo): indicador ao vivo do sensor PIR
Cada disparo do PIR vira um evento SSE 'pir' (mesmo dentro do intervalo do
historico) e o painel mostra 'presenca agora' por 8 s em Ao vivo e no
Historico; /estado inclui o estado do PIR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:47:11 -03:00
julioandClaude Opus 5.5 4b9e805801 fix(historico): ignora a borda falsa do PIR ao iniciar a leitura do pino
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:43:09 -03:00
julioandClaude Opus 5.5 1445ffdcde fix(historico): pull-down no pino do PIR evita disparos falsos com o sensor desligado
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:42:10 -03:00
julioandClaude Opus 5.5 788215b49e feat(historico): deteccoes com foto, sensor PIR na placa e tela apagando no sono
- historico.py: eventos de PIR (gpiomon no pino 11, gpiochip1 linha 70),
  movimento e som do celular em SQLite; ate 200 eventos e 100 fotos;
  cada sensor e a foto ligam/desligam; intervalo minimo por tipo
- PIR acorda o Carinha quando ele esta dormindo
- APK 0.8.3: o app consulta o pedido de foto e envia uma foto da camera
  frontal (quadro da camera de movimento ou do video ao vivo)
- tela apaga depois de X minutos dormindo (preto total + brilho minimo no
  AMOLED); acende com som, movimento, toque, comando ou PIR
- painel: tela Historico (sensores, fotos, filtros, apagar), atalho em Ao
  vivo e no Ctrl+K, ajustes "Apagar a tela quando dormir"
- testes do historico (servico e rotas HTTP)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:40:13 -03:00
julioandClaude Opus 5.5 98c9e8803e fix(rosto): expressoes e reacoes mantem a cor escolhida
Surpreso, triste e dormindo usavam azuis fixos; agora usam tons da cor
escolhida (mais clara no susto, mais escura e apagada na tristeza e no
sono). Nas reacoes da biblioteca o ciano padrao vira a cor escolhida e o
azul-claro um tom claro dela, no celular e nas miniaturas do painel.
Bravo (vermelho) e coracao (rosa) continuam, pois fazem parte da emocao.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:26:45 -03:00
julioandClaude Opus 5.5 2dab45604c feat(rosto): dormindo com olhos fechados e Zzz subindo
Dormindo, os rostos novos mostram os olhos como tracos (-) e tres Z de
tamanhos diferentes saem do canto do olho direito, sobem crescendo e somem
em ciclo. Na Matriz a boca vira '_' (- _ -) e os Z sao maiores, em LEDs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:19:58 -03:00
julioandClaude Opus 5.5 982bf56cd0 feat(rosto): sobrancelha perto do olho, boca mais alta e ajustes de posicao
A sobrancelha passa a ser posicionada a partir do topo real do olho de cada
estilo e formato (antes usava a altura do olho classico, alto demais nos
rostos novos). Nos rostos novos a boca sobe um pouco. Novos ajustes
'Subir a boca' e 'Distancia das sobrancelhas' (tela Rosto e Ajustes),
validados no servidor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:18:17 -03:00
julioandClaude Opus 5.5 1029b5fa99 feat(rosto): iris, boca e sobrancelhas na Matriz; emocoes da tela Rosto vao ao celular
- opcao "iris" (app config, Ajustes e tela Rosto): anel mais escuro com
  pupila que acompanha o olhar, dilata na alegria e contrai no susto; na
  Matriz vira LEDs apagados
- Matriz desenha boca e sobrancelhas em LEDs (antes a tela de pontos
  cobria o SVG); Bloco e Lente passam a obedecer o ajuste de boca
- tela Rosto: boca, sobrancelhas e iris ali mesmo; as teclas de emocao
  agora vao ao celular por padrao ("testar so na previa" e opcional)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:15:39 -03:00
julioandClaude Opus 5.5 a67c5ea9af fix(android): transmissao de video nao trava quando o servidor reinicia
Se o servidor caia no meio da transmissao, o app tentava reabrir a camera
ainda presa pelo sensor de movimento; o getUserMedia ficava esperando para
sempre e a fila de pedidos do painel travava (video "desligado" ate
reiniciar o app). Agora abrir a camera tem prazo de 8 s, cada pedido do
painel tem prazo de 12 s e uma camera encerrada de repente desfaz a sessao
para a proxima mensagem reabrir. Validado no S10: servidor reiniciado com
video ativo volta sozinho em ~8 s. APK 0.8.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:10:39 -03:00
julioandClaude Opus 5.5 e0dd03411d fix(voz): tira o timbre de sapo; voz aguda, limpa e com brilho metalico
O coaxar vinha do zumbido de 72 Hz, da ressonancia grave (~230 Hz), da
oitava de baixo e do FM desalinhado. Agora: ressonancia de lata fina
(~850 Hz), FM harmonico leve, realce em 3,2 kHz, oitava de baixo quase
nula e tom 25% acima. Bravo vira bipes zangados e pensar vira 'hmm?' com
bipinhos; nenhum som tem energia relevante abaixo de 250 Hz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 03:02:14 -03:00
julioandClaude Opus 5.5 74ae2863a7 feat(voz): timbre mais robotico e metalico
Modulacao em anel (72 Hz), ressonancia de lata (atraso de 4,3 ms
realimentado), degraus digitais com saturacao suave e FM com razao 1,41
nas silabas. Volume geral ajustado para compensar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:59:25 -03:00
julioandClaude Opus 5.5 f7c4793dc2 feat(voz): voz de robo sintetizada no estilo dos robozinhos de cinema
- voz-robo.js: silabas com deslize de tom, vibrato e filtro de formante
  ("uou", "ohh"), motorzinho de ruido filtrado e cliques; tudo gerado na
  hora com Web Audio, sem gravacoes nem arquivos de terceiros
- um som proprio para cada gesto, incluindo maos (motor ao subir e descer)
- balbucio de robo na resposta da conversa (mesma frase, mesmo som; sobe
  no fim quando e pergunta) e silencio dos gestos enquanto ele "fala"
- tela Rosto: ouvir a voz no PC e botao "Testar fala"; bipes antigos
  ficam como reserva

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:57:32 -03:00
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
99 changed files with 5168 additions and 6265 deletions
+3
View File
@@ -23,3 +23,6 @@ api-privada.tmp
ia-config.json ia-config.json
seguranca.json seguranca.json
historico.db
historico.json
fotos/
+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) ## O painel
- [Plano e verificações](docs/PLANO.md)
- [Registro de alterações](docs/REGISTRO.md) 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 ## 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. 1. `iniciar.bat` confere o Orange Pi e abre o painel.
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. 2. No celular, toque longo › **Mudar o servidor** › `http://192.168.1.35:8100`.
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. 3. Se a placa tiver token (recomendado, veja [docs/SEGURANCA.md](docs/SEGURANCA.md)):
4. Em **Comportamento**, ajuste som, movimento, volume, sono e economia de tela. **Configurações aplicadas no celular** depende da resposta do aparelho. 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 ## Verificações
```powershell ```powershell
python -m unittest discover -s tests -p test_controle_pc.py python -m pytest tests -q --ignore-glob='*browser*' # servidor: token, SSE, E-Stop, trava, módulos
python tests/test_browser.py cd web; npm run check; npm test; npm run build; cd .. # tipos, lógica pura e build do painel
python tests/test_rtc_browser.py python tests/test_painel_web_browser.py # painel de ponta a ponta (Playwright)
node tests/test_qualidade.js python tests/test_rtc_browser.py # WebRTC real com câmera sintética
python android/build-apk.py 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. [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) ·
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. [Vídeo](docs/VIDEO-AUTO.md) · [Rastreamento](docs/RASTREAMENTO.md) · [Repositório](docs/GITEA.md) ·
# Atualização 0.4 — sensores e câmeras [Registro](docs/REGISTRO.md)
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).
+4 -2
View File
@@ -1,14 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto" package="br.carinha.rosto"
android:versionCode="6" android:versionCode="11"
android:versionName="0.6-ia-local"> android:versionName="0.8.3">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" /> <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
<!-- precisa de rede para falar com o servidor da carinha (troca de rosto) --> <!-- 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.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <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) --> <!-- escutar o ambiente: reage a barulho (surpreso) e a silencio (dormir) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- camera: pagina de visualizacao e, depois, seguir o movimento --> <!-- camera: pagina de visualizacao e, depois, seguir o movimento -->
+253 -20
View File
@@ -25,7 +25,28 @@
<path id="sobrancelha2" class="traco" style="display:none"/> <path id="sobrancelha2" class="traco" style="display:none"/>
</svg> </svg>
<div id="fps"></div> <div id="fps"></div>
<script src="rosto-motor.js"></script>
<script src="voz-robo.js"></script>
<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"; "use strict";
/* ------------------------------------------------------------------------- /* -------------------------------------------------------------------------
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer). Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
@@ -71,12 +92,33 @@ addEventListener("resize", ajustar);
const PRETO = [6, 8, 14]; const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255]; const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false }; const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
function cuidarDaTela() {
const agora = performance.now();
if (!r.dormindo) SONO.desde = 0;
else if (!SONO.desde) SONO.desde = agora;
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
if (apagar !== SONO.apagada) {
SONO.apagada = apagar;
window.carinhaTelaApagada = apagar;
document.body.style.visibility = apagar ? "hidden" : "";
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
}
}
setInterval(cuidarDaTela, 1000);
const CIANO_E = [150, 232, 255]; const CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78]; const VERM = [255, 78, 78];
const ROSA = [255, 112, 176]; const ROSA = [255, 112, 176];
const AMARELO = [255, 218, 96]; const AMARELO = [255, 218, 96];
const AZUL = [130, 170, 255]; const AZUL = [130, 170, 255];
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
const VERDE = [120, 255, 160]; const VERDE = [120, 255, 160];
const BRANCO = [245, 250, 255]; const BRANCO = [245, 250, 255];
@@ -84,10 +126,17 @@ const BRANCO = [245, 250, 255];
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos // 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" // crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = { 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 }, eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 }, carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 }, 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_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME]; let ROSTO = ROSTOS[ROSTO_NOME];
@@ -122,8 +171,18 @@ function caminhoLinha(x0, y0, x1, y1) {
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1; return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
} }
/* formas da boca (o humor escolhe qual) */ /* formas da boca (o humor escolhe qual) */
function deslocamentoBoca() {
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
}
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
function metadeOlho(esc) {
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
return m * RY * esc;
}
function caminhoBoca(tipo) { function caminhoBoca(tipo) {
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2; const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
if (tipo === "aberta") { // surpresa: um "O" if (tipo === "aberta") { // surpresa: um "O"
const rx = l * 0.17, ry = l * 0.22; const rx = l * 0.17, ry = l * 0.22;
return caminhoElipse(cx, cy, rx, ry); return caminhoElipse(cx, cy, rx, ry);
@@ -264,7 +323,7 @@ function sequencias() {
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]]; const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18], const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]]; [{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09], const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
[{ dy: -10 }, 0.5], [{ dy: -10 }, 0.5],
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]]; [{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1], const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
@@ -273,13 +332,21 @@ function sequencias() {
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15], const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15], [{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
[{ forma: "aberto", cor: CIANO }, 0.3]]; [{ forma: "aberto", cor: CIANO }, 0.3]];
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5], const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]]; [{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5], const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]]; [{ 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], const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]]; [{ 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 }; bravo, coracao, triste, piscadinha, alegre };
} }
@@ -298,6 +365,7 @@ function proximo() {
const [alvo, dur] = r.fila[0]; const [alvo, dur] = r.fila[0];
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy }; r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
r.cor_ini = r.cor.slice(); r.cor_ini = r.cor.slice();
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
if ("forma" in alvo) r.forma = alvo.forma; if ("forma" in alvo) r.forma = alvo.forma;
if ("forma2" in alvo) r.forma2 = alvo.forma2; if ("forma2" in alvo) r.forma2 = alvo.forma2;
if ("boca" in alvo) r.boca = alvo.boca; if ("boca" in alvo) r.boca = alvo.boca;
@@ -332,7 +400,7 @@ function passo(dt) { // avanca a fila; true se ha gesto em
function repouso(t) { // pose calma: olha devagar, "respira" function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) { if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono"; r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2)); r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
r.ab = 0; r.ab2 = 0; r.ab = 0; r.ab2 = 0;
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4); r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0; r.dx = 0;
@@ -361,20 +429,39 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0); 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 dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; 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,
iris: EXTRAS.iris,
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : 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]; const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc, 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); abx, fx, r.cor, lado, dx, dy * 0.6);
} }
desenharBoca(); const matriz = comMotor && ROSTO.motor === "matriz";
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
else desenharBoca();
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1)); const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none'; brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue; if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2; const c = i === 0 ? E1 : E2;
const lado = i === 0 ? 1 : -1; const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85; const largura = RX * esc * 0.85;
const y = c[1] - RY * esc - 20 * ESC; const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0; const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const elevacao = r.esc > 1.1 ? -15 * ESC : 0; const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao)); brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
@@ -383,6 +470,27 @@ function desenhar() {
} }
} }
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
function tracosDoRosto(esc) {
const tracos = [];
if (EXTRAS.boca) {
const forma = r.boca;
if (r.dormindo) { // dormindo na Matriz: "– _ –"
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
}
if (EXTRAS.sobrancelhas && !r.dormindo) {
[E1, E2].forEach((c, i) => {
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inc = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
tracos.push({ d: 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele), largura: 9 * ESC });
});
}
return tracos;
}
// ------------------------------------------------------------------ controle // ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */ /* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) { function trocarRosto(nome) {
@@ -411,6 +519,9 @@ function trata(ordem) {
trocarRosto(ordem.slice(6).trim()); trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) { } else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo" 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) { } else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number); const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) { if (v.length === 3 && v.every((n) => !isNaN(n))) {
@@ -423,7 +534,14 @@ function trata(ordem) {
} else if (ordem === "aleatorio") { } else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) { } else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(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") { } else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; } try { window.close(); } catch (e) { r.fila = []; }
} }
@@ -440,7 +558,7 @@ function pose(nome) {
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) { for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k]; if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
} }
if ("cor" in alvo) r.cor = alvo.cor.slice(); if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
congelado = true; congelado = true;
desenhar(); desenhar();
return true; return true;
@@ -460,7 +578,7 @@ window.eilik = {
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto // busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
// do PC com um curl, igual ao FIFO de antes // do PC com um curl, igual ao FIFO de antes
async function buscarOrdens() { async function buscarOrdens() {
if (new URLSearchParams(location.search).get('espelho') === '1') return; if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
try { try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" }); const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return; if (!resp.ok) return;
@@ -479,12 +597,44 @@ async function aplicarAparencia() {
if (!resp.ok) return; if (!resp.ok) return;
const c = await resp.json(); const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto); 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))) 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); } { CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ } } catch (e) { /* sem servidor — segue com o padrao */ }
} }
setTimeout(aplicarAparencia, 200); setTimeout(aplicarAparencia, 200);
if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000); // ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
if (MODO_PAINEL) {
window.carinhaAtiva = false;
addEventListener('message', (e) => {
if (e.origin !== location.origin || e.source !== parent || !e.data) return;
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
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) // teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo", const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
@@ -583,6 +733,16 @@ function checarSilencio(agora) {
journal do carinha-web (so quando o microfone esta ativo). */ journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) { function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
if (evento !== "adormeceu") registrarEvento("som", evento, n);
}
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
function registrarEvento(tipo, evento, nivel) {
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
try {
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
} catch (e) {}
} }
// ------------------------------------------------------ presenca (movimento) // ------------------------------------------------------ presenca (movimento)
@@ -671,6 +831,7 @@ function movimento(vel, cx, cy) {
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */ /* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) { function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
registrarEvento("movimento", evento, n);
} }
// ------------------------------------------------- camera oculta (sensor) // ------------------------------------------------- camera oculta (sensor)
@@ -801,7 +962,11 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null; let configRecebida = null;
function configurarApp(c) { function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
SONO.apagar = c.apagarTela !== false;
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
AUTO = c.auto; AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume; AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir; COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
@@ -818,8 +983,10 @@ async function sincronizarApp() {
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return; if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
sincronizando = true; sincronizando = true;
try { try {
if (!window.carinhaSSE) {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) }); const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json()); if (resp.ok) configurarApp(await resp.json());
}
if (par.get('aparelho') === '1') { if (par.get('aparelho') === '1') {
const agora = performance.now() / 1000; const agora = performance.now() / 1000;
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'}, await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
@@ -829,7 +996,9 @@ async function sincronizarApp() {
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2, movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida, centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho, brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
}) }); }) });
} }
@@ -874,15 +1043,32 @@ const SONS = {
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); }, dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
}; };
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
os sons dos gestos ficam quietos para não embolar. */
function falar(texto) {
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
const c = contextoAudio();
if (!c) return;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try {
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
AUDIO.mudoAte = performance.now() + dur * 1000;
} catch (e) { /* segue sem voz */ }
}
function tocarSom(gesto) { function tocarSom(gesto) {
if (!AUDIO.ligado) return false; if (!AUDIO.ligado) return false;
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
const f = SONS[gesto]; const f = SONS[gesto];
if (!f) return false; if (!voz && !f) return false;
const c = contextoAudio(); const c = contextoAudio();
if (!c) return false; if (!c) return false;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } } if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try { try {
f(c, c.currentTime + 0.02, AUDIO.volume); if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo // aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
console.log("voz: " + gesto + " ctx=" + c.state); console.log("voz: " + gesto + " ctx=" + c.state);
return true; return true;
@@ -965,11 +1151,13 @@ setTimeout(relatar, 1500);
if(location.protocol==='file:')return; if(location.protocol==='file:')return;
let seen=null,fetching=false; let seen=null,fetching=false;
async function poll(){ 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; 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;} 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; if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
falar(m.texto);
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto); 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;} }catch(e){}finally{fetching=false;}
} }
setInterval(poll,1000);poll(); setInterval(poll,1000);poll();
@@ -977,11 +1165,56 @@ setTimeout(relatar, 1500);
</script> </script>
<script src="/reacoes-render.js"></script> <script src="/reacoes-render.js"></script>
</body>
</html>
<script> <script>
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) { 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); 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;
falar(m.texto);
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> </script>
</body>
</html>
+533
View File
@@ -0,0 +1,533 @@
/* 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 (o.dormindo && !temConteudo) { // dormindo: "–" (olho fechado)
const esp = Math.max(2, p.ry * 0.2);
caixa(c, p.cx - w * 0.5, p.cy + h * 0.08 - esp / 2, w, esp, esp / 2); c.fill();
return;
}
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 (o.iris && fecha < 0.6 && e.bot < 0.35) {
const R = Math.min(w, h) * 0.3;
const ix = Math.max(x + R * 1.1, Math.min(x + w - R * 1.1, p.cx + (p.lx + sacada.x) * w * 0.016));
const iy = Math.max(y + R * 1.1, Math.min(y + h - R * 1.1, p.cy + h * 0.04 + (p.ly + sacada.y) * h * 0.016));
c.fillStyle = css(o.cor.map((v) => v * 0.42));
c.beginPath(); c.arc(ix, iy, R, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'destination-out';
const pupila = p.ab > 1.05 ? 0.38 : e.bot > 0.2 ? 0.62 : 0.5; // susto contrai, alegria dilata
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over';
if (est.brilho) {
c.fillStyle = 'rgba(245,250,255,0.9)';
c.beginPath(); c.arc(ix - R * 0.42, iy - R * 0.42, R * 0.2, 0, Math.PI * 2); c.fill();
}
} 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]));
for (const traco of o.tracos || []) {
if (typeof traco.d !== 'string') continue;
const caminho = new Path2D(traco.d);
bctx.strokeStyle = bctx.fillStyle = css(o.cor);
bctx.lineWidth = traco.largura; bctx.lineCap = 'round'; bctx.lineJoin = 'round';
if (traco.cheio) bctx.fill(caminho); else bctx.stroke(caminho);
}
desenharMaos(bctx, o, olhos, tela.height / d);
desenharZzz(bctx, o, olhos, 1.6); // em LEDs o Z precisa ser maior para ler
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);
desenharZzz(ctx, o, olhos);
} 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);
desenharZzz(cctx, o, olhos);
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);
}
}
// ------------------------------------------------------------------ Zzz
function desenharZzz(c, o, olhos, fator) {
fator = fator || 1;
if (!o.dormindo) return;
const d = olhos.slice().sort((a, b) => a.cx - b.cx)[1]; // olho da direita na tela
const x0 = d.cx + d.ry * 0.7, y0 = d.cy - d.ry * 0.55;
c.save();
c.strokeStyle = css(o.cor); c.lineCap = 'round'; c.lineJoin = 'round';
for (let i = 0; i < 3; i++) {
const k = ((o.t || 0) * 0.32 + i / 3) % 1; // cada Z num ponto do ciclo
const tam = d.ry * (0.28 + 0.3 * k) * fator;
const x = x0 + d.ry * 0.7 * k + Math.sin(k * Math.PI * 2) * d.ry * 0.08, y = y0 - d.ry * 1.7 * k;
c.globalAlpha = Math.min(1, k / 0.15) * (1 - k);
c.lineWidth = Math.max(1.5, tam * 0.2);
c.beginPath();
c.moveTo(x - tam / 2, y - tam / 2); c.lineTo(x + tam / 2, y - tam / 2);
c.lineTo(x - tam / 2, y + tam / 2); c.lineTo(x + tam / 2, y + tam / 2);
c.stroke();
}
c.restore();
}
// --------------------------------------------------------- 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,
/** Metade da altura do olho (em frações de RY) para o estilo/formato: posiciona as sobrancelhas. */
metadeAltura: (estilo, formato) => {
const base = ESTILOS[estilo]; if (!base) return 1;
if (base.lente) return base.w * 0.5 * 1.12;
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
},
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
})();
+23 -3
View File
@@ -12,8 +12,13 @@ let config = {}, signature = '', movementEnabled = true, aviso = '', capacidades
let allowed = true; let allowed = true;
let lastMotionFrame = -1; let lastMotionFrame = -1;
function chave(c) { return JSON.stringify([c.cameraLado || 'user', c.videoResolucao || 'auto', c.videoFPS || 0]); } function chave(c) { return JSON.stringify([c.cameraLado || 'user', c.videoResolucao || 'auto', c.videoFPS || 0]); }
/* Nada pode esperar para sempre: se a câmera estiver presa (ex.: servidor caiu no meio e o
sensor de movimento ainda a segura), desiste e a próxima mensagem do painel tenta de novo. */
function comPrazo(promessa, ms, oque) {
return Promise.race([promessa, new Promise((_, rej) => setTimeout(() => rej(new Error(oque + ' demorou mais de ' + ms / 1000 + ' s')), ms))]);
}
async function capturar(c) { async function capturar(c) {
const media = await navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}}); const media = await comPrazo(navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}}), 8000, 'Abrir a câmera');
const track = media.getVideoTracks()[0]; const track = media.getVideoTracks()[0];
const facing = track.getSettings().facingMode; const facing = track.getSettings().facingMode;
if (facing && facing !== (c.cameraLado || 'user')) { if (facing && facing !== (c.cameraLado || 'user')) {
@@ -74,6 +79,12 @@ async function iniciar(id) {
const media = await capturar(config); signature = chave(config); const media = await capturar(config); signature = chave(config);
if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; } if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; }
stream = media; video.srcObject = stream; await video.play(); stream = media; video.srcObject = stream; await video.play();
// câmera fechada de repente (outro uso, erro do sistema): desfaz tudo; o próximo pacote reabre
stream.getVideoTracks()[0].onended = () => {
if (captureGeneration !== generation) return;
console.log('RTC câmera encerrada; recomeçando no próximo pedido');
parar(); sid = null;
};
pc = new RTCPeerConnection({iceServers:[]}); pc = new RTCPeerConnection({iceServers:[]});
sender = pc.addTrack(stream.getVideoTracks()[0], stream); sender = pc.addTrack(stream.getVideoTracks()[0], stream);
const mine = pc; const mine = pc;
@@ -133,14 +144,23 @@ async function receber(packet) {
} }
window.rtcReceber = packet => { window.rtcReceber = packet => {
allowed = true; allowed = true;
tasks = tasks.then(() => allowed ? receber(packet) : undefined).catch(e => { tasks = tasks.then(() => allowed ? comPrazo(receber(packet), 12000, 'Pedido de vídeo') : undefined).catch(e => {
console.log('RTC erro=' + e.name + ': ' + e.message); console.log('RTC erro=' + e.name + ': ' + e.message);
sinal({tipo:'erro',erro:e.name + ': ' + e.message}); sinal({tipo:'erro',erro:e.name + ': ' + e.message});
estado({tipo:'webrtc',estado:'erro',erro:e.message}); estado({tipo:'webrtc',estado:'erro',erro:e.message});
parar(); parar(); sid = null;
}); });
}; };
window.rtcParar = () => { allowed = false; parar(); sid = null; }; window.rtcParar = () => { allowed = false; parar(); sid = null; };
/* Foto do evento para o histórico, tirada do vídeo ao vivo (câmera de movimento parada). */
window.rtcFoto = () => {
if (!stream || video.readyState < 2 || !video.videoWidth) return;
const escala = Math.min(1, 1280 / video.videoWidth);
const tela = document.createElement('canvas');
tela.width = Math.round(video.videoWidth * escala); tela.height = Math.round(video.videoHeight * escala);
tela.getContext('2d').drawImage(video, 0, 0, tela.width, tela.height);
CarinhaRTC.foto(tela.toDataURL('image/jpeg', 0.82));
};
setInterval(() => { setInterval(() => {
if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return; if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return;
lastMotionFrame=video.currentTime; lastMotionFrame=video.currentTime;
+188
View File
@@ -0,0 +1,188 @@
/* voz-robo.js — voz sintetizada do Carinha, no estilo dos robozinhos de cinema.
*
* Tudo é gerado na hora com Web Audio: nenhuma gravação, nenhum arquivo de terceiros.
* Cada som é uma sequência de "sílabas": um oscilador que desliza de um tom a outro, com vibrato,
* passando por um filtro de formante (a "vogal": o→i faz o "uou"). Motores das mãos viram ruído
* filtrado, e a resposta da conversa vira um balbucio que sobe no fim quando é pergunta.
*/
(function () {
'use strict';
let ruido = null; // buffer de ruído reaproveitado
const TOM = 1.25; // todas as sílabas 25% acima do tom escrito
const saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
/*
* Cadeia de "brilho metálico" (criada uma vez por AudioContext):
* voz → ressonância de lata fina (agudo, ~850 Hz) → saturação leve → compressor → saída
* Nada grave: zumbido e ressonância graves deixavam a voz com cara de sapo.
*/
function saida(c) {
if (saidas.has(c)) return saidas.get(c);
const entrada = c.createGain();
const seca = c.createGain(); seca.gain.value = 0.8;
const atraso = c.createDelay(0.05); atraso.delayTime.value = 0.00118;
const volta = c.createGain(); volta.gain.value = 0.42;
const molhada = c.createGain(); molhada.gain.value = 0.35;
entrada.connect(seca);
entrada.connect(atraso); atraso.connect(volta); volta.connect(atraso); atraso.connect(molhada);
const satura = c.createWaveShaper();
const n = 2048, curva = new Float32Array(n);
for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; curva[i] = Math.tanh(x * 1.3); }
satura.curve = curva;
seca.connect(satura); molhada.connect(satura);
const brilho = c.createBiquadFilter(); // realce leve nos agudos: "metal" limpo
brilho.type = 'peaking'; brilho.frequency.value = 3200; brilho.Q.value = 1.2; brilho.gain.value = 4;
const comp = c.createDynamicsCompressor();
comp.threshold.value = -16; comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.15;
const passaBaixa = c.createBiquadFilter();
passaBaixa.type = 'lowpass'; passaBaixa.frequency.value = 8000;
const volume = c.createGain(); volume.gain.value = 0.8;
satura.connect(brilho); brilho.connect(comp); comp.connect(passaBaixa); passaBaixa.connect(volume); volume.connect(c.destination);
saidas.set(c, entrada);
return entrada;
}
/**
* Uma sílaba. de/para: tom em Hz; vogal: [formante inicial, final] em Hz;
* vib: [Hz, profundidade em Hz]; forma: tipo de onda.
*/
function silaba(c, t0, dur, de, para, vol, op) {
op = op || {};
vol *= 2.4; // compensa a energia que o filtro de formante tira
de *= TOM; para *= TOM; // voz de robozinho: aguda
const osc = c.createOscillator();
osc.type = op.forma || 'sawtooth';
osc.frequency.setValueAtTime(de, t0);
osc.frequency.exponentialRampToValueAtTime(Math.max(20, para), t0 + dur);
if (op.vib) {
const lfo = c.createOscillator(), prof = c.createGain();
lfo.frequency.value = op.vib[0]; prof.gain.value = op.vib[1];
lfo.connect(prof); prof.connect(osc.frequency);
lfo.start(t0); lfo.stop(t0 + dur + 0.05);
}
// FM harmônico e leve: brilho de metal sem aspereza
const mod = c.createOscillator(), modProf = c.createGain();
mod.type = 'sine';
mod.frequency.setValueAtTime(de * 2, t0);
mod.frequency.exponentialRampToValueAtTime(Math.max(20, para * 2), t0 + dur);
modProf.gain.setValueAtTime(de * (op.metal ?? 0.22), t0);
modProf.gain.exponentialRampToValueAtTime(Math.max(1, para * (op.metal ?? 0.22)), t0 + dur);
mod.connect(modProf); modProf.connect(osc.frequency);
mod.start(t0); mod.stop(t0 + dur + 0.05);
// corpo: uma senoide uma oitava abaixo deixa a voz menos "apito"
const sub = c.createOscillator();
sub.type = 'sine';
sub.frequency.setValueAtTime(de / 2, t0);
sub.frequency.exponentialRampToValueAtTime(Math.max(20, para / 2), t0 + dur);
const vogal = op.vogal || [900, 1500];
const filtro = c.createBiquadFilter();
filtro.type = 'bandpass'; filtro.Q.value = op.q || 5;
filtro.frequency.setValueAtTime(vogal[0], t0);
filtro.frequency.exponentialRampToValueAtTime(vogal[1], t0 + dur);
const g = c.createGain(), gs = c.createGain();
const ataque = Math.min(0.02, dur * 0.25);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + ataque);
g.gain.setValueAtTime(vol, t0 + dur * 0.7);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
gs.gain.value = 0.12; // pouca oitava de baixo: grave demais vira coaxar
osc.connect(filtro); sub.connect(gs); gs.connect(filtro); filtro.connect(g); g.connect(saida(c));
osc.start(t0); sub.start(t0);
osc.stop(t0 + dur + 0.05); sub.stop(t0 + dur + 0.05);
}
function bufferRuido(c) {
if (ruido && ruido.sampleRate === c.sampleRate) return ruido;
ruido = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = ruido.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return ruido;
}
/** Motorzinho: ruído filtrado que sobe de tom, com um tremor mecânico. */
function servo(c, t0, dur, vol, sobe) {
const fonte = c.createBufferSource();
fonte.buffer = bufferRuido(c); fonte.loop = true;
const f = c.createBiquadFilter();
f.type = 'bandpass'; f.Q.value = 9;
f.frequency.setValueAtTime(sobe === false ? 1500 : 500, t0);
f.frequency.exponentialRampToValueAtTime(sobe === false ? 500 : 1500, t0 + dur);
const g = c.createGain(), trem = c.createOscillator(), tg = c.createGain();
trem.frequency.value = 38; tg.gain.value = vol * 0.35;
trem.connect(tg); tg.connect(g.gain);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol * 0.6, t0 + 0.05);
g.gain.setValueAtTime(vol * 0.6, t0 + dur - 0.06);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
fonte.connect(f); f.connect(g); g.connect(saida(c));
fonte.start(t0); trem.start(t0);
fonte.stop(t0 + dur + 0.05); trem.stop(t0 + dur + 0.05);
}
function clique(c, t0, vol) {
const fonte = c.createBufferSource();
fonte.buffer = bufferRuido(c);
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 3000;
const g = c.createGain();
g.gain.setValueAtTime(vol * 0.5, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.03);
fonte.connect(f); f.connect(g); g.connect(saida(c));
fonte.start(t0); fonte.stop(t0 + 0.05);
}
const O = [500, 900], I = [1800, 2400], U = [350, 700], A = [900, 1300]; // vogais (formantes)
// Cada som: (c, t, v) → agenda as sílabas. Volumes relativos ao volume do app.
const SONS = {
feliz: (c, t, v) => { silaba(c, t, 0.12, 620, 900, v, { vogal: [1200, 2000] }); silaba(c, t + 0.13, 0.18, 900, 1250, v, { vogal: I, vib: [9, 18] }); },
alegre: (c, t, v) => [560, 700, 860, 1100].forEach((f, i) => silaba(c, t + i * 0.085, 0.09, f, f * 1.25, v * 0.85, { vogal: I, forma: 'square' })),
coracao: (c, t, v) => { silaba(c, t, 0.55, 760, 560, v, { vogal: [800, 1300], vib: [7, 28] }); },
piscadinha: (c, t, v) => { clique(c, t, v); silaba(c, t + 0.04, 0.08, 1000, 1300, v * 0.6, { forma: 'square', vogal: I }); },
surpreso: (c, t, v) => { silaba(c, t, 0.22, 260, 1100, v, { vogal: [450, 2000] }); silaba(c, t + 0.22, 0.12, 1100, 820, v * 0.8, { vogal: I }); },
bravo: (c, t, v) => [0, 0.13, 0.26].forEach((d, i) => silaba(c, t + d, 0.1, 480 - i * 30, 380 - i * 30, v * 0.85, { forma: 'square', vogal: [1300, 900] })), // bipes zangados
triste: (c, t, v) => { silaba(c, t, 0.65, 640, 380, v * 0.85, { vogal: [1200, 750], vib: [5, 14] }); },
dormir: (c, t, v) => { silaba(c, t, 0.35, 460, 760, v * 0.7, { vogal: [900, 1500] }); silaba(c, t + 0.36, 0.75, 760, 340, v * 0.6, { vogal: [1500, 700], vib: [4, 8] }); },
acordar: (c, t, v) => { servo(c, t, 0.25, v * 0.5); [300, 450, 700].forEach((f, i) => silaba(c, t + 0.22 + i * 0.1, 0.1, f, f * 1.2, v * 0.8, { vogal: I, forma: 'square' })); silaba(c, t + 0.56, 0.2, 700, 1050, v, { vogal: [1200, 2200], vib: [8, 15] }); },
acenar: (c, t, v) => { servo(c, t, 0.3, v * 0.45); silaba(c, t + 0.28, 0.13, 520, 820, v, { vogal: [1000, 1900] }); silaba(c, t + 0.44, 0.2, 760, 1080, v, { vogal: I, vib: [9, 20] }); },
esconder: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 0.1, 0.08, 950, 1350, v * 0.8, { vogal: I }); for (let i = 0; i < 5; i++) silaba(c, t + 0.6 + i * 0.09, 0.05, 1200 + (i % 2) * 180, 1300, v * 0.35, { forma: 'square', vogal: I }); servo(c, t + 2.05, 0.4, v * 0.45, false); },
espiar: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 1.0, 0.3, 480, 760, v * 0.9, { vogal: [600, 1700] }); silaba(c, t + 2.0, 0.16, 800, 1150, v, { vogal: I }); },
pensar: (c, t, v) => { servo(c, t, 0.35, v * 0.4); silaba(c, t + 0.35, 0.5, 520, 640, v * 0.7, { vogal: [900, 1600], vib: [5, 10] }); [0.95, 1.12].forEach((d) => silaba(c, t + d, 0.05, 1500, 1600, v * 0.35, { forma: 'square', vogal: I })); }, // "hmm?" e bipinhos
musica: (c, t, v) => [523, 659, 784, 659, 880, 784, 659, 1047].forEach((f, i) => silaba(c, t + i * 0.26, 0.2, f, f * 1.02, v * 0.6, { forma: 'triangle', vogal: A, vib: [6, 5] })),
};
// Gerador previsível: a mesma frase sempre "soa" igual.
function semente(texto) {
let h = 2166136261;
for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
return () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; };
}
/** Balbucio de robô para uma resposta: ~1 sílaba a cada 6 letras, no máximo 16. */
function balbuciar(c, texto, v) {
texto = String(texto || '').trim();
if (!texto) return 0;
const rnd = semente(texto), n = Math.max(2, Math.min(16, Math.ceil(texto.length / 6)));
const pergunta = /\?\s*$/.test(texto), exclama = /!\s*$/.test(texto);
const vogais = [O, I, A, U];
let t = c.currentTime + 0.03, tom = 520 + rnd() * 180;
for (let i = 0; i < n; i++) {
const ultima = i === n - 1;
const dur = 0.07 + rnd() * 0.1;
let para = tom * (0.8 + rnd() * 0.5);
if (ultima) para = pergunta ? tom * 1.6 : exclama ? tom * 1.35 : tom * 0.7;
silaba(c, t, ultima ? dur + 0.08 : dur, tom, para, v * (0.65 + rnd() * 0.3), { vogal: vogais[Math.floor(rnd() * 4)], vib: rnd() < 0.3 ? [8, 14] : null, forma: rnd() < 0.25 ? 'square' : 'sawtooth' });
t += dur + 0.025 + rnd() * 0.05;
tom = Math.max(330, Math.min(1000, para * (0.9 + rnd() * 0.25)));
}
return t - c.currentTime;
}
function tocar(c, nome, v) {
const f = SONS[nome];
if (!f || !c) return false;
f(c, c.currentTime + 0.02, v);
return true;
}
window.VozRobo = { tocar, balbuciar, servo: (c, dur, v) => servo(c, c.currentTime + 0.01, dur, v), sons: Object.keys(SONS) };
})();
@@ -10,6 +10,8 @@ import java.security.MessageDigest;
public final class CerebroLocal { public final class CerebroLocal {
private volatile boolean fechado; private volatile boolean fechado;
private Process processo; 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) { public void iniciar(final Context contexto, final String servidor) {
new Thread(() -> { new Thread(() -> {
try { try {
@@ -43,7 +45,9 @@ public final class CerebroLocal {
try { if (health.getResponseCode() == 200) { Log.i("CarinhaIA", "Executor existente pronto"); return; } } try { if (health.getResponseCode() == 200) { Log.i("CarinhaIA", "Executor existente pronto"); return; } }
finally { health.disconnect(); } finally { health.disconnect(); }
} catch (IOException ignored) { } } 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("LD_LIBRARY_PATH", libs.getAbsolutePath());
builder.environment().put("GGML_BACKEND_DL_PATH", libs.getAbsolutePath()); builder.environment().put("GGML_BACKEND_DL_PATH", libs.getAbsolutePath());
builder.redirectErrorStream(true); builder.redirectErrorStream(true);
@@ -28,6 +28,15 @@ public final class ControleRemoto {
private volatile ScheduledExecutorService worker; private volatile ScheduledExecutorService worker;
private long consultarEm; private long consultarEm;
private long quadroEm; private long quadroEm;
// Foto do evento (histórico): o servidor pede, o app tira e envia.
private volatile long fotoPedida = 0, fotoFeita = 0, fotoId = 0, pedidoEm = 0;
private volatile byte[] fotoJpeg = null;
/** Id do evento que está esperando foto (0 = nenhum). */
public long fotoPendente() { return fotoJpeg == null ? fotoPedida : 0; }
/** Foto vinda do vídeo ao vivo (quando a câmera de movimento está desligada). */
public void fotoRTC(byte[] jpeg) {
if (fotoPedida > 0 && fotoJpeg == null && jpeg != null && jpeg.length > 0) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; }
}
private long logEm; private long logEm;
private final Runnable mudanca; private final Runnable mudanca;
public interface RTCListener { boolean receber(JSONObject pacote); } public interface RTCListener { boolean receber(JSONObject pacote); }
@@ -46,6 +55,9 @@ public final class ControleRemoto {
public void sinalRTC(String json) { public void sinalRTC(String json) {
if (sinais.size() < 64) sinais.offer(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) { public void servidor(String valor) {
servidor = valor; servidor = valor;
pendente = null; pendente = null;
@@ -115,6 +127,7 @@ public final class ControleRemoto {
mudanca.run(); mudanca.run();
} }
if (videoRTC()) rtcTick(base); if (videoRTC()) rtcTick(base);
fotoTick(base);
byte[] jpeg = pendente; byte[] jpeg = pendente;
pendente = null; pendente = null;
if (jpeg != null && preview()) { if (jpeg != null && preview()) {
@@ -140,13 +153,17 @@ public final class ControleRemoto {
HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection(); HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
c.setConnectTimeout(1500); c.setReadTimeout(1500); c.setConnectTimeout(1500); c.setReadTimeout(1500);
c.setUseCaches(false); c.setUseCaches(false);
if (!token.isEmpty()) c.setRequestProperty("Authorization", "Bearer " + token);
return c; return c;
} }
public void quadro(Image image, int rotacao) { public void quadro(Image image, int rotacao) {
if (videoRTC()) return; boolean querFoto = fotoPedida > 0 && fotoJpeg == null;
long agora = System.currentTimeMillis(); long agora = System.currentTimeMillis();
if (!querFoto) {
if (videoRTC()) return;
if (!preview() || pendente != null || agora < quadroEm) return; if (!preview() || pendente != null || agora < quadroEm) return;
quadroEm = agora + 200; quadroEm = agora + 200;
}
try { try {
Image.Plane[] p = image.getPlanes(); Image.Plane[] p = image.getPlanes();
ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()}; ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()};
@@ -167,10 +184,44 @@ public final class ControleRemoto {
if (rotated != b) rotated.recycle(); if (rotated != b) rotated.recycle();
b.recycle(); b.recycle();
} }
if (preview()) pendente = jpeg; if (querFoto) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; }
else if (preview()) pendente = jpeg;
} catch (Exception e) { Log.w("CarinhaPC", "quadro descartado: " + e); } } catch (Exception e) { Log.w("CarinhaPC", "quadro descartado: " + e); }
} }
/** Consulta o pedido de foto (1x/s) e envia a foto pronta para o histórico. */
private void fotoTick(String base) throws Exception {
byte[] jpeg = fotoJpeg;
if (jpeg != null) {
long id = fotoId;
fotoJpeg = null;
HttpURLConnection c = abrir(base + "/historico/foto?id=" + id);
try {
c.setRequestMethod("POST"); c.setDoOutput(true);
c.setRequestProperty("Content-Type", "image/jpeg");
c.setFixedLengthStreamingMode(jpeg.length);
try (java.io.OutputStream out = c.getOutputStream()) { out.write(jpeg); }
int code = c.getResponseCode();
Log.i("CarinhaPC", "foto do evento " + id + ": HTTP " + code);
fotoFeita = id;
} finally { c.disconnect(); }
}
long agora = System.currentTimeMillis();
if (agora < pedidoEm) return;
pedidoEm = agora + 1000;
HttpURLConnection c = abrir(base + "/historico/pedido");
try {
if (c.getResponseCode() != 200) return;
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (InputStream in = c.getInputStream()) {
byte[] buffer = new byte[512]; int n;
while ((n = in.read(buffer)) != -1 && bytes.size() < 4096) bytes.write(buffer, 0, n);
}
long id = new JSONObject(bytes.toString("UTF-8")).optLong("id", 0);
if (id > 0 && id != fotoFeita && id != fotoId && fotoJpeg == null) fotoPedida = id;
} finally { c.disconnect(); }
}
private void rtcTick(String base) throws Exception { private void rtcTick(String base) throws Exception {
String json; String json;
while ((json = sinais.poll()) != null) { while ((json = sinais.poll()) != null) {
@@ -8,6 +8,7 @@ import android.content.Intent;
import android.content.IntentFilter; import android.content.IntentFilter;
import android.os.BatteryManager; import android.os.BatteryManager;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.net.Uri;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.graphics.Color; import android.graphics.Color;
import android.hardware.camera2.CameraAccessException; import android.hardware.camera2.CameraAccessException;
@@ -83,6 +84,7 @@ public class MainActivity extends Activity {
private static final String PREFS = "carinha"; private static final String PREFS = "carinha";
private static final String CHAVE = "servidor"; 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 PADRAO = "http://192.168.1.15:8100";
private static final String TAG = "CarinhaMic"; private static final String TAG = "CarinhaMic";
@@ -100,6 +102,7 @@ public class MainActivity extends Activity {
private WebView web; private WebView web;
private String servidor; private String servidor;
private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto
private final CerebroLocal cerebroLocal = new CerebroLocal(); private final CerebroLocal cerebroLocal = new CerebroLocal();
private boolean jaCaiuParaLocal = false; private boolean jaCaiuParaLocal = false;
private boolean jaRecarregou = false; private boolean jaRecarregou = false;
@@ -179,8 +182,10 @@ public class MainActivity extends Activity {
SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE); SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
servidor = p.getString(CHAVE, PADRAO); servidor = p.getString(CHAVE, PADRAO);
token = p.getString(CHAVE_TOKEN, "");
movimentoLigado = p.getBoolean(CHAVE_MOV, true); movimentoLigado = p.getBoolean(CHAVE_MOV, true);
usarServidorDoPedido(getIntent()); // permite apontar o servidor ao lancar usarServidorDoPedido(getIntent()); // permite apontar o servidor ao lancar
cerebroLocal.chave = token;
cerebroLocal.iniciar(this, servidor); cerebroLocal.iniciar(this, servidor);
remoto = new ControleRemoto(new Runnable() { remoto = new ControleRemoto(new Runnable() {
@Override public void run() { @Override public void run() {
@@ -193,6 +198,8 @@ public class MainActivity extends Activity {
} }
atualizarSensores(); atualizarSensores();
atualizarBrilho(); atualizarBrilho();
if (remoto.fotoPendente() > 0 && rtcAtivo)
rtcWeb.evaluateJavascript("window.rtcFoto&&window.rtcFoto()", null);
} }
} }
}); });
@@ -272,7 +279,7 @@ public class MainActivity extends Activity {
runOnUiThread(new Runnable() { runOnUiThread(new Runnable() {
@Override @Override
public void run() { public void run() {
boolean pode = true; boolean pode = origemConfiavel(pedido.getOrigin());
for (String recurso : pedido.getResources()) { for (String recurso : pedido.getResources()) {
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(recurso) if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(recurso)
&& checkSelfPermission(Manifest.permission.CAMERA) && checkSelfPermission(Manifest.permission.CAMERA)
@@ -348,6 +355,15 @@ public class MainActivity extends Activity {
if (it == null) return; if (it == null) return;
String novo = it.getStringExtra("servidor"); String novo = it.getStringExtra("servidor");
if (novo == null) return; 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(); novo = novo.trim();
while (novo.endsWith("/")) novo = novo.substring(0, novo.length() - 1); while (novo.endsWith("/")) novo = novo.substring(0, novo.length() - 1);
if (novo.length() == 0) return; if (novo.length() == 0) return;
@@ -374,8 +390,9 @@ public class MainActivity extends Activity {
private void abrirRosto() { private void abrirRosto() {
naCamera = false; naCamera = false;
remoto.servidor(servidor); remoto.servidor(servidor);
remoto.token(token);
if (usandoLocal) web.loadUrl("file:///android_asset/carinha.html"); 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 iniciarMovimento(); // volta a observar quem passa na frente
} }
@@ -397,7 +414,7 @@ public class MainActivity extends Activity {
private void menu() { private void menu() {
final String[] opcoes = {"Ver a camera", "Voltar para a carinha", final String[] opcoes = {"Ver a camera", "Voltar para a carinha",
(movimentoLigado ? "Desligar reacao ao movimento" : "Ligar reacao ao movimento"), (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) new AlertDialog.Builder(this)
.setTitle("Carinha") .setTitle("Carinha")
.setItems(opcoes, new DialogInterface.OnClickListener() { .setItems(opcoes, new DialogInterface.OnClickListener() {
@@ -408,7 +425,8 @@ public class MainActivity extends Activity {
else if (i == 2) alternarMovimento(); else if (i == 2) alternarMovimento();
else if (i == 3) pedirServidor(); else if (i == 3) pedirServidor();
else if (i == 4) menuSensores(); else if (i == 4) menuSensores();
else menuVideo(); else if (i == 5) menuVideo();
else pedirToken();
} }
}) })
.show(); .show();
@@ -520,6 +538,37 @@ public class MainActivity extends Activity {
.show(); .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. */ /** Voltar nao sai do app (num enfeite de mesa isso seria chato): volta a carinha. */
@Override @Override
public void onBackPressed() { public void onBackPressed() {
@@ -974,8 +1023,53 @@ public class MainActivity extends Activity {
return String.format(java.util.Locale.US, "%.3f", v); 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() { private void atualizarBrilho() {
if (naCamera) return; if (naCamera) return;
web.evaluateJavascript("window.carinhaAparelho=" + infoAparelho(), null);
Intent bateria = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); Intent bateria = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
if (bateria != null) { if (bateria != null) {
int level = bateria.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); int level = bateria.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
@@ -987,7 +1081,7 @@ public class MainActivity extends Activity {
web.evaluateJavascript("window.carinhaBateria={nivel:" + percent + ",carregando:" + charging + "}", null); web.evaluateJavascript("window.carinhaBateria={nivel:" + percent + ",carregando:" + charging + "}", null);
} }
} }
web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length)}):null", web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length),apagada:!!window.carinhaTelaApagada}):null",
new ValueCallback<String>() { new ValueCallback<String>() {
@Override public void onReceiveValue(String valor) { @Override public void onReceiveValue(String valor) {
if (!emPrimeiroPlano) return; if (!emPrimeiroPlano) return;
@@ -999,6 +1093,7 @@ public class MainActivity extends Activity {
float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(), float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(),
System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(), System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(),
estado.optBoolean("ocupado"), estado.optBoolean("dormindo")); estado.optBoolean("ocupado"), estado.optBoolean("dormindo"));
if (estado.optBoolean("apagada")) nivel = 0f; // tela preta + brilho mínimo
WindowManager.LayoutParams attr = getWindow().getAttributes(); WindowManager.LayoutParams attr = getWindow().getAttributes();
if (Math.abs(attr.screenBrightness - nivel) > 0.005f) { if (Math.abs(attr.screenBrightness - nivel) > 0.005f) {
attr.screenBrightness = nivel; attr.screenBrightness = nivel;
@@ -1012,6 +1107,14 @@ public class MainActivity extends Activity {
} }
private final class PonteRTC { private final class PonteRTC {
/** Foto do vídeo ao vivo (data:image/jpeg;base64,...) para o histórico. */
@JavascriptInterface public void foto(final String dataUrl) {
try {
int virgula = dataUrl == null ? -1 : dataUrl.indexOf(',');
if (virgula < 0 || dataUrl.length() > 4_000_000) return;
remoto.fotoRTC(android.util.Base64.decode(dataUrl.substring(virgula + 1), android.util.Base64.DEFAULT));
} catch (Exception e) { Log.w("CarinhaPC", "foto do video falhou: " + e); }
}
@JavascriptInterface public void rastrear(final String json) { @JavascriptInterface public void rastrear(final String json) {
if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return; if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return;
try { try {
+1 -1
View File
@@ -30,7 +30,7 @@ def main():
def run(*command): def run(*command):
subprocess.run([str(x) for x in command], check=True) 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', 'voz-robo.js'):
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name) shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder: with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
tmp = Path(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.');}};
+238 -19
View File
@@ -25,7 +25,28 @@
<path id="sobrancelha2" class="traco" style="display:none"/> <path id="sobrancelha2" class="traco" style="display:none"/>
</svg> </svg>
<div id="fps"></div> <div id="fps"></div>
<script src="rosto-motor.js"></script>
<script src="voz-robo.js"></script>
<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"; "use strict";
/* ------------------------------------------------------------------------- /* -------------------------------------------------------------------------
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer). Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
@@ -71,12 +92,33 @@ addEventListener("resize", ajustar);
const PRETO = [6, 8, 14]; const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255]; const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false }; const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
function cuidarDaTela() {
const agora = performance.now();
if (!r.dormindo) SONO.desde = 0;
else if (!SONO.desde) SONO.desde = agora;
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
if (apagar !== SONO.apagada) {
SONO.apagada = apagar;
window.carinhaTelaApagada = apagar;
document.body.style.visibility = apagar ? "hidden" : "";
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
}
}
setInterval(cuidarDaTela, 1000);
const CIANO_E = [150, 232, 255]; const CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78]; const VERM = [255, 78, 78];
const ROSA = [255, 112, 176]; const ROSA = [255, 112, 176];
const AMARELO = [255, 218, 96]; const AMARELO = [255, 218, 96];
const AZUL = [130, 170, 255]; const AZUL = [130, 170, 255];
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
const VERDE = [120, 255, 160]; const VERDE = [120, 255, 160];
const BRANCO = [245, 250, 255]; const BRANCO = [245, 250, 255];
@@ -84,10 +126,17 @@ const BRANCO = [245, 250, 255];
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos // 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" // crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = { 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 }, eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 }, carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 }, 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_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME]; let ROSTO = ROSTOS[ROSTO_NOME];
@@ -122,8 +171,18 @@ function caminhoLinha(x0, y0, x1, y1) {
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1; return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
} }
/* formas da boca (o humor escolhe qual) */ /* formas da boca (o humor escolhe qual) */
function deslocamentoBoca() {
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
}
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
function metadeOlho(esc) {
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
return m * RY * esc;
}
function caminhoBoca(tipo) { function caminhoBoca(tipo) {
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2; const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
if (tipo === "aberta") { // surpresa: um "O" if (tipo === "aberta") { // surpresa: um "O"
const rx = l * 0.17, ry = l * 0.22; const rx = l * 0.17, ry = l * 0.22;
return caminhoElipse(cx, cy, rx, ry); return caminhoElipse(cx, cy, rx, ry);
@@ -264,7 +323,7 @@ function sequencias() {
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]]; const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18], const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]]; [{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09], const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
[{ dy: -10 }, 0.5], [{ dy: -10 }, 0.5],
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]]; [{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1], const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
@@ -273,13 +332,21 @@ function sequencias() {
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15], const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15], [{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
[{ forma: "aberto", cor: CIANO }, 0.3]]; [{ forma: "aberto", cor: CIANO }, 0.3]];
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5], const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]]; [{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5], const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]]; [{ 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], const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]]; [{ 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 }; bravo, coracao, triste, piscadinha, alegre };
} }
@@ -298,6 +365,7 @@ function proximo() {
const [alvo, dur] = r.fila[0]; const [alvo, dur] = r.fila[0];
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy }; r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
r.cor_ini = r.cor.slice(); r.cor_ini = r.cor.slice();
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
if ("forma" in alvo) r.forma = alvo.forma; if ("forma" in alvo) r.forma = alvo.forma;
if ("forma2" in alvo) r.forma2 = alvo.forma2; if ("forma2" in alvo) r.forma2 = alvo.forma2;
if ("boca" in alvo) r.boca = alvo.boca; if ("boca" in alvo) r.boca = alvo.boca;
@@ -332,7 +400,7 @@ function passo(dt) { // avanca a fila; true se ha gesto em
function repouso(t) { // pose calma: olha devagar, "respira" function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) { if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono"; r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2)); r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
r.ab = 0; r.ab2 = 0; r.ab = 0; r.ab2 = 0;
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4); r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0; r.dx = 0;
@@ -361,20 +429,39 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0); 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 dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; 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,
iris: EXTRAS.iris,
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : 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]; const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc, 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); abx, fx, r.cor, lado, dx, dy * 0.6);
} }
desenharBoca(); const matriz = comMotor && ROSTO.motor === "matriz";
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
else desenharBoca();
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1)); const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none'; brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue; if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2; const c = i === 0 ? E1 : E2;
const lado = i === 0 ? 1 : -1; const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85; const largura = RX * esc * 0.85;
const y = c[1] - RY * esc - 20 * ESC; const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0; const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const elevacao = r.esc > 1.1 ? -15 * ESC : 0; const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao)); brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
@@ -383,6 +470,27 @@ function desenhar() {
} }
} }
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
function tracosDoRosto(esc) {
const tracos = [];
if (EXTRAS.boca) {
const forma = r.boca;
if (r.dormindo) { // dormindo na Matriz: "– _ –"
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
}
if (EXTRAS.sobrancelhas && !r.dormindo) {
[E1, E2].forEach((c, i) => {
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
const inc = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const ele = r.esc > 1.1 ? -15 * ESC : 0;
tracos.push({ d: 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele), largura: 9 * ESC });
});
}
return tracos;
}
// ------------------------------------------------------------------ controle // ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */ /* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) { function trocarRosto(nome) {
@@ -411,6 +519,9 @@ function trata(ordem) {
trocarRosto(ordem.slice(6).trim()); trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) { } else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo" 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) { } else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number); const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) { if (v.length === 3 && v.every((n) => !isNaN(n))) {
@@ -423,6 +534,10 @@ function trata(ordem) {
} else if (ordem === "aleatorio") { } else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) { } else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(ordem); animar(ordem);
} else if (ordem === "calma") { } else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro. // E-Stop do painel: interrompe a animação e volta ao rosto neutro.
@@ -443,7 +558,7 @@ function pose(nome) {
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) { for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k]; if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
} }
if ("cor" in alvo) r.cor = alvo.cor.slice(); if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
congelado = true; congelado = true;
desenhar(); desenhar();
return true; return true;
@@ -463,7 +578,7 @@ window.eilik = {
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto // busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
// do PC com um curl, igual ao FIFO de antes // do PC com um curl, igual ao FIFO de antes
async function buscarOrdens() { async function buscarOrdens() {
if (new URLSearchParams(location.search).get('espelho') === '1') return; if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
try { try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" }); const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return; if (!resp.ok) return;
@@ -482,6 +597,7 @@ async function aplicarAparencia() {
if (!resp.ok) return; if (!resp.ok) return;
const c = await resp.json(); const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto); 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))) 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); } { CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ } } catch (e) { /* sem servidor — segue com o padrao */ }
@@ -497,9 +613,29 @@ if (MODO_PAINEL) {
const m = e.data; const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem); 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 === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
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); } 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) // teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo", const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir", t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
@@ -597,6 +733,16 @@ function checarSilencio(agora) {
journal do carinha-web (so quando o microfone esta ativo). */ journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) { function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
if (evento !== "adormeceu") registrarEvento("som", evento, n);
}
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
function registrarEvento(tipo, evento, nivel) {
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
try {
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
} catch (e) {}
} }
// ------------------------------------------------------ presenca (movimento) // ------------------------------------------------------ presenca (movimento)
@@ -685,6 +831,7 @@ function movimento(vel, cx, cy) {
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */ /* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) { function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
registrarEvento("movimento", evento, n);
} }
// ------------------------------------------------- camera oculta (sensor) // ------------------------------------------------- camera oculta (sensor)
@@ -815,7 +962,11 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null; let configRecebida = null;
function configurarApp(c) { function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
SONO.apagar = c.apagarTela !== false;
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
AUTO = c.auto; AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume; AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir; COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
@@ -832,8 +983,10 @@ async function sincronizarApp() {
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return; if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
sincronizando = true; sincronizando = true;
try { try {
if (!window.carinhaSSE) {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) }); const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json()); if (resp.ok) configurarApp(await resp.json());
}
if (par.get('aparelho') === '1') { if (par.get('aparelho') === '1') {
const agora = performance.now() / 1000; const agora = performance.now() / 1000;
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'}, await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
@@ -843,7 +996,9 @@ async function sincronizarApp() {
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2, movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida, centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho, brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
}) }); }) });
} }
@@ -888,15 +1043,32 @@ const SONS = {
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); }, dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
}; };
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
os sons dos gestos ficam quietos para não embolar. */
function falar(texto) {
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
const c = contextoAudio();
if (!c) return;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try {
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
AUDIO.mudoAte = performance.now() + dur * 1000;
} catch (e) { /* segue sem voz */ }
}
function tocarSom(gesto) { function tocarSom(gesto) {
if (!AUDIO.ligado) return false; if (!AUDIO.ligado) return false;
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
const f = SONS[gesto]; const f = SONS[gesto];
if (!f) return false; if (!voz && !f) return false;
const c = contextoAudio(); const c = contextoAudio();
if (!c) return false; if (!c) return false;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } } if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try { try {
f(c, c.currentTime + 0.02, AUDIO.volume); if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo // aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
console.log("voz: " + gesto + " ctx=" + c.state); console.log("voz: " + gesto + " ctx=" + c.state);
return true; return true;
@@ -979,11 +1151,13 @@ setTimeout(relatar, 1500);
if(location.protocol==='file:')return; if(location.protocol==='file:')return;
let seen=null,fetching=false; let seen=null,fetching=false;
async function poll(){ 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; 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;} 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; if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
falar(m.texto);
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto); 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;} }catch(e){}finally{fetching=false;}
} }
setInterval(poll,1000);poll(); setInterval(poll,1000);poll();
@@ -991,11 +1165,56 @@ setTimeout(relatar, 1500);
</script> </script>
<script src="/reacoes-render.js"></script> <script src="/reacoes-render.js"></script>
</body>
</html>
<script> <script>
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) { 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); 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;
falar(m.texto);
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> </script>
</body>
</html>
+8 -2
View File
@@ -7,7 +7,7 @@ import time
from urllib.request import Request,urlopen from urllib.request import Request,urlopen
from comum import gravar_atomico, ler_json 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') EVENTOS=('som','movimento','saudacao','sozinho')
def validar_intencao(data): def validar_intencao(data):
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS: 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): def __init__(self,path,controle,enviar):
self.path=Path(path);self.controle=controle;self.enviar=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') 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.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.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 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 try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
except (OSError,ValueError,KeyError,TypeError):pass except (OSError,ValueError,KeyError,TypeError):pass
threading.Thread(target=self.loop,daemon=True).start() 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): def config(self,ativo):
if type(ativo) is not bool:raise ValueError('ativo deve ser booleano') if type(ativo) is not bool:raise ValueError('ativo deve ser booleano')
with self.lock: with self.lock:
@@ -43,7 +49,7 @@ class Cerebro:
return dict(ok=True) return dict(ok=True)
def decide(self,event): 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)))) 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() start=time.monotonic()
with urlopen(request,timeout=90) as response:data=json.load(response) with urlopen(request,timeout=90) as response:data=json.load(response)
content=data['choices'][0]['message']['content'] content=data['choices'][0]['message']['content']
+20 -68
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 ipaddress
import json import json
import math import math
import os
from pathlib import Path from pathlib import Path
import subprocess
import threading import threading
import time import time
from comum import origem_valida from comum import origem_valida
@@ -22,7 +20,8 @@ PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
somaltolim=0.55, sompausa=20, somcooldown=5, somaltolim=0.55, sompausa=20, somcooldown=5,
movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40, movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30, economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30,
boca=True, sobrancelhas=False, videoRTC=True, boca=True, sobrancelhas=False, iris=False, subirBoca=0, distSobrancelha=16, videoRTC=True,
apagarTela=True, apagarTelaMin=5,
sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False, sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
cameraLado='user', videoResolucao='auto', videoFPS=0, cameraLado='user', videoResolucao='auto', videoFPS=0,
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True) rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
@@ -32,9 +31,8 @@ ESCOLHAS = dict(cameraLado=('user', 'environment'),
LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1), LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120), somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100), movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600)) brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600),
TOOLS = Path(os.environ.get('CARINHA_TOOLS', subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120))
'C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools'))
def validar(dados): def validar(dados):
@@ -56,6 +54,16 @@ def validar(dados):
return 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: class ControlePC:
def __init__(self, arquivo): def __init__(self, arquivo):
self.arquivo = Path(arquivo) self.arquivo = Path(arquivo)
@@ -65,9 +73,6 @@ class ControlePC:
self.status_at = 0 self.status_at = 0
self.celular_ip = None self.celular_ip = None
self.recarga = {"id": None, "confirmada": False} self.recarga = {"id": None, "confirmada": False}
self.frame = b''
self.frame_at = 0
self.frame_seq = 0
self.rtc = Sinalizacao() self.rtc = Sinalizacao()
self.publicar = lambda tipo, dados: None self.publicar = lambda tipo, dados: None
try: try:
@@ -85,8 +90,6 @@ class ControlePC:
self.config = novo self.config = novo
if not novo['preview']: if not novo['preview']:
self.rtc.encerrar() self.rtc.encerrar()
self.frame = b''
self.frame_at = 0
return novo.copy() return novo.copy()
def resumo(self, host=''): def resumo(self, host=''):
@@ -96,15 +99,13 @@ class ControlePC:
idade = agora - self.status_at idade = agora - self.status_at
return dict(online=idade < 5, idade=round(idade, 1) if self.status_at else None, 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(), celular_ip=self.celular_ip, servidor_ip=host, recarga=self.recarga.copy(),
estado=self.estado.copy(), preview=self.config['preview'], 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)
def rotas(self, h, metodo): def rotas(self, h, metodo):
if self.rtc.atender(h, metodo, self.config['preview']): if self.rtc.atender(h, metodo, self.config['preview']):
return True return True
rota = h.path.split('?', 1)[0].rstrip('/') rota = h.path.split('?', 1)[0].rstrip('/')
if rota not in ('/app/config', '/app/padrao', '/app/status', '/camera/frame', '/app/connect', '/app/health'): if rota not in ('/app/config', '/app/padrao', '/app/status', '/app/health'):
return False return False
def enviar(dados, codigo=200): def enviar(dados, codigo=200):
@@ -117,14 +118,13 @@ class ControlePC:
if rota == '/app/config' and h.headers.get('Content-Type', '').split(';')[0] != 'application/json': if rota == '/app/config' and h.headers.get('Content-Type', '').split(';')[0] != 'application/json':
raise ValueError('Use JSON') raise ValueError('Use JSON')
tamanho = int(h.headers.get('Content-Length', '0')) tamanho = int(h.headers.get('Content-Length', '0'))
limite = 1_000_000 if rota == '/camera/frame' else 16384 if tamanho <= 0 or tamanho > 16384:
if tamanho <= 0 or tamanho > limite:
enviar({'erro': 'Corpo vazio ou grande demais'}, 413) enviar({'erro': 'Corpo vazio ou grande demais'}, 413)
return True return True
corpo = h.rfile.read(tamanho) corpo = h.rfile.read(tamanho)
if len(corpo) != tamanho: if len(corpo) != tamanho:
raise ValueError('Corpo incompleto') raise ValueError('Corpo incompleto')
dados = None if rota == '/camera/frame' else json.loads(corpo) dados = json.loads(corpo)
else: else:
dados = None dados = None
@@ -147,7 +147,7 @@ class ControlePC:
raise ValueError('Estado deve ser objeto JSON') raise ValueError('Estado deve ser objeto JSON')
with self.lock: with self.lock:
self.estado = dados 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"]: if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
self.recarga["confirmada"] = True self.recarga["confirmada"] = True
self.status_at = time.monotonic() self.status_at = time.monotonic()
@@ -155,56 +155,8 @@ class ControlePC:
enviar({'ok': True}) enviar({'ok': True})
else: else:
enviar(self.resumo(h.headers.get("Host", "").split(":")[0])) 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: except (ValueError, TypeError) as e:
enviar({'erro': str(e)}, 400) enviar({'erro': str(e)}, 400)
except OSError as e: except OSError as e:
enviar({'erro': str(e)}, 503) enviar({'erro': str(e)}, 503)
return True 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()) 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) 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))) 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) with urlopen(req,timeout=90) as response:data=json.load(response)
result=validar_resposta(json.loads(data['choices'][0]['message']['content'])) result=validar_resposta(json.loads(data['choices'][0]['message']['content']))
with self.lock: 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
});
}
})();
+108
View File
@@ -0,0 +1,108 @@
<#
.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', 'historico.py',
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
)
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'
# O PowerShell 5.1 põe BOM no stdin do ssh; sem tirar, o bash lê "<BOM>set -e" e não para no primeiro erro.
$script -replace "`r", '' | & ssh $alvo "sed '1s/^\xEF\xBB\xBF//' | 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 # 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. 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. 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 - Diretório: /opt/carinha-pc
- Serviço: carinha-pc.service, habilitado para iniciar no boot. - Serviço: carinha-pc.service, habilitado para iniciar no boot.
- Sistema: Armbian Debian 13, Python 3.13.5, arquitetura aarch64. - 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. 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. ## Como ativar (ordem importa)
> 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: 1. Publique servidor e painel 0.8 (`deploy.ps1`) e instale o APK `0.8-token` no S10.
> checagem de Origin em todos os POST, comandos validados e E-Stop. 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 ## 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] [Service]
Type=simple Type=simple
WorkingDirectory=/opt/carinha-pc 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 ExecStart=/usr/bin/python3 -u /opt/carinha-pc/kiosk-server.py --porta 8100
Restart=on-failure Restart=on-failure
RestartSec=3 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>
+368
View File
@@ -0,0 +1,368 @@
"""Histórico de detecções (PIR da placa, movimento e som do celular) com uma foto por evento.
- PIR: lê o pino com `gpiomon` (libgpiod). Padrão: gpiochip1 linha 70 = pino 11 (PC6) do
Orange Pi Zero 3; outro pino com CARINHA_PIR=gpiochip1:79.
- Foto: ao registrar um evento, o servidor deixa um "pedido de foto"; o app do S10 consulta
/historico/pedido, tira a foto com a câmera frontal e envia em /historico/foto?id=N.
- Guarda só os últimos 3 dias (eventos e fotos mais antigos são apagados sozinhos), com teto de
segurança de 2000 eventos e 500 fotos. Cada sensor e a foto podem ser desligados.
"""
import json
import os
from pathlib import Path
import shutil
import sqlite3
import subprocess
import threading
import time
from contextlib import contextmanager
from urllib.parse import parse_qs, urlsplit
from comum import gravar_atomico, ler_json
TIPOS = ('pir', 'movimento', 'som')
PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15, dias=3,
presenca=True, ausencia=5) # modo presença: o PIR liga a câmera; sem ninguém, ela desliga
MAX_EVENTOS, MAX_FOTOS = 2000, 500 # teto de segurança; quem manda é a retenção em dias
VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto
class Historico:
def __init__(self, pasta, iniciar_pir=True):
self.pasta = Path(pasta)
self.fotos = self.pasta / 'fotos'
self.fotos.mkdir(exist_ok=True)
self.caminho_db = str(self.pasta / 'historico.db')
self.arquivo_config = self.pasta / 'historico.json'
self.lock = threading.RLock()
self.publicar = lambda tipo, dados: None
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
# Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia).
self.ao_presenca = lambda: None
self.ao_ausencia = lambda: None
self.presenca = dict(estado='presente', desde=time.time(), ultimo=time.time())
self.config = dict(PADRAO)
try:
salvo = json.loads(self.arquivo_config.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo))
except (OSError, ValueError, TypeError):
pass
self.ultimo = {} # tipo -> horário do último evento (intervalo mínimo)
self.pedido = None # (id do evento, monotonic) aguardando foto do app
self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None,
ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma)
self.processo_pir = None
with self.db() as db:
db.execute('CREATE TABLE IF NOT EXISTS eventos(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, evento TEXT, nivel REAL, foto TEXT)')
self.limpar_antigos()
if iniciar_pir:
threading.Thread(target=self.vigiar_pir, daemon=True).start()
threading.Thread(target=self.faxina, daemon=True).start()
threading.Thread(target=self.vigiar_ausencia, daemon=True).start()
@contextmanager
def db(self):
con = sqlite3.connect(self.caminho_db)
try:
with con:
yield con
finally:
con.close()
@staticmethod
def validar(dados):
if not isinstance(dados, dict):
raise ValueError('Esperava um objeto')
limpo = {}
for chave, valor in dados.items():
if chave not in PADRAO:
raise ValueError('Ajuste desconhecido: ' + chave)
if chave == 'ausencia':
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 120:
raise ValueError('Ausência de 1 a 120 minutos')
elif chave == 'dias':
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30:
raise ValueError('Guardar de 1 a 30 dias')
elif chave == 'intervalo':
if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600:
raise ValueError('Intervalo entre 0 e 600 s')
elif not isinstance(valor, bool):
raise ValueError(chave + ' precisa ser verdadeiro ou falso')
limpo[chave] = valor
return limpo
# ------------------------------------------------------------------ retenção
def limpar_antigos(self, agora=None):
"""Apaga eventos (e fotos) mais velhos que a retenção em dias."""
limite = (agora if agora is not None else time.time()) - self.config['dias'] * 86400
with self.lock, self.db() as db:
velhos = db.execute('SELECT id, foto FROM eventos WHERE horario < ?', (limite,)).fetchall()
for vid, foto in velhos:
self.apagar_arquivo(foto)
db.execute('DELETE FROM eventos WHERE horario < ?', (limite,))
if velhos:
self.publicar('historico', dict(expirados=len(velhos)))
return len(velhos)
# ------------------------------------------------------------------ modo presença
def marcar_presenca(self, agora=None):
agora = agora if agora is not None else time.time()
with self.lock:
self.presenca['ultimo'] = agora
voltou = self.presenca['estado'] != 'presente'
if voltou:
self.presenca.update(estado='presente', desde=agora)
if voltou and self.config['presenca']:
self.ao_presenca()
def checar_ausencia(self, agora=None):
"""Sem presença no PIR por `ausencia` minutos: desliga câmera, dorme e fica quieto."""
agora = agora if agora is not None else time.time()
if not (self.config['presenca'] and self.config['pir'] and self.pir['disponivel']):
return False
with self.lock:
if self.pir['ativo']: # sensor ainda segurando: a pessoa continua ali
self.presenca['ultimo'] = agora
return False
if self.presenca['estado'] != 'presente' or agora - self.presenca['ultimo'] < self.config['ausencia'] * 60:
return False
self.presenca.update(estado='ausente', desde=agora)
self.ao_ausencia()
self.avisar_pir()
return True
def avisar_pir(self):
self.publicar('pir', dict(ultimo=self.pir['ultimo'], ativo=self.pir['ativo'], presenca=dict(self.presenca)))
def nivel_pir(self, alto, agora=None, registrar=True):
"""Mudança no pino do PIR. Subida = presença (evento + foto); enquanto alto, a bolinha fica acesa."""
agora = agora if agora is not None else time.time()
self.pir['ativo'] = alto
if alto:
self.pir['ultimo'] = agora
self.marcar_presenca(agora)
self.avisar_pir()
if alto and registrar:
self.registrar('pir', 'presença')
def vigiar_ausencia(self):
while True:
time.sleep(10)
try:
self.checar_ausencia()
except Exception:
pass
def faxina(self):
while True:
time.sleep(600)
try:
self.limpar_antigos()
except Exception:
pass
# ------------------------------------------------------------------ eventos
def registrar(self, tipo, evento='', nivel=None, agora=None):
"""Grava o evento se o sensor estiver ligado e fora do intervalo mínimo; devolve o id ou None."""
if tipo not in TIPOS:
raise ValueError('Tipo de evento inválido')
agora = agora if agora is not None else time.time()
with self.lock:
if not self.config.get(tipo, False):
return None
if tipo in self.ultimo and agora - self.ultimo[tipo] < self.config['intervalo']:
return None
self.ultimo[tipo] = agora
evento = str(evento or '')[:40]
nivel = float(nivel) if isinstance(nivel, (int, float)) and not isinstance(nivel, bool) else None
with self.db() as db:
ident = db.execute('INSERT INTO eventos(horario, tipo, evento, nivel) VALUES (?,?,?,?)',
(agora, tipo, evento, nivel)).lastrowid
velhos = db.execute('SELECT id, foto FROM eventos ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_EVENTOS,)).fetchall()
for vid, foto in velhos:
self.apagar_arquivo(foto)
db.execute('DELETE FROM eventos WHERE id=?', (vid,))
if self.config['foto']:
self.pedido = (ident, time.monotonic())
self.limpar_antigos(agora)
self.publicar('historico', dict(novo=self.evento(ident)))
try:
self.ao_detectar(tipo)
except Exception:
pass
return ident
def evento(self, ident):
with self.db() as db:
linha = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos WHERE id=?', (ident,)).fetchone()
return self.como_dict(linha) if linha else None
@staticmethod
def como_dict(linha):
i, h, t, e, n, f = linha
return dict(id=i, horario=h, tipo=t, evento=e, nivel=n, foto=bool(f))
def listar(self, limite=500):
with self.db() as db:
linhas = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos ORDER BY id DESC LIMIT ?', (limite,)).fetchall()
total = db.execute('SELECT count(*) FROM eventos').fetchone()[0]
return [self.como_dict(l) for l in linhas], total
def apagar(self, ident):
with self.lock, self.db() as db:
linha = db.execute('SELECT foto FROM eventos WHERE id=?', (ident,)).fetchone()
if linha:
self.apagar_arquivo(linha[0])
db.execute('DELETE FROM eventos WHERE id=?', (ident,))
self.publicar('historico', dict(apagado=ident))
def limpar(self):
with self.lock, self.db() as db:
db.execute('DELETE FROM eventos')
for f in self.fotos.glob('evento-*.jpg'):
f.unlink(missing_ok=True)
self.pedido = None
self.publicar('historico', dict(limpo=True))
def apagar_arquivo(self, nome):
if nome:
(self.fotos / nome).unlink(missing_ok=True)
# ------------------------------------------------------------------ foto
def pedido_atual(self):
with self.lock:
if self.pedido and time.monotonic() - self.pedido[1] < VALIDADE_PEDIDO:
return self.pedido[0]
self.pedido = None
return None
def guardar_foto(self, ident, jpeg):
if not (jpeg.startswith(b'\xff\xd8') and jpeg.endswith(b'\xff\xd9')) or len(jpeg) > 3_000_000:
raise ValueError('Esperava uma foto JPEG de até 3 MB')
with self.lock:
if not self.config['foto']:
raise ValueError('Fotos desligadas')
if self.pedido and self.pedido[0] == ident:
self.pedido = None
nome = f'evento-{ident}.jpg'
with self.db() as db:
if not db.execute('SELECT 1 FROM eventos WHERE id=?', (ident,)).fetchone():
raise ValueError('Evento não existe mais')
tmp = self.fotos / (nome + '.tmp')
tmp.write_bytes(jpeg); tmp.replace(self.fotos / nome)
db.execute('UPDATE eventos SET foto=? WHERE id=?', (nome, ident))
antigas = db.execute('SELECT id, foto FROM eventos WHERE foto IS NOT NULL ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_FOTOS,)).fetchall()
for vid, foto in antigas:
self.apagar_arquivo(foto)
db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,))
self.publicar('historico', dict(foto=ident))
# ------------------------------------------------------------------ PIR
def vigiar_pir(self):
"""Mantém um `gpiomon` rodando enquanto o PIR estiver ligado e acompanha o nível do pino."""
while True:
if not self.config['pir'] or os.name != 'posix' or not shutil.which('gpiomon'):
self.pir.update(disponivel=False, erro='' if self.config['pir'] else 'desligado')
if os.name == 'posix' and not shutil.which('gpiomon'):
self.pir['erro'] = 'gpiomon não instalado (apt install gpiod)'
time.sleep(2)
continue
chip, _, linha = self.pir['linha'].partition(':')
try:
# pull-down: com o sensor desligado o pino não fica "flutuando" (disparos falsos)
lido = subprocess.run(['gpioget', '-c', chip, '-b', 'pull-down', '--numeric', linha],
capture_output=True, text=True, timeout=5)
self.processo_pir = subprocess.Popen(['gpiomon', '-c', chip, '-b', 'pull-down', '-e', 'both', '-F', '%E', linha],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self.pir.update(disponivel=True, erro='')
# nível de partida: alguém já na frente não gera borda de subida, mas acende a bolinha
self.nivel_pir(lido.stdout.strip() == '1', registrar=False)
inicio = time.monotonic()
for linha_evento in self.processo_pir.stdout:
if not self.config['pir']:
break
alto = linha_evento.strip() == 'rising'
# o pino "assenta" ao ligar o pull-down: acompanha o nível, mas não registra evento
self.nivel_pir(alto, registrar=time.monotonic() - inicio >= 2)
erro = (self.processo_pir.stderr.read() or '').strip()[:200]
except OSError as e:
erro = str(e)
except subprocess.TimeoutExpired:
erro = 'gpioget não respondeu'
finally:
self.parar_pir()
self.pir.update(disponivel=False, ativo=False, erro=erro if self.config['pir'] else 'desligado')
time.sleep(3)
def parar_pir(self):
p, self.processo_pir = self.processo_pir, None
if p and p.poll() is None:
p.terminate()
try:
p.wait(timeout=2)
except subprocess.TimeoutExpired:
p.kill()
def status(self):
eventos, total = self.listar()
with self.lock:
return dict(config=dict(self.config), pir=dict(self.pir), presenca=dict(self.presenca), eventos=eventos, total=total,
max_eventos=MAX_EVENTOS, max_fotos=MAX_FOTOS)
# ------------------------------------------------------------------ HTTP
def rotas(self, h, metodo):
url = urlsplit(h.path)
rota = url.path
if not rota.startswith('/historico/'):
return False
try:
if metodo == 'GET' and rota == '/historico/status':
return h.json(self.status()) or True
if metodo == 'GET' and rota == '/historico/pedido':
return h.json(dict(id=self.pedido_atual())) or True
if metodo == 'GET' and rota.startswith('/historico/foto/'):
nome = rota.rsplit('/', 1)[-1]
if not nome.startswith('evento-') or not nome.endswith('.jpg') or '/' in nome or '..' in nome:
raise ValueError('Foto inválida')
arquivo = self.fotos / nome
if not arquivo.is_file():
h.json(dict(erro='Foto não encontrada'), 404)
return True
h.enviar(arquivo.read_bytes(), 'image/jpeg')
return True
if metodo == 'POST' and rota == '/historico/foto':
ident = int(parse_qs(url.query).get('id', ['0'])[0])
if h.headers.get('Content-Type', '').split(';')[0] != 'image/jpeg':
raise ValueError('Use image/jpeg')
tamanho = int(h.headers.get('Content-Length', '0'))
if not 0 < tamanho <= 3_000_000:
raise ValueError('Foto vazia ou grande demais')
self.guardar_foto(ident, h.rfile.read(tamanho))
return h.json(dict(ok=True)) or True
if metodo == 'POST':
dados = ler_json(h, 4096)
if rota == '/historico/evento':
ident = self.registrar(dados.get('tipo'), dados.get('evento'), dados.get('nivel'))
return h.json(dict(ok=True, id=ident)) or True
if rota == '/historico/config':
limpo = self.validar(dados)
with self.lock:
self.config.update(limpo)
gravar_atomico(self.arquivo_config, self.config)
if 'pir' in limpo and not limpo['pir']:
self.parar_pir()
if 'dias' in limpo:
self.limpar_antigos()
self.publicar('historico', dict(config=dict(self.config)))
return h.json(self.status()) or True
if rota == '/historico/apagar':
if type(dados.get('id')) is not int:
raise ValueError('ID inválido')
self.apagar(dados['id'])
return h.json(dict(ok=True)) or True
if rota == '/historico/limpar':
self.limpar()
return h.json(dict(ok=True)) or True
h.json(dict(erro='Rota do histórico desconhecida'), 404)
except (ValueError, TypeError) as e:
h.json(dict(erro=str(e)), 400)
return True
+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' $ErrorActionPreference = 'Stop'
$url = "http://192.168.1.35:$Porta" $url = "http://${Placa}:$Porta"
try { try {
$health = Invoke-RestMethod "$url/app/health" -TimeoutSec 5 $health = Invoke-RestMethod "$url/app/health" -TimeoutSec 5
if ($health.projeto -ne 'carinha-pc') { throw 'Servidor inesperado' } if ($health.projeto -ne 'carinha-pc') { throw 'Servidor inesperado' }
} catch { } catch {
throw "Orange Pi indisponível em $url. Confira alimentação, rede e o serviço carinha-pc. Nenhum servidor será iniciado no PC." 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" Write-Host "Painel Carinha $($health.versao): $url/"
if (-not $SemAbrir) { Start-Process "$url/painel" } if (-not $SemAbrir) { Start-Process "$url/" }
+76 -39
View File
@@ -40,27 +40,37 @@ from cerebro import Cerebro
from conversa import Conversa from conversa import Conversa
from treinamento import Treinamento from treinamento import Treinamento
from api_config import ConfigAPI from api_config import ConfigAPI
from historico import Historico
TRAVA = threading.Lock() TRAVA = threading.Lock()
# Espelho da biblioteca de rostos do carinha.html (para /faces funcionar) # 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) # Espelho dos gestos do carinha.html (para o painel montar os botoes)
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz", GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre", "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 que o rosto entende mas não viram botão.
ORDENS_INTERNAS = {"calma"} ORDENS_INTERNAS = {"calma"}
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num # Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
# arquivo ao lado do servidor, para sobreviver a reinicios. # 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" 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", "/voz-robo.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. # 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"} 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/", "/historico/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -117,6 +127,8 @@ def carregar_aparencia():
if isinstance(dados, dict): if isinstance(dados, dict):
if dados.get("rosto") in ROSTOS: if dados.get("rosto") in ROSTOS:
APARENCIA["rosto"] = dados["rosto"] APARENCIA["rosto"] = dados["rosto"]
if dados.get("formato") in FORMATOS:
APARENCIA["formato"] = dados["formato"]
cor = dados.get("cor") cor = dados.get("cor")
if isinstance(cor, list) and len(cor) == 3: if isinstance(cor, list) and len(cor) == 3:
APARENCIA["cor"] = [max(0, min(255, int(v))) for v in cor] APARENCIA["cor"] = [max(0, min(255, int(v))) for v in cor]
@@ -147,6 +159,11 @@ def aplicar_aparencia(dados):
if nome in ROSTOS: if nome in ROSTOS:
APARENCIA["rosto"] = nome APARENCIA["rosto"] = nome
ordens.append("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: if "cor" in dados:
try: try:
APARENCIA["cor"] = validar_cor(dados["cor"]) APARENCIA["cor"] = validar_cor(dados["cor"])
@@ -220,7 +237,7 @@ class Handler(SimpleHTTPRequestHandler):
def modulos(self): def modulos(self):
s = self.server s = self.server
return [getattr(s, nome) for nome in ("api_config", "treinamento", "conversa", "cerebro", return [getattr(s, nome) for nome in ("api_config", "historico", "treinamento", "conversa", "cerebro",
"braco", "servos", "controle") if hasattr(s, nome)] "braco", "servos", "controle") if hasattr(s, nome)]
def estado(self): def estado(self):
@@ -229,7 +246,7 @@ class Handler(SimpleHTTPRequestHandler):
with TRAVA: with TRAVA:
aparencia = dict(APARENCIA) aparencia = dict(APARENCIA)
dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)), 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"): if hasattr(s, "controle"):
dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy()) dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy())
if hasattr(s, "servos"): if hasattr(s, "servos"):
@@ -243,6 +260,8 @@ class Handler(SimpleHTTPRequestHandler):
dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro) dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro)
if hasattr(s, "api_config"): if hasattr(s, "api_config"):
dados["api"] = s.api_config.status() dados["api"] = s.api_config.status()
if hasattr(s, "historico"):
dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca))
return dados return dados
def do_GET(self): def do_GET(self):
@@ -290,45 +309,31 @@ class Handler(SimpleHTTPRequestHandler):
enfileirar(ordem) enfileirar(ordem)
return self.json({"ok": True, partes[0]: ordem}) return self.json({"ok": True, partes[0]: ordem})
# Painel novo (Vite/Svelte em web/dist); as telas antigas seguem nos caminhos próprios. # Painel (Vite/Svelte compilado em web/dist) em "/"; o resto é lista branca.
painel = Path("web/dist") 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" self.path = "/web/dist/index.html"
return super().do_GET() elif caminho.startswith("/assets/"):
if caminho.startswith("/assets/"):
nome = caminho[len("/assets/"):] nome = caminho[len("/assets/"):]
if "/" in nome or "\\" in nome or nome.startswith(".") or not (painel / "assets" / nome).is_file(): 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) return self.json({"erro": "Arquivo não disponível"}, 404)
self.path = "/web/dist/assets/" + nome self.path = "/web/dist/assets/" + nome
return super().do_GET() elif caminho in ("/dashboard", "/painel", "/conversa", "/emojis", "/treinamento", "/braco", "/servos", "/api"):
# Endereços das telas antigas levam à seção equivalente do painel novo.
if caminho in ("/dashboard", "/emojis", "/api"): secao = {"/conversa": "conversa", "/emojis": "reacoes", "/treinamento": "reacoes", "/braco": "movimento",
self.path = caminho + ".html" "/servos": "movimento", "/api": "ajustes"}.get(caminho, "ao-vivo")
elif caminho == "/painel": self.send_response(302)
self.path = "/painel.html" # painel de controle self.send_header("Location", "/#/" + secao)
elif caminho == '/servos': self.send_header("Content-Length", "0")
self.path = '/servos.html' self.end_headers()
elif caminho == '/conversa': return
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 == '/qualidade-auto.js': elif caminho == '/qualidade-auto.js':
self.path = '/android/app/assets/qualidade-auto.js' self.path = '/android/app/assets/qualidade-auto.js'
elif caminho == '/app.apk': elif caminho == '/app.apk':
self.path = '/android/carinha-rosto.apk' self.path = '/android/carinha-rosto.apk'
elif caminho in ( elif caminho not in ESTATICOS:
'/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'):
return self.enviar(json.dumps({'erro': 'Arquivo não disponível'}), codigo=404) return self.enviar(json.dumps({'erro': 'Arquivo não disponível'}), codigo=404)
return super().do_GET() return super().do_GET()
@@ -368,9 +373,13 @@ class Handler(SimpleHTTPRequestHandler):
def comando(self, dados): def comando(self, dados):
tipo, valor = dados.get("tipo"), dados.get("valor") tipo, valor = dados.get("tipo"), dados.get("valor")
s = self.server s = self.server
if tipo == "cor": if tipo == "rosto":
if not aplicar_aparencia({"cor": valor}): ordem_de_comando("rosto", valor) # valida e dá erro legível
raise ValueError("Cor inválida") 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}) return self.json({"ok": True})
if tipo == "recarregar": if tipo == "recarregar":
return self.recarregar() return self.recarregar()
@@ -424,6 +433,7 @@ class Handler(SimpleHTTPRequestHandler):
if not online: if not online:
return self.json({"erro": "Abra o Carinha e conecte ao servidor primeiro"}, 409) return self.json({"erro": "Abra o Carinha e conecte ao servidor primeiro"}, 409)
FILA.adicionar("recarregar") 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."}) return self.json({"ok": True, "mensagem": "Solicitação enviada. Aguardando o celular recarregar a tela."})
def log_message(self, formato, *args): def log_message(self, formato, *args):
@@ -444,7 +454,7 @@ class Servidor(ThreadingHTTPServer):
def ligar_eventos(servidor): def ligar_eventos(servidor):
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento"): for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico"):
if hasattr(servidor, nome): if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar getattr(servidor, nome).publicar = EVENTOS.publicar
@@ -465,9 +475,36 @@ def main():
servidor.servos = ControleServos(Path.cwd() / 'servos.json') servidor.servos = ControleServos(Path.cwd() / 'servos.json')
servidor.braco = Braco(Path.cwd() / 'braco.json', servidor.servos) servidor.braco = Braco(Path.cwd() / 'braco.json', servidor.servos)
servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, enfileirar) 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.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db') servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
servidor.historico = Historico(Path.cwd())
def presenca(tipo):
# PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda
if tipo == "pir":
with servidor.controle.lock:
dormindo = bool(servidor.controle.estado.get("dormindo"))
if dormindo:
enfileirar("acordar")
def camera(ligada):
if servidor.controle.config.get("sensorCamera") != ligada:
servidor.controle.publicar("app_config", servidor.controle.atualizar({"sensorCamera": ligada}))
def alguem_chegou():
camera(True) # modo presença: o PIR liga a câmera do celular
enfileirar("acordar")
def ninguem_por_perto():
camera(False) # sem ninguém: câmera desligada, dorme e fica em silêncio
enfileirar("dormir")
servidor.historico.ao_detectar = presenca
servidor.historico.ao_presenca = alguem_chegou
servidor.historico.ao_ausencia = ninguem_por_perto
servidor.conversa.treinamento = servidor.treinamento servidor.conversa.treinamento = servidor.treinamento
ligar_eventos(servidor) ligar_eventos(servidor)
servidor.registrar = args.log servidor.registrar = args.log
-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);
+12 -4
View File
@@ -1,4 +1,12 @@
(function(){ (function(){
// Cores das receitas: o ciano padrão (#5cd6ff) vira a cor escolhida no painel e o azul-claro
// (#aaccff) um tom mais claro dela; rosa, amarelo e verde continuam (fazem parte da emoção).
function corDaReacao(hex){
const h=String(hex||'').toLowerCase(), base=(typeof CIANO!=='undefined'?CIANO:[92,214,255]);
if(h==='#5cd6ff')return base.slice();
if(h==='#aaccff')return base.map(v=>v+(255-v)*.45);
return [1,3,5].map(i=>parseInt(h.slice(i,i+2),16));
}
const icons={nenhum:'',estrela:'✦',coracao:'♥',musica:'♫',cafe:'☕',ideia:'💡'}; const icons={nenhum:'',estrela:'✦',coracao:'♥',musica:'♫',cafe:'☕',ideia:'💡'};
const badge=document.createElement('div');badge.id='reacaoSimbolo';badge.style.cssText='position:fixed;left:5%;top:6%;z-index:15;color:#5cd6ff;font:clamp(30px,7vw,64px) sans-serif;pointer-events:none;white-space:pre-wrap';document.body.append(badge); const badge=document.createElement('div');badge.id='reacaoSimbolo';badge.style.cssText='position:fixed;left:5%;top:6%;z-index:15;color:#5cd6ff;font:clamp(30px,7vw,64px) sans-serif;pointer-events:none;white-space:pre-wrap';document.body.append(badge);
let timer,last=null,fetching=false,animation;let particles=[]; let timer,last=null,fetching=false,animation;let particles=[];
@@ -15,13 +23,13 @@
function visual(recipe){ function visual(recipe){
if(recipe.emoji){ if(recipe.emoji){
const e=recipe.emoji;if(e.local==='olhos'){window.carinhaOlhosIcone=e.caractere;return;} 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; animation=badge.animate([{transform:'scale(.92)'},{transform:'scale(1.06)'},{transform:'scale(.92)'}],{duration:1400,iterations:Infinity});return;
} }
if(recipe.figurinha){ if(recipe.figurinha){
const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)return; const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)return;
if(recipe.figurinha.local==='olhos'){window.carinhaOlhosDesenho=recipe.figurinha;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; 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;} if(['ideia','cafe'].includes(recipe.simbolo)){window.carinhaOlhosIcone=icons[recipe.simbolo];return;}
@@ -42,13 +50,13 @@
for(const s of recipe.passos)if(!['aberto','feliz','surpreso','coracao','triste'].includes(s.olhos)||!['neutra','sorriso','aberta','triste'].includes(s.boca)||!/^#[0-9a-f]{6}$/i.test(s.cor)||!Number.isFinite(s.duracao)||s.duracao<.15||s.duracao>2||!Number.isFinite(s.dx)||Math.abs(s.dx)>25||!Number.isFinite(s.dy)||Math.abs(s.dy)>12||!Number.isFinite(s.escala)||s.escala<.8||s.escala>1.3)return; for(const s of recipe.passos)if(!['aberto','feliz','surpreso','coracao','triste'].includes(s.olhos)||!['neutra','sorriso','aberta','triste'].includes(s.boca)||!/^#[0-9a-f]{6}$/i.test(s.cor)||!Number.isFinite(s.duracao)||s.duracao<.15||s.duracao>2||!Number.isFinite(s.dx)||Math.abs(s.dx)>25||!Number.isFinite(s.dy)||Math.abs(s.dy)>12||!Number.isFinite(s.escala)||s.escala<.8||s.escala>1.3)return;
clearTimeout(timer);clearVisual();window.eilik.soltar();trata('acordar');MIC.ultimoSom=performance.now()/1000; clearTimeout(timer);clearVisual();window.eilik.soltar();trata('acordar');MIC.ultimoSom=performance.now()/1000;
visual(recipe); visual(recipe);
r.fila=recipe.passos.map(s=>[{forma:s.olhos,forma2:null,boca:s.boca,cor:[1,3,5].map(i=>parseInt(s.cor.slice(i,i+2),16)),dx:s.dx,dy:s.dy,esc:s.escala,ab:1,ab2:1},s.duracao]); r.fila=recipe.passos.map(s=>[{forma:s.olhos,forma2:null,boca:s.boca,cor:corDaReacao(s.cor),dx:s.dx,dy:s.dy,esc:s.escala,ab:1,ab2:1},s.duracao]);
r.fila.push([{forma:'aberto',forma2:null,boca:'neutra',cor:CIANO.slice(),dx:0,dy:0,esc:1,ab:1,ab2:1},.4]);proximo(); r.fila.push([{forma:'aberto',forma2:null,boca:'neutra',cor:CIANO.slice(),dx:0,dy:0,esc:1,ab:1,ab2:1},.4]);proximo();
timer=setTimeout(clearVisual,Math.max(recipe.figurinha?6000:0,['musica','estrela','coracao'].includes(recipe.simbolo)?4500:['ideia','cafe'].includes(recipe.simbolo)?2500:0,recipe.passos.reduce((n,s)=>n+s.duracao,0)*1000+400)); timer=setTimeout(clearVisual,Math.max(recipe.figurinha?6000:0,['musica','estrela','coracao'].includes(recipe.simbolo)?4500:['ideia','cafe'].includes(recipe.simbolo)?2500:0,recipe.passos.reduce((n,s)=>n+s.duracao,0)*1000+400));
} }
window.eilik.receita=render; 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);}); 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; 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); setInterval(poll,1000);
})(); })();
+533
View File
@@ -0,0 +1,533 @@
/* 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 (o.dormindo && !temConteudo) { // dormindo: "–" (olho fechado)
const esp = Math.max(2, p.ry * 0.2);
caixa(c, p.cx - w * 0.5, p.cy + h * 0.08 - esp / 2, w, esp, esp / 2); c.fill();
return;
}
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 (o.iris && fecha < 0.6 && e.bot < 0.35) {
const R = Math.min(w, h) * 0.3;
const ix = Math.max(x + R * 1.1, Math.min(x + w - R * 1.1, p.cx + (p.lx + sacada.x) * w * 0.016));
const iy = Math.max(y + R * 1.1, Math.min(y + h - R * 1.1, p.cy + h * 0.04 + (p.ly + sacada.y) * h * 0.016));
c.fillStyle = css(o.cor.map((v) => v * 0.42));
c.beginPath(); c.arc(ix, iy, R, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'destination-out';
const pupila = p.ab > 1.05 ? 0.38 : e.bot > 0.2 ? 0.62 : 0.5; // susto contrai, alegria dilata
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over';
if (est.brilho) {
c.fillStyle = 'rgba(245,250,255,0.9)';
c.beginPath(); c.arc(ix - R * 0.42, iy - R * 0.42, R * 0.2, 0, Math.PI * 2); c.fill();
}
} 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]));
for (const traco of o.tracos || []) {
if (typeof traco.d !== 'string') continue;
const caminho = new Path2D(traco.d);
bctx.strokeStyle = bctx.fillStyle = css(o.cor);
bctx.lineWidth = traco.largura; bctx.lineCap = 'round'; bctx.lineJoin = 'round';
if (traco.cheio) bctx.fill(caminho); else bctx.stroke(caminho);
}
desenharMaos(bctx, o, olhos, tela.height / d);
desenharZzz(bctx, o, olhos, 1.6); // em LEDs o Z precisa ser maior para ler
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);
desenharZzz(ctx, o, olhos);
} 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);
desenharZzz(cctx, o, olhos);
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);
}
}
// ------------------------------------------------------------------ Zzz
function desenharZzz(c, o, olhos, fator) {
fator = fator || 1;
if (!o.dormindo) return;
const d = olhos.slice().sort((a, b) => a.cx - b.cx)[1]; // olho da direita na tela
const x0 = d.cx + d.ry * 0.7, y0 = d.cy - d.ry * 0.55;
c.save();
c.strokeStyle = css(o.cor); c.lineCap = 'round'; c.lineJoin = 'round';
for (let i = 0; i < 3; i++) {
const k = ((o.t || 0) * 0.32 + i / 3) % 1; // cada Z num ponto do ciclo
const tam = d.ry * (0.28 + 0.3 * k) * fator;
const x = x0 + d.ry * 0.7 * k + Math.sin(k * Math.PI * 2) * d.ry * 0.08, y = y0 - d.ry * 1.7 * k;
c.globalAlpha = Math.min(1, k / 0.15) * (1 - k);
c.lineWidth = Math.max(1.5, tam * 0.2);
c.beginPath();
c.moveTo(x - tam / 2, y - tam / 2); c.lineTo(x + tam / 2, y - tam / 2);
c.lineTo(x - tam / 2, y + tam / 2); c.lineTo(x + tam / 2, y + tam / 2);
c.stroke();
}
c.restore();
}
// --------------------------------------------------------- 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,
/** Metade da altura do olho (em frações de RY) para o estilo/formato: posiciona as sobrancelhas. */
metadeAltura: (estilo, formato) => {
const base = ESTILOS[estilo]; if (!base) return 1;
if (base.lente) return base.w * 0.5 * 1.12;
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
},
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; });
-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') kiosk.ARQUIVO_APARENCIA = str(Path(tmp) / 'aparencia.json')
server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler) server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
server.controle = kiosk.ControlePC(Path(tmp) / 'app.json') 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 = threading.Thread(target=server.serve_forever, daemon=True)
thread.start() thread.start()
try: try:
@@ -63,21 +64,23 @@ def browser_test(base):
panel = browser.new_page(viewport={'width': 1440, 'height': 1000}) panel = browser.new_page(viewport={'width': 1440, 'height': 1000})
errors = [] errors = []
panel.on('pageerror', lambda error: errors.append(str(error) + '\n' + error.stack)) panel.on('pageerror', lambda error: errors.append(str(error) + '\n' + error.stack))
panel.goto(base + '/painel') # Painel novo (web/dist): ajustes do celular e transmissão.
panel.wait_for_selector('#gestos button') panel.goto(base + '/#/ajustes')
panel.locator('#ajuste-sensorMic').uncheck() alternar = lambda nome: panel.get_by_role('switch', name=nome).locator('xpath=..').click()
alternar('Microfone Só o nível')
panel.wait_for_timeout(400) panel.wait_for_timeout(400)
assert panel.request.get(base + '/app/config').json()['sensorMic'] is False 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.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) panel.wait_for_timeout(400)
c = panel.request.get(base + '/app/config').json() c = panel.request.get(base + '/app/config').json()
assert c['cameraSono'] is True and c['cameraLado'] == 'environment' 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) panel.wait_for_timeout(500)
assert panel.request.get(base + '/app/config').json()['preview'] is True 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) panel.wait_for_timeout(500)
assert panel.request.get(base + '/app/config').json()['preview'] is False assert panel.request.get(base + '/app/config').json()['preview'] is False
assert not errors, errors assert not errors, errors
+10 -12
View File
@@ -86,21 +86,19 @@ class ControleTest(unittest.TestCase):
self.assertTrue(json.loads(body)['online']) self.assertTrue(json.loads(body)['online'])
self.assertEqual(json.loads(body)['estado']['rosto'], 'miudo') self.assertEqual(json.loads(body)['estado']['rosto'], 'miudo')
def test_frame_is_hidden_when_disabled_and_after_expiry(self): def test_phone_reports_its_own_ip(self):
jpeg = b'\xff\xd8fixture\xff\xd9' self.request('/app/status', {'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F'}})
self.assertEqual(self.request('/camera/frame', jpeg, 'image/jpeg')[0], 409) 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.request('/app/config', {'preview': True})
self.assertEqual(self.request('/camera/frame', jpeg, 'image/jpeg')[0], 200) self.assertEqual(self.request('/camera/frame', b'\xff\xd8x\xff\xd9', 'image/jpeg')[0], 404)
self.assertEqual(self.request('/camera/frame'), (200, jpeg)) self.assertEqual(self.request('/camera/frame')[0], 404)
self.server.controle.frame_at -= 4 self.assertEqual(self.request('/app/connect', {'ip': '127.0.0.1'})[0], 404)
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)
def test_rejects_non_jpeg_and_unknown_settings(self): 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', {'preview': 'false'})[0], 400)
self.assertEqual(self.request('/app/config', {'extra': 1})[0], 400) self.assertEqual(self.request('/app/config', {'extra': 1})[0], 400)
+160
View File
@@ -0,0 +1,160 @@
"""Histórico de detecções: sensores liga/desliga, intervalo, pedido de foto e rotas HTTP."""
import http.client
import importlib.util
import json
import sys
import tempfile
import threading
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from historico import Historico, MAX_FOTOS
spec = importlib.util.spec_from_file_location('kiosk_hist', ROOT / 'kiosk-server.py')
kiosk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kiosk)
JPEG = b'\xff\xd8' + b'foto' * 10 + b'\xff\xd9'
class HistoricoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.h = Historico(self.tmp.name, iniciar_pir=False)
self.publicados = []
self.h.publicar = lambda t, d: self.publicados.append((t, d))
def tearDown(self):
self.tmp.cleanup()
def test_intervalo_e_sensor_desligado(self):
self.assertIsNotNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=100))
self.assertIsNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=105)) # dentro dos 15 s
self.assertIsNotNone(self.h.registrar('som', 'surpreso', 0.9, agora=105)) # outro tipo vale
self.h.config['pir'] = False
self.assertIsNone(self.h.registrar('pir', agora=200))
with self.assertRaises(ValueError):
self.h.registrar('fumaça')
self.assertEqual(self.h.listar()[1], 2)
self.assertEqual(self.publicados[0][0], 'historico')
def test_pedido_e_foto(self):
ident = self.h.registrar('pir', 'presença', agora=10)
self.assertEqual(self.h.pedido_atual(), ident)
with self.assertRaises(ValueError):
self.h.guardar_foto(ident, b'nao e jpeg')
self.h.guardar_foto(ident, JPEG)
self.assertIsNone(self.h.pedido_atual())
self.assertTrue(self.h.evento(ident)['foto'])
self.assertTrue((Path(self.tmp.name) / 'fotos' / f'evento-{ident}.jpg').is_file())
def test_sem_foto_quando_desligado(self):
self.h.config['foto'] = False
self.h.registrar('pir', agora=10)
self.assertIsNone(self.h.pedido_atual())
def test_guarda_no_maximo_as_fotos_recentes(self):
self.h.config['intervalo'] = 0
ids = []
for i in range(MAX_FOTOS + 3):
ids.append(self.h.registrar('pir', agora=i))
self.h.guardar_foto(ids[-1], JPEG)
com_foto = [e for e in self.h.listar(500)[0] if e['foto']]
self.assertEqual(len(com_foto), MAX_FOTOS)
self.assertFalse(self.h.evento(ids[0])['foto'])
self.assertEqual(len(list((Path(self.tmp.name) / 'fotos').glob('*.jpg'))), MAX_FOTOS)
def test_guarda_so_os_ultimos_dias(self):
agora = 10 * 86400
velho = self.h.registrar('pir', agora=agora - 4 * 86400)
self.h.guardar_foto(velho, JPEG)
novo = self.h.registrar('pir', agora=agora - 86400)
self.assertEqual(self.h.limpar_antigos(agora), 1) # 3 dias por padrão
self.assertIsNone(self.h.evento(velho))
self.assertIsNotNone(self.h.evento(novo))
self.assertFalse((Path(self.tmp.name) / 'fotos' / f'evento-{velho}.jpg').exists())
with self.assertRaises(ValueError):
self.h.validar({'dias': 0})
def test_modo_presenca(self):
chamadas = []
self.h.ao_presenca = lambda: chamadas.append('chegou')
self.h.ao_ausencia = lambda: chamadas.append('saiu')
self.h.pir['disponivel'] = True
self.h.marcar_presenca(1000)
self.assertFalse(self.h.checar_ausencia(1000 + 4 * 60)) # 4 min: ainda presente
self.assertTrue(self.h.checar_ausencia(1000 + 5 * 60 + 1)) # 5 min: ausente
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # não repete
self.h.marcar_presenca(2000) # voltou alguém
self.assertEqual(chamadas, ['saiu', 'chegou'])
self.h.config['presenca'] = False
self.h.marcar_presenca(3000)
self.assertFalse(self.h.checar_ausencia(9999))
def test_pir_segurando_conta_como_presenca(self):
avisos = []
self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados))
self.h.pir['disponivel'] = True
self.h.nivel_pir(True, agora=1000) # subida: evento + bolinha acesa
self.assertTrue(self.h.pir['ativo'])
self.assertEqual(self.h.status()['eventos'][0]['tipo'], 'pir')
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # pino ainda alto: não dorme
self.h.nivel_pir(False, agora=1000 + 10 * 60) # descida: só apaga a bolinha
self.assertFalse(self.h.pir['ativo'])
self.assertEqual(self.h.status()['total'], 1)
self.assertTrue(self.h.checar_ausencia(1000 + 15 * 60 + 1))
self.assertIn(('pir', True), [(t, d.get('ativo')) for t, d in avisos])
def test_apagar_e_limpar(self):
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
b = self.h.registrar('som', agora=1)
self.h.apagar(a)
self.assertIsNone(self.h.evento(a))
self.h.limpar()
self.assertEqual(self.h.listar()[1], 0)
self.assertEqual(list((Path(self.tmp.name) / 'fotos').glob('*.jpg')), [])
class HistoricoHttpTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
self.server.token = 'token-historico'
self.server.historico = Historico(self.tmp.name, iniciar_pir=False)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def tearDown(self):
self.server.shutdown(); self.server.server_close(); self.tmp.cleanup()
def pedir(self, metodo, rota, corpo=None, tipo='application/json', token='token-historico'):
c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5)
h = {'Content-Type': tipo}
if token:
h['Authorization'] = 'Bearer ' + token
if isinstance(corpo, dict):
corpo = json.dumps(corpo)
c.request(metodo, rota, body=corpo, headers=h)
r = c.getresponse(); dados = r.read(); c.close()
return r.status, dados
def test_fluxo_completo(self):
self.assertEqual(self.pedir('POST', '/historico/evento', {'tipo': 'pir'}, token=None)[0], 401)
codigo, corpo = self.pedir('POST', '/historico/evento', {'tipo': 'movimento', 'evento': 'surpreso', 'nivel': 0.3})
ident = json.loads(corpo)['id']
self.assertEqual(json.loads(self.pedir('GET', '/historico/pedido')[1])['id'], ident)
self.assertEqual(self.pedir('POST', f'/historico/foto?id={ident}', JPEG, 'image/jpeg')[0], 200)
codigo, foto = self.pedir('GET', f'/historico/foto/evento-{ident}.jpg?t=token-historico', token=None)
self.assertEqual((codigo, foto), (200, JPEG))
self.assertEqual(self.pedir('GET', '/historico/foto/../historico.db')[0], 400)
status = json.loads(self.pedir('GET', '/historico/status')[1])
self.assertEqual(status['eventos'][0]['id'], ident)
self.assertTrue(status['eventos'][0]['foto'])
self.assertEqual(self.pedir('POST', '/historico/config', {'foto': False, 'intervalo': 30})[0], 200)
self.assertEqual(self.pedir('POST', '/historico/config', {'intervalo': 9999})[0], 400)
self.assertFalse(self.server.historico.config['foto'])
if __name__ == '__main__':
unittest.main()
-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');
+23 -1
View File
@@ -74,8 +74,20 @@ def testar(base, server, capturas):
# 2. telemetria do celular chega por SSE # 2. telemetria do celular chega por SSE
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8, 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) 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 # 3. gesto pelo dock
kiosk.FILA.limpar() kiosk.FILA.limpar()
@@ -116,6 +128,16 @@ def testar(base, server, capturas):
page.get_by_label('Buscar ajuste').fill('') page.get_by_label('Buscar ajuste').fill('')
page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True) 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 # 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_role('link', name='Conversa').first.click()
page.get_by_label('Mensagem').fill('Que horas são?') page.get_by_label('Mensagem').fill('Que horas são?')
-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 import importlib.util
from pathlib import Path from pathlib import Path
import sys import sys
@@ -27,6 +29,7 @@ def main():
server = kiosk.Servidor(('127.0.0.1', 0), Handler) server = kiosk.Servidor(('127.0.0.1', 0), Handler)
server.controle = kiosk.ControlePC(Path(tmp) / 'config.json') server.controle = kiosk.ControlePC(Path(tmp) / 'config.json')
server.controle.atualizar({'preview': True,'rastrearRosto':False,'rastrearMaos':False}) 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 = threading.Thread(target=server.serve_forever, daemon=True)
thread.start() thread.start()
base = 'http://127.0.0.1:' + str(server.server_port) base = 'http://127.0.0.1:' + str(server.server_port)
@@ -62,40 +65,49 @@ def main():
},200); },200);
}""") }""")
sender.request.post(base + '/app/status', data={'rosto':'carinha'}) 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 = context.new_page()
panel.on('pageerror', lambda e: errors.append(str(e))) panel.on('pageerror', lambda e: errors.append(str(e)))
panel.goto(base + '/painel') panel.goto(base + '/')
panel.wait_for_function("document.getElementById('cameraVideo').videoWidth > 0", timeout=20000) video = "document.querySelector('.camera video')"
first = panel.evaluate("document.getElementById('cameraVideo').getVideoPlaybackQuality().totalVideoFrames") panel.wait_for_function(video + ".videoWidth > 0", timeout=20000)
first = panel.evaluate(video + ".getVideoPlaybackQuality().totalVideoFrames")
panel.wait_for_timeout(2200) 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 count >= 45, 'Vídeo não está fluido: %s quadros em 2.2 s' % count
assert panel.locator('#cameraVideo').is_visible() assert panel.locator('.camera video').is_visible()
assert 'fps' in panel.locator('#videoQualidade').inner_text() qualidade = panel.locator('.camera .rodape .mono').inner_text()
assert 'fps' in qualidade, qualidade
assert not errors, errors 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.request.post(base+'/app/config',data={'rastrearMaos':True})
sender.wait_for_function('config.rastrearMaos === true') sender.wait_for_function('config.rastrearMaos === true')
panel.wait_for_function('CarinhaVideo.rastreamentoConectado()') panel.wait_for_function('window.carinhaVideo?.rastreamentoConectado')
panel.evaluate("CarinhaVideo.rastrear({tipo:'mao',x:.8,y:.3,atividade:true})") 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") 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.wait_for_function('window.testAlvo === null')
sender.request.post(base+'/app/config',data={'rastrearMaos':False}) 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) 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) sender.wait_for_function("aviso.includes('indisponível')", timeout=10000)
assert sender.evaluate('stream.getVideoTracks()[0].readyState') == 'live' assert sender.evaluate('stream.getVideoTracks()[0].readyState') == 'live'
panel.locator('#ajuste-sensorCamera').uncheck() sender.request.post(base+'/app/config',data={'sensorCamera':False})
panel.wait_for_function("document.getElementById('cameraVideo').srcObject === null", timeout=10000) panel.wait_for_function(video + ".srcObject === null", timeout=10000)
sender.wait_for_function('stream === null', timeout=10000) sender.wait_for_function('stream === null', timeout=10000)
panel.locator('#ajuste-sensorCamera').check() sender.request.post(base+'/app/config',data={'sensorCamera':True})
panel.wait_for_function("document.getElementById('cameraVideo').videoWidth > 0 && document.getElementById('cameraVideo').srcObject !== null", timeout=15000) panel.wait_for_function(video + ".videoWidth > 0 && " + video + ".srcObject !== null", timeout=15000)
assert not errors, errors 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) panel.wait_for_timeout(1400)
assert panel.evaluate("document.getElementById('cameraVideo').srcObject === null") assert panel.evaluate(video + ".srcObject === null")
preview = context.new_page() preview = context.new_page()
preview.on('pageerror', lambda e: errors.append(str(e))) preview.on('pageerror', lambda e: errors.append(str(e)))
preview.goto(base + '/camera.html?lado=user&resolucao=480&fps=30') preview.goto(base + '/camera.html?lado=user&resolucao=480&fps=30')
+15
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': 'gesto', 'valor': 'rm -rf'})[0], 400)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400) self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo']) 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], ['feliz', 'rosto miudo'])
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], []) 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): def test_post_face_usa_prefixo_rosto(self):
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5) conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
conn.request('POST', '/face', body='miudo', headers={'Authorization': 'Bearer ' + TOKEN}) conn.request('POST', '/face', body='miudo', headers={'Authorization': 'Bearer ' + TOKEN})
+2 -28
View File
@@ -35,7 +35,7 @@ def main():
if not r.request.url.startswith(base): external.append(r.request.url);r.abort() if not r.request.url.startswith(base): external.append(r.request.url);r.abort()
else:r.continue_() else:r.continue_()
page.route('**/*',route) page.route('**/*',route)
page.goto(base+'/painel') page.goto(base+'/') # painel novo: nada pode sair para a internet
results=page.evaluate("""async () => { results=page.evaluate("""async () => {
const worker=new Worker('/rastreador-worker.js'); const worker=new Worker('/rastreador-worker.js');
const send=msg=>new Promise((resolve,reject)=>{ const send=msg=>new Promise((resolve,reject)=>{
@@ -56,36 +56,10 @@ def main():
assert len(results['hand']['hands'][0])==21 assert len(results['hand']['hands'][0])==21
print('Inferencia real: %s rosto(s), %s mao(s); %.1f/%.1f ms' % ( 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'])) 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 external,external
assert not errors,errors assert not errors,errors
browser.close() 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: finally:
server.shutdown();server.server_close();thread.join() 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);
})();
+6 -5
View File
@@ -1,10 +1,11 @@
{ {
"versao": "0.7.0", "versao": "0.8.0",
"data": "2026-10-05", "data": "2026-10-05",
"alteracoes": [ "alteracoes": [
"Dashboard escuro com sidebar e oito telas", "Painel novo em Svelte: Ao vivo, Conversa, Reações, Movimento e Ajustes",
"Biblioteca de emojis com edição e desfazer persistente", "Estado em tempo real por SSE (/eventos) no painel e no rosto, sem polling",
"Configuração privada e teste de API Gemini", "E-Stop real (/parar-tudo) e braço com homem-morto e interpolação no servidor",
"Estilos e navegação compartilhados" "Token opcional para todas as rotas de controle; app Android envia o token",
"Telas antigas, /camera/frame e /app/connect removidos"
] ]
} }
+188
View File
@@ -0,0 +1,188 @@
/* voz-robo.js — voz sintetizada do Carinha, no estilo dos robozinhos de cinema.
*
* Tudo é gerado na hora com Web Audio: nenhuma gravação, nenhum arquivo de terceiros.
* Cada som é uma sequência de "sílabas": um oscilador que desliza de um tom a outro, com vibrato,
* passando por um filtro de formante (a "vogal": o→i faz o "uou"). Motores das mãos viram ruído
* filtrado, e a resposta da conversa vira um balbucio que sobe no fim quando é pergunta.
*/
(function () {
'use strict';
let ruido = null; // buffer de ruído reaproveitado
const TOM = 1.25; // todas as sílabas 25% acima do tom escrito
const saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
/*
* Cadeia de "brilho metálico" (criada uma vez por AudioContext):
* voz → ressonância de lata fina (agudo, ~850 Hz) → saturação leve → compressor → saída
* Nada grave: zumbido e ressonância graves deixavam a voz com cara de sapo.
*/
function saida(c) {
if (saidas.has(c)) return saidas.get(c);
const entrada = c.createGain();
const seca = c.createGain(); seca.gain.value = 0.8;
const atraso = c.createDelay(0.05); atraso.delayTime.value = 0.00118;
const volta = c.createGain(); volta.gain.value = 0.42;
const molhada = c.createGain(); molhada.gain.value = 0.35;
entrada.connect(seca);
entrada.connect(atraso); atraso.connect(volta); volta.connect(atraso); atraso.connect(molhada);
const satura = c.createWaveShaper();
const n = 2048, curva = new Float32Array(n);
for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; curva[i] = Math.tanh(x * 1.3); }
satura.curve = curva;
seca.connect(satura); molhada.connect(satura);
const brilho = c.createBiquadFilter(); // realce leve nos agudos: "metal" limpo
brilho.type = 'peaking'; brilho.frequency.value = 3200; brilho.Q.value = 1.2; brilho.gain.value = 4;
const comp = c.createDynamicsCompressor();
comp.threshold.value = -16; comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.15;
const passaBaixa = c.createBiquadFilter();
passaBaixa.type = 'lowpass'; passaBaixa.frequency.value = 8000;
const volume = c.createGain(); volume.gain.value = 0.8;
satura.connect(brilho); brilho.connect(comp); comp.connect(passaBaixa); passaBaixa.connect(volume); volume.connect(c.destination);
saidas.set(c, entrada);
return entrada;
}
/**
* Uma sílaba. de/para: tom em Hz; vogal: [formante inicial, final] em Hz;
* vib: [Hz, profundidade em Hz]; forma: tipo de onda.
*/
function silaba(c, t0, dur, de, para, vol, op) {
op = op || {};
vol *= 2.4; // compensa a energia que o filtro de formante tira
de *= TOM; para *= TOM; // voz de robozinho: aguda
const osc = c.createOscillator();
osc.type = op.forma || 'sawtooth';
osc.frequency.setValueAtTime(de, t0);
osc.frequency.exponentialRampToValueAtTime(Math.max(20, para), t0 + dur);
if (op.vib) {
const lfo = c.createOscillator(), prof = c.createGain();
lfo.frequency.value = op.vib[0]; prof.gain.value = op.vib[1];
lfo.connect(prof); prof.connect(osc.frequency);
lfo.start(t0); lfo.stop(t0 + dur + 0.05);
}
// FM harmônico e leve: brilho de metal sem aspereza
const mod = c.createOscillator(), modProf = c.createGain();
mod.type = 'sine';
mod.frequency.setValueAtTime(de * 2, t0);
mod.frequency.exponentialRampToValueAtTime(Math.max(20, para * 2), t0 + dur);
modProf.gain.setValueAtTime(de * (op.metal ?? 0.22), t0);
modProf.gain.exponentialRampToValueAtTime(Math.max(1, para * (op.metal ?? 0.22)), t0 + dur);
mod.connect(modProf); modProf.connect(osc.frequency);
mod.start(t0); mod.stop(t0 + dur + 0.05);
// corpo: uma senoide uma oitava abaixo deixa a voz menos "apito"
const sub = c.createOscillator();
sub.type = 'sine';
sub.frequency.setValueAtTime(de / 2, t0);
sub.frequency.exponentialRampToValueAtTime(Math.max(20, para / 2), t0 + dur);
const vogal = op.vogal || [900, 1500];
const filtro = c.createBiquadFilter();
filtro.type = 'bandpass'; filtro.Q.value = op.q || 5;
filtro.frequency.setValueAtTime(vogal[0], t0);
filtro.frequency.exponentialRampToValueAtTime(vogal[1], t0 + dur);
const g = c.createGain(), gs = c.createGain();
const ataque = Math.min(0.02, dur * 0.25);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol, t0 + ataque);
g.gain.setValueAtTime(vol, t0 + dur * 0.7);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
gs.gain.value = 0.12; // pouca oitava de baixo: grave demais vira coaxar
osc.connect(filtro); sub.connect(gs); gs.connect(filtro); filtro.connect(g); g.connect(saida(c));
osc.start(t0); sub.start(t0);
osc.stop(t0 + dur + 0.05); sub.stop(t0 + dur + 0.05);
}
function bufferRuido(c) {
if (ruido && ruido.sampleRate === c.sampleRate) return ruido;
ruido = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = ruido.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return ruido;
}
/** Motorzinho: ruído filtrado que sobe de tom, com um tremor mecânico. */
function servo(c, t0, dur, vol, sobe) {
const fonte = c.createBufferSource();
fonte.buffer = bufferRuido(c); fonte.loop = true;
const f = c.createBiquadFilter();
f.type = 'bandpass'; f.Q.value = 9;
f.frequency.setValueAtTime(sobe === false ? 1500 : 500, t0);
f.frequency.exponentialRampToValueAtTime(sobe === false ? 500 : 1500, t0 + dur);
const g = c.createGain(), trem = c.createOscillator(), tg = c.createGain();
trem.frequency.value = 38; tg.gain.value = vol * 0.35;
trem.connect(tg); tg.connect(g.gain);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(vol * 0.6, t0 + 0.05);
g.gain.setValueAtTime(vol * 0.6, t0 + dur - 0.06);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
fonte.connect(f); f.connect(g); g.connect(saida(c));
fonte.start(t0); trem.start(t0);
fonte.stop(t0 + dur + 0.05); trem.stop(t0 + dur + 0.05);
}
function clique(c, t0, vol) {
const fonte = c.createBufferSource();
fonte.buffer = bufferRuido(c);
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 3000;
const g = c.createGain();
g.gain.setValueAtTime(vol * 0.5, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.03);
fonte.connect(f); f.connect(g); g.connect(saida(c));
fonte.start(t0); fonte.stop(t0 + 0.05);
}
const O = [500, 900], I = [1800, 2400], U = [350, 700], A = [900, 1300]; // vogais (formantes)
// Cada som: (c, t, v) → agenda as sílabas. Volumes relativos ao volume do app.
const SONS = {
feliz: (c, t, v) => { silaba(c, t, 0.12, 620, 900, v, { vogal: [1200, 2000] }); silaba(c, t + 0.13, 0.18, 900, 1250, v, { vogal: I, vib: [9, 18] }); },
alegre: (c, t, v) => [560, 700, 860, 1100].forEach((f, i) => silaba(c, t + i * 0.085, 0.09, f, f * 1.25, v * 0.85, { vogal: I, forma: 'square' })),
coracao: (c, t, v) => { silaba(c, t, 0.55, 760, 560, v, { vogal: [800, 1300], vib: [7, 28] }); },
piscadinha: (c, t, v) => { clique(c, t, v); silaba(c, t + 0.04, 0.08, 1000, 1300, v * 0.6, { forma: 'square', vogal: I }); },
surpreso: (c, t, v) => { silaba(c, t, 0.22, 260, 1100, v, { vogal: [450, 2000] }); silaba(c, t + 0.22, 0.12, 1100, 820, v * 0.8, { vogal: I }); },
bravo: (c, t, v) => [0, 0.13, 0.26].forEach((d, i) => silaba(c, t + d, 0.1, 480 - i * 30, 380 - i * 30, v * 0.85, { forma: 'square', vogal: [1300, 900] })), // bipes zangados
triste: (c, t, v) => { silaba(c, t, 0.65, 640, 380, v * 0.85, { vogal: [1200, 750], vib: [5, 14] }); },
dormir: (c, t, v) => { silaba(c, t, 0.35, 460, 760, v * 0.7, { vogal: [900, 1500] }); silaba(c, t + 0.36, 0.75, 760, 340, v * 0.6, { vogal: [1500, 700], vib: [4, 8] }); },
acordar: (c, t, v) => { servo(c, t, 0.25, v * 0.5); [300, 450, 700].forEach((f, i) => silaba(c, t + 0.22 + i * 0.1, 0.1, f, f * 1.2, v * 0.8, { vogal: I, forma: 'square' })); silaba(c, t + 0.56, 0.2, 700, 1050, v, { vogal: [1200, 2200], vib: [8, 15] }); },
acenar: (c, t, v) => { servo(c, t, 0.3, v * 0.45); silaba(c, t + 0.28, 0.13, 520, 820, v, { vogal: [1000, 1900] }); silaba(c, t + 0.44, 0.2, 760, 1080, v, { vogal: I, vib: [9, 20] }); },
esconder: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 0.1, 0.08, 950, 1350, v * 0.8, { vogal: I }); for (let i = 0; i < 5; i++) silaba(c, t + 0.6 + i * 0.09, 0.05, 1200 + (i % 2) * 180, 1300, v * 0.35, { forma: 'square', vogal: I }); servo(c, t + 2.05, 0.4, v * 0.45, false); },
espiar: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 1.0, 0.3, 480, 760, v * 0.9, { vogal: [600, 1700] }); silaba(c, t + 2.0, 0.16, 800, 1150, v, { vogal: I }); },
pensar: (c, t, v) => { servo(c, t, 0.35, v * 0.4); silaba(c, t + 0.35, 0.5, 520, 640, v * 0.7, { vogal: [900, 1600], vib: [5, 10] }); [0.95, 1.12].forEach((d) => silaba(c, t + d, 0.05, 1500, 1600, v * 0.35, { forma: 'square', vogal: I })); }, // "hmm?" e bipinhos
musica: (c, t, v) => [523, 659, 784, 659, 880, 784, 659, 1047].forEach((f, i) => silaba(c, t + i * 0.26, 0.2, f, f * 1.02, v * 0.6, { forma: 'triangle', vogal: A, vib: [6, 5] })),
};
// Gerador previsível: a mesma frase sempre "soa" igual.
function semente(texto) {
let h = 2166136261;
for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
return () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; };
}
/** Balbucio de robô para uma resposta: ~1 sílaba a cada 6 letras, no máximo 16. */
function balbuciar(c, texto, v) {
texto = String(texto || '').trim();
if (!texto) return 0;
const rnd = semente(texto), n = Math.max(2, Math.min(16, Math.ceil(texto.length / 6)));
const pergunta = /\?\s*$/.test(texto), exclama = /!\s*$/.test(texto);
const vogais = [O, I, A, U];
let t = c.currentTime + 0.03, tom = 520 + rnd() * 180;
for (let i = 0; i < n; i++) {
const ultima = i === n - 1;
const dur = 0.07 + rnd() * 0.1;
let para = tom * (0.8 + rnd() * 0.5);
if (ultima) para = pergunta ? tom * 1.6 : exclama ? tom * 1.35 : tom * 0.7;
silaba(c, t, ultima ? dur + 0.08 : dur, tom, para, v * (0.65 + rnd() * 0.3), { vogal: vogais[Math.floor(rnd() * 4)], vib: rnd() < 0.3 ? [8, 14] : null, forma: rnd() < 0.25 ? 'square' : 'sawtooth' });
t += dur + 0.025 + rnd() * 0.05;
tom = Math.max(330, Math.min(1000, para * (0.9 + rnd() * 0.25)));
}
return t - c.currentTime;
}
function tocar(c, nome, v) {
const f = SONS[nome];
if (!f || !c) return false;
f(c, c.currentTime + 0.02, v);
return true;
}
window.VozRobo = { tocar, balbuciar, servo: (c, dur, v) => servo(c, c.currentTime + 0.01, dur, v), sons: Object.keys(SONS) };
})();
+3
View File
@@ -9,6 +9,9 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<!-- mesmo motor de rosto do celular, usado nas miniaturas das reações -->
<script src="/rosto-motor.js"></script>
<script src="/voz-robo.js"></script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json" "check": "svelte-check --tsconfig ./tsconfig.json",
"test": "node --test \"tests/**/*.test.ts\""
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1", "@sveltejs/vite-plugin-svelte": "^7.3.1",
+16 -13
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte'; import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
import { onMount, type Component } from 'svelte'; import { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte'; import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte'; import EStop from './lib/componentes/EStop.svelte';
@@ -10,28 +10,35 @@
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte'; import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
import { conectar, estado } from './lib/estado.svelte'; import { conectar, estado } from './lib/estado.svelte';
import { paleta } from './lib/paleta.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 AoVivo from './telas/AoVivo.svelte';
import Ajustes from './telas/Ajustes.svelte'; import Ajustes from './telas/Ajustes.svelte';
import Conversa from './telas/Conversa.svelte'; import Conversa from './telas/Conversa.svelte';
import Movimento from './telas/Movimento.svelte'; import Movimento from './telas/Movimento.svelte';
import Reacoes from './telas/Reacoes.svelte'; import Reacoes from './telas/Reacoes.svelte';
import Rosto from './telas/Rosto.svelte';
import Eventos from './telas/Eventos.svelte';
const TELAS: Record<Rota, Component> = { const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo, 'ao-vivo': AoVivo,
eventos: Eventos,
conversa: Conversa, conversa: Conversa,
reacoes: Reacoes, reacoes: Reacoes,
movimento: Movimento, movimento: Movimento,
ajustes: Ajustes, ajustes: Ajustes,
rosto: Rosto,
}; };
const ICONES: Record<Rota, Component<any>> = { const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video, 'ao-vivo': Video,
eventos: Bell,
conversa: MessageCircle, conversa: MessageCircle,
reacoes: Smile, reacoes: Smile,
movimento: Hand, movimento: Hand,
ajustes: Settings, ajustes: Settings,
}; };
const Tela = $derived(TELAS[nav.atual]); 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 // ---------- tema
let tema = $state<'dark' | 'light'>('dark'); let tema = $state<'dark' | 'light'>('dark');
@@ -100,11 +107,7 @@
const conexaoTexto = $derived( const conexaoTexto = $derived(
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao], { conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
); );
const bateria = $derived.by(() => { const bateria = $derived(estado.bateria.nivel);
const b = estado.app?.estado?.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
});
</script> </script>
<svelte:window onkeydown={teclado} /> <svelte:window onkeydown={teclado} />
@@ -126,7 +129,7 @@
{#each ROTAS as r (r.id)} {#each ROTAS as r (r.id)}
{@const Icone = ICONES[r.id]} {@const Icone = ICONES[r.id]}
<li> <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> <Icone size={20} /> <span>{r.titulo}</span>
</a> </a>
</li> </li>
@@ -138,12 +141,12 @@
</nav> </nav>
<header class="topo"> <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"> <div class="status linha">
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}> <Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto} {conexaoTexto}
</Selo> </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}%` : ''} Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
</Selo> </Selo>
{#if estado.conversa} {#if estado.conversa}
@@ -169,7 +172,7 @@
<Tela /> <Tela />
</main> </main>
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes'} {#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto'}
<MiniRosto /> <MiniRosto />
{/if} {/if}
</div> </div>
@@ -206,7 +209,7 @@
margin: 0; margin: 0;
padding: var(--e-1); padding: var(--e-1);
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(6, 1fr);
} }
li a { li a {
display: grid; display: grid;
+5
View File
@@ -46,12 +46,17 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' }, { chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' }, { chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' }, { chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
{ chave: 'iris', rotulo: 'Mostrar íris', ajuda: 'Pupila que acompanha o olhar (Viva, Bloco e Matriz)', tipo: 'toggle' },
{ chave: 'subirBoca', rotulo: 'Subir a boca', tipo: 'slider', min: -20, max: 60, passo: 2 },
{ chave: 'distSobrancelha', rotulo: 'Distância das sobrancelhas', tipo: 'slider', min: 0, max: 60, passo: 2 },
{ chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' }, { chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
{ chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 }, { chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
{ chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 }, { chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' }, { chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' },
{ chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 }, { chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
{ chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' }, { chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' },
{ chave: 'apagarTela', rotulo: 'Apagar a tela quando dormir', ajuda: 'Tela preta e brilho mínimo; acorda com som, movimento, toque ou PIR', tipo: 'toggle' },
{ chave: 'apagarTelaMin', rotulo: 'Dormindo há quanto tempo para apagar', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' min' },
], ],
}, },
{ {
+4 -44
View File
@@ -7,6 +7,9 @@
import { pedir, postar } from './api'; import { pedir, postar } from './api';
import { avisos } from './avisos.svelte'; import { avisos } from './avisos.svelte';
import { estado } from './estado.svelte'; import { estado } from './estado.svelte';
import { limites } from './nucleo';
export { sequencias } from './nucleo';
export interface Calibracao { export interface Calibracao {
canal: number; canal: number;
@@ -33,49 +36,6 @@ export interface StatusBraco {
export const PRAZO_MS = 450; export const PRAZO_MS = 450;
const RENOVAR_MS = 150; const RENOVAR_MS = 150;
function anguloDoPulso(p: number, c: Calibracao) {
const centro = c.centro ?? 1500;
return p <= centro ? (90 * (p - 500)) / (centro - 500) : 90 + (90 * (p - centro)) / (2500 - centro);
}
/** Faixa segura (graus) de cada articulação a partir da calibração; o canal 5 respeita o par 6 invertido. */
export function limites(st: StatusBraco): [number, number][] {
return st.articulacoes.map(({ canal }) => {
const c = st.calibracao[canal];
let lo = anguloDoPulso(c.minimo, c);
let hi = anguloDoPulso(c.maximo, c);
if (canal === 5) {
const par = st.calibracao[6];
lo = Math.max(lo, 180 - anguloDoPulso(par.maximo, par));
hi = Math.min(hi, 180 - anguloDoPulso(par.minimo, par));
}
return [Math.ceil(lo), Math.floor(hi)];
});
}
/** As 5 sequências prontas, escaladas para a faixa calibrada (porta de movimentos-core.js). */
export function sequencias(faixas: [number, number][]): { nome: string; passos: number[][] }[] {
const neutro = [0, 0, 0, 0, 0, 0, 0];
const base: [string, number[][]][] = [
['Acenar', [[0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], [0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], neutro]],
['Olhar para os lados', [[0, 0.2, -0.4, 0, 0, 0, -0.7], [0, -0.2, 0.4, 0, 0, 0, 0.7], neutro]],
['Alcançar e recolher', [[0, 0, 0.2, 0.6, -0.5, 0.4, 0], [0, 0, -0.2, -0.4, 0.5, -0.4, 0], neutro]],
['Garra e punho', [[-0.7, 0.3, 0, 0.2, 0, 0.1, 0], [0.7, -0.3, 0, 0.2, 0, 0.1, 0], [-0.7, 0.3, 0, 0, 0, 0, 0], neutro]],
['Apresentação', [[0.3, 0.3, -0.3, 0.4, -0.3, 0.3, -0.6], [-0.3, -0.3, 0.3, -0.3, 0.4, -0.3, 0.6], [0.5, 0.4, 0.2, 0.3, -0.3, 0.2, 0], neutro]],
];
return base.map(([nome, passos]) => ({
nome,
passos: passos.map((s) =>
s.map((v, i) => {
const [lo, hi] = faixas[i];
// Centro 90°, mas sempre dentro da faixa calibrada.
const meio = Math.max(lo, Math.min(hi, 90));
return Math.round(meio + v * (v < 0 ? meio - lo : hi - meio));
}),
),
}));
}
class Braco { class Braco {
status = $state<StatusBraco | null>(null); status = $state<StatusBraco | null>(null);
alvo = $state<number[]>(Array(7).fill(90)); alvo = $state<number[]>(Array(7).fill(90));
@@ -90,7 +50,7 @@ class Braco {
private sequencia: number[][] | null = null; private sequencia: number[][] | null = null;
private pausaAte = 0; private pausaAte = 0;
faixas = $derived(this.status ? limites(this.status) : []); faixas = $derived(this.status ? limites(this.status.calibracao, this.status.articulacoes) : []);
async carregar() { async carregar() {
try { try {
+37 -3
View File
@@ -20,17 +20,34 @@ export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
dormir: { nome: 'Dormir', emoji: '😴' }, dormir: { nome: 'Dormir', emoji: '😴' },
acordar: { nome: 'Acordar', emoji: '🌅' }, acordar: { nome: 'Acordar', emoji: '🌅' },
aleatorio: { nome: 'Aleatório', 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); export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
const CHAVE_FAVORITOS = 'carinha_favoritos'; 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[] { function lerFavoritos(): string[] {
try { try {
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null'); 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 { } catch {
/* sem armazenamento */ /* sem armazenamento */
} }
@@ -40,9 +57,10 @@ function lerFavoritos(): string[] {
class Favoritos { class Favoritos {
lista = $state<string[]>(lerFavoritos()); lista = $state<string[]>(lerFavoritos());
alternar(g: string) { 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 { try {
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista)); localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
localStorage.setItem(CHAVE_MAOS, '1');
} catch { } catch {
/* ok */ /* ok */
} }
@@ -53,6 +71,22 @@ export const favoritos = new Favoritos();
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome })); 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 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 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 = () => export const recarregarCelular = () =>
tentar(async () => { tentar(async () => {
const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' }); const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
+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)} {#each lista as g, i (g)}
{@const info = GESTOS_INFO[g]} {@const info = GESTOS_INFO[g]}
<div class="item"> <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="emoji" aria-hidden="true">{info.emoji}</span>
<span class="nome">{info.nome}</span> <span class="nome">{info.nome}</span>
{#if !completo}<kbd>{i + 1}</kbd>{/if} {#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if}
</button> </button>
{#if completo} {#if completo}
<button <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>
+68 -33
View File
@@ -1,56 +1,91 @@
<script lang="ts"> <script lang="ts">
import { rgb, ROSTOS_INFO } from '../comandos.svelte';
import { estado } from '../estado.svelte';
import { SIMBOLO_EMOJI } from '../treinamento.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 { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
let tela: HTMLCanvasElement;
const passo = $derived(receita?.passos?.[0]); 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;
});
// Mesma regra do celular (reacoes-render.js): ciano padrão = cor escolhida; azul-claro = tom claro dela.
function corDaReacao(hex: string | undefined): number[] {
const h = (hex ?? '').toLowerCase(), base = estado.aparencia.cor;
if (!/^#[0-9a-f]{6}$/.test(h) || h === '#5cd6ff') return [...base];
if (h === '#aaccff') return base.map((v) => v + (255 - v) * 0.45);
return rgb(h);
}
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: corDaReacao(passo?.cor),
forma: passo?.olhos ?? 'aberto',
icone: nosOlhos.icone,
desenho: nosOlhos.desenho ? $state.snapshot(nosOlhos.desenho) : null,
});
});
</script> </script>
<div class="miniatura" style="--t: {tamanho}px" aria-hidden="true"> <div class="miniatura" style="--t: {tamanho}px" aria-hidden="true">
{#if receita?.figurinha?.formas} <canvas bind:this={tela}></canvas>
<svg viewBox="0 0 100 100" width={tamanho} height={tamanho}> {#if aoRedor?.emoji}
{#each receita.figurinha.formas as forma, i (i)} <span class="extra">{aoRedor.emoji}</span>
<path d={forma.caminho} fill={forma.cor} /> {:else if aoRedor?.figurinha}
{/each} <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> </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} {/if}
</div> </div>
<style> <style>
.miniatura { .miniatura {
position: relative; position: relative;
width: var(--t); width: calc(var(--t) * 1.5);
height: var(--t); height: var(--t);
display: grid;
place-items: center;
border-radius: var(--raio); border-radius: var(--raio);
background: #05080c; background: #000;
flex: none; flex: none;
overflow: hidden;
} }
.emoji { canvas {
font-size: calc(var(--t) * 0.55); width: 100%;
line-height: 1; height: 100%;
display: block;
} }
.simbolo { .extra {
position: absolute; position: absolute;
right: 2px; right: 4px;
bottom: 0; top: 3px;
font-size: calc(var(--t) * 0.3); 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> </style>
@@ -0,0 +1,52 @@
<script lang="ts">
import { estado } from '../estado.svelte';
// Selo ao vivo do PIR: "Presença agora" por 8 s depois de cada disparo (o HC-SR505 segura ~8 s).
const seg = $derived(estado.pir?.ultimo ? Math.max(0, Math.round((estado.agora - estado.pir.ultimo * 1000) / 1000)) : null);
const agora = $derived(seg !== null && seg < 8);
const texto = $derived(
!estado.pir?.disponivel ? 'sensor indisponível'
: agora ? 'presença agora'
: seg === null ? 'aguardando presença'
: seg < 60 ? `último há ${seg} s` : seg < 3600 ? `último há ${Math.round(seg / 60)} min` : `último há ${Math.round(seg / 3600)} h`,
);
</script>
<span class="pir" class:agora class:off={!estado.pir?.disponivel} aria-live="polite">
<span class="ponto" aria-hidden="true"></span>{texto}
</span>
<style>
.pir {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: var(--raio-pill);
font-size: var(--t-xs);
font-weight: 600;
background: var(--superficie-2);
color: var(--texto-3);
white-space: nowrap;
}
.ponto {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.agora {
background: var(--ok-suave);
color: var(--ok);
}
.agora .ponto {
animation: pulso 1s ease-in-out infinite;
}
.off {
opacity: 0.7;
}
@keyframes pulso {
50% {
box-shadow: 0 0 0 5px rgb(52 199 89 / 0.25);
}
}
</style>
+20 -1
View File
@@ -7,7 +7,8 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { estado, ouvir } from '../estado.svelte'; import { estado, ouvir } from '../estado.svelte';
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props(); /** espelharSono: dorme/acorda junto com o celular (ele adormece sozinho no silêncio). */
let { titulo = 'Prévia do rosto', espelharSono = true }: { titulo?: string; espelharSono?: boolean } = $props();
let quadro: HTMLIFrameElement; let quadro: HTMLIFrameElement;
let pronto = $state(false); let pronto = $state(false);
@@ -20,6 +21,13 @@
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) }); if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
} }
// Sono que o celular decidiu sozinho (silêncio, toque, barulho) não passa pelo SSE de ordens:
// vem na telemetria. Sem celular online, a prévia fica como está.
const dormindoNoCelular = $derived(estado.celularOnline ? !!estado.app?.estado?.dormindo : null);
$effect(() => {
if (espelharSono && pronto && dormindoNoCelular !== null) enviar({ tipo: 'sono', dormindo: dormindoNoCelular });
});
let ultimaConversa = estado.conversa?.ultima?.id; let ultimaConversa = estado.conversa?.ultima?.id;
const cancelar = [ const cancelar = [
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })), ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
@@ -31,11 +39,22 @@
ultimaConversa = c.ultima.id; ultimaConversa = c.ultima.id;
enviar({ tipo: 'ordem', ordem: 'acordar' }); enviar({ tipo: 'ordem', ordem: 'acordar' });
enviar({ tipo: 'ordem', ordem: c.ultima.gesto }); enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
enviar({ tipo: 'legenda', texto: c.ultima.texto });
} }
}), }),
]; ];
onDestroy(() => cancelar.forEach((c) => c())); 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 });
}
/** Mostra uma legenda na faixa de baixo da prévia. */
export function legenda(texto: string) {
enviar({ tipo: 'legenda', texto });
}
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */ /** Para a tela de reações: mostra uma receita sem mandar ao celular. */
export function previa(receita: unknown) { export function previa(receita: unknown) {
enviar({ tipo: 'previa-reacao', receita }); enviar({ tipo: 'previa-reacao', receita });
+45 -3
View File
@@ -3,6 +3,7 @@
* Nenhuma tela faz polling; todas leem daqui. * Nenhuma tela faz polling; todas leem daqui.
*/ */
import { ErroApi, pedir, quandoNegado, urlEventos } from './api'; import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
import { avisos } from './avisos.svelte';
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos'; import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token'; export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
@@ -19,7 +20,8 @@ class Estado {
versao = $state(''); versao = $state('');
autenticacao = $state(false); autenticacao = $state(false);
estop = $state({ ativo: false, desde: null as number | null }); 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[]>([]); gestos = $state<string[]>([]);
rostos = $state<string[]>([]); rostos = $state<string[]>([]);
app = $state<Telemetria | null>(null); app = $state<Telemetria | null>(null);
@@ -32,10 +34,24 @@ class Estado {
treinamento = $state({ ocupado: false, erro: '' }); treinamento = $state({ ocupado: false, erro: '' });
api = $state<EstadoServidor['api'] | null>(null); api = $state<EstadoServidor['api'] | null>(null);
feed = $state<ItemFeed[]>([]); feed = $state<ItemFeed[]>([]);
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
/** Relógio de 1 s para derivar "online" sem esperar evento novo. */ /** Relógio de 1 s para derivar "online" sem esperar evento novo. */
agora = $state(Date.now()); agora = $state(Date.now());
celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000); 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(); export const estado = new Estado();
@@ -50,6 +66,23 @@ export function ouvir(tipo: string, fn: Ouvinte): () => void {
return () => ouvintes.get(tipo)!.delete(fn); return () => ouvintes.get(tipo)!.delete(fn);
} }
// Avisa uma vez em 15% e outra em 5% (sem carregador); zera quando volta a carregar.
let avisouEm: number | null = null;
function avisarBateria() {
const { nivel, carregando } = estado.bateria;
if (nivel === null) return;
if (carregando || nivel > 20) {
avisouEm = null;
return;
}
const limite = nivel <= 5 ? 5 : nivel <= 15 ? 15 : null;
if (limite !== null && avisouEm !== limite) {
avisouEm = limite;
avisos.mostrar(`Bateria do celular em ${nivel}%. Ligue o carregador ou o Carinha vai desligar.`, 'erro');
noFeed('estop', `Bateria do celular em ${nivel}%`);
}
}
let seqFeed = 0; let seqFeed = 0;
function noFeed(tipo: string, texto: string) { function noFeed(tipo: string, texto: string) {
estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40); estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40);
@@ -62,9 +95,11 @@ function aplicarFoto(f: EstadoServidor) {
estado.aparencia = f.aparencia; estado.aparencia = f.aparencia;
estado.gestos = f.gestos; estado.gestos = f.gestos;
estado.rostos = f.rostos; estado.rostos = f.rostos;
estado.formatos = f.formatos ?? [];
if (f.app) { if (f.app) {
estado.app = f.app; estado.app = f.app;
estado.appRecebidoEm = Date.now(); 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.app_config) estado.appConfig = f.app_config;
if (f.motores) estado.motores = f.motores; if (f.motores) estado.motores = f.motores;
@@ -73,6 +108,7 @@ function aplicarFoto(f: EstadoServidor) {
estado.reacao = f.reacao ?? null; estado.reacao = f.reacao ?? null;
if (f.treinamento) estado.treinamento = f.treinamento; if (f.treinamento) estado.treinamento = f.treinamento;
if (f.api) estado.api = f.api; if (f.api) estado.api = f.api;
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca };
} }
const tratadores: Record<string, (d: any) => void> = { const tratadores: Record<string, (d: any) => void> = {
@@ -81,7 +117,8 @@ const tratadores: Record<string, (d: any) => void> = {
}, },
telemetria: (d: Telemetria) => { telemetria: (d: Telemetria) => {
estado.app = d; estado.app = d;
estado.appRecebidoEm = Date.now(); estado.appRecebidoEm = estado.ultimoSinalEm = Date.now();
avisarBateria();
}, },
app_config: (d: AppConfig) => (estado.appConfig = d), app_config: (d: AppConfig) => (estado.appConfig = d),
aparencia: (d) => (estado.aparencia = d), aparencia: (d) => (estado.aparencia = d),
@@ -105,6 +142,11 @@ const tratadores: Record<string, (d: any) => void> = {
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome); if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
}, },
treinamento: (d) => (estado.treinamento = d), treinamento: (d) => (estado.treinamento = d),
pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, presenca: d.presenca }),
historico: (d) => {
const n = d?.novo;
if (n) noFeed('historico', { pir: '🚶 Presença no PIR', movimento: '👀 Movimento na câmera', som: '🔊 Som alto' }[n.tipo as string] ?? 'Detecção');
},
}; };
let fonte: EventSource | null = null; let fonte: EventSource | null = null;
@@ -127,7 +169,7 @@ function abrirEventos() {
if (estado.conexao !== 'token') estado.conexao = 'caiu'; if (estado.conexao !== 'token') estado.conexao = 'caiu';
}; };
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia', for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
'reacao', 'treinamento']) { 'reacao', 'treinamento', 'historico', 'pir']) {
fonte.addEventListener(tipo, (e) => { fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data); const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados); tratadores[tipo]?.(dados);
+85
View File
@@ -0,0 +1,85 @@
/** Histórico de detecções (/historico/*): recarrega ao abrir e quando o servidor avisa por SSE. */
import { lerToken, pedir, postar } from './api';
import { avisos } from './avisos.svelte';
import { ouvir } from './estado.svelte';
export interface EventoHistorico {
id: number;
horario: number;
tipo: 'pir' | 'movimento' | 'som';
evento: string;
nivel: number | null;
foto: boolean;
}
export interface StatusHistorico {
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number };
presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number };
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
eventos: EventoHistorico[];
total: number;
max_eventos: number;
max_fotos: number;
}
export const TIPOS_EVENTO: Record<string, { nome: string; emoji: string; cor: string }> = {
pir: { nome: 'Presença (PIR)', emoji: '🚶', cor: '#34c759' },
movimento: { nome: 'Movimento na câmera', emoji: '👀', cor: '#0a84ff' },
som: { nome: 'Som alto', emoji: '🔊', cor: '#ff9500' },
};
/** URL da foto com o token na query (a tag <img> não manda cabeçalho). */
export function urlFoto(id: number) {
const t = lerToken();
return `/historico/foto/evento-${id}.jpg` + (t ? '?t=' + encodeURIComponent(t) : '');
}
class Historico {
status = $state<StatusHistorico | null>(null);
private espera = 0;
async carregar() {
try {
this.status = await pedir<StatusHistorico>('/historico/status');
} catch (e) {
avisos.erro(e);
}
}
async configurar(parcial: Partial<StatusHistorico['config']>) {
const antes = this.status ? { ...this.status.config } : null;
if (this.status) this.status = { ...this.status, config: { ...this.status.config, ...parcial } };
try {
this.status = await postar<StatusHistorico>('/historico/config', parcial);
} catch (e) {
if (this.status && antes) this.status = { ...this.status, config: antes };
avisos.erro(e);
}
}
async apagar(id: number) {
try {
await postar('/historico/apagar', { id });
} catch (e) {
avisos.erro(e);
}
}
async limpar() {
try {
await postar('/historico/limpar');
avisos.ok('Histórico apagado.');
} catch (e) {
avisos.erro(e);
}
}
/** Vários avisos seguidos (evento + foto) viram uma recarga só. */
agendar() {
clearTimeout(this.espera);
this.espera = window.setTimeout(() => this.carregar(), 300);
}
}
export const historico = new Historico();
ouvir('historico', () => historico.status && historico.agendar());
+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. */ /** 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 { estado } from './estado.svelte';
import { nav, ROTAS } from './rotas.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. // Itens base: navegação, ações gerais, gestos, rostos e cores.
registrarNaPaleta(() => [ registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })), ...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 estado.estop.ativo
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar } ? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
: { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo }, : { id: '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}`, titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`,
fazer: () => gesto(g), 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) })), ...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. * servidor em /rastreador-worker.js). Porta de painel-rastreamento.js + rastreamento-core.js.
*/ */
import type { AppConfig } from './tipos'; 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 { interface Resultado {
faces: Face[]; faces: Face[];
hands: Ponto[][]; hands: Ponto[][];
@@ -22,35 +13,9 @@ interface Resultado {
timestamp: number; timestamp: number;
} }
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
const limitar = (v: number) => Math.max(0, Math.min(1, v));
const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12], 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]]; [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 { export class Rastreamento {
texto = $state('Rastreamento desligado.'); texto = $state('Rastreamento desligado.');
@@ -125,7 +90,7 @@ export class Rastreamento {
const p = pt(rosto); const p = pt(rosto);
c.strokeStyle = c.fillStyle = '#5cd6ff'; c.strokeStyle = c.fillStyle = '#5cd6ff';
c.strokeRect(p.x, p.y, rosto.w * f.w, rosto.h * f.h); 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 q of rosto.pontos || []) bola(pt(q));
} }
for (const mao of r.hands) { for (const mao of r.hands) {
+12 -3
View File
@@ -1,18 +1,27 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */ /** 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' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' }, { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' }, { id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' }, { id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' }, { id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
{ id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' }, { 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 { function lerHash(): Rota {
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota; let id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo'; if ((id as string) === 'historico') id = 'eventos'; // endereço antigo
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
} }
class Navegacao { class Navegacao {
+5 -1
View File
@@ -18,6 +18,8 @@ export interface Telemetria {
bateria?: { nivel?: number; carregando?: boolean } | number | null; bateria?: { nivel?: number; carregando?: boolean } | number | null;
brilho?: number; brilho?: number;
video?: { qualidade?: string } | null; 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; [k: string]: unknown;
}; };
preview: boolean; preview: boolean;
@@ -67,7 +69,8 @@ export interface EstadoServidor {
seq: number; seq: number;
autenticacao: boolean; autenticacao: boolean;
estop: { ativo: boolean; desde: number | null }; 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[]; gestos: string[];
rostos: string[]; rostos: string[];
app?: Telemetria; app?: Telemetria;
@@ -78,4 +81,5 @@ export interface EstadoServidor {
reacao?: Reacao | null; reacao?: Reacao | null;
treinamento?: { ocupado: boolean; erro: string }; treinamento?: { ocupado: boolean; erro: string };
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown }; api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
} }
+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. * Só a tela Ao vivo cria uma instância: assim não há disputa pela única sessão.
*/ */
import { lerToken, pedir } from './api'; import { lerToken, pedir } from './api';
import type { AlvoRastreio } from './nucleo';
import type { AppConfig, Telemetria } from './tipos'; import type { AppConfig, Telemetria } from './tipos';
export interface AlvoRastreio { export type { AlvoRastreio } from './nucleo';
tipo: 'mao' | 'rosto';
x: number;
y: number;
atividade?: boolean;
}
export class VideoAoVivo { export class VideoAoVivo {
mensagem = $state('Transmissão desligada.'); mensagem = $state('Transmissão desligada.');
@@ -186,6 +182,10 @@ export class VideoAoVivo {
} }
} }
get rastreamentoConectado() {
return this.canal?.readyState === 'open';
}
rastrear(alvo: AlvoRastreio | null) { rastrear(alvo: AlvoRastreio | null) {
if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096) if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096)
this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo })); this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo }));
+254 -238
View File
@@ -1,15 +1,19 @@
<script lang="ts"> <script lang="ts">
import { Battery, Brain, Camera, Download, Ear, Moon, RefreshCw, ScanFace } from '@lucide/svelte'; import {
Brain, Camera, Download, Eye, Footprints, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
} from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api'; import { postar } from '../lib/api';
import { salvarApp } from '../lib/ajustes.svelte'; import { salvarApp } from '../lib/ajustes.svelte';
import { tentar } from '../lib/avisos.svelte'; import { tentar } from '../lib/avisos.svelte';
import { cor, CORES, hex, recarregarCelular, rgb, rosto } from '../lib/comandos.svelte'; import { FORMATOS_INFO, hex, nomeRosto, recarregarCelular, ROSTOS_INFO } from '../lib/comandos.svelte';
import Card from '../lib/componentes/Card.svelte'; import Celular from '../lib/componentes/Celular.svelte';
import Gestos from '../lib/componentes/Gestos.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 PresencaPir from '../lib/componentes/PresencaPir.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.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 { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte'; import { Rastreamento } from '../lib/rastreamento.svelte';
import { VideoAoVivo } from '../lib/video.svelte'; import { VideoAoVivo } from '../lib/video.svelte';
@@ -19,13 +23,18 @@
let video = $state<VideoAoVivo>(); let video = $state<VideoAoVivo>();
let rastreio = $state<Rastreamento>(); 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(() => { onMount(() => {
video = new VideoAoVivo(elVideo); video = new VideoAoVivo(elVideo);
rastreio = new Rastreamento(elVideo, elTela, video); rastreio = new Rastreamento(elVideo, elTela, video);
(window as any).carinhaVideo = video; // depuração e testes de ponta a ponta
}); });
onDestroy(() => { onDestroy(() => {
rastreio?.destruir(); rastreio?.destruir();
video?.destruir(); video?.destruir();
delete (window as any).carinhaVideo;
}); });
// Vídeo e rastreamento seguem o estado do servidor (SSE), sem polling próprio. // Vídeo e rastreamento seguem o estado do servidor (SSE), sem polling próprio.
@@ -38,16 +47,16 @@
rastreio?.atualizar(cfg, permitido); rastreio?.atualizar(cfg, permitido);
}); });
const sensores = $derived(estado.app?.estado ?? {}); const cfg = $derived(estado.appConfig);
const bateria = $derived.by(() => { // Pontinho do PIR sobre a imagem: verde pulsando nos 8 s depois de cada disparo.
const b = sensores.bateria; // aceso enquanto o pino do sensor está alto; servidor antigo (sem `ativo`) cai nos 8 s após o disparo
const n = typeof b === 'number' ? b : b?.nivel; const pirAtivo = $derived(
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null; estado.pir?.ativo ?? (!!estado.pir?.ultimo && estado.agora - estado.pir.ultimo * 1000 < 8000),
}); );
const pct = (v: unknown) => (typeof v === 'number' ? Math.round(Math.min(1, v) * 100) : 0); const transmitindo = $derived(!!cfg?.preview);
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); 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; if (!elVideo.videoWidth) return;
const c = document.createElement('canvas'); const c = document.createElement('canvas');
c.width = elVideo.videoWidth; c.width = elVideo.videoWidth;
@@ -59,156 +68,145 @@
a.click(); 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> </script>
<div class="ao-vivo"> <div class="ao-vivo">
<section class="palco"> <!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
<div class="quadro camera"> <section class="palco" aria-label="Câmera e rosto">
{#if estado.pir?.disponivel}
<span class="ponto-pir" class:ativo={pirAtivo} title="Sensor de presença (PIR)" aria-live="polite">
<span class="luz" aria-hidden="true"></span>
PIR{pirAtivo ? ' · presença' : ''}
</span>
{/if}
<div class="quadro camera" class:mini={rostoGrande}>
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video> <video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
<canvas bind:this={elTela} class="sobreposicao" aria-hidden="true"></canvas> <canvas bind:this={elTela} class="sobreposicao" aria-hidden="true"></canvas>
{#if !video?.exibindo} {#if !video?.exibindo}
<div class="vazio"> <div class="vazio">
<Camera size={32} /> <Camera size={rostoGrande ? 18 : 32} />
{#if !rostoGrande}
<p>{video?.mensagem ?? 'Carregando…'}</p> <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> </div>
{/if} {/if}
{#if rostoGrande}
<button class="trocar" onclick={() => (rostoGrande = false)} aria-label="Mostrar a câmera em destaque"></button>
{:else}
<div class="rodape"> <div class="rodape">
<Selo tom={video?.exibindo ? 'ok' : 'neutro'}>Câmera</Selo> <span class="ponto" class:ao-vivo-on={video?.exibindo}></span>
{#if video?.qualidade && video.exibindo}<span class="mono fraco">{video.qualidade}</span>{/if} <span>{video?.exibindo ? 'Ao vivo' : 'Câmera'}</span>
{#if video?.qualidade && video.exibindo}<span class="mono">{video.qualidade}</span>{/if}
<span class="espaco"></span> <span class="espaco"></span>
{#if video?.exibindo} {#if video?.exibindo}
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button> <button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
{/if} {/if}
</div> </div>
{/if}
</div> </div>
<div class="quadro rosto">
<div class="quadro rosto" class:mini={!rostoGrande}>
<RostoPrevia titulo="Rosto ao vivo" /> <RostoPrevia titulo="Rosto ao vivo" />
<div class="rodape"> {#if !rostoGrande}
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>Rosto {estado.celularOnline ? 'no celular' : '· prévia'}</Selo> <button class="trocar" onclick={() => (rostoGrande = true)} aria-label="Mostrar o rosto em destaque">
</div> <Repeat size={14} />
</button>
{/if}
</div> </div>
</section> </section>
<div class="controles"> <div class="lista">
<div class="transmissao"> <Grupo titulo="Celular">
<Toggle <Celular />
rotulo="Transmitir câmera do celular" <Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
ajuda="Vídeo direto do celular para este PC (WebRTC)" </Grupo>
checked={!!estado.appConfig?.preview}
disabled={!estado.appConfig}
onchange={(v) => salvarApp({ preview: v })}
/>
{#if rastreio && estado.appConfig?.preview}
<p class="fraco pequeno-texto linha"><ScanFace size={14} /> {rastreio.texto}</p>
{/if}
</div>
</div>
<div class="grade-cards"> <Grupo titulo="Câmera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
<Card titulo="Gestos" descricao="Favoritos com atalho de 1 a 9. Fixe outros na tela de Reações ou pelo Ctrl+K."> <Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}>
<Gestos /> {#snippet acessorio()}
</Card> <Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} />
<Card titulo="Aparência">
<div class="campo">
<span>Rosto</span>
<div class="segmentado" role="radiogroup" aria-label="Rosto">
{#each estado.rostos as r (r)}
<button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button>
{/each}
</div>
</div>
<div class="campo">
<span>Cor dos olhos</span>
<div class="cores">
{#each CORES as c (c.nome)}
<button
class="cor"
style="--c: {hex(c.rgb)}"
aria-label={c.nome}
aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)}
onclick={() => cor(c.rgb)}
></button>
{/each}
<label class="cor livre" style="--c: {hex(estado.aparencia.cor)}" title="Outra cor">
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
<span class="sr-only">Escolher outra cor</span>
</label>
</div>
</div>
</Card>
<Card titulo="Celular">
{#snippet acoes()}
<button class="btn pequeno" onclick={recarregarCelular} disabled={!estado.celularOnline}>
<RefreshCw size={14} /> Recarregar tela
</button>
{/snippet} {/snippet}
{#if estado.celularOnline} </Linha>
<dl class="sensores"> {#if transmitindo}
<div> <Linha icone={ScanFace} cor="#af52de" rotulo="Rastrear rosto">
<dt><Battery size={16} /> Bateria</dt> {#snippet acessorio()}
<dd>{bateria !== null ? bateria + '%' : '—'}</dd> <Interruptor rotulo="Rastrear rosto" checked={!!cfg?.rastrearRosto} onchange={(v) => salvarApp({ rastrearRosto: v })} />
</div> {/snippet}
<div> </Linha>
<dt><Moon size={16} /> Estado</dt> <Linha icone={Hand} cor="#ff9500" rotulo="Rastrear mãos">
<dd>{sensores.dormindo ? 'Dormindo' : 'Acordado'}</dd> {#snippet acessorio()}
</div> <Interruptor rotulo="Rastrear mãos" checked={!!cfg?.rastrearMaos} onchange={(v) => salvarApp({ rastrearMaos: v })} />
<div> {/snippet}
<dt><Ear size={16} /> Som</dt> </Linha>
<dd> <Linha icone={Eye} cor="#007aff" rotulo="Olhos seguem o alvo">
{#if sensores.microfone_ativo} {#snippet acessorio()}
<meter min="0" max="100" value={pct(sensores.microfone)} aria-label="Nível de som"></meter> <Interruptor rotulo="Olhos seguem o alvo" checked={!!cfg?.seguirRastreamento} onchange={(v) => salvarApp({ seguirRastreamento: v })} />
{:else}<span class="fraco">desligado</span>{/if} {/snippet}
</dd> </Linha>
</div> <Linha icone={PenLine} cor="#8e8e93" rotulo="Desenhar rastreamento no vídeo">
<div> {#snippet acessorio()}
<dt><Camera size={16} /> Movimento</dt> <Interruptor rotulo="Desenhar rastreamento no vídeo" checked={!!cfg?.mostrarRastreamento} onchange={(v) => salvarApp({ mostrarRastreamento: v })} />
<dd> {/snippet}
{#if sensores.camera_ativa} </Linha>
<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} {/if}
</Card> <Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
</Grupo>
<Card titulo="Personalidade local" descricao="Qwen escolhe expressões a partir de sons e movimentos."> <Grupo titulo="Gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
<Toggle <Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos />
</Linha>
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
</Grupo>
<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>
<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" rotulo="Reagir sozinho com IA local"
ajuda={estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''} 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}` : ''}
checked={!!estado.ia?.ativo} >
disabled={!estado.ia} {#snippet acessorio()}
onchange={iaLocal} <Interruptor rotulo="Reagir sozinho com IA local" checked={!!estado.ia?.ativo} disabled={!estado.ia} onchange={iaLocal} />
/> {/snippet}
{#if estado.ia?.ultima} </Linha>
<p class="pequeno-texto linha"> </Grupo>
<Brain size={14} /> Último: {estado.ia.ultima.evento} → <strong>{estado.ia.ultima.gesto}</strong>
<span class="fraco">({estado.ia.ultima.segundos}s)</span>
</p>
{/if}
{#if estado.ia?.erro}<p class="erro pequeno-texto">{estado.ia.erro}</p>{/if}
</Card>
<Card titulo="Acontecimentos" class="feed"> <Grupo titulo="Acontecimentos">
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
{#snippet acessorio()}<PresencaPir />{/snippet}
</Linha>
<Linha icone={History} cor="#30b0c7" rotulo="Eventos" detalhe="Presença, movimento e som, com foto" href="#/eventos" />
{#if estado.feed.length} {#if estado.feed.length}
<ol> <ol class="feed">
{#each estado.feed as item (item.id)} {#each estado.feed.slice(0, 12) as item (item.id)}
<li class={item.tipo}><time class="mono fraco">{hora(item.quando)}</time> {item.texto}</li> <li class={item.tipo}><time class="mono">{hora(item.quando)}</time> <span>{item.texto}</span></li>
{/each} {/each}
</ol> </ol>
{:else} {: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} {/if}
</Card> </Grupo>
</div> </div>
</div> </div>
@@ -216,19 +214,35 @@
.ao-vivo { .ao-vivo {
display: grid; display: grid;
gap: var(--e-4); gap: var(--e-4);
align-items: start;
} }
/* ---------- palco: sempre visível */
.palco { .palco {
display: grid; position: sticky;
gap: var(--e-3); top: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
grid-template-columns: 1fr; z-index: 20;
aspect-ratio: 16 / 9;
margin: calc(-1 * var(--e-4)) calc(-1 * var(--e-4)) 0;
background: #000;
} }
.quadro { .quadro {
position: relative; position: absolute;
aspect-ratio: 16 / 9;
border-radius: var(--raio-lg);
overflow: hidden; overflow: hidden;
background: #000; 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, .camera video,
.sobreposicao { .sobreposicao {
@@ -247,12 +261,23 @@
display: grid; display: grid;
place-content: center; place-content: center;
justify-items: center; justify-items: center;
gap: var(--e-2); gap: var(--e-3);
padding: var(--e-5); padding: var(--e-5);
text-align: center; text-align: center;
color: #8396ab; color: #a9b8c9;
font-size: var(--t-sm); 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 { .rodape {
position: absolute; position: absolute;
left: 0; left: 0;
@@ -262,127 +287,118 @@
align-items: center; align-items: center;
gap: var(--e-2); gap: var(--e-2);
padding: var(--e-2) var(--e-3); 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; color: #e8eef6;
font-size: var(--t-xs); font-size: var(--t-xs);
pointer-events: none;
} }
.rodape button { .rodape .mono {
pointer-events: auto; 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 { .espaco {
flex: 1; flex: 1;
} }
.controles { .ponto-pir {
display: flex;
flex-wrap: wrap;
gap: var(--e-4);
align-items: center;
}
.transmissao {
display: grid;
gap: var(--e-1);
min-width: min(100%, 420px);
}
.grade-cards {
display: grid;
gap: var(--e-4);
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
}
.segmentado {
display: flex;
background: var(--fundo-2);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
padding: 3px;
gap: 3px;
}
.segmentado button {
flex: 1;
min-height: 34px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
text-transform: capitalize;
cursor: pointer;
}
.segmentado button[aria-checked='true'] {
background: var(--superficie-3);
color: var(--texto);
}
.cores {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.cor {
width: 34px;
height: 34px;
border-radius: 50%;
border: 2px solid var(--borda-forte);
background: var(--c);
cursor: pointer;
position: relative;
}
.cor[aria-pressed='true'] {
outline: 2px solid var(--texto);
outline-offset: 2px;
}
.livre {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
}
.livre input {
position: absolute; position: absolute;
inset: 0; z-index: 3;
opacity: 0; left: var(--e-3);
cursor: pointer; top: var(--e-3);
} display: inline-flex;
.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; align-items: center;
gap: 6px; gap: 6px;
font-size: var(--t-xs); padding: 4px 10px 4px 7px;
color: var(--texto-3); border-radius: var(--raio-pill);
background: rgb(0 0 0 / 0.55);
color: #c7cdd4;
font: 600 11px var(--fonte);
letter-spacing: 0.04em;
pointer-events: none;
transition: color var(--transicao);
} }
dd { .ponto-pir .luz {
margin: 0; width: 10px;
font-weight: 500; height: 10px;
border-radius: 50%;
background: #6b7280;
transition: background var(--transicao);
} }
meter { .ponto-pir.ativo {
width: 100%; color: #b8f5c9;
height: 8px;
} }
.erro { .ponto-pir.ativo .luz {
color: var(--perigo); background: #34c759;
animation: pulsoPir 1s ease-in-out infinite;
} }
:global(.feed) ol { @keyframes pulsoPir {
0%, 100% { box-shadow: 0 0 0 0 rgb(52 199 89 / 0.7); }
50% { box-shadow: 0 0 0 7px rgb(52 199 89 / 0); }
}
/* ---------- lista */
.lista {
display: grid;
gap: var(--e-5);
min-width: 0;
}
.amostra-cor {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--c);
border: 2px solid var(--borda-forte);
}
.feed {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: var(--e-2) 0;
display: grid; max-height: 280px;
gap: var(--e-2);
max-height: 260px;
overflow: auto; overflow: auto;
font-size: var(--t-sm); 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); 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 { .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> </style>
+308
View File
@@ -0,0 +1,308 @@
<script lang="ts">
import { Camera, Ear, Footprints, Image as ImageIcon, Moon, Timer, Trash2, Video } from '@lucide/svelte';
import { onMount } from 'svelte';
import Grupo from '../lib/componentes/Grupo.svelte';
import Interruptor from '../lib/componentes/Interruptor.svelte';
import Linha from '../lib/componentes/Linha.svelte';
import Modal from '../lib/componentes/Modal.svelte';
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import Slider from '../lib/componentes/Slider.svelte';
import { estado } from '../lib/estado.svelte';
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
onMount(() => {
historico.carregar();
});
const st = $derived(historico.status);
// Presença e movimento em destaque; som é opcional no filtro.
type Filtro = 'principais' | 'pir' | 'movimento' | 'som' | 'todos';
let filtro = $state<Filtro>('principais');
const FILTROS: [Filtro, string][] = [
['principais', 'Presença e movimento'], ['pir', '🚶 Presença'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['todos', 'Todos'],
];
const eventos = $derived(
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'principais' ? e.tipo !== 'som' : e.tipo === filtro)),
);
// Agrupa por dia: Hoje, Ontem, depois a data.
const dias = $derived.by(() => {
const grupos: { titulo: string; itens: EventoHistorico[] }[] = [];
const hoje = new Date(estado.agora); hoje.setHours(0, 0, 0, 0);
for (const ev of eventos) {
const d = new Date(ev.horario * 1000); d.setHours(0, 0, 0, 0);
const diff = Math.round((hoje.getTime() - d.getTime()) / 86400000);
const titulo = diff === 0 ? 'Hoje' : diff === 1 ? 'Ontem' : d.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' });
if (!grupos.length || grupos[grupos.length - 1].titulo !== titulo) grupos.push({ titulo, itens: [] });
grupos[grupos.length - 1].itens.push(ev);
}
return grupos;
});
const TITULO: Record<string, string> = { pir: 'Presença detectada', movimento: 'Movimento na câmera', som: 'Som alto' };
const DETALHE: Record<string, string> = { surpreso: 'deu um susto', acordou: 'acordou o Carinha', presença: 'sensor PIR' };
const horaCurta = (t: number) => new Date(t * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
const desde = (t: number) => {
const min = Math.max(0, Math.round((estado.agora - t * 1000) / 60000));
return min < 1 ? 'agora' : min < 60 ? `há ${min} min` : `há ${Math.round(min / 60)} h`;
};
const presenca = $derived(st?.presenca);
let aberta = $state<EventoHistorico | null>(null);
let modal = $state(false);
let confirmar = $state(false);
function verFoto(ev: EventoHistorico) {
aberta = ev;
modal = true;
}
</script>
<div class="eventos">
<header>
<div class="linha entre">
<h1>Eventos</h1>
{#if st?.config.pir}<PresencaPir />{/if}
</div>
<p class="fraco">
O que o sensor de presença e a câmera do celular perceberam, com uma foto de cada momento. Guardamos {st?.config.dias ?? 3} dias.
</p>
</header>
<div class="colunas">
<section class="lista" aria-label="Lista de eventos">
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
{#each FILTROS as [id, nome] (id)}
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id)}>{nome}</button>
{/each}
</div>
{#if !st}
<p class="fraco">Carregando eventos…</p>
{:else if !eventos.length}
<div class="vazio">
<p>Nenhum evento {filtro === 'todos' ? 'nos últimos dias' : 'com esse filtro'}.</p>
<p class="fraco pequeno-texto">Quando alguém passar na frente do sensor ou a câmera perceber movimento, aparece aqui na hora.</p>
</div>
{:else}
{#each dias as dia (dia.titulo)}
<Grupo titulo={`${dia.titulo} · ${dia.itens.length}`}>
{#each dia.itens as ev (ev.id)}
{@const info = TIPOS_EVENTO[ev.tipo]}
<div class="evento">
<span class="icone" style="--c: {info.cor}" aria-hidden="true">{info.emoji}</span>
<div class="texto">
<strong>{TITULO[ev.tipo] ?? ev.tipo}</strong>
<span class="fraco pequeno-texto">
{horaCurta(ev.horario)} · {DETALHE[ev.evento] ?? ev.evento}{ev.nivel !== null && ev.tipo !== 'pir' ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}
</span>
</div>
{#if ev.foto}
<button class="ver-foto" onclick={() => verFoto(ev)} aria-label="Ver foto de {horaCurta(ev.horario)}">
<img src={urlFoto(ev.id)} alt="" loading="lazy" />
<span>Ver foto</span>
</button>
{:else}
<span class="sem-foto fraco pequeno-texto">sem foto</span>
{/if}
<button class="btn pequeno icone fantasma" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
</div>
{/each}
</Grupo>
{/each}
{/if}
</section>
<aside class="lado">
<Grupo
titulo="Modo presença"
rodape="Alguém aparece: o PIR liga a câmera do celular e acorda o Carinha. Ninguém pelo tempo escolhido: a câmera desliga, ele dorme e fica em silêncio."
>
<Linha
icone={Footprints}
cor="#34c759"
rotulo="Câmera ligada pela presença"
detalhe={presenca ? (presenca.estado === 'presente' ? `alguém por perto · último movimento ${desde(presenca.ultimo)}` : `ninguém por perto ${desde(presenca.desde)}`) : ''}
>
{#snippet acessorio()}
<Interruptor rotulo="Câmera ligada pela presença" checked={!!st?.config.presenca} disabled={!st} onchange={(v) => historico.configurar({ presenca: v })} />
{/snippet}
</Linha>
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por">
<Slider rotulo="Desligar câmera e dormir depois de" valor={st?.config.ausencia ?? 5} min={1} max={60} passo={1} unidade=" min" onchange={(v) => historico.configurar({ ausencia: v })} />
</Linha>
</Grupo>
<Grupo titulo="O que registrar">
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)" detalhe={st?.pir.disponivel ? 'Pino 11 · HC-SR505' : st?.pir.erro || 'indisponível'}>
{#snippet acessorio()}
<Interruptor rotulo="Sensor de presença (PIR)" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
{/snippet}
</Linha>
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera">
{#snippet acessorio()}
<Interruptor rotulo="Movimento na câmera" checked={!!st?.config.movimento} disabled={!st} onchange={(v) => historico.configurar({ movimento: v })} />
{/snippet}
</Linha>
<Linha icone={Ear} cor="#ff9500" rotulo="Som alto">
{#snippet acessorio()}
<Interruptor rotulo="Som alto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
{/snippet}
</Linha>
<Linha icone={Camera} cor="#af52de" rotulo="Tirar uma foto por evento" detalhe="Câmera frontal do S10">
{#snippet acessorio()}
<Interruptor rotulo="Tirar uma foto por evento" checked={!!st?.config.foto} disabled={!st} onchange={(v) => historico.configurar({ foto: v })} />
{/snippet}
</Linha>
<Linha icone={Timer} cor="#8e8e93" rotulo="Intervalo entre eventos do mesmo tipo">
<Slider rotulo="Intervalo" valor={st?.config.intervalo ?? 15} min={0} max={120} passo={5} unidade=" s" onchange={(v) => historico.configurar({ intervalo: v })} />
</Linha>
<Linha icone={ImageIcon} cor="#30b0c7" rotulo="Guardar">
<Slider rotulo="Dias de histórico" valor={st?.config.dias ?? 3} min={1} max={30} passo={1} unidade=" dias" onchange={(v) => historico.configurar({ dias: v })} />
</Linha>
</Grupo>
<div class="limpar">
{#if confirmar}
<p class="pequeno-texto">Apagar todos os eventos e fotos?</p>
<div class="linha">
<button class="btn perigo pequeno" onclick={() => { confirmar = false; historico.limpar(); }}>Apagar tudo</button>
<button class="btn pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
</div>
{:else}
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!st?.total}><Trash2 size={14} /> Limpar eventos</button>
{/if}
</div>
</aside>
</div>
</div>
<Modal bind:aberto={modal} titulo={aberta ? `${TITULO[aberta.tipo]} · ${hora(aberta.horario)}` : 'Foto'}>
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
</Modal>
<style>
.eventos {
display: grid;
gap: var(--e-5);
}
header {
display: grid;
gap: var(--e-2);
}
header p {
max-width: 70ch;
}
.colunas {
display: grid;
gap: var(--e-5);
align-items: start;
}
.lista,
.lado {
display: grid;
gap: var(--e-5);
min-width: 0;
}
.filtros {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.filtros button {
min-height: 34px;
padding: 0 var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio-pill);
background: var(--superficie);
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.filtros button[aria-checked='true'] {
background: var(--destaque);
border-color: var(--destaque);
color: var(--destaque-texto);
}
.vazio {
display: grid;
gap: var(--e-1);
padding: var(--e-6);
text-align: center;
border: 1px dashed var(--borda-forte);
border-radius: var(--raio-lg);
}
.evento {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-2) var(--e-3) var(--e-2) var(--e-4);
min-height: 64px;
}
.evento + .evento {
border-top: 1px solid var(--borda);
}
.icone {
flex: none;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: color-mix(in srgb, var(--c) 18%, transparent);
font-size: 1.1rem;
}
.texto {
flex: 1;
display: grid;
min-width: 0;
}
.ver-foto {
flex: none;
display: flex;
align-items: center;
gap: var(--e-2);
padding: 3px var(--e-3) 3px 3px;
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--destaque);
font: 600 var(--t-xs) var(--fonte);
cursor: zoom-in;
}
.ver-foto img {
width: 56px;
height: 40px;
object-fit: cover;
border-radius: 7px;
}
.sem-foto {
flex: none;
}
.limpar {
display: grid;
gap: var(--e-2);
padding: 0 var(--e-2);
}
.grande {
width: 100%;
border-radius: var(--raio);
}
@media (max-width: 520px) {
.ver-foto span {
display: none;
}
.ver-foto {
padding: 3px;
}
}
@media (min-width: 1100px) {
.colunas {
grid-template-columns: 1fr 380px;
}
.lado {
position: sticky;
top: calc(var(--topo-altura) + var(--e-5));
}
}
</style>
+10 -2
View File
@@ -204,7 +204,7 @@
</Card> </Card>
</div> </div>
{:else if aba === 'gestos'} {: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 /> <Gestos completo />
</Card> </Card>
{:else} {:else}
@@ -249,7 +249,7 @@
</div> </div>
<aside class="lateral"> <aside class="lateral">
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" /></div> <div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" espelharSono={false} /></div>
<p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p> <p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p>
</aside> </aside>
</div> </div>
@@ -364,6 +364,14 @@
.espaco { .espaco {
flex: 1; 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 { .marcado {
color: var(--ok); color: var(--ok);
border-color: var(--ok); border-color: var(--ok);
+483
View File
@@ -0,0 +1,483 @@
<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 Slider from '../lib/componentes/Slider.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { salvarApp } from '../lib/ajustes.svelte';
import { estado } from '../lib/estado.svelte';
let previa = $state<{ ordem: (t: string) => void; legenda: (t: string) => void }>();
// Ouvir a voz de robô aqui no PC (o clique na tecla libera o áudio do navegador).
let ouvirAqui = $state(true);
let audio: AudioContext | null = null;
function voz(): AudioContext | null {
const AC = window.AudioContext || (window as any).webkitAudioContext;
if (!ouvirAqui || !AC || !(window as any).VozRobo) return null;
audio ??= new AC();
if (audio.state === 'suspended') audio.resume();
return audio;
}
const FRASE = 'Oi! Eu sou o Carinha. Quer brincar comigo?';
function testarFala() {
previa?.legenda(FRASE);
const c = voz();
if (c) (window as any).VozRobo.balbuciar(c, FRASE, 0.5);
}
// As emoções vão para o celular (e a prévia mostra junto); "só na prévia" serve para testar quieto.
let soPrevia = $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 (soPrevia) previa?.ordem(g);
else gesto(g);
const c = voz();
if (c) (window as any).VozRobo.tocar(c, g, 0.5);
}
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: 'Voz de robô', texto: 'Chilreios, “uou!”, “ohh” e um balbucio quando responde, sintetizados na hora; motorzinho quando as mãos se mexem.', 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" espelharSono={false} /></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>
<div class="linha">
<button class="tecla" onclick={testarFala}>🗣️ Testar fala</button>
</div>
<Toggle
rotulo="Ouvir a voz de robô aqui no PC"
ajuda="Sons sintetizados na hora, inspirados nos robozinhos de cinema"
checked={ouvirAqui}
onchange={(v) => (ouvirAqui = v)}
/>
<Toggle
rotulo="Testar só na prévia, sem mandar ao celular"
ajuda="Desligado, cada emoção aparece no celular na hora"
checked={soPrevia}
onchange={(v) => (soPrevia = 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-detalhes">
<h2 id="t-detalhes">Detalhes do rosto</h2>
<div class="detalhes">
<Toggle rotulo="Boca" checked={estado.appConfig?.boca !== false} disabled={!estado.appConfig} onchange={(v) => salvarApp({ boca: v })} />
<Toggle rotulo="Sobrancelhas" checked={!!estado.appConfig?.sobrancelhas} disabled={!estado.appConfig} onchange={(v) => salvarApp({ sobrancelhas: v })} />
<Toggle
rotulo="Íris"
ajuda={aceitaFormato ? 'Pupila que acompanha o olhar' : 'Vale para Viva, Bloco e Matriz'}
checked={!!estado.appConfig?.iris}
disabled={!estado.appConfig}
onchange={(v) => salvarApp({ iris: v })}
/>
</div>
<div class="detalhes">
<Slider
rotulo="Subir a boca"
valor={Number(estado.appConfig?.subirBoca ?? 0)}
min={-20}
max={60}
passo={2}
onchange={(v) => salvarApp({ subirBoca: v })}
/>
<Slider
rotulo="Distância das sobrancelhas"
valor={Number(estado.appConfig?.distSobrancelha ?? 16)}
min={0}
max={60}
passo={2}
onchange={(v) => salvarApp({ distSobrancelha: v })}
/>
</div>
</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;
}
.detalhes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
gap: var(--e-2) var(--e-6);
max-width: 780px;
}
.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>
+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 alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos', const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face', '/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', '/voz-robo.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [svelte()],