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:
julio
2026-10-05 20:11:58 -03:00
co-authored by Claude Opus 5.5
parent 673dea62e8
commit c5fd54c1d9
67 changed files with 626 additions and 5904 deletions
@@ -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() {