feat(historico): deteccoes com foto, sensor PIR na placa e tela apagando no sono

- historico.py: eventos de PIR (gpiomon no pino 11, gpiochip1 linha 70),
  movimento e som do celular em SQLite; ate 200 eventos e 100 fotos;
  cada sensor e a foto ligam/desligam; intervalo minimo por tipo
- PIR acorda o Carinha quando ele esta dormindo
- APK 0.8.3: o app consulta o pedido de foto e envia uma foto da camera
  frontal (quadro da camera de movimento ou do video ao vivo)
- tela apaga depois de X minutos dormindo (preto total + brilho minimo no
  AMOLED); acende com som, movimento, toque, comando ou PIR
- painel: tela Historico (sensores, fotos, filtros, apagar), atalho em Ao
  vivo e no Ctrl+K, ajustes "Apagar a tela quando dormir"
- testes do historico (servico e rotas HTTP)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-06 03:40:13 -03:00
co-authored by Claude Opus 5.5
parent 98c9e8803e
commit 788215b49e
20 changed files with 934 additions and 16 deletions
+3
View File
@@ -23,3 +23,6 @@ api-privada.tmp
ia-config.json ia-config.json
seguranca.json seguranca.json
historico.db
historico.json
fotos/
+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="br.carinha.rosto" package="br.carinha.rosto"
android:versionCode="10" android:versionCode="11"
android:versionName="0.8.2"> android:versionName="0.8.3">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" /> <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
+30
View File
@@ -93,6 +93,22 @@ addEventListener("resize", ajustar);
const PRETO = [6, 8, 14]; const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255]; const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 }; 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 CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78]; const VERM = [255, 78, 78];
const ROSA = [255, 112, 176]; const ROSA = [255, 112, 176];
@@ -716,6 +732,16 @@ function checarSilencio(agora) {
journal do carinha-web (so quando o microfone esta ativo). */ journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) { function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} 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) // ------------------------------------------------------ presenca (movimento)
@@ -804,6 +830,7 @@ function movimento(vel, cx, cy) {
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */ /* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) { function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
registrarEvento("movimento", evento, n);
} }
// ------------------------------------------------- camera oculta (sensor) // ------------------------------------------------- camera oculta (sensor)
@@ -936,6 +963,8 @@ let configRecebida = null;
function configurarApp(c) { function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true; EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca; 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; if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
AUTO = c.auto; AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume; AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
@@ -966,6 +995,7 @@ async function sincronizarApp() {
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2, movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida, centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho, brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho, aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
+9
View File
@@ -152,6 +152,15 @@ window.rtcReceber = packet => {
}); });
}; };
window.rtcParar = () => { allowed = false; parar(); sid = null; }; window.rtcParar = () => { allowed = false; parar(); sid = null; };
/* Foto do evento para o histórico, tirada do vídeo ao vivo (câmera de movimento parada). */
window.rtcFoto = () => {
if (!stream || video.readyState < 2 || !video.videoWidth) return;
const escala = Math.min(1, 1280 / video.videoWidth);
const tela = document.createElement('canvas');
tela.width = Math.round(video.videoWidth * escala); tela.height = Math.round(video.videoHeight * escala);
tela.getContext('2d').drawImage(video, 0, 0, tela.width, tela.height);
CarinhaRTC.foto(tela.toDataURL('image/jpeg', 0.82));
};
setInterval(() => { setInterval(() => {
if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return; if (!movementEnabled || !stream || video.readyState < 2 || video.currentTime===lastMotionFrame) return;
lastMotionFrame=video.currentTime; lastMotionFrame=video.currentTime;
@@ -28,6 +28,15 @@ public final class ControleRemoto {
private volatile ScheduledExecutorService worker; private volatile ScheduledExecutorService worker;
private long consultarEm; private long consultarEm;
private long quadroEm; 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 long logEm;
private final Runnable mudanca; private final Runnable mudanca;
public interface RTCListener { boolean receber(JSONObject pacote); } public interface RTCListener { boolean receber(JSONObject pacote); }
@@ -118,6 +127,7 @@ public final class ControleRemoto {
mudanca.run(); mudanca.run();
} }
if (videoRTC()) rtcTick(base); if (videoRTC()) rtcTick(base);
fotoTick(base);
byte[] jpeg = pendente; byte[] jpeg = pendente;
pendente = null; pendente = null;
if (jpeg != null && preview()) { if (jpeg != null && preview()) {
@@ -147,10 +157,13 @@ public final class ControleRemoto {
return c; return c;
} }
public void quadro(Image image, int rotacao) { public void quadro(Image image, int rotacao) {
if (videoRTC()) return; boolean querFoto = fotoPedida > 0 && fotoJpeg == null;
long agora = System.currentTimeMillis(); long agora = System.currentTimeMillis();
if (!preview() || pendente != null || agora < quadroEm) return; if (!querFoto) {
quadroEm = agora + 200; if (videoRTC()) return;
if (!preview() || pendente != null || agora < quadroEm) return;
quadroEm = agora + 200;
}
try { try {
Image.Plane[] p = image.getPlanes(); Image.Plane[] p = image.getPlanes();
ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()}; ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()};
@@ -171,10 +184,44 @@ public final class ControleRemoto {
if (rotated != b) rotated.recycle(); if (rotated != b) rotated.recycle();
b.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); } } 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 { private void rtcTick(String base) throws Exception {
String json; String json;
while ((json = sinais.poll()) != null) { while ((json = sinais.poll()) != null) {
@@ -198,6 +198,8 @@ public class MainActivity extends Activity {
} }
atualizarSensores(); atualizarSensores();
atualizarBrilho(); 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.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>() { new ValueCallback<String>() {
@Override public void onReceiveValue(String valor) { @Override public void onReceiveValue(String valor) {
if (!emPrimeiroPlano) return; if (!emPrimeiroPlano) return;
@@ -1091,6 +1093,7 @@ public class MainActivity extends Activity {
float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(), float nivel = BrilhoRepouso.nivel(remoto.economia(), remoto.brilho(), remoto.brilhoRepouso(),
System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(), System.currentTimeMillis() - ultimoUsoTela, remoto.repousoTela(),
estado.optBoolean("ocupado"), estado.optBoolean("dormindo")); estado.optBoolean("ocupado"), estado.optBoolean("dormindo"));
if (estado.optBoolean("apagada")) nivel = 0f; // tela preta + brilho mínimo
WindowManager.LayoutParams attr = getWindow().getAttributes(); WindowManager.LayoutParams attr = getWindow().getAttributes();
if (Math.abs(attr.screenBrightness - nivel) > 0.005f) { if (Math.abs(attr.screenBrightness - nivel) > 0.005f) {
attr.screenBrightness = nivel; attr.screenBrightness = nivel;
@@ -1104,6 +1107,14 @@ public class MainActivity extends Activity {
} }
private final class PonteRTC { 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) { @JavascriptInterface public void rastrear(final String json) {
if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return; if (!emPrimeiroPlano || !cameraPermitida() || !remoto.config().optBoolean("seguirRastreamento", true)) return;
try { try {
+30
View File
@@ -93,6 +93,22 @@ addEventListener("resize", ajustar);
const PRETO = [6, 8, 14]; const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255]; const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 }; 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 CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78]; const VERM = [255, 78, 78];
const ROSA = [255, 112, 176]; const ROSA = [255, 112, 176];
@@ -716,6 +732,16 @@ function checarSilencio(agora) {
journal do carinha-web (so quando o microfone esta ativo). */ journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) { function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} 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) // ------------------------------------------------------ presenca (movimento)
@@ -804,6 +830,7 @@ function movimento(vel, cx, cy) {
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */ /* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) { function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {} try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
registrarEvento("movimento", evento, n);
} }
// ------------------------------------------------- camera oculta (sensor) // ------------------------------------------------- camera oculta (sensor)
@@ -936,6 +963,8 @@ let configRecebida = null;
function configurarApp(c) { function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true; EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca; 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; if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
AUTO = c.auto; AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume; AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
@@ -966,6 +995,7 @@ async function sincronizarApp() {
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2, movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida, centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho, brilho: window.carinhaBrilho,
tela_apagada: !!window.carinhaTelaApagada,
bateria: window.carinhaBateria, bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho, aparelho: window.carinhaAparelho,
video: window.carinhaVideo, video: window.carinhaVideo,
+2 -1
View File
@@ -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, movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30, economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30,
boca=True, sobrancelhas=False, iris=False, subirBoca=0, distSobrancelha=16, videoRTC=True, boca=True, sobrancelhas=False, iris=False, subirBoca=0, distSobrancelha=16, videoRTC=True,
apagarTela=True, apagarTelaMin=5,
sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False, sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
cameraLado='user', videoResolucao='auto', videoFPS=0, cameraLado='user', videoResolucao='auto', videoFPS=0,
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True) 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), somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100), movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600), brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600),
subirBoca=(-20, 60), distSobrancelha=(0, 60)) subirBoca=(-20, 60), distSobrancelha=(0, 60), apagarTelaMin=(1, 120))
def validar(dados): def validar(dados):
+1 -1
View File
@@ -27,7 +27,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
# chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai). # chaves, vendor/ (modelos já instalados na placa) e o fonte do painel (só web/dist vai).
$arquivos = @( $arquivos = @(
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py', 'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py', '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', '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'
) )
+270
View File
@@ -0,0 +1,270 @@
"""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 os 200 eventos e as 100 fotos mais recentes. 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)
MAX_EVENTOS, MAX_FOTOS = 200, 100
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
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)
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)')
if iniciar_pir:
threading.Thread(target=self.vigiar_pir, 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 == '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
# ------------------------------------------------------------------ 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.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=100):
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; cada borda de subida = presença."""
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:
self.processo_pir = subprocess.Popen(['gpiomon', '-c', chip, '-e', 'rising', linha],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
self.pir.update(disponivel=True, erro='')
for _ in self.processo_pir.stdout:
if not self.config['pir']:
break
self.pir['ultimo'] = time.time()
self.registrar('pir', 'presença')
erro = (self.processo_pir.stderr.read() or '').strip()[:200]
except OSError as e:
erro = str(e)
finally:
self.parar_pir()
self.pir.update(disponivel=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), 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()
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
+14 -3
View File
@@ -40,6 +40,7 @@ from cerebro import Cerebro
from conversa import Conversa from conversa import Conversa
from treinamento import Treinamento from treinamento import Treinamento
from api_config import ConfigAPI from api_config import ConfigAPI
from historico import Historico
TRAVA = threading.Lock() 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. # 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/", PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/conversa/", "/treinamento/", "/historico/",
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
@@ -236,7 +237,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", "treinamento", "conversa", "cerebro", return [getattr(s, nome) for nome in ("api_config", "historico", "treinamento", "conversa", "cerebro",
"braco", "servos", "controle") if hasattr(s, nome)] "braco", "servos", "controle") if hasattr(s, nome)]
def estado(self): def estado(self):
@@ -451,7 +452,7 @@ class Servidor(ThreadingHTTPServer):
def ligar_eventos(servidor): 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): if hasattr(servidor, nome):
getattr(servidor, nome).publicar = EVENTOS.publicar getattr(servidor, nome).publicar = EVENTOS.publicar
@@ -477,6 +478,16 @@ def main():
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro) servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db') servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar 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")
servidor.historico.ao_detectar = presenca
servidor.conversa.treinamento = servidor.treinamento servidor.conversa.treinamento = servidor.treinamento
ligar_eventos(servidor) ligar_eventos(servidor)
servidor.registrar = args.log servidor.registrar = args.log
+119
View File
@@ -0,0 +1,119 @@
"""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_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()
+2
View File
@@ -17,6 +17,7 @@
import Movimento from './telas/Movimento.svelte'; import Movimento from './telas/Movimento.svelte';
import Reacoes from './telas/Reacoes.svelte'; import Reacoes from './telas/Reacoes.svelte';
import Rosto from './telas/Rosto.svelte'; import Rosto from './telas/Rosto.svelte';
import Historico from './telas/Historico.svelte';
const TELAS: Record<Rota, Component> = { const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo, 'ao-vivo': AoVivo,
@@ -25,6 +26,7 @@
movimento: Movimento, movimento: Movimento,
ajustes: Ajustes, ajustes: Ajustes,
rosto: Rosto, rosto: Rosto,
historico: Historico,
}; };
const ICONES: Record<string, Component<any>> = { const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video, 'ao-vivo': Video,
+2
View File
@@ -55,6 +55,8 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' }, { 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: '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: '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' },
], ],
}, },
{ {
+5 -1
View File
@@ -139,6 +139,10 @@ const tratadores: Record<string, (d: any) => void> = {
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome); if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
}, },
treinamento: (d) => (estado.treinamento = d), treinamento: (d) => (estado.treinamento = d),
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; let fonte: EventSource | null = null;
@@ -161,7 +165,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']) { 'reacao', 'treinamento', 'historico']) {
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);
+84
View File
@@ -0,0 +1,84 @@
/** 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 };
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
View File
@@ -36,6 +36,7 @@ export function itensDaPaleta(): ItemPaleta[] {
registrarNaPaleta(() => [ registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })), ...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') }, { id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
{ id: 'ir-historico', grupo: 'Ir para', titulo: 'Histórico de detecções', fazer: () => nav.ir('historico') },
...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })), ...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
estado.estop.ativo estado.estop.ativo
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar } ? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
+5 -2
View File
@@ -1,6 +1,6 @@
/** 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' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto'; export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'historico';
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' },
@@ -11,7 +11,10 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
]; ];
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */ /** 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' },
{ id: 'historico', titulo: 'Histórico', volta: 'ao-vivo' },
];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? ''; export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
+2 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { import {
Brain, Camera, Download, Eye, Hand, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video, Brain, Camera, Download, Eye, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api'; import { postar } from '../lib/api';
@@ -181,6 +181,7 @@
</Grupo> </Grupo>
<Grupo titulo="Acontecimentos"> <Grupo titulo="Acontecimentos">
<Linha icone={History} cor="#34c759" rotulo="Histórico de detecções" detalhe="PIR, movimento e som, com foto" href="#/historico" />
{#if estado.feed.length} {#if estado.feed.length}
<ol class="feed"> <ol class="feed">
{#each estado.feed.slice(0, 12) as item (item.id)} {#each estado.feed.slice(0, 12) as item (item.id)}
+290
View File
@@ -0,0 +1,290 @@
<script lang="ts">
import { ArrowLeft, Camera, Ear, Footprints, ImageOff, 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 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);
type Filtro = 'todos' | 'pir' | 'movimento' | 'som' | 'foto';
let filtro = $state<Filtro>('todos');
const eventos = $derived(
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'foto' ? e.foto : e.tipo === filtro)),
);
const quando = (t: number) => {
const seg = Math.max(0, Math.round((estado.agora - t * 1000) / 1000));
if (seg < 60) return `há ${seg} s`;
if (seg < 3600) return `há ${Math.round(seg / 60)} min`;
if (seg < 86400) return `há ${Math.round(seg / 3600)} h`;
return new Date(t * 1000).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' });
};
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
const NOME_EVENTO: Record<string, string> = { surpreso: 'susto', acordou: 'acordou o Carinha', presença: 'presença' };
const pirTexto = $derived.by(() => {
if (!st) return '';
if (!st.config.pir) return 'Desligado';
if (st.pir.disponivel) return `Pino 11 · ${st.pir.ultimo ? 'último disparo ' + quando(st.pir.ultimo) : 'aguardando presença'}`;
return st.pir.erro || 'Indisponível neste servidor';
});
let aberta = $state<EventoHistorico | null>(null);
let modal = $state(false);
let confirmar = $state(false);
</script>
<div class="historico">
<a class="voltar" href="#/ao-vivo"><ArrowLeft size={16} /> Ao vivo</a>
<header>
<h1>Histórico de detecções</h1>
<p class="fraco">
Presença do sensor PIR, movimento e som percebidos pelo celular, com uma foto da câmera frontal em cada evento.
{#if st}Guarda os {st.max_eventos} eventos e as {st.max_fotos} fotos mais recentes.{/if}
</p>
</header>
<div class="colunas">
<div class="lado">
<Grupo titulo="Sensores">
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor PIR da placa" detalhe={pirTexto}>
{#snippet acessorio()}
<Interruptor rotulo="Sensor PIR da placa" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
{/snippet}
</Linha>
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera" detalhe="Câmera do S10 como sensor">
{#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 e susto" detalhe="Microfone do S10 (só o nível, nunca o áudio)">
{#snippet acessorio()}
<Interruptor rotulo="Som alto e susto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
{/snippet}
</Linha>
</Grupo>
<Grupo titulo="Fotos" rodape="A foto sai da câmera de movimento do S10 ou, com o vídeo ao vivo no ar, do próprio vídeo.">
<Linha icone={Camera} cor="#af52de" rotulo="Guardar uma foto por evento">
{#snippet acessorio()}
<Interruptor rotulo="Guardar 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 mínimo entre eventos">
<Slider
rotulo="Do mesmo tipo"
valor={st?.config.intervalo ?? 15}
min={0}
max={120}
passo={5}
unidade=" s"
onchange={(v) => historico.configurar({ intervalo: 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 histórico</button>
{/if}
</div>
</div>
<section class="lista" aria-label="Eventos">
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
{#each [['todos', 'Todos'], ['pir', '🚶 PIR'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['foto', '📷 Com foto']] as [id, nome] (id)}
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id as Filtro)}>{nome}</button>
{/each}
{#if st}<span class="fraco pequeno-texto">{st.total} evento(s)</span>{/if}
</div>
{#if !st}
<p class="fraco">Carregando histórico…</p>
{:else if !eventos.length}
<div class="vazio">
<p>Nenhum evento {filtro === 'todos' ? 'ainda' : 'com esse filtro'}.</p>
<p class="fraco pequeno-texto">Quando o PIR ou o celular perceber algo, o evento aparece aqui na hora, com a foto.</p>
</div>
{:else}
<ul class="grade">
{#each eventos as ev (ev.id)}
{@const info = TIPOS_EVENTO[ev.tipo]}
<li class="cartao">
<button class="foto" onclick={() => ev.foto && ((aberta = ev), (modal = true))} disabled={!ev.foto} aria-label={ev.foto ? 'Ver foto' : 'Sem foto'}>
{#if ev.foto}
<img src={urlFoto(ev.id)} alt="Foto do evento de {hora(ev.horario)}" loading="lazy" />
{:else}
<ImageOff size={22} />
{/if}
<span class="selo" style="--c: {info.cor}">{info.emoji} {info.nome}</span>
</button>
<div class="dados">
<strong>{NOME_EVENTO[ev.evento] ?? ev.evento}</strong>
<span class="fraco pequeno-texto" title={hora(ev.horario)}>{quando(ev.horario)}{ev.nivel !== null ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}</span>
</div>
<button class="btn pequeno icone fantasma apagar" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
</li>
{/each}
</ul>
{/if}
</section>
</div>
</div>
<Modal bind:aberto={modal} titulo={aberta ? `${TIPOS_EVENTO[aberta.tipo].nome} · ${hora(aberta.horario)}` : 'Foto'}>
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
</Modal>
<style>
.historico {
display: grid;
gap: var(--e-5);
}
.voltar {
display: inline-flex;
align-items: center;
gap: var(--e-1);
width: fit-content;
color: var(--texto-2);
text-decoration: none;
font-size: var(--t-sm);
}
header {
display: grid;
gap: var(--e-2);
}
header p {
max-width: 70ch;
}
.colunas {
display: grid;
gap: var(--e-5);
align-items: start;
}
.lado {
display: grid;
gap: var(--e-5);
}
.limpar {
display: grid;
gap: var(--e-2);
padding: 0 var(--e-2);
}
.lista {
display: grid;
gap: var(--e-4);
min-width: 0;
}
.filtros {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
align-items: center;
}
.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);
}
.grade {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-3);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
}
.cartao {
position: relative;
display: grid;
gap: var(--e-2);
padding: var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
background: var(--superficie);
}
.foto {
position: relative;
aspect-ratio: 4 / 3;
border: 0;
border-radius: var(--raio);
overflow: hidden;
background: var(--fundo-2);
color: var(--texto-3);
display: grid;
place-items: center;
cursor: zoom-in;
padding: 0;
}
.foto:disabled {
cursor: default;
}
.foto img {
width: 100%;
height: 100%;
object-fit: cover;
}
.selo {
position: absolute;
left: 6px;
bottom: 6px;
padding: 2px 8px;
border-radius: var(--raio-pill);
background: rgb(0 0 0 / 0.6);
color: #fff;
font-size: 11px;
border-left: 3px solid var(--c);
}
.dados {
display: grid;
padding: 0 var(--e-1) var(--e-1);
}
.apagar {
position: absolute;
right: 6px;
bottom: 6px;
}
.grande {
width: 100%;
border-radius: var(--raio);
}
@media (min-width: 1100px) {
.colunas {
grid-template-columns: 360px 1fr;
}
.lado {
position: sticky;
top: calc(var(--topo-altura) + var(--e-5));
}
}
</style>