Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43770c6d67 | ||
|
|
f8d2ebf705 | ||
|
|
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 |
@@ -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
|
||||
@@ -23,3 +23,6 @@ api-privada.tmp
|
||||
|
||||
ia-config.json
|
||||
seguranca.json
|
||||
historico.db
|
||||
historico.json
|
||||
fotos/
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="br.carinha.rosto"
|
||||
android:versionCode="9"
|
||||
android:versionName="0.8.1">
|
||||
android:versionCode="12"
|
||||
android:versionName="0.9.0">
|
||||
|
||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||
|
||||
@@ -36,6 +36,23 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- no modo quiosque a carinha é a tela inicial do aparelho -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- dono do aparelho (modo quiosque): adb shell dpm set-device-owner br.carinha.rosto/.Admin -->
|
||||
<receiver
|
||||
android:name=".Admin"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_DEVICE_ADMIN">
|
||||
<meta-data android:name="android.app.device_admin" android:resource="@xml/admin" />
|
||||
<intent-filter>
|
||||
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
+626
-52
@@ -5,7 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
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}
|
||||
.traco{fill:none;stroke-linecap:round}
|
||||
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
|
||||
@@ -14,6 +20,52 @@
|
||||
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
|
||||
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
|
||||
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
|
||||
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
|
||||
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
|
||||
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
|
||||
z-index:5;pointer-events:none;text-align:center;opacity:.82;font-family:system-ui,sans-serif;
|
||||
animation:semServidorEntra 1.2s ease-out}
|
||||
#semServidor svg{display:block;margin:0 auto;overflow:visible;filter:drop-shadow(0 0 3px currentColor)}
|
||||
#semServidor svg.horizontal{display:none}
|
||||
#semServidor svg.vertical{height:58vh;width:auto;max-width:100%}
|
||||
#semServidor svg.vertical .olhinhos{animation-name:semServidorOlharCima}
|
||||
@media (orientation:portrait){
|
||||
body.semServidor #tela,body.semServidor #rostoMotor{transform:translateY(-20%) scale(.8)}
|
||||
#semServidor{right:auto;top:auto;left:50%;bottom:3.5vh;transform:translateX(-50%);width:min(80vw,400px)}
|
||||
#semServidor svg.horizontal{display:block;width:100%;height:auto}
|
||||
#semServidor svg.vertical{display:none}
|
||||
}
|
||||
#semServidor .titulo{text-wrap:balance;font-weight:600;font-size:clamp(12px,3.2vh,20px);margin-top:.4em;letter-spacing:.02em}
|
||||
#semServidor .sub{font-size:clamp(10px,2.4vh,15px);opacity:.6;margin-top:.25em;font-variant-numeric:tabular-nums}
|
||||
#semServidor .placa rect,#semServidor .celular>rect{fill:none;stroke:currentColor;stroke-width:3}
|
||||
#semServidor .chip{fill:currentColor;fill-opacity:.25}
|
||||
#semServidor .pino{fill:currentColor;opacity:.7}
|
||||
#semServidor .led{fill:#ff5a5a;animation:semServidorLed 1.6s steps(1) infinite}
|
||||
#semServidor .cabo{stroke:currentColor;stroke-width:3;stroke-dasharray:5 6;opacity:.5;fill:none}
|
||||
#semServidor .arco{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;opacity:0;
|
||||
animation:semServidorArco 2.4s ease-out infinite}
|
||||
#semServidor .a2{animation-delay:.3s}
|
||||
#semServidor .a3{animation-delay:.6s}
|
||||
#semServidor .xis path{stroke:#ff5a5a;stroke-width:5;stroke-linecap:round;fill:none}
|
||||
#semServidor .xis{transform-box:fill-box;transform-origin:center;animation:semServidorXis 2.4s ease-in-out infinite}
|
||||
#semServidor .olhinhos{fill:currentColor;transform-box:fill-box;transform-origin:center;
|
||||
animation:semServidorOlhar 5s ease-in-out infinite}
|
||||
#semServidor .boquinha{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round}
|
||||
#semServidor .celular{transform-box:fill-box;transform-origin:50% 100%;animation:semServidorProcura 4.8s ease-in-out infinite}
|
||||
#semServidor.cutucado .celular{animation:semServidorChacoalha .5s ease-in-out}
|
||||
@keyframes semServidorEntra{from{opacity:0}to{opacity:.82}}
|
||||
/* em pé no canto direito: o celularzinho olha para cima, para a placa */
|
||||
@keyframes semServidorOlharCima{0%,30%{transform:translateY(-3px) scaleY(1)}34%{transform:translateY(-3px) scaleY(.1)}38%,60%{transform:translateY(-3px) scaleY(1)}
|
||||
70%,90%{transform:translateY(1px) scaleY(1)}100%{transform:translateY(-3px)}}
|
||||
@keyframes semServidorArco{0%{opacity:0}25%{opacity:.9}70%,100%{opacity:0}}
|
||||
@keyframes semServidorXis{0%,55%,100%{transform:scale(1);opacity:.75}65%{transform:scale(1.25);opacity:1}80%{transform:scale(1);opacity:.85}}
|
||||
@keyframes semServidorLed{0%{opacity:1}50%{opacity:.15}}
|
||||
/* o celularzinho olha para a placa, olha de volta e pisca */
|
||||
@keyframes semServidorOlhar{0%,30%{transform:translateX(-3px) scaleY(1)}34%{transform:translateX(-3px) scaleY(.1)}38%,60%{transform:translateX(-3px) scaleY(1)}
|
||||
70%,90%{transform:translateX(1px) scaleY(1)}100%{transform:translateX(-3px)}}
|
||||
@keyframes semServidorProcura{0%,100%{transform:rotate(0)}20%{transform:rotate(-5deg)}40%{transform:rotate(0)}}
|
||||
@keyframes semServidorChacoalha{0%,100%{transform:rotate(0)}25%{transform:rotate(-9deg)}50%{transform:rotate(7deg)}75%{transform:rotate(-4deg)}}
|
||||
@media(prefers-reduced-motion:reduce){#semServidor *,#semServidor{animation:none!important}#semServidor .arco{opacity:.6}}
|
||||
</style>
|
||||
<body>
|
||||
<svg id="tela" viewBox="0 0 480 320" preserveAspectRatio="xMidYMid meet">
|
||||
@@ -25,6 +77,8 @@
|
||||
<path id="sobrancelha2" class="traco" style="display:none"/>
|
||||
</svg>
|
||||
<div id="fps"></div>
|
||||
<script src="rosto-motor.js"></script>
|
||||
<script src="voz-robo.js"></script>
|
||||
<script>
|
||||
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
|
||||
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
|
||||
@@ -90,12 +144,33 @@ addEventListener("resize", ajustar);
|
||||
|
||||
const PRETO = [6, 8, 14];
|
||||
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 && !window.carinhaSemServidor && 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 VERM = [255, 78, 78];
|
||||
const ROSA = [255, 112, 176];
|
||||
const AMARELO = [255, 218, 96];
|
||||
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 BRANCO = [245, 250, 255];
|
||||
|
||||
@@ -103,10 +178,17 @@ const BRANCO = [245, 250, 255];
|
||||
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
|
||||
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
|
||||
const ROSTOS = {
|
||||
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
|
||||
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
|
||||
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
|
||||
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
|
||||
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
|
||||
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
|
||||
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
|
||||
};
|
||||
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
|
||||
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
|
||||
let FORMATO = "padrao";
|
||||
let ROSTO_NOME = "carinha";
|
||||
let ROSTO = ROSTOS[ROSTO_NOME];
|
||||
|
||||
@@ -141,8 +223,18 @@ function caminhoLinha(x0, y0, x1, y1) {
|
||||
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
||||
}
|
||||
/* 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) {
|
||||
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"
|
||||
const rx = l * 0.17, ry = l * 0.22;
|
||||
return caminhoElipse(cx, cy, rx, ry);
|
||||
@@ -163,8 +255,25 @@ function caminhoBoca(tipo) {
|
||||
return "M " + (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
|
||||
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0";
|
||||
// repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
|
||||
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
|
||||
@@ -216,6 +325,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.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) {
|
||||
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
|
||||
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
|
||||
@@ -283,7 +399,7 @@ function sequencias() {
|
||||
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],
|
||||
[{ 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],
|
||||
[{ 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],
|
||||
@@ -292,13 +408,37 @@ function sequencias() {
|
||||
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],
|
||||
[{ 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]];
|
||||
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
||||
[{ 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],
|
||||
[{ 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 };
|
||||
}
|
||||
|
||||
@@ -311,12 +451,92 @@ const r = {
|
||||
const SEQS = sequencias();
|
||||
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
|
||||
"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() {
|
||||
if (!r.fila.length) { r.kf = null; return; }
|
||||
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.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 ("forma2" in alvo) r.forma2 = alvo.forma2;
|
||||
if ("boca" in alvo) r.boca = alvo.boca;
|
||||
@@ -324,7 +544,22 @@ function proximo() {
|
||||
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) {
|
||||
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]) {
|
||||
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
|
||||
proximo();
|
||||
@@ -348,10 +583,40 @@ function passo(dt) { // avanca a fila; true se ha gesto em
|
||||
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"
|
||||
if (r.dormindo) {
|
||||
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.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
||||
r.dx = 0;
|
||||
@@ -362,9 +627,28 @@ function repouso(t) { // pose calma: olha devagar, "respira
|
||||
r.boca = "neutra";
|
||||
r.cor = CIANO.slice();
|
||||
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)
|
||||
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
|
||||
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
|
||||
// olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
|
||||
// centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
|
||||
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() {
|
||||
@@ -376,32 +660,61 @@ function desenhar() {
|
||||
// desliza na direcao do movimento; parado, volta ao repouso discreto
|
||||
const tAgora = performance.now() / 1000;
|
||||
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 dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
|
||||
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const comMotor = ROSTO.motor && window.MotorRosto;
|
||||
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];
|
||||
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
|
||||
abx, fx, r.cor, lado, dx, dy * 0.6);
|
||||
}
|
||||
desenharBoca();
|
||||
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++) {
|
||||
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;
|
||||
const c = i === 0 ? E1 : E2;
|
||||
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('d', caminhoSobrancelha(i, esc));
|
||||
brow.setAttribute('stroke', rgb(r.cor));
|
||||
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
|
||||
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
||||
function trocarRosto(nome) {
|
||||
@@ -430,22 +743,51 @@ function trata(ordem) {
|
||||
trocarRosto(ordem.slice(6).trim());
|
||||
} else if (ROSTOS[ordem]) {
|
||||
trocarRosto(ordem); // aceita so o nome: "miudo"
|
||||
} else if (ordem.indexOf("formato ") === 0) {
|
||||
const f = ordem.slice(8).trim();
|
||||
if (FORMATOS.includes(f)) FORMATO = f;
|
||||
} else if (ordem.indexOf("cor ") === 0) {
|
||||
const v = ordem.slice(4).split(",").map(Number);
|
||||
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
||||
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") {
|
||||
r.dormindo = true;
|
||||
atividade(null);
|
||||
} else if (ordem === "acordar") {
|
||||
if (window.carinhaSemServidor) { // sem a placa continua dormindo; o aviso chacoalha
|
||||
const aviso = document.getElementById("semServidor");
|
||||
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
|
||||
return;
|
||||
}
|
||||
r.dormindo = false; animar("surpreso");
|
||||
} else if (ordem === "aleatorio") {
|
||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||
} else if (SEQS[ordem]) {
|
||||
if (window.MotorRosto) {
|
||||
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
||||
}
|
||||
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") {
|
||||
try { window.close(); } catch (e) { r.fila = []; }
|
||||
}
|
||||
@@ -462,7 +804,7 @@ function pose(nome) {
|
||||
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
|
||||
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;
|
||||
desenhar();
|
||||
return true;
|
||||
@@ -486,26 +828,44 @@ async function buscarOrdens() {
|
||||
try {
|
||||
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
|
||||
if (!resp.ok) return;
|
||||
conexaoOk();
|
||||
const lista = await resp.json();
|
||||
for (const nome of lista) trata(nome);
|
||||
} catch (e) { /* sem servidor (file://) — ignora */ }
|
||||
}
|
||||
setInterval(buscarOrdens, 500);
|
||||
|
||||
// O app (window.CarinhaApp) guarda a ultima aparencia e os ajustes vindos do servidor:
|
||||
// a copia do APK (file://) nao enxerga o armazenamento da pagina do servidor.
|
||||
const LOCAL = location.protocol === "file:";
|
||||
function lembrar(chave, valor) {
|
||||
try { if (window.CarinhaApp && !LOCAL) window.CarinhaApp.guardar(chave, JSON.stringify(valor)); } catch (e) {}
|
||||
}
|
||||
function lembrado(chave) {
|
||||
try { const s = window.CarinhaApp && window.CarinhaApp.lembrado(chave); return s ? JSON.parse(s) : null; } catch (e) { return null; }
|
||||
}
|
||||
|
||||
// A aparencia guardada no servidor (rosto + cor) e aplicada ao carregar: o
|
||||
// painel de controle personaliza e a carinha ja abre do jeito escolhido. Sem
|
||||
// servidor (file://) isso falha calado e vale o padrao.
|
||||
// servidor vale a ultima que o app guardou (ou o padrao).
|
||||
async function aplicarAparencia() {
|
||||
try {
|
||||
const resp = await fetch("config", { cache: "no-store" });
|
||||
if (!resp.ok) return;
|
||||
const c = await resp.json();
|
||||
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
|
||||
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
|
||||
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
|
||||
} catch (e) { /* sem servidor — segue com o padrao */ }
|
||||
let c = null;
|
||||
if (!LOCAL) {
|
||||
try {
|
||||
const resp = await fetch("config", { cache: "no-store" });
|
||||
if (resp.ok) c = await resp.json();
|
||||
} catch (e) { /* sem servidor */ }
|
||||
}
|
||||
if (!c) c = lembrado("aparencia");
|
||||
if (!c) return;
|
||||
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
|
||||
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
|
||||
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
|
||||
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
|
||||
}
|
||||
setTimeout(aplicarAparencia, 200);
|
||||
// O painel troca rosto/cor por ordens: guarda a aparencia atual de tempos em tempos.
|
||||
setInterval(() => { if (!window.carinhaSemServidor) lembrar("aparencia", { rosto: ROSTO_NOME, formato: FORMATO, cor: CIANO.slice() }); }, 15000);
|
||||
// ?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';
|
||||
@@ -516,9 +876,31 @@ if (MODO_PAINEL) {
|
||||
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)
|
||||
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
|
||||
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
|
||||
@@ -558,6 +940,49 @@ const MIC = {
|
||||
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) {
|
||||
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
|
||||
const agora = performance.now() / 1000;
|
||||
@@ -572,8 +997,8 @@ function som(nivel) {
|
||||
if (MIC.piso === null) MIC.piso = n;
|
||||
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
|
||||
|
||||
const temSom = n >= MIC.limiarSom; // ruido audivel (absoluto)
|
||||
const alto = n >= MIC.limiarAlto && (n - MIC.piso) >= MIC.ganho;
|
||||
const temSom = n >= limiar(MIC.limiarSom); // ruido audivel (absoluto)
|
||||
const alto = n >= limiar(MIC.limiarAlto) && (n - MIC.piso) >= limiar(MIC.ganho);
|
||||
if (temSom) MIC.ultimoSom = agora;
|
||||
|
||||
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
|
||||
@@ -582,15 +1007,19 @@ function som(nivel) {
|
||||
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;
|
||||
animar("surpreso");
|
||||
MIC.proxSurpresa = agora + MIC.cooldown;
|
||||
console.log("ouvido: acordou nivel=" + n.toFixed(2));
|
||||
relatarSom("acordou", n);
|
||||
} else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) {
|
||||
MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino
|
||||
animar("surpreso");
|
||||
}
|
||||
} else if (contar(CONTA.somReagir, PERFIL.qualquer ? temSom : alto, agora, PERFIL.reagir, "ouvido: estimulo") &&
|
||||
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));
|
||||
relatarSom("surpreso", n);
|
||||
}
|
||||
@@ -603,6 +1032,7 @@ function checarSilencio(agora) {
|
||||
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
|
||||
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
|
||||
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
|
||||
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
|
||||
if (agora - atividade >= MIC.segSilencio) {
|
||||
@@ -616,6 +1046,25 @@ function checarSilencio(agora) {
|
||||
journal do carinha-web (so quando o microfone esta ativo). */
|
||||
function relatarSom(evento, n) {
|
||||
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)
|
||||
@@ -655,7 +1104,7 @@ function rastrear(t) {
|
||||
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
|
||||
if(t.atividade===true) {
|
||||
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) {
|
||||
@@ -669,7 +1118,7 @@ function movimento(vel, cx, cy) {
|
||||
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; 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;
|
||||
|
||||
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
|
||||
@@ -684,17 +1133,21 @@ function movimento(vel, cx, cy) {
|
||||
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
|
||||
}
|
||||
|
||||
const forte = v >= MOV.limiarForte;
|
||||
if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente
|
||||
const forte = v >= limiar(MOV.limiarForte);
|
||||
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;
|
||||
animar("surpreso");
|
||||
MOV.proxSurpresa = agora + MOV.cooldown;
|
||||
console.log("presenca: acordou nivel=" + v.toFixed(2));
|
||||
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
|
||||
MOV.proxSurpresa = agora + MOV.cooldown;
|
||||
animar("surpreso");
|
||||
MOV.proxSurpresa = agora + MOV.cooldown * PERFIL.espera;
|
||||
animar(reacaoEstimulo());
|
||||
console.log("presenca: surpreso nivel=" + v.toFixed(2));
|
||||
relatarMov("surpreso", v);
|
||||
}
|
||||
@@ -704,6 +1157,7 @@ function movimento(vel, cx, cy) {
|
||||
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
|
||||
function relatarMov(evento, n) {
|
||||
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||
registrarEvento("movimento", evento, n);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- camera oculta (sensor)
|
||||
@@ -834,7 +1288,15 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
||||
|
||||
let configRecebida = null;
|
||||
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;
|
||||
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
||||
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
||||
@@ -844,6 +1306,7 @@ function configurarApp(c) {
|
||||
MOV.cooldown = c.movcooldown; MOV.olharMax = c.movolhar;
|
||||
if (!c.reagir) { MOV.olharX = MOV.olharY = 0; MOV.ultimoMov = 0; }
|
||||
configRecebida = c;
|
||||
lembrar('config', c);
|
||||
if(!c.seguirRastreamento || !c.sensorCamera || !c.reagir) TRACK.alvo=null;
|
||||
}
|
||||
let sincronizando = false;
|
||||
@@ -864,6 +1327,8 @@ async function sincronizarApp() {
|
||||
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
|
||||
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
||||
brilho: window.carinhaBrilho,
|
||||
tela_apagada: !!window.carinhaTelaApagada,
|
||||
atividade: window.MotorRosto && window.MotorRosto.atividadeAtual ? window.MotorRosto.atividadeAtual() : null,
|
||||
bateria: window.carinhaBateria,
|
||||
aparelho: window.carinhaAparelho,
|
||||
video: window.carinhaVideo,
|
||||
@@ -910,15 +1375,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); },
|
||||
};
|
||||
|
||||
/* 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) {
|
||||
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];
|
||||
if (!f) return false;
|
||||
if (!voz && !f) return false;
|
||||
const c = contextoAudio();
|
||||
if (!c) return false;
|
||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||
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
|
||||
console.log("voz: " + gesto + " ctx=" + c.state);
|
||||
return true;
|
||||
@@ -944,7 +1426,13 @@ function laco(t) {
|
||||
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
|
||||
|
||||
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);
|
||||
|
||||
if (window.carinhaSemServidor && !r.dormindo) { r.fila = []; r.kf = null; r.dormindo = true; } // sem a placa: só dorme
|
||||
const ocupado = congelado ? true : passo(dt);
|
||||
if (!ocupado) {
|
||||
descanso += dt;
|
||||
@@ -954,15 +1442,18 @@ function laco(t) {
|
||||
} else if (t >= proxPiscar) {
|
||||
animar("piscar");
|
||||
proxPiscar = t + 1600 + Math.random() * 3500;
|
||||
} else if (AUTO && t >= proxGesto) {
|
||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||
proxGesto = t + 7000 + Math.random() * 8000;
|
||||
} else if (AUTO && t >= proxGesto && !ATIV) {
|
||||
animar(gestoSolto());
|
||||
// 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) {
|
||||
proxGesto = t + 7000 + Math.random() * 8000;
|
||||
}
|
||||
|
||||
desenhar();
|
||||
if (window.MotorRosto && window.MotorRosto.palco) window.MotorRosto.palco({ t: t / 1000, cor: CIANO });
|
||||
|
||||
contador++;
|
||||
if (t - marco >= 1000) {
|
||||
@@ -1005,12 +1496,89 @@ setTimeout(relatar, 1500);
|
||||
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
|
||||
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
|
||||
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
|
||||
falar(m.texto);
|
||||
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;}
|
||||
}
|
||||
setInterval(poll,1000);poll();
|
||||
})();
|
||||
|
||||
/* ---------------------------------------------------------------- sem servidor
|
||||
Sem a placa (copia do APK, ou o servidor caiu com a pagina aberta) a carinha dorme com a
|
||||
ultima aparencia e ajustes guardados e mostra placa ✕ celular. Volta sozinha quando a placa
|
||||
responde (o app recarrega a pagina do servidor; aberta pelo servidor, o SSE reconecta). */
|
||||
let ultimoOk = performance.now();
|
||||
const TOLERANCIA_SEM_SERVIDOR = 15000; // ms sem resposta antes de dormir
|
||||
function conexaoOk() { ultimoOk = performance.now(); }
|
||||
|
||||
function mostrarSemServidor(ligar) {
|
||||
let el = document.getElementById("semServidor");
|
||||
document.body.classList.toggle("semServidor", ligar);
|
||||
if (!ligar) { if (el) el.hidden = true; return; }
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.id = "semServidor";
|
||||
// placa e celular pelo canto de cima à esquerda; o desenho vertical vai no canto direito com o celular deitado
|
||||
const placa = (px, py) =>
|
||||
'<g class="placa">' +
|
||||
'<rect x="' + px + '" y="' + py + '" width="84" height="56" rx="6"/>' +
|
||||
'<rect class="chip" x="' + (px + 29) + '" y="' + (py + 15) + '" width="26" height="26" rx="3"/>' +
|
||||
[0,1,2,3,4,5,6,7].map((i) => '<circle class="pino" cx="' + (px + 11 + i * 9) + '" cy="' + (py + 7) + '" r="2"/>').join("") +
|
||||
'<circle class="pino" cx="' + (px + 7) + '" cy="' + (py + 50) + '" r="2.4"/><circle class="pino" cx="' + (px + 77) + '" cy="' + (py + 50) + '" r="2.4"/>' +
|
||||
'<circle class="led" cx="' + (px + 72) + '" cy="' + (py + 22) + '" r="2.6"/>' +
|
||||
'</g>';
|
||||
const celular = (cx, cy) =>
|
||||
'<g class="celular">' +
|
||||
'<rect x="' + cx + '" y="' + cy + '" width="40" height="76" rx="8"/>' +
|
||||
'<g class="olhinhos"><ellipse cx="' + (cx + 13) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/><ellipse cx="' + (cx + 27) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/></g>' +
|
||||
'<path class="boquinha" d="M' + (cx + 14) + ' ' + (cy + 48) + ' Q' + (cx + 20) + ' ' + (cy + 44) + ' ' + (cx + 26) + ' ' + (cy + 48) + '"/>' +
|
||||
'</g>';
|
||||
const xis = (x, y) => '<g class="xis"><path d="M' + (x - 10) + ' ' + (y - 10) + ' L' + (x + 10) + ' ' + (y + 10) + ' M' + (x + 10) + ' ' + (y - 10) + ' L' + (x - 10) + ' ' + (y + 10) + '"/></g>';
|
||||
el.innerHTML =
|
||||
'<svg class="horizontal" viewBox="0 0 300 104" aria-hidden="true">' + placa(18, 30) +
|
||||
'<path class="cabo" d="M104 58 H146"/>' +
|
||||
'<g class="arcos">' +
|
||||
'<path class="arco a1" d="M212 50 A10 10 0 0 0 212 66"/>' +
|
||||
'<path class="arco a2" d="M203 44 A18 18 0 0 0 203 72"/>' +
|
||||
'<path class="arco a3" d="M194 38 A26 26 0 0 0 194 78"/>' +
|
||||
'</g>' + xis(160, 58) + celular(220, 18) +
|
||||
'</svg>' +
|
||||
'<svg class="vertical" viewBox="0 0 120 262" aria-hidden="true">' + placa(18, 8) +
|
||||
'<path class="cabo" d="M60 66 V108"/>' +
|
||||
'<g class="arcos">' +
|
||||
'<path class="arco a1" d="M52 172 A10 10 0 0 1 68 172"/>' +
|
||||
'<path class="arco a2" d="M46 163 A18 18 0 0 1 74 163"/>' +
|
||||
'<path class="arco a3" d="M40 154 A26 26 0 0 1 80 154"/>' +
|
||||
'</g>' + xis(60, 122) + celular(40, 182) +
|
||||
'</svg>' +
|
||||
'<div class="titulo">sem conexão com a placa</div>' +
|
||||
'<div class="sub"></div>';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
let alvo = "";
|
||||
try { alvo = (window.CarinhaApp && window.CarinhaApp.servidor()) || (LOCAL ? "" : location.host); } catch (e) {}
|
||||
el.querySelector(".sub").textContent = "tentando de novo" + (alvo ? " · " + String(alvo).replace(/^https?:\/\//, "") : "") + "…";
|
||||
el.style.color = "rgb(" + CIANO.map(Math.round).join(",") + ")";
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function checarServidor() {
|
||||
if (MODO_PAINEL || par.get("espelho") === "1") return;
|
||||
if (window.carinhaSSE) conexaoOk();
|
||||
const fora = LOCAL || performance.now() - ultimoOk > TOLERANCIA_SEM_SERVIDOR;
|
||||
if (window.carinhaSemServidor) mostrarSemServidor(true); // acompanha a cor guardada
|
||||
if (fora === !!window.carinhaSemServidor) return;
|
||||
window.carinhaSemServidor = fora;
|
||||
console.log(fora ? "sem servidor: dormindo" : "servidor de volta");
|
||||
mostrarSemServidor(fora);
|
||||
if (fora) { atividade(null); r.fila = []; r.kf = null; r.dormindo = true; }
|
||||
else trata("acordar");
|
||||
}
|
||||
// Copia do APK: os ultimos ajustes guardados (boca, sobrancelhas, sono...) valem também aqui.
|
||||
setTimeout(() => { if (LOCAL) { const c = lembrado("config"); if (c) configurarApp(c); } checarServidor(); }, 300);
|
||||
setInterval(checarServidor, 1000);
|
||||
|
||||
</script>
|
||||
<script src="/reacoes-render.js"></script>
|
||||
<script>
|
||||
@@ -1046,10 +1614,16 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
|
||||
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;
|
||||
|
||||
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 lastMotionFrame = -1;
|
||||
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) {
|
||||
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 facing = track.getSettings().facingMode;
|
||||
if (facing && facing !== (c.cameraLado || 'user')) {
|
||||
@@ -74,6 +79,12 @@ async function iniciar(id) {
|
||||
const media = await capturar(config); signature = chave(config);
|
||||
if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; }
|
||||
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:[]});
|
||||
sender = pc.addTrack(stream.getVideoTracks()[0], stream);
|
||||
const mine = pc;
|
||||
@@ -133,14 +144,23 @@ async function receber(packet) {
|
||||
}
|
||||
window.rtcReceber = packet => {
|
||||
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);
|
||||
sinal({tipo:'erro',erro:e.name + ': ' + e.message});
|
||||
estado({tipo:'webrtc',estado:'erro',erro:e.message});
|
||||
parar();
|
||||
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(() => {
|
||||
if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return;
|
||||
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) };
|
||||
})();
|
||||
@@ -0,0 +1,10 @@
|
||||
package br.carinha.rosto;
|
||||
|
||||
import android.app.admin.DeviceAdminReceiver;
|
||||
|
||||
/**
|
||||
* Administrador do aparelho. Vira "dono" (device owner) uma vez, pelo ADB:
|
||||
* adb shell dpm set-device-owner br.carinha.rosto/.Admin
|
||||
* A partir daí o app trava a tela nele mesmo (ver Quiosque).
|
||||
*/
|
||||
public final class Admin extends DeviceAdminReceiver { }
|
||||
@@ -28,6 +28,15 @@ public final class ControleRemoto {
|
||||
private volatile ScheduledExecutorService worker;
|
||||
private long consultarEm;
|
||||
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 final Runnable mudanca;
|
||||
public interface RTCListener { boolean receber(JSONObject pacote); }
|
||||
@@ -118,6 +127,7 @@ public final class ControleRemoto {
|
||||
mudanca.run();
|
||||
}
|
||||
if (videoRTC()) rtcTick(base);
|
||||
fotoTick(base);
|
||||
byte[] jpeg = pendente;
|
||||
pendente = null;
|
||||
if (jpeg != null && preview()) {
|
||||
@@ -147,10 +157,13 @@ public final class ControleRemoto {
|
||||
return c;
|
||||
}
|
||||
public void quadro(Image image, int rotacao) {
|
||||
if (videoRTC()) return;
|
||||
boolean querFoto = fotoPedida > 0 && fotoJpeg == null;
|
||||
long agora = System.currentTimeMillis();
|
||||
if (!preview() || pendente != null || agora < quadroEm) return;
|
||||
quadroEm = agora + 200;
|
||||
if (!querFoto) {
|
||||
if (videoRTC()) return;
|
||||
if (!preview() || pendente != null || agora < quadroEm) return;
|
||||
quadroEm = agora + 200;
|
||||
}
|
||||
try {
|
||||
Image.Plane[] p = image.getPlanes();
|
||||
ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()};
|
||||
@@ -171,10 +184,44 @@ public final class ControleRemoto {
|
||||
if (rotated != b) rotated.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); }
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
String json;
|
||||
while ((json = sinais.poll()) != null) {
|
||||
|
||||
@@ -85,7 +85,11 @@ public class MainActivity extends Activity {
|
||||
private static final String PREFS = "carinha";
|
||||
private static final String CHAVE = "servidor";
|
||||
private static final String CHAVE_TOKEN = "token";
|
||||
private static final String PADRAO = "http://192.168.1.15:8100";
|
||||
private static final String PADRAO = "http://192.168.1.35:8100"; // a Orange Pi
|
||||
private static final long ESPERA_SERVIDOR = 10000; // ms entre tentativas sem servidor
|
||||
/** O que a pagina pode guardar no app (sobrevive sem servidor; a copia do APK le). */
|
||||
private static final java.util.Set<String> LEMBRAVEIS =
|
||||
new java.util.HashSet<String>(Arrays.asList("aparencia", "config"));
|
||||
|
||||
private static final String TAG = "CarinhaMic";
|
||||
private static final String TAG_JS = "CarinhaJS"; // console.log da pagina
|
||||
@@ -198,6 +202,8 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
atualizarSensores();
|
||||
atualizarBrilho();
|
||||
if (remoto.fotoPendente() > 0 && rtcAtivo)
|
||||
rtcWeb.evaluateJavascript("window.rtcFoto&&window.rtcFoto()", null);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -245,6 +251,7 @@ public class MainActivity extends Activity {
|
||||
cfg.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
||||
}
|
||||
|
||||
web.addJavascriptInterface(new PonteApp(), "CarinhaApp");
|
||||
web.setWebViewClient(new WebViewClient() {
|
||||
@Override
|
||||
public void onReceivedError(WebView vista, WebResourceRequest pedido,
|
||||
@@ -412,7 +419,8 @@ public class MainActivity extends Activity {
|
||||
private void menu() {
|
||||
final String[] opcoes = {"Ver a camera", "Voltar para a carinha",
|
||||
(movimentoLigado ? "Desligar reacao ao movimento" : "Ligar reacao ao movimento"),
|
||||
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video", "Token do servidor"};
|
||||
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video", "Token do servidor",
|
||||
"Modo quiosque"};
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Carinha")
|
||||
.setItems(opcoes, new DialogInterface.OnClickListener() {
|
||||
@@ -424,12 +432,55 @@ public class MainActivity extends Activity {
|
||||
else if (i == 3) pedirServidor();
|
||||
else if (i == 4) menuSensores();
|
||||
else if (i == 5) menuVideo();
|
||||
else pedirToken();
|
||||
else if (i == 6) pedirToken();
|
||||
else menuQuiosque();
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
/** Manutenção: soltar a tela para usar o Android, ou deixar de ser dono do aparelho. */
|
||||
private void menuQuiosque() {
|
||||
if (!Quiosque.dono(this)) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Modo quiosque desligado")
|
||||
.setMessage("Para ligar, pelo PC:\nadb shell dpm set-device-owner br.carinha.rosto/.Admin")
|
||||
.setPositiveButton("OK", null)
|
||||
.show();
|
||||
return;
|
||||
}
|
||||
final boolean pausado = Quiosque.pausado();
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(pausado ? "Quiosque pausado" : "Quiosque ligado")
|
||||
.setItems(new String[]{
|
||||
pausado ? "Travar de novo" : "Pausar até reiniciar (abre o Android)",
|
||||
"Desligar o modo quiosque de vez"}, new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface d, int i) {
|
||||
if (i == 0) {
|
||||
if (pausado) { Quiosque.retomar(MainActivity.this); aviso("quiosque ligado"); }
|
||||
else { Quiosque.pausar(MainActivity.this); aviso("quiosque pausado: Home e barra liberados até reiniciar"); }
|
||||
} else confirmarRemoverQuiosque();
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void confirmarRemoverQuiosque() {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Desligar o modo quiosque?")
|
||||
.setMessage("A carinha deixa de ser dona do aparelho. Para voltar, será preciso o ADB de novo.")
|
||||
.setPositiveButton("Desligar", new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
Quiosque.remover(MainActivity.this);
|
||||
aviso("modo quiosque desligado");
|
||||
}
|
||||
})
|
||||
.setNegativeButton("Cancelar", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
/** Liga/desliga a reacao ao movimento (guardado nas preferencias). */
|
||||
private void salvarAjuste(String chave, Object valor) {
|
||||
try { JSONObject c = new JSONObject(); c.put(chave, valor); remoto.configurar(c);
|
||||
@@ -498,14 +549,63 @@ public class MainActivity extends Activity {
|
||||
Toast.makeText(this, texto, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
/** Servidor fora do ar: mostra a carinha embutida no APK (sem controle remoto). */
|
||||
/**
|
||||
* Servidor fora do ar: a copia do APK mostra a carinha dormindo, com a ultima aparencia
|
||||
* guardada, e o desenho "placa x celular". Enquanto isso o app tenta o servidor de novo.
|
||||
*/
|
||||
private void cairParaLocal(WebView vista) {
|
||||
if (jaCaiuParaLocal) return;
|
||||
jaCaiuParaLocal = true;
|
||||
usandoLocal = true;
|
||||
naCamera = false;
|
||||
aviso("sem servidor " + servidor + " — usando o rosto local");
|
||||
Log.i(TAG, "sem servidor " + servidor + ": rosto local dormindo, tentando de novo");
|
||||
vista.loadUrl("file:///android_asset/carinha.html");
|
||||
procurarServidor();
|
||||
}
|
||||
|
||||
/** Sem servidor: a cada 10 s pergunta se ele voltou; voltou, abre o rosto dele de novo. */
|
||||
private void procurarServidor() {
|
||||
web.removeCallbacks(tentarServidor);
|
||||
web.postDelayed(tentarServidor, ESPERA_SERVIDOR);
|
||||
}
|
||||
|
||||
private final Runnable tentarServidor = new Runnable() {
|
||||
@Override public void run() {
|
||||
if (!usandoLocal) return;
|
||||
if (!emPrimeiroPlano) { procurarServidor(); return; }
|
||||
final String alvo = servidor;
|
||||
new Thread(new Runnable() {
|
||||
@Override public void run() {
|
||||
final boolean voltou = servidorResponde(alvo);
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (!usandoLocal || !alvo.equals(servidor)) return;
|
||||
if (!voltou) { procurarServidor(); return; }
|
||||
Log.i(TAG, "servidor voltou: " + servidor);
|
||||
usandoLocal = false;
|
||||
jaCaiuParaLocal = false;
|
||||
abrirRosto();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, "procura-servidor").start();
|
||||
}
|
||||
};
|
||||
|
||||
/** Qualquer resposta HTTP (até 401 de token) quer dizer que o servidor está no ar. */
|
||||
private static boolean servidorResponde(String endereco) {
|
||||
java.net.HttpURLConnection c = null;
|
||||
try {
|
||||
c = (java.net.HttpURLConnection) new java.net.URL(endereco + "/config").openConnection();
|
||||
c.setConnectTimeout(2500);
|
||||
c.setReadTimeout(2500);
|
||||
c.setUseCaches(false);
|
||||
return c.getResponseCode() > 0;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
} finally {
|
||||
if (c != null) c.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private void pedirServidor() {
|
||||
@@ -740,12 +840,14 @@ public class MainActivity extends Activity {
|
||||
rtcWeb.onResume();
|
||||
web.evaluateJavascript("window.carinhaAtiva=true", null);
|
||||
telaCheia();
|
||||
Quiosque.aplicar(this);
|
||||
// voltou do segundo plano: atualiza o rosto — mas sem expulsar quem
|
||||
// estava vendo a pagina da camera
|
||||
if (jaRecarregou && !naCamera) {
|
||||
abrirRosto();
|
||||
}
|
||||
jaRecarregou = true;
|
||||
if (usandoLocal) procurarServidor();
|
||||
pedirPermissoes(); // religa o microfone (onPause desligou)
|
||||
if (!naCamera) iniciarMovimento(); // e volta a enxergar o movimento
|
||||
}
|
||||
@@ -1079,7 +1181,7 @@ public class MainActivity extends Activity {
|
||||
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>() {
|
||||
@Override public void onReceiveValue(String valor) {
|
||||
if (!emPrimeiroPlano) return;
|
||||
@@ -1091,6 +1193,7 @@ public class MainActivity extends Activity {
|
||||
float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(),
|
||||
System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(),
|
||||
estado.optBoolean("ocupado"), estado.optBoolean("dormindo"));
|
||||
if (estado.optBoolean("apagada")) nivel = 0f; // tela preta + brilho mínimo
|
||||
WindowManager.LayoutParams attr = getWindow().getAttributes();
|
||||
if (Math.abs(attr.screenBrightness - nivel) > 0.005f) {
|
||||
attr.screenBrightness = nivel;
|
||||
@@ -1103,7 +1206,29 @@ public class MainActivity extends Activity {
|
||||
});
|
||||
}
|
||||
|
||||
/** window.CarinhaApp: guarda a ultima aparencia/ajustes vindos do servidor para a copia do APK. */
|
||||
private final class PonteApp {
|
||||
@JavascriptInterface public void guardar(String chave, String json) {
|
||||
if (!LEMBRAVEIS.contains(chave) || json == null || json.length() > 50000) return;
|
||||
try { new JSONObject(json); } catch (Exception e) { return; }
|
||||
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString("ultimo_" + chave, json).apply();
|
||||
}
|
||||
@JavascriptInterface public String lembrado(String chave) {
|
||||
if (!LEMBRAVEIS.contains(chave)) return null;
|
||||
return getSharedPreferences(PREFS, MODE_PRIVATE).getString("ultimo_" + chave, null);
|
||||
}
|
||||
@JavascriptInterface public String servidor() { return servidor; }
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package br.carinha.rosto;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.Activity;
|
||||
import android.app.ActivityManager;
|
||||
import android.app.admin.DevicePolicyManager;
|
||||
import android.app.admin.SystemUpdatePolicy;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.BatteryManager;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
|
||||
/**
|
||||
* Modo quiosque: com o app como dono do aparelho, a carinha é a tela inicial,
|
||||
* abre sozinha ao ligar e ninguém sai dela (sem barra de status, sem tela de bloqueio).
|
||||
* Sem ser dono, tudo aqui é ignorado e o app funciona como antes.
|
||||
*/
|
||||
public final class Quiosque {
|
||||
private static final String TAG = "CarinhaQuiosque";
|
||||
/** Pausado pelo menu para manutenção; volta sozinho quando o celular reinicia. */
|
||||
private static boolean pausado;
|
||||
|
||||
private Quiosque() { }
|
||||
|
||||
private static DevicePolicyManager dpm(Context c) {
|
||||
return (DevicePolicyManager) c.getSystemService(Context.DEVICE_POLICY_SERVICE);
|
||||
}
|
||||
|
||||
private static ComponentName admin(Context c) { return new ComponentName(c, Admin.class); }
|
||||
|
||||
public static boolean dono(Context c) {
|
||||
DevicePolicyManager d = dpm(c);
|
||||
return d != null && d.isDeviceOwnerApp(c.getPackageName());
|
||||
}
|
||||
|
||||
public static boolean pausado() { return pausado; }
|
||||
|
||||
/** Chamado no onResume: reaplica as travas (idempotente). */
|
||||
public static void aplicar(Activity a) {
|
||||
if (pausado || !dono(a)) return;
|
||||
DevicePolicyManager d = dpm(a);
|
||||
ComponentName adm = admin(a);
|
||||
String pacote = a.getPackageName();
|
||||
try {
|
||||
d.setLockTaskPackages(adm, new String[]{pacote});
|
||||
// a carinha é a tela inicial: botão Home e o fim do boot caem nela
|
||||
IntentFilter inicio = new IntentFilter(Intent.ACTION_MAIN);
|
||||
inicio.addCategory(Intent.CATEGORY_HOME);
|
||||
inicio.addCategory(Intent.CATEGORY_DEFAULT);
|
||||
d.addPersistentPreferredActivity(adm, inicio, new ComponentName(a, MainActivity.class));
|
||||
// tela ligada enquanto estiver na tomada, USB ou carregador sem fio
|
||||
d.setGlobalSetting(adm, Settings.Global.STAY_ON_WHILE_PLUGGED_IN, String.valueOf(
|
||||
BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS));
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
d.setKeyguardDisabled(adm, true);
|
||||
d.setStatusBarDisabled(adm, true);
|
||||
// atualização do sistema só de madrugada (3h–5h), nunca no meio do dia
|
||||
d.setSystemUpdatePolicy(adm, SystemUpdatePolicy.createWindowedInstallPolicy(180, 300));
|
||||
// câmera e microfone sem perguntar, inclusive depois de reinstalar o app
|
||||
for (String p : new String[]{Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO})
|
||||
d.setPermissionGrantState(adm, pacote, p, DevicePolicyManager.PERMISSION_GRANT_STATE_GRANTED);
|
||||
}
|
||||
// o botão Ligar continua abrindo o menu de desligar/reiniciar
|
||||
if (Build.VERSION.SDK_INT >= 28)
|
||||
d.setLockTaskFeatures(adm, DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS);
|
||||
if (!travado(a)) a.startLockTask();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "aplicar: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Solta a tela até o próximo reinício, para mexer nas configurações do Android. */
|
||||
public static void pausar(Activity a) {
|
||||
if (!dono(a)) return;
|
||||
pausado = true;
|
||||
DevicePolicyManager d = dpm(a);
|
||||
ComponentName adm = admin(a);
|
||||
try {
|
||||
if (travado(a)) a.stopLockTask();
|
||||
d.clearPackagePersistentPreferredActivities(adm, a.getPackageName());
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
d.setKeyguardDisabled(adm, false);
|
||||
d.setStatusBarDisabled(adm, false);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "pausar: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Volta a travar sem precisar reiniciar. */
|
||||
public static void retomar(Activity a) {
|
||||
pausado = false;
|
||||
aplicar(a);
|
||||
}
|
||||
|
||||
/** Deixa de ser dono do aparelho de vez (para desinstalar ou trocar de app). */
|
||||
@SuppressWarnings("deprecation")
|
||||
public static void remover(Activity a) {
|
||||
if (!dono(a)) return;
|
||||
pausar(a);
|
||||
try {
|
||||
dpm(a).clearDeviceOwnerApp(a.getPackageName());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "remover: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean travado(Activity a) {
|
||||
ActivityManager am = (ActivityManager) a.getSystemService(Context.ACTIVITY_SERVICE);
|
||||
if (am == null) return false;
|
||||
if (Build.VERSION.SDK_INT >= 23) return am.getLockTaskModeState() != ActivityManager.LOCK_TASK_MODE_NONE;
|
||||
return am.isInLockTaskMode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Como dono do aparelho, o quiosque não precisa de políticas de administrador antigas -->
|
||||
<device-admin>
|
||||
<uses-policies />
|
||||
</device-admin>
|
||||
@@ -30,7 +30,7 @@ def main():
|
||||
def run(*command):
|
||||
subprocess.run([str(x) for x in command], check=True)
|
||||
|
||||
for name in ('carinha.html', 'camera.html'):
|
||||
for name in ('carinha.html', 'camera.html', 'rosto-motor.js', 'voz-robo.js'):
|
||||
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
|
||||
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
|
||||
tmp = Path(folder)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Desativa no S10 os apps que não servem ao robô (reversível: .\enxugar-s10.ps1 -Reativar)
|
||||
param([switch]$Reativar)
|
||||
$adb = 'C:/Users/JullioPC/Documents/Open/orange-pi-zero3/android/tools/android-sdk/platform-tools/adb.exe'
|
||||
$pacotes = @(
|
||||
# Bixby
|
||||
'com.samsung.android.bixby.agent', 'com.samsung.android.bixby.service', 'com.samsung.android.bixby.wakeup',
|
||||
'com.samsung.systemui.bixby2', 'com.samsung.android.app.settings.bixby', 'com.samsung.android.visionintelligence',
|
||||
'com.samsung.android.app.spage', 'com.samsung.android.app.routines',
|
||||
# enfeites da Samsung
|
||||
'com.samsung.android.app.tips', 'com.samsung.android.aremoji', 'com.samsung.android.ardrawing', 'com.samsung.android.arzone',
|
||||
'com.samsung.android.livestickers', 'com.samsung.android.stickercenter', 'com.samsung.android.app.camera.sticker.facearavatar.preload',
|
||||
'com.samsung.android.app.dressroom', 'com.samsung.android.app.ledbackcover', 'com.samsung.android.app.watchmanagerstub',
|
||||
'com.samsung.android.themestore', 'com.samsung.android.app.aodservice', 'com.samsung.android.game.gamehome',
|
||||
'com.samsung.android.game.gametools', 'com.samsung.android.app.reminder', 'com.samsung.android.forest',
|
||||
'com.sec.android.app.vepreload', 'com.samsung.android.mateagent', 'com.samsung.android.beaconmanager',
|
||||
'com.samsung.android.kidsinstaller',
|
||||
# contas e loja Samsung
|
||||
'com.sec.android.app.samsungapps', 'com.sec.android.app.billing', 'com.samsung.android.scloud',
|
||||
'com.samsung.android.samsungpass', 'com.samsung.android.samsungpassautofill', 'com.samsung.android.spayfw',
|
||||
'com.samsung.android.smartswitchassistant', 'com.sec.android.easyMover.Agent', 'com.samsung.android.easysetup',
|
||||
'com.samsung.android.app.sharelive', 'com.sec.android.app.desktoplauncher', 'com.sec.android.desktopmode.uiservice',
|
||||
'com.samsung.android.mdx', 'com.samsung.android.mdx.kit', 'com.samsung.android.mdx.quickboard',
|
||||
# apps de uso pessoal
|
||||
'com.samsung.android.messaging', 'com.samsung.android.calendar', 'com.google.android.gm', 'com.google.android.youtube',
|
||||
'com.google.android.apps.maps', 'com.google.android.apps.tachyon', 'com.google.android.googlequicksearchbox',
|
||||
# pré-instalados
|
||||
'com.facebook.katana', 'com.facebook.services', 'com.facebook.system', 'com.facebook.appmanager',
|
||||
'com.microsoft.skydrive', 'com.microsoft.appmanager', 'br.com.bemobi.appsclub.vivo'
|
||||
)
|
||||
$acao = if ($Reativar) { 'enable' } else { 'disable-user --user 0' }
|
||||
$ok = 0; $falhas = @()
|
||||
foreach ($p in $pacotes) {
|
||||
$r = & $adb -d shell "pm $acao $p" 2>&1 | Out-String
|
||||
if ($r -match 'new state') { $ok++ } else { $falhas += "$p -> $($r.Trim())" }
|
||||
}
|
||||
"$ok de $($pacotes.Count) " + $(if ($Reativar) { 'reativados' } else { 'desativados' })
|
||||
$falhas
|
||||
+626
-52
@@ -5,7 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
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}
|
||||
.traco{fill:none;stroke-linecap:round}
|
||||
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
|
||||
@@ -14,6 +20,52 @@
|
||||
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
|
||||
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
|
||||
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
|
||||
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
|
||||
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
|
||||
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
|
||||
z-index:5;pointer-events:none;text-align:center;opacity:.82;font-family:system-ui,sans-serif;
|
||||
animation:semServidorEntra 1.2s ease-out}
|
||||
#semServidor svg{display:block;margin:0 auto;overflow:visible;filter:drop-shadow(0 0 3px currentColor)}
|
||||
#semServidor svg.horizontal{display:none}
|
||||
#semServidor svg.vertical{height:58vh;width:auto;max-width:100%}
|
||||
#semServidor svg.vertical .olhinhos{animation-name:semServidorOlharCima}
|
||||
@media (orientation:portrait){
|
||||
body.semServidor #tela,body.semServidor #rostoMotor{transform:translateY(-20%) scale(.8)}
|
||||
#semServidor{right:auto;top:auto;left:50%;bottom:3.5vh;transform:translateX(-50%);width:min(80vw,400px)}
|
||||
#semServidor svg.horizontal{display:block;width:100%;height:auto}
|
||||
#semServidor svg.vertical{display:none}
|
||||
}
|
||||
#semServidor .titulo{text-wrap:balance;font-weight:600;font-size:clamp(12px,3.2vh,20px);margin-top:.4em;letter-spacing:.02em}
|
||||
#semServidor .sub{font-size:clamp(10px,2.4vh,15px);opacity:.6;margin-top:.25em;font-variant-numeric:tabular-nums}
|
||||
#semServidor .placa rect,#semServidor .celular>rect{fill:none;stroke:currentColor;stroke-width:3}
|
||||
#semServidor .chip{fill:currentColor;fill-opacity:.25}
|
||||
#semServidor .pino{fill:currentColor;opacity:.7}
|
||||
#semServidor .led{fill:#ff5a5a;animation:semServidorLed 1.6s steps(1) infinite}
|
||||
#semServidor .cabo{stroke:currentColor;stroke-width:3;stroke-dasharray:5 6;opacity:.5;fill:none}
|
||||
#semServidor .arco{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;opacity:0;
|
||||
animation:semServidorArco 2.4s ease-out infinite}
|
||||
#semServidor .a2{animation-delay:.3s}
|
||||
#semServidor .a3{animation-delay:.6s}
|
||||
#semServidor .xis path{stroke:#ff5a5a;stroke-width:5;stroke-linecap:round;fill:none}
|
||||
#semServidor .xis{transform-box:fill-box;transform-origin:center;animation:semServidorXis 2.4s ease-in-out infinite}
|
||||
#semServidor .olhinhos{fill:currentColor;transform-box:fill-box;transform-origin:center;
|
||||
animation:semServidorOlhar 5s ease-in-out infinite}
|
||||
#semServidor .boquinha{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round}
|
||||
#semServidor .celular{transform-box:fill-box;transform-origin:50% 100%;animation:semServidorProcura 4.8s ease-in-out infinite}
|
||||
#semServidor.cutucado .celular{animation:semServidorChacoalha .5s ease-in-out}
|
||||
@keyframes semServidorEntra{from{opacity:0}to{opacity:.82}}
|
||||
/* em pé no canto direito: o celularzinho olha para cima, para a placa */
|
||||
@keyframes semServidorOlharCima{0%,30%{transform:translateY(-3px) scaleY(1)}34%{transform:translateY(-3px) scaleY(.1)}38%,60%{transform:translateY(-3px) scaleY(1)}
|
||||
70%,90%{transform:translateY(1px) scaleY(1)}100%{transform:translateY(-3px)}}
|
||||
@keyframes semServidorArco{0%{opacity:0}25%{opacity:.9}70%,100%{opacity:0}}
|
||||
@keyframes semServidorXis{0%,55%,100%{transform:scale(1);opacity:.75}65%{transform:scale(1.25);opacity:1}80%{transform:scale(1);opacity:.85}}
|
||||
@keyframes semServidorLed{0%{opacity:1}50%{opacity:.15}}
|
||||
/* o celularzinho olha para a placa, olha de volta e pisca */
|
||||
@keyframes semServidorOlhar{0%,30%{transform:translateX(-3px) scaleY(1)}34%{transform:translateX(-3px) scaleY(.1)}38%,60%{transform:translateX(-3px) scaleY(1)}
|
||||
70%,90%{transform:translateX(1px) scaleY(1)}100%{transform:translateX(-3px)}}
|
||||
@keyframes semServidorProcura{0%,100%{transform:rotate(0)}20%{transform:rotate(-5deg)}40%{transform:rotate(0)}}
|
||||
@keyframes semServidorChacoalha{0%,100%{transform:rotate(0)}25%{transform:rotate(-9deg)}50%{transform:rotate(7deg)}75%{transform:rotate(-4deg)}}
|
||||
@media(prefers-reduced-motion:reduce){#semServidor *,#semServidor{animation:none!important}#semServidor .arco{opacity:.6}}
|
||||
</style>
|
||||
<body>
|
||||
<svg id="tela" viewBox="0 0 480 320" preserveAspectRatio="xMidYMid meet">
|
||||
@@ -25,6 +77,8 @@
|
||||
<path id="sobrancelha2" class="traco" style="display:none"/>
|
||||
</svg>
|
||||
<div id="fps"></div>
|
||||
<script src="rosto-motor.js"></script>
|
||||
<script src="voz-robo.js"></script>
|
||||
<script>
|
||||
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
|
||||
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
|
||||
@@ -90,12 +144,33 @@ addEventListener("resize", ajustar);
|
||||
|
||||
const PRETO = [6, 8, 14];
|
||||
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 && !window.carinhaSemServidor && 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 VERM = [255, 78, 78];
|
||||
const ROSA = [255, 112, 176];
|
||||
const AMARELO = [255, 218, 96];
|
||||
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 BRANCO = [245, 250, 255];
|
||||
|
||||
@@ -103,10 +178,17 @@ const BRANCO = [245, 250, 255];
|
||||
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
|
||||
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
|
||||
const ROSTOS = {
|
||||
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
|
||||
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
|
||||
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
|
||||
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
|
||||
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
|
||||
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
|
||||
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
|
||||
};
|
||||
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
|
||||
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
|
||||
let FORMATO = "padrao";
|
||||
let ROSTO_NOME = "carinha";
|
||||
let ROSTO = ROSTOS[ROSTO_NOME];
|
||||
|
||||
@@ -141,8 +223,18 @@ function caminhoLinha(x0, y0, x1, y1) {
|
||||
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
||||
}
|
||||
/* 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) {
|
||||
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"
|
||||
const rx = l * 0.17, ry = l * 0.22;
|
||||
return caminhoElipse(cx, cy, rx, ry);
|
||||
@@ -163,8 +255,25 @@ function caminhoBoca(tipo) {
|
||||
return "M " + (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
|
||||
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0";
|
||||
// repouso: leve, com as microexpressões (canto que sobe, boca de lado, entreaberta)
|
||||
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
|
||||
@@ -216,6 +325,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.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) {
|
||||
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
|
||||
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
|
||||
@@ -283,7 +399,7 @@ function sequencias() {
|
||||
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],
|
||||
[{ 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],
|
||||
[{ 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],
|
||||
@@ -292,13 +408,37 @@ function sequencias() {
|
||||
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],
|
||||
[{ 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]];
|
||||
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
||||
[{ 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],
|
||||
[{ 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 };
|
||||
}
|
||||
|
||||
@@ -311,12 +451,92 @@ const r = {
|
||||
const SEQS = sequencias();
|
||||
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
|
||||
"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() {
|
||||
if (!r.fila.length) { r.kf = null; return; }
|
||||
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.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 ("forma2" in alvo) r.forma2 = alvo.forma2;
|
||||
if ("boca" in alvo) r.boca = alvo.boca;
|
||||
@@ -324,7 +544,22 @@ function proximo() {
|
||||
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) {
|
||||
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]) {
|
||||
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
|
||||
proximo();
|
||||
@@ -348,10 +583,40 @@ function passo(dt) { // avanca a fila; true se ha gesto em
|
||||
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"
|
||||
if (r.dormindo) {
|
||||
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.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
||||
r.dx = 0;
|
||||
@@ -362,9 +627,28 @@ function repouso(t) { // pose calma: olha devagar, "respira
|
||||
r.boca = "neutra";
|
||||
r.cor = CIANO.slice();
|
||||
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)
|
||||
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
|
||||
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
|
||||
// olhar de gente: fixa num ponto por 1 a 4 s e salta rápido para outro (quase sempre perto do
|
||||
// centro, às vezes para o lado), mais a direção de quem se mexeu na frente (se houver)
|
||||
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() {
|
||||
@@ -376,32 +660,61 @@ function desenhar() {
|
||||
// desliza na direcao do movimento; parado, volta ao repouso discreto
|
||||
const tAgora = performance.now() / 1000;
|
||||
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 dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
|
||||
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const comMotor = ROSTO.motor && window.MotorRosto;
|
||||
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];
|
||||
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
|
||||
abx, fx, r.cor, lado, dx, dy * 0.6);
|
||||
}
|
||||
desenharBoca();
|
||||
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++) {
|
||||
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;
|
||||
const c = i === 0 ? E1 : E2;
|
||||
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('d', caminhoSobrancelha(i, esc));
|
||||
brow.setAttribute('stroke', rgb(r.cor));
|
||||
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
|
||||
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
||||
function trocarRosto(nome) {
|
||||
@@ -430,22 +743,51 @@ function trata(ordem) {
|
||||
trocarRosto(ordem.slice(6).trim());
|
||||
} else if (ROSTOS[ordem]) {
|
||||
trocarRosto(ordem); // aceita so o nome: "miudo"
|
||||
} else if (ordem.indexOf("formato ") === 0) {
|
||||
const f = ordem.slice(8).trim();
|
||||
if (FORMATOS.includes(f)) FORMATO = f;
|
||||
} else if (ordem.indexOf("cor ") === 0) {
|
||||
const v = ordem.slice(4).split(",").map(Number);
|
||||
if (v.length === 3 && v.every((n) => !isNaN(n))) {
|
||||
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") {
|
||||
r.dormindo = true;
|
||||
atividade(null);
|
||||
} else if (ordem === "acordar") {
|
||||
if (window.carinhaSemServidor) { // sem a placa continua dormindo; o aviso chacoalha
|
||||
const aviso = document.getElementById("semServidor");
|
||||
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
|
||||
return;
|
||||
}
|
||||
r.dormindo = false; animar("surpreso");
|
||||
} else if (ordem === "aleatorio") {
|
||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||
} else if (SEQS[ordem]) {
|
||||
if (window.MotorRosto) {
|
||||
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
||||
}
|
||||
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") {
|
||||
try { window.close(); } catch (e) { r.fila = []; }
|
||||
}
|
||||
@@ -462,7 +804,7 @@ function pose(nome) {
|
||||
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
|
||||
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;
|
||||
desenhar();
|
||||
return true;
|
||||
@@ -486,26 +828,44 @@ async function buscarOrdens() {
|
||||
try {
|
||||
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
|
||||
if (!resp.ok) return;
|
||||
conexaoOk();
|
||||
const lista = await resp.json();
|
||||
for (const nome of lista) trata(nome);
|
||||
} catch (e) { /* sem servidor (file://) — ignora */ }
|
||||
}
|
||||
setInterval(buscarOrdens, 500);
|
||||
|
||||
// O app (window.CarinhaApp) guarda a ultima aparencia e os ajustes vindos do servidor:
|
||||
// a copia do APK (file://) nao enxerga o armazenamento da pagina do servidor.
|
||||
const LOCAL = location.protocol === "file:";
|
||||
function lembrar(chave, valor) {
|
||||
try { if (window.CarinhaApp && !LOCAL) window.CarinhaApp.guardar(chave, JSON.stringify(valor)); } catch (e) {}
|
||||
}
|
||||
function lembrado(chave) {
|
||||
try { const s = window.CarinhaApp && window.CarinhaApp.lembrado(chave); return s ? JSON.parse(s) : null; } catch (e) { return null; }
|
||||
}
|
||||
|
||||
// A aparencia guardada no servidor (rosto + cor) e aplicada ao carregar: o
|
||||
// painel de controle personaliza e a carinha ja abre do jeito escolhido. Sem
|
||||
// servidor (file://) isso falha calado e vale o padrao.
|
||||
// servidor vale a ultima que o app guardou (ou o padrao).
|
||||
async function aplicarAparencia() {
|
||||
try {
|
||||
const resp = await fetch("config", { cache: "no-store" });
|
||||
if (!resp.ok) return;
|
||||
const c = await resp.json();
|
||||
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
|
||||
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
|
||||
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
|
||||
} catch (e) { /* sem servidor — segue com o padrao */ }
|
||||
let c = null;
|
||||
if (!LOCAL) {
|
||||
try {
|
||||
const resp = await fetch("config", { cache: "no-store" });
|
||||
if (resp.ok) c = await resp.json();
|
||||
} catch (e) { /* sem servidor */ }
|
||||
}
|
||||
if (!c) c = lembrado("aparencia");
|
||||
if (!c) return;
|
||||
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
|
||||
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
|
||||
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
|
||||
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
|
||||
}
|
||||
setTimeout(aplicarAparencia, 200);
|
||||
// O painel troca rosto/cor por ordens: guarda a aparencia atual de tempos em tempos.
|
||||
setInterval(() => { if (!window.carinhaSemServidor) lembrar("aparencia", { rosto: ROSTO_NOME, formato: FORMATO, cor: CIANO.slice() }); }, 15000);
|
||||
// ?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';
|
||||
@@ -516,9 +876,31 @@ if (MODO_PAINEL) {
|
||||
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)
|
||||
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
|
||||
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
|
||||
@@ -558,6 +940,49 @@ const MIC = {
|
||||
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) {
|
||||
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
|
||||
const agora = performance.now() / 1000;
|
||||
@@ -572,8 +997,8 @@ function som(nivel) {
|
||||
if (MIC.piso === null) MIC.piso = n;
|
||||
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
|
||||
|
||||
const temSom = n >= MIC.limiarSom; // ruido audivel (absoluto)
|
||||
const alto = n >= MIC.limiarAlto && (n - MIC.piso) >= MIC.ganho;
|
||||
const temSom = n >= limiar(MIC.limiarSom); // ruido audivel (absoluto)
|
||||
const alto = n >= limiar(MIC.limiarAlto) && (n - MIC.piso) >= limiar(MIC.ganho);
|
||||
if (temSom) MIC.ultimoSom = agora;
|
||||
|
||||
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
|
||||
@@ -582,15 +1007,19 @@ function som(nivel) {
|
||||
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;
|
||||
animar("surpreso");
|
||||
MIC.proxSurpresa = agora + MIC.cooldown;
|
||||
console.log("ouvido: acordou nivel=" + n.toFixed(2));
|
||||
relatarSom("acordou", n);
|
||||
} else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) {
|
||||
MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino
|
||||
animar("surpreso");
|
||||
}
|
||||
} else if (contar(CONTA.somReagir, PERFIL.qualquer ? temSom : alto, agora, PERFIL.reagir, "ouvido: estimulo") &&
|
||||
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));
|
||||
relatarSom("surpreso", n);
|
||||
}
|
||||
@@ -603,6 +1032,7 @@ function checarSilencio(agora) {
|
||||
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
|
||||
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
|
||||
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
|
||||
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
|
||||
if (agora - atividade >= MIC.segSilencio) {
|
||||
@@ -616,6 +1046,25 @@ function checarSilencio(agora) {
|
||||
journal do carinha-web (so quando o microfone esta ativo). */
|
||||
function relatarSom(evento, n) {
|
||||
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)
|
||||
@@ -655,7 +1104,7 @@ function rastrear(t) {
|
||||
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
|
||||
if(t.atividade===true) {
|
||||
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) {
|
||||
@@ -669,7 +1118,7 @@ function movimento(vel, cx, cy) {
|
||||
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; 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;
|
||||
|
||||
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
|
||||
@@ -684,17 +1133,21 @@ function movimento(vel, cx, cy) {
|
||||
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
|
||||
}
|
||||
|
||||
const forte = v >= MOV.limiarForte;
|
||||
if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente
|
||||
const forte = v >= limiar(MOV.limiarForte);
|
||||
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;
|
||||
animar("surpreso");
|
||||
MOV.proxSurpresa = agora + MOV.cooldown;
|
||||
console.log("presenca: acordou nivel=" + v.toFixed(2));
|
||||
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
|
||||
MOV.proxSurpresa = agora + MOV.cooldown;
|
||||
animar("surpreso");
|
||||
MOV.proxSurpresa = agora + MOV.cooldown * PERFIL.espera;
|
||||
animar(reacaoEstimulo());
|
||||
console.log("presenca: surpreso nivel=" + v.toFixed(2));
|
||||
relatarMov("surpreso", v);
|
||||
}
|
||||
@@ -704,6 +1157,7 @@ function movimento(vel, cx, cy) {
|
||||
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
|
||||
function relatarMov(evento, n) {
|
||||
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||
registrarEvento("movimento", evento, n);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- camera oculta (sensor)
|
||||
@@ -834,7 +1288,15 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
||||
|
||||
let configRecebida = null;
|
||||
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;
|
||||
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
||||
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
||||
@@ -844,6 +1306,7 @@ function configurarApp(c) {
|
||||
MOV.cooldown = c.movcooldown; MOV.olharMax = c.movolhar;
|
||||
if (!c.reagir) { MOV.olharX = MOV.olharY = 0; MOV.ultimoMov = 0; }
|
||||
configRecebida = c;
|
||||
lembrar('config', c);
|
||||
if(!c.seguirRastreamento || !c.sensorCamera || !c.reagir) TRACK.alvo=null;
|
||||
}
|
||||
let sincronizando = false;
|
||||
@@ -864,6 +1327,8 @@ async function sincronizarApp() {
|
||||
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
|
||||
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
||||
brilho: window.carinhaBrilho,
|
||||
tela_apagada: !!window.carinhaTelaApagada,
|
||||
atividade: window.MotorRosto && window.MotorRosto.atividadeAtual ? window.MotorRosto.atividadeAtual() : null,
|
||||
bateria: window.carinhaBateria,
|
||||
aparelho: window.carinhaAparelho,
|
||||
video: window.carinhaVideo,
|
||||
@@ -910,15 +1375,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); },
|
||||
};
|
||||
|
||||
/* 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) {
|
||||
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];
|
||||
if (!f) return false;
|
||||
if (!voz && !f) return false;
|
||||
const c = contextoAudio();
|
||||
if (!c) return false;
|
||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||
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
|
||||
console.log("voz: " + gesto + " ctx=" + c.state);
|
||||
return true;
|
||||
@@ -944,7 +1426,13 @@ function laco(t) {
|
||||
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
|
||||
|
||||
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);
|
||||
|
||||
if (window.carinhaSemServidor && !r.dormindo) { r.fila = []; r.kf = null; r.dormindo = true; } // sem a placa: só dorme
|
||||
const ocupado = congelado ? true : passo(dt);
|
||||
if (!ocupado) {
|
||||
descanso += dt;
|
||||
@@ -954,15 +1442,18 @@ function laco(t) {
|
||||
} else if (t >= proxPiscar) {
|
||||
animar("piscar");
|
||||
proxPiscar = t + 1600 + Math.random() * 3500;
|
||||
} else if (AUTO && t >= proxGesto) {
|
||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||
proxGesto = t + 7000 + Math.random() * 8000;
|
||||
} else if (AUTO && t >= proxGesto && !ATIV) {
|
||||
animar(gestoSolto());
|
||||
// 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) {
|
||||
proxGesto = t + 7000 + Math.random() * 8000;
|
||||
}
|
||||
|
||||
desenhar();
|
||||
if (window.MotorRosto && window.MotorRosto.palco) window.MotorRosto.palco({ t: t / 1000, cor: CIANO });
|
||||
|
||||
contador++;
|
||||
if (t - marco >= 1000) {
|
||||
@@ -1005,12 +1496,89 @@ setTimeout(relatar, 1500);
|
||||
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
|
||||
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
|
||||
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
|
||||
falar(m.texto);
|
||||
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;}
|
||||
}
|
||||
setInterval(poll,1000);poll();
|
||||
})();
|
||||
|
||||
/* ---------------------------------------------------------------- sem servidor
|
||||
Sem a placa (copia do APK, ou o servidor caiu com a pagina aberta) a carinha dorme com a
|
||||
ultima aparencia e ajustes guardados e mostra placa ✕ celular. Volta sozinha quando a placa
|
||||
responde (o app recarrega a pagina do servidor; aberta pelo servidor, o SSE reconecta). */
|
||||
let ultimoOk = performance.now();
|
||||
const TOLERANCIA_SEM_SERVIDOR = 15000; // ms sem resposta antes de dormir
|
||||
function conexaoOk() { ultimoOk = performance.now(); }
|
||||
|
||||
function mostrarSemServidor(ligar) {
|
||||
let el = document.getElementById("semServidor");
|
||||
document.body.classList.toggle("semServidor", ligar);
|
||||
if (!ligar) { if (el) el.hidden = true; return; }
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.id = "semServidor";
|
||||
// placa e celular pelo canto de cima à esquerda; o desenho vertical vai no canto direito com o celular deitado
|
||||
const placa = (px, py) =>
|
||||
'<g class="placa">' +
|
||||
'<rect x="' + px + '" y="' + py + '" width="84" height="56" rx="6"/>' +
|
||||
'<rect class="chip" x="' + (px + 29) + '" y="' + (py + 15) + '" width="26" height="26" rx="3"/>' +
|
||||
[0,1,2,3,4,5,6,7].map((i) => '<circle class="pino" cx="' + (px + 11 + i * 9) + '" cy="' + (py + 7) + '" r="2"/>').join("") +
|
||||
'<circle class="pino" cx="' + (px + 7) + '" cy="' + (py + 50) + '" r="2.4"/><circle class="pino" cx="' + (px + 77) + '" cy="' + (py + 50) + '" r="2.4"/>' +
|
||||
'<circle class="led" cx="' + (px + 72) + '" cy="' + (py + 22) + '" r="2.6"/>' +
|
||||
'</g>';
|
||||
const celular = (cx, cy) =>
|
||||
'<g class="celular">' +
|
||||
'<rect x="' + cx + '" y="' + cy + '" width="40" height="76" rx="8"/>' +
|
||||
'<g class="olhinhos"><ellipse cx="' + (cx + 13) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/><ellipse cx="' + (cx + 27) + '" cy="' + (cy + 34) + '" rx="3.2" ry="4.2"/></g>' +
|
||||
'<path class="boquinha" d="M' + (cx + 14) + ' ' + (cy + 48) + ' Q' + (cx + 20) + ' ' + (cy + 44) + ' ' + (cx + 26) + ' ' + (cy + 48) + '"/>' +
|
||||
'</g>';
|
||||
const xis = (x, y) => '<g class="xis"><path d="M' + (x - 10) + ' ' + (y - 10) + ' L' + (x + 10) + ' ' + (y + 10) + ' M' + (x + 10) + ' ' + (y - 10) + ' L' + (x - 10) + ' ' + (y + 10) + '"/></g>';
|
||||
el.innerHTML =
|
||||
'<svg class="horizontal" viewBox="0 0 300 104" aria-hidden="true">' + placa(18, 30) +
|
||||
'<path class="cabo" d="M104 58 H146"/>' +
|
||||
'<g class="arcos">' +
|
||||
'<path class="arco a1" d="M212 50 A10 10 0 0 0 212 66"/>' +
|
||||
'<path class="arco a2" d="M203 44 A18 18 0 0 0 203 72"/>' +
|
||||
'<path class="arco a3" d="M194 38 A26 26 0 0 0 194 78"/>' +
|
||||
'</g>' + xis(160, 58) + celular(220, 18) +
|
||||
'</svg>' +
|
||||
'<svg class="vertical" viewBox="0 0 120 262" aria-hidden="true">' + placa(18, 8) +
|
||||
'<path class="cabo" d="M60 66 V108"/>' +
|
||||
'<g class="arcos">' +
|
||||
'<path class="arco a1" d="M52 172 A10 10 0 0 1 68 172"/>' +
|
||||
'<path class="arco a2" d="M46 163 A18 18 0 0 1 74 163"/>' +
|
||||
'<path class="arco a3" d="M40 154 A26 26 0 0 1 80 154"/>' +
|
||||
'</g>' + xis(60, 122) + celular(40, 182) +
|
||||
'</svg>' +
|
||||
'<div class="titulo">sem conexão com a placa</div>' +
|
||||
'<div class="sub"></div>';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
let alvo = "";
|
||||
try { alvo = (window.CarinhaApp && window.CarinhaApp.servidor()) || (LOCAL ? "" : location.host); } catch (e) {}
|
||||
el.querySelector(".sub").textContent = "tentando de novo" + (alvo ? " · " + String(alvo).replace(/^https?:\/\//, "") : "") + "…";
|
||||
el.style.color = "rgb(" + CIANO.map(Math.round).join(",") + ")";
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function checarServidor() {
|
||||
if (MODO_PAINEL || par.get("espelho") === "1") return;
|
||||
if (window.carinhaSSE) conexaoOk();
|
||||
const fora = LOCAL || performance.now() - ultimoOk > TOLERANCIA_SEM_SERVIDOR;
|
||||
if (window.carinhaSemServidor) mostrarSemServidor(true); // acompanha a cor guardada
|
||||
if (fora === !!window.carinhaSemServidor) return;
|
||||
window.carinhaSemServidor = fora;
|
||||
console.log(fora ? "sem servidor: dormindo" : "servidor de volta");
|
||||
mostrarSemServidor(fora);
|
||||
if (fora) { atividade(null); r.fila = []; r.kf = null; r.dormindo = true; }
|
||||
else trata("acordar");
|
||||
}
|
||||
// Copia do APK: os ultimos ajustes guardados (boca, sobrancelhas, sono...) valem também aqui.
|
||||
setTimeout(() => { if (LOCAL) { const c = lembrado("config"); if (c) configurarApp(c); } checarServidor(); }, 300);
|
||||
setInterval(checarServidor, 1000);
|
||||
|
||||
</script>
|
||||
<script src="/reacoes-render.js"></script>
|
||||
<script>
|
||||
@@ -1046,10 +1614,16 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
|
||||
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;
|
||||
|
||||
+24
-5
@@ -7,8 +7,19 @@ import time
|
||||
from urllib.request import Request,urlopen
|
||||
from comum import gravar_atomico, ler_json
|
||||
|
||||
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo')
|
||||
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo','acenar','esconder','pensar',
|
||||
'espiar','bocejar','confuso')
|
||||
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):
|
||||
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')
|
||||
@@ -24,6 +35,7 @@ class Cerebro:
|
||||
self.lock=threading.Lock();self.ativo=False;self.pending=None;self.busy=False
|
||||
self.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
|
||||
self.publicar=lambda tipo,dados:None
|
||||
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
|
||||
except (OSError,ValueError,KeyError,TypeError):pass
|
||||
threading.Thread(target=self.loop,daemon=True).start()
|
||||
@@ -47,13 +59,18 @@ class Cerebro:
|
||||
if self.busy or self.pending or self.conversando:raise ValueError('IA processando; aguarde')
|
||||
self.pending=event
|
||||
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):
|
||||
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)
|
||||
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()
|
||||
with urlopen(request,timeout=90) as response:data=json.load(response)
|
||||
content=data['choices'][0]['message']['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())
|
||||
def loop(self):
|
||||
while True:
|
||||
@@ -65,10 +82,12 @@ class Cerebro:
|
||||
with self.controle.lock:
|
||||
online=time.monotonic()-self.controle.status_at<5
|
||||
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'
|
||||
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
|
||||
with self.lock:
|
||||
if self.conversando:continue
|
||||
@@ -79,7 +98,7 @@ class Cerebro:
|
||||
if self.ativo and generation==self.generation:
|
||||
self.result=result;self.error='';self.history.append(result['gesto']);self.history=self.history[-6:]
|
||||
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'])
|
||||
except Exception as e:
|
||||
with self.lock:self.error=str(e)[:200]
|
||||
|
||||
+17
-2
@@ -20,7 +20,9 @@ PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
|
||||
somaltolim=0.55, sompausa=20, somcooldown=5,
|
||||
movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
|
||||
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,
|
||||
cameraLado='user', videoResolucao='auto', videoFPS=0,
|
||||
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
|
||||
@@ -30,7 +32,9 @@ ESCOLHAS = dict(cameraLado=('user', 'environment'),
|
||||
LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
|
||||
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
|
||||
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
|
||||
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600))
|
||||
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600),
|
||||
subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120),
|
||||
somAcordar=(1, 20), noiteInicio=(0, 23), noiteFim=(0, 23))
|
||||
|
||||
|
||||
def validar(dados):
|
||||
@@ -52,6 +56,17 @@ def validar(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')
|
||||
|
||||
+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
|
||||
+4
-3
@@ -27,8 +27,8 @@ Set-Location $PSScriptRoot -ErrorAction Stop
|
||||
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
|
||||
$arquivos = @(
|
||||
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
|
||||
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py',
|
||||
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'version.json',
|
||||
'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' }
|
||||
@@ -92,7 +92,8 @@ systemctl restart carinha-pc
|
||||
echo "backup em `$backup"
|
||||
"@
|
||||
Write-Host '== Instalando na placa'
|
||||
$script -replace "`r", '' | & ssh $alvo 'bash -s'
|
||||
# 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'
|
||||
|
||||
+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
|
||||
+107
-13
@@ -32,7 +32,7 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import unquote
|
||||
from pathlib import Path
|
||||
from comum import autorizado, carregar_token, gravar_atomico, ler_json
|
||||
from controle_pc import ControlePC, versao
|
||||
from controle_pc import ControlePC, madrugada, versao
|
||||
from eventos import Eventos
|
||||
from servos import ControleServos
|
||||
from braco import Braco
|
||||
@@ -40,33 +40,43 @@ from cerebro import Cerebro
|
||||
from conversa import Conversa
|
||||
from treinamento import Treinamento
|
||||
from api_config import ConfigAPI
|
||||
from historico import Historico
|
||||
from vida import Vida
|
||||
from criacao import Criacao
|
||||
|
||||
TRAVA = threading.Lock()
|
||||
|
||||
# Espelho da biblioteca de rostos do carinha.html (para /faces funcionar)
|
||||
ROSTOS = ["eilik", "carinha", "miudo"]
|
||||
ROSTOS = ["vivo", "bloco", "matriz", "lente", "eilik", "carinha", "miudo"]
|
||||
# Formato do olho nos rostos novos (rosto-motor.js)
|
||||
FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"]
|
||||
|
||||
# Espelho dos gestos do carinha.html (para o painel montar os botoes)
|
||||
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
|
||||
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
|
||||
"dormir", "acordar", "aleatorio", "parar"]
|
||||
"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
|
||||
# arquivo ao lado do servidor, para sobreviver a reinicios.
|
||||
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255]}
|
||||
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255], "formato": "padrao"}
|
||||
ARQUIVO_APARENCIA = "aparencia.json"
|
||||
|
||||
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
|
||||
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js",
|
||||
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/",
|
||||
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/criacao/",
|
||||
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
|
||||
|
||||
|
||||
@@ -123,6 +133,8 @@ def carregar_aparencia():
|
||||
if isinstance(dados, dict):
|
||||
if dados.get("rosto") in ROSTOS:
|
||||
APARENCIA["rosto"] = dados["rosto"]
|
||||
if dados.get("formato") in FORMATOS:
|
||||
APARENCIA["formato"] = dados["formato"]
|
||||
cor = dados.get("cor")
|
||||
if isinstance(cor, list) and len(cor) == 3:
|
||||
APARENCIA["cor"] = [max(0, min(255, int(v))) for v in cor]
|
||||
@@ -153,6 +165,11 @@ def aplicar_aparencia(dados):
|
||||
if nome in ROSTOS:
|
||||
APARENCIA["rosto"] = nome
|
||||
ordens.append("rosto " + nome)
|
||||
if "formato" in dados:
|
||||
nome = str(dados["formato"]).strip().lower()
|
||||
if nome in FORMATOS:
|
||||
APARENCIA["formato"] = nome
|
||||
ordens.append("formato " + nome)
|
||||
if "cor" in dados:
|
||||
try:
|
||||
APARENCIA["cor"] = validar_cor(dados["cor"])
|
||||
@@ -175,6 +192,11 @@ def ordem_de_comando(tipo, valor):
|
||||
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:
|
||||
@@ -226,7 +248,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
|
||||
def modulos(self):
|
||||
s = self.server
|
||||
return [getattr(s, nome) for nome in ("api_config", "treinamento", "conversa", "cerebro",
|
||||
return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "criacao", "treinamento", "conversa", "cerebro",
|
||||
"braco", "servos", "controle") if hasattr(s, nome)]
|
||||
|
||||
def estado(self):
|
||||
@@ -235,7 +257,8 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
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)
|
||||
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"):
|
||||
@@ -249,6 +272,10 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
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):
|
||||
@@ -360,9 +387,13 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
def comando(self, dados):
|
||||
tipo, valor = dados.get("tipo"), dados.get("valor")
|
||||
s = self.server
|
||||
if tipo == "cor":
|
||||
if not aplicar_aparencia({"cor": valor}):
|
||||
raise ValueError("Cor inválida")
|
||||
if tipo == "rosto":
|
||||
ordem_de_comando("rosto", valor) # valida e dá erro legível
|
||||
aplicar_aparencia({"rosto": valor}) # salva, avisa os painéis e manda ao celular
|
||||
return self.json({"ok": True})
|
||||
if tipo in ("cor", "formato"):
|
||||
if not aplicar_aparencia({tipo: valor}):
|
||||
raise ValueError("Cor inválida" if tipo == "cor" else "Formato inválido")
|
||||
return self.json({"ok": True})
|
||||
if tipo == "recarregar":
|
||||
return self.recarregar()
|
||||
@@ -437,7 +468,7 @@ class Servidor(ThreadingHTTPServer):
|
||||
|
||||
|
||||
def ligar_eventos(servidor):
|
||||
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento"):
|
||||
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao"):
|
||||
if hasattr(servidor, nome):
|
||||
getattr(servidor, nome).publicar = EVENTOS.publicar
|
||||
|
||||
@@ -462,7 +493,70 @@ def main():
|
||||
servidor.cerebro.chave = servidor.token
|
||||
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
|
||||
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
|
||||
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
|
||||
servidor.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
|
||||
ligar_eventos(servidor)
|
||||
servidor.registrar = args.log
|
||||
|
||||
+11
-3
@@ -1,4 +1,12 @@
|
||||
(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 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=[];
|
||||
@@ -15,13 +23,13 @@
|
||||
function visual(recipe){
|
||||
if(recipe.emoji){
|
||||
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;
|
||||
}
|
||||
if(recipe.figurinha){
|
||||
const svg=window.carinhaConstruirSVG(recipe.figurinha);if(!svg)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;
|
||||
}
|
||||
if(['ideia','cafe'].includes(recipe.simbolo)){window.carinhaOlhosIcone=icons[recipe.simbolo];return;}
|
||||
@@ -42,7 +50,7 @@
|
||||
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;
|
||||
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();
|
||||
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));
|
||||
}
|
||||
|
||||
+1394
File diff suppressed because it is too large
Load Diff
@@ -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,401 @@
|
||||
#!/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 gateway_wlan(self):
|
||||
m = re.search(r'default via (\S+)', sh('ip', '-4', 'route', 'show', 'dev', IFACE).stdout)
|
||||
return m.group(1) if m else (GATEWAY if (self.rede() or {}).get('ip_fixo', True) else '')
|
||||
|
||||
def chega_na_rede(self):
|
||||
"""Associar não basta: o roteador tem que responder PELO Wi-Fi. Uma rede que não é a da casa
|
||||
(outro roteador, rede de visitante) associa normalmente e deixaria a placa inacessível."""
|
||||
gw = self.gateway_wlan()
|
||||
return bool(gw) and sh('ping', '-c', '1', '-W', '2', '-I', IFACE, gw, timeout=6).returncode == 0
|
||||
|
||||
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):
|
||||
# rotas do Wi-Fi com métrica maior: com o cabo ligado, o cabo sempre ganha
|
||||
conteudo = (f'[Match]\nName={IFACE}\n\n[Network]\nDNS={DNS}\n\n[Address]\nAddress={IP_FIXO}/24\nRouteMetric=600\n'
|
||||
f'\n[Route]\nGateway={GATEWAY}\nMetric=600\n')
|
||||
else:
|
||||
conteudo = f'[Match]\nName={IFACE}\n\n[Network]\nDHCP=ipv4\n\n[DHCPv4]\nRouteMetric=600\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() and self.chega_na_rede():
|
||||
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:
|
||||
if self.conectado():
|
||||
self.erro = (f'Conectei em “{self.rede()["ssid"]}”, mas por ela não chego ao roteador da casa '
|
||||
f'({self.gateway_wlan() or GATEWAY}). Escolha a rede onde ficam o PC e o celular do Carinha.')
|
||||
else:
|
||||
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,20 @@
|
||||
#!/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")
|
||||
# e cabo desconectado não segura rota: o tráfego passa direto para o Wi-Fi
|
||||
printf 'net.ipv4.conf.all.arp_ignore=1\nnet.ipv4.conf.all.arp_announce=2\nnet.ipv4.conf.all.ignore_routes_with_linkdown=1\nnet.ipv4.conf.default.ignore_routes_with_linkdown=1\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"
|
||||
+18
-1
@@ -1,8 +1,25 @@
|
||||
import unittest
|
||||
from cerebro import validar_intencao
|
||||
from cerebro import POR_EVENTO, Cerebro, validar_intencao
|
||||
|
||||
|
||||
class Tests(unittest.TestCase):
|
||||
def test_only_allowlisted_expression(self):
|
||||
self.assertEqual(validar_intencao({'gesto':'feliz'}),'feliz')
|
||||
for data in [{'gesto':'motor 2500'},{'gesto':'feliz','pulso':2500},[],{'gesto':'dormir'}]:
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
@@ -82,6 +82,13 @@ def testar(base, server, capturas):
|
||||
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()
|
||||
@@ -121,6 +128,16 @@ def testar(base, server, 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?')
|
||||
|
||||
@@ -106,9 +106,31 @@ class ServidorTest(unittest.TestCase):
|
||||
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})
|
||||
|
||||
@@ -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()
|
||||
@@ -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) };
|
||||
})();
|
||||
@@ -9,6 +9,9 @@
|
||||
</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>
|
||||
|
||||
+30
-15
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
||||
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
||||
import { onMount, type Component } from 'svelte';
|
||||
import Avisos from './lib/componentes/Avisos.svelte';
|
||||
import EStop from './lib/componentes/EStop.svelte';
|
||||
@@ -7,31 +7,44 @@
|
||||
import Paleta from './lib/componentes/Paleta.svelte';
|
||||
import Selo from './lib/componentes/Selo.svelte';
|
||||
import Token from './lib/componentes/Token.svelte';
|
||||
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
|
||||
import { favoritos, pararTudo, tocarFavorito } from './lib/comandos.svelte';
|
||||
import { conectar, estado } from './lib/estado.svelte';
|
||||
import { paleta } from './lib/paleta.svelte';
|
||||
import { nav, ROTAS, type Rota } from './lib/rotas.svelte';
|
||||
import { nav, ROTAS, SECUNDARIAS, tituloDa, type Rota } from './lib/rotas.svelte';
|
||||
import AoVivo from './telas/AoVivo.svelte';
|
||||
import 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<Rota, Component<any>> = {
|
||||
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');
|
||||
@@ -82,7 +95,7 @@
|
||||
}
|
||||
if (/^[1-9]$/.test(e.key)) {
|
||||
const g = favoritos.lista[Number(e.key) - 1];
|
||||
if (g) gesto(g);
|
||||
if (g) tocarFavorito(g);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,11 +113,7 @@
|
||||
const conexaoTexto = $derived(
|
||||
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
|
||||
);
|
||||
const bateria = $derived.by(() => {
|
||||
const b = estado.app?.estado?.bateria;
|
||||
const n = typeof b === 'number' ? b : b?.nivel;
|
||||
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
|
||||
});
|
||||
const bateria = $derived(estado.bateria.nivel);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={teclado} />
|
||||
@@ -126,7 +135,7 @@
|
||||
{#each ROTAS as r (r.id)}
|
||||
{@const Icone = ICONES[r.id]}
|
||||
<li>
|
||||
<a href={'#/' + r.id} aria-current={nav.atual === r.id ? 'page' : undefined}>
|
||||
<a href={'#/' + r.id} aria-current={secao === r.id ? 'page' : undefined}>
|
||||
<Icone size={20} /> <span>{r.titulo}</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -138,12 +147,15 @@
|
||||
</nav>
|
||||
|
||||
<header class="topo">
|
||||
<h1 class="titulo">{ROTAS.find((r) => r.id === nav.atual)?.titulo}</h1>
|
||||
<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 ? 'ok' : 'neutro'}>
|
||||
<Selo tom={!estado.celularOnline ? 'neutro' : estado.bateriaBaixa ? 'perigo' : 'ok'}>
|
||||
Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
|
||||
</Selo>
|
||||
{#if estado.conversa}
|
||||
@@ -169,7 +181,7 @@
|
||||
<Tela />
|
||||
</main>
|
||||
|
||||
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes'}
|
||||
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto' && nav.atual !== 'guia' && nav.atual !== 'criar'}
|
||||
<MiniRosto />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -206,7 +218,7 @@
|
||||
margin: 0;
|
||||
padding: var(--e-1);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
li a {
|
||||
display: grid;
|
||||
@@ -238,6 +250,9 @@
|
||||
border-bottom: 1px solid var(--borda);
|
||||
}
|
||||
.titulo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: var(--t-lg);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -38,24 +38,32 @@ export interface Campo {
|
||||
opcoes?: Opcoes;
|
||||
}
|
||||
|
||||
export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] = [
|
||||
/** `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' },
|
||||
@@ -68,8 +76,25 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
|
||||
{ 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']] },
|
||||
@@ -93,6 +118,7 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
|
||||
},
|
||||
{
|
||||
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 },
|
||||
|
||||
@@ -20,17 +20,76 @@ export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
|
||||
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 PADRAO_FAVORITOS = ['feliz', 'coracao', 'piscadinha', 'surpreso', 'triste', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
|
||||
const CHAVE_MAOS = 'carinha_favoritos_maos'; // marca que os gestos com mãos já entraram
|
||||
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')) return v.slice(0, 9);
|
||||
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 */
|
||||
}
|
||||
@@ -40,9 +99,11 @@ function lerFavoritos(): string[] {
|
||||
class Favoritos {
|
||||
lista = $state<string[]>(lerFavoritos());
|
||||
alternar(g: string) {
|
||||
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, 9);
|
||||
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 */
|
||||
}
|
||||
@@ -51,8 +112,27 @@ class Favoritos {
|
||||
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' });
|
||||
|
||||
@@ -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>
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
let { titulo = '', descricao = '', acoes, children, class: classe = '' }: {
|
||||
import Ajuda from './Ajuda.svelte';
|
||||
let { titulo = '', descricao = '', ajuda = '', acoes, children, class: classe = '' }: {
|
||||
titulo?: string;
|
||||
descricao?: string;
|
||||
/** Tópico do guia: mostra um ⓘ ao lado do título. */
|
||||
ajuda?: string;
|
||||
acoes?: Snippet;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
@@ -13,7 +16,7 @@
|
||||
{#if titulo || acoes}
|
||||
<header>
|
||||
<div>
|
||||
{#if titulo}<h2>{titulo}</h2>{/if}
|
||||
{#if titulo}<h2>{titulo}{#if ajuda}<Ajuda topico={ajuda} />{/if}</h2>{/if}
|
||||
{#if descricao}<p class="fraco pequeno-texto">{descricao}</p>{/if}
|
||||
</div>
|
||||
{#if acoes}<div class="linha">{@render acoes()}</div>{/if}
|
||||
|
||||
@@ -5,15 +5,19 @@
|
||||
// Cartão do aparelho com o que o próprio app informa (IP, modelo, Android, versão, Wi-Fi, bateria).
|
||||
const s = $derived(estado.app?.estado ?? {});
|
||||
const ap = $derived(s.aparelho ?? null);
|
||||
const bateria = $derived.by(() => {
|
||||
const b = s.bateria;
|
||||
const n = typeof b === 'number' ? b : b?.nivel;
|
||||
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
|
||||
const bateria = $derived(estado.bateria.nivel);
|
||||
const carregando = $derived(estado.bateria.carregando);
|
||||
// Tempo desde o último sinal, em texto curto ("há 3 min").
|
||||
const semSinal = $derived.by(() => {
|
||||
if (!estado.ultimoSinalEm) return '';
|
||||
const seg = Math.max(0, Math.round((estado.agora - estado.ultimoSinalEm) / 1000));
|
||||
if (seg < 60) return `há ${seg} s`;
|
||||
if (seg < 3600) return `há ${Math.round(seg / 60)} min`;
|
||||
if (seg < 86400) return `há ${Math.round(seg / 3600)} h`;
|
||||
return `há ${Math.round(seg / 86400)} dia(s)`;
|
||||
});
|
||||
const carregando = $derived(typeof s.bateria === 'object' && !!s.bateria?.carregando);
|
||||
const IconeBateria = $derived(carregando ? BatteryCharging : bateria === null ? BatteryMedium : bateria < 20 ? BatteryLow : bateria < 70 ? BatteryMedium : BatteryFull);
|
||||
const ip = $derived(ap?.ip || estado.app?.celular_ip || '—');
|
||||
const visto = $derived(estado.app?.idade ?? null);
|
||||
</script>
|
||||
|
||||
<div class="celular" class:offline={!estado.celularOnline}>
|
||||
@@ -25,7 +29,7 @@
|
||||
{#if estado.celularOnline}
|
||||
{[ap?.android && 'Android ' + ap.android, ap?.app && 'app ' + ap.app].filter(Boolean).join(' · ') || 'online'}
|
||||
{:else}
|
||||
offline{visto !== null ? ` · visto há ${Math.round(visto)} s` : ''}
|
||||
offline{semSinal ? ' · último sinal ' + semSinal : ''}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -70,6 +74,12 @@
|
||||
</div>
|
||||
</dl>
|
||||
{:else}
|
||||
{#if bateria !== null && estado.ultimoSinalEm}
|
||||
<p class="ultimo" class:baixa={bateria <= 15 && !carregando}>
|
||||
<IconeBateria size={18} /> Último sinal com {bateria}%{carregando ? ', carregando' : ''}.
|
||||
{#if bateria <= 10 && !carregando}Provavelmente a bateria acabou: ligue o carregador e abra o app.{/if}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="ajuda">Abra o app Carinha no celular e confira se ele aponta para este servidor (toque longo › Mudar o servidor).</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -170,6 +180,17 @@
|
||||
rect.cheia {
|
||||
fill: var(--texto);
|
||||
}
|
||||
.ultimo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--t-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.ultimo.baixa {
|
||||
color: var(--perigo);
|
||||
}
|
||||
.ajuda {
|
||||
font-size: var(--t-sm);
|
||||
color: var(--texto-3);
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Star } from '@lucide/svelte';
|
||||
import { favoritos, gesto, GESTOS_INFO, gestosDisponiveis } from '../comandos.svelte';
|
||||
import { favoritos, GESTOS_INFO, gestosDisponiveis, tocarFavorito } from '../comandos.svelte';
|
||||
import { criacao } from '../criacao.svelte';
|
||||
|
||||
// compacto: só os favoritos com atalho 1–9 (dock). Completo: todos, com estrela para fixar.
|
||||
// compacto: só os favoritos com atalho 1–9 (dock), inclusive criações do estúdio. Completo: todos, com estrela.
|
||||
let { completo = false }: { completo?: boolean } = $props();
|
||||
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => g in GESTOS_INFO));
|
||||
$effect(() => {
|
||||
if (!completo && !criacao.status && favoritos.lista.some((g) => g.startsWith('criado:'))) criacao.carregar();
|
||||
});
|
||||
function infoDe(g: string) {
|
||||
if (g.startsWith('criado:')) {
|
||||
const c = criacao.item(Number(g.slice(7)));
|
||||
return c ? { nome: c.nome, emoji: c.emoji } : null;
|
||||
}
|
||||
return GESTOS_INFO[g] ?? null;
|
||||
}
|
||||
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => infoDe(g)));
|
||||
</script>
|
||||
|
||||
<div class="gestos" class:completo>
|
||||
{#each lista as g, i (g)}
|
||||
{@const info = GESTOS_INFO[g]}
|
||||
{@const info = infoDe(g)!}
|
||||
<div class="item">
|
||||
<button class="gesto" onclick={() => gesto(g)} title={completo ? info.nome : `${info.nome} (tecla ${i + 1})`}>
|
||||
<button class="gesto" class:criado={g.startsWith('criado:')} onclick={() => tocarFavorito(g)} title={completo || i > 8 ? info.nome : `${info.nome} (tecla ${i + 1})`}>
|
||||
<span class="emoji" aria-hidden="true">{info.emoji}</span>
|
||||
<span class="nome">{info.nome}</span>
|
||||
{#if !completo}<kbd>{i + 1}</kbd>{/if}
|
||||
{#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if}
|
||||
</button>
|
||||
{#if completo}
|
||||
<button
|
||||
@@ -71,6 +82,13 @@
|
||||
color: var(--texto-2);
|
||||
text-align: center;
|
||||
}
|
||||
.gesto.criado::after {
|
||||
content: '✨';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
kbd {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Ajuda from './Ajuda.svelte';
|
||||
// Lista agrupada no estilo dos Ajustes do iPhone: título pequeno, cartão arredondado, rodapé de ajuda.
|
||||
let { titulo = '', rodape = '', children }: { titulo?: string; rodape?: string; children: Snippet } = $props();
|
||||
// `ajuda` = tópico do guia; aparece um ⓘ ao lado do título.
|
||||
let { titulo = '', rodape = '', ajuda = '', children }: { titulo?: string; rodape?: string; ajuda?: string; children: Snippet } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<section class="grupo">
|
||||
{#if titulo}<h2>{titulo}</h2>{/if}
|
||||
{#if titulo}<h2>{titulo}{#if ajuda}<Ajuda topico={ajuda} />{/if}</h2>{/if}
|
||||
<div class="itens">{@render children()}</div>
|
||||
{#if rodape}<p class="rodape">{rodape}</p>{/if}
|
||||
</section>
|
||||
@@ -17,6 +20,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-height: 26px;
|
||||
padding: 0 var(--e-4);
|
||||
font-size: var(--t-xs);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,56 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { rgb, ROSTOS_INFO } from '../comandos.svelte';
|
||||
import { estado } from '../estado.svelte';
|
||||
import { SIMBOLO_EMOJI } from '../treinamento.svelte';
|
||||
|
||||
// Prévia estática de uma reação (sem iframe): figurinha SVG, emoji ou olhos do 1º passo.
|
||||
// Retrato parado da reação, desenhado pelo mesmo motor do celular (rosto-motor.js),
|
||||
// no rosto, formato e cor que o robô está usando agora.
|
||||
let { receita, tamanho = 64 }: { receita: any; tamanho?: number } = $props();
|
||||
let tela: HTMLCanvasElement;
|
||||
|
||||
const passo = $derived(receita?.passos?.[0]);
|
||||
const simbolo = $derived(receita?.emoji?.caractere || SIMBOLO_EMOJI[receita?.simbolo] || '');
|
||||
const nosOlhos = $derived({
|
||||
icone:
|
||||
receita?.emoji?.local === 'olhos'
|
||||
? receita.emoji.caractere
|
||||
: ['ideia', 'cafe'].includes(receita?.simbolo) && !receita?.emoji && !receita?.figurinha
|
||||
? SIMBOLO_EMOJI[receita.simbolo]
|
||||
: null,
|
||||
desenho: receita?.figurinha?.local === 'olhos' ? receita.figurinha : null,
|
||||
});
|
||||
// O que aparece fora dos olhos (emoji/figurinha "ao redor", notas, estrelas, corações).
|
||||
const aoRedor = $derived.by(() => {
|
||||
if (receita?.emoji && receita.emoji.local !== 'olhos') return { emoji: receita.emoji.caractere };
|
||||
if (receita?.figurinha && receita.figurinha.local !== 'olhos') return { figurinha: receita.figurinha };
|
||||
if (['musica', 'estrela', 'coracao'].includes(receita?.simbolo)) return { emoji: SIMBOLO_EMOJI[receita.simbolo] };
|
||||
return null;
|
||||
});
|
||||
// Mesma regra do celular (reacoes-render.js): ciano padrão = cor escolhida; azul-claro = tom claro dela.
|
||||
function corDaReacao(hex: string | undefined): number[] {
|
||||
const h = (hex ?? '').toLowerCase(), base = estado.aparencia.cor;
|
||||
if (!/^#[0-9a-f]{6}$/.test(h) || h === '#5cd6ff') return [...base];
|
||||
if (h === '#aaccff') return base.map((v) => v + (255 - v) * 0.45);
|
||||
return rgb(h);
|
||||
}
|
||||
const estilo = $derived(ROSTOS_INFO[estado.aparencia.rosto]?.novo ? estado.aparencia.rosto : 'vivo');
|
||||
|
||||
$effect(() => {
|
||||
const motor = (window as any).MotorRosto;
|
||||
if (!motor || !tela) return;
|
||||
const d = Math.min(2, devicePixelRatio || 1);
|
||||
tela.width = Math.round(tamanho * 1.5 * d); // 3:2, cabe os dois olhos com folga
|
||||
tela.height = Math.round(tamanho * d);
|
||||
motor.retrato(tela, {
|
||||
estilo,
|
||||
formato: estado.aparencia.formato ?? 'padrao',
|
||||
cor: corDaReacao(passo?.cor),
|
||||
forma: passo?.olhos ?? 'aberto',
|
||||
icone: nosOlhos.icone,
|
||||
desenho: nosOlhos.desenho ? $state.snapshot(nosOlhos.desenho) : null,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="miniatura" style="--t: {tamanho}px" aria-hidden="true">
|
||||
{#if receita?.figurinha?.formas}
|
||||
<svg viewBox="0 0 100 100" width={tamanho} height={tamanho}>
|
||||
{#each receita.figurinha.formas as forma, i (i)}
|
||||
<path d={forma.caminho} fill={forma.cor} />
|
||||
{/each}
|
||||
<canvas bind:this={tela}></canvas>
|
||||
{#if aoRedor?.emoji}
|
||||
<span class="extra">{aoRedor.emoji}</span>
|
||||
{:else if aoRedor?.figurinha}
|
||||
<svg class="extra" viewBox="0 0 100 100">
|
||||
{#each aoRedor.figurinha.formas as forma, i (i)}<path d={forma.caminho} fill={forma.cor} />{/each}
|
||||
</svg>
|
||||
{:else if receita?.emoji}
|
||||
<span class="emoji">{receita.emoji.caractere}</span>
|
||||
{:else}
|
||||
<svg viewBox="0 0 100 60" width={tamanho} height={tamanho * 0.6}>
|
||||
{#if passo?.olhos === 'feliz'}
|
||||
<path d="M18 38 Q30 18 42 38 M58 38 Q70 18 82 38" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
|
||||
{:else if passo?.olhos === 'triste'}
|
||||
<path d="M18 26 Q30 44 42 26 M58 26 Q70 44 82 26" stroke={passo.cor} stroke-width="8" fill="none" stroke-linecap="round" />
|
||||
{:else}
|
||||
{@const ry = passo?.olhos === 'surpreso' ? 20 : 16}
|
||||
<ellipse cx="30" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
|
||||
<ellipse cx="70" cy="30" rx="12" {ry} fill={passo?.cor ?? '#5cd6ff'} />
|
||||
{/if}
|
||||
</svg>
|
||||
{#if simbolo}<span class="simbolo">{simbolo}</span>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.miniatura {
|
||||
position: relative;
|
||||
width: var(--t);
|
||||
width: calc(var(--t) * 1.5);
|
||||
height: var(--t);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--raio);
|
||||
background: #05080c;
|
||||
background: #000;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.emoji {
|
||||
font-size: calc(var(--t) * 0.55);
|
||||
line-height: 1;
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.simbolo {
|
||||
.extra {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 0;
|
||||
font-size: calc(var(--t) * 0.3);
|
||||
right: 4px;
|
||||
top: 3px;
|
||||
width: calc(var(--t) * 0.34);
|
||||
height: calc(var(--t) * 0.34);
|
||||
font-size: calc(var(--t) * 0.26);
|
||||
line-height: calc(var(--t) * 0.34);
|
||||
text-align: center;
|
||||
filter: drop-shadow(0 0 4px rgb(92 214 255 / 0.6));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { estado } from '../estado.svelte';
|
||||
// Selo ao vivo do PIR: "Presença agora" por 8 s depois de cada disparo (o HC-SR505 segura ~8 s).
|
||||
const seg = $derived(estado.pir?.ultimo ? Math.max(0, Math.round((estado.agora - estado.pir.ultimo * 1000) / 1000)) : null);
|
||||
const agora = $derived(seg !== null && seg < 8);
|
||||
const texto = $derived(
|
||||
!estado.pir?.disponivel ? 'sensor indisponível'
|
||||
: agora ? 'presença agora'
|
||||
: seg === null ? 'aguardando presença'
|
||||
: seg < 60 ? `último há ${seg} s` : seg < 3600 ? `último há ${Math.round(seg / 60)} min` : `último há ${Math.round(seg / 3600)} h`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="pir" class:agora class:off={!estado.pir?.disponivel} aria-live="polite">
|
||||
<span class="ponto" aria-hidden="true"></span>{texto}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.pir {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--raio-pill);
|
||||
font-size: var(--t-xs);
|
||||
font-weight: 600;
|
||||
background: var(--superficie-2);
|
||||
color: var(--texto-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ponto {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.agora {
|
||||
background: var(--ok-suave);
|
||||
color: var(--ok);
|
||||
}
|
||||
.agora .ponto {
|
||||
animation: pulso 1s ease-in-out infinite;
|
||||
}
|
||||
.off {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@keyframes pulso {
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgb(52 199 89 / 0.25);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,8 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { estado, ouvir } from '../estado.svelte';
|
||||
|
||||
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props();
|
||||
/** espelharSono: dorme/acorda junto com o celular (ele adormece sozinho no silêncio). */
|
||||
let { titulo = 'Prévia do rosto', espelharSono = true }: { titulo?: string; espelharSono?: boolean } = $props();
|
||||
let quadro: HTMLIFrameElement;
|
||||
let pronto = $state(false);
|
||||
|
||||
@@ -20,6 +21,13 @@
|
||||
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
|
||||
}
|
||||
|
||||
// Sono que o celular decidiu sozinho (silêncio, toque, barulho) não passa pelo SSE de ordens:
|
||||
// vem na telemetria. Sem celular online, a prévia fica como está.
|
||||
const dormindoNoCelular = $derived(estado.celularOnline ? !!estado.app?.estado?.dormindo : null);
|
||||
$effect(() => {
|
||||
if (espelharSono && pronto && dormindoNoCelular !== null) enviar({ tipo: 'sono', dormindo: dormindoNoCelular });
|
||||
});
|
||||
|
||||
let ultimaConversa = estado.conversa?.ultima?.id;
|
||||
const cancelar = [
|
||||
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
|
||||
@@ -31,11 +39,22 @@
|
||||
ultimaConversa = c.ultima.id;
|
||||
enviar({ tipo: 'ordem', ordem: 'acordar' });
|
||||
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
|
||||
enviar({ tipo: 'legenda', texto: c.ultima.texto });
|
||||
}
|
||||
}),
|
||||
];
|
||||
onDestroy(() => cancelar.forEach((c) => c()));
|
||||
|
||||
/** Mostra uma ordem (gesto, rosto, formato) só na prévia, sem mandar ao celular. */
|
||||
export function ordem(texto: string) {
|
||||
enviar({ tipo: 'ordem', ordem: texto });
|
||||
}
|
||||
|
||||
/** Mostra uma legenda na faixa de baixo da prévia. */
|
||||
export function legenda(texto: string) {
|
||||
enviar({ tipo: 'legenda', texto });
|
||||
}
|
||||
|
||||
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
|
||||
export function previa(receita: unknown) {
|
||||
enviar({ tipo: 'previa-reacao', receita });
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import type { TopicoGuia } from '../guia';
|
||||
// Conteúdo de um tópico do guia (no balão do ⓘ e na página Guia).
|
||||
let { topico }: { topico: TopicoGuia } = $props();
|
||||
</script>
|
||||
|
||||
<div class="topico">
|
||||
<p class="onde">{topico.onde}</p>
|
||||
<p class="resumo">{topico.resumo}</p>
|
||||
{#if topico.itens?.length}
|
||||
<dl>
|
||||
{#each topico.itens as item (item.nome)}
|
||||
<div class="item">
|
||||
<dt>
|
||||
{#if item.emoji}<span class="emoji" aria-hidden="true">{item.emoji}</span>{/if}
|
||||
{item.nome}
|
||||
</dt>
|
||||
<dd>{item.texto}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{/if}
|
||||
{#if topico.dica}<p class="dica"><strong>Dica:</strong> {topico.dica}</p>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.topico {
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
}
|
||||
.onde {
|
||||
font-size: var(--t-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.resumo {
|
||||
color: var(--texto);
|
||||
line-height: 1.5;
|
||||
max-width: 65ch;
|
||||
}
|
||||
dl {
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
margin: 0;
|
||||
}
|
||||
.item {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
dt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.emoji {
|
||||
width: 1.4em;
|
||||
text-align: center;
|
||||
}
|
||||
dd {
|
||||
margin: 0 0 0 calc(1.4em + var(--e-2));
|
||||
color: var(--texto-2);
|
||||
font-size: var(--t-sm);
|
||||
line-height: 1.45;
|
||||
max-width: 65ch;
|
||||
}
|
||||
.dica {
|
||||
padding: var(--e-2) var(--e-3);
|
||||
border-radius: var(--raio-sm);
|
||||
background: var(--destaque-suave);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,311 @@
|
||||
<script lang="ts">
|
||||
import { Gauge, Moon, Sparkles } from '@lucide/svelte';
|
||||
import { estado } from '../estado.svelte';
|
||||
import { configurarVida, cuidar, CUIDADOS, NECESSIDADES } from '../vida.svelte';
|
||||
import Ajuda from './Ajuda.svelte';
|
||||
import Grupo from './Grupo.svelte';
|
||||
import Interruptor from './Interruptor.svelte';
|
||||
import Linha from './Linha.svelte';
|
||||
|
||||
// Cartão do bichinho: humor, necessidades, botões de cuidado e o diário do que ele fez sozinho.
|
||||
const v = $derived(estado.vida);
|
||||
let diarioAberto = $state(false);
|
||||
|
||||
// O ritmo também é a sensibilidade dos sensores no celular (detalhes no ⓘ).
|
||||
const RITMOS = [
|
||||
{ valor: 'calmo', nome: 'Calmo', descricao: 'Reage pouco' },
|
||||
{ valor: 'normal', nome: 'Normal', descricao: 'Equilibrado' },
|
||||
{ valor: 'agitado', nome: 'Agitado', descricao: 'Reage a tudo' },
|
||||
] as const;
|
||||
|
||||
const MOTIVOS: Record<string, string> = {
|
||||
desligado: '',
|
||||
'sem chave Gemini': 'precisa da chave Gemini (Ajustes)',
|
||||
'limite do dia': 'limite de hoje atingido',
|
||||
'descansando a criatividade': 'próxima ideia em breve',
|
||||
'biblioteca indisponível': 'biblioteca indisponível',
|
||||
};
|
||||
|
||||
function hora(s: number) {
|
||||
return new Date(s * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function idade(d: number) {
|
||||
return d === 0 ? 'nasceu hoje' : d === 1 ? '1 dia de vida' : `${d} dias de vida`;
|
||||
}
|
||||
const horarioNoite = $derived.by(() => {
|
||||
const c = estado.appConfig;
|
||||
const ini = typeof c?.noiteInicio === 'number' ? c.noiteInicio : 23;
|
||||
const fim = typeof c?.noiteFim === 'number' ? c.noiteFim : 7;
|
||||
return `Das ${ini}h às ${fim}h. Gente se mexendo não acorda; carinho e conversa sim`;
|
||||
});
|
||||
const detalheFigurinhas = $derived(
|
||||
v ? [`${v.emojis_hoje} de ${v.config.max_emojis_dia} hoje`, MOTIVOS[v.pode_criar] ?? v.pode_criar].filter(Boolean).join(' · ') : '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<Grupo
|
||||
titulo="Vida"
|
||||
ajuda="vida"
|
||||
rodape={v?.config.ligado
|
||||
? 'Ele decide sozinho o que fazer pelo humor. Tocar ou deslizar o dedo na tela do celular também conta como carinho.'
|
||||
: 'Ligado, ele tem necessidades, humor, cochilos e inventa figurinhas, como um bichinho virtual.'}
|
||||
>
|
||||
{#if !v}
|
||||
<p class="vazio">Carregando…</p>
|
||||
{:else}
|
||||
<div class="bicho" class:dormindo={v.dormindo}>
|
||||
<span class="humor" aria-hidden="true">{v.config.ligado ? v.emoji : '🤖'}</span>
|
||||
<div class="quem">
|
||||
<strong>{v.config.ligado ? v.nome_humor : 'Modo autônomo desligado'}{#if v.config.ligado}<Ajuda topico="humores" />{/if}</strong>
|
||||
<span>
|
||||
{idade(v.idade_dias)}{v.config.ligado && v.sono_profundo ? ' · sono pesado: só acorda descansado' : v.config.ligado && v.diario[0] ? ' · ' + v.diario[0].texto : ''}
|
||||
</span>
|
||||
</div>
|
||||
<Interruptor rotulo="Modo autônomo" checked={v.config.ligado} onchange={(l) => configurarVida({ ligado: l })} />
|
||||
</div>
|
||||
|
||||
{#if v.config.ligado}
|
||||
<div class="barras">
|
||||
{#each NECESSIDADES as n (n.chave)}
|
||||
{@const valor = v.necessidades[n.chave]}
|
||||
<div class="barra" class:baixa={valor < 25}>
|
||||
<span class="nome"><span aria-hidden="true">{n.emoji}</span> {n.nome}</span>
|
||||
<span
|
||||
class="trilho"
|
||||
role="meter"
|
||||
aria-label={n.nome}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow={valor}
|
||||
>
|
||||
<span class="nivel" style="--v: {valor}%; --c: {n.cor}"></span>
|
||||
</span>
|
||||
<span class="num mono">{valor}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="cuidados">
|
||||
<span class="cuidar">Cuidar <Ajuda topico="cuidados" /></span>
|
||||
{#each CUIDADOS as c (c.acao)}
|
||||
<button class="btn pequeno" onclick={() => cuidar(c.acao)}><span aria-hidden="true">{c.emoji}</span> {c.nome}</button>
|
||||
{/each}
|
||||
{#if v.dormindo}
|
||||
<button class="btn pequeno" onclick={() => cuidar('acordar')}><span aria-hidden="true">☀️</span> Acordar</button>
|
||||
{:else}
|
||||
<button class="btn pequeno" onclick={() => cuidar('ninar')}><span aria-hidden="true">🌙</span> Ninar</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Linha icone={Sparkles} cor="#af52de" rotulo="Inventar figurinhas sozinho" detalhe={detalheFigurinhas}>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Inventar figurinhas sozinho" checked={v.config.criar_emojis} onchange={(l) => configurarVida({ criar_emojis: l })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Gauge} cor="#ff9500" rotulo="Ritmo" detalhe={RITMOS.find((r) => r.valor === v.config.ritmo)?.descricao ?? ''}>
|
||||
{#snippet acessorio()}
|
||||
<Ajuda topico="ritmo" />
|
||||
<span class="segmentos" role="radiogroup" aria-label="Ritmo">
|
||||
{#each RITMOS as r (r.valor)}
|
||||
<button
|
||||
role="radio"
|
||||
aria-checked={v.config.ritmo === r.valor}
|
||||
class:ativo={v.config.ritmo === r.valor}
|
||||
onclick={() => configurarVida({ ritmo: r.valor })}>{r.nome}</button
|
||||
>
|
||||
{/each}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Moon} cor="#5e5ce6" rotulo="Dormir à noite" detalhe={horarioNoite}>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Dormir à noite" checked={v.config.noite} onchange={(l) => configurarVida({ noite: l })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
|
||||
{#if v.diario.length}
|
||||
<ol class="diario">
|
||||
{#each v.diario.slice(0, diarioAberto ? 30 : 5) as d (d.horario + d.texto)}
|
||||
<li class={d.tipo}><time class="mono">{hora(d.horario)}</time> <span>{d.texto}</span></li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if v.diario.length > 5}
|
||||
<button class="btn fantasma pequeno mais" onclick={() => (diarioAberto = !diarioAberto)}>
|
||||
{diarioAberto ? 'Mostrar menos' : 'Ver o diário inteiro'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</Grupo>
|
||||
|
||||
<style>
|
||||
.vazio {
|
||||
padding: var(--e-3) var(--e-4);
|
||||
color: var(--texto-3);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.bicho {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
padding: var(--e-3) var(--e-4);
|
||||
}
|
||||
.humor {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: var(--superficie-2);
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
animation: respirar 3.2s ease-in-out infinite;
|
||||
}
|
||||
.dormindo .humor {
|
||||
animation-duration: 6s;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@keyframes respirar {
|
||||
50% {
|
||||
transform: scale(1.07);
|
||||
}
|
||||
}
|
||||
.quem {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.quem strong {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: var(--t-md);
|
||||
}
|
||||
.cuidar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-bottom: -4px;
|
||||
font-size: var(--t-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.quem span {
|
||||
color: var(--texto-3);
|
||||
font-size: var(--t-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.barras {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
padding: 0 var(--e-4) var(--e-3);
|
||||
}
|
||||
.barra {
|
||||
display: grid;
|
||||
grid-template-columns: 7.5em 1fr 2.2em;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.nome {
|
||||
color: var(--texto-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.trilho {
|
||||
height: 8px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.nivel {
|
||||
display: block;
|
||||
width: var(--v);
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--c);
|
||||
transition: width 600ms ease;
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
color: var(--texto-3);
|
||||
font-size: var(--t-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.baixa .num,
|
||||
.baixa .nome {
|
||||
color: var(--perigo);
|
||||
}
|
||||
.cuidados {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--e-2);
|
||||
padding: 0 var(--e-4) var(--e-3);
|
||||
}
|
||||
.cuidados .btn {
|
||||
gap: 4px;
|
||||
padding: 0 var(--e-1);
|
||||
}
|
||||
.segmentos {
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
border-radius: var(--raio-sm);
|
||||
background: var(--superficie-3);
|
||||
}
|
||||
.segmentos button {
|
||||
min-height: 28px;
|
||||
padding: 0 var(--e-2);
|
||||
border: 0;
|
||||
border-radius: calc(var(--raio-sm) - 2px);
|
||||
background: transparent;
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-xs) var(--fonte);
|
||||
cursor: pointer;
|
||||
}
|
||||
.segmentos button.ativo {
|
||||
background: var(--superficie);
|
||||
color: var(--texto);
|
||||
box-shadow: var(--sombra);
|
||||
}
|
||||
.diario {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: var(--e-3) var(--e-4);
|
||||
border-top: 1px solid var(--borda);
|
||||
list-style: none;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.diario li {
|
||||
display: flex;
|
||||
gap: var(--e-2);
|
||||
color: var(--texto-2);
|
||||
}
|
||||
.diario time {
|
||||
color: var(--texto-3);
|
||||
font-size: var(--t-xs);
|
||||
padding-top: 2px;
|
||||
}
|
||||
.diario li.emoji {
|
||||
color: #af52de;
|
||||
}
|
||||
.diario li.cuidado {
|
||||
color: var(--texto);
|
||||
}
|
||||
.mais {
|
||||
justify-self: start;
|
||||
margin: 0 var(--e-3) var(--e-2);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.humor {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Estúdio de criação (/criacao/*): expressões, atividades e figurinhas feitas por IA.
|
||||
* O estado chega por SSE ('criacao') e é recarregado ao abrir a tela.
|
||||
*/
|
||||
import { pedir, postar } from './api';
|
||||
import { avisos, tentar } from './avisos.svelte';
|
||||
import { ouvir } from './estado.svelte';
|
||||
|
||||
export type TipoCriacao = 'expressao' | 'atividade' | 'figurinha';
|
||||
export type IaCriacao = 'gemini' | 'local' | 's10';
|
||||
|
||||
export interface ItemCriado {
|
||||
id: number;
|
||||
horario: number;
|
||||
tipo: TipoCriacao;
|
||||
nome: string;
|
||||
emoji: string;
|
||||
receita: any;
|
||||
ia: IaCriacao;
|
||||
pedido: string;
|
||||
}
|
||||
|
||||
export interface Provedor {
|
||||
nome: string;
|
||||
pronto: boolean;
|
||||
detalhe: string;
|
||||
motivo: string;
|
||||
url?: string;
|
||||
modelo?: string;
|
||||
chave?: boolean;
|
||||
}
|
||||
|
||||
export interface StatusCriacao {
|
||||
ocupado: { tipo: TipoCriacao; ia: IaCriacao; desde: number; pedido: string } | null;
|
||||
erro: string;
|
||||
provedores: Record<IaCriacao, Provedor>;
|
||||
criacoes: ItemCriado[];
|
||||
bases: Record<'expressao' | 'atividade', { id: string; nome: string; emoji: string }[]>;
|
||||
}
|
||||
|
||||
export const TIPOS: { id: TipoCriacao; nome: string; emoji: string; exemplo: string }[] = [
|
||||
{ id: 'expressao', nome: 'Expressão', emoji: '😊', exemplo: 'Ex.: desconfiado, olhando de lado com um olho meio fechado' },
|
||||
{ id: 'atividade', nome: 'Atividade', emoji: '🤹', exemplo: 'Ex.: fazendo malabarismo com 3 bolinhas, concentrado' },
|
||||
{ id: 'figurinha', nome: 'Figurinha', emoji: '🎨', exemplo: 'Ex.: um sol de óculos escuros sorrindo' },
|
||||
];
|
||||
|
||||
class Criacao {
|
||||
status = $state<StatusCriacao | null>(null);
|
||||
|
||||
async carregar() {
|
||||
try {
|
||||
this.status = await pedir<StatusCriacao>('/criacao/status');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
item(id: number) {
|
||||
return this.status?.criacoes.find((c) => c.id === id) ?? null;
|
||||
}
|
||||
|
||||
async criar(tipo: TipoCriacao, pedido: string, ia: IaCriacao, base: string) {
|
||||
const r = await tentar(() => postar<StatusCriacao>('/criacao/criar', { tipo, pedido, ia, base: base || null }));
|
||||
if (r) this.status = r;
|
||||
return !!r;
|
||||
}
|
||||
|
||||
tocar(id: number) {
|
||||
return tentar(() => postar('/criacao/tocar', { id }));
|
||||
}
|
||||
|
||||
async apagar(id: number) {
|
||||
await tentar(() => postar('/criacao/apagar', { id }), 'Criação apagada.');
|
||||
}
|
||||
|
||||
async configurar(local: { url?: string; modelo?: string; chave?: string }) {
|
||||
const r = await tentar(() => postar<StatusCriacao>('/criacao/config', { local }), 'IA local salva.');
|
||||
if (r) this.status = r;
|
||||
}
|
||||
|
||||
async testar(ia: IaCriacao) {
|
||||
const r = await tentar(() => postar<{ mensagem: string }>('/criacao/testar', { ia }));
|
||||
if (r) avisos.ok(r.mensagem);
|
||||
}
|
||||
}
|
||||
|
||||
export const criacao = new Criacao();
|
||||
ouvir('criacao', (d: StatusCriacao) => {
|
||||
const antes = criacao.status;
|
||||
criacao.status = d;
|
||||
// terminou: avisa o resultado
|
||||
if (antes?.ocupado && !d.ocupado) {
|
||||
if (d.erro) avisos.erro(new Error(d.erro));
|
||||
else if (d.criacoes[0]) avisos.ok(`Criado: ${d.criacoes[0].emoji} ${d.criacoes[0].nome}`);
|
||||
}
|
||||
});
|
||||
|
||||
/** Favoritos podem guardar criações ("criado:12"): tocar no celular. */
|
||||
export const ehCriado = (g: string) => g.startsWith('criado:');
|
||||
export const idCriado = (g: string) => Number(g.slice(7));
|
||||
@@ -3,7 +3,8 @@
|
||||
* Nenhuma tela faz polling; todas leem daqui.
|
||||
*/
|
||||
import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
|
||||
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
|
||||
import { avisos } from './avisos.svelte';
|
||||
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria, Vida } from './tipos';
|
||||
|
||||
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
|
||||
|
||||
@@ -19,7 +20,8 @@ class Estado {
|
||||
versao = $state('');
|
||||
autenticacao = $state(false);
|
||||
estop = $state({ ativo: false, desde: null as number | null });
|
||||
aparencia = $state({ rosto: 'carinha', cor: [92, 214, 255] as [number, number, number] });
|
||||
aparencia = $state<{ rosto: string; cor: [number, number, number]; formato?: string }>({ rosto: 'carinha', cor: [92, 214, 255], formato: 'padrao' });
|
||||
formatos = $state<string[]>([]);
|
||||
gestos = $state<string[]>([]);
|
||||
rostos = $state<string[]>([]);
|
||||
app = $state<Telemetria | null>(null);
|
||||
@@ -31,11 +33,26 @@ class Estado {
|
||||
reacao = $state<Reacao | null>(null);
|
||||
treinamento = $state({ ocupado: false, erro: '' });
|
||||
api = $state<EstadoServidor['api'] | null>(null);
|
||||
vida = $state<Vida | null>(null);
|
||||
feed = $state<ItemFeed[]>([]);
|
||||
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
|
||||
pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
|
||||
/** Relógio de 1 s para derivar "online" sem esperar evento novo. */
|
||||
agora = $state(Date.now());
|
||||
|
||||
celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000);
|
||||
/** Quando o celular mandou o último sinal (0 = nunca, nesta sessão nem no servidor). */
|
||||
ultimoSinalEm = $state(0);
|
||||
/** Bateria do último sinal recebido, mesmo com o celular offline. */
|
||||
bateria = $derived.by(() => {
|
||||
const b = this.app?.estado?.bateria;
|
||||
const n = typeof b === 'number' ? b : b?.nivel;
|
||||
return {
|
||||
nivel: typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null,
|
||||
carregando: typeof b === 'object' && !!b?.carregando,
|
||||
};
|
||||
});
|
||||
bateriaBaixa = $derived(this.bateria.nivel !== null && this.bateria.nivel <= 15 && !this.bateria.carregando);
|
||||
}
|
||||
|
||||
export const estado = new Estado();
|
||||
@@ -50,6 +67,23 @@ export function ouvir(tipo: string, fn: Ouvinte): () => void {
|
||||
return () => ouvintes.get(tipo)!.delete(fn);
|
||||
}
|
||||
|
||||
// Avisa uma vez em 15% e outra em 5% (sem carregador); zera quando volta a carregar.
|
||||
let avisouEm: number | null = null;
|
||||
function avisarBateria() {
|
||||
const { nivel, carregando } = estado.bateria;
|
||||
if (nivel === null) return;
|
||||
if (carregando || nivel > 20) {
|
||||
avisouEm = null;
|
||||
return;
|
||||
}
|
||||
const limite = nivel <= 5 ? 5 : nivel <= 15 ? 15 : null;
|
||||
if (limite !== null && avisouEm !== limite) {
|
||||
avisouEm = limite;
|
||||
avisos.mostrar(`Bateria do celular em ${nivel}%. Ligue o carregador ou o Carinha vai desligar.`, 'erro');
|
||||
noFeed('estop', `Bateria do celular em ${nivel}%`);
|
||||
}
|
||||
}
|
||||
|
||||
let seqFeed = 0;
|
||||
function noFeed(tipo: string, texto: string) {
|
||||
estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40);
|
||||
@@ -62,9 +96,11 @@ function aplicarFoto(f: EstadoServidor) {
|
||||
estado.aparencia = f.aparencia;
|
||||
estado.gestos = f.gestos;
|
||||
estado.rostos = f.rostos;
|
||||
estado.formatos = f.formatos ?? [];
|
||||
if (f.app) {
|
||||
estado.app = f.app;
|
||||
estado.appRecebidoEm = Date.now();
|
||||
estado.ultimoSinalEm = typeof f.app.idade === 'number' ? Date.now() - f.app.idade * 1000 : 0;
|
||||
}
|
||||
if (f.app_config) estado.appConfig = f.app_config;
|
||||
if (f.motores) estado.motores = f.motores;
|
||||
@@ -73,15 +109,25 @@ function aplicarFoto(f: EstadoServidor) {
|
||||
estado.reacao = f.reacao ?? null;
|
||||
if (f.treinamento) estado.treinamento = f.treinamento;
|
||||
if (f.api) estado.api = f.api;
|
||||
if (f.vida) estado.vida = f.vida;
|
||||
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca };
|
||||
}
|
||||
|
||||
const tratadores: Record<string, (d: any) => void> = {
|
||||
expressao: (d: { ordem: string }) => {
|
||||
if (d.ordem !== 'calma') noFeed('expressao', 'Expressão: ' + d.ordem);
|
||||
// 'humor x' é o modo vida avisando o celular; aparece no cartão Vida, não no feed
|
||||
if (d.ordem === 'calma' || /^(humor|ritmo|zanga|sono) /.test(d.ordem)) return;
|
||||
const criada = /^(expressao|atividade)_criada (.*)$/.exec(d.ordem);
|
||||
if (criada) {
|
||||
let nome = '';
|
||||
try { nome = JSON.parse(criada[2]).nome; } catch { /* ok */ }
|
||||
noFeed('expressao', (criada[1] === 'expressao' ? 'Expressão criada: ' : 'Atividade criada: ') + nome);
|
||||
} else noFeed('expressao', 'Expressão: ' + d.ordem);
|
||||
},
|
||||
telemetria: (d: Telemetria) => {
|
||||
estado.app = d;
|
||||
estado.appRecebidoEm = Date.now();
|
||||
estado.appRecebidoEm = estado.ultimoSinalEm = Date.now();
|
||||
avisarBateria();
|
||||
},
|
||||
app_config: (d: AppConfig) => (estado.appConfig = d),
|
||||
aparencia: (d) => (estado.aparencia = d),
|
||||
@@ -105,6 +151,16 @@ const tratadores: Record<string, (d: any) => void> = {
|
||||
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
|
||||
},
|
||||
treinamento: (d) => (estado.treinamento = d),
|
||||
vida: (d: Vida) => {
|
||||
const novo = d.diario[0];
|
||||
if (novo && novo.horario !== estado.vida?.diario[0]?.horario && novo.tipo !== 'acao') noFeed('vida', d.emoji + ' ' + novo.texto);
|
||||
estado.vida = d;
|
||||
},
|
||||
pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, presenca: d.presenca }),
|
||||
historico: (d) => {
|
||||
const n = d?.novo;
|
||||
if (n) noFeed('historico', { pir: '🚶 Presença no PIR', movimento: '👀 Movimento na câmera', som: '🔊 Som alto' }[n.tipo as string] ?? 'Detecção');
|
||||
},
|
||||
};
|
||||
|
||||
let fonte: EventSource | null = null;
|
||||
@@ -127,7 +183,7 @@ function abrirEventos() {
|
||||
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
||||
};
|
||||
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
||||
'reacao', 'treinamento']) {
|
||||
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'criacao']) {
|
||||
fonte.addEventListener(tipo, (e) => {
|
||||
const dados = JSON.parse((e as MessageEvent).data);
|
||||
tratadores[tipo]?.(dados);
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Guia do painel: o que cada parte faz, em linguagem simples. Usado pelo botão ⓘ ao lado dos
|
||||
* títulos (abre só o tópico) e pela página Guia (todos os tópicos, como um tutorial).
|
||||
*/
|
||||
|
||||
export interface ItemGuia {
|
||||
nome: string;
|
||||
texto: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
export interface TopicoGuia {
|
||||
titulo: string;
|
||||
emoji: string;
|
||||
/** Onde fica no painel. */
|
||||
onde: string;
|
||||
resumo: string;
|
||||
itens?: ItemGuia[];
|
||||
dica?: string;
|
||||
}
|
||||
|
||||
export const TOPICOS: Record<string, TopicoGuia> = {
|
||||
inicio: {
|
||||
titulo: 'Primeiros passos',
|
||||
emoji: '👋',
|
||||
onde: 'Todo o painel',
|
||||
resumo:
|
||||
'O Carinha é um rosto de robô no celular (S10), ligado a esta placa (Orange Pi) que também move o braço. O painel mostra o que ele vê e sente, e deixa você mandar gestos, mudar o rosto e cuidar dele.',
|
||||
itens: [
|
||||
{ emoji: '📹', nome: 'Ao vivo', texto: 'Câmera do celular, rosto ao vivo, modo vida, gestos e o que está acontecendo agora.' },
|
||||
{ emoji: '🔔', nome: 'Eventos', texto: 'Histórico de presença, movimento e som, com foto de cada um.' },
|
||||
{ emoji: '💬', nome: 'Conversa', texto: 'Converse por texto ou voz; ele responde falando e com um gesto.' },
|
||||
{ emoji: '😊', nome: 'Reações', texto: 'Biblioteca de animações e figurinhas, e criação de novas com IA.' },
|
||||
{ emoji: '✋', nome: 'Movimento', texto: 'Controle do braço robótico, calibração e teste dos motores.' },
|
||||
{ emoji: '⚙️', nome: 'Ajustes', texto: 'Tudo do celular (tela, sensores, sono, vídeo), IA, segurança e sistema.' },
|
||||
{ emoji: '🛑', nome: 'Parar tudo (E-Stop)', texto: 'O botão vermelho no topo para o braço, os motores e as animações na hora.' },
|
||||
],
|
||||
dica: 'O ⓘ ao lado de cada título explica só aquela parte. Ctrl+K abre a busca de comandos.',
|
||||
},
|
||||
vida: {
|
||||
titulo: 'Modo vida (autônomo)',
|
||||
emoji: '🐣',
|
||||
onde: 'Ao vivo › Vida',
|
||||
resumo:
|
||||
'Com o modo vida ligado, o Carinha funciona como um Sim: tem necessidades que caem com o tempo, e cada necessidade baixa puxa um comportamento. Ele decide sozinho, mas você sempre pode interromper.',
|
||||
itens: [
|
||||
{ emoji: '⚡', nome: 'Energia', texto: 'Cai acordado e mais rápido nas atividades agitadas; o café dá um pique. Abaixo de 40%, às vezes tira um cochilo na caminha. Abaixo de 20%, dorme de verdade e o sono é respeitado: som, movimento e PIR não acordam até ele chegar a 95%.' },
|
||||
{ emoji: '😊', nome: 'Alegria', texto: 'Acompanha a companhia e a diversão. Abaixo de 60%, o rosto vai ficando emburrado aos poucos (testa franzida, boca para baixo e, no fim, olhos bravos). Abaixo de 30%, fica mal-humorado.' },
|
||||
{ emoji: '💬', nome: 'Companhia', texto: 'Sobe quando alguém aparece (PIR), conversa com ele ou faz carinho. Baixa, ele fica carente.' },
|
||||
{ emoji: '🎈', nome: 'Diversão', texto: 'Abaixo de 45%, ele vai fazer uma atividade por 2 a 5 minutos (cansado escolhe coisas calmas; cheio de pique, coisas agitadas). Enquanto faz, a diversão sobe.' },
|
||||
{ emoji: '🛌', nome: 'Sono do celular', texto: 'Com o modo vida ligado, quem decide dormir é a energia; o celular não adormece mais só porque ficou 20 s em silêncio.' },
|
||||
{ emoji: '🎨', nome: 'Inventar figurinhas sozinho', texto: 'De vez em quando ele tem uma ideia e desenha uma figurinha nova com o Gemini, que vai para a biblioteca de Reações. No máximo 3 por dia, com 90 min entre elas.' },
|
||||
{ emoji: '🌙', nome: 'Dormir à noite', texto: 'Na madrugada ele vai dormir mesmo com gente se mexendo por perto. Carinho ou conversa seguram ele acordado por 10 minutos.' },
|
||||
{ emoji: '📖', nome: 'Diário', texto: 'Lista do que ele fez sozinho e dos cuidados que recebeu.' },
|
||||
],
|
||||
dica: 'Desligar o modo vida volta o celular aos gestos soltos de sempre e à sensibilidade dos Ajustes.',
|
||||
},
|
||||
cuidados: {
|
||||
titulo: 'Cuidar do Carinha',
|
||||
emoji: '❤️',
|
||||
onde: 'Ao vivo › Vida (botões) e a tela do celular',
|
||||
resumo: 'Os botões do cartão Vida funcionam como os de um Tamagotchi. Cada um mexe nas necessidades e ele reage na hora.',
|
||||
itens: [
|
||||
{ emoji: '❤️', nome: 'Carinho', texto: 'Companhia +15 e alegria +10. Ele faz coração.' },
|
||||
{ emoji: '🎈', nome: 'Brincar', texto: 'Diversão +25 e alegria +8, gasta um pouco de energia. Ele fica alegre.' },
|
||||
{ emoji: '⚡', nome: 'Energia', texto: 'Energia +30, como se fosse comida. Ele sorri.' },
|
||||
{ emoji: '🌙', nome: 'Ninar / ☀️ Acordar', texto: 'Põe para dormir ou acorda na hora.' },
|
||||
{ emoji: '👆', nome: 'Na tela do celular', texto: 'Deslizar o dedo é carinho; tocar é uma cutucada (um pouco de companhia e diversão). Tocar com ele dormindo acorda.' },
|
||||
],
|
||||
},
|
||||
humores: {
|
||||
titulo: 'Humores',
|
||||
emoji: '🙂',
|
||||
onde: 'Ao vivo › Vida (o emoji grande)',
|
||||
resumo: 'O humor sai das necessidades e escolhe os gestos, as expressões da boca e das sobrancelhas e o quanto ele se mexe.',
|
||||
itens: [
|
||||
{ emoji: '😄', nome: 'Feliz', texto: 'Tudo em dia. Saltita, cantarola, dá sorrisinhos e piscadinhas, mexe mais.' },
|
||||
{ emoji: '🙂', nome: 'Tranquilo', texto: 'Normal. Olha em volta, pisca devagar, meio sorriso.' },
|
||||
{ emoji: '😐', nome: 'Entediado', texto: 'Diversão baixa. Fica pensativo, boca de lado, procura o que fazer e às vezes toca uma reação da biblioteca.' },
|
||||
{ emoji: '🥺', nome: 'Carente', texto: 'Companhia baixa. Com alguém perto, acena e pede carinho; sozinho, fica cabisbaixo.' },
|
||||
{ emoji: '😠', nome: 'Mal-humorado', texto: 'Alegria abaixo de 30%. Testa franzida, olhos bravos, resmunga. Carinho, conversa e brincadeira resolvem.' },
|
||||
{ emoji: '🥱', nome: 'Cansado', texto: 'Energia baixa. Boceja, suspira e logo vai dormir.' },
|
||||
{ emoji: '😴', nome: 'Dormindo', texto: 'Olhos fechados, respira devagar e recupera energia.' },
|
||||
],
|
||||
},
|
||||
ritmo: {
|
||||
titulo: 'Ritmo e sensibilidade',
|
||||
emoji: '🎚️',
|
||||
onde: 'Ao vivo › Vida › Ritmo',
|
||||
resumo: 'O ritmo diz o quanto ele age sozinho e o quanto os sensores precisam ser estimulados para ele reagir ou acordar.',
|
||||
itens: [
|
||||
{ emoji: '🐢', nome: 'Calmo', texto: 'Age a cada 1 a 2,5 min. Limiares 50% mais altos; reage só no 3º barulho alto ou movimento forte seguido; acorda com o dobro de sons, 4 movimentos ou 3 disparos do PIR em 2 min.' },
|
||||
{ emoji: '🙂', nome: 'Normal', texto: 'Age a cada 25 s a 1 min. Reage a barulho alto e movimento forte; acorda com os sons configurados ou 2 movimentos.' },
|
||||
{ emoji: '⚡', nome: 'Agitado', texto: 'Age a cada 12 a 35 s. Limiares 40% mais baixos; reage a qualquer som ou movimento com reações variadas e acorda no primeiro estímulo.' },
|
||||
],
|
||||
dica: 'A madrugada silenciosa vale por cima de qualquer ritmo.',
|
||||
},
|
||||
sono: {
|
||||
titulo: 'Sono e madrugada',
|
||||
emoji: '🌙',
|
||||
onde: 'Ajustes › Sono e madrugada',
|
||||
resumo: 'Decide o que acorda o rosto enquanto ele dorme.',
|
||||
itens: [
|
||||
{ emoji: '🔊', nome: 'Sons para acordar', texto: 'Quantos barulhos em 1 minuto ele precisa ouvir para acordar. Um barulho comprido conta como um.' },
|
||||
{ emoji: '🌌', nome: 'Madrugada silenciosa', texto: 'No horário escolhido, movimento, câmera e PIR não acordam. Só barulho alto repetido, toque na tela ou conversa.' },
|
||||
{ emoji: '🕚', nome: 'Começa às / Termina às', texto: 'Horário da madrugada (padrão 23h às 7h). Pode atravessar a meia-noite.' },
|
||||
{ emoji: '⬛', nome: 'Apagar a tela quando dormir', texto: 'Fica em Tela e personalidade: depois de um tempo dormindo, a tela fica preta e o brilho vai ao mínimo para poupar bateria.' },
|
||||
],
|
||||
},
|
||||
celular: {
|
||||
titulo: 'Celular',
|
||||
emoji: '📱',
|
||||
onde: 'Ao vivo › Celular',
|
||||
resumo: 'Estado do S10: bateria, Wi-Fi, IP, versão do app e se ele está acordado. Tudo vem do próprio aplicativo.',
|
||||
itens: [
|
||||
{ emoji: '🔋', nome: 'Bateria', texto: 'Nível e se está carregando. O painel avisa quando está baixa.' },
|
||||
{ emoji: '🔄', nome: 'Recarregar tela do celular', texto: 'Recarrega o rosto no celular para pegar uma versão nova sem mexer no aparelho.' },
|
||||
{ emoji: '🕒', nome: 'Último sinal', texto: 'Se o celular sumir (bateria acabou, Wi-Fi caiu), aparece há quanto tempo ele não responde.' },
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
titulo: 'Câmera e rastreamento',
|
||||
emoji: '📹',
|
||||
onde: 'Ao vivo › Câmera e Ajustes › Vídeo e rastreamento',
|
||||
resumo: 'O vídeo vai direto do celular para este navegador (WebRTC). Nada é gravado; só a foto de cada evento fica no histórico.',
|
||||
itens: [
|
||||
{ emoji: '🎥', nome: 'Transmitir câmera do celular', texto: 'Liga o vídeo ao vivo. Toque no quadrinho do canto para trocar câmera e rosto de lugar.' },
|
||||
{ emoji: '🙂', nome: 'Rastrear rosto / mãos', texto: 'Este computador acha rostos e mãos no vídeo.' },
|
||||
{ emoji: '👀', nome: 'Olhos seguem o alvo', texto: 'Os olhos do Carinha acompanham o rosto ou a mão encontrada.' },
|
||||
{ emoji: '✏️', nome: 'Desenhar rastreamento', texto: 'Mostra no vídeo as marcações do que foi encontrado.' },
|
||||
{ emoji: '🟢', nome: 'Bolinha PIR na imagem', texto: 'Cinza com o sensor parado; verde pulsando enquanto o sensor de presença está vendo alguém.' },
|
||||
],
|
||||
},
|
||||
gestos: {
|
||||
titulo: 'Gestos',
|
||||
emoji: '😉',
|
||||
onde: 'Ao vivo › Gestos e Reações › Gestos do rosto',
|
||||
resumo: 'Animações prontas do rosto: feliz, surpreso, bravo, acenar, esconder o rosto com as mãos, dançar e outras.',
|
||||
itens: [
|
||||
{ emoji: '⭐', nome: 'Favoritos', texto: 'Até 20 gestos fixados com a estrela. Aparecem no Ao vivo.' },
|
||||
{ emoji: '😂', nome: 'Rir', texto: 'Olhos fechadinhos e boca aberta, pulando de rir.' },
|
||||
{ emoji: '🥳', nome: 'Comemorar', texto: 'Pula trocando de cor.' },
|
||||
{ emoji: '😳', nome: 'Tímido', texto: 'Olha para baixo e para o lado, cor-de-rosa.' },
|
||||
{ emoji: '😕', nome: 'Confuso', texto: 'Um olho meio fechado e a cabeça indo de um lado para o outro.' },
|
||||
{ emoji: '🥱', nome: 'Bocejar', texto: 'Olhos pesados, a mão na frente da boca e Zzz subindo. Aparece sozinho quando ele está cansado ou entediado.' },
|
||||
{ emoji: '😵💫', nome: 'Tonto', texto: 'Espirais girando no lugar dos olhos e boca de "ó".' },
|
||||
{ emoji: '⌨️', nome: 'Teclas 1 a 9', texto: 'Disparam os nove primeiros favoritos.' },
|
||||
{ emoji: '🔍', nome: 'Ctrl+K', texto: 'Busca qualquer gesto, rosto, cor ou tela pelo nome.' },
|
||||
],
|
||||
},
|
||||
aparencia: {
|
||||
titulo: 'Aparência do rosto',
|
||||
emoji: '🎨',
|
||||
onde: 'Ao vivo › Aparência › Rosto, formato e cor',
|
||||
resumo: 'Escolha o estilo do rosto, o formato dos olhos e a cor. Muda na hora no celular.',
|
||||
itens: [
|
||||
{ emoji: '👁️', nome: 'Carinha Viva', texto: 'Pálpebras, brilho e micro-movimentos.' },
|
||||
{ emoji: '🟦', nome: 'Bloco', texto: 'Olhos quadrados, estilo robô de mesa.' },
|
||||
{ emoji: '🔵', nome: 'Matriz', texto: 'Painel de LEDs; boca e sobrancelhas também em pontos.' },
|
||||
{ emoji: '🔘', nome: 'Lente', texto: 'Íris e pupila que dilata.' },
|
||||
{ emoji: '🙂', nome: 'Clássico, Miúdo e Eilik', texto: 'Os desenhos originais.' },
|
||||
{ emoji: '⬭', nome: 'Formato', texto: 'Padrão, redondo, pílula, quadrado ou largo (nos rostos novos).' },
|
||||
{ emoji: '🤨', nome: 'Microexpressões', texto: 'Acordado e sem gesto, a boca e as sobrancelhas mexem sozinhas de vez em quando (meio sorriso, sobrancelha arqueada, suspiro) conforme o humor.' },
|
||||
],
|
||||
dica: 'Boca, sobrancelhas e íris ligam e desligam em Ajustes › Tela e personalidade.',
|
||||
},
|
||||
atividades: {
|
||||
titulo: 'Atividades (momentos dele)',
|
||||
emoji: '🎮',
|
||||
onde: 'Ao vivo › Gestos › Atividades',
|
||||
resumo:
|
||||
'São 20 atividades. A tela se divide: o rosto vai para a esquerda e à direita aparece um bonequinho dele, na cor dos olhos, fazendo a atividade. Os olhos grandes acompanham (seguem a bola, leem as linhas, olham a TV, a pipa, o peixe).',
|
||||
itens: [
|
||||
{ emoji: '⚽', nome: 'Agitadas (gastam energia)', texto: 'Jogar bola, malhar, pular corda, skate, dançar (com globo espelhado) e soltar pipa.' },
|
||||
{ emoji: '📖', nome: 'Calmas', texto: 'Ler, tomar café (dá energia), meditar flutuando, olhar estrelas no telescópio, pescar, computador e regar a plantinha (que cresce e dá flor).' },
|
||||
{ emoji: '🎨', nome: 'Criativas', texto: 'Pintar, tocar violão, cantar, cozinhar (vira a panqueca), bolhas de sabão e videogame.' },
|
||||
{ emoji: '🛏️', nome: 'Cochilar', texto: 'Deita na caminha com cobertor e Zzz; recupera energia como dormir.' },
|
||||
{ emoji: '🐣', nome: 'No modo vida', texto: 'Com a diversão baixa, ele escolhe uma pelo pique e fica nela de 2 a 5 minutos; cansado, cochila. Nunca de madrugada.' },
|
||||
{ emoji: '👆', nome: 'Parar', texto: 'Botão Parar no painel ou um toque na tela do celular. Dormir também encerra a atividade.' },
|
||||
],
|
||||
},
|
||||
criar: {
|
||||
titulo: 'Criar com IA',
|
||||
emoji: '🪄',
|
||||
onde: 'Ao vivo › Gestos › Criar com IA (ou Reações › Criar com IA)',
|
||||
resumo:
|
||||
'Escreva um pedido e uma IA cria uma expressão, uma atividade ou uma figurinha nova. Ela não escreve código: monta a criação só com peças que o Carinha já sabe animar, com exemplos reais do que já existe. Tudo é conferido no servidor antes de chegar ao celular.',
|
||||
itens: [
|
||||
{ emoji: '😊', nome: 'Expressão', texto: 'Sequência de olhos, boca, cor, olhar e mãozinhas, como os gestos favoritos. Ex.: "desconfiado, olhando de lado".' },
|
||||
{ emoji: '🤹', nome: 'Atividade', texto: 'O bonequinho faz algo com braços, pernas e objetos (bola, livro, notas, estrelas, halter, microfone, flor...). Ex.: "malabarismo com 3 bolinhas".' },
|
||||
{ emoji: '🎨', nome: 'Figurinha', texto: 'Desenho que aparece nos olhos ou em volta do rosto.' },
|
||||
{ emoji: '🧬', nome: 'Basear em', texto: 'Escolha um gesto, atividade ou criação que já existe e peça uma variação: a IA parte da receita dele.' },
|
||||
{ emoji: '✨', nome: 'Gemini', texto: 'O melhor resultado e o mais rápido (alguns segundos). Usa a chave paga.' },
|
||||
{ emoji: '🖥️', nome: 'IA local', texto: 'Um servidor na sua rede (LM Studio ou llama-server, como o ROG Ally). Grátis, mas pode levar alguns minutos e criar coisas mais simples. Configure em "IA local".' },
|
||||
{ emoji: '📱', nome: 'Qwen do S10', texto: 'O modelo pequeno do celular. Rápido e grátis, mas fraco para criar: bom só para testar.' },
|
||||
{ emoji: '⭐', nome: 'Favoritos', texto: 'A estrela põe expressões e atividades criadas na grade de favoritos do Ao vivo (e nas teclas 1 a 9).' },
|
||||
],
|
||||
},
|
||||
personalidade: {
|
||||
titulo: 'IA local (Qwen)',
|
||||
emoji: '🧠',
|
||||
onde: 'Ao vivo › Personalidade',
|
||||
resumo: 'Com "Reagir sozinho com IA local" ligado, o Qwen escolhe uma expressão a partir dos sons e movimentos que o celular percebe, no máximo a cada 45 s. Cada evento só tem gestos que combinam (barulho: susto ou olhar para o lado; movimento: olhar e acenar) e ele nunca repete os dois últimos. Não interrompe atividades e, com o modo vida ligado, ficar sozinho é com o modo vida. Roda sem internet e sem custo.',
|
||||
},
|
||||
acontecimentos: {
|
||||
titulo: 'Acontecimentos',
|
||||
emoji: '🔔',
|
||||
onde: 'Ao vivo › Acontecimentos',
|
||||
resumo: 'Lista ao vivo do que acabou de acontecer: gestos, respostas da conversa, reações, detecções e o que o modo vida fez.',
|
||||
itens: [
|
||||
{ emoji: '🚶', nome: 'Sensor de presença (PIR)', texto: 'Mostra se o sensor está funcionando e quando viu alguém pela última vez.' },
|
||||
{ emoji: '🗂️', nome: 'Eventos', texto: 'Abre o histórico completo com fotos.' },
|
||||
],
|
||||
},
|
||||
eventos: {
|
||||
titulo: 'Eventos e fotos',
|
||||
emoji: '🗂️',
|
||||
onde: 'Eventos',
|
||||
resumo: 'Cada detecção vira um evento com horário e, se ligado, uma foto da câmera do celular. Os eventos ficam guardados pelos dias escolhidos (padrão 3) e depois são apagados sozinhos.',
|
||||
itens: [
|
||||
{ emoji: '🚶', nome: 'Sensor de presença (PIR)', texto: 'Sensor HC-SR505 no pino 11 da placa. Vê calor de corpo em movimento a até ~3 m.' },
|
||||
{ emoji: '👀', nome: 'Movimento na câmera', texto: 'O celular percebe algo se mexendo na frente.' },
|
||||
{ emoji: '🔊', nome: 'Som alto', texto: 'O celular ouve um barulho forte (só o nível, nunca o áudio).' },
|
||||
{ emoji: '📷', nome: 'Guardar foto', texto: 'Tira uma foto em cada evento. "Ver foto" abre a imagem.' },
|
||||
{ emoji: '⏱️', nome: 'Intervalo', texto: 'Tempo mínimo entre dois eventos do mesmo tipo, para não lotar o histórico.' },
|
||||
],
|
||||
},
|
||||
presenca: {
|
||||
titulo: 'Modo presença',
|
||||
emoji: '🚶',
|
||||
onde: 'Eventos › Modo presença',
|
||||
resumo:
|
||||
'O PIR liga a câmera do celular quando alguém aparece e acorda o Carinha. Sem ninguém pelo tempo escolhido, a câmera desliga, ele dorme e fica em silêncio. Na madrugada silenciosa o PIR não acorda.',
|
||||
},
|
||||
tela: {
|
||||
titulo: 'Tela e personalidade',
|
||||
emoji: '📺',
|
||||
onde: 'Ajustes › Tela e personalidade',
|
||||
resumo: 'Como o rosto aparece no celular e o que ele faz sozinho.',
|
||||
itens: [
|
||||
{ emoji: '🎲', nome: 'Gestos espontâneos', texto: 'Pisca e olha em volta sem comando. Com o modo vida, os gestos seguem o humor.' },
|
||||
{ emoji: '👄', nome: 'Boca, sobrancelhas e íris', texto: 'Liga ou desliga cada parte do rosto.' },
|
||||
{ emoji: '💡', nome: 'Brilho e repouso', texto: 'Brilho normal e o mais fraco depois de um tempo sem nada acontecer.' },
|
||||
],
|
||||
},
|
||||
sensores: {
|
||||
titulo: 'Sensores e reações',
|
||||
emoji: '🎙️',
|
||||
onde: 'Ajustes › Sensores e reações',
|
||||
resumo: 'O que o celular usa para perceber o ambiente. Só os níveis são usados: nenhum áudio é gravado e a câmera não sai do celular sem você ligar a transmissão.',
|
||||
itens: [
|
||||
{ emoji: '🎙️', nome: 'Microfone / Reagir a sons', texto: 'Ouve o nível do som para se assustar e acordar.' },
|
||||
{ emoji: '🤫', nome: 'Silêncio para dormir', texto: 'Quantos segundos sem som nem movimento até ele adormecer sozinho.' },
|
||||
{ emoji: '📷', nome: 'Câmera como sensor / Reagir a movimento', texto: 'Detecta movimento na frente do celular.' },
|
||||
{ emoji: '😴', nome: 'Microfone e câmera durante o sono', texto: 'Se continuam ligados dormindo (para poder acordar).' },
|
||||
],
|
||||
},
|
||||
sensibilidade: {
|
||||
titulo: 'Sensibilidade (avançado)',
|
||||
emoji: '🎛️',
|
||||
onde: 'Ajustes › Sensibilidade',
|
||||
resumo: 'Os limiares de base dos sustos. O ritmo do modo vida multiplica esses valores (calmo sobe, agitado desce).',
|
||||
itens: [
|
||||
{ emoji: '🔉', nome: 'Nível para perceber som', texto: 'Acima disso conta como "tem som".' },
|
||||
{ emoji: '💥', nome: 'Som mínimo e aumento súbito para susto', texto: 'Precisa passar dos dois: ser alto e ser bem mais alto que o barulho de fundo.' },
|
||||
{ emoji: '🏃', nome: 'Movimento mínimo / para susto', texto: 'Quanto precisa mexer para ele olhar e para ele se assustar.' },
|
||||
{ emoji: '⏳', nome: 'Intervalos', texto: 'Tempo mínimo entre dois sustos seguidos.' },
|
||||
],
|
||||
},
|
||||
reacoes: {
|
||||
titulo: 'Reações',
|
||||
emoji: '✨',
|
||||
onde: 'Reações',
|
||||
resumo: 'Animações e figurinhas que aparecem nos olhos ou em volta do rosto.',
|
||||
itens: [
|
||||
{ emoji: '📚', nome: 'Biblioteca', texto: 'Toque para tocar no celular. Dá para curtir, editar, duplicar, desativar e apagar (com desfazer).' },
|
||||
{ emoji: '🖌️', nome: 'Criar com IA', texto: 'Descreva uma figurinha e o Gemini desenha; ou deixe ele criar 3 reações a partir da conversa e da rotina.' },
|
||||
{ emoji: '😉', nome: 'Gestos do rosto', texto: 'Todos os gestos, com a estrela para fixar nos favoritos.' },
|
||||
{ emoji: '📖', nome: 'Diário', texto: 'Resumos e registros usados para criar reações.' },
|
||||
],
|
||||
},
|
||||
conversa: {
|
||||
titulo: 'Conversa',
|
||||
emoji: '💬',
|
||||
onde: 'Conversa',
|
||||
resumo: 'Fale ou escreva; o Carinha responde em voz de robô, com legenda na tela do celular e um gesto.',
|
||||
itens: [
|
||||
{ emoji: '🤖', nome: 'Motor', texto: 'Qwen local (sem internet, sem custo) ou Gemini (responde melhor). Troca em Ajustes › Inteligência artificial.' },
|
||||
{ emoji: '🧠', nome: 'Lembranças', texto: 'Coisas que ele guarda sobre você e usa nas respostas.' },
|
||||
{ emoji: '🗓️', nome: 'Rotina e preferências', texto: 'Hábitos e gostos que você ensina; também inspiram as reações.' },
|
||||
],
|
||||
},
|
||||
movimento: {
|
||||
titulo: 'Movimento (braço)',
|
||||
emoji: '🦾',
|
||||
onde: 'Movimento',
|
||||
resumo: 'Controle do braço de 7 articulações pela placa de servos (PCA9685).',
|
||||
itens: [
|
||||
{ emoji: '✋', nome: 'Segurar para mover', texto: 'O braço só se mexe enquanto você segura o botão ou o controle. Soltou, ele para.' },
|
||||
{ emoji: '🧍', nome: 'Posturas e sequências', texto: 'Posições salvas e movimentos encadeados. A prévia anima só a tela.' },
|
||||
{ emoji: '📏', nome: 'Calibração', texto: 'Limites de cada motor, testados passo a passo.' },
|
||||
{ emoji: '🩺', nome: 'Diagnóstico', texto: 'Teste canal a canal com limites conservadores. Não abre junto com o controle do braço.' },
|
||||
],
|
||||
dica: 'O E-Stop vermelho no topo para tudo. Depois é preciso rearmar para mover de novo.',
|
||||
},
|
||||
atalhos: {
|
||||
titulo: 'Atalhos de teclado',
|
||||
emoji: '⌨️',
|
||||
onde: 'Em qualquer tela',
|
||||
resumo: 'Para usar o painel sem o mouse.',
|
||||
itens: [
|
||||
{ nome: 'Ctrl+K', texto: 'Busca de comandos (gestos, rostos, cores, telas, este guia).' },
|
||||
{ nome: '1 a 9', texto: 'Gestos favoritos.' },
|
||||
{ nome: 'Esc Esc ou Shift+Espaço', texto: 'E-Stop: para tudo.' },
|
||||
{ nome: 'g e uma letra', texto: 'Vai para uma tela: g v Ao vivo, g e Eventos, g c Conversa, g r Reações, g m Movimento, g a Ajustes.' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Ordem do tutorial (página Guia). */
|
||||
export const ORDEM_GUIA = [
|
||||
'inicio', 'vida', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'aparencia',
|
||||
'personalidade', 'acontecimentos', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
|
||||
'reacoes', 'conversa', 'movimento', 'atalhos',
|
||||
];
|
||||
|
||||
/** Tópico de cada tela, para o ⓘ do topo. */
|
||||
export const TOPICO_DA_TELA: Record<string, string> = {
|
||||
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
|
||||
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar',
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/** Histórico de detecções (/historico/*): recarrega ao abrir e quando o servidor avisa por SSE. */
|
||||
import { lerToken, pedir, postar } from './api';
|
||||
import { avisos } from './avisos.svelte';
|
||||
import { ouvir } from './estado.svelte';
|
||||
|
||||
export interface EventoHistorico {
|
||||
id: number;
|
||||
horario: number;
|
||||
tipo: 'pir' | 'movimento' | 'som';
|
||||
evento: string;
|
||||
nivel: number | null;
|
||||
foto: boolean;
|
||||
}
|
||||
|
||||
export interface StatusHistorico {
|
||||
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number };
|
||||
presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number };
|
||||
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||
eventos: EventoHistorico[];
|
||||
total: number;
|
||||
max_eventos: number;
|
||||
max_fotos: number;
|
||||
}
|
||||
|
||||
export const TIPOS_EVENTO: Record<string, { nome: string; emoji: string; cor: string }> = {
|
||||
pir: { nome: 'Presença (PIR)', emoji: '🚶', cor: '#34c759' },
|
||||
movimento: { nome: 'Movimento na câmera', emoji: '👀', cor: '#0a84ff' },
|
||||
som: { nome: 'Som alto', emoji: '🔊', cor: '#ff9500' },
|
||||
};
|
||||
|
||||
/** URL da foto com o token na query (a tag <img> não manda cabeçalho). */
|
||||
export function urlFoto(id: number) {
|
||||
const t = lerToken();
|
||||
return `/historico/foto/evento-${id}.jpg` + (t ? '?t=' + encodeURIComponent(t) : '');
|
||||
}
|
||||
|
||||
class Historico {
|
||||
status = $state<StatusHistorico | null>(null);
|
||||
private espera = 0;
|
||||
|
||||
async carregar() {
|
||||
try {
|
||||
this.status = await pedir<StatusHistorico>('/historico/status');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async configurar(parcial: Partial<StatusHistorico['config']>) {
|
||||
const antes = this.status ? { ...this.status.config } : null;
|
||||
if (this.status) this.status = { ...this.status, config: { ...this.status.config, ...parcial } };
|
||||
try {
|
||||
this.status = await postar<StatusHistorico>('/historico/config', parcial);
|
||||
} catch (e) {
|
||||
if (this.status && antes) this.status = { ...this.status, config: antes };
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async apagar(id: number) {
|
||||
try {
|
||||
await postar('/historico/apagar', { id });
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async limpar() {
|
||||
try {
|
||||
await postar('/historico/limpar');
|
||||
avisos.ok('Histórico apagado.');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Vários avisos seguidos (evento + foto) viram uma recarga só. */
|
||||
agendar() {
|
||||
clearTimeout(this.espera);
|
||||
this.espera = window.setTimeout(() => this.carregar(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
export const historico = new Historico();
|
||||
ouvir('historico', () => historico.status && historico.agendar());
|
||||
@@ -1,6 +1,9 @@
|
||||
/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */
|
||||
import { cor, CORES, gesto, GESTOS_INFO, gestosDisponiveis, pararTudo, rearmar, recarregarCelular, rosto } from './comandos.svelte';
|
||||
import {
|
||||
atividade, ATIVIDADES_INFO, cor, CORES, formato, FORMATOS_INFO, gesto, GESTOS_INFO, gestosDisponiveis, nomeRosto, pararTudo, rearmar, recarregarCelular, rosto,
|
||||
} from './comandos.svelte';
|
||||
import { estado } from './estado.svelte';
|
||||
import { ORDEM_GUIA, TOPICOS } from './guia';
|
||||
import { nav, ROTAS } from './rotas.svelte';
|
||||
|
||||
export interface ItemPaleta {
|
||||
@@ -33,6 +36,20 @@ export function itensDaPaleta(): ItemPaleta[] {
|
||||
// Itens base: navegação, ações gerais, gestos, rostos e cores.
|
||||
registrarNaPaleta(() => [
|
||||
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
|
||||
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
|
||||
{ id: 'ir-criar', grupo: 'Ir para', titulo: 'Criar com IA (expressões, atividades, figurinhas)', fazer: () => nav.ir('criar') },
|
||||
...Object.entries(ATIVIDADES_INFO).map(([id, a]) => ({
|
||||
id: 'atividade-' + id, grupo: 'Atividades', titulo: a.emoji + ' ' + a.nome, fazer: () => atividade(id),
|
||||
})),
|
||||
{ id: 'atividade-parar', grupo: 'Atividades', titulo: '⏹ Parar a atividade', fazer: () => atividade('parar') },
|
||||
...ORDEM_GUIA.map((t) => ({
|
||||
id: 'guia-' + t,
|
||||
grupo: 'Guia',
|
||||
titulo: TOPICOS[t].emoji + ' ' + TOPICOS[t].titulo,
|
||||
dica: TOPICOS[t].onde,
|
||||
fazer: () => (location.hash = '/guia?t=' + t),
|
||||
})),
|
||||
...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
|
||||
estado.estop.ativo
|
||||
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
|
||||
: { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo },
|
||||
@@ -43,6 +60,6 @@ registrarNaPaleta(() => [
|
||||
titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`,
|
||||
fazer: () => gesto(g),
|
||||
})),
|
||||
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + r, fazer: () => rosto(r) })),
|
||||
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + nomeRosto(r), fazer: () => rosto(r) })),
|
||||
...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })),
|
||||
]);
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
|
||||
|
||||
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes';
|
||||
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar';
|
||||
|
||||
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
|
||||
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
|
||||
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
|
||||
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
|
||||
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
|
||||
{ id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' },
|
||||
];
|
||||
|
||||
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */
|
||||
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
|
||||
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' },
|
||||
{ id: 'guia', titulo: 'Guia', volta: 'ajustes' },
|
||||
{ id: 'criar', titulo: 'Criar com IA', volta: 'reacoes' },
|
||||
];
|
||||
|
||||
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
|
||||
|
||||
function lerHash(): Rota {
|
||||
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
|
||||
return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo';
|
||||
let id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
|
||||
if ((id as string) === 'historico') id = 'eventos'; // endereço antigo
|
||||
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
|
||||
}
|
||||
|
||||
class Navegacao {
|
||||
|
||||
+25
-1
@@ -69,7 +69,8 @@ export interface EstadoServidor {
|
||||
seq: number;
|
||||
autenticacao: boolean;
|
||||
estop: { ativo: boolean; desde: number | null };
|
||||
aparencia: { rosto: string; cor: [number, number, number] };
|
||||
aparencia: { rosto: string; cor: [number, number, number]; formato?: string };
|
||||
formatos?: string[];
|
||||
gestos: string[];
|
||||
rostos: string[];
|
||||
app?: Telemetria;
|
||||
@@ -80,4 +81,27 @@ export interface EstadoServidor {
|
||||
reacao?: Reacao | null;
|
||||
treinamento?: { ocupado: boolean; erro: string };
|
||||
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
|
||||
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||
vida?: Vida;
|
||||
}
|
||||
|
||||
/** Modo vida (/vida/status e SSE 'vida'): necessidades de 0 a 100, 100 = satisfeito. */
|
||||
export interface Vida {
|
||||
config: { ligado: boolean; criar_emojis: boolean; max_emojis_dia: number; ritmo: 'calmo' | 'normal' | 'agitado'; noite: boolean };
|
||||
necessidades: { energia: number; alegria: number; social: number; diversao: number };
|
||||
humor: string;
|
||||
emoji: string;
|
||||
nome_humor: string;
|
||||
dormindo: boolean;
|
||||
nascimento: number;
|
||||
idade_dias: number;
|
||||
emojis_hoje: number;
|
||||
ultimo_emoji: number | null;
|
||||
/** 0 a 1: o quanto está emburrado (alegria baixa). */
|
||||
zanga?: number;
|
||||
/** Dormindo de cansaço: som, movimento e PIR não acordam. */
|
||||
sono_profundo?: boolean;
|
||||
/** '' quando pode inventar figurinha agora; senão, o motivo. */
|
||||
pode_criar: string;
|
||||
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade'; texto: string }[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Modo vida: ajustes e cuidados. O estado chega pelo SSE 'vida' em estado.vida. */
|
||||
import { postar } from './api';
|
||||
import { avisos } from './avisos.svelte';
|
||||
import { estado } from './estado.svelte';
|
||||
import type { Vida } from './tipos';
|
||||
|
||||
export const NECESSIDADES: { chave: keyof Vida['necessidades']; nome: string; emoji: string; cor: string }[] = [
|
||||
{ chave: 'energia', nome: 'Energia', emoji: '⚡', cor: '#ffcc00' },
|
||||
{ chave: 'alegria', nome: 'Alegria', emoji: '😊', cor: '#ff2d55' },
|
||||
{ chave: 'social', nome: 'Companhia', emoji: '💬', cor: '#0a84ff' },
|
||||
{ chave: 'diversao', nome: 'Diversão', emoji: '🎈', cor: '#34c759' },
|
||||
];
|
||||
|
||||
export const CUIDADOS = [
|
||||
{ acao: 'carinho', nome: 'Carinho', emoji: '❤️' },
|
||||
{ acao: 'brincar', nome: 'Brincar', emoji: '🎈' },
|
||||
{ acao: 'alimentar', nome: 'Energia', emoji: '⚡' },
|
||||
] as const;
|
||||
|
||||
export async function configurarVida(parcial: Partial<Vida['config']>) {
|
||||
const antes = estado.vida;
|
||||
if (antes) estado.vida = { ...antes, config: { ...antes.config, ...parcial } };
|
||||
try {
|
||||
estado.vida = await postar<Vida>('/vida/config', parcial);
|
||||
} catch (e) {
|
||||
estado.vida = antes;
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cuidar(acao: string) {
|
||||
try {
|
||||
estado.vida = await postar<Vida>('/vida/cuidar', { acao });
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Download, KeyRound, RefreshCw, RotateCcw, Search, ShieldCheck, ShieldOff } from '@lucide/svelte';
|
||||
import { BookOpen, Download, KeyRound, RefreshCw, RotateCcw, Search, ShieldCheck, ShieldOff } from '@lucide/svelte';
|
||||
import { gravarToken, lerToken, postar } from '../lib/api';
|
||||
import { GRUPOS, restaurarPadrao, salvarApp, type Campo } from '../lib/ajustes.svelte';
|
||||
import { avisos, tentar } from '../lib/avisos.svelte';
|
||||
import { recarregarCelular } from '../lib/comandos.svelte';
|
||||
import Ajuda from '../lib/componentes/Ajuda.svelte';
|
||||
import Card from '../lib/componentes/Card.svelte';
|
||||
import Selo from '../lib/componentes/Selo.svelte';
|
||||
import Slider from '../lib/componentes/Slider.svelte';
|
||||
@@ -97,7 +98,7 @@
|
||||
{#each grupos as g (g.titulo)}
|
||||
<fieldset>
|
||||
<legend>
|
||||
<strong>{g.titulo}</strong>
|
||||
<strong class="titulo-grupo">{g.titulo} <Ajuda topico={g.ajuda} /></strong>
|
||||
<span class="fraco pequeno-texto">{g.descricao}</span>
|
||||
</legend>
|
||||
{#each g.campos as c (c.chave)}
|
||||
@@ -137,7 +138,7 @@
|
||||
</Card>
|
||||
|
||||
<div class="coluna">
|
||||
<Card titulo="Inteligência artificial">
|
||||
<Card titulo="Inteligência artificial" ajuda="conversa">
|
||||
<div class="campo">
|
||||
<span>Motor da conversa</span>
|
||||
<div class="segmentado" role="radiogroup" aria-label="Motor da conversa">
|
||||
@@ -223,6 +224,7 @@
|
||||
<RefreshCw size={14} /> Recarregar tela do celular
|
||||
</button>
|
||||
<a class="btn pequeno" href="/app.apk" download><Download size={14} /> Baixar app Android</a>
|
||||
<a class="btn pequeno" href="#/guia"><BookOpen size={14} /> Guia do painel</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -267,6 +269,11 @@
|
||||
gap: var(--e-2);
|
||||
align-content: start;
|
||||
}
|
||||
.titulo-grupo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
legend {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
|
||||
+105
-78
@@ -1,18 +1,20 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Brain, Camera, Download, Eye, Hand, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
|
||||
Brain, Camera, Download, Eye, Footprints, Gamepad2, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video, Wand2,
|
||||
} from '@lucide/svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { postar } from '../lib/api';
|
||||
import { salvarApp } from '../lib/ajustes.svelte';
|
||||
import { tentar } from '../lib/avisos.svelte';
|
||||
import { cor, CORES, hex, recarregarCelular, rgb, rosto } from '../lib/comandos.svelte';
|
||||
import { atividade, ATIVIDADES_INFO, FORMATOS_INFO, hex, nomeRosto, recarregarCelular, ROSTOS_INFO } from '../lib/comandos.svelte';
|
||||
import Celular from '../lib/componentes/Celular.svelte';
|
||||
import Gestos from '../lib/componentes/Gestos.svelte';
|
||||
import Grupo from '../lib/componentes/Grupo.svelte';
|
||||
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
||||
import Linha from '../lib/componentes/Linha.svelte';
|
||||
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||
import VidaCartao from '../lib/componentes/VidaCartao.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
import { Rastreamento } from '../lib/rastreamento.svelte';
|
||||
import { VideoAoVivo } from '../lib/video.svelte';
|
||||
@@ -24,6 +26,8 @@
|
||||
|
||||
// Palco: câmera grande e rosto no quadrinho do canto; tocar no quadrinho troca os dois.
|
||||
let rostoGrande = $state(false);
|
||||
// Atividade que o celular está fazendo agora (vem na telemetria).
|
||||
const atividadeAgora = $derived((estado.app?.online && (estado.app.estado?.atividade as string | null)) || null);
|
||||
|
||||
onMount(() => {
|
||||
video = new VideoAoVivo(elVideo);
|
||||
@@ -47,6 +51,11 @@
|
||||
});
|
||||
|
||||
const cfg = $derived(estado.appConfig);
|
||||
// Pontinho do PIR sobre a imagem: verde pulsando nos 8 s depois de cada disparo.
|
||||
// aceso enquanto o pino do sensor está alto; servidor antigo (sem `ativo`) cai nos 8 s após o disparo
|
||||
const pirAtivo = $derived(
|
||||
estado.pir?.ativo ?? (!!estado.pir?.ultimo && estado.agora - estado.pir.ultimo * 1000 < 8000),
|
||||
);
|
||||
const transmitindo = $derived(!!cfg?.preview);
|
||||
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
@@ -69,6 +78,12 @@
|
||||
<div class="ao-vivo">
|
||||
<!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
|
||||
<section class="palco" aria-label="Câmera e rosto">
|
||||
{#if estado.pir?.disponivel}
|
||||
<span class="ponto-pir" class:ativo={pirAtivo} title="Sensor de presença (PIR)" aria-live="polite">
|
||||
<span class="luz" aria-hidden="true"></span>
|
||||
PIR{pirAtivo ? ' · presença' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="quadro camera" class:mini={rostoGrande}>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
|
||||
@@ -110,12 +125,14 @@
|
||||
</section>
|
||||
|
||||
<div class="lista">
|
||||
<Grupo titulo="Celular">
|
||||
<VidaCartao />
|
||||
|
||||
<Grupo titulo="Celular" ajuda="celular">
|
||||
<Celular />
|
||||
<Linha icone={RefreshCw} cor="#0a84ff" rotulo="Recarregar tela do celular" onclick={recarregarCelular} disabled={!estado.celularOnline} />
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="Câmera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
|
||||
<Grupo titulo="Câmera" ajuda="camera" rodape={transmitindo && rastreio ? rastreio.texto : 'O vídeo vai direto do celular para este navegador (WebRTC); nada é gravado.'}>
|
||||
<Linha icone={Video} cor="#34c759" rotulo="Transmitir câmera do celular" detalhe={video?.exibindo ? video.qualidade : ''}>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Transmitir câmera do celular" checked={transmitindo} disabled={!cfg} onchange={(v) => salvarApp({ preview: v })} />
|
||||
@@ -146,41 +163,46 @@
|
||||
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="Gestos" rodape="Teclas 1 a 9 no teclado. Ctrl+K busca qualquer gesto.">
|
||||
<Grupo titulo="Gestos" ajuda="gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
|
||||
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
|
||||
<Gestos />
|
||||
</Linha>
|
||||
<Linha
|
||||
icone={Gamepad2}
|
||||
cor="#30b0c7"
|
||||
rotulo="Atividades"
|
||||
detalhe={atividadeAgora ? `Agora: ${ATIVIDADES_INFO[atividadeAgora]?.emoji ?? ''} ${ATIVIDADES_INFO[atividadeAgora]?.nome ?? atividadeAgora}` : 'Um bonequinho dele faz algo embaixo dos olhos'}
|
||||
>
|
||||
<div class="atividades">
|
||||
{#each Object.entries(ATIVIDADES_INFO) as [id, a] (id)}
|
||||
<button class="btn pequeno" class:ativo={atividadeAgora === id} onclick={() => atividade(id)}>
|
||||
<span aria-hidden="true">{a.emoji}</span> {a.nome}
|
||||
</button>
|
||||
{/each}
|
||||
{#if atividadeAgora}
|
||||
<button class="btn pequeno" onclick={() => atividade('parar')}>⏹ Parar</button>
|
||||
{/if}
|
||||
</div>
|
||||
</Linha>
|
||||
<Linha icone={Wand2} cor="#af52de" rotulo="Criar com IA" detalhe="Expressões, atividades e figurinhas novas" href="#/criar" />
|
||||
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="Aparência">
|
||||
<Linha icone={Smile} cor="#5ac8fa" rotulo="Rosto">
|
||||
<div class="segmentado" role="radiogroup" aria-label="Rosto">
|
||||
{#each estado.rostos as r (r)}
|
||||
<button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Linha>
|
||||
<Linha icone={Palette} cor="#ff9500" rotulo="Cor dos olhos">
|
||||
<div class="cores">
|
||||
{#each CORES as c (c.nome)}
|
||||
<button
|
||||
class="cor"
|
||||
style="--c: {hex(c.rgb)}"
|
||||
aria-label={c.nome}
|
||||
aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)}
|
||||
onclick={() => cor(c.rgb)}
|
||||
></button>
|
||||
{/each}
|
||||
<label class="cor livre" title="Outra cor">
|
||||
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
|
||||
<span class="sr-only">Escolher outra cor</span>
|
||||
</label>
|
||||
</div>
|
||||
<Grupo titulo="Aparência" ajuda="aparencia">
|
||||
<Linha
|
||||
icone={Palette}
|
||||
cor="#ff9500"
|
||||
rotulo="Rosto, formato e cor"
|
||||
detalhe={nomeRosto(estado.aparencia.rosto) + (ROSTOS_INFO[estado.aparencia.rosto]?.formato ? ' · ' + (FORMATOS_INFO[estado.aparencia.formato ?? 'padrao'] ?? '') : '')}
|
||||
href="#/rosto"
|
||||
>
|
||||
{#snippet acessorio()}
|
||||
<span class="amostra-cor" style="--c: {hex(estado.aparencia.cor)}" aria-hidden="true"></span>
|
||||
{/snippet}
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="Personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}>
|
||||
<Grupo titulo="Personalidade" ajuda="personalidade" rodape={estado.ia?.erro || 'Qwen escolhe expressões a partir de sons e movimentos do celular.'}>
|
||||
<Linha
|
||||
icone={Brain}
|
||||
cor="#5e5ce6"
|
||||
@@ -193,7 +215,11 @@
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="Acontecimentos">
|
||||
<Grupo titulo="Acontecimentos" ajuda="acontecimentos">
|
||||
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
|
||||
{#snippet acessorio()}<PresencaPir />{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={History} cor="#30b0c7" rotulo="Eventos" detalhe="Presença, movimento e som, com foto" href="#/eventos" />
|
||||
{#if estado.feed.length}
|
||||
<ol class="feed">
|
||||
{#each estado.feed.slice(0, 12) as item (item.id)}
|
||||
@@ -213,6 +239,15 @@
|
||||
gap: var(--e-4);
|
||||
align-items: start;
|
||||
}
|
||||
.atividades {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.atividades .btn.ativo {
|
||||
border-color: var(--destaque);
|
||||
background: var(--destaque-suave);
|
||||
}
|
||||
/* ---------- palco: sempre visível */
|
||||
.palco {
|
||||
position: sticky;
|
||||
@@ -308,61 +343,53 @@
|
||||
.espaco {
|
||||
flex: 1;
|
||||
}
|
||||
.ponto-pir {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: var(--e-3);
|
||||
top: var(--e-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px 4px 7px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: rgb(0 0 0 / 0.55);
|
||||
color: #c7cdd4;
|
||||
font: 600 11px var(--fonte);
|
||||
letter-spacing: 0.04em;
|
||||
pointer-events: none;
|
||||
transition: color var(--transicao);
|
||||
}
|
||||
.ponto-pir .luz {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #6b7280;
|
||||
transition: background var(--transicao);
|
||||
}
|
||||
.ponto-pir.ativo {
|
||||
color: #b8f5c9;
|
||||
}
|
||||
.ponto-pir.ativo .luz {
|
||||
background: #34c759;
|
||||
animation: pulsoPir 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulsoPir {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgb(52 199 89 / 0.7); }
|
||||
50% { box-shadow: 0 0 0 7px rgb(52 199 89 / 0); }
|
||||
}
|
||||
/* ---------- lista */
|
||||
.lista {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.segmentado {
|
||||
display: flex;
|
||||
background: var(--superficie-2);
|
||||
border-radius: 9px;
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
.segmentado button {
|
||||
flex: 1;
|
||||
min-height: 32px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
text-transform: capitalize;
|
||||
cursor: pointer;
|
||||
}
|
||||
.segmentado button[aria-checked='true'] {
|
||||
background: var(--superficie);
|
||||
color: var(--texto);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
.cores {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.cor {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
.amostra-cor {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--borda-forte);
|
||||
background: var(--c);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.cor[aria-pressed='true'] {
|
||||
outline: 2px solid var(--texto);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.livre {
|
||||
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||
}
|
||||
.livre input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--borda-forte);
|
||||
}
|
||||
.feed {
|
||||
list-style: none;
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card titulo="Memória" descricao="O que o Carinha leva em conta ao responder. São dados, nunca instruções.">
|
||||
<Card titulo="Memória" ajuda="conversa" descricao="O que o Carinha leva em conta ao responder. São dados, nunca instruções.">
|
||||
<Abas
|
||||
rotulo="Memória"
|
||||
bind:ativa={aba}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
<script lang="ts">
|
||||
import { Cpu, Settings2, Sparkles, Star, Trash2, Wand2 } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { favoritos } from '../lib/comandos.svelte';
|
||||
import Card from '../lib/componentes/Card.svelte';
|
||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||
import Selo from '../lib/componentes/Selo.svelte';
|
||||
import { criacao, TIPOS, type IaCriacao, type TipoCriacao } from '../lib/criacao.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
|
||||
// Estúdio: escolhe o que criar e com qual IA, escreve o pedido (pode partir de algo que já existe)
|
||||
// e as criações ficam numa grade igual à dos favoritos.
|
||||
onMount(() => criacao.carregar());
|
||||
const st = $derived(criacao.status);
|
||||
|
||||
let tipo = $state<TipoCriacao>('expressao');
|
||||
let ia = $state<IaCriacao>('gemini');
|
||||
let pedido = $state('');
|
||||
let base = $state('');
|
||||
let filtro = $state<'todos' | TipoCriacao>('todos');
|
||||
let configAberta = $state(false);
|
||||
let url = $state('');
|
||||
let modelo = $state('');
|
||||
let chave = $state('');
|
||||
|
||||
$effect(() => {
|
||||
// base só vale para o tipo atual
|
||||
tipo;
|
||||
base = '';
|
||||
});
|
||||
$effect(() => {
|
||||
const l = st?.provedores.local;
|
||||
if (l && !url && !modelo) {
|
||||
url = l.url ?? '';
|
||||
modelo = l.modelo ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
const IAS: { id: IaCriacao; icone: string }[] = [
|
||||
{ id: 'gemini', icone: '✨' },
|
||||
{ id: 'local', icone: '🖥️' },
|
||||
{ id: 's10', icone: '📱' },
|
||||
];
|
||||
const tipoInfo = $derived(TIPOS.find((t) => t.id === tipo)!);
|
||||
const bases = $derived(
|
||||
tipo === 'figurinha' || !st
|
||||
? []
|
||||
: [
|
||||
...st.bases[tipo].map((b) => ({ id: b.id, rotulo: `${b.emoji} ${b.nome}` })),
|
||||
...st.criacoes.filter((c) => c.tipo === tipo).map((c) => ({ id: `criado:${c.id}`, rotulo: `${c.emoji} ${c.nome} (criado)` })),
|
||||
],
|
||||
);
|
||||
const lista = $derived((st?.criacoes ?? []).filter((c) => filtro === 'todos' || c.tipo === filtro));
|
||||
const ocupado = $derived(st?.ocupado ?? null);
|
||||
const segundos = $derived(ocupado ? Math.max(0, Math.round(estado.agora / 1000 - ocupado.desde)) : 0);
|
||||
const provedor = $derived(st?.provedores[ia]);
|
||||
|
||||
async function criar(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (await criacao.criar(tipo, pedido.trim(), ia, base)) pedido = '';
|
||||
}
|
||||
async function salvarLocal(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const dados: { url: string; modelo: string; chave?: string } = { url: url.trim(), modelo: modelo.trim() };
|
||||
if (chave.trim()) dados.chave = chave.trim();
|
||||
await criacao.configurar(dados);
|
||||
chave = '';
|
||||
}
|
||||
const nomeTipo = (t: TipoCriacao) => TIPOS.find((x) => x.id === t)?.nome ?? t;
|
||||
const podeFavoritar = (t: TipoCriacao) => t !== 'figurinha';
|
||||
</script>
|
||||
|
||||
<div class="criar">
|
||||
<div class="topo">
|
||||
<Card titulo="Criar com IA" ajuda="criar" descricao="A IA monta a criação só com peças que o Carinha já sabe animar, usando exemplos do que já existe.">
|
||||
<form class="form" onsubmit={criar}>
|
||||
<div class="campo">
|
||||
<span>O que criar</span>
|
||||
<div class="segmentado" role="radiogroup" aria-label="O que criar">
|
||||
{#each TIPOS as t (t.id)}
|
||||
<button type="button" role="radio" aria-checked={tipo === t.id} onclick={() => (tipo = t.id)}>
|
||||
<span aria-hidden="true">{t.emoji}</span> {t.nome}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="campo">
|
||||
<span>Qual IA</span>
|
||||
<div class="ias" role="radiogroup" aria-label="Qual IA">
|
||||
{#each IAS as x (x.id)}
|
||||
{@const p = st?.provedores[x.id]}
|
||||
<button type="button" role="radio" class="ia" aria-checked={ia === x.id} class:indisponivel={p && !p.pronto} onclick={() => (ia = x.id)}>
|
||||
<span class="ia-nome"><span aria-hidden="true">{x.icone}</span> {p?.nome ?? x.id}</span>
|
||||
<span class="ia-detalhe">{p ? (p.pronto ? p.detalhe : p.motivo) : '…'}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="campo">
|
||||
<span>Pedido</span>
|
||||
<textarea bind:value={pedido} maxlength="600" placeholder={tipoInfo.exemplo} required></textarea>
|
||||
</label>
|
||||
|
||||
{#if bases.length}
|
||||
<label class="campo">
|
||||
<span>Basear em (opcional)</span>
|
||||
<select bind:value={base}>
|
||||
<option value="">Nada: criar do zero (com exemplos do que existe)</option>
|
||||
{#each bases as b (b.id)}<option value={b.id}>{b.rotulo}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="linha acoes">
|
||||
<button class="btn primario" type="submit" disabled={!!ocupado || !pedido.trim() || !provedor?.pronto}>
|
||||
<Wand2 size={16} /> {ocupado ? 'Criando…' : `Criar ${nomeTipo(tipo).toLowerCase()}`}
|
||||
</button>
|
||||
<button class="btn fantasma pequeno" type="button" onclick={() => (configAberta = !configAberta)}>
|
||||
<Settings2 size={14} /> IA local
|
||||
</button>
|
||||
</div>
|
||||
{#if ocupado}
|
||||
<Selo tom="aviso">
|
||||
{st?.provedores[ocupado.ia]?.nome} criando {nomeTipo(ocupado.tipo).toLowerCase()}… {segundos}s
|
||||
{ocupado.ia !== 'gemini' ? '(IA local pode levar alguns minutos)' : ''}
|
||||
</Selo>
|
||||
{:else if st?.erro}
|
||||
<p class="erro pequeno-texto">{st.erro}</p>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#if configAberta}
|
||||
<form class="form local" onsubmit={salvarLocal}>
|
||||
<p class="fraco pequeno-texto">
|
||||
Servidor compatível com OpenAI na sua rede (LM Studio com "Serve on Local Network" ou llama-server), por exemplo o ROG Ally.
|
||||
</p>
|
||||
<label class="campo"><span>Endereço</span><input type="text" bind:value={url} placeholder="http://192.168.1.50:1234" /></label>
|
||||
<label class="campo"><span>Modelo</span><input type="text" bind:value={modelo} placeholder="qwen3-coder-30b-a3b-instruct" /></label>
|
||||
<label class="campo">
|
||||
<span>Senha da API {st?.provedores.local.chave ? '(deixe vazio para manter)' : '(opcional)'}</span>
|
||||
<input type="password" bind:value={chave} autocomplete="off" />
|
||||
</label>
|
||||
<div class="linha">
|
||||
<button class="btn pequeno primario" type="submit">Salvar</button>
|
||||
<button class="btn pequeno" type="button" onclick={() => criacao.testar('local')} disabled={!st?.provedores.local.url}><Cpu size={14} /> Testar</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<div class="previa"><RostoPrevia titulo="Prévia das criações" espelharSono={false} /></div>
|
||||
</div>
|
||||
|
||||
<Card titulo="Criações" descricao="Toque para mostrar no celular. A estrela fixa expressões e atividades nos favoritos do Ao vivo.">
|
||||
{#snippet acoes()}
|
||||
<div class="segmentado pequeno" role="radiogroup" aria-label="Filtrar">
|
||||
<button role="radio" aria-checked={filtro === 'todos'} onclick={() => (filtro = 'todos')}>Todas</button>
|
||||
{#each TIPOS as t (t.id)}
|
||||
<button role="radio" aria-checked={filtro === t.id} onclick={() => (filtro = t.id)}>{t.emoji}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#if !st}
|
||||
<p class="fraco">Carregando…</p>
|
||||
{:else if !lista.length}
|
||||
<p class="fraco">Nada criado ainda. Escreva um pedido acima. <Sparkles size={14} /></p>
|
||||
{:else}
|
||||
<div class="grade">
|
||||
{#each lista as c (c.id)}
|
||||
{@const fav = favoritos.lista.includes(`criado:${c.id}`)}
|
||||
<div class="item">
|
||||
<button class="cartao" onclick={() => criacao.tocar(c.id)} title={c.pedido}>
|
||||
<span class="emoji" aria-hidden="true">{c.emoji}</span>
|
||||
<span class="nome">{c.nome}</span>
|
||||
<span class="meta">{nomeTipo(c.tipo)} · {st.provedores[c.ia]?.nome ?? c.ia}</span>
|
||||
</button>
|
||||
{#if podeFavoritar(c.tipo)}
|
||||
<button
|
||||
class="canto estrela"
|
||||
class:ativa={fav}
|
||||
aria-pressed={fav}
|
||||
aria-label={fav ? `Tirar ${c.nome} dos favoritos` : `Fixar ${c.nome} nos favoritos`}
|
||||
onclick={() => favoritos.alternar(`criado:${c.id}`)}><Star size={14} /></button
|
||||
>
|
||||
{/if}
|
||||
<button class="canto lixo" aria-label={`Apagar ${c.nome}`} onclick={() => criacao.apagar(c.id)}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.criar {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
}
|
||||
.topo {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
align-items: start;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.topo {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
.previa {
|
||||
position: sticky;
|
||||
top: calc(var(--topo-altura) + var(--e-4));
|
||||
}
|
||||
}
|
||||
.previa {
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--raio-lg);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
border: 1px solid var(--borda);
|
||||
}
|
||||
.form {
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
}
|
||||
.form.local {
|
||||
margin-top: var(--e-3);
|
||||
padding-top: var(--e-3);
|
||||
border-top: 1px solid var(--borda);
|
||||
}
|
||||
textarea {
|
||||
min-height: 84px;
|
||||
resize: vertical;
|
||||
}
|
||||
.segmentado {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: var(--raio-sm);
|
||||
background: var(--superficie-3);
|
||||
}
|
||||
.segmentado button {
|
||||
flex: 1;
|
||||
min-height: 34px;
|
||||
padding: 0 var(--e-3);
|
||||
border: 0;
|
||||
border-radius: calc(var(--raio-sm) - 2px);
|
||||
background: transparent;
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.segmentado.pequeno button {
|
||||
min-height: 28px;
|
||||
padding: 0 var(--e-2);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
.segmentado button[aria-checked='true'] {
|
||||
background: var(--superficie);
|
||||
color: var(--texto);
|
||||
box-shadow: var(--sombra);
|
||||
}
|
||||
.ias {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.ia {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: var(--e-2) var(--e-3);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
color: var(--texto);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ia[aria-checked='true'] {
|
||||
border-color: var(--destaque);
|
||||
background: var(--destaque-suave);
|
||||
}
|
||||
.ia.indisponivel .ia-detalhe {
|
||||
color: var(--aviso);
|
||||
}
|
||||
.ia-nome {
|
||||
font-weight: 600;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.ia-detalhe {
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.acoes {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.erro {
|
||||
color: var(--perigo);
|
||||
}
|
||||
.grade {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.item {
|
||||
position: relative;
|
||||
}
|
||||
.cartao {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2px;
|
||||
padding: var(--e-3) var(--e-2) var(--e-2);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
color: var(--texto);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
|
||||
}
|
||||
.cartao:hover {
|
||||
border-color: var(--destaque);
|
||||
background: var(--destaque-suave);
|
||||
}
|
||||
.cartao:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.emoji {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.nome {
|
||||
font-size: var(--t-sm);
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.meta {
|
||||
font-size: 10px;
|
||||
color: var(--texto-3);
|
||||
text-align: center;
|
||||
}
|
||||
.canto {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--texto-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.estrela {
|
||||
right: 2px;
|
||||
}
|
||||
.lixo {
|
||||
left: 2px;
|
||||
}
|
||||
.lixo:hover {
|
||||
color: var(--perigo);
|
||||
}
|
||||
.estrela.ativa {
|
||||
color: var(--aviso);
|
||||
}
|
||||
.estrela.ativa :global(svg) {
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,309 @@
|
||||
<script lang="ts">
|
||||
import { Camera, Ear, Footprints, Image as ImageIcon, Moon, Timer, Trash2, Video } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import Grupo from '../lib/componentes/Grupo.svelte';
|
||||
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
||||
import Linha from '../lib/componentes/Linha.svelte';
|
||||
import Modal from '../lib/componentes/Modal.svelte';
|
||||
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||
import Slider from '../lib/componentes/Slider.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
|
||||
|
||||
onMount(() => {
|
||||
historico.carregar();
|
||||
});
|
||||
|
||||
const st = $derived(historico.status);
|
||||
// Presença e movimento em destaque; som é opcional no filtro.
|
||||
type Filtro = 'principais' | 'pir' | 'movimento' | 'som' | 'todos';
|
||||
let filtro = $state<Filtro>('principais');
|
||||
const FILTROS: [Filtro, string][] = [
|
||||
['principais', 'Presença e movimento'], ['pir', '🚶 Presença'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['todos', 'Todos'],
|
||||
];
|
||||
const eventos = $derived(
|
||||
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'principais' ? e.tipo !== 'som' : e.tipo === filtro)),
|
||||
);
|
||||
|
||||
// Agrupa por dia: Hoje, Ontem, depois a data.
|
||||
const dias = $derived.by(() => {
|
||||
const grupos: { titulo: string; itens: EventoHistorico[] }[] = [];
|
||||
const hoje = new Date(estado.agora); hoje.setHours(0, 0, 0, 0);
|
||||
for (const ev of eventos) {
|
||||
const d = new Date(ev.horario * 1000); d.setHours(0, 0, 0, 0);
|
||||
const diff = Math.round((hoje.getTime() - d.getTime()) / 86400000);
|
||||
const titulo = diff === 0 ? 'Hoje' : diff === 1 ? 'Ontem' : d.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' });
|
||||
if (!grupos.length || grupos[grupos.length - 1].titulo !== titulo) grupos.push({ titulo, itens: [] });
|
||||
grupos[grupos.length - 1].itens.push(ev);
|
||||
}
|
||||
return grupos;
|
||||
});
|
||||
|
||||
const TITULO: Record<string, string> = { pir: 'Presença detectada', movimento: 'Movimento na câmera', som: 'Som alto' };
|
||||
const DETALHE: Record<string, string> = { surpreso: 'deu um susto', acordou: 'acordou o Carinha', presença: 'sensor PIR' };
|
||||
const horaCurta = (t: number) => new Date(t * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
|
||||
const desde = (t: number) => {
|
||||
const min = Math.max(0, Math.round((estado.agora - t * 1000) / 60000));
|
||||
return min < 1 ? 'agora' : min < 60 ? `há ${min} min` : `há ${Math.round(min / 60)} h`;
|
||||
};
|
||||
|
||||
const presenca = $derived(st?.presenca);
|
||||
let aberta = $state<EventoHistorico | null>(null);
|
||||
let modal = $state(false);
|
||||
let confirmar = $state(false);
|
||||
function verFoto(ev: EventoHistorico) {
|
||||
aberta = ev;
|
||||
modal = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="eventos">
|
||||
<header>
|
||||
<div class="linha entre">
|
||||
<h1>Eventos</h1>
|
||||
{#if st?.config.pir}<PresencaPir />{/if}
|
||||
</div>
|
||||
<p class="fraco">
|
||||
O que o sensor de presença e a câmera do celular perceberam, com uma foto de cada momento. Guardamos {st?.config.dias ?? 3} dias.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="colunas">
|
||||
<section class="lista" aria-label="Lista de eventos">
|
||||
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
|
||||
{#each FILTROS as [id, nome] (id)}
|
||||
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id)}>{nome}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !st}
|
||||
<p class="fraco">Carregando eventos…</p>
|
||||
{:else if !eventos.length}
|
||||
<div class="vazio">
|
||||
<p>Nenhum evento {filtro === 'todos' ? 'nos últimos dias' : 'com esse filtro'}.</p>
|
||||
<p class="fraco pequeno-texto">Quando alguém passar na frente do sensor ou a câmera perceber movimento, aparece aqui na hora.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each dias as dia (dia.titulo)}
|
||||
<Grupo titulo={`${dia.titulo} · ${dia.itens.length}`}>
|
||||
{#each dia.itens as ev (ev.id)}
|
||||
{@const info = TIPOS_EVENTO[ev.tipo]}
|
||||
<div class="evento">
|
||||
<span class="icone" style="--c: {info.cor}" aria-hidden="true">{info.emoji}</span>
|
||||
<div class="texto">
|
||||
<strong>{TITULO[ev.tipo] ?? ev.tipo}</strong>
|
||||
<span class="fraco pequeno-texto">
|
||||
{horaCurta(ev.horario)} · {DETALHE[ev.evento] ?? ev.evento}{ev.nivel !== null && ev.tipo !== 'pir' ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{#if ev.foto}
|
||||
<button class="ver-foto" onclick={() => verFoto(ev)} aria-label="Ver foto de {horaCurta(ev.horario)}">
|
||||
<img src={urlFoto(ev.id)} alt="" loading="lazy" />
|
||||
<span>Ver foto</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="sem-foto fraco pequeno-texto">sem foto</span>
|
||||
{/if}
|
||||
<button class="btn pequeno icone fantasma" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</Grupo>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="lado">
|
||||
<Grupo
|
||||
titulo="Modo presença"
|
||||
ajuda="presenca"
|
||||
rodape="Alguém aparece: o PIR liga a câmera do celular e acorda o Carinha. Ninguém pelo tempo escolhido: a câmera desliga, ele dorme e fica em silêncio."
|
||||
>
|
||||
<Linha
|
||||
icone={Footprints}
|
||||
cor="#34c759"
|
||||
rotulo="Câmera ligada pela presença"
|
||||
detalhe={presenca ? (presenca.estado === 'presente' ? `alguém por perto · último movimento ${desde(presenca.ultimo)}` : `ninguém por perto ${desde(presenca.desde)}`) : ''}
|
||||
>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Câmera ligada pela presença" checked={!!st?.config.presenca} disabled={!st} onchange={(v) => historico.configurar({ presenca: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por">
|
||||
<Slider rotulo="Desligar câmera e dormir depois de" valor={st?.config.ausencia ?? 5} min={1} max={60} passo={1} unidade=" min" onchange={(v) => historico.configurar({ ausencia: v })} />
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="O que registrar" ajuda="eventos">
|
||||
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)" detalhe={st?.pir.disponivel ? 'Pino 11 · HC-SR505' : st?.pir.erro || 'indisponível'}>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Sensor de presença (PIR)" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Movimento na câmera" checked={!!st?.config.movimento} disabled={!st} onchange={(v) => historico.configurar({ movimento: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Ear} cor="#ff9500" rotulo="Som alto">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Som alto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Camera} cor="#af52de" rotulo="Tirar uma foto por evento" detalhe="Câmera frontal do S10">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Tirar uma foto por evento" checked={!!st?.config.foto} disabled={!st} onchange={(v) => historico.configurar({ foto: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Timer} cor="#8e8e93" rotulo="Intervalo entre eventos do mesmo tipo">
|
||||
<Slider rotulo="Intervalo" valor={st?.config.intervalo ?? 15} min={0} max={120} passo={5} unidade=" s" onchange={(v) => historico.configurar({ intervalo: v })} />
|
||||
</Linha>
|
||||
<Linha icone={ImageIcon} cor="#30b0c7" rotulo="Guardar">
|
||||
<Slider rotulo="Dias de histórico" valor={st?.config.dias ?? 3} min={1} max={30} passo={1} unidade=" dias" onchange={(v) => historico.configurar({ dias: v })} />
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<div class="limpar">
|
||||
{#if confirmar}
|
||||
<p class="pequeno-texto">Apagar todos os eventos e fotos?</p>
|
||||
<div class="linha">
|
||||
<button class="btn perigo pequeno" onclick={() => { confirmar = false; historico.limpar(); }}>Apagar tudo</button>
|
||||
<button class="btn pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!st?.total}><Trash2 size={14} /> Limpar eventos</button>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:aberto={modal} titulo={aberta ? `${TITULO[aberta.tipo]} · ${hora(aberta.horario)}` : 'Foto'}>
|
||||
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.eventos {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
header p {
|
||||
max-width: 70ch;
|
||||
}
|
||||
.colunas {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
align-items: start;
|
||||
}
|
||||
.lista,
|
||||
.lado {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.filtros {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.filtros button {
|
||||
min-height: 34px;
|
||||
padding: 0 var(--e-3);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie);
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
cursor: pointer;
|
||||
}
|
||||
.filtros button[aria-checked='true'] {
|
||||
background: var(--destaque);
|
||||
border-color: var(--destaque);
|
||||
color: var(--destaque-texto);
|
||||
}
|
||||
.vazio {
|
||||
display: grid;
|
||||
gap: var(--e-1);
|
||||
padding: var(--e-6);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--borda-forte);
|
||||
border-radius: var(--raio-lg);
|
||||
}
|
||||
.evento {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
padding: var(--e-2) var(--e-3) var(--e-2) var(--e-4);
|
||||
min-height: 64px;
|
||||
}
|
||||
.evento + .evento {
|
||||
border-top: 1px solid var(--borda);
|
||||
}
|
||||
.icone {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--c) 18%, transparent);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.texto {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.ver-foto {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
padding: 3px var(--e-3) 3px 3px;
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
color: var(--destaque);
|
||||
font: 600 var(--t-xs) var(--fonte);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.ver-foto img {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.sem-foto {
|
||||
flex: none;
|
||||
}
|
||||
.limpar {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
padding: 0 var(--e-2);
|
||||
}
|
||||
.grande {
|
||||
width: 100%;
|
||||
border-radius: var(--raio);
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.ver-foto span {
|
||||
display: none;
|
||||
}
|
||||
.ver-foto {
|
||||
padding: 3px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1100px) {
|
||||
.colunas {
|
||||
grid-template-columns: 1fr 380px;
|
||||
}
|
||||
.lado {
|
||||
position: sticky;
|
||||
top: calc(var(--topo-altura) + var(--e-5));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import TopicoGuia from '../lib/componentes/TopicoGuia.svelte';
|
||||
import { ORDEM_GUIA, TOPICOS } from '../lib/guia';
|
||||
|
||||
// Guia completo: todos os tópicos em ordem de tutorial, com índice. #/guia?t=vida abre no tópico.
|
||||
let busca = $state('');
|
||||
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
const lista = $derived(
|
||||
ORDEM_GUIA.filter((id) => {
|
||||
if (!busca) return true;
|
||||
const t = TOPICOS[id];
|
||||
const texto = [t.titulo, t.resumo, t.onde, t.dica ?? '', ...(t.itens ?? []).map((i) => i.nome + ' ' + i.texto)].join(' ');
|
||||
return normal(texto).includes(normal(busca));
|
||||
}),
|
||||
);
|
||||
|
||||
async function irPara(id: string) {
|
||||
await tick();
|
||||
document.getElementById('guia-' + id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// Abre no tópico do endereço, também quando o ⓘ é usado com o guia já aberto.
|
||||
function pelaUrl() {
|
||||
const t = new URLSearchParams(location.hash.split('?')[1] ?? '').get('t');
|
||||
if (t && TOPICOS[t]) irPara(t);
|
||||
}
|
||||
onMount(() => {
|
||||
pelaUrl();
|
||||
addEventListener('hashchange', pelaUrl);
|
||||
return () => removeEventListener('hashchange', pelaUrl);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="guia">
|
||||
<aside class="indice">
|
||||
<label class="busca">
|
||||
<span class="sr">Buscar no guia</span>
|
||||
<input type="search" bind:value={busca} placeholder="Buscar no guia (ex.: ritmo, PIR, dormir)" />
|
||||
</label>
|
||||
<nav aria-label="Tópicos do guia">
|
||||
{#each lista as id (id)}
|
||||
<a href={'#/guia?t=' + id} onclick={(e) => { e.preventDefault(); history.replaceState(null, '', '#/guia?t=' + id); irPara(id); }}>
|
||||
<span aria-hidden="true">{TOPICOS[id].emoji}</span> {TOPICOS[id].titulo}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="topicos">
|
||||
{#each lista as id (id)}
|
||||
<section class="topico" id={'guia-' + id}>
|
||||
<h2><span aria-hidden="true">{TOPICOS[id].emoji}</span> {TOPICOS[id].titulo}</h2>
|
||||
<TopicoGuia topico={TOPICOS[id]} />
|
||||
</section>
|
||||
{:else}
|
||||
<p class="vazio">Nada no guia com “{busca}”.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.guia {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
align-items: start;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.guia {
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
}
|
||||
.indice {
|
||||
position: sticky;
|
||||
top: calc(var(--topo-altura) + var(--e-4));
|
||||
}
|
||||
}
|
||||
.indice {
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
}
|
||||
.sr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
nav {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
nav a {
|
||||
display: flex;
|
||||
gap: var(--e-2);
|
||||
align-items: center;
|
||||
padding: var(--e-1) var(--e-3);
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie);
|
||||
border: 1px solid var(--borda);
|
||||
color: var(--texto-2);
|
||||
font-size: var(--t-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
nav a {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: var(--raio-sm);
|
||||
}
|
||||
}
|
||||
nav a:hover {
|
||||
background: var(--superficie-2);
|
||||
color: var(--texto);
|
||||
}
|
||||
.topicos {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
min-width: 0;
|
||||
}
|
||||
.topico {
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
padding: var(--e-5);
|
||||
background: var(--superficie);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-lg);
|
||||
scroll-margin-top: calc(var(--topo-altura) + var(--e-4));
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--t-lg);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.vazio {
|
||||
color: var(--texto-3);
|
||||
}
|
||||
</style>
|
||||
@@ -171,7 +171,10 @@
|
||||
{/if}
|
||||
{:else if aba === 'criar'}
|
||||
<div class="grade-criar">
|
||||
<Card titulo="Desenhar figurinha" descricao="O Gemini desenha um SVG e o celular mostra assim que ficar pronto.">
|
||||
<Card titulo="Estúdio de criação" ajuda="criar" descricao="Expressões, atividades e figurinhas novas, escolhendo a IA (Gemini, IA local ou Qwen do S10).">
|
||||
<a class="btn primario" href="#/criar">Abrir o estúdio</a>
|
||||
</Card>
|
||||
<Card titulo="Desenhar figurinha" ajuda="reacoes" descricao="O Gemini desenha um SVG e o celular mostra assim que ficar pronto.">
|
||||
<form class="grade" onsubmit={desenhar}>
|
||||
<label class="campo">
|
||||
<span>O que desenhar</span>
|
||||
@@ -204,7 +207,7 @@
|
||||
</Card>
|
||||
</div>
|
||||
{:else if aba === 'gestos'}
|
||||
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (atalhos 1 a 9).">
|
||||
<Card titulo="Gestos do rosto" ajuda="gestos" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (até 12; as teclas 1 a 9 valem para os nove primeiros).">
|
||||
<Gestos completo />
|
||||
</Card>
|
||||
{:else}
|
||||
@@ -249,7 +252,7 @@
|
||||
</div>
|
||||
|
||||
<aside class="lateral">
|
||||
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" /></div>
|
||||
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" espelharSono={false} /></div>
|
||||
<p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -364,6 +367,14 @@
|
||||
.espaco {
|
||||
flex: 1;
|
||||
}
|
||||
/* seis ações pequenas + Mostrar cabem numa linha num cartão de 300 px */
|
||||
.acoes :global(.btn.pequeno.icone) {
|
||||
width: 28px;
|
||||
}
|
||||
.acoes {
|
||||
flex-wrap: nowrap;
|
||||
gap: 2px;
|
||||
}
|
||||
.marcado {
|
||||
color: var(--ok);
|
||||
border-color: var(--ok);
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Check } from '@lucide/svelte';
|
||||
import {
|
||||
cor, CORES, FORMATOS_INFO, formato, gesto, GESTOS_INFO, hex, nomeRosto, rgb, rosto, ROSTOS_INFO,
|
||||
} from '../lib/comandos.svelte';
|
||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||
import Slider from '../lib/componentes/Slider.svelte';
|
||||
import Toggle from '../lib/componentes/Toggle.svelte';
|
||||
import { salvarApp } from '../lib/ajustes.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
|
||||
let previa = $state<{ ordem: (t: string) => void; legenda: (t: string) => void }>();
|
||||
// Ouvir a voz de robô aqui no PC (o clique na tecla libera o áudio do navegador).
|
||||
let ouvirAqui = $state(true);
|
||||
let audio: AudioContext | null = null;
|
||||
function voz(): AudioContext | null {
|
||||
const AC = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!ouvirAqui || !AC || !(window as any).VozRobo) return null;
|
||||
audio ??= new AC();
|
||||
if (audio.state === 'suspended') audio.resume();
|
||||
return audio;
|
||||
}
|
||||
const FRASE = 'Oi! Eu sou o Carinha. Quer brincar comigo?';
|
||||
function testarFala() {
|
||||
previa?.legenda(FRASE);
|
||||
const c = voz();
|
||||
if (c) (window as any).VozRobo.balbuciar(c, FRASE, 0.5);
|
||||
}
|
||||
// As emoções vão para o celular (e a prévia mostra junto); "só na prévia" serve para testar quieto.
|
||||
let soPrevia = $state(false);
|
||||
|
||||
const atual = $derived(estado.aparencia.rosto);
|
||||
const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao');
|
||||
const aceitaFormato = $derived(!!ROSTOS_INFO[atual]?.formato);
|
||||
const novos = $derived(estado.rostos.filter((r) => ROSTOS_INFO[r]?.novo));
|
||||
const classicos = $derived(estado.rostos.filter((r) => !ROSTOS_INFO[r]?.novo));
|
||||
const formatos = $derived(estado.formatos.length ? estado.formatos : Object.keys(FORMATOS_INFO));
|
||||
|
||||
const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'dormir', 'acordar'];
|
||||
function emocao(g: string) {
|
||||
if (soPrevia) previa?.ordem(g);
|
||||
else gesto(g);
|
||||
const c = voz();
|
||||
if (c) (window as any).VozRobo.tocar(c, g, 0.5);
|
||||
}
|
||||
|
||||
const IDEIAS: { titulo: string; texto: string; feito: boolean; onde: string }[] = [
|
||||
{ titulo: 'Pálpebras no lugar de formas prontas', texto: 'Cada emoção é posição de pálpebra; as trocas ficam suaves em vez de saltar.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Nunca parado', texto: 'Respiração, sacadas e piscada que achata o olho. Também protege a tela AMOLED.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Toque na tela', texto: 'Cutucar dá susto e risada; deslizar o dedo é carinho; tocar acorda.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Mãos', texto: 'Mãozinhas na cor dos olhos: esconde o rosto, espia, acena e pensa com a mão no queixo.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Faixa de baixo', texto: 'Legenda da conversa sendo digitada, pontinhos enquanto pensa, equalizador ao dançar e carga quando está no carregador.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Voz de robô', texto: 'Chilreios, “uou!”, “ohh” e um balbucio quando responde, sintetizados na hora; motorzinho quando as mãos se mexem.', feito: true, onde: 'rosto' },
|
||||
{ titulo: 'Fala pelo próprio celular', texto: 'A resposta da conversa sai no alto-falante do S10, com os olhos pulsando na voz.', feito: false, onde: 'app' },
|
||||
{ titulo: 'Ritmo do dia', texto: 'Boceja à noite, fica sonolento com bateria baixa, mostra um raio quando carrega.', feito: false, onde: 'rosto e servidor' },
|
||||
{ titulo: 'Contato visual', texto: 'Sustenta o olhar de quem a câmera vê e desvia quando fica tímido.', feito: false, onde: 'rosto' },
|
||||
{ titulo: 'Humor que dura', texto: 'Energia e alegria sobem com conversa e carinho e caem devagar.', feito: false, onde: 'servidor e IA' },
|
||||
{ titulo: 'Hora nos olhos', texto: 'Ao perguntar a hora, ela aparece dentro dos olhos por alguns segundos.', feito: false, onde: 'rosto' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="rosto-pagina">
|
||||
<a class="voltar" href="#/ao-vivo"><ArrowLeft size={16} /> Ao vivo</a>
|
||||
|
||||
<header>
|
||||
<span class="rotulo">Galaxy S10 · 19:9 · AMOLED</span>
|
||||
<h1>Rosto do Carinha</h1>
|
||||
<p>Escolha o rosto, o formato dos olhos e a cor. A prévia abaixo é o mesmo rosto que roda no celular; clique nela para cutucar.</p>
|
||||
</header>
|
||||
|
||||
<section class="bancada" aria-label="Prévia">
|
||||
<div class="celular">
|
||||
<div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" espelharSono={false} /></div>
|
||||
<span class="camera-frontal" aria-hidden="true"></span>
|
||||
<span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span>
|
||||
</div>
|
||||
|
||||
<div class="emocoes" role="group" aria-label="Testar emoção">
|
||||
{#each EMOCOES.filter((g) => GESTOS_INFO[g]) as g (g)}
|
||||
<button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="linha">
|
||||
<button class="tecla" onclick={testarFala}>🗣️ Testar fala</button>
|
||||
</div>
|
||||
<Toggle
|
||||
rotulo="Ouvir a voz de robô aqui no PC"
|
||||
ajuda="Sons sintetizados na hora, inspirados nos robozinhos de cinema"
|
||||
checked={ouvirAqui}
|
||||
onchange={(v) => (ouvirAqui = v)}
|
||||
/>
|
||||
<Toggle
|
||||
rotulo="Testar só na prévia, sem mandar ao celular"
|
||||
ajuda="Desligado, cada emoção aparece no celular na hora"
|
||||
checked={soPrevia}
|
||||
onchange={(v) => (soPrevia = v)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="bloco-escolha" aria-labelledby="t-rostos">
|
||||
<h2 id="t-rostos">Rosto</h2>
|
||||
<div class="cartoes" role="radiogroup" aria-label="Rosto">
|
||||
{#each novos as r (r)}
|
||||
<button class="cartao" role="radio" aria-checked={atual === r} onclick={() => rosto(r)}>
|
||||
<span class="nome">{ROSTOS_INFO[r].nome} {#if atual === r}<Check size={16} />{/if}</span>
|
||||
<span class="desc">{ROSTOS_INFO[r].descricao}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if classicos.length}
|
||||
<div class="classicos" role="radiogroup" aria-label="Rostos clássicos">
|
||||
<span class="rotulo">Clássicos</span>
|
||||
{#each classicos as r (r)}
|
||||
<button class="tecla" role="radio" aria-checked={atual === r} onclick={() => rosto(r)}>{nomeRosto(r)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="bloco-escolha" aria-labelledby="t-formato">
|
||||
<h2 id="t-formato">Formato dos olhos</h2>
|
||||
<div class="formatos" role="radiogroup" aria-label="Formato dos olhos">
|
||||
{#each formatos as f (f)}
|
||||
<button class="formato" role="radio" aria-checked={formatoAtual === f} disabled={!aceitaFormato} onclick={() => formato(f)}>
|
||||
<span class="amostra {f}" aria-hidden="true"></span>
|
||||
{FORMATOS_INFO[f] ?? f}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !aceitaFormato}
|
||||
<p class="fraco pequeno-texto">O formato vale para Carinha Viva, Bloco e Matriz.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="bloco-escolha" aria-labelledby="t-detalhes">
|
||||
<h2 id="t-detalhes">Detalhes do rosto</h2>
|
||||
<div class="detalhes">
|
||||
<Toggle rotulo="Boca" checked={estado.appConfig?.boca !== false} disabled={!estado.appConfig} onchange={(v) => salvarApp({ boca: v })} />
|
||||
<Toggle rotulo="Sobrancelhas" checked={!!estado.appConfig?.sobrancelhas} disabled={!estado.appConfig} onchange={(v) => salvarApp({ sobrancelhas: v })} />
|
||||
<Toggle
|
||||
rotulo="Íris"
|
||||
ajuda={aceitaFormato ? 'Pupila que acompanha o olhar' : 'Vale para Viva, Bloco e Matriz'}
|
||||
checked={!!estado.appConfig?.iris}
|
||||
disabled={!estado.appConfig}
|
||||
onchange={(v) => salvarApp({ iris: v })}
|
||||
/>
|
||||
</div>
|
||||
<div class="detalhes">
|
||||
<Slider
|
||||
rotulo="Subir a boca"
|
||||
valor={Number(estado.appConfig?.subirBoca ?? 0)}
|
||||
min={-20}
|
||||
max={60}
|
||||
passo={2}
|
||||
onchange={(v) => salvarApp({ subirBoca: v })}
|
||||
/>
|
||||
<Slider
|
||||
rotulo="Distância das sobrancelhas"
|
||||
valor={Number(estado.appConfig?.distSobrancelha ?? 16)}
|
||||
min={0}
|
||||
max={60}
|
||||
passo={2}
|
||||
onchange={(v) => salvarApp({ distSobrancelha: v })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bloco-escolha" aria-labelledby="t-cor">
|
||||
<h2 id="t-cor">Cor dos olhos</h2>
|
||||
<div class="cores">
|
||||
{#each CORES as c (c.nome)}
|
||||
<button class="cor" style="--c: {hex(c.rgb)}" aria-label={c.nome} aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)} onclick={() => cor(c.rgb)}></button>
|
||||
{/each}
|
||||
<label class="cor livre" title="Outra cor">
|
||||
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
|
||||
<span class="sr-only">Escolher outra cor</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bloco-escolha" aria-labelledby="t-vivo">
|
||||
<h2 id="t-vivo">O que deixa o Carinha vivo</h2>
|
||||
<ul class="ideias">
|
||||
{#each IDEIAS as i (i.titulo)}
|
||||
<li>
|
||||
<div class="linha entre">
|
||||
<h3>{i.titulo}</h3>
|
||||
<span class="status" class:feito={i.feito}>{i.feito ? 'Pronto' : 'Próximo'}</span>
|
||||
</div>
|
||||
<p>{i.texto}</p>
|
||||
<span class="onde">{i.onde}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rosto-pagina {
|
||||
display: grid;
|
||||
gap: var(--e-6);
|
||||
max-width: 1120px;
|
||||
}
|
||||
.voltar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--e-1);
|
||||
width: fit-content;
|
||||
color: var(--texto-2);
|
||||
text-decoration: none;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
header h1 {
|
||||
font-size: clamp(1.6rem, 3.5vw, 2.3rem);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
header p {
|
||||
color: var(--texto-2);
|
||||
max-width: 65ch;
|
||||
}
|
||||
.rotulo {
|
||||
font: 500 11px var(--fonte-mono);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.bancada {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
}
|
||||
.celular {
|
||||
position: relative;
|
||||
aspect-ratio: 19 / 9;
|
||||
max-width: 100%;
|
||||
border-radius: clamp(18px, 3.4vw, 40px);
|
||||
padding: clamp(6px, 0.9vw, 12px);
|
||||
background: linear-gradient(145deg, #2a313b, #11161d 45%, #222932);
|
||||
box-shadow: 0 30px 80px rgb(0 0 0 / 0.45), inset 0 1px 0 rgb(255 255 255 / 0.08);
|
||||
}
|
||||
.tela {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: clamp(13px, 2.6vw, 30px);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
.camera-frontal {
|
||||
position: absolute;
|
||||
left: clamp(14px, 2.2vw, 26px);
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
width: clamp(6px, 0.8vw, 10px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 35%, #2b3a55, #05070a 70%);
|
||||
}
|
||||
.etiqueta {
|
||||
position: absolute;
|
||||
right: clamp(20px, 3vw, 36px);
|
||||
top: clamp(14px, 2vw, 24px);
|
||||
font: 500 11px var(--fonte-mono);
|
||||
color: rgb(255 255 255 / 0.4);
|
||||
letter-spacing: 0.08em;
|
||||
pointer-events: none;
|
||||
}
|
||||
.emocoes,
|
||||
.classicos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
align-items: center;
|
||||
}
|
||||
.tecla {
|
||||
min-height: 40px;
|
||||
padding: 0 var(--e-4);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie);
|
||||
color: var(--texto);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tecla:hover {
|
||||
border-color: var(--destaque);
|
||||
}
|
||||
.tecla[aria-checked='true'] {
|
||||
background: var(--destaque);
|
||||
border-color: var(--destaque);
|
||||
color: var(--destaque-texto);
|
||||
}
|
||||
.bloco-escolha {
|
||||
display: grid;
|
||||
gap: var(--e-3);
|
||||
}
|
||||
.bloco-escolha h2 {
|
||||
font-size: var(--t-lg);
|
||||
}
|
||||
.cartoes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--e-3);
|
||||
}
|
||||
.cartao {
|
||||
display: grid;
|
||||
gap: var(--e-1);
|
||||
align-content: start;
|
||||
padding: var(--e-4);
|
||||
text-align: left;
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie);
|
||||
color: var(--texto);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cartao:hover {
|
||||
border-color: var(--borda-forte);
|
||||
}
|
||||
.cartao[aria-checked='true'] {
|
||||
border-color: var(--destaque);
|
||||
background: var(--destaque-suave);
|
||||
}
|
||||
.nome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nome :global(svg) {
|
||||
color: var(--destaque);
|
||||
}
|
||||
.desc {
|
||||
font-size: var(--t-sm);
|
||||
color: var(--texto-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.formatos {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.formato {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--e-2);
|
||||
padding: var(--e-3) var(--e-2);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie);
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
cursor: pointer;
|
||||
}
|
||||
.formato[aria-checked='true'] {
|
||||
border-color: var(--destaque);
|
||||
color: var(--texto);
|
||||
}
|
||||
.formato:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* amostras com as mesmas proporções do rosto-motor.js */
|
||||
.amostra {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 29px;
|
||||
border-radius: 10px;
|
||||
background: var(--destaque);
|
||||
}
|
||||
.amostra.redondo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.amostra.pilula {
|
||||
width: 16px;
|
||||
height: 31px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.amostra.quadrado {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.amostra.largo {
|
||||
width: 33px;
|
||||
height: 18px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.detalhes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
|
||||
gap: var(--e-2) var(--e-6);
|
||||
max-width: 780px;
|
||||
}
|
||||
.cores {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.cor {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--borda-forte);
|
||||
background: var(--c);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.cor[aria-pressed='true'] {
|
||||
outline: 2px solid var(--texto);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.livre {
|
||||
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||
}
|
||||
.livre input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ideias {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
background: var(--borda);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ideias li {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
align-content: start;
|
||||
padding: var(--e-4) var(--e-5);
|
||||
background: var(--superficie);
|
||||
}
|
||||
.ideias h3 {
|
||||
font-size: var(--t-md);
|
||||
}
|
||||
.ideias p {
|
||||
font-size: var(--t-sm);
|
||||
color: var(--texto-2);
|
||||
}
|
||||
.status {
|
||||
flex: none;
|
||||
font: 500 11px var(--fonte-mono);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--raio-pill);
|
||||
border: 1px solid var(--borda-forte);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.status.feito {
|
||||
color: var(--ok);
|
||||
border-color: transparent;
|
||||
background: var(--ok-suave);
|
||||
}
|
||||
.onde {
|
||||
font: 500 11px var(--fonte-mono);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.cartoes {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.ideias {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.formatos {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -125,7 +125,7 @@
|
||||
</Card>
|
||||
|
||||
<div class="coluna">
|
||||
<Card titulo="Mover">
|
||||
<Card titulo="Mover" ajuda="movimento">
|
||||
<Toggle
|
||||
rotulo="Ativar movimento físico"
|
||||
ajuda={!st?.disponivel ? 'PCA9685 indisponível neste servidor' : estado.estop.ativo ? 'Rearme o E-Stop primeiro' : 'Com isso desligado, tudo acontece só na tela'}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
|
||||
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
|
||||
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face',
|
||||
'/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
|
||||
'/carinha.html', '/reacoes-render.js', '/rosto-motor.js', '/voz-robo.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
|
||||
Reference in New Issue
Block a user