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:
@@ -23,3 +23,6 @@ api-privada.tmp
|
|||||||
|
|
||||||
ia-config.json
|
ia-config.json
|
||||||
seguranca.json
|
seguranca.json
|
||||||
|
historico.db
|
||||||
|
historico.json
|
||||||
|
fotos/
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 ?? '';
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user