Compare commits

...
10 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"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto"
android:versionCode="13"
android:versionName="0.9.1">
android:versionCode="14"
android:versionName="0.10.0">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
+172 -5
View File
@@ -20,6 +20,28 @@
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
/* 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
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);
@@ -595,6 +617,10 @@ function atividade(nome, segundos, receita) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
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");
}
@@ -618,6 +644,121 @@ function tocarExpressaoCriada(texto) {
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"
if (r.dormindo) {
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.dx = 0;
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;
}
r.forma = "aberto"; r.forma2 = null;
@@ -638,7 +780,7 @@ function repouso(t) { // pose calma: olha devagar, "respira
const longe = Math.random() < 0.25;
OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
OLHAR.prox = t + 1.2 + Math.random() * 3;
OLHAR.prox = t + (1.2 + Math.random() * 3) * (1 + 2 * grogue(t)); // grogue: olhar lento
}
OLHAR.x += (OLHAR.ax - OLHAR.x) * 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.boca) r.boca = ATIV.boca;
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)
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() {
@@ -667,7 +823,9 @@ function desenhar() {
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV;
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const 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 comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
@@ -751,6 +909,9 @@ function contarAprendi(texto) {
function trata(ordem) {
const bruta = (ordem || "").trim();
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();
if (!ordem) return;
if(ordem==='recarregar') {
@@ -797,14 +958,16 @@ function trata(ordem) {
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
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") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
}
animar(ordem);
hesitar(() => animar(ordem), ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
@@ -1121,6 +1284,7 @@ function rastrear(t) {
(r.dormindo && !configRecebida?.cameraSono)) return;
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;
contagio(t, agora);
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
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.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;
function configurarApp(c) {
@@ -1353,6 +1518,7 @@ async function sincronizarApp() {
bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo,
luz: HUMANO.luz.lux,
}) });
}
} catch (e) { /* servidor ausente: animação continua */ }
@@ -1448,6 +1614,7 @@ function laco(t) {
checarSilencio(t / 1000);
atualizarFeicao(t / 1000);
humanoTick(t / 1000);
ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null;
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
@@ -1461,7 +1628,7 @@ function laco(t) {
if (r.dormindo) {
proxPiscar = proxGesto = t + 3600000;
} else if (t >= proxPiscar) {
animar("piscar");
piscarHumano(t / 1000);
proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto && !ATIV) {
animar(gestoSolto());
+30 -1
View File
@@ -287,11 +287,13 @@
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
desenharMaos(ctx, o, olhos, tela.height);
desenharZzz(ctx, o, olhos);
desenharSonho(ctx, o, olhos);
} else {
cctx.clearRect(0, 0, camada.width, camada.height);
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
desenharMaos(cctx, o, olhos, tela.height);
desenharZzz(cctx, o, olhos);
desenharSonho(cctx, o, olhos);
ctx.save();
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
ctx.drawImage(camada, 0, 0);
@@ -421,6 +423,33 @@
}
// ------------------------------------------------------------------ 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) {
fator = fator || 1;
if (!o.dormindo) return;
@@ -1385,7 +1414,7 @@
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
},
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. */
atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
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.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
* carinha.local). Assim, se o IP da placa mudar, o app se reconecta sem ninguém digitar endereço.
* Uma busca por vez; desiste depois de alguns segundos e responde no fio principal.
* Acha a placa na rede quando o endereço salvo não responde, para o app se reconectar sem
* ninguém digitar endereço:
* 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 {
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 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 Handler principal = new Handler(Looper.getMainLooper());
private NsdManager.DiscoveryListener ouvinte;
private Resposta resposta;
private boolean ocupada;
Descoberta(Context c) {
nsd = (NsdManager) c.getApplicationContext().getSystemService(Context.NSD_SERVICE);
}
boolean procurando() { return ouvinte != null; }
boolean procurando() { return ocupada; }
void procurar(Resposta r) {
if (nsd == null) { r.achou(null); return; }
if (ouvinte != null) return;
if (ocupada) return;
ocupada = true;
resposta = r;
if (nsd == null) { semAnuncio(); return; }
ouvinte = new NsdManager.DiscoveryListener() {
@Override public void onDiscoveryStarted(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 onServiceLost(NsdServiceInfo s) { }
@Override public void onServiceFound(NsdServiceInfo s) {
if (!s.getServiceType().startsWith("_carinha._tcp")) return;
resolver(s);
if (s.getServiceType().startsWith("_carinha._tcp")) resolver(s);
}
};
try {
nsd.discoverServices(TIPO, NsdManager.PROTOCOL_DNS_SD, ouvinte);
} catch (Exception e) {
Log.w(TAG, "busca falhou: " + e);
Log.w(TAG, "busca por anúncio falhou: " + e);
ouvinte = null;
r.achou(null);
semAnuncio();
return;
}
principal.postDelayed(new Runnable() {
@Override public void run() { terminar(null); }
}, LIMITE);
@Override public void run() { fimDoAnuncio(null); }
}, LIMITE_ANUNCIO);
}
@SuppressWarnings("deprecation")
@@ -67,7 +87,7 @@ final class Descoberta {
if (s.getHost() == null || s.getHost().isLoopbackAddress()) return;
String ip = s.getHost().getHostAddress();
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) {
@@ -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() {
@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 */ }
ouvinte = null;
Resposta r = resposta;
resposta = null;
Log.i(TAG, endereco == null ? "placa não encontrada na rede" : "placa encontrada: " + endereco);
if (r != null) r.achou(endereco);
if (endereco != null) { Log.i(TAG, "placa achada pelo anúncio: " + endereco); responder(endereco); }
else semAnuncio();
}
});
}
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 final CerebroLocal cerebroLocal = new CerebroLocal();
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 jaRecarregou = false;
private boolean usandoLocal = false; // servidor caiu: ficamos na copia do APK
@@ -193,6 +194,11 @@ public class MainActivity extends Activity {
cerebroLocal.chave = token;
cerebroLocal.iniciar(this, servidor);
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() {
@Override public void run() {
runOnUiThread(new Runnable() {
@@ -867,6 +873,7 @@ public class MainActivity extends Activity {
protected void onResume() {
super.onResume();
emPrimeiroPlano = true;
luz.ligar();
ultimoUsoTela = System.currentTimeMillis();
remoto.servidor(servidor);
remoto.iniciar();
@@ -890,6 +897,7 @@ public class MainActivity extends Activity {
protected void onPause() {
super.onPause();
emPrimeiroPlano = false;
luz.desligar();
remoto.parar();
rtcWeb.evaluateJavascript("window.rtcParar&&window.rtcParar()", null);
rtcAtivo = false;
+97 -6
View File
@@ -24,7 +24,7 @@ from pathlib import Path
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')
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}
@@ -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
AVISO_CHEGADA = 15 * 60 # s antes da hora movimentada em que ele acorda do cochilo
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
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']
@@ -87,11 +90,17 @@ def _faixas(horas):
faixas[-1] = (faixas[-1][0], h + 1)
else:
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
def _na_noite(h, ini, fim):
return ini <= h < fim if ini < fim else (h >= ini or h < fim)
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]
@@ -126,6 +135,12 @@ class Aprendizado:
self.pode_ia = lambda provedor: False
self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro
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:
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo.get('config', {})))
@@ -151,6 +166,9 @@ class Aprendizado:
self.analise['em'] = salvo['analise_em']
if isinstance(salvo.get('hoje'), dict) and salvo['hoje'].get('dia'):
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):
pass
@@ -200,7 +218,8 @@ class Aprendizado:
licoes=self.licoes, rotina_avisada=self.rotina_avisada, rotina_avisada_dia=self.rotina_avisada_dia,
hoje=self.hoje, semana=self.semana,
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()
try:
gravar_atomico(self.caminho, dados)
@@ -327,6 +346,7 @@ class Aprendizado:
with self.lock:
self.decisoes.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto, motivo=motivo))
del self.decisoes[MAX_DECISOES:]
self.console('aprendizado', texto, motivo, agora=agora)
def porque_chegada(self, agora):
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]))
if observou and hoje['atos'] >= 5 and not sum(hoje['respostas'].values()):
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:
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 (
cheias != self.rotina_avisada and self.dias - self.rotina_avisada_dia >= 7)
if cheias and mudou:
licoes.append('Percebi que vocês costumam estar por perto %s. Vou estar acordado nessa hora.'
% _texto_faixas(_faixas(cheias)))
noite = self.noite()
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
# 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)
@@ -463,6 +496,63 @@ class Aprendizado:
del self.licoes[MAX_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
@staticmethod
def validar_plano(bruto, origem, criado=None):
@@ -691,6 +781,7 @@ class Aprendizado:
gosto.sort(key=lambda g: -g['peso'])
hoje = self.hoje
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],
gosto=gosto, licoes=self.licoes[:12],
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}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation: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
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);
@@ -595,6 +617,10 @@ function atividade(nome, segundos, receita) {
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
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");
}
@@ -618,6 +644,121 @@ function tocarExpressaoCriada(texto) {
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"
if (r.dormindo) {
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.dx = 0;
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;
}
r.forma = "aberto"; r.forma2 = null;
@@ -638,7 +780,7 @@ function repouso(t) { // pose calma: olha devagar, "respira
const longe = Math.random() < 0.25;
OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
OLHAR.prox = t + 1.2 + Math.random() * 3;
OLHAR.prox = t + (1.2 + Math.random() * 3) * (1 + 2 * grogue(t)); // grogue: olhar lento
}
OLHAR.x += (OLHAR.ax - OLHAR.x) * 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.boca) r.boca = ATIV.boca;
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)
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() {
@@ -667,7 +823,9 @@ function desenhar() {
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV;
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const 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 comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
@@ -751,6 +909,9 @@ function contarAprendi(texto) {
function trata(ordem) {
const bruta = (ordem || "").trim();
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();
if (!ordem) return;
if(ordem==='recarregar') {
@@ -797,14 +958,16 @@ function trata(ordem) {
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
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") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
}
animar(ordem);
hesitar(() => animar(ordem), ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
@@ -1121,6 +1284,7 @@ function rastrear(t) {
(r.dormindo && !configRecebida?.cameraSono)) return;
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;
contagio(t, agora);
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
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.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;
function configurarApp(c) {
@@ -1353,6 +1518,7 @@ async function sincronizarApp() {
bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo,
luz: HUMANO.luz.lux,
}) });
}
} catch (e) { /* servidor ausente: animação continua */ }
@@ -1448,6 +1614,7 @@ function laco(t) {
checarSilencio(t / 1000);
atualizarFeicao(t / 1000);
humanoTick(t / 1000);
ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null;
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
@@ -1461,7 +1628,7 @@ function laco(t) {
if (r.dormindo) {
proxPiscar = proxGesto = t + 3600000;
} else if (t >= proxPiscar) {
animar("piscar");
piscarHumano(t / 1000);
proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto && !ATIV) {
animar(gestoSolto());
+3
View File
@@ -39,6 +39,7 @@ class Cerebro:
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
except (OSError,ValueError,KeyError,TypeError):pass
threading.Thread(target=self.loop,daemon=True).start()
console = staticmethod(lambda origem, texto, motivo='', nivel='info', agora=None: None) # o main troca pelo console do painel
def cabecalhos(self):
h={'Content-Type':'application/json'}
if self.chave:h['Authorization']='Bearer '+self.chave
@@ -100,8 +101,10 @@ class Cerebro:
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'):
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:
with self.lock:self.error=str(e)[:200]
self.console('ia','IA local não respondeu',str(e)[:200],'detalhe')
finally:
self.last=time.monotonic()
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()
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=''):
"""Estado do celular para o painel (GET /app/status, /estado e evento 'telemetria')."""
with self.lock:
@@ -160,12 +162,14 @@ class ControlePC:
if not isinstance(dados, dict):
raise ValueError('Estado deve ser objeto JSON')
with self.lock:
antes = self.estado
self.estado = dados
self.celular_ip = ip_informado(dados) or h.client_address[0]
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
self.recarga["confirmada"] = True
self.status_at = time.monotonic()
self.publicar('telemetria', self.resumo(h.headers.get("Host", "").split(":")[0]))
self.mudancas(antes, dados)
enviar({'ok': True})
else:
enviar(self.resumo(h.headers.get("Host", "").split(":")[0]))
@@ -176,6 +180,21 @@ class ControlePC:
enviar({'erro': str(e)}, 503)
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):
"""Pedido do celular recusado: aparece no journal (no máximo 1 linha por minuto)."""
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).
$arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', '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',
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
)
@@ -90,6 +90,8 @@ tar -xzf '$remoto' -C '$Destino'
rm -f '$remoto'
systemctl restart carinha-pc
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'
# 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')
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
VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto
@@ -36,6 +40,7 @@ class Historico:
self.lock = threading.RLock()
self.publicar = lambda tipo, dados: None
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
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).
self.ao_presenca = 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,
ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma)
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:
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()
@@ -82,6 +90,9 @@ class Historico:
elif chave == 'dias':
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30:
raise ValueError('Guardar de 1 a 30 dias')
elif chave == 'sensibilidade_pir':
if valor not in REGRAS_PIR:
raise ValueError('Sensibilidade do sensor: alta, media ou baixa')
elif chave == 'intervalo':
if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600:
raise ValueError('Intervalo entre 0 e 600 s')
@@ -134,21 +145,54 @@ class Historico:
self.publicar('pir', dict(ultimo=self.pir['ultimo'], ativo=self.pir['ativo'], presenca=dict(self.presenca)))
def nivel_pir(self, alto, agora=None, registrar=True):
"""Mudança no pino do PIR. Subida = presença (evento + foto); enquanto alto, a bolinha fica acesa."""
"""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()
self.pir['ativo'] = alto
if alto:
self.pir['ultimo'] = agora
self.marcar_presenca(agora)
self.avisar_pir()
if alto and registrar:
self.registrar('pir', 'presença')
if not alto:
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):
passo = 0
while True:
time.sleep(10)
time.sleep(1)
passo += 1
try:
self.checar_ausencia()
self.checar_pir()
if passo % 10 == 0:
self.checar_ausencia()
except Exception:
pass
@@ -255,6 +299,16 @@ class Historico:
self.apagar_arquivo(foto)
db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,))
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
def vigiar_pir(self):
+4
View File
@@ -90,6 +90,7 @@ class Inspiracao:
self.madrugada = lambda agora: False
self.pode_gemini = lambda: False
self.baixar = _baixar # trocado nos testes
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None
self.ocupado = False
self.config = json.loads(json.dumps(PADRAO))
self.hoje = self._dia_vazio('')
@@ -326,11 +327,13 @@ class Inspiracao:
item = self.criacao.item(ident)
if item:
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:
self.criacao.avisar() # a grade do estúdio mostra as novidades
except Exception:
pass
except Exception as e:
self.console('ideias', 'Não conseguiu criar as ideias do dia', str(e)[:200], agora=agora)
with self.lock:
self.hoje['erro'] = str(e)[:200]
if not manual:
@@ -404,6 +407,7 @@ class Inspiracao:
chave = str(item['id'])
self.hoje['contadas'][chave] = self.hoje['contadas'].get(chave, 0) + 1
self.anotar('aprendi', 'Contou o que aprendeu: ' + item['aprendi'], agora)
self.console('ideias', 'Contou o que aprendeu', item['aprendi'], agora=agora)
self.salvar()
return dict(ok=True)
+92 -3
View File
@@ -45,6 +45,8 @@ from vida import Vida
from criacao import Criacao
from inspiracao import Inspiracao
from aprendizado import Aprendizado
from console import Console
from reconhecimento import Reconhecimento
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"]
# Ordens internas que o rosto entende mas não viram botão.
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
# 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.
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/expressao.task",
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm",
"/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"}
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/",
PREFIXOS_API = ("/eventos", "/console", "/pessoas", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -122,10 +127,26 @@ EVENTOS = Eventos()
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):
"""Caminho único de uma ordem para o rosto: fila do celular + evento para os painéis."""
FILA.adicionar(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():
@@ -204,9 +225,53 @@ def ordem_de_comando(tipo, valor):
if nome not in ROSTOS:
raise ValueError("Rosto desconhecido: " + 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")
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):
extensoes_map = {".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
@@ -264,7 +329,7 @@ class Handler(SimpleHTTPRequestHandler):
def modulos(self):
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)]
def estado(self):
@@ -486,7 +551,8 @@ class Servidor(ThreadingHTTPServer):
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):
getattr(servidor, nome).publicar = EVENTOS.publicar
@@ -560,11 +626,34 @@ def main():
def nome_criada(ident):
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)
# 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())
vida.aprendizado = aprendizado
# a análise da semana usa a mesma IA do estúdio (Gemini, IA local ou o S10)
aprendizado.ia = servidor.criacao.chamar
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
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',
'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',
# 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():
data = download(url); (dest/name).write_bytes(data)
files[name] = {'url':url, 'sha256':hashlib.sha256(data).hexdigest()}
+18 -1
View File
@@ -1,5 +1,7 @@
'use strict';
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;
async function prepare(c) {
if (!vision) {
@@ -9,6 +11,13 @@ async function prepare(c) {
}
if (c.rosto && !face) face = await vision.module.FaceDetector.createFromOptions(vision.files, {
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, {
baseOptions:{modelAssetPath:'/vendor/mediapipe/hands.task',delegate:'CPU'},runningMode:'VIDEO',numHands:2,
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,
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:[]};
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)}); }
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]));
desenharMaos(ctx, o, olhos, tela.height);
desenharZzz(ctx, o, olhos);
desenharSonho(ctx, o, olhos);
} else {
cctx.clearRect(0, 0, camada.width, camada.height);
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
desenharMaos(cctx, o, olhos, tela.height);
desenharZzz(cctx, o, olhos);
desenharSonho(cctx, o, olhos);
ctx.save();
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
ctx.drawImage(camada, 0, 0);
@@ -421,6 +423,33 @@
}
// ------------------------------------------------------------------ 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) {
fator = fator || 1;
if (!o.dormindo) return;
@@ -1385,7 +1414,7 @@
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
},
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. */
atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
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(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):
with self.assertRaises(ValueError):
self.a.configurar(dict(ligado='sim'))
+33
View File
@@ -97,6 +97,7 @@ class HistoricoTest(unittest.TestCase):
avisos = []
self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados))
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.assertTrue(self.h.pir['ativo'])
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.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):
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
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', '/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):
# 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')):
+247 -8
View File
@@ -43,6 +43,8 @@ ESTIMULOS = {
'brincar': (dict(diversao=25, alegria=8, energia=-5), 'alegre'),
'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',
'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'])]
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}
@@ -149,6 +163,22 @@ class Vida:
self.aprendi_pendente = lambda: None # curiosidade de hoje que ainda dá para contar
self.contar_aprendi = lambda ident, agora=None: None
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()
self.config = dict(PADRAO)
@@ -157,6 +187,10 @@ class Vida:
self.diario = []
self.emojis = dict(dia='', total=0, ultimo=0.0)
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:
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo.get('config', {})))
@@ -165,6 +199,11 @@ class Vida:
self.necessidades[k] = _limitar(float(salvo['necessidades'][k]))
self.nascimento = float(salvo.get('nascimento', agora))
self.diario = [d for d in salvo.get('diario', []) if isinstance(d, dict)][:MAX_DIARIO]
if isinstance(salvo.get('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):
self.emojis.update({k: salvo['emojis'][k] for k in ('dia', 'total', 'ultimo') if k in salvo['emojis']})
except (OSError, ValueError, TypeError, AttributeError):
@@ -222,7 +261,8 @@ class Vida:
def salvar(self):
with self.lock:
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()
try:
gravar_atomico(self.caminho, dados)
@@ -274,7 +314,9 @@ class Vida:
nascimento=self.nascimento, idade_dias=int((agora - self.nascimento) // 86400),
emojis_hoje=self.emojis_hoje(agora), ultimo_emoji=self.emojis.get('ultimo') or None,
zanga=self.zanga(), sono_profundo=self.sono_profundo,
pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30])
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):
self.publicado_em = time.time()
@@ -323,6 +365,13 @@ class Vida:
self.interagiu_em = agora # alguém quis ele acordado (sensor não conta)
if self.aprendizado:
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 self.dormindo():
self.acordar(agora, None)
@@ -390,12 +439,131 @@ class Vida:
self.anotar('atividade', 'Foi fazer %s %s%s' % (item['emoji'], item['nome'], motivo), agora)
return 'atividade ' + nome
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.atividade_em = agora
self.ultima_atividade = nome
self.anotar('atividade', NOME_ATIVIDADE.get(nome, 'Foi fazer algo'), agora)
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):
"""Disparo do PIR com o rosto dormindo: acorda? No ritmo calmo precisa de vários em 2 min."""
agora = agora or time.time()
@@ -423,8 +591,27 @@ class Vida:
"""Quanto o aprendizado multiplica a chance de um ato (1 = sem opinião)."""
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):
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)
x = self.rng.uniform(0, total)
for gesto, peso, texto in opcoes:
@@ -467,42 +654,48 @@ class Vida:
self.anotar('emoji', 'Teve uma ideia e desenhou ' + tema, agora)
return True
def tocar_reacao(self, agora):
def tocar_reacao(self, agora, anotar=True):
try:
reacoes = [r for r in self.treinamento.status()['reacoes'] if r.get('ativa', True) and r['feedback'] >= 0]
except Exception:
return False
if not reacoes:
return False
r = self.rng.choice(reacoes[:40])
r = self.rng.choice(reacoes[:40]) if anotar else reacoes[0] # para mostrar: a mais nova
try:
self.treinamento.testar(r['id'])
except ValueError:
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
def agir(self, agora):
"""Um ato: o que um bichinho com essas necessidades faria agora."""
n = self.necessidades
perto = self.alguem_perto()
self.porque = ''
if self.dormindo():
# de madrugada nunca acorda sozinho (nem com gente se mexendo na cama); de dia, só descansado
if n['energia'] >= DESCANSADO and not self.madrugada(agora):
self.acordar(agora, 'Acordou descansado')
self.porque = 'energia %d: descansou' % n['energia']
return 'acordar'
# 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
and self.aprendizado.chega_logo(agora)):
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 None
if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO:
self.dormir(agora, 'Ficou tarde, foi dormir')
self.porque = 'madrugada e ninguém interagiu nos últimos 10 min'
return 'dormir'
if n['energia'] < SONO:
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'
humor = self.humor()
if self.celular_atividade():
@@ -511,9 +704,11 @@ class Vida:
folga = agora - self.atividade_em > 90 # um respiro entre uma atividade e outra
# cansado: um cochilo na caminha de vez em quando (recupera energia sem o sono pesado)
if dia and folga and n['energia'] < COCHILO and self.rng.random() < 0.35:
self.porque = 'energia %d: abaixo de %d, um cochilo' % (n['energia'], COCHILO)
return self.comecar_atividade(agora, 'dormir', self.rng.uniform(120, 240))
# entediado: vai fazer alguma coisa por alguns minutos (pode ser interrompido)
if dia and folga and n['diversao'] < TEDIO and self.rng.random() < 0.75:
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))
# ninguém olhando há um tempo: vai cuidar da vida dele (ler, pintar, música...)
if perto:
@@ -523,8 +718,9 @@ class Vida:
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):
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.porque_vazia(agora), agora)
self.porque, agora)
return self.comecar_atividade(agora, 'dormir', segundos)
# alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!"
if dia and perto and humor != 'bravo':
@@ -533,26 +729,41 @@ class Vida:
self.contar_aprendi(item['id'], agora)
with self.lock:
n['social'] = _limitar(n['social'] + 5)
self.porque = 'alguém por perto e aprendeu "%s" hoje' % item.get('nome', '?')
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:
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))
# 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:
self.porque = 'de bem (%s): vontade própria' % humor
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(40, 90))
# habilidade: inventar uma figurinha nova (raro, com limite por dia e intervalo)
if not self.motivo_sem_emoji(agora) and self.celular_online() and self.rng.random() < 0.08:
if self.criar_figurinha(agora, humor):
self.porque = 'teve uma ideia (até %d figurinhas por dia)' % self.config['max_emojis_dia']
return 'figurinha'
# às vezes lembra de uma reação da biblioteca
if self.treinamento is not None and humor in ('feliz', 'tranquilo', 'entediado') and self.rng.random() < 0.15:
if self.tocar_reacao(agora):
with self.lock:
n['diversao'] = _limitar(n['diversao'] + 8)
self.porque = 'humor %s: lembrou de uma reação da biblioteca' % humor
return 'reacao'
lista = ATOS['carente_perto'] if humor == 'carente' and perto else ATOS.get(humor, ATOS['tranquilo'])
if sozinho: # sem plateia: nada de acenar ou ficar tímido
lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3]
gesto, texto = self.escolher(lista)
self.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:
self.aprendizado.escolheu(gesto, agora)
self.enfileirar(gesto)
@@ -588,6 +799,28 @@ class Vida:
self.sono_profundo = False
self.enfileirar('sono leve')
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():
# o celular escolhe os gestos soltos pelo humor; reenvia de tempos em tempos (pode ter reiniciado)
zanga = self.zanga()
@@ -600,8 +833,14 @@ class Vida:
self.humor_enviado = (humor, agora)
self.zanga_enviada = zanga
if agora >= self.proximo_ato:
antes = self.diario[0]['horario'] if self.diario else None
ato = self.agir(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']]
self.proximo_ato = agora + self.rng.uniform(lo, hi)
if ato or agora - self.publicado_em > 30 or humor != self.humor():
+7 -1
View File
@@ -1,5 +1,5 @@
<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 Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte';
@@ -21,6 +21,8 @@
import Guia from './telas/Guia.svelte';
import Criar from './telas/Criar.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 { TOPICO_DA_TELA } from './lib/guia';
@@ -35,10 +37,14 @@
guia: Guia,
criar: Criar,
'dia-a-dia': DiaADia,
console: Console,
pessoas: Pessoas,
};
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
eventos: Bell,
console: Terminal,
pessoas: Users,
conversa: MessageCircle,
reacoes: Smile,
movimento: Hand,
@@ -81,6 +81,14 @@
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>
<Grupo
@@ -148,6 +156,10 @@
{: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>
{/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 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';
};
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) => {
const dados = JSON.parse((e as MessageEvent).data);
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).' },
],
},
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: {
titulo: 'Ideias do dia',
emoji: '💡',
@@ -279,6 +337,10 @@ export const TOPICOS: Record<string, TopicoGuia> = {
onde: 'Eventos › Modo presença',
resumo:
'O PIR liga a câmera do celular quando alguém aparece e acorda o Carinha. Sem ninguém pelo tempo escolhido, a câmera desliga, ele dorme e fica em silêncio. Na madrugada silenciosa o PIR não acorda.',
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: {
titulo: 'Tela e personalidade',
@@ -367,13 +429,13 @@ export const TOPICOS: Record<string, TopicoGuia> = {
/** Ordem do tutorial (página 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',
'reacoes', 'conversa', 'movimento', 'atalhos',
];
/** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = {
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
'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',
};
+1 -1
View File
@@ -13,7 +13,7 @@ export interface EventoHistorico {
}
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 };
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
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;
y: number;
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);
+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[][];
ms: 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],
@@ -26,6 +27,8 @@ export class Rastreamento {
private ativo = false;
private config: AppConfig = {};
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 alvoEm = 0;
private em = 0;
@@ -48,6 +51,11 @@ export class Rastreamento {
addEventListener('resize', this.desenhar);
}
aoResultado(fn: (r: { faces: Face[]; maos: Ponto[][] }) => void) {
this.ouvintes.add(fn);
return () => this.ouvintes.delete(fn);
}
private limpar() {
this.ctx.clearRect(0, 0, this.tela.width, this.tela.height);
}
@@ -142,10 +150,12 @@ export class Rastreamento {
}
const r = data as Resultado;
this.resultado = r;
this.ouvintes.forEach((fn) => fn({ faces: r.faces, maos: r.hands }));
this.em = r.timestamp;
const anterior = this.alvo;
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.tipo === 'rosto' && typeof r.sorriso === 'number') alvo.sorriso = Math.round(r.sorriso * 100) / 100;
this.alvo = alvo;
this.alvoEm = r.timestamp;
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 "/". */
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 }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'console', titulo: 'Console', atalho: 'g k' },
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
{ id: 'pessoas', titulo: 'Pessoas', atalho: 'g p' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
+12 -2
View File
@@ -104,7 +104,15 @@ export interface Vida {
sono_profundo?: boolean;
/** '' quando pode inventar figurinha agora; senão, o motivo. */
pode_criar: string;
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade' | '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). */
@@ -120,7 +128,9 @@ export interface PlanoSemana {
}
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). */
semana: (number | null)[][];
historico_dias: number;
+7
View File
@@ -15,6 +15,8 @@
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.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 { estado } from '../lib/estado.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 celular as duas colunas se desfazem (display: contents) e a ordem vem do `order`. -->
<div class="coluna direita">
<div class="c-quem"><QuemE minimo={3} /></div>
<div class="c-vida"><VidaCartao /></div>
{#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" />
</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.">
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
<Gestos />
@@ -394,10 +398,13 @@ const pirAtivo = $derived(
min-width: 0;
}
.palco { order: 0; }
.c-quem { order: 1; }
.c-quem:empty { display: none; }
.c-vida { order: 1; }
.c-aprendizado { order: 2; }
.c-celular { order: 3; }
.c-camera { order: 4; }
.c-jogos { order: 5; }
.c-gestos { order: 5; }
.c-aparencia { order: 6; }
.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;
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>
<div class="eventos">
@@ -132,6 +138,21 @@
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por">
<Slider rotulo="Desligar câmera e dormir depois de" valor={st?.config.ausencia ?? 5} min={1} max={60} passo={1} unidade=" min" onchange={(v) => historico.configurar({ ausencia: v })} />
</Linha>
<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 titulo="O que registrar" ajuda="eventos">
@@ -306,4 +327,25 @@
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>
+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>