feat(celular): rosto e app usam SSE e token; remove telas legadas
- carinha.html assina /eventos (expressao, app_config, conversa, reacao, recarregar) e volta ao polling de /fila sozinho se o SSE cair; foto dos ajustes a cada (re)conexao; token (?t=) vai em todo fetch ao servidor - APK 0.8-token: token nas preferencias e no menu de toque longo, enviado na pagina, no HTTP do Java e ao llama-server (--api-key); trocar servidor por Intent exige o token salvo; camera/microfone so para o servidor configurado ou a copia local - cerebro/conversa mandam a chave ao llama-server do S10 - servidor: evento SSE 'recarregar'; remove /camera/frame e /app/connect; enderecos antigos redirecionam para a secao do painel novo - remove dashboard, painel, conversa, emojis, treinamento, braco, servos, api, scripts e CSS antigos, audio-robot.js e servir-local.bat - logica pura do painel em web/src/lib/nucleo.ts com testes node --test (substitui test_movimentos.js e test_rastreamento.js) - testes de navegador portados para o painel novo (rtc, rosto, rastreamento) - README, docs/PAINEL.md e docs/SEGURANCA.md reescritos; versao 0.8.0; carinha-pc.service le /etc/carinha.env Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="br.carinha.rosto"
|
||||
android:versionCode="6"
|
||||
android:versionName="0.6-ia-local">
|
||||
android:versionCode="8"
|
||||
android:versionName="0.8-token">
|
||||
|
||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||
|
||||
|
||||
@@ -26,6 +26,25 @@
|
||||
</svg>
|
||||
<div id="fps"></div>
|
||||
<script>
|
||||
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
|
||||
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
|
||||
(function () {
|
||||
const token = new URLSearchParams(location.search).get('t');
|
||||
window.carinhaToken = token || '';
|
||||
if (!token || !window.fetch) return;
|
||||
const original = window.fetch.bind(window);
|
||||
window.fetch = (recurso, opcoes = {}) => {
|
||||
try {
|
||||
const url = new URL(typeof recurso === 'string' ? recurso : recurso.url, location.href);
|
||||
if (url.origin === location.origin) {
|
||||
const cabecalhos = new Headers(opcoes.headers || {});
|
||||
cabecalhos.set('Authorization', 'Bearer ' + token);
|
||||
opcoes = Object.assign({}, opcoes, { headers: cabecalhos });
|
||||
}
|
||||
} catch (e) { /* URL estranha: segue sem token */ }
|
||||
return original(recurso, opcoes);
|
||||
};
|
||||
})();
|
||||
"use strict";
|
||||
/* -------------------------------------------------------------------------
|
||||
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
|
||||
@@ -424,6 +443,9 @@ function trata(ordem) {
|
||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||
} else if (SEQS[ordem]) {
|
||||
animar(ordem);
|
||||
} else if (ordem === "calma") {
|
||||
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
||||
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
|
||||
} else if (ordem === "parar") {
|
||||
try { window.close(); } catch (e) { r.fila = []; }
|
||||
}
|
||||
@@ -460,7 +482,7 @@ window.eilik = {
|
||||
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
|
||||
// do PC com um curl, igual ao FIFO de antes
|
||||
async function buscarOrdens() {
|
||||
if (new URLSearchParams(location.search).get('espelho') === '1') return;
|
||||
if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
|
||||
try {
|
||||
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
|
||||
if (!resp.ok) return;
|
||||
@@ -484,7 +506,18 @@ async function aplicarAparencia() {
|
||||
} catch (e) { /* sem servidor — segue com o padrao */ }
|
||||
}
|
||||
setTimeout(aplicarAparencia, 200);
|
||||
if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
||||
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
|
||||
// do servidor (SSE) por postMessage.
|
||||
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
|
||||
if (MODO_PAINEL) {
|
||||
window.carinhaAtiva = false;
|
||||
addEventListener('message', (e) => {
|
||||
if (e.origin !== location.origin || e.source !== parent || !e.data) return;
|
||||
const m = e.data;
|
||||
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
|
||||
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
|
||||
});
|
||||
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
||||
|
||||
// teclado (util para testar no PC)
|
||||
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
|
||||
@@ -818,8 +851,10 @@ async function sincronizarApp() {
|
||||
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
|
||||
sincronizando = true;
|
||||
try {
|
||||
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
|
||||
if (resp.ok) configurarApp(await resp.json());
|
||||
if (!window.carinhaSSE) {
|
||||
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
|
||||
if (resp.ok) configurarApp(await resp.json());
|
||||
}
|
||||
if (par.get('aparelho') === '1') {
|
||||
const agora = performance.now() / 1000;
|
||||
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
@@ -965,7 +1000,7 @@ setTimeout(relatar, 1500);
|
||||
if(location.protocol==='file:')return;
|
||||
let seen=null,fetching=false;
|
||||
async function poll(){
|
||||
if(fetching||window.carinhaAtiva===false)return;fetching=true;
|
||||
if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;
|
||||
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
|
||||
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
|
||||
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
|
||||
@@ -977,11 +1012,50 @@ setTimeout(relatar, 1500);
|
||||
|
||||
</script>
|
||||
<script src="/reacoes-render.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<script>
|
||||
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
|
||||
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
|
||||
}
|
||||
|
||||
/* Tempo real: um EventSource em /eventos substitui os pollings de /fila, /app/config,
|
||||
/conversa/status e /treinamento/expressao. Se cair, os pollings voltam sozinhos
|
||||
(window.carinhaSSE=false) até o EventSource reconectar. */
|
||||
(function () {
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (location.protocol === 'file:' || p.get('espelho') === '1' || !window.EventSource) return;
|
||||
const aparelho = p.get('aparelho') === '1';
|
||||
const t = window.carinhaToken;
|
||||
const fonte = new EventSource('/eventos' + (t ? '?t=' + encodeURIComponent(t) : ''));
|
||||
let ultimaConversa = null, ultimaReacao = null, primeira = true;
|
||||
const ouvir = (tipo, fn) => fonte.addEventListener(tipo, (e) => { try { fn(JSON.parse(e.data)); } catch (err) {} });
|
||||
fonte.addEventListener('pronto', () => {
|
||||
// Ordens que chegaram antes da conexão ficam na fila: executa uma vez e passa ao SSE.
|
||||
if (primeira) { primeira = false; buscarOrdens(); }
|
||||
window.carinhaSSE = true;
|
||||
// Foto dos ajustes a cada (re)conexão; depois só mudanças chegam por 'app_config'.
|
||||
fetch('/app/config', { cache: 'no-store' }).then((r) => r.ok && r.json()).then((c) => c && configurarApp(c)).catch(() => {});
|
||||
});
|
||||
fonte.onerror = () => {
|
||||
if (!window.carinhaSSE) return;
|
||||
window.carinhaSSE = false;
|
||||
// Volta ao polling sem repetir o que o SSE já entregou.
|
||||
fetch('fila' + (aparelho ? '?aparelho=1' : ''), { cache: 'no-store' }).catch(() => {});
|
||||
};
|
||||
ouvir('expressao', (d) => trata(d.ordem));
|
||||
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
|
||||
ouvir('app_config', (c) => configurarApp(c));
|
||||
ouvir('conversa', (s) => {
|
||||
const m = s.ultima;
|
||||
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
|
||||
ultimaConversa = m.id;
|
||||
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
|
||||
});
|
||||
ouvir('reacao', (d) => {
|
||||
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
|
||||
ultimaReacao = d.id;
|
||||
if (window.eilik && window.eilik.receita) window.eilik.receita(d.receita);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,6 +10,8 @@ import java.security.MessageDigest;
|
||||
public final class CerebroLocal {
|
||||
private volatile boolean fechado;
|
||||
private Process processo;
|
||||
/** Com token, o llama-server (aberto na rede para a placa) exige a mesma chave. */
|
||||
public volatile String chave = "";
|
||||
public void iniciar(final Context contexto, final String servidor) {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
@@ -43,7 +45,9 @@ public final class CerebroLocal {
|
||||
try { if (health.getResponseCode() == 200) { Log.i("CarinhaIA", "Executor existente pronto"); return; } }
|
||||
finally { health.disconnect(); }
|
||||
} catch (IOException ignored) { }
|
||||
ProcessBuilder builder = new ProcessBuilder(executavel.getAbsolutePath(), "-m", modelo.getAbsolutePath(), "--host", "0.0.0.0", "--port", "8101", "-c", "1024", "-t", "2", "-np", "1", "--jinja");
|
||||
java.util.List<String> comando = new java.util.ArrayList<>(java.util.Arrays.asList(executavel.getAbsolutePath(), "-m", modelo.getAbsolutePath(), "--host", "0.0.0.0", "--port", "8101", "-c", "1024", "-t", "2", "-np", "1", "--jinja"));
|
||||
if (chave != null && !chave.isEmpty()) { comando.add("--api-key"); comando.add(chave); }
|
||||
ProcessBuilder builder = new ProcessBuilder(comando);
|
||||
builder.environment().put("LD_LIBRARY_PATH", libs.getAbsolutePath());
|
||||
builder.environment().put("GGML_BACKEND_DL_PATH", libs.getAbsolutePath());
|
||||
builder.redirectErrorStream(true);
|
||||
|
||||
@@ -46,6 +46,9 @@ public final class ControleRemoto {
|
||||
public void sinalRTC(String json) {
|
||||
if (sinais.size() < 64) sinais.offer(json);
|
||||
}
|
||||
private volatile String token = "";
|
||||
/** Token do servidor (CARINHA_TOKEN); vai como Authorization em todo pedido. */
|
||||
public void token(String valor) { token = valor == null ? "" : valor.trim(); }
|
||||
public void servidor(String valor) {
|
||||
servidor = valor;
|
||||
pendente = null;
|
||||
@@ -140,6 +143,7 @@ public final class ControleRemoto {
|
||||
HttpURLConnection c = (HttpURLConnection) new URL(url).openConnection();
|
||||
c.setConnectTimeout(1500); c.setReadTimeout(1500);
|
||||
c.setUseCaches(false);
|
||||
if (!token.isEmpty()) c.setRequestProperty("Authorization", "Bearer " + token);
|
||||
return c;
|
||||
}
|
||||
public void quadro(Image image, int rotacao) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.BatteryManager;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Color;
|
||||
import android.hardware.camera2.CameraAccessException;
|
||||
@@ -83,6 +84,7 @@ public class MainActivity extends Activity {
|
||||
|
||||
private static final String PREFS = "carinha";
|
||||
private static final String CHAVE = "servidor";
|
||||
private static final String CHAVE_TOKEN = "token";
|
||||
private static final String PADRAO = "http://192.168.1.15:8100";
|
||||
|
||||
private static final String TAG = "CarinhaMic";
|
||||
@@ -100,6 +102,7 @@ public class MainActivity extends Activity {
|
||||
|
||||
private WebView web;
|
||||
private String servidor;
|
||||
private String token = ""; // CARINHA_TOKEN do servidor; vazio = servidor aberto
|
||||
private final CerebroLocal cerebroLocal = new CerebroLocal();
|
||||
private boolean jaCaiuParaLocal = false;
|
||||
private boolean jaRecarregou = false;
|
||||
@@ -179,8 +182,10 @@ public class MainActivity extends Activity {
|
||||
|
||||
SharedPreferences p = getSharedPreferences(PREFS, MODE_PRIVATE);
|
||||
servidor = p.getString(CHAVE, PADRAO);
|
||||
token = p.getString(CHAVE_TOKEN, "");
|
||||
movimentoLigado = p.getBoolean(CHAVE_MOV, true);
|
||||
usarServidorDoPedido(getIntent()); // permite apontar o servidor ao lancar
|
||||
cerebroLocal.chave = token;
|
||||
cerebroLocal.iniciar(this, servidor);
|
||||
remoto = new ControleRemoto(new Runnable() {
|
||||
@Override public void run() {
|
||||
@@ -272,7 +277,7 @@ public class MainActivity extends Activity {
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
boolean pode = true;
|
||||
boolean pode = origemConfiavel(pedido.getOrigin());
|
||||
for (String recurso : pedido.getResources()) {
|
||||
if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(recurso)
|
||||
&& checkSelfPermission(Manifest.permission.CAMERA)
|
||||
@@ -348,6 +353,15 @@ public class MainActivity extends Activity {
|
||||
if (it == null) return;
|
||||
String novo = it.getStringExtra("servidor");
|
||||
if (novo == null) return;
|
||||
String tokenPedido = it.getStringExtra("token");
|
||||
if (!token.isEmpty() && !token.equals(tokenPedido)) {
|
||||
Log.w(TAG, "pedido para trocar o servidor sem o token certo: ignorado");
|
||||
return;
|
||||
}
|
||||
if (token.isEmpty() && tokenPedido != null && !tokenPedido.trim().isEmpty()) {
|
||||
token = tokenPedido.trim();
|
||||
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE_TOKEN, token).apply();
|
||||
}
|
||||
novo = novo.trim();
|
||||
while (novo.endsWith("/")) novo = novo.substring(0, novo.length() - 1);
|
||||
if (novo.length() == 0) return;
|
||||
@@ -374,8 +388,9 @@ public class MainActivity extends Activity {
|
||||
private void abrirRosto() {
|
||||
naCamera = false;
|
||||
remoto.servidor(servidor);
|
||||
remoto.token(token);
|
||||
if (usandoLocal) web.loadUrl("file:///android_asset/carinha.html");
|
||||
else web.loadUrl(servidor + "/carinha.html?aparelho=1");
|
||||
else web.loadUrl(servidor + "/carinha.html?aparelho=1" + (token.isEmpty() ? "" : "&t=" + Uri.encode(token)));
|
||||
iniciarMovimento(); // volta a observar quem passa na frente
|
||||
}
|
||||
|
||||
@@ -397,7 +412,7 @@ public class MainActivity extends Activity {
|
||||
private void menu() {
|
||||
final String[] opcoes = {"Ver a camera", "Voltar para a carinha",
|
||||
(movimentoLigado ? "Desligar reacao ao movimento" : "Ligar reacao ao movimento"),
|
||||
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video"};
|
||||
"Mudar o servidor", "Sensores acordado / dormindo", "Camera e qualidade do video", "Token do servidor"};
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Carinha")
|
||||
.setItems(opcoes, new DialogInterface.OnClickListener() {
|
||||
@@ -408,7 +423,8 @@ public class MainActivity extends Activity {
|
||||
else if (i == 2) alternarMovimento();
|
||||
else if (i == 3) pedirServidor();
|
||||
else if (i == 4) menuSensores();
|
||||
else menuVideo();
|
||||
else if (i == 5) menuVideo();
|
||||
else pedirToken();
|
||||
}
|
||||
})
|
||||
.show();
|
||||
@@ -520,6 +536,37 @@ public class MainActivity extends Activity {
|
||||
.show();
|
||||
}
|
||||
|
||||
/** Token do servidor (CARINHA_TOKEN da placa). Vazio = servidor sem autenticacao. */
|
||||
private void pedirToken() {
|
||||
final EditText campo = new EditText(this);
|
||||
campo.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
campo.setText(token);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Token do servidor")
|
||||
.setMessage("O mesmo CARINHA_TOKEN configurado na placa")
|
||||
.setView(campo)
|
||||
.setPositiveButton("Salvar", new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface d, int w) {
|
||||
token = campo.getText().toString().trim();
|
||||
getSharedPreferences(PREFS, MODE_PRIVATE).edit().putString(CHAVE_TOKEN, token).apply();
|
||||
cerebroLocal.chave = token;
|
||||
aviso(token.isEmpty() ? "token removido" : "token salvo");
|
||||
abrirRosto();
|
||||
}
|
||||
})
|
||||
.setNegativeButton("Cancelar", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
/** So o servidor configurado (ou a copia local do APK) pode usar camera/microfone. */
|
||||
private boolean origemConfiavel(Uri origem) {
|
||||
if (origem == null) return false;
|
||||
String o = origem.toString();
|
||||
while (o.endsWith("/")) o = o.substring(0, o.length() - 1);
|
||||
return o.startsWith("file:") || o.equals(servidor);
|
||||
}
|
||||
|
||||
/** Voltar nao sai do app (num enfeite de mesa isso seria chato): volta a carinha. */
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
|
||||
Reference in New Issue
Block a user