Compare commits

..
Author SHA1 Message Date
julioandClaude Opus 5.5 bacecdd17e fix(jogos): letreiro do telão some no fim do tempo
O #telao tinha display:flex, que vencia o atributo hidden: depois do jogo o
"EU SOU O CAMPEÃO!!!" ficava preso na tela do rosto. #telao[hidden] volta a
esconder. Conferido no S10: some sozinho no tempo pedido.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:39:39 -03:00
julioandClaude Opus 5.5 82f9673692 feat(jogos): telão com contagem regressiva, jogadas lado a lado e resultado
- comando "telao" (validado: texto curto, tom, símbolos permitidos, até
  2 lados) e letreiro por cima do rosto: título enorme que estoura e
  pisca, as duas jogadas (VOCÊ ✊ × ✋ CARINHA), cor por resultado;
- pedra, papel e tesoura: 3, 2, 1 piscando, "JÁ!", o que ele leu da sua
  mão, "VENCI!!!" / "VOCÊ GANHOU!" / "EMPATE!" com placar e o campeão;
  quantos dedos e achou! também usam o telão;
- o cartão do painel mostra o mesmo destaque.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:33:09 -03:00
julioandClaude Opus 5.5 3d954efc03 feat(jogos): pedra, papel e tesoura, quantos dedos e achou! com a mão
- lib/maos.ts: dedos esticados pelos 21 pontos do MediaPipe (vale em
  qualquer rotação), contagem, jogada e leitura estável (mão parada);
- lib/jogos.svelte.ts: os três jogos conversam com o Carinha por comandos
  (fala com legenda, gestos e símbolo nos olhos) e contam como brincadeira
  para o modo vida; cartão "Jogos com a mão" no Ao vivo liga a câmera e o
  rastreamento com um toque;
- servidor: comandos "dizer" (até 120 caracteres) e "olhos" (símbolos e
  números permitidos, 0,5 a 8 s); rosto mostra o símbolo nos olhos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:53:48 -03:00
julioandClaude Opus 5.5 92701e8a16 feat(pessoas): cartão "Quem é?" com o rosto, no Ao vivo e em Pessoas
Um cartão por pessoa desconhecida (grupo de rostos parecidos), com o rosto
grande: toca no nome de quem já conhece ou digita um novo, e a resposta vale
para todas as fotos do grupo. Não é ninguém / Pular. No Ao vivo só aparece
de quem ele já viu 3 vezes ou mais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:13:26 -03:00
julioandClaude Opus 5.5 938467e318 feat(pessoas): reconhecimento de quem chega, só na placa
- reconhecimento.py: cada foto de evento passa pelo YuNet (acha rostos)
  e pelo SFace (impressão de 128 números) no OpenCV da placa (~0,3 s por
  foto); compara com as amostras marcadas (cosseno >= 0,40). Rostos sem
  nome ficam agrupados por semelhança para marcar de uma vez; marcar uma
  pessoa reconhece também os rostos antigos parecidos. Procura nas fotos
  guardadas, renomear, apagar (apaga recortes e impressões), sem nome
  somem em 7 dias, liga/desliga.
- modo vida: espera até 8 s a foto para cumprimentar pelo nome ("Ana!
  Senti sua falta!"), saudade de cada pessoa, "Oi, Bia!" para quem chega
  com alguém já por perto, afinidade com quem dá carinho logo depois.
- painel: tela Pessoas e tópico no guia; console mostra "Reconheceu Ana".
- sistema/reconhecimento/instalar.sh: python3-opencv e os modelos do
  opencv_zoo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:38:10 -03:00
julioandClaude Opus 5.5 2933407b9e feat(vida): jeito de gente e console com o porquê de cada decisão
Console (console.py + tela Console no painel):
- cada parte registra o que fez e o motivo: modo vida (com necessidades
  e humor), sentidos, celular (ordens e o que o rosto fez), IA local,
  aprendizado e ideias do dia; últimas 400 linhas, ao vivo pelo SSE;
- tela com o "agora" (humor, necessidades, o que está fazendo, próxima
  decisão, última decisão, jeito), filtros, detalhes, pausa e busca.

Jeito de gente:
- saudade: a festa cresce com o tempo fora (aceno, "Oi! Você voltou!",
  "Senti sua falta!"); bom dia lembrando de ontem;
- pedidos com motivo ("Brinca comigo?", "Me faz um carinho?", mostrar a
  figurinha nova), no máximo 1 a cada 20 min;
- larga atividade no meio quando fica chato; personalidade (curiosidade,
  brincadeira, timidez) que pesa nos gestos e amadurece com o convívio;
- no rosto: acordar grogue, sonhar com algo do dia (balão no motor),
  hesitar antes de gestos, errar na atividade e ficar sem graça, olhar
  nos olhos e desviar, sorrir de volta (FaceLandmarker no rastreador do
  painel) e reagir à luz (sensor de luz no app 0.10.0);
- ordem "dizer" com legenda e voz de robô.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:37:07 -03:00
julioandClaude Opus 5.5 a99aa897c4 feat(vida): horário de dormir acompanha a rotina da casa e lição honesta
- A lição da rotina separa o que cai na madrugada: "das 10h às 23h vou
  estar acordado; das 23h à 1h é a minha hora de dormir, fico quietinho
  a não ser que alguém me chame" (antes prometia ficar acordado de
  madrugada, o que nunca acontecia).
- Com 5+ dias de rotina, na virada do dia ele mexe sozinho no horário
  da madrugada: atrasa o início se a primeira hora costuma ter gente,
  adianta o fim se a casa acorda cedo; 1 h por noite, no máximo 3 h
  depois / 2 h antes do que a pessoa escolheu, nunca menos de 5 h de
  madrugada, e volta quando as horas esvaziam. Mudança à mão vira a
  nova base. Fica no diário e no "Dia a dia"; liga/desliga no cartão.
- Faixas que passam da meia-noite viram uma só ("das 23h à 1h").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:47:26 -03:00
julioandClaude Opus 5.5 7b0a05435f feat(presenca): sensibilidade do PIR por confirmação (padrão média)
O HC-SR505 não tem ajuste físico e disparava por pouco (57 disparos numa
hora de madrugada). Agora só conta como presença o disparo confirmado:
média = 2 em 1 min ou ligado direto 12 s; baixa = 3 em 90 s ou 20 s;
alta = qualquer um. A bolinha do Ao vivo segue o pino cru. Ajuste em
Eventos › Modo presença.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:05:20 -03:00
julioandClaude Opus 5.5 6e80332a8d chore(deploy): placa guarda só os 5 backups de deploy mais recentes
Cada backup leva o APK (~70 MB): 50 deles chegaram a 3 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:12:33 -03:00
julioandClaude Opus 5.5 93f91e491c fix(celular): sem anúncio na rede, o app varre a /24 atrás da placa
O roteador/chip Wi-Fi descarta o multicast entre aparelhos sem fio: a placa
não via ninguém e o celular não recebia o _carinha._tcp. Sem anúncio em 6 s,
o app pergunta /app/health a cada endereço da rede (32 em paralelo) e usa
quem responde carinha-pc. Testado no S10: achou .35 em ~17 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:12:36 -03:00
44 changed files with 3587 additions and 71 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto" package="br.carinha.rosto"
android:versionCode="13" android:versionCode="14"
android:versionName="0.9.1"> android:versionName="0.10.0">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" /> <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
+172 -5
View File
@@ -20,6 +20,28 @@
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}} @keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}} @media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none} #fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
/* telão dos jogos: por cima do rosto, texto grande na cor dos olhos (o resultado tem cor própria) */
#telao{position:fixed;inset:0;z-index:8;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:2vh;background:rgba(0,0,0,.9);font-family:system-ui,sans-serif;color:var(--cor);text-align:center;pointer-events:none}
#telao[hidden]{display:none} /* sem isto o display:flex acima venceria o hidden e o letreiro não sumia */
#telao .titulo{font-weight:900;font-size:clamp(40px,24vh,240px);line-height:1;letter-spacing:.02em;
text-shadow:0 0 .25em currentColor}
#telao .sub{font-size:clamp(14px,6vh,48px);font-weight:600;opacity:.9}
#telao .lados{display:flex;align-items:center;gap:5vw}
#telao .lado{display:flex;flex-direction:column;align-items:center;gap:1vh;min-width:22vw}
#telao .rot{font-size:clamp(12px,5vh,36px);font-weight:700;letter-spacing:.1em;text-transform:uppercase;opacity:.85}
#telao .sim{font-size:clamp(40px,26vh,220px);line-height:1}
#telao .nome{font-size:clamp(12px,5vh,36px);font-weight:600}
#telao .x{font-size:clamp(24px,12vh,110px);font-weight:300;opacity:.7}
#telao.tom-eu .titulo{color:#ffd166}
#telao.tom-voce .titulo{color:#7ae582}
#telao.tom-empate .titulo{color:#cfe8ff}
#telao.tom-conta .titulo{font-size:clamp(60px,48vh,420px)}
#telao.entra .titulo,#telao.entra .lados{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1)}
#telao.piscar.entra .titulo{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1),telaoPisca .5s steps(2) .45s infinite}
@keyframes telaoEstoura{from{transform:scale(.3);opacity:0}to{transform:scale(1);opacity:1}}
@keyframes telaoPisca{50%{opacity:.25}}
@media(prefers-reduced-motion:reduce){#telao *{animation:none!important}}
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o /* 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. */ 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); #semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
@@ -595,6 +617,10 @@ function atividade(nome, segundos, receita) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return; if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; } if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
if (r.dormindo) { r.dormindo = false; animar("surpreso"); } if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
lembrarDoDia(nome, receita);
const agora = performance.now() / 1000, dur = Number(segundos) || 0;
HUMANO.oops = dur > 20 && Math.random() < 0.35 ? { ini: agora + dur * (0.3 + 0.4 * Math.random()) } : null;
if (HUMANO.oops) HUMANO.oops.fim = HUMANO.oops.ini + 2.2;
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica"); if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
} }
@@ -618,6 +644,121 @@ function tocarExpressaoCriada(texto) {
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos); if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
} }
/* ------------------------------------------------------------- jeito de gente
Acordar grogue, sonhar dormindo, hesitar antes de agir, errar na atividade e ficar sem graça,
olhar nos olhos e desviar, sorrir de volta para quem sorri e reagir à luz do ambiente. */
const HUMANO = {
dormiaAntes: false, dormiuEm: 0, grogueIni: 0, grogueAte: 0, bocejou: true,
sonhoProx: 0, sonhoAte: 0, sonhos: ['estrela', 'bola', 'nota', 'coracao', 'livro', 'flor'],
oops: null, // {ini, fim}: um tropeço no meio da atividade
evita: { ate: 0, prox: 0, dx: 0, dy: 0 }, // desvio do olhar durante o contato visual
sorriso: { desde: 0, ultimo: 0 },
luz: { lux: null, media: null, sonolentoAte: 0, ofuscadoAte: 0, mudou: 0 },
};
const OBJETO_DA_ATIVIDADE = { bola: 'bola', ler: 'livro', cafe: 'xicara', cantar: 'nota', violao: 'nota', pintar: 'pincel',
regar: 'flor', telescopio: 'estrela', malhar: 'halter', bolhas: 'bolha', videogame: 'controle', dancar: 'estrela',
meditar: 'coracao', cozinhar: 'chama', computador: 'controle', pescar: 'bolha', pipa: 'estrela', skate: 'estrela',
corda: 'estrela' };
function lembrarDoDia(nome, receita) { // o que ele fez hoje vira assunto dos sonhos
const obj = receita && Array.isArray(receita.objetos) && receita.objetos[0] ? receita.objetos[0].tipo : OBJETO_DA_ATIVIDADE[nome];
if (obj) HUMANO.sonhos = [obj].concat(HUMANO.sonhos.filter((o) => o !== obj)).slice(0, 8);
}
function falarFrase(texto, segundos) { // "dizer ..." do modo vida: legenda + voz de robô
if (window.carinhaSemServidor || !texto || r.dormindo) return;
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto, segundos: segundos || Math.max(4, Math.min(14, texto.length / 9)) });
falar(texto);
}
/* Telão dos jogos: contagem regressiva piscando, as duas jogadas lado a lado e o resultado grande. */
let telaoAte = null;
function mostrarTelao(json) {
let t;
try { t = JSON.parse(json); } catch (e) { return; }
let el = document.getElementById('telao');
if (!el) { el = document.createElement('div'); el.id = 'telao'; document.body.appendChild(el); }
const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const lados = (t.lados || []).map((l) =>
'<div class="lado"><span class="rot">' + esc(l.rotulo) + '</span><span class="sim">' + esc(l.simbolo) + '</span>' +
'<span class="nome">' + esc(l.nome) + '</span></div>').join('<div class="x">×</div>');
el.className = 'tom-' + (t.tom || 'neutro') + (t.piscar ? ' piscar' : '');
el.style.setProperty('--cor', 'rgb(' + CIANO.map(Math.round).join(',') + ')');
el.innerHTML = (lados ? '<div class="lados">' + lados + '</div>' : '') +
(t.titulo ? '<div class="titulo">' + esc(t.titulo) + '</div>' : '') + (t.sub ? '<div class="sub">' + esc(t.sub) + '</div>' : '');
el.hidden = false;
void el.offsetWidth; el.classList.add('entra'); // reinicia a animação a cada letreiro
if (r.dormindo) r.dormindo = false;
clearTimeout(telaoAte);
telaoAte = setTimeout(() => { el.hidden = true; }, (t.segundos || 2.5) * 1000);
}
let olhosAte = null;
function mostrarNosOlhos(texto) { // "✌️ 2.5": símbolo nos olhos; "- 0" apaga
const i = texto.lastIndexOf(' ');
const simbolo = i > 0 ? texto.slice(0, i) : texto, seg = i > 0 ? parseFloat(texto.slice(i + 1)) : 2.5;
clearTimeout(olhosAte);
if (r.dormindo) { r.dormindo = false; }
window.carinhaOlhosIcone = simbolo && simbolo !== '-' ? simbolo : null;
if (window.carinhaOlhosIcone) olhosAte = setTimeout(() => { window.carinhaOlhosIcone = null; }, (seg || 2.5) * 1000);
}
function hesitar(fn, gesto) { // às vezes pensa um instante antes de agir
if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn();
setTimeout(fn, 150 + Math.random() * 450);
}
function humanoTick(t) {
// acordou depois de dormir um tempo: fica grogue (olhos pesados, lento) e boceja
if (r.dormindo && !HUMANO.dormiaAntes) HUMANO.dormiuEm = t;
if (!r.dormindo && HUMANO.dormiaAntes && t - HUMANO.dormiuEm > 180) {
HUMANO.grogueIni = t; HUMANO.grogueAte = t + 30 + Math.random() * 20; HUMANO.bocejou = false;
}
HUMANO.dormiaAntes = r.dormindo;
if (!HUMANO.bocejou && t - HUMANO.grogueIni > 1.6 && !r.dormindo && !r.kf) { HUMANO.bocejou = true; animar('bocejar'); }
// dormindo (com a tela acesa): de vez em quando sonha com algo do dia
if (r.dormindo && !SONO.apagada && !window.carinhaSemServidor) {
if (!HUMANO.sonhoProx) HUMANO.sonhoProx = t + 40 + Math.random() * 60;
if (t >= HUMANO.sonhoProx) {
const dur = 6 + Math.random() * 4;
HUMANO.sonhoAte = t + dur; HUMANO.sonhoProx = t + dur + 45 + Math.random() * 90;
const obj = HUMANO.sonhos[Math.floor(Math.random() * Math.min(4, HUMANO.sonhos.length))];
if (window.MotorRosto && window.MotorRosto.sonho) window.MotorRosto.sonho(obj, dur);
}
} else HUMANO.sonhoProx = 0;
}
function grogue(t) { // 1 = acabou de acordar, 0 = esperto
return t < HUMANO.grogueAte ? Math.min(1, (HUMANO.grogueAte - t) / (HUMANO.grogueAte - HUMANO.grogueIni)) : 0;
}
function piscarHumano(t) { animar(grogue(t) > 0.2 || t < HUMANO.luz.sonolentoAte ? 'piscar2' : 'piscar'); }
function desvioDoOlhar(t) { // olha nos olhos 2 a 5 s, desvia um instante e volta
const ev = HUMANO.evita;
if (t >= ev.prox) {
const dura = Math.random() < 0.7 ? 0.6 + Math.random() * 0.7 : 0;
ev.ate = t + dura; ev.prox = ev.ate + 2 + Math.random() * 3;
ev.dx = (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 8); ev.dy = 3 + Math.random() * 5;
}
return t < ev.ate ? ev : null;
}
function contagio(t, agora) { // sorriu para ele (rastreamento do painel): sorri de volta
const S = HUMANO.sorriso;
if (t.tipo !== 'rosto' || typeof t.sorriso !== 'number' || r.dormindo || ATIV) { S.desde = 0; return; }
if (t.sorriso > 0.55) { if (!S.desde) S.desde = agora; } else S.desde = 0;
if (S.desde && agora - S.desde > 0.6 && agora - S.ultimo > 20 && !r.kf) {
S.ultimo = agora; S.desde = 0;
animar(t.sorriso > 0.8 ? 'rir' : 'feliz');
}
}
function luzAmbiente(lux) { // sensor de luz do celular (lux)
lux = Number(lux);
if (!Number.isFinite(lux) || lux < 0) return;
const L = HUMANO.luz, t = performance.now() / 1000, antes = L.media;
L.lux = lux;
L.media = antes === null ? lux : antes + (lux - antes) * 0.15;
if (antes === null || t - L.mudou < 8) return;
if (lux < 3 && antes > 25) { // apagaram a luz: fica com sono
L.mudou = t; L.sonolentoAte = t + 60; L.media = lux;
if (!r.dormindo && !r.kf) animar('bocejar');
} else if (lux > 80 && antes < 8) { // acenderam a luz de repente: aperta os olhos
L.mudou = t; L.ofuscadoAte = t + 1.8; L.media = lux;
if (r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) r.dormindo = false;
}
}
function repouso(t) { // pose calma: olha devagar, "respira" function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) { if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono"; r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
@@ -626,6 +767,7 @@ function repouso(t) { // pose calma: olha devagar, "respira
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4); r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0; r.dx = 0;
r.dy = 3.0 * Math.sin(t * 1.4); r.dy = 3.0 * Math.sin(t * 1.4);
if (t < HUMANO.sonhoAte) { r.dx = 3.2 * Math.sin(t * 6.5); r.boca = 'sorriso'; } // sonhando
return; return;
} }
r.forma = "aberto"; r.forma2 = null; r.forma = "aberto"; r.forma2 = null;
@@ -638,7 +780,7 @@ function repouso(t) { // pose calma: olha devagar, "respira
const longe = Math.random() < 0.25; const longe = Math.random() < 0.25;
OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7); OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3); OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
OLHAR.prox = t + 1.2 + Math.random() * 3; OLHAR.prox = t + (1.2 + Math.random() * 3) * (1 + 2 * grogue(t)); // grogue: olhar lento
} }
OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3; OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3;
OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3; OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
@@ -651,9 +793,23 @@ function repouso(t) { // pose calma: olha devagar, "respira
if (ATIV.forma) r.forma = ATIV.forma; if (ATIV.forma) r.forma = ATIV.forma;
if (ATIV.boca) r.boca = ATIV.boca; if (ATIV.boca) r.boca = ATIV.boca;
if (ATIV.ab) r.ab = ATIV.ab; if (ATIV.ab) r.ab = ATIV.ab;
const o = HUMANO.oops; // errou: susto e depois fica sem graça
if (o && t >= o.ini && t < o.fim) {
if (t - o.ini < 0.5) { r.forma = 'surpreso'; r.boca = 'aberta'; r.ab = 1.1; }
else { r.forma = 'feliz'; r.boca = 'sorriso'; r.cor = lerpCor(r.cor, ROSA, 0.5); r.dy += 6; }
}
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica) } else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
r.forma = "bravo"; r.forma = "bravo";
} }
const g = grogue(t);
if (g > 0) { // acabou de acordar: olhos pesados, cor apagada, olhar baixo
r.ab = Math.min(r.ab, 1 - 0.5 * g);
r.cor = lerpCor(r.cor, corEscura(0.7), 0.35 * g);
if (g > 0.5 && !ATIV) r.boca = 'sono';
r.dx *= 1 - 0.5 * g; r.dy = r.dy * (1 - 0.5 * g) + 3 * g;
}
if (t < HUMANO.luz.sonolentoAte) r.ab = Math.min(r.ab, 0.78);
if (t < HUMANO.luz.ofuscadoAte) { r.ab = Math.min(r.ab, 0.3); r.forma = 'feliz'; }
} }
function desenhar() { function desenhar() {
@@ -667,7 +823,9 @@ function desenhar() {
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo; const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV; const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV;
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0); const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy; const ev = seguindo && TRACK.alvo.tipo === 'rosto' ? desvioDoOlhar(tAgora) : null;
const dx = seguindo ? (ev ? MOV.olharX * 0.3 + ev.dx : MOV.olharX) : r.dx;
const dy = seguindo ? (ev ? MOV.olharY * 0.3 + ev.dy : MOV.olharY) : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
const comMotor = ROSTO.motor && window.MotorRosto; const comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; }); OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
@@ -751,6 +909,9 @@ function contarAprendi(texto) {
function trata(ordem) { function trata(ordem) {
const bruta = (ordem || "").trim(); const bruta = (ordem || "").trim();
if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase
if (/^dizer /i.test(bruta)) { falarFrase(bruta.slice(6).trim()); return; }
if (/^olhos /i.test(bruta)) { mostrarNosOlhos(bruta.slice(6).trim()); return; } // jogos: ✊ ✋ ✌️ e números
if (/^telao /i.test(bruta)) { mostrarTelao(bruta.slice(6)); return; } // letreiro grande dos jogos
ordem = bruta.toLowerCase(); ordem = bruta.toLowerCase();
if (!ordem) return; if (!ordem) return;
if(ordem==='recarregar') { if(ordem==='recarregar') {
@@ -797,14 +958,16 @@ function trata(ordem) {
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); } if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
return; return;
} }
r.dormindo = false; animar("surpreso"); const longo = r.dormindo && performance.now() / 1000 - HUMANO.dormiuEm > 180;
r.dormindo = false;
if (!longo) animar("surpreso"); // sono longo: acorda devagar, grogue (humanoTick)
} else if (ordem === "aleatorio") { } else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) { } else if (SEQS[ordem]) {
if (window.MotorRosto) { if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 }); if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
} }
animar(ordem); hesitar(() => animar(ordem), ordem);
} else if (ordem === "calma") { } else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro. // E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false; r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
@@ -1121,6 +1284,7 @@ function rastrear(t) {
(r.dormindo && !configRecebida?.cameraSono)) return; (r.dormindo && !configRecebida?.cameraSono)) return;
const agora=performance.now()/1000; const agora=performance.now()/1000;
TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y; TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y;
contagio(t, agora);
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65; MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65; MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
if(t.atividade===true) { if(t.atividade===true) {
@@ -1306,6 +1470,7 @@ const AUDIO = {
}; };
if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ
if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
if (window.eilik) window.eilik.luz = luzAmbiente; // sensor de luz do celular (o app chama)
let configRecebida = null; let configRecebida = null;
function configurarApp(c) { function configurarApp(c) {
@@ -1353,6 +1518,7 @@ async function sincronizarApp() {
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho, aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
luz: HUMANO.luz.lux,
}) }); }) });
} }
} catch (e) { /* servidor ausente: animação continua */ } } catch (e) { /* servidor ausente: animação continua */ }
@@ -1448,6 +1614,7 @@ function laco(t) {
checarSilencio(t / 1000); checarSilencio(t / 1000);
atualizarFeicao(t / 1000); atualizarFeicao(t / 1000);
humanoTick(t / 1000);
ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null; 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); if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
@@ -1461,7 +1628,7 @@ function laco(t) {
if (r.dormindo) { if (r.dormindo) {
proxPiscar = proxGesto = t + 3600000; proxPiscar = proxGesto = t + 3600000;
} else if (t >= proxPiscar) { } else if (t >= proxPiscar) {
animar("piscar"); piscarHumano(t / 1000);
proxPiscar = t + 1600 + Math.random() * 3500; proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto && !ATIV) { } else if (AUTO && t >= proxGesto && !ATIV) {
animar(gestoSolto()); animar(gestoSolto());
+30 -1
View File
@@ -287,11 +287,13 @@
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
desenharMaos(ctx, o, olhos, tela.height); desenharMaos(ctx, o, olhos, tela.height);
desenharZzz(ctx, o, olhos); desenharZzz(ctx, o, olhos);
desenharSonho(ctx, o, olhos);
} else { } else {
cctx.clearRect(0, 0, camada.width, camada.height); cctx.clearRect(0, 0, camada.width, camada.height);
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
desenharMaos(cctx, o, olhos, tela.height); desenharMaos(cctx, o, olhos, tela.height);
desenharZzz(cctx, o, olhos); desenharZzz(cctx, o, olhos);
desenharSonho(cctx, o, olhos);
ctx.save(); ctx.save();
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); } if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
ctx.drawImage(camada, 0, 0); ctx.drawImage(camada, 0, 0);
@@ -421,6 +423,33 @@
} }
// ------------------------------------------------------------------ Zzz // ------------------------------------------------------------------ Zzz
// ----------------------------------------------------------- sonho
// Dormindo, às vezes aparece bem fraquinho um balão de pensamento com algo do dia (a bola, a estrela...).
const sonho = { obj: null, t0: 0, dur: 0 };
function definirSonho(obj, segundos) {
sonho.obj = obj || null; sonho.t0 = performance.now() / 1000; sonho.dur = segundos || 7;
}
function desenharSonho(c, o, olhos) {
if (!o.dormindo || !sonho.obj) return;
const k = (o.t - sonho.t0) / sonho.dur;
if (k < 0) return;
if (k >= 1) { sonho.obj = null; return; }
const surge = Math.min(1, k * sonho.dur / 1.2, (1 - k) * sonho.dur / 1.2); // aparece e some devagar
const e = olhos.slice().sort((a, b) => a.cx - b.cx)[0]; // olho da esquerda na tela
const R = e.ry * 0.62, bx = e.cx - e.ry * 0.1;
const by = Math.max(R * 1.2, e.cy - e.ry * 1.75) + Math.sin(o.t * 1.3) * e.ry * 0.05; // nunca sai da tela
c.save();
c.strokeStyle = css(o.cor); c.fillStyle = css(o.cor, 0.08); c.lineWidth = Math.max(1.5, R * 0.06);
c.globalAlpha = 0.45 * surge;
for (const [f, rr] of [[0.25, 0.09], [0.55, 0.15]]) { // bolinhas subindo até o balão
c.beginPath(); c.arc(e.cx + e.ry * 0.3 - f * e.ry * 0.3, e.cy - e.ry * 0.75 + (by + R - e.cy + e.ry * 0.75) * f, R * rr * 1.3, 0, Math.PI * 2); c.stroke();
}
c.beginPath(); c.arc(bx, by, R, 0, Math.PI * 2); c.fill(); c.stroke();
c.globalAlpha = 0.55 * surge;
desenharObjeto(c, sonho.obj, bx, by, R * 1.15, o.t, o.cor, Math.sin(o.t * 0.8) * 0.15, 0);
c.restore();
}
function desenharZzz(c, o, olhos, fator) { function desenharZzz(c, o, olhos, fator) {
fator = fator || 1; fator = fator || 1;
if (!o.dormindo) return; if (!o.dormindo) return;
@@ -1385,7 +1414,7 @@
return ((!base.lente && FORMATOS[formato]) || base).h / 2; return ((!base.lente && FORMATOS[formato]) || base).h / 2;
}, },
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } }, maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS), faixa: definirFaixa, sonho: definirSonho, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS),
/** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */ /** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */
atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)), atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null), atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null),
+117 -23
View File
@@ -7,55 +7,75 @@ import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.util.Log; import android.util.Log;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/** /**
* Acha a placa na rede pelo anúncio mDNS "_carinha._tcp" (avahi na Orange Pi, o mesmo do * Acha a placa na rede quando o endereço salvo não responde, para o app se reconectar sem
* carinha.local). Assim, se o IP da placa mudar, o app se reconecta sem ninguém digitar endereço. * ninguém digitar endereço:
* Uma busca por vez; desiste depois de alguns segundos e responde no fio principal. * 1. pelo anúncio mDNS "_carinha._tcp" (avahi na Orange Pi, o mesmo do carinha.local);
* 2. se o anúncio não chegar (alguns roteadores e chips Wi-Fi descartam multicast entre aparelhos
* sem fio), perguntando a cada endereço da rede do celular quem responde /app/health como
* "carinha-pc".
* Uma busca por vez; a resposta chega no fio principal (null = não achou).
*/ */
final class Descoberta { final class Descoberta {
interface Resposta { void achou(String endereco); } // null = não achou interface Resposta { void achou(String endereco); }
private static final String TAG = "CarinhaDescoberta"; private static final String TAG = "CarinhaDescoberta";
private static final String TIPO = "_carinha._tcp."; private static final String TIPO = "_carinha._tcp.";
private static final long LIMITE = 8000; private static final long LIMITE_ANUNCIO = 6000;
private static final int PORTA = 8100;
private final NsdManager nsd; private final NsdManager nsd;
private final Handler principal = new Handler(Looper.getMainLooper()); private final Handler principal = new Handler(Looper.getMainLooper());
private NsdManager.DiscoveryListener ouvinte; private NsdManager.DiscoveryListener ouvinte;
private Resposta resposta; private Resposta resposta;
private boolean ocupada;
Descoberta(Context c) { Descoberta(Context c) {
nsd = (NsdManager) c.getApplicationContext().getSystemService(Context.NSD_SERVICE); nsd = (NsdManager) c.getApplicationContext().getSystemService(Context.NSD_SERVICE);
} }
boolean procurando() { return ouvinte != null; } boolean procurando() { return ocupada; }
void procurar(Resposta r) { void procurar(Resposta r) {
if (nsd == null) { r.achou(null); return; } if (ocupada) return;
if (ouvinte != null) return; ocupada = true;
resposta = r; resposta = r;
if (nsd == null) { semAnuncio(); return; }
ouvinte = new NsdManager.DiscoveryListener() { ouvinte = new NsdManager.DiscoveryListener() {
@Override public void onDiscoveryStarted(String tipo) { } @Override public void onDiscoveryStarted(String tipo) { }
@Override public void onDiscoveryStopped(String tipo) { } @Override public void onDiscoveryStopped(String tipo) { }
@Override public void onStartDiscoveryFailed(String tipo, int erro) { terminar(null); } @Override public void onStartDiscoveryFailed(String tipo, int erro) { fimDoAnuncio(null); }
@Override public void onStopDiscoveryFailed(String tipo, int erro) { } @Override public void onStopDiscoveryFailed(String tipo, int erro) { }
@Override public void onServiceLost(NsdServiceInfo s) { } @Override public void onServiceLost(NsdServiceInfo s) { }
@Override public void onServiceFound(NsdServiceInfo s) { @Override public void onServiceFound(NsdServiceInfo s) {
if (!s.getServiceType().startsWith("_carinha._tcp")) return; if (s.getServiceType().startsWith("_carinha._tcp")) resolver(s);
resolver(s);
} }
}; };
try { try {
nsd.discoverServices(TIPO, NsdManager.PROTOCOL_DNS_SD, ouvinte); nsd.discoverServices(TIPO, NsdManager.PROTOCOL_DNS_SD, ouvinte);
} catch (Exception e) { } catch (Exception e) {
Log.w(TAG, "busca falhou: " + e); Log.w(TAG, "busca por anúncio falhou: " + e);
ouvinte = null; ouvinte = null;
r.achou(null); semAnuncio();
return; return;
} }
principal.postDelayed(new Runnable() { principal.postDelayed(new Runnable() {
@Override public void run() { terminar(null); } @Override public void run() { fimDoAnuncio(null); }
}, LIMITE); }, LIMITE_ANUNCIO);
} }
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")
@@ -67,7 +87,7 @@ final class Descoberta {
if (s.getHost() == null || s.getHost().isLoopbackAddress()) return; if (s.getHost() == null || s.getHost().isLoopbackAddress()) return;
String ip = s.getHost().getHostAddress(); String ip = s.getHost().getHostAddress();
if (ip == null || ip.contains(":")) return; // só IPv4: o servidor ouve em 0.0.0.0 if (ip == null || ip.contains(":")) return; // só IPv4: o servidor ouve em 0.0.0.0
terminar("http://" + ip + ":" + s.getPort()); fimDoAnuncio("http://" + ip + ":" + s.getPort());
} }
}); });
} catch (Exception e) { } catch (Exception e) {
@@ -75,19 +95,93 @@ final class Descoberta {
} }
} }
private void terminar(final String endereco) { /** Fim da busca por anúncio: achou responde; não achou passa para a varredura. */
private void fimDoAnuncio(final String endereco) {
principal.post(new Runnable() { principal.post(new Runnable() {
@Override public void run() { @Override public void run() {
if (ouvinte == null) return; // já respondeu if (ouvinte == null) return; // já terminou
try { nsd.stopServiceDiscovery(ouvinte); } catch (Exception e) { /* já parou */ } try { nsd.stopServiceDiscovery(ouvinte); } catch (Exception e) { /* já parou */ }
ouvinte = null; ouvinte = null;
Resposta r = resposta; if (endereco != null) { Log.i(TAG, "placa achada pelo anúncio: " + endereco); responder(endereco); }
resposta = null; else semAnuncio();
Log.i(TAG, endereco == null ? "placa não encontrada na rede" : "placa encontrada: " + endereco);
if (r != null) r.achou(endereco);
} }
}); });
} }
void parar() { terminar(null); } private void semAnuncio() {
new Thread(new Runnable() {
@Override public void run() {
final String achado = varrer();
Log.i(TAG, achado == null ? "placa não encontrada na rede" : "placa achada na varredura: " + achado);
principal.post(new Runnable() { @Override public void run() { responder(achado); } });
}
}, "procura-placa").start();
}
private void responder(String endereco) {
ocupada = false;
Resposta r = resposta;
resposta = null;
if (r != null) r.achou(endereco);
}
/** Pergunta a cada endereço da rede /24 do celular (em paralelo) quem é o servidor do Carinha. */
private static String varrer() {
final List<String> alvos = new ArrayList<String>();
for (InetAddress meu : enderecosLocais()) {
byte[] b = meu.getAddress();
String prefixo = (b[0] & 255) + "." + (b[1] & 255) + "." + (b[2] & 255) + ".";
for (int i = 1; i < 255; i++) {
if (i != (b[3] & 255)) alvos.add(prefixo + i);
}
}
if (alvos.isEmpty()) return null;
final AtomicReference<String> achado = new AtomicReference<String>();
ExecutorService fios = Executors.newFixedThreadPool(32);
for (final String ip : alvos) {
fios.execute(new Runnable() {
@Override public void run() {
if (achado.get() == null && ehCarinha(ip)) achado.compareAndSet(null, "http://" + ip + ":" + PORTA);
}
});
}
fios.shutdown();
try { fios.awaitTermination(20, TimeUnit.SECONDS); } catch (InterruptedException e) { /* segue */ }
fios.shutdownNow();
return achado.get();
}
private static boolean ehCarinha(String ip) {
HttpURLConnection c = null;
try {
c = (HttpURLConnection) new URL("http://" + ip + ":" + PORTA + "/app/health").openConnection();
c.setConnectTimeout(600);
c.setReadTimeout(1500);
if (c.getResponseCode() != 200) return false;
byte[] buf = new byte[256];
int n;
try (InputStream in = c.getInputStream()) { n = in.read(buf); }
return n > 0 && new String(buf, 0, n, "UTF-8").contains("carinha-pc");
} catch (Exception e) {
return false;
} finally {
if (c != null) c.disconnect();
}
}
/** IPv4 privados das redes do celular (Wi-Fi), sem loopback nem dados móveis de operadora. */
private static List<InetAddress> enderecosLocais() {
List<InetAddress> lista = new ArrayList<InetAddress>();
try {
for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) {
if (!ni.isUp() || ni.isLoopback() || ni.getName().startsWith("rmnet")) continue;
for (InetAddress a : Collections.list(ni.getInetAddresses())) {
if (a instanceof Inet4Address && a.isSiteLocalAddress()) lista.add(a);
}
}
} catch (Exception e) {
Log.w(TAG, "sem endereços locais: " + e);
}
return lista;
}
} }
@@ -0,0 +1,44 @@
package br.carinha.rosto;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
/**
* Sensor de luz do celular (lux). A página decide o que fazer (apagaram a luz: sono; acenderam de
* repente: aperta os olhos). Manda no máximo a cada INTERVALO e só quando muda de verdade.
*/
final class Luz implements SensorEventListener {
interface Ouvinte { void luz(float lux); }
private static final long INTERVALO = 700; // ms
private final SensorManager gerente;
private final Sensor sensor;
private final Ouvinte ouvinte;
private long enviadoEm;
private float enviado = -1;
Luz(Context c, Ouvinte ouvinte) {
gerente = (SensorManager) c.getSystemService(Context.SENSOR_SERVICE);
sensor = gerente == null ? null : gerente.getDefaultSensor(Sensor.TYPE_LIGHT);
this.ouvinte = ouvinte;
}
void ligar() { if (sensor != null) gerente.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL); }
void desligar() { if (sensor != null) gerente.unregisterListener(this); }
@Override public void onSensorChanged(SensorEvent e) {
float lux = e.values[0];
long agora = System.currentTimeMillis();
boolean mudou = enviado < 0 || Math.abs(lux - enviado) > Math.max(2f, enviado * 0.15f);
if (!mudou || agora - enviadoEm < INTERVALO) return;
enviado = lux;
enviadoEm = agora;
ouvinte.luz(lux);
}
@Override public void onAccuracyChanged(Sensor s, int precisao) { }
}
@@ -109,6 +109,7 @@ public class MainActivity extends Activity {
private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto
private final CerebroLocal cerebroLocal = new CerebroLocal(); private final CerebroLocal cerebroLocal = new CerebroLocal();
private Descoberta descoberta; // acha a placa na rede quando o endereço salvo não responde private Descoberta descoberta; // acha a placa na rede quando o endereço salvo não responde
private Luz luz; // sensor de luz: a página reage a apagar/acender a luz
private boolean jaCaiuParaLocal = false; private boolean jaCaiuParaLocal = false;
private boolean jaRecarregou = false; private boolean jaRecarregou = false;
private boolean usandoLocal = false; // servidor caiu: ficamos na copia do APK private boolean usandoLocal = false; // servidor caiu: ficamos na copia do APK
@@ -193,6 +194,11 @@ public class MainActivity extends Activity {
cerebroLocal.chave = token; cerebroLocal.chave = token;
cerebroLocal.iniciar(this, servidor); cerebroLocal.iniciar(this, servidor);
descoberta = new Descoberta(this); descoberta = new Descoberta(this);
luz = new Luz(this, new Luz.Ouvinte() {
@Override public void luz(final float lux) {
if (emPrimeiroPlano) web.evaluateJavascript("window.eilik&&window.eilik.luz&&window.eilik.luz(" + fmt(lux) + ")", null);
}
});
remoto = new ControleRemoto(new Runnable() { remoto = new ControleRemoto(new Runnable() {
@Override public void run() { @Override public void run() {
runOnUiThread(new Runnable() { runOnUiThread(new Runnable() {
@@ -867,6 +873,7 @@ public class MainActivity extends Activity {
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();
emPrimeiroPlano = true; emPrimeiroPlano = true;
luz.ligar();
ultimoUsoTela = System.currentTimeMillis(); ultimoUsoTela = System.currentTimeMillis();
remoto.servidor(servidor); remoto.servidor(servidor);
remoto.iniciar(); remoto.iniciar();
@@ -890,6 +897,7 @@ public class MainActivity extends Activity {
protected void onPause() { protected void onPause() {
super.onPause(); super.onPause();
emPrimeiroPlano = false; emPrimeiroPlano = false;
luz.desligar();
remoto.parar(); remoto.parar();
rtcWeb.evaluateJavascript("window.rtcParar&&window.rtcParar()", null); rtcWeb.evaluateJavascript("window.rtcParar&&window.rtcParar()", null);
rtcAtivo = false; rtcAtivo = false;
+97 -6
View File
@@ -24,7 +24,7 @@ from pathlib import Path
from comum import gravar_atomico, ler_json from comum import gravar_atomico, ler_json
PADRAO = dict(ligado=True, analise_ia=True, ia='gemini') PADRAO = dict(ligado=True, analise_ia=True, ia='gemini', ajustar_sono=True)
IAS = ('gemini', 'local', 's10') IAS = ('gemini', 'local', 's10')
JANELA = 60 # s depois de um ato em que a resposta de alguém conta para ele JANELA = 60 # s depois de um ato em que a resposta de alguém conta para ele
PESO_RESPOSTA = {'carinho': 1.0, 'brincar': 1.0, 'conversa': 1.0, 'alimentar': 0.6, 'cutucar': 0.5} PESO_RESPOSTA = {'carinho': 1.0, 'brincar': 1.0, 'conversa': 1.0, 'alimentar': 0.6, 'cutucar': 0.5}
@@ -38,6 +38,9 @@ MIN_MINUTOS_HORA = 10 # minutos observados numa hora para ela entrar na rotin
ROTINA_CHEIA, ROTINA_VAZIA = 0.5, 0.3 ROTINA_CHEIA, ROTINA_VAZIA = 0.5, 0.3
AVISO_CHEGADA = 15 * 60 # s antes da hora movimentada em que ele acorda do cochilo AVISO_CHEGADA = 15 * 60 # s antes da hora movimentada em que ele acorda do cochilo
MAX_LICOES = 30 MAX_LICOES = 30
# Horário de dormir pela rotina: anda 1 h por noite, no máximo 3 h depois / 2 h antes do que a pessoa
# escolheu, com pelo menos 5 dias observados, e a madrugada nunca fica menor que 5 h.
SONO_MIN_DIAS, SONO_ATRASO_MAX, SONO_ADIANTO_MAX, SONO_MIN_HORAS = 5, 3, 2, 5
MAX_DECISOES = 800 # decisões guardadas (com o motivo), umas duas semanas MAX_DECISOES = 800 # decisões guardadas (com o motivo), umas duas semanas
AVISO_SONO = 10 * 60 # no máximo uma nota de "não acordou com o sensor" a cada 10 min AVISO_SONO = 10 * 60 # no máximo uma nota de "não acordou com o sensor" a cada 10 min
DIAS_SEMANA = ['segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado', 'domingo'] DIAS_SEMANA = ['segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado', 'domingo']
@@ -87,11 +90,17 @@ def _faixas(horas):
faixas[-1] = (faixas[-1][0], h + 1) faixas[-1] = (faixas[-1][0], h + 1)
else: else:
faixas.append((h, h + 1)) faixas.append((h, h + 1))
if len(faixas) > 1 and faixas[0][0] == 0 and faixas[-1][1] == 24: # passa da meia-noite: uma faixa só
faixas = [(faixas[-1][0], faixas[0][1] + 24)] + faixas[1:-1]
return faixas return faixas
def _na_noite(h, ini, fim):
return ini <= h < fim if ini < fim else (h >= ini or h < fim)
def _texto_faixas(faixas): def _texto_faixas(faixas):
partes = ['das %dh às %dh' % (a, b % 24) for a, b in faixas] partes = ['%s %dh %s %dh' % ('da' if a == 1 else 'das', a, 'à' if b % 24 == 1 else 'às', b % 24) for a, b in faixas]
return partes[0] if len(partes) == 1 else ', '.join(partes[:-1]) + ' e ' + partes[-1] return partes[0] if len(partes) == 1 else ', '.join(partes[:-1]) + ' e ' + partes[-1]
@@ -126,6 +135,12 @@ class Aprendizado:
self.pode_ia = lambda provedor: False self.pode_ia = lambda provedor: False
self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro
self.sono_avisado = 0.0 self.sono_avisado = 0.0
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None
# horário da madrugada (Ajustes do celular): o main liga; None = madrugada silenciosa desligada
self.noite = lambda: None
self.mudar_noite = lambda ini, fim: None
self.sono_base = None # (início, fim) que a pessoa escolheu
self.sono_auto = None # (início, fim) que ele mesmo pôs por último
try: try:
salvo = json.loads(self.caminho.read_text(encoding='utf-8')) salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo.get('config', {}))) self.config.update(self.validar(salvo.get('config', {})))
@@ -151,6 +166,9 @@ class Aprendizado:
self.analise['em'] = salvo['analise_em'] self.analise['em'] = salvo['analise_em']
if isinstance(salvo.get('hoje'), dict) and salvo['hoje'].get('dia'): if isinstance(salvo.get('hoje'), dict) and salvo['hoje'].get('dia'):
self.hoje.update(salvo['hoje']) self.hoje.update(salvo['hoje'])
for chave in ('sono_base', 'sono_auto'):
if isinstance(salvo.get(chave), list) and len(salvo[chave]) == 2:
setattr(self, chave, tuple(int(v) % 24 for v in salvo[chave]))
except (OSError, ValueError, TypeError, KeyError, AttributeError): except (OSError, ValueError, TypeError, KeyError, AttributeError):
pass pass
@@ -200,7 +218,8 @@ class Aprendizado:
licoes=self.licoes, rotina_avisada=self.rotina_avisada, rotina_avisada_dia=self.rotina_avisada_dia, licoes=self.licoes, rotina_avisada=self.rotina_avisada, rotina_avisada_dia=self.rotina_avisada_dia,
hoje=self.hoje, semana=self.semana, hoje=self.hoje, semana=self.semana,
semana_n=self.semana_n, historico=self.historico, plano=self.plano, sugestao=self.sugestao, semana_n=self.semana_n, historico=self.historico, plano=self.plano, sugestao=self.sugestao,
analise_em=self.analise['em'], decisoes=self.decisoes) analise_em=self.analise['em'], decisoes=self.decisoes, sono_base=self.sono_base,
sono_auto=self.sono_auto)
self.salvo_em = time.time() self.salvo_em = time.time()
try: try:
gravar_atomico(self.caminho, dados) gravar_atomico(self.caminho, dados)
@@ -327,6 +346,7 @@ class Aprendizado:
with self.lock: with self.lock:
self.decisoes.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto, motivo=motivo)) self.decisoes.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto, motivo=motivo))
del self.decisoes[MAX_DECISOES:] del self.decisoes[MAX_DECISOES:]
self.console('aprendizado', texto, motivo, agora=agora)
def porque_chegada(self, agora): def porque_chegada(self, agora):
t = time.localtime(agora + 3600) t = time.localtime(agora + 3600)
@@ -436,14 +456,27 @@ class Aprendizado:
licoes.append('Tentei %s %d vezes e ninguém ligou. Vou fazer menos.' % (self.nome(ignorado[0][1]), ignorado[0][0])) licoes.append('Tentei %s %d vezes e ninguém ligou. Vou fazer menos.' % (self.nome(ignorado[0][1]), ignorado[0][0]))
if observou and hoje['atos'] >= 5 and not sum(hoje['respostas'].values()): if observou and hoje['atos'] >= 5 and not sum(hoje['respostas'].values()):
licoes.append('Hoje ninguém brincou comigo. Amanhã eu tento coisas diferentes.') licoes.append('Hoje ninguém brincou comigo. Amanhã eu tento coisas diferentes.')
# rotina: quando passa a ter um padrão (ou ele muda), conta # sono: a rotina mexe no horário de dormir (antes de contar a rotina, que já usa o novo horário)
licoes += self.ajustar_noite(hoje['dia'])
# rotina: quando passa a ter um padrão (ou ele muda), conta, separando o que cai na madrugada
if self.dias >= 2 and not self.plano: if self.dias >= 2 and not self.plano:
cheias = [h for h in range(24) if self.rotina[h] is not None and self.rotina[h] >= ROTINA_CHEIA] cheias = [h for h in range(24) if self.rotina[h] is not None and self.rotina[h] >= ROTINA_CHEIA]
mudou = not self.rotina_avisada or len(set(cheias) ^ set(self.rotina_avisada)) >= 3 or ( mudou = not self.rotina_avisada or len(set(cheias) ^ set(self.rotina_avisada)) >= 3 or (
cheias != self.rotina_avisada and self.dias - self.rotina_avisada_dia >= 7) cheias != self.rotina_avisada and self.dias - self.rotina_avisada_dia >= 7)
if cheias and mudou: if cheias and mudou:
licoes.append('Percebi que vocês costumam estar por perto %s. Vou estar acordado nessa hora.' noite = self.noite()
% _texto_faixas(_faixas(cheias))) dormindo = [h for h in cheias if noite and _na_noite(h, *noite)]
acordado = [h for h in cheias if h not in dormindo]
quieto = 'é a minha hora de dormir: fico quietinho, a não ser que alguém me chame.'
if acordado:
texto = ('Percebi que vocês costumam estar por perto %s. Vou estar acordado nessa hora.'
% _texto_faixas(_faixas(acordado)))
if dormindo:
resto = _texto_faixas(_faixas(dormindo)) + ' ' + quieto
texto += ' ' + resto[0].upper() + resto[1:]
else:
texto = 'Percebi gente por perto %s, mas %s' % (_texto_faixas(_faixas(dormindo)), quieto)
licoes.append(texto)
self.rotina_avisada, self.rotina_avisada_dia = cheias, self.dias self.rotina_avisada, self.rotina_avisada_dia = cheias, self.dias
# sono: acordou à toa várias vezes na mesma hora # sono: acordou à toa várias vezes na mesma hora
piores = sorted(((n, h) for h, n in enumerate(hoje['falsos']) if n >= 2), reverse=True) piores = sorted(((n, h) for h, n in enumerate(hoje['falsos']) if n >= 2), reverse=True)
@@ -463,6 +496,63 @@ class Aprendizado:
del self.licoes[MAX_LICOES:] del self.licoes[MAX_LICOES:]
return licoes return licoes
def ajustar_noite(self, dia):
"""Muda o horário da madrugada aos poucos pela rotina da casa. Devolve as lições."""
noite = self.noite()
if not noite or not self.config['ajustar_sono'] or not self.ligado():
return []
if self.sono_base is None or noite != self.sono_auto:
self.sono_base = noite # a pessoa escolheu (ou mudou) o horário: vira a base
if self.dias < SONO_MIN_DIAS:
return []
ini, fim = noite
base_ini, base_fim = self.sono_base
def cheia(h):
v = self.rotina[h % 24]
return v is not None and v >= ROTINA_CHEIA
def vazia(h):
v = self.rotina[h % 24]
return v is not None and v <= ROTINA_VAZIA
atraso, adianto = (ini - base_ini) % 24, (base_fim - fim) % 24
novo_ini, novo_fim = ini, fim
if cheia(ini) and atraso < SONO_ATRASO_MAX:
novo_ini = (ini + 1) % 24 # a primeira hora da madrugada costuma ter gente
elif 0 < atraso <= SONO_ATRASO_MAX and vazia(ini - 1):
novo_ini = (ini - 1) % 24 # a casa voltou a dormir cedo: volta para o de antes
if cheia(fim - 1) and adianto < SONO_ADIANTO_MAX:
novo_fim = (fim - 1) % 24 # a casa acorda antes
elif 0 < adianto <= SONO_ADIANTO_MAX and vazia(fim):
novo_fim = (fim + 1) % 24
if (novo_ini, novo_fim) == tuple(noite) or (novo_fim - novo_ini) % 24 < SONO_MIN_HORAS:
return []
self.mudar_noite(novo_ini, novo_fim)
self.sono_auto = (novo_ini, novo_fim)
licoes, motivos = [], []
if novo_ini != ini:
if novo_ini == (ini + 1) % 24:
licoes.append('Vocês têm ficado acordados até mais tarde: agora eu vou dormir às %dh (antes era %dh).'
% (novo_ini, ini))
motivos.append('às %dh costuma ter gente' % ini)
else:
licoes.append('A casa voltou a dormir mais cedo: volto a dormir às %dh.' % novo_ini)
motivos.append('às %dh a casa costuma estar vazia' % novo_ini)
if novo_fim != fim:
if novo_fim == (fim - 1) % 24:
licoes.append('Vocês têm acordado cedo: agora eu acordo às %dh (antes era %dh).' % (novo_fim, fim))
motivos.append('às %dh costuma ter gente' % novo_fim)
else:
licoes.append('A casa voltou a acordar mais tarde: volto a acordar às %dh.' % novo_fim)
motivos.append('às %dh a casa costuma estar vazia' % fim)
fim_do_dia = time.mktime(time.strptime(dia + ' 23:59:59', '%Y-%m-%d %H:%M:%S'))
self.decisoes.insert(0, dict(
horario=fim_do_dia, tipo='sono', texto='Mudou o horário de dormir para %dh às %dh' % (novo_ini, novo_fim),
motivo='; '.join(motivos) + ' (o horário escolhido é %dh às %dh)' % tuple(self.sono_base)))
self.console('aprendizado', self.decisoes[0]['texto'], self.decisoes[0]['motivo'])
return licoes
# ------------------------------------------------------------------ semana: IA e plano # ------------------------------------------------------------------ semana: IA e plano
@staticmethod @staticmethod
def validar_plano(bruto, origem, criado=None): def validar_plano(bruto, origem, criado=None):
@@ -691,6 +781,7 @@ class Aprendizado:
gosto.sort(key=lambda g: -g['peso']) gosto.sort(key=lambda g: -g['peso'])
hoje = self.hoje hoje = self.hoje
return dict(config=dict(self.config), dias=self.dias, rotina=list(self.rotina), return dict(config=dict(self.config), dias=self.dias, rotina=list(self.rotina),
sono_base=self.sono_base, sono_auto=self.sono_auto,
sono=[self._extra(f) if self.ligado() else 0 for f in self.falsos], sono=[self._extra(f) if self.ligado() else 0 for f in self.falsos],
gosto=gosto, licoes=self.licoes[:12], gosto=gosto, licoes=self.licoes[:12],
semana=[[v if self.semana_n[s][h] else None for h, v in enumerate(linha)] semana=[[v if self.semana_n[s][h] else None for h, v in enumerate(linha)]
+172 -5
View File
@@ -20,6 +20,28 @@
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}} @keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}} @media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none} #fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
/* telão dos jogos: por cima do rosto, texto grande na cor dos olhos (o resultado tem cor própria) */
#telao{position:fixed;inset:0;z-index:8;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:2vh;background:rgba(0,0,0,.9);font-family:system-ui,sans-serif;color:var(--cor);text-align:center;pointer-events:none}
#telao[hidden]{display:none} /* sem isto o display:flex acima venceria o hidden e o letreiro não sumia */
#telao .titulo{font-weight:900;font-size:clamp(40px,24vh,240px);line-height:1;letter-spacing:.02em;
text-shadow:0 0 .25em currentColor}
#telao .sub{font-size:clamp(14px,6vh,48px);font-weight:600;opacity:.9}
#telao .lados{display:flex;align-items:center;gap:5vw}
#telao .lado{display:flex;flex-direction:column;align-items:center;gap:1vh;min-width:22vw}
#telao .rot{font-size:clamp(12px,5vh,36px);font-weight:700;letter-spacing:.1em;text-transform:uppercase;opacity:.85}
#telao .sim{font-size:clamp(40px,26vh,220px);line-height:1}
#telao .nome{font-size:clamp(12px,5vh,36px);font-weight:600}
#telao .x{font-size:clamp(24px,12vh,110px);font-weight:300;opacity:.7}
#telao.tom-eu .titulo{color:#ffd166}
#telao.tom-voce .titulo{color:#7ae582}
#telao.tom-empate .titulo{color:#cfe8ff}
#telao.tom-conta .titulo{font-size:clamp(60px,48vh,420px)}
#telao.entra .titulo,#telao.entra .lados{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1)}
#telao.piscar.entra .titulo{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1),telaoPisca .5s steps(2) .45s infinite}
@keyframes telaoEstoura{from{transform:scale(.3);opacity:0}to{transform:scale(1);opacity:1}}
@keyframes telaoPisca{50%{opacity:.25}}
@media(prefers-reduced-motion:reduce){#telao *{animation:none!important}}
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o /* 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. */ 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); #semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
@@ -595,6 +617,10 @@ function atividade(nome, segundos, receita) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return; if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; } if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
if (r.dormindo) { r.dormindo = false; animar("surpreso"); } if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
lembrarDoDia(nome, receita);
const agora = performance.now() / 1000, dur = Number(segundos) || 0;
HUMANO.oops = dur > 20 && Math.random() < 0.35 ? { ini: agora + dur * (0.3 + 0.4 * Math.random()) } : null;
if (HUMANO.oops) HUMANO.oops.fim = HUMANO.oops.ini + 2.2;
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica"); if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
} }
@@ -618,6 +644,121 @@ function tocarExpressaoCriada(texto) {
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos); if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
} }
/* ------------------------------------------------------------- jeito de gente
Acordar grogue, sonhar dormindo, hesitar antes de agir, errar na atividade e ficar sem graça,
olhar nos olhos e desviar, sorrir de volta para quem sorri e reagir à luz do ambiente. */
const HUMANO = {
dormiaAntes: false, dormiuEm: 0, grogueIni: 0, grogueAte: 0, bocejou: true,
sonhoProx: 0, sonhoAte: 0, sonhos: ['estrela', 'bola', 'nota', 'coracao', 'livro', 'flor'],
oops: null, // {ini, fim}: um tropeço no meio da atividade
evita: { ate: 0, prox: 0, dx: 0, dy: 0 }, // desvio do olhar durante o contato visual
sorriso: { desde: 0, ultimo: 0 },
luz: { lux: null, media: null, sonolentoAte: 0, ofuscadoAte: 0, mudou: 0 },
};
const OBJETO_DA_ATIVIDADE = { bola: 'bola', ler: 'livro', cafe: 'xicara', cantar: 'nota', violao: 'nota', pintar: 'pincel',
regar: 'flor', telescopio: 'estrela', malhar: 'halter', bolhas: 'bolha', videogame: 'controle', dancar: 'estrela',
meditar: 'coracao', cozinhar: 'chama', computador: 'controle', pescar: 'bolha', pipa: 'estrela', skate: 'estrela',
corda: 'estrela' };
function lembrarDoDia(nome, receita) { // o que ele fez hoje vira assunto dos sonhos
const obj = receita && Array.isArray(receita.objetos) && receita.objetos[0] ? receita.objetos[0].tipo : OBJETO_DA_ATIVIDADE[nome];
if (obj) HUMANO.sonhos = [obj].concat(HUMANO.sonhos.filter((o) => o !== obj)).slice(0, 8);
}
function falarFrase(texto, segundos) { // "dizer ..." do modo vida: legenda + voz de robô
if (window.carinhaSemServidor || !texto || r.dormindo) return;
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto, segundos: segundos || Math.max(4, Math.min(14, texto.length / 9)) });
falar(texto);
}
/* Telão dos jogos: contagem regressiva piscando, as duas jogadas lado a lado e o resultado grande. */
let telaoAte = null;
function mostrarTelao(json) {
let t;
try { t = JSON.parse(json); } catch (e) { return; }
let el = document.getElementById('telao');
if (!el) { el = document.createElement('div'); el.id = 'telao'; document.body.appendChild(el); }
const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const lados = (t.lados || []).map((l) =>
'<div class="lado"><span class="rot">' + esc(l.rotulo) + '</span><span class="sim">' + esc(l.simbolo) + '</span>' +
'<span class="nome">' + esc(l.nome) + '</span></div>').join('<div class="x">×</div>');
el.className = 'tom-' + (t.tom || 'neutro') + (t.piscar ? ' piscar' : '');
el.style.setProperty('--cor', 'rgb(' + CIANO.map(Math.round).join(',') + ')');
el.innerHTML = (lados ? '<div class="lados">' + lados + '</div>' : '') +
(t.titulo ? '<div class="titulo">' + esc(t.titulo) + '</div>' : '') + (t.sub ? '<div class="sub">' + esc(t.sub) + '</div>' : '');
el.hidden = false;
void el.offsetWidth; el.classList.add('entra'); // reinicia a animação a cada letreiro
if (r.dormindo) r.dormindo = false;
clearTimeout(telaoAte);
telaoAte = setTimeout(() => { el.hidden = true; }, (t.segundos || 2.5) * 1000);
}
let olhosAte = null;
function mostrarNosOlhos(texto) { // "✌️ 2.5": símbolo nos olhos; "- 0" apaga
const i = texto.lastIndexOf(' ');
const simbolo = i > 0 ? texto.slice(0, i) : texto, seg = i > 0 ? parseFloat(texto.slice(i + 1)) : 2.5;
clearTimeout(olhosAte);
if (r.dormindo) { r.dormindo = false; }
window.carinhaOlhosIcone = simbolo && simbolo !== '-' ? simbolo : null;
if (window.carinhaOlhosIcone) olhosAte = setTimeout(() => { window.carinhaOlhosIcone = null; }, (seg || 2.5) * 1000);
}
function hesitar(fn, gesto) { // às vezes pensa um instante antes de agir
if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn();
setTimeout(fn, 150 + Math.random() * 450);
}
function humanoTick(t) {
// acordou depois de dormir um tempo: fica grogue (olhos pesados, lento) e boceja
if (r.dormindo && !HUMANO.dormiaAntes) HUMANO.dormiuEm = t;
if (!r.dormindo && HUMANO.dormiaAntes && t - HUMANO.dormiuEm > 180) {
HUMANO.grogueIni = t; HUMANO.grogueAte = t + 30 + Math.random() * 20; HUMANO.bocejou = false;
}
HUMANO.dormiaAntes = r.dormindo;
if (!HUMANO.bocejou && t - HUMANO.grogueIni > 1.6 && !r.dormindo && !r.kf) { HUMANO.bocejou = true; animar('bocejar'); }
// dormindo (com a tela acesa): de vez em quando sonha com algo do dia
if (r.dormindo && !SONO.apagada && !window.carinhaSemServidor) {
if (!HUMANO.sonhoProx) HUMANO.sonhoProx = t + 40 + Math.random() * 60;
if (t >= HUMANO.sonhoProx) {
const dur = 6 + Math.random() * 4;
HUMANO.sonhoAte = t + dur; HUMANO.sonhoProx = t + dur + 45 + Math.random() * 90;
const obj = HUMANO.sonhos[Math.floor(Math.random() * Math.min(4, HUMANO.sonhos.length))];
if (window.MotorRosto && window.MotorRosto.sonho) window.MotorRosto.sonho(obj, dur);
}
} else HUMANO.sonhoProx = 0;
}
function grogue(t) { // 1 = acabou de acordar, 0 = esperto
return t < HUMANO.grogueAte ? Math.min(1, (HUMANO.grogueAte - t) / (HUMANO.grogueAte - HUMANO.grogueIni)) : 0;
}
function piscarHumano(t) { animar(grogue(t) > 0.2 || t < HUMANO.luz.sonolentoAte ? 'piscar2' : 'piscar'); }
function desvioDoOlhar(t) { // olha nos olhos 2 a 5 s, desvia um instante e volta
const ev = HUMANO.evita;
if (t >= ev.prox) {
const dura = Math.random() < 0.7 ? 0.6 + Math.random() * 0.7 : 0;
ev.ate = t + dura; ev.prox = ev.ate + 2 + Math.random() * 3;
ev.dx = (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 8); ev.dy = 3 + Math.random() * 5;
}
return t < ev.ate ? ev : null;
}
function contagio(t, agora) { // sorriu para ele (rastreamento do painel): sorri de volta
const S = HUMANO.sorriso;
if (t.tipo !== 'rosto' || typeof t.sorriso !== 'number' || r.dormindo || ATIV) { S.desde = 0; return; }
if (t.sorriso > 0.55) { if (!S.desde) S.desde = agora; } else S.desde = 0;
if (S.desde && agora - S.desde > 0.6 && agora - S.ultimo > 20 && !r.kf) {
S.ultimo = agora; S.desde = 0;
animar(t.sorriso > 0.8 ? 'rir' : 'feliz');
}
}
function luzAmbiente(lux) { // sensor de luz do celular (lux)
lux = Number(lux);
if (!Number.isFinite(lux) || lux < 0) return;
const L = HUMANO.luz, t = performance.now() / 1000, antes = L.media;
L.lux = lux;
L.media = antes === null ? lux : antes + (lux - antes) * 0.15;
if (antes === null || t - L.mudou < 8) return;
if (lux < 3 && antes > 25) { // apagaram a luz: fica com sono
L.mudou = t; L.sonolentoAte = t + 60; L.media = lux;
if (!r.dormindo && !r.kf) animar('bocejar');
} else if (lux > 80 && antes < 8) { // acenderam a luz de repente: aperta os olhos
L.mudou = t; L.ofuscadoAte = t + 1.8; L.media = lux;
if (r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) r.dormindo = false;
}
}
function repouso(t) { // pose calma: olha devagar, "respira" function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) { if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono"; r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
@@ -626,6 +767,7 @@ function repouso(t) { // pose calma: olha devagar, "respira
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4); r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0; r.dx = 0;
r.dy = 3.0 * Math.sin(t * 1.4); r.dy = 3.0 * Math.sin(t * 1.4);
if (t < HUMANO.sonhoAte) { r.dx = 3.2 * Math.sin(t * 6.5); r.boca = 'sorriso'; } // sonhando
return; return;
} }
r.forma = "aberto"; r.forma2 = null; r.forma = "aberto"; r.forma2 = null;
@@ -638,7 +780,7 @@ function repouso(t) { // pose calma: olha devagar, "respira
const longe = Math.random() < 0.25; const longe = Math.random() < 0.25;
OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7); OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3); OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
OLHAR.prox = t + 1.2 + Math.random() * 3; OLHAR.prox = t + (1.2 + Math.random() * 3) * (1 + 2 * grogue(t)); // grogue: olhar lento
} }
OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3; OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3;
OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3; OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
@@ -651,9 +793,23 @@ function repouso(t) { // pose calma: olha devagar, "respira
if (ATIV.forma) r.forma = ATIV.forma; if (ATIV.forma) r.forma = ATIV.forma;
if (ATIV.boca) r.boca = ATIV.boca; if (ATIV.boca) r.boca = ATIV.boca;
if (ATIV.ab) r.ab = ATIV.ab; if (ATIV.ab) r.ab = ATIV.ab;
const o = HUMANO.oops; // errou: susto e depois fica sem graça
if (o && t >= o.ini && t < o.fim) {
if (t - o.ini < 0.5) { r.forma = 'surpreso'; r.boca = 'aberta'; r.ab = 1.1; }
else { r.forma = 'feliz'; r.boca = 'sorriso'; r.cor = lerpCor(r.cor, ROSA, 0.5); r.dy += 6; }
}
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica) } else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
r.forma = "bravo"; r.forma = "bravo";
} }
const g = grogue(t);
if (g > 0) { // acabou de acordar: olhos pesados, cor apagada, olhar baixo
r.ab = Math.min(r.ab, 1 - 0.5 * g);
r.cor = lerpCor(r.cor, corEscura(0.7), 0.35 * g);
if (g > 0.5 && !ATIV) r.boca = 'sono';
r.dx *= 1 - 0.5 * g; r.dy = r.dy * (1 - 0.5 * g) + 3 * g;
}
if (t < HUMANO.luz.sonolentoAte) r.ab = Math.min(r.ab, 0.78);
if (t < HUMANO.luz.ofuscadoAte) { r.ab = Math.min(r.ab, 0.3); r.forma = 'feliz'; }
} }
function desenhar() { function desenhar() {
@@ -667,7 +823,9 @@ function desenhar() {
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo; const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV; const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV;
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0); const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy; const ev = seguindo && TRACK.alvo.tipo === 'rosto' ? desvioDoOlhar(tAgora) : null;
const dx = seguindo ? (ev ? MOV.olharX * 0.3 + ev.dx : MOV.olharX) : r.dx;
const dy = seguindo ? (ev ? MOV.olharY * 0.3 + ev.dy : MOV.olharY) : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]]; const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
const comMotor = ROSTO.motor && window.MotorRosto; const comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; }); OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
@@ -751,6 +909,9 @@ function contarAprendi(texto) {
function trata(ordem) { function trata(ordem) {
const bruta = (ordem || "").trim(); const bruta = (ordem || "").trim();
if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase
if (/^dizer /i.test(bruta)) { falarFrase(bruta.slice(6).trim()); return; }
if (/^olhos /i.test(bruta)) { mostrarNosOlhos(bruta.slice(6).trim()); return; } // jogos: ✊ ✋ ✌️ e números
if (/^telao /i.test(bruta)) { mostrarTelao(bruta.slice(6)); return; } // letreiro grande dos jogos
ordem = bruta.toLowerCase(); ordem = bruta.toLowerCase();
if (!ordem) return; if (!ordem) return;
if(ordem==='recarregar') { if(ordem==='recarregar') {
@@ -797,14 +958,16 @@ function trata(ordem) {
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); } if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
return; return;
} }
r.dormindo = false; animar("surpreso"); const longo = r.dormindo && performance.now() / 1000 - HUMANO.dormiuEm > 180;
r.dormindo = false;
if (!longo) animar("surpreso"); // sono longo: acorda devagar, grogue (humanoTick)
} else if (ordem === "aleatorio") { } else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) { } else if (SEQS[ordem]) {
if (window.MotorRosto) { if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 }); if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
} }
animar(ordem); hesitar(() => animar(ordem), ordem);
} else if (ordem === "calma") { } else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro. // E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false; r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
@@ -1121,6 +1284,7 @@ function rastrear(t) {
(r.dormindo && !configRecebida?.cameraSono)) return; (r.dormindo && !configRecebida?.cameraSono)) return;
const agora=performance.now()/1000; const agora=performance.now()/1000;
TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y; TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y;
contagio(t, agora);
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65; MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65; MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
if(t.atividade===true) { if(t.atividade===true) {
@@ -1306,6 +1470,7 @@ const AUDIO = {
}; };
if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ
if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
if (window.eilik) window.eilik.luz = luzAmbiente; // sensor de luz do celular (o app chama)
let configRecebida = null; let configRecebida = null;
function configurarApp(c) { function configurarApp(c) {
@@ -1353,6 +1518,7 @@ async function sincronizarApp() {
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho, aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
luz: HUMANO.luz.lux,
}) }); }) });
} }
} catch (e) { /* servidor ausente: animação continua */ } } catch (e) { /* servidor ausente: animação continua */ }
@@ -1448,6 +1614,7 @@ function laco(t) {
checarSilencio(t / 1000); checarSilencio(t / 1000);
atualizarFeicao(t / 1000); atualizarFeicao(t / 1000);
humanoTick(t / 1000);
ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null; 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); if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
@@ -1461,7 +1628,7 @@ function laco(t) {
if (r.dormindo) { if (r.dormindo) {
proxPiscar = proxGesto = t + 3600000; proxPiscar = proxGesto = t + 3600000;
} else if (t >= proxPiscar) { } else if (t >= proxPiscar) {
animar("piscar"); piscarHumano(t / 1000);
proxPiscar = t + 1600 + Math.random() * 3500; proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto && !ATIV) { } else if (AUTO && t >= proxGesto && !ATIV) {
animar(gestoSolto()); animar(gestoSolto());
+3
View File
@@ -39,6 +39,7 @@ class Cerebro:
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
except (OSError,ValueError,KeyError,TypeError):pass except (OSError,ValueError,KeyError,TypeError):pass
threading.Thread(target=self.loop,daemon=True).start() threading.Thread(target=self.loop,daemon=True).start()
console = staticmethod(lambda origem, texto, motivo='', nivel='info', agora=None: None) # o main troca pelo console do painel
def cabecalhos(self): def cabecalhos(self):
h={'Content-Type':'application/json'} h={'Content-Type':'application/json'}
if self.chave:h['Authorization']='Bearer '+self.chave if self.chave:h['Authorization']='Bearer '+self.chave
@@ -100,8 +101,10 @@ class Cerebro:
with self.controle.lock: with self.controle.lock:
if time.monotonic()-self.controle.status_at<5 and not self.controle.estado.get('dormindo') and not self.controle.estado.get('atividade'): if time.monotonic()-self.controle.status_at<5 and not self.controle.estado.get('dormindo') and not self.controle.estado.get('atividade'):
self.enviar(result['gesto']) self.enviar(result['gesto'])
self.console('ia','IA local escolheu: '+result['gesto'],'evento "%s", respondeu em %.1f s'%(event,result['segundos']))
except Exception as e: except Exception as e:
with self.lock:self.error=str(e)[:200] with self.lock:self.error=str(e)[:200]
self.console('ia','IA local não respondeu',str(e)[:200],'detalhe')
finally: finally:
self.last=time.monotonic() self.last=time.monotonic()
with self.lock:self.busy=False with self.lock:self.busy=False
+50
View File
@@ -0,0 +1,50 @@
"""Console: tudo o que o Carinha está fazendo e por quê, ao vivo.
Cada parte do servidor registra o que fez e o motivo (modo vida, sentidos, IA local, aprendizado,
ideias do dia, celular). As linhas ficam na memória (as últimas MAX_LINHAS) e vão para o painel pelo
SSE ('console'). Nível 'detalhe' é o que se repete muito (humor reenviado, disparo solto do sensor):
o painel esconde por padrão.
"""
import json
import threading
import time
from collections import deque
MAX_LINHAS = 400
ORIGENS = ('vida', 'sentidos', 'celular', 'ia', 'aprendizado', 'ideias', 'sistema')
class Console:
def __init__(self):
self.lock = threading.Lock()
self.linhas = deque(maxlen=MAX_LINHAS)
self.seq = 0
self.publicar = lambda tipo, dados: None
def registrar(self, origem, texto, motivo='', nivel='info', agora=None):
if origem not in ORIGENS:
origem = 'sistema'
with self.lock:
self.seq += 1
linha = dict(id=self.seq, horario=agora or time.time(), origem=origem, texto=str(texto)[:240],
motivo=str(motivo or '')[:300], nivel='detalhe' if nivel == 'detalhe' else 'info')
self.linhas.append(linha)
try:
self.publicar('console', linha)
except Exception:
pass
return linha
def status(self, desde=0):
with self.lock:
return dict(linhas=[l for l in self.linhas if l['id'] > desde], seq=self.seq, origens=list(ORIGENS))
def rotas(self, h, metodo):
rota = h.path.split('?', 1)[0]
if rota != '/console':
return False
if metodo != 'GET':
h.enviar(json.dumps(dict(erro='Método inválido')), codigo=400)
return True
h.enviar(json.dumps(self.status(), ensure_ascii=False))
return True
+19
View File
@@ -105,6 +105,8 @@ class ControlePC:
self.rtc.encerrar() self.rtc.encerrar()
return novo.copy() return novo.copy()
console = staticmethod(lambda origem, texto, motivo='', nivel='info', agora=None: None) # o main troca pelo console do painel
def resumo(self, host=''): def resumo(self, host=''):
"""Estado do celular para o painel (GET /app/status, /estado e evento 'telemetria').""" """Estado do celular para o painel (GET /app/status, /estado e evento 'telemetria')."""
with self.lock: with self.lock:
@@ -160,12 +162,14 @@ class ControlePC:
if not isinstance(dados, dict): if not isinstance(dados, dict):
raise ValueError('Estado deve ser objeto JSON') raise ValueError('Estado deve ser objeto JSON')
with self.lock: with self.lock:
antes = self.estado
self.estado = dados self.estado = dados
self.celular_ip = ip_informado(dados) or h.client_address[0] self.celular_ip = ip_informado(dados) or h.client_address[0]
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]: if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
self.recarga["confirmada"] = True self.recarga["confirmada"] = True
self.status_at = time.monotonic() self.status_at = time.monotonic()
self.publicar('telemetria', self.resumo(h.headers.get("Host", "").split(":")[0])) self.publicar('telemetria', self.resumo(h.headers.get("Host", "").split(":")[0]))
self.mudancas(antes, dados)
enviar({'ok': True}) enviar({'ok': True})
else: else:
enviar(self.resumo(h.headers.get("Host", "").split(":")[0])) enviar(self.resumo(h.headers.get("Host", "").split(":")[0]))
@@ -176,6 +180,21 @@ class ControlePC:
enviar({'erro': str(e)}, 503) enviar({'erro': str(e)}, 503)
return True return True
def mudancas(self, antes, agora):
"""O que mudou no celular desde o último status: vai para o console do painel."""
if not antes:
self.console('celular', 'Celular conectado', 'rosto %s' % agora.get('rosto', '?'))
return
if bool(antes.get('dormindo')) != bool(agora.get('dormindo')):
self.console('celular', 'O rosto dormiu' if agora.get('dormindo') else 'O rosto acordou')
if antes.get('atividade') != agora.get('atividade'):
if agora.get('atividade'):
self.console('celular', 'Começou a atividade: %s' % agora['atividade'])
else:
self.console('celular', 'Terminou a atividade: %s' % antes['atividade'])
if bool(antes.get('tela_apagada')) != bool(agora.get('tela_apagada')):
self.console('celular', 'Apagou a tela (dormindo há um tempo)' if agora.get('tela_apagada') else 'Acendeu a tela')
def registrar_recusa(self, rota, motivo): def registrar_recusa(self, rota, motivo):
"""Pedido do celular recusado: aparece no journal (no máximo 1 linha por minuto).""" """Pedido do celular recusado: aparece no journal (no máximo 1 linha por minuto)."""
agora = time.monotonic() agora = time.monotonic()
+3 -1
View File
@@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai). # chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
$arquivos = @( $arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py', 'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', 'aprendizado.py', 'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', 'console.py', 'reconhecimento.py', 'aprendizado.py',
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json', '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' 'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
) )
@@ -90,6 +90,8 @@ tar -xzf '$remoto' -C '$Destino'
rm -f '$remoto' rm -f '$remoto'
systemctl restart carinha-pc systemctl restart carinha-pc
echo "backup em `$backup" echo "backup em `$backup"
# guarda só os 5 backups de deploy mais recentes (cada um tem ~70 MB por causa do APK)
(ls -d backup-2[0-9]*-[0-9]* | sort | head -n -5 | xargs -r rm -rf) 2>/dev/null || true
"@ "@
Write-Host '== Instalando na placa' Write-Host '== Instalando na placa'
# O PowerShell 5.1 põe BOM no stdin do ssh; sem tirar, o bash lê "<BOM>set -e" e não para no primeiro erro. # 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.
+61 -7
View File
@@ -21,7 +21,11 @@ from comum import gravar_atomico, ler_json
TIPOS = ('pir', 'movimento', 'som') TIPOS = ('pir', 'movimento', 'som')
PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15, dias=3, 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 presenca=True, ausencia=5, # modo presença: o PIR liga a câmera; sem ninguém, ela desliga
sensibilidade_pir='media')
# O HC-SR505 não tem ajuste de sensibilidade e segura ~8 s a cada disparo. Para separar alguém de
# verdade de um disparo à toa: (disparos que bastam, dentro de quantos s, ou ligado direto por quantos s).
REGRAS_PIR = {'alta': (1, 0, 0), 'media': (2, 60, 12), 'baixa': (3, 90, 20)}
MAX_EVENTOS, MAX_FOTOS = 2000, 500 # teto de segurança; quem manda é a retenção em dias 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 VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto
@@ -36,6 +40,7 @@ class Historico:
self.lock = threading.RLock() self.lock = threading.RLock()
self.publicar = lambda tipo, dados: None self.publicar = lambda tipo, dados: None
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
self.ao_foto = lambda ident, caminho: None # foto nova de um evento (reconhecimento de pessoas)
# Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia). # Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia).
self.ao_presenca = lambda: None self.ao_presenca = lambda: None
self.ao_ausencia = lambda: None self.ao_ausencia = lambda: None
@@ -51,6 +56,9 @@ class Historico:
self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None, 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) ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma)
self.processo_pir = None self.processo_pir = None
self.subidas_pir = [] # disparos ainda não confirmados (horários)
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None
self.subiu_em = None # quando o pino subiu da última vez (ligado direto desde então)
with self.db() as db: 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)') 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() self.limpar_antigos()
@@ -82,6 +90,9 @@ class Historico:
elif chave == 'dias': elif chave == 'dias':
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30: if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30:
raise ValueError('Guardar de 1 a 30 dias') raise ValueError('Guardar de 1 a 30 dias')
elif chave == 'sensibilidade_pir':
if valor not in REGRAS_PIR:
raise ValueError('Sensibilidade do sensor: alta, media ou baixa')
elif chave == 'intervalo': elif chave == 'intervalo':
if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600: if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600:
raise ValueError('Intervalo entre 0 e 600 s') raise ValueError('Intervalo entre 0 e 600 s')
@@ -134,21 +145,54 @@ class Historico:
self.publicar('pir', dict(ultimo=self.pir['ultimo'], ativo=self.pir['ativo'], presenca=dict(self.presenca))) 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): 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.""" """Mudança no pino do PIR. A bolinha acompanha o pino; presença (evento, foto, acordar) só
quando o disparo se confirma pela sensibilidade escolhida."""
agora = agora if agora is not None else time.time() agora = agora if agora is not None else time.time()
self.pir['ativo'] = alto self.pir['ativo'] = alto
if alto: if alto:
self.pir['ultimo'] = agora self.pir['ultimo'] = agora
self.marcar_presenca(agora)
self.avisar_pir() self.avisar_pir()
if alto and registrar: if not alto:
self.registrar('pir', 'presença') self.checar_pir(agora) # ligado direto até agora pode já ter confirmado
self.subiu_em = None
return
if not registrar: # pino assentando ao ligar: só a bolinha
return
quantos, janela, _ = REGRAS_PIR[self.config['sensibilidade_pir']]
with self.lock:
self.subidas_pir = [t for t in self.subidas_pir if agora - t < janela] + [agora]
self.subiu_em = agora
confirmou = len(self.subidas_pir) >= quantos
if confirmou:
self.confirmar_pir(agora)
else:
self.console('sentidos', 'Disparo solto do sensor de presença', 'sensibilidade %s: ainda não confirmou (%d de %d)' % (
self.config['sensibilidade_pir'], len(self.subidas_pir), quantos), 'detalhe', agora)
def checar_pir(self, agora=None):
"""Ligado direto por bastante tempo = alguém se mexendo na frente (o sensor foi redisparando)."""
agora = agora if agora is not None else time.time()
_, _, segurar = REGRAS_PIR[self.config['sensibilidade_pir']]
with self.lock:
pronto = segurar and self.subiu_em is not None and self.subidas_pir and agora - self.subiu_em >= segurar
if pronto:
self.confirmar_pir(agora)
def confirmar_pir(self, agora):
with self.lock:
self.subidas_pir = []
self.marcar_presenca(agora)
self.registrar('pir', 'presença', agora=agora)
def vigiar_ausencia(self): def vigiar_ausencia(self):
passo = 0
while True: while True:
time.sleep(10) time.sleep(1)
passo += 1
try: try:
self.checar_ausencia() self.checar_pir()
if passo % 10 == 0:
self.checar_ausencia()
except Exception: except Exception:
pass pass
@@ -255,6 +299,16 @@ class Historico:
self.apagar_arquivo(foto) self.apagar_arquivo(foto)
db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,)) db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,))
self.publicar('historico', dict(foto=ident)) self.publicar('historico', dict(foto=ident))
try:
self.ao_foto(ident, self.fotos / nome)
except Exception:
pass
def fotos_com_evento(self):
"""[(id do evento, arquivo da foto)] das fotos guardadas, mais novas primeiro."""
with self.db() as db:
linhas = db.execute('SELECT id, foto FROM eventos WHERE foto IS NOT NULL ORDER BY id DESC').fetchall()
return [(i, self.fotos / f) for i, f in linhas if (self.fotos / f).is_file()]
# ------------------------------------------------------------------ PIR # ------------------------------------------------------------------ PIR
def vigiar_pir(self): def vigiar_pir(self):
+4
View File
@@ -90,6 +90,7 @@ class Inspiracao:
self.madrugada = lambda agora: False self.madrugada = lambda agora: False
self.pode_gemini = lambda: False self.pode_gemini = lambda: False
self.baixar = _baixar # trocado nos testes self.baixar = _baixar # trocado nos testes
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None
self.ocupado = False self.ocupado = False
self.config = json.loads(json.dumps(PADRAO)) self.config = json.loads(json.dumps(PADRAO))
self.hoje = self._dia_vazio('') self.hoje = self._dia_vazio('')
@@ -326,11 +327,13 @@ class Inspiracao:
item = self.criacao.item(ident) item = self.criacao.item(ident)
if item: if item:
self.anotar('ideia', 'Teve uma ideia nova: %s %s (%s)' % (item['emoji'], item['nome'], item['motivo']), agora) self.anotar('ideia', 'Teve uma ideia nova: %s %s (%s)' % (item['emoji'], item['nome'], item['motivo']), agora)
self.console('ideias', 'Criou a atividade %s %s' % (item['emoji'], item['nome']), item['motivo'], agora=agora)
try: try:
self.criacao.avisar() # a grade do estúdio mostra as novidades self.criacao.avisar() # a grade do estúdio mostra as novidades
except Exception: except Exception:
pass pass
except Exception as e: except Exception as e:
self.console('ideias', 'Não conseguiu criar as ideias do dia', str(e)[:200], agora=agora)
with self.lock: with self.lock:
self.hoje['erro'] = str(e)[:200] self.hoje['erro'] = str(e)[:200]
if not manual: if not manual:
@@ -404,6 +407,7 @@ class Inspiracao:
chave = str(item['id']) chave = str(item['id'])
self.hoje['contadas'][chave] = self.hoje['contadas'].get(chave, 0) + 1 self.hoje['contadas'][chave] = self.hoje['contadas'].get(chave, 0) + 1
self.anotar('aprendi', 'Contou o que aprendeu: ' + item['aprendi'], agora) self.anotar('aprendi', 'Contou o que aprendeu: ' + item['aprendi'], agora)
self.console('ideias', 'Contou o que aprendeu', item['aprendi'], agora=agora)
self.salvar() self.salvar()
return dict(ok=True) return dict(ok=True)
+92 -3
View File
@@ -45,6 +45,8 @@ from vida import Vida
from criacao import Criacao from criacao import Criacao
from inspiracao import Inspiracao from inspiracao import Inspiracao
from aprendizado import Aprendizado from aprendizado import Aprendizado
from console import Console
from reconhecimento import Reconhecimento
TRAVA = threading.Lock() TRAVA = threading.Lock()
@@ -64,6 +66,8 @@ ATIVIDADES = ["ler", "cantar", "bola", "videogame", "pintar", "malhar", "dormir"
"violao", "dancar", "corda", "meditar", "telescopio", "pipa", "bolhas", "skate", "computador", "pescar"] "violao", "dancar", "corda", "meditar", "telescopio", "pipa", "bolhas", "skate", "computador", "pescar"]
# Ordens internas que o rosto entende mas não viram botão. # Ordens internas que o rosto entende mas não viram botão.
ORDENS_INTERNAS = {"calma"} ORDENS_INTERNAS = {"calma"}
# O que os jogos podem mostrar nos olhos (pedra, papel, tesoura, números...)
OLHOS_JOGO = {"✊", "✋", "✌️", "❓", "⭐", "❤️", "👀", "🙈"} | {str(n) for n in range(11)}
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num # Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
# arquivo ao lado do servidor, para sobreviver a reinicios. # arquivo ao lado do servidor, para sobreviver a reinicios.
@@ -73,12 +77,13 @@ ARQUIVO_APARENCIA = "aparencia.json"
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel. # Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js", "/voz-robo.js", 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/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task",
"/vendor/mediapipe/expressao.task",
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm", "/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"} "/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. # 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"} PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/", PREFIXOS_API = ("/eventos", "/console", "/pessoas", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -122,10 +127,26 @@ EVENTOS = Eventos()
ESTOP = {"ativo": False, "desde": None} ESTOP = {"ativo": False, "desde": None}
CONSOLE = Console()
# ordens que se repetem (o modo vida reenvia o humor a cada minuto): no console, só em "detalhes"
ORDENS_DETALHE = ("humor ", "ritmo ", "zanga ", "sono ")
def enfileirar(ordem): def enfileirar(ordem):
"""Caminho único de uma ordem para o rosto: fila do celular + evento para os painéis.""" """Caminho único de uma ordem para o rosto: fila do celular + evento para os painéis."""
FILA.adicionar(ordem) FILA.adicionar(ordem)
EVENTOS.publicar("expressao", {"ordem": ordem}) EVENTOS.publicar("expressao", {"ordem": ordem})
if ordem.startswith("dizer "):
CONSOLE.registrar("celular", "Falou: " + ordem[6:])
elif ordem.startswith(("atividade_criada ", "expressao_criada ")):
try:
nome = json.loads(ordem.split(" ", 1)[1]).get("nome", "?")
except ValueError:
nome = "?"
CONSOLE.registrar("celular", "Mandou ao rosto a criação: " + nome)
elif not ordem.startswith("aprendi "):
CONSOLE.registrar("celular", "Mandou ao rosto: " + ordem,
nivel="detalhe" if ordem.startswith(ORDENS_DETALHE) else "info")
def carregar_aparencia(): def carregar_aparencia():
@@ -204,9 +225,53 @@ def ordem_de_comando(tipo, valor):
if nome not in ROSTOS: if nome not in ROSTOS:
raise ValueError("Rosto desconhecido: " + nome) raise ValueError("Rosto desconhecido: " + nome)
return "rosto " + nome return "rosto " + nome
if tipo == "dizer": # fala curta com legenda (jogos, painel)
texto = " ".join(str(valor or "").split())[:120]
if not texto:
raise ValueError("Diga alguma coisa")
return "dizer " + texto
if tipo == "olhos": # símbolo nos olhos por alguns segundos
valor = valor if isinstance(valor, dict) else {}
simbolo = str(valor.get("simbolo") or "")
if simbolo and simbolo not in OLHOS_JOGO:
raise ValueError("Símbolo inválido para os olhos")
try:
segundos = max(0.5, min(8.0, float(valor.get("segundos", 2.5))))
except (TypeError, ValueError):
raise ValueError("Segundos inválidos") from None
return "olhos %s %.1f" % (simbolo or "-", segundos)
if tipo == "telao": # letreiro grande por cima do rosto (jogos)
return "telao " + json.dumps(validar_telao(valor), ensure_ascii=False, separators=(",", ":"))
raise ValueError("Tipo de comando inválido") raise ValueError("Tipo de comando inválido")
TONS_TELAO = ("neutro", "eu", "voce", "empate", "conta")
def validar_telao(v):
"""{titulo, sub, tom, piscar, segundos, lados:[{rotulo, simbolo, nome}]} só com texto curto e símbolos permitidos."""
if not isinstance(v, dict):
raise ValueError("Telão inválido")
texto = lambda x, n: " ".join(str(x or "").split())[:n]
t = dict(titulo=texto(v.get("titulo"), 24), sub=texto(v.get("sub"), 80),
tom=v.get("tom") if v.get("tom") in TONS_TELAO else "neutro", piscar=v.get("piscar") is True)
try:
t["segundos"] = max(0.3, min(10.0, float(v.get("segundos", 2.5))))
except (TypeError, ValueError):
raise ValueError("Segundos inválidos") from None
lados = v.get("lados") or []
if not isinstance(lados, list) or len(lados) > 2:
raise ValueError("Lados inválidos")
t["lados"] = []
for l in lados:
if not isinstance(l, dict) or (l.get("simbolo") and l.get("simbolo") not in OLHOS_JOGO):
raise ValueError("Símbolo inválido no telão")
t["lados"].append(dict(rotulo=texto(l.get("rotulo"), 12), simbolo=l.get("simbolo") or "", nome=texto(l.get("nome"), 14)))
if not t["titulo"] and not t["lados"]:
raise ValueError("Telão vazio")
return t
class Handler(SimpleHTTPRequestHandler): class Handler(SimpleHTTPRequestHandler):
extensoes_map = {".html": "text/html; charset=utf-8", extensoes_map = {".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8", ".js": "text/javascript; charset=utf-8",
@@ -264,7 +329,7 @@ class Handler(SimpleHTTPRequestHandler):
def modulos(self): def modulos(self):
s = self.server s = self.server
return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "aprendizado", "inspiracao", "criacao", "treinamento", "conversa", "cerebro", return [getattr(s, nome) for nome in ("console", "reconhecimento", "api_config", "historico", "vida", "aprendizado", "inspiracao", "criacao", "treinamento", "conversa", "cerebro",
"braco", "servos", "controle") if hasattr(s, nome)] "braco", "servos", "controle") if hasattr(s, nome)]
def estado(self): def estado(self):
@@ -486,7 +551,8 @@ class Servidor(ThreadingHTTPServer):
def ligar_eventos(servidor): def ligar_eventos(servidor):
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao", "aprendizado"): for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao", "aprendizado",
"console", "reconhecimento"):
if hasattr(servidor, nome): if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar getattr(servidor, nome).publicar = EVENTOS.publicar
@@ -560,11 +626,34 @@ def main():
def nome_criada(ident): def nome_criada(ident):
return next((a['nome'] for a in servidor.criacao.atividades() if a['id'] == ident), None) return next((a['nome'] for a in servidor.criacao.atividades() if a['id'] == ident), None)
servidor.aprendizado = aprendizado = Aprendizado(Path.cwd() / 'aprendizado.json', nome_criada) servidor.aprendizado = aprendizado = Aprendizado(Path.cwd() / 'aprendizado.json', nome_criada)
# Console: tudo o que ele faz e por quê (cada parte registra o motivo da decisão)
servidor.console = CONSOLE
# Reconhecimento de pessoas: as fotos dos eventos dizem quem chegou (tudo na placa)
servidor.reconhecimento = rec = Reconhecimento(Path.cwd())
servidor.historico.ao_foto = rec.nova_foto
rec.fotos_guardadas = servidor.historico.fotos_com_evento
rec.ao_reconhecer = vida.reconhecido
vida.reconhece = lambda: rec.config['ligado'] and rec.visao.pronta() and bool(rec.amostras())
vida.gostou_de = rec.gostou
for parte in (vida, aprendizado, inspiracao, servidor.cerebro, servidor.historico, servidor.controle, rec):
parte.console = CONSOLE.registrar
vida.dia_anterior = lambda: aprendizado.historico[-1] if aprendizado.historico else None
CONSOLE.registrar("sistema", "Servidor iniciado", "versão " + versao())
aprendizado.anotar = lambda tipo, texto, agora=None: (vida.anotar(tipo, texto, agora), vida.avisar()) aprendizado.anotar = lambda tipo, texto, agora=None: (vida.anotar(tipo, texto, agora), vida.avisar())
vida.aprendizado = aprendizado vida.aprendizado = aprendizado
# a análise da semana usa a mesma IA do estúdio (Gemini, IA local ou o S10) # a análise da semana usa a mesma IA do estúdio (Gemini, IA local ou o S10)
aprendizado.ia = servidor.criacao.chamar aprendizado.ia = servidor.criacao.chamar
aprendizado.pode_ia = lambda provedor: bool(servidor.criacao.provedores().get(provedor, {}).get('pronto')) aprendizado.pode_ia = lambda provedor: bool(servidor.criacao.provedores().get(provedor, {}).get('pronto'))
# a rotina da casa pode mexer no horário da madrugada (o mesmo dos Ajustes do celular)
def noite_atual():
c = servidor.controle.config
return (int(c.get('noiteInicio', 23)), int(c.get('noiteFim', 7))) if c.get('noiteSilenciosa', True) else None
def mudar_noite(ini, fim):
servidor.controle.publicar('app_config', servidor.controle.atualizar({'noiteInicio': ini, 'noiteFim': fim}))
aprendizado.noite = noite_atual
aprendizado.mudar_noite = mudar_noite
# E-Stop parado, ou modo presença com ninguém em casa (dormindo em silêncio): não faz nada # 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" vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente"
+2
View File
@@ -36,6 +36,8 @@ def main():
'LICENSE':'https://raw.githubusercontent.com/google-ai-edge/mediapipe/master/LICENSE', 'LICENSE':'https://raw.githubusercontent.com/google-ai-edge/mediapipe/master/LICENSE',
'face.tflite':'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite', 'face.tflite':'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite',
'hands.task':'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task', 'hands.task':'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task',
# expressão do rosto (sorriso) para o contágio emocional
'expressao.task':'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task',
}.items(): }.items():
data = download(url); (dest/name).write_bytes(data) data = download(url); (dest/name).write_bytes(data)
files[name] = {'url':url, 'sha256':hashlib.sha256(data).hexdigest()} files[name] = {'url':url, 'sha256':hashlib.sha256(data).hexdigest()}
+18 -1
View File
@@ -1,5 +1,7 @@
'use strict'; 'use strict';
let face = null, hands = null, vision = null; let face = null, hands = null, vision = null;
// expressão (sorriso) para o contágio emocional: modelo opcional, roda 1 quadro a cada 4
let expressao = null, semExpressao = false, quadro = 0, sorriso = null;
let lastTime = 0; let lastTime = 0;
async function prepare(c) { async function prepare(c) {
if (!vision) { if (!vision) {
@@ -9,6 +11,13 @@ async function prepare(c) {
} }
if (c.rosto && !face) face = await vision.module.FaceDetector.createFromOptions(vision.files, { if (c.rosto && !face) face = await vision.module.FaceDetector.createFromOptions(vision.files, {
baseOptions:{modelAssetPath:'/vendor/mediapipe/face.tflite',delegate:'CPU'},runningMode:'VIDEO',minDetectionConfidence:.6}); baseOptions:{modelAssetPath:'/vendor/mediapipe/face.tflite',delegate:'CPU'},runningMode:'VIDEO',minDetectionConfidence:.6});
if (c.rosto && !expressao && !semExpressao) {
try {
expressao = await vision.module.FaceLandmarker.createFromOptions(vision.files, {
baseOptions:{modelAssetPath:'/vendor/mediapipe/expressao.task',delegate:'CPU'},runningMode:'VIDEO',numFaces:1,
outputFaceBlendshapes:true});
} catch (e) { semExpressao = true; } // sem o modelo: segue só com a posição do rosto
}
if (c.maos && !hands) hands = await vision.module.HandLandmarker.createFromOptions(vision.files, { if (c.maos && !hands) hands = await vision.module.HandLandmarker.createFromOptions(vision.files, {
baseOptions:{modelAssetPath:'/vendor/mediapipe/hands.task',delegate:'CPU'},runningMode:'VIDEO',numHands:2, baseOptions:{modelAssetPath:'/vendor/mediapipe/hands.task',delegate:'CPU'},runningMode:'VIDEO',numHands:2,
minHandDetectionConfidence:.6,minHandPresenceConfidence:.6,minTrackingConfidence:.5}); minHandDetectionConfidence:.6,minHandPresenceConfidence:.6,minTrackingConfidence:.5});
@@ -25,9 +34,17 @@ self.onmessage = async ({data}) => {
w:d.boundingBox.width/bitmap.width,h:d.boundingBox.height/bitmap.height, w:d.boundingBox.width/bitmap.width,h:d.boundingBox.height/bitmap.height,
score:d.categories[0]?.score || 0,pontos:d.keypoints.map(p=>({x:p.x,y:p.y})) score:d.categories[0]?.score || 0,pontos:d.keypoints.map(p=>({x:p.x,y:p.y}))
})) : []; })) : [];
if (!faces.length) sorriso = null;
else if (expressao && quadro++ % 4 === 0) {
const b = (expressao.detectForVideo(bitmap,stamp).faceBlendshapes || [])[0];
if (b) {
const s = {}; for (const c of b.categories) s[c.categoryName] = c.score;
sorriso = ((s.mouthSmileLeft || 0) + (s.mouthSmileRight || 0)) / 2;
}
}
const result = data.config.maos ? hands.detectForVideo(bitmap,stamp) : {landmarks:[]}; const result = data.config.maos ? hands.detectForVideo(bitmap,stamp) : {landmarks:[]};
self.postMessage({tipo:'resultado',faces,hands:result.landmarks.map(h=>h.map(p=>({x:p.x,y:p.y}))), self.postMessage({tipo:'resultado',faces,hands:result.landmarks.map(h=>h.map(p=>({x:p.x,y:p.y}))),
ms:performance.now()-began,timestamp:data.timestamp}); sorriso,ms:performance.now()-began,timestamp:data.timestamp});
} catch(e) { self.postMessage({tipo:'erro',erro:e.message || String(e)}); } } catch(e) { self.postMessage({tipo:'erro',erro:e.message || String(e)}); }
finally { if (bitmap) bitmap.close(); } finally { if (bitmap) bitmap.close(); }
}; };
+406
View File
@@ -0,0 +1,406 @@
"""Reconhecimento de pessoas: quem é quem, só na placa.
Cada foto de evento (o celular manda uma quando o sensor vê alguém) passa pelo YuNet, que acha os
rostos, e pelo SFace, que vira cada rosto numa "impressão" de 128 números. A impressão é comparada
com as das pessoas cadastradas (semelhança de cosseno); acima do limiar, é aquela pessoa. Rostos sem
nome ficam numa lista para alguém marcar no painel ("esta é a Ana"), e cada marcação vira amostra.
Privacidade: nada sai da placa. Fotos, recortes e impressões ficam em /opt/carinha-pc; apagar uma
pessoa apaga as amostras e os recortes dela; rostos sem nome somem depois de DIAS_SEM_NOME dias.
"""
import json
import os
import queue
import sqlite3
import threading
import time
from contextlib import contextmanager
from pathlib import Path
from comum import gravar_atomico, ler_json
LIMIAR = 0.40 # cosseno mínimo para dizer "é essa pessoa" (o SFace recomenda 0,363)
LIMIAR_GRUPO = 0.45 # rostos sem nome tão parecidos assim vão para o mesmo grupo (marcar de uma vez)
SCORE_ROSTO = 0.80 # confiança mínima do detector para considerar um rosto
TAMANHO_MIN = 48 # px: rosto menor que isso é longe demais para reconhecer bem
DIAS_SEM_NOME = 7 # rostos sem nome guardados para marcar no painel
MAX_AMOSTRAS = 30 # por pessoa (as mais novas)
PADRAO = dict(ligado=True)
MODELOS = ('face_detection_yunet_2023mar.onnx', 'face_recognition_sface_2021dec.onnx')
def cosseno(a, b):
na = sum(x * x for x in a) ** 0.5
nb = sum(x * x for x in b) ** 0.5
if not na or not nb:
return 0.0
return sum(x * y for x, y in zip(a, b)) / (na * nb)
class Visao:
"""YuNet + SFace (OpenCV). Carrega na primeira foto; sem OpenCV ou modelo, fica indisponível."""
def __init__(self, pasta_modelos):
self.pasta = Path(pasta_modelos)
self.det = self.rec = self.cv2 = self.np = None
self.motivo = ''
def pronta(self):
if self.rec:
return True
try:
import cv2
import numpy as np
except ImportError:
self.motivo = 'OpenCV não instalado na placa (apt install python3-opencv)'
return False
faltando = [m for m in MODELOS if not (self.pasta / m).exists()]
if faltando:
self.motivo = 'Faltam os modelos em %s: %s' % (self.pasta, ', '.join(faltando))
return False
self.cv2, self.np = cv2, np
self.det = cv2.FaceDetectorYN.create(str(self.pasta / MODELOS[0]), '', (320, 320), SCORE_ROSTO, 0.3, 5000)
self.rec = cv2.FaceRecognizerSF.create(str(self.pasta / MODELOS[1]), '')
self.motivo = ''
return True
def rostos(self, jpeg):
"""bytes JPEG -> [(vetor[128], recorte_jpeg, score, largura)]"""
cv2, np = self.cv2, self.np
if not jpeg: # foto que não chegou inteira
return []
img = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR)
if img is None:
return []
h, w = img.shape[:2]
escala = min(1.0, 640 / max(h, w)) # a placa é pequena: analisa em até 640 px
if escala < 1:
img = cv2.resize(img, (int(w * escala), int(h * escala)))
h, w = img.shape[:2]
self.det.setInputSize((w, h))
_, achados = self.det.detect(img)
saida = []
for f in (achados if achados is not None else []):
if f[14] < SCORE_ROSTO or min(f[2], f[3]) < TAMANHO_MIN * escala:
continue
alinhado = self.rec.alignCrop(img, f)
vetor = self.rec.feature(alinhado).flatten().tolist()
ok, recorte = cv2.imencode('.jpg', alinhado, [cv2.IMWRITE_JPEG_QUALITY, 85])
saida.append((vetor, recorte.tobytes() if ok else b'', float(f[14]), float(f[2])))
return saida
class Reconhecimento:
def __init__(self, pasta, visao=None, iniciar=True):
self.pasta = Path(pasta)
self.recortes = self.pasta / 'fotos' / 'rostos'
self.recortes.mkdir(parents=True, exist_ok=True)
self.caminho_db = str(self.pasta / 'pessoas.db')
self.arquivo_config = self.pasta / 'pessoas.json'
self.visao = visao or Visao(self.pasta / 'modelos')
self.lock = threading.RLock()
self.fila = queue.Queue(maxsize=500)
self.publicar = lambda tipo, dados: None
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None
self.ao_reconhecer = lambda pessoa, confianca, agora: None # o modo vida cumprimenta pelo nome
self.varrendo = None # {feitas, total} ao procurar nas fotos guardadas
self.config = dict(PADRAO)
try:
self.config.update({k: v for k, v in json.loads(self.arquivo_config.read_text(encoding='utf-8')).items()
if k in PADRAO and isinstance(v, bool)})
except (OSError, ValueError, AttributeError):
pass
with self.db() as db:
db.execute('CREATE TABLE IF NOT EXISTS pessoas(id INTEGER PRIMARY KEY, nome TEXT UNIQUE, criado REAL, '
'visto REAL, vezes INTEGER DEFAULT 0, afinidade REAL DEFAULT 0)')
db.execute('CREATE TABLE IF NOT EXISTS rostos(id INTEGER PRIMARY KEY, horario REAL, evento INTEGER, '
'pessoa INTEGER, amostra INTEGER DEFAULT 0, confianca REAL, score REAL, vetor TEXT, recorte TEXT)')
if iniciar:
threading.Thread(target=self.trabalhar, daemon=True).start()
threading.Thread(target=self.faxina, daemon=True).start()
def faxina(self):
while True:
time.sleep(3600)
try:
self.limpar_antigos()
except Exception:
pass
@contextmanager
def db(self):
con = sqlite3.connect(self.caminho_db)
try:
with con:
yield con
finally:
con.close()
# ------------------------------------------------------------ pessoas
def amostras(self):
"""{pessoa: [vetores]} das faces marcadas por alguém."""
with self.db() as db:
linhas = db.execute('SELECT pessoa, vetor FROM rostos WHERE amostra=1 AND pessoa IS NOT NULL').fetchall()
saida = {}
for pessoa, vetor in linhas:
saida.setdefault(pessoa, []).append(json.loads(vetor))
return saida
def quem(self, vetor, amostras=None):
"""(pessoa, confiança) da amostra mais parecida, ou (None, melhor) abaixo do limiar."""
melhor, quem = 0.0, None
for pessoa, vetores in (amostras if amostras is not None else self.amostras()).items():
s = max(cosseno(vetor, v) for v in vetores)
if s > melhor:
melhor, quem = s, pessoa
return (quem, melhor) if melhor >= LIMIAR else (None, melhor)
def pessoa(self, ident):
with self.db() as db:
linha = db.execute('SELECT id, nome, visto, vezes, afinidade FROM pessoas WHERE id=?', (ident,)).fetchone()
return dict(id=linha[0], nome=linha[1], visto=linha[2], vezes=linha[3], afinidade=round(linha[4] or 0, 1)) if linha else None
def pessoa_por_nome(self, nome):
with self.db() as db:
linha = db.execute('SELECT id FROM pessoas WHERE lower(nome)=lower(?)', (nome,)).fetchone()
return self.pessoa(linha[0]) if linha else None
# ------------------------------------------------------------ fotos
def nova_foto(self, evento, caminho):
"""Chamado pelo histórico quando chega a foto de um evento: analisa em segundo plano."""
if self.config['ligado']:
try:
self.fila.put_nowait((evento, str(caminho), False))
except queue.Full:
pass
def analisar(self, evento, jpeg, agora=None, avisar=True):
"""Acha e reconhece os rostos de uma foto. Devolve [(pessoa ou None, confiança)]."""
agora = agora or time.time()
if not self.visao.pronta():
return []
achados = self.visao.rostos(jpeg)
amostras = self.amostras()
resultado = []
for vetor, recorte, score, _ in achados:
pessoa, conf = self.quem(vetor, amostras)
antes = self.pessoa(pessoa)['visto'] if pessoa else None # para a saudade de cada um
with self.lock, self.db() as db:
ident = db.execute('INSERT INTO rostos(horario, evento, pessoa, confianca, score, vetor) VALUES (?,?,?,?,?,?)',
(agora, evento, pessoa, round(conf, 3), round(score, 3), json.dumps([round(x, 5) for x in vetor]))).lastrowid
if recorte:
nome = 'rosto-%d.jpg' % ident
(self.recortes / nome).write_bytes(recorte)
db.execute('UPDATE rostos SET recorte=? WHERE id=?', (nome, ident))
if pessoa:
db.execute('UPDATE pessoas SET visto=?, vezes=vezes+1 WHERE id=?', (agora, pessoa))
resultado.append((pessoa, conf))
if avisar:
if pessoa:
p = dict(self.pessoa(pessoa), visto_antes=antes)
self.console('sentidos', 'Reconheceu %s' % p['nome'], 'semelhança %d%% (evento %s)' % (round(conf * 100), evento),
agora=agora)
try:
self.ao_reconhecer(p, conf, agora)
except Exception:
pass
else:
self.console('sentidos', 'Viu um rosto que ainda não conhece', 'parecido %d%% com alguém (precisa %d%%)' % (
round(conf * 100), round(LIMIAR * 100)), 'detalhe', agora)
if achados and avisar:
self.avisar()
return resultado
def trabalhar(self):
while True:
evento, caminho, varredura = self.fila.get()
try:
jpeg = Path(caminho).read_bytes()
self.analisar(evento, jpeg, avisar=not varredura)
except Exception as e:
print('reconhecimento: erro:', e, flush=True)
finally:
if varredura and self.varrendo:
self.varrendo['feitas'] += 1
if self.varrendo['feitas'] >= self.varrendo['total']:
self.varrendo = None
self.console('sentidos', 'Terminou de procurar rostos nas fotos guardadas')
self.avisar()
def varrer(self, eventos):
"""Procura rostos nas fotos já guardadas (para cadastrar pessoas logo de cara)."""
if not self.visao.pronta():
raise ValueError(self.visao.motivo)
if self.varrendo:
raise ValueError('Já estou procurando nas fotos guardadas')
with self.db() as db:
feitos = {r[0] for r in db.execute('SELECT DISTINCT evento FROM rostos')}
alvos = [(e, c) for e, c in eventos if e not in feitos and c]
if not alvos:
raise ValueError('Nenhuma foto nova para analisar')
self.varrendo = dict(feitas=0, total=len(alvos))
for evento, caminho in alvos:
self.fila.put((evento, str(caminho), True))
self.avisar()
return self.status()
# ------------------------------------------------------------ marcar no painel
def marcar(self, rostos, pessoa=None, nome=None):
"""Diz de quem são estes rostos (vira amostra). nome cria a pessoa; sem pessoa e nome, descarta."""
if not isinstance(rostos, list) or not rostos or not all(type(r) is int for r in rostos):
raise ValueError('Escolha pelo menos um rosto')
with self.lock, self.db() as db:
if nome is not None:
nome = ' '.join(str(nome).split())[:30]
if not nome:
raise ValueError('Digite o nome')
existente = db.execute('SELECT id FROM pessoas WHERE lower(nome)=lower(?)', (nome,)).fetchone()
pessoa = existente[0] if existente else db.execute(
'INSERT INTO pessoas(nome, criado) VALUES (?,?)', (nome, time.time())).lastrowid
elif pessoa is not None and not db.execute('SELECT 1 FROM pessoas WHERE id=?', (pessoa,)).fetchone():
raise ValueError('Pessoa não encontrada')
marcas = ','.join('?' * len(rostos))
if pessoa is None: # não é ninguém (ou foto ruim): some da lista
for (arquivo,) in db.execute('SELECT recorte FROM rostos WHERE id IN (%s)' % marcas, rostos).fetchall():
self.apagar_recorte(arquivo)
db.execute('DELETE FROM rostos WHERE id IN (%s)' % marcas, rostos)
else:
db.execute('UPDATE rostos SET pessoa=?, amostra=1 WHERE id IN (%s)' % marcas, [pessoa] + rostos)
# guarda só as amostras mais novas de cada pessoa
velhas = db.execute('SELECT id, recorte FROM rostos WHERE pessoa=? AND amostra=1 ORDER BY id DESC LIMIT -1 OFFSET ?',
(pessoa, MAX_AMOSTRAS)).fetchall()
for vid, arquivo in velhas:
self.apagar_recorte(arquivo)
db.execute('DELETE FROM rostos WHERE id=?', (vid,))
if pessoa is not None:
self.reconferir()
p = self.pessoa(pessoa)
self.console('sentidos', 'Aprendeu o rosto de %s' % p['nome'], '%d foto(s) marcada(s) no painel' % len(rostos))
self.avisar()
return self.status()
def reconferir(self):
"""Com amostras novas, os rostos sem nome são comparados de novo (sem reabrir as fotos)."""
amostras = self.amostras()
if not amostras:
return
with self.lock, self.db() as db:
for ident, vetor in db.execute('SELECT id, vetor FROM rostos WHERE pessoa IS NULL').fetchall():
pessoa, conf = self.quem(json.loads(vetor), amostras)
if pessoa:
db.execute('UPDATE rostos SET pessoa=?, confianca=? WHERE id=?', (pessoa, round(conf, 3), ident))
def renomear(self, pessoa, nome):
nome = ' '.join(str(nome or '').split())[:30]
if not nome:
raise ValueError('Digite o nome')
with self.db() as db:
try:
db.execute('UPDATE pessoas SET nome=? WHERE id=?', (nome, pessoa))
except sqlite3.IntegrityError:
raise ValueError('Já existe alguém com esse nome') from None
self.avisar()
return self.status()
def apagar_pessoa(self, pessoa):
with self.lock, self.db() as db:
for (arquivo,) in db.execute('SELECT recorte FROM rostos WHERE pessoa=?', (pessoa,)).fetchall():
self.apagar_recorte(arquivo)
db.execute('DELETE FROM rostos WHERE pessoa=?', (pessoa,))
db.execute('DELETE FROM pessoas WHERE id=?', (pessoa,))
self.avisar()
return self.status()
def gostou(self, pessoa, quanto=1.0):
"""Carinho ou conversa de quem foi reconhecido há pouco: a afinidade com essa pessoa cresce."""
with self.db() as db:
db.execute('UPDATE pessoas SET afinidade=min(100, afinidade+?) WHERE id=?', (quanto, pessoa))
def apagar_recorte(self, arquivo):
if arquivo:
(self.recortes / arquivo).unlink(missing_ok=True)
def limpar_antigos(self, agora=None):
limite = (agora or time.time()) - DIAS_SEM_NOME * 86400
with self.lock, self.db() as db:
velhos = db.execute('SELECT id, recorte FROM rostos WHERE amostra=0 AND horario < ?', (limite,)).fetchall()
for vid, arquivo in velhos:
self.apagar_recorte(arquivo)
db.execute('DELETE FROM rostos WHERE amostra=0 AND horario < ?', (limite,))
return len(velhos)
def configurar(self, dados):
if not isinstance(dados, dict) or set(dados) - set(PADRAO) or not all(isinstance(v, bool) for v in dados.values()):
raise ValueError('Ajuste inválido')
self.config.update(dados)
gravar_atomico(self.arquivo_config, self.config)
self.avisar()
return self.status()
# ------------------------------------------------------------ painel
def status(self):
pronta = self.visao.pronta()
with self.db() as db:
pessoas = []
for pid, nome, visto, vezes, afinidade in db.execute('SELECT id, nome, visto, vezes, afinidade FROM pessoas ORDER BY nome'):
fotos = [dict(id=i, recorte=r) for i, r in db.execute(
'SELECT id, recorte FROM rostos WHERE pessoa=? AND recorte IS NOT NULL ORDER BY amostra DESC, id DESC LIMIT 6', (pid,))]
amostras = db.execute('SELECT count(*) FROM rostos WHERE pessoa=? AND amostra=1', (pid,)).fetchone()[0]
pessoas.append(dict(id=pid, nome=nome, visto=visto, vezes=vezes, afinidade=round(afinidade or 0, 1),
amostras=amostras, fotos=fotos))
linhas = db.execute('SELECT id, horario, evento, recorte, confianca, vetor FROM rostos WHERE pessoa IS NULL '
'AND recorte IS NOT NULL ORDER BY id DESC LIMIT 120').fetchall()
total = db.execute('SELECT count(*) FROM rostos').fetchone()[0]
sem_nome, grupos = [], [] # grupos: rostos parecidos entre si (provável mesma pessoa)
for i, hr, e, r, c, vetor in linhas:
v = json.loads(vetor)
g = next((g for g in grupos if cosseno(v, g['v']) >= LIMIAR_GRUPO), None)
if g is None:
g = dict(v=v, ids=[])
grupos.append(g)
g['ids'].append(i)
sem_nome.append(dict(id=i, horario=hr, evento=e, recorte=r, parecido=c, grupo=grupos.index(g)))
grupos = [g['ids'] for g in grupos]
return dict(config=dict(self.config), disponivel=pronta, motivo='' if pronta else self.visao.motivo,
pessoas=pessoas, sem_nome=sem_nome, grupos=grupos, rostos=total, varrendo=self.varrendo, limiar=LIMIAR,
dias_sem_nome=DIAS_SEM_NOME)
def avisar(self):
self.publicar('pessoas', self.status())
def rotas(self, h, metodo):
rota = h.path.split('?', 1)[0]
if not rota.startswith('/pessoas'):
return False
try:
if metodo == 'GET' and rota == '/pessoas':
resultado = self.status()
elif metodo == 'GET' and rota.startswith('/pessoas/rosto/'):
nome = os.path.basename(rota)
arquivo = self.recortes / nome
if not nome.startswith('rosto-') or not nome.endswith('.jpg') or not arquivo.is_file():
raise ValueError('Rosto não encontrado')
h.enviar(arquivo.read_bytes(), 'image/jpeg')
return True
elif metodo == 'POST':
d = ler_json(h, 8192)
if rota == '/pessoas/marcar':
resultado = self.marcar(d.get('rostos'), d.get('pessoa'), d.get('nome'))
elif rota == '/pessoas/renomear':
resultado = self.renomear(d.get('pessoa'), d.get('nome'))
elif rota == '/pessoas/apagar':
resultado = self.apagar_pessoa(d.get('pessoa'))
elif rota == '/pessoas/config':
resultado = self.configurar(d)
elif rota == '/pessoas/varrer':
resultado = self.varrer(self.fotos_guardadas())
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
fotos_guardadas = staticmethod(lambda: []) # o main liga ao histórico
+30 -1
View File
@@ -287,11 +287,13 @@
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
desenharMaos(ctx, o, olhos, tela.height); desenharMaos(ctx, o, olhos, tela.height);
desenharZzz(ctx, o, olhos); desenharZzz(ctx, o, olhos);
desenharSonho(ctx, o, olhos);
} else { } else {
cctx.clearRect(0, 0, camada.width, camada.height); cctx.clearRect(0, 0, camada.width, camada.height);
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i])); olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
desenharMaos(cctx, o, olhos, tela.height); desenharMaos(cctx, o, olhos, tela.height);
desenharZzz(cctx, o, olhos); desenharZzz(cctx, o, olhos);
desenharSonho(cctx, o, olhos);
ctx.save(); ctx.save();
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); } if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
ctx.drawImage(camada, 0, 0); ctx.drawImage(camada, 0, 0);
@@ -421,6 +423,33 @@
} }
// ------------------------------------------------------------------ Zzz // ------------------------------------------------------------------ Zzz
// ----------------------------------------------------------- sonho
// Dormindo, às vezes aparece bem fraquinho um balão de pensamento com algo do dia (a bola, a estrela...).
const sonho = { obj: null, t0: 0, dur: 0 };
function definirSonho(obj, segundos) {
sonho.obj = obj || null; sonho.t0 = performance.now() / 1000; sonho.dur = segundos || 7;
}
function desenharSonho(c, o, olhos) {
if (!o.dormindo || !sonho.obj) return;
const k = (o.t - sonho.t0) / sonho.dur;
if (k < 0) return;
if (k >= 1) { sonho.obj = null; return; }
const surge = Math.min(1, k * sonho.dur / 1.2, (1 - k) * sonho.dur / 1.2); // aparece e some devagar
const e = olhos.slice().sort((a, b) => a.cx - b.cx)[0]; // olho da esquerda na tela
const R = e.ry * 0.62, bx = e.cx - e.ry * 0.1;
const by = Math.max(R * 1.2, e.cy - e.ry * 1.75) + Math.sin(o.t * 1.3) * e.ry * 0.05; // nunca sai da tela
c.save();
c.strokeStyle = css(o.cor); c.fillStyle = css(o.cor, 0.08); c.lineWidth = Math.max(1.5, R * 0.06);
c.globalAlpha = 0.45 * surge;
for (const [f, rr] of [[0.25, 0.09], [0.55, 0.15]]) { // bolinhas subindo até o balão
c.beginPath(); c.arc(e.cx + e.ry * 0.3 - f * e.ry * 0.3, e.cy - e.ry * 0.75 + (by + R - e.cy + e.ry * 0.75) * f, R * rr * 1.3, 0, Math.PI * 2); c.stroke();
}
c.beginPath(); c.arc(bx, by, R, 0, Math.PI * 2); c.fill(); c.stroke();
c.globalAlpha = 0.55 * surge;
desenharObjeto(c, sonho.obj, bx, by, R * 1.15, o.t, o.cor, Math.sin(o.t * 0.8) * 0.15, 0);
c.restore();
}
function desenharZzz(c, o, olhos, fator) { function desenharZzz(c, o, olhos, fator) {
fator = fator || 1; fator = fator || 1;
if (!o.dormindo) return; if (!o.dormindo) return;
@@ -1385,7 +1414,7 @@
return ((!base.lente && FORMATOS[formato]) || base).h / 2; return ((!base.lente && FORMATOS[formato]) || base).h / 2;
}, },
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } }, maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS), faixa: definirFaixa, sonho: definirSonho, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS),
/** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */ /** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */
atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)), atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null), atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null),
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Reconhecimento de pessoas na placa: OpenCV + modelos YuNet (acha rostos) e SFace (impressão do rosto).
# Rodar como root. Tudo fica na placa; nada vai para a internet depois de baixar os modelos.
set -e
DESTINO=${1:-/opt/carinha-pc}
apt-get install -y --no-install-recommends python3-opencv
mkdir -p "$DESTINO/modelos"
cd "$DESTINO/modelos"
for m in face_detection_yunet/face_detection_yunet_2023mar.onnx face_recognition_sface/face_recognition_sface_2021dec.onnx; do
[ -s "$(basename "$m")" ] || curl -fsSL -o "$(basename "$m")" "https://github.com/opencv/opencv_zoo/raw/main/models/$m"
done
python3 -c "import cv2; assert hasattr(cv2, 'FaceRecognizerSF'); print('reconhecimento pronto: OpenCV', cv2.__version__)"
systemctl restart carinha-pc
+72
View File
@@ -136,6 +136,78 @@ class AprendizadoTest(unittest.TestCase):
self.assertEqual(outro.peso('rir'), 1.0) self.assertEqual(outro.peso('rir'), 1.0)
self.assertEqual(Aprendizado(self.caminho).itens, {}) self.assertEqual(Aprendizado(self.caminho).itens, {})
def ligar_noite(self, ini=23, fim=7):
estado = {'noite': (ini, fim)}
self.a.noite = lambda: estado['noite']
self.a.mudar_noite = lambda i, f: estado.update(noite=(i, f))
return estado
def test_rotina_separa_o_que_cai_na_madrugada(self):
self.ligar_noite(23, 7)
self.a.dias = 3
for h in list(range(10, 24)) + [0, 2]:
self.a.rotina[h] = 0.8
self.a.config['ajustar_sono'] = False
licoes = self.a.fechar_dia()
texto = next(t for t in licoes if t.startswith('Percebi'))
self.assertIn('das 10h às 23h. Vou estar acordado nessa hora.', texto)
self.assertIn('Das 23h à 1h e das 2h às 3h é a minha hora de dormir', texto)
# só gente na madrugada: não promete ficar acordado
self.a.rotina = [None] * 24
self.a.rotina[2], self.a.rotina_avisada = 0.9, []
texto = next(t for t in self.a.fechar_dia() if t.startswith('Percebi'))
self.assertEqual(texto, 'Percebi gente por perto das 2h às 3h, mas é a minha hora de dormir: '
'fico quietinho, a não ser que alguém me chame.')
def test_horario_de_dormir_acompanha_a_casa_aos_poucos_e_volta(self):
estado = self.ligar_noite(23, 7)
self.a.dias = 2
self.a.rotina[23], self.a.rotina[0], self.a.rotina[1] = 0.8, 0.7, 0.7
self.assertEqual(self.a.ajustar_noite('2026-10-06'), []) # poucos dias: ainda não mexe
self.a.dias = 6
licoes = self.a.ajustar_noite('2026-10-06')
self.assertEqual(estado['noite'], (0, 7)) # 1 h por noite
self.assertIn('agora eu vou dormir às 0h (antes era 23h)', licoes[0])
self.assertEqual(self.a.decisoes[0]['tipo'], 'sono')
self.a.ajustar_noite('2026-10-07')
self.a.ajustar_noite('2026-10-08')
self.assertEqual(estado['noite'], (2, 7)) # 1h tem gente, 2h já não conta
self.a.rotina[2] = 0.9
self.a.ajustar_noite('2026-10-09')
self.assertEqual(estado['noite'], (2, 7)) # no máximo 3 h depois do escolhido
# a casa voltou a dormir cedo: volta 1 h por noite até o horário escolhido
self.a.rotina[1] = self.a.rotina[0] = self.a.rotina[23] = 0.1
self.assertIn('volto a dormir às 1h', self.a.ajustar_noite('2026-10-10')[0])
self.a.ajustar_noite('2026-10-11')
self.a.ajustar_noite('2026-10-12')
self.assertEqual(estado['noite'], (23, 7))
self.assertEqual(self.a.ajustar_noite('2026-10-13'), [])
def test_acordar_mais_cedo_limites_e_horario_escolhido_a_mao(self):
estado = self.ligar_noite(23, 7)
self.a.dias = 6
self.a.rotina[6], self.a.rotina[5] = 0.8, 0.8
self.a.ajustar_noite('2026-10-06')
self.a.ajustar_noite('2026-10-07')
self.a.ajustar_noite('2026-10-08')
self.assertEqual(estado['noite'], (23, 5)) # no máximo 2 h antes
# a pessoa muda o horário à mão: vira a nova base e ele não desfaz
estado['noite'] = (22, 8)
self.a.rotina = [None] * 24
self.assertEqual(self.a.ajustar_noite('2026-10-09'), [])
self.assertEqual(self.a.sono_base, (22, 8))
# madrugada nunca fica menor que 5 h
estado['noite'] = (2, 7)
self.a.rotina[2], self.a.rotina[6] = 0.9, 0.9
self.a.ajustar_noite('2026-10-10')
self.assertEqual(estado['noite'], (2, 7))
# desligado ou sem madrugada: não mexe
self.a.config['ajustar_sono'] = False
self.assertEqual(self.a.ajustar_noite('2026-10-11'), [])
self.a.config['ajustar_sono'] = True
self.a.noite = lambda: None
self.assertEqual(self.a.ajustar_noite('2026-10-12'), [])
def test_validar(self): def test_validar(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
self.a.configurar(dict(ligado='sim')) self.a.configurar(dict(ligado='sim'))
+33
View File
@@ -97,6 +97,7 @@ class HistoricoTest(unittest.TestCase):
avisos = [] avisos = []
self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados)) self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados))
self.h.pir['disponivel'] = True self.h.pir['disponivel'] = True
self.h.config['sensibilidade_pir'] = 'alta' # qualquer disparo conta
self.h.nivel_pir(True, agora=1000) # subida: evento + bolinha acesa self.h.nivel_pir(True, agora=1000) # subida: evento + bolinha acesa
self.assertTrue(self.h.pir['ativo']) self.assertTrue(self.h.pir['ativo'])
self.assertEqual(self.h.status()['eventos'][0]['tipo'], 'pir') self.assertEqual(self.h.status()['eventos'][0]['tipo'], 'pir')
@@ -107,6 +108,38 @@ class HistoricoTest(unittest.TestCase):
self.assertTrue(self.h.checar_ausencia(1000 + 15 * 60 + 1)) self.assertTrue(self.h.checar_ausencia(1000 + 15 * 60 + 1))
self.assertIn(('pir', True), [(t, d.get('ativo')) for t, d in avisos]) self.assertIn(('pir', True), [(t, d.get('ativo')) for t, d in avisos])
def test_sensibilidade_do_pir_confirma_antes_de_contar(self):
detectados = []
self.h.ao_detectar = detectados.append
self.h.config['intervalo'] = 0
# média (padrão): um disparo solto só acende a bolinha
self.h.nivel_pir(True, agora=1000)
self.assertTrue(self.h.pir['ativo'])
self.h.nivel_pir(False, agora=1008) # o HC-SR505 segura ~8 s
self.assertEqual((self.h.status()['total'], detectados), (0, []))
self.h.nivel_pir(True, agora=1040) # segundo disparo em menos de 1 min
self.assertEqual(detectados, ['pir'])
self.h.nivel_pir(False, agora=1048)
self.h.nivel_pir(True, agora=1200) # 2 min depois: solto de novo
self.h.nivel_pir(False, agora=1208)
self.assertEqual(len(detectados), 1)
# alguém se mexendo na frente: o sensor fica ligado direto e confirma
self.h.nivel_pir(True, agora=2000)
self.h.checar_pir(2010)
self.assertEqual(len(detectados), 1)
self.h.checar_pir(2012)
self.assertEqual(len(detectados), 2)
self.h.nivel_pir(False, agora=2030)
# baixa: precisa de 3 disparos em 90 s
self.h.config['sensibilidade_pir'] = 'baixa'
for t in (3000, 3030):
self.h.nivel_pir(True, agora=t); self.h.nivel_pir(False, agora=t + 8)
self.assertEqual(len(detectados), 2)
self.h.nivel_pir(True, agora=3060)
self.assertEqual(len(detectados), 3)
with self.assertRaises(ValueError):
Historico.validar(dict(sensibilidade_pir='maxima'))
def test_apagar_e_limpar(self): def test_apagar_e_limpar(self):
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG) a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
b = self.h.registrar('som', agora=1) b = self.h.registrar('som', agora=1)
+158
View File
@@ -0,0 +1,158 @@
"""Jeito de gente do modo vida: reencontro e saudade, bom dia lembrando de ontem, pedidos com motivo,
largar atividade no meio, personalidade e o console com o porquê de cada decisão."""
import random
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from console import Console # noqa: E402
from vida import Vida # noqa: E402
DIA = time.mktime((2026, 10, 12, 9, 0, 0, 0, 0, -1)) # uma segunda, 9h
class HumanoTest(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.linhas = [], []
self.v.enfileirar = self.ordens.append
self.v.console = lambda origem, texto, motivo='', nivel='info', agora=None: self.linhas.append((origem, texto, motivo, nivel))
self.v.celular_online = lambda: True
self.v.celular_dormindo = lambda: False
self.v.madrugada = lambda agora: False
self.perto = False
self.v.alguem_perto = lambda: self.perto
def tearDown(self):
self.tmp.cleanup()
def falas(self):
return [o[6:] for o in self.ordens if o.startswith('dizer ')]
def chegar(self, agora, ausencia):
self.v.ultimo_perto = agora - ausencia
self.v.perto_antes = False
self.perto = True
self.v.tick(agora)
def test_reencontro_cresce_com_o_tempo_fora(self):
self.v.saudou_dia = '2026-10-12' # bom dia já foi dado
self.chegar(DIA, 5 * 60) # 5 min fora: nada
self.assertEqual(self.falas(), [])
self.assertNotIn('acenar', self.ordens)
self.chegar(DIA + 100, 20 * 60) # 20 min: acena
self.assertIn('acenar', self.ordens)
self.chegar(DIA + 200, 2 * 3600) # 2 h: comemora
self.assertEqual(self.falas()[-1], 'Oi! Você voltou!')
social = self.v.necessidades['social']
self.chegar(DIA + 300, 6 * 3600) # 6 h: saudade
self.assertEqual(self.falas()[-1], 'Senti sua falta!')
self.assertIn('coracao', self.ordens)
self.assertGreater(self.v.necessidades['social'], social)
self.assertTrue(any('ninguém por perto havia 6h00' in m for _, _, m, _ in self.linhas))
def test_bom_dia_lembra_de_ontem(self):
ontem = dict(presenca=[None] * 24, respostas=0, atos=8)
ontem['presenca'][1] = 0.6 # ficaram acordados até as 2h
self.v.dia_anterior = lambda: ontem
self.v.acaso = random.Random(0)
self.chegar(DIA, 8 * 3600)
fala = self.falas()[0]
self.assertTrue(fala.startswith('Bom dia! '), fala)
self.assertIn(fala[9:], ['Ontem a gente ficou acordado até as 2h, hein!',
'Ontem ninguém brincou comigo... hoje vai ser diferente?'])
self.assertEqual(self.v.saudou_dia, '2026-10-12')
self.chegar(DIA + 3600, 30 * 60) # mesmo dia: não repete o bom dia
self.assertEqual(len([f for f in self.falas() if f.startswith('Bom dia')]), 1)
# de madrugada não faz festa
self.v.madrugada = lambda agora: True
n = len(self.falas())
self.chegar(DIA + 7200, 6 * 3600)
self.assertEqual(len(self.falas()), n)
def test_pede_o_que_falta_com_motivo_e_intervalo(self):
self.v.saudou_dia = '2026-10-12'
self.perto, self.v.perto_antes, self.v.visto_em = True, True, DIA
self.v.atividade_em = DIA # sem folga para atividades
self.v.necessidades.update(social=30, diversao=80, energia=80, alegria=70)
self.v.acaso = random.Random(3)
pedido = None
for i in range(40):
if self.v.agir(DIA + i) == 'pedido':
pedido = DIA + i
break
self.assertIsNotNone(pedido)
self.assertEqual(self.falas()[-1], 'Tô carente... me faz um carinho?')
self.assertIn('social 30', self.v.porque)
self.assertEqual(self.v.diario[0]['tipo'], 'pedido')
n = len(self.falas())
for i in range(40): # menos de 20 min depois: não pede de novo
self.v.agir(pedido + 60 + i)
self.assertEqual(len(self.falas()), n)
def test_larga_atividade_no_meio_e_conta(self):
self.v.acaso = random.Random(0)
largou = None
for i in range(60):
self.v.desistencia = None
self.v.comecar_atividade(DIA + i, 'ler', 200)
if self.v.desistencia:
largou = self.v.desistencia
break
self.assertIsNotNone(largou)
segundos = int(self.ordens[-1].split()[-1])
self.assertLess(segundos, 110) # mandou ao celular a duração encurtada
self.v.tick(largou[0] + 1)
self.assertIn('bocejar', self.ordens)
self.assertIn('Largou no meio: cansou de ler um livro', [d['texto'] for d in self.v.diario[:3]])
def test_personalidade_muda_o_jeito_e_amadurece(self):
self.assertEqual(self.v.jeito('rir'), 1.0) # neutro nos testes
self.v.personalidade['brincadeira'] = 0.9
self.assertGreater(self.v.jeito('rir'), 1.4)
self.v.personalidade['timidez'] = 0.2
self.assertLess(self.v.jeito('timido'), 0.7)
antes = self.v.personalidade['timidez']
for _ in range(10):
self.v.estimulo('carinho', DIA)
self.assertLess(self.v.personalidade['timidez'], antes)
self.v.salvar()
outra = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False, rng=random.Random(1))
self.assertEqual(outra.personalidade, self.v.personalidade)
self.assertIn('personalidade', self.v.status())
def test_cada_decisao_vai_para_o_console_com_o_porque(self):
self.v.necessidades['energia'] = 10
self.v.proximo_ato = 0
self.v.ultimo_tick = DIA
self.v.tick(DIA)
origem, texto, motivo, _ = self.linhas[-1]
self.assertEqual((origem, texto), ('vida', 'Ficou exausto e foi dormir'))
self.assertIn('energia 10: abaixo de 20', motivo)
self.assertIn('energia 10 · alegria', motivo)
self.v.estimulo('carinho', DIA)
self.assertIn(('sentidos', 'Recebeu carinho'), [(o, t) for o, t, _, _ in self.linhas])
class ConsoleTest(unittest.TestCase):
def test_guarda_as_ultimas_e_publica(self):
c = Console()
publicados = []
c.publicar = lambda tipo, dados: publicados.append((tipo, dados))
for i in range(450):
c.registrar('vida', 'ato %d' % i, 'motivo')
c.registrar('inventada', 'vira sistema', nivel='qualquer')
st = c.status()
self.assertEqual(len(st['linhas']), 400)
self.assertEqual(st['linhas'][-1]['origem'], 'sistema')
self.assertEqual(st['linhas'][-1]['nivel'], 'info')
self.assertEqual(publicados[-1][0], 'console')
self.assertEqual(len(c.status(desde=449)['linhas']), 2)
if __name__ == '__main__':
unittest.main()
+177
View File
@@ -0,0 +1,177 @@
"""Reconhecimento de pessoas: marcar rostos, reconhecer, saudade por pessoa, afinidade e privacidade."""
import random
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from reconhecimento import DIAS_SEM_NOME, Reconhecimento, cosseno # noqa: E402
from vida import Vida # noqa: E402
DIA = time.mktime((2026, 10, 12, 15, 0, 0, 0, 0, -1))
def rosto_de(semente, ruido=0.25):
"""Impressão de 128 números: a mesma pessoa varia pouco, pessoas diferentes são bem diferentes."""
base = random.Random(semente)
r = random.Random()
return [base.gauss(0, 1) + r.gauss(0, ruido) for _ in range(128)]
class VisaoFalsa:
motivo = ''
def __init__(self):
self.fotos = {}
def pronta(self):
return True
def rostos(self, jpeg):
return [(v, b'\xff\xd8recorte\xff\xd9', 0.95, 120) for v in self.fotos.get(jpeg, [])]
class ReconhecimentoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.visao = VisaoFalsa()
self.r = Reconhecimento(self.tmp.name, visao=self.visao, iniciar=False)
self.vistos, self.linhas = [], []
self.r.ao_reconhecer = lambda p, conf, agora: self.vistos.append((p['nome'], round(conf, 2), p.get('visto_antes')))
self.r.console = lambda origem, texto, motivo='', nivel='info', agora=None: self.linhas.append(texto)
def tearDown(self):
self.tmp.cleanup()
def foto(self, evento, *pessoas, agora=DIA):
jpeg = ('foto-%d' % evento).encode()
self.visao.fotos[jpeg] = [rosto_de(p) for p in pessoas]
return self.r.analisar(evento, jpeg, agora)
def test_impressoes_da_mesma_pessoa_se_parecem(self):
self.assertGreater(cosseno(rosto_de('ana'), rosto_de('ana')), 0.8)
self.assertLess(abs(cosseno(rosto_de('ana'), rosto_de('bia'))), 0.3)
def test_marcar_e_depois_reconhecer_pelo_nome(self):
(pessoa, _), = self.foto(1, 'ana') # ninguém cadastrado: rosto sem nome
self.assertIsNone(pessoa)
self.foto(2, 'ana'); self.foto(3, 'ana')
sem_nome = [s['id'] for s in self.r.status()['sem_nome']]
self.assertEqual(len(sem_nome), 3)
self.r.marcar(sem_nome, nome='Ana')
st = self.r.status()
self.assertEqual([p['nome'] for p in st['pessoas']], ['Ana'])
self.assertEqual(st['pessoas'][0]['amostras'], 3)
self.assertEqual(st['sem_nome'], [])
# foto nova: reconhece a Ana e não a Bia
(pessoa, conf), = self.foto(4, 'ana', agora=DIA + 60)
self.assertIsNotNone(pessoa)
self.assertEqual(self.vistos[-1][0], 'Ana')
self.assertIn('Reconheceu Ana', self.linhas)
(pessoa, _), = self.foto(5, 'bia', agora=DIA + 120)
self.assertIsNone(pessoa)
self.assertEqual(len(self.r.status()['sem_nome']), 1)
def test_marcar_a_pessoa_reconhece_os_rostos_antigos_sem_nome(self):
for i in range(4):
self.foto(10 + i, 'bia')
ids = [s['id'] for s in self.r.status()['sem_nome']]
self.r.marcar(ids[:1], nome='Bia') # marcou um: os outros parecidos já passam a ser dela
st = self.r.status()
self.assertEqual(st['sem_nome'], [])
self.assertEqual(st['pessoas'][0]['amostras'], 1)
def test_saudade_conta_quando_cada_um_foi_visto(self):
self.foto(1, 'ana')
self.r.marcar([s['id'] for s in self.r.status()['sem_nome']], nome='Ana')
self.foto(2, 'ana', agora=DIA + 100)
self.foto(3, 'ana', agora=DIA + 5000)
self.assertEqual(self.vistos[-1][2], DIA + 100) # visto antes desta foto
def test_rostos_parecidos_ficam_no_mesmo_grupo(self):
self.foto(1, 'ana'); self.foto(2, 'bia'); self.foto(3, 'ana'); self.foto(4, 'ana')
grupos = sorted(len(g) for g in self.r.status()['grupos'])
self.assertEqual(grupos, [1, 3])
def test_descartar_renomear_apagar(self):
self.foto(1, 'ana'); self.foto(2, 'gato')
ana, gato = sorted(s['id'] for s in self.r.status()['sem_nome'])
self.r.marcar([gato]) # não é ninguém
self.r.marcar([ana], nome='ana')
self.r.marcar([ana], nome='ANA') # mesmo nome: mesma pessoa
pid = self.r.status()['pessoas'][0]['id']
self.r.renomear(pid, 'Ana Paula')
self.assertEqual(self.r.status()['pessoas'][0]['nome'], 'Ana Paula')
self.assertEqual(len(list((Path(self.tmp.name) / 'fotos' / 'rostos').glob('*.jpg'))), 1)
self.r.apagar_pessoa(pid) # apaga a pessoa e tudo dela
self.assertEqual(self.r.status()['pessoas'], [])
self.assertEqual(self.r.status()['rostos'], 0)
self.assertEqual(list((Path(self.tmp.name) / 'fotos' / 'rostos').glob('*.jpg')), [])
with self.assertRaises(ValueError):
self.r.marcar([], nome='x')
def test_rostos_sem_nome_somem_depois_de_uns_dias(self):
self.foto(1, 'ana', agora=DIA)
self.foto(2, 'bia', agora=DIA)
self.r.marcar([self.r.status()['sem_nome'][0]['id']], nome='Bia')
self.assertEqual(self.r.limpar_antigos(DIA + DIAS_SEM_NOME * 86400 + 1), 1) # a amostra da Bia fica
self.assertEqual(self.r.status()['rostos'], 1)
def test_desligado_nao_analisa_fotos_novas(self):
self.r.configurar(dict(ligado=False))
self.r.nova_foto(1, 'x.jpg')
self.assertTrue(self.r.fila.empty())
with self.assertRaises(ValueError):
self.r.configurar(dict(outra=True))
class VidaComNomesTest(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.afinidade = [], []
self.v.enfileirar = self.ordens.append
self.v.celular_online = lambda: True
self.v.celular_dormindo = lambda: False
self.v.madrugada = lambda agora: False
self.v.reconhece = lambda: True
self.v.gostou_de = lambda pessoa, quanto=1.0: self.afinidade.append((pessoa, quanto))
self.v.saudou_dia = '2026-10-12'
self.perto = False
self.v.alguem_perto = lambda: self.perto
def tearDown(self):
self.tmp.cleanup()
def falas(self):
return [o[6:] for o in self.ordens if o.startswith('dizer ')]
def test_espera_saber_quem_chegou_para_cumprimentar_pelo_nome(self):
self.v.ultimo_perto, self.perto = DIA - 2 * 3600, True
self.v.tick(DIA)
self.assertEqual(self.falas(), []) # esperando a foto
self.v.reconhecido(dict(id=1, nome='Ana', afinidade=25, visto_antes=DIA - 6 * 3600), 0.8, DIA + 3)
self.assertEqual(self.falas(), ['Ana! Senti sua falta!']) # 6 h sem a Ana, mesmo a casa tendo gente há 2 h
self.assertIsNone(self.v.reencontro_pendente)
def test_sem_reconhecer_cumprimenta_sem_nome_depois_de_esperar(self):
self.v.ultimo_perto, self.perto = DIA - 2 * 3600, True
self.v.tick(DIA)
self.v.tick(DIA + 5)
self.assertEqual(self.falas(), [])
self.v.tick(DIA + 9)
self.assertEqual(self.falas(), ['Oi! Você voltou!'])
def test_outra_pessoa_chega_com_alguem_ja_por_perto(self):
self.v.reconhecido(dict(id=2, nome='Bia', afinidade=0, visto_antes=DIA - 3 * 3600), 0.7, DIA)
self.assertEqual(self.falas(), ['Oi, Bia! Você voltou!'])
self.v.reconhecido(dict(id=2, nome='Bia', afinidade=0, visto_antes=DIA), 0.7, DIA + 60) # acabou de ver
self.assertEqual(len(self.falas()), 1)
self.v.estimulo('carinho', DIA + 90) # carinho logo depois: afinidade com a Bia
self.assertEqual(self.afinidade, [(2, 1.0)])
if __name__ == '__main__':
unittest.main()
+24
View File
@@ -81,6 +81,30 @@ class ServidorTest(unittest.TestCase):
self.assertEqual(self.pedir('GET', '/app/health', token=None)[0], 200) self.assertEqual(self.pedir('GET', '/app/health', token=None)[0], 200)
self.assertEqual(self.pedir('GET', '/estado?t=' + TOKEN, token=None)[0], 200) self.assertEqual(self.pedir('GET', '/estado?t=' + TOKEN, token=None)[0], 200)
def test_comandos_dos_jogos_falar_e_olhos(self):
self.assertEqual(kiosk.ordem_de_comando('dizer', ' Pedra... '), 'dizer Pedra...')
self.assertEqual(kiosk.ordem_de_comando('olhos', {'simbolo': '✌️', 'segundos': 3}), 'olhos ✌️ 3.0')
self.assertEqual(kiosk.ordem_de_comando('olhos', {'simbolo': '3', 'segundos': 99}), 'olhos 3 8.0')
self.assertEqual(kiosk.ordem_de_comando('olhos', {}), 'olhos - 2.5')
for tipo, valor in (('olhos', {'simbolo': '<b>'}), ('olhos', {'simbolo': '1', 'segundos': 'x'}), ('dizer', ' ')):
with self.assertRaises(ValueError):
kiosk.ordem_de_comando(tipo, valor)
codigo, _ = self.pedir('POST', '/comando', {'tipo': 'dizer', 'valor': 'Já!'})
self.assertEqual(codigo, 200)
self.assertIn('dizer Já!', kiosk.FILA.ordens())
def test_telao_dos_jogos(self):
ordem = kiosk.ordem_de_comando('telao', {'titulo': 'VENCI!!!', 'tom': 'eu', 'sub': 'Você 0 × 1 Carinha', 'segundos': 3,
'lados': [{'rotulo': 'Você', 'simbolo': '✊', 'nome': 'pedra'},
{'rotulo': 'Carinha', 'simbolo': '✋', 'nome': 'papel'}]})
self.assertTrue(ordem.startswith('telao {'))
t = json.loads(ordem[6:])
self.assertEqual((t['titulo'], t['tom'], len(t['lados'])), ('VENCI!!!', 'eu', 2))
self.assertEqual(json.loads(kiosk.ordem_de_comando('telao', {'titulo': 'x' * 99, 'tom': 'roxo'})[6:])['tom'], 'neutro')
for ruim in ({}, {'titulo': 'a', 'lados': [{'simbolo': '<img>'}]}, {'titulo': 'a', 'lados': [{}, {}, {}]}, 'texto'):
with self.assertRaises(ValueError):
kiosk.ordem_de_comando('telao', ruim)
def test_pagina_do_rosto_sempre_conferida_e_api_sem_cache(self): def test_pagina_do_rosto_sempre_conferida_e_api_sem_cache(self):
# o WebView do celular não pode seguir com uma carinha.html velha depois de um deploy # o WebView do celular não pode seguir com uma carinha.html velha depois de um deploy
for rota, esperado in (('/carinha.html', 'no-cache'), ('/gestos', 'no-store')): for rota, esperado in (('/carinha.html', 'no-cache'), ('/gestos', 'no-store')):
+247 -8
View File
@@ -43,6 +43,8 @@ ESTIMULOS = {
'brincar': (dict(diversao=25, alegria=8, energia=-5), 'alegre'), 'brincar': (dict(diversao=25, alegria=8, energia=-5), 'alegre'),
'alimentar': (dict(energia=30, alegria=4), 'feliz'), 'alimentar': (dict(energia=30, alegria=4), 'feliz'),
} }
NOME_ESTIMULO = {'pir': 'O sensor de presença viu alguém', 'movimento': 'A câmera viu movimento',
'som': 'Ouviu um barulho', 'conversa': 'Alguém conversou com ele'}
CUIDADOS = {'carinho': 'Recebeu carinho', 'cutucar': 'Levou uma cutucada', 'brincar': 'Brincou', CUIDADOS = {'carinho': 'Recebeu carinho', 'cutucar': 'Levou uma cutucada', 'brincar': 'Brincou',
'alimentar': 'Ganhou energia', 'ninar': 'Foi posto para dormir', 'acordar': 'Foi acordado'} 'alimentar': 'Ganhou energia', 'ninar': 'Foi posto para dormir', 'acordar': 'Foi acordado'}
@@ -112,6 +114,18 @@ PERIODOS = [(5, 12, 'de manhã', ['um sol nascendo', 'um pãozinho', 'uma xícar
(0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])] (0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])]
MAX_DIARIO = 60 MAX_DIARIO = 60
# Personalidade: traços de 0 a 1 que mudam o jeito de tudo (0,5 = neutro) e amadurecem com o convívio.
TRACOS = ('curiosidade', 'brincadeira', 'timidez')
GESTOS_TRACO = {'brincadeira': {'rir', 'comemorar', 'alegre', 'musica', 'piscadinha'},
'curiosidade': {'olhar_esq', 'olhar_dir', 'pensar', 'espiar', 'confuso'},
'timidez': {'timido', 'esconder', 'espiar'}}
# Reencontro: quanto mais tempo a pessoa ficou fora, maior a festa.
AUSENCIA_ACENO, AUSENCIA_OI, AUSENCIA_SAUDADE = 10 * 60, 60 * 60, 4 * 3600
INTERVALO_PEDIDO = 20 * 60 # no mínimo 20 min entre um pedido ("brinca comigo?") e outro
DESISTE = 0.12 # chance de largar uma atividade no meio porque ficou chato
ESPERA_NOME = 8 # s esperando o reconhecimento antes de cumprimentar sem o nome
OI_DE_NOVO = 10 * 60 # reconheceu alguém que não via há esse tempo: "Oi, Ana!"
GESTOS_VIDA = {g for atos in ATOS.values() for g, _, _ in atos} GESTOS_VIDA = {g for atos in ATOS.values() for g, _, _ in atos}
@@ -149,6 +163,22 @@ class Vida:
self.aprendi_pendente = lambda: None # curiosidade de hoje que ainda dá para contar self.aprendi_pendente = lambda: None # curiosidade de hoje que ainda dá para contar
self.contar_aprendi = lambda ident, agora=None: None self.contar_aprendi = lambda ident, agora=None: None
self.aprendizado = None # o que ele aprende com o dia a dia (aprendizado.py) self.aprendizado = None # o que ele aprende com o dia a dia (aprendizado.py)
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None # console do painel
self.dia_anterior = lambda: None # resumo de ontem (aprendizado): para lembrar no bom dia
# sorteios dos comportamentos "humanos" ficam à parte: não mexem na sequência do rng principal
self.acaso = random.Random() if rng is None else random.Random(7)
self.porque = '' # motivo da última decisão (vai para o console)
self.ultima = None # {horario, texto, porque}: a última decisão de fato
self.perto_antes = False
self.ultimo_perto = time.time()
self.pediu_em = 0.0
self.desistencia = None # (quando, o que) se largou a atividade no meio
self.figurinha_mostrada = 0.0
# reconhecimento de pessoas (reconhecimento.py): o main liga
self.reconhece = lambda: False # True: vale esperar a foto para cumprimentar pelo nome
self.gostou_de = lambda pessoa, quanto=1.0: None
self.reencontro_pendente = None # (desde, ausência) esperando saber quem chegou
self.ultimo_reconhecido = None # (id, nome, quando)
agora = time.time() agora = time.time()
self.config = dict(PADRAO) self.config = dict(PADRAO)
@@ -157,6 +187,10 @@ class Vida:
self.diario = [] self.diario = []
self.emojis = dict(dia='', total=0, ultimo=0.0) self.emojis = dict(dia='', total=0, ultimo=0.0)
self.dormindo_proprio = False self.dormindo_proprio = False
# cada Carinha nasce com um jeito um pouco diferente (nos testes, neutro)
self.personalidade = ({k: 0.5 for k in TRACOS} if rng is not None else
{k: round(self.acaso.uniform(0.35, 0.65), 2) for k in TRACOS})
self.saudou_dia = ''
try: try:
salvo = json.loads(self.caminho.read_text(encoding='utf-8')) salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo.get('config', {}))) self.config.update(self.validar(salvo.get('config', {})))
@@ -165,6 +199,11 @@ class Vida:
self.necessidades[k] = _limitar(float(salvo['necessidades'][k])) self.necessidades[k] = _limitar(float(salvo['necessidades'][k]))
self.nascimento = float(salvo.get('nascimento', agora)) self.nascimento = float(salvo.get('nascimento', agora))
self.diario = [d for d in salvo.get('diario', []) if isinstance(d, dict)][:MAX_DIARIO] self.diario = [d for d in salvo.get('diario', []) if isinstance(d, dict)][:MAX_DIARIO]
if isinstance(salvo.get('personalidade'), dict):
for k in TRACOS:
if isinstance(salvo['personalidade'].get(k), (int, float)):
self.personalidade[k] = max(0.0, min(1.0, float(salvo['personalidade'][k])))
self.saudou_dia = str(salvo.get('saudou_dia', ''))
if isinstance(salvo.get('emojis'), dict): if isinstance(salvo.get('emojis'), dict):
self.emojis.update({k: salvo['emojis'][k] for k in ('dia', 'total', 'ultimo') if k in salvo['emojis']}) self.emojis.update({k: salvo['emojis'][k] for k in ('dia', 'total', 'ultimo') if k in salvo['emojis']})
except (OSError, ValueError, TypeError, AttributeError): except (OSError, ValueError, TypeError, AttributeError):
@@ -222,7 +261,8 @@ class Vida:
def salvar(self): def salvar(self):
with self.lock: with self.lock:
dados = dict(config=self.config, necessidades=self.necessidades, nascimento=self.nascimento, dados = dict(config=self.config, necessidades=self.necessidades, nascimento=self.nascimento,
diario=self.diario, emojis=self.emojis) diario=self.diario, emojis=self.emojis, personalidade=self.personalidade,
saudou_dia=self.saudou_dia)
self.salvo_em = time.time() self.salvo_em = time.time()
try: try:
gravar_atomico(self.caminho, dados) gravar_atomico(self.caminho, dados)
@@ -274,7 +314,9 @@ class Vida:
nascimento=self.nascimento, idade_dias=int((agora - self.nascimento) // 86400), nascimento=self.nascimento, idade_dias=int((agora - self.nascimento) // 86400),
emojis_hoje=self.emojis_hoje(agora), ultimo_emoji=self.emojis.get('ultimo') or None, emojis_hoje=self.emojis_hoje(agora), ultimo_emoji=self.emojis.get('ultimo') or None,
zanga=self.zanga(), sono_profundo=self.sono_profundo, zanga=self.zanga(), sono_profundo=self.sono_profundo,
pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30]) pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30],
personalidade=dict(self.personalidade), porque=self.porque, ultima=self.ultima,
proximo_ato_em=max(0, round(self.proximo_ato - agora)))
def avisar(self): def avisar(self):
self.publicado_em = time.time() self.publicado_em = time.time()
@@ -323,6 +365,13 @@ class Vida:
self.interagiu_em = agora # alguém quis ele acordado (sensor não conta) self.interagiu_em = agora # alguém quis ele acordado (sensor não conta)
if self.aprendizado: if self.aprendizado:
self.aprendizado.resposta(tipo, agora) self.aprendizado.resposta(tipo, agora)
self.amadurecer(tipo)
rec = self.ultimo_reconhecido
if rec and agora - rec[2] < 120:
self.gostou_de(rec[0], 1.0 if tipo in ('carinho', 'brincar', 'conversa') else 0.5)
self.console('sentidos', CUIDADOS.get(tipo) or NOME_ESTIMULO.get(tipo, tipo),
', '.join('%s %+d' % (k, v) for k, v in efeito.items()),
'detalhe' if tipo in ('som', 'movimento') else 'info', agora)
if gesto and not self.bloqueado(): if gesto and not self.bloqueado():
if self.dormindo(): if self.dormindo():
self.acordar(agora, None) self.acordar(agora, None)
@@ -390,12 +439,131 @@ class Vida:
self.anotar('atividade', 'Foi fazer %s %s%s' % (item['emoji'], item['nome'], motivo), agora) self.anotar('atividade', 'Foi fazer %s %s%s' % (item['emoji'], item['nome'], motivo), agora)
return 'atividade ' + nome return 'atividade ' + nome
nome = self.rng.choice(CALMAS) # apagaram a criação no meio do caminho nome = self.rng.choice(CALMAS) # apagaram a criação no meio do caminho
if nome != 'dormir' and segundos > 60 and self.acaso.random() < DESISTE * (1.3 - self.personalidade['curiosidade']):
segundos = segundos * self.acaso.uniform(0.3, 0.5) # vai largar no meio: ficou chato
self.desistencia = (agora + segundos, NOME_ATIVIDADE.get(nome, 'a atividade'))
self.enfileirar('atividade %s %d' % (nome, segundos)) self.enfileirar('atividade %s %d' % (nome, segundos))
self.atividade_em = agora self.atividade_em = agora
self.ultima_atividade = nome self.ultima_atividade = nome
self.anotar('atividade', NOME_ATIVIDADE.get(nome, 'Foi fazer algo'), agora) self.anotar('atividade', NOME_ATIVIDADE.get(nome, 'Foi fazer algo'), agora)
return 'atividade ' + nome return 'atividade ' + nome
def dizer(self, texto, gesto=None):
"""Uma fala curta: o gesto e a frase em legenda, com a voz de robô."""
if gesto:
self.enfileirar(gesto)
self.enfileirar('dizer ' + texto)
def resumo(self):
n = self.necessidades
return 'energia %d · alegria %d · social %d · diversão %d · %s' % (
n['energia'], n['alegria'], n['social'], n['diversao'], HUMORES[self.humor()][1].lower())
@staticmethod
def tempo(seg):
return '%d min' % round(seg / 60) if seg < 3600 else '%dh%02d' % (seg // 3600, seg % 3600 // 60)
def saudacao(self, agora, nome=None):
"""Bom dia com uma lembrança de ontem (do que ele viveu, sem IA)."""
hora = time.localtime(agora).tm_hour
prefixo = 'Bom dia' if 5 <= hora < 12 else 'Boa tarde' if hora < 18 else 'Boa noite'
if nome:
prefixo += ', ' + nome
fatos = []
ontem = None
try:
ontem = self.dia_anterior()
except Exception:
pass
if isinstance(ontem, dict):
pres = ontem.get('presenca') or []
tarde = [hh for hh in range(0, 5) if hh < len(pres) and pres[hh] is not None and pres[hh] >= 0.3]
if tarde:
fatos.append('Ontem a gente ficou acordado até as %dh, hein!' % (max(tarde) + 1))
elif len(pres) == 24 and pres[23] is not None and pres[23] >= 0.3:
fatos.append('Ontem a gente ficou até tarde, hein!')
if (ontem.get('respostas') or 0) >= 5:
fatos.append('Ontem foi um dia cheio de carinho. Obrigado!')
elif (ontem.get('atos') or 0) >= 5 and not ontem.get('respostas'):
fatos.append('Ontem ninguém brincou comigo... hoje vai ser diferente?')
if not fatos:
fatos.append('Acordei cheio de energia!' if self.necessidades['energia'] >= 80 else 'Que bom te ver!')
return '%s! %s' % (prefixo, self.acaso.choice(fatos))
def reencontro(self, agora, ausencia, nome=None, afinidade=0.0):
"""Alguém apareceu: a reação depende de quanto tempo ficou fora (e de quem ele é)."""
if self.dormindo() or self.madrugada(agora) or self.bloqueado():
return None
n, tim = self.necessidades, self.personalidade['timidez']
dia = time.strftime('%Y-%m-%d', time.localtime(agora))
motivo = ('%s não aparecia havia ' % nome if nome else 'ninguém por perto havia ') + self.tempo(ausencia)
querido = afinidade >= 20 # muito carinho dessa pessoa: festa com coração
if self.saudou_dia != dia and time.localtime(agora).tm_hour >= 5 and ausencia >= AUSENCIA_ACENO:
self.saudou_dia = dia
frase = self.saudacao(agora, nome)
self.dizer(frase, 'timido' if tim > 0.65 else 'acenar')
ganho, texto, motivo = 10, 'Deu bom dia: "%s"' % frase, 'primeiro encontro do dia; ' + motivo
elif ausencia >= AUSENCIA_SAUDADE:
self.dizer('%s! Senti sua falta!' % nome if nome else 'Senti sua falta!', 'comemorar')
self.enfileirar('coracao')
ganho, texto = 20, 'Fez festa: estava com saudade' + (' de ' + nome if nome else '')
n['alegria'] = _limitar(n['alegria'] + 10)
elif ausencia >= AUSENCIA_OI:
self.dizer('Oi, %s! Você voltou!' % nome if nome else 'Oi! Você voltou!', 'timido' if tim > 0.65 else 'comemorar')
if querido:
self.enfileirar('coracao')
ganho, texto = 10, 'Comemorou que %s voltou' % (nome or 'alguém')
elif ausencia >= AUSENCIA_ACENO:
if nome:
self.dizer('Oi, %s!' % nome, 'acenar')
else:
self.enfileirar('acenar')
ganho, texto = 4, 'Acenou para %s' % (nome or 'quem voltou')
else:
return None
with self.lock:
n['social'] = _limitar(n['social'] + ganho)
self.anotar('social', texto, agora)
self.console('vida', texto, motivo, agora=agora)
self.avisar()
return texto
def reconhecido(self, pessoa, confianca, agora=None):
"""O reconhecimento disse quem está aí (vem da thread do reconhecimento)."""
agora = agora or time.time()
nome = pessoa['nome']
self.ultimo_reconhecido = (pessoa['id'], nome, agora)
antes = pessoa.get('visto_antes')
pendente = self.reencontro_pendente
if pendente: # chegou alguém e agora sabe quem é
self.reencontro_pendente = None
ausencia = agora - antes if antes else pendente[1]
return self.reencontro(agora, ausencia, nome, pessoa.get('afinidade', 0))
if antes and agora - antes > OI_DE_NOVO: # chegou outra pessoa com alguém já por perto
return self.reencontro(agora, agora - antes, nome, pessoa.get('afinidade', 0))
return None
def pedir_algo(self, agora):
"""Iniciativa com motivo: pede o que está faltando (ou mostra o que fez)."""
n = self.necessidades
if n['social'] < 35:
frase, gesto, porque = 'Tô carente... me faz um carinho?', 'coracao', 'social %d' % n['social']
elif n['diversao'] < 40:
frase, gesto, porque = 'Brinca comigo?', 'acenar', 'diversão %d' % n['diversao']
elif (float(self.emojis.get('ultimo') or 0) > self.figurinha_mostrada and agora - float(self.emojis['ultimo']) < 3 * 3600
and self.tocar_reacao(agora, anotar=False)):
self.figurinha_mostrada = float(self.emojis['ultimo'])
frase, gesto, porque = 'Olha o que eu desenhei!', None, 'criou uma figurinha e ainda não tinha mostrado'
elif n['energia'] < 35:
frase, gesto, porque = 'Tô com soninho...', 'bocejar', 'energia %d' % n['energia']
else:
return None
self.pediu_em = agora
self.dizer(frase, gesto)
self.anotar('pedido', 'Pediu: "%s"' % frase, agora)
self.porque = 'alguém por perto e ' + porque
return 'pedido'
def pir_acorda(self, agora=None): def pir_acorda(self, agora=None):
"""Disparo do PIR com o rosto dormindo: acorda? No ritmo calmo precisa de vários em 2 min.""" """Disparo do PIR com o rosto dormindo: acorda? No ritmo calmo precisa de vários em 2 min."""
agora = agora or time.time() agora = agora or time.time()
@@ -423,8 +591,27 @@ class Vida:
"""Quanto o aprendizado multiplica a chance de um ato (1 = sem opinião).""" """Quanto o aprendizado multiplica a chance de um ato (1 = sem opinião)."""
return self.aprendizado.peso(nome) if self.aprendizado else 1.0 return self.aprendizado.peso(nome) if self.aprendizado else 1.0
def jeito(self, gesto):
"""Quanto a personalidade puxa um gesto (1 = neutro): brincalhão ri mais, tímido se esconde mais."""
f = 1.0
for traco, gestos in GESTOS_TRACO.items():
if gesto in gestos:
f *= 1 + (self.personalidade[traco] - 0.5) * 1.2
return f
def amadurecer(self, tipo):
"""O convívio muda o jeito devagar: carinho deixa menos tímido, brincar deixa mais brincalhão."""
p = self.personalidade
with self.lock:
if tipo in ('carinho', 'conversa', 'brincar'):
p['timidez'] = round(max(0.1, p['timidez'] - 0.003), 3)
if tipo == 'brincar':
p['brincadeira'] = round(min(0.95, p['brincadeira'] + 0.002), 3)
if tipo == 'conversa':
p['curiosidade'] = round(min(0.95, p['curiosidade'] + 0.001), 3)
def escolher(self, opcoes): def escolher(self, opcoes):
opcoes = [(g, p * self.gosto(g), t) for g, p, t in opcoes] opcoes = [(g, p * self.gosto(g) * self.jeito(g), t) for g, p, t in opcoes]
total = sum(p for _, p, _ in opcoes) total = sum(p for _, p, _ in opcoes)
x = self.rng.uniform(0, total) x = self.rng.uniform(0, total)
for gesto, peso, texto in opcoes: for gesto, peso, texto in opcoes:
@@ -467,42 +654,48 @@ class Vida:
self.anotar('emoji', 'Teve uma ideia e desenhou ' + tema, agora) self.anotar('emoji', 'Teve uma ideia e desenhou ' + tema, agora)
return True return True
def tocar_reacao(self, agora): def tocar_reacao(self, agora, anotar=True):
try: try:
reacoes = [r for r in self.treinamento.status()['reacoes'] if r.get('ativa', True) and r['feedback'] >= 0] reacoes = [r for r in self.treinamento.status()['reacoes'] if r.get('ativa', True) and r['feedback'] >= 0]
except Exception: except Exception:
return False return False
if not reacoes: if not reacoes:
return False return False
r = self.rng.choice(reacoes[:40]) r = self.rng.choice(reacoes[:40]) if anotar else reacoes[0] # para mostrar: a mais nova
try: try:
self.treinamento.testar(r['id']) self.treinamento.testar(r['id'])
except ValueError: except ValueError:
return False return False
self.anotar('acao', 'Lembrou da reação "' + r['receita'].get('nome', '?') + '"', agora) if anotar:
self.anotar('acao', 'Lembrou da reação "' + r['receita'].get('nome', '?') + '"', agora)
return True return True
def agir(self, agora): def agir(self, agora):
"""Um ato: o que um bichinho com essas necessidades faria agora.""" """Um ato: o que um bichinho com essas necessidades faria agora."""
n = self.necessidades n = self.necessidades
perto = self.alguem_perto() perto = self.alguem_perto()
self.porque = ''
if self.dormindo(): if self.dormindo():
# de madrugada nunca acorda sozinho (nem com gente se mexendo na cama); de dia, só descansado # 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): if n['energia'] >= DESCANSADO and not self.madrugada(agora):
self.acordar(agora, 'Acordou descansado') self.acordar(agora, 'Acordou descansado')
self.porque = 'energia %d: descansou' % n['energia']
return 'acordar' return 'acordar'
# aprendeu a rotina: acorda do cochilo um pouco antes da hora em que costuma ter gente # aprendeu a rotina: acorda do cochilo um pouco antes da hora em que costuma ter gente
if (self.aprendizado and not self.sono_profundo and not self.madrugada(agora) and n['energia'] >= 50 if (self.aprendizado and not self.sono_profundo and not self.madrugada(agora) and n['energia'] >= 50
and self.aprendizado.chega_logo(agora)): and self.aprendizado.chega_logo(agora)):
self.acordar(agora, 'Acordou: daqui a pouco costuma chegar alguém') self.acordar(agora, 'Acordou: daqui a pouco costuma chegar alguém')
self.aprendizado.decidir('acordar', 'Acordou do cochilo antes da hora', self.aprendizado.porque_chegada(agora), agora) self.porque = self.aprendizado.porque_chegada(agora)
self.aprendizado.decidir('acordar', 'Acordou do cochilo antes da hora', self.porque, agora)
return 'acordar' return 'acordar'
return None return None
if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO: if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO:
self.dormir(agora, 'Ficou tarde, foi dormir') self.dormir(agora, 'Ficou tarde, foi dormir')
self.porque = 'madrugada e ninguém interagiu nos últimos 10 min'
return 'dormir' return 'dormir'
if n['energia'] < SONO: if n['energia'] < SONO:
self.dormir(agora, 'Ficou exausto e foi dormir', profundo=True) self.dormir(agora, 'Ficou exausto e foi dormir', profundo=True)
self.porque = 'energia %d: abaixo de %d, sono pesado até descansar' % (n['energia'], SONO)
return 'dormir' return 'dormir'
humor = self.humor() humor = self.humor()
if self.celular_atividade(): if self.celular_atividade():
@@ -511,9 +704,11 @@ class Vida:
folga = agora - self.atividade_em > 90 # um respiro entre uma atividade e outra 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) # 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: if dia and folga and n['energia'] < COCHILO and self.rng.random() < 0.35:
self.porque = 'energia %d: abaixo de %d, um cochilo' % (n['energia'], COCHILO)
return self.comecar_atividade(agora, 'dormir', self.rng.uniform(120, 240)) return self.comecar_atividade(agora, 'dormir', self.rng.uniform(120, 240))
# entediado: vai fazer alguma coisa por alguns minutos (pode ser interrompido) # 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: if dia and folga and n['diversao'] < TEDIO and self.rng.random() < 0.75:
self.porque = 'diversão %d: entediado (abaixo de %d)' % (n['diversao'], TEDIO)
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(120, 300)) 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...) # ninguém olhando há um tempo: vai cuidar da vida dele (ler, pintar, música...)
if perto: if perto:
@@ -523,8 +718,9 @@ class Vida:
if (dia and folga and sozinho and self.aprendizado and n['energia'] < 85 if (dia and folga and sozinho and self.aprendizado and n['energia'] < 85
and self.aprendizado.casa_vazia(agora) and self.rng.random() < 0.3): and self.aprendizado.casa_vazia(agora) and self.rng.random() < 0.3):
segundos = self.rng.uniform(600, 1200) segundos = self.rng.uniform(600, 1200)
self.porque = self.aprendizado.porque_vazia(agora)
self.aprendizado.decidir('cochilo', 'Tirou um cochilo de %d min para guardar energia' % round(segundos / 60), self.aprendizado.decidir('cochilo', 'Tirou um cochilo de %d min para guardar energia' % round(segundos / 60),
self.aprendizado.porque_vazia(agora), agora) self.porque, agora)
return self.comecar_atividade(agora, 'dormir', segundos) return self.comecar_atividade(agora, 'dormir', segundos)
# alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!" # alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!"
if dia and perto and humor != 'bravo': if dia and perto and humor != 'bravo':
@@ -533,26 +729,41 @@ class Vida:
self.contar_aprendi(item['id'], agora) self.contar_aprendi(item['id'], agora)
with self.lock: with self.lock:
n['social'] = _limitar(n['social'] + 5) n['social'] = _limitar(n['social'] + 5)
self.porque = 'alguém por perto e aprendeu "%s" hoje' % item.get('nome', '?')
return 'aprendi' return 'aprendi'
# iniciativa: com gente por perto, pede o que está faltando (tímido pede menos)
if (dia and perto and not sozinho and agora - self.pediu_em > INTERVALO_PEDIDO
and self.acaso.random() < 0.25 * (1.3 - self.personalidade['timidez'])):
pedido = self.pedir_algo(agora)
if pedido:
return pedido
if dia and folga and sozinho and self.rng.random() < 0.4: if dia and folga and sozinho and self.rng.random() < 0.4:
self.porque = 'ninguém por perto há %s: vai cuidar da vida dele' % self.tempo(agora - self.visto_em)
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(60, 180)) 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 # 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: if dia and folga and humor in ('feliz', 'tranquilo') and self.rng.random() < 0.1:
self.porque = 'de bem (%s): vontade própria' % humor
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(40, 90)) 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) # 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 not self.motivo_sem_emoji(agora) and self.celular_online() and self.rng.random() < 0.08:
if self.criar_figurinha(agora, humor): if self.criar_figurinha(agora, humor):
self.porque = 'teve uma ideia (até %d figurinhas por dia)' % self.config['max_emojis_dia']
return 'figurinha' return 'figurinha'
# às vezes lembra de uma reação da biblioteca # à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.treinamento is not None and humor in ('feliz', 'tranquilo', 'entediado') and self.rng.random() < 0.15:
if self.tocar_reacao(agora): if self.tocar_reacao(agora):
with self.lock: with self.lock:
n['diversao'] = _limitar(n['diversao'] + 8) n['diversao'] = _limitar(n['diversao'] + 8)
self.porque = 'humor %s: lembrou de uma reação da biblioteca' % humor
return 'reacao' return 'reacao'
lista = ATOS['carente_perto'] if humor == 'carente' and perto else ATOS.get(humor, ATOS['tranquilo']) 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 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] lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3]
gesto, texto = self.escolher(lista) gesto, texto = self.escolher(lista)
self.porque = 'humor %s%s%s' % (
humor, ' com gente por perto' if perto else ', sozinho' if sozinho else '',
'; gosto %.1f×, jeito %.1f×' % (self.gosto(gesto), self.jeito(gesto))
if (self.gosto(gesto), round(self.jeito(gesto), 1)) != (1.0, 1.0) else '')
if self.aprendizado and perto: if self.aprendizado and perto:
self.aprendizado.escolheu(gesto, agora) self.aprendizado.escolheu(gesto, agora)
self.enfileirar(gesto) self.enfileirar(gesto)
@@ -588,6 +799,28 @@ class Vida:
self.sono_profundo = False self.sono_profundo = False
self.enfileirar('sono leve') self.enfileirar('sono leve')
self.anotar('sono', 'Acordou antes de descansar', agora) self.anotar('sono', 'Acordou antes de descansar', agora)
perto = self.alguem_perto()
if perto:
if not self.perto_antes:
if self.reconhece():
self.reencontro_pendente = (agora, agora - self.ultimo_perto) # espera a foto dizer quem é
else:
self.reencontro(agora, agora - self.ultimo_perto)
self.ultimo_perto = agora
self.perto_antes = perto
pendente = self.reencontro_pendente
if pendente and agora - pendente[0] >= ESPERA_NOME:
self.reencontro_pendente = None
self.reencontro(agora, pendente[1])
if self.desistencia and agora >= self.desistencia[0]:
_, oque = self.desistencia
self.desistencia = None
if not self.dormindo():
self.enfileirar('bocejar')
acao = oque[4:] if oque.startswith('Foi ') else 'a atividade'
texto = 'Largou no meio: cansou de ' + acao
self.anotar('acao', texto, agora)
self.console('vida', texto, 'perdeu a graça (curiosidade %.2f)' % self.personalidade['curiosidade'], agora=agora)
if not self.bloqueado(): if not self.bloqueado():
# o celular escolhe os gestos soltos pelo humor; reenvia de tempos em tempos (pode ter reiniciado) # o celular escolhe os gestos soltos pelo humor; reenvia de tempos em tempos (pode ter reiniciado)
zanga = self.zanga() zanga = self.zanga()
@@ -600,8 +833,14 @@ class Vida:
self.humor_enviado = (humor, agora) self.humor_enviado = (humor, agora)
self.zanga_enviada = zanga self.zanga_enviada = zanga
if agora >= self.proximo_ato: if agora >= self.proximo_ato:
antes = self.diario[0]['horario'] if self.diario else None
ato = self.agir(agora) ato = self.agir(agora)
self.aprender_ato(ato, agora) self.aprender_ato(ato, agora)
if ato:
novo = self.diario[0] if self.diario and self.diario[0]['horario'] != antes else None
self.ultima = dict(horario=agora, texto=novo['texto'] if novo else ato, porque=self.porque)
self.console('vida', novo['texto'] if novo else ato,
(self.porque + ' · ' if self.porque else '') + self.resumo(), agora=agora)
lo, hi = RITMOS[self.config['ritmo']] lo, hi = RITMOS[self.config['ritmo']]
self.proximo_ato = agora + self.rng.uniform(lo, hi) self.proximo_ato = agora + self.rng.uniform(lo, hi)
if ato or agora - self.publicado_em > 30 or humor != self.humor(): if ato or agora - self.publicado_em > 30 or humor != self.humor():
+7 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte'; import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Terminal, Users, Video } from '@lucide/svelte';
import { onMount, type Component } from 'svelte'; import { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte'; import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte'; import EStop from './lib/componentes/EStop.svelte';
@@ -21,6 +21,8 @@
import Guia from './telas/Guia.svelte'; import Guia from './telas/Guia.svelte';
import Criar from './telas/Criar.svelte'; import Criar from './telas/Criar.svelte';
import DiaADia from './telas/DiaADia.svelte'; import DiaADia from './telas/DiaADia.svelte';
import Console from './telas/Console.svelte';
import Pessoas from './telas/Pessoas.svelte';
import Ajuda from './lib/componentes/Ajuda.svelte'; import Ajuda from './lib/componentes/Ajuda.svelte';
import { TOPICO_DA_TELA } from './lib/guia'; import { TOPICO_DA_TELA } from './lib/guia';
@@ -35,10 +37,14 @@
guia: Guia, guia: Guia,
criar: Criar, criar: Criar,
'dia-a-dia': DiaADia, 'dia-a-dia': DiaADia,
console: Console,
pessoas: Pessoas,
}; };
const ICONES: Record<string, Component<any>> = { const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video, 'ao-vivo': Video,
eventos: Bell, eventos: Bell,
console: Terminal,
pessoas: Users,
conversa: MessageCircle, conversa: MessageCircle,
reacoes: Smile, reacoes: Smile,
movimento: Hand, movimento: Hand,
@@ -81,6 +81,14 @@
vista = 'plano'; vista = 'plano';
} }
} }
// horário de dormir: o que a pessoa escolheu e o que ele ajustou pela rotina da casa
const sonoTexto = $derived.by(() => {
if (!a) return '';
if (!(a.config.ajustar_sono ?? true)) return 'Desligado: dorme sempre no horário dos Ajustes';
const b = a.sono_base, x = a.sono_auto;
if (b && x && (b[0] !== x[0] || b[1] !== x[1])) return `Dorme das ${x[0]}h às ${x[1]}h pela rotina (você escolheu ${b[0]}h às ${b[1]}h)`;
return 'Com 5 dias de rotina, atrasa ou adianta até 3 h / 2 h, uma hora por noite';
});
</script> </script>
<Grupo <Grupo
@@ -148,6 +156,10 @@
{:else if a.sono.some((n) => n > 0)} {:else if a.sono.some((n) => n > 0)}
<p class="nota">☾ Em algumas horas ele acordou à toa: ali o sensor precisa de mais movimento para acordá-lo.</p> <p class="nota">☾ Em algumas horas ele acordou à toa: ali o sensor precisa de mais movimento para acordá-lo.</p>
{/if} {/if}
<div class="ajustes-ia">
<Interruptor rotulo="Ajustar o horário de dormir pela rotina" checked={a.config.ajustar_sono ?? true} onchange={(l) => configurarAprendizado({ ajustar_sono: l })} />
<span>{sonoTexto}</span>
</div>
</section> </section>
<section class="bloco" aria-labelledby="ap-rotina"> <section class="bloco" aria-labelledby="ap-rotina">
+160
View File
@@ -0,0 +1,160 @@
<script lang="ts">
import { Square, Video } from '@lucide/svelte';
import { salvarApp } from '../ajustes.svelte';
import { estado } from '../estado.svelte';
import { JOGOS, jogos } from '../jogos.svelte';
import type { Rastreamento } from '../rastreamento.svelte';
import Grupo from './Grupo.svelte';
// Jogos com a mão no Ao vivo: precisam da câmera transmitindo e do rastreamento de mãos (e de rosto
// para o "Achou!"), que rodam aqui no navegador.
let { rastreio }: { rastreio?: Rastreamento } = $props();
$effect(() => rastreio?.aoResultado((r) => jogos.alimentar(r)));
const cfg = $derived(estado.appConfig);
const pronto = $derived(!!cfg?.preview && !!cfg?.rastrearMaos && !!cfg?.rastrearRosto);
const atual = $derived(JOGOS.find((j) => j.id === jogos.ativo));
</script>
<Grupo titulo="Jogos com a mão" ajuda="jogos" rodape={atual ? atual.como : 'A câmera do celular lê a sua mão aqui no navegador; nada é gravado.'}>
{#if !pronto}
<div class="aviso">
<p>Para jogar, a câmera precisa estar transmitindo e o rastreamento de mãos e de rosto ligado.</p>
<button class="btn pequeno primario" disabled={!cfg} onclick={() => salvarApp({ preview: true, rastrearMaos: true, rastrearRosto: true })}>
<Video size={14} /> Ligar câmera e rastreamento
</button>
</div>
{:else if jogos.ativo}
<div class="jogando">
<div class="cabeca">
<strong>{atual?.emoji} {atual?.nome}</strong>
<button class="btn pequeno" onclick={() => jogos.parar()}><Square size={12} /> Parar</button>
</div>
<p class="fase">{jogos.fase || 'Começando…'}</p>
<p class="vendo">A câmera vê: <strong>{jogos.vendo || '…'}</strong></p>
{#if jogos.destaque}
{@const d = jogos.destaque}
<div class="destaque tom-{d.tom ?? 'neutro'}">
{#if d.lados?.length}
<div class="lados">
{#each d.lados as l, i (i)}
{#if i}<span class="x">×</span>{/if}
<span class="lado"><small>{l.rotulo}</small><b>{l.simbolo}</b><small>{l.nome}</small></span>
{/each}
</div>
{/if}
{#if d.titulo}<strong class="titulo">{d.titulo}</strong>{/if}
{#if d.sub}<small>{d.sub}</small>{/if}
</div>
{/if}
{#if jogos.ativo === 'ppt'}
<p class="placar"><span>Você {jogos.placar.voce}</span> × <span>{jogos.placar.carinha} Carinha</span></p>
{/if}
</div>
{:else}
<div class="lista">
{#each JOGOS as j (j.id)}
<button class="jogo" onclick={() => jogos.iniciar(j.id)}>
<span class="emoji" aria-hidden="true">{j.emoji}</span>
<span class="nome">{j.nome}</span>
</button>
{/each}
</div>
{/if}
</Grupo>
<style>
.aviso, .jogando {
display: grid;
gap: var(--e-2);
padding: var(--e-3) var(--e-4);
}
.aviso p, .jogando p {
margin: 0;
font-size: var(--t-sm);
color: var(--texto-2);
}
.aviso .btn {
justify-self: start;
}
.cabeca {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--e-2);
}
.fase {
font-weight: 600;
color: var(--texto) !important;
}
.placar {
font-size: var(--t-lg) !important;
font-variant-numeric: tabular-nums;
color: var(--texto) !important;
}
.destaque {
display: grid;
justify-items: center;
gap: 4px;
padding: var(--e-3);
border-radius: var(--raio);
background: var(--superficie-3);
text-align: center;
}
.destaque .titulo {
font-size: var(--t-xl);
font-weight: 800;
}
.destaque.tom-eu .titulo { color: #d39b00; }
.destaque.tom-voce .titulo { color: var(--ok); }
.destaque .lados {
display: flex;
align-items: center;
gap: var(--e-4);
}
.destaque .lado {
display: grid;
justify-items: center;
}
.destaque .lado b {
font-size: 2.2rem;
line-height: 1.1;
}
.destaque .x {
color: var(--texto-3);
}
.destaque small {
color: var(--texto-2);
}
.lista {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--e-2);
padding: var(--e-3) var(--e-4);
}
.jogo {
display: grid;
justify-items: center;
gap: 4px;
padding: var(--e-3) var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--texto);
font: inherit;
cursor: pointer;
transition: border-color var(--transicao), background var(--transicao);
}
.jogo:hover {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.emoji {
font-size: 1.8rem;
line-height: 1.1;
}
.nome {
font-size: var(--t-sm);
text-align: center;
}
</style>
+147
View File
@@ -0,0 +1,147 @@
<script lang="ts">
import { ChevronRight, UserCheck, UserX } from '@lucide/svelte';
import { onMount } from 'svelte';
import { pessoas, urlRosto } from '../pessoas.svelte';
// "Quem é?": um cartão por pessoa desconhecida (grupo de rostos parecidos), com o rosto grande.
// A resposta vale para todas as fotos do grupo. minimo: só pergunta de quem ele já viu tantas vezes.
let { minimo = 1 }: { minimo?: number } = $props();
onMount(() => {
if (!pessoas.status) pessoas.carregar();
});
const st = $derived(pessoas.status);
let pulados = $state<number[]>([]); // primeiro id de cada grupo pulado nesta tela
let nome = $state('');
let ocupado = $state(false);
const grupo = $derived.by(() => {
if (!st?.disponivel || !st.config.ligado) return null;
return st.grupos.find((ids) => ids.length >= minimo && !pulados.includes(ids[0])) ?? null;
});
const rostos = $derived(grupo && st ? st.sem_nome.filter((r) => grupo.includes(r.id)) : []);
const restantes = $derived(st ? st.grupos.filter((ids) => ids.length >= minimo && !pulados.includes(ids[0])).length : 0);
const capa = $derived(rostos[0] ?? null);
const quando = $derived(capa ? new Date(Math.min(...rostos.map((r) => r.horario)) * 1000).toLocaleDateString('pt-BR') : '');
async function responder(alvo: { pessoa?: number; nome?: string } | null) {
if (!grupo || ocupado) return;
ocupado = true;
const ok = alvo ? await pessoas.marcar([...grupo], alvo) : await pessoas.descartar([...grupo]);
ocupado = false;
if (ok) nome = '';
}
async function salvar(e: SubmitEvent) {
e.preventDefault();
if (nome.trim()) await responder({ nome: nome.trim() });
}
function pular() {
if (grupo) pulados = [...pulados, grupo[0]];
nome = '';
}
</script>
{#if grupo && capa}
<section class="quem" aria-label="Quem é esta pessoa?">
<img class="capa" src={urlRosto(capa.recorte)} alt="Rosto que ele viu e ainda não conhece" />
<div class="corpo">
<h3>Quem é?</h3>
<p>Ele viu esta pessoa {grupo.length} {grupo.length === 1 ? 'vez' : 'vezes'} desde {quando}.</p>
{#if rostos.length > 1}
<div class="mais">
{#each rostos.slice(1, 7) as r (r.id)}<img src={urlRosto(r.recorte)} alt="" loading="lazy" />{/each}
{#if rostos.length > 7}<span>+{rostos.length - 7}</span>{/if}
</div>
{/if}
{#if st?.pessoas.length}
<div class="conhecidos">
{#each st.pessoas as p (p.id)}
<button class="btn pequeno" disabled={ocupado} onclick={() => responder({ pessoa: p.id })}><UserCheck size={14} /> {p.nome}</button>
{/each}
</div>
{/if}
<form class="novo" onsubmit={salvar}>
<input bind:value={nome} placeholder={st?.pessoas.length ? 'Ou alguém novo: nome' : 'Nome'} maxlength="30" aria-label="Nome desta pessoa" />
<button class="btn pequeno primario" type="submit" disabled={!nome.trim() || ocupado}>Salvar</button>
</form>
<div class="rodape">
<button class="btn pequeno fantasma" disabled={ocupado} onclick={() => responder(null)}><UserX size={14} /> Não é ninguém</button>
<button class="btn pequeno fantasma" onclick={pular}>Pular <ChevronRight size={14} /></button>
{#if restantes > 1}<small>mais {restantes - 1} para conhecer</small>{/if}
</div>
</div>
</section>
{/if}
<style>
.quem {
display: grid;
grid-template-columns: 132px minmax(0, 1fr);
gap: var(--e-4);
align-items: start;
padding: var(--e-4);
border: 1px solid var(--destaque);
border-radius: var(--raio-lg);
background: var(--destaque-suave);
}
@media (max-width: 520px) {
.quem {
grid-template-columns: minmax(0, 1fr);
justify-items: center;
text-align: center;
}
.mais, .conhecidos, .rodape {
justify-content: center;
}
}
.capa {
width: 132px;
height: 132px;
border-radius: var(--raio);
object-fit: cover;
background: var(--superficie-3);
}
.corpo {
display: grid;
gap: var(--e-2);
min-width: 0;
}
h3 {
margin: 0;
font-size: var(--t-lg);
}
p {
margin: 0;
color: var(--texto-2);
font-size: var(--t-sm);
}
.mais {
display: flex;
align-items: center;
gap: 4px;
}
.mais img {
width: 34px;
height: 34px;
border-radius: 50%;
object-fit: cover;
}
.mais span {
font-size: var(--t-xs);
color: var(--texto-3);
}
.conhecidos, .novo, .rodape {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
align-items: center;
}
.novo input {
flex: 1;
min-width: 140px;
}
.rodape small {
color: var(--texto-3);
font-size: var(--t-xs);
}
</style>
+1 -1
View File
@@ -188,7 +188,7 @@ function abrirEventos() {
if (estado.conexao !== 'token') estado.conexao = 'caiu'; if (estado.conexao !== 'token') estado.conexao = 'caiu';
}; };
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia', for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao']) { 'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao', 'console', 'pessoas']) {
fonte.addEventListener(tipo, (e) => { fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data); const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados); tratadores[tipo]?.(dados);
+64 -2
View File
@@ -229,6 +229,64 @@ export const TOPICOS: Record<string, TopicoGuia> = {
{ 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).' }, { 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).' },
], ],
}, },
console: {
titulo: 'Console',
emoji: '🖥️',
onde: 'Console (menu lateral)',
resumo:
'Tudo o que ele faz, ao vivo, com o porquê. Em cima, o agora: humor, necessidades, o que está fazendo, quando decide de novo e o jeito dele. Embaixo, cada decisão com o motivo (ex.: "Ficou exausto e foi dormir, porque energia 12: abaixo de 20"). Os filtros escolhem de onde vêm as linhas; "Detalhes" mostra também o que se repete muito (humor reenviado, disparo solto do sensor). Pausar segura a tela para ler.',
itens: [
{ emoji: '🫀', nome: 'Modo vida', texto: 'As decisões dele: dormir, atividades, gestos, pedidos, reencontros.' },
{ emoji: '👀', nome: 'Sentidos', texto: 'Carinho, toque, conversa, presença, som e movimento, com o efeito nas necessidades.' },
{ emoji: '📱', nome: 'Celular', texto: 'O que foi mandado ao rosto e o que o rosto fez (dormiu, acordou, começou atividade).' },
{ emoji: '🧠', nome: 'IA local / Aprendizado / Ideias', texto: 'Escolhas da IA do S10, decisões tiradas da rotina e as ideias do dia.' },
],
},
pessoas: {
titulo: 'Reconhecimento de pessoas',
emoji: '🧑‍🤝‍🧑',
onde: 'Pessoas (menu lateral)',
resumo:
'Quando o sensor vê alguém, o celular manda uma foto e a placa procura rostos nela (YuNet) e compara com quem ele conhece (SFace). Reconheceu: cumprimenta pelo nome e a saudade é de cada pessoa ("Ana! Senti sua falta!"). Tudo roda e fica na placa; nada vai para a internet.',
itens: [
{ emoji: '👆', nome: 'Ensinar', texto: 'Em "Rostos sem nome", toque nos rostos da mesma pessoa e escolha quem é (ou crie a pessoa). Com 3 a 5 fotos ele já reconhece bem; marcar uma pessoa também reconhece os rostos antigos parecidos.' },
{ emoji: '🔎', nome: 'Procurar nas fotos guardadas', texto: 'Analisa as fotos de eventos que já existem, para cadastrar as pessoas logo de cara.' },
{ emoji: '💗', nome: 'Afinidade', texto: 'Carinho, brincadeira ou conversa logo depois de reconhecer alguém aumenta a afinidade com essa pessoa: quem tem bastante ganha coração na festa.' },
{ emoji: '🌙', nome: 'Limites', texto: 'Precisa de luz e do rosto virado para a câmera do celular. No escuro, ou de costas, ele cumprimenta sem o nome.' },
{ emoji: '🛡️', nome: 'Privacidade', texto: 'Rostos sem nome somem em 7 dias. Apagar uma pessoa apaga as fotos e as impressões dela. Dá para desligar o reconhecimento a qualquer hora.' },
],
},
jogos: {
titulo: 'Jogos com a mão',
emoji: '✌️',
onde: 'Ao vivo › Jogos com a mão',
resumo:
'A câmera do celular vem para o painel e o rastreador lê os 21 pontos de cada mão aqui no navegador. O Carinha fala com legenda, faz gestos e mostra a jogada dele nos olhos. Precisa do Ao vivo aberto com a câmera transmitindo e o rastreamento de mãos e de rosto ligado (o botão do cartão liga tudo). Jogar conta como brincadeira para o modo vida.',
itens: [
{ emoji: '✌️', nome: 'Pedra, papel e tesoura', texto: 'Melhor de três. Ele conta "Pedra... papel... tesoura... já!", mostra ✊ ✋ ou ✌️ nos olhos e lê a sua mão parada. Pedra: mão fechada; papel: mão aberta; tesoura: indicador e médio.' },
{ emoji: '🖐️', nome: 'Quantos dedos?', texto: 'Cinco rodadas: nas ímpares o número aparece nos olhos, nas pares ele só fala. Vale somar as duas mãos.' },
{ emoji: '🙈', nome: 'Achou!', texto: 'Esconda o rosto com as mãos (ele procura: "Cadê?") e apareça: ele ri. Três vezes.' },
{ emoji: '💡', nome: 'Dica', texto: 'Mão inteira na imagem, de frente para a câmera e com luz. Mexer a mão no "Já!" atrapalha: deixe parada um instante.' },
],
},
jeito: {
titulo: 'Jeito de gente',
emoji: '🧒',
onde: 'Modo vida e rosto',
resumo: 'O que faz ele parecer alguém, e não um programa.',
itens: [
{ emoji: '🤗', nome: 'Saudade', texto: 'A festa depende do tempo fora: 10 min, um aceno; 1 h, "Oi! Você voltou!"; 4 h ou mais, "Senti sua falta!".' },
{ emoji: '☀️', nome: 'Bom dia com lembrança', texto: 'No primeiro encontro do dia lembra de ontem: "ontem a gente ficou acordado até as 2h, hein!".' },
{ emoji: '🥱', nome: 'Acordar grogue', texto: 'Depois de dormir um tempo, acorda com os olhos pesados, lento, e boceja antes de ficar esperto.' },
{ emoji: '💭', nome: 'Sonhar', texto: 'Dormindo, às vezes aparece bem fraco um balão com algo do dia (a bola, as estrelas) e ele sorri.' },
{ emoji: '🙈', nome: 'Imperfeição', texto: 'Hesita antes de alguns gestos, erra no meio da atividade e fica sem graça, e às vezes larga uma atividade porque ficou chato.' },
{ emoji: '🙋', nome: 'Pedidos', texto: 'Com alguém perto, pede o que falta ("Brinca comigo?", "Me faz um carinho?") ou mostra a figurinha que desenhou. No máximo um pedido a cada 20 min.' },
{ emoji: '👁️', nome: 'Contato visual', texto: 'Com o rastreamento de rosto, olha nos olhos alguns segundos, desvia um instante e volta.' },
{ emoji: '😊', nome: 'Sorriso de volta', texto: 'Com o Ao vivo aberto e o rastreamento de rosto ligado, se você sorri ele sorri (ou ri) de volta.' },
{ emoji: '💡', nome: 'Luz', texto: 'Apagaram a luz: boceja e fica com sono. Acenderam de repente: aperta os olhos.' },
{ emoji: '🧬', nome: 'Personalidade', texto: 'Curioso, brincalhão e tímido em medidas próprias. Muda o jeito de tudo e amadurece: carinho deixa menos tímido, brincar deixa mais brincalhão.' },
],
},
ideias: { ideias: {
titulo: 'Ideias do dia', titulo: 'Ideias do dia',
emoji: '💡', emoji: '💡',
@@ -279,6 +337,10 @@ export const TOPICOS: Record<string, TopicoGuia> = {
onde: 'Eventos › Modo presença', onde: 'Eventos › Modo presença',
resumo: 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.', '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.',
itens: [
{ emoji: '🎚️', nome: 'Sensibilidade do sensor', texto: 'O HC-SR505 não tem botão de ajuste e dispara por pouca coisa. A bolinha do Ao vivo sempre mostra o sensor, mas só conta como presença (evento, foto, acordar, câmera) o que se confirma. Baixa: 3 disparos em 90 s ou alguém se mexendo por 20 s. Média (padrão): 2 disparos em 1 min ou 12 s. Alta: qualquer disparo.' },
{ emoji: '🩹', nome: 'Ajuste físico', texto: 'Para cortar ainda mais, cubra parte da lente branca com fita (diminui o campo de visão) ou vire o sensor para longe de janelas, ar-condicionado e da cama.' },
],
}, },
tela: { tela: {
titulo: 'Tela e personalidade', titulo: 'Tela e personalidade',
@@ -367,13 +429,13 @@ export const TOPICOS: Record<string, TopicoGuia> = {
/** Ordem do tutorial (página Guia). */ /** Ordem do tutorial (página Guia). */
export const ORDEM_GUIA = [ export const ORDEM_GUIA = [
'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'ideias', 'aparencia', 'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'jeito', 'jogos', 'console', 'pessoas', 'criar', 'ideias', 'aparencia',
'personalidade', 'acontecimentos', 'ligacoes', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade', 'personalidade', 'acontecimentos', 'ligacoes', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
'reacoes', 'conversa', 'movimento', 'atalhos', 'reacoes', 'conversa', 'movimento', 'atalhos',
]; ];
/** Tópico de cada tela, para o ⓘ do topo. */ /** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = { export const TOPICO_DA_TELA: Record<string, string> = {
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes', 'ao-vivo': 'inicio', console: 'console', pessoas: 'pessoas', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado', movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado',
}; };
+1 -1
View File
@@ -13,7 +13,7 @@ export interface EventoHistorico {
} }
export interface StatusHistorico { export interface StatusHistorico {
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number }; config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number; sensibilidade_pir?: 'alta' | 'media' | 'baixa' };
presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number }; presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number };
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null }; pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
eventos: EventoHistorico[]; eventos: EventoHistorico[];
+269
View File
@@ -0,0 +1,269 @@
/**
* Jogos com a mão: a câmera do celular vem para o painel (WebRTC), o rastreador acha os 21 pontos de
* cada mão e o jogo conversa com o Carinha por comandos (fala com legenda, gestos e símbolos nos olhos).
* - Pedra, papel e tesoura: melhor de três, ele mostra a jogada dele nos olhos.
* - Quantos dedos?: ele pede um número e festeja quando você mostra certo.
* - Achou!: você esconde o rosto com as mãos e aparece; ele procura e ri.
*/
import { postar } from './api';
import { avisos } from './avisos.svelte';
import { contarDedos, Estavel, jogada, vence, type Jogada, type Ponto } from './maos';
import { cuidar } from './vida.svelte';
export type IdJogo = 'ppt' | 'dedos' | 'achou';
export const JOGOS: { id: IdJogo; nome: string; emoji: string; como: string }[] = [
{ id: 'ppt', nome: 'Pedra, papel e tesoura', emoji: '✌️', como: 'Melhor de três. No "Já!", mostre a mão parada para a câmera.' },
{ id: 'dedos', nome: 'Quantos dedos?', emoji: '🖐️', como: 'Ele pede um número e você mostra com os dedos. Cinco rodadas.' },
{ id: 'achou', nome: 'Achou!', emoji: '🙈', como: 'Esconda o rosto com as mãos e depois apareça. Três vezes.' },
];
const SIMBOLO: Record<Jogada, string> = { pedra: '✊', papel: '✋', tesoura: '✌️' };
const NOME: Record<Jogada, string> = { pedra: 'pedra', papel: 'papel', tesoura: 'tesoura' };
class Parou extends Error {}
export interface Telao {
titulo?: string;
sub?: string;
tom?: 'neutro' | 'eu' | 'voce' | 'empate' | 'conta';
piscar?: boolean;
segundos?: number;
lados?: { rotulo: string; simbolo: string; nome: string }[];
}
class Jogos {
ativo = $state<IdJogo | null>(null);
fase = $state(''); // o que está acontecendo agora (aparece no painel)
vendo = $state(''); // o que a câmera está lendo da sua mão
placar = $state({ voce: 0, carinha: 0 });
/** O que o telão do Carinha está mostrando (o painel mostra igual). */
destaque = $state<Telao | null>(null);
private rodada = 0;
private maos: Ponto[][] = [];
private faces = 0;
private visto = 0; // quando chegou a última leitura
private jogadaEstavel = new Estavel<Jogada>(700, 0.7);
private dedosEstavel = new Estavel<number>(800, 0.75);
/** Cada leitura do rastreador (chamado pelo Ao vivo). */
alimentar(r: { faces: unknown[]; maos: Ponto[][] }) {
this.maos = r.maos;
this.faces = r.faces.length;
this.visto = performance.now();
const j = this.maos.length ? jogada(this.maos[0]) : null;
const d = this.maos.reduce((s, m) => s + contarDedos(m), 0);
this.jogadaEstavel.ver(j);
this.dedosEstavel.ver(this.maos.length ? d : null);
if (!this.ativo) return;
this.vendo = !this.maos.length ? (this.faces ? 'rosto, sem mão' : 'ninguém') :
this.ativo === 'ppt' ? (j ? `${SIMBOLO[j]} ${NOME[j]}` : 'mão (não entendi a jogada)') : `${d} dedo${d === 1 ? '' : 's'}`;
}
// ------------------------------------------------------------ falar com o Carinha
private async ordem(tipo: string, valor: unknown) {
try { await postar('/comando', { tipo, valor }); } catch { /* um comando perdido não para o jogo */ }
}
private dizer = (texto: string) => this.ordem('dizer', texto);
private gesto = (nome: string) => this.ordem('gesto', nome);
private olhos = (simbolo: string, segundos = 2.5) => this.ordem('olhos', { simbolo, segundos });
/** Letreiro grande por cima do rosto (e no cartão do painel). */
private telao(t: Telao) {
this.destaque = t;
return this.ordem('telao', t);
}
private esperar(ms: number) {
return new Promise<void>((ok, nao) => {
const id = this.ativo;
setTimeout(() => (this.ativo === id ? ok() : nao(new Parou())), ms);
});
}
/** Espera até a condição valer (checando a cada 100 ms); false se passar do tempo. */
private async ate(cond: () => boolean, ms: number) {
const fim = performance.now() + ms;
while (performance.now() < fim) {
if (cond()) return true;
await this.esperar(100);
}
return false;
}
private semCamera = () => performance.now() - this.visto > 3000;
// ------------------------------------------------------------ começar e parar
async iniciar(id: IdJogo) {
if (this.ativo) return;
if (this.semCamera()) {
avisos.erro(new Error('Ligue a câmera e o rastreamento de mãos aqui no Ao vivo para jogar.'));
return;
}
this.ativo = id;
this.placar = { voce: 0, carinha: 0 };
this.rodada = 0;
await this.gesto('acordar');
try {
if (id === 'ppt') await this.pedraPapelTesoura();
else if (id === 'dedos') await this.quantosDedos();
else await this.achou();
cuidar('brincar'); // brincar conta para o modo vida
} catch (e) {
if (!(e instanceof Parou)) avisos.erro(e);
} finally {
this.ativo = null;
this.fase = '';
this.destaque = null;
this.olhos('', 0.5);
}
}
parar() {
if (!this.ativo) return;
this.ativo = null;
this.dizer('Tá bom, depois a gente brinca!');
}
// ------------------------------------------------------------ pedra, papel e tesoura
private async pedraPapelTesoura() {
this.fase = 'Explicando';
await this.dizer('Vamos jogar pedra, papel e tesoura! Melhor de três.');
await this.gesto('alegre');
await this.esperar(2500);
while (this.placar.voce < 2 && this.placar.carinha < 2) {
if (!(await this.ate(() => this.maos.length > 0, 8000))) {
this.fase = 'Esperando a sua mão';
await this.dizer('Cadê sua mão? Mostra pra câmera!');
if (!(await this.ate(() => this.maos.length > 0, 12000))) {
await this.dizer('Tudo bem, a gente joga depois!');
await this.gesto('triste');
return;
}
}
this.rodada++;
this.fase = `Rodada ${this.rodada}: contando`;
const palavras = ['Pedra...', 'Papel...', 'Tesoura...'];
for (let i = 0; i < 3; i++) {
await this.telao({ titulo: String(3 - i), sub: palavras[i], tom: 'conta', piscar: true, segundos: 1.0 });
await this.dizer(palavras[i]);
await this.esperar(850);
}
const minha = (['pedra', 'papel', 'tesoura'] as Jogada[])[Math.floor(Math.random() * 3)];
this.jogadaEstavel.limpar();
await this.olhos(SIMBOLO[minha], 3.5);
await this.telao({ titulo: 'JÁ!', sub: 'Mostra a mão parada!', tom: 'conta', segundos: 1.8 });
await this.dizer('Já!');
this.fase = `Rodada ${this.rodada}: lendo a sua mão`;
const leitura = { v: null as Jogada | null };
await this.ate(() => (leitura.v = this.jogadaEstavel.valor()) !== null, 2200);
const sua = leitura.v;
if (!sua) {
await this.telao({ titulo: '?', sub: 'Não vi sua mão... de novo!', segundos: 2.2 });
await this.dizer('Não vi direito... de novo!');
await this.gesto('confuso');
await this.esperar(2200);
continue;
}
const r = vence(sua, minha);
if (r > 0) this.placar.voce++;
else if (r < 0) this.placar.carinha++;
await this.telao({
titulo: r > 0 ? 'VOCÊ GANHOU!' : r < 0 ? 'VENCI!!!' : 'EMPATE!',
tom: r > 0 ? 'voce' : r < 0 ? 'eu' : 'empate',
sub: `Você ${this.placar.voce} × ${this.placar.carinha} Carinha`,
segundos: 3.4,
lados: [{ rotulo: 'Você', simbolo: SIMBOLO[sua], nome: NOME[sua] }, { rotulo: 'Carinha', simbolo: SIMBOLO[minha], nome: NOME[minha] }],
});
if (r > 0) {
await this.dizer(`${NOME[sua]} ganha de ${NOME[minha]}... Você ganhou essa!`);
await this.gesto('triste');
} else if (r < 0) {
await this.dizer(`${NOME[minha]} ganha de ${NOME[sua]}! Essa é minha!`);
await this.gesto('comemorar');
} else {
await this.dizer(`Os dois ${NOME[minha]}! Empate!`);
await this.gesto('surpreso');
}
this.fase = `Você ${this.placar.voce} × ${this.placar.carinha} Carinha`;
await this.esperar(3200);
}
const ganhou = this.placar.voce > this.placar.carinha;
await this.telao({ titulo: ganhou ? 'VOCÊ É O CAMPEÃO!' : 'EU SOU O CAMPEÃO!!!', tom: ganhou ? 'voce' : 'eu', piscar: true,
sub: `Você ${this.placar.voce} × ${this.placar.carinha} Carinha`, segundos: 4.5 });
if (ganhou) {
await this.dizer(`Você ganhou de ${this.placar.voce} a ${this.placar.carinha}! Revanche depois, hein?`);
await this.gesto('rir');
} else {
await this.dizer(`Ganhei de ${this.placar.carinha} a ${this.placar.voce}! Foi divertido!`);
await this.gesto('comemorar');
}
await this.esperar(2500);
}
// ------------------------------------------------------------ quantos dedos?
private async quantosDedos() {
await this.dizer('Vamos brincar de contar! Mostra os dedos que eu pedir.');
await this.gesto('feliz');
await this.esperar(2500);
let anterior = 0;
for (this.rodada = 1; this.rodada <= 5; this.rodada++) {
let alvo = 1 + Math.floor(Math.random() * 5);
if (alvo === anterior) alvo = (alvo % 5) + 1;
anterior = alvo;
const mostra = this.rodada % 2 === 1; // rodadas ímpares: o número aparece nos olhos
this.fase = `Rodada ${this.rodada} de 5: pediu ${alvo}`;
this.dedosEstavel.limpar();
if (mostra) await this.olhos(String(alvo), 6);
await this.telao(mostra ? { titulo: String(alvo), sub: 'Mostra esse tanto de dedos!', tom: 'conta', segundos: 3 }
: { titulo: '?', sub: `Mostra ${alvo} ${alvo === 1 ? 'dedo' : 'dedos'}!`, segundos: 3 });
await this.dizer(mostra ? 'Mostra esse tanto de dedos!' : `Mostra ${alvo} ${alvo === 1 ? 'dedo' : 'dedos'}!`);
const acertou = await this.ate(() => this.dedosEstavel.valor() === alvo, 9000);
await this.telao(acertou ? { titulo: '✔ ACERTOU!', tom: 'voce', sub: `${alvo} ${alvo === 1 ? 'dedo' : 'dedos'}`, segundos: 2.6 }
: { titulo: `ERAM ${alvo}!`, tom: 'empate', sub: 'Vamos pro próximo', segundos: 2.6 });
if (acertou) {
this.placar.voce++;
await this.dizer(['Isso!', 'Acertou!', 'Muito bem!', 'Boa!'][this.rodada % 4]);
await this.gesto('comemorar');
} else {
await this.dizer(`Eram ${alvo}! Vamos pro próximo.`);
await this.gesto('pensar');
}
this.fase = `Acertos: ${this.placar.voce} de ${this.rodada}`;
await this.esperar(2800);
}
await this.dizer(this.placar.voce >= 4 ? `Você acertou ${this.placar.voce} de 5! Craque!` : `Você acertou ${this.placar.voce} de 5. Vamos treinar mais!`);
await this.gesto(this.placar.voce >= 4 ? 'coracao' : 'feliz');
await this.esperar(2500);
}
// ------------------------------------------------------------ achou!
private async achou() {
await this.dizer('Esconde o rosto com as mãos e depois aparece!');
await this.gesto('alegre');
await this.esperar(2000);
for (this.rodada = 1; this.rodada <= 3; this.rodada++) {
this.fase = `Rodada ${this.rodada} de 3: esperando você se esconder`;
if (!(await this.ate(() => this.faces > 0, 10000))) {
await this.dizer('Cadê você? Fica na frente da câmera!');
if (!(await this.ate(() => this.faces > 0, 10000))) return;
}
// escondeu: rosto some e as mãos aparecem
if (!(await this.ate(() => this.faces === 0 && this.maos.length > 0, 15000))) {
await this.dizer('Esconde o rosto com as mãos!');
if (!(await this.ate(() => this.faces === 0 && this.maos.length > 0, 15000))) return;
}
this.fase = 'Você se escondeu';
await this.gesto('espiar');
await this.dizer(['Cadê? Cadê?', 'Sumiu!', 'Onde você foi?'][this.rodada - 1]);
// apareceu de novo
if (await this.ate(() => this.faces > 0, 15000)) {
await this.telao({ titulo: 'ACHOU!', tom: 'voce', piscar: true, segundos: 2.2 });
await this.gesto('rir');
await this.dizer('Achou!');
this.placar.voce++;
}
await this.esperar(2500);
}
await this.dizer('Que divertido! Adoro brincar de achou!');
await this.gesto('coracao');
await this.esperar(2000);
}
}
export const jogos = new Jogos();
+61
View File
@@ -0,0 +1,61 @@
/**
* Leitura da mão a partir dos 21 pontos do MediaPipe (0 = pulso; 4, 8, 12, 16, 20 = pontas dos dedos).
* Um dedo está esticado quando a ponta fica bem mais longe do pulso que a junta do meio, o que vale
* em qualquer rotação da mão. O polegar é medido em relação à base do mindinho.
*/
export interface Ponto { x: number; y: number }
export type Jogada = 'pedra' | 'papel' | 'tesoura';
const dist = (a: Ponto, b: Ponto) => Math.hypot(a.x - b.x, a.y - b.y);
/** [polegar, indicador, médio, anelar, mindinho] */
export function dedosEsticados(m: Ponto[]): boolean[] {
if (!m || m.length < 21) return [false, false, false, false, false];
const pulso = m[0];
const dedo = (ponta: number, junta: number) => dist(m[ponta], pulso) > dist(m[junta], pulso) * 1.12;
const polegar = dist(m[4], m[17]) > dist(m[3], m[17]) * 1.08 && dist(m[4], m[5]) > dist(m[3], m[5]) * 1.15;
return [polegar, dedo(8, 6), dedo(12, 10), dedo(16, 14), dedo(20, 18)];
}
export function contarDedos(m: Ponto[]): number {
return dedosEsticados(m).filter(Boolean).length;
}
export function jogada(m: Ponto[]): Jogada | null {
const [, indicador, medio, anelar, mindinho] = dedosEsticados(m);
const n = [indicador, medio, anelar, mindinho].filter(Boolean).length;
if (n === 0) return 'pedra';
if (n >= 4) return 'papel';
if (indicador && medio && !anelar && !mindinho) return 'tesoura';
return null; // mão no meio do caminho: não conta
}
/** Quem ganha: 1 = a, -1 = b, 0 = empate. */
export function vence(a: Jogada, b: Jogada): number {
if (a === b) return 0;
return (a === 'pedra' && b === 'tesoura') || (a === 'tesoura' && b === 'papel') || (a === 'papel' && b === 'pedra') ? 1 : -1;
}
/**
* Leitura estável: guarda o que viu nos últimos instantes e só responde quando a maioria dos quadros
* concorda (mão parada), para não pegar a mão no meio do movimento.
*/
export class Estavel<T> {
private amostras: { t: number; v: T | null }[] = [];
constructor(private janela = 700, private minimo = 0.7) {}
ver(v: T | null, t = performance.now()) {
this.amostras.push({ t, v });
this.amostras = this.amostras.filter((a) => t - a.t <= this.janela);
}
limpar() {
this.amostras = [];
}
valor(): T | null {
if (this.amostras.length < 3) return null;
const conta = new Map<T, number>();
for (const a of this.amostras) if (a.v !== null) conta.set(a.v, (conta.get(a.v) ?? 0) + 1);
let melhor: T | null = null, n = 0;
for (const [v, c] of conta) if (c > n) { melhor = v; n = c; }
return melhor !== null && n / this.amostras.length >= this.minimo ? melhor : null;
}
}
+2
View File
@@ -18,6 +18,8 @@ export interface AlvoRastreio {
x: number; x: number;
y: number; y: number;
atividade?: boolean; atividade?: boolean;
/** 0 a 1: o quanto a pessoa está sorrindo (só rosto; o celular sorri de volta) */
sorriso?: number;
} }
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y); const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
+78
View File
@@ -0,0 +1,78 @@
/**
* Reconhecimento de pessoas (/pessoas): quem ele conhece, os rostos que viu e ainda não sabe de quem
* são, e as ações para ensinar ("esta é a Ana"). Tudo fica na placa. Estado por SSE ('pessoas').
*/
import { lerToken, pedir, postar } from './api';
import { avisos, tentar } from './avisos.svelte';
import { ouvir } from './estado.svelte';
export interface FotoRosto { id: number; recorte: string }
export interface Pessoa {
id: number;
nome: string;
visto: number | null;
vezes: number;
afinidade: number;
amostras: number;
fotos: FotoRosto[];
}
export interface RostoSemNome { id: number; horario: number; evento: number; recorte: string; parecido: number; grupo: number }
export interface StatusPessoas {
config: { ligado: boolean };
disponivel: boolean;
motivo: string;
pessoas: Pessoa[];
sem_nome: RostoSemNome[];
/** ids dos rostos sem nome agrupados por semelhança (provavelmente a mesma pessoa) */
grupos: number[][];
rostos: number;
varrendo: { feitas: number; total: number } | null;
limiar: number;
dias_sem_nome: number;
}
export function urlRosto(arquivo: string) {
const t = lerToken();
return '/pessoas/rosto/' + arquivo + (t ? '?t=' + encodeURIComponent(t) : '');
}
class Pessoas {
status = $state<StatusPessoas | null>(null);
async carregar() {
try {
this.status = await pedir<StatusPessoas>('/pessoas');
} catch (e) {
avisos.erro(e);
}
}
private async enviar(rota: string, dados: object, ok?: string) {
const r = await tentar(() => postar<StatusPessoas>(rota, dados), ok);
if (r) this.status = r;
return !!r;
}
marcar(rostos: number[], alvo: { pessoa?: number; nome?: string }) {
const quem = alvo.nome ?? this.status?.pessoas.find((p) => p.id === alvo.pessoa)?.nome;
return this.enviar('/pessoas/marcar', { rostos, ...alvo }, quem ? `Aprendi o rosto de ${quem}.` : undefined);
}
descartar(rostos: number[]) {
return this.enviar('/pessoas/marcar', { rostos }, 'Rostos descartados.');
}
renomear(pessoa: number, nome: string) {
return this.enviar('/pessoas/renomear', { pessoa, nome }, 'Nome trocado.');
}
apagar(pessoa: number) {
return this.enviar('/pessoas/apagar', { pessoa }, 'Pessoa apagada, com todas as fotos dela.');
}
ligar(ligado: boolean) {
return this.enviar('/pessoas/config', { ligado });
}
varrer() {
return this.enviar('/pessoas/varrer', {}, 'Procurando rostos nas fotos guardadas…');
}
}
export const pessoas = new Pessoas();
ouvir('pessoas', (d: StatusPessoas) => (pessoas.status = d));
+10
View File
@@ -11,6 +11,7 @@ interface Resultado {
hands: Ponto[][]; hands: Ponto[][];
ms: number; ms: number;
timestamp: number; timestamp: number;
sorriso?: number | null;
} }
const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12], const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12],
@@ -26,6 +27,8 @@ export class Rastreamento {
private ativo = false; private ativo = false;
private config: AppConfig = {}; private config: AppConfig = {};
private resultado: Resultado | null = null; private resultado: Resultado | null = null;
/** Quem quer cada leitura (os jogos com a mão). */
private ouvintes = new Set<(r: { faces: Face[]; maos: Ponto[][] }) => void>();
private alvo: AlvoRastreio | null = null; private alvo: AlvoRastreio | null = null;
private alvoEm = 0; private alvoEm = 0;
private em = 0; private em = 0;
@@ -48,6 +51,11 @@ export class Rastreamento {
addEventListener('resize', this.desenhar); addEventListener('resize', this.desenhar);
} }
aoResultado(fn: (r: { faces: Face[]; maos: Ponto[][] }) => void) {
this.ouvintes.add(fn);
return () => this.ouvintes.delete(fn);
}
private limpar() { private limpar() {
this.ctx.clearRect(0, 0, this.tela.width, this.tela.height); this.ctx.clearRect(0, 0, this.tela.width, this.tela.height);
} }
@@ -142,10 +150,12 @@ export class Rastreamento {
} }
const r = data as Resultado; const r = data as Resultado;
this.resultado = r; this.resultado = r;
this.ouvintes.forEach((fn) => fn({ faces: r.faces, maos: r.hands }));
this.em = r.timestamp; this.em = r.timestamp;
const anterior = this.alvo; const anterior = this.alvo;
const alvo = escolherAlvo(r.faces, r.hands, anterior); const alvo = escolherAlvo(r.faces, r.hands, anterior);
if (alvo) alvo.atividade = !!anterior && alvo.tipo === anterior.tipo && Math.hypot(alvo.x - anterior.x, alvo.y - anterior.y) > 0.008; if (alvo) alvo.atividade = !!anterior && alvo.tipo === anterior.tipo && Math.hypot(alvo.x - anterior.x, alvo.y - anterior.y) > 0.008;
if (alvo && alvo.tipo === 'rosto' && typeof r.sorriso === 'number') alvo.sorriso = Math.round(r.sorriso * 100) / 100;
this.alvo = alvo; this.alvo = alvo;
this.alvoEm = r.timestamp; this.alvoEm = r.timestamp;
this.intervalo = Math.max(50, Math.min(200, r.ms * 1.2)); this.intervalo = Math.max(50, Math.min(200, r.ms * 1.2));
+3 -1
View File
@@ -1,10 +1,12 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */ /** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia'; export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia' | 'console' | 'pessoas';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' }, { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'console', titulo: 'Console', atalho: 'g k' },
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' }, { id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
{ id: 'pessoas', titulo: 'Pessoas', atalho: 'g p' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' }, { id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' }, { id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' }, { id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
+12 -2
View File
@@ -104,7 +104,15 @@ export interface Vida {
sono_profundo?: boolean; sono_profundo?: boolean;
/** '' quando pode inventar figurinha agora; senão, o motivo. */ /** '' quando pode inventar figurinha agora; senão, o motivo. */
pode_criar: string; pode_criar: string;
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade' | 'aprendizado' | 'aprendi'; texto: string }[]; diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade' | 'aprendizado' | 'aprendi' | 'social' | 'pedido' | 'ideia'; texto: string }[];
/** Traços de 0 a 1 (curiosidade, brincadeira, timidez): mudam o jeito e amadurecem com o convívio. */
personalidade?: Record<string, number>;
/** Motivo da última decisão do modo vida. */
porque?: string;
/** A última decisão de fato (o que fez e por quê). */
ultima?: { horario: number; texto: string; porque: string } | null;
/** Segundos até a próxima decisão. */
proximo_ato_em?: number;
} }
/** O que ele aprendeu com o dia a dia (aprendizado.py). */ /** O que ele aprendeu com o dia a dia (aprendizado.py). */
@@ -120,7 +128,9 @@ export interface PlanoSemana {
} }
export interface Aprendizado { export interface Aprendizado {
config: { ligado: boolean; analise_ia: boolean; ia: 'gemini' | 'local' | 's10' }; config: { ligado: boolean; analise_ia: boolean; ia: 'gemini' | 'local' | 's10'; ajustar_sono?: boolean };
sono_base?: [number, number] | null;
sono_auto?: [number, number] | null;
/** 7 dias × 24 horas: chance de ter gente por perto (null = sem dados). */ /** 7 dias × 24 horas: chance de ter gente por perto (null = sem dados). */
semana: (number | null)[][]; semana: (number | null)[][];
historico_dias: number; historico_dias: number;
+7
View File
@@ -15,6 +15,8 @@
import PresencaPir from '../lib/componentes/PresencaPir.svelte'; import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte'; import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import VidaCartao from '../lib/componentes/VidaCartao.svelte'; import VidaCartao from '../lib/componentes/VidaCartao.svelte';
import QuemE from '../lib/componentes/QuemE.svelte';
import Jogos from '../lib/componentes/Jogos.svelte';
import AprendizadoCartao from '../lib/componentes/AprendizadoCartao.svelte'; import AprendizadoCartao from '../lib/componentes/AprendizadoCartao.svelte';
import { estado } from '../lib/estado.svelte'; import { estado } from '../lib/estado.svelte';
import { Rastreamento } from '../lib/rastreamento.svelte'; import { Rastreamento } from '../lib/rastreamento.svelte';
@@ -128,6 +130,7 @@ const pirAtivo = $derived(
<!-- No PC: à direita Vida e o que ele aprendeu; embaixo da câmera, o resto. <!-- No PC: à direita Vida e o que ele aprendeu; embaixo da câmera, o resto.
No celular as duas colunas se desfazem (display: contents) e a ordem vem do `order`. --> No celular as duas colunas se desfazem (display: contents) e a ordem vem do `order`. -->
<div class="coluna direita"> <div class="coluna direita">
<div class="c-quem"><QuemE minimo={3} /></div>
<div class="c-vida"><VidaCartao /></div> <div class="c-vida"><VidaCartao /></div>
{#if estado.vida?.config.ligado}<div class="c-aprendizado"><AprendizadoCartao /></div>{/if} {#if estado.vida?.config.ligado}<div class="c-aprendizado"><AprendizadoCartao /></div>{/if}
@@ -165,6 +168,7 @@ const pirAtivo = $derived(
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes/camera" /> <Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes/camera" />
</Grupo></div> </Grupo></div>
<div class="c-jogos"><Jogos {rastreio} /></div>
<div class="c-gestos"><Grupo titulo="Gestos" ajuda="gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto."> <div class="c-gestos"><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"> <Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos /> <Gestos />
@@ -394,10 +398,13 @@ const pirAtivo = $derived(
min-width: 0; min-width: 0;
} }
.palco { order: 0; } .palco { order: 0; }
.c-quem { order: 1; }
.c-quem:empty { display: none; }
.c-vida { order: 1; } .c-vida { order: 1; }
.c-aprendizado { order: 2; } .c-aprendizado { order: 2; }
.c-celular { order: 3; } .c-celular { order: 3; }
.c-camera { order: 4; } .c-camera { order: 4; }
.c-jogos { order: 5; }
.c-gestos { order: 5; } .c-gestos { order: 5; }
.c-aparencia { order: 6; } .c-aparencia { order: 6; }
.c-personalidade { order: 7; } .c-personalidade { order: 7; }
+348
View File
@@ -0,0 +1,348 @@
<script lang="ts">
import { Pause, Play, Search, Trash2 } from '@lucide/svelte';
import { onMount } from 'svelte';
import { pedir } from '../lib/api';
import { avisos } from '../lib/avisos.svelte';
import { estado, ouvir } from '../lib/estado.svelte';
// Console: tudo o que ele está fazendo e por quê, ao vivo. Em cima o "agora"; embaixo cada decisão
// com o motivo (modo vida, sentidos, celular, IA local, aprendizado, ideias do dia).
type Origem = 'vida' | 'sentidos' | 'celular' | 'ia' | 'aprendizado' | 'ideias' | 'sistema';
interface Linha { id: number; horario: number; origem: Origem; texto: string; motivo: string; nivel: 'info' | 'detalhe' }
const ORIGENS: { id: Origem; nome: string }[] = [
{ id: 'vida', nome: 'Modo vida' }, { id: 'sentidos', nome: 'Sentidos' }, { id: 'celular', nome: 'Celular' },
{ id: 'ia', nome: 'IA local' }, { id: 'aprendizado', nome: 'Aprendizado' }, { id: 'ideias', nome: 'Ideias' },
{ id: 'sistema', nome: 'Sistema' },
];
const MAX = 600;
let linhas = $state<Linha[]>([]);
let pausado = $state(false);
let guardadas = $state<Linha[]>([]); // chegaram com o console pausado
let detalhes = $state(false);
let busca = $state('');
let ligadas = $state<Record<Origem, boolean>>({ vida: true, sentidos: true, celular: true, ia: true, aprendizado: true, ideias: true, sistema: true });
let vidaEm = $state(Date.now());
onMount(() => {
pedir<{ linhas: Linha[] }>('/console').then((d) => (linhas = d.linhas.slice(-MAX).reverse())).catch((e) => avisos.erro(e));
return ouvir('console', (l: Linha) => {
if (pausado) guardadas = [l, ...guardadas].slice(0, MAX);
else linhas = [l, ...linhas].slice(0, MAX);
});
});
$effect(() => {
estado.vida; // a contagem do próximo ato parte de quando o status chegou
vidaEm = Date.now();
});
function alternarPausa() {
pausado = !pausado;
if (!pausado && guardadas.length) {
linhas = [...guardadas, ...linhas].slice(0, MAX);
guardadas = [];
}
}
const visiveis = $derived.by(() => {
const q = busca.trim().toLowerCase();
return linhas.filter((l) => ligadas[l.origem] && (detalhes || l.nivel !== 'detalhe') &&
(!q || (l.texto + ' ' + l.motivo).toLowerCase().includes(q)));
});
const hora = (s: number) => new Date(s * 1000).toLocaleTimeString('pt-BR');
// ---------------------------------------------------------------- agora
const v = $derived(estado.vida);
const cel = $derived(estado.app);
const proximo = $derived(v?.proximo_ato_em != null ? Math.max(0, Math.round(v.proximo_ato_em - (estado.agora - vidaEm) / 1000)) : null);
const fazendo = $derived.by(() => {
if (!cel?.online) return 'celular desconectado';
const e = cel.estado ?? {};
if (e.atividade) return 'fazendo: ' + e.atividade;
if (e.dormindo) return v?.sono_profundo ? 'dormindo (sono pesado)' : 'dormindo';
return 'acordado, de bobeira';
});
const noite = $derived(estado.appConfig ? `${estado.appConfig.noiteInicio ?? 23}h às ${estado.appConfig.noiteFim ?? 7}h` : '');
const NEC: { id: 'energia' | 'alegria' | 'social' | 'diversao'; nome: string }[] = [
{ id: 'energia', nome: 'Energia' }, { id: 'alegria', nome: 'Alegria' }, { id: 'social', nome: 'Social' }, { id: 'diversao', nome: 'Diversão' },
];
const TRACO: Record<string, string> = { curiosidade: 'Curioso', brincadeira: 'Brincalhão', timidez: 'Tímido' };
</script>
<div class="console">
<section class="agora" aria-label="O que ele está fazendo agora">
<div class="quem">
<span class="emoji" aria-hidden="true">{v?.emoji ?? '…'}</span>
<div>
<strong>{v?.nome_humor ?? 'Carregando'}</strong>
<small>{fazendo}</small>
</div>
</div>
<div class="necessidades">
{#each NEC as n (n.id)}
{@const valor = v?.necessidades[n.id] ?? 0}
<div class="nec">
<span>{n.nome}</span>
<span class="barra" aria-hidden="true"><span style:width="{valor}%" class:baixa={valor < 35}></span></span>
<span class="mono">{valor}</span>
</div>
{/each}
</div>
<dl class="fatos">
<div><dt>Próxima decisão</dt><dd class="mono">{!v?.config.ligado ? 'modo vida desligado' : proximo === null ? '—' : `em ${proximo} s`}</dd></div>
<div><dt>Última decisão</dt><dd>{#if v?.ultima}{v.ultima.texto}<small class="porque">{hora(v.ultima.horario)}{v.ultima.porque ? ' · ' + v.ultima.porque : ''}</small>{:else}—{/if}</dd></div>
<div><dt>Presença</dt><dd>{estado.pir?.ativo ? 'sensor vendo alguém' : estado.pir?.presenca?.estado === 'ausente' ? 'ninguém por perto' : 'ninguém agora'}</dd></div>
<div><dt>Madrugada</dt><dd>{noite}</dd></div>
{#if v?.personalidade}
<div><dt>Jeito</dt><dd>{Object.entries(v.personalidade).map(([k, x]) => `${TRACO[k] ?? k} ${Math.round(x * 100)}%`).join(' · ')}</dd></div>
{/if}
</dl>
</section>
<section class="terminal" aria-label="Registro ao vivo">
<div class="barra-ferramentas">
<div class="filtros" role="group" aria-label="Mostrar">
{#each ORIGENS as o (o.id)}
<button class="chip o-{o.id}" aria-pressed={ligadas[o.id]} onclick={() => (ligadas[o.id] = !ligadas[o.id])}>{o.nome}</button>
{/each}
</div>
<div class="acoes">
<label class="busca"><Search size={14} /><input type="search" placeholder="Procurar" bind:value={busca} aria-label="Procurar no console" /></label>
<label class="detalhes"><input type="checkbox" bind:checked={detalhes} /> Detalhes</label>
<button class="btn pequeno" onclick={alternarPausa} aria-pressed={pausado}>
{#if pausado}<Play size={14} /> Continuar{guardadas.length ? ` (${guardadas.length})` : ''}{:else}<Pause size={14} /> Pausar{/if}
</button>
<button class="btn pequeno fantasma" onclick={() => { linhas = []; guardadas = []; }} aria-label="Limpar a tela"><Trash2 size={14} /></button>
</div>
</div>
<ol class="linhas" aria-live="polite">
{#each visiveis as l (l.id)}
<li class:detalhe={l.nivel === 'detalhe'}>
<time class="mono">{hora(l.horario)}</time>
<span class="tag o-{l.origem}">{ORIGENS.find((o) => o.id === l.origem)?.nome ?? l.origem}</span>
<span class="texto">{l.texto}{#if l.motivo}<span class="motivo">porque {l.motivo}</span>{/if}</span>
</li>
{:else}
<li class="vazio">Nada por aqui ainda. Cada decisão dele aparece aqui na hora, com o motivo.</li>
{/each}
</ol>
</section>
</div>
<style>
.console {
display: grid;
gap: var(--e-4);
}
.agora {
display: grid;
gap: var(--e-4);
padding: var(--e-4);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
background: var(--superficie);
}
@media (min-width: 1024px) {
.agora {
grid-template-columns: minmax(180px, 0.8fr) minmax(220px, 1fr) minmax(0, 2fr);
align-items: start;
}
}
.quem {
display: flex;
gap: var(--e-3);
align-items: center;
}
.quem .emoji {
font-size: 2.4rem;
line-height: 1;
}
.quem strong {
display: block;
font-size: var(--t-lg);
}
.quem small {
color: var(--texto-2);
}
.necessidades {
display: grid;
gap: 6px;
}
.nec {
display: grid;
grid-template-columns: 64px 1fr 28px;
gap: var(--e-2);
align-items: center;
font-size: var(--t-xs);
color: var(--texto-2);
}
.barra {
height: 6px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
overflow: hidden;
}
.barra span {
display: block;
height: 100%;
background: var(--destaque);
transition: width var(--transicao);
}
.barra span.baixa {
background: var(--aviso);
}
.nec .mono {
text-align: right;
font-variant-numeric: tabular-nums;
}
.fatos {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--e-2) var(--e-4);
margin: 0;
}
.fatos div {
min-width: 0;
}
.fatos dt {
font-size: var(--t-xs);
color: var(--texto-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fatos dd {
margin: 2px 0 0;
font-size: var(--t-sm);
overflow-wrap: anywhere;
}
.porque {
display: block;
color: var(--texto-3);
font-size: var(--t-xs);
}
.terminal {
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
background: var(--fundo-2);
overflow: hidden;
}
.barra-ferramentas {
display: flex;
flex-wrap: wrap;
gap: var(--e-2) var(--e-3);
justify-content: space-between;
align-items: center;
padding: var(--e-3);
border-bottom: 1px solid var(--borda);
background: var(--superficie);
}
.filtros, .acoes {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.chip {
min-height: 28px;
padding: 0 var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio-pill);
background: transparent;
color: var(--texto-3);
font: 500 var(--t-xs) var(--fonte);
cursor: pointer;
}
.chip[aria-pressed='true'] {
color: var(--texto);
border-color: var(--cor);
background: color-mix(in srgb, var(--cor) 14%, transparent);
}
.busca {
display: inline-flex;
gap: 6px;
align-items: center;
padding: 0 var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio-sm);
color: var(--texto-3);
}
.busca input {
width: 140px;
min-height: 28px;
border: 0;
background: transparent;
color: var(--texto);
font: inherit;
font-size: var(--t-xs);
outline: none;
}
.detalhes {
display: inline-flex;
gap: 4px;
align-items: center;
font-size: var(--t-xs);
color: var(--texto-2);
}
.linhas {
list-style: none;
margin: 0;
padding: var(--e-2) 0;
max-height: min(70vh, 900px);
overflow-y: auto;
font-size: var(--t-sm);
}
.linhas li {
display: grid;
grid-template-columns: 72px 104px minmax(0, 1fr);
gap: var(--e-3);
align-items: baseline;
padding: 5px var(--e-4);
border-bottom: 1px solid color-mix(in srgb, var(--borda) 45%, transparent);
}
@media (max-width: 640px) {
.linhas li {
grid-template-columns: 64px minmax(0, 1fr);
}
.linhas li .texto {
grid-column: 1 / -1;
}
}
.linhas li.detalhe {
opacity: 0.6;
}
.linhas li.vazio {
display: block;
color: var(--texto-3);
}
time {
color: var(--texto-3);
font-size: var(--t-xs);
font-variant-numeric: tabular-nums;
}
.tag {
justify-self: start;
padding: 1px 8px;
border-radius: var(--raio-pill);
font-size: 11px;
font-weight: 600;
color: color-mix(in srgb, var(--cor) 70%, var(--texto));
background: color-mix(in srgb, var(--cor) 16%, transparent);
white-space: nowrap;
}
.texto {
overflow-wrap: anywhere;
}
.motivo {
display: block;
margin-top: 1px;
font-size: var(--t-xs);
color: var(--texto-3);
}
.o-vida { --cor: var(--destaque); }
.o-sentidos { --cor: var(--ok); }
.o-celular { --cor: #7aa2ff; }
.o-ia { --cor: #c084fc; }
.o-aprendizado { --cor: #fb923c; }
.o-ideias { --cor: var(--aviso); }
.o-sistema { --cor: var(--texto-3); }
</style>
+42
View File
@@ -56,6 +56,12 @@
aberta = ev; aberta = ev;
modal = true; modal = true;
} }
// O HC-SR505 não tem ajuste físico: a sensibilidade é quantos disparos confirmam alguém de verdade
const SENS_PIR = [
{ valor: 'baixa', nome: 'Baixa', descricao: '3 disparos em 90 s ou alguém se mexendo por 20 s' },
{ valor: 'media', nome: 'Média', descricao: '2 disparos em 1 min ou alguém se mexendo por 12 s' },
{ valor: 'alta', nome: 'Alta', descricao: 'qualquer disparo conta como presença' },
] as const;
</script> </script>
<div class="eventos"> <div class="eventos">
@@ -132,6 +138,21 @@
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por"> <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 })} /> <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> </Linha>
<Linha icone={Footprints} cor="#ff9500" rotulo="Sensibilidade do sensor" detalhe={SENS_PIR.find((s) => s.valor === (st?.config.sensibilidade_pir ?? 'media'))?.descricao ?? ''}>
{#snippet acessorio()}
<span class="segmentos" role="radiogroup" aria-label="Sensibilidade do sensor de presença">
{#each SENS_PIR as s (s.valor)}
<button
role="radio"
aria-checked={(st?.config.sensibilidade_pir ?? 'media') === s.valor}
class:ativo={(st?.config.sensibilidade_pir ?? 'media') === s.valor}
disabled={!st}
onclick={() => historico.configurar({ sensibilidade_pir: s.valor })}>{s.nome}</button
>
{/each}
</span>
{/snippet}
</Linha>
</Grupo> </Grupo>
<Grupo titulo="O que registrar" ajuda="eventos"> <Grupo titulo="O que registrar" ajuda="eventos">
@@ -306,4 +327,25 @@
top: calc(var(--topo-altura) + var(--e-5)); top: calc(var(--topo-altura) + var(--e-5));
} }
} }
.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);
}
</style> </style>
+309
View File
@@ -0,0 +1,309 @@
<script lang="ts">
import { Check, Pencil, ScanFace, ShieldCheck, Trash2, UserPlus, X } from '@lucide/svelte';
import { onMount } from 'svelte';
import Card from '../lib/componentes/Card.svelte';
import QuemE from '../lib/componentes/QuemE.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { estado } from '../lib/estado.svelte';
import { pessoas, urlRosto, type Pessoa } from '../lib/pessoas.svelte';
// Quem ele conhece e os rostos que ainda não sabe de quem são. Marcar "esta é a Ana" ensina;
// com 3 a 5 fotos de cada pessoa ele reconhece bem. Tudo fica na placa.
onMount(() => pessoas.carregar());
const st = $derived(pessoas.status);
let escolhidos = $state<number[]>([]);
let novoNome = $state('');
let editando = $state<number | null>(null);
let nomeEditado = $state('');
let apagando = $state<number | null>(null);
const alternar = (id: number) =>
(escolhidos = escolhidos.includes(id) ? escolhidos.filter((x) => x !== id) : [...escolhidos, id]);
async function paraPessoa(p: Pessoa) {
if (await pessoas.marcar(escolhidos, { pessoa: p.id })) escolhidos = [];
}
async function criar(e: SubmitEvent) {
e.preventDefault();
if (novoNome.trim() && (await pessoas.marcar(escolhidos, { nome: novoNome.trim() }))) {
escolhidos = [];
novoNome = '';
}
}
async function descartar() {
if (await pessoas.descartar(escolhidos)) escolhidos = [];
}
async function salvarNome(p: Pessoa) {
if (nomeEditado.trim() && (await pessoas.renomear(p.id, nomeEditado.trim()))) editando = null;
}
function quando(s: number | null) {
if (!s) return 'ainda não foi visto';
const min = Math.round((estado.agora / 1000 - s) / 60);
if (min < 1) return 'visto agora há pouco';
if (min < 60) return `visto há ${min} min`;
if (min < 1440) return `visto há ${Math.round(min / 60)} h`;
return `visto há ${Math.round(min / 1440)} dia(s)`;
}
</script>
<div class="pessoas">
<QuemE />
<Card titulo="Reconhecimento de pessoas" ajuda="pessoas" descricao="Pelas fotos dos eventos ele descobre quem chegou e cumprimenta pelo nome.">
{#snippet acoes()}
<button class="btn pequeno" onclick={() => pessoas.varrer()} disabled={!st?.disponivel || !!st?.varrendo}>
<ScanFace size={14} /> Procurar nas fotos guardadas
</button>
{/snippet}
{#if !st}
<p class="fraco">Carregando…</p>
{:else}
<div class="topo">
<Toggle rotulo="Reconhecer quem chega" checked={st.config.ligado} disabled={!st.disponivel} onchange={(v) => pessoas.ligar(v)} />
{#if !st.disponivel}
<Selo tom="aviso">{st.motivo}</Selo>
{:else if st.varrendo}
<Selo tom="aviso">Procurando rostos: {st.varrendo.feitas} de {st.varrendo.total} fotos</Selo>
{/if}
<p class="privacidade"><ShieldCheck size={15} /> Tudo fica na placa: fotos, rostos e nomes não vão para a internet. Rostos sem nome somem em {st.dias_sem_nome} dias; apagar uma pessoa apaga tudo dela.</p>
</div>
{/if}
</Card>
{#if st}
<Card titulo="Quem ele conhece" descricao={st.pessoas.length ? 'Mais fotos marcadas = reconhece melhor (3 a 5 por pessoa já é bom).' : 'Ninguém ainda. Marque os rostos abaixo para ensinar.'}>
{#if st.pessoas.length}
<ul class="lista">
{#each st.pessoas as p (p.id)}
<li class="pessoa">
<div class="fotos">
{#each p.fotos.slice(0, 4) as f (f.id)}<img src={urlRosto(f.recorte)} alt="" loading="lazy" />{/each}
</div>
<div class="info">
{#if editando === p.id}
<form class="renomear" onsubmit={(e) => { e.preventDefault(); salvarNome(p); }}>
<input bind:value={nomeEditado} maxlength="30" aria-label="Novo nome" />
<button class="btn pequeno" type="submit" aria-label="Salvar nome"><Check size={14} /></button>
<button class="btn pequeno fantasma" type="button" onclick={() => (editando = null)} aria-label="Cancelar"><X size={14} /></button>
</form>
{:else}
<strong>{p.nome}</strong>
{/if}
<small>{quando(p.visto)} · {p.vezes} {p.vezes === 1 ? 'vez' : 'vezes'} · {p.amostras} foto(s) de exemplo</small>
<span class="afinidade" title="Afinidade: cresce com carinho e conversa logo depois de reconhecer">
<span class="barra"><span style:width="{Math.min(100, p.afinidade)}%"></span></span> afinidade {Math.round(p.afinidade)}
</span>
</div>
<div class="botoes">
<button class="btn pequeno fantasma" onclick={() => { editando = p.id; nomeEditado = p.nome; }} aria-label={`Renomear ${p.nome}`}><Pencil size={14} /></button>
{#if apagando === p.id}
<button class="btn pequeno perigo" onclick={() => { pessoas.apagar(p.id); apagando = null; }}>Apagar tudo de {p.nome}</button>
<button class="btn pequeno fantasma" onclick={() => (apagando = null)}>Cancelar</button>
{:else}
<button class="btn pequeno fantasma" onclick={() => (apagando = p.id)} aria-label={`Apagar ${p.nome}`}><Trash2 size={14} /></button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</Card>
<Card titulo="Rostos sem nome" descricao="Toque nos rostos da mesma pessoa e diga quem é. Foto ruim ou de ninguém: descarte.">
{#if st.sem_nome.length}
{#each st.grupos as ids, g (ids[0])}
{@const todos = ids.every((i) => escolhidos.includes(i))}
<section class="grupo" aria-label="Provavelmente a mesma pessoa">
<header>
<span>{st.grupos.length > 1 ? `Desconhecido ${g + 1}` : 'Desconhecido'} · {ids.length} {ids.length === 1 ? 'foto' : 'fotos'}</span>
<button class="btn pequeno fantasma" onclick={() => (escolhidos = todos ? escolhidos.filter((x) => !ids.includes(x)) : [...new Set([...escolhidos, ...ids])])}>
{todos ? 'Tirar a seleção' : 'Selecionar todas'}
</button>
</header>
<div class="grade" role="group">
{#each st.sem_nome.filter((r) => r.grupo === g) as r (r.id)}
<button class="rosto" aria-pressed={escolhidos.includes(r.id)} onclick={() => alternar(r.id)}
title={new Date(r.horario * 1000).toLocaleString('pt-BR')}>
<img src={urlRosto(r.recorte)} alt="Rosto visto em {new Date(r.horario * 1000).toLocaleString('pt-BR')}" loading="lazy" />
{#if escolhidos.includes(r.id)}<span class="marca"><Check size={14} /></span>{/if}
</button>
{/each}
</div>
</section>
{/each}
{#if escolhidos.length}
<div class="acoes-marcar">
<span>{escolhidos.length} escolhido(s): é…</span>
{#each st.pessoas as p (p.id)}
<button class="btn pequeno" onclick={() => paraPessoa(p)}>{p.nome}</button>
{/each}
<form class="novo" onsubmit={criar}>
<input bind:value={novoNome} placeholder="Nome de alguém novo" maxlength="30" aria-label="Nome de alguém novo" />
<button class="btn pequeno primario" type="submit" disabled={!novoNome.trim()}><UserPlus size={14} /> Criar</button>
</form>
<button class="btn pequeno fantasma" onclick={descartar}><Trash2 size={14} /> Não é ninguém</button>
</div>
{/if}
{:else}
<p class="fraco">Nenhum rosto esperando nome. Eles aparecem aqui quando o sensor vê alguém e a câmera pega o rosto (com luz).</p>
{/if}
</Card>
{/if}
</div>
<style>
.pessoas {
display: grid;
gap: var(--e-4);
}
.topo {
display: grid;
gap: var(--e-2);
}
.privacidade {
display: flex;
gap: var(--e-2);
align-items: flex-start;
margin: 0;
font-size: var(--t-xs);
color: var(--texto-3);
}
.privacidade :global(svg) {
flex: none;
color: var(--ok);
margin-top: 1px;
}
.lista {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-2);
}
.pessoa {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--e-3);
align-items: center;
padding: var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
}
@media (max-width: 640px) {
.pessoa {
grid-template-columns: minmax(0, 1fr);
}
}
.fotos {
display: flex;
}
.fotos img {
width: 44px;
height: 44px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--superficie);
margin-left: -10px;
background: var(--superficie-3);
}
.fotos img:first-child {
margin-left: 0;
}
.info {
display: grid;
gap: 2px;
min-width: 0;
}
.info small {
color: var(--texto-3);
font-size: var(--t-xs);
}
.afinidade {
display: flex;
gap: var(--e-2);
align-items: center;
font-size: var(--t-xs);
color: var(--texto-3);
}
.barra {
width: 90px;
height: 5px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
overflow: hidden;
}
.barra span {
display: block;
height: 100%;
background: #ff6b9a;
}
.botoes, .renomear, .novo, .acoes-marcar {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
align-items: center;
}
.renomear input, .novo input {
min-width: 0;
width: 180px;
}
.grupo + .grupo {
margin-top: var(--e-4);
}
.grupo header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--e-2);
margin-bottom: var(--e-2);
font-size: var(--t-sm);
color: var(--texto-2);
}
.grade {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
gap: var(--e-2);
}
.rosto {
position: relative;
padding: 0;
border: 2px solid transparent;
border-radius: var(--raio);
background: var(--superficie-3);
overflow: hidden;
cursor: pointer;
aspect-ratio: 1;
}
.rosto img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.rosto[aria-pressed='true'] {
border-color: var(--destaque);
}
.marca {
position: absolute;
top: 4px;
right: 4px;
display: grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--destaque);
color: var(--destaque-texto);
}
.acoes-marcar {
margin-top: var(--e-3);
padding-top: var(--e-3);
border-top: 1px solid var(--borda);
font-size: var(--t-sm);
}
.perigo {
color: var(--perigo);
}
</style>