Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bacecdd17e | ||
|
|
82f9673692 | ||
|
|
3d954efc03 | ||
|
|
92701e8a16 | ||
|
|
938467e318 | ||
|
|
2933407b9e | ||
|
|
a99aa897c4 | ||
|
|
7b0a05435f | ||
|
|
6e80332a8d | ||
|
|
93f91e491c |
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="br.carinha.rosto"
|
package="br.carinha.rosto"
|
||||||
android:versionCode="13"
|
android:versionCode="14"
|
||||||
android:versionName="0.9.1">
|
android:versionName="0.10.0">
|
||||||
|
|
||||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,28 @@
|
|||||||
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
|
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
|
||||||
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
|
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
|
||||||
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
|
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
|
||||||
|
/* telão dos jogos: por cima do rosto, texto grande na cor dos olhos (o resultado tem cor própria) */
|
||||||
|
#telao{position:fixed;inset:0;z-index:8;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
|
gap:2vh;background:rgba(0,0,0,.9);font-family:system-ui,sans-serif;color:var(--cor);text-align:center;pointer-events:none}
|
||||||
|
#telao[hidden]{display:none} /* sem isto o display:flex acima venceria o hidden e o letreiro não sumia */
|
||||||
|
#telao .titulo{font-weight:900;font-size:clamp(40px,24vh,240px);line-height:1;letter-spacing:.02em;
|
||||||
|
text-shadow:0 0 .25em currentColor}
|
||||||
|
#telao .sub{font-size:clamp(14px,6vh,48px);font-weight:600;opacity:.9}
|
||||||
|
#telao .lados{display:flex;align-items:center;gap:5vw}
|
||||||
|
#telao .lado{display:flex;flex-direction:column;align-items:center;gap:1vh;min-width:22vw}
|
||||||
|
#telao .rot{font-size:clamp(12px,5vh,36px);font-weight:700;letter-spacing:.1em;text-transform:uppercase;opacity:.85}
|
||||||
|
#telao .sim{font-size:clamp(40px,26vh,220px);line-height:1}
|
||||||
|
#telao .nome{font-size:clamp(12px,5vh,36px);font-weight:600}
|
||||||
|
#telao .x{font-size:clamp(24px,12vh,110px);font-weight:300;opacity:.7}
|
||||||
|
#telao.tom-eu .titulo{color:#ffd166}
|
||||||
|
#telao.tom-voce .titulo{color:#7ae582}
|
||||||
|
#telao.tom-empate .titulo{color:#cfe8ff}
|
||||||
|
#telao.tom-conta .titulo{font-size:clamp(60px,48vh,420px)}
|
||||||
|
#telao.entra .titulo,#telao.entra .lados{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1)}
|
||||||
|
#telao.piscar.entra .titulo{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1),telaoPisca .5s steps(2) .45s infinite}
|
||||||
|
@keyframes telaoEstoura{from{transform:scale(.3);opacity:0}to{transform:scale(1);opacity:1}}
|
||||||
|
@keyframes telaoPisca{50%{opacity:.25}}
|
||||||
|
@media(prefers-reduced-motion:reduce){#telao *{animation:none!important}}
|
||||||
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
|
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
|
||||||
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
|
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
|
||||||
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
|
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
|
||||||
@@ -595,6 +617,10 @@ function atividade(nome, segundos, receita) {
|
|||||||
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
|
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
|
||||||
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
|
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
|
||||||
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
|
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
|
||||||
|
lembrarDoDia(nome, receita);
|
||||||
|
const agora = performance.now() / 1000, dur = Number(segundos) || 0;
|
||||||
|
HUMANO.oops = dur > 20 && Math.random() < 0.35 ? { ini: agora + dur * (0.3 + 0.4 * Math.random()) } : null;
|
||||||
|
if (HUMANO.oops) HUMANO.oops.fim = HUMANO.oops.ini + 2.2;
|
||||||
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
|
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,6 +644,121 @@ function tocarExpressaoCriada(texto) {
|
|||||||
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
|
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- jeito de gente
|
||||||
|
Acordar grogue, sonhar dormindo, hesitar antes de agir, errar na atividade e ficar sem graça,
|
||||||
|
olhar nos olhos e desviar, sorrir de volta para quem sorri e reagir à luz do ambiente. */
|
||||||
|
const HUMANO = {
|
||||||
|
dormiaAntes: false, dormiuEm: 0, grogueIni: 0, grogueAte: 0, bocejou: true,
|
||||||
|
sonhoProx: 0, sonhoAte: 0, sonhos: ['estrela', 'bola', 'nota', 'coracao', 'livro', 'flor'],
|
||||||
|
oops: null, // {ini, fim}: um tropeço no meio da atividade
|
||||||
|
evita: { ate: 0, prox: 0, dx: 0, dy: 0 }, // desvio do olhar durante o contato visual
|
||||||
|
sorriso: { desde: 0, ultimo: 0 },
|
||||||
|
luz: { lux: null, media: null, sonolentoAte: 0, ofuscadoAte: 0, mudou: 0 },
|
||||||
|
};
|
||||||
|
const OBJETO_DA_ATIVIDADE = { bola: 'bola', ler: 'livro', cafe: 'xicara', cantar: 'nota', violao: 'nota', pintar: 'pincel',
|
||||||
|
regar: 'flor', telescopio: 'estrela', malhar: 'halter', bolhas: 'bolha', videogame: 'controle', dancar: 'estrela',
|
||||||
|
meditar: 'coracao', cozinhar: 'chama', computador: 'controle', pescar: 'bolha', pipa: 'estrela', skate: 'estrela',
|
||||||
|
corda: 'estrela' };
|
||||||
|
function lembrarDoDia(nome, receita) { // o que ele fez hoje vira assunto dos sonhos
|
||||||
|
const obj = receita && Array.isArray(receita.objetos) && receita.objetos[0] ? receita.objetos[0].tipo : OBJETO_DA_ATIVIDADE[nome];
|
||||||
|
if (obj) HUMANO.sonhos = [obj].concat(HUMANO.sonhos.filter((o) => o !== obj)).slice(0, 8);
|
||||||
|
}
|
||||||
|
function falarFrase(texto, segundos) { // "dizer ..." do modo vida: legenda + voz de robô
|
||||||
|
if (window.carinhaSemServidor || !texto || r.dormindo) return;
|
||||||
|
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto, segundos: segundos || Math.max(4, Math.min(14, texto.length / 9)) });
|
||||||
|
falar(texto);
|
||||||
|
}
|
||||||
|
/* Telão dos jogos: contagem regressiva piscando, as duas jogadas lado a lado e o resultado grande. */
|
||||||
|
let telaoAte = null;
|
||||||
|
function mostrarTelao(json) {
|
||||||
|
let t;
|
||||||
|
try { t = JSON.parse(json); } catch (e) { return; }
|
||||||
|
let el = document.getElementById('telao');
|
||||||
|
if (!el) { el = document.createElement('div'); el.id = 'telao'; document.body.appendChild(el); }
|
||||||
|
const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
const lados = (t.lados || []).map((l) =>
|
||||||
|
'<div class="lado"><span class="rot">' + esc(l.rotulo) + '</span><span class="sim">' + esc(l.simbolo) + '</span>' +
|
||||||
|
'<span class="nome">' + esc(l.nome) + '</span></div>').join('<div class="x">×</div>');
|
||||||
|
el.className = 'tom-' + (t.tom || 'neutro') + (t.piscar ? ' piscar' : '');
|
||||||
|
el.style.setProperty('--cor', 'rgb(' + CIANO.map(Math.round).join(',') + ')');
|
||||||
|
el.innerHTML = (lados ? '<div class="lados">' + lados + '</div>' : '') +
|
||||||
|
(t.titulo ? '<div class="titulo">' + esc(t.titulo) + '</div>' : '') + (t.sub ? '<div class="sub">' + esc(t.sub) + '</div>' : '');
|
||||||
|
el.hidden = false;
|
||||||
|
void el.offsetWidth; el.classList.add('entra'); // reinicia a animação a cada letreiro
|
||||||
|
if (r.dormindo) r.dormindo = false;
|
||||||
|
clearTimeout(telaoAte);
|
||||||
|
telaoAte = setTimeout(() => { el.hidden = true; }, (t.segundos || 2.5) * 1000);
|
||||||
|
}
|
||||||
|
let olhosAte = null;
|
||||||
|
function mostrarNosOlhos(texto) { // "✌️ 2.5": símbolo nos olhos; "- 0" apaga
|
||||||
|
const i = texto.lastIndexOf(' ');
|
||||||
|
const simbolo = i > 0 ? texto.slice(0, i) : texto, seg = i > 0 ? parseFloat(texto.slice(i + 1)) : 2.5;
|
||||||
|
clearTimeout(olhosAte);
|
||||||
|
if (r.dormindo) { r.dormindo = false; }
|
||||||
|
window.carinhaOlhosIcone = simbolo && simbolo !== '-' ? simbolo : null;
|
||||||
|
if (window.carinhaOlhosIcone) olhosAte = setTimeout(() => { window.carinhaOlhosIcone = null; }, (seg || 2.5) * 1000);
|
||||||
|
}
|
||||||
|
function hesitar(fn, gesto) { // às vezes pensa um instante antes de agir
|
||||||
|
if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn();
|
||||||
|
setTimeout(fn, 150 + Math.random() * 450);
|
||||||
|
}
|
||||||
|
function humanoTick(t) {
|
||||||
|
// acordou depois de dormir um tempo: fica grogue (olhos pesados, lento) e boceja
|
||||||
|
if (r.dormindo && !HUMANO.dormiaAntes) HUMANO.dormiuEm = t;
|
||||||
|
if (!r.dormindo && HUMANO.dormiaAntes && t - HUMANO.dormiuEm > 180) {
|
||||||
|
HUMANO.grogueIni = t; HUMANO.grogueAte = t + 30 + Math.random() * 20; HUMANO.bocejou = false;
|
||||||
|
}
|
||||||
|
HUMANO.dormiaAntes = r.dormindo;
|
||||||
|
if (!HUMANO.bocejou && t - HUMANO.grogueIni > 1.6 && !r.dormindo && !r.kf) { HUMANO.bocejou = true; animar('bocejar'); }
|
||||||
|
// dormindo (com a tela acesa): de vez em quando sonha com algo do dia
|
||||||
|
if (r.dormindo && !SONO.apagada && !window.carinhaSemServidor) {
|
||||||
|
if (!HUMANO.sonhoProx) HUMANO.sonhoProx = t + 40 + Math.random() * 60;
|
||||||
|
if (t >= HUMANO.sonhoProx) {
|
||||||
|
const dur = 6 + Math.random() * 4;
|
||||||
|
HUMANO.sonhoAte = t + dur; HUMANO.sonhoProx = t + dur + 45 + Math.random() * 90;
|
||||||
|
const obj = HUMANO.sonhos[Math.floor(Math.random() * Math.min(4, HUMANO.sonhos.length))];
|
||||||
|
if (window.MotorRosto && window.MotorRosto.sonho) window.MotorRosto.sonho(obj, dur);
|
||||||
|
}
|
||||||
|
} else HUMANO.sonhoProx = 0;
|
||||||
|
}
|
||||||
|
function grogue(t) { // 1 = acabou de acordar, 0 = esperto
|
||||||
|
return t < HUMANO.grogueAte ? Math.min(1, (HUMANO.grogueAte - t) / (HUMANO.grogueAte - HUMANO.grogueIni)) : 0;
|
||||||
|
}
|
||||||
|
function piscarHumano(t) { animar(grogue(t) > 0.2 || t < HUMANO.luz.sonolentoAte ? 'piscar2' : 'piscar'); }
|
||||||
|
function desvioDoOlhar(t) { // olha nos olhos 2 a 5 s, desvia um instante e volta
|
||||||
|
const ev = HUMANO.evita;
|
||||||
|
if (t >= ev.prox) {
|
||||||
|
const dura = Math.random() < 0.7 ? 0.6 + Math.random() * 0.7 : 0;
|
||||||
|
ev.ate = t + dura; ev.prox = ev.ate + 2 + Math.random() * 3;
|
||||||
|
ev.dx = (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 8); ev.dy = 3 + Math.random() * 5;
|
||||||
|
}
|
||||||
|
return t < ev.ate ? ev : null;
|
||||||
|
}
|
||||||
|
function contagio(t, agora) { // sorriu para ele (rastreamento do painel): sorri de volta
|
||||||
|
const S = HUMANO.sorriso;
|
||||||
|
if (t.tipo !== 'rosto' || typeof t.sorriso !== 'number' || r.dormindo || ATIV) { S.desde = 0; return; }
|
||||||
|
if (t.sorriso > 0.55) { if (!S.desde) S.desde = agora; } else S.desde = 0;
|
||||||
|
if (S.desde && agora - S.desde > 0.6 && agora - S.ultimo > 20 && !r.kf) {
|
||||||
|
S.ultimo = agora; S.desde = 0;
|
||||||
|
animar(t.sorriso > 0.8 ? 'rir' : 'feliz');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function luzAmbiente(lux) { // sensor de luz do celular (lux)
|
||||||
|
lux = Number(lux);
|
||||||
|
if (!Number.isFinite(lux) || lux < 0) return;
|
||||||
|
const L = HUMANO.luz, t = performance.now() / 1000, antes = L.media;
|
||||||
|
L.lux = lux;
|
||||||
|
L.media = antes === null ? lux : antes + (lux - antes) * 0.15;
|
||||||
|
if (antes === null || t - L.mudou < 8) return;
|
||||||
|
if (lux < 3 && antes > 25) { // apagaram a luz: fica com sono
|
||||||
|
L.mudou = t; L.sonolentoAte = t + 60; L.media = lux;
|
||||||
|
if (!r.dormindo && !r.kf) animar('bocejar');
|
||||||
|
} else if (lux > 80 && antes < 8) { // acenderam a luz de repente: aperta os olhos
|
||||||
|
L.mudou = t; L.ofuscadoAte = t + 1.8; L.media = lux;
|
||||||
|
if (r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) r.dormindo = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function repouso(t) { // pose calma: olha devagar, "respira"
|
function repouso(t) { // pose calma: olha devagar, "respira"
|
||||||
if (r.dormindo) {
|
if (r.dormindo) {
|
||||||
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
||||||
@@ -626,6 +767,7 @@ function repouso(t) { // pose calma: olha devagar, "respira
|
|||||||
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
||||||
r.dx = 0;
|
r.dx = 0;
|
||||||
r.dy = 3.0 * Math.sin(t * 1.4);
|
r.dy = 3.0 * Math.sin(t * 1.4);
|
||||||
|
if (t < HUMANO.sonhoAte) { r.dx = 3.2 * Math.sin(t * 6.5); r.boca = 'sorriso'; } // sonhando
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
r.forma = "aberto"; r.forma2 = null;
|
r.forma = "aberto"; r.forma2 = null;
|
||||||
@@ -638,7 +780,7 @@ function repouso(t) { // pose calma: olha devagar, "respira
|
|||||||
const longe = Math.random() < 0.25;
|
const longe = Math.random() < 0.25;
|
||||||
OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
|
OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
|
||||||
OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
|
OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
|
||||||
OLHAR.prox = t + 1.2 + Math.random() * 3;
|
OLHAR.prox = t + (1.2 + Math.random() * 3) * (1 + 2 * grogue(t)); // grogue: olhar lento
|
||||||
}
|
}
|
||||||
OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3;
|
OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3;
|
||||||
OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
|
OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
|
||||||
@@ -651,9 +793,23 @@ function repouso(t) { // pose calma: olha devagar, "respira
|
|||||||
if (ATIV.forma) r.forma = ATIV.forma;
|
if (ATIV.forma) r.forma = ATIV.forma;
|
||||||
if (ATIV.boca) r.boca = ATIV.boca;
|
if (ATIV.boca) r.boca = ATIV.boca;
|
||||||
if (ATIV.ab) r.ab = ATIV.ab;
|
if (ATIV.ab) r.ab = ATIV.ab;
|
||||||
|
const o = HUMANO.oops; // errou: susto e depois fica sem graça
|
||||||
|
if (o && t >= o.ini && t < o.fim) {
|
||||||
|
if (t - o.ini < 0.5) { r.forma = 'surpreso'; r.boca = 'aberta'; r.ab = 1.1; }
|
||||||
|
else { r.forma = 'feliz'; r.boca = 'sorriso'; r.cor = lerpCor(r.cor, ROSA, 0.5); r.dy += 6; }
|
||||||
|
}
|
||||||
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
|
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
|
||||||
r.forma = "bravo";
|
r.forma = "bravo";
|
||||||
}
|
}
|
||||||
|
const g = grogue(t);
|
||||||
|
if (g > 0) { // acabou de acordar: olhos pesados, cor apagada, olhar baixo
|
||||||
|
r.ab = Math.min(r.ab, 1 - 0.5 * g);
|
||||||
|
r.cor = lerpCor(r.cor, corEscura(0.7), 0.35 * g);
|
||||||
|
if (g > 0.5 && !ATIV) r.boca = 'sono';
|
||||||
|
r.dx *= 1 - 0.5 * g; r.dy = r.dy * (1 - 0.5 * g) + 3 * g;
|
||||||
|
}
|
||||||
|
if (t < HUMANO.luz.sonolentoAte) r.ab = Math.min(r.ab, 0.78);
|
||||||
|
if (t < HUMANO.luz.ofuscadoAte) { r.ab = Math.min(r.ab, 0.3); r.forma = 'feliz'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function desenhar() {
|
function desenhar() {
|
||||||
@@ -667,7 +823,9 @@ function desenhar() {
|
|||||||
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
|
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
|
||||||
const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV;
|
const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV;
|
||||||
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
|
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
|
||||||
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
|
const ev = seguindo && TRACK.alvo.tipo === 'rosto' ? desvioDoOlhar(tAgora) : null;
|
||||||
|
const dx = seguindo ? (ev ? MOV.olharX * 0.3 + ev.dx : MOV.olharX) : r.dx;
|
||||||
|
const dy = seguindo ? (ev ? MOV.olharY * 0.3 + ev.dy : MOV.olharY) : r.dy;
|
||||||
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
||||||
const comMotor = ROSTO.motor && window.MotorRosto;
|
const comMotor = ROSTO.motor && window.MotorRosto;
|
||||||
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
|
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
|
||||||
@@ -751,6 +909,9 @@ function contarAprendi(texto) {
|
|||||||
function trata(ordem) {
|
function trata(ordem) {
|
||||||
const bruta = (ordem || "").trim();
|
const bruta = (ordem || "").trim();
|
||||||
if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase
|
if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase
|
||||||
|
if (/^dizer /i.test(bruta)) { falarFrase(bruta.slice(6).trim()); return; }
|
||||||
|
if (/^olhos /i.test(bruta)) { mostrarNosOlhos(bruta.slice(6).trim()); return; } // jogos: ✊ ✋ ✌️ e números
|
||||||
|
if (/^telao /i.test(bruta)) { mostrarTelao(bruta.slice(6)); return; } // letreiro grande dos jogos
|
||||||
ordem = bruta.toLowerCase();
|
ordem = bruta.toLowerCase();
|
||||||
if (!ordem) return;
|
if (!ordem) return;
|
||||||
if(ordem==='recarregar') {
|
if(ordem==='recarregar') {
|
||||||
@@ -797,14 +958,16 @@ function trata(ordem) {
|
|||||||
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
|
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
r.dormindo = false; animar("surpreso");
|
const longo = r.dormindo && performance.now() / 1000 - HUMANO.dormiuEm > 180;
|
||||||
|
r.dormindo = false;
|
||||||
|
if (!longo) animar("surpreso"); // sono longo: acorda devagar, grogue (humanoTick)
|
||||||
} else if (ordem === "aleatorio") {
|
} else if (ordem === "aleatorio") {
|
||||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||||
} else if (SEQS[ordem]) {
|
} else if (SEQS[ordem]) {
|
||||||
if (window.MotorRosto) {
|
if (window.MotorRosto) {
|
||||||
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
||||||
}
|
}
|
||||||
animar(ordem);
|
hesitar(() => animar(ordem), ordem);
|
||||||
} else if (ordem === "calma") {
|
} else if (ordem === "calma") {
|
||||||
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
||||||
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
|
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
|
||||||
@@ -1121,6 +1284,7 @@ function rastrear(t) {
|
|||||||
(r.dormindo && !configRecebida?.cameraSono)) return;
|
(r.dormindo && !configRecebida?.cameraSono)) return;
|
||||||
const agora=performance.now()/1000;
|
const agora=performance.now()/1000;
|
||||||
TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y;
|
TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y;
|
||||||
|
contagio(t, agora);
|
||||||
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
|
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
|
||||||
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
|
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
|
||||||
if(t.atividade===true) {
|
if(t.atividade===true) {
|
||||||
@@ -1306,6 +1470,7 @@ const AUDIO = {
|
|||||||
};
|
};
|
||||||
if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ
|
if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ
|
||||||
if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
||||||
|
if (window.eilik) window.eilik.luz = luzAmbiente; // sensor de luz do celular (o app chama)
|
||||||
|
|
||||||
let configRecebida = null;
|
let configRecebida = null;
|
||||||
function configurarApp(c) {
|
function configurarApp(c) {
|
||||||
@@ -1353,6 +1518,7 @@ async function sincronizarApp() {
|
|||||||
bateria: window.carinhaBateria,
|
bateria: window.carinhaBateria,
|
||||||
aparelho: window.carinhaAparelho,
|
aparelho: window.carinhaAparelho,
|
||||||
video: window.carinhaVideo,
|
video: window.carinhaVideo,
|
||||||
|
luz: HUMANO.luz.lux,
|
||||||
}) });
|
}) });
|
||||||
}
|
}
|
||||||
} catch (e) { /* servidor ausente: animação continua */ }
|
} catch (e) { /* servidor ausente: animação continua */ }
|
||||||
@@ -1448,6 +1614,7 @@ function laco(t) {
|
|||||||
|
|
||||||
checarSilencio(t / 1000);
|
checarSilencio(t / 1000);
|
||||||
atualizarFeicao(t / 1000);
|
atualizarFeicao(t / 1000);
|
||||||
|
humanoTick(t / 1000);
|
||||||
|
|
||||||
ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null;
|
ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null;
|
||||||
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
|
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
|
||||||
@@ -1461,7 +1628,7 @@ function laco(t) {
|
|||||||
if (r.dormindo) {
|
if (r.dormindo) {
|
||||||
proxPiscar = proxGesto = t + 3600000;
|
proxPiscar = proxGesto = t + 3600000;
|
||||||
} else if (t >= proxPiscar) {
|
} else if (t >= proxPiscar) {
|
||||||
animar("piscar");
|
piscarHumano(t / 1000);
|
||||||
proxPiscar = t + 1600 + Math.random() * 3500;
|
proxPiscar = t + 1600 + Math.random() * 3500;
|
||||||
} else if (AUTO && t >= proxGesto && !ATIV) {
|
} else if (AUTO && t >= proxGesto && !ATIV) {
|
||||||
animar(gestoSolto());
|
animar(gestoSolto());
|
||||||
|
|||||||
@@ -287,11 +287,13 @@
|
|||||||
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
|
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
|
||||||
desenharMaos(ctx, o, olhos, tela.height);
|
desenharMaos(ctx, o, olhos, tela.height);
|
||||||
desenharZzz(ctx, o, olhos);
|
desenharZzz(ctx, o, olhos);
|
||||||
|
desenharSonho(ctx, o, olhos);
|
||||||
} else {
|
} else {
|
||||||
cctx.clearRect(0, 0, camada.width, camada.height);
|
cctx.clearRect(0, 0, camada.width, camada.height);
|
||||||
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
|
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
|
||||||
desenharMaos(cctx, o, olhos, tela.height);
|
desenharMaos(cctx, o, olhos, tela.height);
|
||||||
desenharZzz(cctx, o, olhos);
|
desenharZzz(cctx, o, olhos);
|
||||||
|
desenharSonho(cctx, o, olhos);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
|
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
|
||||||
ctx.drawImage(camada, 0, 0);
|
ctx.drawImage(camada, 0, 0);
|
||||||
@@ -421,6 +423,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Zzz
|
// ------------------------------------------------------------------ Zzz
|
||||||
|
// ----------------------------------------------------------- sonho
|
||||||
|
// Dormindo, às vezes aparece bem fraquinho um balão de pensamento com algo do dia (a bola, a estrela...).
|
||||||
|
const sonho = { obj: null, t0: 0, dur: 0 };
|
||||||
|
function definirSonho(obj, segundos) {
|
||||||
|
sonho.obj = obj || null; sonho.t0 = performance.now() / 1000; sonho.dur = segundos || 7;
|
||||||
|
}
|
||||||
|
function desenharSonho(c, o, olhos) {
|
||||||
|
if (!o.dormindo || !sonho.obj) return;
|
||||||
|
const k = (o.t - sonho.t0) / sonho.dur;
|
||||||
|
if (k < 0) return;
|
||||||
|
if (k >= 1) { sonho.obj = null; return; }
|
||||||
|
const surge = Math.min(1, k * sonho.dur / 1.2, (1 - k) * sonho.dur / 1.2); // aparece e some devagar
|
||||||
|
const e = olhos.slice().sort((a, b) => a.cx - b.cx)[0]; // olho da esquerda na tela
|
||||||
|
const R = e.ry * 0.62, bx = e.cx - e.ry * 0.1;
|
||||||
|
const by = Math.max(R * 1.2, e.cy - e.ry * 1.75) + Math.sin(o.t * 1.3) * e.ry * 0.05; // nunca sai da tela
|
||||||
|
c.save();
|
||||||
|
c.strokeStyle = css(o.cor); c.fillStyle = css(o.cor, 0.08); c.lineWidth = Math.max(1.5, R * 0.06);
|
||||||
|
c.globalAlpha = 0.45 * surge;
|
||||||
|
for (const [f, rr] of [[0.25, 0.09], [0.55, 0.15]]) { // bolinhas subindo até o balão
|
||||||
|
c.beginPath(); c.arc(e.cx + e.ry * 0.3 - f * e.ry * 0.3, e.cy - e.ry * 0.75 + (by + R - e.cy + e.ry * 0.75) * f, R * rr * 1.3, 0, Math.PI * 2); c.stroke();
|
||||||
|
}
|
||||||
|
c.beginPath(); c.arc(bx, by, R, 0, Math.PI * 2); c.fill(); c.stroke();
|
||||||
|
c.globalAlpha = 0.55 * surge;
|
||||||
|
desenharObjeto(c, sonho.obj, bx, by, R * 1.15, o.t, o.cor, Math.sin(o.t * 0.8) * 0.15, 0);
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
function desenharZzz(c, o, olhos, fator) {
|
function desenharZzz(c, o, olhos, fator) {
|
||||||
fator = fator || 1;
|
fator = fator || 1;
|
||||||
if (!o.dormindo) return;
|
if (!o.dormindo) return;
|
||||||
@@ -1385,7 +1414,7 @@
|
|||||||
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
|
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
|
||||||
},
|
},
|
||||||
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
|
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
|
||||||
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS),
|
faixa: definirFaixa, sonho: definirSonho, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS),
|
||||||
/** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */
|
/** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */
|
||||||
atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
|
atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
|
||||||
atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null),
|
atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null),
|
||||||
|
|||||||
@@ -7,55 +7,75 @@ import android.os.Handler;
|
|||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.Inet4Address;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.NetworkInterface;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acha a placa na rede pelo anúncio mDNS "_carinha._tcp" (avahi na Orange Pi, o mesmo do
|
* Acha a placa na rede quando o endereço salvo não responde, para o app se reconectar sem
|
||||||
* carinha.local). Assim, se o IP da placa mudar, o app se reconecta sem ninguém digitar endereço.
|
* ninguém digitar endereço:
|
||||||
* Uma busca por vez; desiste depois de alguns segundos e responde no fio principal.
|
* 1. pelo anúncio mDNS "_carinha._tcp" (avahi na Orange Pi, o mesmo do carinha.local);
|
||||||
|
* 2. se o anúncio não chegar (alguns roteadores e chips Wi-Fi descartam multicast entre aparelhos
|
||||||
|
* sem fio), perguntando a cada endereço da rede do celular quem responde /app/health como
|
||||||
|
* "carinha-pc".
|
||||||
|
* Uma busca por vez; a resposta chega no fio principal (null = não achou).
|
||||||
*/
|
*/
|
||||||
final class Descoberta {
|
final class Descoberta {
|
||||||
interface Resposta { void achou(String endereco); } // null = não achou
|
interface Resposta { void achou(String endereco); }
|
||||||
|
|
||||||
private static final String TAG = "CarinhaDescoberta";
|
private static final String TAG = "CarinhaDescoberta";
|
||||||
private static final String TIPO = "_carinha._tcp.";
|
private static final String TIPO = "_carinha._tcp.";
|
||||||
private static final long LIMITE = 8000;
|
private static final long LIMITE_ANUNCIO = 6000;
|
||||||
|
private static final int PORTA = 8100;
|
||||||
|
|
||||||
private final NsdManager nsd;
|
private final NsdManager nsd;
|
||||||
private final Handler principal = new Handler(Looper.getMainLooper());
|
private final Handler principal = new Handler(Looper.getMainLooper());
|
||||||
private NsdManager.DiscoveryListener ouvinte;
|
private NsdManager.DiscoveryListener ouvinte;
|
||||||
private Resposta resposta;
|
private Resposta resposta;
|
||||||
|
private boolean ocupada;
|
||||||
|
|
||||||
Descoberta(Context c) {
|
Descoberta(Context c) {
|
||||||
nsd = (NsdManager) c.getApplicationContext().getSystemService(Context.NSD_SERVICE);
|
nsd = (NsdManager) c.getApplicationContext().getSystemService(Context.NSD_SERVICE);
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean procurando() { return ouvinte != null; }
|
boolean procurando() { return ocupada; }
|
||||||
|
|
||||||
void procurar(Resposta r) {
|
void procurar(Resposta r) {
|
||||||
if (nsd == null) { r.achou(null); return; }
|
if (ocupada) return;
|
||||||
if (ouvinte != null) return;
|
ocupada = true;
|
||||||
resposta = r;
|
resposta = r;
|
||||||
|
if (nsd == null) { semAnuncio(); return; }
|
||||||
ouvinte = new NsdManager.DiscoveryListener() {
|
ouvinte = new NsdManager.DiscoveryListener() {
|
||||||
@Override public void onDiscoveryStarted(String tipo) { }
|
@Override public void onDiscoveryStarted(String tipo) { }
|
||||||
@Override public void onDiscoveryStopped(String tipo) { }
|
@Override public void onDiscoveryStopped(String tipo) { }
|
||||||
@Override public void onStartDiscoveryFailed(String tipo, int erro) { terminar(null); }
|
@Override public void onStartDiscoveryFailed(String tipo, int erro) { fimDoAnuncio(null); }
|
||||||
@Override public void onStopDiscoveryFailed(String tipo, int erro) { }
|
@Override public void onStopDiscoveryFailed(String tipo, int erro) { }
|
||||||
@Override public void onServiceLost(NsdServiceInfo s) { }
|
@Override public void onServiceLost(NsdServiceInfo s) { }
|
||||||
@Override public void onServiceFound(NsdServiceInfo s) {
|
@Override public void onServiceFound(NsdServiceInfo s) {
|
||||||
if (!s.getServiceType().startsWith("_carinha._tcp")) return;
|
if (s.getServiceType().startsWith("_carinha._tcp")) resolver(s);
|
||||||
resolver(s);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
nsd.discoverServices(TIPO, NsdManager.PROTOCOL_DNS_SD, ouvinte);
|
nsd.discoverServices(TIPO, NsdManager.PROTOCOL_DNS_SD, ouvinte);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "busca falhou: " + e);
|
Log.w(TAG, "busca por anúncio falhou: " + e);
|
||||||
ouvinte = null;
|
ouvinte = null;
|
||||||
r.achou(null);
|
semAnuncio();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
principal.postDelayed(new Runnable() {
|
principal.postDelayed(new Runnable() {
|
||||||
@Override public void run() { terminar(null); }
|
@Override public void run() { fimDoAnuncio(null); }
|
||||||
}, LIMITE);
|
}, LIMITE_ANUNCIO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
@SuppressWarnings("deprecation")
|
||||||
@@ -67,7 +87,7 @@ final class Descoberta {
|
|||||||
if (s.getHost() == null || s.getHost().isLoopbackAddress()) return;
|
if (s.getHost() == null || s.getHost().isLoopbackAddress()) return;
|
||||||
String ip = s.getHost().getHostAddress();
|
String ip = s.getHost().getHostAddress();
|
||||||
if (ip == null || ip.contains(":")) return; // só IPv4: o servidor ouve em 0.0.0.0
|
if (ip == null || ip.contains(":")) return; // só IPv4: o servidor ouve em 0.0.0.0
|
||||||
terminar("http://" + ip + ":" + s.getPort());
|
fimDoAnuncio("http://" + ip + ":" + s.getPort());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -75,19 +95,93 @@ final class Descoberta {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void terminar(final String endereco) {
|
/** Fim da busca por anúncio: achou responde; não achou passa para a varredura. */
|
||||||
|
private void fimDoAnuncio(final String endereco) {
|
||||||
principal.post(new Runnable() {
|
principal.post(new Runnable() {
|
||||||
@Override public void run() {
|
@Override public void run() {
|
||||||
if (ouvinte == null) return; // já respondeu
|
if (ouvinte == null) return; // já terminou
|
||||||
try { nsd.stopServiceDiscovery(ouvinte); } catch (Exception e) { /* já parou */ }
|
try { nsd.stopServiceDiscovery(ouvinte); } catch (Exception e) { /* já parou */ }
|
||||||
ouvinte = null;
|
ouvinte = null;
|
||||||
Resposta r = resposta;
|
if (endereco != null) { Log.i(TAG, "placa achada pelo anúncio: " + endereco); responder(endereco); }
|
||||||
resposta = null;
|
else semAnuncio();
|
||||||
Log.i(TAG, endereco == null ? "placa não encontrada na rede" : "placa encontrada: " + endereco);
|
|
||||||
if (r != null) r.achou(endereco);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void parar() { terminar(null); }
|
private void semAnuncio() {
|
||||||
|
new Thread(new Runnable() {
|
||||||
|
@Override public void run() {
|
||||||
|
final String achado = varrer();
|
||||||
|
Log.i(TAG, achado == null ? "placa não encontrada na rede" : "placa achada na varredura: " + achado);
|
||||||
|
principal.post(new Runnable() { @Override public void run() { responder(achado); } });
|
||||||
|
}
|
||||||
|
}, "procura-placa").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void responder(String endereco) {
|
||||||
|
ocupada = false;
|
||||||
|
Resposta r = resposta;
|
||||||
|
resposta = null;
|
||||||
|
if (r != null) r.achou(endereco);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pergunta a cada endereço da rede /24 do celular (em paralelo) quem é o servidor do Carinha. */
|
||||||
|
private static String varrer() {
|
||||||
|
final List<String> alvos = new ArrayList<String>();
|
||||||
|
for (InetAddress meu : enderecosLocais()) {
|
||||||
|
byte[] b = meu.getAddress();
|
||||||
|
String prefixo = (b[0] & 255) + "." + (b[1] & 255) + "." + (b[2] & 255) + ".";
|
||||||
|
for (int i = 1; i < 255; i++) {
|
||||||
|
if (i != (b[3] & 255)) alvos.add(prefixo + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (alvos.isEmpty()) return null;
|
||||||
|
final AtomicReference<String> achado = new AtomicReference<String>();
|
||||||
|
ExecutorService fios = Executors.newFixedThreadPool(32);
|
||||||
|
for (final String ip : alvos) {
|
||||||
|
fios.execute(new Runnable() {
|
||||||
|
@Override public void run() {
|
||||||
|
if (achado.get() == null && ehCarinha(ip)) achado.compareAndSet(null, "http://" + ip + ":" + PORTA);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fios.shutdown();
|
||||||
|
try { fios.awaitTermination(20, TimeUnit.SECONDS); } catch (InterruptedException e) { /* segue */ }
|
||||||
|
fios.shutdownNow();
|
||||||
|
return achado.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean ehCarinha(String ip) {
|
||||||
|
HttpURLConnection c = null;
|
||||||
|
try {
|
||||||
|
c = (HttpURLConnection) new URL("http://" + ip + ":" + PORTA + "/app/health").openConnection();
|
||||||
|
c.setConnectTimeout(600);
|
||||||
|
c.setReadTimeout(1500);
|
||||||
|
if (c.getResponseCode() != 200) return false;
|
||||||
|
byte[] buf = new byte[256];
|
||||||
|
int n;
|
||||||
|
try (InputStream in = c.getInputStream()) { n = in.read(buf); }
|
||||||
|
return n > 0 && new String(buf, 0, n, "UTF-8").contains("carinha-pc");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (c != null) c.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IPv4 privados das redes do celular (Wi-Fi), sem loopback nem dados móveis de operadora. */
|
||||||
|
private static List<InetAddress> enderecosLocais() {
|
||||||
|
List<InetAddress> lista = new ArrayList<InetAddress>();
|
||||||
|
try {
|
||||||
|
for (NetworkInterface ni : Collections.list(NetworkInterface.getNetworkInterfaces())) {
|
||||||
|
if (!ni.isUp() || ni.isLoopback() || ni.getName().startsWith("rmnet")) continue;
|
||||||
|
for (InetAddress a : Collections.list(ni.getInetAddresses())) {
|
||||||
|
if (a instanceof Inet4Address && a.isSiteLocalAddress()) lista.add(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "sem endereços locais: " + e);
|
||||||
|
}
|
||||||
|
return lista;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package br.carinha.rosto;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.hardware.Sensor;
|
||||||
|
import android.hardware.SensorEvent;
|
||||||
|
import android.hardware.SensorEventListener;
|
||||||
|
import android.hardware.SensorManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sensor de luz do celular (lux). A página decide o que fazer (apagaram a luz: sono; acenderam de
|
||||||
|
* repente: aperta os olhos). Manda no máximo a cada INTERVALO e só quando muda de verdade.
|
||||||
|
*/
|
||||||
|
final class Luz implements SensorEventListener {
|
||||||
|
interface Ouvinte { void luz(float lux); }
|
||||||
|
|
||||||
|
private static final long INTERVALO = 700; // ms
|
||||||
|
private final SensorManager gerente;
|
||||||
|
private final Sensor sensor;
|
||||||
|
private final Ouvinte ouvinte;
|
||||||
|
private long enviadoEm;
|
||||||
|
private float enviado = -1;
|
||||||
|
|
||||||
|
Luz(Context c, Ouvinte ouvinte) {
|
||||||
|
gerente = (SensorManager) c.getSystemService(Context.SENSOR_SERVICE);
|
||||||
|
sensor = gerente == null ? null : gerente.getDefaultSensor(Sensor.TYPE_LIGHT);
|
||||||
|
this.ouvinte = ouvinte;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ligar() { if (sensor != null) gerente.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL); }
|
||||||
|
|
||||||
|
void desligar() { if (sensor != null) gerente.unregisterListener(this); }
|
||||||
|
|
||||||
|
@Override public void onSensorChanged(SensorEvent e) {
|
||||||
|
float lux = e.values[0];
|
||||||
|
long agora = System.currentTimeMillis();
|
||||||
|
boolean mudou = enviado < 0 || Math.abs(lux - enviado) > Math.max(2f, enviado * 0.15f);
|
||||||
|
if (!mudou || agora - enviadoEm < INTERVALO) return;
|
||||||
|
enviado = lux;
|
||||||
|
enviadoEm = agora;
|
||||||
|
ouvinte.luz(lux);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onAccuracyChanged(Sensor s, int precisao) { }
|
||||||
|
}
|
||||||
@@ -109,6 +109,7 @@ public class MainActivity extends Activity {
|
|||||||
private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto
|
private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto
|
||||||
private final CerebroLocal cerebroLocal = new CerebroLocal();
|
private final CerebroLocal cerebroLocal = new CerebroLocal();
|
||||||
private Descoberta descoberta; // acha a placa na rede quando o endereço salvo não responde
|
private Descoberta descoberta; // acha a placa na rede quando o endereço salvo não responde
|
||||||
|
private Luz luz; // sensor de luz: a página reage a apagar/acender a luz
|
||||||
private boolean jaCaiuParaLocal = false;
|
private boolean jaCaiuParaLocal = false;
|
||||||
private boolean jaRecarregou = false;
|
private boolean jaRecarregou = false;
|
||||||
private boolean usandoLocal = false; // servidor caiu: ficamos na copia do APK
|
private boolean usandoLocal = false; // servidor caiu: ficamos na copia do APK
|
||||||
@@ -193,6 +194,11 @@ public class MainActivity extends Activity {
|
|||||||
cerebroLocal.chave = token;
|
cerebroLocal.chave = token;
|
||||||
cerebroLocal.iniciar(this, servidor);
|
cerebroLocal.iniciar(this, servidor);
|
||||||
descoberta = new Descoberta(this);
|
descoberta = new Descoberta(this);
|
||||||
|
luz = new Luz(this, new Luz.Ouvinte() {
|
||||||
|
@Override public void luz(final float lux) {
|
||||||
|
if (emPrimeiroPlano) web.evaluateJavascript("window.eilik&&window.eilik.luz&&window.eilik.luz(" + fmt(lux) + ")", null);
|
||||||
|
}
|
||||||
|
});
|
||||||
remoto = new ControleRemoto(new Runnable() {
|
remoto = new ControleRemoto(new Runnable() {
|
||||||
@Override public void run() {
|
@Override public void run() {
|
||||||
runOnUiThread(new Runnable() {
|
runOnUiThread(new Runnable() {
|
||||||
@@ -867,6 +873,7 @@ public class MainActivity extends Activity {
|
|||||||
protected void onResume() {
|
protected void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
emPrimeiroPlano = true;
|
emPrimeiroPlano = true;
|
||||||
|
luz.ligar();
|
||||||
ultimoUsoTela = System.currentTimeMillis();
|
ultimoUsoTela = System.currentTimeMillis();
|
||||||
remoto.servidor(servidor);
|
remoto.servidor(servidor);
|
||||||
remoto.iniciar();
|
remoto.iniciar();
|
||||||
@@ -890,6 +897,7 @@ public class MainActivity extends Activity {
|
|||||||
protected void onPause() {
|
protected void onPause() {
|
||||||
super.onPause();
|
super.onPause();
|
||||||
emPrimeiroPlano = false;
|
emPrimeiroPlano = false;
|
||||||
|
luz.desligar();
|
||||||
remoto.parar();
|
remoto.parar();
|
||||||
rtcWeb.evaluateJavascript("window.rtcParar&&window.rtcParar()", null);
|
rtcWeb.evaluateJavascript("window.rtcParar&&window.rtcParar()", null);
|
||||||
rtcAtivo = false;
|
rtcAtivo = false;
|
||||||
|
|||||||
+97
-6
@@ -24,7 +24,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from comum import gravar_atomico, ler_json
|
from comum import gravar_atomico, ler_json
|
||||||
|
|
||||||
PADRAO = dict(ligado=True, analise_ia=True, ia='gemini')
|
PADRAO = dict(ligado=True, analise_ia=True, ia='gemini', ajustar_sono=True)
|
||||||
IAS = ('gemini', 'local', 's10')
|
IAS = ('gemini', 'local', 's10')
|
||||||
JANELA = 60 # s depois de um ato em que a resposta de alguém conta para ele
|
JANELA = 60 # s depois de um ato em que a resposta de alguém conta para ele
|
||||||
PESO_RESPOSTA = {'carinho': 1.0, 'brincar': 1.0, 'conversa': 1.0, 'alimentar': 0.6, 'cutucar': 0.5}
|
PESO_RESPOSTA = {'carinho': 1.0, 'brincar': 1.0, 'conversa': 1.0, 'alimentar': 0.6, 'cutucar': 0.5}
|
||||||
@@ -38,6 +38,9 @@ MIN_MINUTOS_HORA = 10 # minutos observados numa hora para ela entrar na rotin
|
|||||||
ROTINA_CHEIA, ROTINA_VAZIA = 0.5, 0.3
|
ROTINA_CHEIA, ROTINA_VAZIA = 0.5, 0.3
|
||||||
AVISO_CHEGADA = 15 * 60 # s antes da hora movimentada em que ele acorda do cochilo
|
AVISO_CHEGADA = 15 * 60 # s antes da hora movimentada em que ele acorda do cochilo
|
||||||
MAX_LICOES = 30
|
MAX_LICOES = 30
|
||||||
|
# Horário de dormir pela rotina: anda 1 h por noite, no máximo 3 h depois / 2 h antes do que a pessoa
|
||||||
|
# escolheu, com pelo menos 5 dias observados, e a madrugada nunca fica menor que 5 h.
|
||||||
|
SONO_MIN_DIAS, SONO_ATRASO_MAX, SONO_ADIANTO_MAX, SONO_MIN_HORAS = 5, 3, 2, 5
|
||||||
MAX_DECISOES = 800 # decisões guardadas (com o motivo), umas duas semanas
|
MAX_DECISOES = 800 # decisões guardadas (com o motivo), umas duas semanas
|
||||||
AVISO_SONO = 10 * 60 # no máximo uma nota de "não acordou com o sensor" a cada 10 min
|
AVISO_SONO = 10 * 60 # no máximo uma nota de "não acordou com o sensor" a cada 10 min
|
||||||
DIAS_SEMANA = ['segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado', 'domingo']
|
DIAS_SEMANA = ['segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado', 'domingo']
|
||||||
@@ -87,11 +90,17 @@ def _faixas(horas):
|
|||||||
faixas[-1] = (faixas[-1][0], h + 1)
|
faixas[-1] = (faixas[-1][0], h + 1)
|
||||||
else:
|
else:
|
||||||
faixas.append((h, h + 1))
|
faixas.append((h, h + 1))
|
||||||
|
if len(faixas) > 1 and faixas[0][0] == 0 and faixas[-1][1] == 24: # passa da meia-noite: uma faixa só
|
||||||
|
faixas = [(faixas[-1][0], faixas[0][1] + 24)] + faixas[1:-1]
|
||||||
return faixas
|
return faixas
|
||||||
|
|
||||||
|
|
||||||
|
def _na_noite(h, ini, fim):
|
||||||
|
return ini <= h < fim if ini < fim else (h >= ini or h < fim)
|
||||||
|
|
||||||
|
|
||||||
def _texto_faixas(faixas):
|
def _texto_faixas(faixas):
|
||||||
partes = ['das %dh às %dh' % (a, b % 24) for a, b in faixas]
|
partes = ['%s %dh %s %dh' % ('da' if a == 1 else 'das', a, 'à' if b % 24 == 1 else 'às', b % 24) for a, b in faixas]
|
||||||
return partes[0] if len(partes) == 1 else ', '.join(partes[:-1]) + ' e ' + partes[-1]
|
return partes[0] if len(partes) == 1 else ', '.join(partes[:-1]) + ' e ' + partes[-1]
|
||||||
|
|
||||||
|
|
||||||
@@ -126,6 +135,12 @@ class Aprendizado:
|
|||||||
self.pode_ia = lambda provedor: False
|
self.pode_ia = lambda provedor: False
|
||||||
self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro
|
self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro
|
||||||
self.sono_avisado = 0.0
|
self.sono_avisado = 0.0
|
||||||
|
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None
|
||||||
|
# horário da madrugada (Ajustes do celular): o main liga; None = madrugada silenciosa desligada
|
||||||
|
self.noite = lambda: None
|
||||||
|
self.mudar_noite = lambda ini, fim: None
|
||||||
|
self.sono_base = None # (início, fim) que a pessoa escolheu
|
||||||
|
self.sono_auto = None # (início, fim) que ele mesmo pôs por último
|
||||||
try:
|
try:
|
||||||
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
|
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
|
||||||
self.config.update(self.validar(salvo.get('config', {})))
|
self.config.update(self.validar(salvo.get('config', {})))
|
||||||
@@ -151,6 +166,9 @@ class Aprendizado:
|
|||||||
self.analise['em'] = salvo['analise_em']
|
self.analise['em'] = salvo['analise_em']
|
||||||
if isinstance(salvo.get('hoje'), dict) and salvo['hoje'].get('dia'):
|
if isinstance(salvo.get('hoje'), dict) and salvo['hoje'].get('dia'):
|
||||||
self.hoje.update(salvo['hoje'])
|
self.hoje.update(salvo['hoje'])
|
||||||
|
for chave in ('sono_base', 'sono_auto'):
|
||||||
|
if isinstance(salvo.get(chave), list) and len(salvo[chave]) == 2:
|
||||||
|
setattr(self, chave, tuple(int(v) % 24 for v in salvo[chave]))
|
||||||
except (OSError, ValueError, TypeError, KeyError, AttributeError):
|
except (OSError, ValueError, TypeError, KeyError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -200,7 +218,8 @@ class Aprendizado:
|
|||||||
licoes=self.licoes, rotina_avisada=self.rotina_avisada, rotina_avisada_dia=self.rotina_avisada_dia,
|
licoes=self.licoes, rotina_avisada=self.rotina_avisada, rotina_avisada_dia=self.rotina_avisada_dia,
|
||||||
hoje=self.hoje, semana=self.semana,
|
hoje=self.hoje, semana=self.semana,
|
||||||
semana_n=self.semana_n, historico=self.historico, plano=self.plano, sugestao=self.sugestao,
|
semana_n=self.semana_n, historico=self.historico, plano=self.plano, sugestao=self.sugestao,
|
||||||
analise_em=self.analise['em'], decisoes=self.decisoes)
|
analise_em=self.analise['em'], decisoes=self.decisoes, sono_base=self.sono_base,
|
||||||
|
sono_auto=self.sono_auto)
|
||||||
self.salvo_em = time.time()
|
self.salvo_em = time.time()
|
||||||
try:
|
try:
|
||||||
gravar_atomico(self.caminho, dados)
|
gravar_atomico(self.caminho, dados)
|
||||||
@@ -327,6 +346,7 @@ class Aprendizado:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
self.decisoes.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto, motivo=motivo))
|
self.decisoes.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto, motivo=motivo))
|
||||||
del self.decisoes[MAX_DECISOES:]
|
del self.decisoes[MAX_DECISOES:]
|
||||||
|
self.console('aprendizado', texto, motivo, agora=agora)
|
||||||
|
|
||||||
def porque_chegada(self, agora):
|
def porque_chegada(self, agora):
|
||||||
t = time.localtime(agora + 3600)
|
t = time.localtime(agora + 3600)
|
||||||
@@ -436,14 +456,27 @@ class Aprendizado:
|
|||||||
licoes.append('Tentei %s %d vezes e ninguém ligou. Vou fazer menos.' % (self.nome(ignorado[0][1]), ignorado[0][0]))
|
licoes.append('Tentei %s %d vezes e ninguém ligou. Vou fazer menos.' % (self.nome(ignorado[0][1]), ignorado[0][0]))
|
||||||
if observou and hoje['atos'] >= 5 and not sum(hoje['respostas'].values()):
|
if observou and hoje['atos'] >= 5 and not sum(hoje['respostas'].values()):
|
||||||
licoes.append('Hoje ninguém brincou comigo. Amanhã eu tento coisas diferentes.')
|
licoes.append('Hoje ninguém brincou comigo. Amanhã eu tento coisas diferentes.')
|
||||||
# rotina: quando passa a ter um padrão (ou ele muda), conta
|
# sono: a rotina mexe no horário de dormir (antes de contar a rotina, que já usa o novo horário)
|
||||||
|
licoes += self.ajustar_noite(hoje['dia'])
|
||||||
|
# rotina: quando passa a ter um padrão (ou ele muda), conta, separando o que cai na madrugada
|
||||||
if self.dias >= 2 and not self.plano:
|
if self.dias >= 2 and not self.plano:
|
||||||
cheias = [h for h in range(24) if self.rotina[h] is not None and self.rotina[h] >= ROTINA_CHEIA]
|
cheias = [h for h in range(24) if self.rotina[h] is not None and self.rotina[h] >= ROTINA_CHEIA]
|
||||||
mudou = not self.rotina_avisada or len(set(cheias) ^ set(self.rotina_avisada)) >= 3 or (
|
mudou = not self.rotina_avisada or len(set(cheias) ^ set(self.rotina_avisada)) >= 3 or (
|
||||||
cheias != self.rotina_avisada and self.dias - self.rotina_avisada_dia >= 7)
|
cheias != self.rotina_avisada and self.dias - self.rotina_avisada_dia >= 7)
|
||||||
if cheias and mudou:
|
if cheias and mudou:
|
||||||
licoes.append('Percebi que vocês costumam estar por perto %s. Vou estar acordado nessa hora.'
|
noite = self.noite()
|
||||||
% _texto_faixas(_faixas(cheias)))
|
dormindo = [h for h in cheias if noite and _na_noite(h, *noite)]
|
||||||
|
acordado = [h for h in cheias if h not in dormindo]
|
||||||
|
quieto = 'é a minha hora de dormir: fico quietinho, a não ser que alguém me chame.'
|
||||||
|
if acordado:
|
||||||
|
texto = ('Percebi que vocês costumam estar por perto %s. Vou estar acordado nessa hora.'
|
||||||
|
% _texto_faixas(_faixas(acordado)))
|
||||||
|
if dormindo:
|
||||||
|
resto = _texto_faixas(_faixas(dormindo)) + ' ' + quieto
|
||||||
|
texto += ' ' + resto[0].upper() + resto[1:]
|
||||||
|
else:
|
||||||
|
texto = 'Percebi gente por perto %s, mas %s' % (_texto_faixas(_faixas(dormindo)), quieto)
|
||||||
|
licoes.append(texto)
|
||||||
self.rotina_avisada, self.rotina_avisada_dia = cheias, self.dias
|
self.rotina_avisada, self.rotina_avisada_dia = cheias, self.dias
|
||||||
# sono: acordou à toa várias vezes na mesma hora
|
# sono: acordou à toa várias vezes na mesma hora
|
||||||
piores = sorted(((n, h) for h, n in enumerate(hoje['falsos']) if n >= 2), reverse=True)
|
piores = sorted(((n, h) for h, n in enumerate(hoje['falsos']) if n >= 2), reverse=True)
|
||||||
@@ -463,6 +496,63 @@ class Aprendizado:
|
|||||||
del self.licoes[MAX_LICOES:]
|
del self.licoes[MAX_LICOES:]
|
||||||
return licoes
|
return licoes
|
||||||
|
|
||||||
|
def ajustar_noite(self, dia):
|
||||||
|
"""Muda o horário da madrugada aos poucos pela rotina da casa. Devolve as lições."""
|
||||||
|
noite = self.noite()
|
||||||
|
if not noite or not self.config['ajustar_sono'] or not self.ligado():
|
||||||
|
return []
|
||||||
|
if self.sono_base is None or noite != self.sono_auto:
|
||||||
|
self.sono_base = noite # a pessoa escolheu (ou mudou) o horário: vira a base
|
||||||
|
if self.dias < SONO_MIN_DIAS:
|
||||||
|
return []
|
||||||
|
ini, fim = noite
|
||||||
|
base_ini, base_fim = self.sono_base
|
||||||
|
|
||||||
|
def cheia(h):
|
||||||
|
v = self.rotina[h % 24]
|
||||||
|
return v is not None and v >= ROTINA_CHEIA
|
||||||
|
|
||||||
|
def vazia(h):
|
||||||
|
v = self.rotina[h % 24]
|
||||||
|
return v is not None and v <= ROTINA_VAZIA
|
||||||
|
|
||||||
|
atraso, adianto = (ini - base_ini) % 24, (base_fim - fim) % 24
|
||||||
|
novo_ini, novo_fim = ini, fim
|
||||||
|
if cheia(ini) and atraso < SONO_ATRASO_MAX:
|
||||||
|
novo_ini = (ini + 1) % 24 # a primeira hora da madrugada costuma ter gente
|
||||||
|
elif 0 < atraso <= SONO_ATRASO_MAX and vazia(ini - 1):
|
||||||
|
novo_ini = (ini - 1) % 24 # a casa voltou a dormir cedo: volta para o de antes
|
||||||
|
if cheia(fim - 1) and adianto < SONO_ADIANTO_MAX:
|
||||||
|
novo_fim = (fim - 1) % 24 # a casa acorda antes
|
||||||
|
elif 0 < adianto <= SONO_ADIANTO_MAX and vazia(fim):
|
||||||
|
novo_fim = (fim + 1) % 24
|
||||||
|
if (novo_ini, novo_fim) == tuple(noite) or (novo_fim - novo_ini) % 24 < SONO_MIN_HORAS:
|
||||||
|
return []
|
||||||
|
self.mudar_noite(novo_ini, novo_fim)
|
||||||
|
self.sono_auto = (novo_ini, novo_fim)
|
||||||
|
licoes, motivos = [], []
|
||||||
|
if novo_ini != ini:
|
||||||
|
if novo_ini == (ini + 1) % 24:
|
||||||
|
licoes.append('Vocês têm ficado acordados até mais tarde: agora eu vou dormir às %dh (antes era %dh).'
|
||||||
|
% (novo_ini, ini))
|
||||||
|
motivos.append('às %dh costuma ter gente' % ini)
|
||||||
|
else:
|
||||||
|
licoes.append('A casa voltou a dormir mais cedo: volto a dormir às %dh.' % novo_ini)
|
||||||
|
motivos.append('às %dh a casa costuma estar vazia' % novo_ini)
|
||||||
|
if novo_fim != fim:
|
||||||
|
if novo_fim == (fim - 1) % 24:
|
||||||
|
licoes.append('Vocês têm acordado cedo: agora eu acordo às %dh (antes era %dh).' % (novo_fim, fim))
|
||||||
|
motivos.append('às %dh costuma ter gente' % novo_fim)
|
||||||
|
else:
|
||||||
|
licoes.append('A casa voltou a acordar mais tarde: volto a acordar às %dh.' % novo_fim)
|
||||||
|
motivos.append('às %dh a casa costuma estar vazia' % fim)
|
||||||
|
fim_do_dia = time.mktime(time.strptime(dia + ' 23:59:59', '%Y-%m-%d %H:%M:%S'))
|
||||||
|
self.decisoes.insert(0, dict(
|
||||||
|
horario=fim_do_dia, tipo='sono', texto='Mudou o horário de dormir para %dh às %dh' % (novo_ini, novo_fim),
|
||||||
|
motivo='; '.join(motivos) + ' (o horário escolhido é %dh às %dh)' % tuple(self.sono_base)))
|
||||||
|
self.console('aprendizado', self.decisoes[0]['texto'], self.decisoes[0]['motivo'])
|
||||||
|
return licoes
|
||||||
|
|
||||||
# ------------------------------------------------------------------ semana: IA e plano
|
# ------------------------------------------------------------------ semana: IA e plano
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def validar_plano(bruto, origem, criado=None):
|
def validar_plano(bruto, origem, criado=None):
|
||||||
@@ -691,6 +781,7 @@ class Aprendizado:
|
|||||||
gosto.sort(key=lambda g: -g['peso'])
|
gosto.sort(key=lambda g: -g['peso'])
|
||||||
hoje = self.hoje
|
hoje = self.hoje
|
||||||
return dict(config=dict(self.config), dias=self.dias, rotina=list(self.rotina),
|
return dict(config=dict(self.config), dias=self.dias, rotina=list(self.rotina),
|
||||||
|
sono_base=self.sono_base, sono_auto=self.sono_auto,
|
||||||
sono=[self._extra(f) if self.ligado() else 0 for f in self.falsos],
|
sono=[self._extra(f) if self.ligado() else 0 for f in self.falsos],
|
||||||
gosto=gosto, licoes=self.licoes[:12],
|
gosto=gosto, licoes=self.licoes[:12],
|
||||||
semana=[[v if self.semana_n[s][h] else None for h, v in enumerate(linha)]
|
semana=[[v if self.semana_n[s][h] else None for h, v in enumerate(linha)]
|
||||||
|
|||||||
+172
-5
@@ -20,6 +20,28 @@
|
|||||||
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
|
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
|
||||||
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
|
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
|
||||||
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
|
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
|
||||||
|
/* telão dos jogos: por cima do rosto, texto grande na cor dos olhos (o resultado tem cor própria) */
|
||||||
|
#telao{position:fixed;inset:0;z-index:8;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
|
gap:2vh;background:rgba(0,0,0,.9);font-family:system-ui,sans-serif;color:var(--cor);text-align:center;pointer-events:none}
|
||||||
|
#telao[hidden]{display:none} /* sem isto o display:flex acima venceria o hidden e o letreiro não sumia */
|
||||||
|
#telao .titulo{font-weight:900;font-size:clamp(40px,24vh,240px);line-height:1;letter-spacing:.02em;
|
||||||
|
text-shadow:0 0 .25em currentColor}
|
||||||
|
#telao .sub{font-size:clamp(14px,6vh,48px);font-weight:600;opacity:.9}
|
||||||
|
#telao .lados{display:flex;align-items:center;gap:5vw}
|
||||||
|
#telao .lado{display:flex;flex-direction:column;align-items:center;gap:1vh;min-width:22vw}
|
||||||
|
#telao .rot{font-size:clamp(12px,5vh,36px);font-weight:700;letter-spacing:.1em;text-transform:uppercase;opacity:.85}
|
||||||
|
#telao .sim{font-size:clamp(40px,26vh,220px);line-height:1}
|
||||||
|
#telao .nome{font-size:clamp(12px,5vh,36px);font-weight:600}
|
||||||
|
#telao .x{font-size:clamp(24px,12vh,110px);font-weight:300;opacity:.7}
|
||||||
|
#telao.tom-eu .titulo{color:#ffd166}
|
||||||
|
#telao.tom-voce .titulo{color:#7ae582}
|
||||||
|
#telao.tom-empate .titulo{color:#cfe8ff}
|
||||||
|
#telao.tom-conta .titulo{font-size:clamp(60px,48vh,420px)}
|
||||||
|
#telao.entra .titulo,#telao.entra .lados{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1)}
|
||||||
|
#telao.piscar.entra .titulo{animation:telaoEstoura .45s cubic-bezier(.2,1.6,.4,1),telaoPisca .5s steps(2) .45s infinite}
|
||||||
|
@keyframes telaoEstoura{from{transform:scale(.3);opacity:0}to{transform:scale(1);opacity:1}}
|
||||||
|
@keyframes telaoPisca{50%{opacity:.25}}
|
||||||
|
@media(prefers-reduced-motion:reduce){#telao *{animation:none!important}}
|
||||||
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
|
/* sem a placa: placa ✕ celular na cor dos olhos. Deitado, o rosto dormindo fica onde está e o
|
||||||
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
|
desenho fica em pé no canto direito; em pé, o rosto sobe e o desenho vai embaixo. */
|
||||||
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
|
#semServidor{position:fixed;right:2.5vw;top:50%;transform:translateY(-50%);width:min(24vw,42vh);
|
||||||
@@ -595,6 +617,10 @@ function atividade(nome, segundos, receita) {
|
|||||||
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
|
if (!window.MotorRosto || !window.MotorRosto.atividade) return;
|
||||||
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
|
if (nome === "parar" || !nome) { window.MotorRosto.atividade(null); return; }
|
||||||
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
|
if (r.dormindo) { r.dormindo = false; animar("surpreso"); }
|
||||||
|
lembrarDoDia(nome, receita);
|
||||||
|
const agora = performance.now() / 1000, dur = Number(segundos) || 0;
|
||||||
|
HUMANO.oops = dur > 20 && Math.random() < 0.35 ? { ini: agora + dur * (0.3 + 0.4 * Math.random()) } : null;
|
||||||
|
if (HUMANO.oops) HUMANO.oops.fim = HUMANO.oops.ini + 2.2;
|
||||||
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
|
if (window.MotorRosto.atividade(nome, segundos, receita) && nome === "cantar") tocarSom("musica");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,6 +644,121 @@ function tocarExpressaoCriada(texto) {
|
|||||||
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
|
if (rc.maos && rc.maos !== "nenhuma" && window.MotorRosto && window.MotorRosto.maos) window.MotorRosto.maos(rc.maos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- jeito de gente
|
||||||
|
Acordar grogue, sonhar dormindo, hesitar antes de agir, errar na atividade e ficar sem graça,
|
||||||
|
olhar nos olhos e desviar, sorrir de volta para quem sorri e reagir à luz do ambiente. */
|
||||||
|
const HUMANO = {
|
||||||
|
dormiaAntes: false, dormiuEm: 0, grogueIni: 0, grogueAte: 0, bocejou: true,
|
||||||
|
sonhoProx: 0, sonhoAte: 0, sonhos: ['estrela', 'bola', 'nota', 'coracao', 'livro', 'flor'],
|
||||||
|
oops: null, // {ini, fim}: um tropeço no meio da atividade
|
||||||
|
evita: { ate: 0, prox: 0, dx: 0, dy: 0 }, // desvio do olhar durante o contato visual
|
||||||
|
sorriso: { desde: 0, ultimo: 0 },
|
||||||
|
luz: { lux: null, media: null, sonolentoAte: 0, ofuscadoAte: 0, mudou: 0 },
|
||||||
|
};
|
||||||
|
const OBJETO_DA_ATIVIDADE = { bola: 'bola', ler: 'livro', cafe: 'xicara', cantar: 'nota', violao: 'nota', pintar: 'pincel',
|
||||||
|
regar: 'flor', telescopio: 'estrela', malhar: 'halter', bolhas: 'bolha', videogame: 'controle', dancar: 'estrela',
|
||||||
|
meditar: 'coracao', cozinhar: 'chama', computador: 'controle', pescar: 'bolha', pipa: 'estrela', skate: 'estrela',
|
||||||
|
corda: 'estrela' };
|
||||||
|
function lembrarDoDia(nome, receita) { // o que ele fez hoje vira assunto dos sonhos
|
||||||
|
const obj = receita && Array.isArray(receita.objetos) && receita.objetos[0] ? receita.objetos[0].tipo : OBJETO_DA_ATIVIDADE[nome];
|
||||||
|
if (obj) HUMANO.sonhos = [obj].concat(HUMANO.sonhos.filter((o) => o !== obj)).slice(0, 8);
|
||||||
|
}
|
||||||
|
function falarFrase(texto, segundos) { // "dizer ..." do modo vida: legenda + voz de robô
|
||||||
|
if (window.carinhaSemServidor || !texto || r.dormindo) return;
|
||||||
|
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto, segundos: segundos || Math.max(4, Math.min(14, texto.length / 9)) });
|
||||||
|
falar(texto);
|
||||||
|
}
|
||||||
|
/* Telão dos jogos: contagem regressiva piscando, as duas jogadas lado a lado e o resultado grande. */
|
||||||
|
let telaoAte = null;
|
||||||
|
function mostrarTelao(json) {
|
||||||
|
let t;
|
||||||
|
try { t = JSON.parse(json); } catch (e) { return; }
|
||||||
|
let el = document.getElementById('telao');
|
||||||
|
if (!el) { el = document.createElement('div'); el.id = 'telao'; document.body.appendChild(el); }
|
||||||
|
const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||||
|
const lados = (t.lados || []).map((l) =>
|
||||||
|
'<div class="lado"><span class="rot">' + esc(l.rotulo) + '</span><span class="sim">' + esc(l.simbolo) + '</span>' +
|
||||||
|
'<span class="nome">' + esc(l.nome) + '</span></div>').join('<div class="x">×</div>');
|
||||||
|
el.className = 'tom-' + (t.tom || 'neutro') + (t.piscar ? ' piscar' : '');
|
||||||
|
el.style.setProperty('--cor', 'rgb(' + CIANO.map(Math.round).join(',') + ')');
|
||||||
|
el.innerHTML = (lados ? '<div class="lados">' + lados + '</div>' : '') +
|
||||||
|
(t.titulo ? '<div class="titulo">' + esc(t.titulo) + '</div>' : '') + (t.sub ? '<div class="sub">' + esc(t.sub) + '</div>' : '');
|
||||||
|
el.hidden = false;
|
||||||
|
void el.offsetWidth; el.classList.add('entra'); // reinicia a animação a cada letreiro
|
||||||
|
if (r.dormindo) r.dormindo = false;
|
||||||
|
clearTimeout(telaoAte);
|
||||||
|
telaoAte = setTimeout(() => { el.hidden = true; }, (t.segundos || 2.5) * 1000);
|
||||||
|
}
|
||||||
|
let olhosAte = null;
|
||||||
|
function mostrarNosOlhos(texto) { // "✌️ 2.5": símbolo nos olhos; "- 0" apaga
|
||||||
|
const i = texto.lastIndexOf(' ');
|
||||||
|
const simbolo = i > 0 ? texto.slice(0, i) : texto, seg = i > 0 ? parseFloat(texto.slice(i + 1)) : 2.5;
|
||||||
|
clearTimeout(olhosAte);
|
||||||
|
if (r.dormindo) { r.dormindo = false; }
|
||||||
|
window.carinhaOlhosIcone = simbolo && simbolo !== '-' ? simbolo : null;
|
||||||
|
if (window.carinhaOlhosIcone) olhosAte = setTimeout(() => { window.carinhaOlhosIcone = null; }, (seg || 2.5) * 1000);
|
||||||
|
}
|
||||||
|
function hesitar(fn, gesto) { // às vezes pensa um instante antes de agir
|
||||||
|
if (['surpreso', 'piscar', 'piscar2'].indexOf(gesto) >= 0 || Math.random() > 0.3) return fn();
|
||||||
|
setTimeout(fn, 150 + Math.random() * 450);
|
||||||
|
}
|
||||||
|
function humanoTick(t) {
|
||||||
|
// acordou depois de dormir um tempo: fica grogue (olhos pesados, lento) e boceja
|
||||||
|
if (r.dormindo && !HUMANO.dormiaAntes) HUMANO.dormiuEm = t;
|
||||||
|
if (!r.dormindo && HUMANO.dormiaAntes && t - HUMANO.dormiuEm > 180) {
|
||||||
|
HUMANO.grogueIni = t; HUMANO.grogueAte = t + 30 + Math.random() * 20; HUMANO.bocejou = false;
|
||||||
|
}
|
||||||
|
HUMANO.dormiaAntes = r.dormindo;
|
||||||
|
if (!HUMANO.bocejou && t - HUMANO.grogueIni > 1.6 && !r.dormindo && !r.kf) { HUMANO.bocejou = true; animar('bocejar'); }
|
||||||
|
// dormindo (com a tela acesa): de vez em quando sonha com algo do dia
|
||||||
|
if (r.dormindo && !SONO.apagada && !window.carinhaSemServidor) {
|
||||||
|
if (!HUMANO.sonhoProx) HUMANO.sonhoProx = t + 40 + Math.random() * 60;
|
||||||
|
if (t >= HUMANO.sonhoProx) {
|
||||||
|
const dur = 6 + Math.random() * 4;
|
||||||
|
HUMANO.sonhoAte = t + dur; HUMANO.sonhoProx = t + dur + 45 + Math.random() * 90;
|
||||||
|
const obj = HUMANO.sonhos[Math.floor(Math.random() * Math.min(4, HUMANO.sonhos.length))];
|
||||||
|
if (window.MotorRosto && window.MotorRosto.sonho) window.MotorRosto.sonho(obj, dur);
|
||||||
|
}
|
||||||
|
} else HUMANO.sonhoProx = 0;
|
||||||
|
}
|
||||||
|
function grogue(t) { // 1 = acabou de acordar, 0 = esperto
|
||||||
|
return t < HUMANO.grogueAte ? Math.min(1, (HUMANO.grogueAte - t) / (HUMANO.grogueAte - HUMANO.grogueIni)) : 0;
|
||||||
|
}
|
||||||
|
function piscarHumano(t) { animar(grogue(t) > 0.2 || t < HUMANO.luz.sonolentoAte ? 'piscar2' : 'piscar'); }
|
||||||
|
function desvioDoOlhar(t) { // olha nos olhos 2 a 5 s, desvia um instante e volta
|
||||||
|
const ev = HUMANO.evita;
|
||||||
|
if (t >= ev.prox) {
|
||||||
|
const dura = Math.random() < 0.7 ? 0.6 + Math.random() * 0.7 : 0;
|
||||||
|
ev.ate = t + dura; ev.prox = ev.ate + 2 + Math.random() * 3;
|
||||||
|
ev.dx = (Math.random() < 0.5 ? -1 : 1) * (8 + Math.random() * 8); ev.dy = 3 + Math.random() * 5;
|
||||||
|
}
|
||||||
|
return t < ev.ate ? ev : null;
|
||||||
|
}
|
||||||
|
function contagio(t, agora) { // sorriu para ele (rastreamento do painel): sorri de volta
|
||||||
|
const S = HUMANO.sorriso;
|
||||||
|
if (t.tipo !== 'rosto' || typeof t.sorriso !== 'number' || r.dormindo || ATIV) { S.desde = 0; return; }
|
||||||
|
if (t.sorriso > 0.55) { if (!S.desde) S.desde = agora; } else S.desde = 0;
|
||||||
|
if (S.desde && agora - S.desde > 0.6 && agora - S.ultimo > 20 && !r.kf) {
|
||||||
|
S.ultimo = agora; S.desde = 0;
|
||||||
|
animar(t.sorriso > 0.8 ? 'rir' : 'feliz');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function luzAmbiente(lux) { // sensor de luz do celular (lux)
|
||||||
|
lux = Number(lux);
|
||||||
|
if (!Number.isFinite(lux) || lux < 0) return;
|
||||||
|
const L = HUMANO.luz, t = performance.now() / 1000, antes = L.media;
|
||||||
|
L.lux = lux;
|
||||||
|
L.media = antes === null ? lux : antes + (lux - antes) * 0.15;
|
||||||
|
if (antes === null || t - L.mudou < 8) return;
|
||||||
|
if (lux < 3 && antes > 25) { // apagaram a luz: fica com sono
|
||||||
|
L.mudou = t; L.sonolentoAte = t + 60; L.media = lux;
|
||||||
|
if (!r.dormindo && !r.kf) animar('bocejar');
|
||||||
|
} else if (lux > 80 && antes < 8) { // acenderam a luz de repente: aperta os olhos
|
||||||
|
L.mudou = t; L.ofuscadoAte = t + 1.8; L.media = lux;
|
||||||
|
if (r.dormindo && !sonoRespeitado() && PERFIL !== PERFIS.calmo) r.dormindo = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function repouso(t) { // pose calma: olha devagar, "respira"
|
function repouso(t) { // pose calma: olha devagar, "respira"
|
||||||
if (r.dormindo) {
|
if (r.dormindo) {
|
||||||
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
||||||
@@ -626,6 +767,7 @@ function repouso(t) { // pose calma: olha devagar, "respira
|
|||||||
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
||||||
r.dx = 0;
|
r.dx = 0;
|
||||||
r.dy = 3.0 * Math.sin(t * 1.4);
|
r.dy = 3.0 * Math.sin(t * 1.4);
|
||||||
|
if (t < HUMANO.sonhoAte) { r.dx = 3.2 * Math.sin(t * 6.5); r.boca = 'sorriso'; } // sonhando
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
r.forma = "aberto"; r.forma2 = null;
|
r.forma = "aberto"; r.forma2 = null;
|
||||||
@@ -638,7 +780,7 @@ function repouso(t) { // pose calma: olha devagar, "respira
|
|||||||
const longe = Math.random() < 0.25;
|
const longe = Math.random() < 0.25;
|
||||||
OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
|
OLHAR.ax = (Math.random() * 2 - 1) * (longe ? 18 : 7);
|
||||||
OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
|
OLHAR.ay = (Math.random() * 2 - 1) * (longe ? 8 : 3);
|
||||||
OLHAR.prox = t + 1.2 + Math.random() * 3;
|
OLHAR.prox = t + (1.2 + Math.random() * 3) * (1 + 2 * grogue(t)); // grogue: olhar lento
|
||||||
}
|
}
|
||||||
OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3;
|
OLHAR.x += (OLHAR.ax - OLHAR.x) * 0.3;
|
||||||
OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
|
OLHAR.y += (OLHAR.ay - OLHAR.y) * 0.3;
|
||||||
@@ -651,9 +793,23 @@ function repouso(t) { // pose calma: olha devagar, "respira
|
|||||||
if (ATIV.forma) r.forma = ATIV.forma;
|
if (ATIV.forma) r.forma = ATIV.forma;
|
||||||
if (ATIV.boca) r.boca = ATIV.boca;
|
if (ATIV.boca) r.boca = ATIV.boca;
|
||||||
if (ATIV.ab) r.ab = ATIV.ab;
|
if (ATIV.ab) r.ab = ATIV.ab;
|
||||||
|
const o = HUMANO.oops; // errou: susto e depois fica sem graça
|
||||||
|
if (o && t >= o.ini && t < o.fim) {
|
||||||
|
if (t - o.ini < 0.5) { r.forma = 'surpreso'; r.boca = 'aberta'; r.ab = 1.1; }
|
||||||
|
else { r.forma = 'feliz'; r.boca = 'sorriso'; r.cor = lerpCor(r.cor, ROSA, 0.5); r.dy += 6; }
|
||||||
|
}
|
||||||
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
|
} else if (ZANGA >= 0.7) { // alegria caindo: emburrado (a cor escolhida fica)
|
||||||
r.forma = "bravo";
|
r.forma = "bravo";
|
||||||
}
|
}
|
||||||
|
const g = grogue(t);
|
||||||
|
if (g > 0) { // acabou de acordar: olhos pesados, cor apagada, olhar baixo
|
||||||
|
r.ab = Math.min(r.ab, 1 - 0.5 * g);
|
||||||
|
r.cor = lerpCor(r.cor, corEscura(0.7), 0.35 * g);
|
||||||
|
if (g > 0.5 && !ATIV) r.boca = 'sono';
|
||||||
|
r.dx *= 1 - 0.5 * g; r.dy = r.dy * (1 - 0.5 * g) + 3 * g;
|
||||||
|
}
|
||||||
|
if (t < HUMANO.luz.sonolentoAte) r.ab = Math.min(r.ab, 0.78);
|
||||||
|
if (t < HUMANO.luz.ofuscadoAte) { r.ab = Math.min(r.ab, 0.3); r.forma = 'feliz'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function desenhar() {
|
function desenhar() {
|
||||||
@@ -667,7 +823,9 @@ function desenhar() {
|
|||||||
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
|
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
|
||||||
const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV;
|
const mirando = (MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2) || !!ATIV;
|
||||||
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
|
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
|
||||||
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
|
const ev = seguindo && TRACK.alvo.tipo === 'rosto' ? desvioDoOlhar(tAgora) : null;
|
||||||
|
const dx = seguindo ? (ev ? MOV.olharX * 0.3 + ev.dx : MOV.olharX) : r.dx;
|
||||||
|
const dy = seguindo ? (ev ? MOV.olharY * 0.3 + ev.dy : MOV.olharY) : r.dy;
|
||||||
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
|
||||||
const comMotor = ROSTO.motor && window.MotorRosto;
|
const comMotor = ROSTO.motor && window.MotorRosto;
|
||||||
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
|
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
|
||||||
@@ -751,6 +909,9 @@ function contarAprendi(texto) {
|
|||||||
function trata(ordem) {
|
function trata(ordem) {
|
||||||
const bruta = (ordem || "").trim();
|
const bruta = (ordem || "").trim();
|
||||||
if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase
|
if (/^aprendi /i.test(bruta)) { contarAprendi(bruta.slice(8).trim()); return; } // sem minúsculas: é uma frase
|
||||||
|
if (/^dizer /i.test(bruta)) { falarFrase(bruta.slice(6).trim()); return; }
|
||||||
|
if (/^olhos /i.test(bruta)) { mostrarNosOlhos(bruta.slice(6).trim()); return; } // jogos: ✊ ✋ ✌️ e números
|
||||||
|
if (/^telao /i.test(bruta)) { mostrarTelao(bruta.slice(6)); return; } // letreiro grande dos jogos
|
||||||
ordem = bruta.toLowerCase();
|
ordem = bruta.toLowerCase();
|
||||||
if (!ordem) return;
|
if (!ordem) return;
|
||||||
if(ordem==='recarregar') {
|
if(ordem==='recarregar') {
|
||||||
@@ -797,14 +958,16 @@ function trata(ordem) {
|
|||||||
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
|
if (aviso) { aviso.classList.remove("cutucado"); void aviso.offsetWidth; aviso.classList.add("cutucado"); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
r.dormindo = false; animar("surpreso");
|
const longo = r.dormindo && performance.now() / 1000 - HUMANO.dormiuEm > 180;
|
||||||
|
r.dormindo = false;
|
||||||
|
if (!longo) animar("surpreso"); // sono longo: acorda devagar, grogue (humanoTick)
|
||||||
} else if (ordem === "aleatorio") {
|
} else if (ordem === "aleatorio") {
|
||||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||||
} else if (SEQS[ordem]) {
|
} else if (SEQS[ordem]) {
|
||||||
if (window.MotorRosto) {
|
if (window.MotorRosto) {
|
||||||
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
||||||
}
|
}
|
||||||
animar(ordem);
|
hesitar(() => animar(ordem), ordem);
|
||||||
} else if (ordem === "calma") {
|
} else if (ordem === "calma") {
|
||||||
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
||||||
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
|
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
|
||||||
@@ -1121,6 +1284,7 @@ function rastrear(t) {
|
|||||||
(r.dormindo && !configRecebida?.cameraSono)) return;
|
(r.dormindo && !configRecebida?.cameraSono)) return;
|
||||||
const agora=performance.now()/1000;
|
const agora=performance.now()/1000;
|
||||||
TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y;
|
TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y;
|
||||||
|
contagio(t, agora);
|
||||||
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
|
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
|
||||||
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
|
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
|
||||||
if(t.atividade===true) {
|
if(t.atividade===true) {
|
||||||
@@ -1306,6 +1470,7 @@ const AUDIO = {
|
|||||||
};
|
};
|
||||||
if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ
|
if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ
|
||||||
if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
||||||
|
if (window.eilik) window.eilik.luz = luzAmbiente; // sensor de luz do celular (o app chama)
|
||||||
|
|
||||||
let configRecebida = null;
|
let configRecebida = null;
|
||||||
function configurarApp(c) {
|
function configurarApp(c) {
|
||||||
@@ -1353,6 +1518,7 @@ async function sincronizarApp() {
|
|||||||
bateria: window.carinhaBateria,
|
bateria: window.carinhaBateria,
|
||||||
aparelho: window.carinhaAparelho,
|
aparelho: window.carinhaAparelho,
|
||||||
video: window.carinhaVideo,
|
video: window.carinhaVideo,
|
||||||
|
luz: HUMANO.luz.lux,
|
||||||
}) });
|
}) });
|
||||||
}
|
}
|
||||||
} catch (e) { /* servidor ausente: animação continua */ }
|
} catch (e) { /* servidor ausente: animação continua */ }
|
||||||
@@ -1448,6 +1614,7 @@ function laco(t) {
|
|||||||
|
|
||||||
checarSilencio(t / 1000);
|
checarSilencio(t / 1000);
|
||||||
atualizarFeicao(t / 1000);
|
atualizarFeicao(t / 1000);
|
||||||
|
humanoTick(t / 1000);
|
||||||
|
|
||||||
ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null;
|
ATIV = window.MotorRosto && window.MotorRosto.olharAtividade && !r.dormindo ? window.MotorRosto.olharAtividade(t / 1000) : null;
|
||||||
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
|
if (r.dormindo && window.MotorRosto && window.MotorRosto.atividadeAtual && window.MotorRosto.atividadeAtual()) atividade(null);
|
||||||
@@ -1461,7 +1628,7 @@ function laco(t) {
|
|||||||
if (r.dormindo) {
|
if (r.dormindo) {
|
||||||
proxPiscar = proxGesto = t + 3600000;
|
proxPiscar = proxGesto = t + 3600000;
|
||||||
} else if (t >= proxPiscar) {
|
} else if (t >= proxPiscar) {
|
||||||
animar("piscar");
|
piscarHumano(t / 1000);
|
||||||
proxPiscar = t + 1600 + Math.random() * 3500;
|
proxPiscar = t + 1600 + Math.random() * 3500;
|
||||||
} else if (AUTO && t >= proxGesto && !ATIV) {
|
} else if (AUTO && t >= proxGesto && !ATIV) {
|
||||||
animar(gestoSolto());
|
animar(gestoSolto());
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class Cerebro:
|
|||||||
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
|
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
|
||||||
except (OSError,ValueError,KeyError,TypeError):pass
|
except (OSError,ValueError,KeyError,TypeError):pass
|
||||||
threading.Thread(target=self.loop,daemon=True).start()
|
threading.Thread(target=self.loop,daemon=True).start()
|
||||||
|
console = staticmethod(lambda origem, texto, motivo='', nivel='info', agora=None: None) # o main troca pelo console do painel
|
||||||
def cabecalhos(self):
|
def cabecalhos(self):
|
||||||
h={'Content-Type':'application/json'}
|
h={'Content-Type':'application/json'}
|
||||||
if self.chave:h['Authorization']='Bearer '+self.chave
|
if self.chave:h['Authorization']='Bearer '+self.chave
|
||||||
@@ -100,8 +101,10 @@ class Cerebro:
|
|||||||
with self.controle.lock:
|
with self.controle.lock:
|
||||||
if time.monotonic()-self.controle.status_at<5 and not self.controle.estado.get('dormindo') and not self.controle.estado.get('atividade'):
|
if time.monotonic()-self.controle.status_at<5 and not self.controle.estado.get('dormindo') and not self.controle.estado.get('atividade'):
|
||||||
self.enviar(result['gesto'])
|
self.enviar(result['gesto'])
|
||||||
|
self.console('ia','IA local escolheu: '+result['gesto'],'evento "%s", respondeu em %.1f s'%(event,result['segundos']))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with self.lock:self.error=str(e)[:200]
|
with self.lock:self.error=str(e)[:200]
|
||||||
|
self.console('ia','IA local não respondeu',str(e)[:200],'detalhe')
|
||||||
finally:
|
finally:
|
||||||
self.last=time.monotonic()
|
self.last=time.monotonic()
|
||||||
with self.lock:self.busy=False
|
with self.lock:self.busy=False
|
||||||
|
|||||||
+50
@@ -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
|
||||||
@@ -105,6 +105,8 @@ class ControlePC:
|
|||||||
self.rtc.encerrar()
|
self.rtc.encerrar()
|
||||||
return novo.copy()
|
return novo.copy()
|
||||||
|
|
||||||
|
console = staticmethod(lambda origem, texto, motivo='', nivel='info', agora=None: None) # o main troca pelo console do painel
|
||||||
|
|
||||||
def resumo(self, host=''):
|
def resumo(self, host=''):
|
||||||
"""Estado do celular para o painel (GET /app/status, /estado e evento 'telemetria')."""
|
"""Estado do celular para o painel (GET /app/status, /estado e evento 'telemetria')."""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -160,12 +162,14 @@ class ControlePC:
|
|||||||
if not isinstance(dados, dict):
|
if not isinstance(dados, dict):
|
||||||
raise ValueError('Estado deve ser objeto JSON')
|
raise ValueError('Estado deve ser objeto JSON')
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
antes = self.estado
|
||||||
self.estado = dados
|
self.estado = dados
|
||||||
self.celular_ip = ip_informado(dados) or h.client_address[0]
|
self.celular_ip = ip_informado(dados) or h.client_address[0]
|
||||||
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
|
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
|
||||||
self.recarga["confirmada"] = True
|
self.recarga["confirmada"] = True
|
||||||
self.status_at = time.monotonic()
|
self.status_at = time.monotonic()
|
||||||
self.publicar('telemetria', self.resumo(h.headers.get("Host", "").split(":")[0]))
|
self.publicar('telemetria', self.resumo(h.headers.get("Host", "").split(":")[0]))
|
||||||
|
self.mudancas(antes, dados)
|
||||||
enviar({'ok': True})
|
enviar({'ok': True})
|
||||||
else:
|
else:
|
||||||
enviar(self.resumo(h.headers.get("Host", "").split(":")[0]))
|
enviar(self.resumo(h.headers.get("Host", "").split(":")[0]))
|
||||||
@@ -176,6 +180,21 @@ class ControlePC:
|
|||||||
enviar({'erro': str(e)}, 503)
|
enviar({'erro': str(e)}, 503)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def mudancas(self, antes, agora):
|
||||||
|
"""O que mudou no celular desde o último status: vai para o console do painel."""
|
||||||
|
if not antes:
|
||||||
|
self.console('celular', 'Celular conectado', 'rosto %s' % agora.get('rosto', '?'))
|
||||||
|
return
|
||||||
|
if bool(antes.get('dormindo')) != bool(agora.get('dormindo')):
|
||||||
|
self.console('celular', 'O rosto dormiu' if agora.get('dormindo') else 'O rosto acordou')
|
||||||
|
if antes.get('atividade') != agora.get('atividade'):
|
||||||
|
if agora.get('atividade'):
|
||||||
|
self.console('celular', 'Começou a atividade: %s' % agora['atividade'])
|
||||||
|
else:
|
||||||
|
self.console('celular', 'Terminou a atividade: %s' % antes['atividade'])
|
||||||
|
if bool(antes.get('tela_apagada')) != bool(agora.get('tela_apagada')):
|
||||||
|
self.console('celular', 'Apagou a tela (dormindo há um tempo)' if agora.get('tela_apagada') else 'Acendeu a tela')
|
||||||
|
|
||||||
def registrar_recusa(self, rota, motivo):
|
def registrar_recusa(self, rota, motivo):
|
||||||
"""Pedido do celular recusado: aparece no journal (no máximo 1 linha por minuto)."""
|
"""Pedido do celular recusado: aparece no journal (no máximo 1 linha por minuto)."""
|
||||||
agora = time.monotonic()
|
agora = time.monotonic()
|
||||||
|
|||||||
+3
-1
@@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
|
|||||||
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
|
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
|
||||||
$arquivos = @(
|
$arquivos = @(
|
||||||
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
|
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
|
||||||
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', 'aprendizado.py',
|
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py', 'vida.py', 'criacao.py', 'inspiracao.py', 'console.py', 'reconhecimento.py', 'aprendizado.py',
|
||||||
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
|
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
|
||||||
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
|
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
|
||||||
)
|
)
|
||||||
@@ -90,6 +90,8 @@ tar -xzf '$remoto' -C '$Destino'
|
|||||||
rm -f '$remoto'
|
rm -f '$remoto'
|
||||||
systemctl restart carinha-pc
|
systemctl restart carinha-pc
|
||||||
echo "backup em `$backup"
|
echo "backup em `$backup"
|
||||||
|
# guarda só os 5 backups de deploy mais recentes (cada um tem ~70 MB por causa do APK)
|
||||||
|
(ls -d backup-2[0-9]*-[0-9]* | sort | head -n -5 | xargs -r rm -rf) 2>/dev/null || true
|
||||||
"@
|
"@
|
||||||
Write-Host '== Instalando na placa'
|
Write-Host '== Instalando na placa'
|
||||||
# O PowerShell 5.1 põe BOM no stdin do ssh; sem tirar, o bash lê "<BOM>set -e" e não para no primeiro erro.
|
# O PowerShell 5.1 põe BOM no stdin do ssh; sem tirar, o bash lê "<BOM>set -e" e não para no primeiro erro.
|
||||||
|
|||||||
+60
-6
@@ -21,7 +21,11 @@ from comum import gravar_atomico, ler_json
|
|||||||
|
|
||||||
TIPOS = ('pir', 'movimento', 'som')
|
TIPOS = ('pir', 'movimento', 'som')
|
||||||
PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15, dias=3,
|
PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15, dias=3,
|
||||||
presenca=True, ausencia=5) # modo presença: o PIR liga a câmera; sem ninguém, ela desliga
|
presenca=True, ausencia=5, # modo presença: o PIR liga a câmera; sem ninguém, ela desliga
|
||||||
|
sensibilidade_pir='media')
|
||||||
|
# O HC-SR505 não tem ajuste de sensibilidade e segura ~8 s a cada disparo. Para separar alguém de
|
||||||
|
# verdade de um disparo à toa: (disparos que bastam, dentro de quantos s, ou ligado direto por quantos s).
|
||||||
|
REGRAS_PIR = {'alta': (1, 0, 0), 'media': (2, 60, 12), 'baixa': (3, 90, 20)}
|
||||||
MAX_EVENTOS, MAX_FOTOS = 2000, 500 # teto de segurança; quem manda é a retenção em dias
|
MAX_EVENTOS, MAX_FOTOS = 2000, 500 # teto de segurança; quem manda é a retenção em dias
|
||||||
VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto
|
VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto
|
||||||
|
|
||||||
@@ -36,6 +40,7 @@ class Historico:
|
|||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.publicar = lambda tipo, dados: None
|
self.publicar = lambda tipo, dados: None
|
||||||
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
|
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
|
||||||
|
self.ao_foto = lambda ident, caminho: None # foto nova de um evento (reconhecimento de pessoas)
|
||||||
# Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia).
|
# Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia).
|
||||||
self.ao_presenca = lambda: None
|
self.ao_presenca = lambda: None
|
||||||
self.ao_ausencia = lambda: None
|
self.ao_ausencia = lambda: None
|
||||||
@@ -51,6 +56,9 @@ class Historico:
|
|||||||
self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None,
|
self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None,
|
||||||
ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma)
|
ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma)
|
||||||
self.processo_pir = None
|
self.processo_pir = None
|
||||||
|
self.subidas_pir = [] # disparos ainda não confirmados (horários)
|
||||||
|
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None
|
||||||
|
self.subiu_em = None # quando o pino subiu da última vez (ligado direto desde então)
|
||||||
with self.db() as db:
|
with self.db() as db:
|
||||||
db.execute('CREATE TABLE IF NOT EXISTS eventos(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, evento TEXT, nivel REAL, foto TEXT)')
|
db.execute('CREATE TABLE IF NOT EXISTS eventos(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, evento TEXT, nivel REAL, foto TEXT)')
|
||||||
self.limpar_antigos()
|
self.limpar_antigos()
|
||||||
@@ -82,6 +90,9 @@ class Historico:
|
|||||||
elif chave == 'dias':
|
elif chave == 'dias':
|
||||||
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30:
|
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30:
|
||||||
raise ValueError('Guardar de 1 a 30 dias')
|
raise ValueError('Guardar de 1 a 30 dias')
|
||||||
|
elif chave == 'sensibilidade_pir':
|
||||||
|
if valor not in REGRAS_PIR:
|
||||||
|
raise ValueError('Sensibilidade do sensor: alta, media ou baixa')
|
||||||
elif chave == 'intervalo':
|
elif chave == 'intervalo':
|
||||||
if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600:
|
if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600:
|
||||||
raise ValueError('Intervalo entre 0 e 600 s')
|
raise ValueError('Intervalo entre 0 e 600 s')
|
||||||
@@ -134,20 +145,53 @@ class Historico:
|
|||||||
self.publicar('pir', dict(ultimo=self.pir['ultimo'], ativo=self.pir['ativo'], presenca=dict(self.presenca)))
|
self.publicar('pir', dict(ultimo=self.pir['ultimo'], ativo=self.pir['ativo'], presenca=dict(self.presenca)))
|
||||||
|
|
||||||
def nivel_pir(self, alto, agora=None, registrar=True):
|
def nivel_pir(self, alto, agora=None, registrar=True):
|
||||||
"""Mudança no pino do PIR. Subida = presença (evento + foto); enquanto alto, a bolinha fica acesa."""
|
"""Mudança no pino do PIR. A bolinha acompanha o pino; presença (evento, foto, acordar) só
|
||||||
|
quando o disparo se confirma pela sensibilidade escolhida."""
|
||||||
agora = agora if agora is not None else time.time()
|
agora = agora if agora is not None else time.time()
|
||||||
self.pir['ativo'] = alto
|
self.pir['ativo'] = alto
|
||||||
if alto:
|
if alto:
|
||||||
self.pir['ultimo'] = agora
|
self.pir['ultimo'] = agora
|
||||||
self.marcar_presenca(agora)
|
|
||||||
self.avisar_pir()
|
self.avisar_pir()
|
||||||
if alto and registrar:
|
if not alto:
|
||||||
self.registrar('pir', 'presença')
|
self.checar_pir(agora) # ligado direto até agora pode já ter confirmado
|
||||||
|
self.subiu_em = None
|
||||||
|
return
|
||||||
|
if not registrar: # pino assentando ao ligar: só a bolinha
|
||||||
|
return
|
||||||
|
quantos, janela, _ = REGRAS_PIR[self.config['sensibilidade_pir']]
|
||||||
|
with self.lock:
|
||||||
|
self.subidas_pir = [t for t in self.subidas_pir if agora - t < janela] + [agora]
|
||||||
|
self.subiu_em = agora
|
||||||
|
confirmou = len(self.subidas_pir) >= quantos
|
||||||
|
if confirmou:
|
||||||
|
self.confirmar_pir(agora)
|
||||||
|
else:
|
||||||
|
self.console('sentidos', 'Disparo solto do sensor de presença', 'sensibilidade %s: ainda não confirmou (%d de %d)' % (
|
||||||
|
self.config['sensibilidade_pir'], len(self.subidas_pir), quantos), 'detalhe', agora)
|
||||||
|
|
||||||
|
def checar_pir(self, agora=None):
|
||||||
|
"""Ligado direto por bastante tempo = alguém se mexendo na frente (o sensor foi redisparando)."""
|
||||||
|
agora = agora if agora is not None else time.time()
|
||||||
|
_, _, segurar = REGRAS_PIR[self.config['sensibilidade_pir']]
|
||||||
|
with self.lock:
|
||||||
|
pronto = segurar and self.subiu_em is not None and self.subidas_pir and agora - self.subiu_em >= segurar
|
||||||
|
if pronto:
|
||||||
|
self.confirmar_pir(agora)
|
||||||
|
|
||||||
|
def confirmar_pir(self, agora):
|
||||||
|
with self.lock:
|
||||||
|
self.subidas_pir = []
|
||||||
|
self.marcar_presenca(agora)
|
||||||
|
self.registrar('pir', 'presença', agora=agora)
|
||||||
|
|
||||||
def vigiar_ausencia(self):
|
def vigiar_ausencia(self):
|
||||||
|
passo = 0
|
||||||
while True:
|
while True:
|
||||||
time.sleep(10)
|
time.sleep(1)
|
||||||
|
passo += 1
|
||||||
try:
|
try:
|
||||||
|
self.checar_pir()
|
||||||
|
if passo % 10 == 0:
|
||||||
self.checar_ausencia()
|
self.checar_ausencia()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -255,6 +299,16 @@ class Historico:
|
|||||||
self.apagar_arquivo(foto)
|
self.apagar_arquivo(foto)
|
||||||
db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,))
|
db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,))
|
||||||
self.publicar('historico', dict(foto=ident))
|
self.publicar('historico', dict(foto=ident))
|
||||||
|
try:
|
||||||
|
self.ao_foto(ident, self.fotos / nome)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fotos_com_evento(self):
|
||||||
|
"""[(id do evento, arquivo da foto)] das fotos guardadas, mais novas primeiro."""
|
||||||
|
with self.db() as db:
|
||||||
|
linhas = db.execute('SELECT id, foto FROM eventos WHERE foto IS NOT NULL ORDER BY id DESC').fetchall()
|
||||||
|
return [(i, self.fotos / f) for i, f in linhas if (self.fotos / f).is_file()]
|
||||||
|
|
||||||
# ------------------------------------------------------------------ PIR
|
# ------------------------------------------------------------------ PIR
|
||||||
def vigiar_pir(self):
|
def vigiar_pir(self):
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class Inspiracao:
|
|||||||
self.madrugada = lambda agora: False
|
self.madrugada = lambda agora: False
|
||||||
self.pode_gemini = lambda: False
|
self.pode_gemini = lambda: False
|
||||||
self.baixar = _baixar # trocado nos testes
|
self.baixar = _baixar # trocado nos testes
|
||||||
|
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None
|
||||||
self.ocupado = False
|
self.ocupado = False
|
||||||
self.config = json.loads(json.dumps(PADRAO))
|
self.config = json.loads(json.dumps(PADRAO))
|
||||||
self.hoje = self._dia_vazio('')
|
self.hoje = self._dia_vazio('')
|
||||||
@@ -326,11 +327,13 @@ class Inspiracao:
|
|||||||
item = self.criacao.item(ident)
|
item = self.criacao.item(ident)
|
||||||
if item:
|
if item:
|
||||||
self.anotar('ideia', 'Teve uma ideia nova: %s %s (%s)' % (item['emoji'], item['nome'], item['motivo']), agora)
|
self.anotar('ideia', 'Teve uma ideia nova: %s %s (%s)' % (item['emoji'], item['nome'], item['motivo']), agora)
|
||||||
|
self.console('ideias', 'Criou a atividade %s %s' % (item['emoji'], item['nome']), item['motivo'], agora=agora)
|
||||||
try:
|
try:
|
||||||
self.criacao.avisar() # a grade do estúdio mostra as novidades
|
self.criacao.avisar() # a grade do estúdio mostra as novidades
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.console('ideias', 'Não conseguiu criar as ideias do dia', str(e)[:200], agora=agora)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.hoje['erro'] = str(e)[:200]
|
self.hoje['erro'] = str(e)[:200]
|
||||||
if not manual:
|
if not manual:
|
||||||
@@ -404,6 +407,7 @@ class Inspiracao:
|
|||||||
chave = str(item['id'])
|
chave = str(item['id'])
|
||||||
self.hoje['contadas'][chave] = self.hoje['contadas'].get(chave, 0) + 1
|
self.hoje['contadas'][chave] = self.hoje['contadas'].get(chave, 0) + 1
|
||||||
self.anotar('aprendi', 'Contou o que aprendeu: ' + item['aprendi'], agora)
|
self.anotar('aprendi', 'Contou o que aprendeu: ' + item['aprendi'], agora)
|
||||||
|
self.console('ideias', 'Contou o que aprendeu', item['aprendi'], agora=agora)
|
||||||
self.salvar()
|
self.salvar()
|
||||||
return dict(ok=True)
|
return dict(ok=True)
|
||||||
|
|
||||||
|
|||||||
+92
-3
@@ -45,6 +45,8 @@ from vida import Vida
|
|||||||
from criacao import Criacao
|
from criacao import Criacao
|
||||||
from inspiracao import Inspiracao
|
from inspiracao import Inspiracao
|
||||||
from aprendizado import Aprendizado
|
from aprendizado import Aprendizado
|
||||||
|
from console import Console
|
||||||
|
from reconhecimento import Reconhecimento
|
||||||
|
|
||||||
TRAVA = threading.Lock()
|
TRAVA = threading.Lock()
|
||||||
|
|
||||||
@@ -64,6 +66,8 @@ ATIVIDADES = ["ler", "cantar", "bola", "videogame", "pintar", "malhar", "dormir"
|
|||||||
"violao", "dancar", "corda", "meditar", "telescopio", "pipa", "bolhas", "skate", "computador", "pescar"]
|
"violao", "dancar", "corda", "meditar", "telescopio", "pipa", "bolhas", "skate", "computador", "pescar"]
|
||||||
# Ordens internas que o rosto entende mas não viram botão.
|
# Ordens internas que o rosto entende mas não viram botão.
|
||||||
ORDENS_INTERNAS = {"calma"}
|
ORDENS_INTERNAS = {"calma"}
|
||||||
|
# O que os jogos podem mostrar nos olhos (pedra, papel, tesoura, números...)
|
||||||
|
OLHOS_JOGO = {"✊", "✋", "✌️", "❓", "⭐", "❤️", "👀", "🙈"} | {str(n) for n in range(11)}
|
||||||
|
|
||||||
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
|
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
|
||||||
# arquivo ao lado do servidor, para sobreviver a reinicios.
|
# arquivo ao lado do servidor, para sobreviver a reinicios.
|
||||||
@@ -73,12 +77,13 @@ ARQUIVO_APARENCIA = "aparencia.json"
|
|||||||
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
|
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
|
||||||
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js", "/voz-robo.js",
|
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js", "/voz-robo.js",
|
||||||
"/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task",
|
"/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task",
|
||||||
|
"/vendor/mediapipe/expressao.task",
|
||||||
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm",
|
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm",
|
||||||
"/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"}
|
"/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"}
|
||||||
|
|
||||||
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
|
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
|
||||||
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
|
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
|
||||||
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/",
|
PREFIXOS_API = ("/eventos", "/console", "/pessoas", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/", "/vida/", "/aprendizado/", "/criacao/",
|
||||||
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
|
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
|
||||||
|
|
||||||
|
|
||||||
@@ -122,10 +127,26 @@ EVENTOS = Eventos()
|
|||||||
ESTOP = {"ativo": False, "desde": None}
|
ESTOP = {"ativo": False, "desde": None}
|
||||||
|
|
||||||
|
|
||||||
|
CONSOLE = Console()
|
||||||
|
# ordens que se repetem (o modo vida reenvia o humor a cada minuto): no console, só em "detalhes"
|
||||||
|
ORDENS_DETALHE = ("humor ", "ritmo ", "zanga ", "sono ")
|
||||||
|
|
||||||
|
|
||||||
def enfileirar(ordem):
|
def enfileirar(ordem):
|
||||||
"""Caminho único de uma ordem para o rosto: fila do celular + evento para os painéis."""
|
"""Caminho único de uma ordem para o rosto: fila do celular + evento para os painéis."""
|
||||||
FILA.adicionar(ordem)
|
FILA.adicionar(ordem)
|
||||||
EVENTOS.publicar("expressao", {"ordem": ordem})
|
EVENTOS.publicar("expressao", {"ordem": ordem})
|
||||||
|
if ordem.startswith("dizer "):
|
||||||
|
CONSOLE.registrar("celular", "Falou: " + ordem[6:])
|
||||||
|
elif ordem.startswith(("atividade_criada ", "expressao_criada ")):
|
||||||
|
try:
|
||||||
|
nome = json.loads(ordem.split(" ", 1)[1]).get("nome", "?")
|
||||||
|
except ValueError:
|
||||||
|
nome = "?"
|
||||||
|
CONSOLE.registrar("celular", "Mandou ao rosto a criação: " + nome)
|
||||||
|
elif not ordem.startswith("aprendi "):
|
||||||
|
CONSOLE.registrar("celular", "Mandou ao rosto: " + ordem,
|
||||||
|
nivel="detalhe" if ordem.startswith(ORDENS_DETALHE) else "info")
|
||||||
|
|
||||||
|
|
||||||
def carregar_aparencia():
|
def carregar_aparencia():
|
||||||
@@ -204,9 +225,53 @@ def ordem_de_comando(tipo, valor):
|
|||||||
if nome not in ROSTOS:
|
if nome not in ROSTOS:
|
||||||
raise ValueError("Rosto desconhecido: " + nome)
|
raise ValueError("Rosto desconhecido: " + nome)
|
||||||
return "rosto " + nome
|
return "rosto " + nome
|
||||||
|
if tipo == "dizer": # fala curta com legenda (jogos, painel)
|
||||||
|
texto = " ".join(str(valor or "").split())[:120]
|
||||||
|
if not texto:
|
||||||
|
raise ValueError("Diga alguma coisa")
|
||||||
|
return "dizer " + texto
|
||||||
|
if tipo == "olhos": # símbolo nos olhos por alguns segundos
|
||||||
|
valor = valor if isinstance(valor, dict) else {}
|
||||||
|
simbolo = str(valor.get("simbolo") or "")
|
||||||
|
if simbolo and simbolo not in OLHOS_JOGO:
|
||||||
|
raise ValueError("Símbolo inválido para os olhos")
|
||||||
|
try:
|
||||||
|
segundos = max(0.5, min(8.0, float(valor.get("segundos", 2.5))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("Segundos inválidos") from None
|
||||||
|
return "olhos %s %.1f" % (simbolo or "-", segundos)
|
||||||
|
if tipo == "telao": # letreiro grande por cima do rosto (jogos)
|
||||||
|
return "telao " + json.dumps(validar_telao(valor), ensure_ascii=False, separators=(",", ":"))
|
||||||
raise ValueError("Tipo de comando inválido")
|
raise ValueError("Tipo de comando inválido")
|
||||||
|
|
||||||
|
|
||||||
|
TONS_TELAO = ("neutro", "eu", "voce", "empate", "conta")
|
||||||
|
|
||||||
|
|
||||||
|
def validar_telao(v):
|
||||||
|
"""{titulo, sub, tom, piscar, segundos, lados:[{rotulo, simbolo, nome}]} só com texto curto e símbolos permitidos."""
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError("Telão inválido")
|
||||||
|
texto = lambda x, n: " ".join(str(x or "").split())[:n]
|
||||||
|
t = dict(titulo=texto(v.get("titulo"), 24), sub=texto(v.get("sub"), 80),
|
||||||
|
tom=v.get("tom") if v.get("tom") in TONS_TELAO else "neutro", piscar=v.get("piscar") is True)
|
||||||
|
try:
|
||||||
|
t["segundos"] = max(0.3, min(10.0, float(v.get("segundos", 2.5))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("Segundos inválidos") from None
|
||||||
|
lados = v.get("lados") or []
|
||||||
|
if not isinstance(lados, list) or len(lados) > 2:
|
||||||
|
raise ValueError("Lados inválidos")
|
||||||
|
t["lados"] = []
|
||||||
|
for l in lados:
|
||||||
|
if not isinstance(l, dict) or (l.get("simbolo") and l.get("simbolo") not in OLHOS_JOGO):
|
||||||
|
raise ValueError("Símbolo inválido no telão")
|
||||||
|
t["lados"].append(dict(rotulo=texto(l.get("rotulo"), 12), simbolo=l.get("simbolo") or "", nome=texto(l.get("nome"), 14)))
|
||||||
|
if not t["titulo"] and not t["lados"]:
|
||||||
|
raise ValueError("Telão vazio")
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
class Handler(SimpleHTTPRequestHandler):
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
extensoes_map = {".html": "text/html; charset=utf-8",
|
extensoes_map = {".html": "text/html; charset=utf-8",
|
||||||
".js": "text/javascript; charset=utf-8",
|
".js": "text/javascript; charset=utf-8",
|
||||||
@@ -264,7 +329,7 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
|
|
||||||
def modulos(self):
|
def modulos(self):
|
||||||
s = self.server
|
s = self.server
|
||||||
return [getattr(s, nome) for nome in ("api_config", "historico", "vida", "aprendizado", "inspiracao", "criacao", "treinamento", "conversa", "cerebro",
|
return [getattr(s, nome) for nome in ("console", "reconhecimento", "api_config", "historico", "vida", "aprendizado", "inspiracao", "criacao", "treinamento", "conversa", "cerebro",
|
||||||
"braco", "servos", "controle") if hasattr(s, nome)]
|
"braco", "servos", "controle") if hasattr(s, nome)]
|
||||||
|
|
||||||
def estado(self):
|
def estado(self):
|
||||||
@@ -486,7 +551,8 @@ class Servidor(ThreadingHTTPServer):
|
|||||||
|
|
||||||
|
|
||||||
def ligar_eventos(servidor):
|
def ligar_eventos(servidor):
|
||||||
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao", "aprendizado"):
|
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico", "vida", "criacao", "inspiracao", "aprendizado",
|
||||||
|
"console", "reconhecimento"):
|
||||||
if hasattr(servidor, nome):
|
if hasattr(servidor, nome):
|
||||||
getattr(servidor, nome).publicar = EVENTOS.publicar
|
getattr(servidor, nome).publicar = EVENTOS.publicar
|
||||||
|
|
||||||
@@ -560,11 +626,34 @@ def main():
|
|||||||
def nome_criada(ident):
|
def nome_criada(ident):
|
||||||
return next((a['nome'] for a in servidor.criacao.atividades() if a['id'] == ident), None)
|
return next((a['nome'] for a in servidor.criacao.atividades() if a['id'] == ident), None)
|
||||||
servidor.aprendizado = aprendizado = Aprendizado(Path.cwd() / 'aprendizado.json', nome_criada)
|
servidor.aprendizado = aprendizado = Aprendizado(Path.cwd() / 'aprendizado.json', nome_criada)
|
||||||
|
# Console: tudo o que ele faz e por quê (cada parte registra o motivo da decisão)
|
||||||
|
servidor.console = CONSOLE
|
||||||
|
# Reconhecimento de pessoas: as fotos dos eventos dizem quem chegou (tudo na placa)
|
||||||
|
servidor.reconhecimento = rec = Reconhecimento(Path.cwd())
|
||||||
|
servidor.historico.ao_foto = rec.nova_foto
|
||||||
|
rec.fotos_guardadas = servidor.historico.fotos_com_evento
|
||||||
|
rec.ao_reconhecer = vida.reconhecido
|
||||||
|
vida.reconhece = lambda: rec.config['ligado'] and rec.visao.pronta() and bool(rec.amostras())
|
||||||
|
vida.gostou_de = rec.gostou
|
||||||
|
for parte in (vida, aprendizado, inspiracao, servidor.cerebro, servidor.historico, servidor.controle, rec):
|
||||||
|
parte.console = CONSOLE.registrar
|
||||||
|
vida.dia_anterior = lambda: aprendizado.historico[-1] if aprendizado.historico else None
|
||||||
|
CONSOLE.registrar("sistema", "Servidor iniciado", "versão " + versao())
|
||||||
aprendizado.anotar = lambda tipo, texto, agora=None: (vida.anotar(tipo, texto, agora), vida.avisar())
|
aprendizado.anotar = lambda tipo, texto, agora=None: (vida.anotar(tipo, texto, agora), vida.avisar())
|
||||||
vida.aprendizado = aprendizado
|
vida.aprendizado = aprendizado
|
||||||
# a análise da semana usa a mesma IA do estúdio (Gemini, IA local ou o S10)
|
# a análise da semana usa a mesma IA do estúdio (Gemini, IA local ou o S10)
|
||||||
aprendizado.ia = servidor.criacao.chamar
|
aprendizado.ia = servidor.criacao.chamar
|
||||||
aprendizado.pode_ia = lambda provedor: bool(servidor.criacao.provedores().get(provedor, {}).get('pronto'))
|
aprendizado.pode_ia = lambda provedor: bool(servidor.criacao.provedores().get(provedor, {}).get('pronto'))
|
||||||
|
|
||||||
|
# a rotina da casa pode mexer no horário da madrugada (o mesmo dos Ajustes do celular)
|
||||||
|
def noite_atual():
|
||||||
|
c = servidor.controle.config
|
||||||
|
return (int(c.get('noiteInicio', 23)), int(c.get('noiteFim', 7))) if c.get('noiteSilenciosa', True) else None
|
||||||
|
|
||||||
|
def mudar_noite(ini, fim):
|
||||||
|
servidor.controle.publicar('app_config', servidor.controle.atualizar({'noiteInicio': ini, 'noiteFim': fim}))
|
||||||
|
aprendizado.noite = noite_atual
|
||||||
|
aprendizado.mudar_noite = mudar_noite
|
||||||
# E-Stop parado, ou modo presença com ninguém em casa (dormindo em silêncio): não faz nada
|
# E-Stop parado, ou modo presença com ninguém em casa (dormindo em silêncio): não faz nada
|
||||||
vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente"
|
vida.bloqueado = lambda: ESTOP["ativo"] or servidor.historico.presenca.get("estado") == "ausente"
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ def main():
|
|||||||
'LICENSE':'https://raw.githubusercontent.com/google-ai-edge/mediapipe/master/LICENSE',
|
'LICENSE':'https://raw.githubusercontent.com/google-ai-edge/mediapipe/master/LICENSE',
|
||||||
'face.tflite':'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite',
|
'face.tflite':'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite',
|
||||||
'hands.task':'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task',
|
'hands.task':'https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task',
|
||||||
|
# expressão do rosto (sorriso) para o contágio emocional
|
||||||
|
'expressao.task':'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task',
|
||||||
}.items():
|
}.items():
|
||||||
data = download(url); (dest/name).write_bytes(data)
|
data = download(url); (dest/name).write_bytes(data)
|
||||||
files[name] = {'url':url, 'sha256':hashlib.sha256(data).hexdigest()}
|
files[name] = {'url':url, 'sha256':hashlib.sha256(data).hexdigest()}
|
||||||
|
|||||||
+18
-1
@@ -1,5 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
let face = null, hands = null, vision = null;
|
let face = null, hands = null, vision = null;
|
||||||
|
// expressão (sorriso) para o contágio emocional: modelo opcional, roda 1 quadro a cada 4
|
||||||
|
let expressao = null, semExpressao = false, quadro = 0, sorriso = null;
|
||||||
let lastTime = 0;
|
let lastTime = 0;
|
||||||
async function prepare(c) {
|
async function prepare(c) {
|
||||||
if (!vision) {
|
if (!vision) {
|
||||||
@@ -9,6 +11,13 @@ async function prepare(c) {
|
|||||||
}
|
}
|
||||||
if (c.rosto && !face) face = await vision.module.FaceDetector.createFromOptions(vision.files, {
|
if (c.rosto && !face) face = await vision.module.FaceDetector.createFromOptions(vision.files, {
|
||||||
baseOptions:{modelAssetPath:'/vendor/mediapipe/face.tflite',delegate:'CPU'},runningMode:'VIDEO',minDetectionConfidence:.6});
|
baseOptions:{modelAssetPath:'/vendor/mediapipe/face.tflite',delegate:'CPU'},runningMode:'VIDEO',minDetectionConfidence:.6});
|
||||||
|
if (c.rosto && !expressao && !semExpressao) {
|
||||||
|
try {
|
||||||
|
expressao = await vision.module.FaceLandmarker.createFromOptions(vision.files, {
|
||||||
|
baseOptions:{modelAssetPath:'/vendor/mediapipe/expressao.task',delegate:'CPU'},runningMode:'VIDEO',numFaces:1,
|
||||||
|
outputFaceBlendshapes:true});
|
||||||
|
} catch (e) { semExpressao = true; } // sem o modelo: segue só com a posição do rosto
|
||||||
|
}
|
||||||
if (c.maos && !hands) hands = await vision.module.HandLandmarker.createFromOptions(vision.files, {
|
if (c.maos && !hands) hands = await vision.module.HandLandmarker.createFromOptions(vision.files, {
|
||||||
baseOptions:{modelAssetPath:'/vendor/mediapipe/hands.task',delegate:'CPU'},runningMode:'VIDEO',numHands:2,
|
baseOptions:{modelAssetPath:'/vendor/mediapipe/hands.task',delegate:'CPU'},runningMode:'VIDEO',numHands:2,
|
||||||
minHandDetectionConfidence:.6,minHandPresenceConfidence:.6,minTrackingConfidence:.5});
|
minHandDetectionConfidence:.6,minHandPresenceConfidence:.6,minTrackingConfidence:.5});
|
||||||
@@ -25,9 +34,17 @@ self.onmessage = async ({data}) => {
|
|||||||
w:d.boundingBox.width/bitmap.width,h:d.boundingBox.height/bitmap.height,
|
w:d.boundingBox.width/bitmap.width,h:d.boundingBox.height/bitmap.height,
|
||||||
score:d.categories[0]?.score || 0,pontos:d.keypoints.map(p=>({x:p.x,y:p.y}))
|
score:d.categories[0]?.score || 0,pontos:d.keypoints.map(p=>({x:p.x,y:p.y}))
|
||||||
})) : [];
|
})) : [];
|
||||||
|
if (!faces.length) sorriso = null;
|
||||||
|
else if (expressao && quadro++ % 4 === 0) {
|
||||||
|
const b = (expressao.detectForVideo(bitmap,stamp).faceBlendshapes || [])[0];
|
||||||
|
if (b) {
|
||||||
|
const s = {}; for (const c of b.categories) s[c.categoryName] = c.score;
|
||||||
|
sorriso = ((s.mouthSmileLeft || 0) + (s.mouthSmileRight || 0)) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
const result = data.config.maos ? hands.detectForVideo(bitmap,stamp) : {landmarks:[]};
|
const result = data.config.maos ? hands.detectForVideo(bitmap,stamp) : {landmarks:[]};
|
||||||
self.postMessage({tipo:'resultado',faces,hands:result.landmarks.map(h=>h.map(p=>({x:p.x,y:p.y}))),
|
self.postMessage({tipo:'resultado',faces,hands:result.landmarks.map(h=>h.map(p=>({x:p.x,y:p.y}))),
|
||||||
ms:performance.now()-began,timestamp:data.timestamp});
|
sorriso,ms:performance.now()-began,timestamp:data.timestamp});
|
||||||
} catch(e) { self.postMessage({tipo:'erro',erro:e.message || String(e)}); }
|
} catch(e) { self.postMessage({tipo:'erro',erro:e.message || String(e)}); }
|
||||||
finally { if (bitmap) bitmap.close(); }
|
finally { if (bitmap) bitmap.close(); }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -287,11 +287,13 @@
|
|||||||
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
|
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
|
||||||
desenharMaos(ctx, o, olhos, tela.height);
|
desenharMaos(ctx, o, olhos, tela.height);
|
||||||
desenharZzz(ctx, o, olhos);
|
desenharZzz(ctx, o, olhos);
|
||||||
|
desenharSonho(ctx, o, olhos);
|
||||||
} else {
|
} else {
|
||||||
cctx.clearRect(0, 0, camada.width, camada.height);
|
cctx.clearRect(0, 0, camada.width, camada.height);
|
||||||
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
|
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
|
||||||
desenharMaos(cctx, o, olhos, tela.height);
|
desenharMaos(cctx, o, olhos, tela.height);
|
||||||
desenharZzz(cctx, o, olhos);
|
desenharZzz(cctx, o, olhos);
|
||||||
|
desenharSonho(cctx, o, olhos);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
|
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
|
||||||
ctx.drawImage(camada, 0, 0);
|
ctx.drawImage(camada, 0, 0);
|
||||||
@@ -421,6 +423,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Zzz
|
// ------------------------------------------------------------------ Zzz
|
||||||
|
// ----------------------------------------------------------- sonho
|
||||||
|
// Dormindo, às vezes aparece bem fraquinho um balão de pensamento com algo do dia (a bola, a estrela...).
|
||||||
|
const sonho = { obj: null, t0: 0, dur: 0 };
|
||||||
|
function definirSonho(obj, segundos) {
|
||||||
|
sonho.obj = obj || null; sonho.t0 = performance.now() / 1000; sonho.dur = segundos || 7;
|
||||||
|
}
|
||||||
|
function desenharSonho(c, o, olhos) {
|
||||||
|
if (!o.dormindo || !sonho.obj) return;
|
||||||
|
const k = (o.t - sonho.t0) / sonho.dur;
|
||||||
|
if (k < 0) return;
|
||||||
|
if (k >= 1) { sonho.obj = null; return; }
|
||||||
|
const surge = Math.min(1, k * sonho.dur / 1.2, (1 - k) * sonho.dur / 1.2); // aparece e some devagar
|
||||||
|
const e = olhos.slice().sort((a, b) => a.cx - b.cx)[0]; // olho da esquerda na tela
|
||||||
|
const R = e.ry * 0.62, bx = e.cx - e.ry * 0.1;
|
||||||
|
const by = Math.max(R * 1.2, e.cy - e.ry * 1.75) + Math.sin(o.t * 1.3) * e.ry * 0.05; // nunca sai da tela
|
||||||
|
c.save();
|
||||||
|
c.strokeStyle = css(o.cor); c.fillStyle = css(o.cor, 0.08); c.lineWidth = Math.max(1.5, R * 0.06);
|
||||||
|
c.globalAlpha = 0.45 * surge;
|
||||||
|
for (const [f, rr] of [[0.25, 0.09], [0.55, 0.15]]) { // bolinhas subindo até o balão
|
||||||
|
c.beginPath(); c.arc(e.cx + e.ry * 0.3 - f * e.ry * 0.3, e.cy - e.ry * 0.75 + (by + R - e.cy + e.ry * 0.75) * f, R * rr * 1.3, 0, Math.PI * 2); c.stroke();
|
||||||
|
}
|
||||||
|
c.beginPath(); c.arc(bx, by, R, 0, Math.PI * 2); c.fill(); c.stroke();
|
||||||
|
c.globalAlpha = 0.55 * surge;
|
||||||
|
desenharObjeto(c, sonho.obj, bx, by, R * 1.15, o.t, o.cor, Math.sin(o.t * 0.8) * 0.15, 0);
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
function desenharZzz(c, o, olhos, fator) {
|
function desenharZzz(c, o, olhos, fator) {
|
||||||
fator = fator || 1;
|
fator = fator || 1;
|
||||||
if (!o.dormindo) return;
|
if (!o.dormindo) return;
|
||||||
@@ -1385,7 +1414,7 @@
|
|||||||
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
|
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
|
||||||
},
|
},
|
||||||
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
|
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
|
||||||
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS),
|
faixa: definirFaixa, sonho: definirSonho, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS),
|
||||||
/** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */
|
/** Atividades (momentos dele): iniciar/parar, para onde olhar e a camada da cena. */
|
||||||
atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
|
atividade: (nome, segundos, receita) => (nome ? iniciarAtividade(nome, segundos, receita) : (pararAtividade(), false)),
|
||||||
atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null),
|
atividadeAtual: () => (ativ.nome && !ativ.fim ? ativ.nome : null),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -136,6 +136,78 @@ class AprendizadoTest(unittest.TestCase):
|
|||||||
self.assertEqual(outro.peso('rir'), 1.0)
|
self.assertEqual(outro.peso('rir'), 1.0)
|
||||||
self.assertEqual(Aprendizado(self.caminho).itens, {})
|
self.assertEqual(Aprendizado(self.caminho).itens, {})
|
||||||
|
|
||||||
|
def ligar_noite(self, ini=23, fim=7):
|
||||||
|
estado = {'noite': (ini, fim)}
|
||||||
|
self.a.noite = lambda: estado['noite']
|
||||||
|
self.a.mudar_noite = lambda i, f: estado.update(noite=(i, f))
|
||||||
|
return estado
|
||||||
|
|
||||||
|
def test_rotina_separa_o_que_cai_na_madrugada(self):
|
||||||
|
self.ligar_noite(23, 7)
|
||||||
|
self.a.dias = 3
|
||||||
|
for h in list(range(10, 24)) + [0, 2]:
|
||||||
|
self.a.rotina[h] = 0.8
|
||||||
|
self.a.config['ajustar_sono'] = False
|
||||||
|
licoes = self.a.fechar_dia()
|
||||||
|
texto = next(t for t in licoes if t.startswith('Percebi'))
|
||||||
|
self.assertIn('das 10h às 23h. Vou estar acordado nessa hora.', texto)
|
||||||
|
self.assertIn('Das 23h à 1h e das 2h às 3h é a minha hora de dormir', texto)
|
||||||
|
# só gente na madrugada: não promete ficar acordado
|
||||||
|
self.a.rotina = [None] * 24
|
||||||
|
self.a.rotina[2], self.a.rotina_avisada = 0.9, []
|
||||||
|
texto = next(t for t in self.a.fechar_dia() if t.startswith('Percebi'))
|
||||||
|
self.assertEqual(texto, 'Percebi gente por perto das 2h às 3h, mas é a minha hora de dormir: '
|
||||||
|
'fico quietinho, a não ser que alguém me chame.')
|
||||||
|
|
||||||
|
def test_horario_de_dormir_acompanha_a_casa_aos_poucos_e_volta(self):
|
||||||
|
estado = self.ligar_noite(23, 7)
|
||||||
|
self.a.dias = 2
|
||||||
|
self.a.rotina[23], self.a.rotina[0], self.a.rotina[1] = 0.8, 0.7, 0.7
|
||||||
|
self.assertEqual(self.a.ajustar_noite('2026-10-06'), []) # poucos dias: ainda não mexe
|
||||||
|
self.a.dias = 6
|
||||||
|
licoes = self.a.ajustar_noite('2026-10-06')
|
||||||
|
self.assertEqual(estado['noite'], (0, 7)) # 1 h por noite
|
||||||
|
self.assertIn('agora eu vou dormir às 0h (antes era 23h)', licoes[0])
|
||||||
|
self.assertEqual(self.a.decisoes[0]['tipo'], 'sono')
|
||||||
|
self.a.ajustar_noite('2026-10-07')
|
||||||
|
self.a.ajustar_noite('2026-10-08')
|
||||||
|
self.assertEqual(estado['noite'], (2, 7)) # 1h tem gente, 2h já não conta
|
||||||
|
self.a.rotina[2] = 0.9
|
||||||
|
self.a.ajustar_noite('2026-10-09')
|
||||||
|
self.assertEqual(estado['noite'], (2, 7)) # no máximo 3 h depois do escolhido
|
||||||
|
# a casa voltou a dormir cedo: volta 1 h por noite até o horário escolhido
|
||||||
|
self.a.rotina[1] = self.a.rotina[0] = self.a.rotina[23] = 0.1
|
||||||
|
self.assertIn('volto a dormir às 1h', self.a.ajustar_noite('2026-10-10')[0])
|
||||||
|
self.a.ajustar_noite('2026-10-11')
|
||||||
|
self.a.ajustar_noite('2026-10-12')
|
||||||
|
self.assertEqual(estado['noite'], (23, 7))
|
||||||
|
self.assertEqual(self.a.ajustar_noite('2026-10-13'), [])
|
||||||
|
|
||||||
|
def test_acordar_mais_cedo_limites_e_horario_escolhido_a_mao(self):
|
||||||
|
estado = self.ligar_noite(23, 7)
|
||||||
|
self.a.dias = 6
|
||||||
|
self.a.rotina[6], self.a.rotina[5] = 0.8, 0.8
|
||||||
|
self.a.ajustar_noite('2026-10-06')
|
||||||
|
self.a.ajustar_noite('2026-10-07')
|
||||||
|
self.a.ajustar_noite('2026-10-08')
|
||||||
|
self.assertEqual(estado['noite'], (23, 5)) # no máximo 2 h antes
|
||||||
|
# a pessoa muda o horário à mão: vira a nova base e ele não desfaz
|
||||||
|
estado['noite'] = (22, 8)
|
||||||
|
self.a.rotina = [None] * 24
|
||||||
|
self.assertEqual(self.a.ajustar_noite('2026-10-09'), [])
|
||||||
|
self.assertEqual(self.a.sono_base, (22, 8))
|
||||||
|
# madrugada nunca fica menor que 5 h
|
||||||
|
estado['noite'] = (2, 7)
|
||||||
|
self.a.rotina[2], self.a.rotina[6] = 0.9, 0.9
|
||||||
|
self.a.ajustar_noite('2026-10-10')
|
||||||
|
self.assertEqual(estado['noite'], (2, 7))
|
||||||
|
# desligado ou sem madrugada: não mexe
|
||||||
|
self.a.config['ajustar_sono'] = False
|
||||||
|
self.assertEqual(self.a.ajustar_noite('2026-10-11'), [])
|
||||||
|
self.a.config['ajustar_sono'] = True
|
||||||
|
self.a.noite = lambda: None
|
||||||
|
self.assertEqual(self.a.ajustar_noite('2026-10-12'), [])
|
||||||
|
|
||||||
def test_validar(self):
|
def test_validar(self):
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
self.a.configurar(dict(ligado='sim'))
|
self.a.configurar(dict(ligado='sim'))
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ class HistoricoTest(unittest.TestCase):
|
|||||||
avisos = []
|
avisos = []
|
||||||
self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados))
|
self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados))
|
||||||
self.h.pir['disponivel'] = True
|
self.h.pir['disponivel'] = True
|
||||||
|
self.h.config['sensibilidade_pir'] = 'alta' # qualquer disparo conta
|
||||||
self.h.nivel_pir(True, agora=1000) # subida: evento + bolinha acesa
|
self.h.nivel_pir(True, agora=1000) # subida: evento + bolinha acesa
|
||||||
self.assertTrue(self.h.pir['ativo'])
|
self.assertTrue(self.h.pir['ativo'])
|
||||||
self.assertEqual(self.h.status()['eventos'][0]['tipo'], 'pir')
|
self.assertEqual(self.h.status()['eventos'][0]['tipo'], 'pir')
|
||||||
@@ -107,6 +108,38 @@ class HistoricoTest(unittest.TestCase):
|
|||||||
self.assertTrue(self.h.checar_ausencia(1000 + 15 * 60 + 1))
|
self.assertTrue(self.h.checar_ausencia(1000 + 15 * 60 + 1))
|
||||||
self.assertIn(('pir', True), [(t, d.get('ativo')) for t, d in avisos])
|
self.assertIn(('pir', True), [(t, d.get('ativo')) for t, d in avisos])
|
||||||
|
|
||||||
|
def test_sensibilidade_do_pir_confirma_antes_de_contar(self):
|
||||||
|
detectados = []
|
||||||
|
self.h.ao_detectar = detectados.append
|
||||||
|
self.h.config['intervalo'] = 0
|
||||||
|
# média (padrão): um disparo solto só acende a bolinha
|
||||||
|
self.h.nivel_pir(True, agora=1000)
|
||||||
|
self.assertTrue(self.h.pir['ativo'])
|
||||||
|
self.h.nivel_pir(False, agora=1008) # o HC-SR505 segura ~8 s
|
||||||
|
self.assertEqual((self.h.status()['total'], detectados), (0, []))
|
||||||
|
self.h.nivel_pir(True, agora=1040) # segundo disparo em menos de 1 min
|
||||||
|
self.assertEqual(detectados, ['pir'])
|
||||||
|
self.h.nivel_pir(False, agora=1048)
|
||||||
|
self.h.nivel_pir(True, agora=1200) # 2 min depois: solto de novo
|
||||||
|
self.h.nivel_pir(False, agora=1208)
|
||||||
|
self.assertEqual(len(detectados), 1)
|
||||||
|
# alguém se mexendo na frente: o sensor fica ligado direto e confirma
|
||||||
|
self.h.nivel_pir(True, agora=2000)
|
||||||
|
self.h.checar_pir(2010)
|
||||||
|
self.assertEqual(len(detectados), 1)
|
||||||
|
self.h.checar_pir(2012)
|
||||||
|
self.assertEqual(len(detectados), 2)
|
||||||
|
self.h.nivel_pir(False, agora=2030)
|
||||||
|
# baixa: precisa de 3 disparos em 90 s
|
||||||
|
self.h.config['sensibilidade_pir'] = 'baixa'
|
||||||
|
for t in (3000, 3030):
|
||||||
|
self.h.nivel_pir(True, agora=t); self.h.nivel_pir(False, agora=t + 8)
|
||||||
|
self.assertEqual(len(detectados), 2)
|
||||||
|
self.h.nivel_pir(True, agora=3060)
|
||||||
|
self.assertEqual(len(detectados), 3)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
Historico.validar(dict(sensibilidade_pir='maxima'))
|
||||||
|
|
||||||
def test_apagar_e_limpar(self):
|
def test_apagar_e_limpar(self):
|
||||||
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
|
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
|
||||||
b = self.h.registrar('som', agora=1)
|
b = self.h.registrar('som', agora=1)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -81,6 +81,30 @@ class ServidorTest(unittest.TestCase):
|
|||||||
self.assertEqual(self.pedir('GET', '/app/health', token=None)[0], 200)
|
self.assertEqual(self.pedir('GET', '/app/health', token=None)[0], 200)
|
||||||
self.assertEqual(self.pedir('GET', '/estado?t=' + TOKEN, token=None)[0], 200)
|
self.assertEqual(self.pedir('GET', '/estado?t=' + TOKEN, token=None)[0], 200)
|
||||||
|
|
||||||
|
def test_comandos_dos_jogos_falar_e_olhos(self):
|
||||||
|
self.assertEqual(kiosk.ordem_de_comando('dizer', ' Pedra... '), 'dizer Pedra...')
|
||||||
|
self.assertEqual(kiosk.ordem_de_comando('olhos', {'simbolo': '✌️', 'segundos': 3}), 'olhos ✌️ 3.0')
|
||||||
|
self.assertEqual(kiosk.ordem_de_comando('olhos', {'simbolo': '3', 'segundos': 99}), 'olhos 3 8.0')
|
||||||
|
self.assertEqual(kiosk.ordem_de_comando('olhos', {}), 'olhos - 2.5')
|
||||||
|
for tipo, valor in (('olhos', {'simbolo': '<b>'}), ('olhos', {'simbolo': '1', 'segundos': 'x'}), ('dizer', ' ')):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
kiosk.ordem_de_comando(tipo, valor)
|
||||||
|
codigo, _ = self.pedir('POST', '/comando', {'tipo': 'dizer', 'valor': 'Já!'})
|
||||||
|
self.assertEqual(codigo, 200)
|
||||||
|
self.assertIn('dizer Já!', kiosk.FILA.ordens())
|
||||||
|
|
||||||
|
def test_telao_dos_jogos(self):
|
||||||
|
ordem = kiosk.ordem_de_comando('telao', {'titulo': 'VENCI!!!', 'tom': 'eu', 'sub': 'Você 0 × 1 Carinha', 'segundos': 3,
|
||||||
|
'lados': [{'rotulo': 'Você', 'simbolo': '✊', 'nome': 'pedra'},
|
||||||
|
{'rotulo': 'Carinha', 'simbolo': '✋', 'nome': 'papel'}]})
|
||||||
|
self.assertTrue(ordem.startswith('telao {'))
|
||||||
|
t = json.loads(ordem[6:])
|
||||||
|
self.assertEqual((t['titulo'], t['tom'], len(t['lados'])), ('VENCI!!!', 'eu', 2))
|
||||||
|
self.assertEqual(json.loads(kiosk.ordem_de_comando('telao', {'titulo': 'x' * 99, 'tom': 'roxo'})[6:])['tom'], 'neutro')
|
||||||
|
for ruim in ({}, {'titulo': 'a', 'lados': [{'simbolo': '<img>'}]}, {'titulo': 'a', 'lados': [{}, {}, {}]}, 'texto'):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
kiosk.ordem_de_comando('telao', ruim)
|
||||||
|
|
||||||
def test_pagina_do_rosto_sempre_conferida_e_api_sem_cache(self):
|
def test_pagina_do_rosto_sempre_conferida_e_api_sem_cache(self):
|
||||||
# o WebView do celular não pode seguir com uma carinha.html velha depois de um deploy
|
# o WebView do celular não pode seguir com uma carinha.html velha depois de um deploy
|
||||||
for rota, esperado in (('/carinha.html', 'no-cache'), ('/gestos', 'no-store')):
|
for rota, esperado in (('/carinha.html', 'no-cache'), ('/gestos', 'no-store')):
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ ESTIMULOS = {
|
|||||||
'brincar': (dict(diversao=25, alegria=8, energia=-5), 'alegre'),
|
'brincar': (dict(diversao=25, alegria=8, energia=-5), 'alegre'),
|
||||||
'alimentar': (dict(energia=30, alegria=4), 'feliz'),
|
'alimentar': (dict(energia=30, alegria=4), 'feliz'),
|
||||||
}
|
}
|
||||||
|
NOME_ESTIMULO = {'pir': 'O sensor de presença viu alguém', 'movimento': 'A câmera viu movimento',
|
||||||
|
'som': 'Ouviu um barulho', 'conversa': 'Alguém conversou com ele'}
|
||||||
CUIDADOS = {'carinho': 'Recebeu carinho', 'cutucar': 'Levou uma cutucada', 'brincar': 'Brincou',
|
CUIDADOS = {'carinho': 'Recebeu carinho', 'cutucar': 'Levou uma cutucada', 'brincar': 'Brincou',
|
||||||
'alimentar': 'Ganhou energia', 'ninar': 'Foi posto para dormir', 'acordar': 'Foi acordado'}
|
'alimentar': 'Ganhou energia', 'ninar': 'Foi posto para dormir', 'acordar': 'Foi acordado'}
|
||||||
|
|
||||||
@@ -112,6 +114,18 @@ PERIODOS = [(5, 12, 'de manhã', ['um sol nascendo', 'um pãozinho', 'uma xícar
|
|||||||
(0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])]
|
(0, 5, 'de madrugada', ['uma lua', 'uma vela', 'uma coruja'])]
|
||||||
|
|
||||||
MAX_DIARIO = 60
|
MAX_DIARIO = 60
|
||||||
|
|
||||||
|
# Personalidade: traços de 0 a 1 que mudam o jeito de tudo (0,5 = neutro) e amadurecem com o convívio.
|
||||||
|
TRACOS = ('curiosidade', 'brincadeira', 'timidez')
|
||||||
|
GESTOS_TRACO = {'brincadeira': {'rir', 'comemorar', 'alegre', 'musica', 'piscadinha'},
|
||||||
|
'curiosidade': {'olhar_esq', 'olhar_dir', 'pensar', 'espiar', 'confuso'},
|
||||||
|
'timidez': {'timido', 'esconder', 'espiar'}}
|
||||||
|
# Reencontro: quanto mais tempo a pessoa ficou fora, maior a festa.
|
||||||
|
AUSENCIA_ACENO, AUSENCIA_OI, AUSENCIA_SAUDADE = 10 * 60, 60 * 60, 4 * 3600
|
||||||
|
INTERVALO_PEDIDO = 20 * 60 # no mínimo 20 min entre um pedido ("brinca comigo?") e outro
|
||||||
|
DESISTE = 0.12 # chance de largar uma atividade no meio porque ficou chato
|
||||||
|
ESPERA_NOME = 8 # s esperando o reconhecimento antes de cumprimentar sem o nome
|
||||||
|
OI_DE_NOVO = 10 * 60 # reconheceu alguém que não via há esse tempo: "Oi, Ana!"
|
||||||
GESTOS_VIDA = {g for atos in ATOS.values() for g, _, _ in atos}
|
GESTOS_VIDA = {g for atos in ATOS.values() for g, _, _ in atos}
|
||||||
|
|
||||||
|
|
||||||
@@ -149,6 +163,22 @@ class Vida:
|
|||||||
self.aprendi_pendente = lambda: None # curiosidade de hoje que ainda dá para contar
|
self.aprendi_pendente = lambda: None # curiosidade de hoje que ainda dá para contar
|
||||||
self.contar_aprendi = lambda ident, agora=None: None
|
self.contar_aprendi = lambda ident, agora=None: None
|
||||||
self.aprendizado = None # o que ele aprende com o dia a dia (aprendizado.py)
|
self.aprendizado = None # o que ele aprende com o dia a dia (aprendizado.py)
|
||||||
|
self.console = lambda origem, texto, motivo='', nivel='info', agora=None: None # console do painel
|
||||||
|
self.dia_anterior = lambda: None # resumo de ontem (aprendizado): para lembrar no bom dia
|
||||||
|
# sorteios dos comportamentos "humanos" ficam à parte: não mexem na sequência do rng principal
|
||||||
|
self.acaso = random.Random() if rng is None else random.Random(7)
|
||||||
|
self.porque = '' # motivo da última decisão (vai para o console)
|
||||||
|
self.ultima = None # {horario, texto, porque}: a última decisão de fato
|
||||||
|
self.perto_antes = False
|
||||||
|
self.ultimo_perto = time.time()
|
||||||
|
self.pediu_em = 0.0
|
||||||
|
self.desistencia = None # (quando, o que) se largou a atividade no meio
|
||||||
|
self.figurinha_mostrada = 0.0
|
||||||
|
# reconhecimento de pessoas (reconhecimento.py): o main liga
|
||||||
|
self.reconhece = lambda: False # True: vale esperar a foto para cumprimentar pelo nome
|
||||||
|
self.gostou_de = lambda pessoa, quanto=1.0: None
|
||||||
|
self.reencontro_pendente = None # (desde, ausência) esperando saber quem chegou
|
||||||
|
self.ultimo_reconhecido = None # (id, nome, quando)
|
||||||
|
|
||||||
agora = time.time()
|
agora = time.time()
|
||||||
self.config = dict(PADRAO)
|
self.config = dict(PADRAO)
|
||||||
@@ -157,6 +187,10 @@ class Vida:
|
|||||||
self.diario = []
|
self.diario = []
|
||||||
self.emojis = dict(dia='', total=0, ultimo=0.0)
|
self.emojis = dict(dia='', total=0, ultimo=0.0)
|
||||||
self.dormindo_proprio = False
|
self.dormindo_proprio = False
|
||||||
|
# cada Carinha nasce com um jeito um pouco diferente (nos testes, neutro)
|
||||||
|
self.personalidade = ({k: 0.5 for k in TRACOS} if rng is not None else
|
||||||
|
{k: round(self.acaso.uniform(0.35, 0.65), 2) for k in TRACOS})
|
||||||
|
self.saudou_dia = ''
|
||||||
try:
|
try:
|
||||||
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
|
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
|
||||||
self.config.update(self.validar(salvo.get('config', {})))
|
self.config.update(self.validar(salvo.get('config', {})))
|
||||||
@@ -165,6 +199,11 @@ class Vida:
|
|||||||
self.necessidades[k] = _limitar(float(salvo['necessidades'][k]))
|
self.necessidades[k] = _limitar(float(salvo['necessidades'][k]))
|
||||||
self.nascimento = float(salvo.get('nascimento', agora))
|
self.nascimento = float(salvo.get('nascimento', agora))
|
||||||
self.diario = [d for d in salvo.get('diario', []) if isinstance(d, dict)][:MAX_DIARIO]
|
self.diario = [d for d in salvo.get('diario', []) if isinstance(d, dict)][:MAX_DIARIO]
|
||||||
|
if isinstance(salvo.get('personalidade'), dict):
|
||||||
|
for k in TRACOS:
|
||||||
|
if isinstance(salvo['personalidade'].get(k), (int, float)):
|
||||||
|
self.personalidade[k] = max(0.0, min(1.0, float(salvo['personalidade'][k])))
|
||||||
|
self.saudou_dia = str(salvo.get('saudou_dia', ''))
|
||||||
if isinstance(salvo.get('emojis'), dict):
|
if isinstance(salvo.get('emojis'), dict):
|
||||||
self.emojis.update({k: salvo['emojis'][k] for k in ('dia', 'total', 'ultimo') if k in salvo['emojis']})
|
self.emojis.update({k: salvo['emojis'][k] for k in ('dia', 'total', 'ultimo') if k in salvo['emojis']})
|
||||||
except (OSError, ValueError, TypeError, AttributeError):
|
except (OSError, ValueError, TypeError, AttributeError):
|
||||||
@@ -222,7 +261,8 @@ class Vida:
|
|||||||
def salvar(self):
|
def salvar(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
dados = dict(config=self.config, necessidades=self.necessidades, nascimento=self.nascimento,
|
dados = dict(config=self.config, necessidades=self.necessidades, nascimento=self.nascimento,
|
||||||
diario=self.diario, emojis=self.emojis)
|
diario=self.diario, emojis=self.emojis, personalidade=self.personalidade,
|
||||||
|
saudou_dia=self.saudou_dia)
|
||||||
self.salvo_em = time.time()
|
self.salvo_em = time.time()
|
||||||
try:
|
try:
|
||||||
gravar_atomico(self.caminho, dados)
|
gravar_atomico(self.caminho, dados)
|
||||||
@@ -274,7 +314,9 @@ class Vida:
|
|||||||
nascimento=self.nascimento, idade_dias=int((agora - self.nascimento) // 86400),
|
nascimento=self.nascimento, idade_dias=int((agora - self.nascimento) // 86400),
|
||||||
emojis_hoje=self.emojis_hoje(agora), ultimo_emoji=self.emojis.get('ultimo') or None,
|
emojis_hoje=self.emojis_hoje(agora), ultimo_emoji=self.emojis.get('ultimo') or None,
|
||||||
zanga=self.zanga(), sono_profundo=self.sono_profundo,
|
zanga=self.zanga(), sono_profundo=self.sono_profundo,
|
||||||
pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30])
|
pode_criar=self.motivo_sem_emoji(agora) or '', diario=self.diario[:30],
|
||||||
|
personalidade=dict(self.personalidade), porque=self.porque, ultima=self.ultima,
|
||||||
|
proximo_ato_em=max(0, round(self.proximo_ato - agora)))
|
||||||
|
|
||||||
def avisar(self):
|
def avisar(self):
|
||||||
self.publicado_em = time.time()
|
self.publicado_em = time.time()
|
||||||
@@ -323,6 +365,13 @@ class Vida:
|
|||||||
self.interagiu_em = agora # alguém quis ele acordado (sensor não conta)
|
self.interagiu_em = agora # alguém quis ele acordado (sensor não conta)
|
||||||
if self.aprendizado:
|
if self.aprendizado:
|
||||||
self.aprendizado.resposta(tipo, agora)
|
self.aprendizado.resposta(tipo, agora)
|
||||||
|
self.amadurecer(tipo)
|
||||||
|
rec = self.ultimo_reconhecido
|
||||||
|
if rec and agora - rec[2] < 120:
|
||||||
|
self.gostou_de(rec[0], 1.0 if tipo in ('carinho', 'brincar', 'conversa') else 0.5)
|
||||||
|
self.console('sentidos', CUIDADOS.get(tipo) or NOME_ESTIMULO.get(tipo, tipo),
|
||||||
|
', '.join('%s %+d' % (k, v) for k, v in efeito.items()),
|
||||||
|
'detalhe' if tipo in ('som', 'movimento') else 'info', agora)
|
||||||
if gesto and not self.bloqueado():
|
if gesto and not self.bloqueado():
|
||||||
if self.dormindo():
|
if self.dormindo():
|
||||||
self.acordar(agora, None)
|
self.acordar(agora, None)
|
||||||
@@ -390,12 +439,131 @@ class Vida:
|
|||||||
self.anotar('atividade', 'Foi fazer %s %s%s' % (item['emoji'], item['nome'], motivo), agora)
|
self.anotar('atividade', 'Foi fazer %s %s%s' % (item['emoji'], item['nome'], motivo), agora)
|
||||||
return 'atividade ' + nome
|
return 'atividade ' + nome
|
||||||
nome = self.rng.choice(CALMAS) # apagaram a criação no meio do caminho
|
nome = self.rng.choice(CALMAS) # apagaram a criação no meio do caminho
|
||||||
|
if nome != 'dormir' and segundos > 60 and self.acaso.random() < DESISTE * (1.3 - self.personalidade['curiosidade']):
|
||||||
|
segundos = segundos * self.acaso.uniform(0.3, 0.5) # vai largar no meio: ficou chato
|
||||||
|
self.desistencia = (agora + segundos, NOME_ATIVIDADE.get(nome, 'a atividade'))
|
||||||
self.enfileirar('atividade %s %d' % (nome, segundos))
|
self.enfileirar('atividade %s %d' % (nome, segundos))
|
||||||
self.atividade_em = agora
|
self.atividade_em = agora
|
||||||
self.ultima_atividade = nome
|
self.ultima_atividade = nome
|
||||||
self.anotar('atividade', NOME_ATIVIDADE.get(nome, 'Foi fazer algo'), agora)
|
self.anotar('atividade', NOME_ATIVIDADE.get(nome, 'Foi fazer algo'), agora)
|
||||||
return 'atividade ' + nome
|
return 'atividade ' + nome
|
||||||
|
|
||||||
|
def dizer(self, texto, gesto=None):
|
||||||
|
"""Uma fala curta: o gesto e a frase em legenda, com a voz de robô."""
|
||||||
|
if gesto:
|
||||||
|
self.enfileirar(gesto)
|
||||||
|
self.enfileirar('dizer ' + texto)
|
||||||
|
|
||||||
|
def resumo(self):
|
||||||
|
n = self.necessidades
|
||||||
|
return 'energia %d · alegria %d · social %d · diversão %d · %s' % (
|
||||||
|
n['energia'], n['alegria'], n['social'], n['diversao'], HUMORES[self.humor()][1].lower())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def tempo(seg):
|
||||||
|
return '%d min' % round(seg / 60) if seg < 3600 else '%dh%02d' % (seg // 3600, seg % 3600 // 60)
|
||||||
|
|
||||||
|
def saudacao(self, agora, nome=None):
|
||||||
|
"""Bom dia com uma lembrança de ontem (do que ele viveu, sem IA)."""
|
||||||
|
hora = time.localtime(agora).tm_hour
|
||||||
|
prefixo = 'Bom dia' if 5 <= hora < 12 else 'Boa tarde' if hora < 18 else 'Boa noite'
|
||||||
|
if nome:
|
||||||
|
prefixo += ', ' + nome
|
||||||
|
fatos = []
|
||||||
|
ontem = None
|
||||||
|
try:
|
||||||
|
ontem = self.dia_anterior()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if isinstance(ontem, dict):
|
||||||
|
pres = ontem.get('presenca') or []
|
||||||
|
tarde = [hh for hh in range(0, 5) if hh < len(pres) and pres[hh] is not None and pres[hh] >= 0.3]
|
||||||
|
if tarde:
|
||||||
|
fatos.append('Ontem a gente ficou acordado até as %dh, hein!' % (max(tarde) + 1))
|
||||||
|
elif len(pres) == 24 and pres[23] is not None and pres[23] >= 0.3:
|
||||||
|
fatos.append('Ontem a gente ficou até tarde, hein!')
|
||||||
|
if (ontem.get('respostas') or 0) >= 5:
|
||||||
|
fatos.append('Ontem foi um dia cheio de carinho. Obrigado!')
|
||||||
|
elif (ontem.get('atos') or 0) >= 5 and not ontem.get('respostas'):
|
||||||
|
fatos.append('Ontem ninguém brincou comigo... hoje vai ser diferente?')
|
||||||
|
if not fatos:
|
||||||
|
fatos.append('Acordei cheio de energia!' if self.necessidades['energia'] >= 80 else 'Que bom te ver!')
|
||||||
|
return '%s! %s' % (prefixo, self.acaso.choice(fatos))
|
||||||
|
|
||||||
|
def reencontro(self, agora, ausencia, nome=None, afinidade=0.0):
|
||||||
|
"""Alguém apareceu: a reação depende de quanto tempo ficou fora (e de quem ele é)."""
|
||||||
|
if self.dormindo() or self.madrugada(agora) or self.bloqueado():
|
||||||
|
return None
|
||||||
|
n, tim = self.necessidades, self.personalidade['timidez']
|
||||||
|
dia = time.strftime('%Y-%m-%d', time.localtime(agora))
|
||||||
|
motivo = ('%s não aparecia havia ' % nome if nome else 'ninguém por perto havia ') + self.tempo(ausencia)
|
||||||
|
querido = afinidade >= 20 # muito carinho dessa pessoa: festa com coração
|
||||||
|
if self.saudou_dia != dia and time.localtime(agora).tm_hour >= 5 and ausencia >= AUSENCIA_ACENO:
|
||||||
|
self.saudou_dia = dia
|
||||||
|
frase = self.saudacao(agora, nome)
|
||||||
|
self.dizer(frase, 'timido' if tim > 0.65 else 'acenar')
|
||||||
|
ganho, texto, motivo = 10, 'Deu bom dia: "%s"' % frase, 'primeiro encontro do dia; ' + motivo
|
||||||
|
elif ausencia >= AUSENCIA_SAUDADE:
|
||||||
|
self.dizer('%s! Senti sua falta!' % nome if nome else 'Senti sua falta!', 'comemorar')
|
||||||
|
self.enfileirar('coracao')
|
||||||
|
ganho, texto = 20, 'Fez festa: estava com saudade' + (' de ' + nome if nome else '')
|
||||||
|
n['alegria'] = _limitar(n['alegria'] + 10)
|
||||||
|
elif ausencia >= AUSENCIA_OI:
|
||||||
|
self.dizer('Oi, %s! Você voltou!' % nome if nome else 'Oi! Você voltou!', 'timido' if tim > 0.65 else 'comemorar')
|
||||||
|
if querido:
|
||||||
|
self.enfileirar('coracao')
|
||||||
|
ganho, texto = 10, 'Comemorou que %s voltou' % (nome or 'alguém')
|
||||||
|
elif ausencia >= AUSENCIA_ACENO:
|
||||||
|
if nome:
|
||||||
|
self.dizer('Oi, %s!' % nome, 'acenar')
|
||||||
|
else:
|
||||||
|
self.enfileirar('acenar')
|
||||||
|
ganho, texto = 4, 'Acenou para %s' % (nome or 'quem voltou')
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
with self.lock:
|
||||||
|
n['social'] = _limitar(n['social'] + ganho)
|
||||||
|
self.anotar('social', texto, agora)
|
||||||
|
self.console('vida', texto, motivo, agora=agora)
|
||||||
|
self.avisar()
|
||||||
|
return texto
|
||||||
|
|
||||||
|
def reconhecido(self, pessoa, confianca, agora=None):
|
||||||
|
"""O reconhecimento disse quem está aí (vem da thread do reconhecimento)."""
|
||||||
|
agora = agora or time.time()
|
||||||
|
nome = pessoa['nome']
|
||||||
|
self.ultimo_reconhecido = (pessoa['id'], nome, agora)
|
||||||
|
antes = pessoa.get('visto_antes')
|
||||||
|
pendente = self.reencontro_pendente
|
||||||
|
if pendente: # chegou alguém e agora sabe quem é
|
||||||
|
self.reencontro_pendente = None
|
||||||
|
ausencia = agora - antes if antes else pendente[1]
|
||||||
|
return self.reencontro(agora, ausencia, nome, pessoa.get('afinidade', 0))
|
||||||
|
if antes and agora - antes > OI_DE_NOVO: # chegou outra pessoa com alguém já por perto
|
||||||
|
return self.reencontro(agora, agora - antes, nome, pessoa.get('afinidade', 0))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def pedir_algo(self, agora):
|
||||||
|
"""Iniciativa com motivo: pede o que está faltando (ou mostra o que fez)."""
|
||||||
|
n = self.necessidades
|
||||||
|
if n['social'] < 35:
|
||||||
|
frase, gesto, porque = 'Tô carente... me faz um carinho?', 'coracao', 'social %d' % n['social']
|
||||||
|
elif n['diversao'] < 40:
|
||||||
|
frase, gesto, porque = 'Brinca comigo?', 'acenar', 'diversão %d' % n['diversao']
|
||||||
|
elif (float(self.emojis.get('ultimo') or 0) > self.figurinha_mostrada and agora - float(self.emojis['ultimo']) < 3 * 3600
|
||||||
|
and self.tocar_reacao(agora, anotar=False)):
|
||||||
|
self.figurinha_mostrada = float(self.emojis['ultimo'])
|
||||||
|
frase, gesto, porque = 'Olha o que eu desenhei!', None, 'criou uma figurinha e ainda não tinha mostrado'
|
||||||
|
elif n['energia'] < 35:
|
||||||
|
frase, gesto, porque = 'Tô com soninho...', 'bocejar', 'energia %d' % n['energia']
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
self.pediu_em = agora
|
||||||
|
self.dizer(frase, gesto)
|
||||||
|
self.anotar('pedido', 'Pediu: "%s"' % frase, agora)
|
||||||
|
self.porque = 'alguém por perto e ' + porque
|
||||||
|
return 'pedido'
|
||||||
|
|
||||||
def pir_acorda(self, agora=None):
|
def pir_acorda(self, agora=None):
|
||||||
"""Disparo do PIR com o rosto dormindo: acorda? No ritmo calmo precisa de vários em 2 min."""
|
"""Disparo do PIR com o rosto dormindo: acorda? No ritmo calmo precisa de vários em 2 min."""
|
||||||
agora = agora or time.time()
|
agora = agora or time.time()
|
||||||
@@ -423,8 +591,27 @@ class Vida:
|
|||||||
"""Quanto o aprendizado multiplica a chance de um ato (1 = sem opinião)."""
|
"""Quanto o aprendizado multiplica a chance de um ato (1 = sem opinião)."""
|
||||||
return self.aprendizado.peso(nome) if self.aprendizado else 1.0
|
return self.aprendizado.peso(nome) if self.aprendizado else 1.0
|
||||||
|
|
||||||
|
def jeito(self, gesto):
|
||||||
|
"""Quanto a personalidade puxa um gesto (1 = neutro): brincalhão ri mais, tímido se esconde mais."""
|
||||||
|
f = 1.0
|
||||||
|
for traco, gestos in GESTOS_TRACO.items():
|
||||||
|
if gesto in gestos:
|
||||||
|
f *= 1 + (self.personalidade[traco] - 0.5) * 1.2
|
||||||
|
return f
|
||||||
|
|
||||||
|
def amadurecer(self, tipo):
|
||||||
|
"""O convívio muda o jeito devagar: carinho deixa menos tímido, brincar deixa mais brincalhão."""
|
||||||
|
p = self.personalidade
|
||||||
|
with self.lock:
|
||||||
|
if tipo in ('carinho', 'conversa', 'brincar'):
|
||||||
|
p['timidez'] = round(max(0.1, p['timidez'] - 0.003), 3)
|
||||||
|
if tipo == 'brincar':
|
||||||
|
p['brincadeira'] = round(min(0.95, p['brincadeira'] + 0.002), 3)
|
||||||
|
if tipo == 'conversa':
|
||||||
|
p['curiosidade'] = round(min(0.95, p['curiosidade'] + 0.001), 3)
|
||||||
|
|
||||||
def escolher(self, opcoes):
|
def escolher(self, opcoes):
|
||||||
opcoes = [(g, p * self.gosto(g), t) for g, p, t in opcoes]
|
opcoes = [(g, p * self.gosto(g) * self.jeito(g), t) for g, p, t in opcoes]
|
||||||
total = sum(p for _, p, _ in opcoes)
|
total = sum(p for _, p, _ in opcoes)
|
||||||
x = self.rng.uniform(0, total)
|
x = self.rng.uniform(0, total)
|
||||||
for gesto, peso, texto in opcoes:
|
for gesto, peso, texto in opcoes:
|
||||||
@@ -467,18 +654,19 @@ class Vida:
|
|||||||
self.anotar('emoji', 'Teve uma ideia e desenhou ' + tema, agora)
|
self.anotar('emoji', 'Teve uma ideia e desenhou ' + tema, agora)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def tocar_reacao(self, agora):
|
def tocar_reacao(self, agora, anotar=True):
|
||||||
try:
|
try:
|
||||||
reacoes = [r for r in self.treinamento.status()['reacoes'] if r.get('ativa', True) and r['feedback'] >= 0]
|
reacoes = [r for r in self.treinamento.status()['reacoes'] if r.get('ativa', True) and r['feedback'] >= 0]
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
if not reacoes:
|
if not reacoes:
|
||||||
return False
|
return False
|
||||||
r = self.rng.choice(reacoes[:40])
|
r = self.rng.choice(reacoes[:40]) if anotar else reacoes[0] # para mostrar: a mais nova
|
||||||
try:
|
try:
|
||||||
self.treinamento.testar(r['id'])
|
self.treinamento.testar(r['id'])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
if anotar:
|
||||||
self.anotar('acao', 'Lembrou da reação "' + r['receita'].get('nome', '?') + '"', agora)
|
self.anotar('acao', 'Lembrou da reação "' + r['receita'].get('nome', '?') + '"', agora)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -486,23 +674,28 @@ class Vida:
|
|||||||
"""Um ato: o que um bichinho com essas necessidades faria agora."""
|
"""Um ato: o que um bichinho com essas necessidades faria agora."""
|
||||||
n = self.necessidades
|
n = self.necessidades
|
||||||
perto = self.alguem_perto()
|
perto = self.alguem_perto()
|
||||||
|
self.porque = ''
|
||||||
if self.dormindo():
|
if self.dormindo():
|
||||||
# de madrugada nunca acorda sozinho (nem com gente se mexendo na cama); de dia, só descansado
|
# de madrugada nunca acorda sozinho (nem com gente se mexendo na cama); de dia, só descansado
|
||||||
if n['energia'] >= DESCANSADO and not self.madrugada(agora):
|
if n['energia'] >= DESCANSADO and not self.madrugada(agora):
|
||||||
self.acordar(agora, 'Acordou descansado')
|
self.acordar(agora, 'Acordou descansado')
|
||||||
|
self.porque = 'energia %d: descansou' % n['energia']
|
||||||
return 'acordar'
|
return 'acordar'
|
||||||
# aprendeu a rotina: acorda do cochilo um pouco antes da hora em que costuma ter gente
|
# aprendeu a rotina: acorda do cochilo um pouco antes da hora em que costuma ter gente
|
||||||
if (self.aprendizado and not self.sono_profundo and not self.madrugada(agora) and n['energia'] >= 50
|
if (self.aprendizado and not self.sono_profundo and not self.madrugada(agora) and n['energia'] >= 50
|
||||||
and self.aprendizado.chega_logo(agora)):
|
and self.aprendizado.chega_logo(agora)):
|
||||||
self.acordar(agora, 'Acordou: daqui a pouco costuma chegar alguém')
|
self.acordar(agora, 'Acordou: daqui a pouco costuma chegar alguém')
|
||||||
self.aprendizado.decidir('acordar', 'Acordou do cochilo antes da hora', self.aprendizado.porque_chegada(agora), agora)
|
self.porque = self.aprendizado.porque_chegada(agora)
|
||||||
|
self.aprendizado.decidir('acordar', 'Acordou do cochilo antes da hora', self.porque, agora)
|
||||||
return 'acordar'
|
return 'acordar'
|
||||||
return None
|
return None
|
||||||
if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO:
|
if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO:
|
||||||
self.dormir(agora, 'Ficou tarde, foi dormir')
|
self.dormir(agora, 'Ficou tarde, foi dormir')
|
||||||
|
self.porque = 'madrugada e ninguém interagiu nos últimos 10 min'
|
||||||
return 'dormir'
|
return 'dormir'
|
||||||
if n['energia'] < SONO:
|
if n['energia'] < SONO:
|
||||||
self.dormir(agora, 'Ficou exausto e foi dormir', profundo=True)
|
self.dormir(agora, 'Ficou exausto e foi dormir', profundo=True)
|
||||||
|
self.porque = 'energia %d: abaixo de %d, sono pesado até descansar' % (n['energia'], SONO)
|
||||||
return 'dormir'
|
return 'dormir'
|
||||||
humor = self.humor()
|
humor = self.humor()
|
||||||
if self.celular_atividade():
|
if self.celular_atividade():
|
||||||
@@ -511,9 +704,11 @@ class Vida:
|
|||||||
folga = agora - self.atividade_em > 90 # um respiro entre uma atividade e outra
|
folga = agora - self.atividade_em > 90 # um respiro entre uma atividade e outra
|
||||||
# cansado: um cochilo na caminha de vez em quando (recupera energia sem o sono pesado)
|
# cansado: um cochilo na caminha de vez em quando (recupera energia sem o sono pesado)
|
||||||
if dia and folga and n['energia'] < COCHILO and self.rng.random() < 0.35:
|
if dia and folga and n['energia'] < COCHILO and self.rng.random() < 0.35:
|
||||||
|
self.porque = 'energia %d: abaixo de %d, um cochilo' % (n['energia'], COCHILO)
|
||||||
return self.comecar_atividade(agora, 'dormir', self.rng.uniform(120, 240))
|
return self.comecar_atividade(agora, 'dormir', self.rng.uniform(120, 240))
|
||||||
# entediado: vai fazer alguma coisa por alguns minutos (pode ser interrompido)
|
# entediado: vai fazer alguma coisa por alguns minutos (pode ser interrompido)
|
||||||
if dia and folga and n['diversao'] < TEDIO and self.rng.random() < 0.75:
|
if dia and folga and n['diversao'] < TEDIO and self.rng.random() < 0.75:
|
||||||
|
self.porque = 'diversão %d: entediado (abaixo de %d)' % (n['diversao'], TEDIO)
|
||||||
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(120, 300))
|
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(120, 300))
|
||||||
# ninguém olhando há um tempo: vai cuidar da vida dele (ler, pintar, música...)
|
# ninguém olhando há um tempo: vai cuidar da vida dele (ler, pintar, música...)
|
||||||
if perto:
|
if perto:
|
||||||
@@ -523,8 +718,9 @@ class Vida:
|
|||||||
if (dia and folga and sozinho and self.aprendizado and n['energia'] < 85
|
if (dia and folga and sozinho and self.aprendizado and n['energia'] < 85
|
||||||
and self.aprendizado.casa_vazia(agora) and self.rng.random() < 0.3):
|
and self.aprendizado.casa_vazia(agora) and self.rng.random() < 0.3):
|
||||||
segundos = self.rng.uniform(600, 1200)
|
segundos = self.rng.uniform(600, 1200)
|
||||||
|
self.porque = self.aprendizado.porque_vazia(agora)
|
||||||
self.aprendizado.decidir('cochilo', 'Tirou um cochilo de %d min para guardar energia' % round(segundos / 60),
|
self.aprendizado.decidir('cochilo', 'Tirou um cochilo de %d min para guardar energia' % round(segundos / 60),
|
||||||
self.aprendizado.porque_vazia(agora), agora)
|
self.porque, agora)
|
||||||
return self.comecar_atividade(agora, 'dormir', segundos)
|
return self.comecar_atividade(agora, 'dormir', segundos)
|
||||||
# alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!"
|
# alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!"
|
||||||
if dia and perto and humor != 'bravo':
|
if dia and perto and humor != 'bravo':
|
||||||
@@ -533,26 +729,41 @@ class Vida:
|
|||||||
self.contar_aprendi(item['id'], agora)
|
self.contar_aprendi(item['id'], agora)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
n['social'] = _limitar(n['social'] + 5)
|
n['social'] = _limitar(n['social'] + 5)
|
||||||
|
self.porque = 'alguém por perto e aprendeu "%s" hoje' % item.get('nome', '?')
|
||||||
return 'aprendi'
|
return 'aprendi'
|
||||||
|
# iniciativa: com gente por perto, pede o que está faltando (tímido pede menos)
|
||||||
|
if (dia and perto and not sozinho and agora - self.pediu_em > INTERVALO_PEDIDO
|
||||||
|
and self.acaso.random() < 0.25 * (1.3 - self.personalidade['timidez'])):
|
||||||
|
pedido = self.pedir_algo(agora)
|
||||||
|
if pedido:
|
||||||
|
return pedido
|
||||||
if dia and folga and sozinho and self.rng.random() < 0.4:
|
if dia and folga and sozinho and self.rng.random() < 0.4:
|
||||||
|
self.porque = 'ninguém por perto há %s: vai cuidar da vida dele' % self.tempo(agora - self.visto_em)
|
||||||
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(60, 180))
|
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(60, 180))
|
||||||
# bem: de vez em quando, uma atividade curtinha por vontade própria
|
# bem: de vez em quando, uma atividade curtinha por vontade própria
|
||||||
if dia and folga and humor in ('feliz', 'tranquilo') and self.rng.random() < 0.1:
|
if dia and folga and humor in ('feliz', 'tranquilo') and self.rng.random() < 0.1:
|
||||||
|
self.porque = 'de bem (%s): vontade própria' % humor
|
||||||
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(40, 90))
|
return self.comecar_atividade(agora, self.escolher_atividade(), self.rng.uniform(40, 90))
|
||||||
# habilidade: inventar uma figurinha nova (raro, com limite por dia e intervalo)
|
# habilidade: inventar uma figurinha nova (raro, com limite por dia e intervalo)
|
||||||
if not self.motivo_sem_emoji(agora) and self.celular_online() and self.rng.random() < 0.08:
|
if not self.motivo_sem_emoji(agora) and self.celular_online() and self.rng.random() < 0.08:
|
||||||
if self.criar_figurinha(agora, humor):
|
if self.criar_figurinha(agora, humor):
|
||||||
|
self.porque = 'teve uma ideia (até %d figurinhas por dia)' % self.config['max_emojis_dia']
|
||||||
return 'figurinha'
|
return 'figurinha'
|
||||||
# às vezes lembra de uma reação da biblioteca
|
# às vezes lembra de uma reação da biblioteca
|
||||||
if self.treinamento is not None and humor in ('feliz', 'tranquilo', 'entediado') and self.rng.random() < 0.15:
|
if self.treinamento is not None and humor in ('feliz', 'tranquilo', 'entediado') and self.rng.random() < 0.15:
|
||||||
if self.tocar_reacao(agora):
|
if self.tocar_reacao(agora):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
n['diversao'] = _limitar(n['diversao'] + 8)
|
n['diversao'] = _limitar(n['diversao'] + 8)
|
||||||
|
self.porque = 'humor %s: lembrou de uma reação da biblioteca' % humor
|
||||||
return 'reacao'
|
return 'reacao'
|
||||||
lista = ATOS['carente_perto'] if humor == 'carente' and perto else ATOS.get(humor, ATOS['tranquilo'])
|
lista = ATOS['carente_perto'] if humor == 'carente' and perto else ATOS.get(humor, ATOS['tranquilo'])
|
||||||
if sozinho: # sem plateia: nada de acenar ou ficar tímido
|
if sozinho: # sem plateia: nada de acenar ou ficar tímido
|
||||||
lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3]
|
lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3]
|
||||||
gesto, texto = self.escolher(lista)
|
gesto, texto = self.escolher(lista)
|
||||||
|
self.porque = 'humor %s%s%s' % (
|
||||||
|
humor, ' com gente por perto' if perto else ', sozinho' if sozinho else '',
|
||||||
|
'; gosto %.1f×, jeito %.1f×' % (self.gosto(gesto), self.jeito(gesto))
|
||||||
|
if (self.gosto(gesto), round(self.jeito(gesto), 1)) != (1.0, 1.0) else '')
|
||||||
if self.aprendizado and perto:
|
if self.aprendizado and perto:
|
||||||
self.aprendizado.escolheu(gesto, agora)
|
self.aprendizado.escolheu(gesto, agora)
|
||||||
self.enfileirar(gesto)
|
self.enfileirar(gesto)
|
||||||
@@ -588,6 +799,28 @@ class Vida:
|
|||||||
self.sono_profundo = False
|
self.sono_profundo = False
|
||||||
self.enfileirar('sono leve')
|
self.enfileirar('sono leve')
|
||||||
self.anotar('sono', 'Acordou antes de descansar', agora)
|
self.anotar('sono', 'Acordou antes de descansar', agora)
|
||||||
|
perto = self.alguem_perto()
|
||||||
|
if perto:
|
||||||
|
if not self.perto_antes:
|
||||||
|
if self.reconhece():
|
||||||
|
self.reencontro_pendente = (agora, agora - self.ultimo_perto) # espera a foto dizer quem é
|
||||||
|
else:
|
||||||
|
self.reencontro(agora, agora - self.ultimo_perto)
|
||||||
|
self.ultimo_perto = agora
|
||||||
|
self.perto_antes = perto
|
||||||
|
pendente = self.reencontro_pendente
|
||||||
|
if pendente and agora - pendente[0] >= ESPERA_NOME:
|
||||||
|
self.reencontro_pendente = None
|
||||||
|
self.reencontro(agora, pendente[1])
|
||||||
|
if self.desistencia and agora >= self.desistencia[0]:
|
||||||
|
_, oque = self.desistencia
|
||||||
|
self.desistencia = None
|
||||||
|
if not self.dormindo():
|
||||||
|
self.enfileirar('bocejar')
|
||||||
|
acao = oque[4:] if oque.startswith('Foi ') else 'a atividade'
|
||||||
|
texto = 'Largou no meio: cansou de ' + acao
|
||||||
|
self.anotar('acao', texto, agora)
|
||||||
|
self.console('vida', texto, 'perdeu a graça (curiosidade %.2f)' % self.personalidade['curiosidade'], agora=agora)
|
||||||
if not self.bloqueado():
|
if not self.bloqueado():
|
||||||
# o celular escolhe os gestos soltos pelo humor; reenvia de tempos em tempos (pode ter reiniciado)
|
# o celular escolhe os gestos soltos pelo humor; reenvia de tempos em tempos (pode ter reiniciado)
|
||||||
zanga = self.zanga()
|
zanga = self.zanga()
|
||||||
@@ -600,8 +833,14 @@ class Vida:
|
|||||||
self.humor_enviado = (humor, agora)
|
self.humor_enviado = (humor, agora)
|
||||||
self.zanga_enviada = zanga
|
self.zanga_enviada = zanga
|
||||||
if agora >= self.proximo_ato:
|
if agora >= self.proximo_ato:
|
||||||
|
antes = self.diario[0]['horario'] if self.diario else None
|
||||||
ato = self.agir(agora)
|
ato = self.agir(agora)
|
||||||
self.aprender_ato(ato, agora)
|
self.aprender_ato(ato, agora)
|
||||||
|
if ato:
|
||||||
|
novo = self.diario[0] if self.diario and self.diario[0]['horario'] != antes else None
|
||||||
|
self.ultima = dict(horario=agora, texto=novo['texto'] if novo else ato, porque=self.porque)
|
||||||
|
self.console('vida', novo['texto'] if novo else ato,
|
||||||
|
(self.porque + ' · ' if self.porque else '') + self.resumo(), agora=agora)
|
||||||
lo, hi = RITMOS[self.config['ritmo']]
|
lo, hi = RITMOS[self.config['ritmo']]
|
||||||
self.proximo_ato = agora + self.rng.uniform(lo, hi)
|
self.proximo_ato = agora + self.rng.uniform(lo, hi)
|
||||||
if ato or agora - self.publicado_em > 30 or humor != self.humor():
|
if ato or agora - self.publicado_em > 30 or humor != self.humor():
|
||||||
|
|||||||
+7
-1
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Terminal, Users, Video } from '@lucide/svelte';
|
||||||
import { onMount, type Component } from 'svelte';
|
import { onMount, type Component } from 'svelte';
|
||||||
import Avisos from './lib/componentes/Avisos.svelte';
|
import Avisos from './lib/componentes/Avisos.svelte';
|
||||||
import EStop from './lib/componentes/EStop.svelte';
|
import EStop from './lib/componentes/EStop.svelte';
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
import Guia from './telas/Guia.svelte';
|
import Guia from './telas/Guia.svelte';
|
||||||
import Criar from './telas/Criar.svelte';
|
import Criar from './telas/Criar.svelte';
|
||||||
import DiaADia from './telas/DiaADia.svelte';
|
import DiaADia from './telas/DiaADia.svelte';
|
||||||
|
import Console from './telas/Console.svelte';
|
||||||
|
import Pessoas from './telas/Pessoas.svelte';
|
||||||
import Ajuda from './lib/componentes/Ajuda.svelte';
|
import Ajuda from './lib/componentes/Ajuda.svelte';
|
||||||
import { TOPICO_DA_TELA } from './lib/guia';
|
import { TOPICO_DA_TELA } from './lib/guia';
|
||||||
|
|
||||||
@@ -35,10 +37,14 @@
|
|||||||
guia: Guia,
|
guia: Guia,
|
||||||
criar: Criar,
|
criar: Criar,
|
||||||
'dia-a-dia': DiaADia,
|
'dia-a-dia': DiaADia,
|
||||||
|
console: Console,
|
||||||
|
pessoas: Pessoas,
|
||||||
};
|
};
|
||||||
const ICONES: Record<string, Component<any>> = {
|
const ICONES: Record<string, Component<any>> = {
|
||||||
'ao-vivo': Video,
|
'ao-vivo': Video,
|
||||||
eventos: Bell,
|
eventos: Bell,
|
||||||
|
console: Terminal,
|
||||||
|
pessoas: Users,
|
||||||
conversa: MessageCircle,
|
conversa: MessageCircle,
|
||||||
reacoes: Smile,
|
reacoes: Smile,
|
||||||
movimento: Hand,
|
movimento: Hand,
|
||||||
|
|||||||
@@ -81,6 +81,14 @@
|
|||||||
vista = 'plano';
|
vista = 'plano';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// horário de dormir: o que a pessoa escolheu e o que ele ajustou pela rotina da casa
|
||||||
|
const sonoTexto = $derived.by(() => {
|
||||||
|
if (!a) return '';
|
||||||
|
if (!(a.config.ajustar_sono ?? true)) return 'Desligado: dorme sempre no horário dos Ajustes';
|
||||||
|
const b = a.sono_base, x = a.sono_auto;
|
||||||
|
if (b && x && (b[0] !== x[0] || b[1] !== x[1])) return `Dorme das ${x[0]}h às ${x[1]}h pela rotina (você escolheu ${b[0]}h às ${b[1]}h)`;
|
||||||
|
return 'Com 5 dias de rotina, atrasa ou adianta até 3 h / 2 h, uma hora por noite';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Grupo
|
<Grupo
|
||||||
@@ -148,6 +156,10 @@
|
|||||||
{:else if a.sono.some((n) => n > 0)}
|
{:else if a.sono.some((n) => n > 0)}
|
||||||
<p class="nota">☾ Em algumas horas ele acordou à toa: ali o sensor precisa de mais movimento para acordá-lo.</p>
|
<p class="nota">☾ Em algumas horas ele acordou à toa: ali o sensor precisa de mais movimento para acordá-lo.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="ajustes-ia">
|
||||||
|
<Interruptor rotulo="Ajustar o horário de dormir pela rotina" checked={a.config.ajustar_sono ?? true} onchange={(l) => configurarAprendizado({ ajustar_sono: l })} />
|
||||||
|
<span>{sonoTexto}</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="bloco" aria-labelledby="ap-rotina">
|
<section class="bloco" aria-labelledby="ap-rotina">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -188,7 +188,7 @@ function abrirEventos() {
|
|||||||
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
||||||
};
|
};
|
||||||
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
||||||
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao']) {
|
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao', 'console', 'pessoas']) {
|
||||||
fonte.addEventListener(tipo, (e) => {
|
fonte.addEventListener(tipo, (e) => {
|
||||||
const dados = JSON.parse((e as MessageEvent).data);
|
const dados = JSON.parse((e as MessageEvent).data);
|
||||||
tratadores[tipo]?.(dados);
|
tratadores[tipo]?.(dados);
|
||||||
|
|||||||
+64
-2
@@ -229,6 +229,64 @@ export const TOPICOS: Record<string, TopicoGuia> = {
|
|||||||
{ emoji: '⭐', nome: 'Favoritos', texto: 'A estrela põe expressões e atividades criadas na grade de favoritos do Ao vivo (e nas teclas 1 a 9).' },
|
{ emoji: '⭐', nome: 'Favoritos', texto: 'A estrela põe expressões e atividades criadas na grade de favoritos do Ao vivo (e nas teclas 1 a 9).' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
console: {
|
||||||
|
titulo: 'Console',
|
||||||
|
emoji: '🖥️',
|
||||||
|
onde: 'Console (menu lateral)',
|
||||||
|
resumo:
|
||||||
|
'Tudo o que ele faz, ao vivo, com o porquê. Em cima, o agora: humor, necessidades, o que está fazendo, quando decide de novo e o jeito dele. Embaixo, cada decisão com o motivo (ex.: "Ficou exausto e foi dormir, porque energia 12: abaixo de 20"). Os filtros escolhem de onde vêm as linhas; "Detalhes" mostra também o que se repete muito (humor reenviado, disparo solto do sensor). Pausar segura a tela para ler.',
|
||||||
|
itens: [
|
||||||
|
{ emoji: '🫀', nome: 'Modo vida', texto: 'As decisões dele: dormir, atividades, gestos, pedidos, reencontros.' },
|
||||||
|
{ emoji: '👀', nome: 'Sentidos', texto: 'Carinho, toque, conversa, presença, som e movimento, com o efeito nas necessidades.' },
|
||||||
|
{ emoji: '📱', nome: 'Celular', texto: 'O que foi mandado ao rosto e o que o rosto fez (dormiu, acordou, começou atividade).' },
|
||||||
|
{ emoji: '🧠', nome: 'IA local / Aprendizado / Ideias', texto: 'Escolhas da IA do S10, decisões tiradas da rotina e as ideias do dia.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pessoas: {
|
||||||
|
titulo: 'Reconhecimento de pessoas',
|
||||||
|
emoji: '🧑🤝🧑',
|
||||||
|
onde: 'Pessoas (menu lateral)',
|
||||||
|
resumo:
|
||||||
|
'Quando o sensor vê alguém, o celular manda uma foto e a placa procura rostos nela (YuNet) e compara com quem ele conhece (SFace). Reconheceu: cumprimenta pelo nome e a saudade é de cada pessoa ("Ana! Senti sua falta!"). Tudo roda e fica na placa; nada vai para a internet.',
|
||||||
|
itens: [
|
||||||
|
{ emoji: '👆', nome: 'Ensinar', texto: 'Em "Rostos sem nome", toque nos rostos da mesma pessoa e escolha quem é (ou crie a pessoa). Com 3 a 5 fotos ele já reconhece bem; marcar uma pessoa também reconhece os rostos antigos parecidos.' },
|
||||||
|
{ emoji: '🔎', nome: 'Procurar nas fotos guardadas', texto: 'Analisa as fotos de eventos que já existem, para cadastrar as pessoas logo de cara.' },
|
||||||
|
{ emoji: '💗', nome: 'Afinidade', texto: 'Carinho, brincadeira ou conversa logo depois de reconhecer alguém aumenta a afinidade com essa pessoa: quem tem bastante ganha coração na festa.' },
|
||||||
|
{ emoji: '🌙', nome: 'Limites', texto: 'Precisa de luz e do rosto virado para a câmera do celular. No escuro, ou de costas, ele cumprimenta sem o nome.' },
|
||||||
|
{ emoji: '🛡️', nome: 'Privacidade', texto: 'Rostos sem nome somem em 7 dias. Apagar uma pessoa apaga as fotos e as impressões dela. Dá para desligar o reconhecimento a qualquer hora.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
jogos: {
|
||||||
|
titulo: 'Jogos com a mão',
|
||||||
|
emoji: '✌️',
|
||||||
|
onde: 'Ao vivo › Jogos com a mão',
|
||||||
|
resumo:
|
||||||
|
'A câmera do celular vem para o painel e o rastreador lê os 21 pontos de cada mão aqui no navegador. O Carinha fala com legenda, faz gestos e mostra a jogada dele nos olhos. Precisa do Ao vivo aberto com a câmera transmitindo e o rastreamento de mãos e de rosto ligado (o botão do cartão liga tudo). Jogar conta como brincadeira para o modo vida.',
|
||||||
|
itens: [
|
||||||
|
{ emoji: '✌️', nome: 'Pedra, papel e tesoura', texto: 'Melhor de três. Ele conta "Pedra... papel... tesoura... já!", mostra ✊ ✋ ou ✌️ nos olhos e lê a sua mão parada. Pedra: mão fechada; papel: mão aberta; tesoura: indicador e médio.' },
|
||||||
|
{ emoji: '🖐️', nome: 'Quantos dedos?', texto: 'Cinco rodadas: nas ímpares o número aparece nos olhos, nas pares ele só fala. Vale somar as duas mãos.' },
|
||||||
|
{ emoji: '🙈', nome: 'Achou!', texto: 'Esconda o rosto com as mãos (ele procura: "Cadê?") e apareça: ele ri. Três vezes.' },
|
||||||
|
{ emoji: '💡', nome: 'Dica', texto: 'Mão inteira na imagem, de frente para a câmera e com luz. Mexer a mão no "Já!" atrapalha: deixe parada um instante.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
jeito: {
|
||||||
|
titulo: 'Jeito de gente',
|
||||||
|
emoji: '🧒',
|
||||||
|
onde: 'Modo vida e rosto',
|
||||||
|
resumo: 'O que faz ele parecer alguém, e não um programa.',
|
||||||
|
itens: [
|
||||||
|
{ emoji: '🤗', nome: 'Saudade', texto: 'A festa depende do tempo fora: 10 min, um aceno; 1 h, "Oi! Você voltou!"; 4 h ou mais, "Senti sua falta!".' },
|
||||||
|
{ emoji: '☀️', nome: 'Bom dia com lembrança', texto: 'No primeiro encontro do dia lembra de ontem: "ontem a gente ficou acordado até as 2h, hein!".' },
|
||||||
|
{ emoji: '🥱', nome: 'Acordar grogue', texto: 'Depois de dormir um tempo, acorda com os olhos pesados, lento, e boceja antes de ficar esperto.' },
|
||||||
|
{ emoji: '💭', nome: 'Sonhar', texto: 'Dormindo, às vezes aparece bem fraco um balão com algo do dia (a bola, as estrelas) e ele sorri.' },
|
||||||
|
{ emoji: '🙈', nome: 'Imperfeição', texto: 'Hesita antes de alguns gestos, erra no meio da atividade e fica sem graça, e às vezes larga uma atividade porque ficou chato.' },
|
||||||
|
{ emoji: '🙋', nome: 'Pedidos', texto: 'Com alguém perto, pede o que falta ("Brinca comigo?", "Me faz um carinho?") ou mostra a figurinha que desenhou. No máximo um pedido a cada 20 min.' },
|
||||||
|
{ emoji: '👁️', nome: 'Contato visual', texto: 'Com o rastreamento de rosto, olha nos olhos alguns segundos, desvia um instante e volta.' },
|
||||||
|
{ emoji: '😊', nome: 'Sorriso de volta', texto: 'Com o Ao vivo aberto e o rastreamento de rosto ligado, se você sorri ele sorri (ou ri) de volta.' },
|
||||||
|
{ emoji: '💡', nome: 'Luz', texto: 'Apagaram a luz: boceja e fica com sono. Acenderam de repente: aperta os olhos.' },
|
||||||
|
{ emoji: '🧬', nome: 'Personalidade', texto: 'Curioso, brincalhão e tímido em medidas próprias. Muda o jeito de tudo e amadurece: carinho deixa menos tímido, brincar deixa mais brincalhão.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
ideias: {
|
ideias: {
|
||||||
titulo: 'Ideias do dia',
|
titulo: 'Ideias do dia',
|
||||||
emoji: '💡',
|
emoji: '💡',
|
||||||
@@ -279,6 +337,10 @@ export const TOPICOS: Record<string, TopicoGuia> = {
|
|||||||
onde: 'Eventos › Modo presença',
|
onde: 'Eventos › Modo presença',
|
||||||
resumo:
|
resumo:
|
||||||
'O PIR liga a câmera do celular quando alguém aparece e acorda o Carinha. Sem ninguém pelo tempo escolhido, a câmera desliga, ele dorme e fica em silêncio. Na madrugada silenciosa o PIR não acorda.',
|
'O PIR liga a câmera do celular quando alguém aparece e acorda o Carinha. Sem ninguém pelo tempo escolhido, a câmera desliga, ele dorme e fica em silêncio. Na madrugada silenciosa o PIR não acorda.',
|
||||||
|
itens: [
|
||||||
|
{ emoji: '🎚️', nome: 'Sensibilidade do sensor', texto: 'O HC-SR505 não tem botão de ajuste e dispara por pouca coisa. A bolinha do Ao vivo sempre mostra o sensor, mas só conta como presença (evento, foto, acordar, câmera) o que se confirma. Baixa: 3 disparos em 90 s ou alguém se mexendo por 20 s. Média (padrão): 2 disparos em 1 min ou 12 s. Alta: qualquer disparo.' },
|
||||||
|
{ emoji: '🩹', nome: 'Ajuste físico', texto: 'Para cortar ainda mais, cubra parte da lente branca com fita (diminui o campo de visão) ou vire o sensor para longe de janelas, ar-condicionado e da cama.' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
tela: {
|
tela: {
|
||||||
titulo: 'Tela e personalidade',
|
titulo: 'Tela e personalidade',
|
||||||
@@ -367,13 +429,13 @@ export const TOPICOS: Record<string, TopicoGuia> = {
|
|||||||
|
|
||||||
/** Ordem do tutorial (página Guia). */
|
/** Ordem do tutorial (página Guia). */
|
||||||
export const ORDEM_GUIA = [
|
export const ORDEM_GUIA = [
|
||||||
'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'ideias', 'aparencia',
|
'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'jeito', 'jogos', 'console', 'pessoas', 'criar', 'ideias', 'aparencia',
|
||||||
'personalidade', 'acontecimentos', 'ligacoes', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
|
'personalidade', 'acontecimentos', 'ligacoes', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
|
||||||
'reacoes', 'conversa', 'movimento', 'atalhos',
|
'reacoes', 'conversa', 'movimento', 'atalhos',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Tópico de cada tela, para o ⓘ do topo. */
|
/** Tópico de cada tela, para o ⓘ do topo. */
|
||||||
export const TOPICO_DA_TELA: Record<string, string> = {
|
export const TOPICO_DA_TELA: Record<string, string> = {
|
||||||
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
|
'ao-vivo': 'inicio', console: 'console', pessoas: 'pessoas', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
|
||||||
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado',
|
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface EventoHistorico {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusHistorico {
|
export interface StatusHistorico {
|
||||||
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number };
|
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number; sensibilidade_pir?: 'alta' | 'media' | 'baixa' };
|
||||||
presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number };
|
presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number };
|
||||||
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||||
eventos: EventoHistorico[];
|
eventos: EventoHistorico[];
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ export interface AlvoRastreio {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
atividade?: boolean;
|
atividade?: boolean;
|
||||||
|
/** 0 a 1: o quanto a pessoa está sorrindo (só rosto; o celular sorri de volta) */
|
||||||
|
sorriso?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
|
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -11,6 +11,7 @@ interface Resultado {
|
|||||||
hands: Ponto[][];
|
hands: Ponto[][];
|
||||||
ms: number;
|
ms: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
sorriso?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12],
|
const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12],
|
||||||
@@ -26,6 +27,8 @@ export class Rastreamento {
|
|||||||
private ativo = false;
|
private ativo = false;
|
||||||
private config: AppConfig = {};
|
private config: AppConfig = {};
|
||||||
private resultado: Resultado | null = null;
|
private resultado: Resultado | null = null;
|
||||||
|
/** Quem quer cada leitura (os jogos com a mão). */
|
||||||
|
private ouvintes = new Set<(r: { faces: Face[]; maos: Ponto[][] }) => void>();
|
||||||
private alvo: AlvoRastreio | null = null;
|
private alvo: AlvoRastreio | null = null;
|
||||||
private alvoEm = 0;
|
private alvoEm = 0;
|
||||||
private em = 0;
|
private em = 0;
|
||||||
@@ -48,6 +51,11 @@ export class Rastreamento {
|
|||||||
addEventListener('resize', this.desenhar);
|
addEventListener('resize', this.desenhar);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aoResultado(fn: (r: { faces: Face[]; maos: Ponto[][] }) => void) {
|
||||||
|
this.ouvintes.add(fn);
|
||||||
|
return () => this.ouvintes.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
private limpar() {
|
private limpar() {
|
||||||
this.ctx.clearRect(0, 0, this.tela.width, this.tela.height);
|
this.ctx.clearRect(0, 0, this.tela.width, this.tela.height);
|
||||||
}
|
}
|
||||||
@@ -142,10 +150,12 @@ export class Rastreamento {
|
|||||||
}
|
}
|
||||||
const r = data as Resultado;
|
const r = data as Resultado;
|
||||||
this.resultado = r;
|
this.resultado = r;
|
||||||
|
this.ouvintes.forEach((fn) => fn({ faces: r.faces, maos: r.hands }));
|
||||||
this.em = r.timestamp;
|
this.em = r.timestamp;
|
||||||
const anterior = this.alvo;
|
const anterior = this.alvo;
|
||||||
const alvo = escolherAlvo(r.faces, r.hands, anterior);
|
const alvo = escolherAlvo(r.faces, r.hands, anterior);
|
||||||
if (alvo) alvo.atividade = !!anterior && alvo.tipo === anterior.tipo && Math.hypot(alvo.x - anterior.x, alvo.y - anterior.y) > 0.008;
|
if (alvo) alvo.atividade = !!anterior && alvo.tipo === anterior.tipo && Math.hypot(alvo.x - anterior.x, alvo.y - anterior.y) > 0.008;
|
||||||
|
if (alvo && alvo.tipo === 'rosto' && typeof r.sorriso === 'number') alvo.sorriso = Math.round(r.sorriso * 100) / 100;
|
||||||
this.alvo = alvo;
|
this.alvo = alvo;
|
||||||
this.alvoEm = r.timestamp;
|
this.alvoEm = r.timestamp;
|
||||||
this.intervalo = Math.max(50, Math.min(200, r.ms * 1.2));
|
this.intervalo = Math.max(50, Math.min(200, r.ms * 1.2));
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
|
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
|
||||||
|
|
||||||
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia';
|
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia' | 'console' | 'pessoas';
|
||||||
|
|
||||||
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||||
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
|
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
|
||||||
|
{ id: 'console', titulo: 'Console', atalho: 'g k' },
|
||||||
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
|
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
|
||||||
|
{ id: 'pessoas', titulo: 'Pessoas', atalho: 'g p' },
|
||||||
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
|
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
|
||||||
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
|
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
|
||||||
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
|
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
|
||||||
|
|||||||
+12
-2
@@ -104,7 +104,15 @@ export interface Vida {
|
|||||||
sono_profundo?: boolean;
|
sono_profundo?: boolean;
|
||||||
/** '' quando pode inventar figurinha agora; senão, o motivo. */
|
/** '' quando pode inventar figurinha agora; senão, o motivo. */
|
||||||
pode_criar: string;
|
pode_criar: string;
|
||||||
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade' | 'aprendizado' | 'aprendi'; texto: string }[];
|
diario: { horario: number; tipo: 'acao' | 'sono' | 'emoji' | 'cuidado' | 'vida' | 'atividade' | 'aprendizado' | 'aprendi' | 'social' | 'pedido' | 'ideia'; texto: string }[];
|
||||||
|
/** Traços de 0 a 1 (curiosidade, brincadeira, timidez): mudam o jeito e amadurecem com o convívio. */
|
||||||
|
personalidade?: Record<string, number>;
|
||||||
|
/** Motivo da última decisão do modo vida. */
|
||||||
|
porque?: string;
|
||||||
|
/** A última decisão de fato (o que fez e por quê). */
|
||||||
|
ultima?: { horario: number; texto: string; porque: string } | null;
|
||||||
|
/** Segundos até a próxima decisão. */
|
||||||
|
proximo_ato_em?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** O que ele aprendeu com o dia a dia (aprendizado.py). */
|
/** O que ele aprendeu com o dia a dia (aprendizado.py). */
|
||||||
@@ -120,7 +128,9 @@ export interface PlanoSemana {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Aprendizado {
|
export interface Aprendizado {
|
||||||
config: { ligado: boolean; analise_ia: boolean; ia: 'gemini' | 'local' | 's10' };
|
config: { ligado: boolean; analise_ia: boolean; ia: 'gemini' | 'local' | 's10'; ajustar_sono?: boolean };
|
||||||
|
sono_base?: [number, number] | null;
|
||||||
|
sono_auto?: [number, number] | null;
|
||||||
/** 7 dias × 24 horas: chance de ter gente por perto (null = sem dados). */
|
/** 7 dias × 24 horas: chance de ter gente por perto (null = sem dados). */
|
||||||
semana: (number | null)[][];
|
semana: (number | null)[][];
|
||||||
historico_dias: number;
|
historico_dias: number;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||||
import VidaCartao from '../lib/componentes/VidaCartao.svelte';
|
import VidaCartao from '../lib/componentes/VidaCartao.svelte';
|
||||||
|
import QuemE from '../lib/componentes/QuemE.svelte';
|
||||||
|
import Jogos from '../lib/componentes/Jogos.svelte';
|
||||||
import AprendizadoCartao from '../lib/componentes/AprendizadoCartao.svelte';
|
import AprendizadoCartao from '../lib/componentes/AprendizadoCartao.svelte';
|
||||||
import { estado } from '../lib/estado.svelte';
|
import { estado } from '../lib/estado.svelte';
|
||||||
import { Rastreamento } from '../lib/rastreamento.svelte';
|
import { Rastreamento } from '../lib/rastreamento.svelte';
|
||||||
@@ -128,6 +130,7 @@ const pirAtivo = $derived(
|
|||||||
<!-- No PC: à direita Vida e o que ele aprendeu; embaixo da câmera, o resto.
|
<!-- No PC: à direita Vida e o que ele aprendeu; embaixo da câmera, o resto.
|
||||||
No celular as duas colunas se desfazem (display: contents) e a ordem vem do `order`. -->
|
No celular as duas colunas se desfazem (display: contents) e a ordem vem do `order`. -->
|
||||||
<div class="coluna direita">
|
<div class="coluna direita">
|
||||||
|
<div class="c-quem"><QuemE minimo={3} /></div>
|
||||||
<div class="c-vida"><VidaCartao /></div>
|
<div class="c-vida"><VidaCartao /></div>
|
||||||
{#if estado.vida?.config.ligado}<div class="c-aprendizado"><AprendizadoCartao /></div>{/if}
|
{#if estado.vida?.config.ligado}<div class="c-aprendizado"><AprendizadoCartao /></div>{/if}
|
||||||
|
|
||||||
@@ -165,6 +168,7 @@ const pirAtivo = $derived(
|
|||||||
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes/camera" />
|
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes/camera" />
|
||||||
</Grupo></div>
|
</Grupo></div>
|
||||||
|
|
||||||
|
<div class="c-jogos"><Jogos {rastreio} /></div>
|
||||||
<div class="c-gestos"><Grupo titulo="Gestos" ajuda="gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
|
<div class="c-gestos"><Grupo titulo="Gestos" ajuda="gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
|
||||||
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
|
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
|
||||||
<Gestos />
|
<Gestos />
|
||||||
@@ -394,10 +398,13 @@ const pirAtivo = $derived(
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.palco { order: 0; }
|
.palco { order: 0; }
|
||||||
|
.c-quem { order: 1; }
|
||||||
|
.c-quem:empty { display: none; }
|
||||||
.c-vida { order: 1; }
|
.c-vida { order: 1; }
|
||||||
.c-aprendizado { order: 2; }
|
.c-aprendizado { order: 2; }
|
||||||
.c-celular { order: 3; }
|
.c-celular { order: 3; }
|
||||||
.c-camera { order: 4; }
|
.c-camera { order: 4; }
|
||||||
|
.c-jogos { order: 5; }
|
||||||
.c-gestos { order: 5; }
|
.c-gestos { order: 5; }
|
||||||
.c-aparencia { order: 6; }
|
.c-aparencia { order: 6; }
|
||||||
.c-personalidade { order: 7; }
|
.c-personalidade { order: 7; }
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -56,6 +56,12 @@
|
|||||||
aberta = ev;
|
aberta = ev;
|
||||||
modal = true;
|
modal = true;
|
||||||
}
|
}
|
||||||
|
// O HC-SR505 não tem ajuste físico: a sensibilidade é quantos disparos confirmam alguém de verdade
|
||||||
|
const SENS_PIR = [
|
||||||
|
{ valor: 'baixa', nome: 'Baixa', descricao: '3 disparos em 90 s ou alguém se mexendo por 20 s' },
|
||||||
|
{ valor: 'media', nome: 'Média', descricao: '2 disparos em 1 min ou alguém se mexendo por 12 s' },
|
||||||
|
{ valor: 'alta', nome: 'Alta', descricao: 'qualquer disparo conta como presença' },
|
||||||
|
] as const;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="eventos">
|
<div class="eventos">
|
||||||
@@ -132,6 +138,21 @@
|
|||||||
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por">
|
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por">
|
||||||
<Slider rotulo="Desligar câmera e dormir depois de" valor={st?.config.ausencia ?? 5} min={1} max={60} passo={1} unidade=" min" onchange={(v) => historico.configurar({ ausencia: v })} />
|
<Slider rotulo="Desligar câmera e dormir depois de" valor={st?.config.ausencia ?? 5} min={1} max={60} passo={1} unidade=" min" onchange={(v) => historico.configurar({ ausencia: v })} />
|
||||||
</Linha>
|
</Linha>
|
||||||
|
<Linha icone={Footprints} cor="#ff9500" rotulo="Sensibilidade do sensor" detalhe={SENS_PIR.find((s) => s.valor === (st?.config.sensibilidade_pir ?? 'media'))?.descricao ?? ''}>
|
||||||
|
{#snippet acessorio()}
|
||||||
|
<span class="segmentos" role="radiogroup" aria-label="Sensibilidade do sensor de presença">
|
||||||
|
{#each SENS_PIR as s (s.valor)}
|
||||||
|
<button
|
||||||
|
role="radio"
|
||||||
|
aria-checked={(st?.config.sensibilidade_pir ?? 'media') === s.valor}
|
||||||
|
class:ativo={(st?.config.sensibilidade_pir ?? 'media') === s.valor}
|
||||||
|
disabled={!st}
|
||||||
|
onclick={() => historico.configurar({ sensibilidade_pir: s.valor })}>{s.nome}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</Linha>
|
||||||
</Grupo>
|
</Grupo>
|
||||||
|
|
||||||
<Grupo titulo="O que registrar" ajuda="eventos">
|
<Grupo titulo="O que registrar" ajuda="eventos">
|
||||||
@@ -306,4 +327,25 @@
|
|||||||
top: calc(var(--topo-altura) + var(--e-5));
|
top: calc(var(--topo-altura) + var(--e-5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.segmentos {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: var(--raio-sm);
|
||||||
|
background: var(--superficie-3);
|
||||||
|
}
|
||||||
|
.segmentos button {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 var(--e-2);
|
||||||
|
border: 0;
|
||||||
|
border-radius: calc(var(--raio-sm) - 2px);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--texto-2);
|
||||||
|
font: 500 var(--t-xs) var(--fonte);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.segmentos button.ativo {
|
||||||
|
background: var(--superficie);
|
||||||
|
color: var(--texto);
|
||||||
|
box-shadow: var(--sombra);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user