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
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
"check": "svelte-check --tsconfig ./tsconfig.json",
"test": "node --test \"tests/**/*.test.ts\""
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.3.1",
+4 -44
View File
@@ -7,6 +7,9 @@
import { pedir, postar } from './api';
import { avisos } from './avisos.svelte';
import { estado } from './estado.svelte';
import { limites } from './nucleo';
export { sequencias } from './nucleo';
export interface Calibracao {
canal: number;
@@ -33,49 +36,6 @@ export interface StatusBraco {
export const PRAZO_MS = 450;
const RENOVAR_MS = 150;
function anguloDoPulso(p: number, c: Calibracao) {
const centro = c.centro ?? 1500;
return p <= centro ? (90 * (p - 500)) / (centro - 500) : 90 + (90 * (p - centro)) / (2500 - centro);
}
/** Faixa segura (graus) de cada articulação a partir da calibração; o canal 5 respeita o par 6 invertido. */
export function limites(st: StatusBraco): [number, number][] {
return st.articulacoes.map(({ canal }) => {
const c = st.calibracao[canal];
let lo = anguloDoPulso(c.minimo, c);
let hi = anguloDoPulso(c.maximo, c);
if (canal === 5) {
const par = st.calibracao[6];
lo = Math.max(lo, 180 - anguloDoPulso(par.maximo, par));
hi = Math.min(hi, 180 - anguloDoPulso(par.minimo, par));
}
return [Math.ceil(lo), Math.floor(hi)];
});
}
/** As 5 sequências prontas, escaladas para a faixa calibrada (porta de movimentos-core.js). */
export function sequencias(faixas: [number, number][]): { nome: string; passos: number[][] }[] {
const neutro = [0, 0, 0, 0, 0, 0, 0];
const base: [string, number[][]][] = [
['Acenar', [[0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], [0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], neutro]],
['Olhar para os lados', [[0, 0.2, -0.4, 0, 0, 0, -0.7], [0, -0.2, 0.4, 0, 0, 0, 0.7], neutro]],
['Alcançar e recolher', [[0, 0, 0.2, 0.6, -0.5, 0.4, 0], [0, 0, -0.2, -0.4, 0.5, -0.4, 0], neutro]],
['Garra e punho', [[-0.7, 0.3, 0, 0.2, 0, 0.1, 0], [0.7, -0.3, 0, 0.2, 0, 0.1, 0], [-0.7, 0.3, 0, 0, 0, 0, 0], neutro]],
['Apresentação', [[0.3, 0.3, -0.3, 0.4, -0.3, 0.3, -0.6], [-0.3, -0.3, 0.3, -0.3, 0.4, -0.3, 0.6], [0.5, 0.4, 0.2, 0.3, -0.3, 0.2, 0], neutro]],
];
return base.map(([nome, passos]) => ({
nome,
passos: passos.map((s) =>
s.map((v, i) => {
const [lo, hi] = faixas[i];
// Centro 90°, mas sempre dentro da faixa calibrada.
const meio = Math.max(lo, Math.min(hi, 90));
return Math.round(meio + v * (v < 0 ? meio - lo : hi - meio));
}),
),
}));
}
class Braco {
status = $state<StatusBraco | null>(null);
alvo = $state<number[]>(Array(7).fill(90));
@@ -90,7 +50,7 @@ class Braco {
private sequencia: number[][] | null = null;
private pausaAte = 0;
faixas = $derived(this.status ? limites(this.status) : []);
faixas = $derived(this.status ? limites(this.status.calibracao, this.status.articulacoes) : []);
async carregar() {
try {
+99
View File
@@ -0,0 +1,99 @@
/**
* Lógica pura do painel (sem Svelte nem DOM), testada com `npm test` (node --test).
* Rastreamento: escolha do alvo e encaixe do vídeo. Braço: faixas calibradas e sequências.
*/
export interface Ponto {
x: number;
y: number;
}
export interface Face extends Ponto {
w: number;
h: number;
score?: number;
pontos?: Ponto[];
}
export interface AlvoRastreio {
tipo: 'mao' | 'rosto';
x: number;
y: number;
atividade?: boolean;
}
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
const limitar01 = (v: number) => Math.max(0, Math.min(1, v));
/** Mão tem prioridade sobre rosto; entre candidatos do mesmo tipo, o mais perto do alvo anterior. */
export function escolherAlvo(faces: Face[], maos: Ponto[][], anterior: AlvoRastreio | null = null): AlvoRastreio | null {
let candidatos: AlvoRastreio[] = maos
.filter((h) => h.length === 21 && h.every(valido))
.map((h) => {
const palma = [0, 5, 9, 13, 17].map((i) => h[i]);
return { tipo: 'mao', x: palma.reduce((s, p) => s + p.x, 0) / 5, y: palma.reduce((s, p) => s + p.y, 0) / 5 };
});
if (!candidatos.length)
candidatos = faces
.filter((f) => valido(f) && Number.isFinite(f.w) && Number.isFinite(f.h) && f.w > 0 && f.h > 0)
.sort((a, b) => b.w * b.h - a.w * a.h)
.map((f) => ({ tipo: 'rosto', x: f.x + f.w / 2, y: f.y + f.h / 2 }));
if (!candidatos.length) return null;
if (anterior && anterior.tipo === candidatos[0].tipo)
candidatos.sort((a, b) => Math.hypot(a.x - anterior.x, a.y - anterior.y) - Math.hypot(b.x - anterior.x, b.y - anterior.y));
const t = candidatos[0];
return { ...t, x: limitar01(t.x), y: limitar01(t.y) };
}
/** Retângulo do vídeo (vw×vh) encaixado em w×h sem cortar (object-fit: contain). */
export function encaixe(w: number, h: number, vw: number, vh: number) {
const escala = Math.min(w / vw, h / vh);
return { x: Math.max(0, (w - vw * escala) / 2), y: Math.max(0, (h - vh * escala) / 2), w: vw * escala, h: vh * escala };
}
export interface Calibracao {
canal: number;
minimo: number;
maximo: number;
centro?: number;
}
function anguloDoPulso(p: number, c: Calibracao) {
const centro = c.centro ?? 1500;
return p <= centro ? (90 * (p - 500)) / (centro - 500) : 90 + (90 * (p - centro)) / (2500 - centro);
}
/** Faixa segura (graus) de cada articulação; o canal 5 respeita o par 6 invertido. */
export function limites(calibracao: Calibracao[], articulacoes: { canal: number }[]): [number, number][] {
return articulacoes.map(({ canal }) => {
const c = calibracao[canal];
let lo = anguloDoPulso(c.minimo, c);
let hi = anguloDoPulso(c.maximo, c);
if (canal === 5) {
const par = calibracao[6];
lo = Math.max(lo, 180 - anguloDoPulso(par.maximo, par));
hi = Math.min(hi, 180 - anguloDoPulso(par.minimo, par));
}
return [Math.ceil(lo), Math.floor(hi)];
});
}
/** As 5 sequências prontas, escaladas para a faixa calibrada de cada articulação. */
export function sequencias(faixas: [number, number][]): { nome: string; passos: number[][] }[] {
const neutro = [0, 0, 0, 0, 0, 0, 0];
const base: [string, number[][]][] = [
['Acenar', [[0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], [0, -0.7, 0.5, 0.2, 0, 0.1, 0], [0, 0.7, -0.5, 0.2, 0, 0.1, 0], neutro]],
['Olhar para os lados', [[0, 0.2, -0.4, 0, 0, 0, -0.7], [0, -0.2, 0.4, 0, 0, 0, 0.7], neutro]],
['Alcançar e recolher', [[0, 0, 0.2, 0.6, -0.5, 0.4, 0], [0, 0, -0.2, -0.4, 0.5, -0.4, 0], neutro]],
['Garra e punho', [[-0.7, 0.3, 0, 0.2, 0, 0.1, 0], [0.7, -0.3, 0, 0.2, 0, 0.1, 0], [-0.7, 0.3, 0, 0, 0, 0, 0], neutro]],
['Apresentação', [[0.3, 0.3, -0.3, 0.4, -0.3, 0.3, -0.6], [-0.3, -0.3, 0.3, -0.3, 0.4, -0.3, 0.6], [0.5, 0.4, 0.2, 0.3, -0.3, 0.2, 0], neutro]],
];
return base.map(([nome, passos]) => ({
nome,
passos: passos.map((s) =>
s.map((v, i) => {
const [lo, hi] = faixas[i];
const meio = Math.max(lo, Math.min(hi, 90));
return Math.round(meio + v * (v < 0 ? meio - lo : hi - meio));
}),
),
}));
}
+3 -38
View File
@@ -3,18 +3,9 @@
* servidor em /rastreador-worker.js). Porta de painel-rastreamento.js + rastreamento-core.js.
*/
import type { AppConfig } from './tipos';
import type { AlvoRastreio, VideoAoVivo } from './video.svelte';
import { encaixe, escolherAlvo, type AlvoRastreio, type Face, type Ponto } from './nucleo';
import type { VideoAoVivo } from './video.svelte';
interface Ponto {
x: number;
y: number;
}
interface Face extends Ponto {
w: number;
h: number;
score: number;
pontos?: Ponto[];
}
interface Resultado {
faces: Face[];
hands: Ponto[][];
@@ -22,35 +13,9 @@ interface Resultado {
timestamp: number;
}
const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
const limitar = (v: number) => Math.max(0, Math.min(1, v));
const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12],
[9, 13], [13, 14], [14, 15], [15, 16], [13, 17], [0, 17], [17, 18], [18, 19], [19, 20]];
export function escolherAlvo(faces: Face[], maos: Ponto[][], anterior: AlvoRastreio | null): AlvoRastreio | null {
let candidatos: AlvoRastreio[] = maos
.filter((h) => h.length === 21 && h.every(valido))
.map((h) => {
const palma = [0, 5, 9, 13, 17].map((i) => h[i]);
return { tipo: 'mao', x: palma.reduce((s, p) => s + p.x, 0) / 5, y: palma.reduce((s, p) => s + p.y, 0) / 5 };
});
if (!candidatos.length)
candidatos = faces
.filter((f) => valido(f) && f.w > 0 && f.h > 0)
.sort((a, b) => b.w * b.h - a.w * a.h)
.map((f) => ({ tipo: 'rosto', x: f.x + f.w / 2, y: f.y + f.h / 2 }));
if (!candidatos.length) return null;
if (anterior && anterior.tipo === candidatos[0].tipo)
candidatos.sort((a, b) => Math.hypot(a.x - anterior.x, a.y - anterior.y) - Math.hypot(b.x - anterior.x, b.y - anterior.y));
const t = candidatos[0];
return { ...t, x: limitar(t.x), y: limitar(t.y) };
}
function encaixe(w: number, h: number, vw: number, vh: number) {
const escala = Math.min(w / vw, h / vh);
return { x: Math.max(0, (w - vw * escala) / 2), y: Math.max(0, (h - vh * escala) / 2), w: vw * escala, h: vh * escala };
}
export class Rastreamento {
texto = $state('Rastreamento desligado.');
@@ -125,7 +90,7 @@ export class Rastreamento {
const p = pt(rosto);
c.strokeStyle = c.fillStyle = '#5cd6ff';
c.strokeRect(p.x, p.y, rosto.w * f.w, rosto.h * f.h);
c.fillText('Rosto ' + Math.round(rosto.score * 100) + '%', p.x, Math.max(15, p.y - 6));
c.fillText('Rosto ' + Math.round((rosto.score ?? 0) * 100) + '%', p.x, Math.max(15, p.y - 6));
for (const q of rosto.pontos || []) bola(pt(q));
}
for (const mao of r.hands) {
+6 -6
View File
@@ -4,14 +4,10 @@
* Só a tela Ao vivo cria uma instância: assim não há disputa pela única sessão.
*/
import { lerToken, pedir } from './api';
import type { AlvoRastreio } from './nucleo';
import type { AppConfig, Telemetria } from './tipos';
export interface AlvoRastreio {
tipo: 'mao' | 'rosto';
x: number;
y: number;
atividade?: boolean;
}
export type { AlvoRastreio } from './nucleo';
export class VideoAoVivo {
mensagem = $state('Transmissão desligada.');
@@ -186,6 +182,10 @@ export class VideoAoVivo {
}
}
get rastreamentoConectado() {
return this.canal?.readyState === 'open';
}
rastrear(alvo: AlvoRastreio | null) {
if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096)
this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo }));
+2
View File
@@ -22,10 +22,12 @@
onMount(() => {
video = new VideoAoVivo(elVideo);
rastreio = new Rastreamento(elVideo, elTela, video);
(window as any).carinhaVideo = video; // depuração e testes de ponta a ponta
});
onDestroy(() => {
rastreio?.destruir();
video?.destruir();
delete (window as any).carinhaVideo;
});
// Vídeo e rastreamento seguem o estado do servidor (SSE), sem polling próprio.
+57
View File
@@ -0,0 +1,57 @@
// node --test (Node 22.6+ remove os tipos sozinho). Substitui tests/test_movimentos.js e test_rastreamento.js.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { encaixe, escolherAlvo, limites, sequencias } from '../src/lib/nucleo.ts';
test('mão tem prioridade sobre rosto e entradas inválidas são ignoradas', () => {
const faces = [{ x: 0.2, y: 0.1, w: 0.3, h: 0.4, score: 0.9 }];
const maos = [Array.from({ length: 21 }, () => ({ x: 0.8, y: 0.6 }))];
assert.equal(escolherAlvo(faces, maos)?.tipo, 'mao');
const rosto = escolherAlvo(faces, [])!;
assert.equal(rosto.tipo, 'rosto');
assert.ok(Math.abs(rosto.x - 0.35) < 1e-9 && Math.abs(rosto.y - 0.3) < 1e-9);
assert.equal(escolherAlvo([], []), null);
assert.equal(escolherAlvo([{ x: NaN, y: 0, w: 1, h: 1 }], []), null);
});
test('entre rostos, segue o mais perto do alvo anterior', () => {
const faces = [
{ x: 0.0, y: 0.0, w: 0.4, h: 0.4 },
{ x: 0.7, y: 0.7, w: 0.2, h: 0.2 },
];
assert.equal(escolherAlvo(faces, [])?.x, 0.2);
assert.ok(Math.abs(escolherAlvo(faces, [], { tipo: 'rosto', x: 0.8, y: 0.8 })!.x - 0.8) < 1e-9);
});
test('encaixe centraliza sem cortar', () => {
const r = encaixe(1000, 500, 640, 480);
assert.ok(Math.abs(r.x - (500 - (500 * 640) / 480 / 2)) < 1e-9);
assert.equal(r.y, 0);
assert.ok(Math.abs(r.h - 500) < 1e-9);
});
test('sequências respeitam a faixa calibrada e voltam ao neutro', () => {
const faixas = Array.from({ length: 7 }, (): [number, number] => [81, 99]);
const seqs = sequencias(faixas);
assert.equal(seqs.length, 5);
for (const s of seqs) {
assert.ok(s.passos.length >= 3);
for (const passo of s.passos) {
assert.equal(passo.length, 7);
passo.forEach((v) => assert.ok(v >= 81 && v <= 99, `${s.nome}: ${v}`));
}
}
assert.deepEqual(seqs[0].passos.at(-1), Array(7).fill(90));
// Faixa que não contém 90°: o neutro vira o ponto mais próximo dentro dela.
const deslocada = sequencias(Array.from({ length: 7 }, (): [number, number] => [100, 140]));
assert.deepEqual(deslocada[0].passos.at(-1), Array(7).fill(100));
});
test('limites seguem a calibração e o par 5/6 invertido', () => {
const cal = Array.from({ length: 8 }, (_, canal) => ({ canal, minimo: 1400, maximo: 1600, centro: 1500 }));
const art = [0, 1, 2, 3, 4, 5, 7].map((canal) => ({ canal }));
const f = limites(cal, art);
assert.deepEqual(f[0], [81, 99]);
cal[6] = { canal: 6, minimo: 1450, maximo: 1600, centro: 1500 };
assert.deepEqual(limites(cal, art)[5], [81, 94]);
});
+1 -1
View File
@@ -6,7 +6,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face',
'/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/audio-robot.js', '/qualidade-auto.js'];
'/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
export default defineConfig({
plugins: [svelte()],