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,37 +0,0 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import json
|
||||
with sync_playwright() as p:
|
||||
b=p.chromium.launch(headless=True); page=b.new_page(viewport={'width':1400,'height':1000})
|
||||
cfg=dict(disponivel=True,erro='',calibracao=[dict(canal=i,minimo=1400,maximo=1600) for i in range(8)],posturas=[],articulacoes=[dict(nome=n,canal=c) for n,c in zip(['Garra','Punho 1','Punho 2','Braço superior','Braço inferior','Antebraço','Base'],[0,1,2,3,4,5,7])])
|
||||
calls=[]; errors=[]
|
||||
def handle(route):
|
||||
if route.request.method=='POST':
|
||||
data=route.request.post_data_json; calls.append((route.request.url,data))
|
||||
if route.request.url.endswith('/config'): cfg.update(data)
|
||||
route.fulfill(content_type='application/json',body=json.dumps(cfg))
|
||||
page.route('**/braco/status',handle); page.route('**/braco/config',handle); page.route('**/braco/mover',handle); page.route('**/braco/parar',handle)
|
||||
page.on('pageerror',lambda e:errors.append(str(e)))
|
||||
page.goto('http://192.168.1.35:8100/braco');page.wait_for_selector('#joint6')
|
||||
assert page.locator('#sliders input').count()==7
|
||||
assert not page.locator('#enable').is_checked()
|
||||
page.locator('#joint1').evaluate("e=>{e.value=95;e.dispatchEvent(new Event('input'))}");page.wait_for_timeout(200)
|
||||
assert calls==[]
|
||||
page.locator('#enable').check();assert calls==[]
|
||||
page.locator('#joint1').evaluate("e=>{e.value=85;e.dispatchEvent(new Event('input'))}");page.wait_for_timeout(650)
|
||||
moves=[d for u,d in calls if u.endswith('/mover')]
|
||||
assert moves and moves[-1]['angulos'][1]==85,moves
|
||||
assert len(moves[0]['angulos'])==7
|
||||
page.locator('#stop').click();page.wait_for_timeout(200)
|
||||
assert calls[-1][0].endswith('/parar');assert not page.locator('#enable').is_checked()
|
||||
page.on('dialog',lambda d:d.accept('Teste centro'))
|
||||
page.locator('#savePose').click();page.wait_for_timeout(250)
|
||||
assert '1 posturas' in page.locator('#count').inner_text()
|
||||
page.locator('#enable').check();page.locator('#play').click();page.wait_for_timeout(1100)
|
||||
assert 'Sequência concluída' in page.locator('#console').inner_text()
|
||||
page.locator('#stop').click();page.wait_for_timeout(200)
|
||||
page.screenshot(path='C:/Users/JullioPC/Documents/Codex/2026-10-04/o/outputs/carinha-pc/docs/braco-painel.png',full_page=True)
|
||||
page.set_viewport_size({'width':390,'height':844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth<=innerWidth')
|
||||
assert not errors,errors
|
||||
print('OK: sete sliders, opt-in, velocidade, parar, salvar/reproduzir, mobile; comandos simulados')
|
||||
b.close()
|
||||
+10
-7
@@ -18,6 +18,7 @@ def main():
|
||||
kiosk.ARQUIVO_APARENCIA = str(Path(tmp) / 'aparencia.json')
|
||||
server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
||||
server.controle = kiosk.ControlePC(Path(tmp) / 'app.json')
|
||||
kiosk.ligar_eventos(server) # o rosto recebe ajustes e ordens por SSE
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
@@ -63,21 +64,23 @@ def browser_test(base):
|
||||
panel = browser.new_page(viewport={'width': 1440, 'height': 1000})
|
||||
errors = []
|
||||
panel.on('pageerror', lambda error: errors.append(str(error) + '\n' + error.stack))
|
||||
panel.goto(base + '/painel')
|
||||
panel.wait_for_selector('#gestos button')
|
||||
panel.locator('#ajuste-sensorMic').uncheck()
|
||||
# Painel novo (web/dist): ajustes do celular e transmissão.
|
||||
panel.goto(base + '/#/ajustes')
|
||||
alternar = lambda nome: panel.get_by_role('switch', name=nome).locator('xpath=..').click()
|
||||
alternar('Microfone Só o nível')
|
||||
panel.wait_for_timeout(400)
|
||||
assert panel.request.get(base + '/app/config').json()['sensorMic'] is False
|
||||
panel.locator('#ajuste-cameraSono').check()
|
||||
alternar('Câmera durante o sono')
|
||||
panel.wait_for_timeout(400)
|
||||
panel.locator('#ajuste-cameraLado').select_option('environment')
|
||||
panel.get_by_role('combobox', name='Câmera').select_option('environment')
|
||||
panel.wait_for_timeout(400)
|
||||
c = panel.request.get(base + '/app/config').json()
|
||||
assert c['cameraSono'] is True and c['cameraLado'] == 'environment'
|
||||
panel.get_by_role('button', name='Ligar câmera').click()
|
||||
panel.get_by_role('link', name='Ao vivo').first.click()
|
||||
alternar('Transmitir câmera do celular')
|
||||
panel.wait_for_timeout(500)
|
||||
assert panel.request.get(base + '/app/config').json()['preview'] is True
|
||||
panel.get_by_role('button', name='Desligar câmera').click()
|
||||
alternar('Transmitir câmera do celular')
|
||||
panel.wait_for_timeout(500)
|
||||
assert panel.request.get(base + '/app/config').json()['preview'] is False
|
||||
assert not errors, errors
|
||||
|
||||
@@ -86,21 +86,13 @@ class ControleTest(unittest.TestCase):
|
||||
self.assertTrue(json.loads(body)['online'])
|
||||
self.assertEqual(json.loads(body)['estado']['rosto'], 'miudo')
|
||||
|
||||
def test_frame_is_hidden_when_disabled_and_after_expiry(self):
|
||||
jpeg = b'\xff\xd8fixture\xff\xd9'
|
||||
self.assertEqual(self.request('/camera/frame', jpeg, 'image/jpeg')[0], 409)
|
||||
def test_legacy_camera_and_adb_routes_are_gone(self):
|
||||
self.request('/app/config', {'preview': True})
|
||||
self.assertEqual(self.request('/camera/frame', jpeg, 'image/jpeg')[0], 200)
|
||||
self.assertEqual(self.request('/camera/frame'), (200, jpeg))
|
||||
self.server.controle.frame_at -= 4
|
||||
self.assertEqual(self.request('/camera/frame')[0], 204)
|
||||
self.request('/camera/frame', jpeg, 'image/jpeg')
|
||||
self.request('/app/config', {'preview': False})
|
||||
self.assertEqual(self.request('/camera/frame')[0], 204)
|
||||
self.assertEqual(self.request('/camera/frame', b'\xff\xd8x\xff\xd9', 'image/jpeg')[0], 404)
|
||||
self.assertEqual(self.request('/camera/frame')[0], 404)
|
||||
self.assertEqual(self.request('/app/connect', {'ip': '127.0.0.1'})[0], 404)
|
||||
|
||||
def test_rejects_non_jpeg_and_unknown_settings(self):
|
||||
self.request('/app/config', {'preview': True})
|
||||
self.assertEqual(self.request('/camera/frame', b'hello', 'image/jpeg')[0], 400)
|
||||
self.assertEqual(self.request('/app/config', {'preview': 'false'})[0], 400)
|
||||
self.assertEqual(self.request('/app/config', {'extra': 1})[0], 400)
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
from playwright.sync_api import sync_playwright
|
||||
import json
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
calls = []
|
||||
rows = [dict(nome=f'Servo {i+1}',canal=i,minimo=1400,maximo=1600,centro=1500) for i in range(6)]
|
||||
def handle(route):
|
||||
if route.request.method == 'POST': calls.append((route.request.url, route.request.post_data_json))
|
||||
route.fulfill(content_type='application/json',body=json.dumps(dict(disponivel=True,erro='',servos=rows,ativos=[])))
|
||||
page.route('**/servos/status', handle)
|
||||
page.route('**/servos/mover', handle)
|
||||
page.route('**/servos/parar', handle)
|
||||
page.goto('http://192.168.1.35:8100/servos')
|
||||
page.wait_for_selector('.card')
|
||||
live = page.locator('#live')
|
||||
assert live.count() == 1, 'Modo ao vivo ausente'
|
||||
assert not live.is_checked()
|
||||
slider = page.locator('input[type=range]').first
|
||||
slider.evaluate("e=>{e.value=1450;e.dispatchEvent(new Event('input'))}")
|
||||
page.wait_for_timeout(200)
|
||||
assert calls == []
|
||||
live.check()
|
||||
assert calls == [], 'Ativar modo não deve mover'
|
||||
slider.evaluate("e=>{for(let i=0;i<30;i++){e.value=1400+(i%20)*5;e.dispatchEvent(new Event('input'))}e.value=1600;e.dispatchEvent(new Event('input'))}")
|
||||
page.wait_for_timeout(500)
|
||||
moves = [data for url,data in calls if url.endswith('/mover')]
|
||||
assert 1 <= len(moves) <= 6, len(moves)
|
||||
assert moves[-1] == dict(indice=0,pulso=1600)
|
||||
page.locator('#stop').click()
|
||||
page.wait_for_timeout(200)
|
||||
assert not live.is_checked()
|
||||
assert calls[-1][0].endswith('/parar')
|
||||
count = len(calls)
|
||||
slider.evaluate("e=>{e.value=1400;e.dispatchEvent(new Event('input'))}")
|
||||
page.wait_for_timeout(200)
|
||||
assert len(calls) == count
|
||||
page.locator('input[type=text]').first.fill('Teste')
|
||||
assert live.is_disabled()
|
||||
print('OK: live opt-in, limite de envio, último valor, parada cancela fila, alterações bloqueiam; hardware simulado')
|
||||
browser.close()
|
||||
@@ -1,9 +0,0 @@
|
||||
const assert=require('node:assert/strict');
|
||||
const {sequencias}=require('../movimentos-core.js');
|
||||
const ranges=Array.from({length:7},()=>[81,99]);
|
||||
const moves=sequencias(ranges);
|
||||
assert.equal(moves.length,5);
|
||||
for(const move of moves){assert.ok(move.passos.length>=3);for(const step of move.passos){assert.equal(step.length,7);step.forEach(v=>assert.ok(v>=81&&v<=99));}}
|
||||
assert.ok(moves.some(m=>m.passos.some(s=>s.filter(v=>v!==90).length>=3)));
|
||||
assert.deepEqual(moves[0].passos.at(-1),Array(7).fill(90));
|
||||
console.log('OK: cinco sequências, limites e coordenação');
|
||||
@@ -1,19 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
let R;
|
||||
try { R = require('../rastreamento-core.js'); } catch(e) { assert.fail('Rastreamento ausente'); }
|
||||
const faces = [{x:.2,y:.1,w:.3,h:.4,score:.9}];
|
||||
const hands = [Array.from({length:21},()=>({x:.8,y:.6}))];
|
||||
assert.equal(R.target(faces,hands).tipo,'mao');
|
||||
assert.equal(R.target(faces,[]).tipo,'rosto');
|
||||
assert.equal(R.target([],[]),null);
|
||||
assert.equal(R.target([{x:NaN,y:0,w:1,h:1}],[]),null);
|
||||
const rect = R.fit(1000,500,640,480);
|
||||
assert.ok(Math.abs(rect.x - (1000/2 - (500*640/480)/2)) < 1e-9);
|
||||
assert.equal(rect.y,0);
|
||||
assert.equal(R.point({x:0,y:0},rect).x,rect.x);
|
||||
const gate = new R.FreshTarget();
|
||||
gate.accept({tipo:'mao',x:.8,y:.5,atividade:true},100);
|
||||
assert.equal(gate.get(200).x,.8);
|
||||
assert.equal(gate.get(451),null,'Alvo antigo precisa expirar');
|
||||
assert.equal(gate.accept({x:Infinity,y:0},500),false);
|
||||
console.log('Rastreamento: prioridade, coordenadas, validação e expiração OK');
|
||||
+31
-19
@@ -1,4 +1,6 @@
|
||||
"""Real WebRTC sender and panel in Chromium, using a synthetic camera."""
|
||||
"""WebRTC real entre o transmissor do app e o painel novo (web/dist), com câmera sintética.
|
||||
|
||||
Requer `npm run build` em web/."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@@ -27,6 +29,7 @@ def main():
|
||||
server = kiosk.Servidor(('127.0.0.1', 0), Handler)
|
||||
server.controle = kiosk.ControlePC(Path(tmp) / 'config.json')
|
||||
server.controle.atualizar({'preview': True,'rastrearRosto':False,'rastrearMaos':False})
|
||||
kiosk.ligar_eventos(server) # telemetria e ajustes chegam ao painel por SSE
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = 'http://127.0.0.1:' + str(server.server_port)
|
||||
@@ -62,40 +65,49 @@ def main():
|
||||
},200);
|
||||
}""")
|
||||
sender.request.post(base + '/app/status', data={'rosto':'carinha'})
|
||||
# Como o celular real, manda batimento a cada 1 s (senão o painel o considera offline).
|
||||
sender.evaluate("""() => setInterval(() => fetch('/app/status',{method:'POST',
|
||||
headers:{'Content-Type':'application/json'},body:JSON.stringify({rosto:'carinha'})}), 1000)""")
|
||||
panel = context.new_page()
|
||||
panel.on('pageerror', lambda e: errors.append(str(e)))
|
||||
panel.goto(base + '/painel')
|
||||
panel.wait_for_function("document.getElementById('cameraVideo').videoWidth > 0", timeout=20000)
|
||||
first = panel.evaluate("document.getElementById('cameraVideo').getVideoPlaybackQuality().totalVideoFrames")
|
||||
panel.goto(base + '/')
|
||||
video = "document.querySelector('.camera video')"
|
||||
panel.wait_for_function(video + ".videoWidth > 0", timeout=20000)
|
||||
first = panel.evaluate(video + ".getVideoPlaybackQuality().totalVideoFrames")
|
||||
panel.wait_for_timeout(2200)
|
||||
count = panel.evaluate("document.getElementById('cameraVideo').getVideoPlaybackQuality().totalVideoFrames") - first
|
||||
count = panel.evaluate(video + ".getVideoPlaybackQuality().totalVideoFrames") - first
|
||||
assert count >= 45, 'Vídeo não está fluido: %s quadros em 2.2 s' % count
|
||||
assert panel.locator('#cameraVideo').is_visible()
|
||||
assert 'fps' in panel.locator('#videoQualidade').inner_text()
|
||||
assert panel.locator('.camera video').is_visible()
|
||||
qualidade = panel.locator('.camera .rodape .mono').inner_text()
|
||||
assert 'fps' in qualidade, qualidade
|
||||
assert not errors, errors
|
||||
print('Vídeo WebRTC real: %s quadros em 2.2 s; %s' % (count, panel.locator('#videoQualidade').inner_text()))
|
||||
print('Vídeo WebRTC real: %s quadros em 2.2 s; %s' % (count, qualidade))
|
||||
# Canal de rastreamento (DataChannel) chega ao transmissor.
|
||||
sender.request.post(base+'/app/config',data={'rastrearMaos':True})
|
||||
sender.wait_for_function('config.rastrearMaos === true')
|
||||
panel.wait_for_function('CarinhaVideo.rastreamentoConectado()')
|
||||
panel.evaluate("CarinhaVideo.rastrear({tipo:'mao',x:.8,y:.3,atividade:true})")
|
||||
panel.wait_for_function('window.carinhaVideo?.rastreamentoConectado')
|
||||
panel.wait_for_timeout(1000) # sem vendor/ o rastreador falha e manda null uma vez
|
||||
panel.evaluate("carinhaVideo.rastrear({tipo:'mao',x:.8,y:.3,atividade:true})")
|
||||
sender.wait_for_function("window.testAlvo?.tipo === 'mao' && testAlvo.x === .8")
|
||||
panel.evaluate('CarinhaVideo.rastrear(null)')
|
||||
panel.evaluate('carinhaVideo.rastrear(null)')
|
||||
sender.wait_for_function('window.testAlvo === null')
|
||||
sender.request.post(base+'/app/config',data={'rastrearMaos':False})
|
||||
panel.locator('#ajuste-videoResolucao').select_option('480')
|
||||
# Ajustes chegam ao celular; o painel acompanha por SSE.
|
||||
sender.request.post(base+'/app/config',data={'videoResolucao':'480'})
|
||||
sender.wait_for_function("stream && stream.getVideoTracks()[0].getSettings().height === 480", timeout=10000)
|
||||
panel.locator('#ajuste-videoFPS').select_option('240')
|
||||
sender.request.post(base+'/app/config',data={'videoFPS':240})
|
||||
sender.wait_for_function("aviso.includes('indisponível')", timeout=10000)
|
||||
assert sender.evaluate('stream.getVideoTracks()[0].readyState') == 'live'
|
||||
panel.locator('#ajuste-sensorCamera').uncheck()
|
||||
panel.wait_for_function("document.getElementById('cameraVideo').srcObject === null", timeout=10000)
|
||||
sender.request.post(base+'/app/config',data={'sensorCamera':False})
|
||||
panel.wait_for_function(video + ".srcObject === null", timeout=10000)
|
||||
sender.wait_for_function('stream === null', timeout=10000)
|
||||
panel.locator('#ajuste-sensorCamera').check()
|
||||
panel.wait_for_function("document.getElementById('cameraVideo').videoWidth > 0 && document.getElementById('cameraVideo').srcObject !== null", timeout=15000)
|
||||
sender.request.post(base+'/app/config',data={'sensorCamera':True})
|
||||
panel.wait_for_function(video + ".videoWidth > 0 && " + video + ".srcObject !== null", timeout=15000)
|
||||
assert not errors, errors
|
||||
panel.get_by_role('button', name='Desligar câmera').click()
|
||||
# Desligar pela chave da tela Ao vivo.
|
||||
panel.get_by_role('switch', name='Transmitir câmera do celular').locator('xpath=..').click()
|
||||
panel.wait_for_timeout(1400)
|
||||
assert panel.evaluate("document.getElementById('cameraVideo').srcObject === null")
|
||||
assert panel.evaluate(video + ".srcObject === null")
|
||||
preview = context.new_page()
|
||||
preview.on('pageerror', lambda e: errors.append(str(e)))
|
||||
preview.goto(base + '/camera.html?lado=user&resolucao=480&fps=30')
|
||||
|
||||
@@ -35,7 +35,7 @@ def main():
|
||||
if not r.request.url.startswith(base): external.append(r.request.url);r.abort()
|
||||
else:r.continue_()
|
||||
page.route('**/*',route)
|
||||
page.goto(base+'/painel')
|
||||
page.goto(base+'/') # painel novo: nada pode sair para a internet
|
||||
results=page.evaluate("""async () => {
|
||||
const worker=new Worker('/rastreador-worker.js');
|
||||
const send=msg=>new Promise((resolve,reject)=>{
|
||||
@@ -56,36 +56,10 @@ def main():
|
||||
assert len(results['hand']['hands'][0])==21
|
||||
print('Inferencia real: %s rosto(s), %s mao(s); %.1f/%.1f ms' % (
|
||||
len(results['face']['faces']),len(results['hand']['hands']),results['face']['ms'],results['hand']['ms']))
|
||||
# Feed the same real worker into the panel, using a local canvas stream.
|
||||
page.request.post(base+'/app/config',data={'preview':True})
|
||||
page.request.post(base+'/app/status',data={'rosto':'carinha','dormindo':False})
|
||||
page.evaluate("""async () => {
|
||||
const image=await createImageBitmap(await (await fetch('/test-hands')).blob());
|
||||
const c=document.createElement('canvas');c.width=640;c.height=Math.round(640*image.height/image.width);
|
||||
const ctx=c.getContext('2d');ctx.drawImage(image,0,0,c.width,c.height);image.close();
|
||||
window.testStream=c.captureStream(30);
|
||||
window.testDraw=setInterval(()=>ctx.drawImage(c,0,0),33);
|
||||
const v=document.getElementById('cameraVideo');v.srcObject=testStream;v.hidden=false;await v.play();
|
||||
window.testTargets=[];
|
||||
window.CarinhaVideo={rastrear(t){testTargets.push(t)},parar(){},atualizar(){}};
|
||||
window.CarinhaRastreamento.atualizar({online:true,estado:{}}, {preview:true,sensorCamera:true,
|
||||
rastrearRosto:true,rastrearMaos:true,mostrarRastreamento:true,seguirRastreamento:true});
|
||||
}""")
|
||||
page.wait_for_function("window.CarinhaRastreamento.estado().result?.hands.length > 0",timeout=15000)
|
||||
assert page.locator('#trackingOverlay').is_visible()
|
||||
assert page.evaluate("testTargets.some(t=>t?.tipo==='mao')")
|
||||
work=ROOT.parents[1]/'work';page.screenshot(path=str(work/'rastreamento-teste.png'),full_page=True)
|
||||
page.locator('#ajuste-mostrarRastreamento').uncheck()
|
||||
page.wait_for_function("document.getElementById('trackingOverlay').hidden",timeout=5000)
|
||||
page.locator('#ajuste-seguirRastreamento').uncheck()
|
||||
page.wait_for_timeout(1200)
|
||||
assert page.evaluate('testTargets[testTargets.length-1] === null')
|
||||
page.evaluate("CarinhaRastreamento.atualizar({online:true,estado:{dormindo:true}},{preview:true,sensorCamera:true,cameraSono:false,rastrearRosto:true,rastrearMaos:true})")
|
||||
assert not page.evaluate('CarinhaRastreamento.estado().active')
|
||||
assert not external,external
|
||||
assert not errors,errors
|
||||
browser.close()
|
||||
print('Overlay, alvo, controles, sono e inferencia sem acesso externo: OK')
|
||||
print('Inferencia real no worker e painel sem acesso externo: OK')
|
||||
finally:
|
||||
server.shutdown();server.server_close();thread.join()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user