Compare commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:47:18 -03:00
julioandClaude Opus 5.5 35d443a358 feat(servidor): adiciona SSE, token, E-Stop real e trava de hardware
- comum.py: ler_json (Origin/JSON/tamanho), gravar_atomico e token
  (CARINHA_TOKEN ou seguranca.json), substituindo 6 copias nos modulos
- eventos.py: hub SSE em /eventos com heartbeat e Last-Event-ID;
  modulos publicam expressao, telemetria, conversa, reacao, ia, motores
- /estado agrega o snapshot inicial; /comando unifica gesto/rosto/cor/reacao
- /parar-tudo desliga o PCA9685, para reacoes e trava motores ate /rearmar
- braco: lease (ttl_ms) e interpolacao com limite de velocidade no servidor
- braco e diagnostico nao disputam o PCA9685 (409 para o segundo dono)
- fila limitada (20) com validade de 10 s; POST /face usa prefixo "rosto"
- /app/config passa a checar Origin e JSON; versao vem de version.json
- figurinhas automaticas do Gemini limitadas a 6 por hora
- rosto entende a ordem "calma" enviada pelo E-Stop

Token so e exigido quando configurado (docs/SEGURANCA.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:34:35 -03:00
julioandClaude Opus 5.5 6c5bd6f432 docs(gitea): aponta repositorio atual e orienta credenciais via GCM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:25:35 -03:00
125 changed files with 9983 additions and 6006 deletions
+1
View File
@@ -22,3 +22,4 @@ api-privada.json
api-privada.tmp
ia-config.json
seguranca.json
+65 -42
View File
@@ -1,60 +1,83 @@
# Carinha PC
# Carinha Control Panel
Servidor no Orange Pi Zero 3, acessado pelo PC, para controlar o Carinha no Galaxy S10 e testar servos. Dashboard: http://192.168.1.35:8100/dashboard.
Robô de mesa: o **rosto** roda num Galaxy S10 (app Android), o **servidor** num Orange Pi Zero 3
(Python, só biblioteca padrão) e o **painel** em qualquer navegador da rede. O mesmo servidor
comanda um braço de 7 articulações por um PCA9685 (I²C) e conversa por Gemini ou Qwen local.
## Desenvolvimento
Painel: **http://192.168.1.35:8100/**
Pasta criada em 04/10/2026 a pedido do usuário. Código inicial copiado do projeto anterior; o original não será modificado. Design aprovado: controlar rosto, gestos, configurações e visualizar a câmera frontal pelo PC enquanto o celular mantém a carinha na tela.
```
PC / celular (navegador) Orange Pi :8100 Galaxy S10 (app)
┌──────────────────────┐ HTTP ┌──────────────────────┐ HTTP ┌──────────────────┐
│ painel Svelte (web/) │ ───────► │ kiosk-server.py │ ◄─────── │ carinha.html │
│ 1 EventSource │ ◄─SSE─── │ /eventos /estado │ ──SSE──► │ (rosto) │
│ WebRTC ◄────────────┼──────────┼── sinalização /rtc ──┼──────────┼─► câmera │
└──────────────────────┘ │ braço · servos (I²C) │ └──────────────────┘
│ conversa · reações │
└──────────────────────┘
```
- [Design](docs/DESIGN.md)
- [Plano e verificações](docs/PLANO.md)
- [Registro de alterações](docs/REGISTRO.md)
## O painel
Quatro áreas + ajustes, todas alimentadas por **um** fluxo SSE (`/eventos`), sem polling:
| Área | O que tem |
|---|---|
| **Ao vivo** | Câmera do celular (WebRTC) e rosto lado a lado, gestos favoritos, aparência, sensores, IA local, acontecimentos |
| **Conversa** | Chat com voz, motor de IA, memória (lembranças + rotina) |
| **Reações** | Biblioteca (criar, editar, desfazer), criar com Gemini, todos os gestos, diário |
| **Movimento** | Braço com homem-morto, sequências, posturas, calibração e diagnóstico do PCA9685 |
| **Ajustes** | Celular (agrupado, com busca), IA e chave Gemini, token, sistema |
Controles que valem em qualquer tela:
- **Parar tudo** (botão vermelho, `Shift+Espaço` ou `Esc` duas vezes): desliga todos os servos,
para reações e acalma o rosto. Os motores ficam travados até **Rearmar**.
- **Ctrl+K**: paleta com gestos, rostos, cores, telas, ações e posturas.
- **1–9**: gestos favoritos (fixe com a estrela em Reações › Gestos). **g** + letra navega
(`g v` Ao vivo, `g c` Conversa, `g r` Reações, `g m` Movimento, `g a` Ajustes).
- O braço **só se move enquanto você segura** o botão ou o slider; ao soltar, os motores desligam
em menos de meio segundo. Detalhes em [docs/PAINEL.md](docs/PAINEL.md).
## Usar
1. Execute **iniciar.bat**. Verifica e abre **http://192.168.1.35:8100/dashboard**, sem iniciar servidor no PC. O serviço carinha-pc inicia automaticamente na placa.
2. No celular, segure a tela do Carinha e altere o servidor para **http://192.168.1.35:8100**. O botão ADB do antigo servidor Windows não é usado nesta instalação.
3. Troque rosto, cor e gestos. Use **Mostrar boca** e **Mostrar sobrancelhas** para ativar cada uma independentemente. Clique **Ligar câmera** para vídeo ao vivo com qualidade automática, sem trocar a tela do celular.
4. Em **Comportamento**, ajuste som, movimento, volume, sono e economia de tela. **Configurações aplicadas no celular** depende da resposta do aparelho.
1. `iniciar.bat` confere o Orange Pi e abre o painel.
2. No celular, toque longo › **Mudar o servidor** › `http://192.168.1.35:8100`.
3. Se a placa tiver token (recomendado, veja [docs/SEGURANCA.md](docs/SEGURANCA.md)):
toque longo › **Token do servidor** no celular, e o painel pede o token uma vez.
Brilho padrão: **70% ativo / 8% em repouso**, com **30 segundos** de espera. Retorna com som, movimento ou comando. O brilho do sistema é restaurado ao sair do app.
## Desenvolver
Configurações ficam em `app-config.json` e `aparencia.json`. O vídeo não é gravado: WebRTC transmite diretamente do app ao navegador. O servidor troca apenas sinalização. Logs em `runtime/`. Qualidade automática busca 30 fps, adaptando resolução e compressão. O painel mostra os fps efetivos. [Detalhes do vídeo](docs/VIDEO-AUTO.md).
```powershell
# servidor local (sem I²C ele avisa "PCA9685 indisponível")
python kiosk-server.py --porta 8100
APK: `android/carinha-rosto.apk`. Guia: [android/APP.md](android/APP.md).
# painel com recarga automática, falando com o servidor local ou com a placa
cd web; npm install
npm run dev # usa http://127.0.0.1:8100
$env:CARINHA_SERVIDOR='http://192.168.1.35:8100'; npm run dev
```
Publicar na placa: `.\deploy.ps1` (compila o painel, faz backup e reinicia o serviço).
## Verificações
```powershell
python -m unittest discover -s tests -p test_controle_pc.py
python tests/test_browser.py
python tests/test_rtc_browser.py
node tests/test_qualidade.js
python android/build-apk.py
python -m pytest tests -q --ignore-glob='*browser*' # servidor: token, SSE, E-Stop, trava, módulos
cd web; npm run check; npm test; npm run build; cd .. # tipos, lógica pura e build do painel
python tests/test_painel_web_browser.py # painel de ponta a ponta (Playwright)
python tests/test_rtc_browser.py # WebRTC real com câmera sintética
python tests/test_browser.py # rosto (carinha.html)
node tests/test_qualidade.js # qualidade automática do vídeo
python android/build-apk.py # APK (precisa de keystore e libs nativas)
```
O teste de navegador usa Playwright e um servidor efêmero próprio, sem alterar o servidor real. Testes Java em `tests/` verificam conversão de imagem e brilho; compile-os com o JDK junto das classes correspondentes. O servidor em produção exige somente Python 3.9+.
`tests/test_tracking_browser.py` precisa dos modelos (`python obter-rastreamento.py`) e das
imagens de teste em `../work/tracking-fixtures`.
**Validação física em 05/10/2026:** S10 conectado à porta 8100, telemetria de câmera/microfone ativa e JPEG recente recebido pelo PC. Configurações confirmadas pelo app e brilho ativo reportado em 70%. Ainda falta observar a orientação da imagem e a transição física do brilho para repouso.
## Documentos
**Atualização 0.3 instalada:** vídeo contínuo WebRTC automático e boca/sobrancelhas independentes. Teste com câmera simulada recebeu 30 fps. Após atualizar o APK, reabra o Carinha e recarregue o painel; os fps no S10 ainda precisam ser confirmados.
Ferramentas de compilação reutilizadas de `C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools` (JDK 17 e Android SDK). Não é necessário rodar servidor na placa.
# Atualização 0.4 — sensores e câmeras
Controle microfone e câmera separadamente, com permissões específicas durante o sono (padrão: só microfone). Escolha frontal/traseira e qualidade automática, manual ou máxima disponível no painel ou no menu de toque longo do app. Veja [detalhes e limites](docs/SENSORES-CAMERAS.md).
# Atualização 0.5 — rastreamento de rosto e mãos
O painel agora detecta rosto e mãos no PC, mostra pontos sobre o vídeo e pode fazer os olhos acompanharem o alvo por WebRTC. Os controles estão sob o vídeo. Só funciona com a transmissão ligada e painel aberto; os sensores e regras de sono continuam valendo. Veja [uso e validação](docs/RASTREAMENTO.md).
## Dashboard e organização
A navegação lateral reúne Visão geral, Câmera e rosto, Conversar, Emojis, Treinamento, Servos, Braço e API. O tema escuro/ciano e os controles são compartilhados em dashboard.css/dashboard.js; CSS próprio de cada página guarda apenas seu layout específico. No celular, o rosto continua em tela cheia.
A biblioteca /emojis permite criar emojis prontos, editar nome e contexto, substituir por emoji, duplicar, ativar/desativar, excluir, dar feedback, desfazer e exportar. Desfazer preserva o desenho original e funciona após reiniciar o serviço. Parar expressão não movimenta motores. Reações desativadas não são escolhidas automaticamente na conversa.
/api gerencia o modelo Gemini e a chave. Campos de chave ficam vazios após salvar e as respostas não retornam credenciais. Alterações são guardadas em api-privada.json (permissão 600 no Linux), ignorado pelo Git e bloqueado no servidor HTTP. A configuração antiga de ambiente continua válida enquanto não houver override.
[Revisão, critérios e validação do dashboard](docs/DASHBOARD.md).
[Painel](docs/PAINEL.md) · [Segurança](docs/SEGURANCA.md) · [Orange Pi](docs/ORANGEPI.md) ·
[Braço](docs/BRACO.md) · [Conversa](docs/CONVERSA.md) · [IA local](docs/IA-LOCAL.md) ·
[Vídeo](docs/VIDEO-AUTO.md) · [Rastreamento](docs/RASTREAMENTO.md) · [Repositório](docs/GITEA.md) ·
[Registro](docs/REGISTRO.md)
+4 -2
View File
@@ -1,14 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto"
android:versionCode="6"
android:versionName="0.6-ia-local">
android:versionCode="9"
android:versionName="0.8.1">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
<!-- precisa de rede para falar com o servidor da carinha (troca de rosto) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- só o nível do sinal do Wi-Fi, para o painel (sem localização nem nome da rede) -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- escutar o ambiente: reage a barulho (surpreso) e a silencio (dormir) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- camera: pagina de visualizacao e, depois, seguir o movimento -->
+126 -8
View File
@@ -25,7 +25,27 @@
<path id="sobrancelha2" class="traco" style="display:none"/>
</svg>
<div id="fps"></div>
<script src="rosto-motor.js"></script>
<script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
(function () {
const token = new URLSearchParams(location.search).get('t');
window.carinhaToken = token || '';
if (!token || !window.fetch) return;
const original = window.fetch.bind(window);
window.fetch = (recurso, opcoes = {}) => {
try {
const url = new URL(typeof recurso === 'string' ? recurso : recurso.url, location.href);
if (url.origin === location.origin) {
const cabecalhos = new Headers(opcoes.headers || {});
cabecalhos.set('Authorization', 'Bearer ' + token);
opcoes = Object.assign({}, opcoes, { headers: cabecalhos });
}
} catch (e) { /* URL estranha: segue sem token */ }
return original(recurso, opcoes);
};
})();
"use strict";
/* -------------------------------------------------------------------------
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
@@ -84,10 +104,17 @@ const BRANCO = [245, 250, 255];
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = {
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
};
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
let FORMATO = "padrao";
let ROSTO_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME];
@@ -361,12 +388,25 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
for (let i = 0; i < 2; i++) {
const comMotor = ROSTO.motor && window.MotorRosto && !window.carinhaOlhosIcone && !window.carinhaOlhosDesenho;
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,
parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
})),
});
} else if (window.MotorRosto) window.MotorRosto.esconder();
for (let i = 0; i < 2 && !comMotor; i++) {
const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6);
}
desenharBoca();
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
else desenharBoca();
for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
@@ -411,6 +451,9 @@ function trata(ordem) {
trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo"
} else if (ordem.indexOf("formato ") === 0) {
const f = ordem.slice(8).trim();
if (FORMATOS.includes(f)) FORMATO = f;
} else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) {
@@ -424,6 +467,9 @@ function trata(ordem) {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
} else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; }
}
@@ -460,7 +506,7 @@ window.eilik = {
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
// do PC com um curl, igual ao FIFO de antes
async function buscarOrdens() {
if (new URLSearchParams(location.search).get('espelho') === '1') return;
if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
@@ -479,12 +525,42 @@ async function aplicarAparencia() {
if (!resp.ok) return;
const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ }
}
setTimeout(aplicarAparencia, 200);
if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
if (MODO_PAINEL) {
window.carinhaAtiva = false;
addEventListener('message', (e) => {
if (e.origin !== location.origin || e.source !== parent || !e.data) return;
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
(function () {
let inicio = null;
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
addEventListener('pointerup', (e) => {
if (!inicio) return;
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
inicio = null;
if (r.dormindo) { trata('acordar'); return; }
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
if (dur < 350 && Math.hypot(dx, dy) < 15) {
animar('surpreso');
setTimeout(() => animar('feliz'), 700);
}
});
})();
// teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
@@ -818,8 +894,10 @@ async function sincronizarApp() {
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
sincronizando = true;
try {
if (!window.carinhaSSE) {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json());
}
if (par.get('aparelho') === '1') {
const agora = performance.now() / 1000;
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
@@ -830,6 +908,7 @@ async function sincronizarApp() {
centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho,
bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo,
}) });
}
@@ -965,7 +1044,7 @@ setTimeout(relatar, 1500);
if(location.protocol==='file:')return;
let seen=null,fetching=false;
async function poll(){
if(fetching||window.carinhaAtiva===false)return;fetching=true;
if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
@@ -977,11 +1056,50 @@ setTimeout(relatar, 1500);
</script>
<script src="/reacoes-render.js"></script>
</body>
</html>
<script>
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
}
/* Tempo real: um EventSource em /eventos substitui os pollings de /fila, /app/config,
/conversa/status e /treinamento/expressao. Se cair, os pollings voltam sozinhos
(window.carinhaSSE=false) até o EventSource reconectar. */
(function () {
const p = new URLSearchParams(location.search);
if (location.protocol === 'file:' || p.get('espelho') === '1' || !window.EventSource) return;
const aparelho = p.get('aparelho') === '1';
const t = window.carinhaToken;
const fonte = new EventSource('/eventos' + (t ? '?t=' + encodeURIComponent(t) : ''));
let ultimaConversa = null, ultimaReacao = null, primeira = true;
const ouvir = (tipo, fn) => fonte.addEventListener(tipo, (e) => { try { fn(JSON.parse(e.data)); } catch (err) {} });
fonte.addEventListener('pronto', () => {
// Ordens que chegaram antes da conexão ficam na fila: executa uma vez e passa ao SSE.
if (primeira) { primeira = false; buscarOrdens(); }
window.carinhaSSE = true;
// Foto dos ajustes a cada (re)conexão; depois só mudanças chegam por 'app_config'.
fetch('/app/config', { cache: 'no-store' }).then((r) => r.ok && r.json()).then((c) => c && configurarApp(c)).catch(() => {});
});
fonte.onerror = () => {
if (!window.carinhaSSE) return;
window.carinhaSSE = false;
// Volta ao polling sem repetir o que o SSE já entregou.
fetch('fila' + (aparelho ? '?aparelho=1' : ''), { cache: 'no-store' }).catch(() => {});
};
ouvir('expressao', (d) => trata(d.ordem));
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
ouvir('app_config', (c) => configurarApp(c));
ouvir('conversa', (s) => {
const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id;
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
});
ouvir('reacao', (d) => {
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
ultimaReacao = d.id;
if (window.eilik && window.eilik.receita) window.eilik.receita(d.receita);
});
})();
</script>
</body>
</html>
+223
View File
@@ -0,0 +1,223 @@
/* 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();
}
// 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 fecha = Math.min(1, Math.max(e.top, 1 - Math.min(1, p.ab)));
const abre = Math.max(1, p.ab); // surpreso abre além de 1
const amassa = fecha > 0.5 && e.top < 0.5 ? (fecha - 0.5) * 0.2 : 0; // piscada achata e alarga
const w = est.w * p.ry * respira * (1 + amassa);
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
const x = p.cx - w / 2, y = p.cy - h / 2;
c.fillStyle = css(o.cor);
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
caixa(c, x, y, w, h, est.r * p.ry * respira);
c.fill();
c.save();
caixa(c, x - 2, y - 2, w + 4, h + 4, est.r * p.ry);
c.clip();
c.globalCompositeOperation = 'destination-out';
c.fillStyle = '#000';
const sl = p.lado > 0 ? e.slant : -e.slant; // lado +1 = olho esquerdo; canto interno = lado do nariz
const yEsq = y + h * (fecha + sl * 0.5), yDir = y + h * (fecha - sl * 0.5);
c.beginPath(); c.moveTo(x - 4, y - 4); c.lineTo(x + w + 4, y - 4); c.lineTo(x + w + 4, yDir); c.lineTo(x - 4, yEsq); c.closePath(); c.fill();
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over';
if (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(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);
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]));
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(o.cor, 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) return desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
if (estilo.lente) { olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); return; }
cctx.clearRect(0, 0, camada.width, camada.height);
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
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();
}
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = { desenhar, esconder, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
})();
@@ -10,6 +10,8 @@ import java.security.MessageDigest;
public final class CerebroLocal {
private volatile boolean fechado;
private Process processo;
/** Com token, o llama-server (aberto na rede para a placa) exige a mesma chave. */
public volatile String chave = "";
public void iniciar(final Context contexto, final String servidor) {
new Thread(() -> {
try {
@@ -43,7 +45,9 @@ public final class CerebroLocal {
try { if (health.getResponseCode() == 200) { Log.i("CarinhaIA", "Executor existente pronto"); return; } }
finally { health.disconnect(); }
} catch (IOException ignored) { }
ProcessBuilder builder = new ProcessBuilder(executavel.getAbsolutePath(), "-m", modelo.getAbsolutePath(), "--host", "0.0.0.0", "--port", "8101", "-c", "1024", "-t", "2", "-np", "1", "--jinja");
java.util.List<String> comando = new java.util.ArrayList<>(java.util.Arrays.asList(executavel.getAbsolutePath(), "-m", modelo.getAbsolutePath(), "--host", "0.0.0.0", "--port", "8101", "-c", "1024", "-t", "2", "-np", "1", "--jinja"));
if (chave != null && !chave.isEmpty()) { comando.add("--api-key"); comando.add(chave); }
ProcessBuilder builder = new ProcessBuilder(comando);
builder.environment().put("LD_LIBRARY_PATH", libs.getAbsolutePath());
builder.environment().put("GGML_BACKEND_DL_PATH", libs.getAbsolutePath());
builder.redirectErrorStream(true);
@@ -46,6 +46,9 @@ public final class ControleRemoto {
public void sinalRTC(String json) {
if (sinais.size() < 64) sinais.offer(json);
}
private volatile String token = "";
/** Token do servidor (CARINHA_TOKEN); vai como Authorization em todo pedido. */
public void token(String valor) { token = valor == null ? "" : valor.trim(); }
public void servidor(String valor) {
servidor = valor;
pendente = null;
@@ -140,6 +143,7 @@ public final class ControleRemoto {
HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
c.setConnectTimeout(1500); c.setReadTimeout(1500);
c.setUseCaches(false);
if (!token.isEmpty()) c.setRequestProperty("Authorization", "Bearer " + token);
return c;
}
public void quadro(Image image, int rotacao) {
@@ -8,6 +8,7 @@ import android.content.Intent;
import android.content.IntentFilter;
import android.os.BatteryManager;
import android.content.SharedPreferences;
import android.net.Uri;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.hardware.camera2.CameraAccessException;
@@ -83,6 +84,7 @@ public class MainActivity extends Activity {
private static final String PREFS = "carinha";
private static final String CHAVE = "servidor";
private static final String CHAVE_TOKEN = "token";
private static final String PADRAO = "http://192.168.1.15:8100";
private static final String TAG = "CarinhaMic";
@@ -100,6 +102,7 @@ public class MainActivity extends Activity {
private WebView web;
private String servidor;
private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto
private final CerebroLocal cerebroLocal = new CerebroLocal();
private boolean jaCaiuParaLocal = false;
private boolean jaRecarregou = false;
@@ -179,8 +182,10 @@ public class MainActivity extends Activity {
SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
servidor = p.getString(CHAVE, PADRAO);
token = p.getString(CHAVE_TOKEN, "");
movimentoLigado = p.getBoolean(CHAVE_MOV, true);
usarServidorDoPedido(getIntent()); // permite apontar o servidor ao lancar
cerebroLocal.chave = token;
cerebroLocal.iniciar(this, servidor);
remoto = new ControleRemoto(new Runnable() {
@Override public void run() {
@@ -272,7 +277,7 @@ public class MainActivity extends Activity {
runOnUiThread(new Runnable() {
@Override
public void run() {
boolean pode = true;
boolean pode = origemConfiavel(pedido.getOrigin());
for (String recurso : pedido.getResources()) {
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(recurso)
&& checkSelfPermission(Manifest.permission.CAMERA)
@@ -348,6 +353,15 @@ public class MainActivity extends Activity {
if (it == null) return;
String novo = it.getStringExtra("servidor");
if (novo == null) return;
String tokenPedido = it.getStringExtra("token");
if (!token.isEmpty() && !token.equals(tokenPedido)) {
Log.w(TAG, "pedido para trocar o servidor sem o token certo: ignorado");
return;
}
if (token.isEmpty() && tokenPedido != null && !tokenPedido.trim().isEmpty()) {
token = tokenPedido.trim();
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE_TOKEN, token).apply();
}
novo = novo.trim();
while (novo.endsWith("/")) novo = novo.substring(0, novo.length() - 1);
if (novo.length() == 0) return;
@@ -374,8 +388,9 @@ public class MainActivity extends Activity {
private void abrirRosto() {
naCamera = false;
remoto.servidor(servidor);
remoto.token(token);
if (usandoLocal) web.loadUrl("file:///android_asset/carinha.html");
else web.loadUrl(servidor + "/carinha.html?aparelho=1");
else web.loadUrl(servidor + "/carinha.html?aparelho=1" + (token.isEmpty() ? "" : "&t=" + Uri.encode(token)));
iniciarMovimento(); // volta a observar quem passa na frente
}
@@ -397,7 +412,7 @@ public class MainActivity extends Activity {
private void menu() {
final String[] opcoes = {"Ver a camera", "Voltar para a carinha",
(movimentoLigado ? "Desligar reacao ao movimento" : "Ligar reacao ao movimento"),
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video"};
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video", "Token do servidor"};
new AlertDialog.Builder(this)
.setTitle("Carinha")
.setItems(opcoes, new DialogInterface.OnClickListener() {
@@ -408,7 +423,8 @@ public class MainActivity extends Activity {
else if (i == 2) alternarMovimento();
else if (i == 3) pedirServidor();
else if (i == 4) menuSensores();
else menuVideo();
else if (i == 5) menuVideo();
else pedirToken();
}
})
.show();
@@ -520,6 +536,37 @@ public class MainActivity extends Activity {
.show();
}
/** Token do servidor (CARINHA_TOKEN da placa). Vazio = servidor sem autenticacao. */
private void pedirToken() {
final EditText campo = new EditText(this);
campo.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
campo.setText(token);
new AlertDialog.Builder(this)
.setTitle("Token do servidor")
.setMessage("O mesmo CARINHA_TOKEN configurado na placa")
.setView(campo)
.setPositiveButton("Salvar", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface d, int w) {
token = campo.getText().toString().trim();
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE_TOKEN, token).apply();
cerebroLocal.chave = token;
aviso(token.isEmpty() ? "token removido" : "token salvo");
abrirRosto();
}
})
.setNegativeButton("Cancelar", null)
.show();
}
/** So o servidor configurado (ou a copia local do APK) pode usar camera/microfone. */
private boolean origemConfiavel(Uri origem) {
if (origem == null) return false;
String o = origem.toString();
while (o.endsWith("/")) o = o.substring(0, o.length() - 1);
return o.startsWith("file:") || o.equals(servidor);
}
/** Voltar nao sai do app (num enfeite de mesa isso seria chato): volta a carinha. */
@Override
public void onBackPressed() {
@@ -974,8 +1021,53 @@ public class MainActivity extends Activity {
return String.format(java.util.Locale.US, "%.3f", v);
}
private String aparelhoJson = "null";
private long aparelhoEm = 0;
/** IP, modelo, Android, versão do app e sinal do Wi-Fi; recalculado a cada 10 s. */
private String infoAparelho() {
long agora = System.currentTimeMillis();
if (agora - aparelhoEm < 10000) return aparelhoJson;
aparelhoEm = agora;
try {
JSONObject a = new JSONObject();
a.put("ip", ipLocal());
a.put("modelo", (Build.MANUFACTURER + " " + Build.MODEL).trim());
a.put("android", Build.VERSION.RELEASE);
try { a.put("app", getPackageManager().getPackageInfo(getPackageName(), 0).versionName); }
catch (Exception e) { a.put("app", JSONObject.NULL); }
try {
android.net.wifi.WifiManager wifi = (android.net.wifi.WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
android.net.wifi.WifiInfo info = wifi == null ? null : wifi.getConnectionInfo();
// Sem localização o Android 10+ esconde o ID da rede, mas o RSSI continua valendo.
if (info != null && info.getRssi() > -127 && info.getRssi() < 0) {
a.put("wifi", android.net.wifi.WifiManager.calculateSignalLevel(info.getRssi(), 5));
a.put("rssi", info.getRssi());
}
} catch (Exception e) { /* sem permissão ou sem Wi-Fi */ }
aparelhoJson = a.toString();
} catch (Exception e) {
aparelhoJson = "null";
}
return aparelhoJson;
}
/** Primeiro IPv4 privado da rede (Wi-Fi), sem precisar de permissão extra. */
private static String ipLocal() {
try {
for (java.net.NetworkInterface n : java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) {
if (!n.isUp() || n.isLoopback()) continue;
for (java.net.InetAddress e : java.util.Collections.list(n.getInetAddresses())) {
if (e instanceof java.net.Inet4Address && e.isSiteLocalAddress()) return e.getHostAddress();
}
}
} catch (Exception e) { /* sem rede */ }
return null;
}
private void atualizarBrilho() {
if (naCamera) return;
web.evaluateJavascript("window.carinhaAparelho=" + infoAparelho(), null);
Intent bateria = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
if (bateria != null) {
int level = bateria.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
+1 -1
View File
@@ -30,7 +30,7 @@ def main():
def run(*command):
subprocess.run([str(x) for x in command], check=True)
for name in ('carinha.html', 'camera.html'):
for name in ('carinha.html', 'camera.html', 'rosto-motor.js'):
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
tmp = Path(folder)
-1
View File
@@ -1 +0,0 @@
(()=>{const el=id=>document.getElementById(id);let busy=false;async function call(path,data){const r=await fetch('/api/configuracao/'+path,{cache:'no-store',signal:AbortSignal.timeout(55000),...(data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})});const d=await r.json();if(!r.ok)throw Error(d.erro);return d;}function lock(v){busy=v;el('apiSave').disabled=v;el('apiTest').disabled=v;}async function load(){try{const s=await call('status');el('apiModel').value=s.modelo;el('apiBadge').textContent=s.configurada?'Chave configurada':'Chave não configurada';el('apiBadge').classList.toggle('ativo',s.configurada);if(s.ultimo_teste)el('apiState').textContent=s.ultimo_teste.mensagem+(s.ultimo_teste.segundos?' ('+s.ultimo_teste.segundos+' s)':'');}catch(e){el('apiState').textContent=e.message;}}el('apiServer').textContent=location.origin;el('apiForm').onsubmit=async e=>{e.preventDefault();if(busy)return;lock(true);try{const d={modelo:el('apiModel').value.trim()};if(el('apiKey').value.trim())d.chave=el('apiKey').value.trim();await call('salvar',d);el('apiKey').value='';await load();el('apiState').textContent='Configuração salva. As próximas chamadas usarão este modelo.';}catch(e){el('apiState').textContent=e.message;}finally{lock(false);}};el('apiTest').onclick=async()=>{if(busy)return;lock(true);el('apiState').textContent='Testando o modelo salvo…';try{const s=await call('testar',{});el('apiState').textContent=s.mensagem+(s.segundos?' ('+s.segundos+' s)':'');}catch(e){el('apiState').textContent=e.message;}finally{lock(false);}};load();})();
-117
View File
@@ -1,117 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API Gemini & Conexão · Carinha</title>
<link rel="stylesheet" href="/dashboard.css">
<script src="/dashboard.js" defer></script>
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
<div>
<h1>API Gemini & Conexão IA</h1>
<p style="margin:4px 0 0">Configure o modelo do Google Gemini que dá inteligência ao diálogo e cria novas expressões.</p>
</div>
<div>
<span class="badge" id="apiBadge">Verificando…</span>
</div>
</div>
<div class="split" style="margin-top:16px">
<!-- Formulário do Gemini -->
<section class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
Google Gemini Cloud
</h2>
<span class="badge" style="font-size:11px">Seguro · api-privada.json</span>
</div>
<form id="apiForm">
<!-- Model Presets -->
<label for="apiModelSelect">Modelos recomendados:</label>
<select id="apiModelSelect" style="margin-bottom:12px">
<option value="gemini-2.5-flash">gemini-2.5-flash · Rápido, expressivo & moderno (Recomendado)</option>
<option value="gemini-1.5-flash">gemini-1.5-flash · Alta velocidade e respostas curtas</option>
<option value="gemini-2.0-flash">gemini-2.0-flash · Nova arquitetura multimodal</option>
<option value="gemini-1.5-pro">gemini-1.5-pro · Raciocínio profundo e criatividade</option>
<option value="custom">Outro (digitar manualmente abaixo)</option>
</select>
<label for="apiModel">Identificador do Modelo</label>
<input id="apiModel" required maxlength="100" placeholder="gemini-flash-lite-latest">
<label for="apiKey">Chave de API Gemini (Google AI Studio)</label>
<input id="apiKey" type="password" autocomplete="new-password" maxlength="250" placeholder="Insira nova chave ou deixe vazio para manter a atual">
<p style="font-size:12.5px;color:var(--fraco);margin-top:-6px">
🔒 Por segurança, o campo de chave nunca exibe o valor salvo no servidor. Ele é mantido exclusivamente no arquivo privado local do Orange Pi.
</p>
<div class="actions" style="margin-top:20px">
<button class="primary" id="apiSave" type="submit">💾 Salvar Configuração</button>
<button type="button" id="apiTest">⚡ Testar Conexão Agora</button>
</div>
</form>
<p id="apiState" role="status" aria-live="polite" style="margin-top:16px;font-size:13.5px;color:var(--ciano);min-height:24px"></p>
</section>
<!-- Instruções e Informações do Sistema -->
<section class="card" style="display:flex;flex-direction:column;justify-content:space-between">
<div>
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
Arquitetura Híbrida de IA
</h2>
<p>O Carinha combina processamento em nuvem com inteligência embarcada no robô:</p>
<div style="display:flex;flex-direction:column;gap:12px;margin:16px 0">
<div style="background:rgba(12,22,36,0.6);border:1px solid var(--borda);border-radius:var(--raio-md);padding:12px">
<strong style="color:var(--ciano);display:block;margin-bottom:4px">☁️ Google Gemini (Nuvem)</strong>
<small style="color:var(--texto-medio);font-size:13px">Gera as respostas de conversação natural, recupera lembranças e desenha novas figurinhas SVG em tempo real.</small>
</div>
<div style="background:rgba(12,22,36,0.6);border:1px solid var(--borda);border-radius:var(--raio-md);padding:12px">
<strong style="color:#34d399;display:block;margin-bottom:4px">📱 Qwen / Local (Galaxy S10)</strong>
<small style="color:var(--texto-medio);font-size:13px">Disponível no app do celular para quando estiver offline, garantindo que o Carinha continue reagindo aos sensores.</small>
</div>
</div>
<p style="font-size:13px">Você pode alternar entre os motores a qualquer momento na tela <a href="/conversa">Conversar</a>.</p>
</div>
<details style="margin-top:16px">
<summary>Informações do Servidor Local</summary>
<p id="apiServer" style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--ciano);margin-top:8px"></p>
<p style="font-size:12px;color:var(--fraco)">Orange Pi Zero 3 operando na rede interna.</p>
</details>
</section>
</div>
<script src="/api-painel.js" defer></script>
<script>
// Sincroniza o select de modelos com o input de modelo
document.addEventListener('DOMContentLoaded', () => {
const sel = document.getElementById('apiModelSelect');
const input = document.getElementById('apiModel');
if (sel && input) {
sel.onchange = () => {
if (sel.value !== 'custom') {
input.value = sel.value;
}
};
input.addEventListener('input', () => {
let matched = false;
for (let opt of sel.options) {
if (opt.value === input.value) { opt.selected = true; matched = true; break; }
}
if (!matched) sel.value = 'custom';
});
}
});
</script>
</body>
</html>
+3 -8
View File
@@ -2,6 +2,7 @@
import json,os,re,threading,time
from pathlib import Path
import gemini
from comum import gravar_atomico, ler_json
class ConfigAPI:
def __init__(self,path):
@@ -25,9 +26,7 @@ class ConfigAPI:
key=d.get('chave') or os.environ.get('GEMINI_API_KEY','')
saved=dict(modelo=d['modelo'])
if key:saved['chave']=key
tmp=self.path.with_suffix('.tmp');fd=os.open(str(tmp),os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
with os.fdopen(fd,'w',encoding='utf-8') as f:json.dump(saved,f)
os.chmod(tmp,0o600);tmp.replace(self.path);os.chmod(self.path,0o600)
gravar_atomico(self.path,saved,privado=True)
gemini.MODELO=d['modelo']
if key:os.environ['GEMINI_API_KEY']=key
self.ultimo_teste=None
@@ -47,11 +46,7 @@ class ConfigAPI:
try:
if method=='GET' and route.endswith('/status'):result=self.status()
elif method=='POST':
origin=h.headers.get('Origin')
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
size=int(h.headers.get('Content-Length',0))
if not 0<size<=2048 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 2048 bytes')
d=json.loads(h.rfile.read(size))
d=ler_json(h,2048)
if route.endswith('/salvar'):result=self.salvar(d)
elif route.endswith('/testar'):result=self.testar()
else:raise ValueError('Rota inválida')
-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.');}};
+73 -18
View File
@@ -3,11 +3,25 @@ import copy
import json
import math
from pathlib import Path
import threading
import time
from comum import Conflito, gravar_atomico, ler_json
from servos import integer
NAMES=['Garra','Punho 1','Punho 2','Braço superior','Braço inferior','Antebraço','Base']
CHANNELS=[0,1,2,3,4,5,7]
LEASE_PADRAO=2000 # ms; o painel novo manda prazos curtos e renova enquanto o botão está pressionado
PASSO=.02 # 50 Hz de interpolação no servidor
def velocidade_valida(v):
if v is None: return None
if isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) or not 5<=v<=360:
raise ValueError('Velocidade deve ficar entre 5 e 360 graus/s')
return float(v)
def prazo_valido(ms):
if ms is None: return LEASE_PADRAO
return integer(ms,100,2000)
class Braco:
def __init__(self,path,servos):
@@ -15,6 +29,8 @@ class Braco:
self.config=dict(calibracao=[dict(canal=i,minimo=1400,maximo=1600) for i in range(8)],posturas=[])
try: self.config=self.validate(json.loads(self.path.read_text(encoding='utf-8')))
except (OSError,ValueError,TypeError): pass
self.atual=None; self.alvo=None; self.velocidade=None; self.lease=0
threading.Thread(target=self.loop,daemon=True).start()
def validate_pose(self,pose,calibration):
if not isinstance(pose,list) or len(pose)!=7: raise ValueError('Esperava sete ângulos')
pulses={}
@@ -53,27 +69,70 @@ class Braco:
with self.servos.lock:
result=copy.deepcopy(self.config)
result.update(disponivel=self.servos.hw is not None,erro=self.servos.error,
dono=self.servos.dono,travado=self.servos.travado,atual=self.atual,
movendo=self.alvo is not None,
articulacoes=[dict(nome=name,canal=ch) for name,ch in zip(NAMES,CHANNELS)])
return result
def save(self,data):
clean=self.validate(data)
with self.servos.lock:
if self.servos.active: raise ValueError('Pare os motores antes de salvar')
tmp=self.path.with_suffix('.tmp'); tmp.write_text(json.dumps(clean,ensure_ascii=False,indent=2),encoding='utf-8'); tmp.replace(self.path)
gravar_atomico(self.path,clean)
self.config=clean
return self.status()
def move(self,pose):
with self.servos.lock:
pulses=self.validate_pose(pose,self.config['calibracao'])
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
def escrever(self,pulses,prazo):
try:
for channel,pulse in pulses.items():
self.servos.hw.pulse(channel,pulse)
self.servos.active[channel]=time.monotonic()+2
self.servos.ligar([(channel,prazo) for channel in pulses])
except OSError:
self.servos.stop()
self.alvo=None; self.servos.stop()
raise
return dict(ok=True,pulsos=pulses)
def move(self,pose,velocidade=None,ttl_ms=None):
"""Sem velocidade: vai direto (compatível). Com velocidade: o servidor interpola até o alvo.
Em ambos os casos os motores desligam sozinhos quando o prazo (ttl_ms) vence sem renovação."""
velocidade=velocidade_valida(velocidade); ttl=prazo_valido(ttl_ms)
with self.servos.lock:
pulses=self.validate_pose(pose,self.config['calibracao'])
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
self.servos.reservar('braco')
prazo=time.monotonic()+ttl/1000
if velocidade is None or self.atual is None:
self.alvo=None
self.escrever(pulses,prazo)
self.atual=[float(a) for a in pose]
return dict(ok=True,pulsos=pulses,movendo=False)
alvo=[float(a) for a in pose]
if all(abs(a-t)<.01 for a,t in zip(self.atual,alvo)):
# Já no alvo: só renova o prazo (e reenergiza canais que tenham desligado).
self.alvo=None
self.escrever(pulses,prazo)
return dict(ok=True,pulsos=pulses,movendo=False)
self.alvo=alvo; self.velocidade=velocidade; self.lease=prazo
for channel in list(self.servos.active): self.servos.active[channel]=prazo
return dict(ok=True,pulsos=pulses,movendo=True)
def cancelar(self):
with self.servos.lock: self.alvo=None
def passo(self,agora,dt):
with self.servos.lock:
if self.alvo is None: return
if agora>=self.lease or self.servos.travado or (self.servos.dono!='braco' and self.servos.active):
self.alvo=None; return
delta=self.velocidade*dt
nova=[a+max(-delta,min(delta,t-a)) for a,t in zip(self.atual,self.alvo)]
try: pulses=self.validate_pose(nova,self.config['calibracao'])
except ValueError:
self.alvo=None; return
self.escrever(pulses,self.lease)
self.atual=nova
if nova==self.alvo: self.alvo=None
def loop(self):
anterior=time.monotonic()
while not self.servos.closed.wait(PASSO):
agora=time.monotonic()
try: self.passo(agora,agora-anterior)
except OSError as e: self.servos.error=str(e)
anterior=agora
@staticmethod
def angle_pulse(angle,center):
return round(500+angle*(center-500)/90 if angle<=90 else center+(angle-90)*(2500-center)/90)
@@ -86,9 +145,10 @@ class Braco:
outputs[other]=integer(center_sum-pulse,500,2500)
with self.servos.lock:
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
self.servos.reservar('braco'); self.alvo=None; self.atual=None
for ch,us in outputs.items():
self.servos.hw.pulse(ch,us)
self.servos.active[ch]=time.monotonic()+2
self.servos.ligar([(ch,time.monotonic()+2) for ch in outputs])
return dict(ok=True,pulsos=outputs)
def rotas(self,h,method):
route=h.path.split('?',1)[0]
@@ -96,21 +156,16 @@ class Braco:
try:
if method=='GET' and route=='/braco/status': result=self.status()
elif method=='POST':
origin=h.headers.get('Origin')
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')): raise ValueError('Origem inválida')
if h.headers.get('Content-Type','').split(';')[0]!='application/json': raise ValueError('Use JSON')
size=int(h.headers.get('Content-Length','0'))
if not 0<size<=65536: raise ValueError('Tamanho inválido')
data=json.loads(h.rfile.read(size))
if not isinstance(data,dict): raise ValueError('Objeto obrigatório')
data=ler_json(h,65536)
if route=='/braco/config': result=self.save(data)
elif route=='/braco/mover': result=self.move(data.get('angulos'))
elif route=='/braco/mover': result=self.move(data.get('angulos'),data.get('velocidade'),data.get('ttl_ms'))
elif route=='/braco/calibrar': result=self.calibrate(data.get('canal'),data.get('pulso'))
elif route=='/braco/parar': result=self.servos.stop()
elif route=='/braco/parar': self.cancelar(); result=self.servos.stop()
else: raise ValueError('Rota inválida')
else:
h.enviar(json.dumps(dict(erro='Método inválido')),codigo=405); return True
h.enviar(json.dumps(result,ensure_ascii=False))
except Conflito as e: h.enviar(json.dumps(dict(erro=str(e)),ensure_ascii=False),codigo=409)
except (ValueError,TypeError) as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=400)
except OSError as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=503)
return True
+126 -8
View File
@@ -25,7 +25,27 @@
<path id="sobrancelha2" class="traco" style="display:none"/>
</svg>
<div id="fps"></div>
<script src="rosto-motor.js"></script>
<script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
(function () {
const token = new URLSearchParams(location.search).get('t');
window.carinhaToken = token || '';
if (!token || !window.fetch) return;
const original = window.fetch.bind(window);
window.fetch = (recurso, opcoes = {}) => {
try {
const url = new URL(typeof recurso === 'string' ? recurso : recurso.url, location.href);
if (url.origin === location.origin) {
const cabecalhos = new Headers(opcoes.headers || {});
cabecalhos.set('Authorization', 'Bearer ' + token);
opcoes = Object.assign({}, opcoes, { headers: cabecalhos });
}
} catch (e) { /* URL estranha: segue sem token */ }
return original(recurso, opcoes);
};
})();
"use strict";
/* -------------------------------------------------------------------------
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
@@ -84,10 +104,17 @@ const BRANCO = [245, 250, 255];
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = {
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
};
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
let FORMATO = "padrao";
let ROSTO_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME];
@@ -361,12 +388,25 @@ function desenhar() {
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
for (let i = 0; i < 2; i++) {
const comMotor = ROSTO.motor && window.MotorRosto && !window.carinhaOlhosIcone && !window.carinhaOlhosDesenho;
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,
parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
})),
});
} else if (window.MotorRosto) window.MotorRosto.esconder();
for (let i = 0; i < 2 && !comMotor; i++) {
const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6);
}
desenharBoca();
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
else desenharBoca();
for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
@@ -411,6 +451,9 @@ function trata(ordem) {
trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo"
} else if (ordem.indexOf("formato ") === 0) {
const f = ordem.slice(8).trim();
if (FORMATOS.includes(f)) FORMATO = f;
} else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) {
@@ -424,6 +467,9 @@ function trata(ordem) {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
} else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; }
}
@@ -460,7 +506,7 @@ window.eilik = {
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
// do PC com um curl, igual ao FIFO de antes
async function buscarOrdens() {
if (new URLSearchParams(location.search).get('espelho') === '1') return;
if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
@@ -479,12 +525,42 @@ async function aplicarAparencia() {
if (!resp.ok) return;
const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ }
}
setTimeout(aplicarAparencia, 200);
if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
if (MODO_PAINEL) {
window.carinhaAtiva = false;
addEventListener('message', (e) => {
if (e.origin !== location.origin || e.source !== parent || !e.data) return;
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
(function () {
let inicio = null;
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
addEventListener('pointerup', (e) => {
if (!inicio) return;
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
inicio = null;
if (r.dormindo) { trata('acordar'); return; }
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
if (dur < 350 && Math.hypot(dx, dy) < 15) {
animar('surpreso');
setTimeout(() => animar('feliz'), 700);
}
});
})();
// teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
@@ -818,8 +894,10 @@ async function sincronizarApp() {
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
sincronizando = true;
try {
if (!window.carinhaSSE) {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json());
}
if (par.get('aparelho') === '1') {
const agora = performance.now() / 1000;
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
@@ -830,6 +908,7 @@ async function sincronizarApp() {
centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho,
bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo,
}) });
}
@@ -965,7 +1044,7 @@ setTimeout(relatar, 1500);
if(location.protocol==='file:')return;
let seen=null,fetching=false;
async function poll(){
if(fetching||window.carinhaAtiva===false)return;fetching=true;
if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
@@ -977,11 +1056,50 @@ setTimeout(relatar, 1500);
</script>
<script src="/reacoes-render.js"></script>
</body>
</html>
<script>
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
}
/* Tempo real: um EventSource em /eventos substitui os pollings de /fila, /app/config,
/conversa/status e /treinamento/expressao. Se cair, os pollings voltam sozinhos
(window.carinhaSSE=false) até o EventSource reconectar. */
(function () {
const p = new URLSearchParams(location.search);
if (location.protocol === 'file:' || p.get('espelho') === '1' || !window.EventSource) return;
const aparelho = p.get('aparelho') === '1';
const t = window.carinhaToken;
const fonte = new EventSource('/eventos' + (t ? '?t=' + encodeURIComponent(t) : ''));
let ultimaConversa = null, ultimaReacao = null, primeira = true;
const ouvir = (tipo, fn) => fonte.addEventListener(tipo, (e) => { try { fn(JSON.parse(e.data)); } catch (err) {} });
fonte.addEventListener('pronto', () => {
// Ordens que chegaram antes da conexão ficam na fila: executa uma vez e passa ao SSE.
if (primeira) { primeira = false; buscarOrdens(); }
window.carinhaSSE = true;
// Foto dos ajustes a cada (re)conexão; depois só mudanças chegam por 'app_config'.
fetch('/app/config', { cache: 'no-store' }).then((r) => r.ok && r.json()).then((c) => c && configurarApp(c)).catch(() => {});
});
fonte.onerror = () => {
if (!window.carinhaSSE) return;
window.carinhaSSE = false;
// Volta ao polling sem repetir o que o SSE já entregou.
fetch('fila' + (aparelho ? '?aparelho=1' : ''), { cache: 'no-store' }).catch(() => {});
};
ouvir('expressao', (d) => trata(d.ordem));
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
ouvir('app_config', (c) => configurarApp(c));
ouvir('conversa', (s) => {
const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id;
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
});
ouvir('reacao', (d) => {
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
ultimaReacao = d.id;
if (window.eilik && window.eilik.receita) window.eilik.receita(d.receita);
});
})();
</script>
</body>
</html>
+14 -10
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import threading
import time
from urllib.request import Request,urlopen
from comum import gravar_atomico, ler_json
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo')
EVENTOS=('som','movimento','saudacao','sozinho')
@@ -17,18 +18,26 @@ class Cerebro:
def __init__(self,path,controle,enviar):
self.path=Path(path);self.controle=controle;self.enviar=enviar
self.endpoint=os.environ.get('CARINHA_IA_URL','http://127.0.0.1:8101')
# O llama-server do S10 fica na rede: com token, ele exige a mesma chave (--api-key).
self.chave=os.environ.get('CARINHA_IA_CHAVE') or os.environ.get('CARINHA_TOKEN','')
self.executor='S10' if '192.168.1.16' in self.endpoint else 'Orange Pi'
self.lock=threading.Lock();self.ativo=False;self.pending=None;self.busy=False
self.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
self.publicar=lambda tipo,dados:None
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
except (OSError,ValueError,KeyError,TypeError):pass
threading.Thread(target=self.loop,daemon=True).start()
def cabecalhos(self):
h={'Content-Type':'application/json'}
if self.chave:h['Authorization']='Bearer '+self.chave
return h
def config(self,ativo):
if type(ativo) is not bool:raise ValueError('ativo deve ser booleano')
with self.lock:
tmp=self.path.with_suffix('.tmp');tmp.write_text(json.dumps(dict(ativo=ativo)));tmp.replace(self.path)
gravar_atomico(self.path,dict(ativo=ativo))
self.ativo=ativo;self.generation+=1;self.pending=None
return self.status()
status=self.status();self.publicar('ia',status)
return status
def status(self):
with self.lock:return dict(ativo=self.ativo,ocupado=self.busy,modelo='Qwen3-0.6B Q4_K_M',executor=self.executor,ultima=self.result,erro=self.error)
def event(self,event):
@@ -40,7 +49,7 @@ class Cerebro:
return dict(ok=True)
def decide(self,event):
payload=dict(model='local',messages=[dict(role='system',content='Você é Carinha, um pequeno robô curioso, gentil e expressivo, inspirado em WALL-E. Escolha uma expressão adequada ao evento. Varie os gestos recentes. Responda somente JSON.'),dict(role='user',content=json.dumps(dict(evento=event,recentes=self.history[-3:]),ensure_ascii=False)+' /no_think')],max_tokens=32,temperature=.7,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='intencao',strict=True,schema=dict(type='object',properties=dict(gesto=dict(type='string',enum=list(GESTOS))),required=['gesto'],additionalProperties=False))))
request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers={'Content-Type':'application/json'})
request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cabecalhos())
start=time.monotonic()
with urlopen(request,timeout=90) as response:data=json.load(response)
content=data['choices'][0]['message']['content']
@@ -77,6 +86,7 @@ class Cerebro:
finally:
self.last=time.monotonic()
with self.lock:self.busy=False
self.publicar('ia',self.status())
def rotas(self,h,method):
route=h.path.split('?',1)[0]
if route=='/ia/modelo' and method=='GET':
@@ -95,13 +105,7 @@ class Cerebro:
try:
if method=='GET' and route=='/ia/status':result=self.status()
elif method=='POST':
origin=h.headers.get('Origin')
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
if h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON')
size=int(h.headers.get('Content-Length','0'))
if not 0<size<=2048:raise ValueError('Tamanho inválido')
data=json.loads(h.rfile.read(size))
if not isinstance(data,dict):raise ValueError('Objeto obrigatório')
data=ler_json(h,2048)
if route=='/ia/config':result=self.config(data.get('ativo'))
elif route=='/ia/evento':result=self.event(data.get('evento'))
else:raise ValueError('Rota inválida')
+76
View File
@@ -0,0 +1,76 @@
"""Peças compartilhadas pelos módulos HTTP: leitura de JSON, gravação atômica e token."""
import hmac
import json
import os
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
class Conflito(Exception):
"""Pedido válido, mas o estado atual não permite (vira HTTP 409)."""
def origem_valida(h):
origin = h.headers.get('Origin')
host = h.headers.get('Host', '')
return not origin or origin in ('http://' + host, 'https://' + host)
def ler_json(h, limite):
"""Corpo JSON de um POST do painel; ValueError com mensagem legível se inválido."""
if not origem_valida(h):
raise ValueError('Origem inválida')
if h.headers.get('Content-Type', '').split(';')[0] != 'application/json':
raise ValueError('Use JSON')
tamanho = int(h.headers.get('Content-Length', '0'))
if not 0 < tamanho <= limite:
raise ValueError(f'Corpo vazio ou maior que {limite} bytes')
dados = json.loads(h.rfile.read(tamanho))
if not isinstance(dados, dict):
raise ValueError('Objeto JSON obrigatório')
return dados
def gravar_atomico(caminho, obj, privado=False):
caminho = Path(caminho)
tmp = caminho.with_suffix('.tmp')
texto = json.dumps(obj, ensure_ascii=False, indent=2)
if privado:
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(texto)
else:
tmp.write_text(texto, encoding='utf-8')
tmp.replace(caminho)
if privado:
os.chmod(caminho, 0o600)
def carregar_token(pasta):
"""CARINHA_TOKEN do ambiente ou {"token": ...} em seguranca.json; None = sem autenticação."""
token = os.environ.get('CARINHA_TOKEN', '').strip()
if not token:
try:
token = str(json.loads((Path(pasta) / 'seguranca.json').read_text(encoding='utf-8')).get('token', '')).strip()
except (OSError, ValueError, AttributeError):
token = ''
if token and len(token) < 8:
raise ValueError('CARINHA_TOKEN precisa ter pelo menos 8 caracteres')
return token or None
def token_do_pedido(h):
auth = h.headers.get('Authorization', '')
if auth.lower().startswith('bearer '):
return auth[7:].strip()
if h.headers.get('X-Carinha-Token'):
return h.headers['X-Carinha-Token'].strip()
# EventSource e o app Android não mandam cabeçalhos próprios com facilidade.
return parse_qs(urlsplit(h.path).query).get('t', [''])[0]
def autorizado(h):
esperado = getattr(h.server, 'token', None)
if not esperado:
return True
return hmac.compare_digest(token_do_pedido(h).encode(), esperado.encode())
+46 -75
View File
@@ -1,14 +1,20 @@
"""Configuração persistente, telemetria e prévia da câmera do celular."""
"""Configuração persistente e telemetria do celular; sinalização do vídeo WebRTC."""
import ipaddress
import json
import math
import os
from pathlib import Path
import subprocess
import threading
import time
from comum import origem_valida
from sinalizacao import Sinalizacao
def versao():
try:
return json.loads((Path(__file__).with_name('version.json')).read_text(encoding='utf-8'))['versao']
except (OSError, ValueError, KeyError):
return 'desconhecida'
PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
ouvir=True, reagir=True, somlim=0.35, somganho=0.4,
somaltolim=0.55, sompausa=20, somcooldown=5,
@@ -25,8 +31,6 @@ LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600))
TOOLS = Path(os.environ.get('CARINHA_TOOLS',
'C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools'))
def validar(dados):
@@ -48,6 +52,16 @@ def validar(dados):
return dados
def ip_informado(dados):
"""IP que o próprio app informa (aparelho.ip); o endereço da conexão pode ser de um proxy."""
aparelho = dados.get('aparelho')
ip = aparelho.get('ip') if isinstance(aparelho, dict) else None
try:
return str(ipaddress.IPv4Address(ip)) if isinstance(ip, str) else None
except ValueError:
return None
class ControlePC:
def __init__(self, arquivo):
self.arquivo = Path(arquivo)
@@ -57,10 +71,8 @@ class ControlePC:
self.status_at = 0
self.celular_ip = None
self.recarga = {"id": None, "confirmada": False}
self.frame = b''
self.frame_at = 0
self.frame_seq = 0
self.rtc = Sinalizacao()
self.publicar = lambda tipo, dados: None
try:
self.config.update(validar(json.loads(self.arquivo.read_text(encoding='utf-8'))))
except (OSError, ValueError):
@@ -76,15 +88,22 @@ class ControlePC:
self.config = novo
if not novo['preview']:
self.rtc.encerrar()
self.frame = b''
self.frame_at = 0
return novo.copy()
def resumo(self, host=''):
"""Estado do celular para o painel (GET /app/status, /estado e evento 'telemetria')."""
with self.lock:
agora = time.monotonic()
idade = agora - self.status_at
return dict(online=idade < 5, idade=round(idade, 1) if self.status_at else None,
celular_ip=self.celular_ip, servidor_ip=host, recarga=self.recarga.copy(),
estado=self.estado.copy(), preview=self.config['preview'])
def rotas(self, h, metodo):
if self.rtc.atender(h, metodo, self.config['preview']):
return True
rota = h.path.split('?', 1)[0].rstrip('/')
if rota not in ('/app/config', '/app/status', '/camera/frame', '/app/connect', '/app/health'):
if rota not in ('/app/config', '/app/padrao', '/app/status', '/app/health'):
return False
def enviar(dados, codigo=200):
@@ -92,23 +111,30 @@ class ControlePC:
try:
if metodo == 'POST':
if not origem_valida(h):
raise ValueError('Origem inválida')
if rota == '/app/config' and h.headers.get('Content-Type', '').split(';')[0] != 'application/json':
raise ValueError('Use JSON')
tamanho = int(h.headers.get('Content-Length', '0'))
limite = 1_000_000 if rota == '/camera/frame' else 16384
if tamanho <= 0 or tamanho > limite:
if tamanho <= 0 or tamanho > 16384:
enviar({'erro': 'Corpo vazio ou grande demais'}, 413)
return True
corpo = h.rfile.read(tamanho)
if len(corpo) != tamanho:
raise ValueError('Corpo incompleto')
dados = None if rota == '/camera/frame' else json.loads(corpo)
dados = json.loads(corpo)
else:
dados = None
if rota == '/app/health':
enviar({'projeto': 'carinha-pc', 'versao': '0.7.0'})
if rota == '/app/padrao':
enviar(PADRAO)
elif rota == '/app/health':
enviar({'projeto': 'carinha-pc', 'versao': versao()})
elif rota == '/app/config':
if metodo == 'POST':
enviar({'ok': True, 'config': self.atualizar(dados)})
config = self.atualizar(dados)
self.publicar('app_config', config)
enviar({'ok': True, 'config': config})
else:
with self.lock:
config = self.config.copy()
@@ -119,71 +145,16 @@ class ControlePC:
raise ValueError('Estado deve ser objeto JSON')
with self.lock:
self.estado = dados
self.celular_ip = h.client_address[0]
self.celular_ip = ip_informado(dados) or h.client_address[0]
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
self.recarga["confirmada"] = True
self.status_at = time.monotonic()
self.publicar('telemetria', self.resumo(h.headers.get("Host", "").split(":")[0]))
enviar({'ok': True})
else:
with self.lock:
idade = time.monotonic() - self.status_at
frame_idade = time.monotonic() - self.frame_at
resultado = dict(online=idade < 5, idade=round(idade, 1) if self.status_at else None,
celular_ip=self.celular_ip, servidor_ip=h.headers.get("Host", "").split(":")[0], recarga=self.recarga.copy(),
estado=self.estado.copy(), preview=self.config['preview'],
camera_recente=bool(self.frame) and frame_idade < 3 and self.config['preview'],
frame_seq=self.frame_seq)
enviar(resultado)
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)
enviar(self.resumo(h.headers.get("Host", "").split(":")[0]))
except (ValueError, TypeError) as e:
enviar({'erro': str(e)}, 400)
except OSError as e:
enviar({'erro': str(e)}, 503)
return True
def conectar(self, h, dados, enviar):
ip = str(ipaddress.IPv4Address(dados.get('ip', '192.168.1.16')))
porta = dados.get('porta', 5555)
if isinstance(porta, bool) or not isinstance(porta, int) or not 1 <= porta <= 65535:
raise ValueError('Porta ADB inválida')
pc = str(ipaddress.IPv4Address(dados.get('pc', '192.168.1.15')))
adb = str(TOOLS / 'android-sdk/platform-tools/adb.exe')
serial = '%s:%s' % (ip, porta)
url = 'http://%s:%s' % (pc, h.server.server_port)
comandos = [[adb, 'connect', serial], [adb, '-s', serial, 'shell', 'input', 'keyevent', 'KEYCODE_WAKEUP'],
[adb, '-s', serial, 'shell', 'wm', 'dismiss-keyguard'],
[adb, '-s', serial, 'shell', 'am', 'start', '-n', 'br.carinha.rosto/.MainActivity', '-e', 'servidor', url]]
saidas = []
for cmd in comandos:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
except subprocess.TimeoutExpired:
enviar({'erro': 'ADB não respondeu. Confira a depuração Wi-Fi no celular.'}, 504)
return
saidas.append((r.stdout + r.stderr).strip())
if r.returncode or 'cannot connect' in r.stdout.lower() or 'failed' in r.stdout.lower() or 'error:' in r.stdout.lower():
enviar({'erro': '\n'.join(saidas)}, 502)
return
enviar({'ok': True, 'servidor': url, 'mensagem': 'App aberto. Aguarde a confirmação de conexão no painel.'})
-327
View File
@@ -1,327 +0,0 @@
/* ==========================================================================
CONVERSA COM O CARINHA · CHAT MODERNO IA
========================================================================== */
/* Workspace sticky de 2 colunas: rosto fixo + chat rolável */
.conversa-workspace {
display: grid;
grid-template-columns: 310px minmax(0, 1fr);
gap: 24px;
align-items: start;
margin-top: 8px;
}
.conversa-sticky-col {
position: sticky;
top: 84px;
z-index: 40;
display: flex;
flex-direction: column;
gap: 18px;
max-height: calc(100vh - 104px);
overflow-y: auto;
padding-right: 4px;
}
.conversa-sticky-col::-webkit-scrollbar {
width: 4px;
}
.conversa-sticky-col::-webkit-scrollbar-thumb {
background: rgba(92,214,255,0.3);
border-radius: 4px;
}
.conversa-chat-col {
min-width: 0;
display: flex;
flex-direction: column;
}
.btn-gesto-rapido {
flex: 1 1 calc(50% - 4px);
min-width: 0;
}
@media (max-width: 900px) {
.conversa-workspace {
grid-template-columns: 1fr;
}
.conversa-sticky-col {
position: static;
}
}
.chat-card {
margin: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.chat-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
border-bottom: 1px solid var(--borda);
padding-bottom: 14px;
}
.chat-box {
min-height: 380px;
max-height: 520px;
overflow-y: auto;
padding: 20px;
background: rgba(6, 12, 22, 0.75);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
display: flex;
flex-direction: column;
gap: 16px;
scroll-behavior: smooth;
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.4);
}
.chat-row {
display: flex;
gap: 12px;
max-width: 82%;
animation: chatMsgAnim 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes chatMsgAnim {
from { opacity: 0; transform: translateY(10px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.chat-row.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.chat-row.assistant {
align-self: flex-start;
}
.chat-avatar {
width: 38px;
height: 38px;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 18px;
flex-shrink: 0;
}
.chat-row.assistant .chat-avatar {
background: linear-gradient(135deg, #10263f 0%, #1e4b78 100%);
border: 1px solid var(--ciano-brilho, #5cd6ff);
box-shadow: 0 0 12px var(--ciano-glow);
}
.chat-row.user .chat-avatar {
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.chat-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-balloon {
padding: 12px 18px;
border-radius: 16px;
font-size: 14.5px;
line-height: 1.55;
word-break: break-word;
}
.chat-row.assistant .chat-balloon {
background: rgba(16, 28, 46, 0.9);
border: 1px solid rgba(92, 214, 255, 0.3);
color: #f1f7fd;
border-top-left-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
.chat-row.user .chat-balloon {
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
color: #ffffff;
border-top-right-radius: 4px;
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.35);
}
.chat-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.chat-gesto-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11.5px;
font-weight: 600;
padding: 3px 9px;
border-radius: 12px;
background: rgba(92, 214, 255, 0.12);
color: var(--ciano);
border: 1px solid rgba(92, 214, 255, 0.3);
cursor: pointer;
transition: all 0.16s ease;
}
.chat-gesto-badge:hover {
background: rgba(92, 214, 255, 0.25);
transform: scale(1.05);
}
.chat-speak-btn {
background: transparent;
border: none;
padding: 2px 6px;
font-size: 13px;
cursor: pointer;
opacity: 0.7;
color: var(--fraco);
box-shadow: none;
}
.chat-speak-btn:hover {
opacity: 1;
color: var(--ciano);
background: transparent;
transform: none;
}
/* Digitando / Pensando */
.chat-typing {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
background: rgba(16, 28, 46, 0.8);
border: 1px solid rgba(92, 214, 255, 0.2);
border-radius: 16px;
width: fit-content;
color: var(--ciano);
font-size: 13px;
font-style: italic;
}
.dot-pulse {
width: 6px;
height: 6px;
background: var(--ciano);
border-radius: 50%;
animation: dotPulseAnim 1.2s infinite ease-in-out;
}
.dot-pulse:nth-child(2) { animation-delay: 0.2s; }
.dot-pulse:nth-child(3) { animation-delay: 0.4s; }
@keyframes dotPulseAnim {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1.1); opacity: 1; }
}
/* Sugestões de Conversa */
.chat-suggestions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
}
.suggestion-chip {
padding: 6px 14px;
border-radius: 20px;
background: rgba(18, 34, 54, 0.6);
border: 1px solid var(--borda);
color: var(--texto-medio);
font-size: 12.5px;
cursor: pointer;
transition: all 0.16s ease;
}
.suggestion-chip:hover {
border-color: var(--ciano);
color: #ffffff;
background: rgba(30, 56, 88, 0.85);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(92, 214, 255, 0.2);
}
/* Barra de Entrada */
.chat-input-container {
display: flex;
gap: 10px;
align-items: center;
position: relative;
}
.chat-input-container input {
margin: 0 !important;
padding-right: 48px;
border-radius: var(--raio-md);
font-size: 15px;
}
.btn-mic {
width: 44px;
height: 44px;
padding: 0;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 18px;
flex-shrink: 0;
background: rgba(22, 38, 60, 0.8);
border: 1px solid var(--borda);
color: var(--texto-medio);
}
.btn-mic:hover {
border-color: var(--ciano);
color: var(--ciano);
}
.btn-mic.recording {
background: var(--vermelho);
border-color: var(--vermelho);
color: #ffffff;
animation: micPulse 1s infinite alternate;
box-shadow: 0 0 16px rgba(244, 63, 94, 0.6);
}
@keyframes micPulse {
from { transform: scale(1); }
to { transform: scale(1.08); }
}
/* Lembranças / Memória */
.memory-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
background: rgba(12, 22, 36, 0.6);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
margin-bottom: 8px;
transition: border-color 0.16s ease;
}
.memory-card:hover {
border-color: rgba(92, 214, 255, 0.4);
}
-164
View File
@@ -1,164 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Conversar com IA · Carinha</title>
<meta name="description" content="Converse por voz ou texto com o Carinha usando Google Gemini com memória de longo prazo.">
<link rel="stylesheet" href="/dashboard.css">
<link rel="stylesheet" href="/conversa.css">
<script src="/dashboard.js" defer></script>
</head>
<body>
<!-- Cabeçalho da Página -->
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
<div>
<h1>Conversar com o Carinha</h1>
<p style="margin:4px 0 0">Converse por voz ou texto. As expressões do rosto mudam em tempo real conforme a conversa.</p>
</div>
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin:0;cursor:pointer">
<input type="checkbox" id="conversaVozAuto" checked style="width:16px;height:16px">
<span>Falar respostas (TTS)</span>
</label>
<span class="badge ativo"><span class="badge-dot"></span> Gemini Online</span>
</div>
</div>
<!-- WORKSPACE STICKY: Rosto fica fixado enquanto a conversa rola -->
<div class="conversa-workspace">
<!-- Coluna Fixa (Sticky: Rosto + Status) -->
<aside class="conversa-sticky-col">
<!-- Preview do Rosto ao Vivo -->
<section class="card sticky-preview-card" style="margin-bottom:18px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
Rosto ao Vivo
</h2>
<span class="badge ativo" style="font-size:11px">📌 Sempre Visível</span>
</div>
<iframe id="conversaCarinhaPreview"
src="/carinha.html?espelho=1&audio=0"
title="Expressão atual do Carinha"
style="width:100%;height:220px;border-radius:var(--raio-md);border:1px solid var(--borda-brilho);background:#000;box-shadow:inset 0 0 30px rgba(0,0,0,0.8),0 0 20px var(--ciano-glow)">
</iframe>
<!-- Status da Expressão Atual -->
<div id="conversaExpressaoAtual" style="margin-top:10px;font-size:13px;color:var(--ciano);min-height:22px;text-align:center">
Aguardando conversa…
</div>
<!-- Motor de IA selecionado -->
<div style="margin-top:12px">
<label for="conversaProvedor" style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:var(--muito-fraco);margin-bottom:6px">Motor de IA</label>
<select id="conversaProvedor" style="width:100%;margin:0">
<option value="gemini">✨ Google Gemini · Nuvem</option>
<option value="local">📱 Qwen · Local no Celular</option>
</select>
</div>
</section>
<!-- Gestos Rápidos -->
<section class="card">
<h2 style="font-size:15px;margin-bottom:12px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
Gestos Rápidos
</h2>
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin:0">
<button type="button" class="btn-gesto-rapido" data-gesto="feliz" style="padding:7px 12px;font-size:13px">😊 Feliz</button>
<button type="button" class="btn-gesto-rapido" data-gesto="surpreso" style="padding:7px 12px;font-size:13px">😲 Surpreso</button>
<button type="button" class="btn-gesto-rapido" data-gesto="piscar" style="padding:7px 12px;font-size:13px">😉 Piscar</button>
<button type="button" class="btn-gesto-rapido" data-gesto="coracao" style="padding:7px 12px;font-size:13px">💖 Amor</button>
<button type="button" class="btn-gesto-rapido" data-gesto="dormir" style="padding:7px 12px;font-size:13px">🌙 Dormir</button>
<button type="button" class="btn-gesto-rapido" data-gesto="acordar" style="padding:7px 12px;font-size:13px">☀️ Acordar</button>
</div>
<p id="conversaGestoEstado" role="status" style="margin:10px 0 0;font-size:12.5px;color:var(--fraco);min-height:18px"></p>
</section>
</aside>
<!-- Coluna Rolável (Chat) -->
<main class="conversa-chat-col">
<section class="card chat-card" style="margin:0">
<div class="chat-header-bar">
<div style="display:flex;align-items:center;gap:12px">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span style="font-weight:700;font-size:16px">Histórico</span>
</div>
<p id="conversaEstado" role="status" style="margin:0;font-size:12.5px;color:var(--fraco)">Carregando conversa…</p>
</div>
<!-- Caixa de Histórico de Mensagens -->
<div id="conversaHistorico" class="chat-box" role="log" aria-label="Histórico da conversa"></div>
<!-- Indicador de Digitando / Pensando -->
<div id="conversaTyping" class="chat-typing" style="display:none">
<span>Carinha está pensando</span>
<span class="dot-pulse"></span>
<span class="dot-pulse"></span>
<span class="dot-pulse"></span>
</div>
<!-- Sugestões Rápidas de Diálogo -->
<div class="chat-suggestions">
<button type="button" class="suggestion-chip" data-msg="Oi, Carinha! Como você está?">👋 Como você está?</button>
<button type="button" class="suggestion-chip" data-msg="Me conte uma curiosidade sobre robôs!">🤖 Curiosidade robótica</button>
<button type="button" class="suggestion-chip" data-msg="Faça uma cara bem engraçada e feliz!">😄 Faça uma cara feliz</button>
<button type="button" class="suggestion-chip" data-msg="Você gosta de música? Qual é seu som favorito?">🎶 Gosta de música?</button>
</div>
<!-- Formulário de Envio de Mensagem com Microfone -->
<form id="conversaForm" style="margin:0">
<div class="chat-input-container">
<button id="conversaMicBtn" class="btn-mic" type="button" title="Falar no microfone (Reconhecimento de Voz)">🎤</button>
<input id="conversaTexto" maxlength="600" required placeholder="Digite uma mensagem ou clique no microfone para falar..." autocomplete="off">
<button id="conversaEnviar" class="primary" type="submit" style="padding:10px 22px;border-radius:var(--raio-md)">
<span>Enviar</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</form>
<!-- Gerenciador de Lembranças e Memória Persistente -->
<details style="margin-top:10px">
<summary>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Lembranças &amp; Memória de Longo Prazo
</summary>
<p style="margin:8px 0 12px">Fatos e preferências ensinados ao Carinha que enriquecem o prompt do Gemini sem alterar os pesos do modelo.</p>
<form id="memoriaForm" style="display:flex;gap:10px;align-items:center;margin-bottom:14px">
<input id="memoriaTexto" maxlength="300" required placeholder="Ex.: Meu nome é Jullio e prefiro explicações diretas" style="margin:0">
<button type="submit" style="white-space:nowrap">Salvar Memória</button>
</form>
<div id="conversaMemorias" style="display:flex;flex-direction:column;gap:8px"></div>
<div style="margin-top:16px;border-top:1px solid var(--borda);padding-top:12px;display:flex;justify-content:flex-end">
<button id="conversaLimpar" type="button" class="danger" style="font-size:12px">🗑️ Apagar histórico e memórias</button>
</div>
</details>
</section>
</main>
</div>
<script src="/painel-conversa.js"></script>
<script>
// Gestos rápidos no painel lateral
document.querySelectorAll('.btn-gesto-rapido').forEach(btn => {
btn.addEventListener('click', async () => {
const gesto = btn.dataset.gesto;
try {
const r = await fetch('/gesto/' + encodeURIComponent(gesto));
if (r.ok) {
const el = document.getElementById('conversaGestoEstado');
if (el) { el.textContent = 'Gesto: ' + gesto + ' ✓'; setTimeout(() => { el.textContent = ''; }, 2500); }
}
} catch (e) { console.log('gesto rápido:', e); }
});
});
</script>
</body>
</html>
+21 -10
View File
@@ -6,14 +6,18 @@ import threading
import time
import unicodedata
import re
from collections import deque
from datetime import datetime, timezone, timedelta
from contextlib import contextmanager
from urllib.request import Request, urlopen
from cerebro import GESTOS
from pathlib import Path
from gemini import responder_gemini
from comum import gravar_atomico, ler_json
import gemini
FIGURINHAS_POR_HORA = 6 # cada figurinha automática é mais uma chamada paga ao Gemini
def resposta_factual(texto,agora=None):
normal=''.join(c for c in unicodedata.normalize('NFD',texto.lower()) if not unicodedata.combining(c))
normal=' '.join(normal.strip(' ?!.').split())
@@ -93,6 +97,14 @@ class Conversa:
if saved.get('provedor') in ('local','gemini'):self.provedor=saved['provedor']
except (OSError,ValueError,AttributeError):pass
self.lock=threading.Lock();self.busy=False;self.ultima=None;self.erro='';self.generation=0
self.publicar=lambda tipo,dados:None;self.figurinhas_auto=deque()
def avisar(self):
self.publicar('conversa',self.status())
def pode_gerar_figurinha(self,agora=None):
agora=agora or time.time()
while self.figurinhas_auto and agora-self.figurinhas_auto[0]>3600:self.figurinhas_auto.popleft()
if len(self.figurinhas_auto)>=FIGURINHAS_POR_HORA:return False
self.figurinhas_auto.append(agora);return True
def status(self):
with self.lock:return dict(provedor=self.provedor,modelo=gemini.MODELO if self.provedor=='gemini' else 'Qwen3-0.6B',gemini_configurado=bool(os.environ.get('GEMINI_API_KEY')),ocupado=self.busy,ultima=self.ultima,erro=self.erro,historico=self.memoria.historico(),lembrancas=self.memoria.listar())
def enviar(self,texto):
@@ -108,7 +120,9 @@ class Conversa:
self.memoria.registrar('assistant',factual['texto'],2 if self.provedor=='gemini' else 1)
self.ultima=dict(factual,id=time.time_ns(),horario=time.time(),segundos=0,fonte='relogio')
self.busy=False;self.cerebro.conversando=False
return dict(ok=True)
factual=True
self.avisar()
if factual is True:return dict(ok=True)
threading.Thread(target=self.responder,args=(generation,),daemon=True).start()
return dict(ok=True)
def responder(self,generation):
@@ -131,7 +145,7 @@ class Conversa:
messages=mensagens_modelo(history,self.memoria.listar())
schema=dict(type='object',properties=dict(texto=dict(type='string'),gesto=dict(type='string',enum=list(GESTOS))),required=['texto','gesto'],additionalProperties=False)
payload=dict(model='local',messages=messages,max_tokens=120,temperature=.2,repeat_penalty=1.1,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='conversa',strict=True,schema=schema)))
req=Request(self.cerebro.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers={'Content-Type':'application/json'})
req=Request(self.cerebro.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cerebro.cabecalhos())
with urlopen(req,timeout=90) as response:data=json.load(response)
result=validar_resposta(json.loads(data['choices'][0]['message']['content']))
with self.lock:
@@ -140,32 +154,28 @@ class Conversa:
self.ultima=dict(result,id=time.time_ns(),horario=time.time(),segundos=round(time.monotonic()-inicio,2))
if self.provedor=='gemini' and treino:
if type(reaction) is int and reaction in [r['id'] for r in catalog]:treino.testar(reaction)
elif not treino.status()['ocupado']:
elif not treino.status()['ocupado'] and self.pode_gerar_figurinha():
treino.gerar_figurinha(history[-1]['content'])
except Exception as e:
with self.lock:self.erro=str(e)[:200] if generation==self.generation else ''
finally:
with self.lock,self.cerebro.lock:
self.busy=False;self.cerebro.conversando=False;self.cerebro.last=time.monotonic()
self.avisar()
def rotas(self,h,method):
route=h.path.split('?',1)[0]
if not route.startswith('/conversa/'):return False
try:
if method=='GET' and route=='/conversa/status':result=self.status()
elif method=='POST':
origin=h.headers.get('Origin')
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
size=int(h.headers.get('Content-Length','0'))
if not 0<size<=4096 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 4096 bytes')
data=json.loads(h.rfile.read(size))
if not isinstance(data,dict):raise ValueError('Objeto obrigatório')
data=ler_json(h,4096)
if route=='/conversa/config':
with self.lock:
if self.busy:raise ValueError('Aguarde a resposta antes de trocar a IA')
provider=data.get('provedor')
if provider not in ('local','gemini'):raise ValueError('Provedor inválido')
if provider=='gemini' and not os.environ.get('GEMINI_API_KEY'):raise ValueError('Chave Gemini não configurada')
tmp=self.config_path.with_suffix('.tmp');tmp.write_text(json.dumps(dict(provedor=provider)));tmp.replace(self.config_path)
gravar_atomico(self.config_path,dict(provedor=provider))
self.provedor=provider
result=self.status()
elif route=='/conversa/enviar':result=self.enviar(data.get('texto'))
@@ -183,6 +193,7 @@ class Conversa:
else:self.memoria.apagar(data.get('id'))
result=dict(ok=True)
else:raise ValueError('Rota inválida')
if route!='/conversa/enviar':self.avisar()
else:raise ValueError('Método inválido')
h.enviar(json.dumps(result,ensure_ascii=False))
except (ValueError,TypeError) as e:h.enviar(json.dumps(dict(erro=str(e)),ensure_ascii=False),codigo=400)
-1353
View File
File diff suppressed because it is too large Load Diff
-153
View File
@@ -1,153 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Visão Geral · Central Carinha IA</title>
<link rel="stylesheet" href="/dashboard.css">
<script src="/dashboard.js" defer></script>
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
<div>
<h1>Central do Carinha</h1>
<p style="margin:4px 0 0;font-size:15px">Supervisão em tempo real do Galaxy S10, telemetria de sensores, cérebro Gemini e robótica.</p>
</div>
<div style="display:flex;gap:10px;align-items:center">
<span class="badge ativo" id="overviewSystemBadge"><span class="badge-dot"></span> Sistema Ativo</span>
</div>
</div>
<div class="overview-grid">
<!-- Card do Rosto do Robô com Visualizador Interativo -->
<section class="card overview-face-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h2>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
Expressão do Personagem
</h2>
<span class="badge" style="font-size:11.5px">Prévia Sincronizada</span>
</div>
<iframe class="overview-face" id="overviewFace" title="Prévia do rosto do robô" src="/carinha.html?espelho=1&audio=0"></iframe>
<!-- Mood Switches Rápidos -->
<div style="margin:8px 0 16px">
<label style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:var(--muito-fraco);margin-bottom:8px;display:block">Expressão Rápida</label>
<div class="toolbar" style="margin:0;gap:8px">
<button type="button" class="btn-mood" data-gesto="feliz" style="padding:7px 12px;font-size:13px">😊 Feliz</button>
<button type="button" class="btn-mood" data-gesto="surpreso" style="padding:7px 12px;font-size:13px">😲 Surpreso</button>
<button type="button" class="btn-mood" data-gesto="coracao" style="padding:7px 12px;font-size:13px">💖 Amor</button>
<button type="button" class="btn-mood" data-gesto="piscar" style="padding:7px 12px;font-size:13px">😉 Piscar</button>
<button type="button" class="btn-mood" data-gesto="dormir" style="padding:7px 12px;font-size:13px">🌙 Dormir</button>
<button type="button" class="btn-mood" data-gesto="acordar" style="padding:7px 12px;font-size:13px;border-color:var(--ciano)">☀️ Acordar</button>
</div>
</div>
<div class="actions" style="margin-top:auto;border-top:1px solid var(--borda);padding-top:16px">
<a href="/painel" class="primary" style="display:inline-flex;align-items:center;gap:6px;padding:9px 16px;border-radius:var(--raio-sm);font-weight:700">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
Abrir Câmera & Rosto
</a>
<button id="overviewReload" type="button">🔄 Recarregar Tela Celular</button>
<button id="overviewSfxHappy" type="button" title="Testar som procedural alegre">🎵 Tocar Bip</button>
</div>
<p id="overviewMessage" role="status" style="margin-top:10px;font-size:13px;min-height:20px;color:var(--ciano)"></p>
</section>
<!-- Grade de Métricas & Telemetria -->
<div style="display:flex;flex-direction:column;gap:18px">
<div class="metrics">
<section class="card metric">
<div style="display:flex;justify-content:space-between;align-items:center">
<p>Galaxy S10</p>
<span style="font-size:18px">📱</span>
</div>
<strong id="overviewPhone">—</strong>
<small id="overviewIP">Aguardando conexão</small>
</section>
<section class="card metric">
<div style="display:flex;justify-content:space-between;align-items:center">
<p>Bateria</p>
<span style="font-size:18px">🔋</span>
</div>
<strong id="overviewBattery">—</strong>
<small id="overviewCharge">Aguardando dados</small>
</section>
<section class="card metric">
<div style="display:flex;justify-content:space-between;align-items:center">
<p>Inteligência IA</p>
<span style="font-size:18px">🧠</span>
</div>
<strong id="overviewAI" style="font-size:22px">—</strong>
<small id="overviewModel">Verificando API</small>
</section>
<section class="card metric">
<div style="display:flex;justify-content:space-between;align-items:center">
<p>Expressões</p>
<span style="font-size:18px">🎨</span>
</div>
<strong id="overviewReactions">—</strong>
<small>Reações na biblioteca</small>
</section>
</div>
<!-- Feed de Atividades Recentes -->
<section class="card" style="flex:1">
<h2>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Telemetria & Eventos
</h2>
<div id="overviewFeed" style="display:flex;flex-direction:column;gap:10px;margin-top:12px;font-size:13px;max-height:165px;overflow-y:auto">
<div style="display:flex;align-items:center;gap:10px;color:var(--fraco)">
<span class="badge-dot" style="background:var(--ciano)"></span>
<span>Central inicializada no Orange Pi Zero 3</span>
<span style="margin-left:auto;font-size:11px;font-family:monospace">agora</span>
</div>
</div>
</section>
</div>
</div>
<!-- Links Rápidos de Acesso com Visual Moderno -->
<h2 style="margin:36px 0 16px">Navegação Rápida</h2>
<div class="quick-links">
<a class="card" href="/conversa">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2>💬 Conversar</h2>
<span class="badge" style="font-size:11px">Voz & Texto</span>
</div>
<p>Interaja por voz e texto com o Carinha usando Gemini e memória de longo prazo.</p>
</a>
<a class="card" href="/emojis">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2>✨ Emojis & Reações</h2>
<span class="badge" style="font-size:11px">Estúdio SVG</span>
</div>
<p>Desenhe novas reações com inteligência artificial e teste diretamente nos olhos do robô.</p>
</a>
<a class="card" href="/braco">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2>🦾 Braço Robótico</h2>
<span class="badge" style="font-size:11px">Cinemática</span>
</div>
<p>Controle ângulos, crie coreografias articuladas e coordene 8 canais de movimento.</p>
</a>
<a class="card" href="/treinamento">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h2>📚 Treinamento</h2>
<span class="badge" style="font-size:11px">Personalidade</span>
</div>
<p>Defina rotinas, hábitos, prompts e consulte o diário de interações do robô.</p>
</a>
</div>
<script src="/overview.js" defer></script>
</body>
</html>
-459
View File
@@ -1,459 +0,0 @@
/**
* dashboard.js — Shell global da Central de Controle do Carinha
* Injeta barra lateral unificada, telemetria em tempo real no topo,
* controle de áudio robótico e dock de comandos rápidos.
*/
(() => {
if (window.location.search.includes('clean=1')) {
document.documentElement.classList.add('is-clean-embed');
return;
}
// Carrega dinamicamente o sintetizador de áudio caso não esteja presente
if (!window.RobotAudio) {
const audioScript = document.createElement('script');
audioScript.src = '/audio-robot.js';
audioScript.defer = true;
document.head.appendChild(audioScript);
}
const sections = [
{
title: 'Principal',
items: [
['/dashboard', 'Visão geral', 'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z'],
['/painel', 'Câmera e rosto', 'M3 6h12v12H3z M15 10l6-4v12l-6-4']
]
},
{
title: 'Inteligência & IA',
items: [
['/conversa', 'Conversar com IA', 'M3 4h18v13H9l-6 4z'],
['/emojis', 'Emojis & Reações', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18 M8 9h1 M15 9h1 M7 14q5 5 10 0'],
['/treinamento', 'Treinamento', 'M3 8l9-5 9 5-9 5z M6 11v6q6 5 12 0v-6']
]
},
{
title: 'Robótica & Hardware',
items: [
['/braco', 'Braço robótico', 'M3 21h18 M8 21v-5l7-5-3-6 M9 4h6v4H9z M14 10h4v4h-4z'],
['/servos', 'Servos PCA9685', 'M5 3v18 M12 3v18 M19 3v18 M2 8h6 M9 16h6 M16 7h6']
]
},
{
title: 'Configurações',
items: [
['/api', 'API Gemini & Modelo', 'M8 5L2 12l6 7 M16 5l6 7-6 7 M14 3l-4 18']
]
}
];
const pathname = location.pathname === '/' ? '/dashboard' : location.pathname;
document.body.classList.add('dashboard');
const frame = document.createElement('div');
frame.className = 'app-frame';
const main = document.createElement('main');
main.className = 'app-content';
main.id = 'appContent';
// Move elementos existentes do body para o container principal
for (const child of [...document.body.childNodes]) {
if (!(child.nodeType === 1 && ['SCRIPT', 'STYLE', 'LINK'].includes(child.tagName))) {
main.append(child);
}
}
// Sidebar moderna e estilizada
const sidebar = document.createElement('aside');
sidebar.className = 'app-sidebar';
sidebar.innerHTML = `
<a href="/dashboard" class="brand" title="Central Carinha">
<span class="brand-face" aria-hidden="true"><i></i><i></i></span>
<span>Carinha <span style="font-size:12px;color:var(--ciano);font-weight:600;letter-spacing:0.05em;vertical-align:middle;background:rgba(92,214,255,0.12);padding:2px 6px;border-radius:6px;border:1px solid rgba(92,214,255,0.25)">IA</span></span>
</a>
<nav aria-label="Navegação principal" id="sidebarNav"></nav>
<div class="sidebar-footer">
<div><strong>Orange Pi Zero 3</strong></div>
<div style="font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--muito-fraco)" id="shellServer">${location.host}</div>
<div style="margin-top:6px;display:flex;align-items:center;gap:6px">
<span class="badge-dot" style="background:#10b981;color:#10b981"></span>
<span style="font-size:11px;color:var(--fraco)">Serviço operacional</span>
</div>
</div>
`;
const nav = sidebar.querySelector('#sidebarNav');
for (const sec of sections) {
const title = document.createElement('div');
title.className = 'nav-section-title';
title.textContent = sec.title;
nav.append(title);
for (const [url, label, path] of sec.items) {
const a = document.createElement('a');
a.href = url;
a.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${path}"/></svg><span>${label}</span>`;
if (url === pathname) {
a.setAttribute('aria-current', 'page');
}
a.addEventListener('click', () => {
if (window.RobotAudio) window.RobotAudio.click();
});
nav.append(a);
}
}
// Barra de ferramentas superior (Topbar)
const top = document.createElement('header');
top.className = 'app-topbar';
top.innerHTML = `
<div class="topbar-left">
<button class="menu-button" type="button" aria-label="Abrir menu" aria-expanded="false">☰</button>
<span class="server-label" style="font-size:14px;font-weight:600;color:var(--texto);display:flex;align-items:center;gap:8px">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--ciano);box-shadow:0 0 8px var(--ciano)"></span>
Central de Controle
</span>
</div>
<div class="topbar-telemetry">
<button id="pipToggleBtn" type="button" title="Mini-monitor flutuante (Picture-in-Picture) que fica fixo em qualquer página" style="padding:6px 12px;font-size:12.5px;border-radius:20px;border-color:var(--borda)">
📺 Monitor PiP
</button>
<button id="quickRemoteBtn" type="button" title="Atalhos rápidos de expressão" style="padding:6px 12px;font-size:12.5px;border-radius:20px;border-color:var(--borda)">
⚡ Gestos
</button>
<button id="sfxToggleBtn" type="button" title="Ativar/Desativar efeitos sonoros robóticos" style="padding:6px 12px;font-size:12.5px;border-radius:20px">
🔊 Som: Ativo
</button>
<span class="badge" id="shellConnection">
<span class="badge-dot"></span>
<span id="shellConnectionText">Verificando celular…</span>
</span>
</div>
`;
// Mini-Monitor Flutuante (Picture-in-Picture)
const pipWidget = document.createElement('div');
pipWidget.id = 'floatingPipWidget';
pipWidget.className = 'floating-pip-widget';
pipWidget.style.display = 'none';
pipWidget.innerHTML = `
<div class="floating-pip-header">
<div class="pip-title">
<span class="badge-dot" style="background:var(--ciano)"></span>
<span>Carinha Monitor</span>
</div>
<div class="floating-pip-actions">
<button id="pipMinimizeBtn" type="button" title="Minimizar ou Expandir">_</button>
<button id="pipCloseBtn" type="button" title="Fechar">✕</button>
</div>
</div>
<div class="floating-pip-body" id="pipBody">
<iframe src="/carinha.html?espelho=1&audio=0" title="Monitor Flutuante do Carinha"></iframe>
</div>
`;
document.body.appendChild(pipWidget);
const pipToggleBtn = top.querySelector('#pipToggleBtn');
const pipMinBtn = pipWidget.querySelector('#pipMinimizeBtn');
const pipCloseBtn = pipWidget.querySelector('#pipCloseBtn');
function togglePip(force) {
const isVisible = pipWidget.style.display === 'flex';
const next = force !== undefined ? force : !isVisible;
pipWidget.style.display = next ? 'flex' : 'none';
pipToggleBtn.style.color = next ? 'var(--ciano)' : 'var(--texto)';
pipToggleBtn.style.borderColor = next ? 'var(--ciano)' : 'var(--borda)';
localStorage.setItem('carinha_pip', next ? 'true' : 'false');
if (next && window.RobotAudio) window.RobotAudio.click();
}
pipToggleBtn.onclick = () => togglePip();
pipCloseBtn.onclick = () => togglePip(false);
pipMinBtn.onclick = () => {
const isMin = pipWidget.classList.toggle('minimized');
pipMinBtn.textContent = isMin ? '□' : '_';
};
// Se o usuário já deixou o PiP ativo anteriormente
if (localStorage.getItem('carinha_pip') === 'true') {
pipWidget.style.display = 'flex';
pipToggleBtn.style.color = 'var(--ciano)';
pipToggleBtn.style.borderColor = 'var(--ciano)';
}
// Menu móvel
const menuBtn = top.querySelector('.menu-button');
menuBtn.onclick = () => {
const open = document.body.classList.toggle('menu-open');
menuBtn.setAttribute('aria-expanded', String(open));
};
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
document.body.classList.remove('menu-open');
menuBtn.setAttribute('aria-expanded', 'false');
fecharQuickRemote();
}
});
main.addEventListener('click', () => {
document.body.classList.remove('menu-open');
menuBtn.setAttribute('aria-expanded', 'false');
});
// Modal / Dropdown de Gestos Rápidos
const quickRemote = document.createElement('div');
quickRemote.id = 'quickRemoteBar';
quickRemote.style.cssText = `
position: fixed; top: 72px; right: 28px; z-index: 90;
background: rgba(14, 24, 38, 0.95); backdrop-filter: blur(16px);
border: 1px solid var(--borda-brilho); border-radius: 14px;
padding: 16px; box-shadow: var(--sombra); display: none;
flex-direction: column; gap: 10px; width: 290px;
`;
quickRemote.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--borda);padding-bottom:8px">
<strong style="font-size:14px;color:var(--texto)">⚡ Disparo Rápido</strong>
<button id="closeQuickRemote" type="button" style="padding:3px 8px;font-size:12px;border:none;background:transparent;color:var(--fraco)">✕</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:4px">
<button data-gesto="feliz" style="padding:8px;font-size:12px">😊 Feliz</button>
<button data-gesto="surpreso" style="padding:8px;font-size:12px">😲 Surpreso</button>
<button data-gesto="coracao" style="padding:8px;font-size:12px">❤️ Coração</button>
<button data-gesto="bravo" style="padding:8px;font-size:12px">😠 Bravo</button>
<button data-gesto="piscar" style="padding:8px;font-size:12px">😉 Piscar</button>
<button data-gesto="piscadinha" style="padding:8px;font-size:12px">✨ Piscar 2</button>
<button data-gesto="acordar" style="padding:8px;font-size:12px" class="primary">☀️ Acordar</button>
<button data-gesto="dormir" style="padding:8px;font-size:12px">🌙 Dormir</button>
</div>
<button data-gesto="parar" class="danger" style="width:100%;font-size:12px;padding:8px;margin-top:4px">🛑 Parar Tudo (E-Stop)</button>
`;
document.body.appendChild(quickRemote);
function toggleQuickRemote() {
const isVisible = quickRemote.style.display === 'flex';
quickRemote.style.display = isVisible ? 'none' : 'flex';
if (!isVisible && window.RobotAudio) window.RobotAudio.click();
}
function fecharQuickRemote() {
quickRemote.style.display = 'none';
}
top.querySelector('#quickRemoteBtn').onclick = toggleQuickRemote;
quickRemote.querySelector('#closeQuickRemote').onclick = fecharQuickRemote;
quickRemote.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-gesto]');
if (!btn) return;
const gesto = btn.getAttribute('data-gesto');
try {
if (window.RobotAudio) window.RobotAudio.tocarGesto(gesto);
await fetch('/gesto/' + encodeURIComponent(gesto));
btn.style.transform = 'scale(0.95)';
setTimeout(() => btn.style.transform = '', 150);
} catch (err) {
console.error('Falha ao enviar gesto:', err);
}
});
// Botão de Áudio
const sfxBtn = top.querySelector('#sfxToggleBtn');
function atualizarBotaoSfx() {
if (!window.RobotAudio) return;
sfxBtn.textContent = window.RobotAudio.enabled ? '🔊 Som: Ativo' : '🔇 Som: Mudo';
sfxBtn.style.color = window.RobotAudio.enabled ? 'var(--ciano)' : 'var(--fraco)';
}
sfxBtn.onclick = () => {
if (window.RobotAudio) {
window.RobotAudio.toggle();
atualizarBotaoSfx();
}
};
setTimeout(atualizarBotaoSfx, 400);
frame.append(top, main);
document.body.prepend(sidebar, frame);
// Monitoramento periódico de status do celular e servidor
async function checkStatus() {
try {
const r = await fetch('/app/status', { cache: 'no-store', signal: AbortSignal.timeout(3000) });
if (!r.ok) throw Error();
const s = await r.json();
const badge = document.getElementById('shellConnection');
const text = document.getElementById('shellConnectionText') || badge;
const bat = s.estado?.bateria?.nivel;
const carregando = s.estado?.bateria?.carregando;
let batInfo = '';
if (bat != null) {
batInfo = ` · ${bat}%${carregando ? ' ⚡' : ''}`;
}
if (s.online) {
text.textContent = `S10 Online${batInfo}`;
badge.className = 'badge ativo';
} else {
text.textContent = 'S10 Desconectado';
badge.className = 'badge offline';
}
} catch (e) {
const badge = document.getElementById('shellConnection');
if (badge) {
badge.className = 'badge danger';
const text = document.getElementById('shellConnectionText') || badge;
text.textContent = 'Servidor Offline';
}
}
}
checkStatus();
setInterval(checkStatus, 4000);
// =========================================================================
// BARRA DE MONITORAMENTO NO TOPO — câmera e rosto sempre visíveis
// Com botão de expandir/recolher cada painel individualmente
// =========================================================================
const watchBar = document.createElement('div');
watchBar.id = 'watchBar';
watchBar.className = 'watch-bar';
// Lê estado salvo (expandido ou não) e tamanho
const watchState = (() => {
try { return JSON.parse(localStorage.getItem('carinha_watch') || '{}'); }
catch { return {}; }
})();
function saveWatchState() {
localStorage.setItem('carinha_watch', JSON.stringify(watchState));
}
// Altura dos painéis: compacto ou expandido
const HEIGHTS = { compact: 90, medium: 200, full: 340 };
function makePanelHTML(id, icon, label, src) {
const h = watchState[id + '_size'] || 'compact';
const hidden = watchState[id + '_hidden'] === true;
return `
<div class="watch-panel ${hidden ? 'watch-panel-hidden' : ''}" id="wp_${id}" data-id="${id}" data-size="${h}">
<div class="watch-panel-header">
<span class="watch-panel-label">${icon} ${label}</span>
<div class="watch-panel-actions">
<button class="wp-btn wp-shrink" title="Recolher" data-id="${id}">−</button>
<button class="wp-btn wp-expand" title="Expandir" data-id="${id}">+</button>
<button class="wp-btn wp-hide" title="Ocultar painel" data-id="${id}">✕</button>
</div>
</div>
<div class="watch-panel-body" style="height:${HEIGHTS[h]}px">
<iframe src="${src}" title="${label}" loading="lazy" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
</div>
`;
}
watchBar.innerHTML = `
<div class="watch-bar-inner">
<div class="watch-bar-grip" title="Arrastar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/></svg>
<span style="font-size:11px;font-weight:700;letter-spacing:0.06em;color:var(--ciano)">MONITOR</span>
</div>
<div class="watch-panels" id="watchPanels">
${makePanelHTML('camera', '📷', 'Câmera ao Vivo', '/painel?clean=1')}
${makePanelHTML('rosto', '🤖', 'Rosto do Carinha', '/carinha.html?espelho=1&audio=0&clean=1')}
</div>
<div class="watch-bar-tools">
<button id="watchAddCamera" class="wp-tool-btn" title="Mostrar/ocultar Câmera">📷</button>
<button id="watchAddRosto" class="wp-tool-btn" title="Mostrar/ocultar Rosto">🤖</button>
<button id="watchBarToggle" class="wp-tool-btn" title="Ocultar barra inteira" style="margin-top:auto">☰</button>
</div>
</div>
`;
document.body.appendChild(watchBar);
// Aplica estado inicial de visibilidade dos painéis
['camera', 'rosto'].forEach(id => {
const panel = watchBar.querySelector('#wp_' + id);
if (!panel) return;
if (watchState[id + '_hidden'] === true) panel.classList.add('watch-panel-hidden');
});
// Oculta a barra inteira se o usuário fechou antes
if (watchState['bar_hidden'] === true) {
watchBar.classList.add('watch-bar-collapsed');
}
// Toggle da barra inteira
watchBar.querySelector('#watchBarToggle').addEventListener('click', () => {
const collapsed = watchBar.classList.toggle('watch-bar-collapsed');
watchState['bar_hidden'] = collapsed;
saveWatchState();
if (window.RobotAudio) window.RobotAudio.click();
});
// Toggle de visibilidade dos painéis individuais
watchBar.querySelector('#watchAddCamera').addEventListener('click', () => {
const panel = watchBar.querySelector('#wp_camera');
const hidden = panel.classList.toggle('watch-panel-hidden');
watchState['camera_hidden'] = hidden;
saveWatchState();
});
watchBar.querySelector('#watchAddRosto').addEventListener('click', () => {
const panel = watchBar.querySelector('#wp_rosto');
const hidden = panel.classList.toggle('watch-panel-hidden');
watchState['rosto_hidden'] = hidden;
saveWatchState();
});
// Expandir / Recolher tamanho dos painéis
watchBar.addEventListener('click', e => {
const btn = e.target.closest('.wp-expand, .wp-shrink, .wp-hide');
if (!btn) return;
const id = btn.dataset.id;
const panel = watchBar.querySelector('#wp_' + id);
const body = panel.querySelector('.watch-panel-body');
const sizes = Object.keys(HEIGHTS);
if (btn.classList.contains('wp-expand')) {
const cur = sizes.indexOf(panel.dataset.size);
const next = sizes[Math.min(cur + 1, sizes.length - 1)];
panel.dataset.size = next;
body.style.height = HEIGHTS[next] + 'px';
watchState[id + '_size'] = next;
} else if (btn.classList.contains('wp-shrink')) {
const cur = sizes.indexOf(panel.dataset.size);
const next = sizes[Math.max(cur - 1, 0)];
panel.dataset.size = next;
body.style.height = HEIGHTS[next] + 'px';
watchState[id + '_size'] = next;
} else if (btn.classList.contains('wp-hide')) {
const hidden = panel.classList.toggle('watch-panel-hidden');
watchState[id + '_hidden'] = hidden;
}
saveWatchState();
if (window.RobotAudio) window.RobotAudio.click();
});
// Ajusta padding do conteúdo principal para não sobrepor a watch-bar
const appContent = document.getElementById('appContent');
function syncWatchBarOffset() {
const collapsed = watchBar.classList.contains('watch-bar-collapsed');
const h = collapsed ? 0 : watchBar.offsetHeight;
if (appContent) appContent.style.paddingTop = h > 0 ? (h + 12) + 'px' : '';
document.body.classList.toggle('watch-bar-active', !collapsed);
}
const ro = new ResizeObserver(syncWatchBarOffset);
ro.observe(watchBar);
syncWatchBarOffset();
// Também atualiza o offset ao colapsar/expandir a barra
const origToggle = watchBar.querySelector('#watchBarToggle');
if (origToggle) {
const prevHandler = origToggle.onclick;
origToggle.addEventListener('click', () => {
setTimeout(syncWatchBarOffset, 320); // após animação
});
}
})();
+107
View File
@@ -0,0 +1,107 @@
<#
.SYNOPSIS
Publica o Carinha no Orange Pi: compila o painel, empacota o código, faz backup na placa,
instala e reinicia o serviço. Configurações e bancos da placa nunca são sobrescritos.
.EXAMPLE
.\deploy.ps1 # compila, publica em root@192.168.1.35:/opt/carinha-pc e reinicia
.\deploy.ps1 -SoPacote # só gera o pacote local (nada vai para a placa)
.\deploy.ps1 -Placa 192.168.1.40 -Usuario carinha
.\deploy.ps1 -Ssh orangepi # usa um Host do ~/.ssh/config (chave própria)
#>
param(
[string]$Placa = '192.168.1.35',
[string]$Usuario = 'root',
[string]$Destino = '/opt/carinha-pc',
[int]$Porta = 8100,
[string]$Ssh = '',
[switch]$SemCompilar,
[switch]$SoPacote
)
# Comandos nativos (npm, tar, scp, ssh) escrevem avisos no stderr; o PowerShell 5.1 trataria isso
# como erro fatal. Por isso o sucesso de cada um é decidido pelo $LASTEXITCODE.
$ErrorActionPreference = 'Continue'
Set-Location $PSScriptRoot -ErrorAction Stop
# Código que vai para a placa. Ficam de fora: configurações (*.json da placa), bancos (*.db),
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
$arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py',
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'version.json',
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
)
if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' }
# Telas antigas (até 0.7) que ainda podem estar na placa; vão para o backup e saem da pasta.
$legado = 'dashboard.html dashboard.css dashboard.js overview.js painel.html painel.css painel-controle.js ' +
'painel-video.js painel-rastreamento.js painel-conversa.js conversa.html conversa.css emojis.html biblioteca.js ' +
'treinamento.html treinamento.js treinamento.css braco.html braco.css braco.js braco-calibracao.js ' +
'braco-movimentos.js movimentos-core.js servos.html servos.css servos.js api.html api-painel.js ' +
'rastreamento-core.js audio-robot.js'
if (-not $SemCompilar) {
Write-Host '== Compilando o painel (web/)'
Push-Location web
try {
if (-not (Test-Path node_modules)) { npm ci; if ($LASTEXITCODE) { throw 'npm ci falhou' } }
npm run check; if ($LASTEXITCODE) { throw 'svelte-check encontrou erros' }
npm test; if ($LASTEXITCODE) { throw 'testes do painel falharam' }
npm run build; if ($LASTEXITCODE) { throw 'build do painel falhou' }
} finally { Pop-Location }
}
if (-not (Test-Path 'web/dist/index.html')) { throw 'web/dist não existe: rode sem -SemCompilar' }
$versao = (Get-Content version.json -Raw -ErrorAction Stop | ConvertFrom-Json).versao
$carimbo = Get-Date -Format 'yyyyMMdd-HHmmss'
$pacote = Join-Path $env:TEMP "carinha-$versao-$carimbo.tar.gz"
Write-Host "== Empacotando $versao em $pacote"
$faltando = $arquivos | Where-Object { -not (Test-Path $_) }
if ($faltando) { throw "Arquivos ausentes: $($faltando -join ', ')" }
& tar -czf $pacote @arquivos web/dist
if ($LASTEXITCODE) { throw 'tar falhou' }
$hash = (Get-FileHash $pacote -Algorithm SHA256 -ErrorAction Stop).Hash.ToLower()
Write-Host " SHA256 $hash"
if ($SoPacote) {
& tar -tzf $pacote | Measure-Object | ForEach-Object { Write-Host " $($_.Count) arquivos no pacote" }
Write-Host 'Pacote pronto (nada enviado à placa).'
return
}
$alvo = if ($Ssh) { $Ssh } else { "$Usuario@$Placa" }
$remoto = "/tmp/carinha-$versao-$carimbo.tar.gz"
Write-Host "== Enviando para $alvo"
& scp -q $pacote "${alvo}:$remoto"
if ($LASTEXITCODE) { throw 'scp falhou' }
# Na placa: backup do código atual, remove telas antigas, extrai o novo e reinicia.
$script = @"
set -e
cd '$Destino'
backup='$Destino/backup-$carimbo'
mkdir -p "`$backup"
for f in $($arquivos -join ' ') $legado; do [ -e "`$f" ] && cp -a --parents "`$f" "`$backup/" || true; done
[ -d web/dist ] && mkdir -p "`$backup/web" && cp -a web/dist "`$backup/web/" || true
echo "$hash $remoto" | sha256sum -c --quiet
rm -f $legado
rm -rf web/dist
tar -xzf '$remoto' -C '$Destino'
rm -f '$remoto'
systemctl restart carinha-pc
echo "backup em `$backup"
"@
Write-Host '== Instalando na placa'
$script -replace "`r", '' | & ssh $alvo 'bash -s'
if ($LASTEXITCODE) { throw 'instalação na placa falhou (o backup foi feito antes de qualquer remoção)' }
Write-Host '== Conferindo'
$url = "http://${Placa}:$Porta"
for ($i = 0; $i -lt 10; $i++) {
try {
$saude = Invoke-RestMethod "$url/app/health" -TimeoutSec 3
if ($saude.versao -eq $versao) { Write-Host "OK: $url/ responde versão $versao"; return }
} catch { }
Start-Sleep -Seconds 1
}
throw "O serviço não respondeu a versão $versao em $url. Veja: ssh $alvo journalctl -u carinha-pc -n 50"
+2
View File
@@ -1,3 +1,5 @@
> **Histórico.** Este dashboard foi substituído pelo painel 0.8 em Svelte: veja [PAINEL.md](PAINEL.md).
# Dashboard Carinha — 05/10/2026
Objetivo: unificar todas as páginas de operação em tema escuro/ciano com navegação lateral. A tela do celular continua em tela cheia.
+17 -5
View File
@@ -1,9 +1,21 @@
# Repositório do Carinha
Código em https://git.roterizacao.cloud/PSAutomation/painel-ia-gemini, branch `carinha-orange-pi`, pasta `carinha/`.
O repositório já contém outro sistema; sua branch principal foi preservada.
Código em https://git.roterizacao.cloud/julio/carinha-control-panel, branch `main`.
(Antes vivia em `PSAutomation/painel-ia-gemini`, branch `carinha-orange-pi`, pasta `carinha/`; aquele repositório não é mais a fonte.)
Credenciais, chave de assinatura Android, bases de conversa, logs, configurações do aparelho e arquivos gerados não são publicados.
## Credenciais do Git
Nunca coloque usuário/senha na URL do remote (`https://usuario:senha@...`): ela fica em texto claro em `.git/config`.
Use a URL limpa e deixe o Git Credential Manager guardar o login:
```
git remote set-url origin https://git.roterizacao.cloud/julio/carinha-control-panel.git
git config --global credential.helper manager
```
No primeiro `git push` o GCM pede o login (ou um token de acesso do Gitea, preferível à senha) e o guarda no cofre do Windows.
## O que não é publicado
Credenciais, chave de assinatura Android, bases de conversa, logs, configurações do aparelho e arquivos gerados.
Modelos MediaPipe podem ser obtidos com `python obter-rastreamento.py`. As bibliotecas nativas llama.cpp ARM64 e a chave de assinatura Android precisam ser fornecidas separadamente para compilar o APK; permanecem na instalação atual. O servidor Python e a interface HTML estão no código versionado. Gemini usa GEMINI_API_KEY no ambiente do serviço.
Esta publicação não altera a instalação ativa no Orange Pi.
+1 -1
View File
@@ -2,7 +2,7 @@
Implantação em 2026-10-05. IP atual: 192.168.1.35.
- Painel: http://192.168.1.35:8100/painel
- Painel: http://192.168.1.35:8100/ (publicar com `deploy.ps1`)
- Diretório: /opt/carinha-pc
- Serviço: carinha-pc.service, habilitado para iniciar no boot.
- Sistema: Armbian Debian 13, Python 3.13.5, arquitetura aarch64.
+57
View File
@@ -0,0 +1,57 @@
# Painel 0.8 — Svelte + SSE
Substitui as oito páginas antigas (dashboard, painel, conversa, emojis, treinamento, braço,
servos, api). Os endereços antigos redirecionam para a seção equivalente (`/braco` → `/#/movimento`).
## Arquitetura
- `web/`: Vite + Svelte 5 + TypeScript. `npm run build` gera `web/dist/`, que o servidor entrega em `/`.
`web/dist` não vai para o git; o `deploy.ps1` compila e copia.
- **Estado único** (`web/src/lib/estado.svelte.ts`): uma foto inicial (`GET /estado`) e um `EventSource`
em `/eventos`. Eventos: `expressao`, `telemetria`, `app_config`, `aparencia`, `motores`, `estop`,
`conversa`, `ia`, `reacao`, `treinamento`, `recarregar`. Ao reconectar, o navegador manda
`Last-Event-ID` e o servidor reenvia o que estiver no buffer; o painel ainda recarrega a foto.
- **Comandos**: `POST /comando {tipo, valor}` com `tipo` = `gesto` | `rosto` | `cor` | `reacao` |
`parar_reacao` | `recarregar`. Gestos e rostos são validados no servidor.
- **Prévia do rosto**: um único iframe de `carinha.html?espelho=1&painel=1`, sem polling; o painel repassa
os eventos por `postMessage`. A câmera (WebRTC) só existe na tela Ao vivo, então não há disputa pela sessão.
- **Celular**: `carinha.html` também assina `/eventos` (com `?t=<token>`) e volta ao polling de `/fila`
sozinho se o SSE cair. O batimento `POST /app/status` (1/s) continua.
Carga típica: antes 15–20 pedidos/s ao Orange Pi com o dashboard aberto; agora 1 conexão SSE por
painel + o batimento do celular + a sinalização do vídeo enquanto ele conecta.
## Segurança dos controles
| Controle | Comportamento |
|---|---|
| Parar tudo | `POST /parar-tudo`: desliga as 16 saídas, cancela interpolação, para reações, rosto `calma`. Motores travados (409) até `POST /rearmar`. |
| Homem-morto | O painel renova `POST /braco/mover {angulos, velocidade, ttl_ms: 450}` a cada 150 ms enquanto o botão/slider está pressionado. Sem renovação o watchdog desliga os canais. |
| Velocidade | Interpolação a 50 Hz no servidor (`braco.py`), 5–360 °/s. Sem `velocidade` o comando é direto (compatível com scripts antigos). |
| Dono do PCA9685 | Braço e diagnóstico não disputam o hardware: o segundo recebe 409 enquanto o primeiro tem canais ligados. |
| Aba escondida / E-Stop | O painel solta o braço e desativa o movimento físico. |
| Token | Opcional, recomendado: veja [SEGURANCA.md](SEGURANCA.md). |
## Atalhos
`Ctrl+K` paleta · `1`–`9` gestos favoritos · `Shift+Espaço` ou `Esc Esc` parar tudo ·
`g v/c/r/m/a` navegar · `Espaço` segurado no botão do braço = segurar.
## Design
Tokens em `web/src/lib/tema.css`: tema escuro (padrão) e claro, contraste AA, fontes locais
(Outfit e JetBrains Mono, sem Google Fonts). Vidro só em superfícies flutuantes (barra superior,
paleta, mini-rosto); cards sólidos. Estado nunca só por cor: selos com texto. Navegação lateral a
partir de 1024 px, barra inferior abaixo disso; sem rolagem horizontal a 400 px.
Componentes: `Card`, `Selo`, `Toggle`, `Slider`, `Abas`, `Modal` (`<dialog>` nativo), `Avisos`
(toasts com Desfazer, no lugar de alert/confirm/prompt), `Paleta`, `EStop`, `Gestos`,
`RostoPrevia`, `MiniRosto`, `Miniatura`, `BracoDesenho`.
## Testes
- `tests/test_servidor.py`: token, SSE, `/estado`, `/comando`, fila, E-Stop, trava, interpolação e prazo.
- `web/tests/nucleo.test.ts` (`npm test`): escolha de alvo, encaixe, faixas e sequências do braço.
- `tests/test_painel_web_browser.py`: token, gestos (botão, 1–9, paleta), E-Stop, ajustes, conversa,
memória, reações com desfazer, braço com homem-morto, 400 px e tema.
- `tests/test_rtc_browser.py`: vídeo WebRTC real no painel novo, canal de rastreamento e ajustes por SSE.
+56
View File
@@ -0,0 +1,56 @@
# Segurança do servidor
## Token de controle
Com um token configurado, o servidor (porta 8100) exige `Authorization: Bearer <token>`
em todo POST e nas leituras de dados (`/eventos`, `/estado`, `/fila`, `/app/*`, `/rtc/*`,
`/camera/*`, `/conversa/*`, `/treinamento/*`, `/braco/*`, `/servos/*`, `/ia/status`, `/api/*`).
EventSource e o app Android podem mandar `?t=<token>` na URL.
Continuam públicos apenas: páginas estáticas, `/app/health`, `/faces`, `/gestos`,
`GET /config` (aparência), `/ia/modelo` e `/app.apk`.
Configure **um** dos dois (mínimo 8 caracteres):
```
# /etc/carinha.env, carregado pelo carinha-pc.service (EnvironmentFile=)
CARINHA_TOKEN=troque-por-um-segredo-longo
```
ou `seguranca.json` na pasta do servidor (fora do git):
```json
{"token": "troque-por-um-segredo-longo"}
```
Sem token configurado o servidor fica aberto na rede local, como antes.
## Como ativar (ordem importa)
1. Publique servidor e painel 0.8 (`deploy.ps1`) e instale o APK `0.8-token` no S10.
2. No S10: toque longo › **Token do servidor** › digite o token. O app passa a mandar o token
na página do rosto (`?t=`), nos pedidos Java e no llama-server local (`--api-key`).
3. Na placa: `echo 'CARINHA_TOKEN=...' | sudo tee /etc/carinha.env && sudo chmod 600 /etc/carinha.env`
e `sudo systemctl restart carinha-pc`. O serviço lê o arquivo (`EnvironmentFile=`).
4. Abra o painel: ele pede o token uma vez e guarda só naquele navegador.
O cérebro da placa usa o mesmo token para falar com o llama-server do S10
(ou `CARINHA_IA_CHAVE`, se forem diferentes).
## App Android
- Com token salvo, trocar o servidor por Intent (`adb shell am start ... -e servidor URL`) exige
`-e token <token>`; outro app não consegue redirecionar o rosto.
- Câmera e microfone só são liberados para a página do servidor configurado ou para a cópia local.
## E-Stop
`POST /parar-tudo` desliga as 16 saídas do PCA9685, cancela a interpolação do braço,
para reações e manda `calma` ao rosto. Braço e servos ficam **travados** (HTTP 409) até
`POST /rearmar`.
## Trava de hardware
Braço (`/braco/*`) e diagnóstico (`/servos/*`) usam o mesmo PCA9685 com limites
diferentes. Quem estiver com motores ativos é o dono; o outro recebe 409 até os motores
desligarem (parar, ou ~2 s sem comando).
-14
View File
@@ -1,14 +0,0 @@
[Unit]
Description=Cerebro local Qwen3 Carinha
After=network.target
[Service]
Environment=LD_LIBRARY_PATH=/opt/carinha-ai/llama-b11404
ExecStart=/opt/carinha-ai/llama-b11404/llama-server -m /opt/carinha-ai/models/qwen3-0.6b-q4.gguf --host 127.0.0.1 --port 8101 -c 1024 -t 2 -np 1 --jinja --chat-template-kwargs '{"enable_thinking":false}'
Restart=on-failure
RestartSec=5
Nice=10
[Install]
WantedBy=multi-user.target
+2
View File
@@ -5,6 +5,8 @@ After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/carinha-pc
# CARINHA_TOKEN e GEMINI_API_KEY (opcionais); o '-' ignora o arquivo se não existir
EnvironmentFile=-/etc/carinha.env
ExecStart=/usr/bin/python3 -u /opt/carinha-pc/kiosk-server.py --porta 8100
Restart=on-failure
RestartSec=3
-148
View File
@@ -1,148 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Emojis & Reações · Carinha</title>
<link rel="stylesheet" href="/dashboard.css">
<script src="/dashboard.js" defer></script>
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:14px">
<div>
<h1>Biblioteca de Emojis & Reações</h1>
<p style="margin:4px 0 0">Gerencie expressões visuais, desenhos vetoriais e reações inteligentes para o Carinha.</p>
</div>
<div class="actions">
<button class="primary" id="emojiNew">✨ Criar Nova Reação</button>
<button id="emojiUndo" disabled>↩️ Desfazer</button>
<button id="emojiExport">📦 Exportar JSON</button>
</div>
</div>
<!-- WORKSPACE STICKY: O simulador fica fixado na tela enquanto a biblioteca rola ao lado -->
<div class="emojis-workspace" style="margin-top:16px">
<!-- Coluna Fixa (Sticky Preview) -->
<aside class="sticky-preview-col">
<section class="card sticky-preview-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
Simulador ao Vivo
</h2>
<span class="badge ativo" style="font-size:11px">📌 Sempre Visível</span>
</div>
<iframe id="emojiPreview" title="Prévia de emoji no rosto" style="height:250px;border-radius:var(--raio-md);box-shadow:inset 0 0 30px rgba(0,0,0,0.8),0 0 15px var(--ciano-glow)" src="/carinha.html?espelho=1&audio=0"></iframe>
<div class="actions" style="margin-top:14px">
<button id="emojiStop" class="danger" style="flex:1">🛑 Parar no Celular</button>
<a href="/treinamento" class="primary" style="display:inline-flex;align-items:center;gap:6px;padding:8px 12px;border-radius:var(--raio-sm);font-size:13px">
<span>🎨 Gemini SVG</span>
</a>
</div>
<p class="code-label" style="margin-top:10px;font-size:11.5px;color:var(--muito-fraco);line-height:1.4">
Dica: Ao clicar em "Prévia" em qualquer cartão, a animação acontece aqui mesmo sem você perder a posição de rolagem!
</p>
<p id="emojiState" role="status" aria-live="polite" style="font-size:13px;color:var(--ciano);min-height:22px;margin:8px 0 0"></p>
</section>
</aside>
<!-- Coluna Rolável (Biblioteca de Emojis) -->
<main class="emojis-library-col" style="min-width:0">
<section class="card" style="margin-bottom:20px">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px">
<h2 style="margin:0">Catálogo de Reações do Robô</h2>
<span class="badge ativo" id="emojiCount">—</span>
</div>
<p style="margin:6px 0 12px;font-size:13px">Reações salvas entram no repertório da conversa com o Gemini ou podem ser chamadas manualmente.</p>
<label for="emojiSearch">Filtrar por nome ou situação:</label>
<input id="emojiSearch" placeholder="Ex.: café, feliz, ideias, surpresa, música...">
</section>
<!-- Grade de Cartões de Emojis -->
<div id="emojiLibrary" class="library-grid"></div>
</main>
</div>
<!-- Modal / Dialog de Criação e Edição -->
<div id="emojiDialog" class="dialog-overlay" hidden role="dialog" aria-modal="true" aria-labelledby="emojiEditorTitle">
<form id="emojiEditForm" class="dialog-card">
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--borda);padding-bottom:12px;margin-bottom:18px">
<h2 id="emojiEditorTitle" style="margin:0">Criar Emoji</h2>
<button type="button" id="emojiCancel" style="border:none;background:transparent;font-size:18px;cursor:pointer">✕</button>
</div>
<label for="emojiName">Nome da Reação</label>
<input id="emojiName" required maxlength="50" placeholder="Ex.: Café Matinal, Ideia Brilhante">
<label for="emojiReason">Quando Usar (Situação para o Gemini)</label>
<textarea id="emojiReason" required maxlength="200" rows="3" placeholder="Ex.: Quando o usuário falar sobre café, cansaço ou manhã"></textarea>
<label for="emojiCharacter">Caractere ou Desenho</label>
<select id="emojiCharacter">
<option value="">Manter desenho original</option>
</select>
<!-- Grade de Atalhos Rápidos de Emojis -->
<div style="margin:6px 0 16px">
<span style="font-size:12px;color:var(--fraco)">Atalhos rápidos:</span>
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px" id="quickEmojiBar">
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="💡">💡</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="☕">☕</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="❤️">❤️</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="⭐">⭐</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🚀">🚀</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🤖">🤖</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🕶️">🕶️</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🔥">🔥</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🐱">🐱</button>
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="😴">😴</button>
</div>
</div>
<label for="emojiLocation">Posição na Tela do Carinha</label>
<select id="emojiLocation">
<option value="olhos">Dentro dos dois olhos</option>
<option value="ao_redor">Ao lado do rosto</option>
</select>
<p class="code-label" style="font-size:12px;margin:12px 0 18px">
Escolher um emoji substitui o desenho SVG. Você poderá desfazer a qualquer momento.
</p>
<div class="actions" style="justify-content:flex-end">
<button type="button" onclick="document.getElementById('emojiCancel').click()">Cancelar</button>
<button class="primary" type="submit">Salvar Reação</button>
</div>
<p id="emojiEditState" role="status" style="margin-top:10px;font-size:13px;color:var(--vermelho)"></p>
</form>
</div>
<script src="/biblioteca.js" defer></script>
<script>
// Atalho para preencher emoji clicando na barra de atalhos rápidos
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#quickEmojiBar button').forEach(btn => {
btn.onclick = () => {
const val = btn.getAttribute('data-val');
const sel = document.getElementById('emojiCharacter');
if (sel && val) {
let found = false;
for (let opt of sel.options) {
if (opt.value === val) { opt.selected = true; found = true; break; }
}
if (!found) {
sel.add(new Option(val, val, true, true));
}
}
};
});
});
</script>
</body>
</html>
+70
View File
@@ -0,0 +1,70 @@
"""Hub Server-Sent Events: um único fluxo /eventos substitui os pollings do painel."""
from collections import deque
import json
import threading
import time
from urllib.parse import parse_qs, urlsplit
PING = 15
class Eventos:
def __init__(self, buffer=200):
self.cond = threading.Condition()
self.seq = 0
self.buffer = deque(maxlen=buffer)
self.fechado = False
def publicar(self, tipo, dados):
corpo = json.dumps(dados, ensure_ascii=False)
with self.cond:
self.seq += 1
self.buffer.append((self.seq, tipo, corpo))
self.cond.notify_all()
return self.seq
def desde(self, seq):
with self.cond:
return [e for e in self.buffer if e[0] > seq]
def fechar(self):
with self.cond:
self.fechado = True
self.cond.notify_all()
def atender(self, h):
url = urlsplit(h.path)
try:
ultimo = int(h.headers.get('Last-Event-ID') or parse_qs(url.query).get('desde', ['0'])[0])
except ValueError:
ultimo = 0
with self.cond:
# Cliente novo (ou que perdeu demais): começa do agora e lê /estado.
if ultimo <= 0 or (self.buffer and ultimo < self.buffer[0][0] - 1) or ultimo > self.seq:
ultimo = self.seq
h.send_response(200)
h.send_header('Content-Type', 'text/event-stream; charset=utf-8')
h.send_header('Cache-Control', 'no-store')
h.send_header('X-Accel-Buffering', 'no')
h.end_headers()
try:
h.wfile.write(f'retry: 3000\nevent: pronto\nid: {ultimo}\ndata: {{"seq": {ultimo}}}\n\n'.encode())
while True:
limite = time.monotonic() + PING
with self.cond:
while not self.fechado and (not self.buffer or self.buffer[-1][0] <= ultimo):
resta = limite - time.monotonic()
if resta <= 0:
break
self.cond.wait(resta)
if self.fechado:
return
novos = [e for e in self.buffer if e[0] > ultimo]
if not novos:
h.wfile.write(b': ping\n\n')
continue
for seq, tipo, corpo in novos:
h.wfile.write(f'id: {seq}\nevent: {tipo}\ndata: {corpo}\n\n'.encode())
ultimo = seq
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
return
+4 -4
View File
@@ -1,11 +1,11 @@
param([int]$Porta = 8100, [switch]$SemAbrir)
param([string]$Placa = '192.168.1.35', [int]$Porta = 8100, [switch]$SemAbrir)
$ErrorActionPreference = 'Stop'
$url = "http://192.168.1.35:$Porta"
$url = "http://${Placa}:$Porta"
try {
$health = Invoke-RestMethod "$url/app/health" -TimeoutSec 5
if ($health.projeto -ne 'carinha-pc') { throw 'Servidor inesperado' }
} catch {
throw "Orange Pi indisponível em $url. Confira alimentação, rede e o serviço carinha-pc. Nenhum servidor será iniciado no PC."
}
Write-Host "Painel no Orange Pi: $url/painel"
if (-not $SemAbrir) { Start-Process "$url/painel" }
Write-Host "Painel Carinha $($health.versao): $url/"
if (-not $SemAbrir) { Start-Process "$url/" }
+322 -134
View File
@@ -1,38 +1,39 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
kiosk-server.py — servidor local do PC: painel, carinha e fila de ordens.
kiosk-server.py — servidor do Carinha (Orange Pi, porta 8100): painel, rosto e controle.
Alem de entregar os arquivos da pasta (padrao /root/carinha), ele expõe:
Estado em tempo real:
GET /eventos -> Server-Sent Events (expressao, telemetria, conversa, reacao,
ia, motores, estop, aparencia, app_config, treinamento)
GET /estado -> foto de tudo para o primeiro carregamento (inclui 'seq')
GET /fila -> devolve e limpa a fila, em JSON: ["feliz","piscar"]
GET /gesto/<nome> -> poe <nome> na fila (ex.: /gesto/coracao)
POST /gesto -> corpo = nome do gesto
Comandos:
POST /comando -> {"tipo": "gesto"|"rosto"|"cor"|"reacao"|"parar_reacao"|"recarregar", "valor": ...}
POST /parar-tudo -> E-Stop: desliga todos os servos, para reações e acalma o rosto
POST /rearmar -> libera os motores depois do E-Stop
GET /fila?aparelho=1 -> ordens pendentes para o celular (até o rosto migrar para /eventos)
A pagina da carinha pergunta /fila a cada meio segundo; assim da para mandar
um gesto do PC com um curl, igual ao antigo FIFO /tmp/eilik.fifo:
Autenticação: se CARINHA_TOKEN (ambiente) ou seguranca.json {"token": ...} existir, toda
rota de controle/dados exige "Authorization: Bearer <token>" (ou ?t=<token> no EventSource
e no app). Sem token configurado o servidor fica aberto como antes.
curl http://orangepi:8099/gesto/feliz
Alem disso guarda a APARENCIA da carinha (rosto + cor dos olhos), para o painel
personalizar e a pagina aplicar sozinha ao carregar:
GET /config -> {"rosto":"carinha","cor":[92,214,255]}
POST /config -> corpo JSON {"rosto":"miudo","cor":[255,120,0]}
E serve o painel de controle em http://<maquina>:8099/painel .
So usa a biblioteca padrao (http.server). Nada de instalacao extra.
Só usa a biblioteca padrão. Nada de instalação extra.
"""
import argparse
from collections import deque
import json
import os
import threading
import time
import uuid
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import unquote
from pathlib import Path
from controle_pc import ControlePC
from comum import autorizado, carregar_token, gravar_atomico, ler_json
from controle_pc import ControlePC, versao
from eventos import Eventos
from servos import ControleServos
from braco import Braco
from cerebro import Cerebro
@@ -41,21 +42,81 @@ from treinamento import Treinamento
from api_config import ConfigAPI
TRAVA = threading.Lock()
FILA = []
# Espelho da biblioteca de rostos do carinha.html (para /faces funcionar)
ROSTOS = ["eilik", "carinha", "miudo"]
ROSTOS = ["vivo", "bloco", "matriz", "lente", "eilik", "carinha", "miudo"]
# Formato do olho nos rostos novos (rosto-motor.js)
FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"]
# Espelho dos gestos do carinha.html (para o painel montar os botoes)
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
"dormir", "acordar", "aleatorio", "parar"]
# Ordens internas que o rosto entende mas não viram botão.
ORDENS_INTERNAS = {"calma"}
# Apar┌┐ncia atual da carinha (personalizacao pelo painel). Fica gravada num
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
# arquivo ao lado do servidor, para sobreviver a reinicios.
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255]}
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255], "formato": "padrao"}
ARQUIVO_APARENCIA = "aparencia.json"
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js",
"/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task",
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm",
"/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"}
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
class Fila:
"""Ordens para o celular: limitada e com validade, para não repetir ordens velhas
quando o aparelho volta depois de um tempo offline."""
def __init__(self, maximo=20, validade=10.0):
self.lock = threading.Lock()
self.itens = deque(maxlen=maximo)
self.validade = validade
def adicionar(self, ordem, agora=None):
with self.lock:
if ordem == "recarregar" and any(o == ordem for _, o in self.itens):
return
self.itens.append((agora if agora is not None else time.monotonic(), ordem))
def consumir(self, aparelho=True, agora=None):
"""O celular leva tudo; um rosto aberto no navegador (kiosk) leva tudo menos 'recarregar'."""
agora = agora if agora is not None else time.monotonic()
with self.lock:
validos = [(t, o) for t, o in self.itens if agora - t <= self.validade]
pendentes = [o for _, o in validos if aparelho or o != "recarregar"]
self.itens.clear()
if not aparelho:
self.itens.extend((t, o) for t, o in validos if o == "recarregar")
return pendentes
def ordens(self):
with self.lock:
return [o for _, o in self.itens]
def limpar(self):
with self.lock:
self.itens.clear()
FILA = Fila()
EVENTOS = Eventos()
ESTOP = {"ativo": False, "desde": None}
def enfileirar(ordem):
"""Caminho único de uma ordem para o rosto: fila do celular + evento para os painéis."""
FILA.adicionar(ordem)
EVENTOS.publicar("expressao", {"ordem": ordem})
def carregar_aparencia():
try:
@@ -64,6 +125,8 @@ def carregar_aparencia():
if isinstance(dados, dict):
if dados.get("rosto") in ROSTOS:
APARENCIA["rosto"] = dados["rosto"]
if dados.get("formato") in FORMATOS:
APARENCIA["formato"] = dados["formato"]
cor = dados.get("cor")
if isinstance(cor, list) and len(cor) == 3:
APARENCIA["cor"] = [max(0, min(255, int(v))) for v in cor]
@@ -73,14 +136,20 @@ def carregar_aparencia():
def salvar_aparencia():
try:
with open(ARQUIVO_APARENCIA, "w", encoding="utf-8") as f:
json.dump(APARENCIA, f, ensure_ascii=False, indent=2)
except Exception:
gravar_atomico(ARQUIVO_APARENCIA, APARENCIA)
except OSError:
pass
def validar_cor(valor):
cor = [max(0, min(255, int(v))) for v in list(valor)[:3]]
if len(cor) != 3:
raise ValueError("Cor precisa de três números")
return cor
def aplicar_aparencia(dados):
"""Guarda rosto/cor e poe as ordens na fila para a pagina aplicar na hora."""
"""Guarda rosto/cor e manda as ordens para a página aplicar na hora."""
ordens = []
with TRAVA:
if "rosto" in dados:
@@ -88,26 +157,50 @@ def aplicar_aparencia(dados):
if nome in ROSTOS:
APARENCIA["rosto"] = nome
ordens.append("rosto " + nome)
if "formato" in dados:
nome = str(dados["formato"]).strip().lower()
if nome in FORMATOS:
APARENCIA["formato"] = nome
ordens.append("formato " + nome)
if "cor" in dados:
try:
cor = [max(0, min(255, int(v))) for v in list(dados["cor"])[:3]]
if len(cor) == 3:
APARENCIA["cor"] = cor
ordens.append("cor %d,%d,%d" % tuple(cor))
except Exception:
APARENCIA["cor"] = validar_cor(dados["cor"])
ordens.append("cor %d,%d,%d" % tuple(APARENCIA["cor"]))
except (TypeError, ValueError):
pass
FILA.extend(ordens)
aparencia = dict(APARENCIA)
for ordem in ordens:
enfileirar(ordem)
if ordens:
salvar_aparencia()
EVENTOS.publicar("aparencia", aparencia)
return ordens
def ordem_de_comando(tipo, valor):
"""Traduz {tipo, valor} para a ordem de texto que o rosto entende; ValueError se inválido."""
if tipo == "gesto":
nome = str(valor or "").strip().lower()
if nome not in GESTOS and nome not in ORDENS_INTERNAS:
raise ValueError("Gesto desconhecido: " + nome)
return nome
if tipo == "rosto":
nome = str(valor or "").strip().lower()
if nome not in ROSTOS:
raise ValueError("Rosto desconhecido: " + nome)
return "rosto " + nome
raise ValueError("Tipo de comando inválido")
class Handler(SimpleHTTPRequestHandler):
extensoes_map = {".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
".mp4": "video/mp4"}
def guess_type(self, caminho):
@@ -120,32 +213,71 @@ class Handler(SimpleHTTPRequestHandler):
self.send_header("Content-Type", tipo)
self.send_header("Content-Length", str(len(dados)))
self.send_header("Cache-Control", "no-store")
if codigo == 401:
self.send_header("WWW-Authenticate", 'Bearer realm="carinha"')
self.end_headers()
self.wfile.write(dados)
def json(self, dados, codigo=200):
self.enviar(json.dumps(dados, ensure_ascii=False), codigo=codigo)
def rota(self):
return self.path.split("?", 1)[0]
def acesso_negado(self, metodo):
"""True (e já respondeu 401) se a rota exige token e o pedido não trouxe o certo."""
rota = self.rota()
protegida = metodo == "POST" or (rota not in PUBLICAS_GET and rota.startswith(PREFIXOS_API))
if protegida and not autorizado(self):
self.json({"erro": "Token ausente ou inválido"}, 401)
return True
return False
def modulos(self):
s = self.server
return [getattr(s, nome) for nome in ("api_config", "treinamento", "conversa", "cerebro",
"braco", "servos", "controle") if hasattr(s, nome)]
def estado(self):
s = self.server
host = self.headers.get("Host", "").split(":")[0]
with TRAVA:
aparencia = dict(APARENCIA)
dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)),
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS, formatos=FORMATOS)
if hasattr(s, "controle"):
dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy())
if hasattr(s, "servos"):
dados["motores"] = s.servos.motores()
if hasattr(s, "conversa"):
dados["conversa"] = s.conversa.status()
if hasattr(s, "cerebro"):
dados["ia"] = s.cerebro.status()
if hasattr(s, "treinamento"):
dados["reacao"] = s.treinamento.expressao
dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro)
if hasattr(s, "api_config"):
dados["api"] = s.api_config.status()
return dados
def do_GET(self):
if hasattr(self.server, "api_config") and self.server.api_config.rotas(self, "GET"):return
if hasattr(self.server, 'treinamento') and self.server.treinamento.rotas(self, 'GET'):
if self.acesso_negado("GET"):
return
if hasattr(self.server, 'conversa') and self.server.conversa.rotas(self, 'GET'):
for modulo in self.modulos():
if modulo.rotas(self, "GET"):
return
if hasattr(self.server, 'cerebro') and self.server.cerebro.rotas(self, 'GET'):
return
if hasattr(self.server, 'braco') and self.server.braco.rotas(self, 'GET'):
return
if hasattr(self.server, 'servos') and self.server.servos.rotas(self, 'GET'):
return
if self.server.controle.rotas(self, 'GET'):
return
caminho = self.path.split("?", 1)[0]
caminho = self.rota()
partes = [p for p in caminho.split("/") if p]
if caminho == "/eventos":
return EVENTOS.atender(self)
if caminho == "/estado":
return self.json(self.estado())
if partes and partes[0] == "fila":
with TRAVA:
aparelho = 'aparelho=1' in self.path
pendentes = [x for x in FILA if x!='recarregar' or aparelho]
FILA[:] = [x for x in FILA if x=='recarregar' and not aparelho]
return self.enviar(json.dumps(pendentes))
# Painéis não leem a fila: observam o rosto por /eventos.
return self.enviar(json.dumps(FILA.consumir(aparelho="aparelho=1" in self.path)))
if partes and partes[0] == "faces":
return self.enviar(json.dumps({"rostos": ROSTOS}))
@@ -156,12 +288,6 @@ class Handler(SimpleHTTPRequestHandler):
if partes and partes[0] == "gestos":
return self.enviar(json.dumps({"gestos": GESTOS}))
if len(partes) == 2 and partes[0] == "face":
nome = unquote(partes[1]).lower()
with TRAVA:
FILA.append("rosto " + nome)
return self.enviar(json.dumps({"ok": True, "rosto": nome}))
if partes and partes[0] == "diag":
# a propria pagina relata o tamanho que recebeu (ajuda a achar
# barras/enquadramento errado no kiosk). Aparece no journal.
@@ -169,92 +295,142 @@ class Handler(SimpleHTTPRequestHandler):
print("DIAG kiosk:", info, flush=True)
return self.enviar(json.dumps({"ok": True}))
if len(partes) == 2 and partes[0] == "gesto":
nome = unquote(partes[1]).lower() # aceita %20 para espacos
with TRAVA:
FILA.append(nome)
return self.enviar(json.dumps({"ok": True, "gesto": nome}))
# Obsoletos (GET que muda estado); ficam até o painel antigo sair. Use POST /comando.
if len(partes) == 2 and partes[0] in ("gesto", "face"):
tipo = "gesto" if partes[0] == "gesto" else "rosto"
try:
ordem = ordem_de_comando(tipo, unquote(partes[1]))
except ValueError as e:
return self.json({"erro": str(e)}, 400)
enfileirar(ordem)
return self.json({"ok": True, partes[0]: ordem})
if caminho in ("/dashboard", "/emojis", "/api"):
self.path = caminho + ".html"
elif caminho == "/painel":
self.path = "/painel.html" # painel de controle
elif caminho == '/servos':
self.path = '/servos.html'
elif caminho == '/conversa':
self.path = '/conversa.html'
elif caminho == '/treinamento':
self.path = '/treinamento.html'
elif caminho == '/braco':
self.path = '/braco.html'
elif caminho in ("/", ""):
self.path = "/dashboard.html"
# Painel (Vite/Svelte compilado em web/dist) em "/"; o resto é lista branca.
painel = Path("web/dist")
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"
elif caminho.startswith("/assets/"):
nome = caminho[len("/assets/"):]
if "/" in nome or "\\" in nome or nome.startswith(".") or not (painel / "assets" / nome).is_file():
return self.json({"erro": "Arquivo não disponível"}, 404)
self.path = "/web/dist/assets/" + nome
elif caminho in ("/dashboard", "/painel", "/conversa", "/emojis", "/treinamento", "/braco", "/servos", "/api"):
# Endereços das telas antigas levam à seção equivalente do painel novo.
secao = {"/conversa": "conversa", "/emojis": "reacoes", "/treinamento": "reacoes", "/braco": "movimento",
"/servos": "movimento", "/api": "ajustes"}.get(caminho, "ao-vivo")
self.send_response(302)
self.send_header("Location", "/#/" + secao)
self.send_header("Content-Length", "0")
self.end_headers()
return
elif caminho == '/qualidade-auto.js':
self.path = '/android/app/assets/qualidade-auto.js'
elif caminho == '/app.apk':
self.path = '/android/carinha-rosto.apk'
elif caminho in (
'/vendor/mediapipe/vision_bundle.mjs', '/vendor/mediapipe/face.tflite', '/vendor/mediapipe/hands.task',
'/vendor/mediapipe/wasm/vision_wasm_internal.js', '/vendor/mediapipe/wasm/vision_wasm_internal.wasm',
'/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js', '/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm'):
pass
elif caminho in ('/dashboard.html','/emojis.html','/api.html','/dashboard.css','/dashboard.js','/overview.js','/biblioteca.js','/api-painel.js','/painel.css','/conversa.css','/treinamento.css','/servos.css','/braco.css','/audio-robot.js'):
pass
elif caminho not in ('/conversa.html', '/treinamento.html', '/treinamento.js', '/reacoes-render.js', '/painel-conversa.js', '/braco-calibracao.js', '/movimentos-core.js', '/braco-movimentos.js', '/braco.html', '/braco.js', '/servos.html', '/servos.js', '/carinha.html', '/camera.html', '/painel.html', '/painel-controle.js', '/painel-video.js',
'/rastreamento-core.js', '/rastreador-worker.js', '/painel-rastreamento.js', '/audio-robot.js'):
elif caminho not in ESTATICOS:
return self.enviar(json.dumps({'erro': 'Arquivo não disponível'}), codigo=404)
return super().do_GET()
def do_POST(self):
if hasattr(self.server, "api_config") and self.server.api_config.rotas(self, "POST"):return
if hasattr(self.server, 'treinamento') and self.server.treinamento.rotas(self, 'POST'):
if self.acesso_negado("POST"):
return
if hasattr(self.server, 'conversa') and self.server.conversa.rotas(self, 'POST'):
for modulo in self.modulos():
if modulo.rotas(self, "POST"):
return
if hasattr(self.server, 'cerebro') and self.server.cerebro.rotas(self, 'POST'):
return
if hasattr(self.server, 'braco') and self.server.braco.rotas(self, 'POST'):
return
if hasattr(self.server, 'servos') and self.server.servos.rotas(self, 'POST'):
return
if self.server.controle.rotas(self, 'POST'):
return
rota = self.path.split("?", 1)[0].rstrip("/")
tamanho = int(self.headers.get("Content-Length") or 0)
if rota == '/app/recarregar':
origin=self.headers.get('Origin')
if origin and origin not in ('http://'+self.headers.get('Host',''),'https://'+self.headers.get('Host','')):
return self.enviar(json.dumps(dict(erro='Origem inválida')),codigo=400)
import time
with self.server.controle.lock:
online=time.monotonic()-self.server.controle.status_at<5
import uuid
self.server.controle.recarga = dict(id=uuid.uuid4().hex, confirmada=False)
if not online:return self.enviar(json.dumps(dict(erro='Abra o Carinha e conecte ao servidor primeiro')),codigo=409)
with TRAVA:
if 'recarregar' not in FILA:FILA.append('recarregar')
return self.enviar(json.dumps(dict(ok=True,mensagem='Solicitação enviada. Aguardando o celular recarregar a tela.')))
if rota == "/config":
rota = self.rota().rstrip("/")
try:
dados = json.loads(self.rfile.read(tamanho).decode("utf-8", "ignore") or "{}")
if not isinstance(dados, dict):
raise ValueError("esperava um objeto JSON")
except Exception as e:
return self.enviar(json.dumps({"erro": "json invalido: %s" % e}), codigo=400)
ordens = aplicar_aparencia(dados)
return self.enviar(json.dumps({"ok": True, "aparencia": APARENCIA,
"ordens": ordens}))
if rota == "/comando":
return self.comando(ler_json(self, 2048))
if rota == "/parar-tudo":
ler_json(self, 2048)
return self.json(self.parar_tudo())
if rota == "/rearmar":
ler_json(self, 2048)
return self.json(self.rearmar())
if rota == '/app/recarregar':
ler_json(self, 2048)
return self.recarregar()
if rota == "/config":
return self.json({"ok": True, "ordens": aplicar_aparencia(ler_json(self, 2048)),
"aparencia": APARENCIA})
if rota in ("/gesto", "/face"):
# Obsoleto: corpo é o nome cru. Use POST /comando.
tamanho = int(self.headers.get("Content-Length") or 0)
nome = self.rfile.read(min(tamanho, 200)).decode("utf-8", "ignore")
ordem = ordem_de_comando("gesto" if rota == "/gesto" else "rosto", nome)
enfileirar(ordem)
return self.json({"ok": True, "ordem": ordem})
except ValueError as e:
return self.json({"erro": str(e)}, 400)
return self.json({"erro": "Rota POST desconhecida"}, 404)
if rota not in ("/gesto", "/face"):
return self.enviar(json.dumps({"erro": "rotas POST: /gesto, /face, /config"}), codigo=404)
corpo = self.rfile.read(tamanho).decode("utf-8", "ignore").strip().lower()
if not corpo:
return self.enviar(json.dumps({"erro": "corpo vazio"}), codigo=400)
with TRAVA:
FILA.append(corpo)
return self.enviar(json.dumps({"ok": True, "ordem": corpo}))
def comando(self, dados):
tipo, valor = dados.get("tipo"), dados.get("valor")
s = self.server
if tipo == "rosto":
ordem_de_comando("rosto", valor) # valida e dá erro legível
aplicar_aparencia({"rosto": valor}) # salva, avisa os painéis e manda ao celular
return self.json({"ok": True})
if tipo in ("cor", "formato"):
if not aplicar_aparencia({tipo: valor}):
raise ValueError("Cor inválida" if tipo == "cor" else "Formato inválido")
return self.json({"ok": True})
if tipo == "recarregar":
return self.recarregar()
if tipo in ("reacao", "parar_reacao"):
if not hasattr(s, "treinamento"):
raise ValueError("Reações indisponíveis")
if tipo == "reacao":
if type(valor) is not int:
raise ValueError("ID de reação inválido")
s.treinamento.testar(valor)
else:
with s.treinamento.lock:
s.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=dict(limpar=True))
return self.json({"ok": True})
ordem = ordem_de_comando(tipo, valor)
enfileirar(ordem)
return self.json({"ok": True, "ordem": ordem})
def parar_tudo(self):
s = self.server
if hasattr(s, "braco"):
s.braco.cancelar()
if hasattr(s, "servos"):
with s.servos.lock:
s.servos.travado = True
s.servos.stop()
if hasattr(s, "treinamento"):
with s.treinamento.lock:
s.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=dict(limpar=True))
FILA.limpar()
enfileirar("calma")
ESTOP.update(ativo=True, desde=time.time())
EVENTOS.publicar("estop", dict(ESTOP))
return {"ok": True, "estop": dict(ESTOP)}
def rearmar(self):
s = self.server
if hasattr(s, "servos"):
with s.servos.lock:
s.servos.travado = False
EVENTOS.publicar("motores", s.servos.motores())
ESTOP.update(ativo=False, desde=None)
EVENTOS.publicar("estop", dict(ESTOP))
return {"ok": True, "estop": dict(ESTOP)}
def recarregar(self):
controle = self.server.controle
with controle.lock:
online = time.monotonic() - controle.status_at < 5
controle.recarga = dict(id=uuid.uuid4().hex, confirmada=False)
if not online:
return self.json({"erro": "Abra o Carinha e conecte ao servidor primeiro"}, 409)
FILA.adicionar("recarregar")
EVENTOS.publicar("recarregar", {"id": controle.recarga["id"]})
return self.json({"ok": True, "mensagem": "Solicitação enviada. Aguardando o celular recarregar a tela."})
def log_message(self, formato, *args):
# silencio por padrao (roda em segundo plano); --log liga o registro
@@ -270,6 +446,13 @@ class Servidor(ThreadingHTTPServer):
so o desligamos no Windows."""
daemon_threads = True
allow_reuse_address = (os.name != "nt")
token = None
def ligar_eventos(servidor):
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento"):
if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar
def main():
@@ -282,21 +465,26 @@ def main():
os.chdir(args.pasta)
carregar_aparencia()
servidor = Servidor(("0.0.0.0", args.porta), Handler)
servidor.token = carregar_token(Path.cwd())
servidor.api_config = ConfigAPI(Path.cwd() / 'api-privada.json')
servidor.controle = ControlePC(Path.cwd() / 'app-config.json')
servidor.servos = ControleServos(Path.cwd() / 'servos.json')
servidor.braco = Braco(Path.cwd() / 'braco.json', servidor.servos)
def expressao_ia(gesto):
with TRAVA: FILA.append(gesto)
servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, expressao_ia)
servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, enfileirar)
if servidor.token and not servidor.cerebro.chave:
servidor.cerebro.chave = servidor.token
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
servidor.conversa.treinamento = servidor.treinamento
ligar_eventos(servidor)
servidor.registrar = args.log
print(f"servindo {args.pasta} em http://0.0.0.0:{args.porta}/carinha.html")
print(f"painel de controle em http://0.0.0.0:{args.porta}/painel")
print(f"servindo {args.pasta} em http://0.0.0.0:{args.porta}/")
print("autenticação:", "token obrigatório" if servidor.token else "DESLIGADA (defina CARINHA_TOKEN)")
try:
servidor.serve_forever()
finally:
EVENTOS.fechar()
if __name__ == "__main__":
-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);
+1 -1
View File
@@ -49,6 +49,6 @@
window.eilik.receita=render;
addEventListener('message',e=>{if(e.origin===location.origin&&e.source===parent&&e.data&&e.data.tipo==='previa-reacao')render(e.data.receita);});
if(new URLSearchParams(location.search).get('espelho')==='1'||location.protocol==='file:')return;
async function poll(){if(fetching||window.carinhaAtiva===false)return;fetching=true;try{const response=await fetch('/treinamento/expressao',{cache:'no-store',signal:AbortSignal.timeout(2000)});if(response.ok){const d=await response.json();if(d&&d.id!==last&&Date.now()/1000-d.horario<60){last=d.id;render(d.receita);}}}catch(e){}finally{fetching=false;}}
async function poll(){if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;try{const response=await fetch('/treinamento/expressao',{cache:'no-store',signal:AbortSignal.timeout(2000)});if(response.ok){const d=await response.json();if(d&&d.id!==last&&Date.now()/1000-d.horario<60){last=d.id;render(d.receita);}}}catch(e){}finally{fetching=false;}}
setInterval(poll,1000);
})();
+223
View File
@@ -0,0 +1,223 @@
/* 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();
}
// 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 fecha = Math.min(1, Math.max(e.top, 1 - Math.min(1, p.ab)));
const abre = Math.max(1, p.ab); // surpreso abre além de 1
const amassa = fecha > 0.5 && e.top < 0.5 ? (fecha - 0.5) * 0.2 : 0; // piscada achata e alarga
const w = est.w * p.ry * respira * (1 + amassa);
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
const x = p.cx - w / 2, y = p.cy - h / 2;
c.fillStyle = css(o.cor);
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
caixa(c, x, y, w, h, est.r * p.ry * respira);
c.fill();
c.save();
caixa(c, x - 2, y - 2, w + 4, h + 4, est.r * p.ry);
c.clip();
c.globalCompositeOperation = 'destination-out';
c.fillStyle = '#000';
const sl = p.lado > 0 ? e.slant : -e.slant; // lado +1 = olho esquerdo; canto interno = lado do nariz
const yEsq = y + h * (fecha + sl * 0.5), yDir = y + h * (fecha - sl * 0.5);
c.beginPath(); c.moveTo(x - 4, y - 4); c.lineTo(x + w + 4, y - 4); c.lineTo(x + w + 4, yDir); c.lineTo(x - 4, yEsq); c.closePath(); c.fill();
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
c.globalCompositeOperation = 'source-over';
if (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(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);
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]));
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(o.cor, 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) return desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
if (estilo.lente) { olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); return; }
cctx.clearRect(0, 0, camada.width, camada.height);
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
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();
}
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
window.MotorRosto = { desenhar, esconder, 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; });
+39 -13
View File
@@ -4,6 +4,7 @@ import os
from pathlib import Path
import threading
import time
from comum import Conflito, gravar_atomico, ler_json
def integer(value, lo, hi):
if type(value) is not int or not lo <= value <= hi:
@@ -46,6 +47,9 @@ class ControleServos:
self.hw = hardware
self.error = ''
self.active = {}
self.dono = None # 'braco' ou 'diagnostico': quem está com os motores
self.travado = False # E-Stop: nada se move até rearmar
self.publicar = lambda tipo, dados: None
self.closed = threading.Event()
self.config = [dict(nome=f'Servo {i+1}', canal=i, minimo=1400, maximo=1600, centro=1500) for i in range(6)]
try:
@@ -78,14 +82,33 @@ class ControleServos:
def status(self):
with self.lock:
return dict(disponivel=self.hw is not None, erro=self.error,
servos=[dict(row) for row in self.config], ativos=list(self.active), prazo=2)
servos=[dict(row) for row in self.config], ativos=list(self.active), prazo=2,
dono=self.dono, travado=self.travado)
def motores(self):
with self.lock:
return dict(disponivel=self.hw is not None, erro=self.error, ativos=sorted(self.active),
dono=self.dono, travado=self.travado)
def ligar(self, canais_prazos):
"""Registra canais energizados até o prazo; avisa o painel quando algum canal liga."""
with self.lock:
novos = False
for canal, prazo in canais_prazos:
novos = novos or canal not in self.active
self.active[canal] = prazo
if novos:
self.publicar('motores', self.motores())
def reservar(self, nome):
"""Braço e diagnóstico usam o mesmo PCA9685 com limites diferentes: um de cada vez."""
with self.lock:
if self.travado: raise Conflito('E-Stop ativo: rearme antes de mover')
if self.dono not in (None, nome) and self.active:
raise Conflito(f'Motores em uso por {self.dono}; pare-os primeiro')
self.dono = nome
def save(self, rows):
rows = self.validate(rows)
with self.lock:
if self.active: raise ValueError('Desligue os sinais antes de salvar')
tmp = self.path.with_suffix('.tmp')
tmp.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding='utf-8')
tmp.replace(self.path)
gravar_atomico(self.path, rows)
self.config = rows
return self.status()
def move(self, index, pulse):
@@ -94,20 +117,28 @@ class ControleServos:
row = self.config[index]
pulse = integer(pulse, row['minimo'], row['maximo'])
if self.hw is None: raise OSError('PCA9685 indisponível neste servidor')
self.reservar('diagnostico')
self.hw.pulse(row['canal'], pulse)
self.active[row['canal']] = time.monotonic() + 2
self.ligar([(row['canal'], time.monotonic() + 2)])
return self.status()
def expire(self, now):
with self.lock:
liberou = False
for channel, deadline in list(self.active.items()):
if now >= deadline:
self.hw.off(channel)
del self.active[channel]
liberou = True
if liberou and not self.active:
self.dono = None
self.publicar('motores', self.motores())
def stop(self):
with self.lock:
if self.hw:
for channel in range(16): self.hw.off(channel)
self.active.clear()
self.dono = None
self.publicar('motores', self.motores())
return self.status()
def watch(self):
while not self.closed.wait(.1):
@@ -122,14 +153,7 @@ class ControleServos:
try:
if method == 'GET' and route == '/servos/status': result = self.status()
elif method == 'POST':
origin = h.headers.get('Origin')
if origin and origin not in ('http://' + h.headers.get('Host', ''), 'https://' + h.headers.get('Host', '')):
raise ValueError('Origem não permitida')
if h.headers.get('Content-Type', '').split(';')[0] != 'application/json': raise ValueError('Use JSON')
size = int(h.headers.get('Content-Length', '0'))
if not 0 < size <= 8192: raise ValueError('Corpo inválido')
data = json.loads(h.rfile.read(size))
if not isinstance(data, dict): raise ValueError('Objeto JSON obrigatório')
data = ler_json(h, 8192)
if route == '/servos/config': result = self.save(data.get('servos'))
elif route == '/servos/mover': result = self.move(data.get('indice'), data.get('pulso'))
elif route == '/servos/parar': result = self.stop()
@@ -138,6 +162,8 @@ class ControleServos:
h.enviar(json.dumps({'erro':'Método não permitido'}), codigo=405)
return True
h.enviar(json.dumps(result, ensure_ascii=False))
except Conflito as e:
h.enviar(json.dumps({'erro':str(e)}, ensure_ascii=False), codigo=409)
except (ValueError, TypeError) as e:
h.enviar(json.dumps({'erro':str(e)}), codigo=400)
except OSError as e:
-37
View File
@@ -1,37 +0,0 @@
from playwright.sync_api import sync_playwright
import json
with sync_playwright() as p:
b=p.chromium.launch(headless=True); page=b.new_page(viewport={'width':1400,'height':1000})
cfg=dict(disponivel=True,erro='',calibracao=[dict(canal=i,minimo=1400,maximo=1600) for i in range(8)],posturas=[],articulacoes=[dict(nome=n,canal=c) for n,c in zip(['Garra','Punho 1','Punho 2','Braço superior','Braço inferior','Antebraço','Base'],[0,1,2,3,4,5,7])])
calls=[]; errors=[]
def handle(route):
if route.request.method=='POST':
data=route.request.post_data_json; calls.append((route.request.url,data))
if route.request.url.endswith('/config'): cfg.update(data)
route.fulfill(content_type='application/json',body=json.dumps(cfg))
page.route('**/braco/status',handle); page.route('**/braco/config',handle); page.route('**/braco/mover',handle); page.route('**/braco/parar',handle)
page.on('pageerror',lambda e:errors.append(str(e)))
page.goto('http://192.168.1.35:8100/braco');page.wait_for_selector('#joint6')
assert page.locator('#sliders input').count()==7
assert not page.locator('#enable').is_checked()
page.locator('#joint1').evaluate("e=>{e.value=95;e.dispatchEvent(new Event('input'))}");page.wait_for_timeout(200)
assert calls==[]
page.locator('#enable').check();assert calls==[]
page.locator('#joint1').evaluate("e=>{e.value=85;e.dispatchEvent(new Event('input'))}");page.wait_for_timeout(650)
moves=[d for u,d in calls if u.endswith('/mover')]
assert moves and moves[-1]['angulos'][1]==85,moves
assert len(moves[0]['angulos'])==7
page.locator('#stop').click();page.wait_for_timeout(200)
assert calls[-1][0].endswith('/parar');assert not page.locator('#enable').is_checked()
page.on('dialog',lambda d:d.accept('Teste centro'))
page.locator('#savePose').click();page.wait_for_timeout(250)
assert '1 posturas' in page.locator('#count').inner_text()
page.locator('#enable').check();page.locator('#play').click();page.wait_for_timeout(1100)
assert 'Sequência concluída' in page.locator('#console').inner_text()
page.locator('#stop').click();page.wait_for_timeout(200)
page.screenshot(path='C:/Users/JullioPC/Documents/Codex/2026-10-04/o/outputs/carinha-pc/docs/braco-painel.png',full_page=True)
page.set_viewport_size({'width':390,'height':844})
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth')
assert not errors,errors
print('OK: sete sliders, opt-in, velocidade, parar, salvar/reproduzir, mobile; comandos simulados')
b.close()
+10 -7
View File
@@ -18,6 +18,7 @@ def main():
kiosk.ARQUIVO_APARENCIA = str(Path(tmp) / 'aparencia.json')
server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
server.controle = kiosk.ControlePC(Path(tmp) / 'app.json')
kiosk.ligar_eventos(server) # o rosto recebe ajustes e ordens por SSE
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
@@ -63,21 +64,23 @@ def browser_test(base):
panel = browser.new_page(viewport={'width': 1440, 'height': 1000})
errors = []
panel.on('pageerror', lambda error: errors.append(str(error) + '\n' + error.stack))
panel.goto(base + '/painel')
panel.wait_for_selector('#gestos button')
panel.locator('#ajuste-sensorMic').uncheck()
# Painel novo (web/dist): ajustes do celular e transmissão.
panel.goto(base + '/#/ajustes')
alternar = lambda nome: panel.get_by_role('switch', name=nome).locator('xpath=..').click()
alternar('Microfone Só o nível')
panel.wait_for_timeout(400)
assert panel.request.get(base + '/app/config').json()['sensorMic'] is False
panel.locator('#ajuste-cameraSono').check()
alternar('Câmera durante o sono')
panel.wait_for_timeout(400)
panel.locator('#ajuste-cameraLado').select_option('environment')
panel.get_by_role('combobox', name='Câmera').select_option('environment')
panel.wait_for_timeout(400)
c = panel.request.get(base + '/app/config').json()
assert c['cameraSono'] is True and c['cameraLado'] == 'environment'
panel.get_by_role('button', name='Ligar câmera').click()
panel.get_by_role('link', name='Ao vivo').first.click()
alternar('Transmitir câmera do celular')
panel.wait_for_timeout(500)
assert panel.request.get(base + '/app/config').json()['preview'] is True
panel.get_by_role('button', name='Desligar câmera').click()
alternar('Transmitir câmera do celular')
panel.wait_for_timeout(500)
assert panel.request.get(base + '/app/config').json()['preview'] is False
assert not errors, errors
+13 -15
View File
@@ -46,7 +46,7 @@ class ControleTest(unittest.TestCase):
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.url = 'http://127.0.0.1:%d' % self.server.server_port
kiosk.FILA.clear()
kiosk.FILA.limpar()
def tearDown(self):
self.server.shutdown()
@@ -64,12 +64,12 @@ class ControleTest(unittest.TestCase):
return e.code, e.read()
def test_config_is_persisted_without_consuming_gestures(self):
kiosk.FILA.append('feliz')
kiosk.FILA.adicionar('feliz')
code, body = self.request('/app/config', {'volume': 0.7, 'sompausa': 40})
self.assertEqual(code, 200)
self.assertEqual(json.loads(body)['config']['volume'], 0.7)
self.assertEqual(self.request('/app/config')[0], 200)
self.assertEqual(kiosk.FILA, ['feliz'])
self.assertEqual(kiosk.FILA.ordens(), ['feliz'])
self.assertEqual(json.loads((Path(self.tmp.name) / 'app.json').read_text())['sompausa'], 40)
def test_invalid_configuration_does_not_partially_apply(self):
@@ -86,21 +86,19 @@ class ControleTest(unittest.TestCase):
self.assertTrue(json.loads(body)['online'])
self.assertEqual(json.loads(body)['estado']['rosto'], 'miudo')
def test_frame_is_hidden_when_disabled_and_after_expiry(self):
jpeg = b'\xff\xd8fixture\xff\xd9'
self.assertEqual(self.request('/camera/frame', jpeg, 'image/jpeg')[0], 409)
def test_phone_reports_its_own_ip(self):
self.request('/app/status', {'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F'}})
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '192.168.1.16')
self.request('/app/status', {'aparelho': {'ip': 'não é ip'}})
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '127.0.0.1')
def test_legacy_camera_and_adb_routes_are_gone(self):
self.request('/app/config', {'preview': True})
self.assertEqual(self.request('/camera/frame', jpeg, 'image/jpeg')[0], 200)
self.assertEqual(self.request('/camera/frame'), (200, jpeg))
self.server.controle.frame_at -= 4
self.assertEqual(self.request('/camera/frame')[0], 204)
self.request('/camera/frame', jpeg, 'image/jpeg')
self.request('/app/config', {'preview': False})
self.assertEqual(self.request('/camera/frame')[0], 204)
self.assertEqual(self.request('/camera/frame', b'\xff\xd8x\xff\xd9', 'image/jpeg')[0], 404)
self.assertEqual(self.request('/camera/frame')[0], 404)
self.assertEqual(self.request('/app/connect', {'ip': '127.0.0.1'})[0], 404)
def test_rejects_non_jpeg_and_unknown_settings(self):
self.request('/app/config', {'preview': True})
self.assertEqual(self.request('/camera/frame', b'hello', 'image/jpeg')[0], 400)
self.assertEqual(self.request('/app/config', {'preview': 'false'})[0], 400)
self.assertEqual(self.request('/app/config', {'extra': 1})[0], 400)
-41
View File
@@ -1,41 +0,0 @@
from playwright.sync_api import sync_playwright
import json
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
calls = []
rows = [dict(nome=f'Servo {i+1}',canal=i,minimo=1400,maximo=1600,centro=1500) for i in range(6)]
def handle(route):
if route.request.method == 'POST': calls.append((route.request.url, route.request.post_data_json))
route.fulfill(content_type='application/json',body=json.dumps(dict(disponivel=True,erro='',servos=rows,ativos=[])))
page.route('**/servos/status', handle)
page.route('**/servos/mover', handle)
page.route('**/servos/parar', handle)
page.goto('http://192.168.1.35:8100/servos')
page.wait_for_selector('.card')
live = page.locator('#live')
assert live.count() == 1, 'Modo ao vivo ausente'
assert not live.is_checked()
slider = page.locator('input[type=range]').first
slider.evaluate("e=>{e.value=1450;e.dispatchEvent(new Event('input'))}")
page.wait_for_timeout(200)
assert calls == []
live.check()
assert calls == [], 'Ativar modo não deve mover'
slider.evaluate("e=>{for(let i=0;i<30;i++){e.value=1400+(i%20)*5;e.dispatchEvent(new Event('input'))}e.value=1600;e.dispatchEvent(new Event('input'))}")
page.wait_for_timeout(500)
moves = [data for url,data in calls if url.endswith('/mover')]
assert 1 <= len(moves) <= 6, len(moves)
assert moves[-1] == dict(indice=0,pulso=1600)
page.locator('#stop').click()
page.wait_for_timeout(200)
assert not live.is_checked()
assert calls[-1][0].endswith('/parar')
count = len(calls)
slider.evaluate("e=>{e.value=1400;e.dispatchEvent(new Event('input'))}")
page.wait_for_timeout(200)
assert len(calls) == count
page.locator('input[type=text]').first.fill('Teste')
assert live.is_disabled()
print('OK: live opt-in, limite de envio, último valor, parada cancela fila, alterações bloqueiam; hardware simulado')
browser.close()
-9
View File
@@ -1,9 +0,0 @@
const assert=require('node:assert/strict');
const {sequencias}=require('../movimentos-core.js');
const ranges=Array.from({length:7},()=>[81,99]);
const moves=sequencias(ranges);
assert.equal(moves.length,5);
for(const move of moves){assert.ok(move.passos.length>=3);for(const step of move.passos){assert.equal(step.length,7);step.forEach(v=>assert.ok(v>=81&&v<=99));}}
assert.ok(moves.some(m=>m.passos.some(s=>s.filter(v=>v!==90).length>=3)));
assert.deepEqual(moves[0].passos.at(-1),Array(7).fill(90));
console.log('OK: cinco sequências, limites e coordenação');
+218
View File
@@ -0,0 +1,218 @@
"""Painel novo (web/dist) contra o servidor real: token, SSE, gestos, paleta, E-Stop, ajustes e celular.
Requer `npm run build` em web/. Uso: python tests/test_painel_web_browser.py [pasta-para-capturas]
"""
from playwright.sync_api import sync_playwright, expect
from pathlib import Path
import importlib.util
import os
import sys
import tempfile
import threading
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
os.chdir(ROOT)
spec = importlib.util.spec_from_file_location('kiosk_web', ROOT / 'kiosk-server.py')
kiosk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kiosk)
from braco import Braco
from cerebro import Cerebro
from conversa import Conversa
from servos import ControleServos
from treinamento import Treinamento
TOKEN = 'token-de-teste-123'
class HW:
def __init__(self): self.calls = []
def pulse(self, ch, us): self.calls.append((ch, us))
def off(self, ch): self.calls.append((ch, None))
def main(capturas=None):
assert (ROOT / 'web/dist/index.html').is_file(), 'Rode npm run build em web/ primeiro'
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
kiosk.ARQUIVO_APARENCIA = str(tmp / 'aparencia.json')
server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
server.token = TOKEN
server.controle = kiosk.ControlePC(tmp / 'app.json')
server.servos = ControleServos(tmp / 'servos.json', HW())
server.braco = Braco(tmp / 'braco.json', server.servos)
server.cerebro = Cerebro(tmp / 'ia.json', server.controle, kiosk.enfileirar)
server.conversa = Conversa(tmp / 'memoria.db', server.cerebro)
server.treinamento = Treinamento(tmp / 'treinamento.db', iniciar=False)
server.conversa.treinamento = server.treinamento
kiosk.ligar_eventos(server)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
testar('http://127.0.0.1:%d' % server.server_port, server, capturas)
finally:
kiosk.EVENTOS.fechar()
server.shutdown()
server.server_close()
server.servos.close()
def testar(base, server, capturas):
auth = {'Authorization': 'Bearer ' + TOKEN}
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={'width': 1440, 'height': 960})
erros = []
page.on('pageerror', lambda e: erros.append(str(e)))
page.goto(base + '/')
# 1. token
expect(page.get_by_role('heading', name='Token do Carinha')).to_be_visible()
page.get_by_label('Token').fill(TOKEN)
page.get_by_role('button', name='Entrar').click()
expect(page.get_by_text('Servidor', exact=True)).to_be_visible(timeout=5000)
# 2. telemetria do celular chega por SSE
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
'microfone_ativo': True, 'microfone': 0.3,
'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F', 'android': '12',
'app': '0.8.1', 'wifi': 3, 'rssi': -58}})
expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000)
# Dados que o próprio APK informa aparecem no cartão do celular.
expect(page.get_by_text('samsung SM-G973F')).to_be_visible()
expect(page.get_by_text('192.168.1.16')).to_be_visible()
# 3. gesto pelo dock
kiosk.FILA.limpar()
page.get_by_role('button', name='Feliz').first.click()
expect(page.get_by_text('Expressão: feliz')).to_be_visible(timeout=5000)
assert kiosk.FILA.ordens() == ['feliz'], kiosk.FILA.ordens()
# 4. atalho numérico e paleta
page.locator('body').click(position={'x': 5, 'y': 900})
page.keyboard.press('2')
page.wait_for_timeout(300)
assert kiosk.FILA.ordens()[-1] == 'coracao', kiosk.FILA.ordens()
page.keyboard.press('Control+k')
page.get_by_label('Buscar comando').fill('surpreso')
page.keyboard.press('Enter')
page.wait_for_timeout(300)
assert kiosk.FILA.ordens()[-1] == 'surpreso', kiosk.FILA.ordens()
if capturas:
page.screenshot(path=str(Path(capturas) / 'ao-vivo.png'), full_page=True)
# 5. E-Stop real
page.get_by_role('button', name='Parar tudo').click()
expect(page.get_by_role('button', name='PARADO · Rearmar')).to_be_visible(timeout=5000)
assert kiosk.ESTOP['ativo'] and server.servos.travado
page.get_by_role('button', name='PARADO · Rearmar').click()
expect(page.get_by_role('button', name='Parar tudo')).to_be_visible(timeout=5000)
assert not server.servos.travado
# 6. ajustes salvam sem travar
page.get_by_role('link', name='Ajustes').first.click()
page.get_by_label('Buscar ajuste').fill('boca')
boca = page.get_by_role('switch', name='Mostrar boca')
assert boca.is_checked()
boca.locator('xpath=..').click()
page.wait_for_timeout(500)
assert server.controle.config['boca'] is False
if capturas:
page.get_by_label('Buscar ajuste').fill('')
page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True)
# 6a. rosto: escolher estilo e formato do olho
page.goto(base + '/#/rosto')
page.get_by_role('radio', name='Bloco').click()
page.get_by_role('radio', name='Quadrado', exact=True).click()
page.wait_for_timeout(500)
assert kiosk.APARENCIA['rosto'] == 'bloco' and kiosk.APARENCIA['formato'] == 'quadrado', kiosk.APARENCIA
expect(page.get_by_text('BLOCO · QUADRADO')).to_be_visible()
if capturas:
page.screenshot(path=str(Path(capturas) / 'rosto.png'), full_page=True)
# 6b. conversa: pergunta de hora é respondida pelo relógio (sem IA) e chega por SSE
page.get_by_role('link', name='Conversa').first.click()
page.get_by_label('Mensagem').fill('Que horas são?')
page.get_by_role('button', name='Enviar').click()
expect(page.get_by_text('no horário de São Paulo')).to_be_visible(timeout=5000)
page.get_by_label('Nova lembrança').fill('Meu nome é Júlio')
page.get_by_role('button', name='Adicionar').click()
expect(page.get_by_text('Meu nome é Júlio')).to_be_visible(timeout=5000)
page.get_by_role('tab', name='Rotina e preferências').click()
page.get_by_label('Nova rotina').fill('Treino às 7h')
page.get_by_role('button', name='Adicionar').click()
expect(page.get_by_text('Treino às 7h')).to_be_visible(timeout=5000)
if capturas:
page.screenshot(path=str(Path(capturas) / 'conversa.png'), full_page=True)
# 6c. reações: criar emoji, mostrar no celular, excluir e desfazer pelo toast
page.get_by_role('link', name='Reações').first.click()
page.get_by_role('button', name='Novo emoji').click()
page.get_by_label('Nome').fill('Ideia brilhante')
page.get_by_label('Quando usar').fill('Quando tiver uma ideia')
page.get_by_role('radio', name='🚀').click()
page.get_by_role('button', name='Salvar').click()
cartao = page.locator('li.reacao', has_text='Ideia brilhante')
expect(cartao).to_be_visible(timeout=5000)
cartao.get_by_role('button', name='Mostrar').click()
page.wait_for_timeout(400)
assert server.treinamento.expressao['receita']['nome'] == 'Ideia brilhante'
cartao.get_by_role('button', name='Excluir').click()
expect(cartao).to_have_count(0, timeout=5000)
page.get_by_role('button', name='Desfazer').last.click()
expect(page.locator('li.reacao', has_text='Ideia brilhante')).to_be_visible(timeout=5000)
if capturas:
page.screenshot(path=str(Path(capturas) / 'reacoes.png'), full_page=True)
# 6d. braço: opt-in, homem-morto e desligamento pelo prazo ao soltar
hw = server.servos.hw
page.get_by_role('link', name='Movimento').first.click()
segurar = page.get_by_role('button', name='Segure para ir ao alvo')
expect(segurar).to_be_disabled()
page.get_by_role('switch', name='Ativar movimento físico').locator('xpath=..').click()
expect(segurar).to_be_enabled()
hw.calls.clear()
segurar.hover()
page.mouse.down()
page.wait_for_timeout(700)
assert server.servos.active, 'motores deveriam estar ligados enquanto segura'
expect(page.get_by_text('Motores ligados')).to_be_visible(timeout=3000)
page.mouse.up()
page.wait_for_timeout(900)
assert not server.servos.active, 'motores deveriam desligar sozinhos após soltar'
assert any(us for _, us in hw.calls), hw.calls
if capturas:
page.screenshot(path=str(Path(capturas) / 'movimento.png'), full_page=True)
# E-Stop desativa o movimento físico na hora
page.get_by_role('button', name='Parar tudo').click()
expect(page.get_by_role('switch', name='Ativar movimento físico')).to_be_disabled(timeout=3000)
expect(segurar).to_be_disabled()
page.get_by_role('button', name='PARADO · Rearmar').click()
expect(page.get_by_role('button', name='Parar tudo')).to_be_visible(timeout=3000)
# 7. celular: barra inferior e nada de rolagem horizontal
page.set_viewport_size({'width': 400, 'height': 860})
page.get_by_role('link', name='Ao vivo').click()
page.wait_for_timeout(300)
largura = page.evaluate('document.scrollingElement.scrollWidth')
assert largura <= 400, 'rolagem horizontal: %s' % largura
if capturas:
page.screenshot(path=str(Path(capturas) / 'celular.png'), full_page=True)
# 8. tema claro
page.set_viewport_size({'width': 1280, 'height': 900})
inicial = page.evaluate("document.documentElement.dataset.theme")
outro = 'light' if inicial == 'dark' else 'dark'
page.get_by_role('button', name='Usar tema ' + ('claro' if outro == 'light' else 'escuro')).click()
assert page.evaluate("document.documentElement.dataset.theme") == outro
if capturas:
page.screenshot(path=str(Path(capturas) / ('tema-' + outro + '.png')))
assert not erros, erros
browser.close()
print('OK: token, SSE, dock 1-9, paleta, E-Stop, ajustes otimistas, conversa, memória, reações com desfazer, braço com homem-morto, celular 400px, tema')
if __name__ == '__main__':
main(sys.argv[1] if len(sys.argv) > 1 else None)
-19
View File
@@ -1,19 +0,0 @@
const assert = require('node:assert/strict');
let R;
try { R = require('../rastreamento-core.js'); } catch(e) { assert.fail('Rastreamento ausente'); }
const faces = [{x:.2,y:.1,w:.3,h:.4,score:.9}];
const hands = [Array.from({length:21},()=>({x:.8,y:.6}))];
assert.equal(R.target(faces,hands).tipo,'mao');
assert.equal(R.target(faces,[]).tipo,'rosto');
assert.equal(R.target([],[]),null);
assert.equal(R.target([{x:NaN,y:0,w:1,h:1}],[]),null);
const rect = R.fit(1000,500,640,480);
assert.ok(Math.abs(rect.x - (1000/2 - (500*640/480)/2)) < 1e-9);
assert.equal(rect.y,0);
assert.equal(R.point({x:0,y:0},rect).x,rect.x);
const gate = new R.FreshTarget();
gate.accept({tipo:'mao',x:.8,y:.5,atividade:true},100);
assert.equal(gate.get(200).x,.8);
assert.equal(gate.get(451),null,'Alvo antigo precisa expirar');
assert.equal(gate.accept({x:Infinity,y:0},500),false);
console.log('Rastreamento: prioridade, coordenadas, validação e expiração OK');
+31 -19
View File
@@ -1,4 +1,6 @@
"""Real WebRTC sender and panel in Chromium, using a synthetic camera."""
"""WebRTC real entre o transmissor do app e o painel novo (web/dist), com câmera sintética.
Requer `npm run build` em web/."""
import importlib.util
from pathlib import Path
import sys
@@ -27,6 +29,7 @@ def main():
server = kiosk.Servidor(('127.0.0.1', 0), Handler)
server.controle = kiosk.ControlePC(Path(tmp) / 'config.json')
server.controle.atualizar({'preview': True,'rastrearRosto':False,'rastrearMaos':False})
kiosk.ligar_eventos(server) # telemetria e ajustes chegam ao painel por SSE
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base = 'http://127.0.0.1:' + str(server.server_port)
@@ -62,40 +65,49 @@ def main():
},200);
}""")
sender.request.post(base + '/app/status', data={'rosto':'carinha'})
# Como o celular real, manda batimento a cada 1 s (senão o painel o considera offline).
sender.evaluate("""() => setInterval(() => fetch('/app/status',{method:'POST',
headers:{'Content-Type':'application/json'},body:JSON.stringify({rosto:'carinha'})}), 1000)""")
panel = context.new_page()
panel.on('pageerror', lambda e: errors.append(str(e)))
panel.goto(base + '/painel')
panel.wait_for_function("document.getElementById('cameraVideo').videoWidth > 0", timeout=20000)
first = panel.evaluate("document.getElementById('cameraVideo').getVideoPlaybackQuality().totalVideoFrames")
panel.goto(base + '/')
video = "document.querySelector('.camera video')"
panel.wait_for_function(video + ".videoWidth > 0", timeout=20000)
first = panel.evaluate(video + ".getVideoPlaybackQuality().totalVideoFrames")
panel.wait_for_timeout(2200)
count = panel.evaluate("document.getElementById('cameraVideo').getVideoPlaybackQuality().totalVideoFrames") - first
count = panel.evaluate(video + ".getVideoPlaybackQuality().totalVideoFrames") - first
assert count >= 45, 'Vídeo não está fluido: %s quadros em 2.2 s' % count
assert panel.locator('#cameraVideo').is_visible()
assert 'fps' in panel.locator('#videoQualidade').inner_text()
assert panel.locator('.camera video').is_visible()
qualidade = panel.locator('.camera .rodape .mono').inner_text()
assert 'fps' in qualidade, qualidade
assert not errors, errors
print('Vídeo WebRTC real: %s quadros em 2.2 s; %s' % (count, panel.locator('#videoQualidade').inner_text()))
print('Vídeo WebRTC real: %s quadros em 2.2 s; %s' % (count, qualidade))
# Canal de rastreamento (DataChannel) chega ao transmissor.
sender.request.post(base+'/app/config',data={'rastrearMaos':True})
sender.wait_for_function('config.rastrearMaos === true')
panel.wait_for_function('CarinhaVideo.rastreamentoConectado()')
panel.evaluate("CarinhaVideo.rastrear({tipo:'mao',x:.8,y:.3,atividade:true})")
panel.wait_for_function('window.carinhaVideo?.rastreamentoConectado')
panel.wait_for_timeout(1000) # sem vendor/ o rastreador falha e manda null uma vez
panel.evaluate("carinhaVideo.rastrear({tipo:'mao',x:.8,y:.3,atividade:true})")
sender.wait_for_function("window.testAlvo?.tipo === 'mao' && testAlvo.x === .8")
panel.evaluate('CarinhaVideo.rastrear(null)')
panel.evaluate('carinhaVideo.rastrear(null)')
sender.wait_for_function('window.testAlvo === null')
sender.request.post(base+'/app/config',data={'rastrearMaos':False})
panel.locator('#ajuste-videoResolucao').select_option('480')
# Ajustes chegam ao celular; o painel acompanha por SSE.
sender.request.post(base+'/app/config',data={'videoResolucao':'480'})
sender.wait_for_function("stream && stream.getVideoTracks()[0].getSettings().height === 480", timeout=10000)
panel.locator('#ajuste-videoFPS').select_option('240')
sender.request.post(base+'/app/config',data={'videoFPS':240})
sender.wait_for_function("aviso.includes('indisponível')", timeout=10000)
assert sender.evaluate('stream.getVideoTracks()[0].readyState') == 'live'
panel.locator('#ajuste-sensorCamera').uncheck()
panel.wait_for_function("document.getElementById('cameraVideo').srcObject === null", timeout=10000)
sender.request.post(base+'/app/config',data={'sensorCamera':False})
panel.wait_for_function(video + ".srcObject === null", timeout=10000)
sender.wait_for_function('stream === null', timeout=10000)
panel.locator('#ajuste-sensorCamera').check()
panel.wait_for_function("document.getElementById('cameraVideo').videoWidth > 0 && document.getElementById('cameraVideo').srcObject !== null", timeout=15000)
sender.request.post(base+'/app/config',data={'sensorCamera':True})
panel.wait_for_function(video + ".videoWidth > 0 && " + video + ".srcObject !== null", timeout=15000)
assert not errors, errors
panel.get_by_role('button', name='Desligar câmera').click()
# Desligar pela chave da tela Ao vivo.
panel.get_by_role('switch', name='Transmitir câmera do celular').locator('xpath=..').click()
panel.wait_for_timeout(1400)
assert panel.evaluate("document.getElementById('cameraVideo').srcObject === null")
assert panel.evaluate(video + ".srcObject === null")
preview = context.new_page()
preview.on('pageerror', lambda e: errors.append(str(e)))
preview.goto(base + '/camera.html?lado=user&resolucao=480&fps=30')
+245
View File
@@ -0,0 +1,245 @@
"""Servidor base: token, /eventos (SSE), /estado, /comando, E-Stop, trava de hardware e fila."""
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))
spec = importlib.util.spec_from_file_location('kiosk', ROOT / 'kiosk-server.py')
kiosk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kiosk)
from braco import Braco
from comum import carregar_token
from servos import ControleServos
TOKEN = 'segredo-de-teste'
class HW:
def __init__(self): self.calls = []
def pulse(self, ch, us): self.calls.append((ch, us))
def off(self, ch): self.calls.append((ch, None))
class ServidorTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
pasta = Path(self.tmp.name)
self.hw = HW()
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
self.server.token = TOKEN
self.server.controle = kiosk.ControlePC(pasta / 'app.json')
self.server.servos = ControleServos(pasta / 'servos.json', self.hw)
self.server.braco = Braco(pasta / 'braco.json', self.server.servos)
kiosk.ligar_eventos(self.server)
kiosk.FILA.limpar()
kiosk.ESTOP.update(ativo=False, desde=None)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.porta = self.server.server_port
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.server.servos.close()
self.thread.join()
self.tmp.cleanup()
def pedir(self, metodo, rota, dados=None, token=TOKEN, cabecalhos=None):
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
h = {'Content-Type': 'application/json'}
if token:
h['Authorization'] = 'Bearer ' + token
h.update(cabecalhos or {})
corpo = None if dados is None else json.dumps(dados)
conn.request(metodo, rota, body=corpo, headers=h)
r = conn.getresponse()
texto = r.read()
conn.close()
return r.status, json.loads(texto) if texto else None
# ------------------------------------------------------------- token
def test_rotas_de_controle_exigem_token(self):
for metodo, rota, dados in [('POST', '/braco/mover', {'angulos': [90] * 7}),
('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'}),
('POST', '/parar-tudo', {}),
('GET', '/estado', None), ('GET', '/fila?aparelho=1', None),
('GET', '/app/status', None), ('GET', '/gesto/feliz', None)]:
for token in (None, 'errado'):
codigo, corpo = self.pedir(metodo, rota, dados, token=token)
self.assertEqual(codigo, 401, (metodo, rota, token))
self.assertEqual(self.hw.calls, [])
self.assertEqual(kiosk.FILA.ordens(), [])
def test_rotas_publicas_e_token_na_query(self):
self.assertEqual(self.pedir('GET', '/gestos', token=None)[0], 200)
self.assertEqual(self.pedir('GET', '/app/health', token=None)[0], 200)
self.assertEqual(self.pedir('GET', '/estado?t=' + TOKEN, token=None)[0], 200)
def test_sem_token_configurado_fica_aberto(self):
self.server.token = None
self.assertEqual(self.pedir('GET', '/estado', token=None)[0], 200)
def test_carregar_token(self):
pasta = Path(self.tmp.name)
self.assertIsNone(carregar_token(pasta))
(pasta / 'seguranca.json').write_text(json.dumps({'token': 'abcdefgh123'}))
self.assertEqual(carregar_token(pasta), 'abcdefgh123')
(pasta / 'seguranca.json').write_text(json.dumps({'token': 'curto'}))
with self.assertRaises(ValueError):
carregar_token(pasta)
def test_origem_estrangeira_recusada(self):
codigo, _ = self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'},
cabecalhos={'Origin': 'http://malicioso.example'})
self.assertEqual(codigo, 400)
# ------------------------------------------------------------- comando e fila
def test_comando_valida_e_enfileira(self):
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'miudo'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo'])
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo'])
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
def test_rostos_novos_e_formato_do_olho(self):
kiosk.ARQUIVO_APARENCIA = str(Path(self.tmp.name) / 'aparencia.json')
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'bloco'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'formato', 'valor': 'quadrado'})[0], 200)
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'formato', 'valor': 'triangulo'})[0], 400)
self.assertEqual(kiosk.FILA.ordens(), ['rosto bloco', 'formato quadrado'])
estado = self.pedir('GET', '/estado')[1]
self.assertEqual(estado['aparencia']['formato'], 'quadrado')
self.assertEqual(estado['aparencia']['rosto'], 'bloco') # a escolha fica salva
self.assertIn('pilula', estado['formatos'])
def test_post_face_usa_prefixo_rosto(self):
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
conn.request('POST', '/face', body='miudo', headers={'Authorization': 'Bearer ' + TOKEN})
self.assertEqual(conn.getresponse().status, 200)
conn.close()
self.assertEqual(kiosk.FILA.ordens(), ['rosto miudo'])
def test_fila_limitada_e_com_validade(self):
fila = kiosk.Fila(maximo=3, validade=10)
for i in range(5):
fila.adicionar(f'g{i}', agora=0)
self.assertEqual(fila.ordens(), ['g2', 'g3', 'g4'])
self.assertEqual(fila.consumir(agora=11), [])
fila.adicionar('recarregar'); fila.adicionar('recarregar'); fila.adicionar('feliz')
self.assertEqual(fila.ordens(), ['recarregar', 'feliz'])
# Rosto no navegador não rouba o 'recarregar' do celular.
self.assertEqual(fila.consumir(aparelho=False), ['feliz'])
self.assertEqual(fila.consumir(aparelho=True), ['recarregar'])
# ------------------------------------------------------------- SSE
def test_eventos_entrega_comando(self):
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
conn.request('GET', '/eventos?t=' + TOKEN)
r = conn.getresponse()
self.assertEqual(r.status, 200)
self.assertIn('text/event-stream', r.getheader('Content-Type'))
linhas = []
while not linhas or linhas[-1] != '':
linhas.append(r.fp.readline().decode().rstrip('\n'))
self.assertIn('event: pronto', linhas)
self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'coracao'})
bloco = []
while not bloco or bloco[-1] != '':
bloco.append(r.fp.readline().decode().rstrip('\n'))
conn.close()
self.assertIn('event: expressao', bloco)
self.assertIn('data: {"ordem": "coracao"}', bloco)
def test_estado_agrega(self):
codigo, estado = self.pedir('GET', '/estado')
self.assertEqual(codigo, 200)
for chave in ('versao', 'seq', 'estop', 'aparencia', 'gestos', 'app', 'motores'):
self.assertIn(chave, estado)
self.assertTrue(estado['autenticacao'])
# ------------------------------------------------------------- E-Stop e trava
def test_parar_tudo_desliga_e_trava_ate_rearmar(self):
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
self.hw.calls.clear()
codigo, corpo = self.pedir('POST', '/parar-tudo', {})
self.assertEqual(codigo, 200)
self.assertTrue(corpo['estop']['ativo'])
self.assertEqual({c for c, us in self.hw.calls if us is None}, set(range(16)))
self.assertEqual(kiosk.FILA.ordens(), ['calma'])
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 409)
self.assertEqual(self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})[0], 409)
self.assertEqual(self.pedir('POST', '/rearmar', {})[0], 200)
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
def test_braco_e_diagnostico_nao_disputam_motores(self):
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
codigo, corpo = self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})
self.assertEqual(codigo, 409)
self.assertIn('braco', corpo['erro'])
self.pedir('POST', '/braco/parar', {})
self.assertEqual(self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})[0], 200)
class BracoMovimentoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.hw = HW()
self.servos = ControleServos(Path(self.tmp.name) / 's.json', self.hw)
self.servos.closed.set() # sem threads: os passos são chamados à mão
self.arm = Braco(Path(self.tmp.name) / 'b.json', self.servos)
cal = self.arm.status()
for row in cal['calibracao']:
row.update(minimo=500, maximo=2500, centro=1500)
self.arm.save(dict(calibracao=cal['calibracao'], posturas=[]))
def tearDown(self):
self.tmp.cleanup()
def test_interpolacao_no_servidor_respeita_velocidade(self):
self.arm.move([90] * 7)
resposta = self.arm.move([90, 90, 90, 90, 90, 90, 130], velocidade=100, ttl_ms=2000)
self.assertTrue(resposta['movendo'])
agora = self.arm.lease - 1.5
self.arm.passo(agora, .1) # 100 °/s * 0,1 s = 10°
self.assertEqual(self.arm.atual[6], 100)
for i in range(10):
self.arm.passo(agora + .1 * (i + 1), .1)
self.assertEqual(self.arm.atual[6], 130)
self.assertIsNone(self.arm.alvo)
def test_lease_vencido_interrompe_movimento(self):
self.arm.move([90] * 7)
self.arm.move([90] * 6 + [170], velocidade=50, ttl_ms=300)
self.arm.passo(self.arm.lease + .01, .1)
self.assertIsNone(self.arm.alvo)
self.assertEqual(self.arm.atual[6], 90)
# O watchdog desliga os canais no mesmo prazo.
self.servos.expire(self.arm.lease + .01)
self.assertEqual(self.servos.active, {})
self.assertIsNone(self.servos.dono)
def test_renovar_no_alvo_informa_que_chegou(self):
self.arm.move([90] * 7)
self.hw.calls.clear()
r = self.arm.move([90] * 7, velocidade=60, ttl_ms=400)
self.assertFalse(r['movendo'])
self.assertIsNone(self.arm.alvo)
self.assertEqual(len(self.hw.calls), 8) # reenergiza os 8 canais com o novo prazo
def test_parametros_invalidos(self):
for kwargs in (dict(velocidade=1000), dict(velocidade=True), dict(ttl_ms=50), dict(ttl_ms=5000)):
with self.assertRaises(ValueError):
self.arm.move([90] * 7, **kwargs)
self.assertEqual(self.hw.calls, [])
if __name__ == '__main__':
unittest.main()
+2 -28
View File
@@ -35,7 +35,7 @@ def main():
if not r.request.url.startswith(base): external.append(r.request.url);r.abort()
else:r.continue_()
page.route('**/*',route)
page.goto(base+'/painel')
page.goto(base+'/') # painel novo: nada pode sair para a internet
results=page.evaluate("""async () => {
const worker=new Worker('/rastreador-worker.js');
const send=msg=>new Promise((resolve,reject)=>{
@@ -56,36 +56,10 @@ def main():
assert len(results['hand']['hands'][0])==21
print('Inferencia real: %s rosto(s), %s mao(s); %.1f/%.1f ms' % (
len(results['face']['faces']),len(results['hand']['hands']),results['face']['ms'],results['hand']['ms']))
# Feed the same real worker into the panel, using a local canvas stream.
page.request.post(base+'/app/config',data={'preview':True})
page.request.post(base+'/app/status',data={'rosto':'carinha','dormindo':False})
page.evaluate("""async () => {
const image=await createImageBitmap(await (await fetch('/test-hands')).blob());
const c=document.createElement('canvas');c.width=640;c.height=Math.round(640*image.height/image.width);
const ctx=c.getContext('2d');ctx.drawImage(image,0,0,c.width,c.height);image.close();
window.testStream=c.captureStream(30);
window.testDraw=setInterval(()=>ctx.drawImage(c,0,0),33);
const v=document.getElementById('cameraVideo');v.srcObject=testStream;v.hidden=false;await v.play();
window.testTargets=[];
window.CarinhaVideo={rastrear(t){testTargets.push(t)},parar(){},atualizar(){}};
window.CarinhaRastreamento.atualizar({online:true,estado:{}}, {preview:true,sensorCamera:true,
rastrearRosto:true,rastrearMaos:true,mostrarRastreamento:true,seguirRastreamento:true});
}""")
page.wait_for_function("window.CarinhaRastreamento.estado().result?.hands.length > 0",timeout=15000)
assert page.locator('#trackingOverlay').is_visible()
assert page.evaluate("testTargets.some(t=>t?.tipo==='mao')")
work=ROOT.parents[1]/'work';page.screenshot(path=str(work/'rastreamento-teste.png'),full_page=True)
page.locator('#ajuste-mostrarRastreamento').uncheck()
page.wait_for_function("document.getElementById('trackingOverlay').hidden",timeout=5000)
page.locator('#ajuste-seguirRastreamento').uncheck()
page.wait_for_timeout(1200)
assert page.evaluate('testTargets[testTargets.length-1] === null')
page.evaluate("CarinhaRastreamento.atualizar({online:true,estado:{dormindo:true}},{preview:true,sensorCamera:true,cameraSono:false,rastrearRosto:true,rastrearMaos:true})")
assert not page.evaluate('CarinhaRastreamento.estado().active')
assert not external,external
assert not errors,errors
browser.close()
print('Overlay, alvo, controles, sono e inferencia sem acesso externo: OK')
print('Inferencia real no worker e painel sem acesso externo: OK')
finally:
server.shutdown();server.server_close();thread.join()
-60
View File
@@ -1,60 +0,0 @@
/* ==========================================================================
TREINAMENTO & PERSONALIDADE · ESTILOS
========================================================================== */
.treinamento-grid {
display: grid;
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
gap: 24px;
margin-top: 20px;
}
.rotina {
display: flex;
flex-direction: column;
}
.laboratorio {
display: flex;
flex-direction: column;
}
.laboratorio iframe {
height: 340px;
border-radius: var(--raio-md);
margin: 12px 0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6), inset 0 0 30px rgba(0, 0, 0, 0.8);
}
#diario {
max-height: 420px;
overflow-y: auto;
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
#rotinas {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 380px;
overflow-y: auto;
}
#estado {
font-size: 13.5px;
color: var(--ciano);
min-height: 22px;
}
@media (max-width: 1050px) {
.treinamento-grid {
grid-template-columns: 1fr;
}
.laboratorio {
order: -1;
}
}
-116
View File
@@ -1,116 +0,0 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Treinamento & Personalidade · Carinha</title>
<link rel="stylesheet" href="/dashboard.css">
<link rel="stylesheet" href="/treinamento.css">
<script src="/dashboard.js" defer></script>
</head>
<body>
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
<div>
<h1>Treinamento & Personalidade</h1>
<p style="margin:4px 0 0">Ensine novos hábitos, registre preferências e crie desenhos dinâmicos com o Gemini.</p>
</div>
<div>
<a href="/emojis" class="primary" style="display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:var(--raio-sm)">
<span>✨ Ver Biblioteca de Emojis</span>
</a>
</div>
</div>
<div class="treinamento-grid">
<!-- Seção de Rotina e Preferências -->
<section class="card rotina">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Rotina & Preferências
</h2>
<p>Hábitos e gostos que moldam a convivência com o Carinha.</p>
<form id="rotinaForm" style="margin-top:10px">
<label for="rotinaTexto">O que o Carinha deve saber?</label>
<input id="rotinaTexto" type="text" maxlength="300" required placeholder="Ex.: De manhã gosto de café preto e notícias">
<button class="primary" style="width:100%">Registrar Preferência</button>
</form>
<div id="rotinas"></div>
</section>
<!-- Laboratório de Criação de Desenhos SVG com Gemini -->
<section class="card laboratorio">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="12 8 8 12 12 16 16 12 12 8"/></svg>
Laboratório de Expressão IA
</h2>
<span class="badge" style="font-size:11px">Gemini SVG Studio</span>
</div>
<iframe id="previa" title="Prévia animada do Carinha" src="/carinha.html?espelho=1&audio=0"></iframe>
<p style="font-size:13px;color:var(--fraco)">
Descreva uma ideia visual para o Gemini sintetizar em formas vetoriais no rosto do robô.
</p>
<form id="figurinhaForm" style="margin:8px 0 14px">
<label for="figurinhaTexto">O que você quer desenhar?</label>
<input id="figurinhaTexto" type="text" maxlength="1000" required placeholder="Ex.: Uma lâmpada acendendo com brilhos ou um coração pulsando nos olhos">
<div class="actions" style="margin-top:6px">
<button class="primary" id="criarFigurinha" type="submit">✨ Desenhar e Mostrar</button>
<button id="gerar" type="button">Gerar Reações Automáticas</button>
</div>
</form>
<details class="opcoes" style="margin-top:4px">
<summary>Criação Automática Periódica</summary>
<label style="display:flex;align-items:center;gap:10px;margin-top:10px">
<input id="automatico" type="checkbox">
<span>Criar novas reações a cada hora quando houver conversas ativas</span>
</label>
</details>
<details style="margin-top:12px">
<summary>Prompt enviado ao Gemini (Inspeção Técnica)</summary>
<p style="font-size:12.5px">Orientação de sistema para os desenhos vetoriais:</p>
<pre id="promptOrientacao"></pre>
<h3 style="margin-top:14px">Último Prompt Enviado</h3>
<pre id="ultimoPrompt">Nenhum pedido enviado ainda.</pre>
</details>
<p id="estado" role="status" style="margin-top:12px">Carregando…</p>
</section>
</div>
<!-- Diário & Resumo de Sessões -->
<div class="grid" style="margin-top:24px">
<section class="card">
<details>
<summary>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Resumo das Sessões de Treino
</summary>
<div id="resumos" style="margin-top:12px"></div>
</details>
</section>
<section class="card">
<details>
<summary>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
Diário de Conversas do Robô
</summary>
<p style="font-size:13px;margin:8px 0 12px">Registros gerados a partir dos diálogos anteriores.</p>
<div class="actions" style="margin-bottom:12px">
<button id="limparDiario" type="button" class="danger" style="font-size:12px">🗑️ Apagar Diário e Resumos</button>
</div>
<div id="diario"></div>
</details>
</section>
</div>
<script src="/treinamento.js"></script>
</body>
</html>
-19
View File
@@ -1,19 +0,0 @@
(function(){
const el=id=>document.getElementById(id);let signature='',sending=false;
async function api(path,data){const r=await fetch('/treinamento/'+path,data===undefined?{cache:'no-store'}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const d=await r.json();if(!r.ok)throw Error(d.erro);return d;}
const date=t=>new Date(t*1000).toLocaleString('pt-BR');
function text(tag,value){const n=document.createElement(tag);n.textContent=value;return n;}
function button(label,action){const b=text('button',label);b.onclick=action;return b;}
async function action(path,data){try{await api(path,data);await refresh();return true;}catch(e){el('estado').textContent=e.message;return false;}}
async function refresh(){try{const s=await api('status');el('gerar').disabled=s.ocupado||sending;el('criarFigurinha').disabled=s.ocupado;el('automatico').checked=s.automatico;el('estado').textContent=s.erro?s.erro:s.ocupado?'Gemini está criando novas reações…':s.automatico?'Geração por hora ativada.':'Crie agora ou ative a geração por hora.';
el('promptOrientacao').textContent=s.prompt_orientacao||'';el('ultimoPrompt').textContent=s.ultimo_prompt_figurinha||'Nenhum pedido enviado ainda.';
const key=JSON.stringify([s.rotina,s.reacoes,s.diario,s.resumos]);if(key===signature)return;signature=key;
el('rotinas').replaceChildren();for(const r of s.rotina){const row=text('p',r.texto+' ');row.append(button('Apagar',()=>action('rotina-apagar',{id:r.id})));el('rotinas').append(row);}
el('resumos').replaceChildren();for(const r of s.resumos)el('resumos').append(text('small',date(r.horario)),text('p',r.texto));
el('diario').replaceChildren();for(const m of s.diario)el('diario').append(text('small',date(m.horario)+' · '+(m.papel==='user'?'Você':'Carinha')),text('p',m.texto));
}catch(e){el('estado').textContent=e.message;}}
el('limparDiario').onclick=()=>{if(confirm('Apagar o diario e os resumos? As rotinas e reacoes serao mantidas.'))action('limpar-diario',{});};
el('figurinhaForm').onsubmit=async e=>{e.preventDefault();await action('figurinha',{texto:el('figurinhaTexto').value});};
el('rotinaForm').onsubmit=async e=>{e.preventDefault();if(await action('rotina',{texto:el('rotinaTexto').value}))el('rotinaTexto').value='';};
el('gerar').onclick=async()=>{sending=true;el('gerar').disabled=true;await action('gerar',{});sending=false;};el('automatico').onchange=()=>action('config',{automatico:el('automatico').checked});refresh();setInterval(refresh,2000);
})();
+15 -7
View File
@@ -1,6 +1,7 @@
"""Rotina, diário e reações procedurais. Não treina pesos nem controla motores."""
import json,sqlite3,threading,time,math
from contextlib import contextmanager
from comum import ler_json
from gemini import gerar_json
from figurinhas import criar_desenho,validar_desenho,montar_prompt
@@ -28,8 +29,20 @@ def validar_receita(r):
return r
class Treinamento:
# Mudanças de expressão/ocupação viram eventos SSE sem caçar cada atribuição.
@property
def expressao(self):return self._expressao
@expressao.setter
def expressao(self,valor):
self._expressao=valor;self.publicar('reacao',valor)
@property
def busy(self):return self._busy
@busy.setter
def busy(self,valor):
self._busy=valor;self.publicar('treinamento',dict(ocupado=valor,erro=self.erro))
def __init__(self,path,iniciar=True):
self.path=str(path);self.lock=threading.RLock();self.busy=False;self.erro='';self.expressao=None
self.publicar=lambda tipo,dados:None
self.path=str(path);self.lock=threading.RLock();self.erro='';self.busy=False;self.expressao=None
with self.db() as db:
db.execute('CREATE TABLE IF NOT EXISTS rotina(id INTEGER PRIMARY KEY,texto TEXT)')
db.execute('CREATE TABLE IF NOT EXISTS diario(id INTEGER PRIMARY KEY,horario REAL,papel TEXT,texto TEXT)')
@@ -178,12 +191,7 @@ class Treinamento:
elif method=='GET' and route=='/treinamento/expressao':
with self.lock:result=self.expressao
elif method=='POST':
origin=h.headers.get('Origin')
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
size=int(h.headers.get('Content-Length','0'))
if not 0<size<=4096 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 4096 bytes')
d=json.loads(h.rfile.read(size))
if not isinstance(d,dict):raise ValueError('Objeto obrigatório')
d=ler_json(h,4096)
with self.lock,self.db() as db:
if route=='/treinamento/biblioteca':self.alterar_reacao(d.get('acao'),d.get('id'),d)
elif route=='/treinamento/parar':self.expressao=dict(id=time.time_ns(),horario=time.time(),receita=dict(limpar=True))
+6 -5
View File
@@ -1,10 +1,11 @@
{
"versao": "0.7.0",
"versao": "0.8.0",
"data": "2026-10-05",
"alteracoes": [
"Dashboard escuro com sidebar e oito telas",
"Biblioteca de emojis com edição e desfazer persistente",
"Configuração privada e teste de API Gemini",
"Estilos e navegação compartilhados"
"Painel novo em Svelte: Ao vivo, Conversa, Reações, Movimento e Ajustes",
"Estado em tempo real por SSE (/eventos) no painel e no rosto, sem polling",
"E-Stop real (/parar-tudo) e braço com homem-morto e interpolação no servidor",
"Token opcional para todas as rotas de controle; app Android envia o token",
"Telas antigas, /camera/frame e /app/connect removidos"
]
}
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b1017" />
<title>Carinha</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230b1017'/%3E%3Crect x='14' y='20' width='12' height='22' rx='6' fill='%235cd6ff'/%3E%3Crect x='38' y='20' width='12' height='22' rx='6' fill='%235cd6ff'/%3E%3C/svg%3E" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1250
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "carinha-painel",
"private": true,
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "node --test \"tests/**/*.test.ts\""
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tsconfig/svelte": "^5.0.8",
"@types/node": "^26.6.4",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^8.3.2"
},
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@lucide/svelte": "^1.52.0"
}
}
+368
View File
@@ -0,0 +1,368 @@
<script lang="ts">
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
import { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte';
import MiniRosto from './lib/componentes/MiniRosto.svelte';
import Paleta from './lib/componentes/Paleta.svelte';
import Selo from './lib/componentes/Selo.svelte';
import Token from './lib/componentes/Token.svelte';
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
import { conectar, estado } from './lib/estado.svelte';
import { paleta } from './lib/paleta.svelte';
import { nav, ROTAS, SECUNDARIAS, tituloDa, type Rota } from './lib/rotas.svelte';
import AoVivo from './telas/AoVivo.svelte';
import Ajustes from './telas/Ajustes.svelte';
import Conversa from './telas/Conversa.svelte';
import Movimento from './telas/Movimento.svelte';
import Reacoes from './telas/Reacoes.svelte';
import Rosto from './telas/Rosto.svelte';
const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo,
conversa: Conversa,
reacoes: Reacoes,
movimento: Movimento,
ajustes: Ajustes,
rosto: Rosto,
};
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
conversa: MessageCircle,
reacoes: Smile,
movimento: Hand,
ajustes: Settings,
};
const Tela = $derived(TELAS[nav.atual]);
// Páginas secundárias acendem a seção de onde vieram.
const secao = $derived(SECUNDARIAS.find((r) => r.id === nav.atual)?.volta ?? nav.atual);
// ---------- tema
let tema = $state<'dark' | 'light'>('dark');
function aplicarTema(t: 'dark' | 'light') {
tema = t;
document.documentElement.dataset.theme = t;
try {
localStorage.setItem('carinha_tema', t);
} catch {
/* ok */
}
}
// ---------- atalhos: Ctrl+K paleta · 1–9 gestos favoritos · Shift+Espaço ou Esc Esc = E-Stop · g+letra navega
let ultimoEsc = 0;
let prefixoG = 0;
function editando(e: KeyboardEvent) {
const alvo = e.target as HTMLElement;
return alvo.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(alvo.tagName);
}
function teclado(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
paleta.aberta ? paleta.fechar() : paleta.abrir();
return;
}
if (e.shiftKey && e.code === 'Space' && !editando(e)) {
e.preventDefault();
pararTudo();
return;
}
if (e.key === 'Escape' && !paleta.aberta) {
const agora = Date.now();
if (agora - ultimoEsc < 600) pararTudo();
ultimoEsc = agora;
return;
}
if (editando(e) || paleta.aberta || e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key === 'g') {
prefixoG = Date.now();
return;
}
if (Date.now() - prefixoG < 800) {
const rota = ROTAS.find((r) => r.atalho === 'g ' + e.key);
prefixoG = 0;
if (rota) nav.ir(rota.id);
return;
}
if (/^[1-9]$/.test(e.key)) {
const g = favoritos.lista[Number(e.key) - 1];
if (g) gesto(g);
}
}
onMount(() => {
let salvo: string | null = null;
try {
salvo = localStorage.getItem('carinha_tema');
} catch {
/* ok */
}
aplicarTema(salvo === 'light' || (!salvo && matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark');
conectar();
});
const conexaoTexto = $derived(
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
);
const bateria = $derived.by(() => {
const b = estado.app?.estado?.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
});
</script>
<svelte:window onkeydown={teclado} />
{#if estado.conexao === 'token'}
<Token />
{/if}
<div class="app">
<nav class="lateral" aria-label="Seções">
<div class="marca">
<span class="logo" aria-hidden="true"><Bot size={20} /></span>
<div>
<strong>Carinha</strong>
<small class="fraco mono">{estado.versao ? 'v' + estado.versao : ''}</small>
</div>
</div>
<ul>
{#each ROTAS as r (r.id)}
{@const Icone = ICONES[r.id]}
<li>
<a href={'#/' + r.id} aria-current={secao === r.id ? 'page' : undefined}>
<Icone size={20} /> <span>{r.titulo}</span>
</a>
</li>
{/each}
</ul>
<button class="btn fantasma paleta-btn" onclick={paleta.abrir}>
<Command size={16} /> Comandos <kbd>Ctrl K</kbd>
</button>
</nav>
<header class="topo">
<h1 class="titulo">{tituloDa(nav.atual)}</h1>
<div class="status linha">
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto}
</Selo>
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>
Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
</Selo>
{#if estado.conversa}
<Selo tom="destaque">IA: {estado.conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'}</Selo>
{/if}
</div>
<div class="acoes linha">
<button class="btn fantasma icone so-celular" onclick={paleta.abrir} aria-label="Comandos">
<Command size={18} />
</button>
<button
class="btn fantasma icone"
onclick={() => aplicarTema(tema === 'dark' ? 'light' : 'dark')}
aria-label={tema === 'dark' ? 'Usar tema claro' : 'Usar tema escuro'}
>
{#if tema === 'dark'}<Sun size={18} />{:else}<Moon size={18} />{/if}
</button>
<EStop />
</div>
</header>
<main class="conteudo">
<Tela />
</main>
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto'}
<MiniRosto />
{/if}
</div>
<Paleta />
<Avisos />
<style>
.app {
min-height: 100dvh;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: var(--topo-altura) 1fr;
grid-template-areas: 'topo' 'conteudo';
}
/* ---------- navegação: barra inferior no celular, lateral a partir de 1024px */
.lateral {
position: fixed;
z-index: 60;
left: 0;
right: 0;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
background: var(--vidro);
backdrop-filter: blur(16px);
border-top: 1px solid var(--borda);
}
.marca,
.paleta-btn {
display: none;
}
ul {
list-style: none;
margin: 0;
padding: var(--e-1);
display: grid;
grid-template-columns: repeat(5, 1fr);
}
li a {
display: grid;
justify-items: center;
gap: 2px;
padding: var(--e-2) 0;
border-radius: var(--raio);
color: var(--texto-3);
text-decoration: none;
font-size: 11px;
font-weight: 500;
}
li a[aria-current='page'] {
color: var(--destaque);
}
.topo {
grid-area: topo;
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: var(--e-3);
padding: 0 var(--e-4);
padding-top: env(safe-area-inset-top, 0px);
height: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
background: var(--vidro);
backdrop-filter: blur(16px);
border-bottom: 1px solid var(--borda);
}
.titulo {
font-size: var(--t-lg);
flex: none;
}
.status {
flex: 1;
min-width: 0;
overflow: hidden;
flex-wrap: nowrap;
}
.status :global(.selo:nth-child(n + 2)) {
display: none;
}
.acoes {
flex-wrap: nowrap;
}
.conteudo {
grid-area: conteudo;
padding: var(--e-4);
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
min-width: 0;
width: 100%;
max-width: 1440px;
margin: 0 auto;
}
kbd {
margin-left: auto;
font: 500 11px var(--fonte-mono);
color: var(--texto-3);
border: 1px solid var(--borda);
border-radius: 4px;
padding: 1px 5px;
}
@media (min-width: 640px) {
.status :global(.selo:nth-child(n + 2)) {
display: inline-flex;
}
.conteudo {
padding: var(--e-5);
padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px));
}
}
@media (min-width: 1024px) {
.app {
grid-template-columns: var(--nav-largura) 1fr;
grid-template-areas: 'nav topo' 'nav conteudo';
/* a coluna da navegação continua pintada quando a página rola além da viewport */
background: linear-gradient(
to right,
var(--fundo-2) calc(var(--nav-largura) - 1px),
var(--borda) 0 var(--nav-largura),
transparent 0
);
}
.lateral {
grid-area: nav;
position: sticky;
top: 0;
bottom: auto;
height: 100dvh;
display: flex;
flex-direction: column;
gap: var(--e-4);
padding: var(--e-4) var(--e-3);
border-top: 0;
border-right: 1px solid var(--borda);
background: var(--fundo-2);
backdrop-filter: none;
}
.marca {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-2);
}
.marca div {
display: grid;
line-height: 1.2;
}
.logo {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: var(--raio);
background: var(--destaque-suave);
color: var(--destaque);
}
ul {
grid-template-columns: 1fr;
gap: 2px;
padding: 0;
}
li a {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-2) var(--e-3);
font-size: var(--t-sm);
color: var(--texto-2);
}
li a:hover {
background: var(--superficie);
color: var(--texto);
}
li a[aria-current='page'] {
background: var(--destaque-suave);
color: var(--destaque);
}
.paleta-btn {
display: flex;
margin-top: auto;
justify-content: flex-start;
color: var(--texto-2);
}
.so-celular {
display: none;
}
.conteudo {
padding-bottom: var(--e-6);
}
}
</style>
+107
View File
@@ -0,0 +1,107 @@
/** Ajustes do app do celular (/app/config): salvamento otimista e campos agrupados. */
import { pedir, postar } from './api';
import { avisos } from './avisos.svelte';
import { estado } from './estado.svelte';
import type { AppConfig } from './tipos';
/** Atualiza na hora; se o servidor recusar, volta ao valor anterior e avisa. Não trava o formulário. */
export async function salvarApp(parcial: AppConfig) {
const antes = estado.appConfig ? { ...estado.appConfig } : null;
if (estado.appConfig) estado.appConfig = { ...estado.appConfig, ...parcial };
try {
const r = await postar<{ config: AppConfig }>('/app/config', parcial);
estado.appConfig = r.config;
} catch (e) {
estado.appConfig = antes;
avisos.erro(e);
}
}
export async function restaurarPadrao() {
const padrao = await pedir<AppConfig>('/app/padrao');
// A transmissão não é "ajuste": continua como está.
delete padrao.preview;
await salvarApp(padrao);
avisos.ok('Ajustes do celular restaurados para o padrão.');
}
type Opcoes = [string | number, string][];
export interface Campo {
chave: string;
rotulo: string;
ajuda?: string;
tipo: 'toggle' | 'slider' | 'escolha';
min?: number;
max?: number;
passo?: number;
unidade?: string;
opcoes?: Opcoes;
}
export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] = [
{
titulo: 'Tela e personalidade',
descricao: 'Como o rosto aparece e se comporta sozinho.',
campos: [
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
{ chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
{ chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
{ chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' },
{ chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
{ chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' },
],
},
{
titulo: 'Sensores e reações',
descricao: 'O que o celular percebe e como reage.',
campos: [
{ chave: 'sensorMic', rotulo: 'Microfone', ajuda: 'Só o nível de som é usado, nunca o áudio', tipo: 'toggle' },
{ chave: 'ouvir', rotulo: 'Reagir a sons', tipo: 'toggle' },
{ chave: 'micSono', rotulo: 'Microfone durante o sono', tipo: 'toggle' },
{ chave: 'sompausa', rotulo: 'Silêncio para dormir', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' s' },
{ chave: 'sensorCamera', rotulo: 'Câmera como sensor', ajuda: 'Detecta movimento e permite o vídeo', tipo: 'toggle' },
{ chave: 'reagir', rotulo: 'Reagir a movimento', tipo: 'toggle' },
{ chave: 'cameraSono', rotulo: 'Câmera durante o sono', tipo: 'toggle' },
{ chave: 'movolhar', rotulo: 'Quanto os olhos acompanham', tipo: 'slider', min: 0, max: 100, passo: 1, unidade: '%' },
],
},
{
titulo: 'Vídeo e rastreamento',
descricao: 'Qualidade da transmissão e rastreamento feito neste PC.',
campos: [
{ chave: 'cameraLado', rotulo: 'Câmera', tipo: 'escolha', opcoes: [['user', 'Frontal'], ['environment', 'Traseira']] },
{
chave: 'videoResolucao',
rotulo: 'Resolução',
tipo: 'escolha',
opcoes: [['auto', 'Automática'], ['max', 'Máxima'], ['480', '480p'], ['720', '720p'], ['1080', '1080p'], ['2160', '4K'], ['4320', '8K']],
},
{
chave: 'videoFPS',
rotulo: 'Quadros por segundo',
tipo: 'escolha',
opcoes: [[0, 'Automático'], [-1, 'Máximo'], [15, '15'], [24, '24'], [30, '30'], [60, '60'], [120, '120'], [240, '240']],
},
{ chave: 'rastrearRosto', rotulo: 'Rastrear rosto', tipo: 'toggle' },
{ chave: 'rastrearMaos', rotulo: 'Rastrear mãos', tipo: 'toggle' },
{ chave: 'seguirRastreamento', rotulo: 'Olhos seguem o alvo', tipo: 'toggle' },
{ chave: 'mostrarRastreamento', rotulo: 'Desenhar rastreamento no vídeo', tipo: 'toggle' },
],
},
{
titulo: 'Sensibilidade (avançado)',
descricao: 'Limiares dos sustos e intervalos entre reações.',
campos: [
{ chave: 'somlim', rotulo: 'Nível para perceber som', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'somaltolim', rotulo: 'Som mínimo para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'somganho', rotulo: 'Aumento súbito para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'somcooldown', rotulo: 'Intervalo entre sustos por som', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
{ chave: 'movlim', rotulo: 'Movimento mínimo', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'movforte', rotulo: 'Movimento para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
{ chave: 'movcooldown', rotulo: 'Intervalo entre sustos por movimento', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
],
},
];
+70
View File
@@ -0,0 +1,70 @@
/** Cliente HTTP único do painel: token, JSON, tempo limite e erro legível. */
const CHAVE_TOKEN = 'carinha_token';
export class ErroApi extends Error {
constructor(public status: number, mensagem: string) {
super(mensagem);
}
}
export function lerToken(): string {
try {
return localStorage.getItem(CHAVE_TOKEN) || '';
} catch {
return '';
}
}
export function gravarToken(token: string) {
try {
if (token) localStorage.setItem(CHAVE_TOKEN, token);
else localStorage.removeItem(CHAVE_TOKEN);
} catch {
/* navegação privada: vale só nesta aba */
}
}
/** Avisado quando o servidor recusa o token (abre o diálogo de token). */
let aoNegar: () => void = () => {};
export function quandoNegado(fn: () => void) {
aoNegar = fn;
}
export async function pedir<T = any>(caminho: string, dados?: unknown, tempo = 8000): Promise<T> {
const cabecalhos: Record<string, string> = {};
const token = lerToken();
if (token) cabecalhos.Authorization = 'Bearer ' + token;
const opcoes: RequestInit = { cache: 'no-store', headers: cabecalhos, signal: AbortSignal.timeout(tempo) };
if (dados !== undefined) {
opcoes.method = 'POST';
cabecalhos['Content-Type'] = 'application/json';
opcoes.body = JSON.stringify(dados);
}
let resposta: Response;
try {
resposta = await fetch(caminho, opcoes);
} catch (e) {
const nome = (e as Error).name;
throw new ErroApi(0, nome === 'TimeoutError' ? 'O servidor demorou a responder' : 'Sem conexão com o servidor');
}
const texto = await resposta.text();
let corpo: any = null;
try {
corpo = texto ? JSON.parse(texto) : null;
} catch {
corpo = null;
}
if (!resposta.ok) {
if (resposta.status === 401) aoNegar();
throw new ErroApi(resposta.status, corpo?.erro || `Erro ${resposta.status}`);
}
return corpo as T;
}
export const postar = <T = any>(caminho: string, dados: unknown = {}) => pedir<T>(caminho, dados);
export function urlEventos(): string {
const token = lerToken();
return token ? '/eventos?t=' + encodeURIComponent(token) : '/eventos';
}
+41
View File
@@ -0,0 +1,41 @@
/** Toasts no lugar de alert/confirm: mensagem curta, com ação opcional (ex.: Desfazer). */
export type TipoAviso = 'info' | 'ok' | 'erro';
export interface Aviso {
id: number;
texto: string;
tipo: TipoAviso;
acao?: { rotulo: string; fazer: () => void };
}
class Avisos {
lista = $state<Aviso[]>([]);
private seq = 0;
mostrar(texto: string, tipo: TipoAviso = 'info', acao?: Aviso['acao'], ms = tipo === 'erro' ? 6000 : 3500) {
const id = ++this.seq;
this.lista = [...this.lista, { id, texto, tipo, acao }].slice(-4);
setTimeout(() => this.fechar(id), acao ? Math.max(ms, 6000) : ms);
return id;
}
fechar(id: number) {
this.lista = this.lista.filter((a) => a.id !== id);
}
ok = (t: string, acao?: Aviso['acao']) => this.mostrar(t, 'ok', acao);
erro = (e: unknown) => this.mostrar(e instanceof Error ? e.message : String(e), 'erro');
}
export const avisos = new Avisos();
/** Executa uma ação do painel mostrando o erro num toast em vez de engolir. */
export async function tentar<T>(fn: () => Promise<T>, sucesso?: string): Promise<T | undefined> {
try {
const r = await fn();
if (sucesso) avisos.ok(sucesso);
return r;
} catch (e) {
avisos.erro(e);
return undefined;
}
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Braço (/braco/*) e diagnóstico (/servos/*). Segurança:
* - homem-morto: só há movimento enquanto o botão/slider está pressionado; cada pedido leva
* um prazo curto (ttl_ms) e o servidor desliga os motores se ele vencer sem renovação;
* - a interpolação e o limite de velocidade são do servidor, não deste navegador.
*/
import { pedir, postar } from './api';
import { avisos } from './avisos.svelte';
import { estado } from './estado.svelte';
import { limites } from './nucleo';
export { sequencias } from './nucleo';
export interface Calibracao {
canal: number;
minimo: number;
maximo: number;
centro: number;
}
export interface Postura {
nome: string;
angulos: number[];
}
export interface StatusBraco {
calibracao: Calibracao[];
posturas: Postura[];
disponivel: boolean;
erro: string;
dono: string | null;
travado: boolean;
atual: number[] | null;
movendo: boolean;
articulacoes: { nome: string; canal: number }[];
}
export const PRAZO_MS = 450;
const RENOVAR_MS = 150;
class Braco {
status = $state<StatusBraco | null>(null);
alvo = $state<number[]>(Array(7).fill(90));
velocidade = $state(60);
/** Opt-in explícito antes de qualquer movimento físico. */
ativado = $state(false);
segurando = $state(false);
passoSequencia = $state<{ nome: string; indice: number; total: number } | null>(null);
private relogio = 0;
private emVoo = false;
private sequencia: number[][] | null = null;
private pausaAte = 0;
faixas = $derived(this.status ? limites(this.status.calibracao, this.status.articulacoes) : []);
async carregar() {
try {
this.status = await pedir<StatusBraco>('/braco/status');
this.limitarAlvo();
} catch (e) {
avisos.erro(e);
}
}
limitarAlvo() {
if (!this.faixas.length) return;
this.alvo = this.alvo.map((v, i) => Math.max(this.faixas[i][0], Math.min(this.faixas[i][1], v)));
}
/** Começa a segurar: renova o movimento a cada 150 ms até soltar. */
segurar(sequencia?: { nome: string; passos: number[][] }) {
if (!this.ativado || this.segurando) return;
if (sequencia) {
this.sequencia = sequencia.passos;
this.passoSequencia = { nome: sequencia.nome, indice: 0, total: sequencia.passos.length };
this.alvo = [...sequencia.passos[0]];
this.pausaAte = 0;
}
this.segurando = true;
this.renovar();
this.relogio = window.setInterval(() => this.renovar(), RENOVAR_MS);
}
/** Soltou: para de renovar. O servidor desliga os motores quando o prazo vence (≤ 450 ms). */
soltar() {
if (!this.segurando) return;
this.segurando = false;
clearInterval(this.relogio);
if (this.passoSequencia) avisos.mostrar(`${this.passoSequencia.nome} interrompida ao soltar.`);
this.sequencia = null;
this.passoSequencia = null;
}
private async renovar() {
if (this.emVoo || !this.segurando) return;
this.emVoo = true;
try {
const r = await postar<{ movendo: boolean }>('/braco/mover', {
angulos: $state.snapshot(this.alvo),
velocidade: this.velocidade,
ttl_ms: PRAZO_MS,
});
if (!r.movendo && this.sequencia && this.passoSequencia) this.avancarSequencia();
} catch (e) {
this.segurando = false;
clearInterval(this.relogio);
this.sequencia = null;
this.passoSequencia = null;
avisos.erro(e);
} finally {
this.emVoo = false;
}
}
private avancarSequencia() {
if (!this.pausaAte) {
this.pausaAte = performance.now() + 700;
return;
}
if (performance.now() < this.pausaAte) return;
this.pausaAte = 0;
const p = this.passoSequencia!;
if (p.indice + 1 >= p.total) {
avisos.ok(`${p.nome} concluída.`);
this.sequencia = null;
this.passoSequencia = null;
this.segurando = false;
clearInterval(this.relogio);
return;
}
this.passoSequencia = { ...p, indice: p.indice + 1 };
this.alvo = [...this.sequencia![p.indice + 1]];
}
async pararMotores() {
this.soltar();
try {
await postar('/braco/parar');
} catch (e) {
avisos.erro(e);
}
}
async desativar() {
this.ativado = false;
await this.pararMotores();
}
async salvar(dados: { calibracao: Calibracao[]; posturas: Postura[] }, sucesso: string) {
this.soltar();
try {
await postar('/braco/parar');
this.status = await postar<StatusBraco>('/braco/config', dados);
this.limitarAlvo();
avisos.ok(sucesso);
return true;
} catch (e) {
avisos.erro(e);
return false;
}
}
}
export const braco = new Braco();
// Segurança: aba escondida ou E-Stop param de segurar na hora.
document.addEventListener('visibilitychange', () => document.hidden && braco.soltar());
$effect.root(() => {
$effect(() => {
if (estado.estop.ativo) {
braco.soltar();
braco.ativado = false;
}
});
});
+105
View File
@@ -0,0 +1,105 @@
/** Ações que qualquer tela pode disparar: gestos, rosto, cor, E-Stop. */
import { postar } from './api';
import { avisos, tentar } from './avisos.svelte';
import { estado } from './estado.svelte';
/** Nome amigável e emoji de cada gesto do rosto ('parar' fecha o kiosk: não vira botão). */
export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
feliz: { nome: 'Feliz', emoji: '😊' },
alegre: { nome: 'Alegre', emoji: '😄' },
coracao: { nome: 'Coração', emoji: '❤️' },
piscar: { nome: 'Piscar', emoji: '😌' },
piscar2: { nome: 'Piscar duplo', emoji: '😑' },
piscadinha: { nome: 'Piscadinha', emoji: '😉' },
surpreso: { nome: 'Surpreso', emoji: '😮' },
triste: { nome: 'Triste', emoji: '😢' },
bravo: { nome: 'Bravo', emoji: '😠' },
olhar_esq: { nome: 'Olhar à esquerda', emoji: '👈' },
olhar_dir: { nome: 'Olhar à direita', emoji: '👉' },
olhar_baixo: { nome: 'Olhar para baixo', emoji: '👇' },
dormir: { nome: 'Dormir', emoji: '😴' },
acordar: { nome: 'Acordar', emoji: '🌅' },
aleatorio: { nome: 'Aleatório', emoji: '🎲' },
};
export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
const CHAVE_FAVORITOS = 'carinha_favoritos';
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'piscadinha', 'surpreso', 'triste', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
function lerFavoritos(): string[] {
try {
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return v.slice(0, 9);
} catch {
/* sem armazenamento */
}
return PADRAO_FAVORITOS;
}
class Favoritos {
lista = $state<string[]>(lerFavoritos());
alternar(g: string) {
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, 9);
try {
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
} catch {
/* ok */
}
}
}
export const favoritos = new Favoritos();
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome }));
export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb }));
export const formato = (nome: string) => tentar(() => postar('/comando', { tipo: 'formato', valor: nome }));
/** Rostos: os novos usam o motor de pálpebras (rosto-motor.js) e aceitam formato de olho. */
export const ROSTOS_INFO: Record<string, { nome: string; descricao: string; novo: boolean; formato: boolean }> = {
vivo: { nome: 'Carinha Viva', descricao: 'O rosto de sempre com pálpebras, brilho e micro-movimentos.', novo: true, formato: true },
bloco: { nome: 'Bloco', descricao: 'Olhos quadrados que se recortam, estilo robô de mesa.', novo: true, formato: true },
matriz: { nome: 'Matriz', descricao: 'Painel de LEDs retrô; cada olho é feito de pontos.', novo: true, formato: true },
lente: { nome: 'Lente', descricao: 'Íris e pupila que dilata, pálpebras de metal.', novo: true, formato: false },
carinha: { nome: 'Clássico', descricao: 'O desenho original, com boca.', novo: false, formato: false },
miudo: { nome: 'Miúdo', descricao: 'Clássico com olhos menores.', novo: false, formato: false },
eilik: { nome: 'Eilik', descricao: 'Clássico sem boca.', novo: false, formato: false },
};
export const FORMATOS_INFO: Record<string, string> = {
padrao: 'Padrão', redondo: 'Redondo', pilula: 'Pílula', quadrado: 'Quadrado', largo: 'Largo',
};
export const nomeRosto = (id: string) => ROSTOS_INFO[id]?.nome ?? id;
export const recarregarCelular = () =>
tentar(async () => {
const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
avisos.ok(r.mensagem);
});
export async function pararTudo() {
try {
await postar('/parar-tudo');
avisos.mostrar('E-Stop acionado: motores desligados e travados.', 'erro');
} catch (e) {
avisos.erro(e);
}
}
export const rearmar = () => tentar(() => postar('/rearmar'), 'Motores rearmados.');
export function hex(rgb: number[]) {
return '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join('');
}
export function rgb(hexCor: string): [number, number, number] {
const n = parseInt(hexCor.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export const CORES: { nome: string; rgb: [number, number, number] }[] = [
{ nome: 'Ciano', rgb: [92, 214, 255] },
{ nome: 'Verde', rgb: [74, 222, 128] },
{ nome: 'Amarelo', rgb: [251, 191, 36] },
{ nome: 'Laranja', rgb: [255, 140, 50] },
{ nome: 'Rosa', rgb: [255, 110, 190] },
{ nome: 'Roxo', rgb: [167, 139, 250] },
{ nome: 'Vermelho', rgb: [248, 90, 90] },
{ nome: 'Branco', rgb: [235, 240, 245] },
];
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts" generics="T extends string">
let { abas, ativa = $bindable(), rotulo }: { abas: { id: T; titulo: string; contador?: number }[]; ativa: T; rotulo: string } =
$props();
function tecla(e: KeyboardEvent, i: number) {
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (!d) return;
const prox = abas[(i + d + abas.length) % abas.length];
ativa = prox.id;
(e.currentTarget as HTMLElement).parentElement?.querySelector<HTMLElement>(`[data-aba="${prox.id}"]`)?.focus();
}
</script>
<div class="abas" role="tablist" aria-label={rotulo}>
{#each abas as aba, i (aba.id)}
<button
role="tab"
data-aba={aba.id}
aria-selected={ativa === aba.id}
tabindex={ativa === aba.id ? 0 : -1}
onclick={() => (ativa = aba.id)}
onkeydown={(e) => tecla(e, i)}
>
{aba.titulo}
{#if aba.contador !== undefined}<span class="contador">{aba.contador}</span>{/if}
</button>
{/each}
</div>
<style>
.abas {
display: flex;
gap: var(--e-1);
border-bottom: 1px solid var(--borda);
overflow-x: auto;
scrollbar-width: none;
}
button {
display: inline-flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
min-height: 40px;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
color: var(--texto-3);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
white-space: nowrap;
}
button[aria-selected='true'] {
color: var(--texto);
border-bottom-color: var(--destaque);
}
.contador {
font-size: var(--t-xs);
padding: 0 6px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
color: var(--texto-2);
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import { avisos } from '../avisos.svelte';
</script>
<div class="avisos" role="status" aria-live="polite">
{#each avisos.lista as a (a.id)}
<div class="aviso {a.tipo}">
<span>{a.texto}</span>
{#if a.acao}
<button
class="btn pequeno"
onclick={() => {
a.acao!.fazer();
avisos.fechar(a.id);
}}>{a.acao.rotulo}</button
>
{/if}
<button class="btn fantasma pequeno icone" aria-label="Fechar aviso" onclick={() => avisos.fechar(a.id)}>
<X size={16} />
</button>
</div>
{/each}
</div>
<style>
.avisos {
position: fixed;
z-index: 90;
right: var(--e-4);
bottom: calc(var(--e-4) + env(safe-area-inset-bottom, 0px));
display: grid;
gap: var(--e-2);
width: min(420px, calc(100vw - 2 * var(--e-4)));
}
.aviso {
display: flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-2) var(--e-2) var(--e-4);
border-radius: var(--raio);
background: var(--superficie-2);
border: 1px solid var(--borda-forte);
border-left: 4px solid var(--destaque);
box-shadow: var(--sombra-flutuante);
font-size: var(--t-sm);
}
.aviso span {
flex: 1;
}
.ok {
border-left-color: var(--ok);
}
.erro {
border-left-color: var(--perigo);
}
@media (max-width: 1023px) {
.avisos {
bottom: calc(76px + env(safe-area-inset-bottom, 0px));
}
}
</style>
@@ -0,0 +1,96 @@
<script lang="ts">
// Vista lateral esquemática: cada articulação é um <g> aninhado que gira no próprio eixo.
// Ordem dos ângulos: 0 garra, 1 punho 1, 2 punho 2, 3 braço superior, 4 braço inferior, 5 antebraço, 6 base.
let { pose, fantasma = null }: { pose: number[]; fantasma?: number[] | null } = $props();
const r = (v: number) => v - 90;
</script>
{#snippet braco(p: number[], classe: string)}
<g class={classe}>
<g transform="translate(120 236) rotate({r(p[4])})">
<line x1="0" y1="0" x2="0" y2="-78" class="seg" />
<g transform="translate(0 -78) rotate({r(p[3])})">
<line x1="0" y1="0" x2="0" y2="-66" class="seg" />
<g transform="translate(0 -66) rotate({r(p[5])})">
<line x1="0" y1="0" x2="0" y2="-40" class="seg fino" />
<g transform="translate(0 -40) rotate({r(p[1])})">
<line x1="0" y1="0" x2="0" y2="-18" class="seg fino" />
<ellipse cx="0" cy="-18" rx={7 * Math.abs(Math.cos(((p[2] - 90) * Math.PI) / 180)) + 2} ry="4" class="punho" />
<g transform="translate(0 -20)">
<path d="M0 0 L-4 -16 L-1 -24" class="dedo" transform="rotate({-8 - (p[0] - 90) * 0.25})" />
<path d="M0 0 L4 -16 L1 -24" class="dedo" transform="rotate({8 + (p[0] - 90) * 0.25})" />
</g>
<circle r="4" class="junta" />
</g>
<circle r="5" class="junta" />
</g>
<circle r="6" class="junta" />
</g>
<circle r="7" class="junta" />
</g>
</g>
{/snippet}
<svg viewBox="0 0 240 290" role="img" aria-label="Postura do braço">
<!-- base: o arco mostra a rotação (vista de cima) -->
<rect x="80" y="240" width="80" height="16" rx="5" class="base" />
<path d="M85 274 A40 14 0 0 0 155 274" class="trilho" />
<circle
cx={120 + 35 * Math.cos(Math.PI - (pose[6] * Math.PI) / 180)}
cy={274 + 12 * Math.sin((pose[6] * Math.PI) / 180) * 0.4}
r="4"
class="marcador"
/>
{#if fantasma}{@render braco(fantasma, 'fantasma')}{/if}
{@render braco(pose, 'real')}
</svg>
<style>
svg {
width: 100%;
height: 100%;
display: block;
}
.seg {
stroke: var(--destaque);
stroke-width: 12;
stroke-linecap: round;
}
.fino {
stroke-width: 8;
}
.junta {
fill: var(--superficie);
stroke: var(--texto-2);
stroke-width: 2;
}
.punho {
fill: var(--texto-2);
}
.dedo {
fill: none;
stroke: var(--texto);
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.base {
fill: var(--superficie-3);
stroke: var(--borda-forte);
}
.trilho {
fill: none;
stroke: var(--borda-forte);
stroke-width: 2;
stroke-dasharray: 3 4;
}
.marcador {
fill: var(--aviso);
}
.fantasma {
opacity: 0.25;
}
.fantasma .seg {
stroke: var(--texto-3);
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { titulo = '', descricao = '', acoes, children, class: classe = '' }: {
titulo?: string;
descricao?: string;
acoes?: Snippet;
children: Snippet;
class?: string;
} = $props();
</script>
<section class="card {classe}">
{#if titulo || acoes}
<header>
<div>
{#if titulo}<h2>{titulo}</h2>{/if}
{#if descricao}<p class="fraco pequeno-texto">{descricao}</p>{/if}
</div>
{#if acoes}<div class="linha">{@render acoes()}</div>{/if}
</header>
{/if}
{@render children()}
</section>
<style>
.card {
background: var(--superficie);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
padding: var(--e-4);
display: grid;
gap: var(--e-4);
align-content: start;
min-width: 0;
}
header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--e-3);
flex-wrap: wrap;
}
h2 {
font-size: var(--t-md);
}
@media (min-width: 640px) {
.card {
padding: var(--e-5);
}
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<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.by(() => {
const b = s.bateria;
const n = typeof b === 'number' ? b : b?.nivel;
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
});
const carregando = $derived(typeof s.bateria === 'object' && !!s.bateria?.carregando);
const IconeBateria = $derived(carregando ? BatteryCharging : bateria === null ? BatteryMedium : bateria < 20 ? BatteryLow : bateria < 70 ? BatteryMedium : BatteryFull);
const ip = $derived(ap?.ip || estado.app?.celular_ip || '—');
const visto = $derived(estado.app?.idade ?? null);
</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{visto !== null ? ` · visto há ${Math.round(visto)} s` : ''}
{/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}
<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);
}
.ajuda {
font-size: var(--t-sm);
color: var(--texto-3);
}
dd.mono {
white-space: nowrap;
overflow-wrap: normal;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { OctagonX, RotateCcw } from '@lucide/svelte';
import { pararTudo, rearmar } from '../comandos.svelte';
import { estado } from '../estado.svelte';
</script>
{#if estado.estop.ativo}
<button class="btn estop parado" onclick={rearmar} title="Motores travados pelo E-Stop. Clique para rearmar.">
<RotateCcw size={18} /> <span>PARADO · Rearmar</span>
</button>
{:else}
<button class="btn perigo estop" onclick={pararTudo} title="Parar tudo (Shift+Espaço ou Esc duas vezes)">
<OctagonX size={18} /> <span>Parar tudo</span>
</button>
{/if}
<style>
.estop {
font-weight: 700;
letter-spacing: 0.02em;
}
.parado {
background: var(--aviso-suave);
border-color: var(--aviso);
color: var(--aviso);
animation: pulso 1.6s ease-in-out infinite;
}
@keyframes pulso {
50% {
box-shadow: 0 0 0 4px var(--aviso-suave);
}
}
@media (max-width: 480px) {
span {
display: none;
}
.estop {
width: 44px;
padding: 0;
}
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import { Star } from '@lucide/svelte';
import { favoritos, gesto, GESTOS_INFO, gestosDisponiveis } from '../comandos.svelte';
// compacto: só os favoritos com atalho 1–9 (dock). Completo: todos, com estrela para fixar.
let { completo = false }: { completo?: boolean } = $props();
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => g in GESTOS_INFO));
</script>
<div class="gestos" class:completo>
{#each lista as g, i (g)}
{@const info = GESTOS_INFO[g]}
<div class="item">
<button class="gesto" onclick={() => gesto(g)} title={completo ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<span class="emoji" aria-hidden="true">{info.emoji}</span>
<span class="nome">{info.nome}</span>
{#if !completo}<kbd>{i + 1}</kbd>{/if}
</button>
{#if completo}
<button
class="estrela"
class:ativa={favoritos.lista.includes(g)}
aria-label={favoritos.lista.includes(g) ? `Tirar ${info.nome} dos favoritos` : `Fixar ${info.nome} nos favoritos`}
aria-pressed={favoritos.lista.includes(g)}
onclick={() => favoritos.alternar(g)}
>
<Star size={14} />
</button>
{/if}
</div>
{/each}
</div>
<style>
.gestos {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: var(--e-2);
}
.item {
position: relative;
}
.gesto {
width: 100%;
display: grid;
justify-items: center;
gap: 2px;
padding: var(--e-3) var(--e-1) var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--texto);
font: inherit;
cursor: pointer;
position: relative;
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
}
.gesto:hover {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.gesto:active {
transform: scale(0.97);
}
.emoji {
font-size: 1.6rem;
line-height: 1.2;
}
.nome {
font-size: var(--t-xs);
color: var(--texto-2);
text-align: center;
}
kbd {
position: absolute;
top: 4px;
right: 6px;
font: 500 10px var(--fonte-mono);
color: var(--texto-3);
}
.estrela {
position: absolute;
top: 2px;
right: 2px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--texto-3);
cursor: pointer;
}
.estrela.ativa {
color: var(--aviso);
}
.estrela.ativa :global(svg) {
fill: currentColor;
}
</style>
+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>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import { ChevronDown, ChevronUp } from '@lucide/svelte';
import { estado } from '../estado.svelte';
import RostoPrevia from './RostoPrevia.svelte';
// Um só mini-rosto em todas as telas (exceto Ao vivo, que já tem o rosto grande).
let aberto = $state(true);
try {
aberto = localStorage.getItem('carinha_mini') !== '0';
} catch {
/* ok */
}
function alternar() {
aberto = !aberto;
try {
localStorage.setItem('carinha_mini', aberto ? '1' : '0');
} catch {
/* ok */
}
}
</script>
<aside class="mini" class:aberto aria-label="Rosto ao vivo">
<button class="barra" onclick={alternar} aria-expanded={aberto}>
<span class="ponto" class:online={estado.celularOnline} aria-hidden="true"></span>
<span>Rosto {estado.celularOnline ? 'ao vivo' : '· celular offline'}</span>
{#if aberto}<ChevronDown size={16} />{:else}<ChevronUp size={16} />{/if}
</button>
{#if aberto}
<div class="tela"><RostoPrevia /></div>
{/if}
</aside>
<style>
.mini {
position: fixed;
z-index: 55;
right: var(--e-4);
bottom: calc(84px + env(safe-area-inset-bottom, 0px));
width: 220px;
border-radius: var(--raio);
overflow: hidden;
background: var(--vidro);
backdrop-filter: blur(16px);
border: 1px solid var(--borda-forte);
box-shadow: var(--sombra-flutuante);
}
.barra {
width: 100%;
display: flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
border: 0;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-xs) var(--fonte);
cursor: pointer;
}
.barra span:nth-child(2) {
flex: 1;
text-align: left;
}
.ponto {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--texto-3);
}
.ponto.online {
background: var(--ok);
}
.tela {
aspect-ratio: 16 / 9;
}
@media (max-width: 639px) {
.mini {
width: 160px;
}
}
@media (min-width: 1024px) {
.mini {
bottom: var(--e-4);
width: 260px;
}
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { SIMBOLO_EMOJI } from '../treinamento.svelte';
// Prévia estática de uma reação (sem iframe): figurinha SVG, emoji ou olhos do 1º passo.
let { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
const passo = $derived(receita?.passos?.[0]);
const simbolo = $derived(receita?.emoji?.caractere || SIMBOLO_EMOJI[receita?.simbolo] || '');
</script>
<div class="miniatura" style="--t: {tamanho}px" aria-hidden="true">
{#if receita?.figurinha?.formas}
<svg viewBox="0 0 100 100" width={tamanho} height={tamanho}>
{#each receita.figurinha.formas as forma, i (i)}
<path d={forma.caminho} fill={forma.cor} />
{/each}
</svg>
{:else if receita?.emoji}
<span class="emoji">{receita.emoji.caractere}</span>
{:else}
<svg viewBox="0 0 100 60" width={tamanho} height={tamanho * 0.6}>
{#if passo?.olhos === 'feliz'}
<path d="M18 38 Q30 18 42 38 M58 38 Q70 18 82 38" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
{:else if passo?.olhos === 'triste'}
<path d="M18 26 Q30 44 42 26 M58 26 Q70 44 82 26" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
{:else}
{@const ry = passo?.olhos === 'surpreso' ? 20 : 16}
<ellipse cx="30" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
<ellipse cx="70" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
{/if}
</svg>
{#if simbolo}<span class="simbolo">{simbolo}</span>{/if}
{/if}
</div>
<style>
.miniatura {
position: relative;
width: var(--t);
height: var(--t);
display: grid;
place-items: center;
border-radius: var(--raio);
background: #05080c;
flex: none;
}
.emoji {
font-size: calc(var(--t) * 0.55);
line-height: 1;
}
.simbolo {
position: absolute;
right: 2px;
bottom: 0;
font-size: calc(var(--t) * 0.3);
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import type { Snippet } from 'svelte';
// <dialog> nativo: foco preso, Esc e fundo vêm de graça.
let { aberto = $bindable(false), titulo, children }: { aberto: boolean; titulo: string; children: Snippet } = $props();
let dialogo: HTMLDialogElement;
$effect(() => {
if (aberto && !dialogo.open) dialogo.showModal();
else if (!aberto && dialogo.open) dialogo.close();
});
</script>
<dialog
bind:this={dialogo}
onclose={() => (aberto = false)}
onclick={(e) => e.target === dialogo && (aberto = false)}
aria-label={titulo}
>
<div class="conteudo">
<header>
<h2>{titulo}</h2>
<button class="btn fantasma icone pequeno" aria-label="Fechar" onclick={() => (aberto = false)}><X size={18} /></button>
</header>
{#if aberto}{@render children()}{/if}
</div>
</dialog>
<style>
dialog {
width: min(480px, calc(100vw - 2 * var(--e-4)));
padding: 0;
border: 1px solid var(--borda-forte);
border-radius: var(--raio-lg);
background: var(--superficie);
color: var(--texto);
box-shadow: var(--sombra-flutuante);
}
dialog::backdrop {
background: rgb(0 0 0 / 0.5);
}
.conteudo {
display: grid;
gap: var(--e-4);
padding: var(--e-5);
}
header {
display: flex;
justify-content: space-between;
align-items: center;
}
h2 {
font-size: var(--t-lg);
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import { Search } from '@lucide/svelte';
import { itensDaPaleta, paleta, type ItemPaleta } from '../paleta.svelte';
let busca = $state('');
let ativo = $state(0);
let campo = $state<HTMLInputElement>();
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const itens = $derived.by(() => {
const termos = normal(busca).split(/\s+/).filter(Boolean);
return itensDaPaleta()
.filter((i) => termos.every((t) => normal(i.grupo + ' ' + i.titulo).includes(t)))
.slice(0, 50);
});
$effect(() => {
if (paleta.aberta) {
busca = '';
ativo = 0;
queueMicrotask(() => campo?.focus());
}
});
$effect(() => {
void busca;
ativo = 0;
});
function executar(i: ItemPaleta | undefined) {
if (!i) return;
paleta.fechar();
i.fazer();
}
function tecla(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
ativo = Math.min(itens.length - 1, ativo + 1);
e.preventDefault();
} else if (e.key === 'ArrowUp') {
ativo = Math.max(0, ativo - 1);
e.preventDefault();
} else if (e.key === 'Enter') {
executar(itens[ativo]);
e.preventDefault();
} else if (e.key === 'Escape') {
paleta.fechar();
e.stopPropagation();
}
}
</script>
{#if paleta.aberta}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="fundo" onclick={paleta.fechar}>
<div class="caixa" role="dialog" tabindex="-1" aria-modal="true" aria-label="Comandos" onclick={(e) => e.stopPropagation()}>
<div class="busca">
<Search size={18} />
<input
bind:this={campo}
bind:value={busca}
onkeydown={tecla}
placeholder="Gesto, rosto, tela ou ação…"
aria-label="Buscar comando"
role="combobox"
aria-expanded="true"
aria-controls="paleta-lista"
aria-activedescendant={itens[ativo] ? 'pal-' + itens[ativo].id : undefined}
/>
<kbd>Esc</kbd>
</div>
<ul id="paleta-lista" role="listbox">
{#each itens as item, i (item.id)}
{#if i === 0 || itens[i - 1].grupo !== item.grupo}
<li class="grupo" role="presentation">{item.grupo}</li>
{/if}
<li
id={'pal-' + item.id}
role="option"
aria-selected={i === ativo}
class:ativo={i === ativo}
onmousemove={() => (ativo = i)}
onclick={() => executar(item)}
>
<span>{item.titulo}</span>
{#if item.dica}<kbd>{item.dica}</kbd>{/if}
</li>
{:else}
<li class="vazio">Nada encontrado.</li>
{/each}
</ul>
</div>
</div>
{/if}
<style>
.fundo {
position: fixed;
inset: 0;
z-index: 95;
background: rgb(0 0 0 / 0.45);
display: grid;
justify-items: center;
align-items: start;
padding: 12vh var(--e-4) var(--e-4);
}
.caixa {
width: min(560px, 100%);
max-height: 70vh;
display: grid;
grid-template-rows: auto 1fr;
background: var(--vidro);
backdrop-filter: blur(18px);
border: 1px solid var(--borda-forte);
border-radius: var(--raio-lg);
box-shadow: var(--sombra-flutuante);
overflow: hidden;
}
.busca {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-3) var(--e-4);
border-bottom: 1px solid var(--borda);
color: var(--texto-3);
}
.busca input {
border: 0;
background: transparent;
min-height: 36px;
font-size: var(--t-md);
padding: 0;
}
.busca input:focus-visible {
outline: none;
}
ul {
list-style: none;
margin: 0;
padding: var(--e-2);
overflow: auto;
}
li[role='option'] {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
cursor: pointer;
font-size: var(--t-sm);
}
li.ativo {
background: var(--destaque-suave);
color: var(--destaque);
}
.grupo {
padding: var(--e-3) var(--e-3) var(--e-1);
font-size: var(--t-xs);
color: var(--texto-3);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.vazio {
padding: var(--e-4);
color: var(--texto-3);
font-size: var(--t-sm);
}
kbd {
font: 500 11px var(--fonte-mono);
color: var(--texto-3);
border: 1px solid var(--borda);
border-radius: 4px;
padding: 1px 5px;
}
</style>
@@ -0,0 +1,67 @@
<script lang="ts">
/**
* Prévia do rosto: o próprio carinha.html em modo ?painel=1 (sem polling).
* O painel repassa por postMessage o que chega no SSE, então a prévia mostra
* exatamente as mesmas ordens que o celular recebe.
*/
import { onDestroy } from 'svelte';
import { estado, ouvir } from '../estado.svelte';
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props();
let quadro: HTMLIFrameElement;
let pronto = $state(false);
function enviar(msg: unknown) {
if (pronto) quadro?.contentWindow?.postMessage(msg, location.origin);
}
function aoCarregar() {
pronto = true;
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
}
let ultimaConversa = estado.conversa?.ultima?.id;
const cancelar = [
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
ouvir('app_config', (c) => enviar({ tipo: 'config', config: c })),
ouvir('reacao', (r) => r?.receita && enviar({ tipo: 'previa-reacao', receita: r.receita })),
// O celular reage à resposta da conversa com acordar + gesto; a prévia imita.
ouvir('conversa', (c) => {
if (c.ultima && c.ultima.id !== ultimaConversa) {
ultimaConversa = c.ultima.id;
enviar({ tipo: 'ordem', ordem: 'acordar' });
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
}
}),
];
onDestroy(() => cancelar.forEach((c) => c()));
/** Mostra uma ordem (gesto, rosto, formato) só na prévia, sem mandar ao celular. */
export function ordem(texto: string) {
enviar({ tipo: 'ordem', ordem: texto });
}
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
export function previa(receita: unknown) {
enviar({ tipo: 'previa-reacao', receita });
}
</script>
<iframe
bind:this={quadro}
src="/carinha.html?espelho=1&painel=1&auto=0&camera=0&audio=0"
title={titulo}
tabindex="-1"
onload={aoCarregar}
></iframe>
<style>
iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #000;
border-radius: inherit;
}
</style>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// Estado nunca só por cor: o selo sempre tem texto (e o ponto é decorativo).
let { tom = 'neutro', children }: { tom?: 'ok' | 'aviso' | 'perigo' | 'neutro' | 'destaque'; children: Snippet } =
$props();
</script>
<span class="selo {tom}"><span class="ponto" aria-hidden="true"></span>{@render children()}</span>
<style>
.selo {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: var(--raio-pill);
font-size: var(--t-xs);
font-weight: 500;
background: var(--superficie-2);
color: var(--texto-2);
border: 1px solid var(--borda);
white-space: nowrap;
}
.ponto {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.ok {
color: var(--ok);
background: var(--ok-suave);
border-color: transparent;
}
.aviso {
color: var(--aviso);
background: var(--aviso-suave);
border-color: transparent;
}
.perigo {
color: var(--perigo);
background: var(--perigo-suave);
border-color: transparent;
}
.destaque {
color: var(--destaque);
background: var(--destaque-suave);
border-color: transparent;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
let { rotulo, valor, min, max, passo = 1, unidade = '', disabled = false, oninput, onchange }: {
rotulo: string;
valor: number;
min: number;
max: number;
passo?: number;
unidade?: string;
disabled?: boolean;
oninput?: (v: number) => void;
onchange?: (v: number) => void;
} = $props();
const id = 's' + Math.random().toString(36).slice(2, 9);
let local = $state<number | null>(null);
const mostrado = $derived(local ?? valor);
const casas = $derived(passo < 1 ? Math.min(2, String(passo).split('.')[1]?.length ?? 2) : 0);
</script>
<div class="slider">
<label for={id}>
<span>{rotulo}</span>
<output for={id} class="mono">{mostrado.toFixed(casas)}{unidade}</output>
</label>
<input
{id}
type="range"
{min}
{max}
step={passo}
value={mostrado}
{disabled}
style="--p: {((mostrado - min) / (max - min)) * 100}%"
oninput={(e) => {
local = Number(e.currentTarget.value);
oninput?.(local);
}}
onchange={(e) => {
const v = Number(e.currentTarget.value);
local = null;
onchange?.(v);
}}
/>
</div>
<style>
.slider {
display: grid;
gap: var(--e-1);
}
label {
display: flex;
justify-content: space-between;
font-size: var(--t-sm);
gap: var(--e-2);
}
output {
color: var(--texto-2);
}
input {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 28px;
background: transparent;
cursor: pointer;
}
input::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
}
input::-moz-range-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
}
input::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
margin-top: -7px;
border-radius: 50%;
background: var(--texto);
border: 3px solid var(--destaque);
}
input::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--texto);
border: 3px solid var(--destaque);
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

Some files were not shown because too many files have changed in this diff Show More