From c5fd54c1d901189e99feb06fcd708fff8ecf6549 Mon Sep 17 00:00:00 2001 From: julio Date: Mon, 5 Oct 2026 20:11:58 -0300 Subject: [PATCH] feat(celular): rosto e app usam SSE e token; remove telas legadas - carinha.html assina /eventos (expressao, app_config, conversa, reacao, recarregar) e volta ao polling de /fila sozinho se o SSE cair; foto dos ajustes a cada (re)conexao; token (?t=) vai em todo fetch ao servidor - APK 0.8-token: token nas preferencias e no menu de toque longo, enviado na pagina, no HTTP do Java e ao llama-server (--api-key); trocar servidor por Intent exige o token salvo; camera/microfone so para o servidor configurado ou a copia local - cerebro/conversa mandam a chave ao llama-server do S10 - servidor: evento SSE 'recarregar'; remove /camera/frame e /app/connect; enderecos antigos redirecionam para a secao do painel novo - remove dashboard, painel, conversa, emojis, treinamento, braco, servos, api, scripts e CSS antigos, audio-robot.js e servir-local.bat - logica pura do painel em web/src/lib/nucleo.ts com testes node --test (substitui test_movimentos.js e test_rastreamento.js) - testes de navegador portados para o painel novo (rtc, rosto, rastreamento) - README, docs/PAINEL.md e docs/SEGURANCA.md reescritos; versao 0.8.0; carinha-pc.service le /etc/carinha.env Co-Authored-By: Claude Opus 5.5 --- README.md | 107 +- android/app/AndroidManifest.xml | 4 +- android/app/assets/carinha.html | 90 +- .../java/br/carinha/rosto/CerebroLocal.java | 6 +- .../java/br/carinha/rosto/ControleRemoto.java | 4 + .../java/br/carinha/rosto/MainActivity.java | 55 +- api-painel.js | 1 - api.html | 117 -- audio-robot.js | 248 --- biblioteca.js | 13 - braco-calibracao.js | 17 - braco-movimentos.js | 41 - braco.css | 213 --- braco.html | 158 -- braco.js | 49 - carinha.html | 74 +- cerebro.py | 8 +- controle_pc.py | 71 +- conversa.css | 327 ---- conversa.html | 164 -- conversa.py | 2 +- dashboard.css | 1353 ----------------- dashboard.html | 153 -- dashboard.js | 459 ------ docs/DASHBOARD.md | 2 + docs/ORANGEPI.md | 2 +- docs/PAINEL.md | 57 + docs/SEGURANCA.md | 21 +- docs/carinha-ai.service | 14 - docs/carinha-pc.service | 2 + emojis.html | 148 -- iniciar.ps1 | 8 +- kiosk-server.py | 55 +- movimentos-core.js | 13 - overview.js | 159 -- painel-controle.js | 215 --- painel-conversa.js | 328 ---- painel-rastreamento.js | 98 -- painel-video.js | 138 -- painel.css | 247 --- painel.html | 379 ----- rastreamento-core.js | 33 - reacoes-render.js | 2 +- servir-local.bat | 8 - servos.css | 63 - servos.html | 15 - servos.js | 98 -- tests/test_braco_browser.py | 37 - tests/test_browser.py | 17 +- tests/test_controle_pc.py | 16 +- tests/test_live_servos_browser.py | 41 - tests/test_movimentos.js | 9 - tests/test_rastreamento.js | 19 - tests/test_rtc_browser.py | 50 +- tests/test_tracking_browser.py | 30 +- treinamento.css | 60 - treinamento.html | 116 -- treinamento.js | 19 - version.json | 13 +- web/package.json | 3 +- web/src/lib/braco.svelte.ts | 48 +- web/src/lib/nucleo.ts | 99 ++ web/src/lib/rastreamento.svelte.ts | 41 +- web/src/lib/video.svelte.ts | 12 +- web/src/telas/AoVivo.svelte | 2 + web/tests/nucleo.test.ts | 57 + web/vite.config.ts | 2 +- 67 files changed, 626 insertions(+), 5904 deletions(-) delete mode 100644 api-painel.js delete mode 100644 api.html delete mode 100644 audio-robot.js delete mode 100644 biblioteca.js delete mode 100644 braco-calibracao.js delete mode 100644 braco-movimentos.js delete mode 100644 braco.css delete mode 100644 braco.html delete mode 100644 braco.js delete mode 100644 conversa.css delete mode 100644 conversa.html delete mode 100644 dashboard.css delete mode 100644 dashboard.html delete mode 100644 dashboard.js create mode 100644 docs/PAINEL.md delete mode 100644 docs/carinha-ai.service delete mode 100644 emojis.html delete mode 100644 movimentos-core.js delete mode 100644 overview.js delete mode 100644 painel-controle.js delete mode 100644 painel-conversa.js delete mode 100644 painel-rastreamento.js delete mode 100644 painel-video.js delete mode 100644 painel.css delete mode 100644 painel.html delete mode 100644 rastreamento-core.js delete mode 100644 servir-local.bat delete mode 100644 servos.css delete mode 100644 servos.html delete mode 100644 servos.js delete mode 100644 tests/test_braco_browser.py delete mode 100644 tests/test_live_servos_browser.py delete mode 100644 tests/test_movimentos.js delete mode 100644 tests/test_rastreamento.js delete mode 100644 treinamento.css delete mode 100644 treinamento.html delete mode 100644 treinamento.js create mode 100644 web/src/lib/nucleo.ts create mode 100644 web/tests/nucleo.test.ts 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()],