Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d410dbf24 | ||
|
|
fd6a14054d | ||
|
|
784042335c | ||
|
|
cf54a93b53 | ||
|
|
499a4ae479 | ||
|
|
6029c79645 | ||
|
|
4b9e805801 | ||
|
|
1445ffdcde | ||
|
|
788215b49e | ||
|
|
98c9e8803e |
@@ -23,3 +23,6 @@ api-privada.tmp
|
||||
|
||||
ia-config.json
|
||||
seguranca.json
|
||||
historico.db
|
||||
historico.json
|
||||
fotos/
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="br.carinha.rosto"
|
||||
android:versionCode="10"
|
||||
android:versionName="0.8.2">
|
||||
android:versionCode="11"
|
||||
android:versionName="0.8.3">
|
||||
|
||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||
|
||||
|
||||
@@ -93,11 +93,32 @@ addEventListener("resize", ajustar);
|
||||
const PRETO = [6, 8, 14];
|
||||
const CIANO = [92, 214, 255];
|
||||
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
|
||||
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
|
||||
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
|
||||
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
|
||||
function cuidarDaTela() {
|
||||
const agora = performance.now();
|
||||
if (!r.dormindo) SONO.desde = 0;
|
||||
else if (!SONO.desde) SONO.desde = agora;
|
||||
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
|
||||
if (apagar !== SONO.apagada) {
|
||||
SONO.apagada = apagar;
|
||||
window.carinhaTelaApagada = apagar;
|
||||
document.body.style.visibility = apagar ? "hidden" : "";
|
||||
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
|
||||
}
|
||||
}
|
||||
setInterval(cuidarDaTela, 1000);
|
||||
const CIANO_E = [150, 232, 255];
|
||||
const VERM = [255, 78, 78];
|
||||
const ROSA = [255, 112, 176];
|
||||
const AMARELO = [255, 218, 96];
|
||||
const AZUL = [130, 170, 255];
|
||||
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
|
||||
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
|
||||
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
|
||||
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
|
||||
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
|
||||
const VERDE = [120, 255, 160];
|
||||
const BRANCO = [245, 250, 255];
|
||||
|
||||
@@ -302,7 +323,7 @@ function sequencias() {
|
||||
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
|
||||
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
|
||||
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
|
||||
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09],
|
||||
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
|
||||
[{ dy: -10 }, 0.5],
|
||||
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
|
||||
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
|
||||
@@ -311,7 +332,7 @@ function sequencias() {
|
||||
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
|
||||
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
|
||||
[{ forma: "aberto", cor: CIANO }, 0.3]];
|
||||
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
||||
const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
||||
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
||||
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
||||
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
|
||||
@@ -344,6 +365,7 @@ function proximo() {
|
||||
const [alvo, dur] = r.fila[0];
|
||||
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
|
||||
r.cor_ini = r.cor.slice();
|
||||
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
|
||||
if ("forma" in alvo) r.forma = alvo.forma;
|
||||
if ("forma2" in alvo) r.forma2 = alvo.forma2;
|
||||
if ("boca" in alvo) r.boca = alvo.boca;
|
||||
@@ -378,7 +400,7 @@ function passo(dt) { // avanca a fila; true se ha gesto em
|
||||
function repouso(t) { // pose calma: olha devagar, "respira"
|
||||
if (r.dormindo) {
|
||||
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
||||
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2));
|
||||
r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
|
||||
r.ab = 0; r.ab2 = 0;
|
||||
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
||||
r.dx = 0;
|
||||
@@ -536,7 +558,7 @@ function pose(nome) {
|
||||
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
|
||||
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
|
||||
}
|
||||
if ("cor" in alvo) r.cor = alvo.cor.slice();
|
||||
if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
|
||||
congelado = true;
|
||||
desenhar();
|
||||
return true;
|
||||
@@ -591,6 +613,7 @@ if (MODO_PAINEL) {
|
||||
const m = e.data;
|
||||
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
|
||||
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
|
||||
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
|
||||
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||
});
|
||||
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
||||
@@ -710,6 +733,16 @@ function checarSilencio(agora) {
|
||||
journal do carinha-web (so quando o microfone esta ativo). */
|
||||
function relatarSom(evento, n) {
|
||||
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||
if (evento !== "adormeceu") registrarEvento("som", evento, n);
|
||||
}
|
||||
|
||||
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
|
||||
function registrarEvento(tipo, evento, nivel) {
|
||||
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||
try {
|
||||
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ presenca (movimento)
|
||||
@@ -798,6 +831,7 @@ function movimento(vel, cx, cy) {
|
||||
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
|
||||
function relatarMov(evento, n) {
|
||||
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||
registrarEvento("movimento", evento, n);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- camera oculta (sensor)
|
||||
@@ -930,6 +964,8 @@ let configRecebida = null;
|
||||
function configurarApp(c) {
|
||||
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
|
||||
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
|
||||
SONO.apagar = c.apagarTela !== false;
|
||||
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
|
||||
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
|
||||
AUTO = c.auto;
|
||||
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
||||
@@ -960,6 +996,7 @@ async function sincronizarApp() {
|
||||
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
|
||||
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
||||
brilho: window.carinhaBrilho,
|
||||
tela_apagada: !!window.carinhaTelaApagada,
|
||||
bateria: window.carinhaBateria,
|
||||
aparelho: window.carinhaAparelho,
|
||||
video: window.carinhaVideo,
|
||||
@@ -1009,7 +1046,7 @@ const SONS = {
|
||||
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
||||
os sons dos gestos ficam quietos para não embolar. */
|
||||
function falar(texto) {
|
||||
if (!AUDIO.ligado || !window.VozRobo) return;
|
||||
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
|
||||
const c = contextoAudio();
|
||||
if (!c) return;
|
||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||
@@ -1021,6 +1058,7 @@ function falar(texto) {
|
||||
|
||||
function tocarSom(gesto) {
|
||||
if (!AUDIO.ligado) return false;
|
||||
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
|
||||
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
||||
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
||||
const f = SONS[gesto];
|
||||
|
||||
@@ -152,6 +152,15 @@ window.rtcReceber = packet => {
|
||||
});
|
||||
};
|
||||
window.rtcParar = () => { allowed = false; parar(); sid = null; };
|
||||
/* Foto do evento para o histórico, tirada do vídeo ao vivo (câmera de movimento parada). */
|
||||
window.rtcFoto = () => {
|
||||
if (!stream || video.readyState < 2 || !video.videoWidth) return;
|
||||
const escala = Math.min(1, 1280 / video.videoWidth);
|
||||
const tela = document.createElement('canvas');
|
||||
tela.width = Math.round(video.videoWidth * escala); tela.height = Math.round(video.videoHeight * escala);
|
||||
tela.getContext('2d').drawImage(video, 0, 0, tela.width, tela.height);
|
||||
CarinhaRTC.foto(tela.toDataURL('image/jpeg', 0.82));
|
||||
};
|
||||
setInterval(() => {
|
||||
if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return;
|
||||
lastMotionFrame=video.currentTime;
|
||||
|
||||
@@ -28,6 +28,15 @@ public final class ControleRemoto {
|
||||
private volatile ScheduledExecutorService worker;
|
||||
private long consultarEm;
|
||||
private long quadroEm;
|
||||
// Foto do evento (histórico): o servidor pede, o app tira e envia.
|
||||
private volatile long fotoPedida = 0, fotoFeita = 0, fotoId = 0, pedidoEm = 0;
|
||||
private volatile byte[] fotoJpeg = null;
|
||||
/** Id do evento que está esperando foto (0 = nenhum). */
|
||||
public long fotoPendente() { return fotoJpeg == null ? fotoPedida : 0; }
|
||||
/** Foto vinda do vídeo ao vivo (quando a câmera de movimento está desligada). */
|
||||
public void fotoRTC(byte[] jpeg) {
|
||||
if (fotoPedida > 0 && fotoJpeg == null && jpeg != null && jpeg.length > 0) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; }
|
||||
}
|
||||
private long logEm;
|
||||
private final Runnable mudanca;
|
||||
public interface RTCListener { boolean receber(JSONObject pacote); }
|
||||
@@ -118,6 +127,7 @@ public final class ControleRemoto {
|
||||
mudanca.run();
|
||||
}
|
||||
if (videoRTC()) rtcTick(base);
|
||||
fotoTick(base);
|
||||
byte[] jpeg = pendente;
|
||||
pendente = null;
|
||||
if (jpeg != null && preview()) {
|
||||
@@ -147,10 +157,13 @@ public final class ControleRemoto {
|
||||
return c;
|
||||
}
|
||||
public void quadro(Image image, int rotacao) {
|
||||
if (videoRTC()) return;
|
||||
boolean querFoto = fotoPedida > 0 && fotoJpeg == null;
|
||||
long agora = System.currentTimeMillis();
|
||||
if (!querFoto) {
|
||||
if (videoRTC()) return;
|
||||
if (!preview() || pendente != null || agora < quadroEm) return;
|
||||
quadroEm = agora + 200;
|
||||
}
|
||||
try {
|
||||
Image.Plane[] p = image.getPlanes();
|
||||
ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()};
|
||||
@@ -171,10 +184,44 @@ public final class ControleRemoto {
|
||||
if (rotated != b) rotated.recycle();
|
||||
b.recycle();
|
||||
}
|
||||
if (preview()) pendente = jpeg;
|
||||
if (querFoto) { fotoId = fotoPedida; fotoJpeg = jpeg; fotoPedida = 0; }
|
||||
else if (preview()) pendente = jpeg;
|
||||
} catch (Exception e) { Log.w("CarinhaPC", "quadro descartado: " + e); }
|
||||
}
|
||||
|
||||
/** Consulta o pedido de foto (1x/s) e envia a foto pronta para o histórico. */
|
||||
private void fotoTick(String base) throws Exception {
|
||||
byte[] jpeg = fotoJpeg;
|
||||
if (jpeg != null) {
|
||||
long id = fotoId;
|
||||
fotoJpeg = null;
|
||||
HttpURLConnection c = abrir(base + "/historico/foto?id=" + id);
|
||||
try {
|
||||
c.setRequestMethod("POST"); c.setDoOutput(true);
|
||||
c.setRequestProperty("Content-Type", "image/jpeg");
|
||||
c.setFixedLengthStreamingMode(jpeg.length);
|
||||
try (java.io.OutputStream out = c.getOutputStream()) { out.write(jpeg); }
|
||||
int code = c.getResponseCode();
|
||||
Log.i("CarinhaPC", "foto do evento " + id + ": HTTP " + code);
|
||||
fotoFeita = id;
|
||||
} finally { c.disconnect(); }
|
||||
}
|
||||
long agora = System.currentTimeMillis();
|
||||
if (agora < pedidoEm) return;
|
||||
pedidoEm = agora + 1000;
|
||||
HttpURLConnection c = abrir(base + "/historico/pedido");
|
||||
try {
|
||||
if (c.getResponseCode() != 200) return;
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (InputStream in = c.getInputStream()) {
|
||||
byte[] buffer = new byte[512]; int n;
|
||||
while ((n = in.read(buffer)) != -1 && bytes.size() < 4096) bytes.write(buffer, 0, n);
|
||||
}
|
||||
long id = new JSONObject(bytes.toString("UTF-8")).optLong("id", 0);
|
||||
if (id > 0 && id != fotoFeita && id != fotoId && fotoJpeg == null) fotoPedida = id;
|
||||
} finally { c.disconnect(); }
|
||||
}
|
||||
|
||||
private void rtcTick(String base) throws Exception {
|
||||
String json;
|
||||
while ((json = sinais.poll()) != null) {
|
||||
|
||||
@@ -198,6 +198,8 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
atualizarSensores();
|
||||
atualizarBrilho();
|
||||
if (remoto.fotoPendente() > 0 && rtcAtivo)
|
||||
rtcWeb.evaluateJavascript("window.rtcFoto&&window.rtcFoto()", null);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1079,7 +1081,7 @@ public class MainActivity extends Activity {
|
||||
web.evaluateJavascript("window.carinhaBateria={nivel:" + percent + ",carregando:" + charging + "}", null);
|
||||
}
|
||||
}
|
||||
web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length)}):null",
|
||||
web.evaluateJavascript("window.eilik?({dormindo:window.eilik.face.dormindo,comando:window.carinhaUltimoComando||0,ocupado:(Date.now()-(window.carinhaUltimoComando||0)<3000)&&!!(window.eilik.face.kf||window.eilik.face.fila.length),apagada:!!window.carinhaTelaApagada}):null",
|
||||
new ValueCallback<String>() {
|
||||
@Override public void onReceiveValue(String valor) {
|
||||
if (!emPrimeiroPlano) return;
|
||||
@@ -1091,6 +1093,7 @@ public class MainActivity extends Activity {
|
||||
float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(),
|
||||
System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(),
|
||||
estado.optBoolean("ocupado"), estado.optBoolean("dormindo"));
|
||||
if (estado.optBoolean("apagada")) nivel = 0f; // tela preta + brilho mínimo
|
||||
WindowManager.LayoutParams attr = getWindow().getAttributes();
|
||||
if (Math.abs(attr.screenBrightness - nivel) > 0.005f) {
|
||||
attr.screenBrightness = nivel;
|
||||
@@ -1104,6 +1107,14 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
|
||||
private final class PonteRTC {
|
||||
/** Foto do vídeo ao vivo (data:image/jpeg;base64,...) para o histórico. */
|
||||
@JavascriptInterface public void foto(final String dataUrl) {
|
||||
try {
|
||||
int virgula = dataUrl == null ? -1 : dataUrl.indexOf(',');
|
||||
if (virgula < 0 || dataUrl.length() > 4_000_000) return;
|
||||
remoto.fotoRTC(android.util.Base64.decode(dataUrl.substring(virgula + 1), android.util.Base64.DEFAULT));
|
||||
} catch (Exception e) { Log.w("CarinhaPC", "foto do video falhou: " + e); }
|
||||
}
|
||||
@JavascriptInterface public void rastrear(final String json) {
|
||||
if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return;
|
||||
try {
|
||||
|
||||
+43
-5
@@ -93,11 +93,32 @@ addEventListener("resize", ajustar);
|
||||
const PRETO = [6, 8, 14];
|
||||
const CIANO = [92, 214, 255];
|
||||
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
|
||||
const SONO = { apagar: true, minutos: 5, desde: 0, apagada: false };
|
||||
/* Dormindo há tempo demais: apaga a tela (preto total) e o app baixa o brilho ao mínimo.
|
||||
Qualquer coisa que acorde o rosto (som, movimento, toque, comando, PIR) acende de novo. */
|
||||
function cuidarDaTela() {
|
||||
const agora = performance.now();
|
||||
if (!r.dormindo) SONO.desde = 0;
|
||||
else if (!SONO.desde) SONO.desde = agora;
|
||||
const apagar = SONO.apagar && r.dormindo && SONO.desde && agora - SONO.desde > SONO.minutos * 60000;
|
||||
if (apagar !== SONO.apagada) {
|
||||
SONO.apagada = apagar;
|
||||
window.carinhaTelaApagada = apagar;
|
||||
document.body.style.visibility = apagar ? "hidden" : "";
|
||||
console.log(apagar ? "tela apagada (dormindo ha " + SONO.minutos + " min)" : "tela acesa");
|
||||
}
|
||||
}
|
||||
setInterval(cuidarDaTela, 1000);
|
||||
const CIANO_E = [150, 232, 255];
|
||||
const VERM = [255, 78, 78];
|
||||
const ROSA = [255, 112, 176];
|
||||
const AMARELO = [255, 218, 96];
|
||||
const AZUL = [130, 170, 255];
|
||||
// Tons derivados da cor escolhida no painel (CIANO é trocada pela cor escolhida em tempo real):
|
||||
// "claro" para susto/surpresa, "escuro" para tristeza e sono. Nada de azul fixo.
|
||||
const corClara = (k) => CIANO.map((v) => v + (255 - v) * (k === undefined ? 0.4 : k));
|
||||
const corEscura = (k) => CIANO.map((v) => v * (k === undefined ? 0.62 : k));
|
||||
function resolverCor(c) { return c === "claro" ? corClara() : c === "escuro" ? corEscura() : c === "escolhida" ? CIANO.slice() : c; }
|
||||
const VERDE = [120, 255, 160];
|
||||
const BRANCO = [245, 250, 255];
|
||||
|
||||
@@ -302,7 +323,7 @@ function sequencias() {
|
||||
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
|
||||
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
|
||||
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
|
||||
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09],
|
||||
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: "claro", boca: "aberta" }, 0.09],
|
||||
[{ dy: -10 }, 0.5],
|
||||
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
|
||||
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
|
||||
@@ -311,7 +332,7 @@ function sequencias() {
|
||||
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
|
||||
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
|
||||
[{ forma: "aberto", cor: CIANO }, 0.3]];
|
||||
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
||||
const triste = [[{ forma: "triste", cor: "escuro", dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
|
||||
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
||||
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
||||
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
|
||||
@@ -344,6 +365,7 @@ function proximo() {
|
||||
const [alvo, dur] = r.fila[0];
|
||||
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
|
||||
r.cor_ini = r.cor.slice();
|
||||
if ("cor" in alvo) alvo.cor = resolverCor(alvo.cor); // "claro"/"escuro" viram tons da cor escolhida
|
||||
if ("forma" in alvo) r.forma = alvo.forma;
|
||||
if ("forma2" in alvo) r.forma2 = alvo.forma2;
|
||||
if ("boca" in alvo) r.boca = alvo.boca;
|
||||
@@ -378,7 +400,7 @@ function passo(dt) { // avanca a fila; true se ha gesto em
|
||||
function repouso(t) { // pose calma: olha devagar, "respira"
|
||||
if (r.dormindo) {
|
||||
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
|
||||
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2));
|
||||
r.cor = lerpCor(corEscura(0.75), corEscura(0.35), 0.5 + 0.5 * Math.sin(t * 1.2)); // respira na cor escolhida
|
||||
r.ab = 0; r.ab2 = 0;
|
||||
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
|
||||
r.dx = 0;
|
||||
@@ -536,7 +558,7 @@ function pose(nome) {
|
||||
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
|
||||
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
|
||||
}
|
||||
if ("cor" in alvo) r.cor = alvo.cor.slice();
|
||||
if ("cor" in alvo) r.cor = resolverCor(alvo.cor).slice();
|
||||
congelado = true;
|
||||
desenhar();
|
||||
return true;
|
||||
@@ -591,6 +613,7 @@ if (MODO_PAINEL) {
|
||||
const m = e.data;
|
||||
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
|
||||
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
|
||||
else if (m.tipo === 'sono' && !!m.dormindo !== r.dormindo) trata(m.dormindo ? 'dormir' : 'acordar');
|
||||
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||
});
|
||||
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
||||
@@ -710,6 +733,16 @@ function checarSilencio(agora) {
|
||||
journal do carinha-web (so quando o microfone esta ativo). */
|
||||
function relatarSom(evento, n) {
|
||||
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||
if (evento !== "adormeceu") registrarEvento("som", evento, n);
|
||||
}
|
||||
|
||||
/* Histórico de detecções no servidor (o app tira a foto do evento). Só o celular registra. */
|
||||
function registrarEvento(tipo, evento, nivel) {
|
||||
if (new URLSearchParams(location.search).get("aparelho") !== "1") return;
|
||||
try {
|
||||
fetch("/historico/evento", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tipo, evento, nivel }) }).catch(() => {});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ presenca (movimento)
|
||||
@@ -798,6 +831,7 @@ function movimento(vel, cx, cy) {
|
||||
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
|
||||
function relatarMov(evento, n) {
|
||||
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
|
||||
registrarEvento("movimento", evento, n);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- camera oculta (sensor)
|
||||
@@ -930,6 +964,8 @@ let configRecebida = null;
|
||||
function configurarApp(c) {
|
||||
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
|
||||
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
|
||||
SONO.apagar = c.apagarTela !== false;
|
||||
if (typeof c.apagarTelaMin === 'number') SONO.minutos = c.apagarTelaMin;
|
||||
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
|
||||
AUTO = c.auto;
|
||||
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
||||
@@ -960,6 +996,7 @@ async function sincronizarApp() {
|
||||
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
|
||||
centro: [MOV.cx, MOV.cy], config: configRecebida,
|
||||
brilho: window.carinhaBrilho,
|
||||
tela_apagada: !!window.carinhaTelaApagada,
|
||||
bateria: window.carinhaBateria,
|
||||
aparelho: window.carinhaAparelho,
|
||||
video: window.carinhaVideo,
|
||||
@@ -1009,7 +1046,7 @@ const SONS = {
|
||||
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
||||
os sons dos gestos ficam quietos para não embolar. */
|
||||
function falar(texto) {
|
||||
if (!AUDIO.ligado || !window.VozRobo) return;
|
||||
if (!AUDIO.ligado || !window.VozRobo || r.dormindo) return;
|
||||
const c = contextoAudio();
|
||||
if (!c) return;
|
||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||
@@ -1021,6 +1058,7 @@ function falar(texto) {
|
||||
|
||||
function tocarSom(gesto) {
|
||||
if (!AUDIO.ligado) return false;
|
||||
if (r.dormindo && gesto !== "acordar") return false; // dormindo: silêncio
|
||||
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
||||
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
||||
const f = SONS[gesto];
|
||||
|
||||
+2
-1
@@ -21,6 +21,7 @@ PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
|
||||
movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
|
||||
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30,
|
||||
boca=True, sobrancelhas=False, iris=False, subirBoca=0, distSobrancelha=16, videoRTC=True,
|
||||
apagarTela=True, apagarTelaMin=5,
|
||||
sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
|
||||
cameraLado='user', videoResolucao='auto', videoFPS=0,
|
||||
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
|
||||
@@ -31,7 +32,7 @@ LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
|
||||
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
|
||||
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
|
||||
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600),
|
||||
subirBoca=(-20, 60), distSobrancelha=(0, 60))
|
||||
subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120))
|
||||
|
||||
|
||||
def validar(dados):
|
||||
|
||||
+3
-2
@@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
|
||||
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
|
||||
$arquivos = @(
|
||||
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
|
||||
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py',
|
||||
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', 'historico.py',
|
||||
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
|
||||
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
|
||||
)
|
||||
@@ -92,7 +92,8 @@ systemctl restart carinha-pc
|
||||
echo "backup em `$backup"
|
||||
"@
|
||||
Write-Host '== Instalando na placa'
|
||||
$script -replace "`r", '' | & ssh $alvo 'bash -s'
|
||||
# O PowerShell 5.1 põe BOM no stdin do ssh; sem tirar, o bash lê "<BOM>set -e" e não para no primeiro erro.
|
||||
$script -replace "`r", '' | & ssh $alvo "sed '1s/^\xEF\xBB\xBF//' | bash -s"
|
||||
if ($LASTEXITCODE) { throw 'instalação na placa falhou (o backup foi feito antes de qualquer remoção)' }
|
||||
|
||||
Write-Host '== Conferindo'
|
||||
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
"""Histórico de detecções (PIR da placa, movimento e som do celular) com uma foto por evento.
|
||||
|
||||
- PIR: lê o pino com `gpiomon` (libgpiod). Padrão: gpiochip1 linha 70 = pino 11 (PC6) do
|
||||
Orange Pi Zero 3; outro pino com CARINHA_PIR=gpiochip1:79.
|
||||
- Foto: ao registrar um evento, o servidor deixa um "pedido de foto"; o app do S10 consulta
|
||||
/historico/pedido, tira a foto com a câmera frontal e envia em /historico/foto?id=N.
|
||||
- Guarda só os últimos 3 dias (eventos e fotos mais antigos são apagados sozinhos), com teto de
|
||||
segurança de 2000 eventos e 500 fotos. Cada sensor e a foto podem ser desligados.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
from comum import gravar_atomico, ler_json
|
||||
|
||||
TIPOS = ('pir', 'movimento', 'som')
|
||||
PADRAO = dict(pir=True, movimento=True, som=True, foto=True, intervalo=15, dias=3,
|
||||
presenca=True, ausencia=5) # modo presença: o PIR liga a câmera; sem ninguém, ela desliga
|
||||
MAX_EVENTOS, MAX_FOTOS = 2000, 500 # teto de segurança; quem manda é a retenção em dias
|
||||
VALIDADE_PEDIDO = 12 # s que o app tem para mandar a foto
|
||||
|
||||
|
||||
class Historico:
|
||||
def __init__(self, pasta, iniciar_pir=True):
|
||||
self.pasta = Path(pasta)
|
||||
self.fotos = self.pasta / 'fotos'
|
||||
self.fotos.mkdir(exist_ok=True)
|
||||
self.caminho_db = str(self.pasta / 'historico.db')
|
||||
self.arquivo_config = self.pasta / 'historico.json'
|
||||
self.lock = threading.RLock()
|
||||
self.publicar = lambda tipo, dados: None
|
||||
self.ao_detectar = lambda tipo: None # o servidor usa para o PIR acordar o rosto
|
||||
# Modo presença: o servidor liga câmera/acorda (ao_presenca) e desliga/dorme (ao_ausencia).
|
||||
self.ao_presenca = lambda: None
|
||||
self.ao_ausencia = lambda: None
|
||||
self.presenca = dict(estado='presente', desde=time.time(), ultimo=time.time())
|
||||
self.config = dict(PADRAO)
|
||||
try:
|
||||
salvo = json.loads(self.arquivo_config.read_text(encoding='utf-8'))
|
||||
self.config.update(self.validar(salvo))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
self.ultimo = {} # tipo -> horário do último evento (intervalo mínimo)
|
||||
self.pedido = None # (id do evento, monotonic) aguardando foto do app
|
||||
self.pir = dict(disponivel=False, erro='', linha=os.environ.get('CARINHA_PIR', 'gpiochip1:70'), ultimo=None,
|
||||
ativo=False) # ativo = pino em nível alto agora (o HC-SR505 segura ~8 s e rearma)
|
||||
self.processo_pir = None
|
||||
with self.db() as db:
|
||||
db.execute('CREATE TABLE IF NOT EXISTS eventos(id INTEGER PRIMARY KEY, horario REAL, tipo TEXT, evento TEXT, nivel REAL, foto TEXT)')
|
||||
self.limpar_antigos()
|
||||
if iniciar_pir:
|
||||
threading.Thread(target=self.vigiar_pir, daemon=True).start()
|
||||
threading.Thread(target=self.faxina, daemon=True).start()
|
||||
threading.Thread(target=self.vigiar_ausencia, daemon=True).start()
|
||||
|
||||
@contextmanager
|
||||
def db(self):
|
||||
con = sqlite3.connect(self.caminho_db)
|
||||
try:
|
||||
with con:
|
||||
yield con
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
@staticmethod
|
||||
def validar(dados):
|
||||
if not isinstance(dados, dict):
|
||||
raise ValueError('Esperava um objeto')
|
||||
limpo = {}
|
||||
for chave, valor in dados.items():
|
||||
if chave not in PADRAO:
|
||||
raise ValueError('Ajuste desconhecido: ' + chave)
|
||||
if chave == 'ausencia':
|
||||
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 120:
|
||||
raise ValueError('Ausência de 1 a 120 minutos')
|
||||
elif chave == 'dias':
|
||||
if isinstance(valor, bool) or not isinstance(valor, int) or not 1 <= valor <= 30:
|
||||
raise ValueError('Guardar de 1 a 30 dias')
|
||||
elif chave == 'intervalo':
|
||||
if isinstance(valor, bool) or not isinstance(valor, (int, float)) or not 0 <= valor <= 600:
|
||||
raise ValueError('Intervalo entre 0 e 600 s')
|
||||
elif not isinstance(valor, bool):
|
||||
raise ValueError(chave + ' precisa ser verdadeiro ou falso')
|
||||
limpo[chave] = valor
|
||||
return limpo
|
||||
|
||||
# ------------------------------------------------------------------ retenção
|
||||
def limpar_antigos(self, agora=None):
|
||||
"""Apaga eventos (e fotos) mais velhos que a retenção em dias."""
|
||||
limite = (agora if agora is not None else time.time()) - self.config['dias'] * 86400
|
||||
with self.lock, self.db() as db:
|
||||
velhos = db.execute('SELECT id, foto FROM eventos WHERE horario < ?', (limite,)).fetchall()
|
||||
for vid, foto in velhos:
|
||||
self.apagar_arquivo(foto)
|
||||
db.execute('DELETE FROM eventos WHERE horario < ?', (limite,))
|
||||
if velhos:
|
||||
self.publicar('historico', dict(expirados=len(velhos)))
|
||||
return len(velhos)
|
||||
|
||||
# ------------------------------------------------------------------ modo presença
|
||||
def marcar_presenca(self, agora=None):
|
||||
agora = agora if agora is not None else time.time()
|
||||
with self.lock:
|
||||
self.presenca['ultimo'] = agora
|
||||
voltou = self.presenca['estado'] != 'presente'
|
||||
if voltou:
|
||||
self.presenca.update(estado='presente', desde=agora)
|
||||
if voltou and self.config['presenca']:
|
||||
self.ao_presenca()
|
||||
|
||||
def checar_ausencia(self, agora=None):
|
||||
"""Sem presença no PIR por `ausencia` minutos: desliga câmera, dorme e fica quieto."""
|
||||
agora = agora if agora is not None else time.time()
|
||||
if not (self.config['presenca'] and self.config['pir'] and self.pir['disponivel']):
|
||||
return False
|
||||
with self.lock:
|
||||
if self.pir['ativo']: # sensor ainda segurando: a pessoa continua ali
|
||||
self.presenca['ultimo'] = agora
|
||||
return False
|
||||
if self.presenca['estado'] != 'presente' or agora - self.presenca['ultimo'] < self.config['ausencia'] * 60:
|
||||
return False
|
||||
self.presenca.update(estado='ausente', desde=agora)
|
||||
self.ao_ausencia()
|
||||
self.avisar_pir()
|
||||
return True
|
||||
|
||||
def avisar_pir(self):
|
||||
self.publicar('pir', dict(ultimo=self.pir['ultimo'], ativo=self.pir['ativo'], presenca=dict(self.presenca)))
|
||||
|
||||
def nivel_pir(self, alto, agora=None, registrar=True):
|
||||
"""Mudança no pino do PIR. Subida = presença (evento + foto); enquanto alto, a bolinha fica acesa."""
|
||||
agora = agora if agora is not None else time.time()
|
||||
self.pir['ativo'] = alto
|
||||
if alto:
|
||||
self.pir['ultimo'] = agora
|
||||
self.marcar_presenca(agora)
|
||||
self.avisar_pir()
|
||||
if alto and registrar:
|
||||
self.registrar('pir', 'presença')
|
||||
|
||||
def vigiar_ausencia(self):
|
||||
while True:
|
||||
time.sleep(10)
|
||||
try:
|
||||
self.checar_ausencia()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def faxina(self):
|
||||
while True:
|
||||
time.sleep(600)
|
||||
try:
|
||||
self.limpar_antigos()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------ eventos
|
||||
def registrar(self, tipo, evento='', nivel=None, agora=None):
|
||||
"""Grava o evento se o sensor estiver ligado e fora do intervalo mínimo; devolve o id ou None."""
|
||||
if tipo not in TIPOS:
|
||||
raise ValueError('Tipo de evento inválido')
|
||||
agora = agora if agora is not None else time.time()
|
||||
with self.lock:
|
||||
if not self.config.get(tipo, False):
|
||||
return None
|
||||
if tipo in self.ultimo and agora - self.ultimo[tipo] < self.config['intervalo']:
|
||||
return None
|
||||
self.ultimo[tipo] = agora
|
||||
evento = str(evento or '')[:40]
|
||||
nivel = float(nivel) if isinstance(nivel, (int, float)) and not isinstance(nivel, bool) else None
|
||||
with self.db() as db:
|
||||
ident = db.execute('INSERT INTO eventos(horario, tipo, evento, nivel) VALUES (?,?,?,?)',
|
||||
(agora, tipo, evento, nivel)).lastrowid
|
||||
velhos = db.execute('SELECT id, foto FROM eventos ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_EVENTOS,)).fetchall()
|
||||
for vid, foto in velhos:
|
||||
self.apagar_arquivo(foto)
|
||||
db.execute('DELETE FROM eventos WHERE id=?', (vid,))
|
||||
if self.config['foto']:
|
||||
self.pedido = (ident, time.monotonic())
|
||||
self.limpar_antigos(agora)
|
||||
self.publicar('historico', dict(novo=self.evento(ident)))
|
||||
try:
|
||||
self.ao_detectar(tipo)
|
||||
except Exception:
|
||||
pass
|
||||
return ident
|
||||
|
||||
def evento(self, ident):
|
||||
with self.db() as db:
|
||||
linha = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos WHERE id=?', (ident,)).fetchone()
|
||||
return self.como_dict(linha) if linha else None
|
||||
|
||||
@staticmethod
|
||||
def como_dict(linha):
|
||||
i, h, t, e, n, f = linha
|
||||
return dict(id=i, horario=h, tipo=t, evento=e, nivel=n, foto=bool(f))
|
||||
|
||||
def listar(self, limite=500):
|
||||
with self.db() as db:
|
||||
linhas = db.execute('SELECT id, horario, tipo, evento, nivel, foto FROM eventos ORDER BY id DESC LIMIT ?', (limite,)).fetchall()
|
||||
total = db.execute('SELECT count(*) FROM eventos').fetchone()[0]
|
||||
return [self.como_dict(l) for l in linhas], total
|
||||
|
||||
def apagar(self, ident):
|
||||
with self.lock, self.db() as db:
|
||||
linha = db.execute('SELECT foto FROM eventos WHERE id=?', (ident,)).fetchone()
|
||||
if linha:
|
||||
self.apagar_arquivo(linha[0])
|
||||
db.execute('DELETE FROM eventos WHERE id=?', (ident,))
|
||||
self.publicar('historico', dict(apagado=ident))
|
||||
|
||||
def limpar(self):
|
||||
with self.lock, self.db() as db:
|
||||
db.execute('DELETE FROM eventos')
|
||||
for f in self.fotos.glob('evento-*.jpg'):
|
||||
f.unlink(missing_ok=True)
|
||||
self.pedido = None
|
||||
self.publicar('historico', dict(limpo=True))
|
||||
|
||||
def apagar_arquivo(self, nome):
|
||||
if nome:
|
||||
(self.fotos / nome).unlink(missing_ok=True)
|
||||
|
||||
# ------------------------------------------------------------------ foto
|
||||
def pedido_atual(self):
|
||||
with self.lock:
|
||||
if self.pedido and time.monotonic() - self.pedido[1] < VALIDADE_PEDIDO:
|
||||
return self.pedido[0]
|
||||
self.pedido = None
|
||||
return None
|
||||
|
||||
def guardar_foto(self, ident, jpeg):
|
||||
if not (jpeg.startswith(b'\xff\xd8') and jpeg.endswith(b'\xff\xd9')) or len(jpeg) > 3_000_000:
|
||||
raise ValueError('Esperava uma foto JPEG de até 3 MB')
|
||||
with self.lock:
|
||||
if not self.config['foto']:
|
||||
raise ValueError('Fotos desligadas')
|
||||
if self.pedido and self.pedido[0] == ident:
|
||||
self.pedido = None
|
||||
nome = f'evento-{ident}.jpg'
|
||||
with self.db() as db:
|
||||
if not db.execute('SELECT 1 FROM eventos WHERE id=?', (ident,)).fetchone():
|
||||
raise ValueError('Evento não existe mais')
|
||||
tmp = self.fotos / (nome + '.tmp')
|
||||
tmp.write_bytes(jpeg); tmp.replace(self.fotos / nome)
|
||||
db.execute('UPDATE eventos SET foto=? WHERE id=?', (nome, ident))
|
||||
antigas = db.execute('SELECT id, foto FROM eventos WHERE foto IS NOT NULL ORDER BY id DESC LIMIT -1 OFFSET ?', (MAX_FOTOS,)).fetchall()
|
||||
for vid, foto in antigas:
|
||||
self.apagar_arquivo(foto)
|
||||
db.execute('UPDATE eventos SET foto=NULL WHERE id=?', (vid,))
|
||||
self.publicar('historico', dict(foto=ident))
|
||||
|
||||
# ------------------------------------------------------------------ PIR
|
||||
def vigiar_pir(self):
|
||||
"""Mantém um `gpiomon` rodando enquanto o PIR estiver ligado e acompanha o nível do pino."""
|
||||
while True:
|
||||
if not self.config['pir'] or os.name != 'posix' or not shutil.which('gpiomon'):
|
||||
self.pir.update(disponivel=False, erro='' if self.config['pir'] else 'desligado')
|
||||
if os.name == 'posix' and not shutil.which('gpiomon'):
|
||||
self.pir['erro'] = 'gpiomon não instalado (apt install gpiod)'
|
||||
time.sleep(2)
|
||||
continue
|
||||
chip, _, linha = self.pir['linha'].partition(':')
|
||||
try:
|
||||
# pull-down: com o sensor desligado o pino não fica "flutuando" (disparos falsos)
|
||||
lido = subprocess.run(['gpioget', '-c', chip, '-b', 'pull-down', '--numeric', linha],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
self.processo_pir = subprocess.Popen(['gpiomon', '-c', chip, '-b', 'pull-down', '-e', 'both', '-F', '%E', linha],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
self.pir.update(disponivel=True, erro='')
|
||||
# nível de partida: alguém já na frente não gera borda de subida, mas acende a bolinha
|
||||
self.nivel_pir(lido.stdout.strip() == '1', registrar=False)
|
||||
inicio = time.monotonic()
|
||||
for linha_evento in self.processo_pir.stdout:
|
||||
if not self.config['pir']:
|
||||
break
|
||||
alto = linha_evento.strip() == 'rising'
|
||||
# o pino "assenta" ao ligar o pull-down: acompanha o nível, mas não registra evento
|
||||
self.nivel_pir(alto, registrar=time.monotonic() - inicio >= 2)
|
||||
erro = (self.processo_pir.stderr.read() or '').strip()[:200]
|
||||
except OSError as e:
|
||||
erro = str(e)
|
||||
except subprocess.TimeoutExpired:
|
||||
erro = 'gpioget não respondeu'
|
||||
finally:
|
||||
self.parar_pir()
|
||||
self.pir.update(disponivel=False, ativo=False, erro=erro if self.config['pir'] else 'desligado')
|
||||
time.sleep(3)
|
||||
|
||||
def parar_pir(self):
|
||||
p, self.processo_pir = self.processo_pir, None
|
||||
if p and p.poll() is None:
|
||||
p.terminate()
|
||||
try:
|
||||
p.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
|
||||
def status(self):
|
||||
eventos, total = self.listar()
|
||||
with self.lock:
|
||||
return dict(config=dict(self.config), pir=dict(self.pir), presenca=dict(self.presenca), eventos=eventos, total=total,
|
||||
max_eventos=MAX_EVENTOS, max_fotos=MAX_FOTOS)
|
||||
|
||||
# ------------------------------------------------------------------ HTTP
|
||||
def rotas(self, h, metodo):
|
||||
url = urlsplit(h.path)
|
||||
rota = url.path
|
||||
if not rota.startswith('/historico/'):
|
||||
return False
|
||||
try:
|
||||
if metodo == 'GET' and rota == '/historico/status':
|
||||
return h.json(self.status()) or True
|
||||
if metodo == 'GET' and rota == '/historico/pedido':
|
||||
return h.json(dict(id=self.pedido_atual())) or True
|
||||
if metodo == 'GET' and rota.startswith('/historico/foto/'):
|
||||
nome = rota.rsplit('/', 1)[-1]
|
||||
if not nome.startswith('evento-') or not nome.endswith('.jpg') or '/' in nome or '..' in nome:
|
||||
raise ValueError('Foto inválida')
|
||||
arquivo = self.fotos / nome
|
||||
if not arquivo.is_file():
|
||||
h.json(dict(erro='Foto não encontrada'), 404)
|
||||
return True
|
||||
h.enviar(arquivo.read_bytes(), 'image/jpeg')
|
||||
return True
|
||||
if metodo == 'POST' and rota == '/historico/foto':
|
||||
ident = int(parse_qs(url.query).get('id', ['0'])[0])
|
||||
if h.headers.get('Content-Type', '').split(';')[0] != 'image/jpeg':
|
||||
raise ValueError('Use image/jpeg')
|
||||
tamanho = int(h.headers.get('Content-Length', '0'))
|
||||
if not 0 < tamanho <= 3_000_000:
|
||||
raise ValueError('Foto vazia ou grande demais')
|
||||
self.guardar_foto(ident, h.rfile.read(tamanho))
|
||||
return h.json(dict(ok=True)) or True
|
||||
if metodo == 'POST':
|
||||
dados = ler_json(h, 4096)
|
||||
if rota == '/historico/evento':
|
||||
ident = self.registrar(dados.get('tipo'), dados.get('evento'), dados.get('nivel'))
|
||||
return h.json(dict(ok=True, id=ident)) or True
|
||||
if rota == '/historico/config':
|
||||
limpo = self.validar(dados)
|
||||
with self.lock:
|
||||
self.config.update(limpo)
|
||||
gravar_atomico(self.arquivo_config, self.config)
|
||||
if 'pir' in limpo and not limpo['pir']:
|
||||
self.parar_pir()
|
||||
if 'dias' in limpo:
|
||||
self.limpar_antigos()
|
||||
self.publicar('historico', dict(config=dict(self.config)))
|
||||
return h.json(self.status()) or True
|
||||
if rota == '/historico/apagar':
|
||||
if type(dados.get('id')) is not int:
|
||||
raise ValueError('ID inválido')
|
||||
self.apagar(dados['id'])
|
||||
return h.json(dict(ok=True)) or True
|
||||
if rota == '/historico/limpar':
|
||||
self.limpar()
|
||||
return h.json(dict(ok=True)) or True
|
||||
h.json(dict(erro='Rota do histórico desconhecida'), 404)
|
||||
except (ValueError, TypeError) as e:
|
||||
h.json(dict(erro=str(e)), 400)
|
||||
return True
|
||||
+31
-3
@@ -40,6 +40,7 @@ from cerebro import Cerebro
|
||||
from conversa import Conversa
|
||||
from treinamento import Treinamento
|
||||
from api_config import ConfigAPI
|
||||
from historico import Historico
|
||||
|
||||
TRAVA = threading.Lock()
|
||||
|
||||
@@ -69,7 +70,7 @@ ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador
|
||||
|
||||
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
|
||||
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
|
||||
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/",
|
||||
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/",
|
||||
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
|
||||
|
||||
|
||||
@@ -236,7 +237,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
|
||||
def modulos(self):
|
||||
s = self.server
|
||||
return [getattr(s, nome) for nome in ("api_config", "treinamento", "conversa", "cerebro",
|
||||
return [getattr(s, nome) for nome in ("api_config", "historico", "treinamento", "conversa", "cerebro",
|
||||
"braco", "servos", "controle") if hasattr(s, nome)]
|
||||
|
||||
def estado(self):
|
||||
@@ -259,6 +260,8 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro)
|
||||
if hasattr(s, "api_config"):
|
||||
dados["api"] = s.api_config.status()
|
||||
if hasattr(s, "historico"):
|
||||
dados["pir"] = dict(s.historico.pir, presenca=dict(s.historico.presenca))
|
||||
return dados
|
||||
|
||||
def do_GET(self):
|
||||
@@ -451,7 +454,7 @@ class Servidor(ThreadingHTTPServer):
|
||||
|
||||
|
||||
def ligar_eventos(servidor):
|
||||
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento"):
|
||||
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento", "historico"):
|
||||
if hasattr(servidor, nome):
|
||||
getattr(servidor, nome).publicar = EVENTOS.publicar
|
||||
|
||||
@@ -477,6 +480,31 @@ def main():
|
||||
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
|
||||
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
|
||||
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
|
||||
servidor.historico = Historico(Path.cwd())
|
||||
|
||||
def presenca(tipo):
|
||||
# PIR viu alguém: se o rosto estiver dormindo (e talvez com a tela apagada), acorda
|
||||
if tipo == "pir":
|
||||
with servidor.controle.lock:
|
||||
dormindo = bool(servidor.controle.estado.get("dormindo"))
|
||||
if dormindo:
|
||||
enfileirar("acordar")
|
||||
|
||||
def camera(ligada):
|
||||
if servidor.controle.config.get("sensorCamera") != ligada:
|
||||
servidor.controle.publicar("app_config", servidor.controle.atualizar({"sensorCamera": ligada}))
|
||||
|
||||
def alguem_chegou():
|
||||
camera(True) # modo presença: o PIR liga a câmera do celular
|
||||
enfileirar("acordar")
|
||||
|
||||
def ninguem_por_perto():
|
||||
camera(False) # sem ninguém: câmera desligada, dorme e fica em silêncio
|
||||
enfileirar("dormir")
|
||||
|
||||
servidor.historico.ao_detectar = presenca
|
||||
servidor.historico.ao_presenca = alguem_chegou
|
||||
servidor.historico.ao_ausencia = ninguem_por_perto
|
||||
servidor.conversa.treinamento = servidor.treinamento
|
||||
ligar_eventos(servidor)
|
||||
servidor.registrar = args.log
|
||||
|
||||
+9
-1
@@ -1,4 +1,12 @@
|
||||
(function(){
|
||||
// Cores das receitas: o ciano padrão (#5cd6ff) vira a cor escolhida no painel e o azul-claro
|
||||
// (#aaccff) um tom mais claro dela; rosa, amarelo e verde continuam (fazem parte da emoção).
|
||||
function corDaReacao(hex){
|
||||
const h=String(hex||'').toLowerCase(), base=(typeof CIANO!=='undefined'?CIANO:[92,214,255]);
|
||||
if(h==='#5cd6ff')return base.slice();
|
||||
if(h==='#aaccff')return base.map(v=>v+(255-v)*.45);
|
||||
return [1,3,5].map(i=>parseInt(h.slice(i,i+2),16));
|
||||
}
|
||||
const icons={nenhum:'',estrela:'✦',coracao:'♥',musica:'♫',cafe:'☕',ideia:'💡'};
|
||||
const badge=document.createElement('div');badge.id='reacaoSimbolo';badge.style.cssText='position:fixed;left:5%;top:6%;z-index:15;color:#5cd6ff;font:clamp(30px,7vw,64px) sans-serif;pointer-events:none;white-space:pre-wrap';document.body.append(badge);
|
||||
let timer,last=null,fetching=false,animation;let particles=[];
|
||||
@@ -42,7 +50,7 @@
|
||||
for(const s of recipe.passos)if(!['aberto','feliz','surpreso','coracao','triste'].includes(s.olhos)||!['neutra','sorriso','aberta','triste'].includes(s.boca)||!/^#[0-9a-f]{6}$/i.test(s.cor)||!Number.isFinite(s.duracao)||s.duracao<.15||s.duracao>2||!Number.isFinite(s.dx)||Math.abs(s.dx)>25||!Number.isFinite(s.dy)||Math.abs(s.dy)>12||!Number.isFinite(s.escala)||s.escala<.8||s.escala>1.3)return;
|
||||
clearTimeout(timer);clearVisual();window.eilik.soltar();trata('acordar');MIC.ultimoSom=performance.now()/1000;
|
||||
visual(recipe);
|
||||
r.fila=recipe.passos.map(s=>[{forma:s.olhos,forma2:null,boca:s.boca,cor:[1,3,5].map(i=>parseInt(s.cor.slice(i,i+2),16)),dx:s.dx,dy:s.dy,esc:s.escala,ab:1,ab2:1},s.duracao]);
|
||||
r.fila=recipe.passos.map(s=>[{forma:s.olhos,forma2:null,boca:s.boca,cor:corDaReacao(s.cor),dx:s.dx,dy:s.dy,esc:s.escala,ab:1,ab2:1},s.duracao]);
|
||||
r.fila.push([{forma:'aberto',forma2:null,boca:'neutra',cor:CIANO.slice(),dx:0,dy:0,esc:1,ab:1,ab2:1},.4]);proximo();
|
||||
timer=setTimeout(clearVisual,Math.max(recipe.figurinha?6000:0,['musica','estrela','coracao'].includes(recipe.simbolo)?4500:['ideia','cafe'].includes(recipe.simbolo)?2500:0,recipe.passos.reduce((n,s)=>n+s.duracao,0)*1000+400));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Histórico de detecções: sensores liga/desliga, intervalo, pedido de foto e rotas HTTP."""
|
||||
import http.client
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from historico import Historico, MAX_FOTOS
|
||||
|
||||
spec = importlib.util.spec_from_file_location('kiosk_hist', ROOT / 'kiosk-server.py')
|
||||
kiosk = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(kiosk)
|
||||
|
||||
JPEG = b'\xff\xd8' + b'foto' * 10 + b'\xff\xd9'
|
||||
|
||||
|
||||
class HistoricoTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.h = Historico(self.tmp.name, iniciar_pir=False)
|
||||
self.publicados = []
|
||||
self.h.publicar = lambda t, d: self.publicados.append((t, d))
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_intervalo_e_sensor_desligado(self):
|
||||
self.assertIsNotNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=100))
|
||||
self.assertIsNone(self.h.registrar('movimento', 'surpreso', 0.4, agora=105)) # dentro dos 15 s
|
||||
self.assertIsNotNone(self.h.registrar('som', 'surpreso', 0.9, agora=105)) # outro tipo vale
|
||||
self.h.config['pir'] = False
|
||||
self.assertIsNone(self.h.registrar('pir', agora=200))
|
||||
with self.assertRaises(ValueError):
|
||||
self.h.registrar('fumaça')
|
||||
self.assertEqual(self.h.listar()[1], 2)
|
||||
self.assertEqual(self.publicados[0][0], 'historico')
|
||||
|
||||
def test_pedido_e_foto(self):
|
||||
ident = self.h.registrar('pir', 'presença', agora=10)
|
||||
self.assertEqual(self.h.pedido_atual(), ident)
|
||||
with self.assertRaises(ValueError):
|
||||
self.h.guardar_foto(ident, b'nao e jpeg')
|
||||
self.h.guardar_foto(ident, JPEG)
|
||||
self.assertIsNone(self.h.pedido_atual())
|
||||
self.assertTrue(self.h.evento(ident)['foto'])
|
||||
self.assertTrue((Path(self.tmp.name) / 'fotos' / f'evento-{ident}.jpg').is_file())
|
||||
|
||||
def test_sem_foto_quando_desligado(self):
|
||||
self.h.config['foto'] = False
|
||||
self.h.registrar('pir', agora=10)
|
||||
self.assertIsNone(self.h.pedido_atual())
|
||||
|
||||
def test_guarda_no_maximo_as_fotos_recentes(self):
|
||||
self.h.config['intervalo'] = 0
|
||||
ids = []
|
||||
for i in range(MAX_FOTOS + 3):
|
||||
ids.append(self.h.registrar('pir', agora=i))
|
||||
self.h.guardar_foto(ids[-1], JPEG)
|
||||
com_foto = [e for e in self.h.listar(500)[0] if e['foto']]
|
||||
self.assertEqual(len(com_foto), MAX_FOTOS)
|
||||
self.assertFalse(self.h.evento(ids[0])['foto'])
|
||||
self.assertEqual(len(list((Path(self.tmp.name) / 'fotos').glob('*.jpg'))), MAX_FOTOS)
|
||||
|
||||
def test_guarda_so_os_ultimos_dias(self):
|
||||
agora = 10 * 86400
|
||||
velho = self.h.registrar('pir', agora=agora - 4 * 86400)
|
||||
self.h.guardar_foto(velho, JPEG)
|
||||
novo = self.h.registrar('pir', agora=agora - 86400)
|
||||
self.assertEqual(self.h.limpar_antigos(agora), 1) # 3 dias por padrão
|
||||
self.assertIsNone(self.h.evento(velho))
|
||||
self.assertIsNotNone(self.h.evento(novo))
|
||||
self.assertFalse((Path(self.tmp.name) / 'fotos' / f'evento-{velho}.jpg').exists())
|
||||
with self.assertRaises(ValueError):
|
||||
self.h.validar({'dias': 0})
|
||||
|
||||
def test_modo_presenca(self):
|
||||
chamadas = []
|
||||
self.h.ao_presenca = lambda: chamadas.append('chegou')
|
||||
self.h.ao_ausencia = lambda: chamadas.append('saiu')
|
||||
self.h.pir['disponivel'] = True
|
||||
self.h.marcar_presenca(1000)
|
||||
self.assertFalse(self.h.checar_ausencia(1000 + 4 * 60)) # 4 min: ainda presente
|
||||
self.assertTrue(self.h.checar_ausencia(1000 + 5 * 60 + 1)) # 5 min: ausente
|
||||
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # não repete
|
||||
self.h.marcar_presenca(2000) # voltou alguém
|
||||
self.assertEqual(chamadas, ['saiu', 'chegou'])
|
||||
self.h.config['presenca'] = False
|
||||
self.h.marcar_presenca(3000)
|
||||
self.assertFalse(self.h.checar_ausencia(9999))
|
||||
|
||||
def test_pir_segurando_conta_como_presenca(self):
|
||||
avisos = []
|
||||
self.h.publicar = lambda tipo, dados: avisos.append((tipo, dados))
|
||||
self.h.pir['disponivel'] = True
|
||||
self.h.nivel_pir(True, agora=1000) # subida: evento + bolinha acesa
|
||||
self.assertTrue(self.h.pir['ativo'])
|
||||
self.assertEqual(self.h.status()['eventos'][0]['tipo'], 'pir')
|
||||
self.assertFalse(self.h.checar_ausencia(1000 + 10 * 60)) # pino ainda alto: não dorme
|
||||
self.h.nivel_pir(False, agora=1000 + 10 * 60) # descida: só apaga a bolinha
|
||||
self.assertFalse(self.h.pir['ativo'])
|
||||
self.assertEqual(self.h.status()['total'], 1)
|
||||
self.assertTrue(self.h.checar_ausencia(1000 + 15 * 60 + 1))
|
||||
self.assertIn(('pir', True), [(t, d.get('ativo')) for t, d in avisos])
|
||||
|
||||
def test_apagar_e_limpar(self):
|
||||
a = self.h.registrar('pir', agora=1); self.h.guardar_foto(a, JPEG)
|
||||
b = self.h.registrar('som', agora=1)
|
||||
self.h.apagar(a)
|
||||
self.assertIsNone(self.h.evento(a))
|
||||
self.h.limpar()
|
||||
self.assertEqual(self.h.listar()[1], 0)
|
||||
self.assertEqual(list((Path(self.tmp.name) / 'fotos').glob('*.jpg')), [])
|
||||
|
||||
|
||||
class HistoricoHttpTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
||||
self.server.token = 'token-historico'
|
||||
self.server.historico = Historico(self.tmp.name, iniciar_pir=False)
|
||||
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown(); self.server.server_close(); self.tmp.cleanup()
|
||||
|
||||
def pedir(self, metodo, rota, corpo=None, tipo='application/json', token='token-historico'):
|
||||
c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5)
|
||||
h = {'Content-Type': tipo}
|
||||
if token:
|
||||
h['Authorization'] = 'Bearer ' + token
|
||||
if isinstance(corpo, dict):
|
||||
corpo = json.dumps(corpo)
|
||||
c.request(metodo, rota, body=corpo, headers=h)
|
||||
r = c.getresponse(); dados = r.read(); c.close()
|
||||
return r.status, dados
|
||||
|
||||
def test_fluxo_completo(self):
|
||||
self.assertEqual(self.pedir('POST', '/historico/evento', {'tipo': 'pir'}, token=None)[0], 401)
|
||||
codigo, corpo = self.pedir('POST', '/historico/evento', {'tipo': 'movimento', 'evento': 'surpreso', 'nivel': 0.3})
|
||||
ident = json.loads(corpo)['id']
|
||||
self.assertEqual(json.loads(self.pedir('GET', '/historico/pedido')[1])['id'], ident)
|
||||
self.assertEqual(self.pedir('POST', f'/historico/foto?id={ident}', JPEG, 'image/jpeg')[0], 200)
|
||||
codigo, foto = self.pedir('GET', f'/historico/foto/evento-{ident}.jpg?t=token-historico', token=None)
|
||||
self.assertEqual((codigo, foto), (200, JPEG))
|
||||
self.assertEqual(self.pedir('GET', '/historico/foto/../historico.db')[0], 400)
|
||||
status = json.loads(self.pedir('GET', '/historico/status')[1])
|
||||
self.assertEqual(status['eventos'][0]['id'], ident)
|
||||
self.assertTrue(status['eventos'][0]['foto'])
|
||||
self.assertEqual(self.pedir('POST', '/historico/config', {'foto': False, 'intervalo': 30})[0], 200)
|
||||
self.assertEqual(self.pedir('POST', '/historico/config', {'intervalo': 9999})[0], 400)
|
||||
self.assertFalse(self.server.historico.config['foto'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+5
-2
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
||||
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
||||
import { onMount, type Component } from 'svelte';
|
||||
import Avisos from './lib/componentes/Avisos.svelte';
|
||||
import EStop from './lib/componentes/EStop.svelte';
|
||||
@@ -17,9 +17,11 @@
|
||||
import Movimento from './telas/Movimento.svelte';
|
||||
import Reacoes from './telas/Reacoes.svelte';
|
||||
import Rosto from './telas/Rosto.svelte';
|
||||
import Eventos from './telas/Eventos.svelte';
|
||||
|
||||
const TELAS: Record<Rota, Component> = {
|
||||
'ao-vivo': AoVivo,
|
||||
eventos: Eventos,
|
||||
conversa: Conversa,
|
||||
reacoes: Reacoes,
|
||||
movimento: Movimento,
|
||||
@@ -28,6 +30,7 @@
|
||||
};
|
||||
const ICONES: Record<string, Component<any>> = {
|
||||
'ao-vivo': Video,
|
||||
eventos: Bell,
|
||||
conversa: MessageCircle,
|
||||
reacoes: Smile,
|
||||
movimento: Hand,
|
||||
@@ -206,7 +209,7 @@
|
||||
margin: 0;
|
||||
padding: var(--e-1);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
li a {
|
||||
display: grid;
|
||||
|
||||
@@ -55,6 +55,8 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
|
||||
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' },
|
||||
{ chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
|
||||
{ chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' },
|
||||
{ chave: 'apagarTela', rotulo: 'Apagar a tela quando dormir', ajuda: 'Tela preta e brilho mínimo; acorda com som, movimento, toque ou PIR', tipo: 'toggle' },
|
||||
{ chave: 'apagarTelaMin', rotulo: 'Dormindo há quanto tempo para apagar', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' min' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,6 +25,13 @@
|
||||
if (['musica', 'estrela', 'coracao'].includes(receita?.simbolo)) return { emoji: SIMBOLO_EMOJI[receita.simbolo] };
|
||||
return null;
|
||||
});
|
||||
// Mesma regra do celular (reacoes-render.js): ciano padrão = cor escolhida; azul-claro = tom claro dela.
|
||||
function corDaReacao(hex: string | undefined): number[] {
|
||||
const h = (hex ?? '').toLowerCase(), base = estado.aparencia.cor;
|
||||
if (!/^#[0-9a-f]{6}$/.test(h) || h === '#5cd6ff') return [...base];
|
||||
if (h === '#aaccff') return base.map((v) => v + (255 - v) * 0.45);
|
||||
return rgb(h);
|
||||
}
|
||||
const estilo = $derived(ROSTOS_INFO[estado.aparencia.rosto]?.novo ? estado.aparencia.rosto : 'vivo');
|
||||
|
||||
$effect(() => {
|
||||
@@ -36,7 +43,7 @@
|
||||
motor.retrato(tela, {
|
||||
estilo,
|
||||
formato: estado.aparencia.formato ?? 'padrao',
|
||||
cor: /^#[0-9a-f]{6}$/i.test(passo?.cor ?? '') ? rgb(passo.cor) : estado.aparencia.cor,
|
||||
cor: corDaReacao(passo?.cor),
|
||||
forma: passo?.olhos ?? 'aberto',
|
||||
icone: nosOlhos.icone,
|
||||
desenho: nosOlhos.desenho ? $state.snapshot(nosOlhos.desenho) : null,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { estado } from '../estado.svelte';
|
||||
// Selo ao vivo do PIR: "Presença agora" por 8 s depois de cada disparo (o HC-SR505 segura ~8 s).
|
||||
const seg = $derived(estado.pir?.ultimo ? Math.max(0, Math.round((estado.agora - estado.pir.ultimo * 1000) / 1000)) : null);
|
||||
const agora = $derived(seg !== null && seg < 8);
|
||||
const texto = $derived(
|
||||
!estado.pir?.disponivel ? 'sensor indisponível'
|
||||
: agora ? 'presença agora'
|
||||
: seg === null ? 'aguardando presença'
|
||||
: seg < 60 ? `último há ${seg} s` : seg < 3600 ? `último há ${Math.round(seg / 60)} min` : `último há ${Math.round(seg / 3600)} h`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="pir" class:agora class:off={!estado.pir?.disponivel} aria-live="polite">
|
||||
<span class="ponto" aria-hidden="true"></span>{texto}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.pir {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--raio-pill);
|
||||
font-size: var(--t-xs);
|
||||
font-weight: 600;
|
||||
background: var(--superficie-2);
|
||||
color: var(--texto-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ponto {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.agora {
|
||||
background: var(--ok-suave);
|
||||
color: var(--ok);
|
||||
}
|
||||
.agora .ponto {
|
||||
animation: pulso 1s ease-in-out infinite;
|
||||
}
|
||||
.off {
|
||||
opacity: 0.7;
|
||||
}
|
||||
@keyframes pulso {
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px rgb(52 199 89 / 0.25);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,8 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { estado, ouvir } from '../estado.svelte';
|
||||
|
||||
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props();
|
||||
/** espelharSono: dorme/acorda junto com o celular (ele adormece sozinho no silêncio). */
|
||||
let { titulo = 'Prévia do rosto', espelharSono = true }: { titulo?: string; espelharSono?: boolean } = $props();
|
||||
let quadro: HTMLIFrameElement;
|
||||
let pronto = $state(false);
|
||||
|
||||
@@ -20,6 +21,13 @@
|
||||
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
|
||||
}
|
||||
|
||||
// Sono que o celular decidiu sozinho (silêncio, toque, barulho) não passa pelo SSE de ordens:
|
||||
// vem na telemetria. Sem celular online, a prévia fica como está.
|
||||
const dormindoNoCelular = $derived(estado.celularOnline ? !!estado.app?.estado?.dormindo : null);
|
||||
$effect(() => {
|
||||
if (espelharSono && pronto && dormindoNoCelular !== null) enviar({ tipo: 'sono', dormindo: dormindoNoCelular });
|
||||
});
|
||||
|
||||
let ultimaConversa = estado.conversa?.ultima?.id;
|
||||
const cancelar = [
|
||||
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
|
||||
|
||||
@@ -34,6 +34,8 @@ class Estado {
|
||||
treinamento = $state({ ocupado: false, erro: '' });
|
||||
api = $state<EstadoServidor['api'] | null>(null);
|
||||
feed = $state<ItemFeed[]>([]);
|
||||
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
|
||||
pir = $state<{ disponivel: boolean; ultimo: number | null; ativo?: boolean; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
|
||||
/** Relógio de 1 s para derivar "online" sem esperar evento novo. */
|
||||
agora = $state(Date.now());
|
||||
|
||||
@@ -106,6 +108,7 @@ function aplicarFoto(f: EstadoServidor) {
|
||||
estado.reacao = f.reacao ?? null;
|
||||
if (f.treinamento) estado.treinamento = f.treinamento;
|
||||
if (f.api) estado.api = f.api;
|
||||
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, ativo: (f.pir as any).ativo, presenca: (f.pir as any).presenca };
|
||||
}
|
||||
|
||||
const tratadores: Record<string, (d: any) => void> = {
|
||||
@@ -139,6 +142,11 @@ const tratadores: Record<string, (d: any) => void> = {
|
||||
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
|
||||
},
|
||||
treinamento: (d) => (estado.treinamento = d),
|
||||
pir: (d: { ultimo: number; ativo?: boolean; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, ativo: d.ativo, presenca: d.presenca }),
|
||||
historico: (d) => {
|
||||
const n = d?.novo;
|
||||
if (n) noFeed('historico', { pir: '🚶 Presença no PIR', movimento: '👀 Movimento na câmera', som: '🔊 Som alto' }[n.tipo as string] ?? 'Detecção');
|
||||
},
|
||||
};
|
||||
|
||||
let fonte: EventSource | null = null;
|
||||
@@ -161,7 +169,7 @@ function abrirEventos() {
|
||||
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
||||
};
|
||||
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
||||
'reacao', 'treinamento']) {
|
||||
'reacao', 'treinamento', 'historico', 'pir']) {
|
||||
fonte.addEventListener(tipo, (e) => {
|
||||
const dados = JSON.parse((e as MessageEvent).data);
|
||||
tratadores[tipo]?.(dados);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/** Histórico de detecções (/historico/*): recarrega ao abrir e quando o servidor avisa por SSE. */
|
||||
import { lerToken, pedir, postar } from './api';
|
||||
import { avisos } from './avisos.svelte';
|
||||
import { ouvir } from './estado.svelte';
|
||||
|
||||
export interface EventoHistorico {
|
||||
id: number;
|
||||
horario: number;
|
||||
tipo: 'pir' | 'movimento' | 'som';
|
||||
evento: string;
|
||||
nivel: number | null;
|
||||
foto: boolean;
|
||||
}
|
||||
|
||||
export interface StatusHistorico {
|
||||
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number };
|
||||
presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number };
|
||||
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||
eventos: EventoHistorico[];
|
||||
total: number;
|
||||
max_eventos: number;
|
||||
max_fotos: number;
|
||||
}
|
||||
|
||||
export const TIPOS_EVENTO: Record<string, { nome: string; emoji: string; cor: string }> = {
|
||||
pir: { nome: 'Presença (PIR)', emoji: '🚶', cor: '#34c759' },
|
||||
movimento: { nome: 'Movimento na câmera', emoji: '👀', cor: '#0a84ff' },
|
||||
som: { nome: 'Som alto', emoji: '🔊', cor: '#ff9500' },
|
||||
};
|
||||
|
||||
/** URL da foto com o token na query (a tag <img> não manda cabeçalho). */
|
||||
export function urlFoto(id: number) {
|
||||
const t = lerToken();
|
||||
return `/historico/foto/evento-${id}.jpg` + (t ? '?t=' + encodeURIComponent(t) : '');
|
||||
}
|
||||
|
||||
class Historico {
|
||||
status = $state<StatusHistorico | null>(null);
|
||||
private espera = 0;
|
||||
|
||||
async carregar() {
|
||||
try {
|
||||
this.status = await pedir<StatusHistorico>('/historico/status');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async configurar(parcial: Partial<StatusHistorico['config']>) {
|
||||
const antes = this.status ? { ...this.status.config } : null;
|
||||
if (this.status) this.status = { ...this.status, config: { ...this.status.config, ...parcial } };
|
||||
try {
|
||||
this.status = await postar<StatusHistorico>('/historico/config', parcial);
|
||||
} catch (e) {
|
||||
if (this.status && antes) this.status = { ...this.status, config: antes };
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async apagar(id: number) {
|
||||
try {
|
||||
await postar('/historico/apagar', { id });
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
async limpar() {
|
||||
try {
|
||||
await postar('/historico/limpar');
|
||||
avisos.ok('Histórico apagado.');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Vários avisos seguidos (evento + foto) viram uma recarga só. */
|
||||
agendar() {
|
||||
clearTimeout(this.espera);
|
||||
this.espera = window.setTimeout(() => this.carregar(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
export const historico = new Historico();
|
||||
ouvir('historico', () => historico.status && historico.agendar());
|
||||
@@ -1,9 +1,10 @@
|
||||
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
|
||||
|
||||
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
|
||||
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
|
||||
|
||||
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
|
||||
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
|
||||
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
|
||||
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
|
||||
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
|
||||
@@ -11,12 +12,15 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||
];
|
||||
|
||||
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */
|
||||
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }];
|
||||
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
|
||||
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' },
|
||||
];
|
||||
|
||||
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
|
||||
|
||||
function lerHash(): Rota {
|
||||
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
|
||||
let id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
|
||||
if ((id as string) === 'historico') id = 'eventos'; // endereço antigo
|
||||
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
|
||||
}
|
||||
|
||||
|
||||
@@ -81,4 +81,5 @@ export interface EstadoServidor {
|
||||
reacao?: Reacao | null;
|
||||
treinamento?: { ocupado: boolean; erro: string };
|
||||
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
|
||||
pir?: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Brain, Camera, Download, Eye, Hand, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
|
||||
Brain, Camera, Download, Eye, Footprints, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
|
||||
} from '@lucide/svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { postar } from '../lib/api';
|
||||
@@ -12,6 +12,7 @@
|
||||
import Grupo from '../lib/componentes/Grupo.svelte';
|
||||
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
||||
import Linha from '../lib/componentes/Linha.svelte';
|
||||
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
import { Rastreamento } from '../lib/rastreamento.svelte';
|
||||
@@ -47,6 +48,11 @@
|
||||
});
|
||||
|
||||
const cfg = $derived(estado.appConfig);
|
||||
// Pontinho do PIR sobre a imagem: verde pulsando nos 8 s depois de cada disparo.
|
||||
// aceso enquanto o pino do sensor está alto; servidor antigo (sem `ativo`) cai nos 8 s após o disparo
|
||||
const pirAtivo = $derived(
|
||||
estado.pir?.ativo ?? (!!estado.pir?.ultimo && estado.agora - estado.pir.ultimo * 1000 < 8000),
|
||||
);
|
||||
const transmitindo = $derived(!!cfg?.preview);
|
||||
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
@@ -69,6 +75,12 @@
|
||||
<div class="ao-vivo">
|
||||
<!-- Palco fixo: a câmera continua visível enquanto a lista rola -->
|
||||
<section class="palco" aria-label="Câmera e rosto">
|
||||
{#if estado.pir?.disponivel}
|
||||
<span class="ponto-pir" class:ativo={pirAtivo} title="Sensor de presença (PIR)" aria-live="polite">
|
||||
<span class="luz" aria-hidden="true"></span>
|
||||
PIR{pirAtivo ? ' · presença' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
<div class="quadro camera" class:mini={rostoGrande}>
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
|
||||
@@ -181,6 +193,10 @@
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="Acontecimentos">
|
||||
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
|
||||
{#snippet acessorio()}<PresencaPir />{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={History} cor="#30b0c7" rotulo="Eventos" detalhe="Presença, movimento e som, com foto" href="#/eventos" />
|
||||
{#if estado.feed.length}
|
||||
<ol class="feed">
|
||||
{#each estado.feed.slice(0, 12) as item (item.id)}
|
||||
@@ -295,6 +311,41 @@
|
||||
.espaco {
|
||||
flex: 1;
|
||||
}
|
||||
.ponto-pir {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: var(--e-3);
|
||||
top: var(--e-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px 4px 7px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: rgb(0 0 0 / 0.55);
|
||||
color: #c7cdd4;
|
||||
font: 600 11px var(--fonte);
|
||||
letter-spacing: 0.04em;
|
||||
pointer-events: none;
|
||||
transition: color var(--transicao);
|
||||
}
|
||||
.ponto-pir .luz {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #6b7280;
|
||||
transition: background var(--transicao);
|
||||
}
|
||||
.ponto-pir.ativo {
|
||||
color: #b8f5c9;
|
||||
}
|
||||
.ponto-pir.ativo .luz {
|
||||
background: #34c759;
|
||||
animation: pulsoPir 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulsoPir {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgb(52 199 89 / 0.7); }
|
||||
50% { box-shadow: 0 0 0 7px rgb(52 199 89 / 0); }
|
||||
}
|
||||
/* ---------- lista */
|
||||
.lista {
|
||||
display: grid;
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
<script lang="ts">
|
||||
import { Camera, Ear, Footprints, Image as ImageIcon, Moon, Timer, Trash2, Video } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import Grupo from '../lib/componentes/Grupo.svelte';
|
||||
import Interruptor from '../lib/componentes/Interruptor.svelte';
|
||||
import Linha from '../lib/componentes/Linha.svelte';
|
||||
import Modal from '../lib/componentes/Modal.svelte';
|
||||
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
|
||||
import Slider from '../lib/componentes/Slider.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
|
||||
|
||||
onMount(() => {
|
||||
historico.carregar();
|
||||
});
|
||||
|
||||
const st = $derived(historico.status);
|
||||
// Presença e movimento em destaque; som é opcional no filtro.
|
||||
type Filtro = 'principais' | 'pir' | 'movimento' | 'som' | 'todos';
|
||||
let filtro = $state<Filtro>('principais');
|
||||
const FILTROS: [Filtro, string][] = [
|
||||
['principais', 'Presença e movimento'], ['pir', '🚶 Presença'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['todos', 'Todos'],
|
||||
];
|
||||
const eventos = $derived(
|
||||
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'principais' ? e.tipo !== 'som' : e.tipo === filtro)),
|
||||
);
|
||||
|
||||
// Agrupa por dia: Hoje, Ontem, depois a data.
|
||||
const dias = $derived.by(() => {
|
||||
const grupos: { titulo: string; itens: EventoHistorico[] }[] = [];
|
||||
const hoje = new Date(estado.agora); hoje.setHours(0, 0, 0, 0);
|
||||
for (const ev of eventos) {
|
||||
const d = new Date(ev.horario * 1000); d.setHours(0, 0, 0, 0);
|
||||
const diff = Math.round((hoje.getTime() - d.getTime()) / 86400000);
|
||||
const titulo = diff === 0 ? 'Hoje' : diff === 1 ? 'Ontem' : d.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' });
|
||||
if (!grupos.length || grupos[grupos.length - 1].titulo !== titulo) grupos.push({ titulo, itens: [] });
|
||||
grupos[grupos.length - 1].itens.push(ev);
|
||||
}
|
||||
return grupos;
|
||||
});
|
||||
|
||||
const TITULO: Record<string, string> = { pir: 'Presença detectada', movimento: 'Movimento na câmera', som: 'Som alto' };
|
||||
const DETALHE: Record<string, string> = { surpreso: 'deu um susto', acordou: 'acordou o Carinha', presença: 'sensor PIR' };
|
||||
const horaCurta = (t: number) => new Date(t * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
|
||||
const desde = (t: number) => {
|
||||
const min = Math.max(0, Math.round((estado.agora - t * 1000) / 60000));
|
||||
return min < 1 ? 'agora' : min < 60 ? `há ${min} min` : `há ${Math.round(min / 60)} h`;
|
||||
};
|
||||
|
||||
const presenca = $derived(st?.presenca);
|
||||
let aberta = $state<EventoHistorico | null>(null);
|
||||
let modal = $state(false);
|
||||
let confirmar = $state(false);
|
||||
function verFoto(ev: EventoHistorico) {
|
||||
aberta = ev;
|
||||
modal = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="eventos">
|
||||
<header>
|
||||
<div class="linha entre">
|
||||
<h1>Eventos</h1>
|
||||
{#if st?.config.pir}<PresencaPir />{/if}
|
||||
</div>
|
||||
<p class="fraco">
|
||||
O que o sensor de presença e a câmera do celular perceberam, com uma foto de cada momento. Guardamos {st?.config.dias ?? 3} dias.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="colunas">
|
||||
<section class="lista" aria-label="Lista de eventos">
|
||||
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
|
||||
{#each FILTROS as [id, nome] (id)}
|
||||
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id)}>{nome}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !st}
|
||||
<p class="fraco">Carregando eventos…</p>
|
||||
{:else if !eventos.length}
|
||||
<div class="vazio">
|
||||
<p>Nenhum evento {filtro === 'todos' ? 'nos últimos dias' : 'com esse filtro'}.</p>
|
||||
<p class="fraco pequeno-texto">Quando alguém passar na frente do sensor ou a câmera perceber movimento, aparece aqui na hora.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each dias as dia (dia.titulo)}
|
||||
<Grupo titulo={`${dia.titulo} · ${dia.itens.length}`}>
|
||||
{#each dia.itens as ev (ev.id)}
|
||||
{@const info = TIPOS_EVENTO[ev.tipo]}
|
||||
<div class="evento">
|
||||
<span class="icone" style="--c: {info.cor}" aria-hidden="true">{info.emoji}</span>
|
||||
<div class="texto">
|
||||
<strong>{TITULO[ev.tipo] ?? ev.tipo}</strong>
|
||||
<span class="fraco pequeno-texto">
|
||||
{horaCurta(ev.horario)} · {DETALHE[ev.evento] ?? ev.evento}{ev.nivel !== null && ev.tipo !== 'pir' ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{#if ev.foto}
|
||||
<button class="ver-foto" onclick={() => verFoto(ev)} aria-label="Ver foto de {horaCurta(ev.horario)}">
|
||||
<img src={urlFoto(ev.id)} alt="" loading="lazy" />
|
||||
<span>Ver foto</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="sem-foto fraco pequeno-texto">sem foto</span>
|
||||
{/if}
|
||||
<button class="btn pequeno icone fantasma" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
{/each}
|
||||
</Grupo>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="lado">
|
||||
<Grupo
|
||||
titulo="Modo presença"
|
||||
rodape="Alguém aparece: o PIR liga a câmera do celular e acorda o Carinha. Ninguém pelo tempo escolhido: a câmera desliga, ele dorme e fica em silêncio."
|
||||
>
|
||||
<Linha
|
||||
icone={Footprints}
|
||||
cor="#34c759"
|
||||
rotulo="Câmera ligada pela presença"
|
||||
detalhe={presenca ? (presenca.estado === 'presente' ? `alguém por perto · último movimento ${desde(presenca.ultimo)}` : `ninguém por perto ${desde(presenca.desde)}`) : ''}
|
||||
>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Câmera ligada pela presença" checked={!!st?.config.presenca} disabled={!st} onchange={(v) => historico.configurar({ presenca: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por">
|
||||
<Slider rotulo="Desligar câmera e dormir depois de" valor={st?.config.ausencia ?? 5} min={1} max={60} passo={1} unidade=" min" onchange={(v) => historico.configurar({ ausencia: v })} />
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<Grupo titulo="O que registrar">
|
||||
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)" detalhe={st?.pir.disponivel ? 'Pino 11 · HC-SR505' : st?.pir.erro || 'indisponível'}>
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Sensor de presença (PIR)" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Movimento na câmera" checked={!!st?.config.movimento} disabled={!st} onchange={(v) => historico.configurar({ movimento: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Ear} cor="#ff9500" rotulo="Som alto">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Som alto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Camera} cor="#af52de" rotulo="Tirar uma foto por evento" detalhe="Câmera frontal do S10">
|
||||
{#snippet acessorio()}
|
||||
<Interruptor rotulo="Tirar uma foto por evento" checked={!!st?.config.foto} disabled={!st} onchange={(v) => historico.configurar({ foto: v })} />
|
||||
{/snippet}
|
||||
</Linha>
|
||||
<Linha icone={Timer} cor="#8e8e93" rotulo="Intervalo entre eventos do mesmo tipo">
|
||||
<Slider rotulo="Intervalo" valor={st?.config.intervalo ?? 15} min={0} max={120} passo={5} unidade=" s" onchange={(v) => historico.configurar({ intervalo: v })} />
|
||||
</Linha>
|
||||
<Linha icone={ImageIcon} cor="#30b0c7" rotulo="Guardar">
|
||||
<Slider rotulo="Dias de histórico" valor={st?.config.dias ?? 3} min={1} max={30} passo={1} unidade=" dias" onchange={(v) => historico.configurar({ dias: v })} />
|
||||
</Linha>
|
||||
</Grupo>
|
||||
|
||||
<div class="limpar">
|
||||
{#if confirmar}
|
||||
<p class="pequeno-texto">Apagar todos os eventos e fotos?</p>
|
||||
<div class="linha">
|
||||
<button class="btn perigo pequeno" onclick={() => { confirmar = false; historico.limpar(); }}>Apagar tudo</button>
|
||||
<button class="btn pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!st?.total}><Trash2 size={14} /> Limpar eventos</button>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:aberto={modal} titulo={aberta ? `${TITULO[aberta.tipo]} · ${hora(aberta.horario)}` : 'Foto'}>
|
||||
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.eventos {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
header p {
|
||||
max-width: 70ch;
|
||||
}
|
||||
.colunas {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
align-items: start;
|
||||
}
|
||||
.lista,
|
||||
.lado {
|
||||
display: grid;
|
||||
gap: var(--e-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.filtros {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.filtros button {
|
||||
min-height: 34px;
|
||||
padding: 0 var(--e-3);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie);
|
||||
color: var(--texto-2);
|
||||
font: 500 var(--t-sm) var(--fonte);
|
||||
cursor: pointer;
|
||||
}
|
||||
.filtros button[aria-checked='true'] {
|
||||
background: var(--destaque);
|
||||
border-color: var(--destaque);
|
||||
color: var(--destaque-texto);
|
||||
}
|
||||
.vazio {
|
||||
display: grid;
|
||||
gap: var(--e-1);
|
||||
padding: var(--e-6);
|
||||
text-align: center;
|
||||
border: 1px dashed var(--borda-forte);
|
||||
border-radius: var(--raio-lg);
|
||||
}
|
||||
.evento {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-3);
|
||||
padding: var(--e-2) var(--e-3) var(--e-2) var(--e-4);
|
||||
min-height: 64px;
|
||||
}
|
||||
.evento + .evento {
|
||||
border-top: 1px solid var(--borda);
|
||||
}
|
||||
.icone {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--c) 18%, transparent);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.texto {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.ver-foto {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
padding: 3px var(--e-3) 3px 3px;
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
color: var(--destaque);
|
||||
font: 600 var(--t-xs) var(--fonte);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.ver-foto img {
|
||||
width: 56px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.sem-foto {
|
||||
flex: none;
|
||||
}
|
||||
.limpar {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
padding: 0 var(--e-2);
|
||||
}
|
||||
.grande {
|
||||
width: 100%;
|
||||
border-radius: var(--raio);
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.ver-foto span {
|
||||
display: none;
|
||||
}
|
||||
.ver-foto {
|
||||
padding: 3px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1100px) {
|
||||
.colunas {
|
||||
grid-template-columns: 1fr 380px;
|
||||
}
|
||||
.lado {
|
||||
position: sticky;
|
||||
top: calc(var(--topo-altura) + var(--e-5));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -249,7 +249,7 @@
|
||||
</div>
|
||||
|
||||
<aside class="lateral">
|
||||
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" /></div>
|
||||
<div class="quadro"><RostoPrevia bind:this={previa} titulo="Prévia das reações" espelharSono={false} /></div>
|
||||
<p class="fraco pequeno-texto">Clique numa reação para ver aqui sem mandar ao celular.</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
<section class="bancada" aria-label="Prévia">
|
||||
<div class="celular">
|
||||
<div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" /></div>
|
||||
<div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" espelharSono={false} /></div>
|
||||
<span class="camera-frontal" aria-hidden="true"></span>
|
||||
<span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user