diff --git a/README.md b/README.md index 1127d5e..d0a7ccf 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/android/app/AndroidManifest.xml b/android/app/AndroidManifest.xml index ac42801..222852d 100644 --- a/android/app/AndroidManifest.xml +++ b/android/app/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="8" + android:versionName="0.8-token"> diff --git a/android/app/assets/carinha.html b/android/app/assets/carinha.html index 541e8d4..1b54384 100644 --- a/android/app/assets/carinha.html +++ b/android/app/assets/carinha.html @@ -26,6 +26,25 @@
- - - + + diff --git a/android/app/java/br/carinha/rosto/CerebroLocal.java b/android/app/java/br/carinha/rosto/CerebroLocal.java index 3825ddd..13a85a5 100644 --- a/android/app/java/br/carinha/rosto/CerebroLocal.java +++ b/android/app/java/br/carinha/rosto/CerebroLocal.java @@ -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 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); diff --git a/android/app/java/br/carinha/rosto/ControleRemoto.java b/android/app/java/br/carinha/rosto/ControleRemoto.java index 7723af6..c6c73b8 100644 --- a/android/app/java/br/carinha/rosto/ControleRemoto.java +++ b/android/app/java/br/carinha/rosto/ControleRemoto.java @@ -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) { diff --git a/android/app/java/br/carinha/rosto/MainActivity.java b/android/app/java/br/carinha/rosto/MainActivity.java index 894ed67..7971eb5 100644 --- a/android/app/java/br/carinha/rosto/MainActivity.java +++ b/android/app/java/br/carinha/rosto/MainActivity.java @@ -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() { diff --git a/api-painel.js b/api-painel.js deleted file mode 100644 index 1ffe7ff..0000000 --- a/api-painel.js +++ /dev/null @@ -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();})(); \ No newline at end of file diff --git a/api.html b/api.html deleted file mode 100644 index d53fc66..0000000 --- a/api.html +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - API Gemini & Conexão · Carinha - - - - -
-
-

API Gemini & Conexão IA

-

Configure o modelo do Google Gemini que dá inteligência ao diálogo e cria novas expressões.

-
-
- Verificando… -
-
- -
- -
-
-

- - Google Gemini Cloud -

- Seguro · api-privada.json -
- -
- - - - - - - - - - -

- 🔒 Por segurança, o campo de chave nunca exibe o valor salvo no servidor. Ele é mantido exclusivamente no arquivo privado local do Orange Pi. -

- -
- - -
-
- -

-
- - -
-
-

- - Arquitetura Híbrida de IA -

-

O Carinha combina processamento em nuvem com inteligência embarcada no robô:

- -
-
- ☁️ Google Gemini (Nuvem) - Gera as respostas de conversação natural, recupera lembranças e desenha novas figurinhas SVG em tempo real. -
- -
- 📱 Qwen / Local (Galaxy S10) - Disponível no app do celular para quando estiver offline, garantindo que o Carinha continue reagindo aos sensores. -
-
- -

Você pode alternar entre os motores a qualquer momento na tela Conversar.

-
- -
- Informações do Servidor Local -

-

Orange Pi Zero 3 operando na rede interna.

-
-
-
- - - - - \ No newline at end of file diff --git a/audio-robot.js b/audio-robot.js deleted file mode 100644 index bb04f99..0000000 --- a/audio-robot.js +++ /dev/null @@ -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); diff --git a/biblioteca.js b/biblioteca.js deleted file mode 100644 index eb21fe2..0000000 --- a/biblioteca.js +++ /dev/null @@ -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); -})(); \ No newline at end of file diff --git a/braco-calibracao.js b/braco-calibracao.js deleted file mode 100644 index d219285..0000000 --- a/braco-calibracao.js +++ /dev/null @@ -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='

Testar fim de curso · passos de 100 µs

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.

1500 µs

Escolha um canal. Marcar limites não envia movimento.

'; -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(); diff --git a/braco-movimentos.js b/braco-movimentos.js deleted file mode 100644 index 6331d1c..0000000 --- a/braco-movimentos.js +++ /dev/null @@ -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); - diff --git a/braco.css b/braco.css deleted file mode 100644 index 26f4f41..0000000 --- a/braco.css +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/braco.html b/braco.html deleted file mode 100644 index dedba5f..0000000 --- a/braco.html +++ /dev/null @@ -1,158 +0,0 @@ - - - - - - Braço Robótico · Carinha - - - - - - - - -
-
-

Braço Robótico

-

Posturas e movimentos coordenados para os 8 canais de servo.

-
-
- Verificando conexão… -
-
- - -
-
- -
-

Atenção ao usar o braço

-

Â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.

-
-
-
- -
-
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-

- - Controle de Canais -

-
-
-
- - -
- 25 -
-
Carregando configurações…
-
-
- - - - -
- - -
- - - Calibrar Limites por Canal - -
-

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.

-
- -
-
- - -
-

- - Movimentos Coordenados -

-

Prévia é uma simulação dos comandos, sem sensores de posição. Executar envia a sequência aos oito canais dentro dos limites salvos.

-
-
-

Representação esquemática · posição nominal

- -
-
- - - - - - - diff --git a/braco.js b/braco.js deleted file mode 100644 index 68eb071..0000000 --- a/braco.js +++ /dev/null @@ -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.');}}; diff --git a/carinha.html b/carinha.html index c145616..1b54384 100644 --- a/carinha.html +++ b/carinha.html @@ -26,6 +26,25 @@
- - - + + diff --git a/cerebro.py b/cerebro.py index de4c4f4..febae9b 100644 --- a/cerebro.py +++ b/cerebro.py @@ -18,6 +18,8 @@ class Cerebro: def __init__(self,path,controle,enviar): self.path=Path(path);self.controle=controle;self.enviar=enviar self.endpoint=os.environ.get('CARINHA_IA_URL','http://127.0.0.1:8101') + # O llama-server do S10 fica na rede: com token, ele exige a mesma chave (--api-key). + self.chave=os.environ.get('CARINHA_IA_CHAVE') or os.environ.get('CARINHA_TOKEN','') self.executor='S10' if '192.168.1.16' in self.endpoint else 'Orange Pi' self.lock=threading.Lock();self.ativo=False;self.pending=None;self.busy=False self.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0 @@ -25,6 +27,10 @@ class Cerebro: try:self.ativo=json.loads(self.path.read_text())['ativo'] is True except (OSError,ValueError,KeyError,TypeError):pass threading.Thread(target=self.loop,daemon=True).start() + def cabecalhos(self): + h={'Content-Type':'application/json'} + if self.chave:h['Authorization']='Bearer '+self.chave + return h def config(self,ativo): if type(ativo) is not bool:raise ValueError('ativo deve ser booleano') with self.lock: @@ -43,7 +49,7 @@ class Cerebro: return dict(ok=True) def decide(self,event): payload=dict(model='local',messages=[dict(role='system',content='Você é Carinha, um pequeno robô curioso, gentil e expressivo, inspirado em WALL-E. Escolha uma expressão adequada ao evento. Varie os gestos recentes. Responda somente JSON.'),dict(role='user',content=json.dumps(dict(evento=event,recentes=self.history[-3:]),ensure_ascii=False)+' /no_think')],max_tokens=32,temperature=.7,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='intencao',strict=True,schema=dict(type='object',properties=dict(gesto=dict(type='string',enum=list(GESTOS))),required=['gesto'],additionalProperties=False)))) - request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers={'Content-Type':'application/json'}) + request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cabecalhos()) start=time.monotonic() with urlopen(request,timeout=90) as response:data=json.load(response) content=data['choices'][0]['message']['content'] diff --git a/controle_pc.py b/controle_pc.py index 07a8881..d5d4ed3 100644 --- a/controle_pc.py +++ b/controle_pc.py @@ -1,10 +1,7 @@ -"""Configuração persistente, telemetria e prévia da câmera do celular.""" -import ipaddress +"""Configuração persistente e telemetria do celular; sinalização do vídeo WebRTC.""" import json import math -import os from pathlib import Path -import subprocess import threading import time from comum import origem_valida @@ -33,8 +30,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): @@ -65,9 +60,6 @@ class ControlePC: self.status_at = 0 self.celular_ip = None self.recarga = {"id": None, "confirmada": False} - self.frame = b'' - self.frame_at = 0 - self.frame_seq = 0 self.rtc = Sinalizacao() self.publicar = lambda tipo, dados: None try: @@ -85,8 +77,6 @@ class ControlePC: self.config = novo if not novo['preview']: self.rtc.encerrar() - self.frame = b'' - self.frame_at = 0 return novo.copy() def resumo(self, host=''): @@ -96,15 +86,13 @@ class ControlePC: idade = agora - self.status_at return dict(online=idade < 5, idade=round(idade, 1) if self.status_at else None, celular_ip=self.celular_ip, servidor_ip=host, recarga=self.recarga.copy(), - estado=self.estado.copy(), preview=self.config['preview'], - camera_recente=bool(self.frame) and agora - self.frame_at < 3 and self.config['preview'], - frame_seq=self.frame_seq) + estado=self.estado.copy(), preview=self.config['preview']) def rotas(self, h, metodo): if self.rtc.atender(h, metodo, self.config['preview']): return True rota = h.path.split('?', 1)[0].rstrip('/') - if rota not in ('/app/config', '/app/padrao', '/app/status', '/camera/frame', '/app/connect', '/app/health'): + if rota not in ('/app/config', '/app/padrao', '/app/status', '/app/health'): return False def enviar(dados, codigo=200): @@ -117,14 +105,13 @@ class ControlePC: if rota == '/app/config' and h.headers.get('Content-Type', '').split(';')[0] != 'application/json': raise ValueError('Use JSON') tamanho = int(h.headers.get('Content-Length', '0')) - limite = 1_000_000 if rota == '/camera/frame' else 16384 - if tamanho <= 0 or tamanho > limite: + if tamanho <= 0 or tamanho > 16384: enviar({'erro': 'Corpo vazio ou grande demais'}, 413) return True corpo = h.rfile.read(tamanho) if len(corpo) != tamanho: raise ValueError('Corpo incompleto') - dados = None if rota == '/camera/frame' else json.loads(corpo) + dados = json.loads(corpo) else: dados = None @@ -155,56 +142,8 @@ class ControlePC: enviar({'ok': True}) else: enviar(self.resumo(h.headers.get("Host", "").split(":")[0])) - elif rota == '/camera/frame': - with self.lock: - if metodo == 'POST': - if not self.config['preview']: - enviar({'erro': 'Transmissão desligada'}, 409) - elif h.headers.get('Content-Type', '').split(';')[0] != 'image/jpeg' or not corpo.startswith(b'\xff\xd8') or not corpo.endswith(b'\xff\xd9'): - enviar({'erro': 'Esperava uma imagem JPEG'}, 400) - else: - self.frame = corpo - self.frame_at = time.monotonic() - self.frame_seq += 1 - enviar({'ok': True}) - else: - recente = self.config['preview'] and self.frame and time.monotonic() - self.frame_at < 3 - h.enviar(self.frame if recente else b'', 'image/jpeg', 200 if recente else 204) - elif rota == '/app/connect': - if metodo != 'POST': - enviar({'erro': 'Use POST'}, 405) - elif h.client_address[0] not in ('127.0.0.1', '::1'): - enviar({'erro': 'Abra o painel em localhost no PC para conectar'}, 403) - else: - self.conectar(h, dados, enviar) except (ValueError, TypeError) as e: enviar({'erro': str(e)}, 400) except OSError as e: enviar({'erro': str(e)}, 503) return True - - def conectar(self, h, dados, enviar): - ip = str(ipaddress.IPv4Address(dados.get('ip', '192.168.1.16'))) - porta = dados.get('porta', 5555) - if isinstance(porta, bool) or not isinstance(porta, int) or not 1 <= porta <= 65535: - raise ValueError('Porta ADB inválida') - pc = str(ipaddress.IPv4Address(dados.get('pc', '192.168.1.15'))) - adb = str(TOOLS / 'android-sdk/platform-tools/adb.exe') - serial = '%s:%s' % (ip, porta) - url = 'http://%s:%s' % (pc, h.server.server_port) - comandos = [[adb, 'connect', serial], [adb, '-s', serial, 'shell', 'input', 'keyevent', 'KEYCODE_WAKEUP'], - [adb, '-s', serial, 'shell', 'wm', 'dismiss-keyguard'], - [adb, '-s', serial, 'shell', 'am', 'start', '-n', 'br.carinha.rosto/.MainActivity', '-e', 'servidor', url]] - saidas = [] - for cmd in comandos: - try: - r = subprocess.run(cmd, capture_output=True, text=True, timeout=15, - creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0) - except subprocess.TimeoutExpired: - enviar({'erro': 'ADB não respondeu. Confira a depuração Wi-Fi no celular.'}, 504) - return - saidas.append((r.stdout + r.stderr).strip()) - if r.returncode or 'cannot connect' in r.stdout.lower() or 'failed' in r.stdout.lower() or 'error:' in r.stdout.lower(): - enviar({'erro': '\n'.join(saidas)}, 502) - return - enviar({'ok': True, 'servidor': url, 'mensagem': 'App aberto. Aguarde a confirmação de conexão no painel.'}) diff --git a/conversa.css b/conversa.css deleted file mode 100644 index 63f9c0a..0000000 --- a/conversa.css +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/conversa.html b/conversa.html deleted file mode 100644 index 9896b3b..0000000 --- a/conversa.html +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - Conversar com IA · Carinha - - - - - - - -
-
-

Conversar com o Carinha

-

Converse por voz ou texto. As expressões do rosto mudam em tempo real conforme a conversa.

-
-
- - Gemini Online -
-
- - -
- - - - - -
-
-
-
- - Histórico -
-

Carregando conversa…

-
- - -
- - - - - -
- - - - -
- - -
-
- - - -
-
- - -
- - - Lembranças & Memória de Longo Prazo - -

Fatos e preferências ensinados ao Carinha que enriquecem o prompt do Gemini sem alterar os pesos do modelo.

-
- - -
-
-
- -
-
-
-
- -
- - - - - \ No newline at end of file diff --git a/conversa.py b/conversa.py index 5504dd0..152dfd7 100644 --- a/conversa.py +++ b/conversa.py @@ -145,7 +145,7 @@ class Conversa: messages=mensagens_modelo(history,self.memoria.listar()) schema=dict(type='object',properties=dict(texto=dict(type='string'),gesto=dict(type='string',enum=list(GESTOS))),required=['texto','gesto'],additionalProperties=False) payload=dict(model='local',messages=messages,max_tokens=120,temperature=.2,repeat_penalty=1.1,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='conversa',strict=True,schema=schema))) - req=Request(self.cerebro.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers={'Content-Type':'application/json'}) + req=Request(self.cerebro.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cerebro.cabecalhos()) with urlopen(req,timeout=90) as response:data=json.load(response) result=validar_resposta(json.loads(data['choices'][0]['message']['content'])) with self.lock: diff --git a/dashboard.css b/dashboard.css deleted file mode 100644 index 963dffb..0000000 --- a/dashboard.css +++ /dev/null @@ -1,1353 +0,0 @@ -/* ========================================================================== - CARINHA DASHBOARD · DESIGN SYSTEM FUTURISTA & GLASSMORPHISM - Central de Controle Robótica, Telemetria & IA (Orange Pi Zero 3 + Galaxy S10) - ========================================================================== */ - -@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap'); - -:root { - color-scheme: dark; - --fundo: #070b12; - --fundo-grad: radial-gradient(circle at 50% 0%, #101e33 0%, #070b12 70%); - --painel: rgba(14, 23, 38, 0.72); - --painel-card: rgba(16, 27, 44, 0.82); - --painel-hover: rgba(23, 38, 60, 0.9); - --painel-input: rgba(9, 15, 26, 0.85); - - --borda: rgba(65, 96, 128, 0.32); - --borda-brilho: rgba(92, 214, 255, 0.45); - --borda-hover: rgba(92, 214, 255, 0.55); - - --texto: #f0f6fc; - --texto-medio: #c4d6e6; - --fraco: #8ba2b9; - --muito-fraco: #50677e; - - --ciano: #5cd6ff; - --ciano-glow: rgba(92, 214, 255, 0.35); - --ciano-escuro: #0f394c; - --azul: #3b82f6; - --roxo: #a855f7; - --verde: #10b981; - --verde-glow: rgba(16, 185, 129, 0.3); - --amarelo: #f59e0b; - --vermelho: #f43f5e; - --vermelho-bg: rgba(244, 63, 94, 0.15); - - --raio-sm: 8px; - --raio-md: 12px; - --raio-lg: 16px; - --raio-xl: 22px; - - --sombra: 0 10px 30px -8px rgba(0, 0, 0, 0.65), 0 0 1px 1px rgba(255, 255, 255, 0.06); - --sombra-card: 0 8px 24px -6px rgba(0, 0, 0, 0.5), inset 0 1px 0 0 rgba(255, 255, 255, 0.08); - --sombra-glow: 0 0 24px -4px var(--ciano-glow); - - font: 15px/1.6 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: var(--fundo); - color: var(--texto); -} - -*, *::before, *::after { - box-sizing: border-box; -} - -body { - margin: 0; - padding: 0; - background: var(--fundo); - background-image: var(--fundo-grad); - background-attachment: fixed; - color: var(--texto); - min-height: 100vh; - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; -} - -body.dashboard { - display: block; -} - -/* ========================================================================== - TIPOGRAFIA & LINKS - ========================================================================== */ -h1, h2, h3, h4 { - color: var(--texto); - font-weight: 700; - letter-spacing: -0.025em; - margin: 0 0 10px; -} - -h1 { - font-size: 28px; - line-height: 1.2; - background: linear-gradient(135deg, #ffffff 30%, var(--ciano) 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - display: inline-block; -} - -h2 { - font-size: 19px; - display: flex; - align-items: center; - gap: 8px; - color: var(--texto); -} - -h3 { - font-size: 16px; - font-weight: 600; - color: var(--texto-medio); -} - -p { - color: var(--fraco); - margin: 6px 0 14px; - max-width: 85ch; -} - -a { - color: var(--ciano); - text-decoration: none; - transition: color 0.15s ease, text-shadow 0.15s ease; -} - -a:hover { - color: #8be4ff; - text-shadow: 0 0 8px var(--ciano-glow); -} - -small, .sub, .texto-fraco, .notice, .intro { - color: var(--fraco); - font-size: 13.5px; - line-height: 1.5; -} - -.code-label, code, pre { - font-family: 'JetBrains Mono', Consolas, monospace; -} - -pre { - background: rgba(5, 9, 16, 0.7); - border: 1px solid var(--borda); - border-radius: var(--raio-md); - padding: 14px; - font-size: 12.5px; - color: #a5c7e5; - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -/* ========================================================================== - FORMULÁRIOS & BOTÕES MODERNOS - ========================================================================== */ -button, input, select, textarea { - font-family: inherit; - font-size: 14px; -} - -button, .file { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - border: 1px solid var(--borda); - border-radius: var(--raio-sm); - padding: 9px 16px; - background: rgba(22, 36, 56, 0.7); - color: var(--texto); - cursor: pointer; - font-weight: 600; - letter-spacing: 0.01em; - transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1); - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25); - backdrop-filter: blur(8px); -} - -button:hover:enabled, .file:hover { - border-color: var(--borda-hover); - background: rgba(33, 54, 82, 0.85); - color: #ffffff; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); -} - -button:active:enabled { - transform: translateY(0); -} - -button:disabled { - opacity: 0.45; - cursor: not-allowed; - filter: grayscale(0.5); -} - -button.primary, button.principal, button.on { - background: linear-gradient(135deg, #4ac8f5 0%, #2563eb 100%); - border-color: rgba(92, 214, 255, 0.5); - color: #04121d; - font-weight: 700; - box-shadow: 0 4px 14px var(--ciano-glow); -} - -button.primary:hover:enabled, button.principal:hover:enabled, button.on:hover:enabled { - background: linear-gradient(135deg, #70d6ff 0%, #3b82f6 100%); - box-shadow: 0 6px 20px rgba(92, 214, 255, 0.5); - color: #020c14; -} - -button.danger, button.stop, .danger { - background: linear-gradient(135deg, #5c1825 0%, #881337 100%); - border-color: #f43f5e; - color: #ffe4e6; - box-shadow: 0 4px 14px rgba(244, 63, 94, 0.3); -} - -button.danger:hover:enabled, button.stop:hover:enabled { - background: linear-gradient(135deg, #881337 0%, #be123c 100%); - color: #ffffff; -} - -button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible { - outline: 2px solid var(--ciano); - outline-offset: 2px; -} - -input:not([type=checkbox]):not([type=range]):not([type=color]):not([type=file]), select, textarea { - width: 100%; - background: var(--painel-input); - color: var(--texto); - border: 1px solid var(--borda); - border-radius: var(--raio-sm); - padding: 10px 14px; - margin: 6px 0 14px; - transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s; - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3); -} - -input:focus, select:focus, textarea:focus { - border-color: var(--ciano); - box-shadow: 0 0 0 3px rgba(92, 214, 255, 0.15), inset 0 1px 2px rgba(0, 0, 0, 0.4); - background: rgba(12, 20, 34, 0.95); -} - -input[type=range] { - accent-color: var(--ciano); - width: 100%; - height: 6px; - background: rgba(40, 60, 85, 0.5); - border-radius: 4px; - cursor: pointer; -} - -input[type=checkbox] { - accent-color: var(--ciano); - width: 18px; - height: 18px; - cursor: pointer; - border-radius: 4px; -} - -label { - color: var(--texto-medio); - font-size: 13.5px; - font-weight: 500; - display: block; -} - -details { - border-top: 1px solid var(--borda); - padding-top: 14px; - margin-top: 16px; - background: transparent; -} - -summary { - cursor: pointer; - color: var(--texto-medio); - font-weight: 600; - padding: 6px 0; - user-select: none; - display: flex; - align-items: center; - gap: 8px; -} - -summary:hover { - color: var(--ciano); -} - -/* ========================================================================== - CARDS & CONTAINERS GLASSMORPHISM - ========================================================================== */ -.card, .cartao, article { - background: var(--painel-card); - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); - border: 1px solid var(--borda); - border-radius: var(--raio-lg); - padding: 24px; - box-shadow: var(--sombra-card); - transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; - position: relative; - overflow: hidden; -} - -.card::before, .cartao::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 1px; - background: linear-gradient(90deg, transparent, rgba(92, 214, 255, 0.25), transparent); - pointer-events: none; -} - -.card:hover, .cartao:hover { - border-color: rgba(92, 214, 255, 0.38); -} - -.grid, .grade { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 22px; -} - -.full { - grid-column: 1 / -1; -} - -.toolbar, .actions, .linha { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.toolbar { - margin: 18px 0; -} - -.badge, .online, .connection { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12.5px; - font-weight: 600; - border-radius: 9999px; - padding: 5px 12px; - background: rgba(25, 42, 62, 0.8); - color: var(--texto-medio); - border: 1px solid var(--borda); - backdrop-filter: blur(6px); -} - -.badge.ativo, .online.ativo, .connection.ativo { - background: rgba(16, 185, 129, 0.18); - color: #34d399; - border-color: rgba(16, 185, 129, 0.4); - box-shadow: 0 0 12px rgba(16, 185, 129, 0.25); -} - -.badge.offline, .badge.danger { - background: var(--vermelho-bg); - color: #fb7185; - border-color: rgba(244, 63, 94, 0.35); -} - -.badge-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: currentColor; - display: inline-block; - box-shadow: 0 0 8px currentColor; -} - -/* ========================================================================== - APP SIDEBAR & NAVEGAÇÃO - ========================================================================== */ -.app-sidebar { - position: fixed; - inset: 0 auto 0 0; - width: 250px; - background: rgba(9, 15, 26, 0.92); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); - border-right: 1px solid var(--borda); - display: flex; - flex-direction: column; - padding: 24px 16px; - z-index: 80; - box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4); -} - -.brand { - display: flex; - align-items: center; - gap: 14px; - text-decoration: none; - color: var(--texto); - font-size: 22px; - font-weight: 800; - letter-spacing: -0.03em; - padding: 4px 8px 24px; - border-bottom: 1px solid rgba(56, 82, 110, 0.25); - margin-bottom: 16px; -} - -.brand-face { - width: 38px; - height: 32px; - position: relative; - display: flex; - justify-content: center; - align-items: center; - gap: 6px; - background: rgba(15, 30, 48, 0.8); - border: 1px solid var(--borda-brilho); - border-radius: 10px; - box-shadow: 0 0 16px var(--ciano-glow); -} - -.brand-face i { - width: 7px; - height: 12px; - background: var(--ciano); - border-radius: 4px; - box-shadow: 0 0 8px var(--ciano); - animation: carinhaBlink 4s infinite; -} - -.brand-face::after { - content: ""; - position: absolute; - bottom: 5px; - width: 12px; - height: 4px; - border-bottom: 2px solid var(--ciano); - border-radius: 50%; -} - -@keyframes carinhaBlink { - 0%, 94%, 100% { transform: scaleY(1); } - 97% { transform: scaleY(0.1); } -} - -.nav-section-title { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--muito-fraco); - padding: 12px 12px 6px; -} - -.app-sidebar nav { - display: flex; - flex-direction: column; - gap: 5px; - flex: 1; - overflow-y: auto; -} - -.app-sidebar nav a { - display: flex; - align-items: center; - gap: 12px; - padding: 11px 14px; - border-radius: var(--raio-md); - text-decoration: none; - color: var(--texto-medio); - font-size: 14px; - font-weight: 500; - border: 1px solid transparent; - transition: all 0.16s ease; -} - -.app-sidebar nav a:hover { - background: rgba(28, 48, 74, 0.45); - color: #ffffff; - border-color: rgba(92, 214, 255, 0.2); - transform: translateX(3px); -} - -.app-sidebar nav a[aria-current="page"] { - color: var(--ciano); - background: linear-gradient(90deg, rgba(20, 50, 75, 0.8) 0%, rgba(14, 32, 50, 0.4) 100%); - border-color: rgba(92, 214, 255, 0.45); - font-weight: 700; - box-shadow: inset 3px 0 0 var(--ciano), 0 4px 14px rgba(0, 0, 0, 0.25); -} - -.app-sidebar nav svg { - width: 20px; - height: 20px; - flex-shrink: 0; - stroke-width: 1.8; -} - -.sidebar-footer { - margin-top: auto; - border-top: 1px solid rgba(56, 82, 110, 0.25); - padding: 16px 8px 0; - font-size: 12px; - color: var(--fraco); - display: flex; - flex-direction: column; - gap: 4px; -} - -.sidebar-footer strong { - color: var(--texto-medio); - font-weight: 600; -} - -/* ========================================================================== - APP FRAME & TOPBAR - ========================================================================== */ -.app-frame { - margin-left: 250px; - min-width: 0; - display: flex; - flex-direction: column; - min-height: 100vh; -} - -.app-topbar { - height: 68px; - border-bottom: 1px solid var(--borda); - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 0 36px; - background: rgba(8, 14, 24, 0.75); - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); - position: sticky; - top: 0; - z-index: 60; -} - -.topbar-left { - display: flex; - align-items: center; - gap: 16px; -} - -.topbar-telemetry { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.app-content { - padding: 32px 36px 64px; - max-width: 1650px; - width: 100%; - margin: 0 auto; - min-width: 0; - flex: 1; -} - -/* ========================================================================== - OVERVIEW & DASHBOARD ESPECÍFICO - ========================================================================== */ -.overview-grid { - display: grid; - grid-template-columns: minmax(0, 1.35fr) minmax(280px, 1fr); - gap: 24px; - margin-top: 24px; -} - -.overview-face-card { - display: flex; - flex-direction: column; - background: linear-gradient(180deg, rgba(17, 30, 48, 0.85) 0%, rgba(10, 18, 30, 0.95) 100%); -} - -.overview-face { - height: 320px; - border: 1px solid var(--borda); - border-radius: var(--raio-md); - background: #000000; - box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.8), 0 0 20px rgba(92, 214, 255, 0.12); - margin-bottom: 16px; -} - -.metrics { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; -} - -.metric { - padding: 18px 20px; - display: flex; - flex-direction: column; - justify-content: space-between; -} - -.metric strong { - display: block; - font-size: 26px; - font-weight: 700; - line-height: 1.2; - margin: 8px 0; - font-family: 'JetBrains Mono', 'Outfit', sans-serif; - color: #ffffff; -} - -.metric p { - margin: 0; - font-size: 13px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--fraco); -} - -.quick-links { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 18px; - margin-top: 24px; -} - -.quick-links a.card { - text-decoration: none; - transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); -} - -.quick-links a.card:hover { - transform: translateY(-3px); - border-color: var(--ciano); - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4), 0 0 20px var(--ciano-glow); -} - -.quick-links h2 { - color: var(--ciano); - font-size: 17px; - margin-bottom: 6px; -} - -/* ========================================================================== - PAINEL DE CÂMERA & ROSTO - ========================================================================== */ -.supervisao { - display: grid; - grid-template-columns: 1.2fr 0.8fr; - gap: 24px; - margin-top: 20px; -} - -.cabecalho { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; -} - -.visao { - position: relative; - width: 100%; - aspect-ratio: 16 / 10; - background: #020509; - border: 1px solid var(--borda); - border-radius: var(--raio-md); - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.9); -} - -.visao video, .visao img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.hud-overlay { - position: absolute; - inset: 0; - pointer-events: none; - border: 1px solid rgba(92, 214, 255, 0.2); - display: flex; - flex-direction: column; - justify-content: space-between; - padding: 12px; -} - -.hud-reticle { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 80px; - height: 80px; - border: 1px dashed rgba(92, 214, 255, 0.4); - border-radius: 50%; -} - -.sensores { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; - margin: 16px 0; -} - -.sensores meter { - width: 100%; - height: 10px; - border-radius: 6px; - margin-top: 6px; -} - -.rosto-preview { - width: 100%; - height: 220px; - border: 1px solid var(--borda); - border-radius: var(--raio-md); - background: #000000; - margin: 14px 0 6px; -} - -/* Cores & Olhos */ -.swatch { - width: 34px; - height: 34px; - border-radius: 8px; - padding: 0; - border: 2px solid rgba(255, 255, 255, 0.2); - cursor: pointer; - transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s; -} - -.swatch:hover { - transform: scale(1.15); - box-shadow: 0 0 12px currentColor; -} - -.swatch.on { - border-color: #ffffff; - transform: scale(1.12); - box-shadow: 0 0 16px currentColor; -} - -input[type=color] { - width: 44px; - height: 36px; - padding: 0; - border: 1px solid var(--borda); - background: transparent; - border-radius: 8px; - cursor: pointer; -} - -.olho { - display: flex; - align-items: center; - gap: 10px; - margin-top: 14px; -} - -.previa { - display: flex; - gap: 14px; - margin-top: 14px; - justify-content: center; - padding: 16px; - background: #03070c; - border-radius: var(--raio-md); - border: 1px solid var(--borda); -} - -.bolha { - width: 36px; - height: 52px; - background: var(--ciano); - border-radius: 18px; - box-shadow: 0 0 20px var(--ciano); -} - -/* ========================================================================== - CONVERSA (CHAT MODERNO IA) - ========================================================================== */ -.chat-container { - display: flex; - flex-direction: column; - gap: 16px; -} - -.chat-history { - display: flex; - flex-direction: column; - gap: 14px; - max-height: 480px; - overflow-y: auto; - padding: 16px; - background: rgba(6, 11, 20, 0.7); - border: 1px solid var(--borda); - border-radius: var(--raio-lg); - scroll-behavior: smooth; -} - -.chat-msg { - display: flex; - gap: 12px; - max-width: 82%; - animation: fadeInMsg 0.22s ease-out; -} - -@keyframes fadeInMsg { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -} - -.chat-msg.user { - align-self: flex-end; - flex-direction: row-reverse; -} - -.chat-avatar { - width: 36px; - height: 36px; - border-radius: 10px; - display: grid; - place-items: center; - font-weight: 700; - font-size: 15px; - flex-shrink: 0; -} - -.chat-msg.assistant .chat-avatar { - background: linear-gradient(135deg, #102a45 0%, #1e4976 100%); - border: 1px solid var(--ciano); - color: var(--ciano); - box-shadow: 0 0 10px var(--ciano-glow); -} - -.chat-msg.user .chat-avatar { - background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%); - color: #ffffff; -} - -.chat-bubble { - padding: 12px 16px; - border-radius: 14px; - font-size: 14.5px; - line-height: 1.55; - position: relative; -} - -.chat-msg.assistant .chat-bubble { - background: rgba(18, 30, 48, 0.9); - border: 1px solid rgba(92, 214, 255, 0.25); - color: #f1f7fd; - border-top-left-radius: 4px; -} - -.chat-msg.user .chat-bubble { - background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%); - color: #ffffff; - border-top-right-radius: 4px; - box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3); -} - -.chat-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: 12px; - font-size: 11px; - font-weight: 600; - background: rgba(92, 214, 255, 0.15); - color: var(--ciano); - border: 1px solid rgba(92, 214, 255, 0.3); - margin-top: 6px; - cursor: pointer; - transition: background 0.15s; -} - -.chat-badge:hover { - background: rgba(92, 214, 255, 0.3); -} - -.chat-suggestions { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin: 10px 0; -} - -.chat-chip { - padding: 6px 12px; - border-radius: 20px; - background: rgba(22, 38, 60, 0.6); - border: 1px solid var(--borda); - color: var(--texto-medio); - font-size: 12.5px; - cursor: pointer; - transition: all 0.16s ease; -} - -.chat-chip:hover { - border-color: var(--ciano); - color: #ffffff; - background: rgba(30, 55, 85, 0.8); - transform: translateY(-1px); -} - -.chat-input-bar { - display: flex; - gap: 10px; - align-items: center; -} - -/* ========================================================================== - EMOJIS & BIBLIOTECA - ========================================================================== */ -.library-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 20px; -} - -.emoji-swatch { - font: 44px/1.2 sans-serif; - background: #03070c; - border-radius: var(--raio-md); - text-align: center; - padding: 20px; - margin-bottom: 14px; - border: 1px solid var(--borda); - box-shadow: inset 0 0 20px rgba(0, 0, 0, 0.8); -} - -.dialog-overlay { - position: fixed; - inset: 0; - background: rgba(2, 6, 12, 0.85); - backdrop-filter: blur(8px); - display: grid; - place-items: center; - padding: 20px; - z-index: 100; -} - -.dialog-overlay[hidden] { - display: none; -} - -.dialog-card { - background: #0f1c2c; - border: 1px solid var(--borda-hover); - border-radius: var(--raio-xl); - max-width: 640px; - width: 100%; - padding: 28px; - max-height: 90vh; - overflow-y: auto; - box-shadow: 0 24px 60px rgba(0, 0, 0, 0.8), 0 0 30px var(--ciano-glow); -} - -/* ========================================================================== - BRAÇO ROBÓTICO & SERVOS - ========================================================================== */ -.arm { - filter: drop-shadow(0 0 12px rgba(92, 214, 255, 0.15)); -} - -/* ========================================================================== - RESPONSIVIDADE - ========================================================================== */ -@media (max-width: 1150px) { - .app-content { padding: 24px; } - .overview-grid, .supervisao, .split { grid-template-columns: 1fr; } - .app-topbar { padding: 0 24px; } -} - -@media (max-width: 768px) { - .app-sidebar { - transform: translateX(-100%); - transition: transform 0.22s cubic-bezier(0.16, 1, 0.3, 1); - } - .menu-open .app-sidebar { - transform: translateX(0); - } - .app-frame { margin-left: 0; } - .menu-button { - display: inline-flex !important; - padding: 8px 12px; - font-size: 18px; - } - .app-topbar { - padding: 0 16px; - height: 60px; - } - .topbar-telemetry { display: none; } - .app-content { padding: 18px 14px 48px; } - .metrics { grid-template-columns: 1fr; } - .grid, .grade { grid-template-columns: 1fr; } -} - -/* ========================================================================== - STICKY WORKSPACES & MINI-MONITOR FLUTUANTE (PICTURE-IN-PICTURE) - Mantém a carinha e a câmera sempre visíveis na tela durante a rolagem - ========================================================================== */ -.emojis-workspace { - display: grid; - grid-template-columns: 360px minmax(0, 1fr); - gap: 26px; - align-items: start; -} - -.sticky-preview-col { - position: sticky; - top: 84px; - z-index: 40; -} - -.sticky-preview-card { - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.6), 0 0 20px var(--ciano-glow); - border-color: var(--borda-hover); -} - -.painel-workspace { - display: grid; - grid-template-columns: minmax(360px, 480px) minmax(0, 1fr); - gap: 26px; - align-items: start; -} - -.painel-monitor-sticky { - position: sticky; - top: 84px; - z-index: 40; - max-height: calc(100vh - 104px); - overflow-y: auto; - padding-right: 6px; -} - -.painel-monitor-sticky::-webkit-scrollbar { - width: 5px; -} - -.painel-monitor-sticky::-webkit-scrollbar-thumb { - background: rgba(92, 214, 255, 0.35); - border-radius: 4px; -} - -.painel-controls-scroll { - display: flex; - flex-direction: column; - gap: 22px; - min-width: 0; -} - -/* Mini-Monitor Flutuante Global (PiP) */ -.floating-pip-widget { - position: fixed; - bottom: 24px; - right: 28px; - width: 270px; - background: rgba(10, 18, 30, 0.94); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); - border: 1px solid var(--borda-brilho); - border-radius: var(--raio-lg); - box-shadow: 0 16px 45px rgba(0, 0, 0, 0.8), 0 0 25px var(--ciano-glow); - z-index: 95; - overflow: hidden; - transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease; - display: flex; - flex-direction: column; -} - -.floating-pip-widget.minimized { - transform: translateY(calc(100% - 42px)); -} - -.floating-pip-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 14px; - background: rgba(18, 32, 50, 0.85); - border-bottom: 1px solid var(--borda); - font-size: 12.5px; - font-weight: 700; - color: var(--texto); - user-select: none; -} - -.floating-pip-header .pip-title { - display: flex; - align-items: center; - gap: 8px; -} - -.floating-pip-actions { - display: flex; - align-items: center; - gap: 6px; -} - -.floating-pip-actions button { - padding: 2px 7px; - font-size: 11px; - background: transparent; - border: 1px solid var(--borda); - border-radius: 6px; - color: var(--fraco); - box-shadow: none; -} - -.floating-pip-actions button:hover { - color: #fff; - border-color: var(--ciano); - background: rgba(92, 214, 255, 0.15); -} - -.floating-pip-body { - height: 165px; - background: #000000; - position: relative; -} - -.floating-pip-body iframe { - width: 100%; - height: 100%; - border: none; - background: #000000; -} - -@media (max-width: 1100px) { - .emojis-workspace, .painel-workspace { - grid-template-columns: 1fr; - } - .sticky-preview-col, .painel-monitor-sticky { - position: sticky; - top: 68px; - max-height: none; - } -} - -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} - -/* ========================================================================== - WATCH BAR — Barra de Monitoramento Fixada no Topo - Câmera e Rosto sempre visíveis com opção de expandir / recolher - ========================================================================== */ -.watch-bar { - position: fixed; - top: 68px; /* abaixo do app-topbar */ - left: 250px; - right: 0; - z-index: 50; /* abaixo do topbar (z-index:60) */ - background: rgba(7, 11, 18, 0.92); - backdrop-filter: blur(18px); - -webkit-backdrop-filter: blur(18px); - border-bottom: 1px solid rgba(92, 214, 255, 0.22); - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.65); - transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); -} - -.watch-bar.watch-bar-collapsed { - transform: translateY(calc(-100% + 32px)); - box-shadow: none; -} - -.watch-bar.watch-bar-collapsed::after { - content: '▼ MONITOR — clique em ☰ para expandir'; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.1em; - color: var(--ciano); - opacity: 0.6; - pointer-events: none; -} - -.watch-bar-inner { - display: flex; - align-items: stretch; - min-height: 32px; -} - -.watch-bar-grip { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 4px; - padding: 6px 8px; - border-right: 1px solid var(--borda); - color: var(--muito-fraco); - min-width: 40px; - user-select: none; - writing-mode: vertical-rl; - transform: rotate(180deg); - font-size: 10px; -} - -.watch-panels { - display: flex; - align-items: stretch; - flex: 1; - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: thin; - scrollbar-color: rgba(92,214,255,0.2) transparent; -} - -.watch-panel { - display: flex; - flex-direction: column; - border-right: 1px solid var(--borda); - min-width: 280px; - flex: 1; - overflow: hidden; - transition: opacity 0.25s ease, min-width 0.3s ease, flex 0.3s ease; -} - -.watch-panel.watch-panel-hidden { - min-width: 0 !important; - flex: 0 !important; - opacity: 0; - pointer-events: none; - border: none; -} - -.watch-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 3px 8px; - background: rgba(12, 20, 34, 0.7); - border-bottom: 1px solid var(--borda); - height: 26px; - flex-shrink: 0; - gap: 8px; -} - -.watch-panel-label { - font-size: 10.5px; - font-weight: 700; - letter-spacing: 0.05em; - color: var(--fraco); - white-space: nowrap; -} - -.watch-panel-actions { - display: flex; - gap: 3px; -} - -.wp-btn { - width: 20px; - height: 20px; - padding: 0; - font-size: 12px; - font-weight: 700; - background: rgba(20, 36, 56, 0.6); - border: 1px solid rgba(56, 82, 110, 0.35); - border-radius: 5px; - color: var(--fraco); - cursor: pointer; - display: grid; - place-items: center; - box-shadow: none; - line-height: 1; - transition: all 0.12s ease; -} - -.wp-btn:hover { background: rgba(40, 68, 100, 0.9); color: #fff; border-color: var(--ciano); transform: none; box-shadow: none; } -.wp-expand:hover { border-color: var(--verde); color: var(--verde); } -.wp-shrink:hover { border-color: var(--amarelo); color: var(--amarelo); } -.wp-hide:hover { border-color: var(--vermelho); color: var(--vermelho); } - -.watch-panel-body { - overflow: hidden; - background: #000; - transition: height 0.3s cubic-bezier(0.16, 1, 0.3, 1); - position: relative; - flex-shrink: 0; -} - -.watch-panel-body iframe { - width: 100%; - height: 100%; - border: none; - background: #000; - pointer-events: none; -} - -.watch-bar-tools { - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; - gap: 3px; - padding: 4px 5px; - border-left: 1px solid var(--borda); - min-width: 32px; - background: rgba(8, 14, 24, 0.5); -} - -.wp-tool-btn { - width: 22px; - height: 22px; - padding: 0; - font-size: 13px; - background: transparent; - border: 1px solid transparent; - border-radius: 6px; - color: var(--muito-fraco); - cursor: pointer; - display: grid; - place-items: center; - box-shadow: none; - transition: all 0.15s ease; -} - -.wp-tool-btn:hover { - background: rgba(92, 214, 255, 0.1); - border-color: rgba(92, 214, 255, 0.4); - color: var(--ciano); - transform: none; - box-shadow: none; -} - -@media (max-width: 1100px) { - .watch-bar { left: 0; } -} - -/* Espaço extra no app-content quando a watch-bar está visível */ -.watch-bar-active .app-content { - padding-top: 48px; -} - -@media (max-width: 768px) { - .watch-bar { display: none; } -} diff --git a/dashboard.html b/dashboard.html deleted file mode 100644 index 30eff12..0000000 --- a/dashboard.html +++ /dev/null @@ -1,153 +0,0 @@ - - - - - - Visão Geral · Central Carinha IA - - - - -
-
-

Central do Carinha

-

Supervisão em tempo real do Galaxy S10, telemetria de sensores, cérebro Gemini e robótica.

-
-
- Sistema Ativo -
-
- -
- -
-
-

- - Expressão do Personagem -

- Prévia Sincronizada -
- - - - -
- -
- - - - - - -
-
- -
- - - Abrir Câmera & Rosto - - - -
-

-
- - -
-
-
-
-

Galaxy S10

- 📱 -
- — - Aguardando conexão -
- -
-
-

Bateria

- 🔋 -
- — - Aguardando dados -
- -
-
-

Inteligência IA

- 🧠 -
- — - Verificando API -
- -
-
-

Expressões

- 🎨 -
- — - Reações na biblioteca -
-
- - -
-

- - Telemetria & Eventos -

-
-
- - Central inicializada no Orange Pi Zero 3 - agora -
-
-
-
-
- - -

Navegação Rápida

- - - - - \ No newline at end of file diff --git a/dashboard.js b/dashboard.js deleted file mode 100644 index 832ef7d..0000000 --- a/dashboard.js +++ /dev/null @@ -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 = ` - - - Carinha IA - - - - `; - - 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 = `${label}`; - 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 = ` -
- - - - Central de Controle - -
- -
- - - - - - Verificando celular… - -
- `; - - // 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 = ` -
-
- - Carinha Monitor -
-
- - -
-
-
- -
- `; - 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 = ` -
- ⚡ Disparo Rápido - -
-
- - - - - - - - -
- - `; - 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 ` -
-
- ${icon} ${label} -
- - - -
-
-
- -
-
- `; - } - - watchBar.innerHTML = ` -
-
- - MONITOR -
-
- ${makePanelHTML('camera', '📷', 'Câmera ao Vivo', '/painel?clean=1')} - ${makePanelHTML('rosto', '🤖', 'Rosto do Carinha', '/carinha.html?espelho=1&audio=0&clean=1')} -
-
- - - -
-
- `; - - 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 - }); - } -})(); - diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index 28e2fa7..561e158 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -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. diff --git a/docs/ORANGEPI.md b/docs/ORANGEPI.md index 635d192..39c635d 100644 --- a/docs/ORANGEPI.md +++ b/docs/ORANGEPI.md @@ -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. diff --git a/docs/PAINEL.md b/docs/PAINEL.md new file mode 100644 index 0000000..9d77515 --- /dev/null +++ b/docs/PAINEL.md @@ -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=`) 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` (`` 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. diff --git a/docs/SEGURANCA.md b/docs/SEGURANCA.md index 35d6ef6..2a10603 100644 --- a/docs/SEGURANCA.md +++ b/docs/SEGURANCA.md @@ -25,10 +25,23 @@ ou `seguranca.json` na pasta do servidor (fora do git): Sem token configurado o servidor fica aberto na rede local, como antes. -> **Ordem de ativação.** O painel antigo e o `carinha.html`/APK atuais ainda não enviam token. -> Ative o token na placa só depois de publicar o painel novo e o rosto/APK com suporte a token -> (fases 2–5 do plano de modernização). Até lá o servidor já aplica as demais proteções: -> checagem de Origin em todos os POST, comandos validados e E-Stop. +## Como ativar (ordem importa) + +1. Publique servidor e painel 0.8 (`deploy.ps1`) e instale o APK `0.8-token` no S10. +2. No S10: toque longo › **Token do servidor** › digite o token. O app passa a mandar o token + na página do rosto (`?t=`), nos pedidos Java e no llama-server local (`--api-key`). +3. Na placa: `echo 'CARINHA_TOKEN=...' | sudo tee /etc/carinha.env && sudo chmod 600 /etc/carinha.env` + e `sudo systemctl restart carinha-pc`. O serviço lê o arquivo (`EnvironmentFile=`). +4. Abra o painel: ele pede o token uma vez e guarda só naquele navegador. + +O cérebro da placa usa o mesmo token para falar com o llama-server do S10 +(ou `CARINHA_IA_CHAVE`, se forem diferentes). + +## App Android + +- Com token salvo, trocar o servidor por Intent (`adb shell am start ... -e servidor URL`) exige + `-e token `; 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 diff --git a/docs/carinha-ai.service b/docs/carinha-ai.service deleted file mode 100644 index 5b6c2aa..0000000 --- a/docs/carinha-ai.service +++ /dev/null @@ -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 - diff --git a/docs/carinha-pc.service b/docs/carinha-pc.service index 6321bfd..7addb97 100644 --- a/docs/carinha-pc.service +++ b/docs/carinha-pc.service @@ -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 diff --git a/emojis.html b/emojis.html deleted file mode 100644 index 3cd8152..0000000 --- a/emojis.html +++ /dev/null @@ -1,148 +0,0 @@ - - - - - - Emojis & Reações · Carinha - - - - -
-
-

Biblioteca de Emojis & Reações

-

Gerencie expressões visuais, desenhos vetoriais e reações inteligentes para o Carinha.

-
-
- - - -
-
- - -
- - - - - -
-
-
-

Catálogo de Reações do Robô

- — -
-

Reações salvas entram no repertório da conversa com o Gemini ou podem ser chamadas manualmente.

- - - -
- - -
-
- -
- - - - - - - - \ No newline at end of file diff --git a/iniciar.ps1 b/iniciar.ps1 index 480a490..29dd3c6 100644 --- a/iniciar.ps1 +++ b/iniciar.ps1 @@ -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/" } diff --git a/kiosk-server.py b/kiosk-server.py index bcca828..c2d145a 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -58,9 +58,15 @@ ORDENS_INTERNAS = {"calma"} APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255]} 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", + "/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task", + "/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm", + "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"} + # Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar. PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"} -PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/camera/", "/conversa/", "/treinamento/", +PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") @@ -290,45 +296,31 @@ class Handler(SimpleHTTPRequestHandler): enfileirar(ordem) return self.json({"ok": True, partes[0]: ordem}) - # Painel novo (Vite/Svelte em web/dist); as telas antigas seguem nos caminhos próprios. + # Painel (Vite/Svelte compilado em web/dist) em "/"; o resto é lista branca. painel = Path("web/dist") - if caminho in ("/", "") and (painel / "index.html").is_file(): + if caminho in ("/", "", "/index.html"): + if not (painel / "index.html").is_file(): + return self.json({"erro": "Painel não compilado: rode 'npm run build' em web/ (ou use deploy.ps1)"}, 503) self.path = "/web/dist/index.html" - return super().do_GET() - if caminho.startswith("/assets/"): + elif caminho.startswith("/assets/"): nome = caminho[len("/assets/"):] if "/" in nome or "\\" in nome or nome.startswith(".") or not (painel / "assets" / nome).is_file(): return self.json({"erro": "Arquivo não disponível"}, 404) self.path = "/web/dist/assets/" + nome - return super().do_GET() - - if caminho in ("/dashboard", "/emojis", "/api"): - self.path = caminho + ".html" - elif caminho == "/painel": - self.path = "/painel.html" # painel de controle - elif caminho == '/servos': - self.path = '/servos.html' - elif caminho == '/conversa': - self.path = '/conversa.html' - elif caminho == '/treinamento': - self.path = '/treinamento.html' - elif caminho == '/braco': - self.path = '/braco.html' - elif caminho in ("/", ""): - self.path = "/dashboard.html" + elif caminho in ("/dashboard", "/painel", "/conversa", "/emojis", "/treinamento", "/braco", "/servos", "/api"): + # Endereços das telas antigas levam à seção equivalente do painel novo. + secao = {"/conversa": "conversa", "/emojis": "reacoes", "/treinamento": "reacoes", "/braco": "movimento", + "/servos": "movimento", "/api": "ajustes"}.get(caminho, "ao-vivo") + self.send_response(302) + self.send_header("Location", "/#/" + secao) + self.send_header("Content-Length", "0") + self.end_headers() + return elif caminho == '/qualidade-auto.js': self.path = '/android/app/assets/qualidade-auto.js' elif caminho == '/app.apk': self.path = '/android/carinha-rosto.apk' - elif caminho in ( - '/vendor/mediapipe/vision_bundle.mjs', '/vendor/mediapipe/face.tflite', '/vendor/mediapipe/hands.task', - '/vendor/mediapipe/wasm/vision_wasm_internal.js', '/vendor/mediapipe/wasm/vision_wasm_internal.wasm', - '/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js', '/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm'): - pass - elif caminho in ('/dashboard.html','/emojis.html','/api.html','/dashboard.css','/dashboard.js','/overview.js','/biblioteca.js','/api-painel.js','/painel.css','/conversa.css','/treinamento.css','/servos.css','/braco.css','/audio-robot.js'): - pass - elif caminho not in ('/conversa.html', '/treinamento.html', '/treinamento.js', '/reacoes-render.js', '/painel-conversa.js', '/braco-calibracao.js', '/movimentos-core.js', '/braco-movimentos.js', '/braco.html', '/braco.js', '/servos.html', '/servos.js', '/carinha.html', '/camera.html', '/painel.html', '/painel-controle.js', '/painel-video.js', - '/rastreamento-core.js', '/rastreador-worker.js', '/painel-rastreamento.js'): + elif caminho not in ESTATICOS: return self.enviar(json.dumps({'erro': 'Arquivo não disponível'}), codigo=404) return super().do_GET() @@ -424,6 +416,7 @@ class Handler(SimpleHTTPRequestHandler): if not online: return self.json({"erro": "Abra o Carinha e conecte ao servidor primeiro"}, 409) FILA.adicionar("recarregar") + EVENTOS.publicar("recarregar", {"id": controle.recarga["id"]}) return self.json({"ok": True, "mensagem": "Solicitação enviada. Aguardando o celular recarregar a tela."}) def log_message(self, formato, *args): @@ -465,6 +458,8 @@ def main(): servidor.servos = ControleServos(Path.cwd() / 'servos.json') servidor.braco = Braco(Path.cwd() / 'braco.json', servidor.servos) servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, enfileirar) + if servidor.token and not servidor.cerebro.chave: + servidor.cerebro.chave = servidor.token servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro) servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db') servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar diff --git a/movimentos-core.js b/movimentos-core.js deleted file mode 100644 index 0a2e640..0000000 --- a/movimentos-core.js +++ /dev/null @@ -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); diff --git a/overview.js b/overview.js deleted file mode 100644 index 7a9e3ec..0000000 --- a/overview.js +++ /dev/null @@ -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 = ` - - ${texto} - ${hora} - `; - 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); -})(); \ No newline at end of file diff --git a/painel-controle.js b/painel-controle.js deleted file mode 100644 index 4900603..0000000 --- a/painel-controle.js +++ /dev/null @@ -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;} -}; diff --git a/painel-conversa.js b/painel-conversa.js deleted file mode 100644 index 81fab66..0000000 --- a/painel-conversa.js +++ /dev/null @@ -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 = ` -
🤖
- O Carinha está pronto para conversar! -

Envie uma mensagem abaixo ou use os atalhos de sugestão.

- `; - 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 = `✨${s.ultima.gesto}`; - 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 = 'Nenhuma lembrança salva no banco SQLite.'; - } - } - } 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); -})(); diff --git a/painel-rastreamento.js b/painel-rastreamento.js deleted file mode 100644 index a5819e7..0000000 --- a/painel-rastreamento.js +++ /dev/null @@ -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{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.')); -})(); diff --git a/painel-video.js b/painel-video.js deleted file mode 100644 index be8099c..0000000 --- a/painel-video.js +++ /dev/null @@ -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); - }); -})(); diff --git a/painel.css b/painel.css deleted file mode 100644 index 78d1cdc..0000000 --- a/painel.css +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/painel.html b/painel.html deleted file mode 100644 index b8ea1ac..0000000 --- a/painel.html +++ /dev/null @@ -1,379 +0,0 @@ - - - - - Câmera & Rosto · Central Carinha - - - - - - - -
-
-

Câmera & Rosto

-

Bateria do celular: aguardando dados.

-
-
- Monitoramento Contínuo -
-
- -
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.
- - -
- - - - - -
- - -
-
-

- - Disparo de Gestos -

- Fila de Ações -
-

Clique para acionar a expressão física no robô com feedback sonoro instantâneo:

-
-
- - -
-
-

Rosto

-

Selecione a proporção e formato:

-
-
—
-
- -
-

Cor dos olhos

-

Paleta com brilho neon:

-
-
- - escolher outra cor -
-
-
-
-
-
-
- - -
-

Aparência Atual & Demos

-
—
-
- - -
-
- - -
-
- Personalidade e IA Local (Qwen embarcado) -
- -

Qwen3 decide expressões a partir de sons e movimento. Processamento no celular; sem comandos de motores.

-
- - -
-

Carregando IA…

-
-
-
- - -
-
-

- - Comportamento & Sensores -

- Tempo Real -
-
-
- Sensibilidade e tempos de reação -
-
-
Carregando configurações…
-
- -
-
- -
- Servidor no Orange Pi. O vídeo é transmitido ao navegador e não é gravado. -
- - - - - - - - diff --git a/rastreamento-core.js b/rastreamento-core.js deleted file mode 100644 index de91262..0000000 --- a/rastreamento-core.js +++ /dev/null @@ -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); diff --git a/reacoes-render.js b/reacoes-render.js index 5e36736..74a73f6 100644 --- a/reacoes-render.js +++ b/reacoes-render.js @@ -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); })(); diff --git a/servir-local.bat b/servir-local.bat deleted file mode 100644 index 0fcd138..0000000 --- a/servir-local.bat +++ /dev/null @@ -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 diff --git a/servos.css b/servos.css deleted file mode 100644 index d582a06..0000000 --- a/servos.css +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/servos.html b/servos.html deleted file mode 100644 index a6d61a2..0000000 --- a/servos.html +++ /dev/null @@ -1,15 +0,0 @@ - - -Servos · Carinha - -

Teste dos servos

- -

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.

-

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.

- -

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.

-
-

Verificando PCA9685…

-
- - diff --git a/servos.js b/servos.js deleted file mode 100644 index 885ad80..0000000 --- a/servos.js +++ /dev/null @@ -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; }); diff --git a/tests/test_braco_browser.py b/tests/test_braco_browser.py deleted file mode 100644 index e87768f..0000000 --- a/tests/test_braco_browser.py +++ /dev/null @@ -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() diff --git a/tests/test_browser.py b/tests/test_browser.py index a556bb7..765183d 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -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 diff --git a/tests/test_controle_pc.py b/tests/test_controle_pc.py index 70ef892..8eb78ec 100644 --- a/tests/test_controle_pc.py +++ b/tests/test_controle_pc.py @@ -86,21 +86,13 @@ 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_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) diff --git a/tests/test_live_servos_browser.py b/tests/test_live_servos_browser.py deleted file mode 100644 index 318c352..0000000 --- a/tests/test_live_servos_browser.py +++ /dev/null @@ -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() diff --git a/tests/test_movimentos.js b/tests/test_movimentos.js deleted file mode 100644 index 2f7fc54..0000000 --- a/tests/test_movimentos.js +++ /dev/null @@ -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'); diff --git a/tests/test_rastreamento.js b/tests/test_rastreamento.js deleted file mode 100644 index b8c8bf4..0000000 --- a/tests/test_rastreamento.js +++ /dev/null @@ -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'); diff --git a/tests/test_rtc_browser.py b/tests/test_rtc_browser.py index 63490b9..c3a86d8 100644 --- a/tests/test_rtc_browser.py +++ b/tests/test_rtc_browser.py @@ -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') diff --git a/tests/test_tracking_browser.py b/tests/test_tracking_browser.py index 82eb6b0..3aeb85b 100644 --- a/tests/test_tracking_browser.py +++ b/tests/test_tracking_browser.py @@ -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() diff --git a/treinamento.css b/treinamento.css deleted file mode 100644 index b33fba0..0000000 --- a/treinamento.css +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/treinamento.html b/treinamento.html deleted file mode 100644 index c981a47..0000000 --- a/treinamento.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - Treinamento & Personalidade · Carinha - - - - - -
-
-

Treinamento & Personalidade

-

Ensine novos hábitos, registre preferências e crie desenhos dinâmicos com o Gemini.

-
- -
- -
- -
-

- - Rotina & Preferências -

-

Hábitos e gostos que moldam a convivência com o Carinha.

- -
- - - -
- -
-
- - -
-
-

- - Laboratório de Expressão IA -

- Gemini SVG Studio -
- - - -

- Descreva uma ideia visual para o Gemini sintetizar em formas vetoriais no rosto do robô. -

- -
- - -
- - -
-
- -
- Criação Automática Periódica - -
- -
- Prompt enviado ao Gemini (Inspeção Técnica) -

Orientação de sistema para os desenhos vetoriais:

-

-        

Último Prompt Enviado

-
Nenhum pedido enviado ainda.
-
- -

Carregando…

-
-
- - -
-
-
- - - Resumo das Sessões de Treino - -
-
-
- -
-
- - - Diário de Conversas do Robô - -

Registros gerados a partir dos diálogos anteriores.

-
- -
-
-
-
-
- - - - diff --git a/treinamento.js b/treinamento.js deleted file mode 100644 index 557d56a..0000000 --- a/treinamento.js +++ /dev/null @@ -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); -})(); diff --git a/version.json b/version.json index aed2dcb..d1696c2 100644 --- a/version.json +++ b/version.json @@ -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" ] -} \ No newline at end of file +} diff --git a/web/package.json b/web/package.json index 46a2b08..b699aa0 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.json" + "check": "svelte-check --tsconfig ./tsconfig.json", + "test": "node --test \"tests/**/*.test.ts\"" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.3.1", diff --git a/web/src/lib/braco.svelte.ts b/web/src/lib/braco.svelte.ts index a711188..c9146dc 100644 --- a/web/src/lib/braco.svelte.ts +++ b/web/src/lib/braco.svelte.ts @@ -7,6 +7,9 @@ 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; @@ -33,49 +36,6 @@ export interface StatusBraco { export const PRAZO_MS = 450; const RENOVAR_MS = 150; -function anguloDoPulso(p: number, c: Calibracao) { - const centro = c.centro ?? 1500; - return p <= centro ? (90 * (p - 500)) / (centro - 500) : 90 + (90 * (p - centro)) / (2500 - centro); -} - -/** Faixa segura (graus) de cada articulação a partir da calibração; o canal 5 respeita o par 6 invertido. */ -export function limites(st: StatusBraco): [number, number][] { - return st.articulacoes.map(({ canal }) => { - const c = st.calibracao[canal]; - let lo = anguloDoPulso(c.minimo, c); - let hi = anguloDoPulso(c.maximo, c); - if (canal === 5) { - const par = st.calibracao[6]; - lo = Math.max(lo, 180 - anguloDoPulso(par.maximo, par)); - hi = Math.min(hi, 180 - anguloDoPulso(par.minimo, par)); - } - return [Math.ceil(lo), Math.floor(hi)]; - }); -} - -/** As 5 sequências prontas, escaladas para a faixa calibrada (porta de movimentos-core.js). */ -export function sequencias(faixas: [number, number][]): { nome: string; passos: number[][] }[] { - const neutro = [0, 0, 0, 0, 0, 0, 0]; - const base: [string, number[][]][] = [ - ['Acenar', [[0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], [0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], neutro]], - ['Olhar para os lados', [[0, 0.2, -0.4, 0, 0, 0, -0.7], [0, -0.2, 0.4, 0, 0, 0, 0.7], neutro]], - ['Alcançar e recolher', [[0, 0, 0.2, 0.6, -0.5, 0.4, 0], [0, 0, -0.2, -0.4, 0.5, -0.4, 0], neutro]], - ['Garra e punho', [[-0.7, 0.3, 0, 0.2, 0, 0.1, 0], [0.7, -0.3, 0, 0.2, 0, 0.1, 0], [-0.7, 0.3, 0, 0, 0, 0, 0], neutro]], - ['Apresentação', [[0.3, 0.3, -0.3, 0.4, -0.3, 0.3, -0.6], [-0.3, -0.3, 0.3, -0.3, 0.4, -0.3, 0.6], [0.5, 0.4, 0.2, 0.3, -0.3, 0.2, 0], neutro]], - ]; - return base.map(([nome, passos]) => ({ - nome, - passos: passos.map((s) => - s.map((v, i) => { - const [lo, hi] = faixas[i]; - // Centro 90°, mas sempre dentro da faixa calibrada. - const meio = Math.max(lo, Math.min(hi, 90)); - return Math.round(meio + v * (v < 0 ? meio - lo : hi - meio)); - }), - ), - })); -} - class Braco { status = $state(null); alvo = $state(Array(7).fill(90)); @@ -90,7 +50,7 @@ class Braco { private sequencia: number[][] | null = null; private pausaAte = 0; - faixas = $derived(this.status ? limites(this.status) : []); + faixas = $derived(this.status ? limites(this.status.calibracao, this.status.articulacoes) : []); async carregar() { try { diff --git a/web/src/lib/nucleo.ts b/web/src/lib/nucleo.ts new file mode 100644 index 0000000..8b7f2a5 --- /dev/null +++ b/web/src/lib/nucleo.ts @@ -0,0 +1,99 @@ +/** + * Lógica pura do painel (sem Svelte nem DOM), testada com `npm test` (node --test). + * Rastreamento: escolha do alvo e encaixe do vídeo. Braço: faixas calibradas e sequências. + */ + +export interface Ponto { + x: number; + y: number; +} +export interface Face extends Ponto { + w: number; + h: number; + score?: number; + pontos?: Ponto[]; +} +export interface AlvoRastreio { + tipo: 'mao' | 'rosto'; + x: number; + y: number; + atividade?: boolean; +} + +const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y); +const limitar01 = (v: number) => Math.max(0, Math.min(1, v)); + +/** Mão tem prioridade sobre rosto; entre candidatos do mesmo tipo, o mais perto do alvo anterior. */ +export function escolherAlvo(faces: Face[], maos: Ponto[][], anterior: AlvoRastreio | null = null): AlvoRastreio | null { + let candidatos: AlvoRastreio[] = maos + .filter((h) => h.length === 21 && h.every(valido)) + .map((h) => { + const palma = [0, 5, 9, 13, 17].map((i) => h[i]); + return { tipo: 'mao', x: palma.reduce((s, p) => s + p.x, 0) / 5, y: palma.reduce((s, p) => s + p.y, 0) / 5 }; + }); + if (!candidatos.length) + candidatos = faces + .filter((f) => valido(f) && Number.isFinite(f.w) && Number.isFinite(f.h) && f.w > 0 && f.h > 0) + .sort((a, b) => b.w * b.h - a.w * a.h) + .map((f) => ({ tipo: 'rosto', x: f.x + f.w / 2, y: f.y + f.h / 2 })); + if (!candidatos.length) return null; + if (anterior && anterior.tipo === candidatos[0].tipo) + candidatos.sort((a, b) => Math.hypot(a.x - anterior.x, a.y - anterior.y) - Math.hypot(b.x - anterior.x, b.y - anterior.y)); + const t = candidatos[0]; + return { ...t, x: limitar01(t.x), y: limitar01(t.y) }; +} + +/** Retângulo do vídeo (vw×vh) encaixado em w×h sem cortar (object-fit: contain). */ +export function encaixe(w: number, h: number, vw: number, vh: number) { + const escala = Math.min(w / vw, h / vh); + return { x: Math.max(0, (w - vw * escala) / 2), y: Math.max(0, (h - vh * escala) / 2), w: vw * escala, h: vh * escala }; +} + +export interface Calibracao { + canal: number; + minimo: number; + maximo: number; + centro?: number; +} + +function anguloDoPulso(p: number, c: Calibracao) { + const centro = c.centro ?? 1500; + return p <= centro ? (90 * (p - 500)) / (centro - 500) : 90 + (90 * (p - centro)) / (2500 - centro); +} + +/** Faixa segura (graus) de cada articulação; o canal 5 respeita o par 6 invertido. */ +export function limites(calibracao: Calibracao[], articulacoes: { canal: number }[]): [number, number][] { + return articulacoes.map(({ canal }) => { + const c = calibracao[canal]; + let lo = anguloDoPulso(c.minimo, c); + let hi = anguloDoPulso(c.maximo, c); + if (canal === 5) { + const par = calibracao[6]; + lo = Math.max(lo, 180 - anguloDoPulso(par.maximo, par)); + hi = Math.min(hi, 180 - anguloDoPulso(par.minimo, par)); + } + return [Math.ceil(lo), Math.floor(hi)]; + }); +} + +/** As 5 sequências prontas, escaladas para a faixa calibrada de cada articulação. */ +export function sequencias(faixas: [number, number][]): { nome: string; passos: number[][] }[] { + const neutro = [0, 0, 0, 0, 0, 0, 0]; + const base: [string, number[][]][] = [ + ['Acenar', [[0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], [0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], neutro]], + ['Olhar para os lados', [[0, 0.2, -0.4, 0, 0, 0, -0.7], [0, -0.2, 0.4, 0, 0, 0, 0.7], neutro]], + ['Alcançar e recolher', [[0, 0, 0.2, 0.6, -0.5, 0.4, 0], [0, 0, -0.2, -0.4, 0.5, -0.4, 0], neutro]], + ['Garra e punho', [[-0.7, 0.3, 0, 0.2, 0, 0.1, 0], [0.7, -0.3, 0, 0.2, 0, 0.1, 0], [-0.7, 0.3, 0, 0, 0, 0, 0], neutro]], + ['Apresentação', [[0.3, 0.3, -0.3, 0.4, -0.3, 0.3, -0.6], [-0.3, -0.3, 0.3, -0.3, 0.4, -0.3, 0.6], [0.5, 0.4, 0.2, 0.3, -0.3, 0.2, 0], neutro]], + ]; + return base.map(([nome, passos]) => ({ + nome, + passos: passos.map((s) => + s.map((v, i) => { + const [lo, hi] = faixas[i]; + const meio = Math.max(lo, Math.min(hi, 90)); + return Math.round(meio + v * (v < 0 ? meio - lo : hi - meio)); + }), + ), + })); +} diff --git a/web/src/lib/rastreamento.svelte.ts b/web/src/lib/rastreamento.svelte.ts index 5e2699f..624d2bf 100644 --- a/web/src/lib/rastreamento.svelte.ts +++ b/web/src/lib/rastreamento.svelte.ts @@ -3,18 +3,9 @@ * servidor em /rastreador-worker.js). Porta de painel-rastreamento.js + rastreamento-core.js. */ import type { AppConfig } from './tipos'; -import type { AlvoRastreio, VideoAoVivo } from './video.svelte'; +import { encaixe, escolherAlvo, type AlvoRastreio, type Face, type Ponto } from './nucleo'; +import type { VideoAoVivo } from './video.svelte'; -interface Ponto { - x: number; - y: number; -} -interface Face extends Ponto { - w: number; - h: number; - score: number; - pontos?: Ponto[]; -} interface Resultado { faces: Face[]; hands: Ponto[][]; @@ -22,35 +13,9 @@ interface Resultado { timestamp: number; } -const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y); -const limitar = (v: number) => Math.max(0, Math.min(1, v)); const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12], [9, 13], [13, 14], [14, 15], [15, 16], [13, 17], [0, 17], [17, 18], [18, 19], [19, 20]]; -export function escolherAlvo(faces: Face[], maos: Ponto[][], anterior: AlvoRastreio | null): AlvoRastreio | null { - let candidatos: AlvoRastreio[] = maos - .filter((h) => h.length === 21 && h.every(valido)) - .map((h) => { - const palma = [0, 5, 9, 13, 17].map((i) => h[i]); - return { tipo: 'mao', x: palma.reduce((s, p) => s + p.x, 0) / 5, y: palma.reduce((s, p) => s + p.y, 0) / 5 }; - }); - if (!candidatos.length) - candidatos = faces - .filter((f) => valido(f) && f.w > 0 && f.h > 0) - .sort((a, b) => b.w * b.h - a.w * a.h) - .map((f) => ({ tipo: 'rosto', x: f.x + f.w / 2, y: f.y + f.h / 2 })); - if (!candidatos.length) return null; - if (anterior && anterior.tipo === candidatos[0].tipo) - candidatos.sort((a, b) => Math.hypot(a.x - anterior.x, a.y - anterior.y) - Math.hypot(b.x - anterior.x, b.y - anterior.y)); - const t = candidatos[0]; - return { ...t, x: limitar(t.x), y: limitar(t.y) }; -} - -function encaixe(w: number, h: number, vw: number, vh: number) { - const escala = Math.min(w / vw, h / vh); - return { x: Math.max(0, (w - vw * escala) / 2), y: Math.max(0, (h - vh * escala) / 2), w: vw * escala, h: vh * escala }; -} - export class Rastreamento { texto = $state('Rastreamento desligado.'); @@ -125,7 +90,7 @@ export class Rastreamento { const p = pt(rosto); c.strokeStyle = c.fillStyle = '#5cd6ff'; c.strokeRect(p.x, p.y, rosto.w * f.w, rosto.h * f.h); - c.fillText('Rosto ' + Math.round(rosto.score * 100) + '%', p.x, Math.max(15, p.y - 6)); + c.fillText('Rosto ' + Math.round((rosto.score ?? 0) * 100) + '%', p.x, Math.max(15, p.y - 6)); for (const q of rosto.pontos || []) bola(pt(q)); } for (const mao of r.hands) { diff --git a/web/src/lib/video.svelte.ts b/web/src/lib/video.svelte.ts index 53c920a..4957df1 100644 --- a/web/src/lib/video.svelte.ts +++ b/web/src/lib/video.svelte.ts @@ -4,14 +4,10 @@ * Só a tela Ao vivo cria uma instância: assim não há disputa pela única sessão. */ import { lerToken, pedir } from './api'; +import type { AlvoRastreio } from './nucleo'; import type { AppConfig, Telemetria } from './tipos'; -export interface AlvoRastreio { - tipo: 'mao' | 'rosto'; - x: number; - y: number; - atividade?: boolean; -} +export type { AlvoRastreio } from './nucleo'; export class VideoAoVivo { mensagem = $state('Transmissão desligada.'); @@ -186,6 +182,10 @@ export class VideoAoVivo { } } + get rastreamentoConectado() { + return this.canal?.readyState === 'open'; + } + rastrear(alvo: AlvoRastreio | null) { if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096) this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo })); diff --git a/web/src/telas/AoVivo.svelte b/web/src/telas/AoVivo.svelte index c6f615c..8941930 100644 --- a/web/src/telas/AoVivo.svelte +++ b/web/src/telas/AoVivo.svelte @@ -22,10 +22,12 @@ onMount(() => { video = new VideoAoVivo(elVideo); rastreio = new Rastreamento(elVideo, elTela, video); + (window as any).carinhaVideo = video; // depuração e testes de ponta a ponta }); onDestroy(() => { rastreio?.destruir(); video?.destruir(); + delete (window as any).carinhaVideo; }); // Vídeo e rastreamento seguem o estado do servidor (SSE), sem polling próprio. diff --git a/web/tests/nucleo.test.ts b/web/tests/nucleo.test.ts new file mode 100644 index 0000000..c955717 --- /dev/null +++ b/web/tests/nucleo.test.ts @@ -0,0 +1,57 @@ +// node --test (Node 22.6+ remove os tipos sozinho). Substitui tests/test_movimentos.js e test_rastreamento.js. +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { encaixe, escolherAlvo, limites, sequencias } from '../src/lib/nucleo.ts'; + +test('mão tem prioridade sobre rosto e entradas inválidas são ignoradas', () => { + const faces = [{ x: 0.2, y: 0.1, w: 0.3, h: 0.4, score: 0.9 }]; + const maos = [Array.from({ length: 21 }, () => ({ x: 0.8, y: 0.6 }))]; + assert.equal(escolherAlvo(faces, maos)?.tipo, 'mao'); + const rosto = escolherAlvo(faces, [])!; + assert.equal(rosto.tipo, 'rosto'); + assert.ok(Math.abs(rosto.x - 0.35) < 1e-9 && Math.abs(rosto.y - 0.3) < 1e-9); + assert.equal(escolherAlvo([], []), null); + assert.equal(escolherAlvo([{ x: NaN, y: 0, w: 1, h: 1 }], []), null); +}); + +test('entre rostos, segue o mais perto do alvo anterior', () => { + const faces = [ + { x: 0.0, y: 0.0, w: 0.4, h: 0.4 }, + { x: 0.7, y: 0.7, w: 0.2, h: 0.2 }, + ]; + assert.equal(escolherAlvo(faces, [])?.x, 0.2); + assert.ok(Math.abs(escolherAlvo(faces, [], { tipo: 'rosto', x: 0.8, y: 0.8 })!.x - 0.8) < 1e-9); +}); + +test('encaixe centraliza sem cortar', () => { + const r = encaixe(1000, 500, 640, 480); + assert.ok(Math.abs(r.x - (500 - (500 * 640) / 480 / 2)) < 1e-9); + assert.equal(r.y, 0); + assert.ok(Math.abs(r.h - 500) < 1e-9); +}); + +test('sequências respeitam a faixa calibrada e voltam ao neutro', () => { + const faixas = Array.from({ length: 7 }, (): [number, number] => [81, 99]); + const seqs = sequencias(faixas); + assert.equal(seqs.length, 5); + for (const s of seqs) { + assert.ok(s.passos.length >= 3); + for (const passo of s.passos) { + assert.equal(passo.length, 7); + passo.forEach((v) => assert.ok(v >= 81 && v <= 99, `${s.nome}: ${v}`)); + } + } + assert.deepEqual(seqs[0].passos.at(-1), Array(7).fill(90)); + // Faixa que não contém 90°: o neutro vira o ponto mais próximo dentro dela. + const deslocada = sequencias(Array.from({ length: 7 }, (): [number, number] => [100, 140])); + assert.deepEqual(deslocada[0].passos.at(-1), Array(7).fill(100)); +}); + +test('limites seguem a calibração e o par 5/6 invertido', () => { + const cal = Array.from({ length: 8 }, (_, canal) => ({ canal, minimo: 1400, maximo: 1600, centro: 1500 })); + const art = [0, 1, 2, 3, 4, 5, 7].map((canal) => ({ canal })); + const f = limites(cal, art); + assert.deepEqual(f[0], [81, 99]); + cal[6] = { canal: 6, minimo: 1450, maximo: 1600, centro: 1500 }; + assert.deepEqual(limites(cal, art)[5], [81, 94]); +}); diff --git a/web/vite.config.ts b/web/vite.config.ts index e268fdf..b0b0918 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -6,7 +6,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'; const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100'; const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos', '/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face', - '/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/audio-robot.js', '/qualidade-auto.js']; + '/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk']; export default defineConfig({ plugins: [svelte()],