Files
julioandClaude Opus 5.5 c5fd54c1d9 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>
2026-10-05 20:11:58 -03:00

214 lines
10 KiB
Java

package br.carinha.rosto;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.ImageFormat;
import android.graphics.Matrix;
import android.graphics.Rect;
import android.graphics.YuvImage;
import android.media.Image;
import android.util.Log;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.ByteBuffer;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ConcurrentLinkedQueue;
/** One network worker and one pending JPEG; camera never waits on the network. */
public final class ControleRemoto {
private volatile String servidor;
private volatile boolean preview;
private volatile JSONObject config = new JSONObject();
private volatile byte[] pendente;
private volatile ScheduledExecutorService worker;
private long consultarEm;
private long quadroEm;
private long logEm;
private final Runnable mudanca;
public interface RTCListener { boolean receber(JSONObject pacote); }
private RTCListener rtcListener;
private final ConcurrentLinkedQueue<String> sinais = new ConcurrentLinkedQueue<>();
private String rtcId = "";
private int rtcDesde;
private long rtcConsultarEm;
private final ConcurrentLinkedQueue<JSONObject> ajustes = new ConcurrentLinkedQueue<>();
public JSONObject config() { return config; }
public void configurar(JSONObject parte) { ajustes.offer(parte); }
public ControleRemoto(Runnable mudanca) { this.mudanca = mudanca; }
public void rtcListener(RTCListener listener) { rtcListener = listener; }
public boolean videoRTC() { return config.optBoolean("videoRTC", true); }
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;
consultarEm = 0;
rtcId = ""; rtcDesde = 0; rtcConsultarEm = 0;
}
public boolean preview() { return preview && worker != null; }
public boolean economia() { return config.optBoolean("economia", true); }
public float brilho() { return (float) config.optDouble("brilho", 0.7); }
public float brilhoRepouso() { return (float) config.optDouble("brilhoRepouso", 0.08); }
public long repousoTela() { return (long) (config.optDouble("repousoTela", 30) * 1000); }
public double somlim() { return config.optDouble("somlim", 0.35); }
public double movlim() { return config.optDouble("movlim", 0.05); }
public synchronized void iniciar() {
if (worker != null) return;
consultarEm = 0;
worker = Executors.newSingleThreadScheduledExecutor();
worker.scheduleWithFixedDelay(new Runnable() {
@Override public void run() { tick(); }
}, 0, 200, TimeUnit.MILLISECONDS);
}
public synchronized void parar() {
if (worker != null) worker.shutdownNow();
worker = null;
preview = false;
pendente = null;
sinais.clear();
}
private void tick() {
String base = servidor;
if (base == null || worker == null) return;
HttpURLConnection conn = null;
try {
long agora = System.currentTimeMillis();
JSONObject ajuste = ajustes.peek();
if (ajuste != null) {
conn = abrir(base + "/app/config");
conn.setRequestMethod("POST"); conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json");
byte[] dados = ajuste.toString().getBytes("UTF-8");
try (java.io.OutputStream out = conn.getOutputStream()) { out.write(dados); }
if (conn.getResponseCode() != 200) throw new java.io.IOException("Ajuste não salvo");
ajustes.poll(); consultarEm = 0;
conn.disconnect(); conn = null;
}
if (agora >= consultarEm) {
consultarEm = agora + 1000;
conn = abrir(base + "/app/config");
String texto;
try (InputStream input = conn.getInputStream()) {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int n;
while ((n = input.read(buffer)) != -1 && bytes.size() < 16384) bytes.write(buffer, 0, n);
texto = bytes.toString("UTF-8");
}
conn.disconnect(); conn = null;
JSONObject recebido = new JSONObject(texto);
boolean novo = recebido.optBoolean("preview", false);
if (worker == null || !base.equals(servidor)) return;
config = recebido;
if (novo != preview) {
preview = novo;
if (!novo) pendente = null;
Log.i("CarinhaPC", "transmissao=" + novo);
}
mudanca.run();
}
if (videoRTC()) rtcTick(base);
byte[] jpeg = pendente;
pendente = null;
if (jpeg != null && preview()) {
conn = abrir(base + "/camera/frame");
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "image/jpeg");
conn.setFixedLengthStreamingMode(jpeg.length);
try (java.io.OutputStream out = conn.getOutputStream()) { out.write(jpeg); }
int code = conn.getResponseCode();
if (code == 409) { preview = false; pendente = null; }
else if (code != 200) throw new java.io.IOException("HTTP " + code);
}
} catch (Exception e) {
pendente = null;
if (System.currentTimeMillis() >= logEm) {
logEm = System.currentTimeMillis() + 5000;
Log.w("CarinhaPC", "servidor indisponivel: " + e);
}
} finally { if (conn != null) conn.disconnect(); }
}
private HttpURLConnection abrir(String url) throws Exception {
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) {
if (videoRTC()) return;
long agora = System.currentTimeMillis();
if (!preview() || pendente != null || agora < quadroEm) return;
quadroEm = agora + 200;
try {
Image.Plane[] p = image.getPlanes();
ByteBuffer[] buffers = {p[0].getBuffer(), p[1].getBuffer(), p[2].getBuffer()};
int[] rows = {p[0].getRowStride(), p[1].getRowStride(), p[2].getRowStride()};
int[] pixels = {p[0].getPixelStride(), p[1].getPixelStride(), p[2].getPixelStride()};
int w = image.getWidth(), h = image.getHeight();
byte[] nv21 = YuvFrame.nv21(w, h, buffers, rows, pixels);
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
new YuvImage(nv21, ImageFormat.NV21, w, h, null)
.compressToJpeg(new Rect(0, 0, w, h), 70, bytes);
byte[] jpeg = bytes.toByteArray();
if (rotacao != 0) {
Bitmap b = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length);
Matrix m = new Matrix(); m.postRotate(rotacao);
Bitmap rotated = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), m, true);
bytes.reset(); rotated.compress(Bitmap.CompressFormat.JPEG, 70, bytes);
jpeg = bytes.toByteArray();
if (rotated != b) rotated.recycle();
b.recycle();
}
if (preview()) pendente = jpeg;
} catch (Exception e) { Log.w("CarinhaPC", "quadro descartado: " + e); }
}
private void rtcTick(String base) throws Exception {
String json;
while ((json = sinais.poll()) != null) {
HttpURLConnection c = abrir(base + "/rtc/send");
try {
byte[] body = json.getBytes("UTF-8");
c.setRequestMethod("POST"); c.setDoOutput(true);
c.setRequestProperty("Content-Type", "application/json");
c.setFixedLengthStreamingMode(body.length);
try (java.io.OutputStream out = c.getOutputStream()) { out.write(body); }
int code = c.getResponseCode();
if (code != 200 && code != 409) throw new java.io.IOException("RTC HTTP " + code);
} finally { c.disconnect(); }
}
long agora = System.currentTimeMillis();
if (agora < rtcConsultarEm || rtcListener == null) return;
rtcConsultarEm = agora + 400;
HttpURLConnection c = abrir(base + "/rtc/poll?lado=app&desde=" + rtcDesde + "&id=" + rtcId);
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (InputStream in = c.getInputStream()) {
byte[] buffer = new byte[4096]; int n;
while ((n = in.read(buffer)) != -1 && bytes.size() < 262144) bytes.write(buffer, 0, n);
}
JSONObject packet = new JSONObject(bytes.toString("UTF-8"));
if (worker == null || !base.equals(servidor)) return;
if (rtcListener.receber(packet)) {
String id = packet.optString("id", "");
if (!id.equals(rtcId)) { rtcDesde = 0; rtcId = id; }
org.json.JSONArray messages = packet.optJSONArray("mensagens");
if (messages != null) for (int i = 0; i < messages.length(); i++)
rtcDesde = Math.max(rtcDesde, messages.getJSONObject(i).getInt("seq"));
}
} finally { c.disconnect(); }
}
}