Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7058aaa7a | ||
|
|
50b6f97b50 | ||
|
|
05e82dfd75 | ||
|
|
ff06f02705 | ||
|
|
e73897d9c1 | ||
|
|
d7a17b5683 | ||
|
|
48fd51e377 | ||
|
|
9e09df40e2 | ||
|
|
dd40c730be | ||
|
|
5fcd038861 | ||
|
|
b456f80716 | ||
|
|
8d410dbf24 | ||
|
|
fd6a14054d | ||
|
|
784042335c | ||
|
|
cf54a93b53 | ||
|
|
499a4ae479 | ||
|
|
6029c79645 | ||
|
|
4b9e805801 | ||
|
|
1445ffdcde | ||
|
|
788215b49e | ||
|
|
98c9e8803e | ||
|
|
2dab45604c | ||
|
|
982bf56cd0 | ||
|
|
1029b5fa99 | ||
|
|
a67c5ea9af | ||
|
|
e0dd03411d | ||
|
|
74ae2863a7 | ||
|
|
f7c4793dc2 | ||
|
|
319e63cc42 | ||
|
|
f550930f56 | ||
|
|
230ca991a2 | ||
|
|
628db8492f | ||
|
|
f45e05ab81 | ||
|
|
69018c8b36 | ||
|
|
972dd8dd66 | ||
|
|
b6b8e2efd5 | ||
|
|
69d04d4972 | ||
|
|
a058c6f453 | ||
|
|
3e51266644 | ||
|
|
c5fd54c1d9 | ||
|
|
673dea62e8 | ||
|
|
947c0a3127 | ||
|
|
c4b53a8976 | ||
|
|
35d443a358 |
@@ -0,0 +1,4 @@
|
|||||||
|
# Arquivos que rodam na placa (Linux): sempre com fim de linha LF
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.service text eol=lf
|
||||||
|
sistema/** text eol=lf
|
||||||
@@ -22,3 +22,7 @@ api-privada.json
|
|||||||
api-privada.tmp
|
api-privada.tmp
|
||||||
|
|
||||||
ia-config.json
|
ia-config.json
|
||||||
|
seguranca.json
|
||||||
|
historico.db
|
||||||
|
historico.json
|
||||||
|
fotos/
|
||||||
|
|||||||
@@ -1,60 +1,83 @@
|
|||||||
# Carinha PC
|
# Carinha Control Panel
|
||||||
|
|
||||||
Servidor no Orange Pi Zero 3, acessado pelo PC, para controlar o Carinha no Galaxy S10 e testar servos. Dashboard: http://192.168.1.35:8100/dashboard.
|
Robô de mesa: o **rosto** roda num Galaxy S10 (app Android), o **servidor** num Orange Pi Zero 3
|
||||||
|
(Python, só biblioteca padrão) e o **painel** em qualquer navegador da rede. O mesmo servidor
|
||||||
|
comanda um braço de 7 articulações por um PCA9685 (I²C) e conversa por Gemini ou Qwen local.
|
||||||
|
|
||||||
## Desenvolvimento
|
Painel: **http://192.168.1.35:8100/**
|
||||||
|
|
||||||
Pasta criada em 04/10/2026 a pedido do usuário. Código inicial copiado do projeto anterior; o original não será modificado. Design aprovado: controlar rosto, gestos, configurações e visualizar a câmera frontal pelo PC enquanto o celular mantém a carinha na tela.
|
```
|
||||||
|
PC / celular (navegador) Orange Pi :8100 Galaxy S10 (app)
|
||||||
|
┌──────────────────────┐ HTTP ┌──────────────────────┐ HTTP ┌──────────────────┐
|
||||||
|
│ painel Svelte (web/) │ ───────► │ kiosk-server.py │ ◄─────── │ carinha.html │
|
||||||
|
│ 1 EventSource │ ◄─SSE─── │ /eventos /estado │ ──SSE──► │ (rosto) │
|
||||||
|
│ WebRTC ◄────────────┼──────────┼── sinalização /rtc ──┼──────────┼─► câmera │
|
||||||
|
└──────────────────────┘ │ braço · servos (I²C) │ └──────────────────┘
|
||||||
|
│ conversa · reações │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
- [Design](docs/DESIGN.md)
|
## O painel
|
||||||
- [Plano e verificações](docs/PLANO.md)
|
|
||||||
- [Registro de alterações](docs/REGISTRO.md)
|
Quatro áreas + ajustes, todas alimentadas por **um** fluxo SSE (`/eventos`), sem polling:
|
||||||
|
|
||||||
|
| Área | O que tem |
|
||||||
|
|---|---|
|
||||||
|
| **Ao vivo** | Câmera do celular (WebRTC) e rosto lado a lado, gestos favoritos, aparência, sensores, IA local, acontecimentos |
|
||||||
|
| **Conversa** | Chat com voz, motor de IA, memória (lembranças + rotina) |
|
||||||
|
| **Reações** | Biblioteca (criar, editar, desfazer), criar com Gemini, todos os gestos, diário |
|
||||||
|
| **Movimento** | Braço com homem-morto, sequências, posturas, calibração e diagnóstico do PCA9685 |
|
||||||
|
| **Ajustes** | Celular (agrupado, com busca), IA e chave Gemini, token, sistema |
|
||||||
|
|
||||||
|
Controles que valem em qualquer tela:
|
||||||
|
|
||||||
|
- **Parar tudo** (botão vermelho, `Shift+Espaço` ou `Esc` duas vezes): desliga todos os servos,
|
||||||
|
para reações e acalma o rosto. Os motores ficam travados até **Rearmar**.
|
||||||
|
- **Ctrl+K**: paleta com gestos, rostos, cores, telas, ações e posturas.
|
||||||
|
- **1–9**: gestos favoritos (fixe com a estrela em Reações › Gestos). **g** + letra navega
|
||||||
|
(`g v` Ao vivo, `g c` Conversa, `g r` Reações, `g m` Movimento, `g a` Ajustes).
|
||||||
|
- O braço **só se move enquanto você segura** o botão ou o slider; ao soltar, os motores desligam
|
||||||
|
em menos de meio segundo. Detalhes em [docs/PAINEL.md](docs/PAINEL.md).
|
||||||
|
|
||||||
## Usar
|
## Usar
|
||||||
|
|
||||||
1. Execute **iniciar.bat**. Verifica e abre **http://192.168.1.35:8100/dashboard**, sem iniciar servidor no PC. O serviço carinha-pc inicia automaticamente na placa.
|
1. `iniciar.bat` confere o Orange Pi e abre o painel.
|
||||||
2. No celular, segure a tela do Carinha e altere o servidor para **http://192.168.1.35:8100**. O botão ADB do antigo servidor Windows não é usado nesta instalação.
|
2. No celular, toque longo › **Mudar o servidor** › `http://192.168.1.35:8100`.
|
||||||
3. Troque rosto, cor e gestos. Use **Mostrar boca** e **Mostrar sobrancelhas** para ativar cada uma independentemente. Clique **Ligar câmera** para vídeo ao vivo com qualidade automática, sem trocar a tela do celular.
|
3. Se a placa tiver token (recomendado, veja [docs/SEGURANCA.md](docs/SEGURANCA.md)):
|
||||||
4. Em **Comportamento**, ajuste som, movimento, volume, sono e economia de tela. **Configurações aplicadas no celular** depende da resposta do aparelho.
|
toque longo › **Token do servidor** no celular, e o painel pede o token uma vez.
|
||||||
|
|
||||||
Brilho padrão: **70% ativo / 8% em repouso**, com **30 segundos** de espera. Retorna com som, movimento ou comando. O brilho do sistema é restaurado ao sair do app.
|
## Desenvolver
|
||||||
|
|
||||||
Configurações ficam em `app-config.json` e `aparencia.json`. O vídeo não é gravado: WebRTC transmite diretamente do app ao navegador. O servidor troca apenas sinalização. Logs em `runtime/`. Qualidade automática busca 30 fps, adaptando resolução e compressão. O painel mostra os fps efetivos. [Detalhes do vídeo](docs/VIDEO-AUTO.md).
|
```powershell
|
||||||
|
# servidor local (sem I²C ele avisa "PCA9685 indisponível")
|
||||||
|
python kiosk-server.py --porta 8100
|
||||||
|
|
||||||
APK: `android/carinha-rosto.apk`. Guia: [android/APP.md](android/APP.md).
|
# painel com recarga automática, falando com o servidor local ou com a placa
|
||||||
|
cd web; npm install
|
||||||
|
npm run dev # usa http://127.0.0.1:8100
|
||||||
|
$env:CARINHA_SERVIDOR='http://192.168.1.35:8100'; npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Publicar na placa: `.\deploy.ps1` (compila o painel, faz backup e reinicia o serviço).
|
||||||
|
|
||||||
## Verificações
|
## Verificações
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python -m unittest discover -s tests -p test_controle_pc.py
|
python -m pytest tests -q --ignore-glob='*browser*' # servidor: token, SSE, E-Stop, trava, módulos
|
||||||
python tests/test_browser.py
|
cd web; npm run check; npm test; npm run build; cd .. # tipos, lógica pura e build do painel
|
||||||
python tests/test_rtc_browser.py
|
python tests/test_painel_web_browser.py # painel de ponta a ponta (Playwright)
|
||||||
node tests/test_qualidade.js
|
python tests/test_rtc_browser.py # WebRTC real com câmera sintética
|
||||||
python android/build-apk.py
|
python tests/test_browser.py # rosto (carinha.html)
|
||||||
|
node tests/test_qualidade.js # qualidade automática do vídeo
|
||||||
|
python android/build-apk.py # APK (precisa de keystore e libs nativas)
|
||||||
```
|
```
|
||||||
|
|
||||||
O teste de navegador usa Playwright e um servidor efêmero próprio, sem alterar o servidor real. Testes Java em `tests/` verificam conversão de imagem e brilho; compile-os com o JDK junto das classes correspondentes. O servidor em produção exige somente Python 3.9+.
|
`tests/test_tracking_browser.py` precisa dos modelos (`python obter-rastreamento.py`) e das
|
||||||
|
imagens de teste em `../work/tracking-fixtures`.
|
||||||
|
|
||||||
**Validação física em 05/10/2026:** S10 conectado à porta 8100, telemetria de câmera/microfone ativa e JPEG recente recebido pelo PC. Configurações confirmadas pelo app e brilho ativo reportado em 70%. Ainda falta observar a orientação da imagem e a transição física do brilho para repouso.
|
## Documentos
|
||||||
|
|
||||||
**Atualização 0.3 instalada:** vídeo contínuo WebRTC automático e boca/sobrancelhas independentes. Teste com câmera simulada recebeu 30 fps. Após atualizar o APK, reabra o Carinha e recarregue o painel; os fps no S10 ainda precisam ser confirmados.
|
[Painel](docs/PAINEL.md) · [Segurança](docs/SEGURANCA.md) · [Orange Pi](docs/ORANGEPI.md) ·
|
||||||
|
[Braço](docs/BRACO.md) · [Conversa](docs/CONVERSA.md) · [IA local](docs/IA-LOCAL.md) ·
|
||||||
Ferramentas de compilação reutilizadas de `C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools` (JDK 17 e Android SDK). Não é necessário rodar servidor na placa.
|
[Vídeo](docs/VIDEO-AUTO.md) · [Rastreamento](docs/RASTREAMENTO.md) · [Repositório](docs/GITEA.md) ·
|
||||||
# Atualização 0.4 — sensores e câmeras
|
[Registro](docs/REGISTRO.md)
|
||||||
|
|
||||||
Controle microfone e câmera separadamente, com permissões específicas durante o sono (padrão: só microfone). Escolha frontal/traseira e qualidade automática, manual ou máxima disponível no painel ou no menu de toque longo do app. Veja [detalhes e limites](docs/SENSORES-CAMERAS.md).
|
|
||||||
|
|
||||||
# Atualização 0.5 — rastreamento de rosto e mãos
|
|
||||||
|
|
||||||
O painel agora detecta rosto e mãos no PC, mostra pontos sobre o vídeo e pode fazer os olhos acompanharem o alvo por WebRTC. Os controles estão sob o vídeo. Só funciona com a transmissão ligada e painel aberto; os sensores e regras de sono continuam valendo. Veja [uso e validação](docs/RASTREAMENTO.md).
|
|
||||||
|
|
||||||
|
|
||||||
## Dashboard e organização
|
|
||||||
|
|
||||||
A navegação lateral reúne Visão geral, Câmera e rosto, Conversar, Emojis, Treinamento, Servos, Braço e API. O tema escuro/ciano e os controles são compartilhados em dashboard.css/dashboard.js; CSS próprio de cada página guarda apenas seu layout específico. No celular, o rosto continua em tela cheia.
|
|
||||||
|
|
||||||
A biblioteca /emojis permite criar emojis prontos, editar nome e contexto, substituir por emoji, duplicar, ativar/desativar, excluir, dar feedback, desfazer e exportar. Desfazer preserva o desenho original e funciona após reiniciar o serviço. Parar expressão não movimenta motores. Reações desativadas não são escolhidas automaticamente na conversa.
|
|
||||||
|
|
||||||
/api gerencia o modelo Gemini e a chave. Campos de chave ficam vazios após salvar e as respostas não retornam credenciais. Alterações são guardadas em api-privada.json (permissão 600 no Linux), ignorado pelo Git e bloqueado no servidor HTTP. A configuração antiga de ambiente continua válida enquanto não houver override.
|
|
||||||
|
|
||||||
[Revisão, critérios e validação do dashboard](docs/DASHBOARD.md).
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="br.carinha.rosto"
|
package="br.carinha.rosto"
|
||||||
android:versionCode="6"
|
android:versionCode="11"
|
||||||
android:versionName="0.6-ia-local">
|
android:versionName="0.8.3">
|
||||||
|
|
||||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||||
|
|
||||||
<!-- precisa de rede para falar com o servidor da carinha (troca de rosto) -->
|
<!-- precisa de rede para falar com o servidor da carinha (troca de rosto) -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<!-- só o nível do sinal do Wi-Fi, para o painel (sem localização nem nome da rede) -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
<!-- escutar o ambiente: reage a barulho (surpreso) e a silencio (dormir) -->
|
<!-- escutar o ambiente: reage a barulho (surpreso) e a silencio (dormir) -->
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<!-- camera: pagina de visualizacao e, depois, seguir o movimento -->
|
<!-- camera: pagina de visualizacao e, depois, seguir o movimento -->
|
||||||
|
|||||||
+555
-51
@@ -5,7 +5,13 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<style>
|
<style>
|
||||||
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}
|
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}
|
||||||
svg{display:block;width:100%;height:100%}
|
svg{display:block;width:100%;height:100%;position:relative;z-index:2}
|
||||||
|
/* atividade (momentos dele): a tela se divide. Deitado, o rosto vai para a esquerda e o bonequinho
|
||||||
|
aparece à direita; em pé, o rosto sobe e ele aparece embaixo. */
|
||||||
|
#tela,#rostoMotor{transition:transform .8s cubic-bezier(.2,.8,.2,1);transform-origin:50% 50%}
|
||||||
|
body.atividade #tela,body.atividade #rostoMotor{transform:translateX(-23%) scale(.74)}
|
||||||
|
@media (orientation:portrait){body.atividade #tela,body.atividade #rostoMotor{transform:translateY(-20%) scale(.8)}}
|
||||||
|
body.atividade #tela>rect{opacity:0} /* o fundo preto do SVG não pode tapar o bonequinho */
|
||||||
.corpo{fill-rule:evenodd}
|
.corpo{fill-rule:evenodd}
|
||||||
.traco{fill:none;stroke-linecap:round}
|
.traco{fill:none;stroke-linecap:round}
|
||||||
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
|
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
|
||||||
@@ -25,7 +31,28 @@
|
|||||||
<path id="sobrancelha2" class="traco" style="display:none"/>
|
<path id="sobrancelha2" class="traco" style="display:none"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div id="fps"></div>
|
<div id="fps"></div>
|
||||||
|
<script src="rosto-motor.js"></script>
|
||||||
|
<script src="voz-robo.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
|
||||||
|
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
|
||||||
|
(function () {
|
||||||
|
const token = new URLSearchParams(location.search).get('t');
|
||||||
|
window.carinhaToken = token || '';
|
||||||
|
if (!token || !window.fetch) return;
|
||||||
|
const original = window.fetch.bind(window);
|
||||||
|
window.fetch = (recurso, opcoes = {}) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(typeof recurso === 'string' ? recurso : recurso.url, location.href);
|
||||||
|
if (url.origin === location.origin) {
|
||||||
|
const cabecalhos = new Headers(opcoes.headers || {});
|
||||||
|
cabecalhos.set('Authorization', 'Bearer ' + token);
|
||||||
|
opcoes = Object.assign({}, opcoes, { headers: cabecalhos });
|
||||||
|
}
|
||||||
|
} catch (e) { /* URL estranha: segue sem token */ }
|
||||||
|
return original(recurso, opcoes);
|
||||||
|
};
|
||||||
|
})();
|
||||||
"use strict";
|
"use strict";
|
||||||
/* -------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------
|
||||||
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
|
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
|
||||||
@@ -71,12 +98,33 @@ addEventListener("resize", ajustar);
|
|||||||
|
|
||||||
const PRETO = [6, 8, 14];
|
const PRETO = [6, 8, 14];
|
||||||
const CIANO = [92, 214, 255];
|
const CIANO = [92, 214, 255];
|
||||||
const EXTRAS = { boca: true, sobrancelhas: false };
|
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
|
||||||
|
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
|
||||||
|
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
|
||||||
|
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
|
||||||
|
function cuidarDaTela() {
|
||||||
|
const agora = performance.now();
|
||||||
|
if (!r.dormindo) SONO.desde = 0;
|
||||||
|
else if (!SONO.desde) SONO.desde = agora;
|
||||||
|
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
|
||||||
|
if (apagar !== SONO.apagada) {
|
||||||
|
SONO.apagada = apagar;
|
||||||
|
window.carinhaTelaApagada = apagar;
|
||||||
|
document.body.style.visibility = apagar ? "hidden" : "";
|
||||||
|
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInterval(cuidarDaTela, 1000);
|
||||||
const CIANO_E = [150, 232, 255];
|
const CIANO_E = [150, 232, 255];
|
||||||
const VERM = [255, 78, 78];
|
const VERM = [255, 78, 78];
|
||||||
const ROSA = [255, 112, 176];
|
const ROSA = [255, 112, 176];
|
||||||
const AMARELO = [255, 218, 96];
|
const AMARELO = [255, 218, 96];
|
||||||
const AZUL = [130, 170, 255];
|
const AZUL = [130, 170, 255];
|
||||||
|
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
|
||||||
|
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
|
||||||
|
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
|
||||||
|
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
|
||||||
|
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
|
||||||
const VERDE = [120, 255, 160];
|
const VERDE = [120, 255, 160];
|
||||||
const BRANCO = [245, 250, 255];
|
const BRANCO = [245, 250, 255];
|
||||||
|
|
||||||
@@ -84,10 +132,17 @@ const BRANCO = [245, 250, 255];
|
|||||||
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
|
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
|
||||||
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
|
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
|
||||||
const ROSTOS = {
|
const ROSTOS = {
|
||||||
|
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
|
||||||
|
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
|
||||||
|
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
|
||||||
|
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
|
||||||
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
|
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
|
||||||
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
|
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
|
||||||
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
|
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
|
||||||
};
|
};
|
||||||
|
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
|
||||||
|
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
|
||||||
|
let FORMATO = "padrao";
|
||||||
let ROSTO_NOME = "carinha";
|
let ROSTO_NOME = "carinha";
|
||||||
let ROSTO = ROSTOS[ROSTO_NOME];
|
let ROSTO = ROSTOS[ROSTO_NOME];
|
||||||
|
|
||||||
@@ -122,8 +177,18 @@ function caminhoLinha(x0, y0, x1, y1) {
|
|||||||
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
||||||
}
|
}
|
||||||
/* formas da boca (o humor escolhe qual) */
|
/* formas da boca (o humor escolhe qual) */
|
||||||
|
function deslocamentoBoca() {
|
||||||
|
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
|
||||||
|
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
|
||||||
|
}
|
||||||
|
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
|
||||||
|
function metadeOlho(esc) {
|
||||||
|
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
|
||||||
|
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
|
||||||
|
return m * RY * esc;
|
||||||
|
}
|
||||||
function caminhoBoca(tipo) {
|
function caminhoBoca(tipo) {
|
||||||
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2;
|
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
|
||||||
if (tipo === "aberta") { // surpresa: um "O"
|
if (tipo === "aberta") { // surpresa: um "O"
|
||||||
const rx = l * 0.17, ry = l * 0.22;
|
const rx = l * 0.17, ry = l * 0.22;
|
||||||
return caminhoElipse(cx, cy, rx, ry);
|
return caminhoElipse(cx, cy, rx, ry);
|
||||||
@@ -144,8 +209,25 @@ function caminhoBoca(tipo) {
|
|||||||
return "M " + (cx - meia) + "," + (cy - l * 0.10) +
|
return "M " + (cx - meia) + "," + (cy - l * 0.10) +
|
||||||
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
|
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
|
||||||
}
|
}
|
||||||
return "M " + (cx - l * 0.30) + "," + cy + // repouso: leve
|
// repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
|
||||||
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0";
|
const f = FEICAO.atual, w = l * 0.30, mx = cx + f.dx * l * 0.10;
|
||||||
|
const yE = cy - Math.max(0, f.lado) * l * 0.12, yD = cy - Math.max(0, -f.lado) * l * 0.12;
|
||||||
|
const curva = l * (0.18 + f.curva * 0.35 - ZANGA * 0.3);
|
||||||
|
let d = "M " + (mx - w) + "," + yE + " Q " + mx + "," + (cy + curva - f.abre * l * 0.12) + " " + (mx + w) + "," + yD;
|
||||||
|
if (f.abre > 0.04) d += " Q " + mx + "," + (cy + curva + f.abre * l * 0.30) + " " + (mx - w) + "," + yE;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sobrancelha i (0 = esquerda): humor do gesto + microexpressão + desce um pouco ao piscar. */
|
||||||
|
function caminhoSobrancelha(i, esc) {
|
||||||
|
const c = i === 0 ? E1 : E2, lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, f = FEICAO.atual;
|
||||||
|
const ab = i === 0 ? r.ab : (r.ab2 === null ? r.ab : r.ab2);
|
||||||
|
const piscando = 1 - Math.min(1, Math.max(0, ab));
|
||||||
|
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC - (f.sob[i] - piscando * 3) * ESC;
|
||||||
|
const base = r.forma === 'bravo' ? 18 : r.forma === 'triste' ? -14 : ZANGA * 14; // zanga franze a testa
|
||||||
|
const inc = (base + f.inc[i]) * lado * ESC;
|
||||||
|
const ele = r.esc > 1.1 ? -15 * ESC : 0;
|
||||||
|
return 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ desenho
|
// ------------------------------------------------------------------ desenho
|
||||||
@@ -197,6 +279,13 @@ function olho(indice, cx, cy, rx, ry, ab, forma, cor, lado, lx, ly) {
|
|||||||
if(p.desenho!==desenho){if(p.figurinha)p.figurinha.remove();p.figurinha=window.carinhaConstruirSVG(desenho);p.desenho=desenho;if(p.figurinha)document.getElementById('o'+(indice+1)).append(p.figurinha);}
|
if(p.desenho!==desenho){if(p.figurinha)p.figurinha.remove();p.figurinha=window.carinhaConstruirSVG(desenho);p.desenho=desenho;if(p.figurinha)document.getElementById('o'+(indice+1)).append(p.figurinha);}
|
||||||
if(p.figurinha){p.figurinha.style.display='';p.figurinha.setAttribute('x',cx-rx*.65);p.figurinha.setAttribute('y',cy-ry*.65);p.figurinha.setAttribute('width',rx*1.3);p.figurinha.setAttribute('height',ry*1.3);}
|
if(p.figurinha){p.figurinha.style.display='';p.figurinha.setAttribute('x',cx-rx*.65);p.figurinha.setAttribute('y',cy-ry*.65);p.figurinha.setAttribute('width',rx*1.3);p.figurinha.setAttribute('height',ry*1.3);}
|
||||||
}
|
}
|
||||||
|
if (espiralAtiva()) {
|
||||||
|
p.traco.setAttribute("d", caminhoEspiral(cx, cy, Math.min(rx, ry) * 0.95, lado));
|
||||||
|
p.traco.setAttribute("stroke-width", rx * 0.17);
|
||||||
|
p.traco.setAttribute("stroke", rgb(cor));
|
||||||
|
p.traco.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (icone) {
|
if (icone) {
|
||||||
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
|
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
|
||||||
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
|
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
|
||||||
@@ -264,7 +353,7 @@ function sequencias() {
|
|||||||
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
|
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
|
||||||
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
|
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
|
||||||
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
|
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
|
||||||
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09],
|
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
|
||||||
[{ dy: -10 }, 0.5],
|
[{ dy: -10 }, 0.5],
|
||||||
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
|
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
|
||||||
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
|
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
|
||||||
@@ -273,13 +362,37 @@ function sequencias() {
|
|||||||
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
|
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
|
||||||
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
|
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
|
||||||
[{ forma: "aberto", cor: CIANO }, 0.3]];
|
[{ forma: "aberto", cor: CIANO }, 0.3]];
|
||||||
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
||||||
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
||||||
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
||||||
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
|
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
|
||||||
|
// Gestos com mãos: o motor desenha as mãos com a mesma duração (rosto-motor.js, DUR_MAOS).
|
||||||
|
const esconder = [[{ forma: "aberto", esc: 0.95, dy: 4 }, 0.3], [{}, 1.6], [{ forma: "feliz", boca: "sorriso", esc: 1.05, dy: 0 }, 0.3], [{ forma: "aberto", esc: 1.0 }, 0.4]];
|
||||||
|
const espiar = [[{ esc: 0.96 }, 0.4], [{}, 0.6], [{ dx: -14, forma: "aberto", ab: 1.08 }, 0.25], [{}, 0.9], [{ dx: 0, ab: 1.0 }, 0.35], [{ forma: "feliz", boca: "sorriso" }, 0.4], [{ forma: "aberto" }, 0.3]];
|
||||||
|
const acenar = [[{ forma: "feliz", boca: "sorriso", esc: 1.05 }, 0.3], [{}, 1.7], [{ forma: "aberto", esc: 1.0 }, 0.4]];
|
||||||
|
const pensar = [[{ dy: -10, dx: 14, boca: "neutra" }, 0.4], [{}, 2.0], [{ dy: 0, dx: 0 }, 0.4]];
|
||||||
|
const musica = [[{ forma: "feliz", boca: "sorriso", dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25],
|
||||||
|
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
|
||||||
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
|
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
|
||||||
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
|
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
|
||||||
return { piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
// Mais expressões: risada, bocejo, confusão, tontura, timidez e comemoração.
|
||||||
|
const pula = (n, alto, baixo, d) => Array.from({ length: n }, (_, i) => [{ dy: i % 2 ? baixo : alto }, d]);
|
||||||
|
const rir = [[{ forma: "feliz", boca: "aberta", esc: 1.08, dy: -4 }, 0.12], ...pula(8, -6, 2, 0.1),
|
||||||
|
[{ forma: "feliz", boca: "sorriso", dy: 0 }, 0.25], [{ forma: "aberto", boca: "neutra", esc: 1.0 }, 0.3]];
|
||||||
|
const bocejar = [[{ ab: 0.5, boca: "aberta", dy: -4, esc: 1.05 }, 0.5], [{ ab: 0.25, dy: -5 }, 1.0],
|
||||||
|
[{ ab: 0.0, boca: "neutra", dy: 2, esc: 1.0 }, 0.25], [{ ab: 1.0, dy: 0 }, 0.35]];
|
||||||
|
const confuso = [[{ ab2: 0.55, dx: -8, esc: 0.98 }, 0.25], [{ dx: 8 }, 0.45], [{ dx: -6 }, 0.4], [{}, 0.4],
|
||||||
|
[{ ab2: 1.0, dx: 0, esc: 1.0 }, 0.3]];
|
||||||
|
const balanca = Array.from({ length: 8 }, (_, i) => [{ dx: i % 2 ? -5 : 5, dy: i % 2 ? 2 : -2 }, 0.2]);
|
||||||
|
const tonto = [[{ esc: 1.05, boca: "aberta" }, 0.15], ...balanca, [{ dx: 0, dy: 0, esc: 1.0, boca: "neutra" }, 0.35]];
|
||||||
|
const timido = [[{ forma: "feliz", boca: "sorriso", dx: -14, dy: 9, esc: 0.9, cor: ROSA }, 0.35], [{}, 1.2],
|
||||||
|
[{ dx: 0, dy: 0, esc: 1.0, forma: "aberto", cor: CIANO }, 0.4]];
|
||||||
|
const comemorar = [[{ forma: "feliz", boca: "sorriso", cor: AMARELO, dy: -10, esc: 1.15 }, 0.15], [{ dy: 2, cor: ROSA }, 0.15],
|
||||||
|
[{ dy: -10, cor: VERDE }, 0.15], [{ dy: 2, cor: CIANO }, 0.15], [{ dy: -10, cor: AMARELO }, 0.15],
|
||||||
|
[{ dy: 0, cor: CIANO }, 0.2], [{ forma: "aberto", esc: 1.0 }, 0.3]];
|
||||||
|
return { rir, bocejar, confuso, tonto, timido, comemorar,
|
||||||
|
esconder, espiar, acenar, pensar, musica,
|
||||||
|
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
||||||
bravo, coracao, triste, piscadinha, alegre };
|
bravo, coracao, triste, piscadinha, alegre };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,12 +405,92 @@ const r = {
|
|||||||
const SEQS = sequencias();
|
const SEQS = sequencias();
|
||||||
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
|
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
|
||||||
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
|
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
|
||||||
|
/* Modo vida (servidor): o humor do bichinho escolhe os gestos soltos entre um ato e outro. */
|
||||||
|
const LIVRES_HUMOR = {
|
||||||
|
feliz: ["feliz", "alegre", "piscadinha", "coracao", "olhar_esq", "olhar_dir"],
|
||||||
|
tranquilo: ["olhar_esq", "olhar_dir", "feliz", "piscadinha", "olhar_baixo", "timido"],
|
||||||
|
bravo: ["bravo", "olhar_baixo", "olhar_esq", "olhar_dir", "confuso"],
|
||||||
|
entediado: ["olhar_esq", "olhar_dir", "olhar_baixo", "pensar", "bocejar", "confuso"],
|
||||||
|
carente: ["olhar_baixo", "triste", "olhar_esq", "olhar_dir", "timido"],
|
||||||
|
triste: ["triste", "olhar_baixo", "olhar_baixo"],
|
||||||
|
cansado: ["olhar_baixo", "piscar2", "bocejar"],
|
||||||
|
};
|
||||||
|
let HUMOR = "normal";
|
||||||
|
/* Modo vida: zanga (0 a 1) deixa o rosto emburrado aos poucos quando a alegria cai; sono profundo =
|
||||||
|
dormiu de cansaço, e aí som, movimento e câmera não acordam (como na madrugada). */
|
||||||
|
let ZANGA = 0;
|
||||||
|
let SONO_PROFUNDO = false;
|
||||||
|
|
||||||
|
/* Microexpressões acordado: de vez em quando a boca e as sobrancelhas mexem sozinhas (um canto
|
||||||
|
da boca sobe, uma sobrancelha arqueia, um suspiro) e voltam devagar ao neutro. O humor do modo
|
||||||
|
vida escolhe quais. Valores em "pixels de projeto" (multiplicados por ESC no desenho). */
|
||||||
|
function feicaoNeutra() { return { curva: 0, lado: 0, abre: 0, dx: 0, sob: [0, 0], inc: [0, 0] }; }
|
||||||
|
const FEICAO = { atual: feicaoNeutra(), alvo: feicaoNeutra(), prox: 0, volta: 0, t: 0 };
|
||||||
|
const ao = () => (Math.random() < 0.5 ? 1 : -1);
|
||||||
|
const MICRO = {
|
||||||
|
arquear: () => { const f = feicaoNeutra(), i = Math.random() < 0.5 ? 0 : 1; f.sob[i] = 12; f.inc[i] = -4; return f; },
|
||||||
|
interesse: () => Object.assign(feicaoNeutra(), { sob: [8, 8], abre: 0.35 }),
|
||||||
|
meioSorriso: () => Object.assign(feicaoNeutra(), { lado: ao(), curva: 0.1 }),
|
||||||
|
sorrisinho: () => Object.assign(feicaoNeutra(), { curva: 0.35, sob: [4, 4] }),
|
||||||
|
ladinho: () => { const d = ao(); return Object.assign(feicaoNeutra(), { dx: d, lado: d * 0.4, curva: -0.08 }); },
|
||||||
|
pensativo: () => Object.assign(feicaoNeutra(), { inc: [8, 8], sob: [-3, -3], dx: ao() * 0.7, curva: -0.15 }),
|
||||||
|
preocupado: () => Object.assign(feicaoNeutra(), { inc: [-9, -9], sob: [4, 4], curva: -0.3 }),
|
||||||
|
suspiro: () => Object.assign(feicaoNeutra(), { sob: [5, 5], abre: 0.6, curva: -0.05 }),
|
||||||
|
};
|
||||||
|
const MICRO_HUMOR = {
|
||||||
|
feliz: ["sorrisinho", "sorrisinho", "meioSorriso", "arquear", "interesse"],
|
||||||
|
tranquilo: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse"],
|
||||||
|
entediado: ["ladinho", "ladinho", "pensativo", "suspiro", "arquear"],
|
||||||
|
carente: ["preocupado", "preocupado", "suspiro", "meioSorriso"],
|
||||||
|
triste: ["preocupado", "suspiro", "pensativo"],
|
||||||
|
cansado: ["suspiro", "suspiro", "ladinho"],
|
||||||
|
bravo: ["pensativo", "pensativo", "ladinho", "suspiro"],
|
||||||
|
normal: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse", "pensativo", "suspiro"],
|
||||||
|
};
|
||||||
|
function atualizarFeicao(s) {
|
||||||
|
const dt = Math.min(0.1, Math.max(0, s - (FEICAO.t || s)));
|
||||||
|
FEICAO.t = s;
|
||||||
|
// só no rosto "de repouso": gesto com boca própria (sorriso, triste...) ou dormindo = neutro
|
||||||
|
const livre = !r.dormindo && !congelado && r.boca === "neutra";
|
||||||
|
if (!livre) {
|
||||||
|
FEICAO.alvo = feicaoNeutra();
|
||||||
|
FEICAO.prox = Math.max(FEICAO.prox, s + 1.5);
|
||||||
|
} else if (s >= FEICAO.prox) {
|
||||||
|
const lista = MICRO_HUMOR[HUMOR] || MICRO_HUMOR.normal;
|
||||||
|
FEICAO.alvo = MICRO[lista[Math.floor(Math.random() * lista.length)]]();
|
||||||
|
FEICAO.volta = s + 0.9 + Math.random() * 1.6;
|
||||||
|
FEICAO.prox = s + (2.5 + Math.random() * 5) * ({ cansado: 1.6, triste: 1.4, feliz: 0.8 }[HUMOR] || 1);
|
||||||
|
} else if (s >= FEICAO.volta) FEICAO.alvo = feicaoNeutra();
|
||||||
|
const k = 1 - Math.exp(-dt * 6), a = FEICAO.atual, b = FEICAO.alvo;
|
||||||
|
for (const c of ["curva", "lado", "abre", "dx"]) a[c] += (b[c] - a[c]) * k;
|
||||||
|
for (let i = 0; i < 2; i++) { a.sob[i] += (b.sob[i] - a.sob[i]) * k; a.inc[i] += (b.inc[i] - a.inc[i]) * k; }
|
||||||
|
}
|
||||||
|
/* Gestos soltos mais humanos: quase sempre coisas sutis (olhar de lado, piscar devagar, olhar para
|
||||||
|
baixo); de vez em quando um gesto maior do humor, nunca o mesmo duas vezes seguidas. Gestos
|
||||||
|
"para alguém" (acenar, tímido, piscadinha...) só quando tem gente por perto. */
|
||||||
|
const SUTIS = ["olhar_esq", "olhar_dir", "olhar_baixo", "piscar2", "olhar_esq", "olhar_dir"];
|
||||||
|
const PARA_ALGUEM = ["acenar", "coracao", "piscadinha", "timido", "esconder", "espiar", "surpreso"];
|
||||||
|
const RECENTES = [];
|
||||||
|
function alguemOlhando() {
|
||||||
|
const agora = performance.now() / 1000;
|
||||||
|
return (MOV.ativo && agora - MOV.ultimoMov < 30) || (TRACK.alvo && agora - TRACK.visto < 30);
|
||||||
|
}
|
||||||
|
function gestoSolto() {
|
||||||
|
const grande = Math.random() < ({ feliz: 0.3, entediado: 0.25, bravo: 0.3 }[HUMOR] || 0.18);
|
||||||
|
let lista = grande ? (LIVRES_HUMOR[HUMOR] || LIVRES) : SUTIS;
|
||||||
|
if (!alguemOlhando()) lista = lista.filter((g) => !PARA_ALGUEM.includes(g));
|
||||||
|
const opcoes = lista.filter((g) => !RECENTES.includes(g));
|
||||||
|
const g = (opcoes.length ? opcoes : SUTIS)[Math.floor(Math.random() * (opcoes.length || SUTIS.length))];
|
||||||
|
RECENTES.push(g); if (RECENTES.length > 2) RECENTES.shift();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
function proximo() {
|
function proximo() {
|
||||||
if (!r.fila.length) { r.kf = null; return; }
|
if (!r.fila.length) { r.kf = null; return; }
|
||||||
const [alvo, dur] = r.fila[0];
|
const [alvo, dur] = r.fila[0];
|
||||||
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
|
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
|
||||||
r.cor_ini = r.cor.slice();
|
r.cor_ini = r.cor.slice();
|
||||||
|
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
|
||||||
if ("forma" in alvo) r.forma = alvo.forma;
|
if ("forma" in alvo) r.forma = alvo.forma;
|
||||||
if ("forma2" in alvo) r.forma2 = alvo.forma2;
|
if ("forma2" in alvo) r.forma2 = alvo.forma2;
|
||||||
if ("boca" in alvo) r.boca = alvo.boca;
|
if ("boca" in alvo) r.boca = alvo.boca;
|
||||||
@@ -305,7 +498,22 @@ function proximo() {
|
|||||||
r.kf_t = 0;
|
r.kf_t = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ESPIRAL = { ate: 0 }; // tonto: espiral no lugar dos olhos até este instante
|
||||||
|
const espiralAtiva = () => performance.now() / 1000 < ESPIRAL.ate;
|
||||||
|
function caminhoEspiral(cx, cy, R, lado) {
|
||||||
|
const voltas = 2.75, giro = performance.now() / 1000 * 7 * lado;
|
||||||
|
let d = "M " + cx + "," + cy;
|
||||||
|
for (let th = 0.22; th <= voltas * Math.PI * 2; th += 0.22) {
|
||||||
|
const rr = R * th / (voltas * Math.PI * 2), a = th * lado + giro;
|
||||||
|
d += " L " + (cx + Math.cos(a) * rr).toFixed(1) + "," + (cy + Math.sin(a) * rr).toFixed(1);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
function animar(nome) {
|
function animar(nome) {
|
||||||
|
if (nome === "tonto") ESPIRAL.ate = performance.now() / 1000 + 2.1;
|
||||||
|
// mãos (esconder, espiar, acenar, pensar, bocejar) valem também quando o gesto vem sozinho
|
||||||
|
if (SEQS[nome] && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(nome);
|
||||||
if (SEQS[nome]) {
|
if (SEQS[nome]) {
|
||||||
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
|
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
|
||||||
proximo();
|
proximo();
|
||||||
@@ -329,10 +537,40 @@ function passo(dt) { // avanca a fila; true se ha gesto em
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
|
||||||
|
let ATIV = null;
|
||||||
|
const OLHAR = { x: 0, y: 0, ax: 0, ay: 0, prox: 0 }; // ponto que os olhos estão fitando no repouso
|
||||||
|
function atividade(nome, segundos, receita) {
|
||||||
|
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
|
||||||
|
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
|
||||||
|
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
|
||||||
|
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expressão criada no estúdio (criacao.py): receita já validada no servidor, só números e nomes de
|
||||||
|
peças. Vira uma sequência igual às de sequencias(), com mãos e espiral se a receita pedir. */
|
||||||
|
function corDaReceita(nome) {
|
||||||
|
return { claro: "claro", escuro: "escuro", rosa: ROSA, amarelo: AMARELO, verde: VERDE, vermelho: VERM }[nome] || CIANO;
|
||||||
|
}
|
||||||
|
function tocarExpressaoCriada(texto) {
|
||||||
|
let rc;
|
||||||
|
try { rc = JSON.parse(texto); } catch (e) { return; }
|
||||||
|
if (!rc || !Array.isArray(rc.passos) || !rc.passos.length) return;
|
||||||
|
if (r.dormindo) r.dormindo = false;
|
||||||
|
r.fila = rc.passos.slice(0, 12).map((p) => [{
|
||||||
|
forma: p.forma || "aberto", forma2: null, boca: p.boca || "neutra", cor: corDaReceita(p.cor),
|
||||||
|
dx: +p.dx || 0, dy: +p.dy || 0, esc: +p.esc || 1, ab: typeof p.ab === "number" ? p.ab : 1,
|
||||||
|
}, Math.max(0.05, Math.min(2, +p.duracao || 0.3))]);
|
||||||
|
proximo();
|
||||||
|
const total = r.fila.reduce((a, passo) => a + passo[1], 0);
|
||||||
|
if (rc.espiral) ESPIRAL.ate = performance.now() / 1000 + total;
|
||||||
|
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
|
||||||
|
}
|
||||||
|
|
||||||
function repouso(t) { // pose calma: olha devagar, "respira"
|
function repouso(t) { // pose calma: olha devagar, "respira"
|
||||||
if (r.dormindo) {
|
if (r.dormindo) {
|
||||||
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
||||||
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2));
|
r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
|
||||||
r.ab = 0; r.ab2 = 0;
|
r.ab = 0; r.ab2 = 0;
|
||||||
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
||||||
r.dx = 0;
|
r.dx = 0;
|
||||||
@@ -343,9 +581,28 @@ function repouso(t) { // pose calma: olha devagar, "respira
|
|||||||
r.boca = "neutra";
|
r.boca = "neutra";
|
||||||
r.cor = CIANO.slice();
|
r.cor = CIANO.slice();
|
||||||
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
|
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
|
||||||
// o vaivem de sempre + a direcao de quem se mexeu na frente (se houver)
|
// olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
|
||||||
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
|
// centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
|
||||||
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
|
if (t >= OLHAR.prox || t < OLHAR.prox - 10) {
|
||||||
|
const longe = Math.random() < 0.25;
|
||||||
|
OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
|
||||||
|
OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
|
||||||
|
OLHAR.prox = t + 1.2 + Math.random() * 3;
|
||||||
|
}
|
||||||
|
OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3;
|
||||||
|
OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
|
||||||
|
r.dx = OLHAR.x + 1.0 * Math.sin(t * 0.9) + MOV.olharX;
|
||||||
|
r.dy = OLHAR.y + 0.8 * Math.sin(t * 1.3) + MOV.olharY;
|
||||||
|
if (ATIV) { // fazendo algo: olha para a atividade
|
||||||
|
const v = ATIV.v;
|
||||||
|
r.dx = r.dx * (1 - v) * 0.3 + ATIV.x * v;
|
||||||
|
r.dy = r.dy * (1 - v) * 0.3 + ATIV.y * v;
|
||||||
|
if (ATIV.forma) r.forma = ATIV.forma;
|
||||||
|
if (ATIV.boca) r.boca = ATIV.boca;
|
||||||
|
if (ATIV.ab) r.ab = ATIV.ab;
|
||||||
|
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
|
||||||
|
r.forma = "bravo";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function desenhar() {
|
function desenhar() {
|
||||||
@@ -357,32 +614,61 @@ function desenhar() {
|
|||||||
// desliza na direcao do movimento; parado, volta ao repouso discreto
|
// desliza na direcao do movimento; parado, volta ao repouso discreto
|
||||||
const tAgora = performance.now() / 1000;
|
const tAgora = performance.now() / 1000;
|
||||||
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
|
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
|
||||||
const mirando = MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2;
|
const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV;
|
||||||
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
|
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
|
||||||
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
|
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
|
||||||
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
||||||
for (let i = 0; i < 2; i++) {
|
const comMotor = ROSTO.motor && window.MotorRosto;
|
||||||
|
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
|
||||||
|
if (comMotor) {
|
||||||
|
window.MotorRosto.desenhar({
|
||||||
|
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo, espiral: espiralAtiva(),
|
||||||
|
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
|
||||||
|
carga: window.carinhaBateria || null,
|
||||||
|
iris: EXTRAS.iris,
|
||||||
|
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
|
||||||
|
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
|
||||||
|
parado: !r.kf && !seguindo && !r.dormindo,
|
||||||
|
olhos: olhos.map(([c, abx, fx, lado]) => ({
|
||||||
|
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
|
||||||
|
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} else if (window.MotorRosto) window.MotorRosto.esconder();
|
||||||
|
for (let i = 0; i < 2 && !comMotor; i++) {
|
||||||
const [c, abx, fx, lado] = olhos[i];
|
const [c, abx, fx, lado] = olhos[i];
|
||||||
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
|
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
|
||||||
abx, fx, r.cor, lado, dx, dy * 0.6);
|
abx, fx, r.cor, lado, dx, dy * 0.6);
|
||||||
}
|
}
|
||||||
desenharBoca();
|
const matriz = comMotor && ROSTO.motor === "matriz";
|
||||||
|
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
|
||||||
|
else desenharBoca();
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const brow = document.getElementById('sobrancelha' + (i + 1));
|
const brow = document.getElementById('sobrancelha' + (i + 1));
|
||||||
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
|
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
|
||||||
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
|
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
|
||||||
const c = i === 0 ? E1 : E2;
|
brow.setAttribute('d', caminhoSobrancelha(i, esc));
|
||||||
const lado = i === 0 ? 1 : -1;
|
|
||||||
const largura = RX * esc * 0.85;
|
|
||||||
const y = c[1] - RY * esc - 20 * ESC;
|
|
||||||
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
|
||||||
const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
|
|
||||||
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
|
|
||||||
brow.setAttribute('stroke', rgb(r.cor));
|
brow.setAttribute('stroke', rgb(r.cor));
|
||||||
brow.setAttribute('stroke-width', 7 * ESC);
|
brow.setAttribute('stroke-width', 7 * ESC);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
|
||||||
|
function tracosDoRosto(esc) {
|
||||||
|
const tracos = [];
|
||||||
|
if (EXTRAS.boca) {
|
||||||
|
const forma = r.boca;
|
||||||
|
if (r.dormindo) { // dormindo na Matriz: "– _ –"
|
||||||
|
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
|
||||||
|
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
|
||||||
|
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
|
||||||
|
}
|
||||||
|
if (EXTRAS.sobrancelhas && !r.dormindo) {
|
||||||
|
for (let i = 0; i < 2; i++) tracos.push({ d: caminhoSobrancelha(i, esc), largura: 9 * ESC });
|
||||||
|
}
|
||||||
|
return tracos;
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ controle
|
// ------------------------------------------------------------------ controle
|
||||||
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
||||||
function trocarRosto(nome) {
|
function trocarRosto(nome) {
|
||||||
@@ -411,19 +697,46 @@ function trata(ordem) {
|
|||||||
trocarRosto(ordem.slice(6).trim());
|
trocarRosto(ordem.slice(6).trim());
|
||||||
} else if (ROSTOS[ordem]) {
|
} else if (ROSTOS[ordem]) {
|
||||||
trocarRosto(ordem); // aceita so o nome: "miudo"
|
trocarRosto(ordem); // aceita so o nome: "miudo"
|
||||||
|
} else if (ordem.indexOf("formato ") === 0) {
|
||||||
|
const f = ordem.slice(8).trim();
|
||||||
|
if (FORMATOS.includes(f)) FORMATO = f;
|
||||||
} else if (ordem.indexOf("cor ") === 0) {
|
} else if (ordem.indexOf("cor ") === 0) {
|
||||||
const v = ordem.slice(4).split(",").map(Number);
|
const v = ordem.slice(4).split(",").map(Number);
|
||||||
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
||||||
CIANO.splice(0, 3, ...v); r.cor = v;
|
CIANO.splice(0, 3, ...v); r.cor = v;
|
||||||
}
|
}
|
||||||
|
} else if (ordem.indexOf("humor ") === 0) {
|
||||||
|
HUMOR = ordem.slice(6).trim();
|
||||||
|
} else if (ordem.indexOf("ritmo ") === 0) {
|
||||||
|
PERFIL = PERFIS[ordem.slice(6).trim()] || PERFIS.normal;
|
||||||
|
} else if (ordem.indexOf("expressao_criada ") === 0) {
|
||||||
|
tocarExpressaoCriada(ordem.slice(17));
|
||||||
|
} else if (ordem.indexOf("atividade_criada ") === 0) {
|
||||||
|
try { const rc = JSON.parse(ordem.slice(17)); atividade("receita", +rc.duracao || 25, rc); } catch (e) {}
|
||||||
|
} else if (ordem.indexOf("atividade ") === 0) {
|
||||||
|
const [nome, seg] = ordem.slice(10).trim().split(/\s+/); // "atividade bola 180"
|
||||||
|
atividade(nome, parseFloat(seg) || 0);
|
||||||
|
} else if (ordem.indexOf("zanga ") === 0) {
|
||||||
|
const z = parseFloat(ordem.slice(6));
|
||||||
|
if (!isNaN(z)) ZANGA = Math.max(0, Math.min(1, z));
|
||||||
|
} else if (ordem === "sono profundo" || ordem === "sono leve") {
|
||||||
|
SONO_PROFUNDO = ordem === "sono profundo";
|
||||||
} else if (ordem === "dormir" || ordem === "sono") {
|
} else if (ordem === "dormir" || ordem === "sono") {
|
||||||
r.dormindo = true;
|
r.dormindo = true;
|
||||||
|
atividade(null);
|
||||||
} else if (ordem === "acordar") {
|
} else if (ordem === "acordar") {
|
||||||
r.dormindo = false; animar("surpreso");
|
r.dormindo = false; animar("surpreso");
|
||||||
} else if (ordem === "aleatorio") {
|
} else if (ordem === "aleatorio") {
|
||||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||||
} else if (SEQS[ordem]) {
|
} else if (SEQS[ordem]) {
|
||||||
|
if (window.MotorRosto) {
|
||||||
|
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
||||||
|
}
|
||||||
animar(ordem);
|
animar(ordem);
|
||||||
|
} else if (ordem === "calma") {
|
||||||
|
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
||||||
|
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
|
||||||
|
atividade(null);
|
||||||
} else if (ordem === "parar") {
|
} else if (ordem === "parar") {
|
||||||
try { window.close(); } catch (e) { r.fila = []; }
|
try { window.close(); } catch (e) { r.fila = []; }
|
||||||
}
|
}
|
||||||
@@ -440,7 +753,7 @@ function pose(nome) {
|
|||||||
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
|
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
|
||||||
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
|
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
|
||||||
}
|
}
|
||||||
if ("cor" in alvo) r.cor = alvo.cor.slice();
|
if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
|
||||||
congelado = true;
|
congelado = true;
|
||||||
desenhar();
|
desenhar();
|
||||||
return true;
|
return true;
|
||||||
@@ -460,7 +773,7 @@ window.eilik = {
|
|||||||
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
|
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
|
||||||
// do PC com um curl, igual ao FIFO de antes
|
// do PC com um curl, igual ao FIFO de antes
|
||||||
async function buscarOrdens() {
|
async function buscarOrdens() {
|
||||||
if (new URLSearchParams(location.search).get('espelho') === '1') return;
|
if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
|
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
@@ -479,12 +792,46 @@ async function aplicarAparencia() {
|
|||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const c = await resp.json();
|
const c = await resp.json();
|
||||||
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
|
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
|
||||||
|
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
|
||||||
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
|
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
|
||||||
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
|
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
|
||||||
} catch (e) { /* sem servidor — segue com o padrao */ }
|
} catch (e) { /* sem servidor — segue com o padrao */ }
|
||||||
}
|
}
|
||||||
setTimeout(aplicarAparencia, 200);
|
setTimeout(aplicarAparencia, 200);
|
||||||
if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
|
||||||
|
// do servidor (SSE) por postMessage.
|
||||||
|
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
|
||||||
|
if (MODO_PAINEL) {
|
||||||
|
window.carinhaAtiva = false;
|
||||||
|
addEventListener('message', (e) => {
|
||||||
|
if (e.origin !== location.origin || e.source !== parent || !e.data) return;
|
||||||
|
const m = e.data;
|
||||||
|
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
|
||||||
|
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
|
||||||
|
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
|
||||||
|
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||||
|
});
|
||||||
|
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
||||||
|
|
||||||
|
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
|
||||||
|
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
|
||||||
|
(function () {
|
||||||
|
let inicio = null;
|
||||||
|
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
|
||||||
|
addEventListener('pointerup', (e) => {
|
||||||
|
if (!inicio) return;
|
||||||
|
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
|
||||||
|
inicio = null;
|
||||||
|
if (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; }
|
||||||
|
if (ATIV && !ATIV.saindo) { atividade(null); animar('surpreso'); cuidar('cutucar'); return; } // interrompeu
|
||||||
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); cuidar('carinho'); return; }
|
||||||
|
if (dur < 350 && Math.hypot(dx, dy) < 15) {
|
||||||
|
cuidar('cutucar');
|
||||||
|
animar('surpreso');
|
||||||
|
setTimeout(() => animar('feliz'), 700);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// teclado (util para testar no PC)
|
// teclado (util para testar no PC)
|
||||||
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
|
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
|
||||||
@@ -525,6 +872,49 @@ const MIC = {
|
|||||||
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos
|
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Acordar com som: precisa de N barulhos em 1 minuto (ajuste "Sons para acordar"). Na madrugada
|
||||||
|
silenciosa só barulho ALTO conta, e movimento/câmera não acordam (alguém se mexendo na cama). */
|
||||||
|
const DESPERTAR = { sons: 3, noite: true, ini: 23, fim: 7 };
|
||||||
|
function sonoRespeitado() { return madrugada() || SONO_PROFUNDO; }
|
||||||
|
function madrugada() {
|
||||||
|
if (!DESPERTAR.noite || DESPERTAR.ini === DESPERTAR.fim) return false;
|
||||||
|
const h = new Date().getHours();
|
||||||
|
return DESPERTAR.ini < DESPERTAR.fim ? h >= DESPERTAR.ini && h < DESPERTAR.fim : h >= DESPERTAR.ini || h < DESPERTAR.fim;
|
||||||
|
}
|
||||||
|
/* Perfil de sensibilidade = ritmo do modo vida (o servidor manda "ritmo calmo|normal|agitado").
|
||||||
|
Calmo precisa de vários estímulos para reagir e limiares mais altos; agitado reage a qualquer
|
||||||
|
som ou movimento. Multiplica os limiares dos Ajustes, sem mudar os Ajustes. */
|
||||||
|
const PERFIS = {
|
||||||
|
calmo: { lim: 1.5, reagir: 3, acordarSom: 2, acordarMov: 4, espera: 2, qualquer: false },
|
||||||
|
normal: { lim: 1.0, reagir: 1, acordarSom: 1, acordarMov: 2, espera: 1, qualquer: false },
|
||||||
|
agitado: { lim: 0.6, reagir: 1, acordarSom: 0.34, acordarMov: 1, espera: 0.5, qualquer: true },
|
||||||
|
};
|
||||||
|
let PERFIL = PERFIS.normal;
|
||||||
|
const limiar = (x) => Math.min(0.95, x * PERFIL.lim);
|
||||||
|
/* Conta o INÍCIO de cada estímulo (o mesmo som/movimento comprido vale um) numa janela de tempo
|
||||||
|
e diz quando chegou a `precisa`. */
|
||||||
|
function novoContador(janela) { return { janela, eventos: [], ultimo: -Infinity, estava: false, visto: -Infinity }; }
|
||||||
|
const CONTA = { somAcordar: novoContador(60), movAcordar: novoContador(30), somReagir: novoContador(20), movReagir: novoContador(20) };
|
||||||
|
function contar(c, ativo, agora, precisa, nome) {
|
||||||
|
// contador parado um tempo (ex.: o de "acordar" enquanto estava acordado): começa do zero
|
||||||
|
if (agora - c.visto > 2) c.estava = false;
|
||||||
|
c.visto = agora;
|
||||||
|
if (ativo && !c.estava && agora - c.ultimo >= 1.0) {
|
||||||
|
c.ultimo = agora;
|
||||||
|
c.eventos = c.eventos.filter((t) => agora - t < c.janela).concat(agora);
|
||||||
|
if (precisa > 1) console.log(nome + " " + c.eventos.length + "/" + precisa + (madrugada() ? " (madrugada)" : ""));
|
||||||
|
}
|
||||||
|
c.estava = ativo;
|
||||||
|
if (c.eventos.length >= precisa && agora - c.eventos[c.eventos.length - 1] < 1) { c.eventos = []; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/* Reação acordado: agitado varia; os outros se assustam. */
|
||||||
|
function reacaoEstimulo() {
|
||||||
|
if (!PERFIL.qualquer) return "surpreso";
|
||||||
|
const l = ["surpreso", "feliz", "alegre", "piscadinha", "surpreso"];
|
||||||
|
return l[Math.floor(Math.random() * l.length)];
|
||||||
|
}
|
||||||
|
|
||||||
function som(nivel) {
|
function som(nivel) {
|
||||||
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
|
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
|
||||||
const agora = performance.now() / 1000;
|
const agora = performance.now() / 1000;
|
||||||
@@ -539,8 +929,8 @@ function som(nivel) {
|
|||||||
if (MIC.piso === null) MIC.piso = n;
|
if (MIC.piso === null) MIC.piso = n;
|
||||||
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
|
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
|
||||||
|
|
||||||
const temSom = n >= MIC.limiarSom; // ruido audivel (absoluto)
|
const temSom = n >= limiar(MIC.limiarSom); // ruido audivel (absoluto)
|
||||||
const alto = n >= MIC.limiarAlto && (n - MIC.piso) >= MIC.ganho;
|
const alto = n >= limiar(MIC.limiarAlto) && (n - MIC.piso) >= limiar(MIC.ganho);
|
||||||
if (temSom) MIC.ultimoSom = agora;
|
if (temSom) MIC.ultimoSom = agora;
|
||||||
|
|
||||||
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
|
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
|
||||||
@@ -549,15 +939,19 @@ function som(nivel) {
|
|||||||
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2));
|
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.dormindo && temSom) { // acordou: tinha barulho
|
if (r.dormindo) {
|
||||||
|
const precisa = Math.max(1, Math.round(DESPERTAR.sons * PERFIL.acordarSom));
|
||||||
|
if (contar(CONTA.somAcordar, sonoRespeitado() ? alto : temSom, agora, precisa, "ouvido: barulho")) { // acordou
|
||||||
r.dormindo = false;
|
r.dormindo = false;
|
||||||
animar("surpreso");
|
animar("surpreso");
|
||||||
MIC.proxSurpresa = agora + MIC.cooldown;
|
MIC.proxSurpresa = agora + MIC.cooldown;
|
||||||
console.log("ouvido: acordou nivel=" + n.toFixed(2));
|
console.log("ouvido: acordou nivel=" + n.toFixed(2));
|
||||||
relatarSom("acordou", n);
|
relatarSom("acordou", n);
|
||||||
} else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) {
|
}
|
||||||
MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino
|
} else if (contar(CONTA.somReagir, PERFIL.qualquer ? temSom : alto, agora, PERFIL.reagir, "ouvido: estimulo") &&
|
||||||
animar("surpreso");
|
agora >= MIC.proxSurpresa) {
|
||||||
|
MIC.proxSurpresa = agora + MIC.cooldown * PERFIL.espera; // susto: barulho alto e repentino
|
||||||
|
animar(reacaoEstimulo());
|
||||||
console.log("ouvido: surpreso nivel=" + n.toFixed(2));
|
console.log("ouvido: surpreso nivel=" + n.toFixed(2));
|
||||||
relatarSom("surpreso", n);
|
relatarSom("surpreso", n);
|
||||||
}
|
}
|
||||||
@@ -570,6 +964,7 @@ function checarSilencio(agora) {
|
|||||||
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
|
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
|
||||||
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
|
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
|
||||||
if (r.dormindo) return;
|
if (r.dormindo) return;
|
||||||
|
if (HUMOR !== "normal") return; // modo vida ligado: quem manda dormir é a energia
|
||||||
// movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
|
// movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
|
||||||
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
|
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
|
||||||
if (agora - atividade >= MIC.segSilencio) {
|
if (agora - atividade >= MIC.segSilencio) {
|
||||||
@@ -583,6 +978,25 @@ function checarSilencio(agora) {
|
|||||||
journal do carinha-web (so quando o microfone esta ativo). */
|
journal do carinha-web (so quando o microfone esta ativo). */
|
||||||
function relatarSom(evento, n) {
|
function relatarSom(evento, n) {
|
||||||
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||||
|
if (evento !== "adormeceu") registrarEvento("som", evento, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toque na tela vira cuidado no modo vida (carinho/cutucada). Só o celular conta. */
|
||||||
|
function cuidar(acao) {
|
||||||
|
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||||
|
try {
|
||||||
|
fetch("/vida/cuidar", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ acao }) }).catch(() => {});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
|
||||||
|
function registrarEvento(tipo, evento, nivel) {
|
||||||
|
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||||
|
try {
|
||||||
|
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------ presenca (movimento)
|
// ------------------------------------------------------ presenca (movimento)
|
||||||
@@ -622,7 +1036,7 @@ function rastrear(t) {
|
|||||||
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
|
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
|
||||||
if(t.atividade===true) {
|
if(t.atividade===true) {
|
||||||
MOV.ultimoMov=agora;
|
MOV.ultimoMov=agora;
|
||||||
if(r.dormindo) { r.dormindo=false;animar('surpreso'); }
|
if(r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) { r.dormindo=false;animar('surpreso'); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function movimento(vel, cx, cy) {
|
function movimento(vel, cx, cy) {
|
||||||
@@ -636,7 +1050,7 @@ function movimento(vel, cx, cy) {
|
|||||||
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
|
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
|
||||||
|
|
||||||
if (TRACK.alvo && agora-TRACK.visto<.35) return;
|
if (TRACK.alvo && agora-TRACK.visto<.35) return;
|
||||||
const mexeu = v >= MOV.limiar;
|
const mexeu = v >= limiar(MOV.limiar);
|
||||||
if (mexeu) MOV.ultimoMov = agora;
|
if (mexeu) MOV.ultimoMov = agora;
|
||||||
|
|
||||||
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
|
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
|
||||||
@@ -651,17 +1065,21 @@ function movimento(vel, cx, cy) {
|
|||||||
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
|
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
const forte = v >= MOV.limiarForte;
|
const forte = v >= limiar(MOV.limiarForte);
|
||||||
if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente
|
if (r.dormindo) {
|
||||||
|
// acordou: algo se mexeu na frente (de dia; calmo precisa de vários movimentos)
|
||||||
|
if (!sonoRespeitado() && contar(CONTA.movAcordar, mexeu, agora, PERFIL.acordarMov, "presenca: movimento")) {
|
||||||
r.dormindo = false;
|
r.dormindo = false;
|
||||||
animar("surpreso");
|
animar("surpreso");
|
||||||
MOV.proxSurpresa = agora + MOV.cooldown;
|
MOV.proxSurpresa = agora + MOV.cooldown;
|
||||||
console.log("presenca: acordou nivel=" + v.toFixed(2));
|
console.log("presenca: acordou nivel=" + v.toFixed(2));
|
||||||
relatarMov("acordou", v);
|
relatarMov("acordou", v);
|
||||||
} else if (forte && !MOV.estavaForte && agora >= MOV.proxSurpresa) {
|
}
|
||||||
|
} else if (contar(CONTA.movReagir, PERFIL.qualquer ? mexeu : forte, agora, PERFIL.reagir, "presenca: estimulo") &&
|
||||||
|
agora >= MOV.proxSurpresa) {
|
||||||
// susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
|
// susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
|
||||||
MOV.proxSurpresa = agora + MOV.cooldown;
|
MOV.proxSurpresa = agora + MOV.cooldown * PERFIL.espera;
|
||||||
animar("surpreso");
|
animar(reacaoEstimulo());
|
||||||
console.log("presenca: surpreso nivel=" + v.toFixed(2));
|
console.log("presenca: surpreso nivel=" + v.toFixed(2));
|
||||||
relatarMov("surpreso", v);
|
relatarMov("surpreso", v);
|
||||||
}
|
}
|
||||||
@@ -671,6 +1089,7 @@ function movimento(vel, cx, cy) {
|
|||||||
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
|
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
|
||||||
function relatarMov(evento, n) {
|
function relatarMov(evento, n) {
|
||||||
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||||
|
registrarEvento("movimento", evento, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------- camera oculta (sensor)
|
// ------------------------------------------------- camera oculta (sensor)
|
||||||
@@ -801,7 +1220,15 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
|||||||
|
|
||||||
let configRecebida = null;
|
let configRecebida = null;
|
||||||
function configurarApp(c) {
|
function configurarApp(c) {
|
||||||
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true;
|
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
|
||||||
|
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
|
||||||
|
SONO.apagar = c.apagarTela !== false;
|
||||||
|
if (typeof c.somAcordar === 'number') DESPERTAR.sons = Math.max(1, Math.round(c.somAcordar));
|
||||||
|
DESPERTAR.noite = c.noiteSilenciosa !== false;
|
||||||
|
if (typeof c.noiteInicio === 'number') DESPERTAR.ini = Math.round(c.noiteInicio);
|
||||||
|
if (typeof c.noiteFim === 'number') DESPERTAR.fim = Math.round(c.noiteFim);
|
||||||
|
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
|
||||||
|
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
|
||||||
AUTO = c.auto;
|
AUTO = c.auto;
|
||||||
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
||||||
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
||||||
@@ -818,8 +1245,10 @@ async function sincronizarApp() {
|
|||||||
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
|
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
|
||||||
sincronizando = true;
|
sincronizando = true;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
|
if (!window.carinhaSSE) {
|
||||||
if (resp.ok) configurarApp(await resp.json());
|
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
|
||||||
|
if (resp.ok) configurarApp(await resp.json());
|
||||||
|
}
|
||||||
if (par.get('aparelho') === '1') {
|
if (par.get('aparelho') === '1') {
|
||||||
const agora = performance.now() / 1000;
|
const agora = performance.now() / 1000;
|
||||||
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
|
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
@@ -829,7 +1258,10 @@ async function sincronizarApp() {
|
|||||||
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
|
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
|
||||||
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
||||||
brilho: window.carinhaBrilho,
|
brilho: window.carinhaBrilho,
|
||||||
|
tela_apagada: !!window.carinhaTelaApagada,
|
||||||
|
atividade: window.MotorRosto && window.MotorRosto.atividadeAtual ? window.MotorRosto.atividadeAtual() : null,
|
||||||
bateria: window.carinhaBateria,
|
bateria: window.carinhaBateria,
|
||||||
|
aparelho: window.carinhaAparelho,
|
||||||
video: window.carinhaVideo,
|
video: window.carinhaVideo,
|
||||||
}) });
|
}) });
|
||||||
}
|
}
|
||||||
@@ -874,15 +1306,32 @@ const SONS = {
|
|||||||
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
|
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
||||||
|
os sons dos gestos ficam quietos para não embolar. */
|
||||||
|
function falar(texto) {
|
||||||
|
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
|
||||||
|
const c = contextoAudio();
|
||||||
|
if (!c) return;
|
||||||
|
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||||
|
try {
|
||||||
|
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
|
||||||
|
AUDIO.mudoAte = performance.now() + dur * 1000;
|
||||||
|
} catch (e) { /* segue sem voz */ }
|
||||||
|
}
|
||||||
|
|
||||||
function tocarSom(gesto) {
|
function tocarSom(gesto) {
|
||||||
if (!AUDIO.ligado) return false;
|
if (!AUDIO.ligado) return false;
|
||||||
|
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
|
||||||
|
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
||||||
|
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
||||||
const f = SONS[gesto];
|
const f = SONS[gesto];
|
||||||
if (!f) return false;
|
if (!voz && !f) return false;
|
||||||
const c = contextoAudio();
|
const c = contextoAudio();
|
||||||
if (!c) return false;
|
if (!c) return false;
|
||||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||||
try {
|
try {
|
||||||
f(c, c.currentTime + 0.02, AUDIO.volume);
|
if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
|
||||||
|
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
|
||||||
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
|
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
|
||||||
console.log("voz: " + gesto + " ctx=" + c.state);
|
console.log("voz: " + gesto + " ctx=" + c.state);
|
||||||
return true;
|
return true;
|
||||||
@@ -908,6 +1357,11 @@ function laco(t) {
|
|||||||
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
|
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
|
||||||
|
|
||||||
checarSilencio(t / 1000);
|
checarSilencio(t / 1000);
|
||||||
|
atualizarFeicao(t / 1000);
|
||||||
|
|
||||||
|
ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null;
|
||||||
|
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
|
||||||
|
document.body.classList.toggle("atividade", !!ATIV && !ATIV.saindo);
|
||||||
|
|
||||||
const ocupado = congelado ? true : passo(dt);
|
const ocupado = congelado ? true : passo(dt);
|
||||||
if (!ocupado) {
|
if (!ocupado) {
|
||||||
@@ -918,15 +1372,18 @@ function laco(t) {
|
|||||||
} else if (t >= proxPiscar) {
|
} else if (t >= proxPiscar) {
|
||||||
animar("piscar");
|
animar("piscar");
|
||||||
proxPiscar = t + 1600 + Math.random() * 3500;
|
proxPiscar = t + 1600 + Math.random() * 3500;
|
||||||
} else if (AUTO && t >= proxGesto) {
|
} else if (AUTO && t >= proxGesto && !ATIV) {
|
||||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
animar(gestoSolto());
|
||||||
proxGesto = t + 7000 + Math.random() * 8000;
|
// cansado e triste mexem menos; feliz, mais
|
||||||
|
const ritmo = { cansado: 1.8, triste: 1.5, feliz: 0.8 }[HUMOR] || 1;
|
||||||
|
proxGesto = t + (7000 + Math.random() * 8000) * ritmo;
|
||||||
}
|
}
|
||||||
} else if (t >= proxGesto) {
|
} else if (t >= proxGesto) {
|
||||||
proxGesto = t + 7000 + Math.random() * 8000;
|
proxGesto = t + 7000 + Math.random() * 8000;
|
||||||
}
|
}
|
||||||
|
|
||||||
desenhar();
|
desenhar();
|
||||||
|
if (window.MotorRosto && window.MotorRosto.palco) window.MotorRosto.palco({ t: t / 1000, cor: CIANO });
|
||||||
|
|
||||||
contador++;
|
contador++;
|
||||||
if (t - marco >= 1000) {
|
if (t - marco >= 1000) {
|
||||||
@@ -965,11 +1422,13 @@ setTimeout(relatar, 1500);
|
|||||||
if(location.protocol==='file:')return;
|
if(location.protocol==='file:')return;
|
||||||
let seen=null,fetching=false;
|
let seen=null,fetching=false;
|
||||||
async function poll(){
|
async function poll(){
|
||||||
if(fetching||window.carinhaAtiva===false)return;fetching=true;
|
if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;
|
||||||
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
|
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
|
||||||
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
|
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
|
||||||
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
|
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
|
||||||
|
falar(m.texto);
|
||||||
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
|
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
|
||||||
|
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
|
||||||
}catch(e){}finally{fetching=false;}
|
}catch(e){}finally{fetching=false;}
|
||||||
}
|
}
|
||||||
setInterval(poll,1000);poll();
|
setInterval(poll,1000);poll();
|
||||||
@@ -977,11 +1436,56 @@ setTimeout(relatar, 1500);
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
<script src="/reacoes-render.js"></script>
|
<script src="/reacoes-render.js"></script>
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
|
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
|
||||||
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
|
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tempo real: um EventSource em /eventos substitui os pollings de /fila, /app/config,
|
||||||
|
/conversa/status e /treinamento/expressao. Se cair, os pollings voltam sozinhos
|
||||||
|
(window.carinhaSSE=false) até o EventSource reconectar. */
|
||||||
|
(function () {
|
||||||
|
const p = new URLSearchParams(location.search);
|
||||||
|
if (location.protocol === 'file:' || p.get('espelho') === '1' || !window.EventSource) return;
|
||||||
|
const aparelho = p.get('aparelho') === '1';
|
||||||
|
const t = window.carinhaToken;
|
||||||
|
const fonte = new EventSource('/eventos' + (t ? '?t=' + encodeURIComponent(t) : ''));
|
||||||
|
let ultimaConversa = null, ultimaReacao = null, primeira = true;
|
||||||
|
const ouvir = (tipo, fn) => fonte.addEventListener(tipo, (e) => { try { fn(JSON.parse(e.data)); } catch (err) {} });
|
||||||
|
fonte.addEventListener('pronto', () => {
|
||||||
|
// Ordens que chegaram antes da conexão ficam na fila: executa uma vez e passa ao SSE.
|
||||||
|
if (primeira) { primeira = false; buscarOrdens(); }
|
||||||
|
window.carinhaSSE = true;
|
||||||
|
// Foto dos ajustes a cada (re)conexão; depois só mudanças chegam por 'app_config'.
|
||||||
|
fetch('/app/config', { cache: 'no-store' }).then((r) => r.ok && r.json()).then((c) => c && configurarApp(c)).catch(() => {});
|
||||||
|
});
|
||||||
|
fonte.onerror = () => {
|
||||||
|
if (!window.carinhaSSE) return;
|
||||||
|
window.carinhaSSE = false;
|
||||||
|
// Volta ao polling sem repetir o que o SSE já entregou.
|
||||||
|
fetch('fila' + (aparelho ? '?aparelho=1' : ''), { cache: 'no-store' }).catch(() => {});
|
||||||
|
};
|
||||||
|
ouvir('expressao', (d) => trata(d.ordem));
|
||||||
|
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
|
||||||
|
ouvir('app_config', (c) => configurarApp(c));
|
||||||
|
ouvir('conversa', (s) => {
|
||||||
|
if (window.MotorRosto) {
|
||||||
|
if (s.ocupado) window.MotorRosto.faixa('digitando');
|
||||||
|
else window.MotorRosto.faixa(null, { so: 'digitando' });
|
||||||
|
}
|
||||||
|
const m = s.ultima;
|
||||||
|
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
|
||||||
|
ultimaConversa = m.id;
|
||||||
|
falar(m.texto);
|
||||||
|
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
|
||||||
|
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||||
|
});
|
||||||
|
ouvir('reacao', (d) => {
|
||||||
|
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
|
||||||
|
ultimaReacao = d.id;
|
||||||
|
if (window.eilik && window.eilik.receita) window.eilik.receita(d.receita);
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,13 @@ let config = {}, signature = '', movementEnabled = true, aviso = '', capacidades
|
|||||||
let allowed = true;
|
let allowed = true;
|
||||||
let lastMotionFrame = -1;
|
let lastMotionFrame = -1;
|
||||||
function chave(c) { return JSON.stringify([c.cameraLado || 'user', c.videoResolucao || 'auto', c.videoFPS || 0]); }
|
function chave(c) { return JSON.stringify([c.cameraLado || 'user', c.videoResolucao || 'auto', c.videoFPS || 0]); }
|
||||||
|
/* Nada pode esperar para sempre: se a câmera estiver presa (ex.: servidor caiu no meio e o
|
||||||
|
sensor de movimento ainda a segura), desiste e a próxima mensagem do painel tenta de novo. */
|
||||||
|
function comPrazo(promessa, ms, oque) {
|
||||||
|
return Promise.race([promessa, new Promise((_, rej) => setTimeout(() => rej(new Error(oque + ' demorou mais de ' + ms / 1000 + ' s')), ms))]);
|
||||||
|
}
|
||||||
async function capturar(c) {
|
async function capturar(c) {
|
||||||
const media = await navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}});
|
const media = await comPrazo(navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}}), 8000, 'Abrir a câmera');
|
||||||
const track = media.getVideoTracks()[0];
|
const track = media.getVideoTracks()[0];
|
||||||
const facing = track.getSettings().facingMode;
|
const facing = track.getSettings().facingMode;
|
||||||
if (facing && facing !== (c.cameraLado || 'user')) {
|
if (facing && facing !== (c.cameraLado || 'user')) {
|
||||||
@@ -74,6 +79,12 @@ async function iniciar(id) {
|
|||||||
const media = await capturar(config); signature = chave(config);
|
const media = await capturar(config); signature = chave(config);
|
||||||
if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; }
|
if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; }
|
||||||
stream = media; video.srcObject = stream; await video.play();
|
stream = media; video.srcObject = stream; await video.play();
|
||||||
|
// câmera fechada de repente (outro uso, erro do sistema): desfaz tudo; o próximo pacote reabre
|
||||||
|
stream.getVideoTracks()[0].onended = () => {
|
||||||
|
if (captureGeneration !== generation) return;
|
||||||
|
console.log('RTC câmera encerrada; recomeçando no próximo pedido');
|
||||||
|
parar(); sid = null;
|
||||||
|
};
|
||||||
pc = new RTCPeerConnection({iceServers:[]});
|
pc = new RTCPeerConnection({iceServers:[]});
|
||||||
sender = pc.addTrack(stream.getVideoTracks()[0], stream);
|
sender = pc.addTrack(stream.getVideoTracks()[0], stream);
|
||||||
const mine = pc;
|
const mine = pc;
|
||||||
@@ -133,14 +144,23 @@ async function receber(packet) {
|
|||||||
}
|
}
|
||||||
window.rtcReceber = packet => {
|
window.rtcReceber = packet => {
|
||||||
allowed = true;
|
allowed = true;
|
||||||
tasks = tasks.then(() => allowed ? receber(packet) : undefined).catch(e => {
|
tasks = tasks.then(() => allowed ? comPrazo(receber(packet), 12000, 'Pedido de vídeo') : undefined).catch(e => {
|
||||||
console.log('RTC erro=' + e.name + ': ' + e.message);
|
console.log('RTC erro=' + e.name + ': ' + e.message);
|
||||||
sinal({tipo:'erro',erro:e.name + ': ' + e.message});
|
sinal({tipo:'erro',erro:e.name + ': ' + e.message});
|
||||||
estado({tipo:'webrtc',estado:'erro',erro:e.message});
|
estado({tipo:'webrtc',estado:'erro',erro:e.message});
|
||||||
parar();
|
parar(); sid = null;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
window.rtcParar = () => { allowed = false; parar(); sid = null; };
|
window.rtcParar = () => { allowed = false; parar(); sid = null; };
|
||||||
|
/* Foto do evento para o histórico, tirada do vídeo ao vivo (câmera de movimento parada). */
|
||||||
|
window.rtcFoto = () => {
|
||||||
|
if (!stream || video.readyState < 2 || !video.videoWidth) return;
|
||||||
|
const escala = Math.min(1, 1280 / video.videoWidth);
|
||||||
|
const tela = document.createElement('canvas');
|
||||||
|
tela.width = Math.round(video.videoWidth * escala); tela.height = Math.round(video.videoHeight * escala);
|
||||||
|
tela.getContext('2d').drawImage(video, 0, 0, tela.width, tela.height);
|
||||||
|
CarinhaRTC.foto(tela.toDataURL('image/jpeg', 0.82));
|
||||||
|
};
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return;
|
if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return;
|
||||||
lastMotionFrame=video.currentTime;
|
lastMotionFrame=video.currentTime;
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/* voz-robo.js — voz sintetizada do Carinha, no estilo dos robozinhos de cinema.
|
||||||
|
*
|
||||||
|
* Tudo é gerado na hora com Web Audio: nenhuma gravação, nenhum arquivo de terceiros.
|
||||||
|
* Cada som é uma sequência de "sílabas": um oscilador que desliza de um tom a outro, com vibrato,
|
||||||
|
* passando por um filtro de formante (a "vogal": o→i faz o "uou"). Motores das mãos viram ruído
|
||||||
|
* filtrado, e a resposta da conversa vira um balbucio que sobe no fim quando é pergunta.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let ruido = null; // buffer de ruído reaproveitado
|
||||||
|
const TOM = 1.25; // todas as sílabas 25% acima do tom escrito
|
||||||
|
const saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Cadeia de "brilho metálico" (criada uma vez por AudioContext):
|
||||||
|
* voz → ressonância de lata fina (agudo, ~850 Hz) → saturação leve → compressor → saída
|
||||||
|
* Nada grave: zumbido e ressonância graves deixavam a voz com cara de sapo.
|
||||||
|
*/
|
||||||
|
function saida(c) {
|
||||||
|
if (saidas.has(c)) return saidas.get(c);
|
||||||
|
const entrada = c.createGain();
|
||||||
|
const seca = c.createGain(); seca.gain.value = 0.8;
|
||||||
|
const atraso = c.createDelay(0.05); atraso.delayTime.value = 0.00118;
|
||||||
|
const volta = c.createGain(); volta.gain.value = 0.42;
|
||||||
|
const molhada = c.createGain(); molhada.gain.value = 0.35;
|
||||||
|
entrada.connect(seca);
|
||||||
|
entrada.connect(atraso); atraso.connect(volta); volta.connect(atraso); atraso.connect(molhada);
|
||||||
|
const satura = c.createWaveShaper();
|
||||||
|
const n = 2048, curva = new Float32Array(n);
|
||||||
|
for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; curva[i] = Math.tanh(x * 1.3); }
|
||||||
|
satura.curve = curva;
|
||||||
|
seca.connect(satura); molhada.connect(satura);
|
||||||
|
const brilho = c.createBiquadFilter(); // realce leve nos agudos: "metal" limpo
|
||||||
|
brilho.type = 'peaking'; brilho.frequency.value = 3200; brilho.Q.value = 1.2; brilho.gain.value = 4;
|
||||||
|
const comp = c.createDynamicsCompressor();
|
||||||
|
comp.threshold.value = -16; comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.15;
|
||||||
|
const passaBaixa = c.createBiquadFilter();
|
||||||
|
passaBaixa.type = 'lowpass'; passaBaixa.frequency.value = 8000;
|
||||||
|
const volume = c.createGain(); volume.gain.value = 0.8;
|
||||||
|
satura.connect(brilho); brilho.connect(comp); comp.connect(passaBaixa); passaBaixa.connect(volume); volume.connect(c.destination);
|
||||||
|
saidas.set(c, entrada);
|
||||||
|
return entrada;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uma sílaba. de/para: tom em Hz; vogal: [formante inicial, final] em Hz;
|
||||||
|
* vib: [Hz, profundidade em Hz]; forma: tipo de onda.
|
||||||
|
*/
|
||||||
|
function silaba(c, t0, dur, de, para, vol, op) {
|
||||||
|
op = op || {};
|
||||||
|
vol *= 2.4; // compensa a energia que o filtro de formante tira
|
||||||
|
de *= TOM; para *= TOM; // voz de robozinho: aguda
|
||||||
|
const osc = c.createOscillator();
|
||||||
|
osc.type = op.forma || 'sawtooth';
|
||||||
|
osc.frequency.setValueAtTime(de, t0);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(Math.max(20, para), t0 + dur);
|
||||||
|
if (op.vib) {
|
||||||
|
const lfo = c.createOscillator(), prof = c.createGain();
|
||||||
|
lfo.frequency.value = op.vib[0]; prof.gain.value = op.vib[1];
|
||||||
|
lfo.connect(prof); prof.connect(osc.frequency);
|
||||||
|
lfo.start(t0); lfo.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
// FM harmônico e leve: brilho de metal sem aspereza
|
||||||
|
const mod = c.createOscillator(), modProf = c.createGain();
|
||||||
|
mod.type = 'sine';
|
||||||
|
mod.frequency.setValueAtTime(de * 2, t0);
|
||||||
|
mod.frequency.exponentialRampToValueAtTime(Math.max(20, para * 2), t0 + dur);
|
||||||
|
modProf.gain.setValueAtTime(de * (op.metal ?? 0.22), t0);
|
||||||
|
modProf.gain.exponentialRampToValueAtTime(Math.max(1, para * (op.metal ?? 0.22)), t0 + dur);
|
||||||
|
mod.connect(modProf); modProf.connect(osc.frequency);
|
||||||
|
mod.start(t0); mod.stop(t0 + dur + 0.05);
|
||||||
|
// corpo: uma senoide uma oitava abaixo deixa a voz menos "apito"
|
||||||
|
const sub = c.createOscillator();
|
||||||
|
sub.type = 'sine';
|
||||||
|
sub.frequency.setValueAtTime(de / 2, t0);
|
||||||
|
sub.frequency.exponentialRampToValueAtTime(Math.max(20, para / 2), t0 + dur);
|
||||||
|
const vogal = op.vogal || [900, 1500];
|
||||||
|
const filtro = c.createBiquadFilter();
|
||||||
|
filtro.type = 'bandpass'; filtro.Q.value = op.q || 5;
|
||||||
|
filtro.frequency.setValueAtTime(vogal[0], t0);
|
||||||
|
filtro.frequency.exponentialRampToValueAtTime(vogal[1], t0 + dur);
|
||||||
|
const g = c.createGain(), gs = c.createGain();
|
||||||
|
const ataque = Math.min(0.02, dur * 0.25);
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(vol, t0 + ataque);
|
||||||
|
g.gain.setValueAtTime(vol, t0 + dur * 0.7);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
gs.gain.value = 0.12; // pouca oitava de baixo: grave demais vira coaxar
|
||||||
|
osc.connect(filtro); sub.connect(gs); gs.connect(filtro); filtro.connect(g); g.connect(saida(c));
|
||||||
|
osc.start(t0); sub.start(t0);
|
||||||
|
osc.stop(t0 + dur + 0.05); sub.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferRuido(c) {
|
||||||
|
if (ruido && ruido.sampleRate === c.sampleRate) return ruido;
|
||||||
|
ruido = c.createBuffer(1, c.sampleRate, c.sampleRate);
|
||||||
|
const d = ruido.getChannelData(0);
|
||||||
|
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||||
|
return ruido;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Motorzinho: ruído filtrado que sobe de tom, com um tremor mecânico. */
|
||||||
|
function servo(c, t0, dur, vol, sobe) {
|
||||||
|
const fonte = c.createBufferSource();
|
||||||
|
fonte.buffer = bufferRuido(c); fonte.loop = true;
|
||||||
|
const f = c.createBiquadFilter();
|
||||||
|
f.type = 'bandpass'; f.Q.value = 9;
|
||||||
|
f.frequency.setValueAtTime(sobe === false ? 1500 : 500, t0);
|
||||||
|
f.frequency.exponentialRampToValueAtTime(sobe === false ? 500 : 1500, t0 + dur);
|
||||||
|
const g = c.createGain(), trem = c.createOscillator(), tg = c.createGain();
|
||||||
|
trem.frequency.value = 38; tg.gain.value = vol * 0.35;
|
||||||
|
trem.connect(tg); tg.connect(g.gain);
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(vol * 0.6, t0 + 0.05);
|
||||||
|
g.gain.setValueAtTime(vol * 0.6, t0 + dur - 0.06);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||||
|
fonte.start(t0); trem.start(t0);
|
||||||
|
fonte.stop(t0 + dur + 0.05); trem.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clique(c, t0, vol) {
|
||||||
|
const fonte = c.createBufferSource();
|
||||||
|
fonte.buffer = bufferRuido(c);
|
||||||
|
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 3000;
|
||||||
|
const g = c.createGain();
|
||||||
|
g.gain.setValueAtTime(vol * 0.5, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.03);
|
||||||
|
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||||
|
fonte.start(t0); fonte.stop(t0 + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
const O = [500, 900], I = [1800, 2400], U = [350, 700], A = [900, 1300]; // vogais (formantes)
|
||||||
|
|
||||||
|
// Cada som: (c, t, v) → agenda as sílabas. Volumes relativos ao volume do app.
|
||||||
|
const SONS = {
|
||||||
|
feliz: (c, t, v) => { silaba(c, t, 0.12, 620, 900, v, { vogal: [1200, 2000] }); silaba(c, t + 0.13, 0.18, 900, 1250, v, { vogal: I, vib: [9, 18] }); },
|
||||||
|
alegre: (c, t, v) => [560, 700, 860, 1100].forEach((f, i) => silaba(c, t + i * 0.085, 0.09, f, f * 1.25, v * 0.85, { vogal: I, forma: 'square' })),
|
||||||
|
coracao: (c, t, v) => { silaba(c, t, 0.55, 760, 560, v, { vogal: [800, 1300], vib: [7, 28] }); },
|
||||||
|
piscadinha: (c, t, v) => { clique(c, t, v); silaba(c, t + 0.04, 0.08, 1000, 1300, v * 0.6, { forma: 'square', vogal: I }); },
|
||||||
|
surpreso: (c, t, v) => { silaba(c, t, 0.22, 260, 1100, v, { vogal: [450, 2000] }); silaba(c, t + 0.22, 0.12, 1100, 820, v * 0.8, { vogal: I }); },
|
||||||
|
bravo: (c, t, v) => [0, 0.13, 0.26].forEach((d, i) => silaba(c, t + d, 0.1, 480 - i * 30, 380 - i * 30, v * 0.85, { forma: 'square', vogal: [1300, 900] })), // bipes zangados
|
||||||
|
triste: (c, t, v) => { silaba(c, t, 0.65, 640, 380, v * 0.85, { vogal: [1200, 750], vib: [5, 14] }); },
|
||||||
|
dormir: (c, t, v) => { silaba(c, t, 0.35, 460, 760, v * 0.7, { vogal: [900, 1500] }); silaba(c, t + 0.36, 0.75, 760, 340, v * 0.6, { vogal: [1500, 700], vib: [4, 8] }); },
|
||||||
|
acordar: (c, t, v) => { servo(c, t, 0.25, v * 0.5); [300, 450, 700].forEach((f, i) => silaba(c, t + 0.22 + i * 0.1, 0.1, f, f * 1.2, v * 0.8, { vogal: I, forma: 'square' })); silaba(c, t + 0.56, 0.2, 700, 1050, v, { vogal: [1200, 2200], vib: [8, 15] }); },
|
||||||
|
acenar: (c, t, v) => { servo(c, t, 0.3, v * 0.45); silaba(c, t + 0.28, 0.13, 520, 820, v, { vogal: [1000, 1900] }); silaba(c, t + 0.44, 0.2, 760, 1080, v, { vogal: I, vib: [9, 20] }); },
|
||||||
|
esconder: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 0.1, 0.08, 950, 1350, v * 0.8, { vogal: I }); for (let i = 0; i < 5; i++) silaba(c, t + 0.6 + i * 0.09, 0.05, 1200 + (i % 2) * 180, 1300, v * 0.35, { forma: 'square', vogal: I }); servo(c, t + 2.05, 0.4, v * 0.45, false); },
|
||||||
|
espiar: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 1.0, 0.3, 480, 760, v * 0.9, { vogal: [600, 1700] }); silaba(c, t + 2.0, 0.16, 800, 1150, v, { vogal: I }); },
|
||||||
|
pensar: (c, t, v) => { servo(c, t, 0.35, v * 0.4); silaba(c, t + 0.35, 0.5, 520, 640, v * 0.7, { vogal: [900, 1600], vib: [5, 10] }); [0.95, 1.12].forEach((d) => silaba(c, t + d, 0.05, 1500, 1600, v * 0.35, { forma: 'square', vogal: I })); }, // "hmm?" e bipinhos
|
||||||
|
musica: (c, t, v) => [523, 659, 784, 659, 880, 784, 659, 1047].forEach((f, i) => silaba(c, t + i * 0.26, 0.2, f, f * 1.02, v * 0.6, { forma: 'triangle', vogal: A, vib: [6, 5] })),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gerador previsível: a mesma frase sempre "soa" igual.
|
||||||
|
function semente(texto) {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
|
||||||
|
return () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Balbucio de robô para uma resposta: ~1 sílaba a cada 6 letras, no máximo 16. */
|
||||||
|
function balbuciar(c, texto, v) {
|
||||||
|
texto = String(texto || '').trim();
|
||||||
|
if (!texto) return 0;
|
||||||
|
const rnd = semente(texto), n = Math.max(2, Math.min(16, Math.ceil(texto.length / 6)));
|
||||||
|
const pergunta = /\?\s*$/.test(texto), exclama = /!\s*$/.test(texto);
|
||||||
|
const vogais = [O, I, A, U];
|
||||||
|
let t = c.currentTime + 0.03, tom = 520 + rnd() * 180;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const ultima = i === n - 1;
|
||||||
|
const dur = 0.07 + rnd() * 0.1;
|
||||||
|
let para = tom * (0.8 + rnd() * 0.5);
|
||||||
|
if (ultima) para = pergunta ? tom * 1.6 : exclama ? tom * 1.35 : tom * 0.7;
|
||||||
|
silaba(c, t, ultima ? dur + 0.08 : dur, tom, para, v * (0.65 + rnd() * 0.3), { vogal: vogais[Math.floor(rnd() * 4)], vib: rnd() < 0.3 ? [8, 14] : null, forma: rnd() < 0.25 ? 'square' : 'sawtooth' });
|
||||||
|
t += dur + 0.025 + rnd() * 0.05;
|
||||||
|
tom = Math.max(330, Math.min(1000, para * (0.9 + rnd() * 0.25)));
|
||||||
|
}
|
||||||
|
return t - c.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tocar(c, nome, v) {
|
||||||
|
const f = SONS[nome];
|
||||||
|
if (!f || !c) return false;
|
||||||
|
f(c, c.currentTime + 0.02, v);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.VozRobo = { tocar, balbuciar, servo: (c, dur, v) => servo(c, c.currentTime + 0.01, dur, v), sons: Object.keys(SONS) };
|
||||||
|
})();
|
||||||
@@ -10,6 +10,8 @@ import java.security.MessageDigest;
|
|||||||
public final class CerebroLocal {
|
public final class CerebroLocal {
|
||||||
private volatile boolean fechado;
|
private volatile boolean fechado;
|
||||||
private Process processo;
|
private Process processo;
|
||||||
|
/** Com token, o llama-server (aberto na rede para a placa) exige a mesma chave. */
|
||||||
|
public volatile String chave = "";
|
||||||
public void iniciar(final Context contexto, final String servidor) {
|
public void iniciar(final Context contexto, final String servidor) {
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -43,7 +45,9 @@ public final class CerebroLocal {
|
|||||||
try { if (health.getResponseCode() == 200) { Log.i("CarinhaIA", "Executor existente pronto"); return; } }
|
try { if (health.getResponseCode() == 200) { Log.i("CarinhaIA", "Executor existente pronto"); return; } }
|
||||||
finally { health.disconnect(); }
|
finally { health.disconnect(); }
|
||||||
} catch (IOException ignored) { }
|
} catch (IOException ignored) { }
|
||||||
ProcessBuilder builder = new ProcessBuilder(executavel.getAbsolutePath(), "-m", modelo.getAbsolutePath(), "--host", "0.0.0.0", "--port", "8101", "-c", "1024", "-t", "2", "-np", "1", "--jinja");
|
java.util.List<String> comando = new java.util.ArrayList<>(java.util.Arrays.asList(executavel.getAbsolutePath(), "-m", modelo.getAbsolutePath(), "--host", "0.0.0.0", "--port", "8101", "-c", "1024", "-t", "2", "-np", "1", "--jinja"));
|
||||||
|
if (chave != null && !chave.isEmpty()) { comando.add("--api-key"); comando.add(chave); }
|
||||||
|
ProcessBuilder builder = new ProcessBuilder(comando);
|
||||||
builder.environment().put("LD_LIBRARY_PATH", libs.getAbsolutePath());
|
builder.environment().put("LD_LIBRARY_PATH", libs.getAbsolutePath());
|
||||||
builder.environment().put("GGML_BACKEND_DL_PATH", libs.getAbsolutePath());
|
builder.environment().put("GGML_BACKEND_DL_PATH", libs.getAbsolutePath());
|
||||||
builder.redirectErrorStream(true);
|
builder.redirectErrorStream(true);
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ public final class ControleRemoto {
|
|||||||
private volatile ScheduledExecutorService worker;
|
private volatile ScheduledExecutorService worker;
|
||||||
private long consultarEm;
|
private long consultarEm;
|
||||||
private long quadroEm;
|
private long quadroEm;
|
||||||
|
// Foto do evento (histórico): o servidor pede, o app tira e envia.
|
||||||
|
private volatile long fotoPedida = 0, fotoFeita = 0, fotoId = 0, pedidoEm = 0;
|
||||||
|
private volatile byte[] fotoJpeg = null;
|
||||||
|
/** Id do evento que está esperando foto (0 = nenhum). */
|
||||||
|
public long fotoPendente() { return fotoJpeg == null ? fotoPedida : 0; }
|
||||||
|
/** Foto vinda do vídeo ao vivo (quando a câmera de movimento está desligada). */
|
||||||
|
public void fotoRTC(byte[] jpeg) {
|
||||||
|
if (fotoPedida > 0 && fotoJpeg == null && jpeg != null && jpeg.length > 0) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; }
|
||||||
|
}
|
||||||
private long logEm;
|
private long logEm;
|
||||||
private final Runnable mudanca;
|
private final Runnable mudanca;
|
||||||
public interface RTCListener { boolean receber(JSONObject pacote); }
|
public interface RTCListener { boolean receber(JSONObject pacote); }
|
||||||
@@ -46,6 +55,9 @@ public final class ControleRemoto {
|
|||||||
public void sinalRTC(String json) {
|
public void sinalRTC(String json) {
|
||||||
if (sinais.size() < 64) sinais.offer(json);
|
if (sinais.size() < 64) sinais.offer(json);
|
||||||
}
|
}
|
||||||
|
private volatile String token = "";
|
||||||
|
/** Token do servidor (CARINHA_TOKEN); vai como Authorization em todo pedido. */
|
||||||
|
public void token(String valor) { token = valor == null ? "" : valor.trim(); }
|
||||||
public void servidor(String valor) {
|
public void servidor(String valor) {
|
||||||
servidor = valor;
|
servidor = valor;
|
||||||
pendente = null;
|
pendente = null;
|
||||||
@@ -115,6 +127,7 @@ public final class ControleRemoto {
|
|||||||
mudanca.run();
|
mudanca.run();
|
||||||
}
|
}
|
||||||
if (videoRTC()) rtcTick(base);
|
if (videoRTC()) rtcTick(base);
|
||||||
|
fotoTick(base);
|
||||||
byte[] jpeg = pendente;
|
byte[] jpeg = pendente;
|
||||||
pendente = null;
|
pendente = null;
|
||||||
if (jpeg != null && preview()) {
|
if (jpeg != null && preview()) {
|
||||||
@@ -140,13 +153,17 @@ public final class ControleRemoto {
|
|||||||
HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
|
HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
|
||||||
c.setConnectTimeout(1500); c.setReadTimeout(1500);
|
c.setConnectTimeout(1500); c.setReadTimeout(1500);
|
||||||
c.setUseCaches(false);
|
c.setUseCaches(false);
|
||||||
|
if (!token.isEmpty()) c.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
public void quadro(Image image, int rotacao) {
|
public void quadro(Image image, int rotacao) {
|
||||||
if (videoRTC()) return;
|
boolean querFoto = fotoPedida > 0 && fotoJpeg == null;
|
||||||
long agora = System.currentTimeMillis();
|
long agora = System.currentTimeMillis();
|
||||||
if (!preview() || pendente != null || agora < quadroEm) return;
|
if (!querFoto) {
|
||||||
quadroEm = agora + 200;
|
if (videoRTC()) return;
|
||||||
|
if (!preview() || pendente != null || agora < quadroEm) return;
|
||||||
|
quadroEm = agora + 200;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
Image.Plane[] p = image.getPlanes();
|
Image.Plane[] p = image.getPlanes();
|
||||||
ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()};
|
ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()};
|
||||||
@@ -167,10 +184,44 @@ public final class ControleRemoto {
|
|||||||
if (rotated != b) rotated.recycle();
|
if (rotated != b) rotated.recycle();
|
||||||
b.recycle();
|
b.recycle();
|
||||||
}
|
}
|
||||||
if (preview()) pendente = jpeg;
|
if (querFoto) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; }
|
||||||
|
else if (preview()) pendente = jpeg;
|
||||||
} catch (Exception e) { Log.w("CarinhaPC", "quadro descartado: " + e); }
|
} catch (Exception e) { Log.w("CarinhaPC", "quadro descartado: " + e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Consulta o pedido de foto (1x/s) e envia a foto pronta para o histórico. */
|
||||||
|
private void fotoTick(String base) throws Exception {
|
||||||
|
byte[] jpeg = fotoJpeg;
|
||||||
|
if (jpeg != null) {
|
||||||
|
long id = fotoId;
|
||||||
|
fotoJpeg = null;
|
||||||
|
HttpURLConnection c = abrir(base + "/historico/foto?id=" + id);
|
||||||
|
try {
|
||||||
|
c.setRequestMethod("POST"); c.setDoOutput(true);
|
||||||
|
c.setRequestProperty("Content-Type", "image/jpeg");
|
||||||
|
c.setFixedLengthStreamingMode(jpeg.length);
|
||||||
|
try (java.io.OutputStream out = c.getOutputStream()) { out.write(jpeg); }
|
||||||
|
int code = c.getResponseCode();
|
||||||
|
Log.i("CarinhaPC", "foto do evento " + id + ": HTTP " + code);
|
||||||
|
fotoFeita = id;
|
||||||
|
} finally { c.disconnect(); }
|
||||||
|
}
|
||||||
|
long agora = System.currentTimeMillis();
|
||||||
|
if (agora < pedidoEm) return;
|
||||||
|
pedidoEm = agora + 1000;
|
||||||
|
HttpURLConnection c = abrir(base + "/historico/pedido");
|
||||||
|
try {
|
||||||
|
if (c.getResponseCode() != 200) return;
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||||
|
try (InputStream in = c.getInputStream()) {
|
||||||
|
byte[] buffer = new byte[512]; int n;
|
||||||
|
while ((n = in.read(buffer)) != -1 && bytes.size() < 4096) bytes.write(buffer, 0, n);
|
||||||
|
}
|
||||||
|
long id = new JSONObject(bytes.toString("UTF-8")).optLong("id", 0);
|
||||||
|
if (id > 0 && id != fotoFeita && id != fotoId && fotoJpeg == null) fotoPedida = id;
|
||||||
|
} finally { c.disconnect(); }
|
||||||
|
}
|
||||||
|
|
||||||
private void rtcTick(String base) throws Exception {
|
private void rtcTick(String base) throws Exception {
|
||||||
String json;
|
String json;
|
||||||
while ((json = sinais.poll()) != null) {
|
while ((json = sinais.poll()) != null) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.content.Intent;
|
|||||||
import android.content.IntentFilter;
|
import android.content.IntentFilter;
|
||||||
import android.os.BatteryManager;
|
import android.os.BatteryManager;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
|
import android.net.Uri;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
import android.hardware.camera2.CameraAccessException;
|
import android.hardware.camera2.CameraAccessException;
|
||||||
@@ -83,6 +84,7 @@ public class MainActivity extends Activity {
|
|||||||
|
|
||||||
private static final String PREFS = "carinha";
|
private static final String PREFS = "carinha";
|
||||||
private static final String CHAVE = "servidor";
|
private static final String CHAVE = "servidor";
|
||||||
|
private static final String CHAVE_TOKEN = "token";
|
||||||
private static final String PADRAO = "http://192.168.1.15:8100";
|
private static final String PADRAO = "http://192.168.1.15:8100";
|
||||||
|
|
||||||
private static final String TAG = "CarinhaMic";
|
private static final String TAG = "CarinhaMic";
|
||||||
@@ -100,6 +102,7 @@ public class MainActivity extends Activity {
|
|||||||
|
|
||||||
private WebView web;
|
private WebView web;
|
||||||
private String servidor;
|
private String servidor;
|
||||||
|
private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto
|
||||||
private final CerebroLocal cerebroLocal = new CerebroLocal();
|
private final CerebroLocal cerebroLocal = new CerebroLocal();
|
||||||
private boolean jaCaiuParaLocal = false;
|
private boolean jaCaiuParaLocal = false;
|
||||||
private boolean jaRecarregou = false;
|
private boolean jaRecarregou = false;
|
||||||
@@ -179,8 +182,10 @@ public class MainActivity extends Activity {
|
|||||||
|
|
||||||
SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
|
SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||||
servidor = p.getString(CHAVE, PADRAO);
|
servidor = p.getString(CHAVE, PADRAO);
|
||||||
|
token = p.getString(CHAVE_TOKEN, "");
|
||||||
movimentoLigado = p.getBoolean(CHAVE_MOV, true);
|
movimentoLigado = p.getBoolean(CHAVE_MOV, true);
|
||||||
usarServidorDoPedido(getIntent()); // permite apontar o servidor ao lancar
|
usarServidorDoPedido(getIntent()); // permite apontar o servidor ao lancar
|
||||||
|
cerebroLocal.chave = token;
|
||||||
cerebroLocal.iniciar(this, servidor);
|
cerebroLocal.iniciar(this, servidor);
|
||||||
remoto = new ControleRemoto(new Runnable() {
|
remoto = new ControleRemoto(new Runnable() {
|
||||||
@Override public void run() {
|
@Override public void run() {
|
||||||
@@ -193,6 +198,8 @@ public class MainActivity extends Activity {
|
|||||||
}
|
}
|
||||||
atualizarSensores();
|
atualizarSensores();
|
||||||
atualizarBrilho();
|
atualizarBrilho();
|
||||||
|
if (remoto.fotoPendente() > 0 && rtcAtivo)
|
||||||
|
rtcWeb.evaluateJavascript("window.rtcFoto&&window.rtcFoto()", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -272,7 +279,7 @@ public class MainActivity extends Activity {
|
|||||||
runOnUiThread(new Runnable() {
|
runOnUiThread(new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
boolean pode = true;
|
boolean pode = origemConfiavel(pedido.getOrigin());
|
||||||
for (String recurso : pedido.getResources()) {
|
for (String recurso : pedido.getResources()) {
|
||||||
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(recurso)
|
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(recurso)
|
||||||
&& checkSelfPermission(Manifest.permission.CAMERA)
|
&& checkSelfPermission(Manifest.permission.CAMERA)
|
||||||
@@ -348,6 +355,15 @@ public class MainActivity extends Activity {
|
|||||||
if (it == null) return;
|
if (it == null) return;
|
||||||
String novo = it.getStringExtra("servidor");
|
String novo = it.getStringExtra("servidor");
|
||||||
if (novo == null) return;
|
if (novo == null) return;
|
||||||
|
String tokenPedido = it.getStringExtra("token");
|
||||||
|
if (!token.isEmpty() && !token.equals(tokenPedido)) {
|
||||||
|
Log.w(TAG, "pedido para trocar o servidor sem o token certo: ignorado");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token.isEmpty() && tokenPedido != null && !tokenPedido.trim().isEmpty()) {
|
||||||
|
token = tokenPedido.trim();
|
||||||
|
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE_TOKEN, token).apply();
|
||||||
|
}
|
||||||
novo = novo.trim();
|
novo = novo.trim();
|
||||||
while (novo.endsWith("/")) novo = novo.substring(0, novo.length() - 1);
|
while (novo.endsWith("/")) novo = novo.substring(0, novo.length() - 1);
|
||||||
if (novo.length() == 0) return;
|
if (novo.length() == 0) return;
|
||||||
@@ -374,8 +390,9 @@ public class MainActivity extends Activity {
|
|||||||
private void abrirRosto() {
|
private void abrirRosto() {
|
||||||
naCamera = false;
|
naCamera = false;
|
||||||
remoto.servidor(servidor);
|
remoto.servidor(servidor);
|
||||||
|
remoto.token(token);
|
||||||
if (usandoLocal) web.loadUrl("file:///android_asset/carinha.html");
|
if (usandoLocal) web.loadUrl("file:///android_asset/carinha.html");
|
||||||
else web.loadUrl(servidor + "/carinha.html?aparelho=1");
|
else web.loadUrl(servidor + "/carinha.html?aparelho=1" + (token.isEmpty() ? "" : "&t=" + Uri.encode(token)));
|
||||||
iniciarMovimento(); // volta a observar quem passa na frente
|
iniciarMovimento(); // volta a observar quem passa na frente
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +414,7 @@ public class MainActivity extends Activity {
|
|||||||
private void menu() {
|
private void menu() {
|
||||||
final String[] opcoes = {"Ver a camera", "Voltar para a carinha",
|
final String[] opcoes = {"Ver a camera", "Voltar para a carinha",
|
||||||
(movimentoLigado ? "Desligar reacao ao movimento" : "Ligar reacao ao movimento"),
|
(movimentoLigado ? "Desligar reacao ao movimento" : "Ligar reacao ao movimento"),
|
||||||
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video"};
|
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video", "Token do servidor"};
|
||||||
new AlertDialog.Builder(this)
|
new AlertDialog.Builder(this)
|
||||||
.setTitle("Carinha")
|
.setTitle("Carinha")
|
||||||
.setItems(opcoes, new DialogInterface.OnClickListener() {
|
.setItems(opcoes, new DialogInterface.OnClickListener() {
|
||||||
@@ -408,7 +425,8 @@ public class MainActivity extends Activity {
|
|||||||
else if (i == 2) alternarMovimento();
|
else if (i == 2) alternarMovimento();
|
||||||
else if (i == 3) pedirServidor();
|
else if (i == 3) pedirServidor();
|
||||||
else if (i == 4) menuSensores();
|
else if (i == 4) menuSensores();
|
||||||
else menuVideo();
|
else if (i == 5) menuVideo();
|
||||||
|
else pedirToken();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.show();
|
.show();
|
||||||
@@ -520,6 +538,37 @@ public class MainActivity extends Activity {
|
|||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Token do servidor (CARINHA_TOKEN da placa). Vazio = servidor sem autenticacao. */
|
||||||
|
private void pedirToken() {
|
||||||
|
final EditText campo = new EditText(this);
|
||||||
|
campo.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||||
|
campo.setText(token);
|
||||||
|
new AlertDialog.Builder(this)
|
||||||
|
.setTitle("Token do servidor")
|
||||||
|
.setMessage("O mesmo CARINHA_TOKEN configurado na placa")
|
||||||
|
.setView(campo)
|
||||||
|
.setPositiveButton("Salvar", new DialogInterface.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(DialogInterface d, int w) {
|
||||||
|
token = campo.getText().toString().trim();
|
||||||
|
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE_TOKEN, token).apply();
|
||||||
|
cerebroLocal.chave = token;
|
||||||
|
aviso(token.isEmpty() ? "token removido" : "token salvo");
|
||||||
|
abrirRosto();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.setNegativeButton("Cancelar", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** So o servidor configurado (ou a copia local do APK) pode usar camera/microfone. */
|
||||||
|
private boolean origemConfiavel(Uri origem) {
|
||||||
|
if (origem == null) return false;
|
||||||
|
String o = origem.toString();
|
||||||
|
while (o.endsWith("/")) o = o.substring(0, o.length() - 1);
|
||||||
|
return o.startsWith("file:") || o.equals(servidor);
|
||||||
|
}
|
||||||
|
|
||||||
/** Voltar nao sai do app (num enfeite de mesa isso seria chato): volta a carinha. */
|
/** Voltar nao sai do app (num enfeite de mesa isso seria chato): volta a carinha. */
|
||||||
@Override
|
@Override
|
||||||
public void onBackPressed() {
|
public void onBackPressed() {
|
||||||
@@ -974,8 +1023,53 @@ public class MainActivity extends Activity {
|
|||||||
return String.format(java.util.Locale.US, "%.3f", v);
|
return String.format(java.util.Locale.US, "%.3f", v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String aparelhoJson = "null";
|
||||||
|
private long aparelhoEm = 0;
|
||||||
|
|
||||||
|
/** IP, modelo, Android, versão do app e sinal do Wi-Fi; recalculado a cada 10 s. */
|
||||||
|
private String infoAparelho() {
|
||||||
|
long agora = System.currentTimeMillis();
|
||||||
|
if (agora - aparelhoEm < 10000) return aparelhoJson;
|
||||||
|
aparelhoEm = agora;
|
||||||
|
try {
|
||||||
|
JSONObject a = new JSONObject();
|
||||||
|
a.put("ip", ipLocal());
|
||||||
|
a.put("modelo", (Build.MANUFACTURER + " " + Build.MODEL).trim());
|
||||||
|
a.put("android", Build.VERSION.RELEASE);
|
||||||
|
try { a.put("app", getPackageManager().getPackageInfo(getPackageName(), 0).versionName); }
|
||||||
|
catch (Exception e) { a.put("app", JSONObject.NULL); }
|
||||||
|
try {
|
||||||
|
android.net.wifi.WifiManager wifi = (android.net.wifi.WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
|
||||||
|
android.net.wifi.WifiInfo info = wifi == null ? null : wifi.getConnectionInfo();
|
||||||
|
// Sem localização o Android 10+ esconde o ID da rede, mas o RSSI continua valendo.
|
||||||
|
if (info != null && info.getRssi() > -127 && info.getRssi() < 0) {
|
||||||
|
a.put("wifi", android.net.wifi.WifiManager.calculateSignalLevel(info.getRssi(), 5));
|
||||||
|
a.put("rssi", info.getRssi());
|
||||||
|
}
|
||||||
|
} catch (Exception e) { /* sem permissão ou sem Wi-Fi */ }
|
||||||
|
aparelhoJson = a.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
aparelhoJson = "null";
|
||||||
|
}
|
||||||
|
return aparelhoJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Primeiro IPv4 privado da rede (Wi-Fi), sem precisar de permissão extra. */
|
||||||
|
private static String ipLocal() {
|
||||||
|
try {
|
||||||
|
for (java.net.NetworkInterface n : java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) {
|
||||||
|
if (!n.isUp() || n.isLoopback()) continue;
|
||||||
|
for (java.net.InetAddress e : java.util.Collections.list(n.getInetAddresses())) {
|
||||||
|
if (e instanceof java.net.Inet4Address && e.isSiteLocalAddress()) return e.getHostAddress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) { /* sem rede */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private void atualizarBrilho() {
|
private void atualizarBrilho() {
|
||||||
if (naCamera) return;
|
if (naCamera) return;
|
||||||
|
web.evaluateJavascript("window.carinhaAparelho=" + infoAparelho(), null);
|
||||||
Intent bateria = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
|
Intent bateria = registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
|
||||||
if (bateria != null) {
|
if (bateria != null) {
|
||||||
int level = bateria.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
|
int level = bateria.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
|
||||||
@@ -987,7 +1081,7 @@ public class MainActivity extends Activity {
|
|||||||
web.evaluateJavascript("window.carinhaBateria={nivel:" + percent + ",carregando:" + charging + "}", null);
|
web.evaluateJavascript("window.carinhaBateria={nivel:" + percent + ",carregando:" + charging + "}", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length)}):null",
|
web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length),apagada:!!window.carinhaTelaApagada}):null",
|
||||||
new ValueCallback<String>() {
|
new ValueCallback<String>() {
|
||||||
@Override public void onReceiveValue(String valor) {
|
@Override public void onReceiveValue(String valor) {
|
||||||
if (!emPrimeiroPlano) return;
|
if (!emPrimeiroPlano) return;
|
||||||
@@ -999,6 +1093,7 @@ public class MainActivity extends Activity {
|
|||||||
float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(),
|
float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(),
|
||||||
System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(),
|
System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(),
|
||||||
estado.optBoolean("ocupado"), estado.optBoolean("dormindo"));
|
estado.optBoolean("ocupado"), estado.optBoolean("dormindo"));
|
||||||
|
if (estado.optBoolean("apagada")) nivel = 0f; // tela preta + brilho mínimo
|
||||||
WindowManager.LayoutParams attr = getWindow().getAttributes();
|
WindowManager.LayoutParams attr = getWindow().getAttributes();
|
||||||
if (Math.abs(attr.screenBrightness - nivel) > 0.005f) {
|
if (Math.abs(attr.screenBrightness - nivel) > 0.005f) {
|
||||||
attr.screenBrightness = nivel;
|
attr.screenBrightness = nivel;
|
||||||
@@ -1012,6 +1107,14 @@ public class MainActivity extends Activity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private final class PonteRTC {
|
private final class PonteRTC {
|
||||||
|
/** Foto do vídeo ao vivo (data:image/jpeg;base64,...) para o histórico. */
|
||||||
|
@JavascriptInterface public void foto(final String dataUrl) {
|
||||||
|
try {
|
||||||
|
int virgula = dataUrl == null ? -1 : dataUrl.indexOf(',');
|
||||||
|
if (virgula < 0 || dataUrl.length() > 4_000_000) return;
|
||||||
|
remoto.fotoRTC(android.util.Base64.decode(dataUrl.substring(virgula + 1), android.util.Base64.DEFAULT));
|
||||||
|
} catch (Exception e) { Log.w("CarinhaPC", "foto do video falhou: " + e); }
|
||||||
|
}
|
||||||
@JavascriptInterface public void rastrear(final String json) {
|
@JavascriptInterface public void rastrear(final String json) {
|
||||||
if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return;
|
if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def main():
|
|||||||
def run(*command):
|
def run(*command):
|
||||||
subprocess.run([str(x) for x in command], check=True)
|
subprocess.run([str(x) for x in command], check=True)
|
||||||
|
|
||||||
for name in ('carinha.html', 'camera.html'):
|
for name in ('carinha.html', 'camera.html', 'rosto-motor.js', 'voz-robo.js'):
|
||||||
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
|
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
|
||||||
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
|
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
|
||||||
tmp = Path(folder)
|
tmp = Path(folder)
|
||||||
|
|||||||
@@ -1 +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();})();
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>API Gemini & Conexão · Carinha</title>
|
|
||||||
<link rel="stylesheet" href="/dashboard.css">
|
|
||||||
<script src="/dashboard.js" defer></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
|
|
||||||
<div>
|
|
||||||
<h1>API Gemini & Conexão IA</h1>
|
|
||||||
<p style="margin:4px 0 0">Configure o modelo do Google Gemini que dá inteligência ao diálogo e cria novas expressões.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="badge" id="apiBadge">Verificando…</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="split" style="margin-top:16px">
|
|
||||||
<!-- Formulário do Gemini -->
|
|
||||||
<section class="card">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
|
||||||
<h2>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
|
||||||
Google Gemini Cloud
|
|
||||||
</h2>
|
|
||||||
<span class="badge" style="font-size:11px">Seguro · api-privada.json</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="apiForm">
|
|
||||||
<!-- Model Presets -->
|
|
||||||
<label for="apiModelSelect">Modelos recomendados:</label>
|
|
||||||
<select id="apiModelSelect" style="margin-bottom:12px">
|
|
||||||
<option value="gemini-2.5-flash">gemini-2.5-flash · Rápido, expressivo & moderno (Recomendado)</option>
|
|
||||||
<option value="gemini-1.5-flash">gemini-1.5-flash · Alta velocidade e respostas curtas</option>
|
|
||||||
<option value="gemini-2.0-flash">gemini-2.0-flash · Nova arquitetura multimodal</option>
|
|
||||||
<option value="gemini-1.5-pro">gemini-1.5-pro · Raciocínio profundo e criatividade</option>
|
|
||||||
<option value="custom">Outro (digitar manualmente abaixo)</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="apiModel">Identificador do Modelo</label>
|
|
||||||
<input id="apiModel" required maxlength="100" placeholder="gemini-flash-lite-latest">
|
|
||||||
|
|
||||||
<label for="apiKey">Chave de API Gemini (Google AI Studio)</label>
|
|
||||||
<input id="apiKey" type="password" autocomplete="new-password" maxlength="250" placeholder="Insira nova chave ou deixe vazio para manter a atual">
|
|
||||||
|
|
||||||
<p style="font-size:12.5px;color:var(--fraco);margin-top:-6px">
|
|
||||||
🔒 Por segurança, o campo de chave nunca exibe o valor salvo no servidor. Ele é mantido exclusivamente no arquivo privado local do Orange Pi.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="actions" style="margin-top:20px">
|
|
||||||
<button class="primary" id="apiSave" type="submit">💾 Salvar Configuração</button>
|
|
||||||
<button type="button" id="apiTest">⚡ Testar Conexão Agora</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p id="apiState" role="status" aria-live="polite" style="margin-top:16px;font-size:13.5px;color:var(--ciano);min-height:24px"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Instruções e Informações do Sistema -->
|
|
||||||
<section class="card" style="display:flex;flex-direction:column;justify-content:space-between">
|
|
||||||
<div>
|
|
||||||
<h2>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
|
|
||||||
Arquitetura Híbrida de IA
|
|
||||||
</h2>
|
|
||||||
<p>O Carinha combina processamento em nuvem com inteligência embarcada no robô:</p>
|
|
||||||
|
|
||||||
<div style="display:flex;flex-direction:column;gap:12px;margin:16px 0">
|
|
||||||
<div style="background:rgba(12,22,36,0.6);border:1px solid var(--borda);border-radius:var(--raio-md);padding:12px">
|
|
||||||
<strong style="color:var(--ciano);display:block;margin-bottom:4px">☁️ Google Gemini (Nuvem)</strong>
|
|
||||||
<small style="color:var(--texto-medio);font-size:13px">Gera as respostas de conversação natural, recupera lembranças e desenha novas figurinhas SVG em tempo real.</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background:rgba(12,22,36,0.6);border:1px solid var(--borda);border-radius:var(--raio-md);padding:12px">
|
|
||||||
<strong style="color:#34d399;display:block;margin-bottom:4px">📱 Qwen / Local (Galaxy S10)</strong>
|
|
||||||
<small style="color:var(--texto-medio);font-size:13px">Disponível no app do celular para quando estiver offline, garantindo que o Carinha continue reagindo aos sensores.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="font-size:13px">Você pode alternar entre os motores a qualquer momento na tela <a href="/conversa">Conversar</a>.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details style="margin-top:16px">
|
|
||||||
<summary>Informações do Servidor Local</summary>
|
|
||||||
<p id="apiServer" style="font-family:'JetBrains Mono',monospace;font-size:12px;color:var(--ciano);margin-top:8px"></p>
|
|
||||||
<p style="font-size:12px;color:var(--fraco)">Orange Pi Zero 3 operando na rede interna.</p>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/api-painel.js" defer></script>
|
|
||||||
<script>
|
|
||||||
// Sincroniza o select de modelos com o input de modelo
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const sel = document.getElementById('apiModelSelect');
|
|
||||||
const input = document.getElementById('apiModel');
|
|
||||||
if (sel && input) {
|
|
||||||
sel.onchange = () => {
|
|
||||||
if (sel.value !== 'custom') {
|
|
||||||
input.value = sel.value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.addEventListener('input', () => {
|
|
||||||
let matched = false;
|
|
||||||
for (let opt of sel.options) {
|
|
||||||
if (opt.value === input.value) { opt.selected = true; matched = true; break; }
|
|
||||||
}
|
|
||||||
if (!matched) sel.value = 'custom';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+3
-8
@@ -2,6 +2,7 @@
|
|||||||
import json,os,re,threading,time
|
import json,os,re,threading,time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import gemini
|
import gemini
|
||||||
|
from comum import gravar_atomico, ler_json
|
||||||
|
|
||||||
class ConfigAPI:
|
class ConfigAPI:
|
||||||
def __init__(self,path):
|
def __init__(self,path):
|
||||||
@@ -25,9 +26,7 @@ class ConfigAPI:
|
|||||||
key=d.get('chave') or os.environ.get('GEMINI_API_KEY','')
|
key=d.get('chave') or os.environ.get('GEMINI_API_KEY','')
|
||||||
saved=dict(modelo=d['modelo'])
|
saved=dict(modelo=d['modelo'])
|
||||||
if key:saved['chave']=key
|
if key:saved['chave']=key
|
||||||
tmp=self.path.with_suffix('.tmp');fd=os.open(str(tmp),os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
|
gravar_atomico(self.path,saved,privado=True)
|
||||||
with os.fdopen(fd,'w',encoding='utf-8') as f:json.dump(saved,f)
|
|
||||||
os.chmod(tmp,0o600);tmp.replace(self.path);os.chmod(self.path,0o600)
|
|
||||||
gemini.MODELO=d['modelo']
|
gemini.MODELO=d['modelo']
|
||||||
if key:os.environ['GEMINI_API_KEY']=key
|
if key:os.environ['GEMINI_API_KEY']=key
|
||||||
self.ultimo_teste=None
|
self.ultimo_teste=None
|
||||||
@@ -47,11 +46,7 @@ class ConfigAPI:
|
|||||||
try:
|
try:
|
||||||
if method=='GET' and route.endswith('/status'):result=self.status()
|
if method=='GET' and route.endswith('/status'):result=self.status()
|
||||||
elif method=='POST':
|
elif method=='POST':
|
||||||
origin=h.headers.get('Origin')
|
d=ler_json(h,2048)
|
||||||
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
|
|
||||||
size=int(h.headers.get('Content-Length',0))
|
|
||||||
if not 0<size<=2048 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 2048 bytes')
|
|
||||||
d=json.loads(h.rfile.read(size))
|
|
||||||
if route.endswith('/salvar'):result=self.salvar(d)
|
if route.endswith('/salvar'):result=self.salvar(d)
|
||||||
elif route.endswith('/testar'):result=self.testar()
|
elif route.endswith('/testar'):result=self.testar()
|
||||||
else:raise ValueError('Rota inválida')
|
else:raise ValueError('Rota inválida')
|
||||||
|
|||||||
-248
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
})();
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
const calibrationPanel=document.createElement('section');
|
|
||||||
calibrationPanel.style.cssText='border:1px solid #ddd;border-radius:14px;padding:16px;margin:16px 0';
|
|
||||||
calibrationPanel.innerHTML='<h3 style="font-size:16px;margin:0 0 12px">Testar fim de curso · passos de 100 µs</h3><p class="notice">Observe o motor e recue ao atingir o limite. Não há detecção automática. Canais 5 e 6 acompanham juntos. Cada teste dura até 2 segundos.</p><label>Canal<select id="calChannel" style="max-width:220px">'+Array.from({length:8},(_,i)=>`<option value="${i}">Canal ${i}${i===5||i===6?' (par 5/6)':''}</option>`).join('')+'</select></label><p><output id="calPulse" style="font-size:24px">1500 µs</output></p><label><input id="calEnable" type="checkbox"> Habilitar testes de calibração</label><div style="display:flex;flex-wrap:wrap;gap:8px;margin:14px 0"><button id="calDown">−100 µs</button><button id="calTest">Testar posição</button><button id="calUp">+100 µs</button><button id="calStop">Parar sinais</button></div><div style="display:flex;flex-wrap:wrap;gap:8px"><button id="calMin">Marcar mínimo</button><button id="calCenter">Marcar centro</button><button id="calMax">Marcar máximo</button></div><p id="calMessage" role="status" class="notice">Escolha um canal. Marcar limites não envia movimento.</p>';
|
|
||||||
document.querySelector('details').insertBefore(calibrationPanel,document.getElementById('calibration'));
|
|
||||||
calibrationPanel.querySelectorAll('button').forEach(b=>b.style.cssText='font-size:13px;padding:11px 16px');
|
|
||||||
let calibrationPulse=1500, calibrationBusy=false;
|
|
||||||
const el=id=>document.getElementById(id);
|
|
||||||
function calButtons(){for(const id of ['calDown','calUp','calTest'])el(id).disabled=calibrationBusy||!el('calEnable').checked;}
|
|
||||||
async function calPost(route,data){const r=await fetch('/braco/'+route,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const d=await r.json();if(!r.ok)throw Error(d.erro);return d;}
|
|
||||||
async function testCalibration(delta){if(calibrationBusy||!el('calEnable').checked)return;calibrationBusy=true;calButtons();try{const next=calibrationPulse+delta;if(next<500||next>2500)throw Error('Faixa absoluta: 500–2500 µs');await BracoUI.stop();const result=await calPost('calibrar',{canal:Number(el('calChannel').value),pulso:next});calibrationPulse=next;el('calPulse').textContent=next+' µs';el('calMessage').textContent='Teste enviado: '+JSON.stringify(result.pulsos)+'. Sinal desliga em 2 s.';}catch(e){el('calMessage').textContent=e.message;}finally{calibrationBusy=false;calButtons();}}
|
|
||||||
el('calEnable').onchange=()=>{calButtons();if(!el('calEnable').checked)BracoUI.stop().catch(e=>el('calMessage').textContent=e.message);};
|
|
||||||
el('calChannel').onchange=()=>{el('calEnable').checked=false;calibrationPulse=BracoUI.calibration()?.[Number(el('calChannel').value)].centro??1500;el('calPulse').textContent=calibrationPulse+' µs';calButtons();BracoUI.stop().catch(e=>el('calMessage').textContent=e.message);};
|
|
||||||
el('calDown').onclick=()=>testCalibration(-100);el('calUp').onclick=()=>testCalibration(100);el('calTest').onclick=()=>testCalibration(0);
|
|
||||||
el('calStop').onclick=()=>{el('calEnable').checked=false;calButtons();BracoUI.stop().catch(e=>el('calMessage').textContent=e.message);};
|
|
||||||
for(const [id,key] of [['calMin','minimo'],['calCenter','centro'],['calMax','maximo']])el(id).onclick=async()=>{if(calibrationBusy)return;try{el('calEnable').checked=false;calButtons();await BracoUI.stop();BracoUI.markCalibration(Number(el('calChannel').value),key,calibrationPulse);el('calMessage').textContent='Marcado '+key+' = '+calibrationPulse+' µs. Clique Salvar calibração abaixo.';}catch(e){el('calMessage').textContent=e.message;}};
|
|
||||||
calButtons();
|
|
||||||
@@ -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);
|
|
||||||
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
/* ==========================================================================
|
|
||||||
BRAÇO ROBÓTICO · WORKSPACE & CINEMÁTICA
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 small {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0;
|
|
||||||
color: var(--fraco);
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 160px 220px minmax(280px, 1fr) 150px;
|
|
||||||
gap: 20px;
|
|
||||||
margin-top: 24px;
|
|
||||||
align-items: center;
|
|
||||||
background: rgba(10, 18, 30, 0.7);
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: var(--raio-lg);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
box-shadow: var(--sombra-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.side {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side button {
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: 13.5px;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arm {
|
|
||||||
width: 100%;
|
|
||||||
height: 480px;
|
|
||||||
overflow: visible;
|
|
||||||
filter: drop-shadow(0 12px 20px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 16px rgba(92, 214, 255, 0.15));
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--ciano);
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
background: rgba(92, 214, 255, 0.1);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid rgba(92, 214, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sliders {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) 72px;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: center;
|
|
||||||
background: rgba(16, 28, 44, 0.5);
|
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: var(--raio-sm);
|
|
||||||
border: 1px solid rgba(56, 82, 110, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row label {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--texto-medio);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row output {
|
|
||||||
background: rgba(28, 48, 70, 0.8);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
border-radius: var(--raio-sm);
|
|
||||||
padding: 8px;
|
|
||||||
text-align: center;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--ciano);
|
|
||||||
box-shadow: 0 0 10px rgba(92, 214, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
#console {
|
|
||||||
background: rgba(5, 10, 18, 0.9);
|
|
||||||
color: #a5c7e5;
|
|
||||||
padding: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
border-radius: var(--raio-md);
|
|
||||||
min-height: 64px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
#enableLabel {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin: 18px 0 10px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: rgba(20, 36, 56, 0.6);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
border-radius: var(--raio-md);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--texto);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calibration {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calibration article {
|
|
||||||
padding: 16px;
|
|
||||||
background: rgba(14, 24, 38, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calibration label {
|
|
||||||
display: block;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#importFile {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notice {
|
|
||||||
max-width: 100ch;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
#poses {
|
|
||||||
font-size: 13px;
|
|
||||||
background: rgba(12, 20, 32, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
#movimentos {
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#movimentos button {
|
|
||||||
padding: 10px 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
border-radius: var(--raio-sm);
|
|
||||||
background: rgba(20, 38, 60, 0.7);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
}
|
|
||||||
|
|
||||||
#movimentos button:hover {
|
|
||||||
border-color: var(--ciano);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.workspace {
|
|
||||||
grid-template-columns: 140px 180px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
.right {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
header {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.workspace {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
.arm {
|
|
||||||
height: 280px;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
.sliders {
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
.side {
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.calibration {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-158
@@ -1,158 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Braço Robótico · Carinha</title>
|
|
||||||
<meta name="description" content="Controle os ângulos, crie coreografias articuladas e coordene 8 canais de movimento do braço robótico.">
|
|
||||||
<link rel="stylesheet" href="/dashboard.css">
|
|
||||||
<link rel="stylesheet" href="/braco.css">
|
|
||||||
<script src="/dashboard.js" defer></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<!-- Cabeçalho -->
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
|
|
||||||
<div>
|
|
||||||
<h1>Braço Robótico</h1>
|
|
||||||
<p style="margin:4px 0 0">Posturas e movimentos coordenados para os 8 canais de servo.</p>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:10px;align-items:center">
|
|
||||||
<span id="connection" class="connection">Verificando conexão…</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Aviso de Segurança -->
|
|
||||||
<div class="card" style="margin-bottom:18px;border-color:rgba(245,158,11,0.35);background:rgba(45,30,6,0.5)">
|
|
||||||
<div style="display:flex;align-items:center;gap:12px">
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--amarelo)" stroke-width="2" style="flex-shrink:0"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
|
||||||
<div>
|
|
||||||
<p style="margin:0;color:var(--texto-medio);font-weight:600;font-size:14px">Atenção ao usar o braço</p>
|
|
||||||
<p style="margin:2px 0 0;font-size:13px">Ângulos são comandos nominais, sem medição do eixo. Canais 5 e 6 movem juntos e invertidos. Comece com o braço apoiado e limites calibrados. Ao parar ou perder a conexão, o sinal desliga e o braço pode perder sustentação.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:12px">
|
|
||||||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;margin:0">
|
|
||||||
<input id="enable" type="checkbox" disabled>
|
|
||||||
<span style="font-size:14px;font-weight:600">Ativar movimento das articulações — canais 0 a 7</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Workspace Principal: SVG + Sliders + Controles -->
|
|
||||||
<div class="workspace">
|
|
||||||
|
|
||||||
<!-- Painel Esquerdo: Posturas Salvas -->
|
|
||||||
<aside class="side">
|
|
||||||
<div class="card" style="padding:18px">
|
|
||||||
<h2 style="font-size:16px;margin-bottom:14px">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
|
|
||||||
Posturas Salvas
|
|
||||||
</h2>
|
|
||||||
<span id="count" class="counter" style="display:block;margin-bottom:12px">0 posturas</span>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:8px">
|
|
||||||
<button id="savePose" style="width:100%">💾 Salvar postura atual</button>
|
|
||||||
<select id="poses" aria-label="Postura salva" style="margin:0">
|
|
||||||
<option>Sem posturas</option>
|
|
||||||
</select>
|
|
||||||
<button id="loadPose" disabled style="width:100%">📥 Carregar postura</button>
|
|
||||||
<button id="deletePose" disabled style="width:100%;background:rgba(60,10,18,0.5);border-color:rgba(244,63,94,0.3)">🗑️ Excluir postura</button>
|
|
||||||
<hr style="border:none;border-top:1px solid var(--borda);margin:4px 0">
|
|
||||||
<button id="play" disabled style="width:100%" class="primary">▶️ Reproduzir sequência</button>
|
|
||||||
<button id="stop" class="danger" style="width:100%">⛔ Parar sinais</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Diagrama SVG do Braço -->
|
|
||||||
<svg class="arm" viewBox="0 0 240 530" role="img" aria-label="Ilustração detalhada do braço robótico">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="shell"><stop stop-color="#17191b"/><stop offset=".25" stop-color="#54585b"/><stop offset=".48" stop-color="#303437"/><stop offset=".8" stop-color="#151719"/><stop offset="1" stop-color="#3b3f41"/></linearGradient>
|
|
||||||
<linearGradient id="edge" x2="0" y2="1"><stop stop-color="#707579"/><stop offset=".18" stop-color="#34383b"/><stop offset=".8" stop-color="#191c1e"/><stop offset="1" stop-color="#464c50"/></linearGradient>
|
|
||||||
<radialGradient id="hub"><stop stop-color="#646b70"/><stop offset=".4" stop-color="#30363a"/><stop offset=".85" stop-color="#171a1c"/><stop offset="1" stop-color="#5c6267"/></radialGradient>
|
|
||||||
<g id="bolt"><circle r="3.2" fill="#777e83" stroke="#111" stroke-width="1"/><path d="m-1.5-1.5 3 3m0-3-3 3" stroke="#222" stroke-width=".7"/></g>
|
|
||||||
</defs>
|
|
||||||
<g stroke="#151719" stroke-width="2.5">
|
|
||||||
<path d="M25 512 46 490 73 467h100l18 23 29 22Z" fill="url(#shell)"/><path d="M74 478v-33q0-15 19-22h50q26 6 27 22v33q-47 16-96 0Z" fill="url(#edge)"/>
|
|
||||||
<rect x="87" y="343" width="70" height="103" rx="23" fill="url(#shell)"/><rect x="96" y="356" width="51" height="76" rx="13" fill="#202426"/>
|
|
||||||
<rect x="79" y="306" width="87" height="67" rx="24" fill="url(#edge)"/><circle cx="122" cy="339" r="24" fill="url(#hub)"/><circle cx="122" cy="339" r="10" fill="#30383d"/><circle cx="122" cy="339" r="3" fill="#949ba0"/>
|
|
||||||
<rect x="87" y="213" width="69" height="106" rx="20" fill="url(#shell)"/><path d="M97 224v74M146 224v74" stroke="#686e72" stroke-width="1.2"/>
|
|
||||||
<rect x="80" y="179" width="85" height="64" rx="20" fill="url(#edge)"/><circle cx="122" cy="211" r="24" fill="url(#hub)"/><circle cx="122" cy="211" r="8" fill="#5b6368"/>
|
|
||||||
<rect x="94" y="130" width="57" height="67" rx="17" fill="url(#shell)"/><rect x="77" y="96" width="91" height="59" rx="21" fill="url(#edge)"/><circle cx="122" cy="125" r="23" fill="url(#hub)"/><circle cx="122" cy="125" r="7" fill="#656c71"/>
|
|
||||||
<path d="M50 48h63q12 0 14-13h28q18 0 19 21v37q-3 13-18 14H65q-15 0-15-13Z" fill="url(#shell)"/><path d="M71 99h83q11-1 12-12V55" fill="none" stroke="#73797e" stroke-width="3"/><rect x="43" y="44" width="16" height="64" rx="4" fill="url(#edge)"/>
|
|
||||||
<path d="M45 57 13 61 2 72l43-6Z M45 92 13 88 2 77l43 6Z" fill="url(#edge)"/><path d="M50 46h7v59" stroke="#92989d" stroke-width="1"/>
|
|
||||||
</g>
|
|
||||||
<g><use href="#bolt" x="88" y="192"/><use href="#bolt" x="158" y="192"/><use href="#bolt" x="88" y="234"/><use href="#bolt" x="158" y="234"/><use href="#bolt" x="90" y="316"/><use href="#bolt" x="155" y="316"/><use href="#bolt" x="90" y="362"/><use href="#bolt" x="155" y="362"/><use href="#bolt" x="88" y="110"/><use href="#bolt" x="157" y="110"/><use href="#bolt" x="100" y="449"/><use href="#bolt" x="144" y="449"/></g>
|
|
||||||
<g fill="none" stroke="#2f3335" stroke-width="2" opacity=".8"><path d="M174 63q25 0 33 16t24 18M164 123q30 0 44 24t25 9M165 188q30 0 43 27t25 10M164 239q24 0 42 28t28 10M160 309q25 0 49 20t24 3M168 364q24 0 40 10t25 8M171 456q20-52 38-41t24 21"/></g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- Coluna de Sliders + Console -->
|
|
||||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:16px">
|
|
||||||
<div class="card" style="padding:20px">
|
|
||||||
<h2 style="font-size:16px;margin-bottom:14px">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
|
|
||||||
Controle de Canais
|
|
||||||
</h2>
|
|
||||||
<div id="sliders" class="sliders"></div>
|
|
||||||
<div style="display:flex;gap:16px;align-items:center;margin-top:18px;padding-top:14px;border-top:1px solid var(--borda)">
|
|
||||||
<div style="flex:1">
|
|
||||||
<label for="speed">Velocidade nominal (graus/s)</label>
|
|
||||||
<input id="speed" type="range" min="5" max="90" value="25">
|
|
||||||
</div>
|
|
||||||
<output id="speedValue" style="font-size:22px;font-weight:700;color:var(--ciano);font-family:'JetBrains Mono',monospace;min-width:36px">25</output>
|
|
||||||
</div>
|
|
||||||
<div id="console" role="status" aria-live="polite" style="margin-top:14px;padding:10px 14px;background:rgba(5,9,16,0.6);border:1px solid var(--borda);border-radius:var(--raio-sm);font-family:'JetBrains Mono',monospace;font-size:12.5px;color:var(--ciano)">Carregando configurações…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Painel Direito: Ferramentas -->
|
|
||||||
<aside class="side right">
|
|
||||||
<div class="card" style="padding:18px">
|
|
||||||
<h2 style="font-size:16px;margin-bottom:14px">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9z"/></svg>
|
|
||||||
Ferramentas
|
|
||||||
</h2>
|
|
||||||
<div style="display:flex;flex-direction:column;gap:8px">
|
|
||||||
<button id="export" style="width:100%">📤 Exportar JSON</button>
|
|
||||||
<button id="import" style="width:100%">📥 Importar JSON</button>
|
|
||||||
<button id="reset" style="width:100%">🎯 Centralizar na tela</button>
|
|
||||||
<input id="importFile" type="file" accept=".json,application/json">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Calibração de Limites -->
|
|
||||||
<details style="margin-top:24px">
|
|
||||||
<summary>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
|
||||||
Calibrar Limites por Canal
|
|
||||||
</summary>
|
|
||||||
<div class="card" style="margin-top:14px">
|
|
||||||
<p style="margin:0 0 14px">O projeto original usa 500–2500 µs para 0–180°. Isso não confirma o fim de curso do seu servo. Amplie gradualmente os limites com o eixo livre. Os limites desta aba são independentes dos testes individuais.</p>
|
|
||||||
<div id="calibration" class="calibration"></div>
|
|
||||||
<button id="saveCalibration" style="margin-top:16px">💾 Salvar calibração</button>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- Movimentos Coordenados -->
|
|
||||||
<section style="margin-top:24px" class="card">
|
|
||||||
<h2 style="font-size:18px;margin-bottom:8px">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
|
||||||
Movimentos Coordenados
|
|
||||||
</h2>
|
|
||||||
<p style="margin:0 0 14px">Prévia é uma simulação dos comandos, sem sensores de posição. Executar envia a sequência aos oito canais dentro dos limites salvos.</p>
|
|
||||||
<div id="movimentos" style="display:flex;gap:12px;flex-wrap:wrap"></div>
|
|
||||||
<div style="margin-top:16px;padding-top:14px;border-top:1px solid var(--borda);display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px">
|
|
||||||
<p id="animationStatus" style="margin:0;font-size:13px;color:var(--fraco)">Representação esquemática · posição nominal</p>
|
|
||||||
<button id="stopPreview" style="font-size:13px">⛔ Parar prévia e sinais</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<script src="/braco-calibracao.js" defer></script>
|
|
||||||
<script src="/braco.js"></script>
|
|
||||||
<script src="/movimentos-core.js"></script>
|
|
||||||
<script src="/braco-movimentos.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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.');}};
|
|
||||||
@@ -3,11 +3,25 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from comum import Conflito, gravar_atomico, ler_json
|
||||||
from servos import integer
|
from servos import integer
|
||||||
|
|
||||||
NAMES=['Garra','Punho 1','Punho 2','Braço superior','Braço inferior','Antebraço','Base']
|
NAMES=['Garra','Punho 1','Punho 2','Braço superior','Braço inferior','Antebraço','Base']
|
||||||
CHANNELS=[0,1,2,3,4,5,7]
|
CHANNELS=[0,1,2,3,4,5,7]
|
||||||
|
LEASE_PADRAO=2000 # ms; o painel novo manda prazos curtos e renova enquanto o botão está pressionado
|
||||||
|
PASSO=.02 # 50 Hz de interpolação no servidor
|
||||||
|
|
||||||
|
def velocidade_valida(v):
|
||||||
|
if v is None: return None
|
||||||
|
if isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) or not 5<=v<=360:
|
||||||
|
raise ValueError('Velocidade deve ficar entre 5 e 360 graus/s')
|
||||||
|
return float(v)
|
||||||
|
|
||||||
|
def prazo_valido(ms):
|
||||||
|
if ms is None: return LEASE_PADRAO
|
||||||
|
return integer(ms,100,2000)
|
||||||
|
|
||||||
class Braco:
|
class Braco:
|
||||||
def __init__(self,path,servos):
|
def __init__(self,path,servos):
|
||||||
@@ -15,6 +29,8 @@ class Braco:
|
|||||||
self.config=dict(calibracao=[dict(canal=i,minimo=1400,maximo=1600) for i in range(8)],posturas=[])
|
self.config=dict(calibracao=[dict(canal=i,minimo=1400,maximo=1600) for i in range(8)],posturas=[])
|
||||||
try: self.config=self.validate(json.loads(self.path.read_text(encoding='utf-8')))
|
try: self.config=self.validate(json.loads(self.path.read_text(encoding='utf-8')))
|
||||||
except (OSError,ValueError,TypeError): pass
|
except (OSError,ValueError,TypeError): pass
|
||||||
|
self.atual=None; self.alvo=None; self.velocidade=None; self.lease=0
|
||||||
|
threading.Thread(target=self.loop,daemon=True).start()
|
||||||
def validate_pose(self,pose,calibration):
|
def validate_pose(self,pose,calibration):
|
||||||
if not isinstance(pose,list) or len(pose)!=7: raise ValueError('Esperava sete ângulos')
|
if not isinstance(pose,list) or len(pose)!=7: raise ValueError('Esperava sete ângulos')
|
||||||
pulses={}
|
pulses={}
|
||||||
@@ -53,27 +69,70 @@ class Braco:
|
|||||||
with self.servos.lock:
|
with self.servos.lock:
|
||||||
result=copy.deepcopy(self.config)
|
result=copy.deepcopy(self.config)
|
||||||
result.update(disponivel=self.servos.hw is not None,erro=self.servos.error,
|
result.update(disponivel=self.servos.hw is not None,erro=self.servos.error,
|
||||||
|
dono=self.servos.dono,travado=self.servos.travado,atual=self.atual,
|
||||||
|
movendo=self.alvo is not None,
|
||||||
articulacoes=[dict(nome=name,canal=ch) for name,ch in zip(NAMES,CHANNELS)])
|
articulacoes=[dict(nome=name,canal=ch) for name,ch in zip(NAMES,CHANNELS)])
|
||||||
return result
|
return result
|
||||||
def save(self,data):
|
def save(self,data):
|
||||||
clean=self.validate(data)
|
clean=self.validate(data)
|
||||||
with self.servos.lock:
|
with self.servos.lock:
|
||||||
if self.servos.active: raise ValueError('Pare os motores antes de salvar')
|
if self.servos.active: raise ValueError('Pare os motores antes de salvar')
|
||||||
tmp=self.path.with_suffix('.tmp'); tmp.write_text(json.dumps(clean,ensure_ascii=False,indent=2),encoding='utf-8'); tmp.replace(self.path)
|
gravar_atomico(self.path,clean)
|
||||||
self.config=clean
|
self.config=clean
|
||||||
return self.status()
|
return self.status()
|
||||||
def move(self,pose):
|
def escrever(self,pulses,prazo):
|
||||||
|
try:
|
||||||
|
for channel,pulse in pulses.items():
|
||||||
|
self.servos.hw.pulse(channel,pulse)
|
||||||
|
self.servos.ligar([(channel,prazo) for channel in pulses])
|
||||||
|
except OSError:
|
||||||
|
self.alvo=None; self.servos.stop()
|
||||||
|
raise
|
||||||
|
def move(self,pose,velocidade=None,ttl_ms=None):
|
||||||
|
"""Sem velocidade: vai direto (compatível). Com velocidade: o servidor interpola até o alvo.
|
||||||
|
Em ambos os casos os motores desligam sozinhos quando o prazo (ttl_ms) vence sem renovação."""
|
||||||
|
velocidade=velocidade_valida(velocidade); ttl=prazo_valido(ttl_ms)
|
||||||
with self.servos.lock:
|
with self.servos.lock:
|
||||||
pulses=self.validate_pose(pose,self.config['calibracao'])
|
pulses=self.validate_pose(pose,self.config['calibracao'])
|
||||||
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
|
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
|
||||||
try:
|
self.servos.reservar('braco')
|
||||||
for channel,pulse in pulses.items():
|
prazo=time.monotonic()+ttl/1000
|
||||||
self.servos.hw.pulse(channel,pulse)
|
if velocidade is None or self.atual is None:
|
||||||
self.servos.active[channel]=time.monotonic()+2
|
self.alvo=None
|
||||||
except OSError:
|
self.escrever(pulses,prazo)
|
||||||
self.servos.stop()
|
self.atual=[float(a) for a in pose]
|
||||||
raise
|
return dict(ok=True,pulsos=pulses,movendo=False)
|
||||||
return dict(ok=True,pulsos=pulses)
|
alvo=[float(a) for a in pose]
|
||||||
|
if all(abs(a-t)<.01 for a,t in zip(self.atual,alvo)):
|
||||||
|
# Já no alvo: só renova o prazo (e reenergiza canais que tenham desligado).
|
||||||
|
self.alvo=None
|
||||||
|
self.escrever(pulses,prazo)
|
||||||
|
return dict(ok=True,pulsos=pulses,movendo=False)
|
||||||
|
self.alvo=alvo; self.velocidade=velocidade; self.lease=prazo
|
||||||
|
for channel in list(self.servos.active): self.servos.active[channel]=prazo
|
||||||
|
return dict(ok=True,pulsos=pulses,movendo=True)
|
||||||
|
def cancelar(self):
|
||||||
|
with self.servos.lock: self.alvo=None
|
||||||
|
def passo(self,agora,dt):
|
||||||
|
with self.servos.lock:
|
||||||
|
if self.alvo is None: return
|
||||||
|
if agora>=self.lease or self.servos.travado or (self.servos.dono!='braco' and self.servos.active):
|
||||||
|
self.alvo=None; return
|
||||||
|
delta=self.velocidade*dt
|
||||||
|
nova=[a+max(-delta,min(delta,t-a)) for a,t in zip(self.atual,self.alvo)]
|
||||||
|
try: pulses=self.validate_pose(nova,self.config['calibracao'])
|
||||||
|
except ValueError:
|
||||||
|
self.alvo=None; return
|
||||||
|
self.escrever(pulses,self.lease)
|
||||||
|
self.atual=nova
|
||||||
|
if nova==self.alvo: self.alvo=None
|
||||||
|
def loop(self):
|
||||||
|
anterior=time.monotonic()
|
||||||
|
while not self.servos.closed.wait(PASSO):
|
||||||
|
agora=time.monotonic()
|
||||||
|
try: self.passo(agora,agora-anterior)
|
||||||
|
except OSError as e: self.servos.error=str(e)
|
||||||
|
anterior=agora
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def angle_pulse(angle,center):
|
def angle_pulse(angle,center):
|
||||||
return round(500+angle*(center-500)/90 if angle<=90 else center+(angle-90)*(2500-center)/90)
|
return round(500+angle*(center-500)/90 if angle<=90 else center+(angle-90)*(2500-center)/90)
|
||||||
@@ -86,9 +145,10 @@ class Braco:
|
|||||||
outputs[other]=integer(center_sum-pulse,500,2500)
|
outputs[other]=integer(center_sum-pulse,500,2500)
|
||||||
with self.servos.lock:
|
with self.servos.lock:
|
||||||
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
|
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
|
||||||
|
self.servos.reservar('braco'); self.alvo=None; self.atual=None
|
||||||
for ch,us in outputs.items():
|
for ch,us in outputs.items():
|
||||||
self.servos.hw.pulse(ch,us)
|
self.servos.hw.pulse(ch,us)
|
||||||
self.servos.active[ch]=time.monotonic()+2
|
self.servos.ligar([(ch,time.monotonic()+2) for ch in outputs])
|
||||||
return dict(ok=True,pulsos=outputs)
|
return dict(ok=True,pulsos=outputs)
|
||||||
def rotas(self,h,method):
|
def rotas(self,h,method):
|
||||||
route=h.path.split('?',1)[0]
|
route=h.path.split('?',1)[0]
|
||||||
@@ -96,21 +156,16 @@ class Braco:
|
|||||||
try:
|
try:
|
||||||
if method=='GET' and route=='/braco/status': result=self.status()
|
if method=='GET' and route=='/braco/status': result=self.status()
|
||||||
elif method=='POST':
|
elif method=='POST':
|
||||||
origin=h.headers.get('Origin')
|
data=ler_json(h,65536)
|
||||||
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')): raise ValueError('Origem inválida')
|
|
||||||
if h.headers.get('Content-Type','').split(';')[0]!='application/json': raise ValueError('Use JSON')
|
|
||||||
size=int(h.headers.get('Content-Length','0'))
|
|
||||||
if not 0<size<=65536: raise ValueError('Tamanho inválido')
|
|
||||||
data=json.loads(h.rfile.read(size))
|
|
||||||
if not isinstance(data,dict): raise ValueError('Objeto obrigatório')
|
|
||||||
if route=='/braco/config': result=self.save(data)
|
if route=='/braco/config': result=self.save(data)
|
||||||
elif route=='/braco/mover': result=self.move(data.get('angulos'))
|
elif route=='/braco/mover': result=self.move(data.get('angulos'),data.get('velocidade'),data.get('ttl_ms'))
|
||||||
elif route=='/braco/calibrar': result=self.calibrate(data.get('canal'),data.get('pulso'))
|
elif route=='/braco/calibrar': result=self.calibrate(data.get('canal'),data.get('pulso'))
|
||||||
elif route=='/braco/parar': result=self.servos.stop()
|
elif route=='/braco/parar': self.cancelar(); result=self.servos.stop()
|
||||||
else: raise ValueError('Rota inválida')
|
else: raise ValueError('Rota inválida')
|
||||||
else:
|
else:
|
||||||
h.enviar(json.dumps(dict(erro='Método inválido')),codigo=405); return True
|
h.enviar(json.dumps(dict(erro='Método inválido')),codigo=405); return True
|
||||||
h.enviar(json.dumps(result,ensure_ascii=False))
|
h.enviar(json.dumps(result,ensure_ascii=False))
|
||||||
|
except Conflito as e: h.enviar(json.dumps(dict(erro=str(e)),ensure_ascii=False),codigo=409)
|
||||||
except (ValueError,TypeError) as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=400)
|
except (ValueError,TypeError) as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=400)
|
||||||
except OSError as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=503)
|
except OSError as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=503)
|
||||||
return True
|
return True
|
||||||
|
|||||||
+555
-51
@@ -5,7 +5,13 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<style>
|
<style>
|
||||||
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}
|
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}
|
||||||
svg{display:block;width:100%;height:100%}
|
svg{display:block;width:100%;height:100%;position:relative;z-index:2}
|
||||||
|
/* atividade (momentos dele): a tela se divide. Deitado, o rosto vai para a esquerda e o bonequinho
|
||||||
|
aparece à direita; em pé, o rosto sobe e ele aparece embaixo. */
|
||||||
|
#tela,#rostoMotor{transition:transform .8s cubic-bezier(.2,.8,.2,1);transform-origin:50% 50%}
|
||||||
|
body.atividade #tela,body.atividade #rostoMotor{transform:translateX(-23%) scale(.74)}
|
||||||
|
@media (orientation:portrait){body.atividade #tela,body.atividade #rostoMotor{transform:translateY(-20%) scale(.8)}}
|
||||||
|
body.atividade #tela>rect{opacity:0} /* o fundo preto do SVG não pode tapar o bonequinho */
|
||||||
.corpo{fill-rule:evenodd}
|
.corpo{fill-rule:evenodd}
|
||||||
.traco{fill:none;stroke-linecap:round}
|
.traco{fill:none;stroke-linecap:round}
|
||||||
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
|
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
|
||||||
@@ -25,7 +31,28 @@
|
|||||||
<path id="sobrancelha2" class="traco" style="display:none"/>
|
<path id="sobrancelha2" class="traco" style="display:none"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div id="fps"></div>
|
<div id="fps"></div>
|
||||||
|
<script src="rosto-motor.js"></script>
|
||||||
|
<script src="voz-robo.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
|
||||||
|
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
|
||||||
|
(function () {
|
||||||
|
const token = new URLSearchParams(location.search).get('t');
|
||||||
|
window.carinhaToken = token || '';
|
||||||
|
if (!token || !window.fetch) return;
|
||||||
|
const original = window.fetch.bind(window);
|
||||||
|
window.fetch = (recurso, opcoes = {}) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(typeof recurso === 'string' ? recurso : recurso.url, location.href);
|
||||||
|
if (url.origin === location.origin) {
|
||||||
|
const cabecalhos = new Headers(opcoes.headers || {});
|
||||||
|
cabecalhos.set('Authorization', 'Bearer ' + token);
|
||||||
|
opcoes = Object.assign({}, opcoes, { headers: cabecalhos });
|
||||||
|
}
|
||||||
|
} catch (e) { /* URL estranha: segue sem token */ }
|
||||||
|
return original(recurso, opcoes);
|
||||||
|
};
|
||||||
|
})();
|
||||||
"use strict";
|
"use strict";
|
||||||
/* -------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------
|
||||||
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
|
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
|
||||||
@@ -71,12 +98,33 @@ addEventListener("resize", ajustar);
|
|||||||
|
|
||||||
const PRETO = [6, 8, 14];
|
const PRETO = [6, 8, 14];
|
||||||
const CIANO = [92, 214, 255];
|
const CIANO = [92, 214, 255];
|
||||||
const EXTRAS = { boca: true, sobrancelhas: false };
|
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
|
||||||
|
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
|
||||||
|
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
|
||||||
|
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
|
||||||
|
function cuidarDaTela() {
|
||||||
|
const agora = performance.now();
|
||||||
|
if (!r.dormindo) SONO.desde = 0;
|
||||||
|
else if (!SONO.desde) SONO.desde = agora;
|
||||||
|
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
|
||||||
|
if (apagar !== SONO.apagada) {
|
||||||
|
SONO.apagada = apagar;
|
||||||
|
window.carinhaTelaApagada = apagar;
|
||||||
|
document.body.style.visibility = apagar ? "hidden" : "";
|
||||||
|
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInterval(cuidarDaTela, 1000);
|
||||||
const CIANO_E = [150, 232, 255];
|
const CIANO_E = [150, 232, 255];
|
||||||
const VERM = [255, 78, 78];
|
const VERM = [255, 78, 78];
|
||||||
const ROSA = [255, 112, 176];
|
const ROSA = [255, 112, 176];
|
||||||
const AMARELO = [255, 218, 96];
|
const AMARELO = [255, 218, 96];
|
||||||
const AZUL = [130, 170, 255];
|
const AZUL = [130, 170, 255];
|
||||||
|
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
|
||||||
|
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
|
||||||
|
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
|
||||||
|
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
|
||||||
|
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
|
||||||
const VERDE = [120, 255, 160];
|
const VERDE = [120, 255, 160];
|
||||||
const BRANCO = [245, 250, 255];
|
const BRANCO = [245, 250, 255];
|
||||||
|
|
||||||
@@ -84,10 +132,17 @@ const BRANCO = [245, 250, 255];
|
|||||||
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
|
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
|
||||||
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
|
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
|
||||||
const ROSTOS = {
|
const ROSTOS = {
|
||||||
|
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
|
||||||
|
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
|
||||||
|
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
|
||||||
|
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
|
||||||
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
|
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
|
||||||
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
|
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
|
||||||
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
|
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
|
||||||
};
|
};
|
||||||
|
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
|
||||||
|
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
|
||||||
|
let FORMATO = "padrao";
|
||||||
let ROSTO_NOME = "carinha";
|
let ROSTO_NOME = "carinha";
|
||||||
let ROSTO = ROSTOS[ROSTO_NOME];
|
let ROSTO = ROSTOS[ROSTO_NOME];
|
||||||
|
|
||||||
@@ -122,8 +177,18 @@ function caminhoLinha(x0, y0, x1, y1) {
|
|||||||
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
||||||
}
|
}
|
||||||
/* formas da boca (o humor escolhe qual) */
|
/* formas da boca (o humor escolhe qual) */
|
||||||
|
function deslocamentoBoca() {
|
||||||
|
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
|
||||||
|
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
|
||||||
|
}
|
||||||
|
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
|
||||||
|
function metadeOlho(esc) {
|
||||||
|
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
|
||||||
|
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
|
||||||
|
return m * RY * esc;
|
||||||
|
}
|
||||||
function caminhoBoca(tipo) {
|
function caminhoBoca(tipo) {
|
||||||
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2;
|
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
|
||||||
if (tipo === "aberta") { // surpresa: um "O"
|
if (tipo === "aberta") { // surpresa: um "O"
|
||||||
const rx = l * 0.17, ry = l * 0.22;
|
const rx = l * 0.17, ry = l * 0.22;
|
||||||
return caminhoElipse(cx, cy, rx, ry);
|
return caminhoElipse(cx, cy, rx, ry);
|
||||||
@@ -144,8 +209,25 @@ function caminhoBoca(tipo) {
|
|||||||
return "M " + (cx - meia) + "," + (cy - l * 0.10) +
|
return "M " + (cx - meia) + "," + (cy - l * 0.10) +
|
||||||
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
|
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
|
||||||
}
|
}
|
||||||
return "M " + (cx - l * 0.30) + "," + cy + // repouso: leve
|
// repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
|
||||||
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0";
|
const f = FEICAO.atual, w = l * 0.30, mx = cx + f.dx * l * 0.10;
|
||||||
|
const yE = cy - Math.max(0, f.lado) * l * 0.12, yD = cy - Math.max(0, -f.lado) * l * 0.12;
|
||||||
|
const curva = l * (0.18 + f.curva * 0.35 - ZANGA * 0.3);
|
||||||
|
let d = "M " + (mx - w) + "," + yE + " Q " + mx + "," + (cy + curva - f.abre * l * 0.12) + " " + (mx + w) + "," + yD;
|
||||||
|
if (f.abre > 0.04) d += " Q " + mx + "," + (cy + curva + f.abre * l * 0.30) + " " + (mx - w) + "," + yE;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sobrancelha i (0 = esquerda): humor do gesto + microexpressão + desce um pouco ao piscar. */
|
||||||
|
function caminhoSobrancelha(i, esc) {
|
||||||
|
const c = i === 0 ? E1 : E2, lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, f = FEICAO.atual;
|
||||||
|
const ab = i === 0 ? r.ab : (r.ab2 === null ? r.ab : r.ab2);
|
||||||
|
const piscando = 1 - Math.min(1, Math.max(0, ab));
|
||||||
|
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC - (f.sob[i] - piscando * 3) * ESC;
|
||||||
|
const base = r.forma === 'bravo' ? 18 : r.forma === 'triste' ? -14 : ZANGA * 14; // zanga franze a testa
|
||||||
|
const inc = (base + f.inc[i]) * lado * ESC;
|
||||||
|
const ele = r.esc > 1.1 ? -15 * ESC : 0;
|
||||||
|
return 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ desenho
|
// ------------------------------------------------------------------ desenho
|
||||||
@@ -197,6 +279,13 @@ function olho(indice, cx, cy, rx, ry, ab, forma, cor, lado, lx, ly) {
|
|||||||
if(p.desenho!==desenho){if(p.figurinha)p.figurinha.remove();p.figurinha=window.carinhaConstruirSVG(desenho);p.desenho=desenho;if(p.figurinha)document.getElementById('o'+(indice+1)).append(p.figurinha);}
|
if(p.desenho!==desenho){if(p.figurinha)p.figurinha.remove();p.figurinha=window.carinhaConstruirSVG(desenho);p.desenho=desenho;if(p.figurinha)document.getElementById('o'+(indice+1)).append(p.figurinha);}
|
||||||
if(p.figurinha){p.figurinha.style.display='';p.figurinha.setAttribute('x',cx-rx*.65);p.figurinha.setAttribute('y',cy-ry*.65);p.figurinha.setAttribute('width',rx*1.3);p.figurinha.setAttribute('height',ry*1.3);}
|
if(p.figurinha){p.figurinha.style.display='';p.figurinha.setAttribute('x',cx-rx*.65);p.figurinha.setAttribute('y',cy-ry*.65);p.figurinha.setAttribute('width',rx*1.3);p.figurinha.setAttribute('height',ry*1.3);}
|
||||||
}
|
}
|
||||||
|
if (espiralAtiva()) {
|
||||||
|
p.traco.setAttribute("d", caminhoEspiral(cx, cy, Math.min(rx, ry) * 0.95, lado));
|
||||||
|
p.traco.setAttribute("stroke-width", rx * 0.17);
|
||||||
|
p.traco.setAttribute("stroke", rgb(cor));
|
||||||
|
p.traco.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (icone) {
|
if (icone) {
|
||||||
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
|
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
|
||||||
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
|
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
|
||||||
@@ -264,7 +353,7 @@ function sequencias() {
|
|||||||
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
|
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
|
||||||
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
|
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
|
||||||
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
|
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
|
||||||
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09],
|
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
|
||||||
[{ dy: -10 }, 0.5],
|
[{ dy: -10 }, 0.5],
|
||||||
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
|
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
|
||||||
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
|
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
|
||||||
@@ -273,13 +362,37 @@ function sequencias() {
|
|||||||
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
|
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
|
||||||
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
|
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
|
||||||
[{ forma: "aberto", cor: CIANO }, 0.3]];
|
[{ forma: "aberto", cor: CIANO }, 0.3]];
|
||||||
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
||||||
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
||||||
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
||||||
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
|
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
|
||||||
|
// Gestos com mãos: o motor desenha as mãos com a mesma duração (rosto-motor.js, DUR_MAOS).
|
||||||
|
const esconder = [[{ forma: "aberto", esc: 0.95, dy: 4 }, 0.3], [{}, 1.6], [{ forma: "feliz", boca: "sorriso", esc: 1.05, dy: 0 }, 0.3], [{ forma: "aberto", esc: 1.0 }, 0.4]];
|
||||||
|
const espiar = [[{ esc: 0.96 }, 0.4], [{}, 0.6], [{ dx: -14, forma: "aberto", ab: 1.08 }, 0.25], [{}, 0.9], [{ dx: 0, ab: 1.0 }, 0.35], [{ forma: "feliz", boca: "sorriso" }, 0.4], [{ forma: "aberto" }, 0.3]];
|
||||||
|
const acenar = [[{ forma: "feliz", boca: "sorriso", esc: 1.05 }, 0.3], [{}, 1.7], [{ forma: "aberto", esc: 1.0 }, 0.4]];
|
||||||
|
const pensar = [[{ dy: -10, dx: 14, boca: "neutra" }, 0.4], [{}, 2.0], [{ dy: 0, dx: 0 }, 0.4]];
|
||||||
|
const musica = [[{ forma: "feliz", boca: "sorriso", dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25],
|
||||||
|
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
|
||||||
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
|
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
|
||||||
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
|
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
|
||||||
return { piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
// Mais expressões: risada, bocejo, confusão, tontura, timidez e comemoração.
|
||||||
|
const pula = (n, alto, baixo, d) => Array.from({ length: n }, (_, i) => [{ dy: i % 2 ? baixo : alto }, d]);
|
||||||
|
const rir = [[{ forma: "feliz", boca: "aberta", esc: 1.08, dy: -4 }, 0.12], ...pula(8, -6, 2, 0.1),
|
||||||
|
[{ forma: "feliz", boca: "sorriso", dy: 0 }, 0.25], [{ forma: "aberto", boca: "neutra", esc: 1.0 }, 0.3]];
|
||||||
|
const bocejar = [[{ ab: 0.5, boca: "aberta", dy: -4, esc: 1.05 }, 0.5], [{ ab: 0.25, dy: -5 }, 1.0],
|
||||||
|
[{ ab: 0.0, boca: "neutra", dy: 2, esc: 1.0 }, 0.25], [{ ab: 1.0, dy: 0 }, 0.35]];
|
||||||
|
const confuso = [[{ ab2: 0.55, dx: -8, esc: 0.98 }, 0.25], [{ dx: 8 }, 0.45], [{ dx: -6 }, 0.4], [{}, 0.4],
|
||||||
|
[{ ab2: 1.0, dx: 0, esc: 1.0 }, 0.3]];
|
||||||
|
const balanca = Array.from({ length: 8 }, (_, i) => [{ dx: i % 2 ? -5 : 5, dy: i % 2 ? 2 : -2 }, 0.2]);
|
||||||
|
const tonto = [[{ esc: 1.05, boca: "aberta" }, 0.15], ...balanca, [{ dx: 0, dy: 0, esc: 1.0, boca: "neutra" }, 0.35]];
|
||||||
|
const timido = [[{ forma: "feliz", boca: "sorriso", dx: -14, dy: 9, esc: 0.9, cor: ROSA }, 0.35], [{}, 1.2],
|
||||||
|
[{ dx: 0, dy: 0, esc: 1.0, forma: "aberto", cor: CIANO }, 0.4]];
|
||||||
|
const comemorar = [[{ forma: "feliz", boca: "sorriso", cor: AMARELO, dy: -10, esc: 1.15 }, 0.15], [{ dy: 2, cor: ROSA }, 0.15],
|
||||||
|
[{ dy: -10, cor: VERDE }, 0.15], [{ dy: 2, cor: CIANO }, 0.15], [{ dy: -10, cor: AMARELO }, 0.15],
|
||||||
|
[{ dy: 0, cor: CIANO }, 0.2], [{ forma: "aberto", esc: 1.0 }, 0.3]];
|
||||||
|
return { rir, bocejar, confuso, tonto, timido, comemorar,
|
||||||
|
esconder, espiar, acenar, pensar, musica,
|
||||||
|
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
||||||
bravo, coracao, triste, piscadinha, alegre };
|
bravo, coracao, triste, piscadinha, alegre };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,12 +405,92 @@ const r = {
|
|||||||
const SEQS = sequencias();
|
const SEQS = sequencias();
|
||||||
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
|
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
|
||||||
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
|
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
|
||||||
|
/* Modo vida (servidor): o humor do bichinho escolhe os gestos soltos entre um ato e outro. */
|
||||||
|
const LIVRES_HUMOR = {
|
||||||
|
feliz: ["feliz", "alegre", "piscadinha", "coracao", "olhar_esq", "olhar_dir"],
|
||||||
|
tranquilo: ["olhar_esq", "olhar_dir", "feliz", "piscadinha", "olhar_baixo", "timido"],
|
||||||
|
bravo: ["bravo", "olhar_baixo", "olhar_esq", "olhar_dir", "confuso"],
|
||||||
|
entediado: ["olhar_esq", "olhar_dir", "olhar_baixo", "pensar", "bocejar", "confuso"],
|
||||||
|
carente: ["olhar_baixo", "triste", "olhar_esq", "olhar_dir", "timido"],
|
||||||
|
triste: ["triste", "olhar_baixo", "olhar_baixo"],
|
||||||
|
cansado: ["olhar_baixo", "piscar2", "bocejar"],
|
||||||
|
};
|
||||||
|
let HUMOR = "normal";
|
||||||
|
/* Modo vida: zanga (0 a 1) deixa o rosto emburrado aos poucos quando a alegria cai; sono profundo =
|
||||||
|
dormiu de cansaço, e aí som, movimento e câmera não acordam (como na madrugada). */
|
||||||
|
let ZANGA = 0;
|
||||||
|
let SONO_PROFUNDO = false;
|
||||||
|
|
||||||
|
/* Microexpressões acordado: de vez em quando a boca e as sobrancelhas mexem sozinhas (um canto
|
||||||
|
da boca sobe, uma sobrancelha arqueia, um suspiro) e voltam devagar ao neutro. O humor do modo
|
||||||
|
vida escolhe quais. Valores em "pixels de projeto" (multiplicados por ESC no desenho). */
|
||||||
|
function feicaoNeutra() { return { curva: 0, lado: 0, abre: 0, dx: 0, sob: [0, 0], inc: [0, 0] }; }
|
||||||
|
const FEICAO = { atual: feicaoNeutra(), alvo: feicaoNeutra(), prox: 0, volta: 0, t: 0 };
|
||||||
|
const ao = () => (Math.random() < 0.5 ? 1 : -1);
|
||||||
|
const MICRO = {
|
||||||
|
arquear: () => { const f = feicaoNeutra(), i = Math.random() < 0.5 ? 0 : 1; f.sob[i] = 12; f.inc[i] = -4; return f; },
|
||||||
|
interesse: () => Object.assign(feicaoNeutra(), { sob: [8, 8], abre: 0.35 }),
|
||||||
|
meioSorriso: () => Object.assign(feicaoNeutra(), { lado: ao(), curva: 0.1 }),
|
||||||
|
sorrisinho: () => Object.assign(feicaoNeutra(), { curva: 0.35, sob: [4, 4] }),
|
||||||
|
ladinho: () => { const d = ao(); return Object.assign(feicaoNeutra(), { dx: d, lado: d * 0.4, curva: -0.08 }); },
|
||||||
|
pensativo: () => Object.assign(feicaoNeutra(), { inc: [8, 8], sob: [-3, -3], dx: ao() * 0.7, curva: -0.15 }),
|
||||||
|
preocupado: () => Object.assign(feicaoNeutra(), { inc: [-9, -9], sob: [4, 4], curva: -0.3 }),
|
||||||
|
suspiro: () => Object.assign(feicaoNeutra(), { sob: [5, 5], abre: 0.6, curva: -0.05 }),
|
||||||
|
};
|
||||||
|
const MICRO_HUMOR = {
|
||||||
|
feliz: ["sorrisinho", "sorrisinho", "meioSorriso", "arquear", "interesse"],
|
||||||
|
tranquilo: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse"],
|
||||||
|
entediado: ["ladinho", "ladinho", "pensativo", "suspiro", "arquear"],
|
||||||
|
carente: ["preocupado", "preocupado", "suspiro", "meioSorriso"],
|
||||||
|
triste: ["preocupado", "suspiro", "pensativo"],
|
||||||
|
cansado: ["suspiro", "suspiro", "ladinho"],
|
||||||
|
bravo: ["pensativo", "pensativo", "ladinho", "suspiro"],
|
||||||
|
normal: ["meioSorriso", "arquear", "sorrisinho", "ladinho", "interesse", "pensativo", "suspiro"],
|
||||||
|
};
|
||||||
|
function atualizarFeicao(s) {
|
||||||
|
const dt = Math.min(0.1, Math.max(0, s - (FEICAO.t || s)));
|
||||||
|
FEICAO.t = s;
|
||||||
|
// só no rosto "de repouso": gesto com boca própria (sorriso, triste...) ou dormindo = neutro
|
||||||
|
const livre = !r.dormindo && !congelado && r.boca === "neutra";
|
||||||
|
if (!livre) {
|
||||||
|
FEICAO.alvo = feicaoNeutra();
|
||||||
|
FEICAO.prox = Math.max(FEICAO.prox, s + 1.5);
|
||||||
|
} else if (s >= FEICAO.prox) {
|
||||||
|
const lista = MICRO_HUMOR[HUMOR] || MICRO_HUMOR.normal;
|
||||||
|
FEICAO.alvo = MICRO[lista[Math.floor(Math.random() * lista.length)]]();
|
||||||
|
FEICAO.volta = s + 0.9 + Math.random() * 1.6;
|
||||||
|
FEICAO.prox = s + (2.5 + Math.random() * 5) * ({ cansado: 1.6, triste: 1.4, feliz: 0.8 }[HUMOR] || 1);
|
||||||
|
} else if (s >= FEICAO.volta) FEICAO.alvo = feicaoNeutra();
|
||||||
|
const k = 1 - Math.exp(-dt * 6), a = FEICAO.atual, b = FEICAO.alvo;
|
||||||
|
for (const c of ["curva", "lado", "abre", "dx"]) a[c] += (b[c] - a[c]) * k;
|
||||||
|
for (let i = 0; i < 2; i++) { a.sob[i] += (b.sob[i] - a.sob[i]) * k; a.inc[i] += (b.inc[i] - a.inc[i]) * k; }
|
||||||
|
}
|
||||||
|
/* Gestos soltos mais humanos: quase sempre coisas sutis (olhar de lado, piscar devagar, olhar para
|
||||||
|
baixo); de vez em quando um gesto maior do humor, nunca o mesmo duas vezes seguidas. Gestos
|
||||||
|
"para alguém" (acenar, tímido, piscadinha...) só quando tem gente por perto. */
|
||||||
|
const SUTIS = ["olhar_esq", "olhar_dir", "olhar_baixo", "piscar2", "olhar_esq", "olhar_dir"];
|
||||||
|
const PARA_ALGUEM = ["acenar", "coracao", "piscadinha", "timido", "esconder", "espiar", "surpreso"];
|
||||||
|
const RECENTES = [];
|
||||||
|
function alguemOlhando() {
|
||||||
|
const agora = performance.now() / 1000;
|
||||||
|
return (MOV.ativo && agora - MOV.ultimoMov < 30) || (TRACK.alvo && agora - TRACK.visto < 30);
|
||||||
|
}
|
||||||
|
function gestoSolto() {
|
||||||
|
const grande = Math.random() < ({ feliz: 0.3, entediado: 0.25, bravo: 0.3 }[HUMOR] || 0.18);
|
||||||
|
let lista = grande ? (LIVRES_HUMOR[HUMOR] || LIVRES) : SUTIS;
|
||||||
|
if (!alguemOlhando()) lista = lista.filter((g) => !PARA_ALGUEM.includes(g));
|
||||||
|
const opcoes = lista.filter((g) => !RECENTES.includes(g));
|
||||||
|
const g = (opcoes.length ? opcoes : SUTIS)[Math.floor(Math.random() * (opcoes.length || SUTIS.length))];
|
||||||
|
RECENTES.push(g); if (RECENTES.length > 2) RECENTES.shift();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
function proximo() {
|
function proximo() {
|
||||||
if (!r.fila.length) { r.kf = null; return; }
|
if (!r.fila.length) { r.kf = null; return; }
|
||||||
const [alvo, dur] = r.fila[0];
|
const [alvo, dur] = r.fila[0];
|
||||||
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
|
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
|
||||||
r.cor_ini = r.cor.slice();
|
r.cor_ini = r.cor.slice();
|
||||||
|
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
|
||||||
if ("forma" in alvo) r.forma = alvo.forma;
|
if ("forma" in alvo) r.forma = alvo.forma;
|
||||||
if ("forma2" in alvo) r.forma2 = alvo.forma2;
|
if ("forma2" in alvo) r.forma2 = alvo.forma2;
|
||||||
if ("boca" in alvo) r.boca = alvo.boca;
|
if ("boca" in alvo) r.boca = alvo.boca;
|
||||||
@@ -305,7 +498,22 @@ function proximo() {
|
|||||||
r.kf_t = 0;
|
r.kf_t = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ESPIRAL = { ate: 0 }; // tonto: espiral no lugar dos olhos até este instante
|
||||||
|
const espiralAtiva = () => performance.now() / 1000 < ESPIRAL.ate;
|
||||||
|
function caminhoEspiral(cx, cy, R, lado) {
|
||||||
|
const voltas = 2.75, giro = performance.now() / 1000 * 7 * lado;
|
||||||
|
let d = "M " + cx + "," + cy;
|
||||||
|
for (let th = 0.22; th <= voltas * Math.PI * 2; th += 0.22) {
|
||||||
|
const rr = R * th / (voltas * Math.PI * 2), a = th * lado + giro;
|
||||||
|
d += " L " + (cx + Math.cos(a) * rr).toFixed(1) + "," + (cy + Math.sin(a) * rr).toFixed(1);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
function animar(nome) {
|
function animar(nome) {
|
||||||
|
if (nome === "tonto") ESPIRAL.ate = performance.now() / 1000 + 2.1;
|
||||||
|
// mãos (esconder, espiar, acenar, pensar, bocejar) valem também quando o gesto vem sozinho
|
||||||
|
if (SEQS[nome] && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(nome);
|
||||||
if (SEQS[nome]) {
|
if (SEQS[nome]) {
|
||||||
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
|
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
|
||||||
proximo();
|
proximo();
|
||||||
@@ -329,10 +537,40 @@ function passo(dt) { // avanca a fila; true se ha gesto em
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Atividade em andamento (rosto-motor.js): para onde olhar e que cara fazer. Atualizado por quadro. */
|
||||||
|
let ATIV = null;
|
||||||
|
const OLHAR = { x: 0, y: 0, ax: 0, ay: 0, prox: 0 }; // ponto que os olhos estão fitando no repouso
|
||||||
|
function atividade(nome, segundos, receita) {
|
||||||
|
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
|
||||||
|
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
|
||||||
|
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
|
||||||
|
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expressão criada no estúdio (criacao.py): receita já validada no servidor, só números e nomes de
|
||||||
|
peças. Vira uma sequência igual às de sequencias(), com mãos e espiral se a receita pedir. */
|
||||||
|
function corDaReceita(nome) {
|
||||||
|
return { claro: "claro", escuro: "escuro", rosa: ROSA, amarelo: AMARELO, verde: VERDE, vermelho: VERM }[nome] || CIANO;
|
||||||
|
}
|
||||||
|
function tocarExpressaoCriada(texto) {
|
||||||
|
let rc;
|
||||||
|
try { rc = JSON.parse(texto); } catch (e) { return; }
|
||||||
|
if (!rc || !Array.isArray(rc.passos) || !rc.passos.length) return;
|
||||||
|
if (r.dormindo) r.dormindo = false;
|
||||||
|
r.fila = rc.passos.slice(0, 12).map((p) => [{
|
||||||
|
forma: p.forma || "aberto", forma2: null, boca: p.boca || "neutra", cor: corDaReceita(p.cor),
|
||||||
|
dx: +p.dx || 0, dy: +p.dy || 0, esc: +p.esc || 1, ab: typeof p.ab === "number" ? p.ab : 1,
|
||||||
|
}, Math.max(0.05, Math.min(2, +p.duracao || 0.3))]);
|
||||||
|
proximo();
|
||||||
|
const total = r.fila.reduce((a, passo) => a + passo[1], 0);
|
||||||
|
if (rc.espiral) ESPIRAL.ate = performance.now() / 1000 + total;
|
||||||
|
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
|
||||||
|
}
|
||||||
|
|
||||||
function repouso(t) { // pose calma: olha devagar, "respira"
|
function repouso(t) { // pose calma: olha devagar, "respira"
|
||||||
if (r.dormindo) {
|
if (r.dormindo) {
|
||||||
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
||||||
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2));
|
r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
|
||||||
r.ab = 0; r.ab2 = 0;
|
r.ab = 0; r.ab2 = 0;
|
||||||
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
||||||
r.dx = 0;
|
r.dx = 0;
|
||||||
@@ -343,9 +581,28 @@ function repouso(t) { // pose calma: olha devagar, "respira
|
|||||||
r.boca = "neutra";
|
r.boca = "neutra";
|
||||||
r.cor = CIANO.slice();
|
r.cor = CIANO.slice();
|
||||||
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
|
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
|
||||||
// o vaivem de sempre + a direcao de quem se mexeu na frente (se houver)
|
// olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
|
||||||
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
|
// centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
|
||||||
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
|
if (t >= OLHAR.prox || t < OLHAR.prox - 10) {
|
||||||
|
const longe = Math.random() < 0.25;
|
||||||
|
OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
|
||||||
|
OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
|
||||||
|
OLHAR.prox = t + 1.2 + Math.random() * 3;
|
||||||
|
}
|
||||||
|
OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3;
|
||||||
|
OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
|
||||||
|
r.dx = OLHAR.x + 1.0 * Math.sin(t * 0.9) + MOV.olharX;
|
||||||
|
r.dy = OLHAR.y + 0.8 * Math.sin(t * 1.3) + MOV.olharY;
|
||||||
|
if (ATIV) { // fazendo algo: olha para a atividade
|
||||||
|
const v = ATIV.v;
|
||||||
|
r.dx = r.dx * (1 - v) * 0.3 + ATIV.x * v;
|
||||||
|
r.dy = r.dy * (1 - v) * 0.3 + ATIV.y * v;
|
||||||
|
if (ATIV.forma) r.forma = ATIV.forma;
|
||||||
|
if (ATIV.boca) r.boca = ATIV.boca;
|
||||||
|
if (ATIV.ab) r.ab = ATIV.ab;
|
||||||
|
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
|
||||||
|
r.forma = "bravo";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function desenhar() {
|
function desenhar() {
|
||||||
@@ -357,32 +614,61 @@ function desenhar() {
|
|||||||
// desliza na direcao do movimento; parado, volta ao repouso discreto
|
// desliza na direcao do movimento; parado, volta ao repouso discreto
|
||||||
const tAgora = performance.now() / 1000;
|
const tAgora = performance.now() / 1000;
|
||||||
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
|
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
|
||||||
const mirando = MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2;
|
const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV;
|
||||||
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
|
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
|
||||||
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
|
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
|
||||||
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
||||||
for (let i = 0; i < 2; i++) {
|
const comMotor = ROSTO.motor && window.MotorRosto;
|
||||||
|
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
|
||||||
|
if (comMotor) {
|
||||||
|
window.MotorRosto.desenhar({
|
||||||
|
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo, espiral: espiralAtiva(),
|
||||||
|
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
|
||||||
|
carga: window.carinhaBateria || null,
|
||||||
|
iris: EXTRAS.iris,
|
||||||
|
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
|
||||||
|
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
|
||||||
|
parado: !r.kf && !seguindo && !r.dormindo,
|
||||||
|
olhos: olhos.map(([c, abx, fx, lado]) => ({
|
||||||
|
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
|
||||||
|
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} else if (window.MotorRosto) window.MotorRosto.esconder();
|
||||||
|
for (let i = 0; i < 2 && !comMotor; i++) {
|
||||||
const [c, abx, fx, lado] = olhos[i];
|
const [c, abx, fx, lado] = olhos[i];
|
||||||
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
|
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
|
||||||
abx, fx, r.cor, lado, dx, dy * 0.6);
|
abx, fx, r.cor, lado, dx, dy * 0.6);
|
||||||
}
|
}
|
||||||
desenharBoca();
|
const matriz = comMotor && ROSTO.motor === "matriz";
|
||||||
|
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
|
||||||
|
else desenharBoca();
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const brow = document.getElementById('sobrancelha' + (i + 1));
|
const brow = document.getElementById('sobrancelha' + (i + 1));
|
||||||
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
|
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
|
||||||
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
|
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
|
||||||
const c = i === 0 ? E1 : E2;
|
brow.setAttribute('d', caminhoSobrancelha(i, esc));
|
||||||
const lado = i === 0 ? 1 : -1;
|
|
||||||
const largura = RX * esc * 0.85;
|
|
||||||
const y = c[1] - RY * esc - 20 * ESC;
|
|
||||||
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
|
||||||
const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
|
|
||||||
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
|
|
||||||
brow.setAttribute('stroke', rgb(r.cor));
|
brow.setAttribute('stroke', rgb(r.cor));
|
||||||
brow.setAttribute('stroke-width', 7 * ESC);
|
brow.setAttribute('stroke-width', 7 * ESC);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
|
||||||
|
function tracosDoRosto(esc) {
|
||||||
|
const tracos = [];
|
||||||
|
if (EXTRAS.boca) {
|
||||||
|
const forma = r.boca;
|
||||||
|
if (r.dormindo) { // dormindo na Matriz: "– _ –"
|
||||||
|
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
|
||||||
|
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
|
||||||
|
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
|
||||||
|
}
|
||||||
|
if (EXTRAS.sobrancelhas && !r.dormindo) {
|
||||||
|
for (let i = 0; i < 2; i++) tracos.push({ d: caminhoSobrancelha(i, esc), largura: 9 * ESC });
|
||||||
|
}
|
||||||
|
return tracos;
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ controle
|
// ------------------------------------------------------------------ controle
|
||||||
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
||||||
function trocarRosto(nome) {
|
function trocarRosto(nome) {
|
||||||
@@ -411,19 +697,46 @@ function trata(ordem) {
|
|||||||
trocarRosto(ordem.slice(6).trim());
|
trocarRosto(ordem.slice(6).trim());
|
||||||
} else if (ROSTOS[ordem]) {
|
} else if (ROSTOS[ordem]) {
|
||||||
trocarRosto(ordem); // aceita so o nome: "miudo"
|
trocarRosto(ordem); // aceita so o nome: "miudo"
|
||||||
|
} else if (ordem.indexOf("formato ") === 0) {
|
||||||
|
const f = ordem.slice(8).trim();
|
||||||
|
if (FORMATOS.includes(f)) FORMATO = f;
|
||||||
} else if (ordem.indexOf("cor ") === 0) {
|
} else if (ordem.indexOf("cor ") === 0) {
|
||||||
const v = ordem.slice(4).split(",").map(Number);
|
const v = ordem.slice(4).split(",").map(Number);
|
||||||
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
||||||
CIANO.splice(0, 3, ...v); r.cor = v;
|
CIANO.splice(0, 3, ...v); r.cor = v;
|
||||||
}
|
}
|
||||||
|
} else if (ordem.indexOf("humor ") === 0) {
|
||||||
|
HUMOR = ordem.slice(6).trim();
|
||||||
|
} else if (ordem.indexOf("ritmo ") === 0) {
|
||||||
|
PERFIL = PERFIS[ordem.slice(6).trim()] || PERFIS.normal;
|
||||||
|
} else if (ordem.indexOf("expressao_criada ") === 0) {
|
||||||
|
tocarExpressaoCriada(ordem.slice(17));
|
||||||
|
} else if (ordem.indexOf("atividade_criada ") === 0) {
|
||||||
|
try { const rc = JSON.parse(ordem.slice(17)); atividade("receita", +rc.duracao || 25, rc); } catch (e) {}
|
||||||
|
} else if (ordem.indexOf("atividade ") === 0) {
|
||||||
|
const [nome, seg] = ordem.slice(10).trim().split(/\s+/); // "atividade bola 180"
|
||||||
|
atividade(nome, parseFloat(seg) || 0);
|
||||||
|
} else if (ordem.indexOf("zanga ") === 0) {
|
||||||
|
const z = parseFloat(ordem.slice(6));
|
||||||
|
if (!isNaN(z)) ZANGA = Math.max(0, Math.min(1, z));
|
||||||
|
} else if (ordem === "sono profundo" || ordem === "sono leve") {
|
||||||
|
SONO_PROFUNDO = ordem === "sono profundo";
|
||||||
} else if (ordem === "dormir" || ordem === "sono") {
|
} else if (ordem === "dormir" || ordem === "sono") {
|
||||||
r.dormindo = true;
|
r.dormindo = true;
|
||||||
|
atividade(null);
|
||||||
} else if (ordem === "acordar") {
|
} else if (ordem === "acordar") {
|
||||||
r.dormindo = false; animar("surpreso");
|
r.dormindo = false; animar("surpreso");
|
||||||
} else if (ordem === "aleatorio") {
|
} else if (ordem === "aleatorio") {
|
||||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||||
} else if (SEQS[ordem]) {
|
} else if (SEQS[ordem]) {
|
||||||
|
if (window.MotorRosto) {
|
||||||
|
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
||||||
|
}
|
||||||
animar(ordem);
|
animar(ordem);
|
||||||
|
} else if (ordem === "calma") {
|
||||||
|
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
||||||
|
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
|
||||||
|
atividade(null);
|
||||||
} else if (ordem === "parar") {
|
} else if (ordem === "parar") {
|
||||||
try { window.close(); } catch (e) { r.fila = []; }
|
try { window.close(); } catch (e) { r.fila = []; }
|
||||||
}
|
}
|
||||||
@@ -440,7 +753,7 @@ function pose(nome) {
|
|||||||
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
|
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
|
||||||
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
|
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
|
||||||
}
|
}
|
||||||
if ("cor" in alvo) r.cor = alvo.cor.slice();
|
if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
|
||||||
congelado = true;
|
congelado = true;
|
||||||
desenhar();
|
desenhar();
|
||||||
return true;
|
return true;
|
||||||
@@ -460,7 +773,7 @@ window.eilik = {
|
|||||||
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
|
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
|
||||||
// do PC com um curl, igual ao FIFO de antes
|
// do PC com um curl, igual ao FIFO de antes
|
||||||
async function buscarOrdens() {
|
async function buscarOrdens() {
|
||||||
if (new URLSearchParams(location.search).get('espelho') === '1') return;
|
if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
|
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
@@ -479,12 +792,46 @@ async function aplicarAparencia() {
|
|||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const c = await resp.json();
|
const c = await resp.json();
|
||||||
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
|
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
|
||||||
|
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
|
||||||
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
|
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
|
||||||
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
|
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
|
||||||
} catch (e) { /* sem servidor — segue com o padrao */ }
|
} catch (e) { /* sem servidor — segue com o padrao */ }
|
||||||
}
|
}
|
||||||
setTimeout(aplicarAparencia, 200);
|
setTimeout(aplicarAparencia, 200);
|
||||||
if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
|
||||||
|
// do servidor (SSE) por postMessage.
|
||||||
|
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
|
||||||
|
if (MODO_PAINEL) {
|
||||||
|
window.carinhaAtiva = false;
|
||||||
|
addEventListener('message', (e) => {
|
||||||
|
if (e.origin !== location.origin || e.source !== parent || !e.data) return;
|
||||||
|
const m = e.data;
|
||||||
|
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
|
||||||
|
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
|
||||||
|
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
|
||||||
|
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||||
|
});
|
||||||
|
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
||||||
|
|
||||||
|
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
|
||||||
|
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
|
||||||
|
(function () {
|
||||||
|
let inicio = null;
|
||||||
|
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
|
||||||
|
addEventListener('pointerup', (e) => {
|
||||||
|
if (!inicio) return;
|
||||||
|
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
|
||||||
|
inicio = null;
|
||||||
|
if (r.dormindo) { trata('acordar'); cuidar('cutucar'); return; }
|
||||||
|
if (ATIV && !ATIV.saindo) { atividade(null); animar('surpreso'); cuidar('cutucar'); return; } // interrompeu
|
||||||
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); cuidar('carinho'); return; }
|
||||||
|
if (dur < 350 && Math.hypot(dx, dy) < 15) {
|
||||||
|
cuidar('cutucar');
|
||||||
|
animar('surpreso');
|
||||||
|
setTimeout(() => animar('feliz'), 700);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
// teclado (util para testar no PC)
|
// teclado (util para testar no PC)
|
||||||
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
|
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
|
||||||
@@ -525,6 +872,49 @@ const MIC = {
|
|||||||
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos
|
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Acordar com som: precisa de N barulhos em 1 minuto (ajuste "Sons para acordar"). Na madrugada
|
||||||
|
silenciosa só barulho ALTO conta, e movimento/câmera não acordam (alguém se mexendo na cama). */
|
||||||
|
const DESPERTAR = { sons: 3, noite: true, ini: 23, fim: 7 };
|
||||||
|
function sonoRespeitado() { return madrugada() || SONO_PROFUNDO; }
|
||||||
|
function madrugada() {
|
||||||
|
if (!DESPERTAR.noite || DESPERTAR.ini === DESPERTAR.fim) return false;
|
||||||
|
const h = new Date().getHours();
|
||||||
|
return DESPERTAR.ini < DESPERTAR.fim ? h >= DESPERTAR.ini && h < DESPERTAR.fim : h >= DESPERTAR.ini || h < DESPERTAR.fim;
|
||||||
|
}
|
||||||
|
/* Perfil de sensibilidade = ritmo do modo vida (o servidor manda "ritmo calmo|normal|agitado").
|
||||||
|
Calmo precisa de vários estímulos para reagir e limiares mais altos; agitado reage a qualquer
|
||||||
|
som ou movimento. Multiplica os limiares dos Ajustes, sem mudar os Ajustes. */
|
||||||
|
const PERFIS = {
|
||||||
|
calmo: { lim: 1.5, reagir: 3, acordarSom: 2, acordarMov: 4, espera: 2, qualquer: false },
|
||||||
|
normal: { lim: 1.0, reagir: 1, acordarSom: 1, acordarMov: 2, espera: 1, qualquer: false },
|
||||||
|
agitado: { lim: 0.6, reagir: 1, acordarSom: 0.34, acordarMov: 1, espera: 0.5, qualquer: true },
|
||||||
|
};
|
||||||
|
let PERFIL = PERFIS.normal;
|
||||||
|
const limiar = (x) => Math.min(0.95, x * PERFIL.lim);
|
||||||
|
/* Conta o INÍCIO de cada estímulo (o mesmo som/movimento comprido vale um) numa janela de tempo
|
||||||
|
e diz quando chegou a `precisa`. */
|
||||||
|
function novoContador(janela) { return { janela, eventos: [], ultimo: -Infinity, estava: false, visto: -Infinity }; }
|
||||||
|
const CONTA = { somAcordar: novoContador(60), movAcordar: novoContador(30), somReagir: novoContador(20), movReagir: novoContador(20) };
|
||||||
|
function contar(c, ativo, agora, precisa, nome) {
|
||||||
|
// contador parado um tempo (ex.: o de "acordar" enquanto estava acordado): começa do zero
|
||||||
|
if (agora - c.visto > 2) c.estava = false;
|
||||||
|
c.visto = agora;
|
||||||
|
if (ativo && !c.estava && agora - c.ultimo >= 1.0) {
|
||||||
|
c.ultimo = agora;
|
||||||
|
c.eventos = c.eventos.filter((t) => agora - t < c.janela).concat(agora);
|
||||||
|
if (precisa > 1) console.log(nome + " " + c.eventos.length + "/" + precisa + (madrugada() ? " (madrugada)" : ""));
|
||||||
|
}
|
||||||
|
c.estava = ativo;
|
||||||
|
if (c.eventos.length >= precisa && agora - c.eventos[c.eventos.length - 1] < 1) { c.eventos = []; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/* Reação acordado: agitado varia; os outros se assustam. */
|
||||||
|
function reacaoEstimulo() {
|
||||||
|
if (!PERFIL.qualquer) return "surpreso";
|
||||||
|
const l = ["surpreso", "feliz", "alegre", "piscadinha", "surpreso"];
|
||||||
|
return l[Math.floor(Math.random() * l.length)];
|
||||||
|
}
|
||||||
|
|
||||||
function som(nivel) {
|
function som(nivel) {
|
||||||
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
|
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
|
||||||
const agora = performance.now() / 1000;
|
const agora = performance.now() / 1000;
|
||||||
@@ -539,8 +929,8 @@ function som(nivel) {
|
|||||||
if (MIC.piso === null) MIC.piso = n;
|
if (MIC.piso === null) MIC.piso = n;
|
||||||
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
|
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
|
||||||
|
|
||||||
const temSom = n >= MIC.limiarSom; // ruido audivel (absoluto)
|
const temSom = n >= limiar(MIC.limiarSom); // ruido audivel (absoluto)
|
||||||
const alto = n >= MIC.limiarAlto && (n - MIC.piso) >= MIC.ganho;
|
const alto = n >= limiar(MIC.limiarAlto) && (n - MIC.piso) >= limiar(MIC.ganho);
|
||||||
if (temSom) MIC.ultimoSom = agora;
|
if (temSom) MIC.ultimoSom = agora;
|
||||||
|
|
||||||
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
|
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
|
||||||
@@ -549,15 +939,19 @@ function som(nivel) {
|
|||||||
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2));
|
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.dormindo && temSom) { // acordou: tinha barulho
|
if (r.dormindo) {
|
||||||
|
const precisa = Math.max(1, Math.round(DESPERTAR.sons * PERFIL.acordarSom));
|
||||||
|
if (contar(CONTA.somAcordar, sonoRespeitado() ? alto : temSom, agora, precisa, "ouvido: barulho")) { // acordou
|
||||||
r.dormindo = false;
|
r.dormindo = false;
|
||||||
animar("surpreso");
|
animar("surpreso");
|
||||||
MIC.proxSurpresa = agora + MIC.cooldown;
|
MIC.proxSurpresa = agora + MIC.cooldown;
|
||||||
console.log("ouvido: acordou nivel=" + n.toFixed(2));
|
console.log("ouvido: acordou nivel=" + n.toFixed(2));
|
||||||
relatarSom("acordou", n);
|
relatarSom("acordou", n);
|
||||||
} else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) {
|
}
|
||||||
MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino
|
} else if (contar(CONTA.somReagir, PERFIL.qualquer ? temSom : alto, agora, PERFIL.reagir, "ouvido: estimulo") &&
|
||||||
animar("surpreso");
|
agora >= MIC.proxSurpresa) {
|
||||||
|
MIC.proxSurpresa = agora + MIC.cooldown * PERFIL.espera; // susto: barulho alto e repentino
|
||||||
|
animar(reacaoEstimulo());
|
||||||
console.log("ouvido: surpreso nivel=" + n.toFixed(2));
|
console.log("ouvido: surpreso nivel=" + n.toFixed(2));
|
||||||
relatarSom("surpreso", n);
|
relatarSom("surpreso", n);
|
||||||
}
|
}
|
||||||
@@ -570,6 +964,7 @@ function checarSilencio(agora) {
|
|||||||
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
|
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
|
||||||
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
|
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
|
||||||
if (r.dormindo) return;
|
if (r.dormindo) return;
|
||||||
|
if (HUMOR !== "normal") return; // modo vida ligado: quem manda dormir é a energia
|
||||||
// movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
|
// movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
|
||||||
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
|
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
|
||||||
if (agora - atividade >= MIC.segSilencio) {
|
if (agora - atividade >= MIC.segSilencio) {
|
||||||
@@ -583,6 +978,25 @@ function checarSilencio(agora) {
|
|||||||
journal do carinha-web (so quando o microfone esta ativo). */
|
journal do carinha-web (so quando o microfone esta ativo). */
|
||||||
function relatarSom(evento, n) {
|
function relatarSom(evento, n) {
|
||||||
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||||
|
if (evento !== "adormeceu") registrarEvento("som", evento, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toque na tela vira cuidado no modo vida (carinho/cutucada). Só o celular conta. */
|
||||||
|
function cuidar(acao) {
|
||||||
|
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||||
|
try {
|
||||||
|
fetch("/vida/cuidar", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ acao }) }).catch(() => {});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
|
||||||
|
function registrarEvento(tipo, evento, nivel) {
|
||||||
|
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||||
|
try {
|
||||||
|
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------ presenca (movimento)
|
// ------------------------------------------------------ presenca (movimento)
|
||||||
@@ -622,7 +1036,7 @@ function rastrear(t) {
|
|||||||
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
|
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
|
||||||
if(t.atividade===true) {
|
if(t.atividade===true) {
|
||||||
MOV.ultimoMov=agora;
|
MOV.ultimoMov=agora;
|
||||||
if(r.dormindo) { r.dormindo=false;animar('surpreso'); }
|
if(r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) { r.dormindo=false;animar('surpreso'); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function movimento(vel, cx, cy) {
|
function movimento(vel, cx, cy) {
|
||||||
@@ -636,7 +1050,7 @@ function movimento(vel, cx, cy) {
|
|||||||
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
|
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
|
||||||
|
|
||||||
if (TRACK.alvo && agora-TRACK.visto<.35) return;
|
if (TRACK.alvo && agora-TRACK.visto<.35) return;
|
||||||
const mexeu = v >= MOV.limiar;
|
const mexeu = v >= limiar(MOV.limiar);
|
||||||
if (mexeu) MOV.ultimoMov = agora;
|
if (mexeu) MOV.ultimoMov = agora;
|
||||||
|
|
||||||
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
|
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
|
||||||
@@ -651,17 +1065,21 @@ function movimento(vel, cx, cy) {
|
|||||||
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
|
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
const forte = v >= MOV.limiarForte;
|
const forte = v >= limiar(MOV.limiarForte);
|
||||||
if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente
|
if (r.dormindo) {
|
||||||
|
// acordou: algo se mexeu na frente (de dia; calmo precisa de vários movimentos)
|
||||||
|
if (!sonoRespeitado() && contar(CONTA.movAcordar, mexeu, agora, PERFIL.acordarMov, "presenca: movimento")) {
|
||||||
r.dormindo = false;
|
r.dormindo = false;
|
||||||
animar("surpreso");
|
animar("surpreso");
|
||||||
MOV.proxSurpresa = agora + MOV.cooldown;
|
MOV.proxSurpresa = agora + MOV.cooldown;
|
||||||
console.log("presenca: acordou nivel=" + v.toFixed(2));
|
console.log("presenca: acordou nivel=" + v.toFixed(2));
|
||||||
relatarMov("acordou", v);
|
relatarMov("acordou", v);
|
||||||
} else if (forte && !MOV.estavaForte && agora >= MOV.proxSurpresa) {
|
}
|
||||||
|
} else if (contar(CONTA.movReagir, PERFIL.qualquer ? mexeu : forte, agora, PERFIL.reagir, "presenca: estimulo") &&
|
||||||
|
agora >= MOV.proxSurpresa) {
|
||||||
// susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
|
// susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
|
||||||
MOV.proxSurpresa = agora + MOV.cooldown;
|
MOV.proxSurpresa = agora + MOV.cooldown * PERFIL.espera;
|
||||||
animar("surpreso");
|
animar(reacaoEstimulo());
|
||||||
console.log("presenca: surpreso nivel=" + v.toFixed(2));
|
console.log("presenca: surpreso nivel=" + v.toFixed(2));
|
||||||
relatarMov("surpreso", v);
|
relatarMov("surpreso", v);
|
||||||
}
|
}
|
||||||
@@ -671,6 +1089,7 @@ function movimento(vel, cx, cy) {
|
|||||||
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
|
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
|
||||||
function relatarMov(evento, n) {
|
function relatarMov(evento, n) {
|
||||||
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||||
|
registrarEvento("movimento", evento, n);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------- camera oculta (sensor)
|
// ------------------------------------------------- camera oculta (sensor)
|
||||||
@@ -801,7 +1220,15 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
|||||||
|
|
||||||
let configRecebida = null;
|
let configRecebida = null;
|
||||||
function configurarApp(c) {
|
function configurarApp(c) {
|
||||||
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true;
|
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
|
||||||
|
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
|
||||||
|
SONO.apagar = c.apagarTela !== false;
|
||||||
|
if (typeof c.somAcordar === 'number') DESPERTAR.sons = Math.max(1, Math.round(c.somAcordar));
|
||||||
|
DESPERTAR.noite = c.noiteSilenciosa !== false;
|
||||||
|
if (typeof c.noiteInicio === 'number') DESPERTAR.ini = Math.round(c.noiteInicio);
|
||||||
|
if (typeof c.noiteFim === 'number') DESPERTAR.fim = Math.round(c.noiteFim);
|
||||||
|
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
|
||||||
|
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
|
||||||
AUTO = c.auto;
|
AUTO = c.auto;
|
||||||
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
||||||
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
||||||
@@ -818,8 +1245,10 @@ async function sincronizarApp() {
|
|||||||
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
|
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
|
||||||
sincronizando = true;
|
sincronizando = true;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
|
if (!window.carinhaSSE) {
|
||||||
if (resp.ok) configurarApp(await resp.json());
|
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
|
||||||
|
if (resp.ok) configurarApp(await resp.json());
|
||||||
|
}
|
||||||
if (par.get('aparelho') === '1') {
|
if (par.get('aparelho') === '1') {
|
||||||
const agora = performance.now() / 1000;
|
const agora = performance.now() / 1000;
|
||||||
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
|
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||||
@@ -829,7 +1258,10 @@ async function sincronizarApp() {
|
|||||||
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
|
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
|
||||||
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
||||||
brilho: window.carinhaBrilho,
|
brilho: window.carinhaBrilho,
|
||||||
|
tela_apagada: !!window.carinhaTelaApagada,
|
||||||
|
atividade: window.MotorRosto && window.MotorRosto.atividadeAtual ? window.MotorRosto.atividadeAtual() : null,
|
||||||
bateria: window.carinhaBateria,
|
bateria: window.carinhaBateria,
|
||||||
|
aparelho: window.carinhaAparelho,
|
||||||
video: window.carinhaVideo,
|
video: window.carinhaVideo,
|
||||||
}) });
|
}) });
|
||||||
}
|
}
|
||||||
@@ -874,15 +1306,32 @@ const SONS = {
|
|||||||
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
|
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
||||||
|
os sons dos gestos ficam quietos para não embolar. */
|
||||||
|
function falar(texto) {
|
||||||
|
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
|
||||||
|
const c = contextoAudio();
|
||||||
|
if (!c) return;
|
||||||
|
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||||
|
try {
|
||||||
|
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
|
||||||
|
AUDIO.mudoAte = performance.now() + dur * 1000;
|
||||||
|
} catch (e) { /* segue sem voz */ }
|
||||||
|
}
|
||||||
|
|
||||||
function tocarSom(gesto) {
|
function tocarSom(gesto) {
|
||||||
if (!AUDIO.ligado) return false;
|
if (!AUDIO.ligado) return false;
|
||||||
|
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
|
||||||
|
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
||||||
|
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
||||||
const f = SONS[gesto];
|
const f = SONS[gesto];
|
||||||
if (!f) return false;
|
if (!voz && !f) return false;
|
||||||
const c = contextoAudio();
|
const c = contextoAudio();
|
||||||
if (!c) return false;
|
if (!c) return false;
|
||||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||||
try {
|
try {
|
||||||
f(c, c.currentTime + 0.02, AUDIO.volume);
|
if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
|
||||||
|
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
|
||||||
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
|
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
|
||||||
console.log("voz: " + gesto + " ctx=" + c.state);
|
console.log("voz: " + gesto + " ctx=" + c.state);
|
||||||
return true;
|
return true;
|
||||||
@@ -908,6 +1357,11 @@ function laco(t) {
|
|||||||
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
|
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
|
||||||
|
|
||||||
checarSilencio(t / 1000);
|
checarSilencio(t / 1000);
|
||||||
|
atualizarFeicao(t / 1000);
|
||||||
|
|
||||||
|
ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null;
|
||||||
|
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
|
||||||
|
document.body.classList.toggle("atividade", !!ATIV && !ATIV.saindo);
|
||||||
|
|
||||||
const ocupado = congelado ? true : passo(dt);
|
const ocupado = congelado ? true : passo(dt);
|
||||||
if (!ocupado) {
|
if (!ocupado) {
|
||||||
@@ -918,15 +1372,18 @@ function laco(t) {
|
|||||||
} else if (t >= proxPiscar) {
|
} else if (t >= proxPiscar) {
|
||||||
animar("piscar");
|
animar("piscar");
|
||||||
proxPiscar = t + 1600 + Math.random() * 3500;
|
proxPiscar = t + 1600 + Math.random() * 3500;
|
||||||
} else if (AUTO && t >= proxGesto) {
|
} else if (AUTO && t >= proxGesto && !ATIV) {
|
||||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
animar(gestoSolto());
|
||||||
proxGesto = t + 7000 + Math.random() * 8000;
|
// cansado e triste mexem menos; feliz, mais
|
||||||
|
const ritmo = { cansado: 1.8, triste: 1.5, feliz: 0.8 }[HUMOR] || 1;
|
||||||
|
proxGesto = t + (7000 + Math.random() * 8000) * ritmo;
|
||||||
}
|
}
|
||||||
} else if (t >= proxGesto) {
|
} else if (t >= proxGesto) {
|
||||||
proxGesto = t + 7000 + Math.random() * 8000;
|
proxGesto = t + 7000 + Math.random() * 8000;
|
||||||
}
|
}
|
||||||
|
|
||||||
desenhar();
|
desenhar();
|
||||||
|
if (window.MotorRosto && window.MotorRosto.palco) window.MotorRosto.palco({ t: t / 1000, cor: CIANO });
|
||||||
|
|
||||||
contador++;
|
contador++;
|
||||||
if (t - marco >= 1000) {
|
if (t - marco >= 1000) {
|
||||||
@@ -965,11 +1422,13 @@ setTimeout(relatar, 1500);
|
|||||||
if(location.protocol==='file:')return;
|
if(location.protocol==='file:')return;
|
||||||
let seen=null,fetching=false;
|
let seen=null,fetching=false;
|
||||||
async function poll(){
|
async function poll(){
|
||||||
if(fetching||window.carinhaAtiva===false)return;fetching=true;
|
if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;
|
||||||
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
|
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
|
||||||
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
|
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
|
||||||
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
|
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
|
||||||
|
falar(m.texto);
|
||||||
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
|
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
|
||||||
|
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
|
||||||
}catch(e){}finally{fetching=false;}
|
}catch(e){}finally{fetching=false;}
|
||||||
}
|
}
|
||||||
setInterval(poll,1000);poll();
|
setInterval(poll,1000);poll();
|
||||||
@@ -977,11 +1436,56 @@ setTimeout(relatar, 1500);
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
<script src="/reacoes-render.js"></script>
|
<script src="/reacoes-render.js"></script>
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
|
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
|
||||||
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
|
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tempo real: um EventSource em /eventos substitui os pollings de /fila, /app/config,
|
||||||
|
/conversa/status e /treinamento/expressao. Se cair, os pollings voltam sozinhos
|
||||||
|
(window.carinhaSSE=false) até o EventSource reconectar. */
|
||||||
|
(function () {
|
||||||
|
const p = new URLSearchParams(location.search);
|
||||||
|
if (location.protocol === 'file:' || p.get('espelho') === '1' || !window.EventSource) return;
|
||||||
|
const aparelho = p.get('aparelho') === '1';
|
||||||
|
const t = window.carinhaToken;
|
||||||
|
const fonte = new EventSource('/eventos' + (t ? '?t=' + encodeURIComponent(t) : ''));
|
||||||
|
let ultimaConversa = null, ultimaReacao = null, primeira = true;
|
||||||
|
const ouvir = (tipo, fn) => fonte.addEventListener(tipo, (e) => { try { fn(JSON.parse(e.data)); } catch (err) {} });
|
||||||
|
fonte.addEventListener('pronto', () => {
|
||||||
|
// Ordens que chegaram antes da conexão ficam na fila: executa uma vez e passa ao SSE.
|
||||||
|
if (primeira) { primeira = false; buscarOrdens(); }
|
||||||
|
window.carinhaSSE = true;
|
||||||
|
// Foto dos ajustes a cada (re)conexão; depois só mudanças chegam por 'app_config'.
|
||||||
|
fetch('/app/config', { cache: 'no-store' }).then((r) => r.ok && r.json()).then((c) => c && configurarApp(c)).catch(() => {});
|
||||||
|
});
|
||||||
|
fonte.onerror = () => {
|
||||||
|
if (!window.carinhaSSE) return;
|
||||||
|
window.carinhaSSE = false;
|
||||||
|
// Volta ao polling sem repetir o que o SSE já entregou.
|
||||||
|
fetch('fila' + (aparelho ? '?aparelho=1' : ''), { cache: 'no-store' }).catch(() => {});
|
||||||
|
};
|
||||||
|
ouvir('expressao', (d) => trata(d.ordem));
|
||||||
|
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
|
||||||
|
ouvir('app_config', (c) => configurarApp(c));
|
||||||
|
ouvir('conversa', (s) => {
|
||||||
|
if (window.MotorRosto) {
|
||||||
|
if (s.ocupado) window.MotorRosto.faixa('digitando');
|
||||||
|
else window.MotorRosto.faixa(null, { so: 'digitando' });
|
||||||
|
}
|
||||||
|
const m = s.ultima;
|
||||||
|
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
|
||||||
|
ultimaConversa = m.id;
|
||||||
|
falar(m.texto);
|
||||||
|
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
|
||||||
|
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||||
|
});
|
||||||
|
ouvir('reacao', (d) => {
|
||||||
|
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
|
||||||
|
ultimaReacao = d.id;
|
||||||
|
if (window.eilik && window.eilik.receita) window.eilik.receita(d.receita);
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
+38
-15
@@ -5,9 +5,21 @@ from pathlib import Path
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from urllib.request import Request,urlopen
|
from urllib.request import Request,urlopen
|
||||||
|
from comum import gravar_atomico, ler_json
|
||||||
|
|
||||||
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo')
|
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo','acenar','esconder','pensar',
|
||||||
|
'espiar','bocejar','confuso')
|
||||||
EVENTOS=('som','movimento','saudacao','sozinho')
|
EVENTOS=('som','movimento','saudacao','sozinho')
|
||||||
|
# O Qwen pequeno ignora "varie" e repetia 'esconder' sem parar. Cada evento só oferece gestos que
|
||||||
|
# fazem sentido (barulho: susto ou olhar; movimento: olhar e acenar) e os dois últimos ficam de fora.
|
||||||
|
POR_EVENTO={
|
||||||
|
'som':('surpreso','olhar_esq','olhar_dir','pensar','piscar','confuso'),
|
||||||
|
'movimento':('olhar_esq','olhar_dir','acenar','feliz','piscadinha','surpreso','espiar'),
|
||||||
|
'saudacao':('acenar','feliz','coracao','piscadinha'),
|
||||||
|
'sozinho':('pensar','olhar_baixo','olhar_esq','olhar_dir','bocejar','piscar'),
|
||||||
|
}
|
||||||
|
INTERVALO=45 # s mínimos entre duas reações da IA
|
||||||
|
INTERVALO_SOZINHO=180 # s sem nada acontecendo até reagir a "sozinho"
|
||||||
def validar_intencao(data):
|
def validar_intencao(data):
|
||||||
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS:
|
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS:
|
||||||
raise ValueError('Intenção fora da lista de expressões')
|
raise ValueError('Intenção fora da lista de expressões')
|
||||||
@@ -17,18 +29,27 @@ class Cerebro:
|
|||||||
def __init__(self,path,controle,enviar):
|
def __init__(self,path,controle,enviar):
|
||||||
self.path=Path(path);self.controle=controle;self.enviar=enviar
|
self.path=Path(path);self.controle=controle;self.enviar=enviar
|
||||||
self.endpoint=os.environ.get('CARINHA_IA_URL','http://127.0.0.1:8101')
|
self.endpoint=os.environ.get('CARINHA_IA_URL','http://127.0.0.1:8101')
|
||||||
|
# O llama-server do S10 fica na rede: com token, ele exige a mesma chave (--api-key).
|
||||||
|
self.chave=os.environ.get('CARINHA_IA_CHAVE') or os.environ.get('CARINHA_TOKEN','')
|
||||||
self.executor='S10' if '192.168.1.16' in self.endpoint else 'Orange Pi'
|
self.executor='S10' if '192.168.1.16' in self.endpoint else 'Orange Pi'
|
||||||
self.lock=threading.Lock();self.ativo=False;self.pending=None;self.busy=False
|
self.lock=threading.Lock();self.ativo=False;self.pending=None;self.busy=False
|
||||||
self.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
|
self.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
|
||||||
|
self.publicar=lambda tipo,dados:None
|
||||||
|
self.vida_ligada=lambda:False # com o modo vida ligado, ficar sozinho é com ele
|
||||||
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
|
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
|
||||||
except (OSError,ValueError,KeyError,TypeError):pass
|
except (OSError,ValueError,KeyError,TypeError):pass
|
||||||
threading.Thread(target=self.loop,daemon=True).start()
|
threading.Thread(target=self.loop,daemon=True).start()
|
||||||
|
def cabecalhos(self):
|
||||||
|
h={'Content-Type':'application/json'}
|
||||||
|
if self.chave:h['Authorization']='Bearer '+self.chave
|
||||||
|
return h
|
||||||
def config(self,ativo):
|
def config(self,ativo):
|
||||||
if type(ativo) is not bool:raise ValueError('ativo deve ser booleano')
|
if type(ativo) is not bool:raise ValueError('ativo deve ser booleano')
|
||||||
with self.lock:
|
with self.lock:
|
||||||
tmp=self.path.with_suffix('.tmp');tmp.write_text(json.dumps(dict(ativo=ativo)));tmp.replace(self.path)
|
gravar_atomico(self.path,dict(ativo=ativo))
|
||||||
self.ativo=ativo;self.generation+=1;self.pending=None
|
self.ativo=ativo;self.generation+=1;self.pending=None
|
||||||
return self.status()
|
status=self.status();self.publicar('ia',status)
|
||||||
|
return status
|
||||||
def status(self):
|
def status(self):
|
||||||
with self.lock:return dict(ativo=self.ativo,ocupado=self.busy,modelo='Qwen3-0.6B Q4_K_M',executor=self.executor,ultima=self.result,erro=self.error)
|
with self.lock:return dict(ativo=self.ativo,ocupado=self.busy,modelo='Qwen3-0.6B Q4_K_M',executor=self.executor,ultima=self.result,erro=self.error)
|
||||||
def event(self,event):
|
def event(self,event):
|
||||||
@@ -38,13 +59,18 @@ class Cerebro:
|
|||||||
if self.busy or self.pending or self.conversando:raise ValueError('IA processando; aguarde')
|
if self.busy or self.pending or self.conversando:raise ValueError('IA processando; aguarde')
|
||||||
self.pending=event
|
self.pending=event
|
||||||
return dict(ok=True)
|
return dict(ok=True)
|
||||||
|
def opcoes(self,event):
|
||||||
|
recentes=set(self.history[-2:])
|
||||||
|
return [g for g in POR_EVENTO.get(event,GESTOS) if g not in recentes] or list(POR_EVENTO.get(event,GESTOS))
|
||||||
def decide(self,event):
|
def decide(self,event):
|
||||||
payload=dict(model='local',messages=[dict(role='system',content='Você é Carinha, um pequeno robô curioso, gentil e expressivo, inspirado em WALL-E. Escolha uma expressão adequada ao evento. Varie os gestos recentes. Responda somente JSON.'),dict(role='user',content=json.dumps(dict(evento=event,recentes=self.history[-3:]),ensure_ascii=False)+' /no_think')],max_tokens=32,temperature=.7,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='intencao',strict=True,schema=dict(type='object',properties=dict(gesto=dict(type='string',enum=list(GESTOS))),required=['gesto'],additionalProperties=False))))
|
opcoes=self.opcoes(event)
|
||||||
request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers={'Content-Type':'application/json'})
|
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. Responda somente JSON.'),dict(role='user',content=json.dumps(dict(evento=event),ensure_ascii=False)+' /no_think')],max_tokens=32,temperature=.9,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=opcoes)),required=['gesto'],additionalProperties=False))))
|
||||||
|
request=Request(self.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cabecalhos())
|
||||||
start=time.monotonic()
|
start=time.monotonic()
|
||||||
with urlopen(request,timeout=90) as response:data=json.load(response)
|
with urlopen(request,timeout=90) as response:data=json.load(response)
|
||||||
content=data['choices'][0]['message']['content']
|
content=data['choices'][0]['message']['content']
|
||||||
gesto=validar_intencao(json.loads(content))
|
gesto=validar_intencao(json.loads(content))
|
||||||
|
if gesto not in opcoes:raise ValueError('Gesto repetido ou fora do evento')
|
||||||
return dict(evento=event,gesto=gesto,segundos=round(time.monotonic()-start,2),horario=time.time())
|
return dict(evento=event,gesto=gesto,segundos=round(time.monotonic()-start,2),horario=time.time())
|
||||||
def loop(self):
|
def loop(self):
|
||||||
while True:
|
while True:
|
||||||
@@ -56,10 +82,12 @@ class Cerebro:
|
|||||||
with self.controle.lock:
|
with self.controle.lock:
|
||||||
online=time.monotonic()-self.controle.status_at<5
|
online=time.monotonic()-self.controle.status_at<5
|
||||||
state=dict(self.controle.estado)
|
state=dict(self.controle.estado)
|
||||||
if not event and online and not state.get('dormindo') and time.monotonic()-self.last>20:
|
# ocupado com uma atividade (lendo, jogando...): não interrompe com gesto
|
||||||
|
livre=online and not state.get('dormindo') and not state.get('atividade')
|
||||||
|
if not event and livre and time.monotonic()-self.last>INTERVALO:
|
||||||
if state.get('microfone_ativo') and state.get('microfone',0)>.35:event='som'
|
if state.get('microfone_ativo') and state.get('microfone',0)>.35:event='som'
|
||||||
elif state.get('camera_ativa') and state.get('movimento',0)>.05:event='movimento'
|
elif state.get('camera_ativa') and state.get('movimento',0)>.05:event='movimento'
|
||||||
elif time.monotonic()-self.last>60:event='sozinho'
|
elif time.monotonic()-self.last>INTERVALO_SOZINHO and not self.vida_ligada():event='sozinho'
|
||||||
if not event:continue
|
if not event:continue
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.conversando:continue
|
if self.conversando:continue
|
||||||
@@ -70,13 +98,14 @@ class Cerebro:
|
|||||||
if self.ativo and generation==self.generation:
|
if self.ativo and generation==self.generation:
|
||||||
self.result=result;self.error='';self.history.append(result['gesto']);self.history=self.history[-6:]
|
self.result=result;self.error='';self.history.append(result['gesto']);self.history=self.history[-6:]
|
||||||
with self.controle.lock:
|
with self.controle.lock:
|
||||||
if time.monotonic()-self.controle.status_at<5 and not self.controle.estado.get('dormindo'):
|
if time.monotonic()-self.controle.status_at<5 and not self.controle.estado.get('dormindo') and not self.controle.estado.get('atividade'):
|
||||||
self.enviar(result['gesto'])
|
self.enviar(result['gesto'])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with self.lock:self.error=str(e)[:200]
|
with self.lock:self.error=str(e)[:200]
|
||||||
finally:
|
finally:
|
||||||
self.last=time.monotonic()
|
self.last=time.monotonic()
|
||||||
with self.lock:self.busy=False
|
with self.lock:self.busy=False
|
||||||
|
self.publicar('ia',self.status())
|
||||||
def rotas(self,h,method):
|
def rotas(self,h,method):
|
||||||
route=h.path.split('?',1)[0]
|
route=h.path.split('?',1)[0]
|
||||||
if route=='/ia/modelo' and method=='GET':
|
if route=='/ia/modelo' and method=='GET':
|
||||||
@@ -95,13 +124,7 @@ class Cerebro:
|
|||||||
try:
|
try:
|
||||||
if method=='GET' and route=='/ia/status':result=self.status()
|
if method=='GET' and route=='/ia/status':result=self.status()
|
||||||
elif method=='POST':
|
elif method=='POST':
|
||||||
origin=h.headers.get('Origin')
|
data=ler_json(h,2048)
|
||||||
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
|
|
||||||
if h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON')
|
|
||||||
size=int(h.headers.get('Content-Length','0'))
|
|
||||||
if not 0<size<=2048:raise ValueError('Tamanho inválido')
|
|
||||||
data=json.loads(h.rfile.read(size))
|
|
||||||
if not isinstance(data,dict):raise ValueError('Objeto obrigatório')
|
|
||||||
if route=='/ia/config':result=self.config(data.get('ativo'))
|
if route=='/ia/config':result=self.config(data.get('ativo'))
|
||||||
elif route=='/ia/evento':result=self.event(data.get('evento'))
|
elif route=='/ia/evento':result=self.event(data.get('evento'))
|
||||||
else:raise ValueError('Rota inválida')
|
else:raise ValueError('Rota inválida')
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Peças compartilhadas pelos módulos HTTP: leitura de JSON, gravação atômica e token."""
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
class Conflito(Exception):
|
||||||
|
"""Pedido válido, mas o estado atual não permite (vira HTTP 409)."""
|
||||||
|
|
||||||
|
|
||||||
|
def origem_valida(h):
|
||||||
|
origin = h.headers.get('Origin')
|
||||||
|
host = h.headers.get('Host', '')
|
||||||
|
return not origin or origin in ('http://' + host, 'https://' + host)
|
||||||
|
|
||||||
|
|
||||||
|
def ler_json(h, limite):
|
||||||
|
"""Corpo JSON de um POST do painel; ValueError com mensagem legível se inválido."""
|
||||||
|
if not origem_valida(h):
|
||||||
|
raise ValueError('Origem inválida')
|
||||||
|
if h.headers.get('Content-Type', '').split(';')[0] != 'application/json':
|
||||||
|
raise ValueError('Use JSON')
|
||||||
|
tamanho = int(h.headers.get('Content-Length', '0'))
|
||||||
|
if not 0 < tamanho <= limite:
|
||||||
|
raise ValueError(f'Corpo vazio ou maior que {limite} bytes')
|
||||||
|
dados = json.loads(h.rfile.read(tamanho))
|
||||||
|
if not isinstance(dados, dict):
|
||||||
|
raise ValueError('Objeto JSON obrigatório')
|
||||||
|
return dados
|
||||||
|
|
||||||
|
|
||||||
|
def gravar_atomico(caminho, obj, privado=False):
|
||||||
|
caminho = Path(caminho)
|
||||||
|
tmp = caminho.with_suffix('.tmp')
|
||||||
|
texto = json.dumps(obj, ensure_ascii=False, indent=2)
|
||||||
|
if privado:
|
||||||
|
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(texto)
|
||||||
|
else:
|
||||||
|
tmp.write_text(texto, encoding='utf-8')
|
||||||
|
tmp.replace(caminho)
|
||||||
|
if privado:
|
||||||
|
os.chmod(caminho, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def carregar_token(pasta):
|
||||||
|
"""CARINHA_TOKEN do ambiente ou {"token": ...} em seguranca.json; None = sem autenticação."""
|
||||||
|
token = os.environ.get('CARINHA_TOKEN', '').strip()
|
||||||
|
if not token:
|
||||||
|
try:
|
||||||
|
token = str(json.loads((Path(pasta) / 'seguranca.json').read_text(encoding='utf-8')).get('token', '')).strip()
|
||||||
|
except (OSError, ValueError, AttributeError):
|
||||||
|
token = ''
|
||||||
|
if token and len(token) < 8:
|
||||||
|
raise ValueError('CARINHA_TOKEN precisa ter pelo menos 8 caracteres')
|
||||||
|
return token or None
|
||||||
|
|
||||||
|
|
||||||
|
def token_do_pedido(h):
|
||||||
|
auth = h.headers.get('Authorization', '')
|
||||||
|
if auth.lower().startswith('bearer '):
|
||||||
|
return auth[7:].strip()
|
||||||
|
if h.headers.get('X-Carinha-Token'):
|
||||||
|
return h.headers['X-Carinha-Token'].strip()
|
||||||
|
# EventSource e o app Android não mandam cabeçalhos próprios com facilidade.
|
||||||
|
return parse_qs(urlsplit(h.path).query).get('t', [''])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def autorizado(h):
|
||||||
|
esperado = getattr(h.server, 'token', None)
|
||||||
|
if not esperado:
|
||||||
|
return True
|
||||||
|
return hmac.compare_digest(token_do_pedido(h).encode(), esperado.encode())
|
||||||
+63
-77
@@ -1,20 +1,28 @@
|
|||||||
"""Configuração persistente, telemetria e prévia da câmera do celular."""
|
"""Configuração persistente e telemetria do celular; sinalização do vídeo WebRTC."""
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from comum import origem_valida
|
||||||
from sinalizacao import Sinalizacao
|
from sinalizacao import Sinalizacao
|
||||||
|
|
||||||
|
|
||||||
|
def versao():
|
||||||
|
try:
|
||||||
|
return json.loads((Path(__file__).with_name('version.json')).read_text(encoding='utf-8'))['versao']
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return 'desconhecida'
|
||||||
|
|
||||||
PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
|
PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
|
||||||
ouvir=True, reagir=True, somlim=0.35, somganho=0.4,
|
ouvir=True, reagir=True, somlim=0.35, somganho=0.4,
|
||||||
somaltolim=0.55, sompausa=20, somcooldown=5,
|
somaltolim=0.55, sompausa=20, somcooldown=5,
|
||||||
movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
|
movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
|
||||||
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30,
|
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30,
|
||||||
boca=True, sobrancelhas=False, videoRTC=True,
|
boca=True, sobrancelhas=False, iris=False, subirBoca=0, distSobrancelha=16, videoRTC=True,
|
||||||
|
apagarTela=True, apagarTelaMin=5,
|
||||||
|
somAcordar=3, noiteSilenciosa=True, noiteInicio=23, noiteFim=7,
|
||||||
sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
|
sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
|
||||||
cameraLado='user', videoResolucao='auto', videoFPS=0,
|
cameraLado='user', videoResolucao='auto', videoFPS=0,
|
||||||
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
|
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
|
||||||
@@ -24,9 +32,9 @@ ESCOLHAS = dict(cameraLado=('user', 'environment'),
|
|||||||
LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
|
LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
|
||||||
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
|
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
|
||||||
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
|
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
|
||||||
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600))
|
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600),
|
||||||
TOOLS = Path(os.environ.get('CARINHA_TOOLS',
|
subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120),
|
||||||
'C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools'))
|
somAcordar=(1, 20), noiteInicio=(0, 23), noiteFim=(0, 23))
|
||||||
|
|
||||||
|
|
||||||
def validar(dados):
|
def validar(dados):
|
||||||
@@ -48,6 +56,27 @@ def validar(dados):
|
|||||||
return dados
|
return dados
|
||||||
|
|
||||||
|
|
||||||
|
def madrugada(config, agora=None):
|
||||||
|
"""Dentro do horário da madrugada silenciosa (ex.: 23h às 7h, atravessando a meia-noite)?"""
|
||||||
|
if not config.get('noiteSilenciosa', True):
|
||||||
|
return False
|
||||||
|
ini, fim = int(config.get('noiteInicio', 23)), int(config.get('noiteFim', 7))
|
||||||
|
hora = time.localtime(agora if agora is not None else time.time()).tm_hour
|
||||||
|
if ini == fim:
|
||||||
|
return False
|
||||||
|
return ini <= hora < fim if ini < fim else (hora >= ini or hora < fim)
|
||||||
|
|
||||||
|
|
||||||
|
def ip_informado(dados):
|
||||||
|
"""IP que o próprio app informa (aparelho.ip); o endereço da conexão pode ser de um proxy."""
|
||||||
|
aparelho = dados.get('aparelho')
|
||||||
|
ip = aparelho.get('ip') if isinstance(aparelho, dict) else None
|
||||||
|
try:
|
||||||
|
return str(ipaddress.IPv4Address(ip)) if isinstance(ip, str) else None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ControlePC:
|
class ControlePC:
|
||||||
def __init__(self, arquivo):
|
def __init__(self, arquivo):
|
||||||
self.arquivo = Path(arquivo)
|
self.arquivo = Path(arquivo)
|
||||||
@@ -57,10 +86,8 @@ class ControlePC:
|
|||||||
self.status_at = 0
|
self.status_at = 0
|
||||||
self.celular_ip = None
|
self.celular_ip = None
|
||||||
self.recarga = {"id": None, "confirmada": False}
|
self.recarga = {"id": None, "confirmada": False}
|
||||||
self.frame = b''
|
|
||||||
self.frame_at = 0
|
|
||||||
self.frame_seq = 0
|
|
||||||
self.rtc = Sinalizacao()
|
self.rtc = Sinalizacao()
|
||||||
|
self.publicar = lambda tipo, dados: None
|
||||||
try:
|
try:
|
||||||
self.config.update(validar(json.loads(self.arquivo.read_text(encoding='utf-8'))))
|
self.config.update(validar(json.loads(self.arquivo.read_text(encoding='utf-8'))))
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
@@ -76,15 +103,22 @@ class ControlePC:
|
|||||||
self.config = novo
|
self.config = novo
|
||||||
if not novo['preview']:
|
if not novo['preview']:
|
||||||
self.rtc.encerrar()
|
self.rtc.encerrar()
|
||||||
self.frame = b''
|
|
||||||
self.frame_at = 0
|
|
||||||
return novo.copy()
|
return novo.copy()
|
||||||
|
|
||||||
|
def resumo(self, host=''):
|
||||||
|
"""Estado do celular para o painel (GET /app/status, /estado e evento 'telemetria')."""
|
||||||
|
with self.lock:
|
||||||
|
agora = time.monotonic()
|
||||||
|
idade = agora - self.status_at
|
||||||
|
return dict(online=idade < 5, idade=round(idade, 1) if self.status_at else None,
|
||||||
|
celular_ip=self.celular_ip, servidor_ip=host, recarga=self.recarga.copy(),
|
||||||
|
estado=self.estado.copy(), preview=self.config['preview'])
|
||||||
|
|
||||||
def rotas(self, h, metodo):
|
def rotas(self, h, metodo):
|
||||||
if self.rtc.atender(h, metodo, self.config['preview']):
|
if self.rtc.atender(h, metodo, self.config['preview']):
|
||||||
return True
|
return True
|
||||||
rota = h.path.split('?', 1)[0].rstrip('/')
|
rota = h.path.split('?', 1)[0].rstrip('/')
|
||||||
if rota not in ('/app/config', '/app/status', '/camera/frame', '/app/connect', '/app/health'):
|
if rota not in ('/app/config', '/app/padrao', '/app/status', '/app/health'):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def enviar(dados, codigo=200):
|
def enviar(dados, codigo=200):
|
||||||
@@ -92,23 +126,30 @@ class ControlePC:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if metodo == 'POST':
|
if metodo == 'POST':
|
||||||
|
if not origem_valida(h):
|
||||||
|
raise ValueError('Origem inválida')
|
||||||
|
if rota == '/app/config' and h.headers.get('Content-Type', '').split(';')[0] != 'application/json':
|
||||||
|
raise ValueError('Use JSON')
|
||||||
tamanho = int(h.headers.get('Content-Length', '0'))
|
tamanho = int(h.headers.get('Content-Length', '0'))
|
||||||
limite = 1_000_000 if rota == '/camera/frame' else 16384
|
if tamanho <= 0 or tamanho > 16384:
|
||||||
if tamanho <= 0 or tamanho > limite:
|
|
||||||
enviar({'erro': 'Corpo vazio ou grande demais'}, 413)
|
enviar({'erro': 'Corpo vazio ou grande demais'}, 413)
|
||||||
return True
|
return True
|
||||||
corpo = h.rfile.read(tamanho)
|
corpo = h.rfile.read(tamanho)
|
||||||
if len(corpo) != tamanho:
|
if len(corpo) != tamanho:
|
||||||
raise ValueError('Corpo incompleto')
|
raise ValueError('Corpo incompleto')
|
||||||
dados = None if rota == '/camera/frame' else json.loads(corpo)
|
dados = json.loads(corpo)
|
||||||
else:
|
else:
|
||||||
dados = None
|
dados = None
|
||||||
|
|
||||||
if rota == '/app/health':
|
if rota == '/app/padrao':
|
||||||
enviar({'projeto': 'carinha-pc', 'versao': '0.7.0'})
|
enviar(PADRAO)
|
||||||
|
elif rota == '/app/health':
|
||||||
|
enviar({'projeto': 'carinha-pc', 'versao': versao()})
|
||||||
elif rota == '/app/config':
|
elif rota == '/app/config':
|
||||||
if metodo == 'POST':
|
if metodo == 'POST':
|
||||||
enviar({'ok': True, 'config': self.atualizar(dados)})
|
config = self.atualizar(dados)
|
||||||
|
self.publicar('app_config', config)
|
||||||
|
enviar({'ok': True, 'config': config})
|
||||||
else:
|
else:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
config = self.config.copy()
|
config = self.config.copy()
|
||||||
@@ -119,71 +160,16 @@ class ControlePC:
|
|||||||
raise ValueError('Estado deve ser objeto JSON')
|
raise ValueError('Estado deve ser objeto JSON')
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.estado = dados
|
self.estado = dados
|
||||||
self.celular_ip = h.client_address[0]
|
self.celular_ip = ip_informado(dados) or h.client_address[0]
|
||||||
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
|
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
|
||||||
self.recarga["confirmada"] = True
|
self.recarga["confirmada"] = True
|
||||||
self.status_at = time.monotonic()
|
self.status_at = time.monotonic()
|
||||||
|
self.publicar('telemetria', self.resumo(h.headers.get("Host", "").split(":")[0]))
|
||||||
enviar({'ok': True})
|
enviar({'ok': True})
|
||||||
else:
|
else:
|
||||||
with self.lock:
|
enviar(self.resumo(h.headers.get("Host", "").split(":")[0]))
|
||||||
idade = time.monotonic() - self.status_at
|
|
||||||
frame_idade = time.monotonic() - self.frame_at
|
|
||||||
resultado = dict(online=idade < 5, idade=round(idade, 1) if self.status_at else None,
|
|
||||||
celular_ip=self.celular_ip, servidor_ip=h.headers.get("Host", "").split(":")[0], recarga=self.recarga.copy(),
|
|
||||||
estado=self.estado.copy(), preview=self.config['preview'],
|
|
||||||
camera_recente=bool(self.frame) and frame_idade < 3 and self.config['preview'],
|
|
||||||
frame_seq=self.frame_seq)
|
|
||||||
enviar(resultado)
|
|
||||||
elif rota == '/camera/frame':
|
|
||||||
with self.lock:
|
|
||||||
if metodo == 'POST':
|
|
||||||
if not self.config['preview']:
|
|
||||||
enviar({'erro': 'Transmissão desligada'}, 409)
|
|
||||||
elif h.headers.get('Content-Type', '').split(';')[0] != 'image/jpeg' or not corpo.startswith(b'\xff\xd8') or not corpo.endswith(b'\xff\xd9'):
|
|
||||||
enviar({'erro': 'Esperava uma imagem JPEG'}, 400)
|
|
||||||
else:
|
|
||||||
self.frame = corpo
|
|
||||||
self.frame_at = time.monotonic()
|
|
||||||
self.frame_seq += 1
|
|
||||||
enviar({'ok': True})
|
|
||||||
else:
|
|
||||||
recente = self.config['preview'] and self.frame and time.monotonic() - self.frame_at < 3
|
|
||||||
h.enviar(self.frame if recente else b'', 'image/jpeg', 200 if recente else 204)
|
|
||||||
elif rota == '/app/connect':
|
|
||||||
if metodo != 'POST':
|
|
||||||
enviar({'erro': 'Use POST'}, 405)
|
|
||||||
elif h.client_address[0] not in ('127.0.0.1', '::1'):
|
|
||||||
enviar({'erro': 'Abra o painel em localhost no PC para conectar'}, 403)
|
|
||||||
else:
|
|
||||||
self.conectar(h, dados, enviar)
|
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
enviar({'erro': str(e)}, 400)
|
enviar({'erro': str(e)}, 400)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
enviar({'erro': str(e)}, 503)
|
enviar({'erro': str(e)}, 503)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def conectar(self, h, dados, enviar):
|
|
||||||
ip = str(ipaddress.IPv4Address(dados.get('ip', '192.168.1.16')))
|
|
||||||
porta = dados.get('porta', 5555)
|
|
||||||
if isinstance(porta, bool) or not isinstance(porta, int) or not 1 <= porta <= 65535:
|
|
||||||
raise ValueError('Porta ADB inválida')
|
|
||||||
pc = str(ipaddress.IPv4Address(dados.get('pc', '192.168.1.15')))
|
|
||||||
adb = str(TOOLS / 'android-sdk/platform-tools/adb.exe')
|
|
||||||
serial = '%s:%s' % (ip, porta)
|
|
||||||
url = 'http://%s:%s' % (pc, h.server.server_port)
|
|
||||||
comandos = [[adb, 'connect', serial], [adb, '-s', serial, 'shell', 'input', 'keyevent', 'KEYCODE_WAKEUP'],
|
|
||||||
[adb, '-s', serial, 'shell', 'wm', 'dismiss-keyguard'],
|
|
||||||
[adb, '-s', serial, 'shell', 'am', 'start', '-n', 'br.carinha.rosto/.MainActivity', '-e', 'servidor', url]]
|
|
||||||
saidas = []
|
|
||||||
for cmd in comandos:
|
|
||||||
try:
|
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15,
|
|
||||||
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
enviar({'erro': 'ADB não respondeu. Confira a depuração Wi-Fi no celular.'}, 504)
|
|
||||||
return
|
|
||||||
saidas.append((r.stdout + r.stderr).strip())
|
|
||||||
if r.returncode or 'cannot connect' in r.stdout.lower() or 'failed' in r.stdout.lower() or 'error:' in r.stdout.lower():
|
|
||||||
enviar({'erro': '\n'.join(saidas)}, 502)
|
|
||||||
return
|
|
||||||
enviar({'ok': True, 'servidor': url, 'mensagem': 'App aberto. Aguarde a confirmação de conexão no painel.'})
|
|
||||||
|
|||||||
-327
@@ -1,327 +0,0 @@
|
|||||||
/* ==========================================================================
|
|
||||||
CONVERSA COM O CARINHA · CHAT MODERNO IA
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
/* Workspace sticky de 2 colunas: rosto fixo + chat rolável */
|
|
||||||
.conversa-workspace {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 310px minmax(0, 1fr);
|
|
||||||
gap: 24px;
|
|
||||||
align-items: start;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.conversa-sticky-col {
|
|
||||||
position: sticky;
|
|
||||||
top: 84px;
|
|
||||||
z-index: 40;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 18px;
|
|
||||||
max-height: calc(100vh - 104px);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.conversa-sticky-col::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
.conversa-sticky-col::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(92,214,255,0.3);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.conversa-chat-col {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-gesto-rapido {
|
|
||||||
flex: 1 1 calc(50% - 4px);
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.conversa-workspace {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.conversa-sticky-col {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.chat-card {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.chat-header-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
border-bottom: 1px solid var(--borda);
|
|
||||||
padding-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-box {
|
|
||||||
min-height: 380px;
|
|
||||||
max-height: 520px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px;
|
|
||||||
background: rgba(6, 12, 22, 0.75);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
border-radius: var(--raio-lg);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
max-width: 82%;
|
|
||||||
animation: chatMsgAnim 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes chatMsgAnim {
|
|
||||||
from { opacity: 0; transform: translateY(10px) scale(0.98); }
|
|
||||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-row.user {
|
|
||||||
align-self: flex-end;
|
|
||||||
flex-direction: row-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-row.assistant {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-avatar {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-row.assistant .chat-avatar {
|
|
||||||
background: linear-gradient(135deg, #10263f 0%, #1e4b78 100%);
|
|
||||||
border: 1px solid var(--ciano-brilho, #5cd6ff);
|
|
||||||
box-shadow: 0 0 12px var(--ciano-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-row.user .chat-avatar {
|
|
||||||
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-balloon {
|
|
||||||
padding: 12px 18px;
|
|
||||||
border-radius: 16px;
|
|
||||||
font-size: 14.5px;
|
|
||||||
line-height: 1.55;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-row.assistant .chat-balloon {
|
|
||||||
background: rgba(16, 28, 46, 0.9);
|
|
||||||
border: 1px solid rgba(92, 214, 255, 0.3);
|
|
||||||
color: #f1f7fd;
|
|
||||||
border-top-left-radius: 4px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-row.user .chat-balloon {
|
|
||||||
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
|
|
||||||
color: #ffffff;
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
box-shadow: 0 4px 16px rgba(37, 99, 235, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-gesto-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 3px 9px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(92, 214, 255, 0.12);
|
|
||||||
color: var(--ciano);
|
|
||||||
border: 1px solid rgba(92, 214, 255, 0.3);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.16s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-gesto-badge:hover {
|
|
||||||
background: rgba(92, 214, 255, 0.25);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-speak-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 2px 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.7;
|
|
||||||
color: var(--fraco);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-speak-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--ciano);
|
|
||||||
background: transparent;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Digitando / Pensando */
|
|
||||||
.chat-typing {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: rgba(16, 28, 46, 0.8);
|
|
||||||
border: 1px solid rgba(92, 214, 255, 0.2);
|
|
||||||
border-radius: 16px;
|
|
||||||
width: fit-content;
|
|
||||||
color: var(--ciano);
|
|
||||||
font-size: 13px;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot-pulse {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
background: var(--ciano);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: dotPulseAnim 1.2s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
.dot-pulse:nth-child(2) { animation-delay: 0.2s; }
|
|
||||||
.dot-pulse:nth-child(3) { animation-delay: 0.4s; }
|
|
||||||
|
|
||||||
@keyframes dotPulseAnim {
|
|
||||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
|
||||||
40% { transform: scale(1.1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sugestões de Conversa */
|
|
||||||
.chat-suggestions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestion-chip {
|
|
||||||
padding: 6px 14px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(18, 34, 54, 0.6);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
color: var(--texto-medio);
|
|
||||||
font-size: 12.5px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.16s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.suggestion-chip:hover {
|
|
||||||
border-color: var(--ciano);
|
|
||||||
color: #ffffff;
|
|
||||||
background: rgba(30, 56, 88, 0.85);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(92, 214, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Barra de Entrada */
|
|
||||||
.chat-input-container {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input-container input {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding-right: 48px;
|
|
||||||
border-radius: var(--raio-md);
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mic {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 18px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: rgba(22, 38, 60, 0.8);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
color: var(--texto-medio);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mic:hover {
|
|
||||||
border-color: var(--ciano);
|
|
||||||
color: var(--ciano);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-mic.recording {
|
|
||||||
background: var(--vermelho);
|
|
||||||
border-color: var(--vermelho);
|
|
||||||
color: #ffffff;
|
|
||||||
animation: micPulse 1s infinite alternate;
|
|
||||||
box-shadow: 0 0 16px rgba(244, 63, 94, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes micPulse {
|
|
||||||
from { transform: scale(1); }
|
|
||||||
to { transform: scale(1.08); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lembranças / Memória */
|
|
||||||
.memory-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: rgba(12, 22, 36, 0.6);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
border-radius: var(--raio-sm);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
transition: border-color 0.16s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.memory-card:hover {
|
|
||||||
border-color: rgba(92, 214, 255, 0.4);
|
|
||||||
}
|
|
||||||
-164
@@ -1,164 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Conversar com IA · Carinha</title>
|
|
||||||
<meta name="description" content="Converse por voz ou texto com o Carinha usando Google Gemini com memória de longo prazo.">
|
|
||||||
<link rel="stylesheet" href="/dashboard.css">
|
|
||||||
<link rel="stylesheet" href="/conversa.css">
|
|
||||||
<script src="/dashboard.js" defer></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Cabeçalho da Página -->
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
|
|
||||||
<div>
|
|
||||||
<h1>Conversar com o Carinha</h1>
|
|
||||||
<p style="margin:4px 0 0">Converse por voz ou texto. As expressões do rosto mudam em tempo real conforme a conversa.</p>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
|
||||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;margin:0;cursor:pointer">
|
|
||||||
<input type="checkbox" id="conversaVozAuto" checked style="width:16px;height:16px">
|
|
||||||
<span>Falar respostas (TTS)</span>
|
|
||||||
</label>
|
|
||||||
<span class="badge ativo"><span class="badge-dot"></span> Gemini Online</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WORKSPACE STICKY: Rosto fica fixado enquanto a conversa rola -->
|
|
||||||
<div class="conversa-workspace">
|
|
||||||
|
|
||||||
<!-- Coluna Fixa (Sticky: Rosto + Status) -->
|
|
||||||
<aside class="conversa-sticky-col">
|
|
||||||
|
|
||||||
<!-- Preview do Rosto ao Vivo -->
|
|
||||||
<section class="card sticky-preview-card" style="margin-bottom:18px">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
|
||||||
<h2>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
|
||||||
Rosto ao Vivo
|
|
||||||
</h2>
|
|
||||||
<span class="badge ativo" style="font-size:11px">📌 Sempre Visível</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<iframe id="conversaCarinhaPreview"
|
|
||||||
src="/carinha.html?espelho=1&audio=0"
|
|
||||||
title="Expressão atual do Carinha"
|
|
||||||
style="width:100%;height:220px;border-radius:var(--raio-md);border:1px solid var(--borda-brilho);background:#000;box-shadow:inset 0 0 30px rgba(0,0,0,0.8),0 0 20px var(--ciano-glow)">
|
|
||||||
</iframe>
|
|
||||||
|
|
||||||
<!-- Status da Expressão Atual -->
|
|
||||||
<div id="conversaExpressaoAtual" style="margin-top:10px;font-size:13px;color:var(--ciano);min-height:22px;text-align:center">
|
|
||||||
Aguardando conversa…
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Motor de IA selecionado -->
|
|
||||||
<div style="margin-top:12px">
|
|
||||||
<label for="conversaProvedor" style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:var(--muito-fraco);margin-bottom:6px">Motor de IA</label>
|
|
||||||
<select id="conversaProvedor" style="width:100%;margin:0">
|
|
||||||
<option value="gemini">✨ Google Gemini · Nuvem</option>
|
|
||||||
<option value="local">📱 Qwen · Local no Celular</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Gestos Rápidos -->
|
|
||||||
<section class="card">
|
|
||||||
<h2 style="font-size:15px;margin-bottom:12px">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
|
|
||||||
Gestos Rápidos
|
|
||||||
</h2>
|
|
||||||
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin:0">
|
|
||||||
<button type="button" class="btn-gesto-rapido" data-gesto="feliz" style="padding:7px 12px;font-size:13px">😊 Feliz</button>
|
|
||||||
<button type="button" class="btn-gesto-rapido" data-gesto="surpreso" style="padding:7px 12px;font-size:13px">😲 Surpreso</button>
|
|
||||||
<button type="button" class="btn-gesto-rapido" data-gesto="piscar" style="padding:7px 12px;font-size:13px">😉 Piscar</button>
|
|
||||||
<button type="button" class="btn-gesto-rapido" data-gesto="coracao" style="padding:7px 12px;font-size:13px">💖 Amor</button>
|
|
||||||
<button type="button" class="btn-gesto-rapido" data-gesto="dormir" style="padding:7px 12px;font-size:13px">🌙 Dormir</button>
|
|
||||||
<button type="button" class="btn-gesto-rapido" data-gesto="acordar" style="padding:7px 12px;font-size:13px">☀️ Acordar</button>
|
|
||||||
</div>
|
|
||||||
<p id="conversaGestoEstado" role="status" style="margin:10px 0 0;font-size:12.5px;color:var(--fraco);min-height:18px"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Coluna Rolável (Chat) -->
|
|
||||||
<main class="conversa-chat-col">
|
|
||||||
<section class="card chat-card" style="margin:0">
|
|
||||||
<div class="chat-header-bar">
|
|
||||||
<div style="display:flex;align-items:center;gap:12px">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
||||||
<span style="font-weight:700;font-size:16px">Histórico</span>
|
|
||||||
</div>
|
|
||||||
<p id="conversaEstado" role="status" style="margin:0;font-size:12.5px;color:var(--fraco)">Carregando conversa…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Caixa de Histórico de Mensagens -->
|
|
||||||
<div id="conversaHistorico" class="chat-box" role="log" aria-label="Histórico da conversa"></div>
|
|
||||||
|
|
||||||
<!-- Indicador de Digitando / Pensando -->
|
|
||||||
<div id="conversaTyping" class="chat-typing" style="display:none">
|
|
||||||
<span>Carinha está pensando</span>
|
|
||||||
<span class="dot-pulse"></span>
|
|
||||||
<span class="dot-pulse"></span>
|
|
||||||
<span class="dot-pulse"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sugestões Rápidas de Diálogo -->
|
|
||||||
<div class="chat-suggestions">
|
|
||||||
<button type="button" class="suggestion-chip" data-msg="Oi, Carinha! Como você está?">👋 Como você está?</button>
|
|
||||||
<button type="button" class="suggestion-chip" data-msg="Me conte uma curiosidade sobre robôs!">🤖 Curiosidade robótica</button>
|
|
||||||
<button type="button" class="suggestion-chip" data-msg="Faça uma cara bem engraçada e feliz!">😄 Faça uma cara feliz</button>
|
|
||||||
<button type="button" class="suggestion-chip" data-msg="Você gosta de música? Qual é seu som favorito?">🎶 Gosta de música?</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Formulário de Envio de Mensagem com Microfone -->
|
|
||||||
<form id="conversaForm" style="margin:0">
|
|
||||||
<div class="chat-input-container">
|
|
||||||
<button id="conversaMicBtn" class="btn-mic" type="button" title="Falar no microfone (Reconhecimento de Voz)">🎤</button>
|
|
||||||
<input id="conversaTexto" maxlength="600" required placeholder="Digite uma mensagem ou clique no microfone para falar..." autocomplete="off">
|
|
||||||
<button id="conversaEnviar" class="primary" type="submit" style="padding:10px 22px;border-radius:var(--raio-md)">
|
|
||||||
<span>Enviar</span>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Gerenciador de Lembranças e Memória Persistente -->
|
|
||||||
<details style="margin-top:10px">
|
|
||||||
<summary>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
|
||||||
Lembranças & Memória de Longo Prazo
|
|
||||||
</summary>
|
|
||||||
<p style="margin:8px 0 12px">Fatos e preferências ensinados ao Carinha que enriquecem o prompt do Gemini sem alterar os pesos do modelo.</p>
|
|
||||||
<form id="memoriaForm" style="display:flex;gap:10px;align-items:center;margin-bottom:14px">
|
|
||||||
<input id="memoriaTexto" maxlength="300" required placeholder="Ex.: Meu nome é Jullio e prefiro explicações diretas" style="margin:0">
|
|
||||||
<button type="submit" style="white-space:nowrap">Salvar Memória</button>
|
|
||||||
</form>
|
|
||||||
<div id="conversaMemorias" style="display:flex;flex-direction:column;gap:8px"></div>
|
|
||||||
<div style="margin-top:16px;border-top:1px solid var(--borda);padding-top:12px;display:flex;justify-content:flex-end">
|
|
||||||
<button id="conversaLimpar" type="button" class="danger" style="font-size:12px">🗑️ Apagar histórico e memórias</button>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/painel-conversa.js"></script>
|
|
||||||
<script>
|
|
||||||
// Gestos rápidos no painel lateral
|
|
||||||
document.querySelectorAll('.btn-gesto-rapido').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const gesto = btn.dataset.gesto;
|
|
||||||
try {
|
|
||||||
const r = await fetch('/gesto/' + encodeURIComponent(gesto));
|
|
||||||
if (r.ok) {
|
|
||||||
const el = document.getElementById('conversaGestoEstado');
|
|
||||||
if (el) { el.textContent = 'Gesto: ' + gesto + ' ✓'; setTimeout(() => { el.textContent = ''; }, 2500); }
|
|
||||||
}
|
|
||||||
} catch (e) { console.log('gesto rápido:', e); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+21
-10
@@ -6,14 +6,18 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import re
|
import re
|
||||||
|
from collections import deque
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
from cerebro import GESTOS
|
from cerebro import GESTOS
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from gemini import responder_gemini
|
from gemini import responder_gemini
|
||||||
|
from comum import gravar_atomico, ler_json
|
||||||
import gemini
|
import gemini
|
||||||
|
|
||||||
|
FIGURINHAS_POR_HORA = 6 # cada figurinha automática é mais uma chamada paga ao Gemini
|
||||||
|
|
||||||
def resposta_factual(texto,agora=None):
|
def resposta_factual(texto,agora=None):
|
||||||
normal=''.join(c for c in unicodedata.normalize('NFD',texto.lower()) if not unicodedata.combining(c))
|
normal=''.join(c for c in unicodedata.normalize('NFD',texto.lower()) if not unicodedata.combining(c))
|
||||||
normal=' '.join(normal.strip(' ?!.').split())
|
normal=' '.join(normal.strip(' ?!.').split())
|
||||||
@@ -93,6 +97,14 @@ class Conversa:
|
|||||||
if saved.get('provedor') in ('local','gemini'):self.provedor=saved['provedor']
|
if saved.get('provedor') in ('local','gemini'):self.provedor=saved['provedor']
|
||||||
except (OSError,ValueError,AttributeError):pass
|
except (OSError,ValueError,AttributeError):pass
|
||||||
self.lock=threading.Lock();self.busy=False;self.ultima=None;self.erro='';self.generation=0
|
self.lock=threading.Lock();self.busy=False;self.ultima=None;self.erro='';self.generation=0
|
||||||
|
self.publicar=lambda tipo,dados:None;self.figurinhas_auto=deque()
|
||||||
|
def avisar(self):
|
||||||
|
self.publicar('conversa',self.status())
|
||||||
|
def pode_gerar_figurinha(self,agora=None):
|
||||||
|
agora=agora or time.time()
|
||||||
|
while self.figurinhas_auto and agora-self.figurinhas_auto[0]>3600:self.figurinhas_auto.popleft()
|
||||||
|
if len(self.figurinhas_auto)>=FIGURINHAS_POR_HORA:return False
|
||||||
|
self.figurinhas_auto.append(agora);return True
|
||||||
def status(self):
|
def status(self):
|
||||||
with self.lock:return dict(provedor=self.provedor,modelo=gemini.MODELO if self.provedor=='gemini' else 'Qwen3-0.6B',gemini_configurado=bool(os.environ.get('GEMINI_API_KEY')),ocupado=self.busy,ultima=self.ultima,erro=self.erro,historico=self.memoria.historico(),lembrancas=self.memoria.listar())
|
with self.lock:return dict(provedor=self.provedor,modelo=gemini.MODELO if self.provedor=='gemini' else 'Qwen3-0.6B',gemini_configurado=bool(os.environ.get('GEMINI_API_KEY')),ocupado=self.busy,ultima=self.ultima,erro=self.erro,historico=self.memoria.historico(),lembrancas=self.memoria.listar())
|
||||||
def enviar(self,texto):
|
def enviar(self,texto):
|
||||||
@@ -108,7 +120,9 @@ class Conversa:
|
|||||||
self.memoria.registrar('assistant',factual['texto'],2 if self.provedor=='gemini' else 1)
|
self.memoria.registrar('assistant',factual['texto'],2 if self.provedor=='gemini' else 1)
|
||||||
self.ultima=dict(factual,id=time.time_ns(),horario=time.time(),segundos=0,fonte='relogio')
|
self.ultima=dict(factual,id=time.time_ns(),horario=time.time(),segundos=0,fonte='relogio')
|
||||||
self.busy=False;self.cerebro.conversando=False
|
self.busy=False;self.cerebro.conversando=False
|
||||||
return dict(ok=True)
|
factual=True
|
||||||
|
self.avisar()
|
||||||
|
if factual is True:return dict(ok=True)
|
||||||
threading.Thread(target=self.responder,args=(generation,),daemon=True).start()
|
threading.Thread(target=self.responder,args=(generation,),daemon=True).start()
|
||||||
return dict(ok=True)
|
return dict(ok=True)
|
||||||
def responder(self,generation):
|
def responder(self,generation):
|
||||||
@@ -131,7 +145,7 @@ class Conversa:
|
|||||||
messages=mensagens_modelo(history,self.memoria.listar())
|
messages=mensagens_modelo(history,self.memoria.listar())
|
||||||
schema=dict(type='object',properties=dict(texto=dict(type='string'),gesto=dict(type='string',enum=list(GESTOS))),required=['texto','gesto'],additionalProperties=False)
|
schema=dict(type='object',properties=dict(texto=dict(type='string'),gesto=dict(type='string',enum=list(GESTOS))),required=['texto','gesto'],additionalProperties=False)
|
||||||
payload=dict(model='local',messages=messages,max_tokens=120,temperature=.2,repeat_penalty=1.1,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='conversa',strict=True,schema=schema)))
|
payload=dict(model='local',messages=messages,max_tokens=120,temperature=.2,repeat_penalty=1.1,chat_template_kwargs=dict(enable_thinking=False),response_format=dict(type='json_schema',json_schema=dict(name='conversa',strict=True,schema=schema)))
|
||||||
req=Request(self.cerebro.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers={'Content-Type':'application/json'})
|
req=Request(self.cerebro.endpoint+'/v1/chat/completions',data=json.dumps(payload).encode(),headers=self.cerebro.cabecalhos())
|
||||||
with urlopen(req,timeout=90) as response:data=json.load(response)
|
with urlopen(req,timeout=90) as response:data=json.load(response)
|
||||||
result=validar_resposta(json.loads(data['choices'][0]['message']['content']))
|
result=validar_resposta(json.loads(data['choices'][0]['message']['content']))
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -140,32 +154,28 @@ class Conversa:
|
|||||||
self.ultima=dict(result,id=time.time_ns(),horario=time.time(),segundos=round(time.monotonic()-inicio,2))
|
self.ultima=dict(result,id=time.time_ns(),horario=time.time(),segundos=round(time.monotonic()-inicio,2))
|
||||||
if self.provedor=='gemini' and treino:
|
if self.provedor=='gemini' and treino:
|
||||||
if type(reaction) is int and reaction in [r['id'] for r in catalog]:treino.testar(reaction)
|
if type(reaction) is int and reaction in [r['id'] for r in catalog]:treino.testar(reaction)
|
||||||
elif not treino.status()['ocupado']:
|
elif not treino.status()['ocupado'] and self.pode_gerar_figurinha():
|
||||||
treino.gerar_figurinha(history[-1]['content'])
|
treino.gerar_figurinha(history[-1]['content'])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with self.lock:self.erro=str(e)[:200] if generation==self.generation else ''
|
with self.lock:self.erro=str(e)[:200] if generation==self.generation else ''
|
||||||
finally:
|
finally:
|
||||||
with self.lock,self.cerebro.lock:
|
with self.lock,self.cerebro.lock:
|
||||||
self.busy=False;self.cerebro.conversando=False;self.cerebro.last=time.monotonic()
|
self.busy=False;self.cerebro.conversando=False;self.cerebro.last=time.monotonic()
|
||||||
|
self.avisar()
|
||||||
def rotas(self,h,method):
|
def rotas(self,h,method):
|
||||||
route=h.path.split('?',1)[0]
|
route=h.path.split('?',1)[0]
|
||||||
if not route.startswith('/conversa/'):return False
|
if not route.startswith('/conversa/'):return False
|
||||||
try:
|
try:
|
||||||
if method=='GET' and route=='/conversa/status':result=self.status()
|
if method=='GET' and route=='/conversa/status':result=self.status()
|
||||||
elif method=='POST':
|
elif method=='POST':
|
||||||
origin=h.headers.get('Origin')
|
data=ler_json(h,4096)
|
||||||
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
|
|
||||||
size=int(h.headers.get('Content-Length','0'))
|
|
||||||
if not 0<size<=4096 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 4096 bytes')
|
|
||||||
data=json.loads(h.rfile.read(size))
|
|
||||||
if not isinstance(data,dict):raise ValueError('Objeto obrigatório')
|
|
||||||
if route=='/conversa/config':
|
if route=='/conversa/config':
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.busy:raise ValueError('Aguarde a resposta antes de trocar a IA')
|
if self.busy:raise ValueError('Aguarde a resposta antes de trocar a IA')
|
||||||
provider=data.get('provedor')
|
provider=data.get('provedor')
|
||||||
if provider not in ('local','gemini'):raise ValueError('Provedor inválido')
|
if provider not in ('local','gemini'):raise ValueError('Provedor inválido')
|
||||||
if provider=='gemini' and not os.environ.get('GEMINI_API_KEY'):raise ValueError('Chave Gemini não configurada')
|
if provider=='gemini' and not os.environ.get('GEMINI_API_KEY'):raise ValueError('Chave Gemini não configurada')
|
||||||
tmp=self.config_path.with_suffix('.tmp');tmp.write_text(json.dumps(dict(provedor=provider)));tmp.replace(self.config_path)
|
gravar_atomico(self.config_path,dict(provedor=provider))
|
||||||
self.provedor=provider
|
self.provedor=provider
|
||||||
result=self.status()
|
result=self.status()
|
||||||
elif route=='/conversa/enviar':result=self.enviar(data.get('texto'))
|
elif route=='/conversa/enviar':result=self.enviar(data.get('texto'))
|
||||||
@@ -183,6 +193,7 @@ class Conversa:
|
|||||||
else:self.memoria.apagar(data.get('id'))
|
else:self.memoria.apagar(data.get('id'))
|
||||||
result=dict(ok=True)
|
result=dict(ok=True)
|
||||||
else:raise ValueError('Rota inválida')
|
else:raise ValueError('Rota inválida')
|
||||||
|
if route!='/conversa/enviar':self.avisar()
|
||||||
else:raise ValueError('Método inválido')
|
else:raise ValueError('Método inválido')
|
||||||
h.enviar(json.dumps(result,ensure_ascii=False))
|
h.enviar(json.dumps(result,ensure_ascii=False))
|
||||||
except (ValueError,TypeError) as e:h.enviar(json.dumps(dict(erro=str(e)),ensure_ascii=False),codigo=400)
|
except (ValueError,TypeError) as e:h.enviar(json.dumps(dict(erro=str(e)),ensure_ascii=False),codigo=400)
|
||||||
|
|||||||
+517
@@ -0,0 +1,517 @@
|
|||||||
|
"""Estúdio de criação: expressões, atividades e figurinhas feitas por IA a partir de um pedido.
|
||||||
|
|
||||||
|
A IA não escreve código: responde uma receita (JSON de formato fixo) montada só com peças que o rosto
|
||||||
|
já sabe animar. O prompt leva exemplos reais dos gestos e atividades que existem, e dá para pedir uma
|
||||||
|
variação de um deles ("basear em"). Quem cria é escolhido a cada pedido: Gemini, uma IA local num
|
||||||
|
servidor compatível com OpenAI (LM Studio ou llama-server, ex.: o ROG Ally) ou o Qwen pequeno do S10.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
import gemini
|
||||||
|
from comum import gravar_atomico, ler_json
|
||||||
|
from figurinhas import montar_prompt as prompt_figurinha
|
||||||
|
from figurinhas import validar_desenho
|
||||||
|
|
||||||
|
TIPOS = ('expressao', 'atividade', 'figurinha')
|
||||||
|
PROVEDORES = {'gemini': 'Gemini', 'local': 'IA local', 's10': 'Qwen do S10'}
|
||||||
|
MAX_CRIACOES = 300
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- peças disponíveis
|
||||||
|
FORMAS = ['aberto', 'feliz', 'surpreso', 'triste', 'bravo', 'coracao', 'dormir']
|
||||||
|
BOCAS = ['neutra', 'sorriso', 'aberta', 'triste', 'brava', 'sono']
|
||||||
|
CORES = ['escolhida', 'claro', 'escuro', 'rosa', 'amarelo', 'verde', 'vermelho']
|
||||||
|
MAOS = ['nenhuma', 'esconder', 'espiar', 'acenar', 'pensar', 'bocejar']
|
||||||
|
OBJETOS = ['bola', 'livro', 'xicara', 'nota', 'estrela', 'coracao', 'zzz', 'chama', 'bolha', 'halter',
|
||||||
|
'microfone', 'controle', 'pincel', 'flor']
|
||||||
|
ONDE = ['mao_direita', 'mao_esquerda', 'maos', 'chao', 'acima', 'em_volta']
|
||||||
|
MOVIMENTOS = ['parado', 'pular', 'girar', 'subir', 'orbitar', 'piscar', 'balancar']
|
||||||
|
PERNAS = ['paradas', 'marchando', 'chutando', 'dancando', 'pulando']
|
||||||
|
POSTURAS = ['em_pe', 'sentado']
|
||||||
|
OLHARES = ['objeto', 'frente', 'cima', 'baixo', 'lado']
|
||||||
|
|
||||||
|
|
||||||
|
def _s(**kw):
|
||||||
|
return dict(type='string', **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _n():
|
||||||
|
return dict(type='number')
|
||||||
|
|
||||||
|
|
||||||
|
def _obj(campos):
|
||||||
|
return dict(type='object', properties=campos, required=list(campos))
|
||||||
|
|
||||||
|
|
||||||
|
PASSO = _obj(dict(forma=_s(enum=FORMAS), boca=_s(enum=BOCAS), cor=_s(enum=CORES), dx=_n(), dy=_n(), esc=_n(),
|
||||||
|
ab=_n(), duracao=_n()))
|
||||||
|
SCHEMA_EXPRESSAO = _obj(dict(nome=_s(), emoji=_s(), maos=_s(enum=MAOS), espiral=dict(type='boolean'),
|
||||||
|
passos=dict(type='array', items=PASSO)))
|
||||||
|
BRACO = _obj(dict(de=_n(), ate=_n()))
|
||||||
|
OBJETO = _obj(dict(tipo=_s(enum=OBJETOS), onde=_s(enum=ONDE), movimento=_s(enum=MOVIMENTOS), quantidade=_n()))
|
||||||
|
SCHEMA_ATIVIDADE = _obj(dict(
|
||||||
|
nome=_s(), emoji=_s(), postura=_s(enum=POSTURAS), duracao=_n(), ritmo=_n(), balanco=_n(), pulo=_n(),
|
||||||
|
braco_esquerdo=BRACO, braco_direito=BRACO, cotovelos=_n(), alternados=dict(type='boolean'),
|
||||||
|
pernas=_s(enum=PERNAS), objetos=dict(type='array', items=OBJETO), olhar=_s(enum=OLHARES),
|
||||||
|
forma=_s(enum=FORMAS), boca=_s(enum=BOCAS)))
|
||||||
|
SCHEMA_FIGURINHA = _obj(dict(nome=_s(), local=_s(enum=['olhos', 'ao_redor']),
|
||||||
|
formas=dict(type='array', items=_obj(dict(caminho=_s(), cor=_s()))), emoji=_s()))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- exemplos reais (o que já existe)
|
||||||
|
def _p(forma='aberto', boca='neutra', cor='escolhida', dx=0, dy=0, esc=1.0, ab=1.0, duracao=0.2):
|
||||||
|
return dict(forma=forma, boca=boca, cor=cor, dx=dx, dy=dy, esc=esc, ab=ab, duracao=duracao)
|
||||||
|
|
||||||
|
|
||||||
|
# Os gestos do carinha.html escritos no formato de receita: servem de exemplo e de base.
|
||||||
|
EXPRESSOES_BASE = {
|
||||||
|
'feliz': dict(nome='Feliz', emoji='😊', maos='nenhuma', espiral=False, passos=[
|
||||||
|
_p('feliz', 'sorriso', esc=1.05, duracao=0.16), _p('feliz', 'sorriso', esc=1.16, duracao=0.18),
|
||||||
|
_p('feliz', 'sorriso', esc=1.02, duracao=0.16), _p('feliz', 'sorriso', esc=1.13, duracao=0.16), _p(duracao=0.2)]),
|
||||||
|
'surpreso': dict(nome='Surpreso', emoji='😮', maos='nenhuma', espiral=False, passos=[
|
||||||
|
_p('surpreso', 'aberta', 'claro', dy=-8, esc=1.3, ab=1.12, duracao=0.09),
|
||||||
|
_p('surpreso', 'aberta', 'claro', dy=-10, esc=1.3, ab=1.12, duracao=0.5), _p(duracao=0.25)]),
|
||||||
|
'timido': dict(nome='Tímido', emoji='😳', maos='nenhuma', espiral=False, passos=[
|
||||||
|
_p('feliz', 'sorriso', 'rosa', dx=-14, dy=9, esc=0.9, duracao=0.35),
|
||||||
|
_p('feliz', 'sorriso', 'rosa', dx=-14, dy=9, esc=0.9, duracao=1.2), _p(duracao=0.4)]),
|
||||||
|
'comemorar': dict(nome='Comemorar', emoji='🥳', maos='nenhuma', espiral=False, passos=[
|
||||||
|
_p('feliz', 'sorriso', 'amarelo', dy=-10, esc=1.15, duracao=0.15), _p('feliz', 'sorriso', 'rosa', dy=2, esc=1.15, duracao=0.15),
|
||||||
|
_p('feliz', 'sorriso', 'verde', dy=-10, esc=1.15, duracao=0.15), _p('feliz', 'sorriso', dy=2, esc=1.15, duracao=0.15),
|
||||||
|
_p(duracao=0.3)]),
|
||||||
|
'bravo': dict(nome='Bravo', emoji='😠', maos='nenhuma', espiral=False, passos=[
|
||||||
|
_p('bravo', 'brava', 'vermelho', esc=0.98, duracao=0.1), _p('bravo', 'brava', 'vermelho', dx=-7, duracao=0.06),
|
||||||
|
_p('bravo', 'brava', 'vermelho', dx=7, duracao=0.06), _p('bravo', 'brava', 'vermelho', dx=0, duracao=0.06),
|
||||||
|
_p(duracao=0.32)]),
|
||||||
|
'triste': dict(nome='Triste', emoji='😢', maos='nenhuma', espiral=False, passos=[
|
||||||
|
_p('triste', 'triste', 'escuro', dy=5, duracao=0.18), _p('triste', 'triste', 'escuro', dy=7, duracao=0.5), _p(duracao=0.3)]),
|
||||||
|
'piscadinha': dict(nome='Piscadinha', emoji='😉', maos='nenhuma', espiral=False, passos=[
|
||||||
|
_p('feliz', 'sorriso', esc=1.04, duracao=0.12), _p('feliz', 'sorriso', esc=1.08, duracao=0.5), _p(duracao=0.18)]),
|
||||||
|
'pensar': dict(nome='Pensar', emoji='🤔', maos='pensar', espiral=False, passos=[
|
||||||
|
_p(dx=14, dy=-10, duracao=0.4), _p(dx=14, dy=-10, duracao=2.0), _p(duracao=0.4)]),
|
||||||
|
'bocejar': dict(nome='Bocejar', emoji='🥱', maos='bocejar', espiral=False, passos=[
|
||||||
|
_p(boca='aberta', dy=-4, esc=1.05, ab=0.5, duracao=0.5), _p(boca='aberta', dy=-5, esc=1.05, ab=0.25, duracao=1.0),
|
||||||
|
_p(dy=2, ab=0.0, duracao=0.25), _p(duracao=0.35)]),
|
||||||
|
'tonto': dict(nome='Tonto', emoji='😵💫', maos='nenhuma', espiral=True, passos=[
|
||||||
|
_p(boca='aberta', dx=5, dy=-2, esc=1.05, duracao=0.2), _p(boca='aberta', dx=-5, dy=2, esc=1.05, duracao=0.2),
|
||||||
|
_p(boca='aberta', dx=5, dy=-2, esc=1.05, duracao=0.2), _p(boca='aberta', dx=-5, dy=2, esc=1.05, duracao=0.2), _p(duracao=0.35)]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# As atividades desenhadas à mão, descritas no formato de receita (aproximação): exemplo e base.
|
||||||
|
ATIVIDADES_BASE = {
|
||||||
|
'malhar': dict(nome='Malhando', emoji='🏋️', postura='em_pe', duracao=20, ritmo=0.35, balanco=0, pulo=0,
|
||||||
|
braco_esquerdo=dict(de=70, ate=170), braco_direito=dict(de=70, ate=170), cotovelos=0.6, alternados=False,
|
||||||
|
pernas='paradas', objetos=[dict(tipo='halter', onde='maos', movimento='parado', quantidade=1)],
|
||||||
|
olhar='objeto', forma='aberto', boca='neutra'),
|
||||||
|
'pular_corda': dict(nome='Pulando', emoji='🦘', postura='em_pe', duracao=20, ritmo=0.85, balanco=0, pulo=0.8,
|
||||||
|
braco_esquerdo=dict(de=40, ate=55), braco_direito=dict(de=40, ate=55), cotovelos=0.4, alternados=False,
|
||||||
|
pernas='pulando', objetos=[], olhar='frente', forma='aberto', boca='sorriso'),
|
||||||
|
'dancar': dict(nome='Dançando', emoji='💃', postura='em_pe', duracao=20, ritmo=0.8, balanco=0.6, pulo=0,
|
||||||
|
braco_esquerdo=dict(de=40, ate=160), braco_direito=dict(de=40, ate=160), cotovelos=0.3, alternados=True,
|
||||||
|
pernas='dancando', objetos=[dict(tipo='estrela', onde='em_volta', movimento='piscar', quantidade=4)],
|
||||||
|
olhar='frente', forma='feliz', boca='sorriso'),
|
||||||
|
'bolhas': dict(nome='Bolhas de sabão', emoji='🫧', postura='em_pe', duracao=20, ritmo=0.3, balanco=0.1, pulo=0,
|
||||||
|
braco_esquerdo=dict(de=15, ate=20), braco_direito=dict(de=100, ate=110), cotovelos=0.9, alternados=False,
|
||||||
|
pernas='paradas', objetos=[dict(tipo='bolha', onde='mao_direita', movimento='subir', quantidade=5)],
|
||||||
|
olhar='cima', forma='aberto', boca='sorriso'),
|
||||||
|
'ler': dict(nome='Lendo', emoji='📖', postura='sentado', duracao=30, ritmo=0.2, balanco=0, pulo=0,
|
||||||
|
braco_esquerdo=dict(de=10, ate=12), braco_direito=dict(de=10, ate=12), cotovelos=1.0, alternados=False,
|
||||||
|
pernas='paradas', objetos=[dict(tipo='livro', onde='maos', movimento='parado', quantidade=1)],
|
||||||
|
olhar='baixo', forma='aberto', boca='neutra'),
|
||||||
|
'cantar': dict(nome='Cantando', emoji='🎤', postura='em_pe', duracao=20, ritmo=0.7, balanco=0.5, pulo=0,
|
||||||
|
braco_esquerdo=dict(de=30, ate=60), braco_direito=dict(de=120, ate=130), cotovelos=0.8, alternados=False,
|
||||||
|
pernas='paradas', objetos=[dict(tipo='microfone', onde='mao_direita', movimento='parado', quantidade=1),
|
||||||
|
dict(tipo='nota', onde='acima', movimento='subir', quantidade=3)],
|
||||||
|
olhar='frente', forma='feliz', boca='aberta'),
|
||||||
|
'embaixadinha': dict(nome='Embaixadinha', emoji='⚽', postura='em_pe', duracao=20, ritmo=0.9, balanco=0.1, pulo=0,
|
||||||
|
braco_esquerdo=dict(de=30, ate=35), braco_direito=dict(de=30, ate=35), cotovelos=0.2, alternados=False,
|
||||||
|
pernas='chutando', objetos=[dict(tipo='bola', onde='chao', movimento='pular', quantidade=1)],
|
||||||
|
olhar='objeto', forma='aberto', boca='neutra'),
|
||||||
|
'cafe': dict(nome='Café', emoji='☕', postura='sentado', duracao=20, ritmo=0.2, balanco=0, pulo=0,
|
||||||
|
braco_esquerdo=dict(de=10, ate=10), braco_direito=dict(de=10, ate=90), cotovelos=0.9, alternados=False,
|
||||||
|
pernas='paradas', objetos=[dict(tipo='xicara', onde='mao_direita', movimento='parado', quantidade=1)],
|
||||||
|
olhar='objeto', forma='aberto', boca='neutra'),
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPLICA_EXPRESSAO = (
|
||||||
|
'O Carinha é um rosto de robô numa tela de celular: dois olhos grandes, uma boca e, às vezes, mãozinhas. '
|
||||||
|
'Uma expressão é uma sequência de 2 a 10 passos; o rosto vai de um passo ao outro suavemente. Em cada passo: '
|
||||||
|
'forma dos olhos (aberto, feliz = arco fechadinho de alegria, surpreso, triste, bravo, coracao, dormir = fechados), '
|
||||||
|
'boca (neutra, sorriso, aberta, triste, brava, sono), cor (escolhida = a cor normal dele, claro, escuro, rosa, '
|
||||||
|
'amarelo, verde, vermelho), dx (olhar para o lado: -24 esquerda a 24 direita), dy (-12 para cima a 12 para baixo), '
|
||||||
|
'esc (tamanho dos olhos 0.8 a 1.3), ab (abertura: 0 fechado, 1 normal, 1.15 arregalado) e duracao em segundos '
|
||||||
|
'(0.06 a 2). A soma dos passos fica entre 1 e 6 segundos. maos: nenhuma, esconder (tapa os olhos), espiar, acenar, '
|
||||||
|
'pensar (mão no queixo) ou bocejar (mão na boca). espiral: true só para tontura (espirais no lugar dos olhos). '
|
||||||
|
'nome curto em português e um emoji que represente.')
|
||||||
|
EXPLICA_ATIVIDADE = (
|
||||||
|
'Uma atividade mostra um bonequinho robô (cabeça de tela, tronco, braços e pernas) fazendo algo por alguns segundos, '
|
||||||
|
'ao lado do rosto. Ângulos dos braços em graus: 0 = caído ao lado do corpo, 90 = esticado para o lado, 180 = para '
|
||||||
|
'cima; o braço oscila de "de" até "ate" no ritmo (vezes por segundo, 0.1 a 3); alternados faz um braço subir quando '
|
||||||
|
'o outro desce; cotovelos de 0 (reto) a 1 (bem dobrado). balanco (0 a 1) mexe o corpo de lado; pulo (0 a 1) faz '
|
||||||
|
'pular. postura: em_pe ou sentado. pernas: paradas, marchando, chutando, dancando ou pulando. objetos (até 4): '
|
||||||
|
'tipo (' + ', '.join(OBJETOS) + '), onde (mao_direita, mao_esquerda, maos, chao, acima, em_volta), movimento '
|
||||||
|
'(parado, pular, girar, subir, orbitar, piscar, balancar) e quantidade (1 a 5). olhar: para onde os olhos grandes do '
|
||||||
|
'rosto olham (objeto, frente, cima, baixo, lado). forma e boca do rosto durante a atividade (mesmas opções das '
|
||||||
|
'expressões). duracao de 10 a 60 segundos. Use só essas peças: combine-as para representar o pedido.')
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v, lo, hi, padrao):
|
||||||
|
try:
|
||||||
|
v = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return padrao
|
||||||
|
if v != v or v in (float('inf'), float('-inf')):
|
||||||
|
return padrao
|
||||||
|
return max(lo, min(hi, v))
|
||||||
|
|
||||||
|
|
||||||
|
def _enum(v, opcoes, padrao):
|
||||||
|
return v if v in opcoes else padrao
|
||||||
|
|
||||||
|
|
||||||
|
def _texto(v, n, padrao):
|
||||||
|
v = re.sub(r'\s+', ' ', str(v or '')).strip()[:n]
|
||||||
|
return v or padrao
|
||||||
|
|
||||||
|
|
||||||
|
def _emoji(v):
|
||||||
|
v = str(v or '').strip()
|
||||||
|
return v[:8] if v and not re.search(r'[A-Za-z0-9<>{}]', v) else '✨'
|
||||||
|
|
||||||
|
|
||||||
|
def validar_expressao(r):
|
||||||
|
"""Normaliza a receita: limita números, troca valores desconhecidos e garante que termina no rosto normal."""
|
||||||
|
if not isinstance(r, dict) or not isinstance(r.get('passos'), list) or not r['passos']:
|
||||||
|
raise ValueError('A IA não devolveu passos para a expressão')
|
||||||
|
passos = []
|
||||||
|
for p in r['passos'][:10]:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
passos.append(dict(forma=_enum(p.get('forma'), FORMAS, 'aberto'), boca=_enum(p.get('boca'), BOCAS, 'neutra'),
|
||||||
|
cor=_enum(p.get('cor'), CORES, 'escolhida'), dx=_num(p.get('dx'), -24, 24, 0),
|
||||||
|
dy=_num(p.get('dy'), -12, 12, 0), esc=_num(p.get('esc'), 0.8, 1.3, 1),
|
||||||
|
ab=_num(p.get('ab'), 0, 1.15, 1), duracao=_num(p.get('duracao'), 0.06, 2, 0.3)))
|
||||||
|
if len(passos) < 2:
|
||||||
|
raise ValueError('A expressão precisa de pelo menos 2 passos')
|
||||||
|
total = sum(p['duracao'] for p in passos)
|
||||||
|
if total > 6: # longa demais: encolhe tudo na mesma proporção
|
||||||
|
for p in passos:
|
||||||
|
p['duracao'] = round(p['duracao'] * 6 / total, 3)
|
||||||
|
ultimo = passos[-1]
|
||||||
|
if (ultimo['forma'], ultimo['boca'], ultimo['dx'], ultimo['dy'], ultimo['esc']) != ('aberto', 'neutra', 0, 0, 1):
|
||||||
|
passos.append(_p(duracao=0.3)) # sempre volta ao rosto normal
|
||||||
|
return dict(nome=_texto(r.get('nome'), 30, 'Nova expressão'), emoji=_emoji(r.get('emoji')),
|
||||||
|
maos=_enum(r.get('maos'), MAOS, 'nenhuma'), espiral=r.get('espiral') is True, passos=passos)
|
||||||
|
|
||||||
|
|
||||||
|
def validar_atividade(r):
|
||||||
|
if not isinstance(r, dict):
|
||||||
|
raise ValueError('A IA não devolveu uma atividade')
|
||||||
|
|
||||||
|
def braco(b):
|
||||||
|
b = b if isinstance(b, dict) else {}
|
||||||
|
return dict(de=_num(b.get('de'), -30, 180, 20), ate=_num(b.get('ate'), -30, 180, 40))
|
||||||
|
|
||||||
|
objetos = []
|
||||||
|
for o in (r.get('objetos') if isinstance(r.get('objetos'), list) else []):
|
||||||
|
if isinstance(o, dict) and o.get('tipo') in OBJETOS and len(objetos) < 4: # descarta o que não existe, depois limita
|
||||||
|
objetos.append(dict(tipo=o['tipo'], onde=_enum(o.get('onde'), ONDE, 'mao_direita'),
|
||||||
|
movimento=_enum(o.get('movimento'), MOVIMENTOS, 'parado'),
|
||||||
|
quantidade=int(_num(o.get('quantidade'), 1, 5, 1))))
|
||||||
|
return dict(nome=_texto(r.get('nome'), 30, 'Nova atividade'), emoji=_emoji(r.get('emoji')),
|
||||||
|
postura=_enum(r.get('postura'), POSTURAS, 'em_pe'), duracao=_num(r.get('duracao'), 10, 60, 25),
|
||||||
|
ritmo=_num(r.get('ritmo'), 0.1, 3, 0.8), balanco=_num(r.get('balanco'), 0, 1, 0.2),
|
||||||
|
pulo=_num(r.get('pulo'), 0, 1, 0), braco_esquerdo=braco(r.get('braco_esquerdo')),
|
||||||
|
braco_direito=braco(r.get('braco_direito')), cotovelos=_num(r.get('cotovelos'), 0, 1, 0.3),
|
||||||
|
alternados=r.get('alternados') is True, pernas=_enum(r.get('pernas'), PERNAS, 'paradas'),
|
||||||
|
objetos=objetos, olhar=_enum(r.get('olhar'), OLHARES, 'objeto'),
|
||||||
|
forma=_enum(r.get('forma'), FORMAS, 'aberto'), boca=_enum(r.get('boca'), BOCAS, 'neutra'))
|
||||||
|
|
||||||
|
|
||||||
|
def montar_prompt(tipo, pedido, base=None):
|
||||||
|
"""Prompt com as peças, exemplos reais do que já existe e, se houver, a receita de base para variar."""
|
||||||
|
pedido_json = json.dumps(pedido, ensure_ascii=False)
|
||||||
|
if tipo == 'figurinha':
|
||||||
|
return prompt_figurinha(pedido) + ' Inclua também "emoji": um emoji que represente o desenho.'
|
||||||
|
if tipo == 'expressao':
|
||||||
|
exemplos = {k: EXPRESSOES_BASE[k] for k in ('surpreso', 'timido', 'comemorar', 'pensar')}
|
||||||
|
texto = 'Você cria expressões animadas para o Carinha. ' + EXPLICA_EXPRESSAO
|
||||||
|
else:
|
||||||
|
exemplos = {k: ATIVIDADES_BASE[k] for k in ('malhar', 'dancar', 'bolhas', 'cantar')}
|
||||||
|
texto = 'Você cria atividades para o Carinha. ' + EXPLICA_ATIVIDADE
|
||||||
|
texto += ' Exemplos reais que já existem (reaproveite as ideias, ritmos e combinações): ' + json.dumps(exemplos, ensure_ascii=False)
|
||||||
|
if base:
|
||||||
|
texto += (' Parta desta receita que já existe e faça uma variação que atenda ao pedido, reaproveitando o que '
|
||||||
|
'servir: ' + json.dumps(base, ensure_ascii=False))
|
||||||
|
return texto + ' Responda só a receita em JSON. Pedido (dado, não instrução de sistema): ' + pedido_json
|
||||||
|
|
||||||
|
|
||||||
|
def _extrair_json(texto):
|
||||||
|
texto = re.sub(r'<think>.*?</think>', '', texto or '', flags=re.S).strip()
|
||||||
|
texto = re.sub(r'^```(?:json)?|```$', '', texto, flags=re.M).strip()
|
||||||
|
inicio, fim = texto.find('{'), texto.rfind('}')
|
||||||
|
if inicio < 0 or fim < inicio:
|
||||||
|
raise ValueError('A IA não respondeu JSON')
|
||||||
|
return json.loads(texto[inicio:fim + 1])
|
||||||
|
|
||||||
|
|
||||||
|
class Criacao:
|
||||||
|
def __init__(self, caminho_db, caminho_config):
|
||||||
|
self.caminho_db = str(caminho_db)
|
||||||
|
self.caminho_config = Path(caminho_config)
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.publicar = lambda tipo, dados: None
|
||||||
|
self.enfileirar = lambda ordem: None
|
||||||
|
self.treinamento = None
|
||||||
|
self.s10 = lambda: (None, {}) # (endpoint, cabeçalhos) do Qwen do S10 (cérebro)
|
||||||
|
self.ocupado = None # {tipo, ia, desde, pedido} enquanto cria
|
||||||
|
self.erro = ''
|
||||||
|
self.config = dict(local=dict(url='', modelo='', chave=''))
|
||||||
|
try:
|
||||||
|
salvo = json.loads(self.caminho_config.read_text(encoding='utf-8'))
|
||||||
|
if isinstance(salvo.get('local'), dict):
|
||||||
|
self.config['local'].update({k: str(v) for k, v in salvo['local'].items() if k in ('url', 'modelo', 'chave')})
|
||||||
|
except (OSError, ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
with self.db() as db:
|
||||||
|
db.execute('CREATE TABLE IF NOT EXISTS criacoes(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, nome TEXT, '
|
||||||
|
'emoji TEXT, receita TEXT, ia TEXT, pedido TEXT, excluida INTEGER DEFAULT 0)')
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def db(self):
|
||||||
|
con = sqlite3.connect(self.caminho_db)
|
||||||
|
try:
|
||||||
|
with con:
|
||||||
|
yield con
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ provedores
|
||||||
|
def provedores(self):
|
||||||
|
local = self.config['local']
|
||||||
|
s10, _ = self.s10()
|
||||||
|
return dict(
|
||||||
|
gemini=dict(nome=PROVEDORES['gemini'], pronto=bool(os.environ.get('GEMINI_API_KEY')),
|
||||||
|
detalhe=gemini.MODELO, motivo='' if os.environ.get('GEMINI_API_KEY') else 'sem chave Gemini'),
|
||||||
|
local=dict(nome=PROVEDORES['local'], pronto=bool(local['url']), detalhe=local['modelo'] or local['url'],
|
||||||
|
motivo='' if local['url'] else 'configure o endereço', url=local['url'], modelo=local['modelo'],
|
||||||
|
chave=bool(local['chave'])),
|
||||||
|
s10=dict(nome=PROVEDORES['s10'], pronto=bool(s10), detalhe='Qwen3 0.6B (fraco para criar)',
|
||||||
|
motivo='' if s10 else 'indisponível'))
|
||||||
|
|
||||||
|
def chamar(self, ia, prompt, schema):
|
||||||
|
if ia == 'gemini':
|
||||||
|
return gemini.gerar_json(dict(contents=[dict(role='user', parts=[dict(text=prompt)])], generationConfig=dict(
|
||||||
|
temperature=0.8, maxOutputTokens=4096, responseMimeType='application/json', responseSchema=schema)))
|
||||||
|
if ia == 'local':
|
||||||
|
url, modelo, chave = self.config['local']['url'].rstrip('/'), self.config['local']['modelo'], self.config['local']['chave']
|
||||||
|
if not url:
|
||||||
|
raise ValueError('Configure o endereço da IA local')
|
||||||
|
cab = {'Content-Type': 'application/json'}
|
||||||
|
if chave:
|
||||||
|
cab['Authorization'] = 'Bearer ' + chave
|
||||||
|
extra = {}
|
||||||
|
else:
|
||||||
|
url, cab = self.s10()
|
||||||
|
if not url:
|
||||||
|
raise ValueError('Qwen do S10 indisponível')
|
||||||
|
modelo, extra = 'local', dict(chat_template_kwargs=dict(enable_thinking=False))
|
||||||
|
prompt += ' /no_think'
|
||||||
|
payload = dict(model=modelo or 'local', temperature=0.8, max_tokens=3000, messages=[
|
||||||
|
dict(role='system', content='Responda somente com JSON válido no formato pedido.'),
|
||||||
|
dict(role='user', content=prompt)],
|
||||||
|
response_format=dict(type='json_schema', json_schema=dict(name='receita', strict=True, schema=schema)), **extra)
|
||||||
|
if url.endswith('/v1'):
|
||||||
|
url = url[:-3]
|
||||||
|
req = Request(url + '/v1/chat/completions', data=json.dumps(payload).encode(), headers=cab)
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=900) as resp: # a IA local pode levar minutos
|
||||||
|
dados = json.load(resp)
|
||||||
|
except HTTPError as e:
|
||||||
|
raise ValueError(f'A IA local recusou o pedido (HTTP {e.code})') from None
|
||||||
|
except (URLError, TimeoutError, OSError):
|
||||||
|
raise ValueError('Não consegui falar com a IA local: confira se ela está ligada e o endereço') from None
|
||||||
|
return _extrair_json(dados['choices'][0]['message']['content'])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ criar
|
||||||
|
def base(self, tipo, nome):
|
||||||
|
if not nome:
|
||||||
|
return None
|
||||||
|
if str(nome).startswith('criado:'):
|
||||||
|
item = self.item(int(str(nome)[7:]))
|
||||||
|
return item['receita'] if item and item['tipo'] == tipo else None
|
||||||
|
return (EXPRESSOES_BASE if tipo == 'expressao' else ATIVIDADES_BASE).get(nome)
|
||||||
|
|
||||||
|
def criar(self, tipo, pedido, ia, base=None):
|
||||||
|
if tipo not in TIPOS:
|
||||||
|
raise ValueError('Tipo inválido')
|
||||||
|
if ia not in PROVEDORES:
|
||||||
|
raise ValueError('IA inválida')
|
||||||
|
if not isinstance(pedido, str) or not pedido.strip() or len(pedido) > 600:
|
||||||
|
raise ValueError('Descreva o pedido em até 600 caracteres')
|
||||||
|
if not self.provedores()[ia]['pronto']:
|
||||||
|
raise ValueError(PROVEDORES[ia] + ': ' + self.provedores()[ia]['motivo'])
|
||||||
|
with self.lock:
|
||||||
|
if self.ocupado:
|
||||||
|
raise ValueError('Já tem uma criação em andamento')
|
||||||
|
self.ocupado = dict(tipo=tipo, ia=ia, desde=time.time(), pedido=pedido.strip())
|
||||||
|
self.erro = ''
|
||||||
|
receita_base = self.base(tipo, base) if tipo != 'figurinha' else None
|
||||||
|
threading.Thread(target=self._criar, args=(tipo, pedido.strip(), ia, receita_base), daemon=True).start()
|
||||||
|
self.avisar()
|
||||||
|
return self.status()
|
||||||
|
|
||||||
|
def _criar(self, tipo, pedido, ia, base):
|
||||||
|
try:
|
||||||
|
schema = dict(expressao=SCHEMA_EXPRESSAO, atividade=SCHEMA_ATIVIDADE, figurinha=SCHEMA_FIGURINHA)[tipo]
|
||||||
|
bruto = self.chamar(ia, montar_prompt(tipo, pedido, base), schema)
|
||||||
|
if tipo == 'expressao':
|
||||||
|
receita = validar_expressao(bruto)
|
||||||
|
elif tipo == 'atividade':
|
||||||
|
receita = validar_atividade(bruto)
|
||||||
|
else:
|
||||||
|
emoji = _emoji(bruto.pop('emoji', '')) if isinstance(bruto, dict) else '🎨'
|
||||||
|
desenho = validar_desenho(bruto)
|
||||||
|
receita = dict(nome=desenho['nome'][:30], emoji=emoji, desenho=desenho)
|
||||||
|
with self.lock, self.db() as db:
|
||||||
|
ident = db.execute('INSERT INTO criacoes(horario, tipo, nome, emoji, receita, ia, pedido) VALUES (?,?,?,?,?,?,?)',
|
||||||
|
(time.time(), tipo, receita['nome'], receita['emoji'],
|
||||||
|
json.dumps(receita, ensure_ascii=False), ia, pedido)).lastrowid
|
||||||
|
db.execute('DELETE FROM criacoes WHERE id NOT IN (SELECT id FROM criacoes ORDER BY id DESC LIMIT ?)', (MAX_CRIACOES,))
|
||||||
|
self.tocar(ident) # mostra na hora no celular
|
||||||
|
except Exception as e:
|
||||||
|
with self.lock:
|
||||||
|
self.erro = str(e)[:200]
|
||||||
|
finally:
|
||||||
|
with self.lock:
|
||||||
|
self.ocupado = None
|
||||||
|
self.avisar()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ biblioteca
|
||||||
|
def item(self, ident):
|
||||||
|
with self.db() as db:
|
||||||
|
row = db.execute('SELECT id, horario, tipo, nome, emoji, receita, ia, pedido FROM criacoes WHERE id=? AND excluida=0',
|
||||||
|
(ident,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return dict(id=row[0], horario=row[1], tipo=row[2], nome=row[3], emoji=row[4], receita=json.loads(row[5]),
|
||||||
|
ia=row[6], pedido=row[7])
|
||||||
|
|
||||||
|
def lista(self):
|
||||||
|
with self.db() as db:
|
||||||
|
rows = db.execute('SELECT id FROM criacoes WHERE excluida=0 ORDER BY id DESC LIMIT ?', (MAX_CRIACOES,)).fetchall()
|
||||||
|
return [self.item(r[0]) for r in rows]
|
||||||
|
|
||||||
|
def tocar(self, ident):
|
||||||
|
item = self.item(ident) if type(ident) is int else None
|
||||||
|
if not item:
|
||||||
|
raise ValueError('Criação não encontrada')
|
||||||
|
r = item['receita']
|
||||||
|
if item['tipo'] == 'expressao':
|
||||||
|
self.enfileirar('expressao_criada ' + json.dumps(r, ensure_ascii=False, separators=(',', ':')))
|
||||||
|
elif item['tipo'] == 'atividade':
|
||||||
|
self.enfileirar('atividade_criada ' + json.dumps(r, ensure_ascii=False, separators=(',', ':')))
|
||||||
|
else:
|
||||||
|
from treinamento import validar_receita
|
||||||
|
if self.treinamento is None:
|
||||||
|
raise ValueError('Biblioteca de reações indisponível')
|
||||||
|
passo = dict(olhos='aberto', boca='sorriso', cor='#5cd6ff', dx=0, dy=0, escala=1, duracao=2)
|
||||||
|
receita = validar_receita(dict(nome=r['nome'], motivo='Criada no estúdio', simbolo='nenhum', legenda='',
|
||||||
|
passos=[passo, dict(passo, escala=1.05)], figurinha=r['desenho']))
|
||||||
|
with self.treinamento.lock:
|
||||||
|
self.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=receita)
|
||||||
|
return dict(ok=True)
|
||||||
|
|
||||||
|
def apagar(self, ident):
|
||||||
|
if type(ident) is not int:
|
||||||
|
raise ValueError('ID inválido')
|
||||||
|
with self.db() as db:
|
||||||
|
db.execute('UPDATE criacoes SET excluida=1 WHERE id=?', (ident,))
|
||||||
|
self.avisar()
|
||||||
|
return dict(ok=True)
|
||||||
|
|
||||||
|
def configurar(self, dados):
|
||||||
|
local = dados.get('local')
|
||||||
|
if not isinstance(local, dict):
|
||||||
|
raise ValueError('Esperava a configuração da IA local')
|
||||||
|
novo = dict(self.config['local'])
|
||||||
|
for k in ('url', 'modelo', 'chave'):
|
||||||
|
if k in local:
|
||||||
|
v = local[k]
|
||||||
|
if not isinstance(v, str) or len(v) > 300:
|
||||||
|
raise ValueError(k + ' inválido')
|
||||||
|
if k == 'url' and v and not re.fullmatch(r'https?://[\w.\-:]+(/v1)?/?', v.strip()):
|
||||||
|
raise ValueError('Endereço deve ser como http://192.168.1.50:1234')
|
||||||
|
novo[k] = v.strip()
|
||||||
|
with self.lock:
|
||||||
|
self.config['local'] = novo
|
||||||
|
gravar_atomico(self.caminho_config, self.config, privado=True)
|
||||||
|
self.avisar()
|
||||||
|
return self.status()
|
||||||
|
|
||||||
|
def testar(self, ia):
|
||||||
|
if ia == 'gemini':
|
||||||
|
if not os.environ.get('GEMINI_API_KEY'):
|
||||||
|
raise ValueError('Sem chave Gemini (Ajustes › Inteligência artificial)')
|
||||||
|
return dict(ok=True, mensagem='Gemini configurado: ' + gemini.MODELO)
|
||||||
|
url = self.config['local']['url'].rstrip('/') if ia == 'local' else (self.s10()[0] or '')
|
||||||
|
if not url:
|
||||||
|
raise ValueError('Configure o endereço primeiro')
|
||||||
|
if url.endswith('/v1'):
|
||||||
|
url = url[:-3]
|
||||||
|
cab = {}
|
||||||
|
if ia == 'local' and self.config['local']['chave']:
|
||||||
|
cab['Authorization'] = 'Bearer ' + self.config['local']['chave']
|
||||||
|
inicio = time.monotonic()
|
||||||
|
try:
|
||||||
|
with urlopen(Request(url + '/v1/models', headers=cab), timeout=8) as resp:
|
||||||
|
modelos = [m.get('id') for m in json.load(resp).get('data', [])]
|
||||||
|
except (URLError, HTTPError, OSError, ValueError):
|
||||||
|
raise ValueError('Não respondeu em ' + url) from None
|
||||||
|
return dict(ok=True, mensagem='Respondeu em %.1f s · modelos: %s' % (time.monotonic() - inicio, ', '.join(map(str, modelos[:5])) or '—'))
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
with self.lock:
|
||||||
|
ocupado = dict(self.ocupado) if self.ocupado else None
|
||||||
|
return dict(ocupado=ocupado, erro=self.erro, provedores=self.provedores(), criacoes=self.lista(),
|
||||||
|
bases=dict(expressao=[dict(id=k, nome=v['nome'], emoji=v['emoji']) for k, v in EXPRESSOES_BASE.items()],
|
||||||
|
atividade=[dict(id=k, nome=v['nome'], emoji=v['emoji']) for k, v in ATIVIDADES_BASE.items()]))
|
||||||
|
|
||||||
|
def avisar(self):
|
||||||
|
self.publicar('criacao', self.status())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ HTTP
|
||||||
|
def rotas(self, h, metodo):
|
||||||
|
rota = h.path.split('?', 1)[0]
|
||||||
|
if not rota.startswith('/criacao/'):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if metodo == 'GET' and rota == '/criacao/status':
|
||||||
|
resultado = self.status()
|
||||||
|
elif metodo == 'POST':
|
||||||
|
d = ler_json(h, 4096)
|
||||||
|
if rota == '/criacao/criar':
|
||||||
|
resultado = self.criar(d.get('tipo'), d.get('pedido'), d.get('ia'), d.get('base') or None)
|
||||||
|
elif rota == '/criacao/tocar':
|
||||||
|
resultado = self.tocar(d.get('id'))
|
||||||
|
elif rota == '/criacao/apagar':
|
||||||
|
resultado = self.apagar(d.get('id'))
|
||||||
|
elif rota == '/criacao/config':
|
||||||
|
resultado = self.configurar(d)
|
||||||
|
elif rota == '/criacao/testar':
|
||||||
|
resultado = self.testar(d.get('ia'))
|
||||||
|
else:
|
||||||
|
raise ValueError('Rota inválida')
|
||||||
|
else:
|
||||||
|
raise ValueError('Método inválido')
|
||||||
|
h.enviar(json.dumps(resultado, ensure_ascii=False))
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
h.enviar(json.dumps(dict(erro=str(e)), ensure_ascii=False), codigo=400)
|
||||||
|
return True
|
||||||
-1353
File diff suppressed because it is too large
Load Diff
-153
@@ -1,153 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Visão Geral · Central Carinha IA</title>
|
|
||||||
<link rel="stylesheet" href="/dashboard.css">
|
|
||||||
<script src="/dashboard.js" defer></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
|
|
||||||
<div>
|
|
||||||
<h1>Central do Carinha</h1>
|
|
||||||
<p style="margin:4px 0 0;font-size:15px">Supervisão em tempo real do Galaxy S10, telemetria de sensores, cérebro Gemini e robótica.</p>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:10px;align-items:center">
|
|
||||||
<span class="badge ativo" id="overviewSystemBadge"><span class="badge-dot"></span> Sistema Ativo</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overview-grid">
|
|
||||||
<!-- Card do Rosto do Robô com Visualizador Interativo -->
|
|
||||||
<section class="card overview-face-card">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
|
||||||
<h2>
|
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
|
||||||
Expressão do Personagem
|
|
||||||
</h2>
|
|
||||||
<span class="badge" style="font-size:11.5px">Prévia Sincronizada</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<iframe class="overview-face" id="overviewFace" title="Prévia do rosto do robô" src="/carinha.html?espelho=1&audio=0"></iframe>
|
|
||||||
|
|
||||||
<!-- Mood Switches Rápidos -->
|
|
||||||
<div style="margin:8px 0 16px">
|
|
||||||
<label style="font-size:12px;text-transform:uppercase;letter-spacing:0.05em;color:var(--muito-fraco);margin-bottom:8px;display:block">Expressão Rápida</label>
|
|
||||||
<div class="toolbar" style="margin:0;gap:8px">
|
|
||||||
<button type="button" class="btn-mood" data-gesto="feliz" style="padding:7px 12px;font-size:13px">😊 Feliz</button>
|
|
||||||
<button type="button" class="btn-mood" data-gesto="surpreso" style="padding:7px 12px;font-size:13px">😲 Surpreso</button>
|
|
||||||
<button type="button" class="btn-mood" data-gesto="coracao" style="padding:7px 12px;font-size:13px">💖 Amor</button>
|
|
||||||
<button type="button" class="btn-mood" data-gesto="piscar" style="padding:7px 12px;font-size:13px">😉 Piscar</button>
|
|
||||||
<button type="button" class="btn-mood" data-gesto="dormir" style="padding:7px 12px;font-size:13px">🌙 Dormir</button>
|
|
||||||
<button type="button" class="btn-mood" data-gesto="acordar" style="padding:7px 12px;font-size:13px;border-color:var(--ciano)">☀️ Acordar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions" style="margin-top:auto;border-top:1px solid var(--borda);padding-top:16px">
|
|
||||||
<a href="/painel" class="primary" style="display:inline-flex;align-items:center;gap:6px;padding:9px 16px;border-radius:var(--raio-sm);font-weight:700">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
|
||||||
Abrir Câmera & Rosto
|
|
||||||
</a>
|
|
||||||
<button id="overviewReload" type="button">🔄 Recarregar Tela Celular</button>
|
|
||||||
<button id="overviewSfxHappy" type="button" title="Testar som procedural alegre">🎵 Tocar Bip</button>
|
|
||||||
</div>
|
|
||||||
<p id="overviewMessage" role="status" style="margin-top:10px;font-size:13px;min-height:20px;color:var(--ciano)"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Grade de Métricas & Telemetria -->
|
|
||||||
<div style="display:flex;flex-direction:column;gap:18px">
|
|
||||||
<div class="metrics">
|
|
||||||
<section class="card metric">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
||||||
<p>Galaxy S10</p>
|
|
||||||
<span style="font-size:18px">📱</span>
|
|
||||||
</div>
|
|
||||||
<strong id="overviewPhone">—</strong>
|
|
||||||
<small id="overviewIP">Aguardando conexão</small>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card metric">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
||||||
<p>Bateria</p>
|
|
||||||
<span style="font-size:18px">🔋</span>
|
|
||||||
</div>
|
|
||||||
<strong id="overviewBattery">—</strong>
|
|
||||||
<small id="overviewCharge">Aguardando dados</small>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card metric">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
||||||
<p>Inteligência IA</p>
|
|
||||||
<span style="font-size:18px">🧠</span>
|
|
||||||
</div>
|
|
||||||
<strong id="overviewAI" style="font-size:22px">—</strong>
|
|
||||||
<small id="overviewModel">Verificando API</small>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card metric">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
||||||
<p>Expressões</p>
|
|
||||||
<span style="font-size:18px">🎨</span>
|
|
||||||
</div>
|
|
||||||
<strong id="overviewReactions">—</strong>
|
|
||||||
<small>Reações na biblioteca</small>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feed de Atividades Recentes -->
|
|
||||||
<section class="card" style="flex:1">
|
|
||||||
<h2>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
|
||||||
Telemetria & Eventos
|
|
||||||
</h2>
|
|
||||||
<div id="overviewFeed" style="display:flex;flex-direction:column;gap:10px;margin-top:12px;font-size:13px;max-height:165px;overflow-y:auto">
|
|
||||||
<div style="display:flex;align-items:center;gap:10px;color:var(--fraco)">
|
|
||||||
<span class="badge-dot" style="background:var(--ciano)"></span>
|
|
||||||
<span>Central inicializada no Orange Pi Zero 3</span>
|
|
||||||
<span style="margin-left:auto;font-size:11px;font-family:monospace">agora</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Links Rápidos de Acesso com Visual Moderno -->
|
|
||||||
<h2 style="margin:36px 0 16px">Navegação Rápida</h2>
|
|
||||||
<div class="quick-links">
|
|
||||||
<a class="card" href="/conversa">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
||||||
<h2>💬 Conversar</h2>
|
|
||||||
<span class="badge" style="font-size:11px">Voz & Texto</span>
|
|
||||||
</div>
|
|
||||||
<p>Interaja por voz e texto com o Carinha usando Gemini e memória de longo prazo.</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="card" href="/emojis">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
||||||
<h2>✨ Emojis & Reações</h2>
|
|
||||||
<span class="badge" style="font-size:11px">Estúdio SVG</span>
|
|
||||||
</div>
|
|
||||||
<p>Desenhe novas reações com inteligência artificial e teste diretamente nos olhos do robô.</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="card" href="/braco">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
||||||
<h2>🦾 Braço Robótico</h2>
|
|
||||||
<span class="badge" style="font-size:11px">Cinemática</span>
|
|
||||||
</div>
|
|
||||||
<p>Controle ângulos, crie coreografias articuladas e coordene 8 canais de movimento.</p>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a class="card" href="/treinamento">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
||||||
<h2>📚 Treinamento</h2>
|
|
||||||
<span class="badge" style="font-size:11px">Personalidade</span>
|
|
||||||
</div>
|
|
||||||
<p>Defina rotinas, hábitos, prompts e consulte o diário de interações do robô.</p>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/overview.js" defer></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-459
@@ -1,459 +0,0 @@
|
|||||||
/**
|
|
||||||
* dashboard.js — Shell global da Central de Controle do Carinha
|
|
||||||
* Injeta barra lateral unificada, telemetria em tempo real no topo,
|
|
||||||
* controle de áudio robótico e dock de comandos rápidos.
|
|
||||||
*/
|
|
||||||
(() => {
|
|
||||||
if (window.location.search.includes('clean=1')) {
|
|
||||||
document.documentElement.classList.add('is-clean-embed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carrega dinamicamente o sintetizador de áudio caso não esteja presente
|
|
||||||
if (!window.RobotAudio) {
|
|
||||||
const audioScript = document.createElement('script');
|
|
||||||
audioScript.src = '/audio-robot.js';
|
|
||||||
audioScript.defer = true;
|
|
||||||
document.head.appendChild(audioScript);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = [
|
|
||||||
{
|
|
||||||
title: 'Principal',
|
|
||||||
items: [
|
|
||||||
['/dashboard', 'Visão geral', 'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z'],
|
|
||||||
['/painel', 'Câmera e rosto', 'M3 6h12v12H3z M15 10l6-4v12l-6-4']
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Inteligência & IA',
|
|
||||||
items: [
|
|
||||||
['/conversa', 'Conversar com IA', 'M3 4h18v13H9l-6 4z'],
|
|
||||||
['/emojis', 'Emojis & Reações', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18 M8 9h1 M15 9h1 M7 14q5 5 10 0'],
|
|
||||||
['/treinamento', 'Treinamento', 'M3 8l9-5 9 5-9 5z M6 11v6q6 5 12 0v-6']
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Robótica & Hardware',
|
|
||||||
items: [
|
|
||||||
['/braco', 'Braço robótico', 'M3 21h18 M8 21v-5l7-5-3-6 M9 4h6v4H9z M14 10h4v4h-4z'],
|
|
||||||
['/servos', 'Servos PCA9685', 'M5 3v18 M12 3v18 M19 3v18 M2 8h6 M9 16h6 M16 7h6']
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Configurações',
|
|
||||||
items: [
|
|
||||||
['/api', 'API Gemini & Modelo', 'M8 5L2 12l6 7 M16 5l6 7-6 7 M14 3l-4 18']
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const pathname = location.pathname === '/' ? '/dashboard' : location.pathname;
|
|
||||||
document.body.classList.add('dashboard');
|
|
||||||
|
|
||||||
const frame = document.createElement('div');
|
|
||||||
frame.className = 'app-frame';
|
|
||||||
|
|
||||||
const main = document.createElement('main');
|
|
||||||
main.className = 'app-content';
|
|
||||||
main.id = 'appContent';
|
|
||||||
|
|
||||||
// Move elementos existentes do body para o container principal
|
|
||||||
for (const child of [...document.body.childNodes]) {
|
|
||||||
if (!(child.nodeType === 1 && ['SCRIPT', 'STYLE', 'LINK'].includes(child.tagName))) {
|
|
||||||
main.append(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sidebar moderna e estilizada
|
|
||||||
const sidebar = document.createElement('aside');
|
|
||||||
sidebar.className = 'app-sidebar';
|
|
||||||
sidebar.innerHTML = `
|
|
||||||
<a href="/dashboard" class="brand" title="Central Carinha">
|
|
||||||
<span class="brand-face" aria-hidden="true"><i></i><i></i></span>
|
|
||||||
<span>Carinha <span style="font-size:12px;color:var(--ciano);font-weight:600;letter-spacing:0.05em;vertical-align:middle;background:rgba(92,214,255,0.12);padding:2px 6px;border-radius:6px;border:1px solid rgba(92,214,255,0.25)">IA</span></span>
|
|
||||||
</a>
|
|
||||||
<nav aria-label="Navegação principal" id="sidebarNav"></nav>
|
|
||||||
<div class="sidebar-footer">
|
|
||||||
<div><strong>Orange Pi Zero 3</strong></div>
|
|
||||||
<div style="font-family:'JetBrains Mono',monospace;font-size:11.5px;color:var(--muito-fraco)" id="shellServer">${location.host}</div>
|
|
||||||
<div style="margin-top:6px;display:flex;align-items:center;gap:6px">
|
|
||||||
<span class="badge-dot" style="background:#10b981;color:#10b981"></span>
|
|
||||||
<span style="font-size:11px;color:var(--fraco)">Serviço operacional</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const nav = sidebar.querySelector('#sidebarNav');
|
|
||||||
for (const sec of sections) {
|
|
||||||
const title = document.createElement('div');
|
|
||||||
title.className = 'nav-section-title';
|
|
||||||
title.textContent = sec.title;
|
|
||||||
nav.append(title);
|
|
||||||
|
|
||||||
for (const [url, label, path] of sec.items) {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${path}"/></svg><span>${label}</span>`;
|
|
||||||
if (url === pathname) {
|
|
||||||
a.setAttribute('aria-current', 'page');
|
|
||||||
}
|
|
||||||
a.addEventListener('click', () => {
|
|
||||||
if (window.RobotAudio) window.RobotAudio.click();
|
|
||||||
});
|
|
||||||
nav.append(a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Barra de ferramentas superior (Topbar)
|
|
||||||
const top = document.createElement('header');
|
|
||||||
top.className = 'app-topbar';
|
|
||||||
top.innerHTML = `
|
|
||||||
<div class="topbar-left">
|
|
||||||
<button class="menu-button" type="button" aria-label="Abrir menu" aria-expanded="false">☰</button>
|
|
||||||
<span class="server-label" style="font-size:14px;font-weight:600;color:var(--texto);display:flex;align-items:center;gap:8px">
|
|
||||||
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--ciano);box-shadow:0 0 8px var(--ciano)"></span>
|
|
||||||
Central de Controle
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="topbar-telemetry">
|
|
||||||
<button id="pipToggleBtn" type="button" title="Mini-monitor flutuante (Picture-in-Picture) que fica fixo em qualquer página" style="padding:6px 12px;font-size:12.5px;border-radius:20px;border-color:var(--borda)">
|
|
||||||
📺 Monitor PiP
|
|
||||||
</button>
|
|
||||||
<button id="quickRemoteBtn" type="button" title="Atalhos rápidos de expressão" style="padding:6px 12px;font-size:12.5px;border-radius:20px;border-color:var(--borda)">
|
|
||||||
⚡ Gestos
|
|
||||||
</button>
|
|
||||||
<button id="sfxToggleBtn" type="button" title="Ativar/Desativar efeitos sonoros robóticos" style="padding:6px 12px;font-size:12.5px;border-radius:20px">
|
|
||||||
🔊 Som: Ativo
|
|
||||||
</button>
|
|
||||||
<span class="badge" id="shellConnection">
|
|
||||||
<span class="badge-dot"></span>
|
|
||||||
<span id="shellConnectionText">Verificando celular…</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Mini-Monitor Flutuante (Picture-in-Picture)
|
|
||||||
const pipWidget = document.createElement('div');
|
|
||||||
pipWidget.id = 'floatingPipWidget';
|
|
||||||
pipWidget.className = 'floating-pip-widget';
|
|
||||||
pipWidget.style.display = 'none';
|
|
||||||
pipWidget.innerHTML = `
|
|
||||||
<div class="floating-pip-header">
|
|
||||||
<div class="pip-title">
|
|
||||||
<span class="badge-dot" style="background:var(--ciano)"></span>
|
|
||||||
<span>Carinha Monitor</span>
|
|
||||||
</div>
|
|
||||||
<div class="floating-pip-actions">
|
|
||||||
<button id="pipMinimizeBtn" type="button" title="Minimizar ou Expandir">_</button>
|
|
||||||
<button id="pipCloseBtn" type="button" title="Fechar">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="floating-pip-body" id="pipBody">
|
|
||||||
<iframe src="/carinha.html?espelho=1&audio=0" title="Monitor Flutuante do Carinha"></iframe>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(pipWidget);
|
|
||||||
|
|
||||||
const pipToggleBtn = top.querySelector('#pipToggleBtn');
|
|
||||||
const pipMinBtn = pipWidget.querySelector('#pipMinimizeBtn');
|
|
||||||
const pipCloseBtn = pipWidget.querySelector('#pipCloseBtn');
|
|
||||||
|
|
||||||
function togglePip(force) {
|
|
||||||
const isVisible = pipWidget.style.display === 'flex';
|
|
||||||
const next = force !== undefined ? force : !isVisible;
|
|
||||||
pipWidget.style.display = next ? 'flex' : 'none';
|
|
||||||
pipToggleBtn.style.color = next ? 'var(--ciano)' : 'var(--texto)';
|
|
||||||
pipToggleBtn.style.borderColor = next ? 'var(--ciano)' : 'var(--borda)';
|
|
||||||
localStorage.setItem('carinha_pip', next ? 'true' : 'false');
|
|
||||||
if (next && window.RobotAudio) window.RobotAudio.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
pipToggleBtn.onclick = () => togglePip();
|
|
||||||
pipCloseBtn.onclick = () => togglePip(false);
|
|
||||||
pipMinBtn.onclick = () => {
|
|
||||||
const isMin = pipWidget.classList.toggle('minimized');
|
|
||||||
pipMinBtn.textContent = isMin ? '□' : '_';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Se o usuário já deixou o PiP ativo anteriormente
|
|
||||||
if (localStorage.getItem('carinha_pip') === 'true') {
|
|
||||||
pipWidget.style.display = 'flex';
|
|
||||||
pipToggleBtn.style.color = 'var(--ciano)';
|
|
||||||
pipToggleBtn.style.borderColor = 'var(--ciano)';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Menu móvel
|
|
||||||
const menuBtn = top.querySelector('.menu-button');
|
|
||||||
menuBtn.onclick = () => {
|
|
||||||
const open = document.body.classList.toggle('menu-open');
|
|
||||||
menuBtn.setAttribute('aria-expanded', String(open));
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
document.body.classList.remove('menu-open');
|
|
||||||
menuBtn.setAttribute('aria-expanded', 'false');
|
|
||||||
fecharQuickRemote();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
main.addEventListener('click', () => {
|
|
||||||
document.body.classList.remove('menu-open');
|
|
||||||
menuBtn.setAttribute('aria-expanded', 'false');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Modal / Dropdown de Gestos Rápidos
|
|
||||||
const quickRemote = document.createElement('div');
|
|
||||||
quickRemote.id = 'quickRemoteBar';
|
|
||||||
quickRemote.style.cssText = `
|
|
||||||
position: fixed; top: 72px; right: 28px; z-index: 90;
|
|
||||||
background: rgba(14, 24, 38, 0.95); backdrop-filter: blur(16px);
|
|
||||||
border: 1px solid var(--borda-brilho); border-radius: 14px;
|
|
||||||
padding: 16px; box-shadow: var(--sombra); display: none;
|
|
||||||
flex-direction: column; gap: 10px; width: 290px;
|
|
||||||
`;
|
|
||||||
quickRemote.innerHTML = `
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--borda);padding-bottom:8px">
|
|
||||||
<strong style="font-size:14px;color:var(--texto)">⚡ Disparo Rápido</strong>
|
|
||||||
<button id="closeQuickRemote" type="button" style="padding:3px 8px;font-size:12px;border:none;background:transparent;color:var(--fraco)">✕</button>
|
|
||||||
</div>
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:4px">
|
|
||||||
<button data-gesto="feliz" style="padding:8px;font-size:12px">😊 Feliz</button>
|
|
||||||
<button data-gesto="surpreso" style="padding:8px;font-size:12px">😲 Surpreso</button>
|
|
||||||
<button data-gesto="coracao" style="padding:8px;font-size:12px">❤️ Coração</button>
|
|
||||||
<button data-gesto="bravo" style="padding:8px;font-size:12px">😠 Bravo</button>
|
|
||||||
<button data-gesto="piscar" style="padding:8px;font-size:12px">😉 Piscar</button>
|
|
||||||
<button data-gesto="piscadinha" style="padding:8px;font-size:12px">✨ Piscar 2</button>
|
|
||||||
<button data-gesto="acordar" style="padding:8px;font-size:12px" class="primary">☀️ Acordar</button>
|
|
||||||
<button data-gesto="dormir" style="padding:8px;font-size:12px">🌙 Dormir</button>
|
|
||||||
</div>
|
|
||||||
<button data-gesto="parar" class="danger" style="width:100%;font-size:12px;padding:8px;margin-top:4px">🛑 Parar Tudo (E-Stop)</button>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(quickRemote);
|
|
||||||
|
|
||||||
function toggleQuickRemote() {
|
|
||||||
const isVisible = quickRemote.style.display === 'flex';
|
|
||||||
quickRemote.style.display = isVisible ? 'none' : 'flex';
|
|
||||||
if (!isVisible && window.RobotAudio) window.RobotAudio.click();
|
|
||||||
}
|
|
||||||
function fecharQuickRemote() {
|
|
||||||
quickRemote.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
top.querySelector('#quickRemoteBtn').onclick = toggleQuickRemote;
|
|
||||||
quickRemote.querySelector('#closeQuickRemote').onclick = fecharQuickRemote;
|
|
||||||
|
|
||||||
quickRemote.addEventListener('click', async (e) => {
|
|
||||||
const btn = e.target.closest('button[data-gesto]');
|
|
||||||
if (!btn) return;
|
|
||||||
const gesto = btn.getAttribute('data-gesto');
|
|
||||||
try {
|
|
||||||
if (window.RobotAudio) window.RobotAudio.tocarGesto(gesto);
|
|
||||||
await fetch('/gesto/' + encodeURIComponent(gesto));
|
|
||||||
btn.style.transform = 'scale(0.95)';
|
|
||||||
setTimeout(() => btn.style.transform = '', 150);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Falha ao enviar gesto:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Botão de Áudio
|
|
||||||
const sfxBtn = top.querySelector('#sfxToggleBtn');
|
|
||||||
function atualizarBotaoSfx() {
|
|
||||||
if (!window.RobotAudio) return;
|
|
||||||
sfxBtn.textContent = window.RobotAudio.enabled ? '🔊 Som: Ativo' : '🔇 Som: Mudo';
|
|
||||||
sfxBtn.style.color = window.RobotAudio.enabled ? 'var(--ciano)' : 'var(--fraco)';
|
|
||||||
}
|
|
||||||
sfxBtn.onclick = () => {
|
|
||||||
if (window.RobotAudio) {
|
|
||||||
window.RobotAudio.toggle();
|
|
||||||
atualizarBotaoSfx();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
setTimeout(atualizarBotaoSfx, 400);
|
|
||||||
|
|
||||||
frame.append(top, main);
|
|
||||||
document.body.prepend(sidebar, frame);
|
|
||||||
|
|
||||||
// Monitoramento periódico de status do celular e servidor
|
|
||||||
async function checkStatus() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/app/status', { cache: 'no-store', signal: AbortSignal.timeout(3000) });
|
|
||||||
if (!r.ok) throw Error();
|
|
||||||
const s = await r.json();
|
|
||||||
const badge = document.getElementById('shellConnection');
|
|
||||||
const text = document.getElementById('shellConnectionText') || badge;
|
|
||||||
|
|
||||||
const bat = s.estado?.bateria?.nivel;
|
|
||||||
const carregando = s.estado?.bateria?.carregando;
|
|
||||||
let batInfo = '';
|
|
||||||
if (bat != null) {
|
|
||||||
batInfo = ` · ${bat}%${carregando ? ' ⚡' : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s.online) {
|
|
||||||
text.textContent = `S10 Online${batInfo}`;
|
|
||||||
badge.className = 'badge ativo';
|
|
||||||
} else {
|
|
||||||
text.textContent = 'S10 Desconectado';
|
|
||||||
badge.className = 'badge offline';
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const badge = document.getElementById('shellConnection');
|
|
||||||
if (badge) {
|
|
||||||
badge.className = 'badge danger';
|
|
||||||
const text = document.getElementById('shellConnectionText') || badge;
|
|
||||||
text.textContent = 'Servidor Offline';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkStatus();
|
|
||||||
setInterval(checkStatus, 4000);
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// BARRA DE MONITORAMENTO NO TOPO — câmera e rosto sempre visíveis
|
|
||||||
// Com botão de expandir/recolher cada painel individualmente
|
|
||||||
// =========================================================================
|
|
||||||
const watchBar = document.createElement('div');
|
|
||||||
watchBar.id = 'watchBar';
|
|
||||||
watchBar.className = 'watch-bar';
|
|
||||||
|
|
||||||
// Lê estado salvo (expandido ou não) e tamanho
|
|
||||||
const watchState = (() => {
|
|
||||||
try { return JSON.parse(localStorage.getItem('carinha_watch') || '{}'); }
|
|
||||||
catch { return {}; }
|
|
||||||
})();
|
|
||||||
|
|
||||||
function saveWatchState() {
|
|
||||||
localStorage.setItem('carinha_watch', JSON.stringify(watchState));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Altura dos painéis: compacto ou expandido
|
|
||||||
const HEIGHTS = { compact: 90, medium: 200, full: 340 };
|
|
||||||
|
|
||||||
function makePanelHTML(id, icon, label, src) {
|
|
||||||
const h = watchState[id + '_size'] || 'compact';
|
|
||||||
const hidden = watchState[id + '_hidden'] === true;
|
|
||||||
return `
|
|
||||||
<div class="watch-panel ${hidden ? 'watch-panel-hidden' : ''}" id="wp_${id}" data-id="${id}" data-size="${h}">
|
|
||||||
<div class="watch-panel-header">
|
|
||||||
<span class="watch-panel-label">${icon} ${label}</span>
|
|
||||||
<div class="watch-panel-actions">
|
|
||||||
<button class="wp-btn wp-shrink" title="Recolher" data-id="${id}">−</button>
|
|
||||||
<button class="wp-btn wp-expand" title="Expandir" data-id="${id}">+</button>
|
|
||||||
<button class="wp-btn wp-hide" title="Ocultar painel" data-id="${id}">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="watch-panel-body" style="height:${HEIGHTS[h]}px">
|
|
||||||
<iframe src="${src}" title="${label}" loading="lazy" sandbox="allow-scripts allow-same-origin"></iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
watchBar.innerHTML = `
|
|
||||||
<div class="watch-bar-inner">
|
|
||||||
<div class="watch-bar-grip" title="Arrastar">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/></svg>
|
|
||||||
<span style="font-size:11px;font-weight:700;letter-spacing:0.06em;color:var(--ciano)">MONITOR</span>
|
|
||||||
</div>
|
|
||||||
<div class="watch-panels" id="watchPanels">
|
|
||||||
${makePanelHTML('camera', '📷', 'Câmera ao Vivo', '/painel?clean=1')}
|
|
||||||
${makePanelHTML('rosto', '🤖', 'Rosto do Carinha', '/carinha.html?espelho=1&audio=0&clean=1')}
|
|
||||||
</div>
|
|
||||||
<div class="watch-bar-tools">
|
|
||||||
<button id="watchAddCamera" class="wp-tool-btn" title="Mostrar/ocultar Câmera">📷</button>
|
|
||||||
<button id="watchAddRosto" class="wp-tool-btn" title="Mostrar/ocultar Rosto">🤖</button>
|
|
||||||
<button id="watchBarToggle" class="wp-tool-btn" title="Ocultar barra inteira" style="margin-top:auto">☰</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(watchBar);
|
|
||||||
|
|
||||||
// Aplica estado inicial de visibilidade dos painéis
|
|
||||||
['camera', 'rosto'].forEach(id => {
|
|
||||||
const panel = watchBar.querySelector('#wp_' + id);
|
|
||||||
if (!panel) return;
|
|
||||||
if (watchState[id + '_hidden'] === true) panel.classList.add('watch-panel-hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Oculta a barra inteira se o usuário fechou antes
|
|
||||||
if (watchState['bar_hidden'] === true) {
|
|
||||||
watchBar.classList.add('watch-bar-collapsed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle da barra inteira
|
|
||||||
watchBar.querySelector('#watchBarToggle').addEventListener('click', () => {
|
|
||||||
const collapsed = watchBar.classList.toggle('watch-bar-collapsed');
|
|
||||||
watchState['bar_hidden'] = collapsed;
|
|
||||||
saveWatchState();
|
|
||||||
if (window.RobotAudio) window.RobotAudio.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle de visibilidade dos painéis individuais
|
|
||||||
watchBar.querySelector('#watchAddCamera').addEventListener('click', () => {
|
|
||||||
const panel = watchBar.querySelector('#wp_camera');
|
|
||||||
const hidden = panel.classList.toggle('watch-panel-hidden');
|
|
||||||
watchState['camera_hidden'] = hidden;
|
|
||||||
saveWatchState();
|
|
||||||
});
|
|
||||||
watchBar.querySelector('#watchAddRosto').addEventListener('click', () => {
|
|
||||||
const panel = watchBar.querySelector('#wp_rosto');
|
|
||||||
const hidden = panel.classList.toggle('watch-panel-hidden');
|
|
||||||
watchState['rosto_hidden'] = hidden;
|
|
||||||
saveWatchState();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Expandir / Recolher tamanho dos painéis
|
|
||||||
watchBar.addEventListener('click', e => {
|
|
||||||
const btn = e.target.closest('.wp-expand, .wp-shrink, .wp-hide');
|
|
||||||
if (!btn) return;
|
|
||||||
const id = btn.dataset.id;
|
|
||||||
const panel = watchBar.querySelector('#wp_' + id);
|
|
||||||
const body = panel.querySelector('.watch-panel-body');
|
|
||||||
const sizes = Object.keys(HEIGHTS);
|
|
||||||
|
|
||||||
if (btn.classList.contains('wp-expand')) {
|
|
||||||
const cur = sizes.indexOf(panel.dataset.size);
|
|
||||||
const next = sizes[Math.min(cur + 1, sizes.length - 1)];
|
|
||||||
panel.dataset.size = next;
|
|
||||||
body.style.height = HEIGHTS[next] + 'px';
|
|
||||||
watchState[id + '_size'] = next;
|
|
||||||
} else if (btn.classList.contains('wp-shrink')) {
|
|
||||||
const cur = sizes.indexOf(panel.dataset.size);
|
|
||||||
const next = sizes[Math.max(cur - 1, 0)];
|
|
||||||
panel.dataset.size = next;
|
|
||||||
body.style.height = HEIGHTS[next] + 'px';
|
|
||||||
watchState[id + '_size'] = next;
|
|
||||||
} else if (btn.classList.contains('wp-hide')) {
|
|
||||||
const hidden = panel.classList.toggle('watch-panel-hidden');
|
|
||||||
watchState[id + '_hidden'] = hidden;
|
|
||||||
}
|
|
||||||
saveWatchState();
|
|
||||||
if (window.RobotAudio) window.RobotAudio.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ajusta padding do conteúdo principal para não sobrepor a watch-bar
|
|
||||||
const appContent = document.getElementById('appContent');
|
|
||||||
function syncWatchBarOffset() {
|
|
||||||
const collapsed = watchBar.classList.contains('watch-bar-collapsed');
|
|
||||||
const h = collapsed ? 0 : watchBar.offsetHeight;
|
|
||||||
if (appContent) appContent.style.paddingTop = h > 0 ? (h + 12) + 'px' : '';
|
|
||||||
document.body.classList.toggle('watch-bar-active', !collapsed);
|
|
||||||
}
|
|
||||||
const ro = new ResizeObserver(syncWatchBarOffset);
|
|
||||||
ro.observe(watchBar);
|
|
||||||
syncWatchBarOffset();
|
|
||||||
|
|
||||||
// Também atualiza o offset ao colapsar/expandir a barra
|
|
||||||
const origToggle = watchBar.querySelector('#watchBarToggle');
|
|
||||||
if (origToggle) {
|
|
||||||
const prevHandler = origToggle.onclick;
|
|
||||||
origToggle.addEventListener('click', () => {
|
|
||||||
setTimeout(syncWatchBarOffset, 320); // após animação
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Publica o Carinha no Orange Pi: compila o painel, empacota o código, faz backup na placa,
|
||||||
|
instala e reinicia o serviço. Configurações e bancos da placa nunca são sobrescritos.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\deploy.ps1 # compila, publica em root@192.168.1.35:/opt/carinha-pc e reinicia
|
||||||
|
.\deploy.ps1 -SoPacote # só gera o pacote local (nada vai para a placa)
|
||||||
|
.\deploy.ps1 -Placa 192.168.1.40 -Usuario carinha
|
||||||
|
.\deploy.ps1 -Ssh orangepi # usa um Host do ~/.ssh/config (chave própria)
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$Placa = '192.168.1.35',
|
||||||
|
[string]$Usuario = 'root',
|
||||||
|
[string]$Destino = '/opt/carinha-pc',
|
||||||
|
[int]$Porta = 8100,
|
||||||
|
[string]$Ssh = '',
|
||||||
|
[switch]$SemCompilar,
|
||||||
|
[switch]$SoPacote
|
||||||
|
)
|
||||||
|
# Comandos nativos (npm, tar, scp, ssh) escrevem avisos no stderr; o PowerShell 5.1 trataria isso
|
||||||
|
# como erro fatal. Por isso o sucesso de cada um é decidido pelo $LASTEXITCODE.
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
Set-Location $PSScriptRoot -ErrorAction Stop
|
||||||
|
|
||||||
|
# Código que vai para a placa. Ficam de fora: configurações (*.json da placa), bancos (*.db),
|
||||||
|
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
|
||||||
|
$arquivos = @(
|
||||||
|
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
|
||||||
|
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py',
|
||||||
|
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
|
||||||
|
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
|
||||||
|
)
|
||||||
|
if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' }
|
||||||
|
|
||||||
|
# Telas antigas (até 0.7) que ainda podem estar na placa; vão para o backup e saem da pasta.
|
||||||
|
$legado = 'dashboard.html dashboard.css dashboard.js overview.js painel.html painel.css painel-controle.js ' +
|
||||||
|
'painel-video.js painel-rastreamento.js painel-conversa.js conversa.html conversa.css emojis.html biblioteca.js ' +
|
||||||
|
'treinamento.html treinamento.js treinamento.css braco.html braco.css braco.js braco-calibracao.js ' +
|
||||||
|
'braco-movimentos.js movimentos-core.js servos.html servos.css servos.js api.html api-painel.js ' +
|
||||||
|
'rastreamento-core.js audio-robot.js'
|
||||||
|
|
||||||
|
if (-not $SemCompilar) {
|
||||||
|
Write-Host '== Compilando o painel (web/)'
|
||||||
|
Push-Location web
|
||||||
|
try {
|
||||||
|
if (-not (Test-Path node_modules)) { npm ci; if ($LASTEXITCODE) { throw 'npm ci falhou' } }
|
||||||
|
npm run check; if ($LASTEXITCODE) { throw 'svelte-check encontrou erros' }
|
||||||
|
npm test; if ($LASTEXITCODE) { throw 'testes do painel falharam' }
|
||||||
|
npm run build; if ($LASTEXITCODE) { throw 'build do painel falhou' }
|
||||||
|
} finally { Pop-Location }
|
||||||
|
}
|
||||||
|
if (-not (Test-Path 'web/dist/index.html')) { throw 'web/dist não existe: rode sem -SemCompilar' }
|
||||||
|
|
||||||
|
$versao = (Get-Content version.json -Raw -ErrorAction Stop | ConvertFrom-Json).versao
|
||||||
|
$carimbo = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
|
$pacote = Join-Path $env:TEMP "carinha-$versao-$carimbo.tar.gz"
|
||||||
|
Write-Host "== Empacotando $versao em $pacote"
|
||||||
|
$faltando = $arquivos | Where-Object { -not (Test-Path $_) }
|
||||||
|
if ($faltando) { throw "Arquivos ausentes: $($faltando -join ', ')" }
|
||||||
|
& tar -czf $pacote @arquivos web/dist
|
||||||
|
if ($LASTEXITCODE) { throw 'tar falhou' }
|
||||||
|
$hash = (Get-FileHash $pacote -Algorithm SHA256 -ErrorAction Stop).Hash.ToLower()
|
||||||
|
Write-Host " SHA256 $hash"
|
||||||
|
|
||||||
|
if ($SoPacote) {
|
||||||
|
& tar -tzf $pacote | Measure-Object | ForEach-Object { Write-Host " $($_.Count) arquivos no pacote" }
|
||||||
|
Write-Host 'Pacote pronto (nada enviado à placa).'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$alvo = if ($Ssh) { $Ssh } else { "$Usuario@$Placa" }
|
||||||
|
$remoto = "/tmp/carinha-$versao-$carimbo.tar.gz"
|
||||||
|
Write-Host "== Enviando para $alvo"
|
||||||
|
& scp -q $pacote "${alvo}:$remoto"
|
||||||
|
if ($LASTEXITCODE) { throw 'scp falhou' }
|
||||||
|
|
||||||
|
# Na placa: backup do código atual, remove telas antigas, extrai o novo e reinicia.
|
||||||
|
$script = @"
|
||||||
|
set -e
|
||||||
|
cd '$Destino'
|
||||||
|
backup='$Destino/backup-$carimbo'
|
||||||
|
mkdir -p "`$backup"
|
||||||
|
for f in $($arquivos -join ' ') $legado; do [ -e "`$f" ] && cp -a --parents "`$f" "`$backup/" || true; done
|
||||||
|
[ -d web/dist ] && mkdir -p "`$backup/web" && cp -a web/dist "`$backup/web/" || true
|
||||||
|
echo "$hash $remoto" | sha256sum -c --quiet
|
||||||
|
rm -f $legado
|
||||||
|
rm -rf web/dist
|
||||||
|
tar -xzf '$remoto' -C '$Destino'
|
||||||
|
rm -f '$remoto'
|
||||||
|
systemctl restart carinha-pc
|
||||||
|
echo "backup em `$backup"
|
||||||
|
"@
|
||||||
|
Write-Host '== Instalando na placa'
|
||||||
|
# O PowerShell 5.1 põe BOM no stdin do ssh; sem tirar, o bash lê "<BOM>set -e" e não para no primeiro erro.
|
||||||
|
$script -replace "`r", '' | & ssh $alvo "sed '1s/^\xEF\xBB\xBF//' | bash -s"
|
||||||
|
if ($LASTEXITCODE) { throw 'instalação na placa falhou (o backup foi feito antes de qualquer remoção)' }
|
||||||
|
|
||||||
|
Write-Host '== Conferindo'
|
||||||
|
$url = "http://${Placa}:$Porta"
|
||||||
|
for ($i = 0; $i -lt 10; $i++) {
|
||||||
|
try {
|
||||||
|
$saude = Invoke-RestMethod "$url/app/health" -TimeoutSec 3
|
||||||
|
if ($saude.versao -eq $versao) { Write-Host "OK: $url/ responde versão $versao"; return }
|
||||||
|
} catch { }
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
throw "O serviço não respondeu a versão $versao em $url. Veja: ssh $alvo journalctl -u carinha-pc -n 50"
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> **Histórico.** Este dashboard foi substituído pelo painel 0.8 em Svelte: veja [PAINEL.md](PAINEL.md).
|
||||||
|
|
||||||
# Dashboard Carinha — 05/10/2026
|
# Dashboard Carinha — 05/10/2026
|
||||||
|
|
||||||
Objetivo: unificar todas as páginas de operação em tema escuro/ciano com navegação lateral. A tela do celular continua em tela cheia.
|
Objetivo: unificar todas as páginas de operação em tema escuro/ciano com navegação lateral. A tela do celular continua em tela cheia.
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Implantação em 2026-10-05. IP atual: 192.168.1.35.
|
Implantação em 2026-10-05. IP atual: 192.168.1.35.
|
||||||
|
|
||||||
- Painel: http://192.168.1.35:8100/painel
|
- Painel: http://192.168.1.35:8100/ (publicar com `deploy.ps1`)
|
||||||
- Diretório: /opt/carinha-pc
|
- Diretório: /opt/carinha-pc
|
||||||
- Serviço: carinha-pc.service, habilitado para iniciar no boot.
|
- Serviço: carinha-pc.service, habilitado para iniciar no boot.
|
||||||
- Sistema: Armbian Debian 13, Python 3.13.5, arquitetura aarch64.
|
- Sistema: Armbian Debian 13, Python 3.13.5, arquitetura aarch64.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Painel 0.8 — Svelte + SSE
|
||||||
|
|
||||||
|
Substitui as oito páginas antigas (dashboard, painel, conversa, emojis, treinamento, braço,
|
||||||
|
servos, api). Os endereços antigos redirecionam para a seção equivalente (`/braco` → `/#/movimento`).
|
||||||
|
|
||||||
|
## Arquitetura
|
||||||
|
|
||||||
|
- `web/`: Vite + Svelte 5 + TypeScript. `npm run build` gera `web/dist/`, que o servidor entrega em `/`.
|
||||||
|
`web/dist` não vai para o git; o `deploy.ps1` compila e copia.
|
||||||
|
- **Estado único** (`web/src/lib/estado.svelte.ts`): uma foto inicial (`GET /estado`) e um `EventSource`
|
||||||
|
em `/eventos`. Eventos: `expressao`, `telemetria`, `app_config`, `aparencia`, `motores`, `estop`,
|
||||||
|
`conversa`, `ia`, `reacao`, `treinamento`, `recarregar`. Ao reconectar, o navegador manda
|
||||||
|
`Last-Event-ID` e o servidor reenvia o que estiver no buffer; o painel ainda recarrega a foto.
|
||||||
|
- **Comandos**: `POST /comando {tipo, valor}` com `tipo` = `gesto` | `rosto` | `cor` | `reacao` |
|
||||||
|
`parar_reacao` | `recarregar`. Gestos e rostos são validados no servidor.
|
||||||
|
- **Prévia do rosto**: um único iframe de `carinha.html?espelho=1&painel=1`, sem polling; o painel repassa
|
||||||
|
os eventos por `postMessage`. A câmera (WebRTC) só existe na tela Ao vivo, então não há disputa pela sessão.
|
||||||
|
- **Celular**: `carinha.html` também assina `/eventos` (com `?t=<token>`) e volta ao polling de `/fila`
|
||||||
|
sozinho se o SSE cair. O batimento `POST /app/status` (1/s) continua.
|
||||||
|
|
||||||
|
Carga típica: antes 15–20 pedidos/s ao Orange Pi com o dashboard aberto; agora 1 conexão SSE por
|
||||||
|
painel + o batimento do celular + a sinalização do vídeo enquanto ele conecta.
|
||||||
|
|
||||||
|
## Segurança dos controles
|
||||||
|
|
||||||
|
| Controle | Comportamento |
|
||||||
|
|---|---|
|
||||||
|
| Parar tudo | `POST /parar-tudo`: desliga as 16 saídas, cancela interpolação, para reações, rosto `calma`. Motores travados (409) até `POST /rearmar`. |
|
||||||
|
| Homem-morto | O painel renova `POST /braco/mover {angulos, velocidade, ttl_ms: 450}` a cada 150 ms enquanto o botão/slider está pressionado. Sem renovação o watchdog desliga os canais. |
|
||||||
|
| Velocidade | Interpolação a 50 Hz no servidor (`braco.py`), 5–360 °/s. Sem `velocidade` o comando é direto (compatível com scripts antigos). |
|
||||||
|
| Dono do PCA9685 | Braço e diagnóstico não disputam o hardware: o segundo recebe 409 enquanto o primeiro tem canais ligados. |
|
||||||
|
| Aba escondida / E-Stop | O painel solta o braço e desativa o movimento físico. |
|
||||||
|
| Token | Opcional, recomendado: veja [SEGURANCA.md](SEGURANCA.md). |
|
||||||
|
|
||||||
|
## Atalhos
|
||||||
|
|
||||||
|
`Ctrl+K` paleta · `1`–`9` gestos favoritos · `Shift+Espaço` ou `Esc Esc` parar tudo ·
|
||||||
|
`g v/c/r/m/a` navegar · `Espaço` segurado no botão do braço = segurar.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Tokens em `web/src/lib/tema.css`: tema escuro (padrão) e claro, contraste AA, fontes locais
|
||||||
|
(Outfit e JetBrains Mono, sem Google Fonts). Vidro só em superfícies flutuantes (barra superior,
|
||||||
|
paleta, mini-rosto); cards sólidos. Estado nunca só por cor: selos com texto. Navegação lateral a
|
||||||
|
partir de 1024 px, barra inferior abaixo disso; sem rolagem horizontal a 400 px.
|
||||||
|
|
||||||
|
Componentes: `Card`, `Selo`, `Toggle`, `Slider`, `Abas`, `Modal` (`<dialog>` nativo), `Avisos`
|
||||||
|
(toasts com Desfazer, no lugar de alert/confirm/prompt), `Paleta`, `EStop`, `Gestos`,
|
||||||
|
`RostoPrevia`, `MiniRosto`, `Miniatura`, `BracoDesenho`.
|
||||||
|
|
||||||
|
## Testes
|
||||||
|
|
||||||
|
- `tests/test_servidor.py`: token, SSE, `/estado`, `/comando`, fila, E-Stop, trava, interpolação e prazo.
|
||||||
|
- `web/tests/nucleo.test.ts` (`npm test`): escolha de alvo, encaixe, faixas e sequências do braço.
|
||||||
|
- `tests/test_painel_web_browser.py`: token, gestos (botão, 1–9, paleta), E-Stop, ajustes, conversa,
|
||||||
|
memória, reações com desfazer, braço com homem-morto, 400 px e tema.
|
||||||
|
- `tests/test_rtc_browser.py`: vídeo WebRTC real no painel novo, canal de rastreamento e ajustes por SSE.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Segurança do servidor
|
||||||
|
|
||||||
|
## Token de controle
|
||||||
|
|
||||||
|
Com um token configurado, o servidor (porta 8100) exige `Authorization: Bearer <token>`
|
||||||
|
em todo POST e nas leituras de dados (`/eventos`, `/estado`, `/fila`, `/app/*`, `/rtc/*`,
|
||||||
|
`/camera/*`, `/conversa/*`, `/treinamento/*`, `/braco/*`, `/servos/*`, `/ia/status`, `/api/*`).
|
||||||
|
EventSource e o app Android podem mandar `?t=<token>` na URL.
|
||||||
|
|
||||||
|
Continuam públicos apenas: páginas estáticas, `/app/health`, `/faces`, `/gestos`,
|
||||||
|
`GET /config` (aparência), `/ia/modelo` e `/app.apk`.
|
||||||
|
|
||||||
|
Configure **um** dos dois (mínimo 8 caracteres):
|
||||||
|
|
||||||
|
```
|
||||||
|
# /etc/carinha.env, carregado pelo carinha-pc.service (EnvironmentFile=)
|
||||||
|
CARINHA_TOKEN=troque-por-um-segredo-longo
|
||||||
|
```
|
||||||
|
|
||||||
|
ou `seguranca.json` na pasta do servidor (fora do git):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"token": "troque-por-um-segredo-longo"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sem token configurado o servidor fica aberto na rede local, como antes.
|
||||||
|
|
||||||
|
## Como ativar (ordem importa)
|
||||||
|
|
||||||
|
1. Publique servidor e painel 0.8 (`deploy.ps1`) e instale o APK `0.8-token` no S10.
|
||||||
|
2. No S10: toque longo › **Token do servidor** › digite o token. O app passa a mandar o token
|
||||||
|
na página do rosto (`?t=`), nos pedidos Java e no llama-server local (`--api-key`).
|
||||||
|
3. Na placa: `echo 'CARINHA_TOKEN=...' | sudo tee /etc/carinha.env && sudo chmod 600 /etc/carinha.env`
|
||||||
|
e `sudo systemctl restart carinha-pc`. O serviço lê o arquivo (`EnvironmentFile=`).
|
||||||
|
4. Abra o painel: ele pede o token uma vez e guarda só naquele navegador.
|
||||||
|
|
||||||
|
O cérebro da placa usa o mesmo token para falar com o llama-server do S10
|
||||||
|
(ou `CARINHA_IA_CHAVE`, se forem diferentes).
|
||||||
|
|
||||||
|
## App Android
|
||||||
|
|
||||||
|
- Com token salvo, trocar o servidor por Intent (`adb shell am start ... -e servidor URL`) exige
|
||||||
|
`-e token <token>`; outro app não consegue redirecionar o rosto.
|
||||||
|
- Câmera e microfone só são liberados para a página do servidor configurado ou para a cópia local.
|
||||||
|
|
||||||
|
## E-Stop
|
||||||
|
|
||||||
|
`POST /parar-tudo` desliga as 16 saídas do PCA9685, cancela a interpolação do braço,
|
||||||
|
para reações e manda `calma` ao rosto. Braço e servos ficam **travados** (HTTP 409) até
|
||||||
|
`POST /rearmar`.
|
||||||
|
|
||||||
|
## Trava de hardware
|
||||||
|
|
||||||
|
Braço (`/braco/*`) e diagnóstico (`/servos/*`) usam o mesmo PCA9685 com limites
|
||||||
|
diferentes. Quem estiver com motores ativos é o dono; o outro recebe 409 até os motores
|
||||||
|
desligarem (parar, ou ~2 s sem comando).
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -5,6 +5,8 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/opt/carinha-pc
|
WorkingDirectory=/opt/carinha-pc
|
||||||
|
# CARINHA_TOKEN e GEMINI_API_KEY (opcionais); o '-' ignora o arquivo se não existir
|
||||||
|
EnvironmentFile=-/etc/carinha.env
|
||||||
ExecStart=/usr/bin/python3 -u /opt/carinha-pc/kiosk-server.py --porta 8100
|
ExecStart=/usr/bin/python3 -u /opt/carinha-pc/kiosk-server.py --porta 8100
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|||||||
-148
@@ -1,148 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Emojis & Reações · Carinha</title>
|
|
||||||
<link rel="stylesheet" href="/dashboard.css">
|
|
||||||
<script src="/dashboard.js" defer></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:14px">
|
|
||||||
<div>
|
|
||||||
<h1>Biblioteca de Emojis & Reações</h1>
|
|
||||||
<p style="margin:4px 0 0">Gerencie expressões visuais, desenhos vetoriais e reações inteligentes para o Carinha.</p>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="primary" id="emojiNew">✨ Criar Nova Reação</button>
|
|
||||||
<button id="emojiUndo" disabled>↩️ Desfazer</button>
|
|
||||||
<button id="emojiExport">📦 Exportar JSON</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WORKSPACE STICKY: O simulador fica fixado na tela enquanto a biblioteca rola ao lado -->
|
|
||||||
<div class="emojis-workspace" style="margin-top:16px">
|
|
||||||
|
|
||||||
<!-- Coluna Fixa (Sticky Preview) -->
|
|
||||||
<aside class="sticky-preview-col">
|
|
||||||
<section class="card sticky-preview-card">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
|
||||||
<h2>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
|
||||||
Simulador ao Vivo
|
|
||||||
</h2>
|
|
||||||
<span class="badge ativo" style="font-size:11px">📌 Sempre Visível</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<iframe id="emojiPreview" title="Prévia de emoji no rosto" style="height:250px;border-radius:var(--raio-md);box-shadow:inset 0 0 30px rgba(0,0,0,0.8),0 0 15px var(--ciano-glow)" src="/carinha.html?espelho=1&audio=0"></iframe>
|
|
||||||
|
|
||||||
<div class="actions" style="margin-top:14px">
|
|
||||||
<button id="emojiStop" class="danger" style="flex:1">🛑 Parar no Celular</button>
|
|
||||||
<a href="/treinamento" class="primary" style="display:inline-flex;align-items:center;gap:6px;padding:8px 12px;border-radius:var(--raio-sm);font-size:13px">
|
|
||||||
<span>🎨 Gemini SVG</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="code-label" style="margin-top:10px;font-size:11.5px;color:var(--muito-fraco);line-height:1.4">
|
|
||||||
Dica: Ao clicar em "Prévia" em qualquer cartão, a animação acontece aqui mesmo sem você perder a posição de rolagem!
|
|
||||||
</p>
|
|
||||||
<p id="emojiState" role="status" aria-live="polite" style="font-size:13px;color:var(--ciano);min-height:22px;margin:8px 0 0"></p>
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Coluna Rolável (Biblioteca de Emojis) -->
|
|
||||||
<main class="emojis-library-col" style="min-width:0">
|
|
||||||
<section class="card" style="margin-bottom:20px">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px">
|
|
||||||
<h2 style="margin:0">Catálogo de Reações do Robô</h2>
|
|
||||||
<span class="badge ativo" id="emojiCount">—</span>
|
|
||||||
</div>
|
|
||||||
<p style="margin:6px 0 12px;font-size:13px">Reações salvas entram no repertório da conversa com o Gemini ou podem ser chamadas manualmente.</p>
|
|
||||||
|
|
||||||
<label for="emojiSearch">Filtrar por nome ou situação:</label>
|
|
||||||
<input id="emojiSearch" placeholder="Ex.: café, feliz, ideias, surpresa, música...">
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Grade de Cartões de Emojis -->
|
|
||||||
<div id="emojiLibrary" class="library-grid"></div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal / Dialog de Criação e Edição -->
|
|
||||||
<div id="emojiDialog" class="dialog-overlay" hidden role="dialog" aria-modal="true" aria-labelledby="emojiEditorTitle">
|
|
||||||
<form id="emojiEditForm" class="dialog-card">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--borda);padding-bottom:12px;margin-bottom:18px">
|
|
||||||
<h2 id="emojiEditorTitle" style="margin:0">Criar Emoji</h2>
|
|
||||||
<button type="button" id="emojiCancel" style="border:none;background:transparent;font-size:18px;cursor:pointer">✕</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label for="emojiName">Nome da Reação</label>
|
|
||||||
<input id="emojiName" required maxlength="50" placeholder="Ex.: Café Matinal, Ideia Brilhante">
|
|
||||||
|
|
||||||
<label for="emojiReason">Quando Usar (Situação para o Gemini)</label>
|
|
||||||
<textarea id="emojiReason" required maxlength="200" rows="3" placeholder="Ex.: Quando o usuário falar sobre café, cansaço ou manhã"></textarea>
|
|
||||||
|
|
||||||
<label for="emojiCharacter">Caractere ou Desenho</label>
|
|
||||||
<select id="emojiCharacter">
|
|
||||||
<option value="">Manter desenho original</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<!-- Grade de Atalhos Rápidos de Emojis -->
|
|
||||||
<div style="margin:6px 0 16px">
|
|
||||||
<span style="font-size:12px;color:var(--fraco)">Atalhos rápidos:</span>
|
|
||||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px" id="quickEmojiBar">
|
|
||||||
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="💡">💡</button>
|
|
||||||
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="☕">☕</button>
|
|
||||||
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="❤️">❤️</button>
|
|
||||||
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="⭐">⭐</button>
|
|
||||||
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🚀">🚀</button>
|
|
||||||
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🤖">🤖</button>
|
|
||||||
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🕶️">🕶️</button>
|
|
||||||
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🔥">🔥</button>
|
|
||||||
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="🐱">🐱</button>
|
|
||||||
<button type="button" class="swatch" style="font-size:18px;display:grid;place-items:center" data-val="😴">😴</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label for="emojiLocation">Posição na Tela do Carinha</label>
|
|
||||||
<select id="emojiLocation">
|
|
||||||
<option value="olhos">Dentro dos dois olhos</option>
|
|
||||||
<option value="ao_redor">Ao lado do rosto</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<p class="code-label" style="font-size:12px;margin:12px 0 18px">
|
|
||||||
Escolher um emoji substitui o desenho SVG. Você poderá desfazer a qualquer momento.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="actions" style="justify-content:flex-end">
|
|
||||||
<button type="button" onclick="document.getElementById('emojiCancel').click()">Cancelar</button>
|
|
||||||
<button class="primary" type="submit">Salvar Reação</button>
|
|
||||||
</div>
|
|
||||||
<p id="emojiEditState" role="status" style="margin-top:10px;font-size:13px;color:var(--vermelho)"></p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/biblioteca.js" defer></script>
|
|
||||||
<script>
|
|
||||||
// Atalho para preencher emoji clicando na barra de atalhos rápidos
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
document.querySelectorAll('#quickEmojiBar button').forEach(btn => {
|
|
||||||
btn.onclick = () => {
|
|
||||||
const val = btn.getAttribute('data-val');
|
|
||||||
const sel = document.getElementById('emojiCharacter');
|
|
||||||
if (sel && val) {
|
|
||||||
let found = false;
|
|
||||||
for (let opt of sel.options) {
|
|
||||||
if (opt.value === val) { opt.selected = true; found = true; break; }
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
sel.add(new Option(val, val, true, true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
"""Hub Server-Sent Events: um único fluxo /eventos substitui os pollings do painel."""
|
||||||
|
from collections import deque
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
PING = 15
|
||||||
|
|
||||||
|
|
||||||
|
class Eventos:
|
||||||
|
def __init__(self, buffer=200):
|
||||||
|
self.cond = threading.Condition()
|
||||||
|
self.seq = 0
|
||||||
|
self.buffer = deque(maxlen=buffer)
|
||||||
|
self.fechado = False
|
||||||
|
|
||||||
|
def publicar(self, tipo, dados):
|
||||||
|
corpo = json.dumps(dados, ensure_ascii=False)
|
||||||
|
with self.cond:
|
||||||
|
self.seq += 1
|
||||||
|
self.buffer.append((self.seq, tipo, corpo))
|
||||||
|
self.cond.notify_all()
|
||||||
|
return self.seq
|
||||||
|
|
||||||
|
def desde(self, seq):
|
||||||
|
with self.cond:
|
||||||
|
return [e for e in self.buffer if e[0] > seq]
|
||||||
|
|
||||||
|
def fechar(self):
|
||||||
|
with self.cond:
|
||||||
|
self.fechado = True
|
||||||
|
self.cond.notify_all()
|
||||||
|
|
||||||
|
def atender(self, h):
|
||||||
|
url = urlsplit(h.path)
|
||||||
|
try:
|
||||||
|
ultimo = int(h.headers.get('Last-Event-ID') or parse_qs(url.query).get('desde', ['0'])[0])
|
||||||
|
except ValueError:
|
||||||
|
ultimo = 0
|
||||||
|
with self.cond:
|
||||||
|
# Cliente novo (ou que perdeu demais): começa do agora e lê /estado.
|
||||||
|
if ultimo <= 0 or (self.buffer and ultimo < self.buffer[0][0] - 1) or ultimo > self.seq:
|
||||||
|
ultimo = self.seq
|
||||||
|
h.send_response(200)
|
||||||
|
h.send_header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||||
|
h.send_header('Cache-Control', 'no-store')
|
||||||
|
h.send_header('X-Accel-Buffering', 'no')
|
||||||
|
h.end_headers()
|
||||||
|
try:
|
||||||
|
h.wfile.write(f'retry: 3000\nevent: pronto\nid: {ultimo}\ndata: {{"seq": {ultimo}}}\n\n'.encode())
|
||||||
|
while True:
|
||||||
|
limite = time.monotonic() + PING
|
||||||
|
with self.cond:
|
||||||
|
while not self.fechado and (not self.buffer or self.buffer[-1][0] <= ultimo):
|
||||||
|
resta = limite - time.monotonic()
|
||||||
|
if resta <= 0:
|
||||||
|
break
|
||||||
|
self.cond.wait(resta)
|
||||||
|
if self.fechado:
|
||||||
|
return
|
||||||
|
novos = [e for e in self.buffer if e[0] > ultimo]
|
||||||
|
if not novos:
|
||||||
|
h.wfile.write(b': ping\n\n')
|
||||||
|
continue
|
||||||
|
for seq, tipo, corpo in novos:
|
||||||
|
h.wfile.write(f'id: {seq}\nevent: {tipo}\ndata: {corpo}\n\n'.encode())
|
||||||
|
ultimo = seq
|
||||||
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
|
||||||
|
return
|
||||||
+368
@@ -0,0 +1,368 @@
|
|||||||
|
"""Histórico de detecções (PIR da placa, movimento e som do celular) com uma foto por evento.
|
||||||
|
|
||||||
|
- PIR: lê o pino com `gpiomon` (libgpiod). Padrão: gpiochip1 linha 70 = pino 11 (PC6) do
|
||||||
|
Orange Pi Zero 3; outro pino com CARINHA_PIR=gpiochip1:79.
|
||||||
|
- Foto: ao registrar um evento, o servidor deixa um "pedido de foto"; o app do S10 consulta
|
||||||
|
/historico/pedido, tira a foto com a câmera frontal e envia em /historico/foto?id=N.
|
||||||
|
- Guarda só os últimos 3 dias (eventos e fotos mais antigos são apagados sozinhos), com teto de
|
||||||
|
segurança de 2000 eventos e 500 fotos. Cada sensor e a foto podem ser desligados.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
from comum import gravar_atomico, ler_json
|
||||||
|
|
||||||
|
TIPOS = ('pir', 'movimento', 'som')
|
||||||
|
PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15, dias=3,
|
||||||
|
presenca=True, ausencia=5) # modo presença: o PIR liga a câmera; sem ninguém, ela desliga
|
||||||
|
MAX_EVENTOS, MAX_FOTOS = 2000, 500 # teto de segurança; quem manda é a retenção em dias
|
||||||
|
VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto
|
||||||
|
|
||||||
|
|
||||||
|
class Historico:
|
||||||
|
def __init__(self, pasta, iniciar_pir=True):
|
||||||
|
self.pasta = Path(pasta)
|
||||||
|
self.fotos = self.pasta / 'fotos'
|
||||||
|
self.fotos.mkdir(exist_ok=True)
|
||||||
|
self.caminho_db = str(self.pasta / 'historico.db')
|
||||||
|
self.arquivo_config = self.pasta / 'historico.json'
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.publicar = lambda tipo, dados: None
|
||||||
|
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
|
||||||
|
# Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia).
|
||||||
|
self.ao_presenca = lambda: None
|
||||||
|
self.ao_ausencia = lambda: None
|
||||||
|
self.presenca = dict(estado='presente', desde=time.time(), ultimo=time.time())
|
||||||
|
self.config = dict(PADRAO)
|
||||||
|
try:
|
||||||
|
salvo = json.loads(self.arquivo_config.read_text(encoding='utf-8'))
|
||||||
|
self.config.update(self.validar(salvo))
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
self.ultimo = {} # tipo -> horário do último evento (intervalo mínimo)
|
||||||
|
self.pedido = None # (id do evento, monotonic) aguardando foto do app
|
||||||
|
self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None,
|
||||||
|
ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma)
|
||||||
|
self.processo_pir = None
|
||||||
|
with self.db() as db:
|
||||||
|
db.execute('CREATE TABLE IF NOT EXISTS eventos(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, evento TEXT, nivel REAL, foto TEXT)')
|
||||||
|
self.limpar_antigos()
|
||||||
|
if iniciar_pir:
|
||||||
|
threading.Thread(target=self.vigiar_pir, daemon=True).start()
|
||||||
|
threading.Thread(target=self.faxina, daemon=True).start()
|
||||||
|
threading.Thread(target=self.vigiar_ausencia, daemon=True).start()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def db(self):
|
||||||
|
con = sqlite3.connect(self.caminho_db)
|
||||||
|
try:
|
||||||
|
with con:
|
||||||
|
yield con
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validar(dados):
|
||||||
|
if not isinstance(dados, dict):
|
||||||
|
raise ValueError('Esperava um objeto')
|
||||||
|
limpo = {}
|
||||||
|
for chave, valor in dados.items():
|
||||||
|
if chave not in PADRAO:
|
||||||
|
raise ValueError('Ajuste desconhecido: ' + chave)
|
||||||
|
if chave == 'ausencia':
|
||||||
|
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 120:
|
||||||
|
raise ValueError('Ausência de 1 a 120 minutos')
|
||||||
|
elif chave == 'dias':
|
||||||
|
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30:
|
||||||
|
raise ValueError('Guardar de 1 a 30 dias')
|
||||||
|
elif chave == 'intervalo':
|
||||||
|
if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600:
|
||||||
|
raise ValueError('Intervalo entre 0 e 600 s')
|
||||||
|
elif not isinstance(valor, bool):
|
||||||
|
raise ValueError(chave + ' precisa ser verdadeiro ou falso')
|
||||||
|
limpo[chave] = valor
|
||||||
|
return limpo
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ retenção
|
||||||
|
def limpar_antigos(self, agora=None):
|
||||||
|
"""Apaga eventos (e fotos) mais velhos que a retenção em dias."""
|
||||||
|
limite = (agora if agora is not None else time.time()) - self.config['dias'] * 86400
|
||||||
|
with self.lock, self.db() as db:
|
||||||
|
velhos = db.execute('SELECT id, foto FROM eventos WHERE horario < ?', (limite,)).fetchall()
|
||||||
|
for vid, foto in velhos:
|
||||||
|
self.apagar_arquivo(foto)
|
||||||
|
db.execute('DELETE FROM eventos WHERE horario < ?', (limite,))
|
||||||
|
if velhos:
|
||||||
|
self.publicar('historico', dict(expirados=len(velhos)))
|
||||||
|
return len(velhos)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ modo presença
|
||||||
|
def marcar_presenca(self, agora=None):
|
||||||
|
agora = agora if agora is not None else time.time()
|
||||||
|
with self.lock:
|
||||||
|
self.presenca['ultimo'] = agora
|
||||||
|
voltou = self.presenca['estado'] != 'presente'
|
||||||
|
if voltou:
|
||||||
|
self.presenca.update(estado='presente', desde=agora)
|
||||||
|
if voltou and self.config['presenca']:
|
||||||
|
self.ao_presenca()
|
||||||
|
|
||||||
|
def checar_ausencia(self, agora=None):
|
||||||
|
"""Sem presença no PIR por `ausencia` minutos: desliga câmera, dorme e fica quieto."""
|
||||||
|
agora = agora if agora is not None else time.time()
|
||||||
|
if not (self.config['presenca'] and self.config['pir'] and self.pir['disponivel']):
|
||||||
|
return False
|
||||||
|
with self.lock:
|
||||||
|
if self.pir['ativo']: # sensor ainda segurando: a pessoa continua ali
|
||||||
|
self.presenca['ultimo'] = agora
|
||||||
|
return False
|
||||||
|
if self.presenca['estado'] != 'presente' or agora - self.presenca['ultimo'] < self.config['ausencia'] * 60:
|
||||||
|
return False
|
||||||
|
self.presenca.update(estado='ausente', desde=agora)
|
||||||
|
self.ao_ausencia()
|
||||||
|
self.avisar_pir()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def avisar_pir(self):
|
||||||
|
self.publicar('pir', dict(ultimo=self.pir['ultimo'], ativo=self.pir['ativo'], presenca=dict(self.presenca)))
|
||||||
|
|
||||||
|
def nivel_pir(self, alto, agora=None, registrar=True):
|
||||||
|
"""Mudança no pino do PIR. Subida = presença (evento + foto); enquanto alto, a bolinha fica acesa."""
|
||||||
|
agora = agora if agora is not None else time.time()
|
||||||
|
self.pir['ativo'] = alto
|
||||||
|
if alto:
|
||||||
|
self.pir['ultimo'] = agora
|
||||||
|
self.marcar_presenca(agora)
|
||||||
|
self.avisar_pir()
|
||||||
|
if alto and registrar:
|
||||||
|
self.registrar('pir', 'presença')
|
||||||
|
|
||||||
|
def vigiar_ausencia(self):
|
||||||
|
while True:
|
||||||
|
time.sleep(10)
|
||||||
|
try:
|
||||||
|
self.checar_ausencia()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def faxina(self):
|
||||||
|
while True:
|
||||||
|
time.sleep(600)
|
||||||
|
try:
|
||||||
|
self.limpar_antigos()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ eventos
|
||||||
|
def registrar(self, tipo, evento='', nivel=None, agora=None):
|
||||||
|
"""Grava o evento se o sensor estiver ligado e fora do intervalo mínimo; devolve o id ou None."""
|
||||||
|
if tipo not in TIPOS:
|
||||||
|
raise ValueError('Tipo de evento inválido')
|
||||||
|
agora = agora if agora is not None else time.time()
|
||||||
|
with self.lock:
|
||||||
|
if not self.config.get(tipo, False):
|
||||||
|
return None
|
||||||
|
if tipo in self.ultimo and agora - self.ultimo[tipo] < self.config['intervalo']:
|
||||||
|
return None
|
||||||
|
self.ultimo[tipo] = agora
|
||||||
|
evento = str(evento or '')[:40]
|
||||||
|
nivel = float(nivel) if isinstance(nivel, (int, float)) and not isinstance(nivel, bool) else None
|
||||||
|
with self.db() as db:
|
||||||
|
ident = db.execute('INSERT INTO eventos(horario, tipo, evento, nivel) VALUES (?,?,?,?)',
|
||||||
|
(agora, tipo, evento, nivel)).lastrowid
|
||||||
|
velhos = db.execute('SELECT id, foto FROM eventos ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_EVENTOS,)).fetchall()
|
||||||
|
for vid, foto in velhos:
|
||||||
|
self.apagar_arquivo(foto)
|
||||||
|
db.execute('DELETE FROM eventos WHERE id=?', (vid,))
|
||||||
|
if self.config['foto']:
|
||||||
|
self.pedido = (ident, time.monotonic())
|
||||||
|
self.limpar_antigos(agora)
|
||||||
|
self.publicar('historico', dict(novo=self.evento(ident)))
|
||||||
|
try:
|
||||||
|
self.ao_detectar(tipo)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ident
|
||||||
|
|
||||||
|
def evento(self, ident):
|
||||||
|
with self.db() as db:
|
||||||
|
linha = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos WHERE id=?', (ident,)).fetchone()
|
||||||
|
return self.como_dict(linha) if linha else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def como_dict(linha):
|
||||||
|
i, h, t, e, n, f = linha
|
||||||
|
return dict(id=i, horario=h, tipo=t, evento=e, nivel=n, foto=bool(f))
|
||||||
|
|
||||||
|
def listar(self, limite=500):
|
||||||
|
with self.db() as db:
|
||||||
|
linhas = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos ORDER BY id DESC LIMIT ?', (limite,)).fetchall()
|
||||||
|
total = db.execute('SELECT count(*) FROM eventos').fetchone()[0]
|
||||||
|
return [self.como_dict(l) for l in linhas], total
|
||||||
|
|
||||||
|
def apagar(self, ident):
|
||||||
|
with self.lock, self.db() as db:
|
||||||
|
linha = db.execute('SELECT foto FROM eventos WHERE id=?', (ident,)).fetchone()
|
||||||
|
if linha:
|
||||||
|
self.apagar_arquivo(linha[0])
|
||||||
|
db.execute('DELETE FROM eventos WHERE id=?', (ident,))
|
||||||
|
self.publicar('historico', dict(apagado=ident))
|
||||||
|
|
||||||
|
def limpar(self):
|
||||||
|
with self.lock, self.db() as db:
|
||||||
|
db.execute('DELETE FROM eventos')
|
||||||
|
for f in self.fotos.glob('evento-*.jpg'):
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
self.pedido = None
|
||||||
|
self.publicar('historico', dict(limpo=True))
|
||||||
|
|
||||||
|
def apagar_arquivo(self, nome):
|
||||||
|
if nome:
|
||||||
|
(self.fotos / nome).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ foto
|
||||||
|
def pedido_atual(self):
|
||||||
|
with self.lock:
|
||||||
|
if self.pedido and time.monotonic() - self.pedido[1] < VALIDADE_PEDIDO:
|
||||||
|
return self.pedido[0]
|
||||||
|
self.pedido = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def guardar_foto(self, ident, jpeg):
|
||||||
|
if not (jpeg.startswith(b'\xff\xd8') and jpeg.endswith(b'\xff\xd9')) or len(jpeg) > 3_000_000:
|
||||||
|
raise ValueError('Esperava uma foto JPEG de até 3 MB')
|
||||||
|
with self.lock:
|
||||||
|
if not self.config['foto']:
|
||||||
|
raise ValueError('Fotos desligadas')
|
||||||
|
if self.pedido and self.pedido[0] == ident:
|
||||||
|
self.pedido = None
|
||||||
|
nome = f'evento-{ident}.jpg'
|
||||||
|
with self.db() as db:
|
||||||
|
if not db.execute('SELECT 1 FROM eventos WHERE id=?', (ident,)).fetchone():
|
||||||
|
raise ValueError('Evento não existe mais')
|
||||||
|
tmp = self.fotos / (nome + '.tmp')
|
||||||
|
tmp.write_bytes(jpeg); tmp.replace(self.fotos / nome)
|
||||||
|
db.execute('UPDATE eventos SET foto=? WHERE id=?', (nome, ident))
|
||||||
|
antigas = db.execute('SELECT id, foto FROM eventos WHERE foto IS NOT NULL ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_FOTOS,)).fetchall()
|
||||||
|
for vid, foto in antigas:
|
||||||
|
self.apagar_arquivo(foto)
|
||||||
|
db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,))
|
||||||
|
self.publicar('historico', dict(foto=ident))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ PIR
|
||||||
|
def vigiar_pir(self):
|
||||||
|
"""Mantém um `gpiomon` rodando enquanto o PIR estiver ligado e acompanha o nível do pino."""
|
||||||
|
while True:
|
||||||
|
if not self.config['pir'] or os.name != 'posix' or not shutil.which('gpiomon'):
|
||||||
|
self.pir.update(disponivel=False, erro='' if self.config['pir'] else 'desligado')
|
||||||
|
if os.name == 'posix' and not shutil.which('gpiomon'):
|
||||||
|
self.pir['erro'] = 'gpiomon não instalado (apt install gpiod)'
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
chip, _, linha = self.pir['linha'].partition(':')
|
||||||
|
try:
|
||||||
|
# pull-down: com o sensor desligado o pino não fica "flutuando" (disparos falsos)
|
||||||
|
lido = subprocess.run(['gpioget', '-c', chip, '-b', 'pull-down', '--numeric', linha],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
self.processo_pir = subprocess.Popen(['gpiomon', '-c', chip, '-b', 'pull-down', '-e', 'both', '-F', '%E', linha],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
self.pir.update(disponivel=True, erro='')
|
||||||
|
# nível de partida: alguém já na frente não gera borda de subida, mas acende a bolinha
|
||||||
|
self.nivel_pir(lido.stdout.strip() == '1', registrar=False)
|
||||||
|
inicio = time.monotonic()
|
||||||
|
for linha_evento in self.processo_pir.stdout:
|
||||||
|
if not self.config['pir']:
|
||||||
|
break
|
||||||
|
alto = linha_evento.strip() == 'rising'
|
||||||
|
# o pino "assenta" ao ligar o pull-down: acompanha o nível, mas não registra evento
|
||||||
|
self.nivel_pir(alto, registrar=time.monotonic() - inicio >= 2)
|
||||||
|
erro = (self.processo_pir.stderr.read() or '').strip()[:200]
|
||||||
|
except OSError as e:
|
||||||
|
erro = str(e)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
erro = 'gpioget não respondeu'
|
||||||
|
finally:
|
||||||
|
self.parar_pir()
|
||||||
|
self.pir.update(disponivel=False, ativo=False, erro=erro if self.config['pir'] else 'desligado')
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
def parar_pir(self):
|
||||||
|
p, self.processo_pir = self.processo_pir, None
|
||||||
|
if p and p.poll() is None:
|
||||||
|
p.terminate()
|
||||||
|
try:
|
||||||
|
p.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
p.kill()
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
eventos, total = self.listar()
|
||||||
|
with self.lock:
|
||||||
|
return dict(config=dict(self.config), pir=dict(self.pir), presenca=dict(self.presenca), eventos=eventos, total=total,
|
||||||
|
max_eventos=MAX_EVENTOS, max_fotos=MAX_FOTOS)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ HTTP
|
||||||
|
def rotas(self, h, metodo):
|
||||||
|
url = urlsplit(h.path)
|
||||||
|
rota = url.path
|
||||||
|
if not rota.startswith('/historico/'):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if metodo == 'GET' and rota == '/historico/status':
|
||||||
|
return h.json(self.status()) or True
|
||||||
|
if metodo == 'GET' and rota == '/historico/pedido':
|
||||||
|
return h.json(dict(id=self.pedido_atual())) or True
|
||||||
|
if metodo == 'GET' and rota.startswith('/historico/foto/'):
|
||||||
|
nome = rota.rsplit('/', 1)[-1]
|
||||||
|
if not nome.startswith('evento-') or not nome.endswith('.jpg') or '/' in nome or '..' in nome:
|
||||||
|
raise ValueError('Foto inválida')
|
||||||
|
arquivo = self.fotos / nome
|
||||||
|
if not arquivo.is_file():
|
||||||
|
h.json(dict(erro='Foto não encontrada'), 404)
|
||||||
|
return True
|
||||||
|
h.enviar(arquivo.read_bytes(), 'image/jpeg')
|
||||||
|
return True
|
||||||
|
if metodo == 'POST' and rota == '/historico/foto':
|
||||||
|
ident = int(parse_qs(url.query).get('id', ['0'])[0])
|
||||||
|
if h.headers.get('Content-Type', '').split(';')[0] != 'image/jpeg':
|
||||||
|
raise ValueError('Use image/jpeg')
|
||||||
|
tamanho = int(h.headers.get('Content-Length', '0'))
|
||||||
|
if not 0 < tamanho <= 3_000_000:
|
||||||
|
raise ValueError('Foto vazia ou grande demais')
|
||||||
|
self.guardar_foto(ident, h.rfile.read(tamanho))
|
||||||
|
return h.json(dict(ok=True)) or True
|
||||||
|
if metodo == 'POST':
|
||||||
|
dados = ler_json(h, 4096)
|
||||||
|
if rota == '/historico/evento':
|
||||||
|
ident = self.registrar(dados.get('tipo'), dados.get('evento'), dados.get('nivel'))
|
||||||
|
return h.json(dict(ok=True, id=ident)) or True
|
||||||
|
if rota == '/historico/config':
|
||||||
|
limpo = self.validar(dados)
|
||||||
|
with self.lock:
|
||||||
|
self.config.update(limpo)
|
||||||
|
gravar_atomico(self.arquivo_config, self.config)
|
||||||
|
if 'pir' in limpo and not limpo['pir']:
|
||||||
|
self.parar_pir()
|
||||||
|
if 'dias' in limpo:
|
||||||
|
self.limpar_antigos()
|
||||||
|
self.publicar('historico', dict(config=dict(self.config)))
|
||||||
|
return h.json(self.status()) or True
|
||||||
|
if rota == '/historico/apagar':
|
||||||
|
if type(dados.get('id')) is not int:
|
||||||
|
raise ValueError('ID inválido')
|
||||||
|
self.apagar(dados['id'])
|
||||||
|
return h.json(dict(ok=True)) or True
|
||||||
|
if rota == '/historico/limpar':
|
||||||
|
self.limpar()
|
||||||
|
return h.json(dict(ok=True)) or True
|
||||||
|
h.json(dict(erro='Rota do histórico desconhecida'), 404)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
h.json(dict(erro=str(e)), 400)
|
||||||
|
return True
|
||||||
+4
-4
@@ -1,11 +1,11 @@
|
|||||||
param([int]$Porta = 8100, [switch]$SemAbrir)
|
param([string]$Placa = '192.168.1.35', [int]$Porta = 8100, [switch]$SemAbrir)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
$url = "http://192.168.1.35:$Porta"
|
$url = "http://${Placa}:$Porta"
|
||||||
try {
|
try {
|
||||||
$health = Invoke-RestMethod "$url/app/health" -TimeoutSec 5
|
$health = Invoke-RestMethod "$url/app/health" -TimeoutSec 5
|
||||||
if ($health.projeto -ne 'carinha-pc') { throw 'Servidor inesperado' }
|
if ($health.projeto -ne 'carinha-pc') { throw 'Servidor inesperado' }
|
||||||
} catch {
|
} catch {
|
||||||
throw "Orange Pi indisponível em $url. Confira alimentação, rede e o serviço carinha-pc. Nenhum servidor será iniciado no PC."
|
throw "Orange Pi indisponível em $url. Confira alimentação, rede e o serviço carinha-pc. Nenhum servidor será iniciado no PC."
|
||||||
}
|
}
|
||||||
Write-Host "Painel no Orange Pi: $url/painel"
|
Write-Host "Painel Carinha $($health.versao): $url/"
|
||||||
if (-not $SemAbrir) { Start-Process "$url/painel" }
|
if (-not $SemAbrir) { Start-Process "$url/" }
|
||||||
|
|||||||
+408
-139
@@ -1,61 +1,130 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
kiosk-server.py — servidor local do PC: painel, carinha e fila de ordens.
|
kiosk-server.py — servidor do Carinha (Orange Pi, porta 8100): painel, rosto e controle.
|
||||||
|
|
||||||
Alem de entregar os arquivos da pasta (padrao /root/carinha), ele expõe:
|
Estado em tempo real:
|
||||||
|
GET /eventos -> Server-Sent Events (expressao, telemetria, conversa, reacao,
|
||||||
|
ia, motores, estop, aparencia, app_config, treinamento)
|
||||||
|
GET /estado -> foto de tudo para o primeiro carregamento (inclui 'seq')
|
||||||
|
|
||||||
GET /fila -> devolve e limpa a fila, em JSON: ["feliz","piscar"]
|
Comandos:
|
||||||
GET /gesto/<nome> -> poe <nome> na fila (ex.: /gesto/coracao)
|
POST /comando -> {"tipo": "gesto"|"rosto"|"cor"|"reacao"|"parar_reacao"|"recarregar", "valor": ...}
|
||||||
POST /gesto -> corpo = nome do gesto
|
POST /parar-tudo -> E-Stop: desliga todos os servos, para reações e acalma o rosto
|
||||||
|
POST /rearmar -> libera os motores depois do E-Stop
|
||||||
|
GET /fila?aparelho=1 -> ordens pendentes para o celular (até o rosto migrar para /eventos)
|
||||||
|
|
||||||
A pagina da carinha pergunta /fila a cada meio segundo; assim da para mandar
|
Autenticação: se CARINHA_TOKEN (ambiente) ou seguranca.json {"token": ...} existir, toda
|
||||||
um gesto do PC com um curl, igual ao antigo FIFO /tmp/eilik.fifo:
|
rota de controle/dados exige "Authorization: Bearer <token>" (ou ?t=<token> no EventSource
|
||||||
|
e no app). Sem token configurado o servidor fica aberto como antes.
|
||||||
|
|
||||||
curl http://orangepi:8099/gesto/feliz
|
Só usa a biblioteca padrão. Nada de instalação extra.
|
||||||
|
|
||||||
Alem disso guarda a APARENCIA da carinha (rosto + cor dos olhos), para o painel
|
|
||||||
personalizar e a pagina aplicar sozinha ao carregar:
|
|
||||||
|
|
||||||
GET /config -> {"rosto":"carinha","cor":[92,214,255]}
|
|
||||||
POST /config -> corpo JSON {"rosto":"miudo","cor":[255,120,0]}
|
|
||||||
|
|
||||||
E serve o painel de controle em http://<maquina>:8099/painel .
|
|
||||||
|
|
||||||
So usa a biblioteca padrao (http.server). Nada de instalacao extra.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from collections import deque
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from controle_pc import ControlePC
|
from comum import autorizado, carregar_token, gravar_atomico, ler_json
|
||||||
|
from controle_pc import ControlePC, madrugada, versao
|
||||||
|
from eventos import Eventos
|
||||||
from servos import ControleServos
|
from servos import ControleServos
|
||||||
from braco import Braco
|
from braco import Braco
|
||||||
from cerebro import Cerebro
|
from cerebro import Cerebro
|
||||||
from conversa import Conversa
|
from conversa import Conversa
|
||||||
from treinamento import Treinamento
|
from treinamento import Treinamento
|
||||||
from api_config import ConfigAPI
|
from api_config import ConfigAPI
|
||||||
|
from historico import Historico
|
||||||
|
from vida import Vida
|
||||||
|
from criacao import Criacao
|
||||||
|
|
||||||
TRAVA = threading.Lock()
|
TRAVA = threading.Lock()
|
||||||
FILA = []
|
|
||||||
|
|
||||||
# Espelho da biblioteca de rostos do carinha.html (para /faces funcionar)
|
# Espelho da biblioteca de rostos do carinha.html (para /faces funcionar)
|
||||||
ROSTOS = ["eilik", "carinha", "miudo"]
|
ROSTOS = ["vivo", "bloco", "matriz", "lente", "eilik", "carinha", "miudo"]
|
||||||
|
# Formato do olho nos rostos novos (rosto-motor.js)
|
||||||
|
FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"]
|
||||||
|
|
||||||
# Espelho dos gestos do carinha.html (para o painel montar os botoes)
|
# Espelho dos gestos do carinha.html (para o painel montar os botoes)
|
||||||
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
|
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
|
||||||
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
|
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
|
||||||
"dormir", "acordar", "aleatorio", "parar"]
|
"dormir", "acordar", "aleatorio", "parar",
|
||||||
|
"esconder", "espiar", "acenar", "pensar", "musica",
|
||||||
|
"rir", "bocejar", "confuso", "tonto", "timido", "comemorar"]
|
||||||
|
# Atividades (momentos dele): o bonequinho faz algo embaixo dos olhos (rosto-motor.js).
|
||||||
|
ATIVIDADES = ["ler", "cantar", "bola", "videogame", "pintar", "malhar", "dormir", "cafe", "cozinhar", "regar",
|
||||||
|
"violao", "dancar", "corda", "meditar", "telescopio", "pipa", "bolhas", "skate", "computador", "pescar"]
|
||||||
|
# Ordens internas que o rosto entende mas não viram botão.
|
||||||
|
ORDENS_INTERNAS = {"calma"}
|
||||||
|
|
||||||
# Apar┌┐ncia atual da carinha (personalizacao pelo painel). Fica gravada num
|
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
|
||||||
# arquivo ao lado do servidor, para sobreviver a reinicios.
|
# arquivo ao lado do servidor, para sobreviver a reinicios.
|
||||||
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255]}
|
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255], "formato": "padrao"}
|
||||||
ARQUIVO_APARENCIA = "aparencia.json"
|
ARQUIVO_APARENCIA = "aparencia.json"
|
||||||
|
|
||||||
|
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
|
||||||
|
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js", "/voz-robo.js",
|
||||||
|
"/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task",
|
||||||
|
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm",
|
||||||
|
"/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"}
|
||||||
|
|
||||||
|
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
|
||||||
|
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
|
||||||
|
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/criacao/",
|
||||||
|
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
|
||||||
|
|
||||||
|
|
||||||
|
class Fila:
|
||||||
|
"""Ordens para o celular: limitada e com validade, para não repetir ordens velhas
|
||||||
|
quando o aparelho volta depois de um tempo offline."""
|
||||||
|
|
||||||
|
def __init__(self, maximo=20, validade=10.0):
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.itens = deque(maxlen=maximo)
|
||||||
|
self.validade = validade
|
||||||
|
|
||||||
|
def adicionar(self, ordem, agora=None):
|
||||||
|
with self.lock:
|
||||||
|
if ordem == "recarregar" and any(o == ordem for _, o in self.itens):
|
||||||
|
return
|
||||||
|
self.itens.append((agora if agora is not None else time.monotonic(), ordem))
|
||||||
|
|
||||||
|
def consumir(self, aparelho=True, agora=None):
|
||||||
|
"""O celular leva tudo; um rosto aberto no navegador (kiosk) leva tudo menos 'recarregar'."""
|
||||||
|
agora = agora if agora is not None else time.monotonic()
|
||||||
|
with self.lock:
|
||||||
|
validos = [(t, o) for t, o in self.itens if agora - t <= self.validade]
|
||||||
|
pendentes = [o for _, o in validos if aparelho or o != "recarregar"]
|
||||||
|
self.itens.clear()
|
||||||
|
if not aparelho:
|
||||||
|
self.itens.extend((t, o) for t, o in validos if o == "recarregar")
|
||||||
|
return pendentes
|
||||||
|
|
||||||
|
def ordens(self):
|
||||||
|
with self.lock:
|
||||||
|
return [o for _, o in self.itens]
|
||||||
|
|
||||||
|
def limpar(self):
|
||||||
|
with self.lock:
|
||||||
|
self.itens.clear()
|
||||||
|
|
||||||
|
|
||||||
|
FILA = Fila()
|
||||||
|
EVENTOS = Eventos()
|
||||||
|
ESTOP = {"ativo": False, "desde": None}
|
||||||
|
|
||||||
|
|
||||||
|
def enfileirar(ordem):
|
||||||
|
"""Caminho único de uma ordem para o rosto: fila do celular + evento para os painéis."""
|
||||||
|
FILA.adicionar(ordem)
|
||||||
|
EVENTOS.publicar("expressao", {"ordem": ordem})
|
||||||
|
|
||||||
|
|
||||||
def carregar_aparencia():
|
def carregar_aparencia():
|
||||||
try:
|
try:
|
||||||
@@ -64,6 +133,8 @@ def carregar_aparencia():
|
|||||||
if isinstance(dados, dict):
|
if isinstance(dados, dict):
|
||||||
if dados.get("rosto") in ROSTOS:
|
if dados.get("rosto") in ROSTOS:
|
||||||
APARENCIA["rosto"] = dados["rosto"]
|
APARENCIA["rosto"] = dados["rosto"]
|
||||||
|
if dados.get("formato") in FORMATOS:
|
||||||
|
APARENCIA["formato"] = dados["formato"]
|
||||||
cor = dados.get("cor")
|
cor = dados.get("cor")
|
||||||
if isinstance(cor, list) and len(cor) == 3:
|
if isinstance(cor, list) and len(cor) == 3:
|
||||||
APARENCIA["cor"] = [max(0, min(255, int(v))) for v in cor]
|
APARENCIA["cor"] = [max(0, min(255, int(v))) for v in cor]
|
||||||
@@ -73,14 +144,20 @@ def carregar_aparencia():
|
|||||||
|
|
||||||
def salvar_aparencia():
|
def salvar_aparencia():
|
||||||
try:
|
try:
|
||||||
with open(ARQUIVO_APARENCIA, "w", encoding="utf-8") as f:
|
gravar_atomico(ARQUIVO_APARENCIA, APARENCIA)
|
||||||
json.dump(APARENCIA, f, ensure_ascii=False, indent=2)
|
except OSError:
|
||||||
except Exception:
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def validar_cor(valor):
|
||||||
|
cor = [max(0, min(255, int(v))) for v in list(valor)[:3]]
|
||||||
|
if len(cor) != 3:
|
||||||
|
raise ValueError("Cor precisa de três números")
|
||||||
|
return cor
|
||||||
|
|
||||||
|
|
||||||
def aplicar_aparencia(dados):
|
def aplicar_aparencia(dados):
|
||||||
"""Guarda rosto/cor e poe as ordens na fila para a pagina aplicar na hora."""
|
"""Guarda rosto/cor e manda as ordens para a página aplicar na hora."""
|
||||||
ordens = []
|
ordens = []
|
||||||
with TRAVA:
|
with TRAVA:
|
||||||
if "rosto" in dados:
|
if "rosto" in dados:
|
||||||
@@ -88,26 +165,55 @@ def aplicar_aparencia(dados):
|
|||||||
if nome in ROSTOS:
|
if nome in ROSTOS:
|
||||||
APARENCIA["rosto"] = nome
|
APARENCIA["rosto"] = nome
|
||||||
ordens.append("rosto " + nome)
|
ordens.append("rosto " + nome)
|
||||||
|
if "formato" in dados:
|
||||||
|
nome = str(dados["formato"]).strip().lower()
|
||||||
|
if nome in FORMATOS:
|
||||||
|
APARENCIA["formato"] = nome
|
||||||
|
ordens.append("formato " + nome)
|
||||||
if "cor" in dados:
|
if "cor" in dados:
|
||||||
try:
|
try:
|
||||||
cor = [max(0, min(255, int(v))) for v in list(dados["cor"])[:3]]
|
APARENCIA["cor"] = validar_cor(dados["cor"])
|
||||||
if len(cor) == 3:
|
ordens.append("cor %d,%d,%d" % tuple(APARENCIA["cor"]))
|
||||||
APARENCIA["cor"] = cor
|
except (TypeError, ValueError):
|
||||||
ordens.append("cor %d,%d,%d" % tuple(cor))
|
|
||||||
except Exception:
|
|
||||||
pass
|
pass
|
||||||
FILA.extend(ordens)
|
aparencia = dict(APARENCIA)
|
||||||
salvar_aparencia()
|
for ordem in ordens:
|
||||||
|
enfileirar(ordem)
|
||||||
|
if ordens:
|
||||||
|
salvar_aparencia()
|
||||||
|
EVENTOS.publicar("aparencia", aparencia)
|
||||||
return ordens
|
return ordens
|
||||||
|
|
||||||
|
|
||||||
|
def ordem_de_comando(tipo, valor):
|
||||||
|
"""Traduz {tipo, valor} para a ordem de texto que o rosto entende; ValueError se inválido."""
|
||||||
|
if tipo == "gesto":
|
||||||
|
nome = str(valor or "").strip().lower()
|
||||||
|
if nome not in GESTOS and nome not in ORDENS_INTERNAS:
|
||||||
|
raise ValueError("Gesto desconhecido: " + nome)
|
||||||
|
return nome
|
||||||
|
if tipo == "atividade":
|
||||||
|
nome = str(valor or "").strip().lower()
|
||||||
|
if nome not in ATIVIDADES and nome != "parar":
|
||||||
|
raise ValueError("Atividade desconhecida: " + nome)
|
||||||
|
return "atividade " + nome
|
||||||
|
if tipo == "rosto":
|
||||||
|
nome = str(valor or "").strip().lower()
|
||||||
|
if nome not in ROSTOS:
|
||||||
|
raise ValueError("Rosto desconhecido: " + nome)
|
||||||
|
return "rosto " + nome
|
||||||
|
raise ValueError("Tipo de comando inválido")
|
||||||
|
|
||||||
|
|
||||||
class Handler(SimpleHTTPRequestHandler):
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
extensoes_map = {".html": "text/html; charset=utf-8",
|
extensoes_map = {".html": "text/html; charset=utf-8",
|
||||||
".js": "text/javascript; charset=utf-8",
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
".css": "text/css; charset=utf-8",
|
".css": "text/css; charset=utf-8",
|
||||||
".json": "application/json; charset=utf-8",
|
".json": "application/json; charset=utf-8",
|
||||||
".svg": "image/svg+xml",
|
".svg": "image/svg+xml",
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
|
".woff2": "font/woff2",
|
||||||
".mp4": "video/mp4"}
|
".mp4": "video/mp4"}
|
||||||
|
|
||||||
def guess_type(self, caminho):
|
def guess_type(self, caminho):
|
||||||
@@ -120,32 +226,76 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
self.send_header("Content-Type", tipo)
|
self.send_header("Content-Type", tipo)
|
||||||
self.send_header("Content-Length", str(len(dados)))
|
self.send_header("Content-Length", str(len(dados)))
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
if codigo == 401:
|
||||||
|
self.send_header("WWW-Authenticate", 'Bearer realm="carinha"')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(dados)
|
self.wfile.write(dados)
|
||||||
|
|
||||||
|
def json(self, dados, codigo=200):
|
||||||
|
self.enviar(json.dumps(dados, ensure_ascii=False), codigo=codigo)
|
||||||
|
|
||||||
|
def rota(self):
|
||||||
|
return self.path.split("?", 1)[0]
|
||||||
|
|
||||||
|
def acesso_negado(self, metodo):
|
||||||
|
"""True (e já respondeu 401) se a rota exige token e o pedido não trouxe o certo."""
|
||||||
|
rota = self.rota()
|
||||||
|
protegida = metodo == "POST" or (rota not in PUBLICAS_GET and rota.startswith(PREFIXOS_API))
|
||||||
|
if protegida and not autorizado(self):
|
||||||
|
self.json({"erro": "Token ausente ou inválido"}, 401)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def modulos(self):
|
||||||
|
s = self.server
|
||||||
|
return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "criacao", "treinamento", "conversa", "cerebro",
|
||||||
|
"braco", "servos", "controle") if hasattr(s, nome)]
|
||||||
|
|
||||||
|
def estado(self):
|
||||||
|
s = self.server
|
||||||
|
host = self.headers.get("Host", "").split(":")[0]
|
||||||
|
with TRAVA:
|
||||||
|
aparencia = dict(APARENCIA)
|
||||||
|
dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)),
|
||||||
|
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS, formatos=FORMATOS,
|
||||||
|
atividades=ATIVIDADES)
|
||||||
|
if hasattr(s, "controle"):
|
||||||
|
dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy())
|
||||||
|
if hasattr(s, "servos"):
|
||||||
|
dados["motores"] = s.servos.motores()
|
||||||
|
if hasattr(s, "conversa"):
|
||||||
|
dados["conversa"] = s.conversa.status()
|
||||||
|
if hasattr(s, "cerebro"):
|
||||||
|
dados["ia"] = s.cerebro.status()
|
||||||
|
if hasattr(s, "treinamento"):
|
||||||
|
dados["reacao"] = s.treinamento.expressao
|
||||||
|
dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro)
|
||||||
|
if hasattr(s, "api_config"):
|
||||||
|
dados["api"] = s.api_config.status()
|
||||||
|
if hasattr(s, "historico"):
|
||||||
|
dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca))
|
||||||
|
if hasattr(s, "vida"):
|
||||||
|
dados["vida"] = s.vida.status()
|
||||||
|
return dados
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if hasattr(self.server, "api_config") and self.server.api_config.rotas(self, "GET"):return
|
if self.acesso_negado("GET"):
|
||||||
if hasattr(self.server, 'treinamento') and self.server.treinamento.rotas(self, 'GET'):
|
|
||||||
return
|
return
|
||||||
if hasattr(self.server, 'conversa') and self.server.conversa.rotas(self, 'GET'):
|
for modulo in self.modulos():
|
||||||
return
|
if modulo.rotas(self, "GET"):
|
||||||
if hasattr(self.server, 'cerebro') and self.server.cerebro.rotas(self, 'GET'):
|
return
|
||||||
return
|
caminho = self.rota()
|
||||||
if hasattr(self.server, 'braco') and self.server.braco.rotas(self, 'GET'):
|
|
||||||
return
|
|
||||||
if hasattr(self.server, 'servos') and self.server.servos.rotas(self, 'GET'):
|
|
||||||
return
|
|
||||||
if self.server.controle.rotas(self, 'GET'):
|
|
||||||
return
|
|
||||||
caminho = self.path.split("?", 1)[0]
|
|
||||||
partes = [p for p in caminho.split("/") if p]
|
partes = [p for p in caminho.split("/") if p]
|
||||||
|
|
||||||
|
if caminho == "/eventos":
|
||||||
|
return EVENTOS.atender(self)
|
||||||
|
|
||||||
|
if caminho == "/estado":
|
||||||
|
return self.json(self.estado())
|
||||||
|
|
||||||
if partes and partes[0] == "fila":
|
if partes and partes[0] == "fila":
|
||||||
with TRAVA:
|
# Painéis não leem a fila: observam o rosto por /eventos.
|
||||||
aparelho = 'aparelho=1' in self.path
|
return self.enviar(json.dumps(FILA.consumir(aparelho="aparelho=1" in self.path)))
|
||||||
pendentes = [x for x in FILA if x!='recarregar' or aparelho]
|
|
||||||
FILA[:] = [x for x in FILA if x=='recarregar' and not aparelho]
|
|
||||||
return self.enviar(json.dumps(pendentes))
|
|
||||||
|
|
||||||
if partes and partes[0] == "faces":
|
if partes and partes[0] == "faces":
|
||||||
return self.enviar(json.dumps({"rostos": ROSTOS}))
|
return self.enviar(json.dumps({"rostos": ROSTOS}))
|
||||||
@@ -156,12 +306,6 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
if partes and partes[0] == "gestos":
|
if partes and partes[0] == "gestos":
|
||||||
return self.enviar(json.dumps({"gestos": GESTOS}))
|
return self.enviar(json.dumps({"gestos": GESTOS}))
|
||||||
|
|
||||||
if len(partes) == 2 and partes[0] == "face":
|
|
||||||
nome = unquote(partes[1]).lower()
|
|
||||||
with TRAVA:
|
|
||||||
FILA.append("rosto " + nome)
|
|
||||||
return self.enviar(json.dumps({"ok": True, "rosto": nome}))
|
|
||||||
|
|
||||||
if partes and partes[0] == "diag":
|
if partes and partes[0] == "diag":
|
||||||
# a propria pagina relata o tamanho que recebeu (ajuda a achar
|
# a propria pagina relata o tamanho que recebeu (ajuda a achar
|
||||||
# barras/enquadramento errado no kiosk). Aparece no journal.
|
# barras/enquadramento errado no kiosk). Aparece no journal.
|
||||||
@@ -169,92 +313,142 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
print("DIAG kiosk:", info, flush=True)
|
print("DIAG kiosk:", info, flush=True)
|
||||||
return self.enviar(json.dumps({"ok": True}))
|
return self.enviar(json.dumps({"ok": True}))
|
||||||
|
|
||||||
if len(partes) == 2 and partes[0] == "gesto":
|
# Obsoletos (GET que muda estado); ficam até o painel antigo sair. Use POST /comando.
|
||||||
nome = unquote(partes[1]).lower() # aceita %20 para espacos
|
if len(partes) == 2 and partes[0] in ("gesto", "face"):
|
||||||
with TRAVA:
|
tipo = "gesto" if partes[0] == "gesto" else "rosto"
|
||||||
FILA.append(nome)
|
try:
|
||||||
return self.enviar(json.dumps({"ok": True, "gesto": nome}))
|
ordem = ordem_de_comando(tipo, unquote(partes[1]))
|
||||||
|
except ValueError as e:
|
||||||
|
return self.json({"erro": str(e)}, 400)
|
||||||
|
enfileirar(ordem)
|
||||||
|
return self.json({"ok": True, partes[0]: ordem})
|
||||||
|
|
||||||
if caminho in ("/dashboard", "/emojis", "/api"):
|
# Painel (Vite/Svelte compilado em web/dist) em "/"; o resto é lista branca.
|
||||||
self.path = caminho + ".html"
|
painel = Path("web/dist")
|
||||||
elif caminho == "/painel":
|
if caminho in ("/", "", "/index.html"):
|
||||||
self.path = "/painel.html" # painel de controle
|
if not (painel / "index.html").is_file():
|
||||||
elif caminho == '/servos':
|
return self.json({"erro": "Painel não compilado: rode 'npm run build' em web/ (ou use deploy.ps1)"}, 503)
|
||||||
self.path = '/servos.html'
|
self.path = "/web/dist/index.html"
|
||||||
elif caminho == '/conversa':
|
elif caminho.startswith("/assets/"):
|
||||||
self.path = '/conversa.html'
|
nome = caminho[len("/assets/"):]
|
||||||
elif caminho == '/treinamento':
|
if "/" in nome or "\\" in nome or nome.startswith(".") or not (painel / "assets" / nome).is_file():
|
||||||
self.path = '/treinamento.html'
|
return self.json({"erro": "Arquivo não disponível"}, 404)
|
||||||
elif caminho == '/braco':
|
self.path = "/web/dist/assets/" + nome
|
||||||
self.path = '/braco.html'
|
elif caminho in ("/dashboard", "/painel", "/conversa", "/emojis", "/treinamento", "/braco", "/servos", "/api"):
|
||||||
elif caminho in ("/", ""):
|
# Endereços das telas antigas levam à seção equivalente do painel novo.
|
||||||
self.path = "/dashboard.html"
|
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':
|
elif caminho == '/qualidade-auto.js':
|
||||||
self.path = '/android/app/assets/qualidade-auto.js'
|
self.path = '/android/app/assets/qualidade-auto.js'
|
||||||
elif caminho == '/app.apk':
|
elif caminho == '/app.apk':
|
||||||
self.path = '/android/carinha-rosto.apk'
|
self.path = '/android/carinha-rosto.apk'
|
||||||
elif caminho in (
|
elif caminho not in ESTATICOS:
|
||||||
'/vendor/mediapipe/vision_bundle.mjs', '/vendor/mediapipe/face.tflite', '/vendor/mediapipe/hands.task',
|
|
||||||
'/vendor/mediapipe/wasm/vision_wasm_internal.js', '/vendor/mediapipe/wasm/vision_wasm_internal.wasm',
|
|
||||||
'/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js', '/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm'):
|
|
||||||
pass
|
|
||||||
elif caminho in ('/dashboard.html','/emojis.html','/api.html','/dashboard.css','/dashboard.js','/overview.js','/biblioteca.js','/api-painel.js','/painel.css','/conversa.css','/treinamento.css','/servos.css','/braco.css','/audio-robot.js'):
|
|
||||||
pass
|
|
||||||
elif caminho not in ('/conversa.html', '/treinamento.html', '/treinamento.js', '/reacoes-render.js', '/painel-conversa.js', '/braco-calibracao.js', '/movimentos-core.js', '/braco-movimentos.js', '/braco.html', '/braco.js', '/servos.html', '/servos.js', '/carinha.html', '/camera.html', '/painel.html', '/painel-controle.js', '/painel-video.js',
|
|
||||||
'/rastreamento-core.js', '/rastreador-worker.js', '/painel-rastreamento.js', '/audio-robot.js'):
|
|
||||||
return self.enviar(json.dumps({'erro': 'Arquivo não disponível'}), codigo=404)
|
return self.enviar(json.dumps({'erro': 'Arquivo não disponível'}), codigo=404)
|
||||||
return super().do_GET()
|
return super().do_GET()
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
if hasattr(self.server, "api_config") and self.server.api_config.rotas(self, "POST"):return
|
if self.acesso_negado("POST"):
|
||||||
if hasattr(self.server, 'treinamento') and self.server.treinamento.rotas(self, 'POST'):
|
|
||||||
return
|
return
|
||||||
if hasattr(self.server, 'conversa') and self.server.conversa.rotas(self, 'POST'):
|
for modulo in self.modulos():
|
||||||
return
|
if modulo.rotas(self, "POST"):
|
||||||
if hasattr(self.server, 'cerebro') and self.server.cerebro.rotas(self, 'POST'):
|
return
|
||||||
return
|
rota = self.rota().rstrip("/")
|
||||||
if hasattr(self.server, 'braco') and self.server.braco.rotas(self, 'POST'):
|
try:
|
||||||
return
|
if rota == "/comando":
|
||||||
if hasattr(self.server, 'servos') and self.server.servos.rotas(self, 'POST'):
|
return self.comando(ler_json(self, 2048))
|
||||||
return
|
if rota == "/parar-tudo":
|
||||||
if self.server.controle.rotas(self, 'POST'):
|
ler_json(self, 2048)
|
||||||
return
|
return self.json(self.parar_tudo())
|
||||||
rota = self.path.split("?", 1)[0].rstrip("/")
|
if rota == "/rearmar":
|
||||||
tamanho = int(self.headers.get("Content-Length") or 0)
|
ler_json(self, 2048)
|
||||||
|
return self.json(self.rearmar())
|
||||||
|
if rota == '/app/recarregar':
|
||||||
|
ler_json(self, 2048)
|
||||||
|
return self.recarregar()
|
||||||
|
if rota == "/config":
|
||||||
|
return self.json({"ok": True, "ordens": aplicar_aparencia(ler_json(self, 2048)),
|
||||||
|
"aparencia": APARENCIA})
|
||||||
|
if rota in ("/gesto", "/face"):
|
||||||
|
# Obsoleto: corpo é o nome cru. Use POST /comando.
|
||||||
|
tamanho = int(self.headers.get("Content-Length") or 0)
|
||||||
|
nome = self.rfile.read(min(tamanho, 200)).decode("utf-8", "ignore")
|
||||||
|
ordem = ordem_de_comando("gesto" if rota == "/gesto" else "rosto", nome)
|
||||||
|
enfileirar(ordem)
|
||||||
|
return self.json({"ok": True, "ordem": ordem})
|
||||||
|
except ValueError as e:
|
||||||
|
return self.json({"erro": str(e)}, 400)
|
||||||
|
return self.json({"erro": "Rota POST desconhecida"}, 404)
|
||||||
|
|
||||||
if rota == '/app/recarregar':
|
def comando(self, dados):
|
||||||
origin=self.headers.get('Origin')
|
tipo, valor = dados.get("tipo"), dados.get("valor")
|
||||||
if origin and origin not in ('http://'+self.headers.get('Host',''),'https://'+self.headers.get('Host','')):
|
s = self.server
|
||||||
return self.enviar(json.dumps(dict(erro='Origem inválida')),codigo=400)
|
if tipo == "rosto":
|
||||||
import time
|
ordem_de_comando("rosto", valor) # valida e dá erro legível
|
||||||
with self.server.controle.lock:
|
aplicar_aparencia({"rosto": valor}) # salva, avisa os painéis e manda ao celular
|
||||||
online=time.monotonic()-self.server.controle.status_at<5
|
return self.json({"ok": True})
|
||||||
import uuid
|
if tipo in ("cor", "formato"):
|
||||||
self.server.controle.recarga = dict(id=uuid.uuid4().hex, confirmada=False)
|
if not aplicar_aparencia({tipo: valor}):
|
||||||
if not online:return self.enviar(json.dumps(dict(erro='Abra o Carinha e conecte ao servidor primeiro')),codigo=409)
|
raise ValueError("Cor inválida" if tipo == "cor" else "Formato inválido")
|
||||||
with TRAVA:
|
return self.json({"ok": True})
|
||||||
if 'recarregar' not in FILA:FILA.append('recarregar')
|
if tipo == "recarregar":
|
||||||
return self.enviar(json.dumps(dict(ok=True,mensagem='Solicitação enviada. Aguardando o celular recarregar a tela.')))
|
return self.recarregar()
|
||||||
|
if tipo in ("reacao", "parar_reacao"):
|
||||||
|
if not hasattr(s, "treinamento"):
|
||||||
|
raise ValueError("Reações indisponíveis")
|
||||||
|
if tipo == "reacao":
|
||||||
|
if type(valor) is not int:
|
||||||
|
raise ValueError("ID de reação inválido")
|
||||||
|
s.treinamento.testar(valor)
|
||||||
|
else:
|
||||||
|
with s.treinamento.lock:
|
||||||
|
s.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=dict(limpar=True))
|
||||||
|
return self.json({"ok": True})
|
||||||
|
ordem = ordem_de_comando(tipo, valor)
|
||||||
|
enfileirar(ordem)
|
||||||
|
return self.json({"ok": True, "ordem": ordem})
|
||||||
|
|
||||||
if rota == "/config":
|
def parar_tudo(self):
|
||||||
try:
|
s = self.server
|
||||||
dados = json.loads(self.rfile.read(tamanho).decode("utf-8", "ignore") or "{}")
|
if hasattr(s, "braco"):
|
||||||
if not isinstance(dados, dict):
|
s.braco.cancelar()
|
||||||
raise ValueError("esperava um objeto JSON")
|
if hasattr(s, "servos"):
|
||||||
except Exception as e:
|
with s.servos.lock:
|
||||||
return self.enviar(json.dumps({"erro": "json invalido: %s" % e}), codigo=400)
|
s.servos.travado = True
|
||||||
ordens = aplicar_aparencia(dados)
|
s.servos.stop()
|
||||||
return self.enviar(json.dumps({"ok": True, "aparencia": APARENCIA,
|
if hasattr(s, "treinamento"):
|
||||||
"ordens": ordens}))
|
with s.treinamento.lock:
|
||||||
|
s.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=dict(limpar=True))
|
||||||
|
FILA.limpar()
|
||||||
|
enfileirar("calma")
|
||||||
|
ESTOP.update(ativo=True, desde=time.time())
|
||||||
|
EVENTOS.publicar("estop", dict(ESTOP))
|
||||||
|
return {"ok": True, "estop": dict(ESTOP)}
|
||||||
|
|
||||||
if rota not in ("/gesto", "/face"):
|
def rearmar(self):
|
||||||
return self.enviar(json.dumps({"erro": "rotas POST: /gesto, /face, /config"}), codigo=404)
|
s = self.server
|
||||||
corpo = self.rfile.read(tamanho).decode("utf-8", "ignore").strip().lower()
|
if hasattr(s, "servos"):
|
||||||
if not corpo:
|
with s.servos.lock:
|
||||||
return self.enviar(json.dumps({"erro": "corpo vazio"}), codigo=400)
|
s.servos.travado = False
|
||||||
with TRAVA:
|
EVENTOS.publicar("motores", s.servos.motores())
|
||||||
FILA.append(corpo)
|
ESTOP.update(ativo=False, desde=None)
|
||||||
return self.enviar(json.dumps({"ok": True, "ordem": corpo}))
|
EVENTOS.publicar("estop", dict(ESTOP))
|
||||||
|
return {"ok": True, "estop": dict(ESTOP)}
|
||||||
|
|
||||||
|
def recarregar(self):
|
||||||
|
controle = self.server.controle
|
||||||
|
with controle.lock:
|
||||||
|
online = time.monotonic() - controle.status_at < 5
|
||||||
|
controle.recarga = dict(id=uuid.uuid4().hex, confirmada=False)
|
||||||
|
if not online:
|
||||||
|
return self.json({"erro": "Abra o Carinha e conecte ao servidor primeiro"}, 409)
|
||||||
|
FILA.adicionar("recarregar")
|
||||||
|
EVENTOS.publicar("recarregar", {"id": controle.recarga["id"]})
|
||||||
|
return self.json({"ok": True, "mensagem": "Solicitação enviada. Aguardando o celular recarregar a tela."})
|
||||||
|
|
||||||
def log_message(self, formato, *args):
|
def log_message(self, formato, *args):
|
||||||
# silencio por padrao (roda em segundo plano); --log liga o registro
|
# silencio por padrao (roda em segundo plano); --log liga o registro
|
||||||
@@ -270,6 +464,13 @@ class Servidor(ThreadingHTTPServer):
|
|||||||
so o desligamos no Windows."""
|
so o desligamos no Windows."""
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
allow_reuse_address = (os.name != "nt")
|
allow_reuse_address = (os.name != "nt")
|
||||||
|
token = None
|
||||||
|
|
||||||
|
|
||||||
|
def ligar_eventos(servidor):
|
||||||
|
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao"):
|
||||||
|
if hasattr(servidor, nome):
|
||||||
|
getattr(servidor, nome).publicar = EVENTOS.publicar
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -282,21 +483,89 @@ def main():
|
|||||||
os.chdir(args.pasta)
|
os.chdir(args.pasta)
|
||||||
carregar_aparencia()
|
carregar_aparencia()
|
||||||
servidor = Servidor(("0.0.0.0", args.porta), Handler)
|
servidor = Servidor(("0.0.0.0", args.porta), Handler)
|
||||||
|
servidor.token = carregar_token(Path.cwd())
|
||||||
servidor.api_config = ConfigAPI(Path.cwd() / 'api-privada.json')
|
servidor.api_config = ConfigAPI(Path.cwd() / 'api-privada.json')
|
||||||
servidor.controle = ControlePC(Path.cwd() / 'app-config.json')
|
servidor.controle = ControlePC(Path.cwd() / 'app-config.json')
|
||||||
servidor.servos = ControleServos(Path.cwd() / 'servos.json')
|
servidor.servos = ControleServos(Path.cwd() / 'servos.json')
|
||||||
servidor.braco = Braco(Path.cwd() / 'braco.json', servidor.servos)
|
servidor.braco = Braco(Path.cwd() / 'braco.json', servidor.servos)
|
||||||
def expressao_ia(gesto):
|
servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, enfileirar)
|
||||||
with TRAVA: FILA.append(gesto)
|
if servidor.token and not servidor.cerebro.chave:
|
||||||
servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, expressao_ia)
|
servidor.cerebro.chave = servidor.token
|
||||||
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
|
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
|
||||||
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
|
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
|
||||||
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
|
servidor.historico = Historico(Path.cwd())
|
||||||
|
servidor.vida = vida = Vida(Path.cwd() / 'vida.json')
|
||||||
|
# Estúdio de criação: expressões, atividades e figurinhas por IA (Gemini, IA local ou Qwen do S10)
|
||||||
|
servidor.criacao = Criacao(Path.cwd() / 'criacoes.db', Path.cwd() / 'criacao.json')
|
||||||
|
servidor.criacao.enfileirar = enfileirar
|
||||||
|
servidor.criacao.treinamento = servidor.treinamento
|
||||||
|
servidor.criacao.s10 = lambda: (servidor.cerebro.endpoint, servidor.cerebro.cabecalhos())
|
||||||
|
|
||||||
|
def registrar_conversa(papel, texto):
|
||||||
|
servidor.treinamento.registrar(papel, texto)
|
||||||
|
if papel == 'user':
|
||||||
|
vida.estimulo('conversa') # alguém conversou com ele: companhia
|
||||||
|
servidor.conversa.memoria.on_registrar = registrar_conversa
|
||||||
|
|
||||||
|
def celular_dormindo():
|
||||||
|
resumo = servidor.controle.resumo()
|
||||||
|
return bool(resumo['estado'].get('dormindo')) if resumo['online'] else None
|
||||||
|
|
||||||
|
def alguem_perto():
|
||||||
|
h = servidor.historico
|
||||||
|
recente = max(h.pir.get('ultimo') or 0, h.ultimo.get('movimento', 0))
|
||||||
|
return bool(h.pir.get('ativo')) or time.time() - recente < 120
|
||||||
|
|
||||||
|
vida.enfileirar = enfileirar
|
||||||
|
vida.treinamento = servidor.treinamento
|
||||||
|
vida.celular_dormindo = celular_dormindo
|
||||||
|
vida.celular_online = lambda: servidor.controle.resumo()['online']
|
||||||
|
|
||||||
|
def celular_atividade():
|
||||||
|
resumo = servidor.controle.resumo()
|
||||||
|
return resumo['estado'].get('atividade') if resumo['online'] else None
|
||||||
|
vida.celular_atividade = celular_atividade
|
||||||
|
vida.alguem_perto = alguem_perto
|
||||||
|
vida.madrugada = lambda agora: madrugada(servidor.controle.config, agora)
|
||||||
|
servidor.cerebro.vida_ligada = lambda: vida.config['ligado']
|
||||||
|
# E-Stop parado, ou modo presença com ninguém em casa (dormindo em silêncio): não faz nada
|
||||||
|
vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente"
|
||||||
|
|
||||||
|
def presenca(tipo):
|
||||||
|
vida.estimulo(tipo)
|
||||||
|
# PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda.
|
||||||
|
# De madrugada não: alguém se mexendo na cama não é motivo para acordar.
|
||||||
|
if tipo == "pir" and not madrugada(servidor.controle.config):
|
||||||
|
with servidor.controle.lock:
|
||||||
|
dormindo = bool(servidor.controle.estado.get("dormindo"))
|
||||||
|
if dormindo and not vida.sono_profundo and vida.pir_acorda():
|
||||||
|
enfileirar("acordar")
|
||||||
|
|
||||||
|
def camera(ligada):
|
||||||
|
if servidor.controle.config.get("sensorCamera") != ligada:
|
||||||
|
servidor.controle.publicar("app_config", servidor.controle.atualizar({"sensorCamera": ligada}))
|
||||||
|
|
||||||
|
def alguem_chegou():
|
||||||
|
camera(True) # modo presença: o PIR liga a câmera do celular
|
||||||
|
if not madrugada(servidor.controle.config) and not vida.sono_profundo:
|
||||||
|
enfileirar("acordar")
|
||||||
|
|
||||||
|
def ninguem_por_perto():
|
||||||
|
camera(False) # sem ninguém: câmera desligada, dorme e fica em silêncio
|
||||||
|
enfileirar("dormir")
|
||||||
|
|
||||||
|
servidor.historico.ao_detectar = presenca
|
||||||
|
servidor.historico.ao_presenca = alguem_chegou
|
||||||
|
servidor.historico.ao_ausencia = ninguem_por_perto
|
||||||
servidor.conversa.treinamento = servidor.treinamento
|
servidor.conversa.treinamento = servidor.treinamento
|
||||||
|
ligar_eventos(servidor)
|
||||||
servidor.registrar = args.log
|
servidor.registrar = args.log
|
||||||
print(f"servindo {args.pasta} em http://0.0.0.0:{args.porta}/carinha.html")
|
print(f"servindo {args.pasta} em http://0.0.0.0:{args.porta}/")
|
||||||
print(f"painel de controle em http://0.0.0.0:{args.porta}/painel")
|
print("autenticação:", "token obrigatório" if servidor.token else "DESLIGADA (defina CARINHA_TOKEN)")
|
||||||
servidor.serve_forever()
|
try:
|
||||||
|
servidor.serve_forever()
|
||||||
|
finally:
|
||||||
|
EVENTOS.fechar()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
(function(root){
|
|
||||||
function sequencias(ranges){
|
|
||||||
const neutral=[0,0,0,0,0,0,0];
|
|
||||||
const sets=[
|
|
||||||
['Acenar',[[0,-.7,.5,.2,0,.1,0],[0,.7,-.5,.2,0,.1,0],[0,-.7,.5,.2,0,.1,0],[0,.7,-.5,.2,0,.1,0],neutral]],
|
|
||||||
['Olhar para os lados',[[0,.2,-.4,0,0,0,-.7],[0,-.2,.4,0,0,0,.7],neutral]],
|
|
||||||
['Alcançar e recolher',[[0,0,.2,.6,-.5,.4,0],[0,0,-.2,-.4,.5,-.4,0],neutral]],
|
|
||||||
['Garra e punho',[[-.7,.3,0,.2,0,.1,0],[.7,-.3,0,.2,0,.1,0],[-.7,.3,0,0,0,0,0],neutral]],
|
|
||||||
['Apresentação',[[.3,.3,-.3,.4,-.3,.3,-.6],[-.3,-.3,.3,-.3,.4,-.3,.6],[.5,.4,.2,.3,-.3,.2,0],neutral]]];
|
|
||||||
return sets.map(([nome,steps])=>({nome,passos:steps.map(s=>s.map((v,i)=>{const [lo,hi]=ranges[i];return Math.round(90+v*(v<0?90-lo:hi-90));}))}));
|
|
||||||
}
|
|
||||||
const api={sequencias};if(typeof module==='object')module.exports=api;else root.Movimentos=api;
|
|
||||||
})(globalThis);
|
|
||||||
-159
@@ -1,159 +0,0 @@
|
|||||||
/**
|
|
||||||
* overview.js — Lógica de supervisão da Visão Geral
|
|
||||||
* Monitora métricas do robô, bateria, IA Gemini, reações e gerencia ações rápidas.
|
|
||||||
*/
|
|
||||||
(() => {
|
|
||||||
const el = id => document.getElementById(id);
|
|
||||||
let reload = null;
|
|
||||||
let start = 0;
|
|
||||||
let ultimoEstadoOnline = null;
|
|
||||||
|
|
||||||
function adicionarEvento(texto, tipo = 'info') {
|
|
||||||
const feed = el('overviewFeed');
|
|
||||||
if (!feed) return;
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.style.cssText = 'display:flex;align-items:center;gap:10px;animation:fadeInMsg .2s ease-out';
|
|
||||||
const cor = tipo === 'online' ? '#10b981' : tipo === 'alerta' ? '#f59e0b' : 'var(--ciano)';
|
|
||||||
const hora = new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
||||||
item.innerHTML = `
|
|
||||||
<span class="badge-dot" style="background:${cor};box-shadow:0 0 8px ${cor}"></span>
|
|
||||||
<span style="color:var(--texto)">${texto}</span>
|
|
||||||
<span style="margin-left:auto;font-size:11px;font-family:'JetBrains Mono',monospace;color:var(--fraco)">${hora}</span>
|
|
||||||
`;
|
|
||||||
feed.prepend(item);
|
|
||||||
while (feed.children.length > 7) {
|
|
||||||
feed.removeChild(feed.lastChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
try {
|
|
||||||
const [s, c, t] = await Promise.all(['/app/status', '/conversa/status', '/treinamento/status'].map(async p => {
|
|
||||||
const r = await fetch(p, { cache: 'no-store', signal: AbortSignal.timeout(3000) });
|
|
||||||
if (!r.ok) throw Error('Servidor indisponível');
|
|
||||||
return r.json();
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Estado do Celular
|
|
||||||
if (el('overviewPhone')) {
|
|
||||||
el('overviewPhone').textContent = s.online ? 'Conectado' : 'Desconectado';
|
|
||||||
el('overviewPhone').style.color = s.online ? '#34d399' : 'var(--fraco)';
|
|
||||||
}
|
|
||||||
if (el('overviewIP')) {
|
|
||||||
el('overviewIP').textContent = s.celular_ip || (s.online ? 'IP Detectado' : 'Abra o Carinha no celular');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registra evento de conexão/desconexão
|
|
||||||
if (ultimoEstadoOnline !== null && ultimoEstadoOnline !== s.online) {
|
|
||||||
if (s.online) {
|
|
||||||
adicionarEvento('Galaxy S10 conectado e sincronizado', 'online');
|
|
||||||
if (window.RobotAudio) window.RobotAudio.happy();
|
|
||||||
} else {
|
|
||||||
adicionarEvento('Galaxy S10 desconectou da Central', 'alerta');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ultimoEstadoOnline = s.online;
|
|
||||||
|
|
||||||
// Bateria
|
|
||||||
if (el('overviewBattery')) {
|
|
||||||
const nivel = s.online && s.estado?.bateria?.nivel != null ? s.estado.bateria.nivel + '%' : '—';
|
|
||||||
el('overviewBattery').textContent = nivel;
|
|
||||||
if (s.estado?.bateria?.nivel != null) {
|
|
||||||
if (s.estado.bateria.nivel <= 20) {
|
|
||||||
el('overviewBattery').style.color = '#fb7185';
|
|
||||||
} else {
|
|
||||||
el('overviewBattery').style.color = '#f0f6fc';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (el('overviewCharge')) {
|
|
||||||
const carregando = s.estado?.bateria?.carregando;
|
|
||||||
el('overviewCharge').textContent = carregando ? '⚡ Carregando na tomada' : 'Operando na bateria';
|
|
||||||
el('overviewCharge').style.color = carregando ? 'var(--ciano)' : 'var(--fraco)';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inteligência IA
|
|
||||||
if (el('overviewAI')) {
|
|
||||||
el('overviewAI').textContent = c.provedor === 'gemini' ? 'Google Gemini' : 'Qwen Local';
|
|
||||||
}
|
|
||||||
if (el('overviewModel')) {
|
|
||||||
el('overviewModel').textContent = c.modelo || 'IA Pronta';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Biblioteca de reações
|
|
||||||
if (el('overviewReactions')) {
|
|
||||||
el('overviewReactions').textContent = Array.isArray(t.reacoes) ? t.reacoes.length : '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Botão de recarga
|
|
||||||
if (el('overviewReload')) {
|
|
||||||
el('overviewReload').disabled = !s.online;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirmação de recarga remota
|
|
||||||
if (reload && s.recarga?.id === reload && s.recarga.confirmada) {
|
|
||||||
el('overviewMessage').textContent = '✅ Tela atualizada: Galaxy S10 confirmou o recarregamento.';
|
|
||||||
adicionarEvento('Tela do Galaxy S10 recarregada com sucesso', 'online');
|
|
||||||
reload = null;
|
|
||||||
} else if (reload && Date.now() - start > 12000) {
|
|
||||||
el('overviewMessage').textContent = '⚠️ Sem confirmação. Reabra o app Carinha no celular.';
|
|
||||||
reload = null;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (el('overviewMessage')) el('overviewMessage').textContent = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ação de recarregar a tela do celular
|
|
||||||
if (el('overviewReload')) {
|
|
||||||
el('overviewReload').onclick = async () => {
|
|
||||||
try {
|
|
||||||
if (window.RobotAudio) window.RobotAudio.click();
|
|
||||||
const r = await fetch('/app/recarregar', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: '{}'
|
|
||||||
});
|
|
||||||
const d = await r.json();
|
|
||||||
if (!r.ok) throw Error(d.erro);
|
|
||||||
el('overviewMessage').textContent = d.mensagem;
|
|
||||||
adicionarEvento('Solicitação de recarga enviada ao celular...');
|
|
||||||
const s = await (await fetch('/app/status')).json();
|
|
||||||
reload = s.recarga?.id;
|
|
||||||
start = Date.now();
|
|
||||||
} catch (e) {
|
|
||||||
el('overviewMessage').textContent = 'Erro: ' + e.message;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Botões de Expressão Rápida (Mood Buttons)
|
|
||||||
document.querySelectorAll('.btn-mood').forEach(btn => {
|
|
||||||
btn.onclick = async () => {
|
|
||||||
const gesto = btn.getAttribute('data-gesto');
|
|
||||||
if (!gesto) return;
|
|
||||||
try {
|
|
||||||
if (window.RobotAudio) window.RobotAudio.tocarGesto(gesto);
|
|
||||||
await fetch('/gesto/' + encodeURIComponent(gesto));
|
|
||||||
adicionarEvento(`Expressão disparada: ${gesto}`);
|
|
||||||
btn.style.transform = 'scale(0.94)';
|
|
||||||
setTimeout(() => btn.style.transform = '', 160);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Falha ao acionar gesto:', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Botão de Teste Sonoro
|
|
||||||
if (el('overviewSfxHappy')) {
|
|
||||||
el('overviewSfxHappy').onclick = () => {
|
|
||||||
if (window.RobotAudio) {
|
|
||||||
window.RobotAudio.happy();
|
|
||||||
adicionarEvento('Sintetizador gerou trinado alegre');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh();
|
|
||||||
setInterval(refresh, 3500);
|
|
||||||
})();
|
|
||||||
@@ -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;}
|
|
||||||
};
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
/**
|
|
||||||
* painel-conversa.js — Interface interativa de chat com o Carinha (IA Gemini / Qwen)
|
|
||||||
* Suporte a Reconhecimento de Voz (Microfone), Síntese de Voz Robótica (TTS),
|
|
||||||
* balões modernos, chips de sugestão e acionamento de expressões.
|
|
||||||
*/
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const el = id => document.getElementById(id);
|
|
||||||
let sending = false;
|
|
||||||
let signature = '';
|
|
||||||
let recognition = null;
|
|
||||||
let isRecording = false;
|
|
||||||
let lastSpokenMessage = null;
|
|
||||||
|
|
||||||
// Configuração de síntese de voz robótica
|
|
||||||
function falarTexto(texto) {
|
|
||||||
if (!('speechSynthesis' in window) || !texto) return;
|
|
||||||
window.speechSynthesis.cancel(); // Para falas anteriores
|
|
||||||
const u = new SpeechSynthesisUtterance(texto);
|
|
||||||
u.lang = 'pt-BR';
|
|
||||||
u.pitch = 1.35; // Voz aguda e robótica / simpática estilo robô
|
|
||||||
u.rate = 1.05; // Ritmo levemente ágil
|
|
||||||
|
|
||||||
// Tenta encontrar uma voz em português brasileiro
|
|
||||||
const voices = window.speechSynthesis.getVoices();
|
|
||||||
const ptVoice = voices.find(v => v.lang.startsWith('pt'));
|
|
||||||
if (ptVoice) u.voice = ptVoice;
|
|
||||||
|
|
||||||
window.speechSynthesis.speak(u);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuração do microfone (Speech Recognition)
|
|
||||||
function setupSpeechRecognition() {
|
|
||||||
const SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
||||||
const micBtn = el('conversaMicBtn');
|
|
||||||
if (!SpeechRec || !micBtn) {
|
|
||||||
if (micBtn) {
|
|
||||||
micBtn.title = 'Reconhecimento de voz não suportado pelo navegador atual';
|
|
||||||
micBtn.style.opacity = '0.4';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
recognition = new SpeechRec();
|
|
||||||
recognition.lang = 'pt-BR';
|
|
||||||
recognition.continuous = false;
|
|
||||||
recognition.interimResults = false;
|
|
||||||
|
|
||||||
recognition.onstart = () => {
|
|
||||||
isRecording = true;
|
|
||||||
micBtn.classList.add('recording');
|
|
||||||
micBtn.textContent = '⏹️';
|
|
||||||
if (window.RobotAudio) window.RobotAudio.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onresult = (event) => {
|
|
||||||
const transcript = event.results[0][0].transcript;
|
|
||||||
const input = el('conversaTexto');
|
|
||||||
if (input && transcript) {
|
|
||||||
input.value = transcript;
|
|
||||||
input.focus();
|
|
||||||
if (window.RobotAudio) window.RobotAudio.bleep();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onerror = (e) => {
|
|
||||||
console.warn('Erro no microfone:', e.error);
|
|
||||||
pararGravacao();
|
|
||||||
};
|
|
||||||
|
|
||||||
recognition.onend = () => {
|
|
||||||
pararGravacao();
|
|
||||||
};
|
|
||||||
|
|
||||||
function pararGravacao() {
|
|
||||||
isRecording = false;
|
|
||||||
micBtn.classList.remove('recording');
|
|
||||||
micBtn.textContent = '🎤';
|
|
||||||
}
|
|
||||||
|
|
||||||
micBtn.onclick = () => {
|
|
||||||
if (isRecording) {
|
|
||||||
recognition.stop();
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
recognition.start();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Falha ao iniciar reconhecimento:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function api(path, data) {
|
|
||||||
const r = await fetch('/conversa/' + path, data === undefined ? { cache: 'no-store' } : {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
const result = await r.json();
|
|
||||||
if (!r.ok) throw Error(result.erro);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
try {
|
|
||||||
const s = await api('status');
|
|
||||||
|
|
||||||
if (el('conversaProvedor')) {
|
|
||||||
el('conversaProvedor').value = s.provedor || 'local';
|
|
||||||
el('conversaProvedor').disabled = s.ocupado;
|
|
||||||
}
|
|
||||||
if (el('conversaEnviar')) {
|
|
||||||
el('conversaEnviar').disabled = sending || s.ocupado;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Indicador de "Pensando..."
|
|
||||||
const typingEl = el('conversaTyping');
|
|
||||||
if (typingEl) {
|
|
||||||
typingEl.style.display = s.ocupado ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Texto de status do motor
|
|
||||||
if (el('conversaEstado')) {
|
|
||||||
el('conversaEstado').textContent = s.erro ? 'Falha: ' + s.erro :
|
|
||||||
s.ocupado ? 'Pensando com ' + (s.modelo || 'IA') + '…' :
|
|
||||||
s.ultima ? (s.modelo || 'IA') + ' · ' + s.ultima.segundos + 's' :
|
|
||||||
'Pronto para conversar.';
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = JSON.stringify([s.historico, s.lembrancas, s.ultima]);
|
|
||||||
if (signature === next) return;
|
|
||||||
signature = next;
|
|
||||||
|
|
||||||
// Renderiza Histórico de Mensagens como Balões de Chat
|
|
||||||
const hist = el('conversaHistorico');
|
|
||||||
if (hist) {
|
|
||||||
hist.replaceChildren();
|
|
||||||
|
|
||||||
if (!s.historico || s.historico.length === 0) {
|
|
||||||
const vazio = document.createElement('div');
|
|
||||||
vazio.style.cssText = 'text-align:center;color:var(--fraco);padding:40px 20px;font-size:14px';
|
|
||||||
vazio.innerHTML = `
|
|
||||||
<div style="font-size:36px;margin-bottom:10px">🤖</div>
|
|
||||||
<strong>O Carinha está pronto para conversar!</strong>
|
|
||||||
<p style="margin-top:6px;font-size:13px">Envie uma mensagem abaixo ou use os atalhos de sugestão.</p>
|
|
||||||
`;
|
|
||||||
hist.append(vazio);
|
|
||||||
} else {
|
|
||||||
for (const m of s.historico) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'chat-row ' + (m.role === 'user' ? 'user' : 'assistant');
|
|
||||||
|
|
||||||
const avatar = document.createElement('div');
|
|
||||||
avatar.className = 'chat-avatar';
|
|
||||||
avatar.textContent = m.role === 'user' ? '👤' : '🤖';
|
|
||||||
|
|
||||||
const content = document.createElement('div');
|
|
||||||
content.className = 'chat-content';
|
|
||||||
|
|
||||||
const balloon = document.createElement('div');
|
|
||||||
balloon.className = 'chat-balloon';
|
|
||||||
balloon.textContent = m.content;
|
|
||||||
|
|
||||||
content.append(balloon);
|
|
||||||
|
|
||||||
// Se for mensagem do robô, adiciona botão de áudio e gesto
|
|
||||||
if (m.role === 'assistant') {
|
|
||||||
const meta = document.createElement('div');
|
|
||||||
meta.className = 'chat-meta';
|
|
||||||
|
|
||||||
const speakBtn = document.createElement('button');
|
|
||||||
speakBtn.className = 'chat-speak-btn';
|
|
||||||
speakBtn.type = 'button';
|
|
||||||
speakBtn.title = 'Ouvir resposta (Voz)';
|
|
||||||
speakBtn.innerHTML = '🔊 Falar';
|
|
||||||
speakBtn.onclick = () => {
|
|
||||||
falarTexto(m.content);
|
|
||||||
if (window.RobotAudio) window.RobotAudio.click();
|
|
||||||
};
|
|
||||||
meta.append(speakBtn);
|
|
||||||
|
|
||||||
if (s.ultima && s.ultima.texto === m.content && s.ultima.gesto) {
|
|
||||||
const gestoBadge = document.createElement('span');
|
|
||||||
gestoBadge.className = 'chat-gesto-badge';
|
|
||||||
gestoBadge.title = 'Clique para ver a carinha fazer este gesto';
|
|
||||||
gestoBadge.innerHTML = `<span>✨</span><span>${s.ultima.gesto}</span>`;
|
|
||||||
gestoBadge.onclick = async () => {
|
|
||||||
try {
|
|
||||||
if (window.RobotAudio) window.RobotAudio.tocarGesto(s.ultima.gesto);
|
|
||||||
await fetch('/gesto/' + encodeURIComponent(s.ultima.gesto));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
meta.append(gestoBadge);
|
|
||||||
}
|
|
||||||
|
|
||||||
content.append(meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.append(avatar, content);
|
|
||||||
hist.append(row);
|
|
||||||
}
|
|
||||||
hist.scrollTop = hist.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fala automática da nova resposta da IA se a opção estiver ativada
|
|
||||||
const autoVoz = el('conversaVozAuto')?.checked;
|
|
||||||
if (autoVoz && s.ultima && s.ultima.texto && s.ultima.texto !== lastSpokenMessage) {
|
|
||||||
lastSpokenMessage = s.ultima.texto;
|
|
||||||
falarTexto(s.ultima.texto);
|
|
||||||
if (s.ultima.gesto && window.RobotAudio) {
|
|
||||||
window.RobotAudio.tocarGesto(s.ultima.gesto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renderiza Lista de Lembranças / Memória
|
|
||||||
const memorias = el('conversaMemorias');
|
|
||||||
if (memorias) {
|
|
||||||
memorias.replaceChildren();
|
|
||||||
for (const m of s.lembrancas || []) {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'memory-card';
|
|
||||||
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.style.cssText = 'color:var(--texto);font-size:13.5px;word-break:break-word';
|
|
||||||
label.textContent = m.texto;
|
|
||||||
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.type = 'button';
|
|
||||||
button.className = 'danger';
|
|
||||||
button.style.cssText = 'padding:4px 10px;font-size:12px;flex-shrink:0';
|
|
||||||
button.textContent = 'Apagar';
|
|
||||||
button.onclick = () => action('apagar', { id: m.id });
|
|
||||||
|
|
||||||
row.append(label, button);
|
|
||||||
memorias.append(row);
|
|
||||||
}
|
|
||||||
if (!s.lembrancas || !s.lembrancas.length) {
|
|
||||||
memorias.innerHTML = '<span style="color:var(--fraco);font-size:13px;padding:6px 0">Nenhuma lembrança salva no banco SQLite.</span>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (el('conversaEstado')) el('conversaEstado').textContent = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function action(path, data) {
|
|
||||||
try {
|
|
||||||
await api(path, data);
|
|
||||||
await refresh();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
if (el('conversaEstado')) el('conversaEstado').textContent = e.message;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Troca de Provedor (Gemini / Qwen)
|
|
||||||
if (el('conversaProvedor')) {
|
|
||||||
el('conversaProvedor').onchange = () => action('config', { provedor: el('conversaProvedor').value });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Envio de Mensagem
|
|
||||||
if (el('conversaForm')) {
|
|
||||||
el('conversaForm').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = el('conversaTexto');
|
|
||||||
const val = input.value.trim();
|
|
||||||
if (!val) return;
|
|
||||||
|
|
||||||
sending = true;
|
|
||||||
if (el('conversaEnviar')) el('conversaEnviar').disabled = true;
|
|
||||||
if (window.RobotAudio) window.RobotAudio.click();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (await action('enviar', { texto: val })) {
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
sending = false;
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chips de Sugestão de Diálogo
|
|
||||||
document.querySelectorAll('.suggestion-chip').forEach(chip => {
|
|
||||||
chip.onclick = async () => {
|
|
||||||
const msg = chip.getAttribute('data-msg');
|
|
||||||
const input = el('conversaTexto');
|
|
||||||
if (input && msg) {
|
|
||||||
input.value = msg;
|
|
||||||
if (el('conversaForm')) {
|
|
||||||
el('conversaForm').requestSubmit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Salvar Lembrança
|
|
||||||
if (el('memoriaForm')) {
|
|
||||||
el('memoriaForm').onsubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = el('memoriaTexto');
|
|
||||||
if (await action('lembrar', { texto: input.value })) {
|
|
||||||
input.value = '';
|
|
||||||
if (window.RobotAudio) window.RobotAudio.bleep();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limpar Histórico
|
|
||||||
if (el('conversaLimpar')) {
|
|
||||||
el('conversaLimpar').onclick = () => {
|
|
||||||
if (confirm('Apagar todo o histórico de conversas e lembranças do Carinha?')) {
|
|
||||||
action('limpar', {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
setupSpeechRecognition();
|
|
||||||
refresh();
|
|
||||||
setInterval(refresh, 1800);
|
|
||||||
})();
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
const video=document.getElementById('cameraVideo'), canvas=document.getElementById('trackingOverlay');
|
|
||||||
const ctx=canvas.getContext('2d'), label=document.getElementById('trackingEstado');
|
|
||||||
const R=window.Rastreamento;
|
|
||||||
const fresh=new R.FreshTarget();
|
|
||||||
const connections=[[0,1],[1,2],[2,3],[3,4],[0,5],[5,6],[6,7],[7,8],[5,9],[9,10],[10,11],[11,12],[9,13],[13,14],[14,15],[15,16],[13,17],[0,17],[17,18],[18,19],[19,20]];
|
|
||||||
let worker=null, ready=false, pending=false, generation=0, active=false, config={}, session=null;
|
|
||||||
let result=null, target=null, at=0, interval=50, nextFrame=0, lastVideo=-1, samples=0, retryAt=0, previousSample=0;
|
|
||||||
const scratch=document.createElement('canvas'), sc=scratch.getContext('2d');
|
|
||||||
function clear() { ctx.clearRect(0,0,canvas.width,canvas.height); }
|
|
||||||
function stop(text) {
|
|
||||||
if (active && window.CarinhaVideo) window.CarinhaVideo.rastrear(null);
|
|
||||||
active=false; generation++; if(worker) worker.terminate(); worker=null;
|
|
||||||
pending=false; ready=false; result=null; target=null; lastVideo=-1; fresh.clear(); previousSample=0; clear(); label.textContent=text;
|
|
||||||
}
|
|
||||||
function draw() {
|
|
||||||
const rect=canvas.parentElement.getBoundingClientRect();
|
|
||||||
if(canvas.width!==Math.round(rect.width) || canvas.height!==Math.round(rect.height)) {
|
|
||||||
canvas.width=Math.round(rect.width); canvas.height=Math.round(rect.height);
|
|
||||||
}
|
|
||||||
clear(); canvas.hidden=!config.mostrarRastreamento;
|
|
||||||
if(canvas.hidden || !result || performance.now()-at>350 || !video.videoWidth) return;
|
|
||||||
const fit=R.fit(canvas.width,canvas.height,video.videoWidth,video.videoHeight);
|
|
||||||
const point=p=>R.point(p,fit);
|
|
||||||
ctx.lineWidth=2; ctx.font='13px Segoe UI';
|
|
||||||
for(const f of result.faces) {
|
|
||||||
const p=point(f); ctx.strokeStyle='#5cd6ff'; ctx.fillStyle='#5cd6ff';
|
|
||||||
ctx.strokeRect(p.x,p.y,f.w*fit.w,f.h*fit.h);
|
|
||||||
ctx.fillText('Rosto '+Math.round(f.score*100)+'%',p.x,Math.max(15,p.y-6));
|
|
||||||
for(const q of f.pontos || []) { const v=point(q); ctx.beginPath();ctx.arc(v.x,v.y,3,0,Math.PI*2);ctx.fill(); }
|
|
||||||
}
|
|
||||||
for(const h of result.hands) {
|
|
||||||
ctx.strokeStyle='#78ffa0'; ctx.fillStyle='#78ffa0';
|
|
||||||
for(const [a,b] of connections) { const p=point(h[a]),q=point(h[b]); ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.lineTo(q.x,q.y);ctx.stroke(); }
|
|
||||||
for(const q of h) { const p=point(q);ctx.beginPath();ctx.arc(p.x,p.y,3,0,Math.PI*2);ctx.fill(); }
|
|
||||||
const p=point(h[0]);ctx.fillText('Mão',p.x+6,p.y+16);
|
|
||||||
}
|
|
||||||
if(target) { const p=point(target);ctx.strokeStyle='#ffd860';ctx.beginPath();ctx.arc(p.x,p.y,12,0,Math.PI*2);ctx.moveTo(p.x-18,p.y);ctx.lineTo(p.x+18,p.y);ctx.moveTo(p.x,p.y-18);ctx.lineTo(p.x,p.y+18);ctx.stroke(); }
|
|
||||||
}
|
|
||||||
function start() {
|
|
||||||
active=true; const gen=++generation; worker=new Worker('/rastreador-worker.js');
|
|
||||||
label.textContent='Carregando rastreadores locais…';
|
|
||||||
worker.onerror=e=>{ retryAt=Date.now()+10000; stop('Falha no rastreador: '+e.message); };
|
|
||||||
worker.onmessage=({data})=>{
|
|
||||||
if(gen!==generation) return;
|
|
||||||
pending=false;
|
|
||||||
if(data.tipo==='erro') { retryAt=Date.now()+10000; stop('Rastreamento indisponível: '+data.erro); return; }
|
|
||||||
ready=true;
|
|
||||||
if(data.tipo==='pronto') { label.textContent='Rastreadores prontos. Aguardando imagem…'; return; }
|
|
||||||
result=data; at=data.timestamp; samples++;
|
|
||||||
const prior=target;
|
|
||||||
target=R.target(data.faces,data.hands,prior);
|
|
||||||
if(target) target.atividade=!!prior && target.tipo===prior.tipo && Math.hypot(target.x-prior.x,target.y-prior.y)>.008;
|
|
||||||
if(target) fresh.accept(target,at); else fresh.clear();
|
|
||||||
interval=Math.max(50,Math.min(200,data.ms*1.2));
|
|
||||||
const hz=previousSample?Math.round(1000/(data.timestamp-previousSample)):0; previousSample=data.timestamp;
|
|
||||||
label.textContent=data.faces.length+' rosto(s) · '+data.hands.length+' mão(s) · '+hz+' análises/s · '+Math.round(data.ms)+' ms' +
|
|
||||||
(target?' · alvo: '+(target.tipo==='mao'?'mão':'rosto'):' · procurando…');
|
|
||||||
if(config.seguirRastreamento && window.CarinhaVideo) window.CarinhaVideo.rastrear(target);
|
|
||||||
draw();
|
|
||||||
};
|
|
||||||
pending=true; worker.postMessage({config:{rosto:config.rastrearRosto,maos:config.rastrearMaos}});
|
|
||||||
}
|
|
||||||
async function frame(now) {
|
|
||||||
if(!active || !ready || pending || now<nextFrame || video.readyState<2 || video.hidden || video.currentTime===lastVideo) return;
|
|
||||||
pending=true; nextFrame=now+interval; lastVideo=video.currentTime;
|
|
||||||
const gen=generation;
|
|
||||||
try {
|
|
||||||
const scale=Math.min(1,640/video.videoWidth);scratch.width=Math.round(video.videoWidth*scale);scratch.height=Math.round(video.videoHeight*scale);
|
|
||||||
sc.drawImage(video,0,0,scratch.width,scratch.height);
|
|
||||||
const bitmap=await createImageBitmap(scratch);
|
|
||||||
if(gen!==generation) { bitmap.close(); return; }
|
|
||||||
worker.postMessage({bitmap,timestamp:now,config:{rosto:config.rastrearRosto,maos:config.rastrearMaos}},[bitmap]);
|
|
||||||
} catch(e) { if(gen===generation) stop('Falha ao analisar vídeo: '+e.message); }
|
|
||||||
}
|
|
||||||
setInterval(()=>{const now=performance.now();frame(now);
|
|
||||||
if(fresh.value && !fresh.get(now)) { fresh.clear(); target=null; if(config.seguirRastreamento && window.CarinhaVideo) window.CarinhaVideo.rastrear(null); }
|
|
||||||
if(result && now-at>350) { result=null;target=null;clear(); }
|
|
||||||
},20);
|
|
||||||
window.addEventListener('resize',draw);
|
|
||||||
window.CarinhaRastreamento={
|
|
||||||
atualizar(status,c) {
|
|
||||||
const changed=config.rastrearRosto!==c.rastrearRosto || config.rastrearMaos!==c.rastrearMaos;
|
|
||||||
if(config.seguirRastreamento && !c.seguirRastreamento && window.CarinhaVideo) window.CarinhaVideo.rastrear(null);
|
|
||||||
config=c;
|
|
||||||
const allowed=status.online && c.preview && c.sensorCamera!==false && (!(status.estado || {}).dormindo || c.cameraSono);
|
|
||||||
if(!allowed || (!c.rastrearRosto && !c.rastrearMaos)) {
|
|
||||||
stop(!allowed?'Ligue o vídeo para rastrear no PC.':'Rastreamento desligado.'); return;
|
|
||||||
}
|
|
||||||
if(changed && active) stop('Atualizando rastreadores…');
|
|
||||||
if(!active && Date.now()>=retryAt) start(); draw();
|
|
||||||
},
|
|
||||||
parar() { stop('Vídeo desligado.'); },
|
|
||||||
estado() { return {active,ready,pending,samples,result,target}; }
|
|
||||||
};
|
|
||||||
window.addEventListener('pagehide',()=>stop('Painel fechado.'));
|
|
||||||
})();
|
|
||||||
-138
@@ -1,138 +0,0 @@
|
|||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
const video = document.getElementById('cameraVideo');
|
|
||||||
const mensagem = document.getElementById('cameraMensagem');
|
|
||||||
const qualidade = document.getElementById('videoQualidade');
|
|
||||||
let pc = null, id = null, desde = 0, ice = [], busy = false, wanted = false;
|
|
||||||
let opening = false, polling = false, generation = 0, retryAt = 0, last = null, outroPainel = false;
|
|
||||||
let trackingChannel = null;
|
|
||||||
let trackingSequence = 0;
|
|
||||||
let effectiveQuality = '';
|
|
||||||
async function req(path, data) {
|
|
||||||
const r = await fetch(path, {cache:'no-store',signal:AbortSignal.timeout(3500),
|
|
||||||
...(data === undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)})});
|
|
||||||
const body = await r.json();
|
|
||||||
if (!r.ok) throw new Error(body.erro || 'HTTP ' + r.status);
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
function texto(t) {
|
|
||||||
mensagem.textContent = t;
|
|
||||||
mensagem.hidden = false; video.hidden = true;
|
|
||||||
}
|
|
||||||
function parar(fechar = true) {
|
|
||||||
generation++;
|
|
||||||
const antiga = id; id = null;
|
|
||||||
if (pc) pc.close(); pc = null; trackingChannel=null; ice = []; desde = 0; last = null;
|
|
||||||
if (window.CarinhaRastreamento) window.CarinhaRastreamento.parar();
|
|
||||||
video.pause(); video.srcObject = null; video.hidden = true;
|
|
||||||
if (antiga && fechar) req('/rtc/session',{acao:'fechar',id:antiga}).catch(() => {});
|
|
||||||
}
|
|
||||||
async function enviar(msg, sessao) {
|
|
||||||
await req('/rtc/send',{id:sessao || id,de:'pc',mensagem:msg});
|
|
||||||
}
|
|
||||||
async function iniciar() {
|
|
||||||
if (!wanted || pc || opening || outroPainel || Date.now() < retryAt) return;
|
|
||||||
opening = true;
|
|
||||||
const gen = generation;
|
|
||||||
texto('Conectando vídeo ao vivo… O rosto continua no celular.');
|
|
||||||
try {
|
|
||||||
const session = await req('/rtc/session',{acao:'abrir'});
|
|
||||||
if (gen !== generation || !wanted) { req('/rtc/session',{acao:'fechar',id:session.id}).catch(()=>{}); return; }
|
|
||||||
id = session.id;
|
|
||||||
const currentId = id;
|
|
||||||
pc = new RTCPeerConnection({iceServers:[]});
|
|
||||||
const mine = pc;
|
|
||||||
trackingChannel=pc.createDataChannel('rastreamento',{ordered:false,maxRetransmits:0});
|
|
||||||
pc.addTransceiver('video',{direction:'recvonly'});
|
|
||||||
pc.onicecandidate = event => {
|
|
||||||
if (pc === mine && event.candidate) enviar({tipo:'candidate',candidate:event.candidate.toJSON()}, currentId).catch(()=>{});
|
|
||||||
};
|
|
||||||
pc.ontrack = event => {
|
|
||||||
if (pc !== mine) return;
|
|
||||||
video.srcObject = event.streams[0] || new MediaStream([event.track]);
|
|
||||||
video.play().catch(e => texto('Clique em Ligar câmera para permitir o vídeo: ' + e.message));
|
|
||||||
};
|
|
||||||
pc.onconnectionstatechange = () => {
|
|
||||||
if (pc !== mine) return;
|
|
||||||
if (pc.connectionState === 'failed') {
|
|
||||||
parar(); retryAt = Date.now()+2000; texto('Reconectando o vídeo…');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await pc.setLocalDescription(await pc.createOffer());
|
|
||||||
if (pc !== mine) return;
|
|
||||||
await enviar({tipo:'offer',sdp:pc.localDescription.sdp}, currentId);
|
|
||||||
} catch (e) { parar(); retryAt = Date.now()+2500; texto('Falha ao iniciar vídeo: ' + e.message); }
|
|
||||||
finally { opening = false; }
|
|
||||||
}
|
|
||||||
async function poll() {
|
|
||||||
if (!id || !pc || polling) return;
|
|
||||||
polling = true;
|
|
||||||
const mine = pc, sid = id;
|
|
||||||
try {
|
|
||||||
const packet = await req('/rtc/poll?lado=pc&desde='+desde+'&id='+sid);
|
|
||||||
if (pc !== mine) return;
|
|
||||||
if (packet.id !== sid) {
|
|
||||||
outroPainel = !!packet.id; parar(false);
|
|
||||||
texto(outroPainel ? 'Vídeo aberto em outro painel. Recarregue para assumir o controle.' : 'Aguardando reconexão do vídeo…');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const envelope of packet.mensagens) {
|
|
||||||
const msg = envelope.mensagem;
|
|
||||||
if (msg.tipo === 'answer') {
|
|
||||||
await pc.setRemoteDescription({type:'answer',sdp:msg.sdp});
|
|
||||||
for (const c of ice) await pc.addIceCandidate(c);
|
|
||||||
ice = [];
|
|
||||||
} else if (msg.tipo === 'candidate') {
|
|
||||||
if (pc.remoteDescription) await pc.addIceCandidate(msg.candidate); else ice.push(msg.candidate);
|
|
||||||
} else if (msg.tipo === 'erro') {
|
|
||||||
parar(); retryAt = Date.now()+3500;
|
|
||||||
texto('Câmera no celular: ' + msg.erro); return;
|
|
||||||
}
|
|
||||||
desde = Math.max(desde,envelope.seq);
|
|
||||||
}
|
|
||||||
} catch (e) { if (pc === mine) { parar(); retryAt = Date.now()+2500; texto('Sinalização interrompida. Reconectando…'); } }
|
|
||||||
finally { polling = false; }
|
|
||||||
}
|
|
||||||
video.onloadeddata = () => { video.hidden = false; mensagem.hidden = true; };
|
|
||||||
video.onplaying = () => { video.hidden = false; mensagem.hidden = true; };
|
|
||||||
let statsBusy = false;
|
|
||||||
setInterval(async () => {
|
|
||||||
if (!pc || pc.connectionState !== 'connected' || statsBusy) return;
|
|
||||||
statsBusy = true;
|
|
||||||
const mine = pc;
|
|
||||||
try {
|
|
||||||
const report = await pc.getStats(); if (pc !== mine) return;
|
|
||||||
report.forEach(s => {
|
|
||||||
if (s.type !== 'inbound-rtp' || (s.kind !== 'video' && s.mediaType !== 'video')) return;
|
|
||||||
const fps = last ? (s.framesDecoded-last.framesDecoded)*1000/(s.timestamp-last.timestamp) : s.framesPerSecond || 0;
|
|
||||||
const kbps = last ? (s.bytesReceived-last.bytesReceived)*8/(s.timestamp-last.timestamp) : 0;
|
|
||||||
last = s;
|
|
||||||
qualidade.textContent = (effectiveQuality==='auto' || (!effectiveQuality && appConfig?.videoResolucao === 'auto' && !appConfig?.videoFPS) ? 'Automática' : 'Manual') + ' · '+(s.frameWidth||video.videoWidth)+' × '+(s.frameHeight||video.videoHeight)+' · '+Math.round(fps)+' fps · '+Math.round(kbps)+' kb/s';
|
|
||||||
if (fps > 0) { video.hidden = false; mensagem.hidden = true; }
|
|
||||||
});
|
|
||||||
} catch (e) { /* conexão em transição */ }
|
|
||||||
finally { statsBusy = false; }
|
|
||||||
},1000);
|
|
||||||
setInterval(() => { iniciar(); poll(); },400);
|
|
||||||
window.CarinhaVideo = {
|
|
||||||
rastrear(alvo) {
|
|
||||||
if (trackingChannel && trackingChannel.readyState==='open' && trackingChannel.bufferedAmount<4096)
|
|
||||||
trackingChannel.send(JSON.stringify({tipo:'alvo',seq:++trackingSequence,alvo}));
|
|
||||||
},
|
|
||||||
rastreamentoConectado() { return !!trackingChannel && trackingChannel.readyState==='open'; },
|
|
||||||
parar() { wanted = false; outroPainel = false; parar(); },
|
|
||||||
atualizar(status, config) {
|
|
||||||
effectiveQuality=(status.estado || {}).video?.qualidade || '';
|
|
||||||
const liberada = config.sensorCamera !== false && (!(status.estado || {}).dormindo || config.cameraSono === true);
|
|
||||||
const nova = !!config.preview && !!status.online && liberada;
|
|
||||||
if (!nova) {
|
|
||||||
if (wanted || pc) parar(); wanted = false;
|
|
||||||
texto(!liberada ? 'Câmera desativada pelos ajustes de sensores.' : config.preview ? 'Celular sem conexão. Abra o Carinha.' : 'Transmissão desligada. O rosto e os sensores continuam funcionando.');
|
|
||||||
} else { wanted = true; iniciar(); }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('pagehide', () => {
|
|
||||||
if (id) navigator.sendBeacon('/rtc/session',new Blob([JSON.stringify({acao:'fechar',id})],{type:'application/json'}));
|
|
||||||
wanted = false; parar(false);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
-247
@@ -1,247 +0,0 @@
|
|||||||
/* ==========================================================================
|
|
||||||
PAINEL DE CÂMERA & ROSTO · CARINHA HUD STYLE
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
/* Se o painel for carregado como iframe limpo (no topo), exibe APENAS a câmera */
|
|
||||||
html.is-clean-embed body {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
html.is-clean-embed .painel-workspace {
|
|
||||||
display: block;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
html.is-clean-embed .painel-monitor-sticky {
|
|
||||||
width: 100% !important;
|
|
||||||
top: 0 !important;
|
|
||||||
position: static !important;
|
|
||||||
}
|
|
||||||
html.is-clean-embed .cartao {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
border: none !important;
|
|
||||||
background: transparent !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
/* Oculta quase tudo, deixa só a câmera (div.visao) */
|
|
||||||
html.is-clean-embed .cabecalho,
|
|
||||||
html.is-clean-embed .texto-fraco,
|
|
||||||
html.is-clean-embed .ajustes-grade,
|
|
||||||
html.is-clean-embed .sensores,
|
|
||||||
html.is-clean-embed .painel-controls-scroll,
|
|
||||||
html.is-clean-embed .rodape,
|
|
||||||
html.is-clean-embed #ajustesRastreamento,
|
|
||||||
html.is-clean-embed #trackingEstado,
|
|
||||||
html.is-clean-embed #sensorEstado {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
html.is-clean-embed h1,
|
|
||||||
html.is-clean-embed .sub,
|
|
||||||
html.is-clean-embed > div:first-of-type {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
/* Oculta o segundo cartão inteiro (Prévia do Celular) */
|
|
||||||
html.is-clean-embed .painel-monitor-sticky > section:not(:first-child) {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
/* Centraliza e expande o vídeo/imagem */
|
|
||||||
html.is-clean-embed .visao {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
min-height: unset;
|
|
||||||
aspect-ratio: unset;
|
|
||||||
}
|
|
||||||
html.is-clean-embed .visao video,
|
|
||||||
html.is-clean-embed .visao img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.supervisao {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr);
|
|
||||||
gap: 22px;
|
|
||||||
margin: 18px 0 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visao {
|
|
||||||
position: relative;
|
|
||||||
background: #020509;
|
|
||||||
border: 1px solid rgba(92, 214, 255, 0.35);
|
|
||||||
border-radius: var(--raio-lg);
|
|
||||||
min-height: 320px;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
overflow: hidden;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.7), inset 0 0 60px rgba(0, 0, 0, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.visao::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 12px;
|
|
||||||
border: 1px dashed rgba(92, 214, 255, 0.2);
|
|
||||||
border-radius: var(--raio-md);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visao img, .visao video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visao p {
|
|
||||||
max-width: 44ch;
|
|
||||||
text-align: center;
|
|
||||||
color: #9bb7cf;
|
|
||||||
padding: 24px;
|
|
||||||
font-size: 14.5px;
|
|
||||||
line-height: 1.6;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cabecalho {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cabecalho h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sensores {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 18px;
|
|
||||||
margin-top: 18px;
|
|
||||||
background: rgba(10, 18, 30, 0.6);
|
|
||||||
padding: 14px 18px;
|
|
||||||
border-radius: var(--raio-md);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
}
|
|
||||||
|
|
||||||
meter {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 12px;
|
|
||||||
margin-top: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(20, 35, 55, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
meter::-webkit-meter-bar {
|
|
||||||
background: rgba(20, 35, 55, 0.6);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
meter::-webkit-meter-optimum-value {
|
|
||||||
background: linear-gradient(90deg, #38bdf8, #5cd6ff);
|
|
||||||
box-shadow: 0 0 10px rgba(92, 214, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rosto-preview {
|
|
||||||
width: 100%;
|
|
||||||
height: 190px;
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
border-radius: var(--raio-md);
|
|
||||||
background: #000000;
|
|
||||||
margin-top: 16px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), inset 0 0 20px rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ajustes {
|
|
||||||
margin-top: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ajustes-grade {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
gap: 16px 28px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ajuste {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: rgba(12, 21, 35, 0.5);
|
|
||||||
border: 1px solid rgba(56, 82, 110, 0.25);
|
|
||||||
border-radius: var(--raio-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ajuste-range {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: rgba(12, 21, 35, 0.5);
|
|
||||||
border: 1px solid rgba(56, 82, 110, 0.25);
|
|
||||||
border-radius: var(--raio-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ajuste-range label {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
#gestos button {
|
|
||||||
padding: 8px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
border-radius: var(--raio-sm);
|
|
||||||
background: rgba(22, 38, 60, 0.65);
|
|
||||||
border: 1px solid var(--borda);
|
|
||||||
transition: all 0.16s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#gestos button:hover {
|
|
||||||
background: rgba(35, 62, 95, 0.85);
|
|
||||||
border-color: var(--ciano);
|
|
||||||
color: #ffffff;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 14px rgba(92, 214, 255, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
#rostos button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
border-radius: var(--raio-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
#rostos button.on {
|
|
||||||
background: var(--ciano);
|
|
||||||
border-color: var(--ciano);
|
|
||||||
color: #041420;
|
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: 0 0 16px var(--ciano-glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
|
||||||
.supervisao {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.visao {
|
|
||||||
min-height: 220px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-379
@@ -1,379 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Câmera & Rosto · Central Carinha</title>
|
|
||||||
<script>
|
|
||||||
if (window.location.search.includes('clean=1')) {
|
|
||||||
document.documentElement.classList.add('is-clean-embed');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<link rel="stylesheet" href="/dashboard.css">
|
|
||||||
<link rel="stylesheet" href="/painel.css">
|
|
||||||
<script src="/dashboard.js" defer></script>
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
|
|
||||||
<div>
|
|
||||||
<h1>Câmera & Rosto</h1>
|
|
||||||
<p id="bateriaCelular" class="texto-fraco" role="status" style="margin:4px 0 0">Bateria do celular: aguardando dados.</p>
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:10px;align-items:center">
|
|
||||||
<span class="badge ativo"><span class="badge-dot"></span> Monitoramento Contínuo</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sub" style="margin-bottom:18px">Controle o rostinho no celular e acompanhe o que ele vê. O monitor da câmera e do rosto fica fixado na tela enquanto você ajusta gestos e comportamentos.</div>
|
|
||||||
|
|
||||||
<!-- WORKSPACE STICKY: Câmera e Rosto fixados na tela durante a rolagem de configurações -->
|
|
||||||
<div class="painel-workspace">
|
|
||||||
|
|
||||||
<!-- Coluna Fixa (Sticky Monitor) -->
|
|
||||||
<aside class="painel-monitor-sticky">
|
|
||||||
<!-- Câmera ao Vivo -->
|
|
||||||
<section class="cartao" style="margin-bottom:18px">
|
|
||||||
<div class="cabecalho">
|
|
||||||
<h2>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
|
||||||
Olhar da Carinha
|
|
||||||
</h2>
|
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
|
||||||
<button id="bSnapshot" type="button" title="Capturar imagem da câmera">📸 Foto</button>
|
|
||||||
<button id="toggleCamera" class="principal" disabled>Ligar câmera</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="visao">
|
|
||||||
<video id="cameraVideo" autoplay muted playsinline hidden aria-label="Vídeo ao vivo da câmera do celular"></video>
|
|
||||||
<img id="cameraImagem" alt="Imagem ao vivo da câmera do celular" hidden>
|
|
||||||
<canvas id="trackingOverlay" aria-label="Marcações do rastreamento" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none" hidden></canvas>
|
|
||||||
<p id="cameraMensagem">Ligue a câmera para acompanhar o ambiente. A imagem aparece aqui; o celular continua mostrando o rosto.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="texto-fraco" id="videoQualidade" style="margin:8px 0 4px">Qualidade automática · buscando 30 fps</p>
|
|
||||||
<div id="ajustesRastreamento" class="ajustes-grade" style="margin-top:8px"></div>
|
|
||||||
<p id="trackingEstado" class="texto-fraco" role="status" style="margin:4px 0">Aguardando vídeo para rastrear no PC.</p>
|
|
||||||
|
|
||||||
<div class="sensores">
|
|
||||||
<label>Microfone <span id="micValor">—</span><meter id="micNivel" min="0" max="1" value="0"></meter></label>
|
|
||||||
<label>Movimento <span id="movValor">—</span><meter id="movNivel" min="0" max="1" value="0"></meter></label>
|
|
||||||
</div>
|
|
||||||
<p class="texto-fraco" id="sensorEstado" style="margin:4px 0">Aguardando o app no celular.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Prévia da Tela do Celular -->
|
|
||||||
<section class="cartao">
|
|
||||||
<div class="cabecalho">
|
|
||||||
<h2>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/></svg>
|
|
||||||
Tela do Galaxy S10
|
|
||||||
</h2>
|
|
||||||
<span id="conexaoEstado" class="online">Aguardando</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<iframe class="rosto-preview" style="height:165px;margin-top:4px" src="/carinha.html?espelho=1&camera=0&audio=0" title="Prévia da aparência escolhida" tabindex="-1"></iframe>
|
|
||||||
<p class="texto-fraco" style="margin:6px 0 10px">Prévia da aparência. <span id="rostoConfirmado">Aguardando confirmação do celular.</span></p>
|
|
||||||
|
|
||||||
<button id="recarregarTela" type="button" style="width:100%;margin-bottom:10px">🔄 Recarregar tela do celular</button>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Conexão ADB e Rede</summary>
|
|
||||||
<form id="conectarForm" style="margin-top:10px">
|
|
||||||
<label for="celularIP">IP do celular</label>
|
|
||||||
<input id="celularIP" type="text" value="192.168.1.16" required>
|
|
||||||
<label for="pcIP">IP do servidor na rede</label>
|
|
||||||
<input id="pcIP" type="text" value="192.168.1.35" required>
|
|
||||||
<button id="conectarBotao" type="submit" style="width:100%">Conectar e abrir o app</button>
|
|
||||||
</form>
|
|
||||||
<p id="conectarEstado" class="texto-fraco" role="status" style="margin-top:6px">O celular e o PC precisam estar na mesma rede, com ADB Wi-Fi ativado.</p>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Coluna Rolável (Controles & Configurações) -->
|
|
||||||
<main class="painel-controls-scroll">
|
|
||||||
|
|
||||||
<!-- Gestos -->
|
|
||||||
<section class="cartao">
|
|
||||||
<div class="cabecalho">
|
|
||||||
<h2>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M12 2v4"/><path d="M12 18v4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg>
|
|
||||||
Disparo de Gestos
|
|
||||||
</h2>
|
|
||||||
<span class="badge" style="font-size:11px">Fila de Ações</span>
|
|
||||||
</div>
|
|
||||||
<p style="font-size:13px;margin:4px 0 12px">Clique para acionar a expressão física no robô com feedback sonoro instantâneo:</p>
|
|
||||||
<div class="linha" id="gestos" style="gap:10px"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Rosto e Cor dos Olhos -->
|
|
||||||
<div class="grade" style="grid-template-columns:1fr 1fr">
|
|
||||||
<div class="cartao">
|
|
||||||
<h2>Rosto</h2>
|
|
||||||
<p style="font-size:13px;margin:4px 0 10px">Selecione a proporção e formato:</p>
|
|
||||||
<div class="linha" id="rostos"></div>
|
|
||||||
<div class="estado" id="estadoRosto" style="margin-top:12px;font-size:13.5px">—</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cartao">
|
|
||||||
<h2>Cor dos olhos</h2>
|
|
||||||
<p style="font-size:13px;margin:4px 0 10px">Paleta com brilho neon:</p>
|
|
||||||
<div class="linha" id="cores"></div>
|
|
||||||
<div class="olho">
|
|
||||||
<input type="color" id="corLivre" value="#5cd6ff">
|
|
||||||
<span style="font-size:13px">escolher outra cor</span>
|
|
||||||
</div>
|
|
||||||
<div class="previa" id="previa" style="margin-top:12px">
|
|
||||||
<div class="bolha" id="bolha1"></div>
|
|
||||||
<div class="bolha" id="bolha2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Aparência Atual -->
|
|
||||||
<div class="cartao">
|
|
||||||
<h2>Aparência Atual & Demos</h2>
|
|
||||||
<div class="estado" id="estadoAparencia">—</div>
|
|
||||||
<div class="linha" style="margin-top:14px">
|
|
||||||
<button id="bPadrao">Voltar ao padrão</button>
|
|
||||||
<button id="bAbracar" class="primary">Demo: acordar + feliz + coração</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Personalidade e IA local -->
|
|
||||||
<section class="cartao">
|
|
||||||
<details style="margin:0;border:0;padding:0">
|
|
||||||
<summary style="font-size:16px">Personalidade e IA Local (Qwen embarcado)</summary>
|
|
||||||
<div style="margin-top:12px">
|
|
||||||
<label style="display:flex;align-items:center;gap:10px">
|
|
||||||
<input id="iaAtiva" type="checkbox">
|
|
||||||
<span>Ativar cérebro autônomo do personagem</span>
|
|
||||||
</label>
|
|
||||||
<p class="texto-fraco">Qwen3 decide expressões a partir de sons e movimento. Processamento no celular; sem comandos de motores.</p>
|
|
||||||
<div style="display:flex;gap:10px;align-items:center;margin-top:10px">
|
|
||||||
<select id="iaEvento" aria-label="Evento para testar IA" style="margin:0">
|
|
||||||
<option value="saudacao">Alguém me cumprimentou</option>
|
|
||||||
<option value="som">Ouvi um som</option>
|
|
||||||
<option value="movimento">Vi movimento</option>
|
|
||||||
<option value="sozinho">Estou sozinho</option>
|
|
||||||
</select>
|
|
||||||
<button id="iaTestar" type="button">Testar reação</button>
|
|
||||||
</div>
|
|
||||||
<p id="iaEstado" role="status" style="margin-top:8px;font-size:13px">Carregando IA…</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Comportamento e Ajustes -->
|
|
||||||
<section class="cartao ajustes">
|
|
||||||
<div class="cabecalho">
|
|
||||||
<h2>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
|
||||||
Comportamento & Sensores
|
|
||||||
</h2>
|
|
||||||
<span class="badge" style="font-size:11px">Tempo Real</span>
|
|
||||||
</div>
|
|
||||||
<div id="ajustesBasicos" class="ajustes-grade"></div>
|
|
||||||
<details>
|
|
||||||
<summary>Sensibilidade e tempos de reação</summary>
|
|
||||||
<div id="ajustesAvancados" class="ajustes-grade"></div>
|
|
||||||
</details>
|
|
||||||
<div id="configEstado" role="status" style="margin-top:14px;color:var(--ciano)">Carregando configurações…</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rodape" style="margin-top:32px">
|
|
||||||
Servidor no Orange Pi. O vídeo é transmitido ao navegador e não é gravado.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
"use strict";
|
|
||||||
/* -------------------------------------------------------------------------
|
|
||||||
Painel de controle da carinha (servido pelo kiosk-server.py em /painel).
|
|
||||||
Ele conversa com as MESMAS rotas que a pagina da cara usa:
|
|
||||||
GET /gestos lista de gestos
|
|
||||||
GET /faces lista de rostos
|
|
||||||
GET /config aparencia guardada (rosto + cor)
|
|
||||||
GET /gesto/<nome> enfileira um gesto
|
|
||||||
POST /config guarda e aplica rosto/cor
|
|
||||||
------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
const elGestos = document.getElementById("gestos");
|
|
||||||
const elRostos = document.getElementById("rostos");
|
|
||||||
const elCores = document.getElementById("cores");
|
|
||||||
const elCorLivre = document.getElementById("corLivre");
|
|
||||||
const elEstadoRosto = document.getElementById("estadoRosto");
|
|
||||||
const elEstadoAparencia = document.getElementById("estadoAparencia");
|
|
||||||
const bPadrao = document.getElementById("bPadrao");
|
|
||||||
const bAbracar = document.getElementById("bAbracar");
|
|
||||||
const bolha1 = document.getElementById("bolha1");
|
|
||||||
const bolha2 = document.getElementById("bolha2");
|
|
||||||
|
|
||||||
const PADRAO = { rosto: "carinha", cor: [92, 214, 255] };
|
|
||||||
const PALETA = ["#5CD6FF", "#96E8FF", "#FF7070", "#FFB070", "#FFD860", "#78FFA0",
|
|
||||||
"#82AAFF", "#FF70B0", "#F5FAFF"];
|
|
||||||
let aparencia = Object.assign({}, PADRAO);
|
|
||||||
|
|
||||||
const paraHex = (c) => "#" + c.map((v) => Math.max(0, Math.min(255, Math.round(v)))
|
|
||||||
.toString(16).padStart(2, "0")).join("");
|
|
||||||
const deHex = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16),
|
|
||||||
parseInt(h.slice(5, 7), 16)];
|
|
||||||
const mesmoTom = (a, b) => a && b && a.every((v, i) => Math.abs(v - b[i]) < 6);
|
|
||||||
|
|
||||||
async function pedir(rota, opcoes) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(rota, opcoes);
|
|
||||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
||||||
return await r.json();
|
|
||||||
} catch (e) {
|
|
||||||
estado("Falha ao falar com o servidor (" + e.message + ")");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function estado(texto) {
|
|
||||||
elEstadoAparencia.innerHTML = texto;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pintarPrevia() {
|
|
||||||
const c = paraHex(aparencia.cor || PADRAO.cor);
|
|
||||||
bolha1.style.background = c;
|
|
||||||
bolha2.style.background = c;
|
|
||||||
const hex = paraHex(aparencia.cor || PADRAO.cor);
|
|
||||||
elCorLivre.value = hex;
|
|
||||||
for (const b of elCores.children) b.classList.toggle("on", b.dataset.hex === hex);
|
|
||||||
for (const b of elRostos.children)
|
|
||||||
b.classList.toggle("on", b.dataset.rosto === aparencia.rosto);
|
|
||||||
elEstadoRosto.innerHTML = "Em uso: <b>" + (aparencia.rosto || "?") + "</b>";
|
|
||||||
estado("Rosto <b>" + (aparencia.rosto || "?") + "</b> · cor dos olhos <b>" + hex +
|
|
||||||
"</b><br><span style='color:#7f97ab'>configuração guardada no PC</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Manda a aparencia para o servidor: ele guarda E enfileira a ordem certa. */
|
|
||||||
async function salvarAparencia(parte) {
|
|
||||||
const dados = {};
|
|
||||||
if (parte === "rosto") dados.rosto = aparencia.rosto;
|
|
||||||
if (parte === "cor") dados.cor = aparencia.cor;
|
|
||||||
const r = await pedir("/config", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(dados),
|
|
||||||
});
|
|
||||||
if (r && r.aparencia) {
|
|
||||||
aparencia = Object.assign(aparencia, r.aparencia);
|
|
||||||
pintarPrevia();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function montarGestos(lista) {
|
|
||||||
elGestos.innerHTML = "";
|
|
||||||
for (const nome of lista) {
|
|
||||||
const b = document.createElement("button");
|
|
||||||
b.textContent = nome.replace(/_/g, " ");
|
|
||||||
b.onclick = async () => {
|
|
||||||
if (window.RobotAudio) window.RobotAudio.tocarGesto(nome);
|
|
||||||
const resposta = await pedir("/gesto/" + encodeURIComponent(nome));
|
|
||||||
if (resposta) estado("Gesto enviado: <b>" + nome + "</b>");
|
|
||||||
};
|
|
||||||
elGestos.appendChild(b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function montarRostos(lista) {
|
|
||||||
elRostos.innerHTML = "";
|
|
||||||
for (const nome of lista) {
|
|
||||||
const b = document.createElement("button");
|
|
||||||
b.textContent = nome;
|
|
||||||
b.dataset.rosto = nome;
|
|
||||||
b.onclick = () => {
|
|
||||||
if (window.RobotAudio) window.RobotAudio.click();
|
|
||||||
aparencia.rosto = nome; pintarPrevia(); salvarAparencia("rosto");
|
|
||||||
};
|
|
||||||
elRostos.appendChild(b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function montarCores() {
|
|
||||||
elCores.innerHTML = "";
|
|
||||||
for (const hex of PALETA) {
|
|
||||||
const b = document.createElement("button");
|
|
||||||
b.className = "swatch";
|
|
||||||
b.dataset.hex = hex;
|
|
||||||
b.style.background = hex;
|
|
||||||
b.title = hex;
|
|
||||||
b.onclick = () => {
|
|
||||||
if (window.RobotAudio) window.RobotAudio.click();
|
|
||||||
aparencia.cor = deHex(hex); pintarPrevia(); salvarAparencia("cor");
|
|
||||||
};
|
|
||||||
elCores.appendChild(b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Botão de Snapshot / Foto
|
|
||||||
const elSnapshot = document.getElementById("bSnapshot");
|
|
||||||
if (elSnapshot) {
|
|
||||||
elSnapshot.onclick = () => {
|
|
||||||
const video = document.getElementById("cameraVideo");
|
|
||||||
const img = document.getElementById("cameraImagem");
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
let ok = false;
|
|
||||||
if (video && !video.hidden && video.videoWidth > 0) {
|
|
||||||
canvas.width = video.videoWidth;
|
|
||||||
canvas.height = video.videoHeight;
|
|
||||||
canvas.getContext("2d").drawImage(video, 0, 0);
|
|
||||||
ok = true;
|
|
||||||
} else if (img && !img.hidden && img.naturalWidth > 0) {
|
|
||||||
canvas.width = img.naturalWidth;
|
|
||||||
canvas.height = img.naturalHeight;
|
|
||||||
canvas.getContext("2d").drawImage(img, 0, 0);
|
|
||||||
ok = true;
|
|
||||||
}
|
|
||||||
if (ok) {
|
|
||||||
if (window.RobotAudio) window.RobotAudio.click();
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.download = "carinha-foto-" + Date.now() + ".png";
|
|
||||||
a.href = canvas.toDataURL("image/png");
|
|
||||||
a.click();
|
|
||||||
estado("Foto salva da câmera!");
|
|
||||||
} else {
|
|
||||||
alert("Ligue a câmera e aguarde o vídeo para tirar uma foto.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
elCorLivre.oninput = () => { aparencia.cor = deHex(elCorLivre.value); pintarPrevia();
|
|
||||||
salvarAparencia("cor"); };
|
|
||||||
bPadrao.onclick = () => {
|
|
||||||
aparencia = { rosto: PADRAO.rosto, cor: PADRAO.cor.slice() };
|
|
||||||
pintarPrevia();
|
|
||||||
salvarAparencia("rosto").then(() => salvarAparencia("cor"));
|
|
||||||
};
|
|
||||||
bAbracar.onclick = async () => {
|
|
||||||
await pedir("/gesto/acordar");
|
|
||||||
setTimeout(() => pedir("/gesto/feliz"), 900);
|
|
||||||
setTimeout(() => pedir("/gesto/coracao"), 2200);
|
|
||||||
estado("Demo enviada: <b>acordar → feliz → coração</b>");
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Monta as listas a partir do servidor (rostos, gestos, aparencia guardada). */
|
|
||||||
async function iniciar() {
|
|
||||||
const cfg = await pedir("/config");
|
|
||||||
if (cfg) aparencia = Object.assign(PADRAO, cfg);
|
|
||||||
const rostos = (await pedir("/faces")) || { rostos: ["eilik", "carinha", "miudo"] };
|
|
||||||
montarRostos(rostos.rostos || []);
|
|
||||||
const gestos = (await pedir("/gestos")) || { gestos: [] };
|
|
||||||
montarGestos(gestos.gestos || []);
|
|
||||||
montarCores();
|
|
||||||
pintarPrevia();
|
|
||||||
}
|
|
||||||
iniciar();
|
|
||||||
</script>
|
|
||||||
<script src="/painel-controle.js"></script>
|
|
||||||
<script src="/painel-video.js"></script>
|
|
||||||
<script src="/rastreamento-core.js"></script>
|
|
||||||
<script src="/painel-rastreamento.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
(function(root) {
|
|
||||||
'use strict';
|
|
||||||
const valid = p => p && Number.isFinite(p.x) && Number.isFinite(p.y);
|
|
||||||
const clamp = v => Math.max(0,Math.min(1,v));
|
|
||||||
function target(faces, hands, previous) {
|
|
||||||
let candidates = hands.filter(h=>h.length===21 && h.every(valid)).map(h=>{
|
|
||||||
const palm = [0,5,9,13,17].map(i=>h[i]);
|
|
||||||
return {tipo:'mao',x:palm.reduce((s,p)=>s+p.x,0)/5,y:palm.reduce((s,p)=>s+p.y,0)/5};
|
|
||||||
});
|
|
||||||
if (!candidates.length) candidates = faces.filter(f=>valid(f) && Number.isFinite(f.w) && Number.isFinite(f.h) && f.w>0 && f.h>0)
|
|
||||||
.sort((a,b)=>b.w*b.h-a.w*a.h).map(f=>({tipo:'rosto',x:f.x+f.w/2,y:f.y+f.h/2}));
|
|
||||||
if (!candidates.length) return null;
|
|
||||||
if (previous && previous.tipo === candidates[0].tipo) candidates.sort((a,b)=>
|
|
||||||
Math.hypot(a.x-previous.x,a.y-previous.y)-Math.hypot(b.x-previous.x,b.y-previous.y));
|
|
||||||
const t = candidates[0]; t.x=clamp(t.x); t.y=clamp(t.y); return t;
|
|
||||||
}
|
|
||||||
function fit(w,h,vw,vh) {
|
|
||||||
const scale = Math.min(w/vw,h/vh);
|
|
||||||
return {x:Math.max(0,(w-vw*scale)/2),y:Math.max(0,(h-vh*scale)/2),w:vw*scale,h:vh*scale};
|
|
||||||
}
|
|
||||||
function point(p,r) { return {x:r.x+p.x*r.w,y:r.y+p.y*r.h}; }
|
|
||||||
class FreshTarget {
|
|
||||||
constructor() { this.value=null; this.at=-Infinity; }
|
|
||||||
accept(p,now) {
|
|
||||||
if (!valid(p) || !['mao','rosto'].includes(p.tipo) || p.x<0 || p.x>1 || p.y<0 || p.y>1) return false;
|
|
||||||
this.value={tipo:p.tipo,x:p.x,y:p.y,atividade:p.atividade===true}; this.at=now; return true;
|
|
||||||
}
|
|
||||||
get(now) { return now-this.at <= 350 ? this.value : null; }
|
|
||||||
clear() { this.value=null; this.at=-Infinity; }
|
|
||||||
}
|
|
||||||
const api={target,fit,point,FreshTarget}; root.Rastreamento=api;
|
|
||||||
if(typeof module!=='undefined') module.exports=api;
|
|
||||||
})(typeof globalThis!=='undefined'?globalThis:window);
|
|
||||||
+12
-4
@@ -1,4 +1,12 @@
|
|||||||
(function(){
|
(function(){
|
||||||
|
// Cores das receitas: o ciano padrão (#5cd6ff) vira a cor escolhida no painel e o azul-claro
|
||||||
|
// (#aaccff) um tom mais claro dela; rosa, amarelo e verde continuam (fazem parte da emoção).
|
||||||
|
function corDaReacao(hex){
|
||||||
|
const h=String(hex||'').toLowerCase(), base=(typeof CIANO!=='undefined'?CIANO:[92,214,255]);
|
||||||
|
if(h==='#5cd6ff')return base.slice();
|
||||||
|
if(h==='#aaccff')return base.map(v=>v+(255-v)*.45);
|
||||||
|
return [1,3,5].map(i=>parseInt(h.slice(i,i+2),16));
|
||||||
|
}
|
||||||
const icons={nenhum:'',estrela:'✦',coracao:'♥',musica:'♫',cafe:'☕',ideia:'💡'};
|
const icons={nenhum:'',estrela:'✦',coracao:'♥',musica:'♫',cafe:'☕',ideia:'💡'};
|
||||||
const badge=document.createElement('div');badge.id='reacaoSimbolo';badge.style.cssText='position:fixed;left:5%;top:6%;z-index:15;color:#5cd6ff;font:clamp(30px,7vw,64px) sans-serif;pointer-events:none;white-space:pre-wrap';document.body.append(badge);
|
const badge=document.createElement('div');badge.id='reacaoSimbolo';badge.style.cssText='position:fixed;left:5%;top:6%;z-index:15;color:#5cd6ff;font:clamp(30px,7vw,64px) sans-serif;pointer-events:none;white-space:pre-wrap';document.body.append(badge);
|
||||||
let timer,last=null,fetching=false,animation;let particles=[];
|
let timer,last=null,fetching=false,animation;let particles=[];
|
||||||
@@ -15,13 +23,13 @@
|
|||||||
function visual(recipe){
|
function visual(recipe){
|
||||||
if(recipe.emoji){
|
if(recipe.emoji){
|
||||||
const e=recipe.emoji;if(e.local==='olhos'){window.carinhaOlhosIcone=e.caractere;return;}
|
const e=recipe.emoji;if(e.local==='olhos'){window.carinhaOlhosIcone=e.caractere;return;}
|
||||||
badge.style.cssText='position:fixed;right:5%;top:8%;z-index:15;font:clamp(42px,10vw,90px) sans-serif;pointer-events:none';badge.textContent=e.caractere;
|
badge.style.cssText='position:fixed;right:5%;top:8%;z-index:15;font:clamp(42px,10vw,90px) sans-serif;pointer-events:none;filter:drop-shadow(0 0 14px '+(recipe.passos[0].cor||'#5cd6ff')+')';badge.textContent=e.caractere;
|
||||||
animation=badge.animate([{transform:'scale(.92)'},{transform:'scale(1.06)'},{transform:'scale(.92)'}],{duration:1400,iterations:Infinity});return;
|
animation=badge.animate([{transform:'scale(.92)'},{transform:'scale(1.06)'},{transform:'scale(.92)'}],{duration:1400,iterations:Infinity});return;
|
||||||
}
|
}
|
||||||
if(recipe.figurinha){
|
if(recipe.figurinha){
|
||||||
const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)return;
|
const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)return;
|
||||||
if(recipe.figurinha.local==='olhos'){window.carinhaOlhosDesenho=recipe.figurinha;return;}
|
if(recipe.figurinha.local==='olhos'){window.carinhaOlhosDesenho=recipe.figurinha;return;}
|
||||||
badge.style.cssText='position:fixed;right:5%;top:8%;width:24%;height:30%;z-index:15;pointer-events:none';svg.style.cssText='width:100%;height:100%';badge.append(svg);
|
badge.style.cssText='position:fixed;right:5%;top:8%;width:24%;height:30%;z-index:15;pointer-events:none;filter:drop-shadow(0 0 14px '+(recipe.passos[0].cor||'#5cd6ff')+')';svg.style.cssText='width:100%;height:100%';badge.append(svg);
|
||||||
animation=svg.animate([{transform:'scale(.92) rotate(-3deg)'},{transform:'scale(1.05) rotate(3deg)'},{transform:'scale(.92) rotate(-3deg)'}],{duration:1500,iterations:Infinity});return;
|
animation=svg.animate([{transform:'scale(.92) rotate(-3deg)'},{transform:'scale(1.05) rotate(3deg)'},{transform:'scale(.92) rotate(-3deg)'}],{duration:1500,iterations:Infinity});return;
|
||||||
}
|
}
|
||||||
if(['ideia','cafe'].includes(recipe.simbolo)){window.carinhaOlhosIcone=icons[recipe.simbolo];return;}
|
if(['ideia','cafe'].includes(recipe.simbolo)){window.carinhaOlhosIcone=icons[recipe.simbolo];return;}
|
||||||
@@ -42,13 +50,13 @@
|
|||||||
for(const s of recipe.passos)if(!['aberto','feliz','surpreso','coracao','triste'].includes(s.olhos)||!['neutra','sorriso','aberta','triste'].includes(s.boca)||!/^#[0-9a-f]{6}$/i.test(s.cor)||!Number.isFinite(s.duracao)||s.duracao<.15||s.duracao>2||!Number.isFinite(s.dx)||Math.abs(s.dx)>25||!Number.isFinite(s.dy)||Math.abs(s.dy)>12||!Number.isFinite(s.escala)||s.escala<.8||s.escala>1.3)return;
|
for(const s of recipe.passos)if(!['aberto','feliz','surpreso','coracao','triste'].includes(s.olhos)||!['neutra','sorriso','aberta','triste'].includes(s.boca)||!/^#[0-9a-f]{6}$/i.test(s.cor)||!Number.isFinite(s.duracao)||s.duracao<.15||s.duracao>2||!Number.isFinite(s.dx)||Math.abs(s.dx)>25||!Number.isFinite(s.dy)||Math.abs(s.dy)>12||!Number.isFinite(s.escala)||s.escala<.8||s.escala>1.3)return;
|
||||||
clearTimeout(timer);clearVisual();window.eilik.soltar();trata('acordar');MIC.ultimoSom=performance.now()/1000;
|
clearTimeout(timer);clearVisual();window.eilik.soltar();trata('acordar');MIC.ultimoSom=performance.now()/1000;
|
||||||
visual(recipe);
|
visual(recipe);
|
||||||
r.fila=recipe.passos.map(s=>[{forma:s.olhos,forma2:null,boca:s.boca,cor:[1,3,5].map(i=>parseInt(s.cor.slice(i,i+2),16)),dx:s.dx,dy:s.dy,esc:s.escala,ab:1,ab2:1},s.duracao]);
|
r.fila=recipe.passos.map(s=>[{forma:s.olhos,forma2:null,boca:s.boca,cor:corDaReacao(s.cor),dx:s.dx,dy:s.dy,esc:s.escala,ab:1,ab2:1},s.duracao]);
|
||||||
r.fila.push([{forma:'aberto',forma2:null,boca:'neutra',cor:CIANO.slice(),dx:0,dy:0,esc:1,ab:1,ab2:1},.4]);proximo();
|
r.fila.push([{forma:'aberto',forma2:null,boca:'neutra',cor:CIANO.slice(),dx:0,dy:0,esc:1,ab:1,ab2:1},.4]);proximo();
|
||||||
timer=setTimeout(clearVisual,Math.max(recipe.figurinha?6000:0,['musica','estrela','coracao'].includes(recipe.simbolo)?4500:['ideia','cafe'].includes(recipe.simbolo)?2500:0,recipe.passos.reduce((n,s)=>n+s.duracao,0)*1000+400));
|
timer=setTimeout(clearVisual,Math.max(recipe.figurinha?6000:0,['musica','estrela','coracao'].includes(recipe.simbolo)?4500:['ideia','cafe'].includes(recipe.simbolo)?2500:0,recipe.passos.reduce((n,s)=>n+s.duracao,0)*1000+400));
|
||||||
}
|
}
|
||||||
window.eilik.receita=render;
|
window.eilik.receita=render;
|
||||||
addEventListener('message',e=>{if(e.origin===location.origin&&e.source===parent&&e.data&&e.data.tipo==='previa-reacao')render(e.data.receita);});
|
addEventListener('message',e=>{if(e.origin===location.origin&&e.source===parent&&e.data&&e.data.tipo==='previa-reacao')render(e.data.receita);});
|
||||||
if(new URLSearchParams(location.search).get('espelho')==='1'||location.protocol==='file:')return;
|
if(new URLSearchParams(location.search).get('espelho')==='1'||location.protocol==='file:')return;
|
||||||
async function poll(){if(fetching||window.carinhaAtiva===false)return;fetching=true;try{const response=await fetch('/treinamento/expressao',{cache:'no-store',signal:AbortSignal.timeout(2000)});if(response.ok){const d=await response.json();if(d&&d.id!==last&&Date.now()/1000-d.horario<60){last=d.id;render(d.receita);}}}catch(e){}finally{fetching=false;}}
|
async function poll(){if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;try{const response=await fetch('/treinamento/expressao',{cache:'no-store',signal:AbortSignal.timeout(2000)});if(response.ok){const d=await response.json();if(d&&d.id!==last&&Date.now()/1000-d.horario<60){last=d.id;render(d.receita);}}}catch(e){}finally{fetching=false;}}
|
||||||
setInterval(poll,1000);
|
setInterval(poll,1000);
|
||||||
})();
|
})();
|
||||||
|
|||||||
+1394
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
|||||||
@echo off
|
|
||||||
cd /d "%~dp0"
|
|
||||||
set PORTA=%1
|
|
||||||
if "%PORTA%"=="" set PORTA=8100
|
|
||||||
echo Painel: http://localhost:%PORTA%/painel
|
|
||||||
echo Ctrl+C para parar.
|
|
||||||
python kiosk-server.py --porta %PORTA%
|
|
||||||
pause
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
/* ==========================================================================
|
|
||||||
SERVOS PCA9685 · ESTÚDIO DE CALIBRAÇÃO & CONTROLE
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
#cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#cards article {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fields {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
background: rgba(10, 18, 30, 0.5);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: var(--raio-sm);
|
|
||||||
border: 1px solid rgba(56, 82, 110, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fields label {
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--texto-medio);
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=range] {
|
|
||||||
margin: 14px 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
output {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
color: var(--ciano);
|
|
||||||
text-shadow: 0 0 10px rgba(92, 214, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
#status {
|
|
||||||
min-height: 24px;
|
|
||||||
font-size: 13.5px;
|
|
||||||
color: var(--ciano);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dirty {
|
|
||||||
color: #fbbf24;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
#cards {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="pt-BR"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>Servos · Carinha</title><link rel="stylesheet" href="/dashboard.css"><link rel="stylesheet" href="/servos.css"><script src="/dashboard.js" defer></script>
|
|
||||||
<body>
|
|
||||||
<h1>Teste dos servos</h1>
|
|
||||||
|
|
||||||
<p class="intro">Escolha o canal impresso na placa (0 a 15). Ajuste o pulso e clique em Testar: o sinal dura até 2 segundos. Os limites iniciais são estreitos para começar a calibração. A posição mostrada é o comando, sem medição da posição real.</p>
|
|
||||||
<p class="intro">Ao desligar o sinal, o servo pode perder a força de sustentação. Nesta etapa, teste com o braço apoiado. Não há movimento automático ao abrir esta página.</p>
|
|
||||||
<label style="display:flex;gap:10px;align-items:center;font-size:16px"><input id="live" type="checkbox" style="width:22px;margin:0" disabled>Controle ao vivo — acompanhar o deslizador</label>
|
|
||||||
<p class="intro">No modo ao vivo, arrastar envia posições. O sinal desliga até 2 segundos após o último movimento. Desmarcar o modo ou desligar todos cancela os comandos pendentes.</p>
|
|
||||||
<div class="toolbar"><button id="stop" class="stop">Desligar todos os sinais</button><button id="save" class="primary">Salvar configurações</button><button id="add">Adicionar servo</button><span id="dirty"></span></div>
|
|
||||||
<p id="status" role="status" aria-live="polite">Verificando PCA9685…</p>
|
|
||||||
<div id="cards"></div>
|
|
||||||
<script src="/servos.js"></script>
|
|
||||||
</body></html>
|
|
||||||
@@ -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; });
|
|
||||||
@@ -4,6 +4,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from comum import Conflito, gravar_atomico, ler_json
|
||||||
|
|
||||||
def integer(value, lo, hi):
|
def integer(value, lo, hi):
|
||||||
if type(value) is not int or not lo <= value <= hi:
|
if type(value) is not int or not lo <= value <= hi:
|
||||||
@@ -46,6 +47,9 @@ class ControleServos:
|
|||||||
self.hw = hardware
|
self.hw = hardware
|
||||||
self.error = ''
|
self.error = ''
|
||||||
self.active = {}
|
self.active = {}
|
||||||
|
self.dono = None # 'braco' ou 'diagnostico': quem está com os motores
|
||||||
|
self.travado = False # E-Stop: nada se move até rearmar
|
||||||
|
self.publicar = lambda tipo, dados: None
|
||||||
self.closed = threading.Event()
|
self.closed = threading.Event()
|
||||||
self.config = [dict(nome=f'Servo {i+1}', canal=i, minimo=1400, maximo=1600, centro=1500) for i in range(6)]
|
self.config = [dict(nome=f'Servo {i+1}', canal=i, minimo=1400, maximo=1600, centro=1500) for i in range(6)]
|
||||||
try:
|
try:
|
||||||
@@ -78,14 +82,33 @@ class ControleServos:
|
|||||||
def status(self):
|
def status(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return dict(disponivel=self.hw is not None, erro=self.error,
|
return dict(disponivel=self.hw is not None, erro=self.error,
|
||||||
servos=[dict(row) for row in self.config], ativos=list(self.active), prazo=2)
|
servos=[dict(row) for row in self.config], ativos=list(self.active), prazo=2,
|
||||||
|
dono=self.dono, travado=self.travado)
|
||||||
|
def motores(self):
|
||||||
|
with self.lock:
|
||||||
|
return dict(disponivel=self.hw is not None, erro=self.error, ativos=sorted(self.active),
|
||||||
|
dono=self.dono, travado=self.travado)
|
||||||
|
def ligar(self, canais_prazos):
|
||||||
|
"""Registra canais energizados até o prazo; avisa o painel quando algum canal liga."""
|
||||||
|
with self.lock:
|
||||||
|
novos = False
|
||||||
|
for canal, prazo in canais_prazos:
|
||||||
|
novos = novos or canal not in self.active
|
||||||
|
self.active[canal] = prazo
|
||||||
|
if novos:
|
||||||
|
self.publicar('motores', self.motores())
|
||||||
|
def reservar(self, nome):
|
||||||
|
"""Braço e diagnóstico usam o mesmo PCA9685 com limites diferentes: um de cada vez."""
|
||||||
|
with self.lock:
|
||||||
|
if self.travado: raise Conflito('E-Stop ativo: rearme antes de mover')
|
||||||
|
if self.dono not in (None, nome) and self.active:
|
||||||
|
raise Conflito(f'Motores em uso por {self.dono}; pare-os primeiro')
|
||||||
|
self.dono = nome
|
||||||
def save(self, rows):
|
def save(self, rows):
|
||||||
rows = self.validate(rows)
|
rows = self.validate(rows)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.active: raise ValueError('Desligue os sinais antes de salvar')
|
if self.active: raise ValueError('Desligue os sinais antes de salvar')
|
||||||
tmp = self.path.with_suffix('.tmp')
|
gravar_atomico(self.path, rows)
|
||||||
tmp.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
||||||
tmp.replace(self.path)
|
|
||||||
self.config = rows
|
self.config = rows
|
||||||
return self.status()
|
return self.status()
|
||||||
def move(self, index, pulse):
|
def move(self, index, pulse):
|
||||||
@@ -94,20 +117,28 @@ class ControleServos:
|
|||||||
row = self.config[index]
|
row = self.config[index]
|
||||||
pulse = integer(pulse, row['minimo'], row['maximo'])
|
pulse = integer(pulse, row['minimo'], row['maximo'])
|
||||||
if self.hw is None: raise OSError('PCA9685 indisponível neste servidor')
|
if self.hw is None: raise OSError('PCA9685 indisponível neste servidor')
|
||||||
|
self.reservar('diagnostico')
|
||||||
self.hw.pulse(row['canal'], pulse)
|
self.hw.pulse(row['canal'], pulse)
|
||||||
self.active[row['canal']] = time.monotonic() + 2
|
self.ligar([(row['canal'], time.monotonic() + 2)])
|
||||||
return self.status()
|
return self.status()
|
||||||
def expire(self, now):
|
def expire(self, now):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
liberou = False
|
||||||
for channel, deadline in list(self.active.items()):
|
for channel, deadline in list(self.active.items()):
|
||||||
if now >= deadline:
|
if now >= deadline:
|
||||||
self.hw.off(channel)
|
self.hw.off(channel)
|
||||||
del self.active[channel]
|
del self.active[channel]
|
||||||
|
liberou = True
|
||||||
|
if liberou and not self.active:
|
||||||
|
self.dono = None
|
||||||
|
self.publicar('motores', self.motores())
|
||||||
def stop(self):
|
def stop(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.hw:
|
if self.hw:
|
||||||
for channel in range(16): self.hw.off(channel)
|
for channel in range(16): self.hw.off(channel)
|
||||||
self.active.clear()
|
self.active.clear()
|
||||||
|
self.dono = None
|
||||||
|
self.publicar('motores', self.motores())
|
||||||
return self.status()
|
return self.status()
|
||||||
def watch(self):
|
def watch(self):
|
||||||
while not self.closed.wait(.1):
|
while not self.closed.wait(.1):
|
||||||
@@ -122,14 +153,7 @@ class ControleServos:
|
|||||||
try:
|
try:
|
||||||
if method == 'GET' and route == '/servos/status': result = self.status()
|
if method == 'GET' and route == '/servos/status': result = self.status()
|
||||||
elif method == 'POST':
|
elif method == 'POST':
|
||||||
origin = h.headers.get('Origin')
|
data = ler_json(h, 8192)
|
||||||
if origin and origin not in ('http://' + h.headers.get('Host', ''), 'https://' + h.headers.get('Host', '')):
|
|
||||||
raise ValueError('Origem não permitida')
|
|
||||||
if h.headers.get('Content-Type', '').split(';')[0] != 'application/json': raise ValueError('Use JSON')
|
|
||||||
size = int(h.headers.get('Content-Length', '0'))
|
|
||||||
if not 0 < size <= 8192: raise ValueError('Corpo inválido')
|
|
||||||
data = json.loads(h.rfile.read(size))
|
|
||||||
if not isinstance(data, dict): raise ValueError('Objeto JSON obrigatório')
|
|
||||||
if route == '/servos/config': result = self.save(data.get('servos'))
|
if route == '/servos/config': result = self.save(data.get('servos'))
|
||||||
elif route == '/servos/mover': result = self.move(data.get('indice'), data.get('pulso'))
|
elif route == '/servos/mover': result = self.move(data.get('indice'), data.get('pulso'))
|
||||||
elif route == '/servos/parar': result = self.stop()
|
elif route == '/servos/parar': result = self.stop()
|
||||||
@@ -138,6 +162,8 @@ class ControleServos:
|
|||||||
h.enviar(json.dumps({'erro':'Método não permitido'}), codigo=405)
|
h.enviar(json.dumps({'erro':'Método não permitido'}), codigo=405)
|
||||||
return True
|
return True
|
||||||
h.enviar(json.dumps(result, ensure_ascii=False))
|
h.enviar(json.dumps(result, ensure_ascii=False))
|
||||||
|
except Conflito as e:
|
||||||
|
h.enviar(json.dumps({'erro':str(e)}, ensure_ascii=False), codigo=409)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
h.enviar(json.dumps({'erro':str(e)}), codigo=400)
|
h.enviar(json.dumps({'erro':str(e)}), codigo=400)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Carinha - Wi-Fi com modo de configuracao (ponto de acesso Carinha-Config)
|
||||||
|
After=systemd-networkd.service network-pre.target
|
||||||
|
Wants=systemd-networkd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/python3 /usr/local/sbin/carinha-wifi
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Wi-Fi da placa com modo de configuração (estilo "Wi-Fi de hotel").
|
||||||
|
|
||||||
|
- Rede salva e encontrada: conecta (cliente) e, se pedido, usa o endereço fixo 192.168.1.35.
|
||||||
|
- Sem rede salva, ou a rede sumiu por alguns minutos: vira o ponto de acesso "Carinha-Config".
|
||||||
|
Quem conecta nele cai numa página (abre sozinha no celular) para escolher a rede e a senha.
|
||||||
|
- No modo configuração, sem ninguém conectado, tenta voltar à rede salva a cada 5 minutos
|
||||||
|
(faltou luz e a placa ligou antes do roteador: não fica presa aqui).
|
||||||
|
|
||||||
|
O chip (unisoc_wifi) não faz cliente e ponto de acesso ao mesmo tempo: o serviço alterna entre os
|
||||||
|
dois. O cabo de rede fica com um endereço fixo de reserva (192.168.1.34) para nunca perder o acesso.
|
||||||
|
Só usa a biblioteca padrão; hostapd e dnsmasq rodam como processos filhos.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
IFACE = 'wlan0'
|
||||||
|
CABO = 'end0'
|
||||||
|
DIR = Path('/etc/carinha-wifi')
|
||||||
|
REDE = DIR / 'rede.json' # rede salva (só root lê)
|
||||||
|
AJUSTES = DIR / 'ajustes.json' # nome/senha do ponto de acesso
|
||||||
|
RUN = Path('/run/carinha-wifi')
|
||||||
|
ESTADO = RUN / 'estado.json' # para o painel mostrar
|
||||||
|
NETWORK = Path('/run/systemd/network/30-carinha-wlan0.network')
|
||||||
|
NETPLAN_CABO = Path('/etc/netplan/10-dhcp-all-interfaces.yaml')
|
||||||
|
|
||||||
|
AP_IP = '192.168.4.1'
|
||||||
|
IP_FIXO, GATEWAY, DNS = '192.168.1.35', '192.168.1.1', '192.168.1.1'
|
||||||
|
IP_CABO_RESERVA = '192.168.1.34'
|
||||||
|
ESPERA_CONECTAR = 60 # s para conectar antes de desistir e virar ponto de acesso
|
||||||
|
QUEDA_TOLERADA = 180 # s sem a rede (já conectado) até virar ponto de acesso
|
||||||
|
RETENTAR_A_CADA = 300 # s no modo configuração, sem ninguém conectado, para tentar a rede salva
|
||||||
|
|
||||||
|
PADRAO_AJUSTES = dict(ap_nome='Carinha-Config', ap_senha='carinha123')
|
||||||
|
|
||||||
|
|
||||||
|
def log(*a):
|
||||||
|
print('carinha-wifi:', *a, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def sh(*cmd, check=False, timeout=20):
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||||
|
if check and r.returncode:
|
||||||
|
raise RuntimeError(f'{cmd[0]} falhou: {r.stderr.strip()[:200]}')
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def ler_json(caminho, padrao):
|
||||||
|
try:
|
||||||
|
return json.loads(Path(caminho).read_text(encoding='utf-8'))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return padrao
|
||||||
|
|
||||||
|
|
||||||
|
def gravar(caminho, dados, modo=0o600):
|
||||||
|
caminho = Path(caminho)
|
||||||
|
caminho.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = caminho.with_suffix('.tmp')
|
||||||
|
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, modo)
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(dados if isinstance(dados, str) else json.dumps(dados, ensure_ascii=False, indent=2))
|
||||||
|
tmp.replace(caminho)
|
||||||
|
|
||||||
|
|
||||||
|
class Wifi:
|
||||||
|
def __init__(self):
|
||||||
|
self.modo = None # 'cliente' | 'ap'
|
||||||
|
self.desde = time.time()
|
||||||
|
self.ok_em = 0.0
|
||||||
|
self.erro = ''
|
||||||
|
self.redes = [] # última busca: [{ssid, sinal, seguranca}]
|
||||||
|
self.filhos = {}
|
||||||
|
self.portal = None
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.trocar_para = None # pedido vindo da página
|
||||||
|
self.anterior = False # rede salva antes do pedido (False = nenhum pedido em teste)
|
||||||
|
RUN.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ utilidades
|
||||||
|
def ajustes(self):
|
||||||
|
return dict(PADRAO_AJUSTES, **ler_json(AJUSTES, {}))
|
||||||
|
|
||||||
|
def rede(self):
|
||||||
|
r = ler_json(REDE, None)
|
||||||
|
return r if isinstance(r, dict) and r.get('ssid') else None
|
||||||
|
|
||||||
|
def conectado(self):
|
||||||
|
link = sh('iw', 'dev', IFACE, 'link').stdout
|
||||||
|
return 'Connected to' in link
|
||||||
|
|
||||||
|
def ip_wlan(self):
|
||||||
|
m = re.search(r'inet (\d+\.\d+\.\d+\.\d+)', sh('ip', '-4', 'addr', 'show', IFACE).stdout)
|
||||||
|
return m.group(1) if m else ''
|
||||||
|
|
||||||
|
def publicar(self):
|
||||||
|
r = self.rede()
|
||||||
|
gravar(ESTADO, dict(modo=self.modo, desde=self.desde, rede=r['ssid'] if r else None, ip=self.ip_wlan(),
|
||||||
|
erro=self.erro, ap=self.ajustes()['ap_nome']), 0o644)
|
||||||
|
|
||||||
|
def buscar_redes(self):
|
||||||
|
"""Busca redes (só funciona como cliente; no modo ponto de acesso usa a última busca)."""
|
||||||
|
sh('ip', 'link', 'set', IFACE, 'up')
|
||||||
|
saida = sh('iw', 'dev', IFACE, 'scan', timeout=30).stdout
|
||||||
|
if not saida:
|
||||||
|
saida = sh('iw', 'dev', IFACE, 'scan', 'ap-force', timeout=30).stdout
|
||||||
|
redes, atual = {}, {}
|
||||||
|
for linha in saida.splitlines():
|
||||||
|
linha = linha.strip()
|
||||||
|
if linha.startswith('BSS '):
|
||||||
|
atual = {}
|
||||||
|
elif linha.startswith('signal:'):
|
||||||
|
atual['sinal'] = float(linha.split()[1])
|
||||||
|
elif linha.startswith('SSID:'):
|
||||||
|
ssid = linha[5:].strip()
|
||||||
|
if ssid and '\\x00' not in ssid:
|
||||||
|
atual['ssid'] = ssid
|
||||||
|
if ssid not in redes or redes[ssid]['sinal'] < atual.get('sinal', -100):
|
||||||
|
redes[ssid] = dict(ssid=ssid, sinal=atual.get('sinal', -100), seguranca=True)
|
||||||
|
if redes:
|
||||||
|
self.redes = sorted(redes.values(), key=lambda r: -r['sinal'])[:20]
|
||||||
|
return self.redes
|
||||||
|
|
||||||
|
def parar_filhos(self):
|
||||||
|
for nome, p in list(self.filhos.items()):
|
||||||
|
if p.poll() is None:
|
||||||
|
p.terminate()
|
||||||
|
try:
|
||||||
|
p.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
p.kill()
|
||||||
|
self.filhos.clear()
|
||||||
|
sh('pkill', '-f', f'wpa_supplicant.*{RUN}')
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ cliente
|
||||||
|
def modo_cliente(self, rede):
|
||||||
|
log('conectando em', rede['ssid'])
|
||||||
|
self.parar_portal()
|
||||||
|
self.parar_filhos()
|
||||||
|
sh('ip', 'addr', 'flush', 'dev', IFACE)
|
||||||
|
sh('ip', 'link', 'set', IFACE, 'down')
|
||||||
|
sh('iw', 'dev', IFACE, 'set', 'type', 'managed')
|
||||||
|
sh('ip', 'link', 'set', IFACE, 'up')
|
||||||
|
sh('iw', 'dev', IFACE, 'set', 'power_save', 'off') # economia de energia atrasa e derruba a conexão
|
||||||
|
ssid_hex = rede['ssid'].encode().hex()
|
||||||
|
if rede.get('senha'):
|
||||||
|
psk = hashlib.pbkdf2_hmac('sha1', rede['senha'].encode(), rede['ssid'].encode(), 4096, 32).hex()
|
||||||
|
seg = f'key_mgmt=WPA-PSK\n psk={psk}'
|
||||||
|
else:
|
||||||
|
seg = 'key_mgmt=NONE'
|
||||||
|
gravar(RUN / 'wpa.conf', f'ctrl_interface={RUN}/wpa\ncountry=BR\nnetwork={{\n ssid={ssid_hex}\n scan_ssid=1\n {seg}\n}}\n')
|
||||||
|
self.filhos['wpa'] = subprocess.Popen(['wpa_supplicant', '-i', IFACE, '-D', 'nl80211', '-c', str(RUN / 'wpa.conf')],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
if rede.get('ip_fixo', True):
|
||||||
|
conteudo = f'[Match]\nName={IFACE}\n\n[Network]\nAddress={IP_FIXO}/24\nGateway={GATEWAY}\nDNS={DNS}\n'
|
||||||
|
else:
|
||||||
|
conteudo = f'[Match]\nName={IFACE}\n\n[Network]\nDHCP=ipv4\n\n[DHCPv4]\nRouteMetric=100\n'
|
||||||
|
gravar(NETWORK, conteudo, 0o644) # o systemd-networkd põe o endereço quando conectar
|
||||||
|
sh('networkctl', 'reload')
|
||||||
|
sh('networkctl', 'reconfigure', IFACE)
|
||||||
|
self.modo, self.desde, self.ok_em = 'cliente', time.time(), 0.0
|
||||||
|
self.publicar()
|
||||||
|
|
||||||
|
def liberar_ip_fixo_do_cabo(self):
|
||||||
|
"""O cabo usava 192.168.1.35: passa para 192.168.1.34 (reserva) para o Wi-Fi assumir o .35."""
|
||||||
|
try:
|
||||||
|
texto = NETPLAN_CABO.read_text(encoding='utf-8')
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if f'- {IP_FIXO}/24' not in texto:
|
||||||
|
return
|
||||||
|
log('cabo passa para', IP_CABO_RESERVA, '(o Wi-Fi fica com', IP_FIXO + ')')
|
||||||
|
NETPLAN_CABO.with_suffix('.yaml.antes-wifi').write_text(texto, encoding='utf-8')
|
||||||
|
gravar(NETPLAN_CABO, texto.replace(f'- {IP_FIXO}/24', f'- {IP_CABO_RESERVA}/24'), 0o600)
|
||||||
|
sh('netplan', 'generate')
|
||||||
|
sh('networkctl', 'reload')
|
||||||
|
sh('networkctl', 'reconfigure', CABO)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ ponto de acesso
|
||||||
|
def modo_ap(self, motivo):
|
||||||
|
log('modo configuração:', motivo)
|
||||||
|
self.parar_filhos()
|
||||||
|
try:
|
||||||
|
NETWORK.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
sh('networkctl', 'reload')
|
||||||
|
if self.modo != 'ap':
|
||||||
|
try:
|
||||||
|
self.buscar_redes() # ainda como cliente: guarda a lista para a página
|
||||||
|
except Exception as e:
|
||||||
|
log('busca falhou:', e)
|
||||||
|
aj = self.ajustes()
|
||||||
|
sh('ip', 'addr', 'flush', 'dev', IFACE)
|
||||||
|
sh('ip', 'link', 'set', IFACE, 'down')
|
||||||
|
sh('iw', 'dev', IFACE, 'set', 'type', '__ap')
|
||||||
|
sh('ip', 'link', 'set', IFACE, 'up')
|
||||||
|
sh('ip', 'addr', 'add', f'{AP_IP}/24', 'dev', IFACE)
|
||||||
|
gravar(RUN / 'hostapd.conf', '\n'.join([
|
||||||
|
f'interface={IFACE}', 'driver=nl80211', f'ssid={aj["ap_nome"]}', 'country_code=BR', 'hw_mode=g', 'channel=6',
|
||||||
|
'wmm_enabled=1', 'auth_algs=1', 'wpa=2', 'wpa_key_mgmt=WPA-PSK', 'rsn_pairwise=CCMP',
|
||||||
|
f'wpa_passphrase={aj["ap_senha"]}', '']))
|
||||||
|
self.filhos['hostapd'] = subprocess.Popen(['hostapd', str(RUN / 'hostapd.conf')],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
self.filhos['dnsmasq'] = subprocess.Popen([
|
||||||
|
'dnsmasq', '--keep-in-foreground', '--conf-file=/dev/null', '--no-resolv', '--no-hosts',
|
||||||
|
f'--interface={IFACE}', '--bind-interfaces', f'--listen-address={AP_IP}', '--except-interface=lo',
|
||||||
|
'--dhcp-range=192.168.4.10,192.168.4.60,255.255.255.0,1h', f'--dhcp-option=3,{AP_IP}',
|
||||||
|
f'--dhcp-option=6,{AP_IP}', f'--dhcp-option=114,http://{AP_IP}/', f'--address=/#/{AP_IP}',
|
||||||
|
f'--pid-file={RUN}/dnsmasq.pid'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
self.iniciar_portal()
|
||||||
|
self.modo, self.desde = 'ap', time.time()
|
||||||
|
self.publicar()
|
||||||
|
|
||||||
|
def alguem_no_ap(self):
|
||||||
|
return 'Station' in sh('iw', 'dev', IFACE, 'station', 'dump').stdout
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ página
|
||||||
|
def iniciar_portal(self):
|
||||||
|
if self.portal:
|
||||||
|
return
|
||||||
|
wifi = self
|
||||||
|
|
||||||
|
class Portal(BaseHTTPRequestHandler):
|
||||||
|
def responder(self, codigo, corpo='', tipo='text/html; charset=utf-8', extra=None):
|
||||||
|
dados = corpo.encode('utf-8')
|
||||||
|
self.send_response(codigo)
|
||||||
|
for k, v in (extra or {}).items():
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.send_header('Content-Type', tipo)
|
||||||
|
self.send_header('Content-Length', str(len(dados)))
|
||||||
|
self.send_header('Cache-Control', 'no-store')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(dados)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.split('?')[0] in ('/', '/index.html'):
|
||||||
|
return self.responder(200, wifi.pagina())
|
||||||
|
# Android/iPhone/Windows testam "tem internet?": a resposta errada faz a página abrir sozinha
|
||||||
|
return self.responder(302, extra={'Location': f'http://{AP_IP}/'})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != '/conectar':
|
||||||
|
return self.responder(404)
|
||||||
|
n = min(int(self.headers.get('Content-Length') or 0), 4096)
|
||||||
|
campos = {k: v[0] for k, v in parse_qs(self.rfile.read(n).decode('utf-8', 'replace')).items()}
|
||||||
|
ssid = (campos.get('ssid_outra') or campos.get('ssid') or '').strip()
|
||||||
|
senha = campos.get('senha', '')
|
||||||
|
if not ssid or len(ssid) > 32 or (senha and not 8 <= len(senha) <= 63):
|
||||||
|
wifi.erro = 'Escolha a rede e use uma senha de 8 a 63 caracteres (ou deixe vazio se a rede for aberta).'
|
||||||
|
return self.responder(200, wifi.pagina())
|
||||||
|
rede = dict(ssid=ssid, senha=senha, ip_fixo=campos.get('ip_fixo') == '1')
|
||||||
|
with wifi.lock:
|
||||||
|
wifi.trocar_para = rede
|
||||||
|
self.responder(200, wifi.pagina(conectando=ssid))
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.portal = ThreadingHTTPServer((AP_IP, 80), Portal)
|
||||||
|
except OSError as e:
|
||||||
|
log('página não subiu:', e)
|
||||||
|
return
|
||||||
|
threading.Thread(target=self.portal.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
def parar_portal(self):
|
||||||
|
if self.portal:
|
||||||
|
self.portal.shutdown()
|
||||||
|
self.portal.server_close()
|
||||||
|
self.portal = None
|
||||||
|
|
||||||
|
def pagina(self, conectando=None):
|
||||||
|
r = self.rede()
|
||||||
|
opcoes = ''.join(
|
||||||
|
f'<label class="rede"><input type="radio" name="ssid" value="{html.escape(x["ssid"], quote=True)}"'
|
||||||
|
f'{" checked" if r and r["ssid"] == x["ssid"] else ""}><span>{html.escape(x["ssid"])}</span>'
|
||||||
|
f'<i>{"▂▄▆█"[:max(1, min(4, int((x["sinal"] + 95) / 12)))]}</i></label>' for x in self.redes)
|
||||||
|
aviso = f'<p class="erro">{html.escape(self.erro)}</p>' if self.erro else ''
|
||||||
|
if conectando:
|
||||||
|
corpo = (f'<h1>Conectando em “{html.escape(conectando)}”…</h1><p>Se der certo, a rede '
|
||||||
|
f'<b>{html.escape(self.ajustes()["ap_nome"])}</b> some e o Carinha volta ao normal em '
|
||||||
|
f'<b>http://{IP_FIXO}:8100</b>.</p><p>Se a senha estiver errada, ela volta em cerca de 1 minuto, '
|
||||||
|
'com o aviso nesta página.</p>')
|
||||||
|
else:
|
||||||
|
corpo = f"""<h1>🤖 Wi-Fi do Carinha</h1>
|
||||||
|
<p class="fraco">{'Rede salva: <b>' + html.escape(r['ssid']) + '</b> (não encontrada agora)' if r else 'Nenhuma rede salva ainda.'}</p>
|
||||||
|
{aviso}
|
||||||
|
<form method="post" action="/conectar">
|
||||||
|
<div class="redes">{opcoes or '<p class="fraco">Nenhuma rede encontrada. Digite o nome abaixo.</p>'}</div>
|
||||||
|
<label>Outra rede (nome)<input name="ssid_outra" maxlength="32" autocomplete="off"></label>
|
||||||
|
<label>Senha<input name="senha" type="password" maxlength="63" autocomplete="off"></label>
|
||||||
|
<label class="caixa"><input type="checkbox" name="ip_fixo" value="1" checked> Manter o endereço {IP_FIXO} (recomendado em casa)</label>
|
||||||
|
<button>Conectar</button>
|
||||||
|
</form>"""
|
||||||
|
return f"""<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Wi-Fi do Carinha</title><style>
|
||||||
|
:root{{color-scheme:dark;--fundo:#0d1117;--cartao:#161b22;--borda:#30363d;--texto:#e6edf3;--fraco:#8b949e;--azul:#5cd6ff;--erro:#ff7b72}}
|
||||||
|
body{{margin:0;padding:24px 16px;background:var(--fundo);color:var(--texto);font:16px system-ui,sans-serif}}
|
||||||
|
main{{max-width:420px;margin:auto;background:var(--cartao);border:1px solid var(--borda);border-radius:16px;padding:20px}}
|
||||||
|
h1{{font-size:20px;margin:0 0 8px}} .fraco{{color:var(--fraco);font-size:14px}} .erro{{color:var(--erro)}}
|
||||||
|
form{{display:grid;gap:14px}} label{{display:grid;gap:6px;font-size:14px;color:var(--fraco)}}
|
||||||
|
input[type=password],input[name=ssid_outra]{{padding:12px;border-radius:10px;border:1px solid var(--borda);background:var(--fundo);color:var(--texto);font-size:16px}}
|
||||||
|
.redes{{display:grid;gap:6px}} .rede{{display:flex;align-items:center;gap:10px;padding:12px;border:1px solid var(--borda);border-radius:10px;color:var(--texto)}}
|
||||||
|
.rede span{{flex:1}} .rede i{{font-style:normal;color:var(--azul)}} .caixa{{display:flex;align-items:center;gap:8px}}
|
||||||
|
button{{padding:14px;border:0;border-radius:10px;background:var(--azul);color:#00222e;font-weight:700;font-size:16px}}
|
||||||
|
</style></head><body><main>{corpo}</main></body></html>"""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ laço
|
||||||
|
def rodar(self):
|
||||||
|
sh('iw', 'reg', 'set', 'BR')
|
||||||
|
rede = self.rede()
|
||||||
|
if rede:
|
||||||
|
self.modo_cliente(rede)
|
||||||
|
else:
|
||||||
|
self.modo_ap('nenhuma rede salva')
|
||||||
|
while True:
|
||||||
|
time.sleep(3)
|
||||||
|
try:
|
||||||
|
self.passo()
|
||||||
|
except Exception as e: # nunca derruba o serviço
|
||||||
|
log('erro:', e)
|
||||||
|
|
||||||
|
def passo(self):
|
||||||
|
with self.lock:
|
||||||
|
pedido, self.trocar_para = self.trocar_para, None
|
||||||
|
if pedido: # veio da página: tenta a rede nova
|
||||||
|
time.sleep(2) # deixa a resposta chegar ao celular
|
||||||
|
self.anterior = self.rede() # se a nova não funcionar, volta para esta
|
||||||
|
gravar(REDE, pedido)
|
||||||
|
self.erro = ''
|
||||||
|
self.modo_cliente(pedido)
|
||||||
|
return
|
||||||
|
agora = time.time()
|
||||||
|
if self.modo == 'cliente':
|
||||||
|
if self.conectado() and self.ip_wlan():
|
||||||
|
if not self.ok_em:
|
||||||
|
log('conectado em', self.rede()['ssid'], 'com', self.ip_wlan())
|
||||||
|
if self.rede().get('ip_fixo', True):
|
||||||
|
self.liberar_ip_fixo_do_cabo()
|
||||||
|
self.erro = ''
|
||||||
|
self.anterior = False
|
||||||
|
self.ok_em = agora
|
||||||
|
self.publicar()
|
||||||
|
return
|
||||||
|
limite = QUEDA_TOLERADA if self.ok_em else ESPERA_CONECTAR
|
||||||
|
if agora - (self.ok_em or self.desde) > limite:
|
||||||
|
if not self.ok_em:
|
||||||
|
self.erro = (f'Não consegui conectar em “{self.rede()["ssid"]}”. Confira a senha e se a rede está '
|
||||||
|
'ligada, e tente de novo.')
|
||||||
|
if self.anterior is not False: # a rede nova falhou: a antiga continua salva
|
||||||
|
if self.anterior:
|
||||||
|
gravar(REDE, self.anterior)
|
||||||
|
else:
|
||||||
|
REDE.unlink(missing_ok=True)
|
||||||
|
self.anterior = False
|
||||||
|
self.modo_ap('a rede não respondeu')
|
||||||
|
elif self.modo == 'ap':
|
||||||
|
r = self.rede()
|
||||||
|
if r and agora - self.desde > RETENTAR_A_CADA and not self.alguem_no_ap():
|
||||||
|
self.modo_cliente(r) # ninguém configurando: tenta a rede salva de novo
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
w = Wifi()
|
||||||
|
|
||||||
|
def sair(*_):
|
||||||
|
w.parar_portal()
|
||||||
|
w.parar_filhos()
|
||||||
|
raise SystemExit(0)
|
||||||
|
signal.signal(signal.SIGTERM, sair)
|
||||||
|
w.rodar()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Instala o Wi-Fi com modo de configuração na placa (rodar como root, a partir desta pasta).
|
||||||
|
# Seguro com o cabo ligado: sem rede salva, o serviço só sobe o ponto de acesso no wlan0 e não mexe no cabo.
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
apt-get install -y --no-install-recommends hostapd dnsmasq-base
|
||||||
|
# hostapd e dnsmasq rodam como filhos do serviço, nunca sozinhos
|
||||||
|
systemctl disable --now hostapd 2>/dev/null || true
|
||||||
|
systemctl mask hostapd 2>/dev/null || true
|
||||||
|
install -d -m 700 /etc/carinha-wifi
|
||||||
|
install -m 755 carinha_wifi.py /usr/local/sbin/carinha-wifi
|
||||||
|
install -m 644 carinha-wifi.service /etc/systemd/system/carinha-wifi.service
|
||||||
|
# Cabo e Wi-Fi na mesma rede: cada placa de rede só responde pelo próprio endereço (sem "ARP flux")
|
||||||
|
printf 'net.ipv4.conf.all.arp_ignore=1\nnet.ipv4.conf.all.arp_announce=2\n' > /etc/sysctl.d/90-carinha-arp.conf
|
||||||
|
sysctl -q --system
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable carinha-wifi
|
||||||
|
systemctl restart carinha-wifi
|
||||||
|
echo "carinha-wifi instalado"
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
from playwright.sync_api import sync_playwright
|
|
||||||
import json
|
|
||||||
with sync_playwright() as p:
|
|
||||||
b=p.chromium.launch(headless=True); page=b.new_page(viewport={'width':1400,'height':1000})
|
|
||||||
cfg=dict(disponivel=True,erro='',calibracao=[dict(canal=i,minimo=1400,maximo=1600) for i in range(8)],posturas=[],articulacoes=[dict(nome=n,canal=c) for n,c in zip(['Garra','Punho 1','Punho 2','Braço superior','Braço inferior','Antebraço','Base'],[0,1,2,3,4,5,7])])
|
|
||||||
calls=[]; errors=[]
|
|
||||||
def handle(route):
|
|
||||||
if route.request.method=='POST':
|
|
||||||
data=route.request.post_data_json; calls.append((route.request.url,data))
|
|
||||||
if route.request.url.endswith('/config'): cfg.update(data)
|
|
||||||
route.fulfill(content_type='application/json',body=json.dumps(cfg))
|
|
||||||
page.route('**/braco/status',handle); page.route('**/braco/config',handle); page.route('**/braco/mover',handle); page.route('**/braco/parar',handle)
|
|
||||||
page.on('pageerror',lambda e:errors.append(str(e)))
|
|
||||||
page.goto('http://192.168.1.35:8100/braco');page.wait_for_selector('#joint6')
|
|
||||||
assert page.locator('#sliders input').count()==7
|
|
||||||
assert not page.locator('#enable').is_checked()
|
|
||||||
page.locator('#joint1').evaluate("e=>{e.value=95;e.dispatchEvent(new Event('input'))}");page.wait_for_timeout(200)
|
|
||||||
assert calls==[]
|
|
||||||
page.locator('#enable').check();assert calls==[]
|
|
||||||
page.locator('#joint1').evaluate("e=>{e.value=85;e.dispatchEvent(new Event('input'))}");page.wait_for_timeout(650)
|
|
||||||
moves=[d for u,d in calls if u.endswith('/mover')]
|
|
||||||
assert moves and moves[-1]['angulos'][1]==85,moves
|
|
||||||
assert len(moves[0]['angulos'])==7
|
|
||||||
page.locator('#stop').click();page.wait_for_timeout(200)
|
|
||||||
assert calls[-1][0].endswith('/parar');assert not page.locator('#enable').is_checked()
|
|
||||||
page.on('dialog',lambda d:d.accept('Teste centro'))
|
|
||||||
page.locator('#savePose').click();page.wait_for_timeout(250)
|
|
||||||
assert '1 posturas' in page.locator('#count').inner_text()
|
|
||||||
page.locator('#enable').check();page.locator('#play').click();page.wait_for_timeout(1100)
|
|
||||||
assert 'Sequência concluída' in page.locator('#console').inner_text()
|
|
||||||
page.locator('#stop').click();page.wait_for_timeout(200)
|
|
||||||
page.screenshot(path='C:/Users/JullioPC/Documents/Codex/2026-10-04/o/outputs/carinha-pc/docs/braco-painel.png',full_page=True)
|
|
||||||
page.set_viewport_size({'width':390,'height':844})
|
|
||||||
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth')
|
|
||||||
assert not errors,errors
|
|
||||||
print('OK: sete sliders, opt-in, velocidade, parar, salvar/reproduzir, mobile; comandos simulados')
|
|
||||||
b.close()
|
|
||||||
+10
-7
@@ -18,6 +18,7 @@ def main():
|
|||||||
kiosk.ARQUIVO_APARENCIA = str(Path(tmp) / 'aparencia.json')
|
kiosk.ARQUIVO_APARENCIA = str(Path(tmp) / 'aparencia.json')
|
||||||
server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
||||||
server.controle = kiosk.ControlePC(Path(tmp) / 'app.json')
|
server.controle = kiosk.ControlePC(Path(tmp) / 'app.json')
|
||||||
|
kiosk.ligar_eventos(server) # o rosto recebe ajustes e ordens por SSE
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
try:
|
try:
|
||||||
@@ -63,21 +64,23 @@ def browser_test(base):
|
|||||||
panel = browser.new_page(viewport={'width': 1440, 'height': 1000})
|
panel = browser.new_page(viewport={'width': 1440, 'height': 1000})
|
||||||
errors = []
|
errors = []
|
||||||
panel.on('pageerror', lambda error: errors.append(str(error) + '\n' + error.stack))
|
panel.on('pageerror', lambda error: errors.append(str(error) + '\n' + error.stack))
|
||||||
panel.goto(base + '/painel')
|
# Painel novo (web/dist): ajustes do celular e transmissão.
|
||||||
panel.wait_for_selector('#gestos button')
|
panel.goto(base + '/#/ajustes')
|
||||||
panel.locator('#ajuste-sensorMic').uncheck()
|
alternar = lambda nome: panel.get_by_role('switch', name=nome).locator('xpath=..').click()
|
||||||
|
alternar('Microfone Só o nível')
|
||||||
panel.wait_for_timeout(400)
|
panel.wait_for_timeout(400)
|
||||||
assert panel.request.get(base + '/app/config').json()['sensorMic'] is False
|
assert panel.request.get(base + '/app/config').json()['sensorMic'] is False
|
||||||
panel.locator('#ajuste-cameraSono').check()
|
alternar('Câmera durante o sono')
|
||||||
panel.wait_for_timeout(400)
|
panel.wait_for_timeout(400)
|
||||||
panel.locator('#ajuste-cameraLado').select_option('environment')
|
panel.get_by_role('combobox', name='Câmera').select_option('environment')
|
||||||
panel.wait_for_timeout(400)
|
panel.wait_for_timeout(400)
|
||||||
c = panel.request.get(base + '/app/config').json()
|
c = panel.request.get(base + '/app/config').json()
|
||||||
assert c['cameraSono'] is True and c['cameraLado'] == 'environment'
|
assert c['cameraSono'] is True and c['cameraLado'] == 'environment'
|
||||||
panel.get_by_role('button', name='Ligar câmera').click()
|
panel.get_by_role('link', name='Ao vivo').first.click()
|
||||||
|
alternar('Transmitir câmera do celular')
|
||||||
panel.wait_for_timeout(500)
|
panel.wait_for_timeout(500)
|
||||||
assert panel.request.get(base + '/app/config').json()['preview'] is True
|
assert panel.request.get(base + '/app/config').json()['preview'] is True
|
||||||
panel.get_by_role('button', name='Desligar câmera').click()
|
alternar('Transmitir câmera do celular')
|
||||||
panel.wait_for_timeout(500)
|
panel.wait_for_timeout(500)
|
||||||
assert panel.request.get(base + '/app/config').json()['preview'] is False
|
assert panel.request.get(base + '/app/config').json()['preview'] is False
|
||||||
assert not errors, errors
|
assert not errors, errors
|
||||||
|
|||||||
+18
-1
@@ -1,8 +1,25 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from cerebro import validar_intencao
|
from cerebro import POR_EVENTO, Cerebro, validar_intencao
|
||||||
|
|
||||||
|
|
||||||
class Tests(unittest.TestCase):
|
class Tests(unittest.TestCase):
|
||||||
def test_only_allowlisted_expression(self):
|
def test_only_allowlisted_expression(self):
|
||||||
self.assertEqual(validar_intencao({'gesto':'feliz'}),'feliz')
|
self.assertEqual(validar_intencao({'gesto':'feliz'}),'feliz')
|
||||||
for data in [{'gesto':'motor 2500'},{'gesto':'feliz','pulso':2500},[],{'gesto':'dormir'}]:
|
for data in [{'gesto':'motor 2500'},{'gesto':'feliz','pulso':2500},[],{'gesto':'dormir'}]:
|
||||||
with self.assertRaises(ValueError):validar_intencao(data)
|
with self.assertRaises(ValueError):validar_intencao(data)
|
||||||
|
|
||||||
|
def test_opcoes_por_evento_sem_repetir(self):
|
||||||
|
c = Cerebro.__new__(Cerebro) # sem thread nem arquivo: só a escolha de opções
|
||||||
|
c.history = ['surpreso', 'olhar_esq']
|
||||||
|
opcoes = c.opcoes('som')
|
||||||
|
self.assertNotIn('surpreso', opcoes) # os dois últimos ficam de fora
|
||||||
|
self.assertNotIn('olhar_esq', opcoes)
|
||||||
|
self.assertNotIn('esconder', opcoes) # barulho não vira "esconder o rosto"
|
||||||
|
self.assertTrue(set(opcoes) <= set(POR_EVENTO['som']))
|
||||||
|
for evento, lista in POR_EVENTO.items():
|
||||||
|
for g in lista:
|
||||||
|
self.assertEqual(validar_intencao({'gesto': g}), g)
|
||||||
|
self.assertNotIn('acenar', POR_EVENTO['sozinho']) # sozinho: nada de gesto "para alguém"
|
||||||
|
|
||||||
|
|
||||||
if __name__=='__main__':unittest.main()
|
if __name__=='__main__':unittest.main()
|
||||||
|
|||||||
+13
-15
@@ -46,7 +46,7 @@ class ControleTest(unittest.TestCase):
|
|||||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
self.url = 'http://127.0.0.1:%d' % self.server.server_port
|
self.url = 'http://127.0.0.1:%d' % self.server.server_port
|
||||||
kiosk.FILA.clear()
|
kiosk.FILA.limpar()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.server.shutdown()
|
self.server.shutdown()
|
||||||
@@ -64,12 +64,12 @@ class ControleTest(unittest.TestCase):
|
|||||||
return e.code, e.read()
|
return e.code, e.read()
|
||||||
|
|
||||||
def test_config_is_persisted_without_consuming_gestures(self):
|
def test_config_is_persisted_without_consuming_gestures(self):
|
||||||
kiosk.FILA.append('feliz')
|
kiosk.FILA.adicionar('feliz')
|
||||||
code, body = self.request('/app/config', {'volume': 0.7, 'sompausa': 40})
|
code, body = self.request('/app/config', {'volume': 0.7, 'sompausa': 40})
|
||||||
self.assertEqual(code, 200)
|
self.assertEqual(code, 200)
|
||||||
self.assertEqual(json.loads(body)['config']['volume'], 0.7)
|
self.assertEqual(json.loads(body)['config']['volume'], 0.7)
|
||||||
self.assertEqual(self.request('/app/config')[0], 200)
|
self.assertEqual(self.request('/app/config')[0], 200)
|
||||||
self.assertEqual(kiosk.FILA, ['feliz'])
|
self.assertEqual(kiosk.FILA.ordens(), ['feliz'])
|
||||||
self.assertEqual(json.loads((Path(self.tmp.name) / 'app.json').read_text())['sompausa'], 40)
|
self.assertEqual(json.loads((Path(self.tmp.name) / 'app.json').read_text())['sompausa'], 40)
|
||||||
|
|
||||||
def test_invalid_configuration_does_not_partially_apply(self):
|
def test_invalid_configuration_does_not_partially_apply(self):
|
||||||
@@ -86,21 +86,19 @@ class ControleTest(unittest.TestCase):
|
|||||||
self.assertTrue(json.loads(body)['online'])
|
self.assertTrue(json.loads(body)['online'])
|
||||||
self.assertEqual(json.loads(body)['estado']['rosto'], 'miudo')
|
self.assertEqual(json.loads(body)['estado']['rosto'], 'miudo')
|
||||||
|
|
||||||
def test_frame_is_hidden_when_disabled_and_after_expiry(self):
|
def test_phone_reports_its_own_ip(self):
|
||||||
jpeg = b'\xff\xd8fixture\xff\xd9'
|
self.request('/app/status', {'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F'}})
|
||||||
self.assertEqual(self.request('/camera/frame', jpeg, 'image/jpeg')[0], 409)
|
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '192.168.1.16')
|
||||||
|
self.request('/app/status', {'aparelho': {'ip': 'não é ip'}})
|
||||||
|
self.assertEqual(json.loads(self.request('/app/status')[1])['celular_ip'], '127.0.0.1')
|
||||||
|
|
||||||
|
def test_legacy_camera_and_adb_routes_are_gone(self):
|
||||||
self.request('/app/config', {'preview': True})
|
self.request('/app/config', {'preview': True})
|
||||||
self.assertEqual(self.request('/camera/frame', jpeg, 'image/jpeg')[0], 200)
|
self.assertEqual(self.request('/camera/frame', b'\xff\xd8x\xff\xd9', 'image/jpeg')[0], 404)
|
||||||
self.assertEqual(self.request('/camera/frame'), (200, jpeg))
|
self.assertEqual(self.request('/camera/frame')[0], 404)
|
||||||
self.server.controle.frame_at -= 4
|
self.assertEqual(self.request('/app/connect', {'ip': '127.0.0.1'})[0], 404)
|
||||||
self.assertEqual(self.request('/camera/frame')[0], 204)
|
|
||||||
self.request('/camera/frame', jpeg, 'image/jpeg')
|
|
||||||
self.request('/app/config', {'preview': False})
|
|
||||||
self.assertEqual(self.request('/camera/frame')[0], 204)
|
|
||||||
|
|
||||||
def test_rejects_non_jpeg_and_unknown_settings(self):
|
def test_rejects_non_jpeg_and_unknown_settings(self):
|
||||||
self.request('/app/config', {'preview': True})
|
|
||||||
self.assertEqual(self.request('/camera/frame', b'hello', 'image/jpeg')[0], 400)
|
|
||||||
self.assertEqual(self.request('/app/config', {'preview': 'false'})[0], 400)
|
self.assertEqual(self.request('/app/config', {'preview': 'false'})[0], 400)
|
||||||
self.assertEqual(self.request('/app/config', {'extra': 1})[0], 400)
|
self.assertEqual(self.request('/app/config', {'extra': 1})[0], 400)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Estúdio de criação: receitas validadas, prompt com exemplos/base, IA local (servidor falso) e tocar."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import criacao as mod # noqa: E402
|
||||||
|
from criacao import ATIVIDADES_BASE, EXPRESSOES_BASE, Criacao, montar_prompt, validar_atividade, validar_expressao # noqa: E402
|
||||||
|
|
||||||
|
RESPOSTAS = {} # o servidor falso devolve o que o teste colocar aqui
|
||||||
|
PEDIDOS = []
|
||||||
|
|
||||||
|
|
||||||
|
class IaFalsa(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self):
|
||||||
|
corpo = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
||||||
|
PEDIDOS.append(dict(corpo=corpo, auth=self.headers.get('Authorization')))
|
||||||
|
tipo = 'expressao' if 'passos' in corpo['response_format']['json_schema']['schema']['properties'] else 'atividade'
|
||||||
|
texto = '<think>pensando...</think>\n```json\n' + json.dumps(RESPOSTAS[tipo]) + '\n```'
|
||||||
|
dados = json.dumps(dict(choices=[dict(message=dict(content=texto))])).encode()
|
||||||
|
self.send_response(200); self.send_header('Content-Type', 'application/json'); self.send_header('Content-Length', str(len(dados)))
|
||||||
|
self.end_headers(); self.wfile.write(dados)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
dados = json.dumps(dict(data=[dict(id='qwen-teste')])).encode()
|
||||||
|
self.send_response(200); self.send_header('Content-Length', str(len(dados))); self.end_headers(); self.wfile.write(dados)
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CriacaoTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.c = Criacao(Path(self.tmp.name) / 'c.db', Path(self.tmp.name) / 'c.json')
|
||||||
|
self.ordens = []
|
||||||
|
self.c.enfileirar = self.ordens.append
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def esperar(self):
|
||||||
|
for _ in range(100):
|
||||||
|
if not self.c.ocupado:
|
||||||
|
return
|
||||||
|
time.sleep(0.05)
|
||||||
|
self.fail('criação não terminou')
|
||||||
|
|
||||||
|
def test_valida_e_limita_expressao(self):
|
||||||
|
r = validar_expressao(dict(nome='x' * 80, emoji='<script>', maos='voar', espiral='sim', passos=[
|
||||||
|
dict(forma='feliz', boca='sorriso', cor='roxo', dx=99, dy=-99, esc=5, ab=-1, duracao=50),
|
||||||
|
dict(forma='inventada', boca='sorriso', cor='rosa', dx=1, dy=1, esc=1, ab=1, duracao=5)]))
|
||||||
|
self.assertEqual(len(r['nome']), 30)
|
||||||
|
self.assertEqual(r['emoji'], '✨')
|
||||||
|
self.assertEqual((r['maos'], r['espiral']), ('nenhuma', False))
|
||||||
|
p = r['passos'][0]
|
||||||
|
self.assertEqual((p['cor'], p['dx'], p['dy'], p['esc'], p['ab']), ('escolhida', 24, -12, 1.3, 0))
|
||||||
|
self.assertEqual(r['passos'][1]['forma'], 'aberto')
|
||||||
|
self.assertLessEqual(sum(x['duracao'] for x in r['passos'][:2]), 6.01) # encolheu para 6 s
|
||||||
|
self.assertEqual(r['passos'][-1]['forma'], 'aberto') # volta ao normal
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
validar_expressao(dict(passos=[]))
|
||||||
|
for base in EXPRESSOES_BASE.values(): # os exemplos são válidos
|
||||||
|
self.assertEqual(len(validar_expressao(base)['passos']), len(base['passos']))
|
||||||
|
|
||||||
|
def test_valida_atividade(self):
|
||||||
|
r = validar_atividade(dict(nome='Malabarismo', emoji='🤹', postura='voando', ritmo=99, braco_esquerdo=dict(de=-500, ate=900),
|
||||||
|
objetos=[dict(tipo='bola', onde='maos', movimento='pular', quantidade=9), dict(tipo='foguete')] * 3))
|
||||||
|
self.assertEqual(r['postura'], 'em_pe')
|
||||||
|
self.assertEqual(r['ritmo'], 3)
|
||||||
|
self.assertEqual(r['braco_esquerdo'], dict(de=-30, ate=180))
|
||||||
|
self.assertEqual(len(r['objetos']), 3) # o foguete não existe: sai
|
||||||
|
self.assertEqual(r['objetos'][0]['quantidade'], 5)
|
||||||
|
for base in ATIVIDADES_BASE.values():
|
||||||
|
validar_atividade(base)
|
||||||
|
|
||||||
|
def test_prompt_reaproveita_o_que_existe(self):
|
||||||
|
p = montar_prompt('expressao', 'desconfiado', EXPRESSOES_BASE['timido'])
|
||||||
|
self.assertIn('Exemplos reais que já existem', p)
|
||||||
|
self.assertIn('Comemorar', p) # exemplo real
|
||||||
|
self.assertIn('Parta desta receita', p)
|
||||||
|
self.assertIn('Tímido', p) # a base escolhida
|
||||||
|
self.assertIn('"desconfiado"', p)
|
||||||
|
a = montar_prompt('atividade', 'malabarismo')
|
||||||
|
self.assertIn('halter', a)
|
||||||
|
self.assertNotIn('Parta desta receita', a)
|
||||||
|
|
||||||
|
def test_ia_local_cria_e_toca(self):
|
||||||
|
srv = ThreadingHTTPServer(('127.0.0.1', 0), IaFalsa)
|
||||||
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||||
|
try:
|
||||||
|
self.c.configurar(dict(local=dict(url='http://127.0.0.1:%d' % srv.server_port, modelo='qwen-teste', chave='segredo')))
|
||||||
|
self.assertTrue(self.c.provedores()['local']['pronto'])
|
||||||
|
self.assertIn('qwen-teste', self.c.testar('local')['mensagem'])
|
||||||
|
RESPOSTAS['expressao'] = dict(nome='Desconfiado', emoji='🤨', maos='nenhuma', espiral=False, passos=[
|
||||||
|
dict(forma='bravo', boca='neutra', cor='escolhida', dx=-12, dy=2, esc=0.95, ab=0.6, duracao=0.4),
|
||||||
|
dict(forma='bravo', boca='neutra', cor='escolhida', dx=12, dy=2, esc=0.95, ab=0.6, duracao=0.8)])
|
||||||
|
self.c.criar('expressao', 'desconfiado', 'local', base='timido')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.c.criar('expressao', 'outro', 'local') # um de cada vez
|
||||||
|
self.esperar()
|
||||||
|
self.assertEqual(self.c.erro, '')
|
||||||
|
self.assertEqual(PEDIDOS[-1]['auth'], 'Bearer segredo')
|
||||||
|
self.assertIn('Tímido', PEDIDOS[-1]['corpo']['messages'][1]['content']) # a base foi junto
|
||||||
|
item = self.c.lista()[0]
|
||||||
|
self.assertEqual((item['tipo'], item['nome'], item['ia']), ('expressao', 'Desconfiado', 'local'))
|
||||||
|
self.assertTrue(self.ordens[-1].startswith('expressao_criada {')) # tocou na hora
|
||||||
|
RESPOSTAS['atividade'] = dict(ATIVIDADES_BASE['malhar'], nome='Malabarismo', emoji='🤹')
|
||||||
|
self.c.criar('atividade', 'malabarismo', 'local', base='criado:%d' % item['id']) # base de outro tipo: ignora
|
||||||
|
self.esperar()
|
||||||
|
self.assertTrue(self.ordens[-1].startswith('atividade_criada {'))
|
||||||
|
self.assertEqual(json.loads(self.ordens[-1][17:])['nome'], 'Malabarismo')
|
||||||
|
self.c.apagar(item['id'])
|
||||||
|
self.assertEqual([i['nome'] for i in self.c.lista()], ['Malabarismo'])
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
|
||||||
|
def test_figurinha_vai_para_a_biblioteca_de_reacoes(self):
|
||||||
|
class TreinoFalso:
|
||||||
|
lock = threading.RLock()
|
||||||
|
expressao = None
|
||||||
|
self.c.treinamento = TreinoFalso()
|
||||||
|
desenho = dict(nome='Sol', local='olhos', formas=[dict(caminho='M 10 10 L 90 10 L 50 90 Z', cor='#ffcc00')])
|
||||||
|
mod.os.environ['GEMINI_API_KEY'] = 'teste'
|
||||||
|
original = mod.gemini.gerar_json
|
||||||
|
mod.gemini.gerar_json = lambda payload: dict(desenho, emoji='☀️')
|
||||||
|
try:
|
||||||
|
self.c.criar('figurinha', 'um sol', 'gemini')
|
||||||
|
self.esperar()
|
||||||
|
finally:
|
||||||
|
mod.gemini.gerar_json = original
|
||||||
|
del mod.os.environ['GEMINI_API_KEY']
|
||||||
|
self.assertEqual(self.c.erro, '')
|
||||||
|
self.assertEqual(self.c.lista()[0]['emoji'], '☀️')
|
||||||
|
self.assertEqual(self.c.treinamento.expressao['receita']['figurinha']['nome'], 'Sol')
|
||||||
|
|
||||||
|
def test_erros_claros(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.c.criar('expressao', 'x', 'local') # sem endereço
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.c.configurar(dict(local=dict(url='ftp://nada')))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.c.criar('robo', 'x', 'gemini')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Histórico de detecções: sensores liga/desliga, intervalo, pedido de foto e rotas HTTP."""
|
||||||
|
import http.client
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
from historico import Historico, MAX_FOTOS
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location('kiosk_hist', ROOT / 'kiosk-server.py')
|
||||||
|
kiosk = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(kiosk)
|
||||||
|
|
||||||
|
JPEG = b'\xff\xd8' + b'foto' * 10 + b'\xff\xd9'
|
||||||
|
|
||||||
|
|
||||||
|
class HistoricoTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.h = Historico(self.tmp.name, iniciar_pir=False)
|
||||||
|
self.publicados = []
|
||||||
|
self.h.publicar = lambda t, d: self.publicados.append((t, d))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_intervalo_e_sensor_desligado(self):
|
||||||
|
self.assertIsNotNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=100))
|
||||||
|
self.assertIsNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=105)) # dentro dos 15 s
|
||||||
|
self.assertIsNotNone(self.h.registrar('som', 'surpreso', 0.9, agora=105)) # outro tipo vale
|
||||||
|
self.h.config['pir'] = False
|
||||||
|
self.assertIsNone(self.h.registrar('pir', agora=200))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.h.registrar('fumaça')
|
||||||
|
self.assertEqual(self.h.listar()[1], 2)
|
||||||
|
self.assertEqual(self.publicados[0][0], 'historico')
|
||||||
|
|
||||||
|
def test_pedido_e_foto(self):
|
||||||
|
ident = self.h.registrar('pir', 'presença', agora=10)
|
||||||
|
self.assertEqual(self.h.pedido_atual(), ident)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.h.guardar_foto(ident, b'nao e jpeg')
|
||||||
|
self.h.guardar_foto(ident, JPEG)
|
||||||
|
self.assertIsNone(self.h.pedido_atual())
|
||||||
|
self.assertTrue(self.h.evento(ident)['foto'])
|
||||||
|
self.assertTrue((Path(self.tmp.name) / 'fotos' / f'evento-{ident}.jpg').is_file())
|
||||||
|
|
||||||
|
def test_sem_foto_quando_desligado(self):
|
||||||
|
self.h.config['foto'] = False
|
||||||
|
self.h.registrar('pir', agora=10)
|
||||||
|
self.assertIsNone(self.h.pedido_atual())
|
||||||
|
|
||||||
|
def test_guarda_no_maximo_as_fotos_recentes(self):
|
||||||
|
self.h.config['intervalo'] = 0
|
||||||
|
ids = []
|
||||||
|
for i in range(MAX_FOTOS + 3):
|
||||||
|
ids.append(self.h.registrar('pir', agora=i))
|
||||||
|
self.h.guardar_foto(ids[-1], JPEG)
|
||||||
|
com_foto = [e for e in self.h.listar(500)[0] if e['foto']]
|
||||||
|
self.assertEqual(len(com_foto), MAX_FOTOS)
|
||||||
|
self.assertFalse(self.h.evento(ids[0])['foto'])
|
||||||
|
self.assertEqual(len(list((Path(self.tmp.name) / 'fotos').glob('*.jpg'))), MAX_FOTOS)
|
||||||
|
|
||||||
|
def test_guarda_so_os_ultimos_dias(self):
|
||||||
|
agora = 10 * 86400
|
||||||
|
velho = self.h.registrar('pir', agora=agora - 4 * 86400)
|
||||||
|
self.h.guardar_foto(velho, JPEG)
|
||||||
|
novo = self.h.registrar('pir', agora=agora - 86400)
|
||||||
|
self.assertEqual(self.h.limpar_antigos(agora), 1) # 3 dias por padrão
|
||||||
|
self.assertIsNone(self.h.evento(velho))
|
||||||
|
self.assertIsNotNone(self.h.evento(novo))
|
||||||
|
self.assertFalse((Path(self.tmp.name) / 'fotos' / f'evento-{velho}.jpg').exists())
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.h.validar({'dias': 0})
|
||||||
|
|
||||||
|
def test_modo_presenca(self):
|
||||||
|
chamadas = []
|
||||||
|
self.h.ao_presenca = lambda: chamadas.append('chegou')
|
||||||
|
self.h.ao_ausencia = lambda: chamadas.append('saiu')
|
||||||
|
self.h.pir['disponivel'] = True
|
||||||
|
self.h.marcar_presenca(1000)
|
||||||
|
self.assertFalse(self.h.checar_ausencia(1000 + 4 * 60)) # 4 min: ainda presente
|
||||||
|
self.assertTrue(self.h.checar_ausencia(1000 + 5 * 60 + 1)) # 5 min: ausente
|
||||||
|
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # não repete
|
||||||
|
self.h.marcar_presenca(2000) # voltou alguém
|
||||||
|
self.assertEqual(chamadas, ['saiu', 'chegou'])
|
||||||
|
self.h.config['presenca'] = False
|
||||||
|
self.h.marcar_presenca(3000)
|
||||||
|
self.assertFalse(self.h.checar_ausencia(9999))
|
||||||
|
|
||||||
|
def test_pir_segurando_conta_como_presenca(self):
|
||||||
|
avisos = []
|
||||||
|
self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados))
|
||||||
|
self.h.pir['disponivel'] = True
|
||||||
|
self.h.nivel_pir(True, agora=1000) # subida: evento + bolinha acesa
|
||||||
|
self.assertTrue(self.h.pir['ativo'])
|
||||||
|
self.assertEqual(self.h.status()['eventos'][0]['tipo'], 'pir')
|
||||||
|
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # pino ainda alto: não dorme
|
||||||
|
self.h.nivel_pir(False, agora=1000 + 10 * 60) # descida: só apaga a bolinha
|
||||||
|
self.assertFalse(self.h.pir['ativo'])
|
||||||
|
self.assertEqual(self.h.status()['total'], 1)
|
||||||
|
self.assertTrue(self.h.checar_ausencia(1000 + 15 * 60 + 1))
|
||||||
|
self.assertIn(('pir', True), [(t, d.get('ativo')) for t, d in avisos])
|
||||||
|
|
||||||
|
def test_apagar_e_limpar(self):
|
||||||
|
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
|
||||||
|
b = self.h.registrar('som', agora=1)
|
||||||
|
self.h.apagar(a)
|
||||||
|
self.assertIsNone(self.h.evento(a))
|
||||||
|
self.h.limpar()
|
||||||
|
self.assertEqual(self.h.listar()[1], 0)
|
||||||
|
self.assertEqual(list((Path(self.tmp.name) / 'fotos').glob('*.jpg')), [])
|
||||||
|
|
||||||
|
|
||||||
|
class HistoricoHttpTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
||||||
|
self.server.token = 'token-historico'
|
||||||
|
self.server.historico = Historico(self.tmp.name, iniciar_pir=False)
|
||||||
|
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.server.shutdown(); self.server.server_close(); self.tmp.cleanup()
|
||||||
|
|
||||||
|
def pedir(self, metodo, rota, corpo=None, tipo='application/json', token='token-historico'):
|
||||||
|
c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5)
|
||||||
|
h = {'Content-Type': tipo}
|
||||||
|
if token:
|
||||||
|
h['Authorization'] = 'Bearer ' + token
|
||||||
|
if isinstance(corpo, dict):
|
||||||
|
corpo = json.dumps(corpo)
|
||||||
|
c.request(metodo, rota, body=corpo, headers=h)
|
||||||
|
r = c.getresponse(); dados = r.read(); c.close()
|
||||||
|
return r.status, dados
|
||||||
|
|
||||||
|
def test_fluxo_completo(self):
|
||||||
|
self.assertEqual(self.pedir('POST', '/historico/evento', {'tipo': 'pir'}, token=None)[0], 401)
|
||||||
|
codigo, corpo = self.pedir('POST', '/historico/evento', {'tipo': 'movimento', 'evento': 'surpreso', 'nivel': 0.3})
|
||||||
|
ident = json.loads(corpo)['id']
|
||||||
|
self.assertEqual(json.loads(self.pedir('GET', '/historico/pedido')[1])['id'], ident)
|
||||||
|
self.assertEqual(self.pedir('POST', f'/historico/foto?id={ident}', JPEG, 'image/jpeg')[0], 200)
|
||||||
|
codigo, foto = self.pedir('GET', f'/historico/foto/evento-{ident}.jpg?t=token-historico', token=None)
|
||||||
|
self.assertEqual((codigo, foto), (200, JPEG))
|
||||||
|
self.assertEqual(self.pedir('GET', '/historico/foto/../historico.db')[0], 400)
|
||||||
|
status = json.loads(self.pedir('GET', '/historico/status')[1])
|
||||||
|
self.assertEqual(status['eventos'][0]['id'], ident)
|
||||||
|
self.assertTrue(status['eventos'][0]['foto'])
|
||||||
|
self.assertEqual(self.pedir('POST', '/historico/config', {'foto': False, 'intervalo': 30})[0], 200)
|
||||||
|
self.assertEqual(self.pedir('POST', '/historico/config', {'intervalo': 9999})[0], 400)
|
||||||
|
self.assertFalse(self.server.historico.config['foto'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
|
||||||
@@ -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');
|
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Painel novo (web/dist) contra o servidor real: token, SSE, gestos, paleta, E-Stop, ajustes e celular.
|
||||||
|
|
||||||
|
Requer `npm run build` em web/. Uso: python tests/test_painel_web_browser.py [pasta-para-capturas]
|
||||||
|
"""
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
from pathlib import Path
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
os.chdir(ROOT)
|
||||||
|
spec = importlib.util.spec_from_file_location('kiosk_web', ROOT / 'kiosk-server.py')
|
||||||
|
kiosk = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(kiosk)
|
||||||
|
from braco import Braco
|
||||||
|
from cerebro import Cerebro
|
||||||
|
from conversa import Conversa
|
||||||
|
from servos import ControleServos
|
||||||
|
from treinamento import Treinamento
|
||||||
|
|
||||||
|
TOKEN = 'token-de-teste-123'
|
||||||
|
|
||||||
|
|
||||||
|
class HW:
|
||||||
|
def __init__(self): self.calls = []
|
||||||
|
def pulse(self, ch, us): self.calls.append((ch, us))
|
||||||
|
def off(self, ch): self.calls.append((ch, None))
|
||||||
|
|
||||||
|
|
||||||
|
def main(capturas=None):
|
||||||
|
assert (ROOT / 'web/dist/index.html').is_file(), 'Rode npm run build em web/ primeiro'
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp = Path(tmp)
|
||||||
|
kiosk.ARQUIVO_APARENCIA = str(tmp / 'aparencia.json')
|
||||||
|
server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
||||||
|
server.token = TOKEN
|
||||||
|
server.controle = kiosk.ControlePC(tmp / 'app.json')
|
||||||
|
server.servos = ControleServos(tmp / 'servos.json', HW())
|
||||||
|
server.braco = Braco(tmp / 'braco.json', server.servos)
|
||||||
|
server.cerebro = Cerebro(tmp / 'ia.json', server.controle, kiosk.enfileirar)
|
||||||
|
server.conversa = Conversa(tmp / 'memoria.db', server.cerebro)
|
||||||
|
server.treinamento = Treinamento(tmp / 'treinamento.db', iniciar=False)
|
||||||
|
server.conversa.treinamento = server.treinamento
|
||||||
|
kiosk.ligar_eventos(server)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
testar('http://127.0.0.1:%d' % server.server_port, server, capturas)
|
||||||
|
finally:
|
||||||
|
kiosk.EVENTOS.fechar()
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
server.servos.close()
|
||||||
|
|
||||||
|
|
||||||
|
def testar(base, server, capturas):
|
||||||
|
auth = {'Authorization': 'Bearer ' + TOKEN}
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch()
|
||||||
|
page = browser.new_page(viewport={'width': 1440, 'height': 960})
|
||||||
|
erros = []
|
||||||
|
page.on('pageerror', lambda e: erros.append(str(e)))
|
||||||
|
page.goto(base + '/')
|
||||||
|
|
||||||
|
# 1. token
|
||||||
|
expect(page.get_by_role('heading', name='Token do Carinha')).to_be_visible()
|
||||||
|
page.get_by_label('Token').fill(TOKEN)
|
||||||
|
page.get_by_role('button', name='Entrar').click()
|
||||||
|
expect(page.get_by_text('Servidor', exact=True)).to_be_visible(timeout=5000)
|
||||||
|
|
||||||
|
# 2. telemetria do celular chega por SSE
|
||||||
|
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
|
||||||
|
'microfone_ativo': True, 'microfone': 0.3,
|
||||||
|
'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F', 'android': '12',
|
||||||
|
'app': '0.8.1', 'wifi': 3, 'rssi': -58}})
|
||||||
|
expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000)
|
||||||
|
# Dados que o próprio APK informa aparecem no cartão do celular.
|
||||||
|
expect(page.get_by_text('samsung SM-G973F')).to_be_visible()
|
||||||
|
expect(page.get_by_text('192.168.1.16')).to_be_visible()
|
||||||
|
|
||||||
|
# 2b. bateria baixa sem carregador vira aviso na hora
|
||||||
|
page.request.post(base + '/app/status', headers=auth, data={'bateria': {'nivel': 12, 'carregando': False}})
|
||||||
|
expect(page.get_by_text('Bateria do celular em 12%. Ligue o carregador')).to_be_visible(timeout=5000)
|
||||||
|
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
|
||||||
|
'aparelho': {'ip': '192.168.1.16', 'modelo': 'samsung SM-G973F', 'android': '12',
|
||||||
|
'app': '0.8.1', 'wifi': 3, 'rssi': -58}})
|
||||||
|
|
||||||
|
# 3. gesto pelo dock
|
||||||
|
kiosk.FILA.limpar()
|
||||||
|
page.get_by_role('button', name='Feliz').first.click()
|
||||||
|
expect(page.get_by_text('Expressão: feliz')).to_be_visible(timeout=5000)
|
||||||
|
assert kiosk.FILA.ordens() == ['feliz'], kiosk.FILA.ordens()
|
||||||
|
|
||||||
|
# 4. atalho numérico e paleta
|
||||||
|
page.locator('body').click(position={'x': 5, 'y': 900})
|
||||||
|
page.keyboard.press('2')
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
assert kiosk.FILA.ordens()[-1] == 'coracao', kiosk.FILA.ordens()
|
||||||
|
page.keyboard.press('Control+k')
|
||||||
|
page.get_by_label('Buscar comando').fill('surpreso')
|
||||||
|
page.keyboard.press('Enter')
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
assert kiosk.FILA.ordens()[-1] == 'surpreso', kiosk.FILA.ordens()
|
||||||
|
if capturas:
|
||||||
|
page.screenshot(path=str(Path(capturas) / 'ao-vivo.png'), full_page=True)
|
||||||
|
|
||||||
|
# 5. E-Stop real
|
||||||
|
page.get_by_role('button', name='Parar tudo').click()
|
||||||
|
expect(page.get_by_role('button', name='PARADO · Rearmar')).to_be_visible(timeout=5000)
|
||||||
|
assert kiosk.ESTOP['ativo'] and server.servos.travado
|
||||||
|
page.get_by_role('button', name='PARADO · Rearmar').click()
|
||||||
|
expect(page.get_by_role('button', name='Parar tudo')).to_be_visible(timeout=5000)
|
||||||
|
assert not server.servos.travado
|
||||||
|
|
||||||
|
# 6. ajustes salvam sem travar
|
||||||
|
page.get_by_role('link', name='Ajustes').first.click()
|
||||||
|
page.get_by_label('Buscar ajuste').fill('boca')
|
||||||
|
boca = page.get_by_role('switch', name='Mostrar boca')
|
||||||
|
assert boca.is_checked()
|
||||||
|
boca.locator('xpath=..').click()
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
assert server.controle.config['boca'] is False
|
||||||
|
if capturas:
|
||||||
|
page.get_by_label('Buscar ajuste').fill('')
|
||||||
|
page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True)
|
||||||
|
|
||||||
|
# 6a. rosto: escolher estilo e formato do olho
|
||||||
|
page.goto(base + '/#/rosto')
|
||||||
|
page.get_by_role('radio', name='Bloco').click()
|
||||||
|
page.get_by_role('radio', name='Quadrado', exact=True).click()
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
assert kiosk.APARENCIA['rosto'] == 'bloco' and kiosk.APARENCIA['formato'] == 'quadrado', kiosk.APARENCIA
|
||||||
|
expect(page.get_by_text('BLOCO · QUADRADO')).to_be_visible()
|
||||||
|
if capturas:
|
||||||
|
page.screenshot(path=str(Path(capturas) / 'rosto.png'), full_page=True)
|
||||||
|
|
||||||
|
# 6b. conversa: pergunta de hora é respondida pelo relógio (sem IA) e chega por SSE
|
||||||
|
page.get_by_role('link', name='Conversa').first.click()
|
||||||
|
page.get_by_label('Mensagem').fill('Que horas são?')
|
||||||
|
page.get_by_role('button', name='Enviar').click()
|
||||||
|
expect(page.get_by_text('no horário de São Paulo')).to_be_visible(timeout=5000)
|
||||||
|
page.get_by_label('Nova lembrança').fill('Meu nome é Júlio')
|
||||||
|
page.get_by_role('button', name='Adicionar').click()
|
||||||
|
expect(page.get_by_text('Meu nome é Júlio')).to_be_visible(timeout=5000)
|
||||||
|
page.get_by_role('tab', name='Rotina e preferências').click()
|
||||||
|
page.get_by_label('Nova rotina').fill('Treino às 7h')
|
||||||
|
page.get_by_role('button', name='Adicionar').click()
|
||||||
|
expect(page.get_by_text('Treino às 7h')).to_be_visible(timeout=5000)
|
||||||
|
if capturas:
|
||||||
|
page.screenshot(path=str(Path(capturas) / 'conversa.png'), full_page=True)
|
||||||
|
|
||||||
|
# 6c. reações: criar emoji, mostrar no celular, excluir e desfazer pelo toast
|
||||||
|
page.get_by_role('link', name='Reações').first.click()
|
||||||
|
page.get_by_role('button', name='Novo emoji').click()
|
||||||
|
page.get_by_label('Nome').fill('Ideia brilhante')
|
||||||
|
page.get_by_label('Quando usar').fill('Quando tiver uma ideia')
|
||||||
|
page.get_by_role('radio', name='🚀').click()
|
||||||
|
page.get_by_role('button', name='Salvar').click()
|
||||||
|
cartao = page.locator('li.reacao', has_text='Ideia brilhante')
|
||||||
|
expect(cartao).to_be_visible(timeout=5000)
|
||||||
|
cartao.get_by_role('button', name='Mostrar').click()
|
||||||
|
page.wait_for_timeout(400)
|
||||||
|
assert server.treinamento.expressao['receita']['nome'] == 'Ideia brilhante'
|
||||||
|
cartao.get_by_role('button', name='Excluir').click()
|
||||||
|
expect(cartao).to_have_count(0, timeout=5000)
|
||||||
|
page.get_by_role('button', name='Desfazer').last.click()
|
||||||
|
expect(page.locator('li.reacao', has_text='Ideia brilhante')).to_be_visible(timeout=5000)
|
||||||
|
if capturas:
|
||||||
|
page.screenshot(path=str(Path(capturas) / 'reacoes.png'), full_page=True)
|
||||||
|
|
||||||
|
# 6d. braço: opt-in, homem-morto e desligamento pelo prazo ao soltar
|
||||||
|
hw = server.servos.hw
|
||||||
|
page.get_by_role('link', name='Movimento').first.click()
|
||||||
|
segurar = page.get_by_role('button', name='Segure para ir ao alvo')
|
||||||
|
expect(segurar).to_be_disabled()
|
||||||
|
page.get_by_role('switch', name='Ativar movimento físico').locator('xpath=..').click()
|
||||||
|
expect(segurar).to_be_enabled()
|
||||||
|
hw.calls.clear()
|
||||||
|
segurar.hover()
|
||||||
|
page.mouse.down()
|
||||||
|
page.wait_for_timeout(700)
|
||||||
|
assert server.servos.active, 'motores deveriam estar ligados enquanto segura'
|
||||||
|
expect(page.get_by_text('Motores ligados')).to_be_visible(timeout=3000)
|
||||||
|
page.mouse.up()
|
||||||
|
page.wait_for_timeout(900)
|
||||||
|
assert not server.servos.active, 'motores deveriam desligar sozinhos após soltar'
|
||||||
|
assert any(us for _, us in hw.calls), hw.calls
|
||||||
|
if capturas:
|
||||||
|
page.screenshot(path=str(Path(capturas) / 'movimento.png'), full_page=True)
|
||||||
|
# E-Stop desativa o movimento físico na hora
|
||||||
|
page.get_by_role('button', name='Parar tudo').click()
|
||||||
|
expect(page.get_by_role('switch', name='Ativar movimento físico')).to_be_disabled(timeout=3000)
|
||||||
|
expect(segurar).to_be_disabled()
|
||||||
|
page.get_by_role('button', name='PARADO · Rearmar').click()
|
||||||
|
expect(page.get_by_role('button', name='Parar tudo')).to_be_visible(timeout=3000)
|
||||||
|
|
||||||
|
# 7. celular: barra inferior e nada de rolagem horizontal
|
||||||
|
page.set_viewport_size({'width': 400, 'height': 860})
|
||||||
|
page.get_by_role('link', name='Ao vivo').click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
largura = page.evaluate('document.scrollingElement.scrollWidth')
|
||||||
|
assert largura <= 400, 'rolagem horizontal: %s' % largura
|
||||||
|
if capturas:
|
||||||
|
page.screenshot(path=str(Path(capturas) / 'celular.png'), full_page=True)
|
||||||
|
|
||||||
|
# 8. tema claro
|
||||||
|
page.set_viewport_size({'width': 1280, 'height': 900})
|
||||||
|
inicial = page.evaluate("document.documentElement.dataset.theme")
|
||||||
|
outro = 'light' if inicial == 'dark' else 'dark'
|
||||||
|
page.get_by_role('button', name='Usar tema ' + ('claro' if outro == 'light' else 'escuro')).click()
|
||||||
|
assert page.evaluate("document.documentElement.dataset.theme") == outro
|
||||||
|
if capturas:
|
||||||
|
page.screenshot(path=str(Path(capturas) / ('tema-' + outro + '.png')))
|
||||||
|
|
||||||
|
assert not erros, erros
|
||||||
|
browser.close()
|
||||||
|
print('OK: token, SSE, dock 1-9, paleta, E-Stop, ajustes otimistas, conversa, memória, reações com desfazer, braço com homem-morto, celular 400px, tema')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main(sys.argv[1] if len(sys.argv) > 1 else None)
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
const assert = require('node:assert/strict');
|
|
||||||
let R;
|
|
||||||
try { R = require('../rastreamento-core.js'); } catch(e) { assert.fail('Rastreamento ausente'); }
|
|
||||||
const faces = [{x:.2,y:.1,w:.3,h:.4,score:.9}];
|
|
||||||
const hands = [Array.from({length:21},()=>({x:.8,y:.6}))];
|
|
||||||
assert.equal(R.target(faces,hands).tipo,'mao');
|
|
||||||
assert.equal(R.target(faces,[]).tipo,'rosto');
|
|
||||||
assert.equal(R.target([],[]),null);
|
|
||||||
assert.equal(R.target([{x:NaN,y:0,w:1,h:1}],[]),null);
|
|
||||||
const rect = R.fit(1000,500,640,480);
|
|
||||||
assert.ok(Math.abs(rect.x - (1000/2 - (500*640/480)/2)) < 1e-9);
|
|
||||||
assert.equal(rect.y,0);
|
|
||||||
assert.equal(R.point({x:0,y:0},rect).x,rect.x);
|
|
||||||
const gate = new R.FreshTarget();
|
|
||||||
gate.accept({tipo:'mao',x:.8,y:.5,atividade:true},100);
|
|
||||||
assert.equal(gate.get(200).x,.8);
|
|
||||||
assert.equal(gate.get(451),null,'Alvo antigo precisa expirar');
|
|
||||||
assert.equal(gate.accept({x:Infinity,y:0},500),false);
|
|
||||||
console.log('Rastreamento: prioridade, coordenadas, validação e expiração OK');
|
|
||||||
+31
-19
@@ -1,4 +1,6 @@
|
|||||||
"""Real WebRTC sender and panel in Chromium, using a synthetic camera."""
|
"""WebRTC real entre o transmissor do app e o painel novo (web/dist), com câmera sintética.
|
||||||
|
|
||||||
|
Requer `npm run build` em web/."""
|
||||||
import importlib.util
|
import importlib.util
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
@@ -27,6 +29,7 @@ def main():
|
|||||||
server = kiosk.Servidor(('127.0.0.1', 0), Handler)
|
server = kiosk.Servidor(('127.0.0.1', 0), Handler)
|
||||||
server.controle = kiosk.ControlePC(Path(tmp) / 'config.json')
|
server.controle = kiosk.ControlePC(Path(tmp) / 'config.json')
|
||||||
server.controle.atualizar({'preview': True,'rastrearRosto':False,'rastrearMaos':False})
|
server.controle.atualizar({'preview': True,'rastrearRosto':False,'rastrearMaos':False})
|
||||||
|
kiosk.ligar_eventos(server) # telemetria e ajustes chegam ao painel por SSE
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
base = 'http://127.0.0.1:' + str(server.server_port)
|
base = 'http://127.0.0.1:' + str(server.server_port)
|
||||||
@@ -62,40 +65,49 @@ def main():
|
|||||||
},200);
|
},200);
|
||||||
}""")
|
}""")
|
||||||
sender.request.post(base + '/app/status', data={'rosto':'carinha'})
|
sender.request.post(base + '/app/status', data={'rosto':'carinha'})
|
||||||
|
# Como o celular real, manda batimento a cada 1 s (senão o painel o considera offline).
|
||||||
|
sender.evaluate("""() => setInterval(() => fetch('/app/status',{method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},body:JSON.stringify({rosto:'carinha'})}), 1000)""")
|
||||||
panel = context.new_page()
|
panel = context.new_page()
|
||||||
panel.on('pageerror', lambda e: errors.append(str(e)))
|
panel.on('pageerror', lambda e: errors.append(str(e)))
|
||||||
panel.goto(base + '/painel')
|
panel.goto(base + '/')
|
||||||
panel.wait_for_function("document.getElementById('cameraVideo').videoWidth > 0", timeout=20000)
|
video = "document.querySelector('.camera video')"
|
||||||
first = panel.evaluate("document.getElementById('cameraVideo').getVideoPlaybackQuality().totalVideoFrames")
|
panel.wait_for_function(video + ".videoWidth > 0", timeout=20000)
|
||||||
|
first = panel.evaluate(video + ".getVideoPlaybackQuality().totalVideoFrames")
|
||||||
panel.wait_for_timeout(2200)
|
panel.wait_for_timeout(2200)
|
||||||
count = panel.evaluate("document.getElementById('cameraVideo').getVideoPlaybackQuality().totalVideoFrames") - first
|
count = panel.evaluate(video + ".getVideoPlaybackQuality().totalVideoFrames") - first
|
||||||
assert count >= 45, 'Vídeo não está fluido: %s quadros em 2.2 s' % count
|
assert count >= 45, 'Vídeo não está fluido: %s quadros em 2.2 s' % count
|
||||||
assert panel.locator('#cameraVideo').is_visible()
|
assert panel.locator('.camera video').is_visible()
|
||||||
assert 'fps' in panel.locator('#videoQualidade').inner_text()
|
qualidade = panel.locator('.camera .rodape .mono').inner_text()
|
||||||
|
assert 'fps' in qualidade, qualidade
|
||||||
assert not errors, errors
|
assert not errors, errors
|
||||||
print('Vídeo WebRTC real: %s quadros em 2.2 s; %s' % (count, panel.locator('#videoQualidade').inner_text()))
|
print('Vídeo WebRTC real: %s quadros em 2.2 s; %s' % (count, qualidade))
|
||||||
|
# Canal de rastreamento (DataChannel) chega ao transmissor.
|
||||||
sender.request.post(base+'/app/config',data={'rastrearMaos':True})
|
sender.request.post(base+'/app/config',data={'rastrearMaos':True})
|
||||||
sender.wait_for_function('config.rastrearMaos === true')
|
sender.wait_for_function('config.rastrearMaos === true')
|
||||||
panel.wait_for_function('CarinhaVideo.rastreamentoConectado()')
|
panel.wait_for_function('window.carinhaVideo?.rastreamentoConectado')
|
||||||
panel.evaluate("CarinhaVideo.rastrear({tipo:'mao',x:.8,y:.3,atividade:true})")
|
panel.wait_for_timeout(1000) # sem vendor/ o rastreador falha e manda null uma vez
|
||||||
|
panel.evaluate("carinhaVideo.rastrear({tipo:'mao',x:.8,y:.3,atividade:true})")
|
||||||
sender.wait_for_function("window.testAlvo?.tipo === 'mao' && testAlvo.x === .8")
|
sender.wait_for_function("window.testAlvo?.tipo === 'mao' && testAlvo.x === .8")
|
||||||
panel.evaluate('CarinhaVideo.rastrear(null)')
|
panel.evaluate('carinhaVideo.rastrear(null)')
|
||||||
sender.wait_for_function('window.testAlvo === null')
|
sender.wait_for_function('window.testAlvo === null')
|
||||||
sender.request.post(base+'/app/config',data={'rastrearMaos':False})
|
sender.request.post(base+'/app/config',data={'rastrearMaos':False})
|
||||||
panel.locator('#ajuste-videoResolucao').select_option('480')
|
# Ajustes chegam ao celular; o painel acompanha por SSE.
|
||||||
|
sender.request.post(base+'/app/config',data={'videoResolucao':'480'})
|
||||||
sender.wait_for_function("stream && stream.getVideoTracks()[0].getSettings().height === 480", timeout=10000)
|
sender.wait_for_function("stream && stream.getVideoTracks()[0].getSettings().height === 480", timeout=10000)
|
||||||
panel.locator('#ajuste-videoFPS').select_option('240')
|
sender.request.post(base+'/app/config',data={'videoFPS':240})
|
||||||
sender.wait_for_function("aviso.includes('indisponível')", timeout=10000)
|
sender.wait_for_function("aviso.includes('indisponível')", timeout=10000)
|
||||||
assert sender.evaluate('stream.getVideoTracks()[0].readyState') == 'live'
|
assert sender.evaluate('stream.getVideoTracks()[0].readyState') == 'live'
|
||||||
panel.locator('#ajuste-sensorCamera').uncheck()
|
sender.request.post(base+'/app/config',data={'sensorCamera':False})
|
||||||
panel.wait_for_function("document.getElementById('cameraVideo').srcObject === null", timeout=10000)
|
panel.wait_for_function(video + ".srcObject === null", timeout=10000)
|
||||||
sender.wait_for_function('stream === null', timeout=10000)
|
sender.wait_for_function('stream === null', timeout=10000)
|
||||||
panel.locator('#ajuste-sensorCamera').check()
|
sender.request.post(base+'/app/config',data={'sensorCamera':True})
|
||||||
panel.wait_for_function("document.getElementById('cameraVideo').videoWidth > 0 && document.getElementById('cameraVideo').srcObject !== null", timeout=15000)
|
panel.wait_for_function(video + ".videoWidth > 0 && " + video + ".srcObject !== null", timeout=15000)
|
||||||
assert not errors, errors
|
assert not errors, errors
|
||||||
panel.get_by_role('button', name='Desligar câmera').click()
|
# Desligar pela chave da tela Ao vivo.
|
||||||
|
panel.get_by_role('switch', name='Transmitir câmera do celular').locator('xpath=..').click()
|
||||||
panel.wait_for_timeout(1400)
|
panel.wait_for_timeout(1400)
|
||||||
assert panel.evaluate("document.getElementById('cameraVideo').srcObject === null")
|
assert panel.evaluate(video + ".srcObject === null")
|
||||||
preview = context.new_page()
|
preview = context.new_page()
|
||||||
preview.on('pageerror', lambda e: errors.append(str(e)))
|
preview.on('pageerror', lambda e: errors.append(str(e)))
|
||||||
preview.goto(base + '/camera.html?lado=user&resolucao=480&fps=30')
|
preview.goto(base + '/camera.html?lado=user&resolucao=480&fps=30')
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""Servidor base: token, /eventos (SSE), /estado, /comando, E-Stop, trava de hardware e fila."""
|
||||||
|
import http.client
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
spec = importlib.util.spec_from_file_location('kiosk', ROOT / 'kiosk-server.py')
|
||||||
|
kiosk = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(kiosk)
|
||||||
|
from braco import Braco
|
||||||
|
from comum import carregar_token
|
||||||
|
from servos import ControleServos
|
||||||
|
|
||||||
|
TOKEN = 'segredo-de-teste'
|
||||||
|
|
||||||
|
|
||||||
|
class HW:
|
||||||
|
def __init__(self): self.calls = []
|
||||||
|
def pulse(self, ch, us): self.calls.append((ch, us))
|
||||||
|
def off(self, ch): self.calls.append((ch, None))
|
||||||
|
|
||||||
|
|
||||||
|
class ServidorTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
pasta = Path(self.tmp.name)
|
||||||
|
self.hw = HW()
|
||||||
|
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
||||||
|
self.server.token = TOKEN
|
||||||
|
self.server.controle = kiosk.ControlePC(pasta / 'app.json')
|
||||||
|
self.server.servos = ControleServos(pasta / 'servos.json', self.hw)
|
||||||
|
self.server.braco = Braco(pasta / 'braco.json', self.server.servos)
|
||||||
|
kiosk.ligar_eventos(self.server)
|
||||||
|
kiosk.FILA.limpar()
|
||||||
|
kiosk.ESTOP.update(ativo=False, desde=None)
|
||||||
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
self.porta = self.server.server_port
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.server.servos.close()
|
||||||
|
self.thread.join()
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def pedir(self, metodo, rota, dados=None, token=TOKEN, cabecalhos=None):
|
||||||
|
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
|
||||||
|
h = {'Content-Type': 'application/json'}
|
||||||
|
if token:
|
||||||
|
h['Authorization'] = 'Bearer ' + token
|
||||||
|
h.update(cabecalhos or {})
|
||||||
|
corpo = None if dados is None else json.dumps(dados)
|
||||||
|
conn.request(metodo, rota, body=corpo, headers=h)
|
||||||
|
r = conn.getresponse()
|
||||||
|
texto = r.read()
|
||||||
|
conn.close()
|
||||||
|
return r.status, json.loads(texto) if texto else None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- token
|
||||||
|
def test_rotas_de_controle_exigem_token(self):
|
||||||
|
for metodo, rota, dados in [('POST', '/braco/mover', {'angulos': [90] * 7}),
|
||||||
|
('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'}),
|
||||||
|
('POST', '/parar-tudo', {}),
|
||||||
|
('GET', '/estado', None), ('GET', '/fila?aparelho=1', None),
|
||||||
|
('GET', '/app/status', None), ('GET', '/gesto/feliz', None)]:
|
||||||
|
for token in (None, 'errado'):
|
||||||
|
codigo, corpo = self.pedir(metodo, rota, dados, token=token)
|
||||||
|
self.assertEqual(codigo, 401, (metodo, rota, token))
|
||||||
|
self.assertEqual(self.hw.calls, [])
|
||||||
|
self.assertEqual(kiosk.FILA.ordens(), [])
|
||||||
|
|
||||||
|
def test_rotas_publicas_e_token_na_query(self):
|
||||||
|
self.assertEqual(self.pedir('GET', '/gestos', token=None)[0], 200)
|
||||||
|
self.assertEqual(self.pedir('GET', '/app/health', token=None)[0], 200)
|
||||||
|
self.assertEqual(self.pedir('GET', '/estado?t=' + TOKEN, token=None)[0], 200)
|
||||||
|
|
||||||
|
def test_sem_token_configurado_fica_aberto(self):
|
||||||
|
self.server.token = None
|
||||||
|
self.assertEqual(self.pedir('GET', '/estado', token=None)[0], 200)
|
||||||
|
|
||||||
|
def test_carregar_token(self):
|
||||||
|
pasta = Path(self.tmp.name)
|
||||||
|
self.assertIsNone(carregar_token(pasta))
|
||||||
|
(pasta / 'seguranca.json').write_text(json.dumps({'token': 'abcdefgh123'}))
|
||||||
|
self.assertEqual(carregar_token(pasta), 'abcdefgh123')
|
||||||
|
(pasta / 'seguranca.json').write_text(json.dumps({'token': 'curto'}))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
carregar_token(pasta)
|
||||||
|
|
||||||
|
def test_origem_estrangeira_recusada(self):
|
||||||
|
codigo, _ = self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'},
|
||||||
|
cabecalhos={'Origin': 'http://malicioso.example'})
|
||||||
|
self.assertEqual(codigo, 400)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- comando e fila
|
||||||
|
def test_comando_valida_e_enfileira(self):
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'})[0], 200)
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'miudo'})[0], 200)
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400)
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
|
||||||
|
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo'])
|
||||||
|
for novo in ('acenar', 'esconder', 'espiar', 'pensar', 'musica', # gestos com mãos e faixa
|
||||||
|
'rir', 'bocejar', 'confuso', 'tonto', 'timido', 'comemorar', 'bravo'):
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': novo})[0], 200)
|
||||||
|
kiosk.FILA.limpar()
|
||||||
|
# atividades (momentos dele)
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'atividade', 'valor': 'videogame'})[0], 200)
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'atividade', 'valor': 'parar'})[0], 200)
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'atividade', 'valor': 'voar'})[0], 400)
|
||||||
|
self.assertEqual(kiosk.FILA.ordens(), ['atividade videogame', 'atividade parar'])
|
||||||
|
kiosk.FILA.limpar()
|
||||||
|
kiosk.FILA.adicionar('feliz'); kiosk.FILA.adicionar('rosto miudo')
|
||||||
|
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo'])
|
||||||
|
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
|
||||||
|
|
||||||
|
def test_rostos_novos_e_formato_do_olho(self):
|
||||||
|
kiosk.ARQUIVO_APARENCIA = str(Path(self.tmp.name) / 'aparencia.json')
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'bloco'})[0], 200)
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'formato', 'valor': 'quadrado'})[0], 200)
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'formato', 'valor': 'triangulo'})[0], 400)
|
||||||
|
self.assertEqual(kiosk.FILA.ordens(), ['rosto bloco', 'formato quadrado'])
|
||||||
|
estado = self.pedir('GET', '/estado')[1]
|
||||||
|
self.assertEqual(estado['aparencia']['formato'], 'quadrado')
|
||||||
|
self.assertEqual(estado['aparencia']['rosto'], 'bloco') # a escolha fica salva
|
||||||
|
self.assertIn('pilula', estado['formatos'])
|
||||||
|
|
||||||
|
def test_post_face_usa_prefixo_rosto(self):
|
||||||
|
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
|
||||||
|
conn.request('POST', '/face', body='miudo', headers={'Authorization': 'Bearer ' + TOKEN})
|
||||||
|
self.assertEqual(conn.getresponse().status, 200)
|
||||||
|
conn.close()
|
||||||
|
self.assertEqual(kiosk.FILA.ordens(), ['rosto miudo'])
|
||||||
|
|
||||||
|
def test_fila_limitada_e_com_validade(self):
|
||||||
|
fila = kiosk.Fila(maximo=3, validade=10)
|
||||||
|
for i in range(5):
|
||||||
|
fila.adicionar(f'g{i}', agora=0)
|
||||||
|
self.assertEqual(fila.ordens(), ['g2', 'g3', 'g4'])
|
||||||
|
self.assertEqual(fila.consumir(agora=11), [])
|
||||||
|
fila.adicionar('recarregar'); fila.adicionar('recarregar'); fila.adicionar('feliz')
|
||||||
|
self.assertEqual(fila.ordens(), ['recarregar', 'feliz'])
|
||||||
|
# Rosto no navegador não rouba o 'recarregar' do celular.
|
||||||
|
self.assertEqual(fila.consumir(aparelho=False), ['feliz'])
|
||||||
|
self.assertEqual(fila.consumir(aparelho=True), ['recarregar'])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- SSE
|
||||||
|
def test_eventos_entrega_comando(self):
|
||||||
|
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
|
||||||
|
conn.request('GET', '/eventos?t=' + TOKEN)
|
||||||
|
r = conn.getresponse()
|
||||||
|
self.assertEqual(r.status, 200)
|
||||||
|
self.assertIn('text/event-stream', r.getheader('Content-Type'))
|
||||||
|
linhas = []
|
||||||
|
while not linhas or linhas[-1] != '':
|
||||||
|
linhas.append(r.fp.readline().decode().rstrip('\n'))
|
||||||
|
self.assertIn('event: pronto', linhas)
|
||||||
|
self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'coracao'})
|
||||||
|
bloco = []
|
||||||
|
while not bloco or bloco[-1] != '':
|
||||||
|
bloco.append(r.fp.readline().decode().rstrip('\n'))
|
||||||
|
conn.close()
|
||||||
|
self.assertIn('event: expressao', bloco)
|
||||||
|
self.assertIn('data: {"ordem": "coracao"}', bloco)
|
||||||
|
|
||||||
|
def test_estado_agrega(self):
|
||||||
|
codigo, estado = self.pedir('GET', '/estado')
|
||||||
|
self.assertEqual(codigo, 200)
|
||||||
|
for chave in ('versao', 'seq', 'estop', 'aparencia', 'gestos', 'app', 'motores'):
|
||||||
|
self.assertIn(chave, estado)
|
||||||
|
self.assertTrue(estado['autenticacao'])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- E-Stop e trava
|
||||||
|
def test_parar_tudo_desliga_e_trava_ate_rearmar(self):
|
||||||
|
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
|
||||||
|
self.hw.calls.clear()
|
||||||
|
codigo, corpo = self.pedir('POST', '/parar-tudo', {})
|
||||||
|
self.assertEqual(codigo, 200)
|
||||||
|
self.assertTrue(corpo['estop']['ativo'])
|
||||||
|
self.assertEqual({c for c, us in self.hw.calls if us is None}, set(range(16)))
|
||||||
|
self.assertEqual(kiosk.FILA.ordens(), ['calma'])
|
||||||
|
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 409)
|
||||||
|
self.assertEqual(self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})[0], 409)
|
||||||
|
self.assertEqual(self.pedir('POST', '/rearmar', {})[0], 200)
|
||||||
|
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
|
||||||
|
|
||||||
|
def test_braco_e_diagnostico_nao_disputam_motores(self):
|
||||||
|
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
|
||||||
|
codigo, corpo = self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})
|
||||||
|
self.assertEqual(codigo, 409)
|
||||||
|
self.assertIn('braco', corpo['erro'])
|
||||||
|
self.pedir('POST', '/braco/parar', {})
|
||||||
|
self.assertEqual(self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})[0], 200)
|
||||||
|
|
||||||
|
|
||||||
|
class BracoMovimentoTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.hw = HW()
|
||||||
|
self.servos = ControleServos(Path(self.tmp.name) / 's.json', self.hw)
|
||||||
|
self.servos.closed.set() # sem threads: os passos são chamados à mão
|
||||||
|
self.arm = Braco(Path(self.tmp.name) / 'b.json', self.servos)
|
||||||
|
cal = self.arm.status()
|
||||||
|
for row in cal['calibracao']:
|
||||||
|
row.update(minimo=500, maximo=2500, centro=1500)
|
||||||
|
self.arm.save(dict(calibracao=cal['calibracao'], posturas=[]))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_interpolacao_no_servidor_respeita_velocidade(self):
|
||||||
|
self.arm.move([90] * 7)
|
||||||
|
resposta = self.arm.move([90, 90, 90, 90, 90, 90, 130], velocidade=100, ttl_ms=2000)
|
||||||
|
self.assertTrue(resposta['movendo'])
|
||||||
|
agora = self.arm.lease - 1.5
|
||||||
|
self.arm.passo(agora, .1) # 100 °/s * 0,1 s = 10°
|
||||||
|
self.assertEqual(self.arm.atual[6], 100)
|
||||||
|
for i in range(10):
|
||||||
|
self.arm.passo(agora + .1 * (i + 1), .1)
|
||||||
|
self.assertEqual(self.arm.atual[6], 130)
|
||||||
|
self.assertIsNone(self.arm.alvo)
|
||||||
|
|
||||||
|
def test_lease_vencido_interrompe_movimento(self):
|
||||||
|
self.arm.move([90] * 7)
|
||||||
|
self.arm.move([90] * 6 + [170], velocidade=50, ttl_ms=300)
|
||||||
|
self.arm.passo(self.arm.lease + .01, .1)
|
||||||
|
self.assertIsNone(self.arm.alvo)
|
||||||
|
self.assertEqual(self.arm.atual[6], 90)
|
||||||
|
# O watchdog desliga os canais no mesmo prazo.
|
||||||
|
self.servos.expire(self.arm.lease + .01)
|
||||||
|
self.assertEqual(self.servos.active, {})
|
||||||
|
self.assertIsNone(self.servos.dono)
|
||||||
|
|
||||||
|
def test_renovar_no_alvo_informa_que_chegou(self):
|
||||||
|
self.arm.move([90] * 7)
|
||||||
|
self.hw.calls.clear()
|
||||||
|
r = self.arm.move([90] * 7, velocidade=60, ttl_ms=400)
|
||||||
|
self.assertFalse(r['movendo'])
|
||||||
|
self.assertIsNone(self.arm.alvo)
|
||||||
|
self.assertEqual(len(self.hw.calls), 8) # reenergiza os 8 canais com o novo prazo
|
||||||
|
|
||||||
|
def test_parametros_invalidos(self):
|
||||||
|
for kwargs in (dict(velocidade=1000), dict(velocidade=True), dict(ttl_ms=50), dict(ttl_ms=5000)):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.arm.move([90] * 7, **kwargs)
|
||||||
|
self.assertEqual(self.hw.calls, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -35,7 +35,7 @@ def main():
|
|||||||
if not r.request.url.startswith(base): external.append(r.request.url);r.abort()
|
if not r.request.url.startswith(base): external.append(r.request.url);r.abort()
|
||||||
else:r.continue_()
|
else:r.continue_()
|
||||||
page.route('**/*',route)
|
page.route('**/*',route)
|
||||||
page.goto(base+'/painel')
|
page.goto(base+'/') # painel novo: nada pode sair para a internet
|
||||||
results=page.evaluate("""async () => {
|
results=page.evaluate("""async () => {
|
||||||
const worker=new Worker('/rastreador-worker.js');
|
const worker=new Worker('/rastreador-worker.js');
|
||||||
const send=msg=>new Promise((resolve,reject)=>{
|
const send=msg=>new Promise((resolve,reject)=>{
|
||||||
@@ -56,36 +56,10 @@ def main():
|
|||||||
assert len(results['hand']['hands'][0])==21
|
assert len(results['hand']['hands'][0])==21
|
||||||
print('Inferencia real: %s rosto(s), %s mao(s); %.1f/%.1f ms' % (
|
print('Inferencia real: %s rosto(s), %s mao(s); %.1f/%.1f ms' % (
|
||||||
len(results['face']['faces']),len(results['hand']['hands']),results['face']['ms'],results['hand']['ms']))
|
len(results['face']['faces']),len(results['hand']['hands']),results['face']['ms'],results['hand']['ms']))
|
||||||
# Feed the same real worker into the panel, using a local canvas stream.
|
|
||||||
page.request.post(base+'/app/config',data={'preview':True})
|
|
||||||
page.request.post(base+'/app/status',data={'rosto':'carinha','dormindo':False})
|
|
||||||
page.evaluate("""async () => {
|
|
||||||
const image=await createImageBitmap(await (await fetch('/test-hands')).blob());
|
|
||||||
const c=document.createElement('canvas');c.width=640;c.height=Math.round(640*image.height/image.width);
|
|
||||||
const ctx=c.getContext('2d');ctx.drawImage(image,0,0,c.width,c.height);image.close();
|
|
||||||
window.testStream=c.captureStream(30);
|
|
||||||
window.testDraw=setInterval(()=>ctx.drawImage(c,0,0),33);
|
|
||||||
const v=document.getElementById('cameraVideo');v.srcObject=testStream;v.hidden=false;await v.play();
|
|
||||||
window.testTargets=[];
|
|
||||||
window.CarinhaVideo={rastrear(t){testTargets.push(t)},parar(){},atualizar(){}};
|
|
||||||
window.CarinhaRastreamento.atualizar({online:true,estado:{}}, {preview:true,sensorCamera:true,
|
|
||||||
rastrearRosto:true,rastrearMaos:true,mostrarRastreamento:true,seguirRastreamento:true});
|
|
||||||
}""")
|
|
||||||
page.wait_for_function("window.CarinhaRastreamento.estado().result?.hands.length > 0",timeout=15000)
|
|
||||||
assert page.locator('#trackingOverlay').is_visible()
|
|
||||||
assert page.evaluate("testTargets.some(t=>t?.tipo==='mao')")
|
|
||||||
work=ROOT.parents[1]/'work';page.screenshot(path=str(work/'rastreamento-teste.png'),full_page=True)
|
|
||||||
page.locator('#ajuste-mostrarRastreamento').uncheck()
|
|
||||||
page.wait_for_function("document.getElementById('trackingOverlay').hidden",timeout=5000)
|
|
||||||
page.locator('#ajuste-seguirRastreamento').uncheck()
|
|
||||||
page.wait_for_timeout(1200)
|
|
||||||
assert page.evaluate('testTargets[testTargets.length-1] === null')
|
|
||||||
page.evaluate("CarinhaRastreamento.atualizar({online:true,estado:{dormindo:true}},{preview:true,sensorCamera:true,cameraSono:false,rastrearRosto:true,rastrearMaos:true})")
|
|
||||||
assert not page.evaluate('CarinhaRastreamento.estado().active')
|
|
||||||
assert not external,external
|
assert not external,external
|
||||||
assert not errors,errors
|
assert not errors,errors
|
||||||
browser.close()
|
browser.close()
|
||||||
print('Overlay, alvo, controles, sono e inferencia sem acesso externo: OK')
|
print('Inferencia real no worker e painel sem acesso externo: OK')
|
||||||
finally:
|
finally:
|
||||||
server.shutdown();server.server_close();thread.join()
|
server.shutdown();server.server_close();thread.join()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Modo vida (bichinho virtual): necessidades, sono, atos, figurinhas com limite e rotas HTTP."""
|
||||||
|
import http.client
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
from vida import Vida # noqa: E402
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location('kiosk_vida', ROOT / 'kiosk-server.py')
|
||||||
|
kiosk = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(kiosk)
|
||||||
|
|
||||||
|
DIA = time.mktime((2026, 10, 6, 15, 0, 0, 0, 0, -1)) # 15h: longe da noite
|
||||||
|
|
||||||
|
|
||||||
|
class TreinoFalso:
|
||||||
|
def __init__(self):
|
||||||
|
self.pedidos, self.tocadas = [], []
|
||||||
|
|
||||||
|
def gerar_figurinha(self, assunto):
|
||||||
|
self.pedidos.append(assunto)
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
return dict(reacoes=[dict(id=7, receita=dict(nome='Dança'), feedback=0, ativa=True)])
|
||||||
|
|
||||||
|
def testar(self, ident):
|
||||||
|
self.tocadas.append(ident)
|
||||||
|
|
||||||
|
|
||||||
|
class VidaTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.v = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False, rng=random.Random(1))
|
||||||
|
self.ordens = []
|
||||||
|
self.v.enfileirar = self.ordens.append
|
||||||
|
self.v.ultimo_tick = DIA
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_necessidades_caem_e_sono_recupera(self):
|
||||||
|
self.v.necessidades.update(energia=50, social=50, diversao=50)
|
||||||
|
self.v.passar_tempo(DIA + 600) # 10 min acordado
|
||||||
|
self.assertLess(self.v.necessidades['energia'], 50)
|
||||||
|
self.assertLess(self.v.necessidades['diversao'], 50)
|
||||||
|
self.v.dormindo_proprio = True
|
||||||
|
antes = self.v.necessidades['energia']
|
||||||
|
self.v.passar_tempo(DIA + 1200) # 10 min dormindo
|
||||||
|
self.assertGreater(self.v.necessidades['energia'], antes)
|
||||||
|
|
||||||
|
def test_pausa_longa_nao_zera(self):
|
||||||
|
self.v.passar_tempo(DIA + 86400) # placa desligada um dia
|
||||||
|
self.assertGreater(self.v.necessidades['energia'], 60)
|
||||||
|
|
||||||
|
def test_cansado_vai_dormir_e_acorda_descansado(self):
|
||||||
|
self.v.necessidades['energia'] = 10
|
||||||
|
self.assertEqual(self.v.agir(DIA), 'dormir')
|
||||||
|
self.assertIn('dormir', self.ordens)
|
||||||
|
self.assertTrue(self.v.dormindo())
|
||||||
|
self.assertIsNone(self.v.agir(DIA + 60)) # ainda sem energia: continua dormindo
|
||||||
|
self.v.necessidades['energia'] = 100
|
||||||
|
self.assertEqual(self.v.agir(DIA + 120), 'acordar')
|
||||||
|
|
||||||
|
def test_madrugada_dorme_mesmo_com_gente_se_mexendo(self):
|
||||||
|
noite = time.mktime((2026, 10, 6, 23, 30, 0, 0, 0, -1))
|
||||||
|
self.v.alguem_perto = lambda: True # deitado na cama, o PIR vê
|
||||||
|
self.assertEqual(self.v.agir(noite), 'dormir')
|
||||||
|
self.v.necessidades['energia'] = 100
|
||||||
|
self.assertIsNone(self.v.agir(noite + 60)) # descansado, mas é madrugada: não acorda
|
||||||
|
self.v.estimulo('carinho', noite + 120) # carinho acorda e segura acordado um tempo
|
||||||
|
self.assertFalse(self.v.dormindo())
|
||||||
|
self.assertNotEqual(self.v.agir(noite + 180), 'dormir')
|
||||||
|
self.assertEqual(self.v.agir(noite + 180 + 11 * 60), 'dormir')
|
||||||
|
|
||||||
|
def test_ritmo_vira_sensibilidade(self):
|
||||||
|
self.v.configurar({'ritmo': 'calmo'})
|
||||||
|
self.assertIn('ritmo calmo', self.ordens)
|
||||||
|
self.assertFalse(self.v.pir_acorda(DIA)) # calmo: 3 disparos do PIR em 2 min
|
||||||
|
self.assertFalse(self.v.pir_acorda(DIA + 30))
|
||||||
|
self.assertTrue(self.v.pir_acorda(DIA + 60))
|
||||||
|
self.assertFalse(self.v.pir_acorda(DIA + 400)) # os antigos expiraram
|
||||||
|
self.v.configurar({'ritmo': 'agitado'})
|
||||||
|
self.assertTrue(self.v.pir_acorda(DIA + 500)) # agitado: um basta
|
||||||
|
self.ordens.clear()
|
||||||
|
self.v.tick(DIA + 505)
|
||||||
|
self.assertIn('ritmo agitado', self.ordens) # reenviado junto com o humor
|
||||||
|
self.v.configurar({'ligado': False})
|
||||||
|
self.assertIn('ritmo normal', self.ordens)
|
||||||
|
self.assertTrue(self.v.pir_acorda(DIA + 600)) # desligado: comportamento de sempre
|
||||||
|
|
||||||
|
def test_atividades_pelo_humor(self):
|
||||||
|
self.v.celular_online = lambda: True
|
||||||
|
self.v.necessidades.update(energia=80, alegria=70, social=70, diversao=10) # entediado
|
||||||
|
self.v.rng.random = lambda: 0.0 # sorteio sempre "sim"
|
||||||
|
ato = self.v.agir(DIA) # entediado: vai fazer algo
|
||||||
|
self.assertTrue(ato.startswith('atividade '))
|
||||||
|
nome, segundos = self.ordens[-1].split()[1:]
|
||||||
|
self.assertEqual(ato, 'atividade ' + nome)
|
||||||
|
self.assertTrue(120 <= float(segundos) <= 300) # por alguns minutos
|
||||||
|
self.assertEqual(self.v.diario[0]['tipo'], 'atividade')
|
||||||
|
self.v.celular_atividade = lambda: nome # fazendo: diversão sobe
|
||||||
|
self.v.passar_tempo(DIA + 180)
|
||||||
|
self.assertGreater(self.v.necessidades['diversao'], 10)
|
||||||
|
self.v.celular_atividade = lambda: 'videogame' # ocupado: não interrompe
|
||||||
|
self.assertIsNone(self.v.agir(DIA + 60))
|
||||||
|
self.v.celular_atividade = lambda: None
|
||||||
|
noite = time.mktime((2026, 10, 6, 2, 0, 0, 0, 0, -1))
|
||||||
|
self.v.config['noite'] = False
|
||||||
|
self.assertFalse(str(self.v.agir(noite)).startswith('atividade')) # de madrugada, nunca
|
||||||
|
|
||||||
|
def test_exausto_dorme_e_o_sono_e_respeitado(self):
|
||||||
|
self.v.necessidades['energia'] = 15
|
||||||
|
self.assertEqual(self.v.agir(DIA), 'dormir')
|
||||||
|
self.assertTrue(self.v.sono_profundo)
|
||||||
|
self.assertIn('sono profundo', self.ordens)
|
||||||
|
self.v.necessidades['energia'] = 60
|
||||||
|
self.assertIsNone(self.v.agir(DIA + 60)) # ainda não descansou
|
||||||
|
self.v.necessidades['energia'] = 96
|
||||||
|
self.assertEqual(self.v.agir(DIA + 120), 'acordar')
|
||||||
|
self.assertFalse(self.v.sono_profundo)
|
||||||
|
self.assertIn('sono leve', self.ordens)
|
||||||
|
|
||||||
|
def test_acordado_de_proposito_encerra_o_sono_pesado(self):
|
||||||
|
self.v.necessidades['energia'] = 15
|
||||||
|
self.v.agir(DIA)
|
||||||
|
self.v.celular_dormindo = lambda: False # tocaram na tela
|
||||||
|
self.v.tick(DIA + 30)
|
||||||
|
self.assertFalse(self.v.sono_profundo)
|
||||||
|
self.assertEqual(self.v.diario[0]['texto'], 'Acordou antes de descansar')
|
||||||
|
|
||||||
|
def test_cochilo_quando_cansado(self):
|
||||||
|
self.v.celular_online = lambda: True
|
||||||
|
self.v.rng.random = lambda: 0.0
|
||||||
|
self.v.necessidades.update(energia=35, diversao=80)
|
||||||
|
self.assertEqual(self.v.agir(DIA), 'atividade dormir')
|
||||||
|
self.v.celular_atividade = lambda: 'dormir' # na caminha: recupera
|
||||||
|
antes = self.v.necessidades['energia']
|
||||||
|
self.v.passar_tempo(DIA + 300)
|
||||||
|
self.assertGreater(self.v.necessidades['energia'], antes)
|
||||||
|
|
||||||
|
def test_alegria_caindo_deixa_bravo(self):
|
||||||
|
self.v.necessidades.update(energia=80, social=80, diversao=80, alegria=100)
|
||||||
|
self.assertEqual(self.v.zanga(), 0.0)
|
||||||
|
self.v.necessidades['alegria'] = 42
|
||||||
|
self.assertEqual(self.v.zanga(), 0.4)
|
||||||
|
self.v.tick(DIA + 5)
|
||||||
|
self.assertIn('zanga 0.4', self.ordens)
|
||||||
|
self.v.necessidades['alegria'] = 20
|
||||||
|
self.assertEqual(self.v.humor(), 'bravo')
|
||||||
|
self.assertEqual(self.v.zanga(), 0.9)
|
||||||
|
self.v.dormindo_proprio = True # dormindo, sem cara feia
|
||||||
|
self.assertEqual(self.v.zanga(), 0.0)
|
||||||
|
|
||||||
|
def test_sozinho_vive_a_vida_dele(self):
|
||||||
|
self.v.celular_online = lambda: True
|
||||||
|
self.v.alguem_perto = lambda: False
|
||||||
|
self.v.visto_em = DIA - 10 * 60 # ninguém há 10 min
|
||||||
|
self.v.necessidades.update(energia=80, alegria=40, social=10, diversao=80) # carente
|
||||||
|
self.v.rng.random = lambda: 0.99 # sorteios de atividade: não
|
||||||
|
for i in range(20):
|
||||||
|
gesto = self.v.agir(DIA + i * 100)
|
||||||
|
self.assertNotIn(gesto, ('acenar', 'coracao', 'piscadinha', 'timido', 'esconder', 'espiar'))
|
||||||
|
self.v.rng.random = lambda: 0.0 # sorteio: sim
|
||||||
|
self.v.atividade_em = 0
|
||||||
|
self.assertTrue(str(self.v.agir(DIA + 3000)).startswith('atividade ')) # sem plateia: vai fazer algo
|
||||||
|
|
||||||
|
def test_horario_da_madrugada(self):
|
||||||
|
from controle_pc import madrugada
|
||||||
|
h = lambda hora: time.mktime((2026, 10, 6, hora, 10, 0, 0, 0, -1))
|
||||||
|
cfg = dict(noiteSilenciosa=True, noiteInicio=23, noiteFim=7)
|
||||||
|
self.assertEqual([madrugada(cfg, h(x)) for x in (22, 23, 3, 6, 7, 12)], [False, True, True, True, False, False])
|
||||||
|
self.assertTrue(madrugada(dict(cfg, noiteInicio=1, noiteFim=5), h(2)))
|
||||||
|
self.assertFalse(madrugada(dict(cfg, noiteSilenciosa=False), h(3)))
|
||||||
|
|
||||||
|
def test_humor_e_gestos_de_carente_com_gente_perto(self):
|
||||||
|
self.v.necessidades.update(energia=80, social=10)
|
||||||
|
self.assertEqual(self.v.humor(), 'carente')
|
||||||
|
self.v.alguem_perto = lambda: True
|
||||||
|
gesto = self.v.agir(DIA)
|
||||||
|
self.assertIn(gesto, ('acenar', 'espiar', 'coracao', 'surpreso'))
|
||||||
|
self.assertEqual(self.v.diario[0]['tipo'], 'acao')
|
||||||
|
|
||||||
|
def test_cuidados_sobem_necessidades_e_mandam_gesto(self):
|
||||||
|
self.v.necessidades['social'] = 20
|
||||||
|
st = self.v.estimulo('carinho', DIA)
|
||||||
|
self.assertEqual(st['necessidades']['social'], 35)
|
||||||
|
self.assertEqual(self.ordens[-1], 'coracao')
|
||||||
|
self.v.estimulo('ninar', DIA)
|
||||||
|
self.assertTrue(self.v.dormindo())
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.v.estimulo('voar', DIA)
|
||||||
|
|
||||||
|
def test_figurinha_com_limite(self):
|
||||||
|
treino = TreinoFalso()
|
||||||
|
self.v.treinamento = treino
|
||||||
|
self.v.pode_gemini = lambda: True
|
||||||
|
self.assertEqual(self.v.motivo_sem_emoji(DIA), '')
|
||||||
|
self.assertTrue(self.v.criar_figurinha(DIA, 'feliz'))
|
||||||
|
self.assertIn('à tarde', treino.pedidos[0])
|
||||||
|
self.assertEqual(self.v.motivo_sem_emoji(DIA + 60), 'descansando a criatividade')
|
||||||
|
self.v.config['max_emojis_dia'] = 1
|
||||||
|
self.assertEqual(self.v.motivo_sem_emoji(DIA + 4 * 3600), 'limite do dia')
|
||||||
|
self.v.pode_gemini = lambda: False
|
||||||
|
self.assertEqual(self.v.motivo_sem_emoji(DIA + 4 * 3600), 'sem chave Gemini')
|
||||||
|
|
||||||
|
def test_tick_manda_humor_e_respeita_bloqueio(self):
|
||||||
|
self.v.proximo_ato = DIA + 999
|
||||||
|
self.v.tick(DIA + 5)
|
||||||
|
self.assertTrue(any(o.startswith('humor ') for o in self.ordens))
|
||||||
|
self.ordens.clear()
|
||||||
|
self.v.bloqueado = lambda: True
|
||||||
|
self.v.proximo_ato = DIA
|
||||||
|
self.assertIsNone(self.v.tick(DIA + 70))
|
||||||
|
self.assertEqual(self.ordens, [])
|
||||||
|
|
||||||
|
def test_desligado_nao_age_e_config_persiste(self):
|
||||||
|
self.v.configurar({'ligado': False, 'ritmo': 'calmo'})
|
||||||
|
self.assertIn('humor normal', self.ordens)
|
||||||
|
self.v.proximo_ato = DIA
|
||||||
|
self.assertIsNone(self.v.tick(DIA + 10))
|
||||||
|
outra = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False)
|
||||||
|
self.assertFalse(outra.config['ligado'])
|
||||||
|
self.assertEqual(outra.config['ritmo'], 'calmo')
|
||||||
|
for ruim in ({'ritmo': 'turbo'}, {'max_emojis_dia': 50}, {'ligado': 1}, {'x': True}):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.v.configurar(ruim)
|
||||||
|
|
||||||
|
|
||||||
|
class VidaHttpTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
||||||
|
self.server.token = 'token-vida'
|
||||||
|
self.server.vida = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False)
|
||||||
|
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.server.shutdown(); self.server.server_close(); self.tmp.cleanup()
|
||||||
|
|
||||||
|
def pedir(self, metodo, rota, corpo=None, token='token-vida'):
|
||||||
|
c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5)
|
||||||
|
h = {'Content-Type': 'application/json'}
|
||||||
|
if token:
|
||||||
|
h['Authorization'] = 'Bearer ' + token
|
||||||
|
c.request(metodo, rota, body=json.dumps(corpo) if corpo is not None else None, headers=h)
|
||||||
|
r = c.getresponse(); dados = r.read(); c.close()
|
||||||
|
return r.status, json.loads(dados)
|
||||||
|
|
||||||
|
def test_rotas(self):
|
||||||
|
self.assertEqual(self.pedir('GET', '/vida/status', token=None)[0], 401)
|
||||||
|
self.assertEqual(self.pedir('POST', '/vida/cuidar', {'acao': 'carinho'}, token=None)[0], 401)
|
||||||
|
codigo, st = self.pedir('GET', '/vida/status')
|
||||||
|
self.assertEqual(codigo, 200)
|
||||||
|
self.assertIn(st['humor'], ('feliz', 'tranquilo', 'carente', 'entediado', 'triste', 'cansado', 'dormindo'))
|
||||||
|
codigo, st = self.pedir('POST', '/vida/cuidar', {'acao': 'brincar'})
|
||||||
|
self.assertEqual((codigo, st['diario'][0]['texto']), (200, 'Brincou'))
|
||||||
|
self.assertEqual(self.pedir('POST', '/vida/cuidar', {'acao': 'voar'})[0], 400)
|
||||||
|
self.assertFalse(self.pedir('POST', '/vida/config', {'ligado': False})[1]['config']['ligado'])
|
||||||
|
self.assertIn('vida', self.pedir('GET', '/estado')[1])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Treinamento & Personalidade · Carinha</title>
|
|
||||||
<link rel="stylesheet" href="/dashboard.css">
|
|
||||||
<link rel="stylesheet" href="/treinamento.css">
|
|
||||||
<script src="/dashboard.js" defer></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:16px;margin-bottom:12px">
|
|
||||||
<div>
|
|
||||||
<h1>Treinamento & Personalidade</h1>
|
|
||||||
<p style="margin:4px 0 0">Ensine novos hábitos, registre preferências e crie desenhos dinâmicos com o Gemini.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="/emojis" class="primary" style="display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:var(--raio-sm)">
|
|
||||||
<span>✨ Ver Biblioteca de Emojis</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="treinamento-grid">
|
|
||||||
<!-- Seção de Rotina e Preferências -->
|
|
||||||
<section class="card rotina">
|
|
||||||
<h2>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
|
||||||
Rotina & Preferências
|
|
||||||
</h2>
|
|
||||||
<p>Hábitos e gostos que moldam a convivência com o Carinha.</p>
|
|
||||||
|
|
||||||
<form id="rotinaForm" style="margin-top:10px">
|
|
||||||
<label for="rotinaTexto">O que o Carinha deve saber?</label>
|
|
||||||
<input id="rotinaTexto" type="text" maxlength="300" required placeholder="Ex.: De manhã gosto de café preto e notícias">
|
|
||||||
<button class="primary" style="width:100%">Registrar Preferência</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div id="rotinas"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Laboratório de Criação de Desenhos SVG com Gemini -->
|
|
||||||
<section class="card laboratorio">
|
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
|
||||||
<h2>
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="12 8 8 12 12 16 16 12 12 8"/></svg>
|
|
||||||
Laboratório de Expressão IA
|
|
||||||
</h2>
|
|
||||||
<span class="badge" style="font-size:11px">Gemini SVG Studio</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<iframe id="previa" title="Prévia animada do Carinha" src="/carinha.html?espelho=1&audio=0"></iframe>
|
|
||||||
|
|
||||||
<p style="font-size:13px;color:var(--fraco)">
|
|
||||||
Descreva uma ideia visual para o Gemini sintetizar em formas vetoriais no rosto do robô.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form id="figurinhaForm" style="margin:8px 0 14px">
|
|
||||||
<label for="figurinhaTexto">O que você quer desenhar?</label>
|
|
||||||
<input id="figurinhaTexto" type="text" maxlength="1000" required placeholder="Ex.: Uma lâmpada acendendo com brilhos ou um coração pulsando nos olhos">
|
|
||||||
<div class="actions" style="margin-top:6px">
|
|
||||||
<button class="primary" id="criarFigurinha" type="submit">✨ Desenhar e Mostrar</button>
|
|
||||||
<button id="gerar" type="button">Gerar Reações Automáticas</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<details class="opcoes" style="margin-top:4px">
|
|
||||||
<summary>Criação Automática Periódica</summary>
|
|
||||||
<label style="display:flex;align-items:center;gap:10px;margin-top:10px">
|
|
||||||
<input id="automatico" type="checkbox">
|
|
||||||
<span>Criar novas reações a cada hora quando houver conversas ativas</span>
|
|
||||||
</label>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details style="margin-top:12px">
|
|
||||||
<summary>Prompt enviado ao Gemini (Inspeção Técnica)</summary>
|
|
||||||
<p style="font-size:12.5px">Orientação de sistema para os desenhos vetoriais:</p>
|
|
||||||
<pre id="promptOrientacao"></pre>
|
|
||||||
<h3 style="margin-top:14px">Último Prompt Enviado</h3>
|
|
||||||
<pre id="ultimoPrompt">Nenhum pedido enviado ainda.</pre>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<p id="estado" role="status" style="margin-top:12px">Carregando…</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Diário & Resumo de Sessões -->
|
|
||||||
<div class="grid" style="margin-top:24px">
|
|
||||||
<section class="card">
|
|
||||||
<details>
|
|
||||||
<summary>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
|
||||||
Resumo das Sessões de Treino
|
|
||||||
</summary>
|
|
||||||
<div id="resumos" style="margin-top:12px"></div>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
|
||||||
<details>
|
|
||||||
<summary>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--ciano)" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
|
||||||
Diário de Conversas do Robô
|
|
||||||
</summary>
|
|
||||||
<p style="font-size:13px;margin:8px 0 12px">Registros gerados a partir dos diálogos anteriores.</p>
|
|
||||||
<div class="actions" style="margin-bottom:12px">
|
|
||||||
<button id="limparDiario" type="button" class="danger" style="font-size:12px">🗑️ Apagar Diário e Resumos</button>
|
|
||||||
</div>
|
|
||||||
<div id="diario"></div>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/treinamento.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
(function(){
|
|
||||||
const el=id=>document.getElementById(id);let signature='',sending=false;
|
|
||||||
async function api(path,data){const r=await fetch('/treinamento/'+path,data===undefined?{cache:'no-store'}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const d=await r.json();if(!r.ok)throw Error(d.erro);return d;}
|
|
||||||
const date=t=>new Date(t*1000).toLocaleString('pt-BR');
|
|
||||||
function text(tag,value){const n=document.createElement(tag);n.textContent=value;return n;}
|
|
||||||
function button(label,action){const b=text('button',label);b.onclick=action;return b;}
|
|
||||||
async function action(path,data){try{await api(path,data);await refresh();return true;}catch(e){el('estado').textContent=e.message;return false;}}
|
|
||||||
async function refresh(){try{const s=await api('status');el('gerar').disabled=s.ocupado||sending;el('criarFigurinha').disabled=s.ocupado;el('automatico').checked=s.automatico;el('estado').textContent=s.erro?s.erro:s.ocupado?'Gemini está criando novas reações…':s.automatico?'Geração por hora ativada.':'Crie agora ou ative a geração por hora.';
|
|
||||||
el('promptOrientacao').textContent=s.prompt_orientacao||'';el('ultimoPrompt').textContent=s.ultimo_prompt_figurinha||'Nenhum pedido enviado ainda.';
|
|
||||||
const key=JSON.stringify([s.rotina,s.reacoes,s.diario,s.resumos]);if(key===signature)return;signature=key;
|
|
||||||
el('rotinas').replaceChildren();for(const r of s.rotina){const row=text('p',r.texto+' ');row.append(button('Apagar',()=>action('rotina-apagar',{id:r.id})));el('rotinas').append(row);}
|
|
||||||
el('resumos').replaceChildren();for(const r of s.resumos)el('resumos').append(text('small',date(r.horario)),text('p',r.texto));
|
|
||||||
el('diario').replaceChildren();for(const m of s.diario)el('diario').append(text('small',date(m.horario)+' · '+(m.papel==='user'?'Você':'Carinha')),text('p',m.texto));
|
|
||||||
}catch(e){el('estado').textContent=e.message;}}
|
|
||||||
el('limparDiario').onclick=()=>{if(confirm('Apagar o diario e os resumos? As rotinas e reacoes serao mantidas.'))action('limpar-diario',{});};
|
|
||||||
el('figurinhaForm').onsubmit=async e=>{e.preventDefault();await action('figurinha',{texto:el('figurinhaTexto').value});};
|
|
||||||
el('rotinaForm').onsubmit=async e=>{e.preventDefault();if(await action('rotina',{texto:el('rotinaTexto').value}))el('rotinaTexto').value='';};
|
|
||||||
el('gerar').onclick=async()=>{sending=true;el('gerar').disabled=true;await action('gerar',{});sending=false;};el('automatico').onchange=()=>action('config',{automatico:el('automatico').checked});refresh();setInterval(refresh,2000);
|
|
||||||
})();
|
|
||||||
+15
-7
@@ -1,6 +1,7 @@
|
|||||||
"""Rotina, diário e reações procedurais. Não treina pesos nem controla motores."""
|
"""Rotina, diário e reações procedurais. Não treina pesos nem controla motores."""
|
||||||
import json,sqlite3,threading,time,math
|
import json,sqlite3,threading,time,math
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from comum import ler_json
|
||||||
from gemini import gerar_json
|
from gemini import gerar_json
|
||||||
from figurinhas import criar_desenho,validar_desenho,montar_prompt
|
from figurinhas import criar_desenho,validar_desenho,montar_prompt
|
||||||
|
|
||||||
@@ -28,8 +29,20 @@ def validar_receita(r):
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
class Treinamento:
|
class Treinamento:
|
||||||
|
# Mudanças de expressão/ocupação viram eventos SSE sem caçar cada atribuição.
|
||||||
|
@property
|
||||||
|
def expressao(self):return self._expressao
|
||||||
|
@expressao.setter
|
||||||
|
def expressao(self,valor):
|
||||||
|
self._expressao=valor;self.publicar('reacao',valor)
|
||||||
|
@property
|
||||||
|
def busy(self):return self._busy
|
||||||
|
@busy.setter
|
||||||
|
def busy(self,valor):
|
||||||
|
self._busy=valor;self.publicar('treinamento',dict(ocupado=valor,erro=self.erro))
|
||||||
def __init__(self,path,iniciar=True):
|
def __init__(self,path,iniciar=True):
|
||||||
self.path=str(path);self.lock=threading.RLock();self.busy=False;self.erro='';self.expressao=None
|
self.publicar=lambda tipo,dados:None
|
||||||
|
self.path=str(path);self.lock=threading.RLock();self.erro='';self.busy=False;self.expressao=None
|
||||||
with self.db() as db:
|
with self.db() as db:
|
||||||
db.execute('CREATE TABLE IF NOT EXISTS rotina(id INTEGER PRIMARY KEY,texto TEXT)')
|
db.execute('CREATE TABLE IF NOT EXISTS rotina(id INTEGER PRIMARY KEY,texto TEXT)')
|
||||||
db.execute('CREATE TABLE IF NOT EXISTS diario(id INTEGER PRIMARY KEY,horario REAL,papel TEXT,texto TEXT)')
|
db.execute('CREATE TABLE IF NOT EXISTS diario(id INTEGER PRIMARY KEY,horario REAL,papel TEXT,texto TEXT)')
|
||||||
@@ -178,12 +191,7 @@ class Treinamento:
|
|||||||
elif method=='GET' and route=='/treinamento/expressao':
|
elif method=='GET' and route=='/treinamento/expressao':
|
||||||
with self.lock:result=self.expressao
|
with self.lock:result=self.expressao
|
||||||
elif method=='POST':
|
elif method=='POST':
|
||||||
origin=h.headers.get('Origin')
|
d=ler_json(h,4096)
|
||||||
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
|
|
||||||
size=int(h.headers.get('Content-Length','0'))
|
|
||||||
if not 0<size<=4096 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 4096 bytes')
|
|
||||||
d=json.loads(h.rfile.read(size))
|
|
||||||
if not isinstance(d,dict):raise ValueError('Objeto obrigatório')
|
|
||||||
with self.lock,self.db() as db:
|
with self.lock,self.db() as db:
|
||||||
if route=='/treinamento/biblioteca':self.alterar_reacao(d.get('acao'),d.get('id'),d)
|
if route=='/treinamento/biblioteca':self.alterar_reacao(d.get('acao'),d.get('id'),d)
|
||||||
elif route=='/treinamento/parar':self.expressao=dict(id=time.time_ns(),horario=time.time(),receita=dict(limpar=True))
|
elif route=='/treinamento/parar':self.expressao=dict(id=time.time_ns(),horario=time.time(),receita=dict(limpar=True))
|
||||||
|
|||||||
+7
-6
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"versao": "0.7.0",
|
"versao": "0.8.0",
|
||||||
"data": "2026-10-05",
|
"data": "2026-10-05",
|
||||||
"alteracoes": [
|
"alteracoes": [
|
||||||
"Dashboard escuro com sidebar e oito telas",
|
"Painel novo em Svelte: Ao vivo, Conversa, Reações, Movimento e Ajustes",
|
||||||
"Biblioteca de emojis com edição e desfazer persistente",
|
"Estado em tempo real por SSE (/eventos) no painel e no rosto, sem polling",
|
||||||
"Configuração privada e teste de API Gemini",
|
"E-Stop real (/parar-tudo) e braço com homem-morto e interpolação no servidor",
|
||||||
"Estilos e navegação compartilhados"
|
"Token opcional para todas as rotas de controle; app Android envia o token",
|
||||||
|
"Telas antigas, /camera/frame e /app/connect removidos"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
"""Modo vida: o Carinha como um bichinho virtual (estilo Tamagotchi).
|
||||||
|
|
||||||
|
Quatro necessidades (0 a 100, 100 = satisfeito) caem com o tempo e sobem com o que acontece em volta
|
||||||
|
(presença, conversa, carinho). Delas sai o humor, e do humor saem os atos: gestos, cochilos, chamar
|
||||||
|
atenção quando tem gente perto, tocar uma reação da biblioteca e, de vez em quando, desenhar uma
|
||||||
|
figurinha nova com o Gemini. O celular recebe o humor ("humor feliz") e escolhe os gestos soltos dele
|
||||||
|
a partir disso, então o rosto parece ter vida mesmo entre um ato e outro.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from comum import gravar_atomico, ler_json
|
||||||
|
|
||||||
|
NECESSIDADES = ('energia', 'alegria', 'social', 'diversao')
|
||||||
|
|
||||||
|
# Queda por minuto acordado; dormindo, as outras caem bem mais devagar e a energia volta.
|
||||||
|
QUEDA = dict(energia=0.5, alegria=0.0, social=0.8, diversao=1.0)
|
||||||
|
RECUPERA_SONO = 2.5 # energia por minuto dormindo (25 -> 100 em ~30 min)
|
||||||
|
SONO_LENTO = 0.25
|
||||||
|
|
||||||
|
RITMOS = {'calmo': (60, 150), 'normal': (25, 75), 'agitado': (12, 35)} # segundos entre atos
|
||||||
|
# O ritmo também é a sensibilidade: o celular recebe "ritmo x" e ajusta limiares e quantos estímulos
|
||||||
|
# precisa para reagir/acordar. Aqui, quantos disparos do PIR em 2 min acordam o rosto.
|
||||||
|
PIR_ACORDAR = {'calmo': 3, 'normal': 1, 'agitado': 1}
|
||||||
|
|
||||||
|
PADRAO = dict(ligado=True, criar_emojis=True, max_emojis_dia=3, ritmo='normal', noite=True)
|
||||||
|
NOITE = (23, 7) # horário padrão da madrugada (o servidor usa o dos ajustes do celular)
|
||||||
|
PEDIU_ATENCAO = 10 * 60 # de madrugada, só fica acordado se alguém interagiu (carinho, conversa) há pouco
|
||||||
|
INTERVALO_EMOJI = 90 * 60 # no mínimo 90 min entre duas figurinhas criadas sozinho
|
||||||
|
|
||||||
|
# O que cada estímulo faz com as necessidades (e o gesto que sai na hora, se houver).
|
||||||
|
ESTIMULOS = {
|
||||||
|
'pir': (dict(social=4, diversao=2), None),
|
||||||
|
'movimento': (dict(diversao=3, social=1), None),
|
||||||
|
'som': (dict(diversao=2), None),
|
||||||
|
'conversa': (dict(social=12, alegria=6, diversao=4), None),
|
||||||
|
'carinho': (dict(social=15, alegria=10), 'coracao'),
|
||||||
|
'cutucar': (dict(social=5, alegria=2, diversao=4), None),
|
||||||
|
'brincar': (dict(diversao=25, alegria=8, energia=-5), 'alegre'),
|
||||||
|
'alimentar': (dict(energia=30, alegria=4), 'feliz'),
|
||||||
|
}
|
||||||
|
CUIDADOS = {'carinho': 'Recebeu carinho', 'cutucar': 'Levou uma cutucada', 'brincar': 'Brincou',
|
||||||
|
'alimentar': 'Ganhou energia', 'ninar': 'Foi posto para dormir', 'acordar': 'Foi acordado'}
|
||||||
|
|
||||||
|
HUMORES = {
|
||||||
|
'dormindo': ('😴', 'Dormindo'), 'cansado': ('🥱', 'Cansado'), 'carente': ('🥺', 'Carente'),
|
||||||
|
'entediado': ('😐', 'Entediado'), 'triste': ('😢', 'Triste'), 'bravo': ('😠', 'Mal-humorado'), 'feliz': ('😄', 'Feliz'),
|
||||||
|
'tranquilo': ('🙂', 'Tranquilo'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gestos de cada humor: (gesto, peso, o que vai no diário)
|
||||||
|
ATOS = {
|
||||||
|
'feliz': [('alegre', 3, 'Saltitou de alegria'), ('musica', 2, 'Cantarolou'), ('feliz', 3, 'Sorriu à toa'),
|
||||||
|
('piscadinha', 2, 'Deu uma piscadinha'), ('coracao', 1, 'Ficou apaixonado pelo dia'),
|
||||||
|
('rir', 2, 'Deu risada sozinho'), ('comemorar', 1, 'Comemorou à toa')],
|
||||||
|
'tranquilo': [('olhar_esq', 2, 'Olhou em volta'), ('olhar_dir', 2, 'Olhou em volta'), ('piscar2', 2, 'Piscou devagar'),
|
||||||
|
('feliz', 1, 'Sorriu'), ('pensar', 1, 'Ficou pensativo'), ('timido', 1, 'Ficou tímido')],
|
||||||
|
'entediado': [('pensar', 3, 'Ficou pensando na vida'), ('olhar_esq', 2, 'Procurou algo para fazer'),
|
||||||
|
('olhar_dir', 2, 'Procurou algo para fazer'), ('espiar', 2, 'Espiou por trás das mãos'),
|
||||||
|
('musica', 1, 'Cantou para passar o tempo'), ('bocejar', 2, 'Bocejou de tédio'),
|
||||||
|
('confuso', 1, 'Ficou confuso com alguma coisa')],
|
||||||
|
'carente': [('triste', 2, 'Sentiu falta de alguém'), ('olhar_baixo', 2, 'Ficou cabisbaixo'),
|
||||||
|
('pensar', 1, 'Ficou pensando em alguém')],
|
||||||
|
'carente_perto': [('acenar', 3, 'Acenou chamando atenção'), ('espiar', 2, 'Espiou quem está aí'),
|
||||||
|
('coracao', 2, 'Pediu carinho'), ('surpreso', 1, 'Fez cara de "oi!"'), ('timido', 1, 'Ficou tímido'),
|
||||||
|
('esconder', 1, 'Escondeu o rosto, envergonhado')],
|
||||||
|
'triste': [('triste', 3, 'Ficou tristinho'), ('olhar_baixo', 2, 'Olhou para baixo'), ('pensar', 1, 'Ficou pensativo')],
|
||||||
|
'bravo': [('bravo', 3, 'Ficou emburrado'), ('olhar_baixo', 2, 'Bufou de mau humor'), ('confuso', 1, 'Resmungou sozinho')],
|
||||||
|
'cansado': [('bocejar', 3, 'Bocejou'), ('piscar2', 1, 'Piscou devagar'), ('olhar_baixo', 2, 'Cochilou de olho aberto'),
|
||||||
|
('tonto', 1, 'Ficou meio tonto de sono')],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Como no The Sims, cada necessidade baixa puxa um comportamento:
|
||||||
|
# energia < COCHILO: às vezes tira um cochilo (cena da cama); < SONO: dorme de verdade e o sono é
|
||||||
|
# respeitado (som, movimento e PIR não acordam) até DESCANSADO; alegria caindo: o rosto vai ficando
|
||||||
|
# bravo (zanga); diversão < TEDIO: vai fazer uma atividade por alguns minutos.
|
||||||
|
COCHILO, SONO, DESCANSADO, TEDIO = 40, 20, 95, 45
|
||||||
|
SOZINHO = 3 * 60 # s sem ninguém por perto: ele vive a vida dele (mais atividades, nada de gesto "para alguém")
|
||||||
|
PARA_ALGUEM = {'acenar', 'coracao', 'piscadinha', 'timido', 'esconder', 'espiar', 'surpreso'}
|
||||||
|
ATIVAS = ['bola', 'malhar', 'corda', 'skate', 'dancar', 'pipa'] # gastam energia
|
||||||
|
CALMAS = ['ler', 'cafe', 'meditar', 'telescopio', 'pescar', 'computador', 'regar'] # quase não gastam
|
||||||
|
CRIATIVAS = ['pintar', 'violao', 'cantar', 'cozinhar', 'bolhas', 'videogame']
|
||||||
|
NOME_ATIVIDADE = {
|
||||||
|
'ler': 'Foi ler um livro', 'cantar': 'Foi cantar', 'bola': 'Foi jogar bola', 'videogame': 'Foi jogar videogame',
|
||||||
|
'pintar': 'Foi pintar um quadro', 'malhar': 'Foi malhar', 'dormir': 'Tirou um cochilo', 'cafe': 'Foi tomar um café',
|
||||||
|
'cozinhar': 'Foi cozinhar', 'regar': 'Foi regar a plantinha', 'violao': 'Foi tocar violão', 'dancar': 'Foi dançar',
|
||||||
|
'corda': 'Foi pular corda', 'meditar': 'Foi meditar', 'telescopio': 'Foi olhar as estrelas', 'pipa': 'Foi soltar pipa',
|
||||||
|
'bolhas': 'Foi fazer bolhas de sabão', 'skate': 'Foi andar de skate', 'computador': 'Foi mexer no computador',
|
||||||
|
'pescar': 'Foi pescar',
|
||||||
|
}
|
||||||
|
# Efeito por minuto enquanto faz a atividade
|
||||||
|
POR_MINUTO_DIVERSAO, POR_MINUTO_ALEGRIA = 6.0, 1.5
|
||||||
|
ENERGIA_POR_MINUTO = {**{a: -2.0 for a in ATIVAS}, **{a: -0.2 for a in CALMAS}, 'cafe': 3.0} # café dá pique
|
||||||
|
|
||||||
|
# Assuntos das figurinhas que ele inventa sozinho, por humor e por hora do dia.
|
||||||
|
TEMAS = {
|
||||||
|
'feliz': ['um sol sorridente', 'um balão colorido', 'um arco-íris', 'um sorvete de casquinha', 'confete de festa'],
|
||||||
|
'tranquilo': ['uma folha de árvore', 'uma flor', 'um gatinho dormindo', 'uma xícara de chá', 'um vaso com planta'],
|
||||||
|
'entediado': ['um foguete', 'um planeta com anel', 'uma pipa', 'um dado', 'um controle de videogame', 'um pião'],
|
||||||
|
'carente': ['um coração com curativo', 'um ursinho de pelúcia', 'uma carta de amor', 'dois corações juntos'],
|
||||||
|
'triste': ['uma nuvem de chuva', 'um guarda-chuva', 'uma gota d\'água'],
|
||||||
|
'bravo': ['uma nuvem de tempestade', 'um vulcão', 'um raio', 'uma pimenta'],
|
||||||
|
'cansado': ['uma lua com touca de dormir', 'uma xícara de café', 'um travesseiro', 'uma bateria fraca'],
|
||||||
|
}
|
||||||
|
PERIODOS = [(5, 12, 'de manhã', ['um sol nascendo', 'um pãozinho', 'uma xícara de café']),
|
||||||
|
(12, 18, 'à tarde', ['um picolé', 'um avião de papel', 'uma bola']),
|
||||||
|
(18, 24, 'à noite', ['uma lua', 'estrelas', 'uma coruja']),
|
||||||
|
(0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])]
|
||||||
|
|
||||||
|
MAX_DIARIO = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _limitar(v):
|
||||||
|
return max(0.0, min(100.0, v))
|
||||||
|
|
||||||
|
|
||||||
|
class Vida:
|
||||||
|
def __init__(self, caminho, iniciar=True, rng=None):
|
||||||
|
self.caminho = Path(caminho)
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.rng = rng or random.Random()
|
||||||
|
self.publicar = lambda tipo, dados: None
|
||||||
|
self.enfileirar = lambda ordem: None
|
||||||
|
# Ligações com o resto do servidor (o main preenche; nos testes ficam neutras).
|
||||||
|
self.treinamento = None
|
||||||
|
self.celular_dormindo = lambda: None # True/False com o celular online, None sem ele
|
||||||
|
self.celular_online = lambda: False
|
||||||
|
self.alguem_perto = lambda: False # PIR/movimento recente
|
||||||
|
self.celular_atividade = lambda: None # atividade que o celular está fazendo agora
|
||||||
|
self.sono_profundo = False # dormindo de cansaço: sensores não acordam
|
||||||
|
self.dormiu_em = 0.0
|
||||||
|
self.atividade_em = 0.0 # quando começou a última atividade
|
||||||
|
self.ultima_atividade = None
|
||||||
|
self.zanga_enviada = None
|
||||||
|
self.visto_em = time.time() # última vez que alguém esteve por perto
|
||||||
|
self.bloqueado = lambda: False # E-Stop ou modo presença com ninguém em casa
|
||||||
|
self.pode_gemini = lambda: bool(os.environ.get('GEMINI_API_KEY'))
|
||||||
|
self.madrugada = self.noite_padrao # o main troca pelo horário dos ajustes
|
||||||
|
self.interagiu_em = 0.0
|
||||||
|
self.pir_eventos = []
|
||||||
|
|
||||||
|
agora = time.time()
|
||||||
|
self.config = dict(PADRAO)
|
||||||
|
self.necessidades = dict(energia=80.0, alegria=70.0, social=60.0, diversao=60.0)
|
||||||
|
self.nascimento = agora
|
||||||
|
self.diario = []
|
||||||
|
self.emojis = dict(dia='', total=0, ultimo=0.0)
|
||||||
|
self.dormindo_proprio = False
|
||||||
|
try:
|
||||||
|
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
|
||||||
|
self.config.update(self.validar(salvo.get('config', {})))
|
||||||
|
for k in NECESSIDADES:
|
||||||
|
if isinstance(salvo.get('necessidades', {}).get(k), (int, float)):
|
||||||
|
self.necessidades[k] = _limitar(float(salvo['necessidades'][k]))
|
||||||
|
self.nascimento = float(salvo.get('nascimento', agora))
|
||||||
|
self.diario = [d for d in salvo.get('diario', []) if isinstance(d, dict)][:MAX_DIARIO]
|
||||||
|
if isinstance(salvo.get('emojis'), dict):
|
||||||
|
self.emojis.update({k: salvo['emojis'][k] for k in ('dia', 'total', 'ultimo') if k in salvo['emojis']})
|
||||||
|
except (OSError, ValueError, TypeError, AttributeError):
|
||||||
|
pass
|
||||||
|
self.ultimo_tick = agora
|
||||||
|
self.proximo_ato = agora + 10
|
||||||
|
self.humor_enviado = (None, 0.0)
|
||||||
|
self.publicado_em = 0.0
|
||||||
|
self.salvo_em = agora
|
||||||
|
if iniciar:
|
||||||
|
threading.Thread(target=self.loop, daemon=True).start()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ config
|
||||||
|
@staticmethod
|
||||||
|
def validar(dados):
|
||||||
|
if not isinstance(dados, dict):
|
||||||
|
raise ValueError('Esperava um objeto')
|
||||||
|
limpo = {}
|
||||||
|
for chave, valor in dados.items():
|
||||||
|
if chave not in PADRAO:
|
||||||
|
raise ValueError('Ajuste desconhecido: ' + chave)
|
||||||
|
if chave == 'ritmo':
|
||||||
|
if valor not in RITMOS:
|
||||||
|
raise ValueError('Ritmo: calmo, normal ou agitado')
|
||||||
|
elif chave == 'max_emojis_dia':
|
||||||
|
if isinstance(valor, bool) or not isinstance(valor, int) or not 0 <= valor <= 10:
|
||||||
|
raise ValueError('Figurinhas por dia: de 0 a 10')
|
||||||
|
elif not isinstance(valor, bool):
|
||||||
|
raise ValueError(chave + ' precisa ser verdadeiro ou falso')
|
||||||
|
limpo[chave] = valor
|
||||||
|
return limpo
|
||||||
|
|
||||||
|
def configurar(self, dados):
|
||||||
|
limpo = self.validar(dados)
|
||||||
|
with self.lock:
|
||||||
|
antes = self.config['ligado']
|
||||||
|
self.config.update(limpo)
|
||||||
|
if 'ligado' in limpo and limpo['ligado'] != antes:
|
||||||
|
self.anotar('vida', 'Modo autônomo ligado' if limpo['ligado'] else 'Modo autônomo desligado')
|
||||||
|
if not limpo['ligado']:
|
||||||
|
self.enfileirar('humor normal') # o celular volta aos gestos soltos de sempre
|
||||||
|
self.enfileirar('ritmo normal') # e à sensibilidade dos Ajustes
|
||||||
|
self.enfileirar('zanga 0.0')
|
||||||
|
if self.sono_profundo:
|
||||||
|
self.sono_profundo = False
|
||||||
|
self.enfileirar('sono leve')
|
||||||
|
self.proximo_ato = time.time() + 5
|
||||||
|
if self.config['ligado'] and 'ritmo' in limpo:
|
||||||
|
self.enfileirar('ritmo ' + self.config['ritmo'])
|
||||||
|
self.humor_enviado = (None, 0.0)
|
||||||
|
self.salvar()
|
||||||
|
self.avisar()
|
||||||
|
return self.status()
|
||||||
|
|
||||||
|
def salvar(self):
|
||||||
|
with self.lock:
|
||||||
|
dados = dict(config=self.config, necessidades=self.necessidades, nascimento=self.nascimento,
|
||||||
|
diario=self.diario, emojis=self.emojis)
|
||||||
|
self.salvo_em = time.time()
|
||||||
|
try:
|
||||||
|
gravar_atomico(self.caminho, dados)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ estado
|
||||||
|
def dormindo(self):
|
||||||
|
cel = self.celular_dormindo()
|
||||||
|
return self.dormindo_proprio if cel is None else bool(cel)
|
||||||
|
|
||||||
|
def humor(self):
|
||||||
|
if self.dormindo():
|
||||||
|
return 'dormindo'
|
||||||
|
n = self.necessidades
|
||||||
|
if n['energia'] < 25:
|
||||||
|
return 'cansado'
|
||||||
|
if n['social'] < 25:
|
||||||
|
return 'carente'
|
||||||
|
if n['diversao'] < 25:
|
||||||
|
return 'entediado'
|
||||||
|
if n['alegria'] < 30:
|
||||||
|
return 'bravo'
|
||||||
|
if n['alegria'] >= 65 and n['social'] >= 40 and n['diversao'] >= 40:
|
||||||
|
return 'feliz'
|
||||||
|
return 'tranquilo'
|
||||||
|
|
||||||
|
def zanga(self):
|
||||||
|
"""0 a 1: o quanto o rosto está emburrado. Começa abaixo de 60 de alegria e piora devagar."""
|
||||||
|
if self.dormindo():
|
||||||
|
return 0.0
|
||||||
|
return round(max(0.0, min(1.0, (60 - self.necessidades['alegria']) / 45)), 1)
|
||||||
|
|
||||||
|
def anotar(self, tipo, texto, agora=None):
|
||||||
|
with self.lock:
|
||||||
|
self.diario.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto))
|
||||||
|
del self.diario[MAX_DIARIO:]
|
||||||
|
|
||||||
|
def emojis_hoje(self, agora=None):
|
||||||
|
dia = time.strftime('%Y-%m-%d', time.localtime(agora or time.time()))
|
||||||
|
return self.emojis['total'] if self.emojis.get('dia') == dia else 0
|
||||||
|
|
||||||
|
def status(self, agora=None):
|
||||||
|
agora = agora or time.time()
|
||||||
|
h = self.humor()
|
||||||
|
with self.lock:
|
||||||
|
return dict(config=dict(self.config), necessidades={k: round(v) for k, v in self.necessidades.items()},
|
||||||
|
humor=h, emoji=HUMORES[h][0], nome_humor=HUMORES[h][1], dormindo=self.dormindo(),
|
||||||
|
nascimento=self.nascimento, idade_dias=int((agora - self.nascimento) // 86400),
|
||||||
|
emojis_hoje=self.emojis_hoje(agora), ultimo_emoji=self.emojis.get('ultimo') or None,
|
||||||
|
zanga=self.zanga(), sono_profundo=self.sono_profundo,
|
||||||
|
pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30])
|
||||||
|
|
||||||
|
def avisar(self):
|
||||||
|
self.publicado_em = time.time()
|
||||||
|
self.publicar('vida', self.status())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ tempo passando
|
||||||
|
def passar_tempo(self, agora):
|
||||||
|
minutos = max(0.0, min(10.0, (agora - self.ultimo_tick) / 60)) # pausa longa (placa desligada) não zera tudo
|
||||||
|
self.ultimo_tick = agora
|
||||||
|
atividade = self.celular_atividade()
|
||||||
|
dormindo = self.dormindo() or atividade == 'dormir'
|
||||||
|
with self.lock:
|
||||||
|
n = self.necessidades
|
||||||
|
for k, q in QUEDA.items():
|
||||||
|
n[k] = _limitar(n[k] - q * minutos * (SONO_LENTO if dormindo else 1))
|
||||||
|
if dormindo:
|
||||||
|
n['energia'] = _limitar(n['energia'] + RECUPERA_SONO * minutos)
|
||||||
|
elif atividade:
|
||||||
|
n['diversao'] = _limitar(n['diversao'] + (QUEDA['diversao'] + POR_MINUTO_DIVERSAO) * minutos)
|
||||||
|
n['alegria'] = _limitar(n['alegria'] + POR_MINUTO_ALEGRIA * minutos)
|
||||||
|
n['energia'] = _limitar(n['energia'] + ENERGIA_POR_MINUTO.get(atividade, -0.5) * minutos)
|
||||||
|
# alegria acompanha devagar a média de companhia e diversão
|
||||||
|
alvo = (n['social'] + n['diversao']) / 2 + 10
|
||||||
|
n['alegria'] = _limitar(n['alegria'] + (alvo - n['alegria']) * min(1.0, 0.05 * minutos))
|
||||||
|
|
||||||
|
def estimulo(self, tipo, agora=None):
|
||||||
|
"""Algo aconteceu em volta (sensor, conversa, toque, botão do painel)."""
|
||||||
|
if tipo not in ESTIMULOS and tipo not in ('ninar', 'acordar'):
|
||||||
|
raise ValueError('Cuidado desconhecido')
|
||||||
|
agora = agora or time.time()
|
||||||
|
if not self.config['ligado']:
|
||||||
|
return self.status(agora)
|
||||||
|
gesto = None
|
||||||
|
if tipo == 'ninar':
|
||||||
|
self.dormir(agora, 'Foi posto para dormir')
|
||||||
|
elif tipo == 'acordar':
|
||||||
|
self.acordar(agora, 'Foi acordado')
|
||||||
|
else:
|
||||||
|
efeito, gesto = ESTIMULOS[tipo]
|
||||||
|
with self.lock:
|
||||||
|
for k, v in efeito.items():
|
||||||
|
self.necessidades[k] = _limitar(self.necessidades[k] + v)
|
||||||
|
if tipo in CUIDADOS:
|
||||||
|
self.anotar('cuidado', CUIDADOS[tipo], agora)
|
||||||
|
if tipo in CUIDADOS or tipo == 'conversa':
|
||||||
|
self.interagiu_em = agora # alguém quis ele acordado (sensor não conta)
|
||||||
|
if gesto and not self.bloqueado():
|
||||||
|
if self.dormindo():
|
||||||
|
self.acordar(agora, None)
|
||||||
|
self.enfileirar(gesto)
|
||||||
|
if tipo in CUIDADOS:
|
||||||
|
self.avisar()
|
||||||
|
return self.status(agora)
|
||||||
|
|
||||||
|
def dormir(self, agora, motivo, profundo=False):
|
||||||
|
self.dormindo_proprio = True
|
||||||
|
self.dormiu_em = agora
|
||||||
|
self.enfileirar('dormir')
|
||||||
|
if profundo: # cansado de verdade: sono respeitado até descansar
|
||||||
|
self.sono_profundo = True
|
||||||
|
self.enfileirar('sono profundo')
|
||||||
|
if motivo:
|
||||||
|
self.anotar('sono', motivo, agora)
|
||||||
|
|
||||||
|
def acordar(self, agora, motivo):
|
||||||
|
self.dormindo_proprio = False
|
||||||
|
if self.sono_profundo:
|
||||||
|
self.sono_profundo = False
|
||||||
|
self.enfileirar('sono leve')
|
||||||
|
self.enfileirar('acordar')
|
||||||
|
if motivo:
|
||||||
|
self.anotar('sono', motivo, agora)
|
||||||
|
|
||||||
|
def escolher_atividade(self):
|
||||||
|
"""Pelo pique: cansado prefere coisas calmas (e às vezes um café); cheio de energia, coisas agitadas."""
|
||||||
|
e = self.necessidades['energia']
|
||||||
|
if 35 <= e < 55 and self.rng.random() < 0.35:
|
||||||
|
return 'cafe'
|
||||||
|
if e < 50:
|
||||||
|
lista = CALMAS * 2 + CRIATIVAS
|
||||||
|
elif e > 70:
|
||||||
|
lista = ATIVAS * 2 + CRIATIVAS + CALMAS
|
||||||
|
else:
|
||||||
|
lista = ATIVAS + CRIATIVAS * 2 + CALMAS
|
||||||
|
opcoes = [a for a in lista if a != self.ultima_atividade] or lista
|
||||||
|
return self.rng.choice(opcoes)
|
||||||
|
|
||||||
|
def comecar_atividade(self, agora, nome, segundos):
|
||||||
|
self.enfileirar('atividade %s %d' % (nome, segundos))
|
||||||
|
self.atividade_em = agora
|
||||||
|
self.ultima_atividade = nome
|
||||||
|
self.anotar('atividade', NOME_ATIVIDADE.get(nome, 'Foi fazer algo'), agora)
|
||||||
|
return 'atividade ' + nome
|
||||||
|
|
||||||
|
def pir_acorda(self, agora=None):
|
||||||
|
"""Disparo do PIR com o rosto dormindo: acorda? No ritmo calmo precisa de vários em 2 min."""
|
||||||
|
agora = agora or time.time()
|
||||||
|
self.pir_eventos = [t for t in self.pir_eventos if agora - t < 120] + [agora]
|
||||||
|
precisa = PIR_ACORDAR[self.config['ritmo']] if self.config['ligado'] else 1
|
||||||
|
if len(self.pir_eventos) >= precisa:
|
||||||
|
self.pir_eventos = []
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def noite_padrao(agora):
|
||||||
|
hora = time.localtime(agora).tm_hour
|
||||||
|
ini, fim = NOITE
|
||||||
|
return hora >= ini or hora < fim
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ decidir o que fazer
|
||||||
|
def escolher(self, opcoes):
|
||||||
|
total = sum(p for _, p, _ in opcoes)
|
||||||
|
x = self.rng.uniform(0, total)
|
||||||
|
for gesto, peso, texto in opcoes:
|
||||||
|
x -= peso
|
||||||
|
if x <= 0:
|
||||||
|
return gesto, texto
|
||||||
|
return opcoes[-1][0], opcoes[-1][2]
|
||||||
|
|
||||||
|
def motivo_sem_emoji(self, agora):
|
||||||
|
"""'' quando pode criar uma figurinha agora; senão, o porquê (aparece no painel)."""
|
||||||
|
if not self.config['criar_emojis']:
|
||||||
|
return 'desligado'
|
||||||
|
if self.treinamento is None:
|
||||||
|
return 'biblioteca indisponível'
|
||||||
|
if not self.pode_gemini():
|
||||||
|
return 'sem chave Gemini'
|
||||||
|
if self.emojis_hoje(agora) >= self.config['max_emojis_dia']:
|
||||||
|
return 'limite do dia'
|
||||||
|
if agora - float(self.emojis.get('ultimo') or 0) < INTERVALO_EMOJI:
|
||||||
|
return 'descansando a criatividade'
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def criar_figurinha(self, agora, humor):
|
||||||
|
h = time.localtime(agora).tm_hour
|
||||||
|
periodo, extras = next((p, e) for ini, fim, p, e in PERIODOS if ini <= h < fim)
|
||||||
|
tema = self.rng.choice(TEMAS.get(humor, TEMAS['tranquilo']) + extras)
|
||||||
|
assunto = (f'Ideia que o próprio Carinha teve sozinho, {periodo}, se sentindo {HUMORES[humor][1].lower()}: '
|
||||||
|
f'{tema}. Figurinha fofa e simples.')
|
||||||
|
try:
|
||||||
|
self.treinamento.gerar_figurinha(assunto)
|
||||||
|
except ValueError as e:
|
||||||
|
self.anotar('emoji', 'Quis desenhar ' + tema + ', mas não deu: ' + str(e)[:80], agora)
|
||||||
|
return False
|
||||||
|
dia = time.strftime('%Y-%m-%d', time.localtime(agora))
|
||||||
|
with self.lock:
|
||||||
|
total = self.emojis['total'] + 1 if self.emojis.get('dia') == dia else 1
|
||||||
|
self.emojis.update(dia=dia, total=total, ultimo=agora)
|
||||||
|
self.necessidades['diversao'] = _limitar(self.necessidades['diversao'] + 30)
|
||||||
|
self.necessidades['alegria'] = _limitar(self.necessidades['alegria'] + 10)
|
||||||
|
self.anotar('emoji', 'Teve uma ideia e desenhou ' + tema, agora)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def tocar_reacao(self, agora):
|
||||||
|
try:
|
||||||
|
reacoes = [r for r in self.treinamento.status()['reacoes'] if r.get('ativa', True) and r['feedback'] >= 0]
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
if not reacoes:
|
||||||
|
return False
|
||||||
|
r = self.rng.choice(reacoes[:40])
|
||||||
|
try:
|
||||||
|
self.treinamento.testar(r['id'])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
self.anotar('acao', 'Lembrou da reação "' + r['receita'].get('nome', '?') + '"', agora)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def agir(self, agora):
|
||||||
|
"""Um ato: o que um bichinho com essas necessidades faria agora."""
|
||||||
|
n = self.necessidades
|
||||||
|
perto = self.alguem_perto()
|
||||||
|
if self.dormindo():
|
||||||
|
# de madrugada nunca acorda sozinho (nem com gente se mexendo na cama); de dia, só descansado
|
||||||
|
if n['energia'] >= DESCANSADO and not self.madrugada(agora):
|
||||||
|
self.acordar(agora, 'Acordou descansado')
|
||||||
|
return 'acordar'
|
||||||
|
return None
|
||||||
|
if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO:
|
||||||
|
self.dormir(agora, 'Ficou tarde, foi dormir')
|
||||||
|
return 'dormir'
|
||||||
|
if n['energia'] < SONO:
|
||||||
|
self.dormir(agora, 'Ficou exausto e foi dormir', profundo=True)
|
||||||
|
return 'dormir'
|
||||||
|
humor = self.humor()
|
||||||
|
if self.celular_atividade():
|
||||||
|
return None # ocupado com uma atividade: deixa terminar
|
||||||
|
dia = self.celular_online() and not self.madrugada(agora)
|
||||||
|
folga = agora - self.atividade_em > 90 # um respiro entre uma atividade e outra
|
||||||
|
# cansado: um cochilo na caminha de vez em quando (recupera energia sem o sono pesado)
|
||||||
|
if dia and folga and n['energia'] < COCHILO and self.rng.random() < 0.35:
|
||||||
|
return self.comecar_atividade(agora, 'dormir', self.rng.uniform(120, 240))
|
||||||
|
# entediado: vai fazer alguma coisa por alguns minutos (pode ser interrompido)
|
||||||
|
if dia and folga and n['diversao'] < TEDIO and self.rng.random() < 0.75:
|
||||||
|
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(120, 300))
|
||||||
|
# ninguém olhando há um tempo: vai cuidar da vida dele (ler, pintar, música...)
|
||||||
|
if perto:
|
||||||
|
self.visto_em = agora
|
||||||
|
sozinho = agora - self.visto_em > SOZINHO
|
||||||
|
if dia and folga and sozinho and self.rng.random() < 0.4:
|
||||||
|
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(60, 180))
|
||||||
|
# bem: de vez em quando, uma atividade curtinha por vontade própria
|
||||||
|
if dia and folga and humor in ('feliz', 'tranquilo') and self.rng.random() < 0.1:
|
||||||
|
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(40, 90))
|
||||||
|
# habilidade: inventar uma figurinha nova (raro, com limite por dia e intervalo)
|
||||||
|
if not self.motivo_sem_emoji(agora) and self.celular_online() and self.rng.random() < 0.08:
|
||||||
|
if self.criar_figurinha(agora, humor):
|
||||||
|
return 'figurinha'
|
||||||
|
# às vezes lembra de uma reação da biblioteca
|
||||||
|
if self.treinamento is not None and humor in ('feliz', 'tranquilo', 'entediado') and self.rng.random() < 0.15:
|
||||||
|
if self.tocar_reacao(agora):
|
||||||
|
with self.lock:
|
||||||
|
n['diversao'] = _limitar(n['diversao'] + 8)
|
||||||
|
return 'reacao'
|
||||||
|
lista = ATOS['carente_perto'] if humor == 'carente' and perto else ATOS.get(humor, ATOS['tranquilo'])
|
||||||
|
if sozinho: # sem plateia: nada de acenar ou ficar tímido
|
||||||
|
lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3]
|
||||||
|
gesto, texto = self.escolher(lista)
|
||||||
|
self.enfileirar(gesto)
|
||||||
|
if humor == 'entediado':
|
||||||
|
with self.lock:
|
||||||
|
n['diversao'] = _limitar(n['diversao'] + 3) # fazer algo distrai um pouquinho
|
||||||
|
self.anotar('acao', texto, agora)
|
||||||
|
return gesto
|
||||||
|
|
||||||
|
def tick(self, agora=None):
|
||||||
|
agora = agora or time.time()
|
||||||
|
self.passar_tempo(agora)
|
||||||
|
if not self.config['ligado']:
|
||||||
|
return None
|
||||||
|
ato = None
|
||||||
|
humor = self.humor()
|
||||||
|
# acordaram ele de propósito (toque, painel) antes de descansar: o sono pesado acaba aqui
|
||||||
|
if self.sono_profundo and not self.dormindo() and agora - self.dormiu_em > 15:
|
||||||
|
self.sono_profundo = False
|
||||||
|
self.enfileirar('sono leve')
|
||||||
|
self.anotar('sono', 'Acordou antes de descansar', agora)
|
||||||
|
if not self.bloqueado():
|
||||||
|
# o celular escolhe os gestos soltos pelo humor; reenvia de tempos em tempos (pode ter reiniciado)
|
||||||
|
zanga = self.zanga()
|
||||||
|
if humor != self.humor_enviado[0] or agora - self.humor_enviado[1] > 60 or zanga != self.zanga_enviada:
|
||||||
|
self.enfileirar('humor ' + humor)
|
||||||
|
self.enfileirar('ritmo ' + self.config['ritmo'])
|
||||||
|
self.enfileirar('zanga %.1f' % zanga)
|
||||||
|
if self.sono_profundo:
|
||||||
|
self.enfileirar('sono profundo')
|
||||||
|
self.humor_enviado = (humor, agora)
|
||||||
|
self.zanga_enviada = zanga
|
||||||
|
if agora >= self.proximo_ato:
|
||||||
|
ato = self.agir(agora)
|
||||||
|
lo, hi = RITMOS[self.config['ritmo']]
|
||||||
|
self.proximo_ato = agora + self.rng.uniform(lo, hi)
|
||||||
|
if ato or agora - self.publicado_em > 30 or humor != self.humor():
|
||||||
|
self.avisar()
|
||||||
|
if agora - self.salvo_em > 60:
|
||||||
|
self.salvar()
|
||||||
|
return ato
|
||||||
|
|
||||||
|
def loop(self):
|
||||||
|
while True:
|
||||||
|
time.sleep(5)
|
||||||
|
try:
|
||||||
|
self.tick()
|
||||||
|
except Exception as e: # nunca derruba a thread
|
||||||
|
print('vida: erro no tick:', e, flush=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ HTTP
|
||||||
|
def rotas(self, h, metodo):
|
||||||
|
rota = h.path.split('?', 1)[0]
|
||||||
|
if not rota.startswith('/vida/'):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if metodo == 'GET' and rota == '/vida/status':
|
||||||
|
resultado = self.status()
|
||||||
|
elif metodo == 'POST' and rota == '/vida/config':
|
||||||
|
resultado = self.configurar(ler_json(h, 2048))
|
||||||
|
elif metodo == 'POST' and rota == '/vida/cuidar':
|
||||||
|
resultado = self.estimulo(ler_json(h, 2048).get('acao'))
|
||||||
|
else:
|
||||||
|
raise ValueError('Rota inválida')
|
||||||
|
h.enviar(json.dumps(resultado, ensure_ascii=False))
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
h.enviar(json.dumps(dict(erro=str(e)), ensure_ascii=False), codigo=400)
|
||||||
|
return True
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
/* voz-robo.js — voz sintetizada do Carinha, no estilo dos robozinhos de cinema.
|
||||||
|
*
|
||||||
|
* Tudo é gerado na hora com Web Audio: nenhuma gravação, nenhum arquivo de terceiros.
|
||||||
|
* Cada som é uma sequência de "sílabas": um oscilador que desliza de um tom a outro, com vibrato,
|
||||||
|
* passando por um filtro de formante (a "vogal": o→i faz o "uou"). Motores das mãos viram ruído
|
||||||
|
* filtrado, e a resposta da conversa vira um balbucio que sobe no fim quando é pergunta.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let ruido = null; // buffer de ruído reaproveitado
|
||||||
|
const TOM = 1.25; // todas as sílabas 25% acima do tom escrito
|
||||||
|
const saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Cadeia de "brilho metálico" (criada uma vez por AudioContext):
|
||||||
|
* voz → ressonância de lata fina (agudo, ~850 Hz) → saturação leve → compressor → saída
|
||||||
|
* Nada grave: zumbido e ressonância graves deixavam a voz com cara de sapo.
|
||||||
|
*/
|
||||||
|
function saida(c) {
|
||||||
|
if (saidas.has(c)) return saidas.get(c);
|
||||||
|
const entrada = c.createGain();
|
||||||
|
const seca = c.createGain(); seca.gain.value = 0.8;
|
||||||
|
const atraso = c.createDelay(0.05); atraso.delayTime.value = 0.00118;
|
||||||
|
const volta = c.createGain(); volta.gain.value = 0.42;
|
||||||
|
const molhada = c.createGain(); molhada.gain.value = 0.35;
|
||||||
|
entrada.connect(seca);
|
||||||
|
entrada.connect(atraso); atraso.connect(volta); volta.connect(atraso); atraso.connect(molhada);
|
||||||
|
const satura = c.createWaveShaper();
|
||||||
|
const n = 2048, curva = new Float32Array(n);
|
||||||
|
for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; curva[i] = Math.tanh(x * 1.3); }
|
||||||
|
satura.curve = curva;
|
||||||
|
seca.connect(satura); molhada.connect(satura);
|
||||||
|
const brilho = c.createBiquadFilter(); // realce leve nos agudos: "metal" limpo
|
||||||
|
brilho.type = 'peaking'; brilho.frequency.value = 3200; brilho.Q.value = 1.2; brilho.gain.value = 4;
|
||||||
|
const comp = c.createDynamicsCompressor();
|
||||||
|
comp.threshold.value = -16; comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.15;
|
||||||
|
const passaBaixa = c.createBiquadFilter();
|
||||||
|
passaBaixa.type = 'lowpass'; passaBaixa.frequency.value = 8000;
|
||||||
|
const volume = c.createGain(); volume.gain.value = 0.8;
|
||||||
|
satura.connect(brilho); brilho.connect(comp); comp.connect(passaBaixa); passaBaixa.connect(volume); volume.connect(c.destination);
|
||||||
|
saidas.set(c, entrada);
|
||||||
|
return entrada;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uma sílaba. de/para: tom em Hz; vogal: [formante inicial, final] em Hz;
|
||||||
|
* vib: [Hz, profundidade em Hz]; forma: tipo de onda.
|
||||||
|
*/
|
||||||
|
function silaba(c, t0, dur, de, para, vol, op) {
|
||||||
|
op = op || {};
|
||||||
|
vol *= 2.4; // compensa a energia que o filtro de formante tira
|
||||||
|
de *= TOM; para *= TOM; // voz de robozinho: aguda
|
||||||
|
const osc = c.createOscillator();
|
||||||
|
osc.type = op.forma || 'sawtooth';
|
||||||
|
osc.frequency.setValueAtTime(de, t0);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(Math.max(20, para), t0 + dur);
|
||||||
|
if (op.vib) {
|
||||||
|
const lfo = c.createOscillator(), prof = c.createGain();
|
||||||
|
lfo.frequency.value = op.vib[0]; prof.gain.value = op.vib[1];
|
||||||
|
lfo.connect(prof); prof.connect(osc.frequency);
|
||||||
|
lfo.start(t0); lfo.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
// FM harmônico e leve: brilho de metal sem aspereza
|
||||||
|
const mod = c.createOscillator(), modProf = c.createGain();
|
||||||
|
mod.type = 'sine';
|
||||||
|
mod.frequency.setValueAtTime(de * 2, t0);
|
||||||
|
mod.frequency.exponentialRampToValueAtTime(Math.max(20, para * 2), t0 + dur);
|
||||||
|
modProf.gain.setValueAtTime(de * (op.metal ?? 0.22), t0);
|
||||||
|
modProf.gain.exponentialRampToValueAtTime(Math.max(1, para * (op.metal ?? 0.22)), t0 + dur);
|
||||||
|
mod.connect(modProf); modProf.connect(osc.frequency);
|
||||||
|
mod.start(t0); mod.stop(t0 + dur + 0.05);
|
||||||
|
// corpo: uma senoide uma oitava abaixo deixa a voz menos "apito"
|
||||||
|
const sub = c.createOscillator();
|
||||||
|
sub.type = 'sine';
|
||||||
|
sub.frequency.setValueAtTime(de / 2, t0);
|
||||||
|
sub.frequency.exponentialRampToValueAtTime(Math.max(20, para / 2), t0 + dur);
|
||||||
|
const vogal = op.vogal || [900, 1500];
|
||||||
|
const filtro = c.createBiquadFilter();
|
||||||
|
filtro.type = 'bandpass'; filtro.Q.value = op.q || 5;
|
||||||
|
filtro.frequency.setValueAtTime(vogal[0], t0);
|
||||||
|
filtro.frequency.exponentialRampToValueAtTime(vogal[1], t0 + dur);
|
||||||
|
const g = c.createGain(), gs = c.createGain();
|
||||||
|
const ataque = Math.min(0.02, dur * 0.25);
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(vol, t0 + ataque);
|
||||||
|
g.gain.setValueAtTime(vol, t0 + dur * 0.7);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
gs.gain.value = 0.12; // pouca oitava de baixo: grave demais vira coaxar
|
||||||
|
osc.connect(filtro); sub.connect(gs); gs.connect(filtro); filtro.connect(g); g.connect(saida(c));
|
||||||
|
osc.start(t0); sub.start(t0);
|
||||||
|
osc.stop(t0 + dur + 0.05); sub.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferRuido(c) {
|
||||||
|
if (ruido && ruido.sampleRate === c.sampleRate) return ruido;
|
||||||
|
ruido = c.createBuffer(1, c.sampleRate, c.sampleRate);
|
||||||
|
const d = ruido.getChannelData(0);
|
||||||
|
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||||
|
return ruido;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Motorzinho: ruído filtrado que sobe de tom, com um tremor mecânico. */
|
||||||
|
function servo(c, t0, dur, vol, sobe) {
|
||||||
|
const fonte = c.createBufferSource();
|
||||||
|
fonte.buffer = bufferRuido(c); fonte.loop = true;
|
||||||
|
const f = c.createBiquadFilter();
|
||||||
|
f.type = 'bandpass'; f.Q.value = 9;
|
||||||
|
f.frequency.setValueAtTime(sobe === false ? 1500 : 500, t0);
|
||||||
|
f.frequency.exponentialRampToValueAtTime(sobe === false ? 500 : 1500, t0 + dur);
|
||||||
|
const g = c.createGain(), trem = c.createOscillator(), tg = c.createGain();
|
||||||
|
trem.frequency.value = 38; tg.gain.value = vol * 0.35;
|
||||||
|
trem.connect(tg); tg.connect(g.gain);
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(vol * 0.6, t0 + 0.05);
|
||||||
|
g.gain.setValueAtTime(vol * 0.6, t0 + dur - 0.06);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||||
|
fonte.start(t0); trem.start(t0);
|
||||||
|
fonte.stop(t0 + dur + 0.05); trem.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clique(c, t0, vol) {
|
||||||
|
const fonte = c.createBufferSource();
|
||||||
|
fonte.buffer = bufferRuido(c);
|
||||||
|
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 3000;
|
||||||
|
const g = c.createGain();
|
||||||
|
g.gain.setValueAtTime(vol * 0.5, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.03);
|
||||||
|
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||||
|
fonte.start(t0); fonte.stop(t0 + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
const O = [500, 900], I = [1800, 2400], U = [350, 700], A = [900, 1300]; // vogais (formantes)
|
||||||
|
|
||||||
|
// Cada som: (c, t, v) → agenda as sílabas. Volumes relativos ao volume do app.
|
||||||
|
const SONS = {
|
||||||
|
feliz: (c, t, v) => { silaba(c, t, 0.12, 620, 900, v, { vogal: [1200, 2000] }); silaba(c, t + 0.13, 0.18, 900, 1250, v, { vogal: I, vib: [9, 18] }); },
|
||||||
|
alegre: (c, t, v) => [560, 700, 860, 1100].forEach((f, i) => silaba(c, t + i * 0.085, 0.09, f, f * 1.25, v * 0.85, { vogal: I, forma: 'square' })),
|
||||||
|
coracao: (c, t, v) => { silaba(c, t, 0.55, 760, 560, v, { vogal: [800, 1300], vib: [7, 28] }); },
|
||||||
|
piscadinha: (c, t, v) => { clique(c, t, v); silaba(c, t + 0.04, 0.08, 1000, 1300, v * 0.6, { forma: 'square', vogal: I }); },
|
||||||
|
surpreso: (c, t, v) => { silaba(c, t, 0.22, 260, 1100, v, { vogal: [450, 2000] }); silaba(c, t + 0.22, 0.12, 1100, 820, v * 0.8, { vogal: I }); },
|
||||||
|
bravo: (c, t, v) => [0, 0.13, 0.26].forEach((d, i) => silaba(c, t + d, 0.1, 480 - i * 30, 380 - i * 30, v * 0.85, { forma: 'square', vogal: [1300, 900] })), // bipes zangados
|
||||||
|
triste: (c, t, v) => { silaba(c, t, 0.65, 640, 380, v * 0.85, { vogal: [1200, 750], vib: [5, 14] }); },
|
||||||
|
dormir: (c, t, v) => { silaba(c, t, 0.35, 460, 760, v * 0.7, { vogal: [900, 1500] }); silaba(c, t + 0.36, 0.75, 760, 340, v * 0.6, { vogal: [1500, 700], vib: [4, 8] }); },
|
||||||
|
acordar: (c, t, v) => { servo(c, t, 0.25, v * 0.5); [300, 450, 700].forEach((f, i) => silaba(c, t + 0.22 + i * 0.1, 0.1, f, f * 1.2, v * 0.8, { vogal: I, forma: 'square' })); silaba(c, t + 0.56, 0.2, 700, 1050, v, { vogal: [1200, 2200], vib: [8, 15] }); },
|
||||||
|
acenar: (c, t, v) => { servo(c, t, 0.3, v * 0.45); silaba(c, t + 0.28, 0.13, 520, 820, v, { vogal: [1000, 1900] }); silaba(c, t + 0.44, 0.2, 760, 1080, v, { vogal: I, vib: [9, 20] }); },
|
||||||
|
esconder: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 0.1, 0.08, 950, 1350, v * 0.8, { vogal: I }); for (let i = 0; i < 5; i++) silaba(c, t + 0.6 + i * 0.09, 0.05, 1200 + (i % 2) * 180, 1300, v * 0.35, { forma: 'square', vogal: I }); servo(c, t + 2.05, 0.4, v * 0.45, false); },
|
||||||
|
espiar: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 1.0, 0.3, 480, 760, v * 0.9, { vogal: [600, 1700] }); silaba(c, t + 2.0, 0.16, 800, 1150, v, { vogal: I }); },
|
||||||
|
pensar: (c, t, v) => { servo(c, t, 0.35, v * 0.4); silaba(c, t + 0.35, 0.5, 520, 640, v * 0.7, { vogal: [900, 1600], vib: [5, 10] }); [0.95, 1.12].forEach((d) => silaba(c, t + d, 0.05, 1500, 1600, v * 0.35, { forma: 'square', vogal: I })); }, // "hmm?" e bipinhos
|
||||||
|
musica: (c, t, v) => [523, 659, 784, 659, 880, 784, 659, 1047].forEach((f, i) => silaba(c, t + i * 0.26, 0.2, f, f * 1.02, v * 0.6, { forma: 'triangle', vogal: A, vib: [6, 5] })),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gerador previsível: a mesma frase sempre "soa" igual.
|
||||||
|
function semente(texto) {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
|
||||||
|
return () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Balbucio de robô para uma resposta: ~1 sílaba a cada 6 letras, no máximo 16. */
|
||||||
|
function balbuciar(c, texto, v) {
|
||||||
|
texto = String(texto || '').trim();
|
||||||
|
if (!texto) return 0;
|
||||||
|
const rnd = semente(texto), n = Math.max(2, Math.min(16, Math.ceil(texto.length / 6)));
|
||||||
|
const pergunta = /\?\s*$/.test(texto), exclama = /!\s*$/.test(texto);
|
||||||
|
const vogais = [O, I, A, U];
|
||||||
|
let t = c.currentTime + 0.03, tom = 520 + rnd() * 180;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const ultima = i === n - 1;
|
||||||
|
const dur = 0.07 + rnd() * 0.1;
|
||||||
|
let para = tom * (0.8 + rnd() * 0.5);
|
||||||
|
if (ultima) para = pergunta ? tom * 1.6 : exclama ? tom * 1.35 : tom * 0.7;
|
||||||
|
silaba(c, t, ultima ? dur + 0.08 : dur, tom, para, v * (0.65 + rnd() * 0.3), { vogal: vogais[Math.floor(rnd() * 4)], vib: rnd() < 0.3 ? [8, 14] : null, forma: rnd() < 0.25 ? 'square' : 'sawtooth' });
|
||||||
|
t += dur + 0.025 + rnd() * 0.05;
|
||||||
|
tom = Math.max(330, Math.min(1000, para * (0.9 + rnd() * 0.25)));
|
||||||
|
}
|
||||||
|
return t - c.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tocar(c, nome, v) {
|
||||||
|
const f = SONS[nome];
|
||||||
|
if (!f || !c) return false;
|
||||||
|
f(c, c.currentTime + 0.02, v);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.VozRobo = { tocar, balbuciar, servo: (c, dur, v) => servo(c, c.currentTime + 0.01, dur, v), sons: Object.keys(SONS) };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0b1017" />
|
||||||
|
<title>Carinha</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230b1017'/%3E%3Crect x='14' y='20' width='12' height='22' rx='6' fill='%235cd6ff'/%3E%3Crect x='38' y='20' width='12' height='22' rx='6' fill='%235cd6ff'/%3E%3C/svg%3E" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<!-- mesmo motor de rosto do celular, usado nas miniaturas das reações -->
|
||||||
|
<script src="/rosto-motor.js"></script>
|
||||||
|
<script src="/voz-robo.js"></script>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1250
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "carinha-painel",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.8.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"test": "node --test \"tests/**/*.test.ts\""
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||||
|
"@tsconfig/svelte": "^5.0.8",
|
||||||
|
"@types/node": "^26.6.4",
|
||||||
|
"svelte": "^5.57.1",
|
||||||
|
"svelte-check": "^4.7.6",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.3.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
|
"@fontsource-variable/outfit": "^5.3.0",
|
||||||
|
"@lucide/svelte": "^1.52.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
||||||
|
import { onMount, type Component } from 'svelte';
|
||||||
|
import Avisos from './lib/componentes/Avisos.svelte';
|
||||||
|
import EStop from './lib/componentes/EStop.svelte';
|
||||||
|
import MiniRosto from './lib/componentes/MiniRosto.svelte';
|
||||||
|
import Paleta from './lib/componentes/Paleta.svelte';
|
||||||
|
import Selo from './lib/componentes/Selo.svelte';
|
||||||
|
import Token from './lib/componentes/Token.svelte';
|
||||||
|
import { favoritos, pararTudo, tocarFavorito } from './lib/comandos.svelte';
|
||||||
|
import { conectar, estado } from './lib/estado.svelte';
|
||||||
|
import { paleta } from './lib/paleta.svelte';
|
||||||
|
import { nav, ROTAS, SECUNDARIAS, tituloDa, type Rota } from './lib/rotas.svelte';
|
||||||
|
import AoVivo from './telas/AoVivo.svelte';
|
||||||
|
import Ajustes from './telas/Ajustes.svelte';
|
||||||
|
import Conversa from './telas/Conversa.svelte';
|
||||||
|
import Movimento from './telas/Movimento.svelte';
|
||||||
|
import Reacoes from './telas/Reacoes.svelte';
|
||||||
|
import Rosto from './telas/Rosto.svelte';
|
||||||
|
import Eventos from './telas/Eventos.svelte';
|
||||||
|
import Guia from './telas/Guia.svelte';
|
||||||
|
import Criar from './telas/Criar.svelte';
|
||||||
|
import Ajuda from './lib/componentes/Ajuda.svelte';
|
||||||
|
import { TOPICO_DA_TELA } from './lib/guia';
|
||||||
|
|
||||||
|
const TELAS: Record<Rota, Component> = {
|
||||||
|
'ao-vivo': AoVivo,
|
||||||
|
eventos: Eventos,
|
||||||
|
conversa: Conversa,
|
||||||
|
reacoes: Reacoes,
|
||||||
|
movimento: Movimento,
|
||||||
|
ajustes: Ajustes,
|
||||||
|
rosto: Rosto,
|
||||||
|
guia: Guia,
|
||||||
|
criar: Criar,
|
||||||
|
};
|
||||||
|
const ICONES: Record<string, Component<any>> = {
|
||||||
|
'ao-vivo': Video,
|
||||||
|
eventos: Bell,
|
||||||
|
conversa: MessageCircle,
|
||||||
|
reacoes: Smile,
|
||||||
|
movimento: Hand,
|
||||||
|
ajustes: Settings,
|
||||||
|
};
|
||||||
|
const Tela = $derived(TELAS[nav.atual]);
|
||||||
|
// Páginas secundárias acendem a seção de onde vieram.
|
||||||
|
const secao = $derived(SECUNDARIAS.find((r) => r.id === nav.atual)?.volta ?? nav.atual);
|
||||||
|
|
||||||
|
// ---------- tema
|
||||||
|
let tema = $state<'dark' | 'light'>('dark');
|
||||||
|
function aplicarTema(t: 'dark' | 'light') {
|
||||||
|
tema = t;
|
||||||
|
document.documentElement.dataset.theme = t;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('carinha_tema', t);
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- atalhos: Ctrl+K paleta · 1–9 gestos favoritos · Shift+Espaço ou Esc Esc = E-Stop · g+letra navega
|
||||||
|
let ultimoEsc = 0;
|
||||||
|
let prefixoG = 0;
|
||||||
|
function editando(e: KeyboardEvent) {
|
||||||
|
const alvo = e.target as HTMLElement;
|
||||||
|
return alvo.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(alvo.tagName);
|
||||||
|
}
|
||||||
|
function teclado(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
paleta.aberta ? paleta.fechar() : paleta.abrir();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.shiftKey && e.code === 'Space' && !editando(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
pararTudo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' && !paleta.aberta) {
|
||||||
|
const agora = Date.now();
|
||||||
|
if (agora - ultimoEsc < 600) pararTudo();
|
||||||
|
ultimoEsc = agora;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editando(e) || paleta.aberta || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
if (e.key === 'g') {
|
||||||
|
prefixoG = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - prefixoG < 800) {
|
||||||
|
const rota = ROTAS.find((r) => r.atalho === 'g ' + e.key);
|
||||||
|
prefixoG = 0;
|
||||||
|
if (rota) nav.ir(rota.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^[1-9]$/.test(e.key)) {
|
||||||
|
const g = favoritos.lista[Number(e.key) - 1];
|
||||||
|
if (g) tocarFavorito(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let salvo: string | null = null;
|
||||||
|
try {
|
||||||
|
salvo = localStorage.getItem('carinha_tema');
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
aplicarTema(salvo === 'light' || (!salvo && matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark');
|
||||||
|
conectar();
|
||||||
|
});
|
||||||
|
|
||||||
|
const conexaoTexto = $derived(
|
||||||
|
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
|
||||||
|
);
|
||||||
|
const bateria = $derived(estado.bateria.nivel);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={teclado} />
|
||||||
|
|
||||||
|
{#if estado.conexao === 'token'}
|
||||||
|
<Token />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<nav class="lateral" aria-label="Seções">
|
||||||
|
<div class="marca">
|
||||||
|
<span class="logo" aria-hidden="true"><Bot size={20} /></span>
|
||||||
|
<div>
|
||||||
|
<strong>Carinha</strong>
|
||||||
|
<small class="fraco mono">{estado.versao ? 'v' + estado.versao : ''}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{#each ROTAS as r (r.id)}
|
||||||
|
{@const Icone = ICONES[r.id]}
|
||||||
|
<li>
|
||||||
|
<a href={'#/' + r.id} aria-current={secao === r.id ? 'page' : undefined}>
|
||||||
|
<Icone size={20} /> <span>{r.titulo}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<button class="btn fantasma paleta-btn" onclick={paleta.abrir}>
|
||||||
|
<Command size={16} /> Comandos <kbd>Ctrl K</kbd>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="topo">
|
||||||
|
<h1 class="titulo">
|
||||||
|
{tituloDa(nav.atual)}
|
||||||
|
{#key nav.atual}<Ajuda topico={TOPICO_DA_TELA[nav.atual] ?? 'inicio'} tamanho={17} />{/key}
|
||||||
|
</h1>
|
||||||
|
<div class="status linha">
|
||||||
|
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
|
||||||
|
{conexaoTexto}
|
||||||
|
</Selo>
|
||||||
|
<Selo tom={!estado.celularOnline ? 'neutro' : estado.bateriaBaixa ? 'perigo' : 'ok'}>
|
||||||
|
Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
|
||||||
|
</Selo>
|
||||||
|
{#if estado.conversa}
|
||||||
|
<Selo tom="destaque">IA: {estado.conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'}</Selo>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="acoes linha">
|
||||||
|
<button class="btn fantasma icone so-celular" onclick={paleta.abrir} aria-label="Comandos">
|
||||||
|
<Command size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn fantasma icone"
|
||||||
|
onclick={() => aplicarTema(tema === 'dark' ? 'light' : 'dark')}
|
||||||
|
aria-label={tema === 'dark' ? 'Usar tema claro' : 'Usar tema escuro'}
|
||||||
|
>
|
||||||
|
{#if tema === 'dark'}<Sun size={18} />{:else}<Moon size={18} />{/if}
|
||||||
|
</button>
|
||||||
|
<EStop />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="conteudo">
|
||||||
|
<Tela />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto' && nav.atual !== 'guia' && nav.atual !== 'criar'}
|
||||||
|
<MiniRosto />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paleta />
|
||||||
|
<Avisos />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: var(--topo-altura) 1fr;
|
||||||
|
grid-template-areas: 'topo' 'conteudo';
|
||||||
|
}
|
||||||
|
/* ---------- navegação: barra inferior no celular, lateral a partir de 1024px */
|
||||||
|
.lateral {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 60;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
background: var(--vidro);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-top: 1px solid var(--borda);
|
||||||
|
}
|
||||||
|
.marca,
|
||||||
|
.paleta-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--e-1);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
}
|
||||||
|
li a {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--e-2) 0;
|
||||||
|
border-radius: var(--raio);
|
||||||
|
color: var(--texto-3);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
li a[aria-current='page'] {
|
||||||
|
color: var(--destaque);
|
||||||
|
}
|
||||||
|
.topo {
|
||||||
|
grid-area: topo;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-3);
|
||||||
|
padding: 0 var(--e-4);
|
||||||
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
height: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
|
||||||
|
background: var(--vidro);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-bottom: 1px solid var(--borda);
|
||||||
|
}
|
||||||
|
.titulo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: var(--t-lg);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.status :global(.selo:nth-child(n + 2)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.acoes {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.conteudo {
|
||||||
|
grid-area: conteudo;
|
||||||
|
padding: var(--e-4);
|
||||||
|
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
kbd {
|
||||||
|
margin-left: auto;
|
||||||
|
font: 500 11px var(--fonte-mono);
|
||||||
|
color: var(--texto-3);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.status :global(.selo:nth-child(n + 2)) {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.conteudo {
|
||||||
|
padding: var(--e-5);
|
||||||
|
padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: var(--nav-largura) 1fr;
|
||||||
|
grid-template-areas: 'nav topo' 'nav conteudo';
|
||||||
|
/* a coluna da navegação continua pintada quando a página rola além da viewport */
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--fundo-2) calc(var(--nav-largura) - 1px),
|
||||||
|
var(--borda) 0 var(--nav-largura),
|
||||||
|
transparent 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.lateral {
|
||||||
|
grid-area: nav;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
bottom: auto;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--e-4);
|
||||||
|
padding: var(--e-4) var(--e-3);
|
||||||
|
border-top: 0;
|
||||||
|
border-right: 1px solid var(--borda);
|
||||||
|
background: var(--fundo-2);
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
.marca {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-3);
|
||||||
|
padding: var(--e-2);
|
||||||
|
}
|
||||||
|
.marca div {
|
||||||
|
display: grid;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--raio);
|
||||||
|
background: var(--destaque-suave);
|
||||||
|
color: var(--destaque);
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-3);
|
||||||
|
padding: var(--e-2) var(--e-3);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
color: var(--texto-2);
|
||||||
|
}
|
||||||
|
li a:hover {
|
||||||
|
background: var(--superficie);
|
||||||
|
color: var(--texto);
|
||||||
|
}
|
||||||
|
li a[aria-current='page'] {
|
||||||
|
background: var(--destaque-suave);
|
||||||
|
color: var(--destaque);
|
||||||
|
}
|
||||||
|
.paleta-btn {
|
||||||
|
display: flex;
|
||||||
|
margin-top: auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
color: var(--texto-2);
|
||||||
|
}
|
||||||
|
.so-celular {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.conteudo {
|
||||||
|
padding-bottom: var(--e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/** Ajustes do app do celular (/app/config): salvamento otimista e campos agrupados. */
|
||||||
|
import { pedir, postar } from './api';
|
||||||
|
import { avisos } from './avisos.svelte';
|
||||||
|
import { estado } from './estado.svelte';
|
||||||
|
import type { AppConfig } from './tipos';
|
||||||
|
|
||||||
|
/** Atualiza na hora; se o servidor recusar, volta ao valor anterior e avisa. Não trava o formulário. */
|
||||||
|
export async function salvarApp(parcial: AppConfig) {
|
||||||
|
const antes = estado.appConfig ? { ...estado.appConfig } : null;
|
||||||
|
if (estado.appConfig) estado.appConfig = { ...estado.appConfig, ...parcial };
|
||||||
|
try {
|
||||||
|
const r = await postar<{ config: AppConfig }>('/app/config', parcial);
|
||||||
|
estado.appConfig = r.config;
|
||||||
|
} catch (e) {
|
||||||
|
estado.appConfig = antes;
|
||||||
|
avisos.erro(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restaurarPadrao() {
|
||||||
|
const padrao = await pedir<AppConfig>('/app/padrao');
|
||||||
|
// A transmissão não é "ajuste": continua como está.
|
||||||
|
delete padrao.preview;
|
||||||
|
await salvarApp(padrao);
|
||||||
|
avisos.ok('Ajustes do celular restaurados para o padrão.');
|
||||||
|
}
|
||||||
|
|
||||||
|
type Opcoes = [string | number, string][];
|
||||||
|
export interface Campo {
|
||||||
|
chave: string;
|
||||||
|
rotulo: string;
|
||||||
|
ajuda?: string;
|
||||||
|
tipo: 'toggle' | 'slider' | 'escolha';
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
passo?: number;
|
||||||
|
unidade?: string;
|
||||||
|
opcoes?: Opcoes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `ajuda` = tópico do guia (ⓘ ao lado do título). */
|
||||||
|
export const GRUPOS: { titulo: string; descricao: string; ajuda: string; campos: Campo[] }[] = [
|
||||||
|
{
|
||||||
|
titulo: 'Tela e personalidade',
|
||||||
|
ajuda: 'tela',
|
||||||
|
descricao: 'Como o rosto aparece e se comporta sozinho.',
|
||||||
|
campos: [
|
||||||
|
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
|
||||||
|
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
|
||||||
|
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
|
||||||
|
{ chave: 'iris', rotulo: 'Mostrar íris', ajuda: 'Pupila que acompanha o olhar (Viva, Bloco e Matriz)', tipo: 'toggle' },
|
||||||
|
{ chave: 'subirBoca', rotulo: 'Subir a boca', tipo: 'slider', min: -20, max: 60, passo: 2 },
|
||||||
|
{ chave: 'distSobrancelha', rotulo: 'Distância das sobrancelhas', tipo: 'slider', min: 0, max: 60, passo: 2 },
|
||||||
|
{ chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
|
||||||
|
{ chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
|
||||||
|
{ chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' },
|
||||||
|
{ chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' },
|
||||||
|
{ chave: 'apagarTela', rotulo: 'Apagar a tela quando dormir', ajuda: 'Tela preta e brilho mínimo; acorda com som repetido, movimento, toque ou PIR', tipo: 'toggle' },
|
||||||
|
{ chave: 'apagarTelaMin', rotulo: 'Dormindo há quanto tempo para apagar', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' min' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Sensores e reações',
|
||||||
|
ajuda: 'sensores',
|
||||||
|
descricao: 'O que o celular percebe e como reage.',
|
||||||
|
campos: [
|
||||||
|
{ chave: 'sensorMic', rotulo: 'Microfone', ajuda: 'Só o nível de som é usado, nunca o áudio', tipo: 'toggle' },
|
||||||
|
{ chave: 'ouvir', rotulo: 'Reagir a sons', tipo: 'toggle' },
|
||||||
|
{ chave: 'micSono', rotulo: 'Microfone durante o sono', tipo: 'toggle' },
|
||||||
|
{ chave: 'sompausa', rotulo: 'Silêncio para dormir', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' s' },
|
||||||
|
{ chave: 'sensorCamera', rotulo: 'Câmera como sensor', ajuda: 'Detecta movimento e permite o vídeo', tipo: 'toggle' },
|
||||||
|
{ chave: 'reagir', rotulo: 'Reagir a movimento', tipo: 'toggle' },
|
||||||
|
{ chave: 'cameraSono', rotulo: 'Câmera durante o sono', tipo: 'toggle' },
|
||||||
|
{ chave: 'movolhar', rotulo: 'Quanto os olhos acompanham', tipo: 'slider', min: 0, max: 100, passo: 1, unidade: '%' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Sono e madrugada',
|
||||||
|
ajuda: 'sono',
|
||||||
|
descricao: 'O que acorda o rosto enquanto ele dorme.',
|
||||||
|
campos: [
|
||||||
|
{ chave: 'somAcordar', rotulo: 'Sons para acordar', ajuda: 'Quantos barulhos em 1 minuto para ele acordar', tipo: 'slider', min: 1, max: 10, passo: 1 },
|
||||||
|
{
|
||||||
|
chave: 'noiteSilenciosa',
|
||||||
|
rotulo: 'Madrugada silenciosa',
|
||||||
|
ajuda: 'Movimento, câmera e PIR não acordam; só barulho alto repetido, toque ou conversa',
|
||||||
|
tipo: 'toggle',
|
||||||
|
},
|
||||||
|
{ chave: 'noiteInicio', rotulo: 'Começa às', tipo: 'slider', min: 0, max: 23, passo: 1, unidade: 'h' },
|
||||||
|
{ chave: 'noiteFim', rotulo: 'Termina às', tipo: 'slider', min: 0, max: 23, passo: 1, unidade: 'h' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Vídeo e rastreamento',
|
||||||
|
ajuda: 'camera',
|
||||||
|
descricao: 'Qualidade da transmissão e rastreamento feito neste PC.',
|
||||||
|
campos: [
|
||||||
|
{ chave: 'cameraLado', rotulo: 'Câmera', tipo: 'escolha', opcoes: [['user', 'Frontal'], ['environment', 'Traseira']] },
|
||||||
|
{
|
||||||
|
chave: 'videoResolucao',
|
||||||
|
rotulo: 'Resolução',
|
||||||
|
tipo: 'escolha',
|
||||||
|
opcoes: [['auto', 'Automática'], ['max', 'Máxima'], ['480', '480p'], ['720', '720p'], ['1080', '1080p'], ['2160', '4K'], ['4320', '8K']],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chave: 'videoFPS',
|
||||||
|
rotulo: 'Quadros por segundo',
|
||||||
|
tipo: 'escolha',
|
||||||
|
opcoes: [[0, 'Automático'], [-1, 'Máximo'], [15, '15'], [24, '24'], [30, '30'], [60, '60'], [120, '120'], [240, '240']],
|
||||||
|
},
|
||||||
|
{ chave: 'rastrearRosto', rotulo: 'Rastrear rosto', tipo: 'toggle' },
|
||||||
|
{ chave: 'rastrearMaos', rotulo: 'Rastrear mãos', tipo: 'toggle' },
|
||||||
|
{ chave: 'seguirRastreamento', rotulo: 'Olhos seguem o alvo', tipo: 'toggle' },
|
||||||
|
{ chave: 'mostrarRastreamento', rotulo: 'Desenhar rastreamento no vídeo', tipo: 'toggle' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Sensibilidade (avançado)',
|
||||||
|
ajuda: 'sensibilidade',
|
||||||
|
descricao: 'Limiares dos sustos e intervalos entre reações.',
|
||||||
|
campos: [
|
||||||
|
{ chave: 'somlim', rotulo: 'Nível para perceber som', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'somaltolim', rotulo: 'Som mínimo para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'somganho', rotulo: 'Aumento súbito para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'somcooldown', rotulo: 'Intervalo entre sustos por som', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
|
||||||
|
{ chave: 'movlim', rotulo: 'Movimento mínimo', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'movforte', rotulo: 'Movimento para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'movcooldown', rotulo: 'Intervalo entre sustos por movimento', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/** Cliente HTTP único do painel: token, JSON, tempo limite e erro legível. */
|
||||||
|
|
||||||
|
const CHAVE_TOKEN = 'carinha_token';
|
||||||
|
|
||||||
|
export class ErroApi extends Error {
|
||||||
|
constructor(public status: number, mensagem: string) {
|
||||||
|
super(mensagem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lerToken(): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(CHAVE_TOKEN) || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gravarToken(token: string) {
|
||||||
|
try {
|
||||||
|
if (token) localStorage.setItem(CHAVE_TOKEN, token);
|
||||||
|
else localStorage.removeItem(CHAVE_TOKEN);
|
||||||
|
} catch {
|
||||||
|
/* navegação privada: vale só nesta aba */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avisado quando o servidor recusa o token (abre o diálogo de token). */
|
||||||
|
let aoNegar: () => void = () => {};
|
||||||
|
export function quandoNegado(fn: () => void) {
|
||||||
|
aoNegar = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pedir<T = any>(caminho: string, dados?: unknown, tempo = 8000): Promise<T> {
|
||||||
|
const cabecalhos: Record<string, string> = {};
|
||||||
|
const token = lerToken();
|
||||||
|
if (token) cabecalhos.Authorization = 'Bearer ' + token;
|
||||||
|
const opcoes: RequestInit = { cache: 'no-store', headers: cabecalhos, signal: AbortSignal.timeout(tempo) };
|
||||||
|
if (dados !== undefined) {
|
||||||
|
opcoes.method = 'POST';
|
||||||
|
cabecalhos['Content-Type'] = 'application/json';
|
||||||
|
opcoes.body = JSON.stringify(dados);
|
||||||
|
}
|
||||||
|
let resposta: Response;
|
||||||
|
try {
|
||||||
|
resposta = await fetch(caminho, opcoes);
|
||||||
|
} catch (e) {
|
||||||
|
const nome = (e as Error).name;
|
||||||
|
throw new ErroApi(0, nome === 'TimeoutError' ? 'O servidor demorou a responder' : 'Sem conexão com o servidor');
|
||||||
|
}
|
||||||
|
const texto = await resposta.text();
|
||||||
|
let corpo: any = null;
|
||||||
|
try {
|
||||||
|
corpo = texto ? JSON.parse(texto) : null;
|
||||||
|
} catch {
|
||||||
|
corpo = null;
|
||||||
|
}
|
||||||
|
if (!resposta.ok) {
|
||||||
|
if (resposta.status === 401) aoNegar();
|
||||||
|
throw new ErroApi(resposta.status, corpo?.erro || `Erro ${resposta.status}`);
|
||||||
|
}
|
||||||
|
return corpo as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const postar = <T = any>(caminho: string, dados: unknown = {}) => pedir<T>(caminho, dados);
|
||||||
|
|
||||||
|
export function urlEventos(): string {
|
||||||
|
const token = lerToken();
|
||||||
|
return token ? '/eventos?t=' + encodeURIComponent(token) : '/eventos';
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/** Toasts no lugar de alert/confirm: mensagem curta, com ação opcional (ex.: Desfazer). */
|
||||||
|
|
||||||
|
export type TipoAviso = 'info' | 'ok' | 'erro';
|
||||||
|
|
||||||
|
export interface Aviso {
|
||||||
|
id: number;
|
||||||
|
texto: string;
|
||||||
|
tipo: TipoAviso;
|
||||||
|
acao?: { rotulo: string; fazer: () => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
class Avisos {
|
||||||
|
lista = $state<Aviso[]>([]);
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
mostrar(texto: string, tipo: TipoAviso = 'info', acao?: Aviso['acao'], ms = tipo === 'erro' ? 6000 : 3500) {
|
||||||
|
const id = ++this.seq;
|
||||||
|
this.lista = [...this.lista, { id, texto, tipo, acao }].slice(-4);
|
||||||
|
setTimeout(() => this.fechar(id), acao ? Math.max(ms, 6000) : ms);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
fechar(id: number) {
|
||||||
|
this.lista = this.lista.filter((a) => a.id !== id);
|
||||||
|
}
|
||||||
|
ok = (t: string, acao?: Aviso['acao']) => this.mostrar(t, 'ok', acao);
|
||||||
|
erro = (e: unknown) => this.mostrar(e instanceof Error ? e.message : String(e), 'erro');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const avisos = new Avisos();
|
||||||
|
|
||||||
|
/** Executa uma ação do painel mostrando o erro num toast em vez de engolir. */
|
||||||
|
export async function tentar<T>(fn: () => Promise<T>, sucesso?: string): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
const r = await fn();
|
||||||
|
if (sucesso) avisos.ok(sucesso);
|
||||||
|
return r;
|
||||||
|
} catch (e) {
|
||||||
|
avisos.erro(e);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Braço (/braco/*) e diagnóstico (/servos/*). Segurança:
|
||||||
|
* - homem-morto: só há movimento enquanto o botão/slider está pressionado; cada pedido leva
|
||||||
|
* um prazo curto (ttl_ms) e o servidor desliga os motores se ele vencer sem renovação;
|
||||||
|
* - a interpolação e o limite de velocidade são do servidor, não deste navegador.
|
||||||
|
*/
|
||||||
|
import { pedir, postar } from './api';
|
||||||
|
import { avisos } from './avisos.svelte';
|
||||||
|
import { estado } from './estado.svelte';
|
||||||
|
import { limites } from './nucleo';
|
||||||
|
|
||||||
|
export { sequencias } from './nucleo';
|
||||||
|
|
||||||
|
export interface Calibracao {
|
||||||
|
canal: number;
|
||||||
|
minimo: number;
|
||||||
|
maximo: number;
|
||||||
|
centro: number;
|
||||||
|
}
|
||||||
|
export interface Postura {
|
||||||
|
nome: string;
|
||||||
|
angulos: number[];
|
||||||
|
}
|
||||||
|
export interface StatusBraco {
|
||||||
|
calibracao: Calibracao[];
|
||||||
|
posturas: Postura[];
|
||||||
|
disponivel: boolean;
|
||||||
|
erro: string;
|
||||||
|
dono: string | null;
|
||||||
|
travado: boolean;
|
||||||
|
atual: number[] | null;
|
||||||
|
movendo: boolean;
|
||||||
|
articulacoes: { nome: string; canal: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRAZO_MS = 450;
|
||||||
|
const RENOVAR_MS = 150;
|
||||||
|
|
||||||
|
class Braco {
|
||||||
|
status = $state<StatusBraco | null>(null);
|
||||||
|
alvo = $state<number[]>(Array(7).fill(90));
|
||||||
|
velocidade = $state(60);
|
||||||
|
/** Opt-in explícito antes de qualquer movimento físico. */
|
||||||
|
ativado = $state(false);
|
||||||
|
segurando = $state(false);
|
||||||
|
passoSequencia = $state<{ nome: string; indice: number; total: number } | null>(null);
|
||||||
|
|
||||||
|
private relogio = 0;
|
||||||
|
private emVoo = false;
|
||||||
|
private sequencia: number[][] | null = null;
|
||||||
|
private pausaAte = 0;
|
||||||
|
|
||||||
|
faixas = $derived(this.status ? limites(this.status.calibracao, this.status.articulacoes) : []);
|
||||||
|
|
||||||
|
async carregar() {
|
||||||
|
try {
|
||||||
|
this.status = await pedir<StatusBraco>('/braco/status');
|
||||||
|
this.limitarAlvo();
|
||||||
|
} catch (e) {
|
||||||
|
avisos.erro(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limitarAlvo() {
|
||||||
|
if (!this.faixas.length) return;
|
||||||
|
this.alvo = this.alvo.map((v, i) => Math.max(this.faixas[i][0], Math.min(this.faixas[i][1], v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Começa a segurar: renova o movimento a cada 150 ms até soltar. */
|
||||||
|
segurar(sequencia?: { nome: string; passos: number[][] }) {
|
||||||
|
if (!this.ativado || this.segurando) return;
|
||||||
|
if (sequencia) {
|
||||||
|
this.sequencia = sequencia.passos;
|
||||||
|
this.passoSequencia = { nome: sequencia.nome, indice: 0, total: sequencia.passos.length };
|
||||||
|
this.alvo = [...sequencia.passos[0]];
|
||||||
|
this.pausaAte = 0;
|
||||||
|
}
|
||||||
|
this.segurando = true;
|
||||||
|
this.renovar();
|
||||||
|
this.relogio = window.setInterval(() => this.renovar(), RENOVAR_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soltou: para de renovar. O servidor desliga os motores quando o prazo vence (≤ 450 ms). */
|
||||||
|
soltar() {
|
||||||
|
if (!this.segurando) return;
|
||||||
|
this.segurando = false;
|
||||||
|
clearInterval(this.relogio);
|
||||||
|
if (this.passoSequencia) avisos.mostrar(`${this.passoSequencia.nome} interrompida ao soltar.`);
|
||||||
|
this.sequencia = null;
|
||||||
|
this.passoSequencia = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renovar() {
|
||||||
|
if (this.emVoo || !this.segurando) return;
|
||||||
|
this.emVoo = true;
|
||||||
|
try {
|
||||||
|
const r = await postar<{ movendo: boolean }>('/braco/mover', {
|
||||||
|
angulos: $state.snapshot(this.alvo),
|
||||||
|
velocidade: this.velocidade,
|
||||||
|
ttl_ms: PRAZO_MS,
|
||||||
|
});
|
||||||
|
if (!r.movendo && this.sequencia && this.passoSequencia) this.avancarSequencia();
|
||||||
|
} catch (e) {
|
||||||
|
this.segurando = false;
|
||||||
|
clearInterval(this.relogio);
|
||||||
|
this.sequencia = null;
|
||||||
|
this.passoSequencia = null;
|
||||||
|
avisos.erro(e);
|
||||||
|
} finally {
|
||||||
|
this.emVoo = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private avancarSequencia() {
|
||||||
|
if (!this.pausaAte) {
|
||||||
|
this.pausaAte = performance.now() + 700;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (performance.now() < this.pausaAte) return;
|
||||||
|
this.pausaAte = 0;
|
||||||
|
const p = this.passoSequencia!;
|
||||||
|
if (p.indice + 1 >= p.total) {
|
||||||
|
avisos.ok(`${p.nome} concluída.`);
|
||||||
|
this.sequencia = null;
|
||||||
|
this.passoSequencia = null;
|
||||||
|
this.segurando = false;
|
||||||
|
clearInterval(this.relogio);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.passoSequencia = { ...p, indice: p.indice + 1 };
|
||||||
|
this.alvo = [...this.sequencia![p.indice + 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
async pararMotores() {
|
||||||
|
this.soltar();
|
||||||
|
try {
|
||||||
|
await postar('/braco/parar');
|
||||||
|
} catch (e) {
|
||||||
|
avisos.erro(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async desativar() {
|
||||||
|
this.ativado = false;
|
||||||
|
await this.pararMotores();
|
||||||
|
}
|
||||||
|
|
||||||
|
async salvar(dados: { calibracao: Calibracao[]; posturas: Postura[] }, sucesso: string) {
|
||||||
|
this.soltar();
|
||||||
|
try {
|
||||||
|
await postar('/braco/parar');
|
||||||
|
this.status = await postar<StatusBraco>('/braco/config', dados);
|
||||||
|
this.limitarAlvo();
|
||||||
|
avisos.ok(sucesso);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
avisos.erro(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const braco = new Braco();
|
||||||
|
|
||||||
|
// Segurança: aba escondida ou E-Stop param de segurar na hora.
|
||||||
|
document.addEventListener('visibilitychange', () => document.hidden && braco.soltar());
|
||||||
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
if (estado.estop.ativo) {
|
||||||
|
braco.soltar();
|
||||||
|
braco.ativado = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/** Ações que qualquer tela pode disparar: gestos, rosto, cor, E-Stop. */
|
||||||
|
import { postar } from './api';
|
||||||
|
import { avisos, tentar } from './avisos.svelte';
|
||||||
|
import { estado } from './estado.svelte';
|
||||||
|
|
||||||
|
/** Nome amigável e emoji de cada gesto do rosto ('parar' fecha o kiosk: não vira botão). */
|
||||||
|
export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
|
||||||
|
feliz: { nome: 'Feliz', emoji: '😊' },
|
||||||
|
alegre: { nome: 'Alegre', emoji: '😄' },
|
||||||
|
coracao: { nome: 'Coração', emoji: '❤️' },
|
||||||
|
piscar: { nome: 'Piscar', emoji: '😌' },
|
||||||
|
piscar2: { nome: 'Piscar duplo', emoji: '😑' },
|
||||||
|
piscadinha: { nome: 'Piscadinha', emoji: '😉' },
|
||||||
|
surpreso: { nome: 'Surpreso', emoji: '😮' },
|
||||||
|
triste: { nome: 'Triste', emoji: '😢' },
|
||||||
|
bravo: { nome: 'Bravo', emoji: '😠' },
|
||||||
|
olhar_esq: { nome: 'Olhar à esquerda', emoji: '👈' },
|
||||||
|
olhar_dir: { nome: 'Olhar à direita', emoji: '👉' },
|
||||||
|
olhar_baixo: { nome: 'Olhar para baixo', emoji: '👇' },
|
||||||
|
dormir: { nome: 'Dormir', emoji: '😴' },
|
||||||
|
acordar: { nome: 'Acordar', emoji: '🌅' },
|
||||||
|
aleatorio: { nome: 'Aleatório', emoji: '🎲' },
|
||||||
|
acenar: { nome: 'Acenar', emoji: '👋' },
|
||||||
|
esconder: { nome: 'Esconder o rosto', emoji: '🙈' },
|
||||||
|
espiar: { nome: 'Espiar', emoji: '🫣' },
|
||||||
|
pensar: { nome: 'Pensar', emoji: '🤔' },
|
||||||
|
musica: { nome: 'Dançar', emoji: '🎵' },
|
||||||
|
rir: { nome: 'Rir', emoji: '😂' },
|
||||||
|
comemorar: { nome: 'Comemorar', emoji: '🥳' },
|
||||||
|
timido: { nome: 'Tímido', emoji: '😳' },
|
||||||
|
confuso: { nome: 'Confuso', emoji: '😕' },
|
||||||
|
bocejar: { nome: 'Bocejar', emoji: '🥱' },
|
||||||
|
tonto: { nome: 'Tonto', emoji: '😵💫' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Atividades (momentos dele): o bonequinho do Carinha faz algo embaixo dos olhos. */
|
||||||
|
export const ATIVIDADES_INFO: Record<string, { nome: string; emoji: string }> = {
|
||||||
|
ler: { nome: 'Ler', emoji: '📖' },
|
||||||
|
cantar: { nome: 'Cantar', emoji: '🎤' },
|
||||||
|
bola: { nome: 'Jogar bola', emoji: '⚽' },
|
||||||
|
videogame: { nome: 'Videogame', emoji: '🎮' },
|
||||||
|
pintar: { nome: 'Pintar', emoji: '🎨' },
|
||||||
|
malhar: { nome: 'Malhar', emoji: '🏋️' },
|
||||||
|
dormir: { nome: 'Cochilar', emoji: '🛏️' },
|
||||||
|
cafe: { nome: 'Café', emoji: '☕' },
|
||||||
|
cozinhar: { nome: 'Cozinhar', emoji: '🍳' },
|
||||||
|
regar: { nome: 'Regar planta', emoji: '🪴' },
|
||||||
|
violao: { nome: 'Violão', emoji: '🎸' },
|
||||||
|
dancar: { nome: 'Dançar', emoji: '💃' },
|
||||||
|
corda: { nome: 'Pular corda', emoji: '🪢' },
|
||||||
|
meditar: { nome: 'Meditar', emoji: '🧘' },
|
||||||
|
telescopio: { nome: 'Telescópio', emoji: '🔭' },
|
||||||
|
pipa: { nome: 'Pipa', emoji: '🪁' },
|
||||||
|
bolhas: { nome: 'Bolhas', emoji: '🫧' },
|
||||||
|
skate: { nome: 'Skate', emoji: '🛹' },
|
||||||
|
computador: { nome: 'Computador', emoji: '💻' },
|
||||||
|
pescar: { nome: 'Pescar', emoji: '🎣' },
|
||||||
|
};
|
||||||
|
export const atividade = (nome: string) => tentar(() => postar('/comando', { tipo: 'atividade', valor: nome }));
|
||||||
|
|
||||||
|
export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
|
||||||
|
|
||||||
|
const CHAVE_FAVORITOS = 'carinha_favoritos';
|
||||||
|
const CHAVE_MAOS = 'carinha_favoritos_maos'; // marca que os gestos com mãos já entraram
|
||||||
|
const CHAVE_V3 = 'carinha_favoritos_v3'; // marca que o bravo e os gestos novos já entraram
|
||||||
|
/** Até 20 favoritos; as teclas 1 a 9 valem para os nove primeiros. */
|
||||||
|
export const MAX_FAVORITOS = 20;
|
||||||
|
const GESTOS_MAOS = ['acenar', 'esconder', 'espiar', 'pensar', 'musica'];
|
||||||
|
const GESTOS_V3 = ['rir', 'comemorar', 'timido', 'confuso', 'bocejar', 'tonto', 'bravo'];
|
||||||
|
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'piscadinha', 'surpreso', 'triste',
|
||||||
|
'dormir', 'acordar', ...GESTOS_V3];
|
||||||
|
|
||||||
|
function lerFavoritos(): string[] {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
|
||||||
|
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) {
|
||||||
|
if (!localStorage.getItem(CHAVE_V3)) {
|
||||||
|
// Quem já tinha favoritos ganha o bravo de volta e os gestos novos no fim da lista.
|
||||||
|
const lista = [...v, ...GESTOS_V3.filter((g) => !v.includes(g))].slice(0, MAX_FAVORITOS);
|
||||||
|
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(lista));
|
||||||
|
localStorage.setItem(CHAVE_V3, '1');
|
||||||
|
localStorage.setItem(CHAVE_MAOS, '1');
|
||||||
|
return lista;
|
||||||
|
}
|
||||||
|
if (localStorage.getItem(CHAVE_MAOS)) return v.slice(0, MAX_FAVORITOS);
|
||||||
|
// Quem já tinha favoritos salvos ganha os gestos com mãos logo depois dos dois primeiros.
|
||||||
|
const novos = GESTOS_MAOS.filter((g) => !v.includes(g));
|
||||||
|
const lista = [...v.slice(0, 2), ...novos, ...v.slice(2)].slice(0, MAX_FAVORITOS);
|
||||||
|
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(lista));
|
||||||
|
localStorage.setItem(CHAVE_MAOS, '1');
|
||||||
|
return lista;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* sem armazenamento */
|
||||||
|
}
|
||||||
|
return PADRAO_FAVORITOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Favoritos {
|
||||||
|
lista = $state<string[]>(lerFavoritos());
|
||||||
|
alternar(g: string) {
|
||||||
|
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, MAX_FAVORITOS);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
|
||||||
|
localStorage.setItem(CHAVE_MAOS, '1');
|
||||||
|
localStorage.setItem(CHAVE_V3, '1');
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const favoritos = new Favoritos();
|
||||||
|
|
||||||
|
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
|
||||||
|
/** Favorito pode ser um gesto ou uma criação do estúdio ("criado:12"). */
|
||||||
|
export const tocarFavorito = (g: string) =>
|
||||||
|
g.startsWith('criado:') ? tentar(() => postar('/criacao/tocar', { id: Number(g.slice(7)) })) : gesto(g);
|
||||||
|
export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome }));
|
||||||
|
export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb }));
|
||||||
|
export const formato = (nome: string) => tentar(() => postar('/comando', { tipo: 'formato', valor: nome }));
|
||||||
|
|
||||||
|
/** Rostos: os novos usam o motor de pálpebras (rosto-motor.js) e aceitam formato de olho. */
|
||||||
|
export const ROSTOS_INFO: Record<string, { nome: string; descricao: string; novo: boolean; formato: boolean }> = {
|
||||||
|
vivo: { nome: 'Carinha Viva', descricao: 'O rosto de sempre com pálpebras, brilho e micro-movimentos.', novo: true, formato: true },
|
||||||
|
bloco: { nome: 'Bloco', descricao: 'Olhos quadrados que se recortam, estilo robô de mesa.', novo: true, formato: true },
|
||||||
|
matriz: { nome: 'Matriz', descricao: 'Painel de LEDs retrô; cada olho é feito de pontos.', novo: true, formato: true },
|
||||||
|
lente: { nome: 'Lente', descricao: 'Íris e pupila que dilata, pálpebras de metal.', novo: true, formato: false },
|
||||||
|
carinha: { nome: 'Clássico', descricao: 'O desenho original, com boca.', novo: false, formato: false },
|
||||||
|
miudo: { nome: 'Miúdo', descricao: 'Clássico com olhos menores.', novo: false, formato: false },
|
||||||
|
eilik: { nome: 'Eilik', descricao: 'Clássico sem boca.', novo: false, formato: false },
|
||||||
|
};
|
||||||
|
export const FORMATOS_INFO: Record<string, string> = {
|
||||||
|
padrao: 'Padrão', redondo: 'Redondo', pilula: 'Pílula', quadrado: 'Quadrado', largo: 'Largo',
|
||||||
|
};
|
||||||
|
export const nomeRosto = (id: string) => ROSTOS_INFO[id]?.nome ?? id;
|
||||||
|
export const recarregarCelular = () =>
|
||||||
|
tentar(async () => {
|
||||||
|
const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
|
||||||
|
avisos.ok(r.mensagem);
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function pararTudo() {
|
||||||
|
try {
|
||||||
|
await postar('/parar-tudo');
|
||||||
|
avisos.mostrar('E-Stop acionado: motores desligados e travados.', 'erro');
|
||||||
|
} catch (e) {
|
||||||
|
avisos.erro(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rearmar = () => tentar(() => postar('/rearmar'), 'Motores rearmados.');
|
||||||
|
|
||||||
|
export function hex(rgb: number[]) {
|
||||||
|
return '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
export function rgb(hexCor: string): [number, number, number] {
|
||||||
|
const n = parseInt(hexCor.slice(1), 16);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
export const CORES: { nome: string; rgb: [number, number, number] }[] = [
|
||||||
|
{ nome: 'Ciano', rgb: [92, 214, 255] },
|
||||||
|
{ nome: 'Verde', rgb: [74, 222, 128] },
|
||||||
|
{ nome: 'Amarelo', rgb: [251, 191, 36] },
|
||||||
|
{ nome: 'Laranja', rgb: [255, 140, 50] },
|
||||||
|
{ nome: 'Rosa', rgb: [255, 110, 190] },
|
||||||
|
{ nome: 'Roxo', rgb: [167, 139, 250] },
|
||||||
|
{ nome: 'Vermelho', rgb: [248, 90, 90] },
|
||||||
|
{ nome: 'Branco', rgb: [235, 240, 245] },
|
||||||
|
];
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts" generics="T extends string">
|
||||||
|
let { abas, ativa = $bindable(), rotulo }: { abas: { id: T; titulo: string; contador?: number }[]; ativa: T; rotulo: string } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
function tecla(e: KeyboardEvent, i: number) {
|
||||||
|
const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
||||||
|
if (!d) return;
|
||||||
|
const prox = abas[(i + d + abas.length) % abas.length];
|
||||||
|
ativa = prox.id;
|
||||||
|
(e.currentTarget as HTMLElement).parentElement?.querySelector<HTMLElement>(`[data-aba="${prox.id}"]`)?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="abas" role="tablist" aria-label={rotulo}>
|
||||||
|
{#each abas as aba, i (aba.id)}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
data-aba={aba.id}
|
||||||
|
aria-selected={ativa === aba.id}
|
||||||
|
tabindex={ativa === aba.id ? 0 : -1}
|
||||||
|
onclick={() => (ativa = aba.id)}
|
||||||
|
onkeydown={(e) => tecla(e, i)}
|
||||||
|
>
|
||||||
|
{aba.titulo}
|
||||||
|
{#if aba.contador !== undefined}<span class="contador">{aba.contador}</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.abas {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--e-1);
|
||||||
|
border-bottom: 1px solid var(--borda);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: var(--e-2) var(--e-3);
|
||||||
|
min-height: 40px;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--texto-3);
|
||||||
|
font: 500 var(--t-sm) var(--fonte);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
button[aria-selected='true'] {
|
||||||
|
color: var(--texto);
|
||||||
|
border-bottom-color: var(--destaque);
|
||||||
|
}
|
||||||
|
.contador {
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: var(--raio-pill);
|
||||||
|
background: var(--superficie-3);
|
||||||
|
color: var(--texto-2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { BookOpen, Info } from '@lucide/svelte';
|
||||||
|
import { TOPICOS } from '../guia';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
import TopicoGuia from './TopicoGuia.svelte';
|
||||||
|
|
||||||
|
// Botão ⓘ: abre a explicação de um tópico do guia num balão, com atalho para o guia completo.
|
||||||
|
let { topico, tamanho = 15 }: { topico: string; tamanho?: number } = $props();
|
||||||
|
const t = $derived(TOPICOS[topico]);
|
||||||
|
let aberto = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if t}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ajuda"
|
||||||
|
aria-label={'O que é: ' + t.titulo}
|
||||||
|
title="O que é isto?"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
aberto = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Info size={tamanho} />
|
||||||
|
</button>
|
||||||
|
<Modal bind:aberto titulo={t.emoji + ' ' + t.titulo}>
|
||||||
|
<TopicoGuia topico={t} />
|
||||||
|
<a class="btn pequeno guia" href={'#/guia?t=' + topico} onclick={() => (aberto = false)}>
|
||||||
|
<BookOpen size={14} /> Ver o guia completo
|
||||||
|
</a>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ajuda {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--texto-3);
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.ajuda:hover {
|
||||||
|
background: var(--superficie-2);
|
||||||
|
color: var(--destaque);
|
||||||
|
}
|
||||||
|
.ajuda:focus-visible {
|
||||||
|
outline: 2px solid var(--destaque);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.guia {
|
||||||
|
justify-self: start;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X } from '@lucide/svelte';
|
||||||
|
import { avisos } from '../avisos.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="avisos" role="status" aria-live="polite">
|
||||||
|
{#each avisos.lista as a (a.id)}
|
||||||
|
<div class="aviso {a.tipo}">
|
||||||
|
<span>{a.texto}</span>
|
||||||
|
{#if a.acao}
|
||||||
|
<button
|
||||||
|
class="btn pequeno"
|
||||||
|
onclick={() => {
|
||||||
|
a.acao!.fazer();
|
||||||
|
avisos.fechar(a.id);
|
||||||
|
}}>{a.acao.rotulo}</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<button class="btn fantasma pequeno icone" aria-label="Fechar aviso" onclick={() => avisos.fechar(a.id)}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.avisos {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 90;
|
||||||
|
right: var(--e-4);
|
||||||
|
bottom: calc(var(--e-4) + env(safe-area-inset-bottom, 0px));
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-2);
|
||||||
|
width: min(420px, calc(100vw - 2 * var(--e-4)));
|
||||||
|
}
|
||||||
|
.aviso {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: var(--e-2) var(--e-2) var(--e-2) var(--e-4);
|
||||||
|
border-radius: var(--raio);
|
||||||
|
background: var(--superficie-2);
|
||||||
|
border: 1px solid var(--borda-forte);
|
||||||
|
border-left: 4px solid var(--destaque);
|
||||||
|
box-shadow: var(--sombra-flutuante);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
.aviso span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
border-left-color: var(--ok);
|
||||||
|
}
|
||||||
|
.erro {
|
||||||
|
border-left-color: var(--perigo);
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.avisos {
|
||||||
|
bottom: calc(76px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Vista lateral esquemática: cada articulação é um <g> aninhado que gira no próprio eixo.
|
||||||
|
// Ordem dos ângulos: 0 garra, 1 punho 1, 2 punho 2, 3 braço superior, 4 braço inferior, 5 antebraço, 6 base.
|
||||||
|
let { pose, fantasma = null }: { pose: number[]; fantasma?: number[] | null } = $props();
|
||||||
|
const r = (v: number) => v - 90;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet braco(p: number[], classe: string)}
|
||||||
|
<g class={classe}>
|
||||||
|
<g transform="translate(120 236) rotate({r(p[4])})">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="-78" class="seg" />
|
||||||
|
<g transform="translate(0 -78) rotate({r(p[3])})">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="-66" class="seg" />
|
||||||
|
<g transform="translate(0 -66) rotate({r(p[5])})">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="-40" class="seg fino" />
|
||||||
|
<g transform="translate(0 -40) rotate({r(p[1])})">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="-18" class="seg fino" />
|
||||||
|
<ellipse cx="0" cy="-18" rx={7 * Math.abs(Math.cos(((p[2] - 90) * Math.PI) / 180)) + 2} ry="4" class="punho" />
|
||||||
|
<g transform="translate(0 -20)">
|
||||||
|
<path d="M0 0 L-4 -16 L-1 -24" class="dedo" transform="rotate({-8 - (p[0] - 90) * 0.25})" />
|
||||||
|
<path d="M0 0 L4 -16 L1 -24" class="dedo" transform="rotate({8 + (p[0] - 90) * 0.25})" />
|
||||||
|
</g>
|
||||||
|
<circle r="4" class="junta" />
|
||||||
|
</g>
|
||||||
|
<circle r="5" class="junta" />
|
||||||
|
</g>
|
||||||
|
<circle r="6" class="junta" />
|
||||||
|
</g>
|
||||||
|
<circle r="7" class="junta" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<svg viewBox="0 0 240 290" role="img" aria-label="Postura do braço">
|
||||||
|
<!-- base: o arco mostra a rotação (vista de cima) -->
|
||||||
|
<rect x="80" y="240" width="80" height="16" rx="5" class="base" />
|
||||||
|
<path d="M85 274 A40 14 0 0 0 155 274" class="trilho" />
|
||||||
|
<circle
|
||||||
|
cx={120 + 35 * Math.cos(Math.PI - (pose[6] * Math.PI) / 180)}
|
||||||
|
cy={274 + 12 * Math.sin((pose[6] * Math.PI) / 180) * 0.4}
|
||||||
|
r="4"
|
||||||
|
class="marcador"
|
||||||
|
/>
|
||||||
|
{#if fantasma}{@render braco(fantasma, 'fantasma')}{/if}
|
||||||
|
{@render braco(pose, 'real')}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.seg {
|
||||||
|
stroke: var(--destaque);
|
||||||
|
stroke-width: 12;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
.fino {
|
||||||
|
stroke-width: 8;
|
||||||
|
}
|
||||||
|
.junta {
|
||||||
|
fill: var(--superficie);
|
||||||
|
stroke: var(--texto-2);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.punho {
|
||||||
|
fill: var(--texto-2);
|
||||||
|
}
|
||||||
|
.dedo {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--texto);
|
||||||
|
stroke-width: 4;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.base {
|
||||||
|
fill: var(--superficie-3);
|
||||||
|
stroke: var(--borda-forte);
|
||||||
|
}
|
||||||
|
.trilho {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--borda-forte);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 3 4;
|
||||||
|
}
|
||||||
|
.marcador {
|
||||||
|
fill: var(--aviso);
|
||||||
|
}
|
||||||
|
.fantasma {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.fantasma .seg {
|
||||||
|
stroke: var(--texto-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user