Compare commits
3
Commits
main
...
feat/web-shell
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4b53a8976 | ||
|
|
35d443a358 | ||
|
|
6c5bd6f432 |
@@ -22,3 +22,4 @@ api-privada.json
|
|||||||
api-privada.tmp
|
api-privada.tmp
|
||||||
|
|
||||||
ia-config.json
|
ia-config.json
|
||||||
|
seguranca.json
|
||||||
|
|||||||
+3
-8
@@ -2,6 +2,7 @@
|
|||||||
import json,os,re,threading,time
|
import json,os,re,threading,time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import gemini
|
import gemini
|
||||||
|
from comum import gravar_atomico, ler_json
|
||||||
|
|
||||||
class ConfigAPI:
|
class ConfigAPI:
|
||||||
def __init__(self,path):
|
def __init__(self,path):
|
||||||
@@ -25,9 +26,7 @@ class ConfigAPI:
|
|||||||
key=d.get('chave') or os.environ.get('GEMINI_API_KEY','')
|
key=d.get('chave') or os.environ.get('GEMINI_API_KEY','')
|
||||||
saved=dict(modelo=d['modelo'])
|
saved=dict(modelo=d['modelo'])
|
||||||
if key:saved['chave']=key
|
if key:saved['chave']=key
|
||||||
tmp=self.path.with_suffix('.tmp');fd=os.open(str(tmp),os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
|
gravar_atomico(self.path,saved,privado=True)
|
||||||
with os.fdopen(fd,'w',encoding='utf-8') as f:json.dump(saved,f)
|
|
||||||
os.chmod(tmp,0o600);tmp.replace(self.path);os.chmod(self.path,0o600)
|
|
||||||
gemini.MODELO=d['modelo']
|
gemini.MODELO=d['modelo']
|
||||||
if key:os.environ['GEMINI_API_KEY']=key
|
if key:os.environ['GEMINI_API_KEY']=key
|
||||||
self.ultimo_teste=None
|
self.ultimo_teste=None
|
||||||
@@ -47,11 +46,7 @@ class ConfigAPI:
|
|||||||
try:
|
try:
|
||||||
if method=='GET' and route.endswith('/status'):result=self.status()
|
if method=='GET' and route.endswith('/status'):result=self.status()
|
||||||
elif method=='POST':
|
elif method=='POST':
|
||||||
origin=h.headers.get('Origin')
|
d=ler_json(h,2048)
|
||||||
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
|
|
||||||
size=int(h.headers.get('Content-Length',0))
|
|
||||||
if not 0<size<=2048 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 2048 bytes')
|
|
||||||
d=json.loads(h.rfile.read(size))
|
|
||||||
if route.endswith('/salvar'):result=self.salvar(d)
|
if route.endswith('/salvar'):result=self.salvar(d)
|
||||||
elif route.endswith('/testar'):result=self.testar()
|
elif route.endswith('/testar'):result=self.testar()
|
||||||
else:raise ValueError('Rota inválida')
|
else:raise ValueError('Rota inválida')
|
||||||
|
|||||||
@@ -3,11 +3,25 @@ import copy
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from comum import Conflito, gravar_atomico, ler_json
|
||||||
from servos import integer
|
from servos import integer
|
||||||
|
|
||||||
NAMES=['Garra','Punho 1','Punho 2','Braço superior','Braço inferior','Antebraço','Base']
|
NAMES=['Garra','Punho 1','Punho 2','Braço superior','Braço inferior','Antebraço','Base']
|
||||||
CHANNELS=[0,1,2,3,4,5,7]
|
CHANNELS=[0,1,2,3,4,5,7]
|
||||||
|
LEASE_PADRAO=2000 # ms; o painel novo manda prazos curtos e renova enquanto o botão está pressionado
|
||||||
|
PASSO=.02 # 50 Hz de interpolação no servidor
|
||||||
|
|
||||||
|
def velocidade_valida(v):
|
||||||
|
if v is None: return None
|
||||||
|
if isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) or not 5<=v<=360:
|
||||||
|
raise ValueError('Velocidade deve ficar entre 5 e 360 graus/s')
|
||||||
|
return float(v)
|
||||||
|
|
||||||
|
def prazo_valido(ms):
|
||||||
|
if ms is None: return LEASE_PADRAO
|
||||||
|
return integer(ms,100,2000)
|
||||||
|
|
||||||
class Braco:
|
class Braco:
|
||||||
def __init__(self,path,servos):
|
def __init__(self,path,servos):
|
||||||
@@ -15,6 +29,8 @@ class Braco:
|
|||||||
self.config=dict(calibracao=[dict(canal=i,minimo=1400,maximo=1600) for i in range(8)],posturas=[])
|
self.config=dict(calibracao=[dict(canal=i,minimo=1400,maximo=1600) for i in range(8)],posturas=[])
|
||||||
try: self.config=self.validate(json.loads(self.path.read_text(encoding='utf-8')))
|
try: self.config=self.validate(json.loads(self.path.read_text(encoding='utf-8')))
|
||||||
except (OSError,ValueError,TypeError): pass
|
except (OSError,ValueError,TypeError): pass
|
||||||
|
self.atual=None; self.alvo=None; self.velocidade=None; self.lease=0
|
||||||
|
threading.Thread(target=self.loop,daemon=True).start()
|
||||||
def validate_pose(self,pose,calibration):
|
def validate_pose(self,pose,calibration):
|
||||||
if not isinstance(pose,list) or len(pose)!=7: raise ValueError('Esperava sete ângulos')
|
if not isinstance(pose,list) or len(pose)!=7: raise ValueError('Esperava sete ângulos')
|
||||||
pulses={}
|
pulses={}
|
||||||
@@ -53,27 +69,64 @@ class Braco:
|
|||||||
with self.servos.lock:
|
with self.servos.lock:
|
||||||
result=copy.deepcopy(self.config)
|
result=copy.deepcopy(self.config)
|
||||||
result.update(disponivel=self.servos.hw is not None,erro=self.servos.error,
|
result.update(disponivel=self.servos.hw is not None,erro=self.servos.error,
|
||||||
|
dono=self.servos.dono,travado=self.servos.travado,atual=self.atual,
|
||||||
|
movendo=self.alvo is not None,
|
||||||
articulacoes=[dict(nome=name,canal=ch) for name,ch in zip(NAMES,CHANNELS)])
|
articulacoes=[dict(nome=name,canal=ch) for name,ch in zip(NAMES,CHANNELS)])
|
||||||
return result
|
return result
|
||||||
def save(self,data):
|
def save(self,data):
|
||||||
clean=self.validate(data)
|
clean=self.validate(data)
|
||||||
with self.servos.lock:
|
with self.servos.lock:
|
||||||
if self.servos.active: raise ValueError('Pare os motores antes de salvar')
|
if self.servos.active: raise ValueError('Pare os motores antes de salvar')
|
||||||
tmp=self.path.with_suffix('.tmp'); tmp.write_text(json.dumps(clean,ensure_ascii=False,indent=2),encoding='utf-8'); tmp.replace(self.path)
|
gravar_atomico(self.path,clean)
|
||||||
self.config=clean
|
self.config=clean
|
||||||
return self.status()
|
return self.status()
|
||||||
def move(self,pose):
|
def escrever(self,pulses,prazo):
|
||||||
with self.servos.lock:
|
|
||||||
pulses=self.validate_pose(pose,self.config['calibracao'])
|
|
||||||
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
|
|
||||||
try:
|
try:
|
||||||
for channel,pulse in pulses.items():
|
for channel,pulse in pulses.items():
|
||||||
self.servos.hw.pulse(channel,pulse)
|
self.servos.hw.pulse(channel,pulse)
|
||||||
self.servos.active[channel]=time.monotonic()+2
|
self.servos.active[channel]=prazo
|
||||||
except OSError:
|
except OSError:
|
||||||
self.servos.stop()
|
self.alvo=None; self.servos.stop()
|
||||||
raise
|
raise
|
||||||
return dict(ok=True,pulsos=pulses)
|
def move(self,pose,velocidade=None,ttl_ms=None):
|
||||||
|
"""Sem velocidade: vai direto (compatível). Com velocidade: o servidor interpola até o alvo.
|
||||||
|
Em ambos os casos os motores desligam sozinhos quando o prazo (ttl_ms) vence sem renovação."""
|
||||||
|
velocidade=velocidade_valida(velocidade); ttl=prazo_valido(ttl_ms)
|
||||||
|
with self.servos.lock:
|
||||||
|
pulses=self.validate_pose(pose,self.config['calibracao'])
|
||||||
|
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
|
||||||
|
self.servos.reservar('braco')
|
||||||
|
prazo=time.monotonic()+ttl/1000
|
||||||
|
if velocidade is None or self.atual is None:
|
||||||
|
self.alvo=None
|
||||||
|
self.escrever(pulses,prazo)
|
||||||
|
self.atual=[float(a) for a in pose]
|
||||||
|
return dict(ok=True,pulsos=pulses,movendo=False)
|
||||||
|
self.alvo=[float(a) for a in pose]; self.velocidade=velocidade; self.lease=prazo
|
||||||
|
for channel in list(self.servos.active): self.servos.active[channel]=prazo
|
||||||
|
return dict(ok=True,pulsos=pulses,movendo=True)
|
||||||
|
def cancelar(self):
|
||||||
|
with self.servos.lock: self.alvo=None
|
||||||
|
def passo(self,agora,dt):
|
||||||
|
with self.servos.lock:
|
||||||
|
if self.alvo is None: return
|
||||||
|
if agora>=self.lease or self.servos.travado or (self.servos.dono!='braco' and self.servos.active):
|
||||||
|
self.alvo=None; return
|
||||||
|
delta=self.velocidade*dt
|
||||||
|
nova=[a+max(-delta,min(delta,t-a)) for a,t in zip(self.atual,self.alvo)]
|
||||||
|
try: pulses=self.validate_pose(nova,self.config['calibracao'])
|
||||||
|
except ValueError:
|
||||||
|
self.alvo=None; return
|
||||||
|
self.escrever(pulses,self.lease)
|
||||||
|
self.atual=nova
|
||||||
|
if nova==self.alvo: self.alvo=None
|
||||||
|
def loop(self):
|
||||||
|
anterior=time.monotonic()
|
||||||
|
while not self.servos.closed.wait(PASSO):
|
||||||
|
agora=time.monotonic()
|
||||||
|
try: self.passo(agora,agora-anterior)
|
||||||
|
except OSError as e: self.servos.error=str(e)
|
||||||
|
anterior=agora
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def angle_pulse(angle,center):
|
def angle_pulse(angle,center):
|
||||||
return round(500+angle*(center-500)/90 if angle<=90 else center+(angle-90)*(2500-center)/90)
|
return round(500+angle*(center-500)/90 if angle<=90 else center+(angle-90)*(2500-center)/90)
|
||||||
@@ -86,6 +139,7 @@ class Braco:
|
|||||||
outputs[other]=integer(center_sum-pulse,500,2500)
|
outputs[other]=integer(center_sum-pulse,500,2500)
|
||||||
with self.servos.lock:
|
with self.servos.lock:
|
||||||
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
|
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
|
||||||
|
self.servos.reservar('braco'); self.alvo=None; self.atual=None
|
||||||
for ch,us in outputs.items():
|
for ch,us in outputs.items():
|
||||||
self.servos.hw.pulse(ch,us)
|
self.servos.hw.pulse(ch,us)
|
||||||
self.servos.active[ch]=time.monotonic()+2
|
self.servos.active[ch]=time.monotonic()+2
|
||||||
@@ -96,21 +150,16 @@ class Braco:
|
|||||||
try:
|
try:
|
||||||
if method=='GET' and route=='/braco/status': result=self.status()
|
if method=='GET' and route=='/braco/status': result=self.status()
|
||||||
elif method=='POST':
|
elif method=='POST':
|
||||||
origin=h.headers.get('Origin')
|
data=ler_json(h,65536)
|
||||||
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')): raise ValueError('Origem inválida')
|
|
||||||
if h.headers.get('Content-Type','').split(';')[0]!='application/json': raise ValueError('Use JSON')
|
|
||||||
size=int(h.headers.get('Content-Length','0'))
|
|
||||||
if not 0<size<=65536: raise ValueError('Tamanho inválido')
|
|
||||||
data=json.loads(h.rfile.read(size))
|
|
||||||
if not isinstance(data,dict): raise ValueError('Objeto obrigatório')
|
|
||||||
if route=='/braco/config': result=self.save(data)
|
if route=='/braco/config': result=self.save(data)
|
||||||
elif route=='/braco/mover': result=self.move(data.get('angulos'))
|
elif route=='/braco/mover': result=self.move(data.get('angulos'),data.get('velocidade'),data.get('ttl_ms'))
|
||||||
elif route=='/braco/calibrar': result=self.calibrate(data.get('canal'),data.get('pulso'))
|
elif route=='/braco/calibrar': result=self.calibrate(data.get('canal'),data.get('pulso'))
|
||||||
elif route=='/braco/parar': result=self.servos.stop()
|
elif route=='/braco/parar': self.cancelar(); result=self.servos.stop()
|
||||||
else: raise ValueError('Rota inválida')
|
else: raise ValueError('Rota inválida')
|
||||||
else:
|
else:
|
||||||
h.enviar(json.dumps(dict(erro='Método inválido')),codigo=405); return True
|
h.enviar(json.dumps(dict(erro='Método inválido')),codigo=405); return True
|
||||||
h.enviar(json.dumps(result,ensure_ascii=False))
|
h.enviar(json.dumps(result,ensure_ascii=False))
|
||||||
|
except Conflito as e: h.enviar(json.dumps(dict(erro=str(e)),ensure_ascii=False),codigo=409)
|
||||||
except (ValueError,TypeError) as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=400)
|
except (ValueError,TypeError) as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=400)
|
||||||
except OSError as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=503)
|
except OSError as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=503)
|
||||||
return True
|
return True
|
||||||
|
|||||||
+15
-1
@@ -424,6 +424,9 @@ function trata(ordem) {
|
|||||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||||
} else if (SEQS[ordem]) {
|
} else if (SEQS[ordem]) {
|
||||||
animar(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") {
|
} else if (ordem === "parar") {
|
||||||
try { window.close(); } catch (e) { r.fila = []; }
|
try { window.close(); } catch (e) { r.fila = []; }
|
||||||
}
|
}
|
||||||
@@ -484,7 +487,18 @@ async function aplicarAparencia() {
|
|||||||
} catch (e) { /* sem servidor — segue com o padrao */ }
|
} catch (e) { /* sem servidor — segue com o padrao */ }
|
||||||
}
|
}
|
||||||
setTimeout(aplicarAparencia, 200);
|
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)
|
// teclado (util para testar no PC)
|
||||||
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
|
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
|
||||||
|
|||||||
+7
-9
@@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from urllib.request import Request,urlopen
|
from urllib.request import Request,urlopen
|
||||||
|
from comum import gravar_atomico, ler_json
|
||||||
|
|
||||||
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo')
|
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo')
|
||||||
EVENTOS=('som','movimento','saudacao','sozinho')
|
EVENTOS=('som','movimento','saudacao','sozinho')
|
||||||
@@ -20,15 +21,17 @@ class Cerebro:
|
|||||||
self.executor='S10' if '192.168.1.16' in self.endpoint else 'Orange Pi'
|
self.executor='S10' if '192.168.1.16' in self.endpoint else 'Orange Pi'
|
||||||
self.lock=threading.Lock();self.ativo=False;self.pending=None;self.busy=False
|
self.lock=threading.Lock();self.ativo=False;self.pending=None;self.busy=False
|
||||||
self.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
|
self.conversando=False;self.history=[];self.result=None;self.error='';self.last=0;self.generation=0
|
||||||
|
self.publicar=lambda tipo,dados:None
|
||||||
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
|
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
|
||||||
except (OSError,ValueError,KeyError,TypeError):pass
|
except (OSError,ValueError,KeyError,TypeError):pass
|
||||||
threading.Thread(target=self.loop,daemon=True).start()
|
threading.Thread(target=self.loop,daemon=True).start()
|
||||||
def config(self,ativo):
|
def config(self,ativo):
|
||||||
if type(ativo) is not bool:raise ValueError('ativo deve ser booleano')
|
if type(ativo) is not bool:raise ValueError('ativo deve ser booleano')
|
||||||
with self.lock:
|
with self.lock:
|
||||||
tmp=self.path.with_suffix('.tmp');tmp.write_text(json.dumps(dict(ativo=ativo)));tmp.replace(self.path)
|
gravar_atomico(self.path,dict(ativo=ativo))
|
||||||
self.ativo=ativo;self.generation+=1;self.pending=None
|
self.ativo=ativo;self.generation+=1;self.pending=None
|
||||||
return self.status()
|
status=self.status();self.publicar('ia',status)
|
||||||
|
return status
|
||||||
def status(self):
|
def status(self):
|
||||||
with self.lock:return dict(ativo=self.ativo,ocupado=self.busy,modelo='Qwen3-0.6B Q4_K_M',executor=self.executor,ultima=self.result,erro=self.error)
|
with self.lock:return dict(ativo=self.ativo,ocupado=self.busy,modelo='Qwen3-0.6B Q4_K_M',executor=self.executor,ultima=self.result,erro=self.error)
|
||||||
def event(self,event):
|
def event(self,event):
|
||||||
@@ -77,6 +80,7 @@ class Cerebro:
|
|||||||
finally:
|
finally:
|
||||||
self.last=time.monotonic()
|
self.last=time.monotonic()
|
||||||
with self.lock:self.busy=False
|
with self.lock:self.busy=False
|
||||||
|
self.publicar('ia',self.status())
|
||||||
def rotas(self,h,method):
|
def rotas(self,h,method):
|
||||||
route=h.path.split('?',1)[0]
|
route=h.path.split('?',1)[0]
|
||||||
if route=='/ia/modelo' and method=='GET':
|
if route=='/ia/modelo' and method=='GET':
|
||||||
@@ -95,13 +99,7 @@ class Cerebro:
|
|||||||
try:
|
try:
|
||||||
if method=='GET' and route=='/ia/status':result=self.status()
|
if method=='GET' and route=='/ia/status':result=self.status()
|
||||||
elif method=='POST':
|
elif method=='POST':
|
||||||
origin=h.headers.get('Origin')
|
data=ler_json(h,2048)
|
||||||
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
|
|
||||||
if h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON')
|
|
||||||
size=int(h.headers.get('Content-Length','0'))
|
|
||||||
if not 0<size<=2048:raise ValueError('Tamanho inválido')
|
|
||||||
data=json.loads(h.rfile.read(size))
|
|
||||||
if not isinstance(data,dict):raise ValueError('Objeto obrigatório')
|
|
||||||
if route=='/ia/config':result=self.config(data.get('ativo'))
|
if route=='/ia/config':result=self.config(data.get('ativo'))
|
||||||
elif route=='/ia/evento':result=self.event(data.get('evento'))
|
elif route=='/ia/evento':result=self.event(data.get('evento'))
|
||||||
else:raise ValueError('Rota inválida')
|
else:raise ValueError('Rota inválida')
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Peças compartilhadas pelos módulos HTTP: leitura de JSON, gravação atômica e token."""
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
class Conflito(Exception):
|
||||||
|
"""Pedido válido, mas o estado atual não permite (vira HTTP 409)."""
|
||||||
|
|
||||||
|
|
||||||
|
def origem_valida(h):
|
||||||
|
origin = h.headers.get('Origin')
|
||||||
|
host = h.headers.get('Host', '')
|
||||||
|
return not origin or origin in ('http://' + host, 'https://' + host)
|
||||||
|
|
||||||
|
|
||||||
|
def ler_json(h, limite):
|
||||||
|
"""Corpo JSON de um POST do painel; ValueError com mensagem legível se inválido."""
|
||||||
|
if not origem_valida(h):
|
||||||
|
raise ValueError('Origem inválida')
|
||||||
|
if h.headers.get('Content-Type', '').split(';')[0] != 'application/json':
|
||||||
|
raise ValueError('Use JSON')
|
||||||
|
tamanho = int(h.headers.get('Content-Length', '0'))
|
||||||
|
if not 0 < tamanho <= limite:
|
||||||
|
raise ValueError(f'Corpo vazio ou maior que {limite} bytes')
|
||||||
|
dados = json.loads(h.rfile.read(tamanho))
|
||||||
|
if not isinstance(dados, dict):
|
||||||
|
raise ValueError('Objeto JSON obrigatório')
|
||||||
|
return dados
|
||||||
|
|
||||||
|
|
||||||
|
def gravar_atomico(caminho, obj, privado=False):
|
||||||
|
caminho = Path(caminho)
|
||||||
|
tmp = caminho.with_suffix('.tmp')
|
||||||
|
texto = json.dumps(obj, ensure_ascii=False, indent=2)
|
||||||
|
if privado:
|
||||||
|
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(texto)
|
||||||
|
else:
|
||||||
|
tmp.write_text(texto, encoding='utf-8')
|
||||||
|
tmp.replace(caminho)
|
||||||
|
if privado:
|
||||||
|
os.chmod(caminho, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def carregar_token(pasta):
|
||||||
|
"""CARINHA_TOKEN do ambiente ou {"token": ...} em seguranca.json; None = sem autenticação."""
|
||||||
|
token = os.environ.get('CARINHA_TOKEN', '').strip()
|
||||||
|
if not token:
|
||||||
|
try:
|
||||||
|
token = str(json.loads((Path(pasta) / 'seguranca.json').read_text(encoding='utf-8')).get('token', '')).strip()
|
||||||
|
except (OSError, ValueError, AttributeError):
|
||||||
|
token = ''
|
||||||
|
if token and len(token) < 8:
|
||||||
|
raise ValueError('CARINHA_TOKEN precisa ter pelo menos 8 caracteres')
|
||||||
|
return token or None
|
||||||
|
|
||||||
|
|
||||||
|
def token_do_pedido(h):
|
||||||
|
auth = h.headers.get('Authorization', '')
|
||||||
|
if auth.lower().startswith('bearer '):
|
||||||
|
return auth[7:].strip()
|
||||||
|
if h.headers.get('X-Carinha-Token'):
|
||||||
|
return h.headers['X-Carinha-Token'].strip()
|
||||||
|
# EventSource e o app Android não mandam cabeçalhos próprios com facilidade.
|
||||||
|
return parse_qs(urlsplit(h.path).query).get('t', [''])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def autorizado(h):
|
||||||
|
esperado = getattr(h.server, 'token', None)
|
||||||
|
if not esperado:
|
||||||
|
return True
|
||||||
|
return hmac.compare_digest(token_do_pedido(h).encode(), esperado.encode())
|
||||||
+34
-13
@@ -7,8 +7,16 @@ from pathlib import Path
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from comum import origem_valida
|
||||||
from sinalizacao import Sinalizacao
|
from sinalizacao import Sinalizacao
|
||||||
|
|
||||||
|
|
||||||
|
def versao():
|
||||||
|
try:
|
||||||
|
return json.loads((Path(__file__).with_name('version.json')).read_text(encoding='utf-8'))['versao']
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return 'desconhecida'
|
||||||
|
|
||||||
PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
|
PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
|
||||||
ouvir=True, reagir=True, somlim=0.35, somganho=0.4,
|
ouvir=True, reagir=True, somlim=0.35, somganho=0.4,
|
||||||
somaltolim=0.55, sompausa=20, somcooldown=5,
|
somaltolim=0.55, sompausa=20, somcooldown=5,
|
||||||
@@ -61,6 +69,7 @@ class ControlePC:
|
|||||||
self.frame_at = 0
|
self.frame_at = 0
|
||||||
self.frame_seq = 0
|
self.frame_seq = 0
|
||||||
self.rtc = Sinalizacao()
|
self.rtc = Sinalizacao()
|
||||||
|
self.publicar = lambda tipo, dados: None
|
||||||
try:
|
try:
|
||||||
self.config.update(validar(json.loads(self.arquivo.read_text(encoding='utf-8'))))
|
self.config.update(validar(json.loads(self.arquivo.read_text(encoding='utf-8'))))
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
@@ -80,11 +89,22 @@ class ControlePC:
|
|||||||
self.frame_at = 0
|
self.frame_at = 0
|
||||||
return novo.copy()
|
return novo.copy()
|
||||||
|
|
||||||
|
def resumo(self, host=''):
|
||||||
|
"""Estado do celular para o painel (GET /app/status, /estado e evento 'telemetria')."""
|
||||||
|
with self.lock:
|
||||||
|
agora = time.monotonic()
|
||||||
|
idade = agora - self.status_at
|
||||||
|
return dict(online=idade < 5, idade=round(idade, 1) if self.status_at else None,
|
||||||
|
celular_ip=self.celular_ip, servidor_ip=host, recarga=self.recarga.copy(),
|
||||||
|
estado=self.estado.copy(), preview=self.config['preview'],
|
||||||
|
camera_recente=bool(self.frame) and agora - self.frame_at < 3 and self.config['preview'],
|
||||||
|
frame_seq=self.frame_seq)
|
||||||
|
|
||||||
def rotas(self, h, metodo):
|
def rotas(self, h, metodo):
|
||||||
if self.rtc.atender(h, metodo, self.config['preview']):
|
if self.rtc.atender(h, metodo, self.config['preview']):
|
||||||
return True
|
return True
|
||||||
rota = h.path.split('?', 1)[0].rstrip('/')
|
rota = h.path.split('?', 1)[0].rstrip('/')
|
||||||
if rota not in ('/app/config', '/app/status', '/camera/frame', '/app/connect', '/app/health'):
|
if rota not in ('/app/config', '/app/padrao', '/app/status', '/camera/frame', '/app/connect', '/app/health'):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def enviar(dados, codigo=200):
|
def enviar(dados, codigo=200):
|
||||||
@@ -92,6 +112,10 @@ class ControlePC:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if metodo == 'POST':
|
if metodo == 'POST':
|
||||||
|
if not origem_valida(h):
|
||||||
|
raise ValueError('Origem inválida')
|
||||||
|
if rota == '/app/config' and h.headers.get('Content-Type', '').split(';')[0] != 'application/json':
|
||||||
|
raise ValueError('Use JSON')
|
||||||
tamanho = int(h.headers.get('Content-Length', '0'))
|
tamanho = int(h.headers.get('Content-Length', '0'))
|
||||||
limite = 1_000_000 if rota == '/camera/frame' else 16384
|
limite = 1_000_000 if rota == '/camera/frame' else 16384
|
||||||
if tamanho <= 0 or tamanho > limite:
|
if tamanho <= 0 or tamanho > limite:
|
||||||
@@ -104,11 +128,15 @@ class ControlePC:
|
|||||||
else:
|
else:
|
||||||
dados = None
|
dados = None
|
||||||
|
|
||||||
if rota == '/app/health':
|
if rota == '/app/padrao':
|
||||||
enviar({'projeto': 'carinha-pc', 'versao': '0.7.0'})
|
enviar(PADRAO)
|
||||||
|
elif rota == '/app/health':
|
||||||
|
enviar({'projeto': 'carinha-pc', 'versao': versao()})
|
||||||
elif rota == '/app/config':
|
elif rota == '/app/config':
|
||||||
if metodo == 'POST':
|
if metodo == 'POST':
|
||||||
enviar({'ok': True, 'config': self.atualizar(dados)})
|
config = self.atualizar(dados)
|
||||||
|
self.publicar('app_config', config)
|
||||||
|
enviar({'ok': True, 'config': config})
|
||||||
else:
|
else:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
config = self.config.copy()
|
config = self.config.copy()
|
||||||
@@ -123,17 +151,10 @@ class ControlePC:
|
|||||||
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
|
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
|
||||||
self.recarga["confirmada"] = True
|
self.recarga["confirmada"] = True
|
||||||
self.status_at = time.monotonic()
|
self.status_at = time.monotonic()
|
||||||
|
self.publicar('telemetria', self.resumo(h.headers.get("Host", "").split(":")[0]))
|
||||||
enviar({'ok': True})
|
enviar({'ok': True})
|
||||||
else:
|
else:
|
||||||
with self.lock:
|
enviar(self.resumo(h.headers.get("Host", "").split(":")[0]))
|
||||||
idade = time.monotonic() - self.status_at
|
|
||||||
frame_idade = time.monotonic() - self.frame_at
|
|
||||||
resultado = dict(online=idade < 5, idade=round(idade, 1) if self.status_at else None,
|
|
||||||
celular_ip=self.celular_ip, servidor_ip=h.headers.get("Host", "").split(":")[0], recarga=self.recarga.copy(),
|
|
||||||
estado=self.estado.copy(), preview=self.config['preview'],
|
|
||||||
camera_recente=bool(self.frame) and frame_idade < 3 and self.config['preview'],
|
|
||||||
frame_seq=self.frame_seq)
|
|
||||||
enviar(resultado)
|
|
||||||
elif rota == '/camera/frame':
|
elif rota == '/camera/frame':
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if metodo == 'POST':
|
if metodo == 'POST':
|
||||||
|
|||||||
+20
-9
@@ -6,14 +6,18 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import re
|
import re
|
||||||
|
from collections import deque
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
from cerebro import GESTOS
|
from cerebro import GESTOS
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from gemini import responder_gemini
|
from gemini import responder_gemini
|
||||||
|
from comum import gravar_atomico, ler_json
|
||||||
import gemini
|
import gemini
|
||||||
|
|
||||||
|
FIGURINHAS_POR_HORA = 6 # cada figurinha automática é mais uma chamada paga ao Gemini
|
||||||
|
|
||||||
def resposta_factual(texto,agora=None):
|
def resposta_factual(texto,agora=None):
|
||||||
normal=''.join(c for c in unicodedata.normalize('NFD',texto.lower()) if not unicodedata.combining(c))
|
normal=''.join(c for c in unicodedata.normalize('NFD',texto.lower()) if not unicodedata.combining(c))
|
||||||
normal=' '.join(normal.strip(' ?!.').split())
|
normal=' '.join(normal.strip(' ?!.').split())
|
||||||
@@ -93,6 +97,14 @@ class Conversa:
|
|||||||
if saved.get('provedor') in ('local','gemini'):self.provedor=saved['provedor']
|
if saved.get('provedor') in ('local','gemini'):self.provedor=saved['provedor']
|
||||||
except (OSError,ValueError,AttributeError):pass
|
except (OSError,ValueError,AttributeError):pass
|
||||||
self.lock=threading.Lock();self.busy=False;self.ultima=None;self.erro='';self.generation=0
|
self.lock=threading.Lock();self.busy=False;self.ultima=None;self.erro='';self.generation=0
|
||||||
|
self.publicar=lambda tipo,dados:None;self.figurinhas_auto=deque()
|
||||||
|
def avisar(self):
|
||||||
|
self.publicar('conversa',self.status())
|
||||||
|
def pode_gerar_figurinha(self,agora=None):
|
||||||
|
agora=agora or time.time()
|
||||||
|
while self.figurinhas_auto and agora-self.figurinhas_auto[0]>3600:self.figurinhas_auto.popleft()
|
||||||
|
if len(self.figurinhas_auto)>=FIGURINHAS_POR_HORA:return False
|
||||||
|
self.figurinhas_auto.append(agora);return True
|
||||||
def status(self):
|
def status(self):
|
||||||
with self.lock:return dict(provedor=self.provedor,modelo=gemini.MODELO if self.provedor=='gemini' else 'Qwen3-0.6B',gemini_configurado=bool(os.environ.get('GEMINI_API_KEY')),ocupado=self.busy,ultima=self.ultima,erro=self.erro,historico=self.memoria.historico(),lembrancas=self.memoria.listar())
|
with self.lock:return dict(provedor=self.provedor,modelo=gemini.MODELO if self.provedor=='gemini' else 'Qwen3-0.6B',gemini_configurado=bool(os.environ.get('GEMINI_API_KEY')),ocupado=self.busy,ultima=self.ultima,erro=self.erro,historico=self.memoria.historico(),lembrancas=self.memoria.listar())
|
||||||
def enviar(self,texto):
|
def enviar(self,texto):
|
||||||
@@ -108,7 +120,9 @@ class Conversa:
|
|||||||
self.memoria.registrar('assistant',factual['texto'],2 if self.provedor=='gemini' else 1)
|
self.memoria.registrar('assistant',factual['texto'],2 if self.provedor=='gemini' else 1)
|
||||||
self.ultima=dict(factual,id=time.time_ns(),horario=time.time(),segundos=0,fonte='relogio')
|
self.ultima=dict(factual,id=time.time_ns(),horario=time.time(),segundos=0,fonte='relogio')
|
||||||
self.busy=False;self.cerebro.conversando=False
|
self.busy=False;self.cerebro.conversando=False
|
||||||
return dict(ok=True)
|
factual=True
|
||||||
|
self.avisar()
|
||||||
|
if factual is True:return dict(ok=True)
|
||||||
threading.Thread(target=self.responder,args=(generation,),daemon=True).start()
|
threading.Thread(target=self.responder,args=(generation,),daemon=True).start()
|
||||||
return dict(ok=True)
|
return dict(ok=True)
|
||||||
def responder(self,generation):
|
def responder(self,generation):
|
||||||
@@ -140,32 +154,28 @@ class Conversa:
|
|||||||
self.ultima=dict(result,id=time.time_ns(),horario=time.time(),segundos=round(time.monotonic()-inicio,2))
|
self.ultima=dict(result,id=time.time_ns(),horario=time.time(),segundos=round(time.monotonic()-inicio,2))
|
||||||
if self.provedor=='gemini' and treino:
|
if self.provedor=='gemini' and treino:
|
||||||
if type(reaction) is int and reaction in [r['id'] for r in catalog]:treino.testar(reaction)
|
if type(reaction) is int and reaction in [r['id'] for r in catalog]:treino.testar(reaction)
|
||||||
elif not treino.status()['ocupado']:
|
elif not treino.status()['ocupado'] and self.pode_gerar_figurinha():
|
||||||
treino.gerar_figurinha(history[-1]['content'])
|
treino.gerar_figurinha(history[-1]['content'])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with self.lock:self.erro=str(e)[:200] if generation==self.generation else ''
|
with self.lock:self.erro=str(e)[:200] if generation==self.generation else ''
|
||||||
finally:
|
finally:
|
||||||
with self.lock,self.cerebro.lock:
|
with self.lock,self.cerebro.lock:
|
||||||
self.busy=False;self.cerebro.conversando=False;self.cerebro.last=time.monotonic()
|
self.busy=False;self.cerebro.conversando=False;self.cerebro.last=time.monotonic()
|
||||||
|
self.avisar()
|
||||||
def rotas(self,h,method):
|
def rotas(self,h,method):
|
||||||
route=h.path.split('?',1)[0]
|
route=h.path.split('?',1)[0]
|
||||||
if not route.startswith('/conversa/'):return False
|
if not route.startswith('/conversa/'):return False
|
||||||
try:
|
try:
|
||||||
if method=='GET' and route=='/conversa/status':result=self.status()
|
if method=='GET' and route=='/conversa/status':result=self.status()
|
||||||
elif method=='POST':
|
elif method=='POST':
|
||||||
origin=h.headers.get('Origin')
|
data=ler_json(h,4096)
|
||||||
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
|
|
||||||
size=int(h.headers.get('Content-Length','0'))
|
|
||||||
if not 0<size<=4096 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 4096 bytes')
|
|
||||||
data=json.loads(h.rfile.read(size))
|
|
||||||
if not isinstance(data,dict):raise ValueError('Objeto obrigatório')
|
|
||||||
if route=='/conversa/config':
|
if route=='/conversa/config':
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.busy:raise ValueError('Aguarde a resposta antes de trocar a IA')
|
if self.busy:raise ValueError('Aguarde a resposta antes de trocar a IA')
|
||||||
provider=data.get('provedor')
|
provider=data.get('provedor')
|
||||||
if provider not in ('local','gemini'):raise ValueError('Provedor inválido')
|
if provider not in ('local','gemini'):raise ValueError('Provedor inválido')
|
||||||
if provider=='gemini' and not os.environ.get('GEMINI_API_KEY'):raise ValueError('Chave Gemini não configurada')
|
if provider=='gemini' and not os.environ.get('GEMINI_API_KEY'):raise ValueError('Chave Gemini não configurada')
|
||||||
tmp=self.config_path.with_suffix('.tmp');tmp.write_text(json.dumps(dict(provedor=provider)));tmp.replace(self.config_path)
|
gravar_atomico(self.config_path,dict(provedor=provider))
|
||||||
self.provedor=provider
|
self.provedor=provider
|
||||||
result=self.status()
|
result=self.status()
|
||||||
elif route=='/conversa/enviar':result=self.enviar(data.get('texto'))
|
elif route=='/conversa/enviar':result=self.enviar(data.get('texto'))
|
||||||
@@ -183,6 +193,7 @@ class Conversa:
|
|||||||
else:self.memoria.apagar(data.get('id'))
|
else:self.memoria.apagar(data.get('id'))
|
||||||
result=dict(ok=True)
|
result=dict(ok=True)
|
||||||
else:raise ValueError('Rota inválida')
|
else:raise ValueError('Rota inválida')
|
||||||
|
if route!='/conversa/enviar':self.avisar()
|
||||||
else:raise ValueError('Método inválido')
|
else:raise ValueError('Método inválido')
|
||||||
h.enviar(json.dumps(result,ensure_ascii=False))
|
h.enviar(json.dumps(result,ensure_ascii=False))
|
||||||
except (ValueError,TypeError) as e:h.enviar(json.dumps(dict(erro=str(e)),ensure_ascii=False),codigo=400)
|
except (ValueError,TypeError) as e:h.enviar(json.dumps(dict(erro=str(e)),ensure_ascii=False),codigo=400)
|
||||||
|
|||||||
+17
-5
@@ -1,9 +1,21 @@
|
|||||||
# Repositório do Carinha
|
# Repositório do Carinha
|
||||||
|
|
||||||
Código em https://git.roterizacao.cloud/PSAutomation/painel-ia-gemini, branch `carinha-orange-pi`, pasta `carinha/`.
|
Código em https://git.roterizacao.cloud/julio/carinha-control-panel, branch `main`.
|
||||||
O repositório já contém outro sistema; sua branch principal foi preservada.
|
(Antes vivia em `PSAutomation/painel-ia-gemini`, branch `carinha-orange-pi`, pasta `carinha/`; aquele repositório não é mais a fonte.)
|
||||||
|
|
||||||
Credenciais, chave de assinatura Android, bases de conversa, logs, configurações do aparelho e arquivos gerados não são publicados.
|
## Credenciais do Git
|
||||||
|
|
||||||
|
Nunca coloque usuário/senha na URL do remote (`https://usuario:senha@...`): ela fica em texto claro em `.git/config`.
|
||||||
|
Use a URL limpa e deixe o Git Credential Manager guardar o login:
|
||||||
|
|
||||||
|
```
|
||||||
|
git remote set-url origin https://git.roterizacao.cloud/julio/carinha-control-panel.git
|
||||||
|
git config --global credential.helper manager
|
||||||
|
```
|
||||||
|
|
||||||
|
No primeiro `git push` o GCM pede o login (ou um token de acesso do Gitea, preferível à senha) e o guarda no cofre do Windows.
|
||||||
|
|
||||||
|
## O que não é publicado
|
||||||
|
|
||||||
|
Credenciais, chave de assinatura Android, bases de conversa, logs, configurações do aparelho e arquivos gerados.
|
||||||
Modelos MediaPipe podem ser obtidos com `python obter-rastreamento.py`. As bibliotecas nativas llama.cpp ARM64 e a chave de assinatura Android precisam ser fornecidas separadamente para compilar o APK; permanecem na instalação atual. O servidor Python e a interface HTML estão no código versionado. Gemini usa GEMINI_API_KEY no ambiente do serviço.
|
Modelos MediaPipe podem ser obtidos com `python obter-rastreamento.py`. As bibliotecas nativas llama.cpp ARM64 e a chave de assinatura Android precisam ser fornecidas separadamente para compilar o APK; permanecem na instalação atual. O servidor Python e a interface HTML estão no código versionado. Gemini usa GEMINI_API_KEY no ambiente do serviço.
|
||||||
|
|
||||||
Esta publicação não altera a instalação ativa no Orange Pi.
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Segurança do servidor
|
||||||
|
|
||||||
|
## Token de controle
|
||||||
|
|
||||||
|
Com um token configurado, o servidor (porta 8100) exige `Authorization: Bearer <token>`
|
||||||
|
em todo POST e nas leituras de dados (`/eventos`, `/estado`, `/fila`, `/app/*`, `/rtc/*`,
|
||||||
|
`/camera/*`, `/conversa/*`, `/treinamento/*`, `/braco/*`, `/servos/*`, `/ia/status`, `/api/*`).
|
||||||
|
EventSource e o app Android podem mandar `?t=<token>` na URL.
|
||||||
|
|
||||||
|
Continuam públicos apenas: páginas estáticas, `/app/health`, `/faces`, `/gestos`,
|
||||||
|
`GET /config` (aparência), `/ia/modelo` e `/app.apk`.
|
||||||
|
|
||||||
|
Configure **um** dos dois (mínimo 8 caracteres):
|
||||||
|
|
||||||
|
```
|
||||||
|
# /etc/carinha.env, carregado pelo carinha-pc.service (EnvironmentFile=)
|
||||||
|
CARINHA_TOKEN=troque-por-um-segredo-longo
|
||||||
|
```
|
||||||
|
|
||||||
|
ou `seguranca.json` na pasta do servidor (fora do git):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"token": "troque-por-um-segredo-longo"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sem token configurado o servidor fica aberto na rede local, como antes.
|
||||||
|
|
||||||
|
> **Ordem de ativação.** O painel antigo e o `carinha.html`/APK atuais ainda não enviam token.
|
||||||
|
> Ative o token na placa só depois de publicar o painel novo e o rosto/APK com suporte a token
|
||||||
|
> (fases 2–5 do plano de modernização). Até lá o servidor já aplica as demais proteções:
|
||||||
|
> checagem de Origin em todos os POST, comandos validados e E-Stop.
|
||||||
|
|
||||||
|
## E-Stop
|
||||||
|
|
||||||
|
`POST /parar-tudo` desliga as 16 saídas do PCA9685, cancela a interpolação do braço,
|
||||||
|
para reações e manda `calma` ao rosto. Braço e servos ficam **travados** (HTTP 409) até
|
||||||
|
`POST /rearmar`.
|
||||||
|
|
||||||
|
## Trava de hardware
|
||||||
|
|
||||||
|
Braço (`/braco/*`) e diagnóstico (`/servos/*`) usam o mesmo PCA9685 com limites
|
||||||
|
diferentes. Quem estiver com motores ativos é o dono; o outro recebe 409 até os motores
|
||||||
|
desligarem (parar, ou ~2 s sem comando).
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
"""Hub Server-Sent Events: um único fluxo /eventos substitui os pollings do painel."""
|
||||||
|
from collections import deque
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
PING = 15
|
||||||
|
|
||||||
|
|
||||||
|
class Eventos:
|
||||||
|
def __init__(self, buffer=200):
|
||||||
|
self.cond = threading.Condition()
|
||||||
|
self.seq = 0
|
||||||
|
self.buffer = deque(maxlen=buffer)
|
||||||
|
self.fechado = False
|
||||||
|
|
||||||
|
def publicar(self, tipo, dados):
|
||||||
|
corpo = json.dumps(dados, ensure_ascii=False)
|
||||||
|
with self.cond:
|
||||||
|
self.seq += 1
|
||||||
|
self.buffer.append((self.seq, tipo, corpo))
|
||||||
|
self.cond.notify_all()
|
||||||
|
return self.seq
|
||||||
|
|
||||||
|
def desde(self, seq):
|
||||||
|
with self.cond:
|
||||||
|
return [e for e in self.buffer if e[0] > seq]
|
||||||
|
|
||||||
|
def fechar(self):
|
||||||
|
with self.cond:
|
||||||
|
self.fechado = True
|
||||||
|
self.cond.notify_all()
|
||||||
|
|
||||||
|
def atender(self, h):
|
||||||
|
url = urlsplit(h.path)
|
||||||
|
try:
|
||||||
|
ultimo = int(h.headers.get('Last-Event-ID') or parse_qs(url.query).get('desde', ['0'])[0])
|
||||||
|
except ValueError:
|
||||||
|
ultimo = 0
|
||||||
|
with self.cond:
|
||||||
|
# Cliente novo (ou que perdeu demais): começa do agora e lê /estado.
|
||||||
|
if ultimo <= 0 or (self.buffer and ultimo < self.buffer[0][0] - 1) or ultimo > self.seq:
|
||||||
|
ultimo = self.seq
|
||||||
|
h.send_response(200)
|
||||||
|
h.send_header('Content-Type', 'text/event-stream; charset=utf-8')
|
||||||
|
h.send_header('Cache-Control', 'no-store')
|
||||||
|
h.send_header('X-Accel-Buffering', 'no')
|
||||||
|
h.end_headers()
|
||||||
|
try:
|
||||||
|
h.wfile.write(f'retry: 3000\nevent: pronto\nid: {ultimo}\ndata: {{"seq": {ultimo}}}\n\n'.encode())
|
||||||
|
while True:
|
||||||
|
limite = time.monotonic() + PING
|
||||||
|
with self.cond:
|
||||||
|
while not self.fechado and (not self.buffer or self.buffer[-1][0] <= ultimo):
|
||||||
|
resta = limite - time.monotonic()
|
||||||
|
if resta <= 0:
|
||||||
|
break
|
||||||
|
self.cond.wait(resta)
|
||||||
|
if self.fechado:
|
||||||
|
return
|
||||||
|
novos = [e for e in self.buffer if e[0] > ultimo]
|
||||||
|
if not novos:
|
||||||
|
h.wfile.write(b': ping\n\n')
|
||||||
|
continue
|
||||||
|
for seq, tipo, corpo in novos:
|
||||||
|
h.wfile.write(f'id: {seq}\nevent: {tipo}\ndata: {corpo}\n\n'.encode())
|
||||||
|
ultimo = seq
|
||||||
|
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
|
||||||
|
return
|
||||||
+290
-110
@@ -1,38 +1,39 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
kiosk-server.py — servidor local do PC: painel, carinha e fila de ordens.
|
kiosk-server.py — servidor do Carinha (Orange Pi, porta 8100): painel, rosto e controle.
|
||||||
|
|
||||||
Alem de entregar os arquivos da pasta (padrao /root/carinha), ele expõe:
|
Estado em tempo real:
|
||||||
|
GET /eventos -> Server-Sent Events (expressao, telemetria, conversa, reacao,
|
||||||
|
ia, motores, estop, aparencia, app_config, treinamento)
|
||||||
|
GET /estado -> foto de tudo para o primeiro carregamento (inclui 'seq')
|
||||||
|
|
||||||
GET /fila -> devolve e limpa a fila, em JSON: ["feliz","piscar"]
|
Comandos:
|
||||||
GET /gesto/<nome> -> poe <nome> na fila (ex.: /gesto/coracao)
|
POST /comando -> {"tipo": "gesto"|"rosto"|"cor"|"reacao"|"parar_reacao"|"recarregar", "valor": ...}
|
||||||
POST /gesto -> corpo = nome do gesto
|
POST /parar-tudo -> E-Stop: desliga todos os servos, para reações e acalma o rosto
|
||||||
|
POST /rearmar -> libera os motores depois do E-Stop
|
||||||
|
GET /fila?aparelho=1 -> ordens pendentes para o celular (até o rosto migrar para /eventos)
|
||||||
|
|
||||||
A pagina da carinha pergunta /fila a cada meio segundo; assim da para mandar
|
Autenticação: se CARINHA_TOKEN (ambiente) ou seguranca.json {"token": ...} existir, toda
|
||||||
um gesto do PC com um curl, igual ao antigo FIFO /tmp/eilik.fifo:
|
rota de controle/dados exige "Authorization: Bearer <token>" (ou ?t=<token> no EventSource
|
||||||
|
e no app). Sem token configurado o servidor fica aberto como antes.
|
||||||
|
|
||||||
curl http://orangepi:8099/gesto/feliz
|
Só usa a biblioteca padrão. Nada de instalação extra.
|
||||||
|
|
||||||
Alem disso guarda a APARENCIA da carinha (rosto + cor dos olhos), para o painel
|
|
||||||
personalizar e a pagina aplicar sozinha ao carregar:
|
|
||||||
|
|
||||||
GET /config -> {"rosto":"carinha","cor":[92,214,255]}
|
|
||||||
POST /config -> corpo JSON {"rosto":"miudo","cor":[255,120,0]}
|
|
||||||
|
|
||||||
E serve o painel de controle em http://<maquina>:8099/painel .
|
|
||||||
|
|
||||||
So usa a biblioteca padrao (http.server). Nada de instalacao extra.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from collections import deque
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from controle_pc import ControlePC
|
from comum import autorizado, carregar_token, gravar_atomico, ler_json
|
||||||
|
from controle_pc import ControlePC, versao
|
||||||
|
from eventos import Eventos
|
||||||
from servos import ControleServos
|
from servos import ControleServos
|
||||||
from braco import Braco
|
from braco import Braco
|
||||||
from cerebro import Cerebro
|
from cerebro import Cerebro
|
||||||
@@ -41,7 +42,6 @@ from treinamento import Treinamento
|
|||||||
from api_config import ConfigAPI
|
from api_config import ConfigAPI
|
||||||
|
|
||||||
TRAVA = threading.Lock()
|
TRAVA = threading.Lock()
|
||||||
FILA = []
|
|
||||||
|
|
||||||
# Espelho da biblioteca de rostos do carinha.html (para /faces funcionar)
|
# Espelho da biblioteca de rostos do carinha.html (para /faces funcionar)
|
||||||
ROSTOS = ["eilik", "carinha", "miudo"]
|
ROSTOS = ["eilik", "carinha", "miudo"]
|
||||||
@@ -50,12 +50,65 @@ ROSTOS = ["eilik", "carinha", "miudo"]
|
|||||||
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
|
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
|
||||||
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
|
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
|
||||||
"dormir", "acordar", "aleatorio", "parar"]
|
"dormir", "acordar", "aleatorio", "parar"]
|
||||||
|
# Ordens internas que o rosto entende mas não viram botão.
|
||||||
|
ORDENS_INTERNAS = {"calma"}
|
||||||
|
|
||||||
# Apar┌┐ncia atual da carinha (personalizacao pelo painel). Fica gravada num
|
# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num
|
||||||
# arquivo ao lado do servidor, para sobreviver a reinicios.
|
# arquivo ao lado do servidor, para sobreviver a reinicios.
|
||||||
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255]}
|
APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255]}
|
||||||
ARQUIVO_APARENCIA = "aparencia.json"
|
ARQUIVO_APARENCIA = "aparencia.json"
|
||||||
|
|
||||||
|
# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar.
|
||||||
|
PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"}
|
||||||
|
PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/camera/", "/conversa/", "/treinamento/",
|
||||||
|
"/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/")
|
||||||
|
|
||||||
|
|
||||||
|
class Fila:
|
||||||
|
"""Ordens para o celular: limitada e com validade, para não repetir ordens velhas
|
||||||
|
quando o aparelho volta depois de um tempo offline."""
|
||||||
|
|
||||||
|
def __init__(self, maximo=20, validade=10.0):
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.itens = deque(maxlen=maximo)
|
||||||
|
self.validade = validade
|
||||||
|
|
||||||
|
def adicionar(self, ordem, agora=None):
|
||||||
|
with self.lock:
|
||||||
|
if ordem == "recarregar" and any(o == ordem for _, o in self.itens):
|
||||||
|
return
|
||||||
|
self.itens.append((agora if agora is not None else time.monotonic(), ordem))
|
||||||
|
|
||||||
|
def consumir(self, aparelho=True, agora=None):
|
||||||
|
"""O celular leva tudo; um rosto aberto no navegador (kiosk) leva tudo menos 'recarregar'."""
|
||||||
|
agora = agora if agora is not None else time.monotonic()
|
||||||
|
with self.lock:
|
||||||
|
validos = [(t, o) for t, o in self.itens if agora - t <= self.validade]
|
||||||
|
pendentes = [o for _, o in validos if aparelho or o != "recarregar"]
|
||||||
|
self.itens.clear()
|
||||||
|
if not aparelho:
|
||||||
|
self.itens.extend((t, o) for t, o in validos if o == "recarregar")
|
||||||
|
return pendentes
|
||||||
|
|
||||||
|
def ordens(self):
|
||||||
|
with self.lock:
|
||||||
|
return [o for _, o in self.itens]
|
||||||
|
|
||||||
|
def limpar(self):
|
||||||
|
with self.lock:
|
||||||
|
self.itens.clear()
|
||||||
|
|
||||||
|
|
||||||
|
FILA = Fila()
|
||||||
|
EVENTOS = Eventos()
|
||||||
|
ESTOP = {"ativo": False, "desde": None}
|
||||||
|
|
||||||
|
|
||||||
|
def enfileirar(ordem):
|
||||||
|
"""Caminho único de uma ordem para o rosto: fila do celular + evento para os painéis."""
|
||||||
|
FILA.adicionar(ordem)
|
||||||
|
EVENTOS.publicar("expressao", {"ordem": ordem})
|
||||||
|
|
||||||
|
|
||||||
def carregar_aparencia():
|
def carregar_aparencia():
|
||||||
try:
|
try:
|
||||||
@@ -73,14 +126,20 @@ def carregar_aparencia():
|
|||||||
|
|
||||||
def salvar_aparencia():
|
def salvar_aparencia():
|
||||||
try:
|
try:
|
||||||
with open(ARQUIVO_APARENCIA, "w", encoding="utf-8") as f:
|
gravar_atomico(ARQUIVO_APARENCIA, APARENCIA)
|
||||||
json.dump(APARENCIA, f, ensure_ascii=False, indent=2)
|
except OSError:
|
||||||
except Exception:
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def validar_cor(valor):
|
||||||
|
cor = [max(0, min(255, int(v))) for v in list(valor)[:3]]
|
||||||
|
if len(cor) != 3:
|
||||||
|
raise ValueError("Cor precisa de três números")
|
||||||
|
return cor
|
||||||
|
|
||||||
|
|
||||||
def aplicar_aparencia(dados):
|
def aplicar_aparencia(dados):
|
||||||
"""Guarda rosto/cor e poe as ordens na fila para a pagina aplicar na hora."""
|
"""Guarda rosto/cor e manda as ordens para a página aplicar na hora."""
|
||||||
ordens = []
|
ordens = []
|
||||||
with TRAVA:
|
with TRAVA:
|
||||||
if "rosto" in dados:
|
if "rosto" in dados:
|
||||||
@@ -90,24 +149,43 @@ def aplicar_aparencia(dados):
|
|||||||
ordens.append("rosto " + nome)
|
ordens.append("rosto " + nome)
|
||||||
if "cor" in dados:
|
if "cor" in dados:
|
||||||
try:
|
try:
|
||||||
cor = [max(0, min(255, int(v))) for v in list(dados["cor"])[:3]]
|
APARENCIA["cor"] = validar_cor(dados["cor"])
|
||||||
if len(cor) == 3:
|
ordens.append("cor %d,%d,%d" % tuple(APARENCIA["cor"]))
|
||||||
APARENCIA["cor"] = cor
|
except (TypeError, ValueError):
|
||||||
ordens.append("cor %d,%d,%d" % tuple(cor))
|
|
||||||
except Exception:
|
|
||||||
pass
|
pass
|
||||||
FILA.extend(ordens)
|
aparencia = dict(APARENCIA)
|
||||||
|
for ordem in ordens:
|
||||||
|
enfileirar(ordem)
|
||||||
|
if ordens:
|
||||||
salvar_aparencia()
|
salvar_aparencia()
|
||||||
|
EVENTOS.publicar("aparencia", aparencia)
|
||||||
return ordens
|
return ordens
|
||||||
|
|
||||||
|
|
||||||
|
def ordem_de_comando(tipo, valor):
|
||||||
|
"""Traduz {tipo, valor} para a ordem de texto que o rosto entende; ValueError se inválido."""
|
||||||
|
if tipo == "gesto":
|
||||||
|
nome = str(valor or "").strip().lower()
|
||||||
|
if nome not in GESTOS and nome not in ORDENS_INTERNAS:
|
||||||
|
raise ValueError("Gesto desconhecido: " + nome)
|
||||||
|
return nome
|
||||||
|
if tipo == "rosto":
|
||||||
|
nome = str(valor or "").strip().lower()
|
||||||
|
if nome not in ROSTOS:
|
||||||
|
raise ValueError("Rosto desconhecido: " + nome)
|
||||||
|
return "rosto " + nome
|
||||||
|
raise ValueError("Tipo de comando inválido")
|
||||||
|
|
||||||
|
|
||||||
class Handler(SimpleHTTPRequestHandler):
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
extensoes_map = {".html": "text/html; charset=utf-8",
|
extensoes_map = {".html": "text/html; charset=utf-8",
|
||||||
".js": "text/javascript; charset=utf-8",
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
".css": "text/css; charset=utf-8",
|
".css": "text/css; charset=utf-8",
|
||||||
".json": "application/json; charset=utf-8",
|
".json": "application/json; charset=utf-8",
|
||||||
".svg": "image/svg+xml",
|
".svg": "image/svg+xml",
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
|
".woff2": "font/woff2",
|
||||||
".mp4": "video/mp4"}
|
".mp4": "video/mp4"}
|
||||||
|
|
||||||
def guess_type(self, caminho):
|
def guess_type(self, caminho):
|
||||||
@@ -120,32 +198,71 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
self.send_header("Content-Type", tipo)
|
self.send_header("Content-Type", tipo)
|
||||||
self.send_header("Content-Length", str(len(dados)))
|
self.send_header("Content-Length", str(len(dados)))
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
if codigo == 401:
|
||||||
|
self.send_header("WWW-Authenticate", 'Bearer realm="carinha"')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(dados)
|
self.wfile.write(dados)
|
||||||
|
|
||||||
|
def json(self, dados, codigo=200):
|
||||||
|
self.enviar(json.dumps(dados, ensure_ascii=False), codigo=codigo)
|
||||||
|
|
||||||
|
def rota(self):
|
||||||
|
return self.path.split("?", 1)[0]
|
||||||
|
|
||||||
|
def acesso_negado(self, metodo):
|
||||||
|
"""True (e já respondeu 401) se a rota exige token e o pedido não trouxe o certo."""
|
||||||
|
rota = self.rota()
|
||||||
|
protegida = metodo == "POST" or (rota not in PUBLICAS_GET and rota.startswith(PREFIXOS_API))
|
||||||
|
if protegida and not autorizado(self):
|
||||||
|
self.json({"erro": "Token ausente ou inválido"}, 401)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def modulos(self):
|
||||||
|
s = self.server
|
||||||
|
return [getattr(s, nome) for nome in ("api_config", "treinamento", "conversa", "cerebro",
|
||||||
|
"braco", "servos", "controle") if hasattr(s, nome)]
|
||||||
|
|
||||||
|
def estado(self):
|
||||||
|
s = self.server
|
||||||
|
host = self.headers.get("Host", "").split(":")[0]
|
||||||
|
with TRAVA:
|
||||||
|
aparencia = dict(APARENCIA)
|
||||||
|
dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)),
|
||||||
|
estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS)
|
||||||
|
if hasattr(s, "controle"):
|
||||||
|
dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy())
|
||||||
|
if hasattr(s, "servos"):
|
||||||
|
dados["motores"] = s.servos.motores()
|
||||||
|
if hasattr(s, "conversa"):
|
||||||
|
dados["conversa"] = s.conversa.status()
|
||||||
|
if hasattr(s, "cerebro"):
|
||||||
|
dados["ia"] = s.cerebro.status()
|
||||||
|
if hasattr(s, "treinamento"):
|
||||||
|
dados["reacao"] = s.treinamento.expressao
|
||||||
|
dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro)
|
||||||
|
if hasattr(s, "api_config"):
|
||||||
|
dados["api"] = s.api_config.status()
|
||||||
|
return dados
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if hasattr(self.server, "api_config") and self.server.api_config.rotas(self, "GET"):return
|
if self.acesso_negado("GET"):
|
||||||
if hasattr(self.server, 'treinamento') and self.server.treinamento.rotas(self, 'GET'):
|
|
||||||
return
|
return
|
||||||
if hasattr(self.server, 'conversa') and self.server.conversa.rotas(self, 'GET'):
|
for modulo in self.modulos():
|
||||||
|
if modulo.rotas(self, "GET"):
|
||||||
return
|
return
|
||||||
if hasattr(self.server, 'cerebro') and self.server.cerebro.rotas(self, 'GET'):
|
caminho = self.rota()
|
||||||
return
|
|
||||||
if hasattr(self.server, 'braco') and self.server.braco.rotas(self, 'GET'):
|
|
||||||
return
|
|
||||||
if hasattr(self.server, 'servos') and self.server.servos.rotas(self, 'GET'):
|
|
||||||
return
|
|
||||||
if self.server.controle.rotas(self, 'GET'):
|
|
||||||
return
|
|
||||||
caminho = self.path.split("?", 1)[0]
|
|
||||||
partes = [p for p in caminho.split("/") if p]
|
partes = [p for p in caminho.split("/") if p]
|
||||||
|
|
||||||
|
if caminho == "/eventos":
|
||||||
|
return EVENTOS.atender(self)
|
||||||
|
|
||||||
|
if caminho == "/estado":
|
||||||
|
return self.json(self.estado())
|
||||||
|
|
||||||
if partes and partes[0] == "fila":
|
if partes and partes[0] == "fila":
|
||||||
with TRAVA:
|
# Painéis não leem a fila: observam o rosto por /eventos.
|
||||||
aparelho = 'aparelho=1' in self.path
|
return self.enviar(json.dumps(FILA.consumir(aparelho="aparelho=1" in self.path)))
|
||||||
pendentes = [x for x in FILA if x!='recarregar' or aparelho]
|
|
||||||
FILA[:] = [x for x in FILA if x=='recarregar' and not aparelho]
|
|
||||||
return self.enviar(json.dumps(pendentes))
|
|
||||||
|
|
||||||
if partes and partes[0] == "faces":
|
if partes and partes[0] == "faces":
|
||||||
return self.enviar(json.dumps({"rostos": ROSTOS}))
|
return self.enviar(json.dumps({"rostos": ROSTOS}))
|
||||||
@@ -156,12 +273,6 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
if partes and partes[0] == "gestos":
|
if partes and partes[0] == "gestos":
|
||||||
return self.enviar(json.dumps({"gestos": GESTOS}))
|
return self.enviar(json.dumps({"gestos": GESTOS}))
|
||||||
|
|
||||||
if len(partes) == 2 and partes[0] == "face":
|
|
||||||
nome = unquote(partes[1]).lower()
|
|
||||||
with TRAVA:
|
|
||||||
FILA.append("rosto " + nome)
|
|
||||||
return self.enviar(json.dumps({"ok": True, "rosto": nome}))
|
|
||||||
|
|
||||||
if partes and partes[0] == "diag":
|
if partes and partes[0] == "diag":
|
||||||
# a propria pagina relata o tamanho que recebeu (ajuda a achar
|
# a propria pagina relata o tamanho que recebeu (ajuda a achar
|
||||||
# barras/enquadramento errado no kiosk). Aparece no journal.
|
# barras/enquadramento errado no kiosk). Aparece no journal.
|
||||||
@@ -169,11 +280,27 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
print("DIAG kiosk:", info, flush=True)
|
print("DIAG kiosk:", info, flush=True)
|
||||||
return self.enviar(json.dumps({"ok": True}))
|
return self.enviar(json.dumps({"ok": True}))
|
||||||
|
|
||||||
if len(partes) == 2 and partes[0] == "gesto":
|
# Obsoletos (GET que muda estado); ficam até o painel antigo sair. Use POST /comando.
|
||||||
nome = unquote(partes[1]).lower() # aceita %20 para espacos
|
if len(partes) == 2 and partes[0] in ("gesto", "face"):
|
||||||
with TRAVA:
|
tipo = "gesto" if partes[0] == "gesto" else "rosto"
|
||||||
FILA.append(nome)
|
try:
|
||||||
return self.enviar(json.dumps({"ok": True, "gesto": nome}))
|
ordem = ordem_de_comando(tipo, unquote(partes[1]))
|
||||||
|
except ValueError as e:
|
||||||
|
return self.json({"erro": str(e)}, 400)
|
||||||
|
enfileirar(ordem)
|
||||||
|
return self.json({"ok": True, partes[0]: ordem})
|
||||||
|
|
||||||
|
# Painel novo (Vite/Svelte em web/dist); as telas antigas seguem nos caminhos próprios.
|
||||||
|
painel = Path("web/dist")
|
||||||
|
if caminho in ("/", "") and (painel / "index.html").is_file():
|
||||||
|
self.path = "/web/dist/index.html"
|
||||||
|
return super().do_GET()
|
||||||
|
if caminho.startswith("/assets/"):
|
||||||
|
nome = caminho[len("/assets/"):]
|
||||||
|
if "/" in nome or "\\" in nome or nome.startswith(".") or not (painel / "assets" / nome).is_file():
|
||||||
|
return self.json({"erro": "Arquivo não disponível"}, 404)
|
||||||
|
self.path = "/web/dist/assets/" + nome
|
||||||
|
return super().do_GET()
|
||||||
|
|
||||||
if caminho in ("/dashboard", "/emojis", "/api"):
|
if caminho in ("/dashboard", "/emojis", "/api"):
|
||||||
self.path = caminho + ".html"
|
self.path = caminho + ".html"
|
||||||
@@ -201,60 +328,103 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
elif caminho in ('/dashboard.html','/emojis.html','/api.html','/dashboard.css','/dashboard.js','/overview.js','/biblioteca.js','/api-painel.js','/painel.css','/conversa.css','/treinamento.css','/servos.css','/braco.css','/audio-robot.js'):
|
elif caminho in ('/dashboard.html','/emojis.html','/api.html','/dashboard.css','/dashboard.js','/overview.js','/biblioteca.js','/api-painel.js','/painel.css','/conversa.css','/treinamento.css','/servos.css','/braco.css','/audio-robot.js'):
|
||||||
pass
|
pass
|
||||||
elif caminho not in ('/conversa.html', '/treinamento.html', '/treinamento.js', '/reacoes-render.js', '/painel-conversa.js', '/braco-calibracao.js', '/movimentos-core.js', '/braco-movimentos.js', '/braco.html', '/braco.js', '/servos.html', '/servos.js', '/carinha.html', '/camera.html', '/painel.html', '/painel-controle.js', '/painel-video.js',
|
elif caminho not in ('/conversa.html', '/treinamento.html', '/treinamento.js', '/reacoes-render.js', '/painel-conversa.js', '/braco-calibracao.js', '/movimentos-core.js', '/braco-movimentos.js', '/braco.html', '/braco.js', '/servos.html', '/servos.js', '/carinha.html', '/camera.html', '/painel.html', '/painel-controle.js', '/painel-video.js',
|
||||||
'/rastreamento-core.js', '/rastreador-worker.js', '/painel-rastreamento.js', '/audio-robot.js'):
|
'/rastreamento-core.js', '/rastreador-worker.js', '/painel-rastreamento.js'):
|
||||||
return self.enviar(json.dumps({'erro': 'Arquivo não disponível'}), codigo=404)
|
return self.enviar(json.dumps({'erro': 'Arquivo não disponível'}), codigo=404)
|
||||||
return super().do_GET()
|
return super().do_GET()
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
if hasattr(self.server, "api_config") and self.server.api_config.rotas(self, "POST"):return
|
if self.acesso_negado("POST"):
|
||||||
if hasattr(self.server, 'treinamento') and self.server.treinamento.rotas(self, 'POST'):
|
|
||||||
return
|
return
|
||||||
if hasattr(self.server, 'conversa') and self.server.conversa.rotas(self, 'POST'):
|
for modulo in self.modulos():
|
||||||
|
if modulo.rotas(self, "POST"):
|
||||||
return
|
return
|
||||||
if hasattr(self.server, 'cerebro') and self.server.cerebro.rotas(self, 'POST'):
|
rota = self.rota().rstrip("/")
|
||||||
return
|
|
||||||
if hasattr(self.server, 'braco') and self.server.braco.rotas(self, 'POST'):
|
|
||||||
return
|
|
||||||
if hasattr(self.server, 'servos') and self.server.servos.rotas(self, 'POST'):
|
|
||||||
return
|
|
||||||
if self.server.controle.rotas(self, 'POST'):
|
|
||||||
return
|
|
||||||
rota = self.path.split("?", 1)[0].rstrip("/")
|
|
||||||
tamanho = int(self.headers.get("Content-Length") or 0)
|
|
||||||
|
|
||||||
if rota == '/app/recarregar':
|
|
||||||
origin=self.headers.get('Origin')
|
|
||||||
if origin and origin not in ('http://'+self.headers.get('Host',''),'https://'+self.headers.get('Host','')):
|
|
||||||
return self.enviar(json.dumps(dict(erro='Origem inválida')),codigo=400)
|
|
||||||
import time
|
|
||||||
with self.server.controle.lock:
|
|
||||||
online=time.monotonic()-self.server.controle.status_at<5
|
|
||||||
import uuid
|
|
||||||
self.server.controle.recarga = dict(id=uuid.uuid4().hex, confirmada=False)
|
|
||||||
if not online:return self.enviar(json.dumps(dict(erro='Abra o Carinha e conecte ao servidor primeiro')),codigo=409)
|
|
||||||
with TRAVA:
|
|
||||||
if 'recarregar' not in FILA:FILA.append('recarregar')
|
|
||||||
return self.enviar(json.dumps(dict(ok=True,mensagem='Solicitação enviada. Aguardando o celular recarregar a tela.')))
|
|
||||||
|
|
||||||
if rota == "/config":
|
|
||||||
try:
|
try:
|
||||||
dados = json.loads(self.rfile.read(tamanho).decode("utf-8", "ignore") or "{}")
|
if rota == "/comando":
|
||||||
if not isinstance(dados, dict):
|
return self.comando(ler_json(self, 2048))
|
||||||
raise ValueError("esperava um objeto JSON")
|
if rota == "/parar-tudo":
|
||||||
except Exception as e:
|
ler_json(self, 2048)
|
||||||
return self.enviar(json.dumps({"erro": "json invalido: %s" % e}), codigo=400)
|
return self.json(self.parar_tudo())
|
||||||
ordens = aplicar_aparencia(dados)
|
if rota == "/rearmar":
|
||||||
return self.enviar(json.dumps({"ok": True, "aparencia": APARENCIA,
|
ler_json(self, 2048)
|
||||||
"ordens": ordens}))
|
return self.json(self.rearmar())
|
||||||
|
if rota == '/app/recarregar':
|
||||||
|
ler_json(self, 2048)
|
||||||
|
return self.recarregar()
|
||||||
|
if rota == "/config":
|
||||||
|
return self.json({"ok": True, "ordens": aplicar_aparencia(ler_json(self, 2048)),
|
||||||
|
"aparencia": APARENCIA})
|
||||||
|
if rota in ("/gesto", "/face"):
|
||||||
|
# Obsoleto: corpo é o nome cru. Use POST /comando.
|
||||||
|
tamanho = int(self.headers.get("Content-Length") or 0)
|
||||||
|
nome = self.rfile.read(min(tamanho, 200)).decode("utf-8", "ignore")
|
||||||
|
ordem = ordem_de_comando("gesto" if rota == "/gesto" else "rosto", nome)
|
||||||
|
enfileirar(ordem)
|
||||||
|
return self.json({"ok": True, "ordem": ordem})
|
||||||
|
except ValueError as e:
|
||||||
|
return self.json({"erro": str(e)}, 400)
|
||||||
|
return self.json({"erro": "Rota POST desconhecida"}, 404)
|
||||||
|
|
||||||
if rota not in ("/gesto", "/face"):
|
def comando(self, dados):
|
||||||
return self.enviar(json.dumps({"erro": "rotas POST: /gesto, /face, /config"}), codigo=404)
|
tipo, valor = dados.get("tipo"), dados.get("valor")
|
||||||
corpo = self.rfile.read(tamanho).decode("utf-8", "ignore").strip().lower()
|
s = self.server
|
||||||
if not corpo:
|
if tipo == "cor":
|
||||||
return self.enviar(json.dumps({"erro": "corpo vazio"}), codigo=400)
|
if not aplicar_aparencia({"cor": valor}):
|
||||||
with TRAVA:
|
raise ValueError("Cor inválida")
|
||||||
FILA.append(corpo)
|
return self.json({"ok": True})
|
||||||
return self.enviar(json.dumps({"ok": True, "ordem": corpo}))
|
if tipo == "recarregar":
|
||||||
|
return self.recarregar()
|
||||||
|
if tipo in ("reacao", "parar_reacao"):
|
||||||
|
if not hasattr(s, "treinamento"):
|
||||||
|
raise ValueError("Reações indisponíveis")
|
||||||
|
if tipo == "reacao":
|
||||||
|
if type(valor) is not int:
|
||||||
|
raise ValueError("ID de reação inválido")
|
||||||
|
s.treinamento.testar(valor)
|
||||||
|
else:
|
||||||
|
with s.treinamento.lock:
|
||||||
|
s.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=dict(limpar=True))
|
||||||
|
return self.json({"ok": True})
|
||||||
|
ordem = ordem_de_comando(tipo, valor)
|
||||||
|
enfileirar(ordem)
|
||||||
|
return self.json({"ok": True, "ordem": ordem})
|
||||||
|
|
||||||
|
def parar_tudo(self):
|
||||||
|
s = self.server
|
||||||
|
if hasattr(s, "braco"):
|
||||||
|
s.braco.cancelar()
|
||||||
|
if hasattr(s, "servos"):
|
||||||
|
with s.servos.lock:
|
||||||
|
s.servos.travado = True
|
||||||
|
s.servos.stop()
|
||||||
|
if hasattr(s, "treinamento"):
|
||||||
|
with s.treinamento.lock:
|
||||||
|
s.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=dict(limpar=True))
|
||||||
|
FILA.limpar()
|
||||||
|
enfileirar("calma")
|
||||||
|
ESTOP.update(ativo=True, desde=time.time())
|
||||||
|
EVENTOS.publicar("estop", dict(ESTOP))
|
||||||
|
return {"ok": True, "estop": dict(ESTOP)}
|
||||||
|
|
||||||
|
def rearmar(self):
|
||||||
|
s = self.server
|
||||||
|
if hasattr(s, "servos"):
|
||||||
|
with s.servos.lock:
|
||||||
|
s.servos.travado = False
|
||||||
|
EVENTOS.publicar("motores", s.servos.motores())
|
||||||
|
ESTOP.update(ativo=False, desde=None)
|
||||||
|
EVENTOS.publicar("estop", dict(ESTOP))
|
||||||
|
return {"ok": True, "estop": dict(ESTOP)}
|
||||||
|
|
||||||
|
def recarregar(self):
|
||||||
|
controle = self.server.controle
|
||||||
|
with controle.lock:
|
||||||
|
online = time.monotonic() - controle.status_at < 5
|
||||||
|
controle.recarga = dict(id=uuid.uuid4().hex, confirmada=False)
|
||||||
|
if not online:
|
||||||
|
return self.json({"erro": "Abra o Carinha e conecte ao servidor primeiro"}, 409)
|
||||||
|
FILA.adicionar("recarregar")
|
||||||
|
return self.json({"ok": True, "mensagem": "Solicitação enviada. Aguardando o celular recarregar a tela."})
|
||||||
|
|
||||||
def log_message(self, formato, *args):
|
def log_message(self, formato, *args):
|
||||||
# silencio por padrao (roda em segundo plano); --log liga o registro
|
# silencio por padrao (roda em segundo plano); --log liga o registro
|
||||||
@@ -270,6 +440,13 @@ class Servidor(ThreadingHTTPServer):
|
|||||||
so o desligamos no Windows."""
|
so o desligamos no Windows."""
|
||||||
daemon_threads = True
|
daemon_threads = True
|
||||||
allow_reuse_address = (os.name != "nt")
|
allow_reuse_address = (os.name != "nt")
|
||||||
|
token = None
|
||||||
|
|
||||||
|
|
||||||
|
def ligar_eventos(servidor):
|
||||||
|
for nome in ("controle", "servos", "cerebro", "conversa", "treinamento"):
|
||||||
|
if hasattr(servidor, nome):
|
||||||
|
getattr(servidor, nome).publicar = EVENTOS.publicar
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -282,21 +459,24 @@ def main():
|
|||||||
os.chdir(args.pasta)
|
os.chdir(args.pasta)
|
||||||
carregar_aparencia()
|
carregar_aparencia()
|
||||||
servidor = Servidor(("0.0.0.0", args.porta), Handler)
|
servidor = Servidor(("0.0.0.0", args.porta), Handler)
|
||||||
|
servidor.token = carregar_token(Path.cwd())
|
||||||
servidor.api_config = ConfigAPI(Path.cwd() / 'api-privada.json')
|
servidor.api_config = ConfigAPI(Path.cwd() / 'api-privada.json')
|
||||||
servidor.controle = ControlePC(Path.cwd() / 'app-config.json')
|
servidor.controle = ControlePC(Path.cwd() / 'app-config.json')
|
||||||
servidor.servos = ControleServos(Path.cwd() / 'servos.json')
|
servidor.servos = ControleServos(Path.cwd() / 'servos.json')
|
||||||
servidor.braco = Braco(Path.cwd() / 'braco.json', servidor.servos)
|
servidor.braco = Braco(Path.cwd() / 'braco.json', servidor.servos)
|
||||||
def expressao_ia(gesto):
|
servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, enfileirar)
|
||||||
with TRAVA: FILA.append(gesto)
|
|
||||||
servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, expressao_ia)
|
|
||||||
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
|
servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro)
|
||||||
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
|
servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db')
|
||||||
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
|
servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar
|
||||||
servidor.conversa.treinamento = servidor.treinamento
|
servidor.conversa.treinamento = servidor.treinamento
|
||||||
|
ligar_eventos(servidor)
|
||||||
servidor.registrar = args.log
|
servidor.registrar = args.log
|
||||||
print(f"servindo {args.pasta} em http://0.0.0.0:{args.porta}/carinha.html")
|
print(f"servindo {args.pasta} em http://0.0.0.0:{args.porta}/")
|
||||||
print(f"painel de controle em http://0.0.0.0:{args.porta}/painel")
|
print("autenticação:", "token obrigatório" if servidor.token else "DESLIGADA (defina CARINHA_TOKEN)")
|
||||||
|
try:
|
||||||
servidor.serve_forever()
|
servidor.serve_forever()
|
||||||
|
finally:
|
||||||
|
EVENTOS.fechar()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from comum import Conflito, gravar_atomico, ler_json
|
||||||
|
|
||||||
def integer(value, lo, hi):
|
def integer(value, lo, hi):
|
||||||
if type(value) is not int or not lo <= value <= hi:
|
if type(value) is not int or not lo <= value <= hi:
|
||||||
@@ -46,6 +47,9 @@ class ControleServos:
|
|||||||
self.hw = hardware
|
self.hw = hardware
|
||||||
self.error = ''
|
self.error = ''
|
||||||
self.active = {}
|
self.active = {}
|
||||||
|
self.dono = None # 'braco' ou 'diagnostico': quem está com os motores
|
||||||
|
self.travado = False # E-Stop: nada se move até rearmar
|
||||||
|
self.publicar = lambda tipo, dados: None
|
||||||
self.closed = threading.Event()
|
self.closed = threading.Event()
|
||||||
self.config = [dict(nome=f'Servo {i+1}', canal=i, minimo=1400, maximo=1600, centro=1500) for i in range(6)]
|
self.config = [dict(nome=f'Servo {i+1}', canal=i, minimo=1400, maximo=1600, centro=1500) for i in range(6)]
|
||||||
try:
|
try:
|
||||||
@@ -78,14 +82,24 @@ class ControleServos:
|
|||||||
def status(self):
|
def status(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
return dict(disponivel=self.hw is not None, erro=self.error,
|
return dict(disponivel=self.hw is not None, erro=self.error,
|
||||||
servos=[dict(row) for row in self.config], ativos=list(self.active), prazo=2)
|
servos=[dict(row) for row in self.config], ativos=list(self.active), prazo=2,
|
||||||
|
dono=self.dono, travado=self.travado)
|
||||||
|
def motores(self):
|
||||||
|
with self.lock:
|
||||||
|
return dict(disponivel=self.hw is not None, erro=self.error, ativos=sorted(self.active),
|
||||||
|
dono=self.dono, travado=self.travado)
|
||||||
|
def reservar(self, nome):
|
||||||
|
"""Braço e diagnóstico usam o mesmo PCA9685 com limites diferentes: um de cada vez."""
|
||||||
|
with self.lock:
|
||||||
|
if self.travado: raise Conflito('E-Stop ativo: rearme antes de mover')
|
||||||
|
if self.dono not in (None, nome) and self.active:
|
||||||
|
raise Conflito(f'Motores em uso por {self.dono}; pare-os primeiro')
|
||||||
|
self.dono = nome
|
||||||
def save(self, rows):
|
def save(self, rows):
|
||||||
rows = self.validate(rows)
|
rows = self.validate(rows)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.active: raise ValueError('Desligue os sinais antes de salvar')
|
if self.active: raise ValueError('Desligue os sinais antes de salvar')
|
||||||
tmp = self.path.with_suffix('.tmp')
|
gravar_atomico(self.path, rows)
|
||||||
tmp.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding='utf-8')
|
|
||||||
tmp.replace(self.path)
|
|
||||||
self.config = rows
|
self.config = rows
|
||||||
return self.status()
|
return self.status()
|
||||||
def move(self, index, pulse):
|
def move(self, index, pulse):
|
||||||
@@ -94,20 +108,28 @@ class ControleServos:
|
|||||||
row = self.config[index]
|
row = self.config[index]
|
||||||
pulse = integer(pulse, row['minimo'], row['maximo'])
|
pulse = integer(pulse, row['minimo'], row['maximo'])
|
||||||
if self.hw is None: raise OSError('PCA9685 indisponível neste servidor')
|
if self.hw is None: raise OSError('PCA9685 indisponível neste servidor')
|
||||||
|
self.reservar('diagnostico')
|
||||||
self.hw.pulse(row['canal'], pulse)
|
self.hw.pulse(row['canal'], pulse)
|
||||||
self.active[row['canal']] = time.monotonic() + 2
|
self.active[row['canal']] = time.monotonic() + 2
|
||||||
return self.status()
|
return self.status()
|
||||||
def expire(self, now):
|
def expire(self, now):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
liberou = False
|
||||||
for channel, deadline in list(self.active.items()):
|
for channel, deadline in list(self.active.items()):
|
||||||
if now >= deadline:
|
if now >= deadline:
|
||||||
self.hw.off(channel)
|
self.hw.off(channel)
|
||||||
del self.active[channel]
|
del self.active[channel]
|
||||||
|
liberou = True
|
||||||
|
if liberou and not self.active:
|
||||||
|
self.dono = None
|
||||||
|
self.publicar('motores', self.motores())
|
||||||
def stop(self):
|
def stop(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.hw:
|
if self.hw:
|
||||||
for channel in range(16): self.hw.off(channel)
|
for channel in range(16): self.hw.off(channel)
|
||||||
self.active.clear()
|
self.active.clear()
|
||||||
|
self.dono = None
|
||||||
|
self.publicar('motores', self.motores())
|
||||||
return self.status()
|
return self.status()
|
||||||
def watch(self):
|
def watch(self):
|
||||||
while not self.closed.wait(.1):
|
while not self.closed.wait(.1):
|
||||||
@@ -122,14 +144,7 @@ class ControleServos:
|
|||||||
try:
|
try:
|
||||||
if method == 'GET' and route == '/servos/status': result = self.status()
|
if method == 'GET' and route == '/servos/status': result = self.status()
|
||||||
elif method == 'POST':
|
elif method == 'POST':
|
||||||
origin = h.headers.get('Origin')
|
data = ler_json(h, 8192)
|
||||||
if origin and origin not in ('http://' + h.headers.get('Host', ''), 'https://' + h.headers.get('Host', '')):
|
|
||||||
raise ValueError('Origem não permitida')
|
|
||||||
if h.headers.get('Content-Type', '').split(';')[0] != 'application/json': raise ValueError('Use JSON')
|
|
||||||
size = int(h.headers.get('Content-Length', '0'))
|
|
||||||
if not 0 < size <= 8192: raise ValueError('Corpo inválido')
|
|
||||||
data = json.loads(h.rfile.read(size))
|
|
||||||
if not isinstance(data, dict): raise ValueError('Objeto JSON obrigatório')
|
|
||||||
if route == '/servos/config': result = self.save(data.get('servos'))
|
if route == '/servos/config': result = self.save(data.get('servos'))
|
||||||
elif route == '/servos/mover': result = self.move(data.get('indice'), data.get('pulso'))
|
elif route == '/servos/mover': result = self.move(data.get('indice'), data.get('pulso'))
|
||||||
elif route == '/servos/parar': result = self.stop()
|
elif route == '/servos/parar': result = self.stop()
|
||||||
@@ -138,6 +153,8 @@ class ControleServos:
|
|||||||
h.enviar(json.dumps({'erro':'Método não permitido'}), codigo=405)
|
h.enviar(json.dumps({'erro':'Método não permitido'}), codigo=405)
|
||||||
return True
|
return True
|
||||||
h.enviar(json.dumps(result, ensure_ascii=False))
|
h.enviar(json.dumps(result, ensure_ascii=False))
|
||||||
|
except Conflito as e:
|
||||||
|
h.enviar(json.dumps({'erro':str(e)}, ensure_ascii=False), codigo=409)
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
h.enviar(json.dumps({'erro':str(e)}), codigo=400)
|
h.enviar(json.dumps({'erro':str(e)}), codigo=400)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class ControleTest(unittest.TestCase):
|
|||||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
self.thread.start()
|
self.thread.start()
|
||||||
self.url = 'http://127.0.0.1:%d' % self.server.server_port
|
self.url = 'http://127.0.0.1:%d' % self.server.server_port
|
||||||
kiosk.FILA.clear()
|
kiosk.FILA.limpar()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.server.shutdown()
|
self.server.shutdown()
|
||||||
@@ -64,12 +64,12 @@ class ControleTest(unittest.TestCase):
|
|||||||
return e.code, e.read()
|
return e.code, e.read()
|
||||||
|
|
||||||
def test_config_is_persisted_without_consuming_gestures(self):
|
def test_config_is_persisted_without_consuming_gestures(self):
|
||||||
kiosk.FILA.append('feliz')
|
kiosk.FILA.adicionar('feliz')
|
||||||
code, body = self.request('/app/config', {'volume': 0.7, 'sompausa': 40})
|
code, body = self.request('/app/config', {'volume': 0.7, 'sompausa': 40})
|
||||||
self.assertEqual(code, 200)
|
self.assertEqual(code, 200)
|
||||||
self.assertEqual(json.loads(body)['config']['volume'], 0.7)
|
self.assertEqual(json.loads(body)['config']['volume'], 0.7)
|
||||||
self.assertEqual(self.request('/app/config')[0], 200)
|
self.assertEqual(self.request('/app/config')[0], 200)
|
||||||
self.assertEqual(kiosk.FILA, ['feliz'])
|
self.assertEqual(kiosk.FILA.ordens(), ['feliz'])
|
||||||
self.assertEqual(json.loads((Path(self.tmp.name) / 'app.json').read_text())['sompausa'], 40)
|
self.assertEqual(json.loads((Path(self.tmp.name) / 'app.json').read_text())['sompausa'], 40)
|
||||||
|
|
||||||
def test_invalid_configuration_does_not_partially_apply(self):
|
def test_invalid_configuration_does_not_partially_apply(self):
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Painel novo (web/dist) contra o servidor real: token, SSE, gestos, paleta, E-Stop, ajustes e celular.
|
||||||
|
|
||||||
|
Requer `npm run build` em web/. Uso: python tests/test_painel_web_browser.py [pasta-para-capturas]
|
||||||
|
"""
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
from pathlib import Path
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
os.chdir(ROOT)
|
||||||
|
spec = importlib.util.spec_from_file_location('kiosk_web', ROOT / 'kiosk-server.py')
|
||||||
|
kiosk = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(kiosk)
|
||||||
|
from braco import Braco
|
||||||
|
from servos import ControleServos
|
||||||
|
|
||||||
|
TOKEN = 'token-de-teste-123'
|
||||||
|
|
||||||
|
|
||||||
|
class HW:
|
||||||
|
def __init__(self): self.calls = []
|
||||||
|
def pulse(self, ch, us): self.calls.append((ch, us))
|
||||||
|
def off(self, ch): self.calls.append((ch, None))
|
||||||
|
|
||||||
|
|
||||||
|
def main(capturas=None):
|
||||||
|
assert (ROOT / 'web/dist/index.html').is_file(), 'Rode npm run build em web/ primeiro'
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp = Path(tmp)
|
||||||
|
kiosk.ARQUIVO_APARENCIA = str(tmp / 'aparencia.json')
|
||||||
|
server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
||||||
|
server.token = TOKEN
|
||||||
|
server.controle = kiosk.ControlePC(tmp / 'app.json')
|
||||||
|
server.servos = ControleServos(tmp / 'servos.json', HW())
|
||||||
|
server.braco = Braco(tmp / 'braco.json', server.servos)
|
||||||
|
kiosk.ligar_eventos(server)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
testar('http://127.0.0.1:%d' % server.server_port, server, capturas)
|
||||||
|
finally:
|
||||||
|
kiosk.EVENTOS.fechar()
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
server.servos.close()
|
||||||
|
|
||||||
|
|
||||||
|
def testar(base, server, capturas):
|
||||||
|
auth = {'Authorization': 'Bearer ' + TOKEN}
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch()
|
||||||
|
page = browser.new_page(viewport={'width': 1440, 'height': 960})
|
||||||
|
erros = []
|
||||||
|
page.on('pageerror', lambda e: erros.append(str(e)))
|
||||||
|
page.goto(base + '/')
|
||||||
|
|
||||||
|
# 1. token
|
||||||
|
expect(page.get_by_role('heading', name='Token do Carinha')).to_be_visible()
|
||||||
|
page.get_by_label('Token').fill(TOKEN)
|
||||||
|
page.get_by_role('button', name='Entrar').click()
|
||||||
|
expect(page.get_by_text('Servidor', exact=True)).to_be_visible(timeout=5000)
|
||||||
|
|
||||||
|
# 2. telemetria do celular chega por SSE
|
||||||
|
page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
|
||||||
|
'microfone_ativo': True, 'microfone': 0.3})
|
||||||
|
expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000)
|
||||||
|
|
||||||
|
# 3. gesto pelo dock
|
||||||
|
kiosk.FILA.limpar()
|
||||||
|
page.get_by_role('button', name='Feliz').first.click()
|
||||||
|
expect(page.get_by_text('Expressão: feliz')).to_be_visible(timeout=5000)
|
||||||
|
assert kiosk.FILA.ordens() == ['feliz'], kiosk.FILA.ordens()
|
||||||
|
|
||||||
|
# 4. atalho numérico e paleta
|
||||||
|
page.locator('body').click(position={'x': 5, 'y': 900})
|
||||||
|
page.keyboard.press('2')
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
assert kiosk.FILA.ordens()[-1] == 'coracao', kiosk.FILA.ordens()
|
||||||
|
page.keyboard.press('Control+k')
|
||||||
|
page.get_by_label('Buscar comando').fill('surpreso')
|
||||||
|
page.keyboard.press('Enter')
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
assert kiosk.FILA.ordens()[-1] == 'surpreso', kiosk.FILA.ordens()
|
||||||
|
if capturas:
|
||||||
|
page.screenshot(path=str(Path(capturas) / 'ao-vivo.png'), full_page=True)
|
||||||
|
|
||||||
|
# 5. E-Stop real
|
||||||
|
page.get_by_role('button', name='Parar tudo').click()
|
||||||
|
expect(page.get_by_role('button', name='PARADO · Rearmar')).to_be_visible(timeout=5000)
|
||||||
|
assert kiosk.ESTOP['ativo'] and server.servos.travado
|
||||||
|
page.get_by_role('button', name='PARADO · Rearmar').click()
|
||||||
|
expect(page.get_by_role('button', name='Parar tudo')).to_be_visible(timeout=5000)
|
||||||
|
assert not server.servos.travado
|
||||||
|
|
||||||
|
# 6. ajustes salvam sem travar
|
||||||
|
page.get_by_role('link', name='Ajustes').first.click()
|
||||||
|
page.get_by_label('Buscar ajuste').fill('boca')
|
||||||
|
boca = page.get_by_role('switch', name='Mostrar boca')
|
||||||
|
assert boca.is_checked()
|
||||||
|
boca.locator('xpath=..').click()
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
assert server.controle.config['boca'] is False
|
||||||
|
if capturas:
|
||||||
|
page.get_by_label('Buscar ajuste').fill('')
|
||||||
|
page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True)
|
||||||
|
|
||||||
|
# 7. celular: barra inferior e nada de rolagem horizontal
|
||||||
|
page.set_viewport_size({'width': 400, 'height': 860})
|
||||||
|
page.get_by_role('link', name='Ao vivo').click()
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
largura = page.evaluate('document.scrollingElement.scrollWidth')
|
||||||
|
assert largura <= 400, 'rolagem horizontal: %s' % largura
|
||||||
|
if capturas:
|
||||||
|
page.screenshot(path=str(Path(capturas) / 'celular.png'), full_page=True)
|
||||||
|
|
||||||
|
# 8. tema claro
|
||||||
|
page.set_viewport_size({'width': 1280, 'height': 900})
|
||||||
|
inicial = page.evaluate("document.documentElement.dataset.theme")
|
||||||
|
outro = 'light' if inicial == 'dark' else 'dark'
|
||||||
|
page.get_by_role('button', name='Usar tema ' + ('claro' if outro == 'light' else 'escuro')).click()
|
||||||
|
assert page.evaluate("document.documentElement.dataset.theme") == outro
|
||||||
|
if capturas:
|
||||||
|
page.screenshot(path=str(Path(capturas) / ('tema-' + outro + '.png')))
|
||||||
|
|
||||||
|
assert not erros, erros
|
||||||
|
browser.close()
|
||||||
|
print('OK: token, SSE, dock 1-9, paleta, E-Stop, ajustes otimistas, celular 400px, tema claro')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main(sys.argv[1] if len(sys.argv) > 1 else None)
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"""Servidor base: token, /eventos (SSE), /estado, /comando, E-Stop, trava de hardware e fila."""
|
||||||
|
import http.client
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
spec = importlib.util.spec_from_file_location('kiosk', ROOT / 'kiosk-server.py')
|
||||||
|
kiosk = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(kiosk)
|
||||||
|
from braco import Braco
|
||||||
|
from comum import carregar_token
|
||||||
|
from servos import ControleServos
|
||||||
|
|
||||||
|
TOKEN = 'segredo-de-teste'
|
||||||
|
|
||||||
|
|
||||||
|
class HW:
|
||||||
|
def __init__(self): self.calls = []
|
||||||
|
def pulse(self, ch, us): self.calls.append((ch, us))
|
||||||
|
def off(self, ch): self.calls.append((ch, None))
|
||||||
|
|
||||||
|
|
||||||
|
class ServidorTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
pasta = Path(self.tmp.name)
|
||||||
|
self.hw = HW()
|
||||||
|
self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
|
||||||
|
self.server.token = TOKEN
|
||||||
|
self.server.controle = kiosk.ControlePC(pasta / 'app.json')
|
||||||
|
self.server.servos = ControleServos(pasta / 'servos.json', self.hw)
|
||||||
|
self.server.braco = Braco(pasta / 'braco.json', self.server.servos)
|
||||||
|
kiosk.ligar_eventos(self.server)
|
||||||
|
kiosk.FILA.limpar()
|
||||||
|
kiosk.ESTOP.update(ativo=False, desde=None)
|
||||||
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
self.porta = self.server.server_port
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.server.servos.close()
|
||||||
|
self.thread.join()
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def pedir(self, metodo, rota, dados=None, token=TOKEN, cabecalhos=None):
|
||||||
|
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
|
||||||
|
h = {'Content-Type': 'application/json'}
|
||||||
|
if token:
|
||||||
|
h['Authorization'] = 'Bearer ' + token
|
||||||
|
h.update(cabecalhos or {})
|
||||||
|
corpo = None if dados is None else json.dumps(dados)
|
||||||
|
conn.request(metodo, rota, body=corpo, headers=h)
|
||||||
|
r = conn.getresponse()
|
||||||
|
texto = r.read()
|
||||||
|
conn.close()
|
||||||
|
return r.status, json.loads(texto) if texto else None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- token
|
||||||
|
def test_rotas_de_controle_exigem_token(self):
|
||||||
|
for metodo, rota, dados in [('POST', '/braco/mover', {'angulos': [90] * 7}),
|
||||||
|
('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'}),
|
||||||
|
('POST', '/parar-tudo', {}),
|
||||||
|
('GET', '/estado', None), ('GET', '/fila?aparelho=1', None),
|
||||||
|
('GET', '/app/status', None), ('GET', '/gesto/feliz', None)]:
|
||||||
|
for token in (None, 'errado'):
|
||||||
|
codigo, corpo = self.pedir(metodo, rota, dados, token=token)
|
||||||
|
self.assertEqual(codigo, 401, (metodo, rota, token))
|
||||||
|
self.assertEqual(self.hw.calls, [])
|
||||||
|
self.assertEqual(kiosk.FILA.ordens(), [])
|
||||||
|
|
||||||
|
def test_rotas_publicas_e_token_na_query(self):
|
||||||
|
self.assertEqual(self.pedir('GET', '/gestos', token=None)[0], 200)
|
||||||
|
self.assertEqual(self.pedir('GET', '/app/health', token=None)[0], 200)
|
||||||
|
self.assertEqual(self.pedir('GET', '/estado?t=' + TOKEN, token=None)[0], 200)
|
||||||
|
|
||||||
|
def test_sem_token_configurado_fica_aberto(self):
|
||||||
|
self.server.token = None
|
||||||
|
self.assertEqual(self.pedir('GET', '/estado', token=None)[0], 200)
|
||||||
|
|
||||||
|
def test_carregar_token(self):
|
||||||
|
pasta = Path(self.tmp.name)
|
||||||
|
self.assertIsNone(carregar_token(pasta))
|
||||||
|
(pasta / 'seguranca.json').write_text(json.dumps({'token': 'abcdefgh123'}))
|
||||||
|
self.assertEqual(carregar_token(pasta), 'abcdefgh123')
|
||||||
|
(pasta / 'seguranca.json').write_text(json.dumps({'token': 'curto'}))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
carregar_token(pasta)
|
||||||
|
|
||||||
|
def test_origem_estrangeira_recusada(self):
|
||||||
|
codigo, _ = self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'},
|
||||||
|
cabecalhos={'Origin': 'http://malicioso.example'})
|
||||||
|
self.assertEqual(codigo, 400)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- comando e fila
|
||||||
|
def test_comando_valida_e_enfileira(self):
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'})[0], 200)
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'miudo'})[0], 200)
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400)
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
|
||||||
|
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo'])
|
||||||
|
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo'])
|
||||||
|
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
|
||||||
|
|
||||||
|
def test_post_face_usa_prefixo_rosto(self):
|
||||||
|
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
|
||||||
|
conn.request('POST', '/face', body='miudo', headers={'Authorization': 'Bearer ' + TOKEN})
|
||||||
|
self.assertEqual(conn.getresponse().status, 200)
|
||||||
|
conn.close()
|
||||||
|
self.assertEqual(kiosk.FILA.ordens(), ['rosto miudo'])
|
||||||
|
|
||||||
|
def test_fila_limitada_e_com_validade(self):
|
||||||
|
fila = kiosk.Fila(maximo=3, validade=10)
|
||||||
|
for i in range(5):
|
||||||
|
fila.adicionar(f'g{i}', agora=0)
|
||||||
|
self.assertEqual(fila.ordens(), ['g2', 'g3', 'g4'])
|
||||||
|
self.assertEqual(fila.consumir(agora=11), [])
|
||||||
|
fila.adicionar('recarregar'); fila.adicionar('recarregar'); fila.adicionar('feliz')
|
||||||
|
self.assertEqual(fila.ordens(), ['recarregar', 'feliz'])
|
||||||
|
# Rosto no navegador não rouba o 'recarregar' do celular.
|
||||||
|
self.assertEqual(fila.consumir(aparelho=False), ['feliz'])
|
||||||
|
self.assertEqual(fila.consumir(aparelho=True), ['recarregar'])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- SSE
|
||||||
|
def test_eventos_entrega_comando(self):
|
||||||
|
conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5)
|
||||||
|
conn.request('GET', '/eventos?t=' + TOKEN)
|
||||||
|
r = conn.getresponse()
|
||||||
|
self.assertEqual(r.status, 200)
|
||||||
|
self.assertIn('text/event-stream', r.getheader('Content-Type'))
|
||||||
|
linhas = []
|
||||||
|
while not linhas or linhas[-1] != '':
|
||||||
|
linhas.append(r.fp.readline().decode().rstrip('\n'))
|
||||||
|
self.assertIn('event: pronto', linhas)
|
||||||
|
self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'coracao'})
|
||||||
|
bloco = []
|
||||||
|
while not bloco or bloco[-1] != '':
|
||||||
|
bloco.append(r.fp.readline().decode().rstrip('\n'))
|
||||||
|
conn.close()
|
||||||
|
self.assertIn('event: expressao', bloco)
|
||||||
|
self.assertIn('data: {"ordem": "coracao"}', bloco)
|
||||||
|
|
||||||
|
def test_estado_agrega(self):
|
||||||
|
codigo, estado = self.pedir('GET', '/estado')
|
||||||
|
self.assertEqual(codigo, 200)
|
||||||
|
for chave in ('versao', 'seq', 'estop', 'aparencia', 'gestos', 'app', 'motores'):
|
||||||
|
self.assertIn(chave, estado)
|
||||||
|
self.assertTrue(estado['autenticacao'])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- E-Stop e trava
|
||||||
|
def test_parar_tudo_desliga_e_trava_ate_rearmar(self):
|
||||||
|
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
|
||||||
|
self.hw.calls.clear()
|
||||||
|
codigo, corpo = self.pedir('POST', '/parar-tudo', {})
|
||||||
|
self.assertEqual(codigo, 200)
|
||||||
|
self.assertTrue(corpo['estop']['ativo'])
|
||||||
|
self.assertEqual({c for c, us in self.hw.calls if us is None}, set(range(16)))
|
||||||
|
self.assertEqual(kiosk.FILA.ordens(), ['calma'])
|
||||||
|
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 409)
|
||||||
|
self.assertEqual(self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})[0], 409)
|
||||||
|
self.assertEqual(self.pedir('POST', '/rearmar', {})[0], 200)
|
||||||
|
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
|
||||||
|
|
||||||
|
def test_braco_e_diagnostico_nao_disputam_motores(self):
|
||||||
|
self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200)
|
||||||
|
codigo, corpo = self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})
|
||||||
|
self.assertEqual(codigo, 409)
|
||||||
|
self.assertIn('braco', corpo['erro'])
|
||||||
|
self.pedir('POST', '/braco/parar', {})
|
||||||
|
self.assertEqual(self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})[0], 200)
|
||||||
|
|
||||||
|
|
||||||
|
class BracoMovimentoTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.hw = HW()
|
||||||
|
self.servos = ControleServos(Path(self.tmp.name) / 's.json', self.hw)
|
||||||
|
self.servos.closed.set() # sem threads: os passos são chamados à mão
|
||||||
|
self.arm = Braco(Path(self.tmp.name) / 'b.json', self.servos)
|
||||||
|
cal = self.arm.status()
|
||||||
|
for row in cal['calibracao']:
|
||||||
|
row.update(minimo=500, maximo=2500, centro=1500)
|
||||||
|
self.arm.save(dict(calibracao=cal['calibracao'], posturas=[]))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_interpolacao_no_servidor_respeita_velocidade(self):
|
||||||
|
self.arm.move([90] * 7)
|
||||||
|
resposta = self.arm.move([90, 90, 90, 90, 90, 90, 130], velocidade=100, ttl_ms=2000)
|
||||||
|
self.assertTrue(resposta['movendo'])
|
||||||
|
agora = self.arm.lease - 1.5
|
||||||
|
self.arm.passo(agora, .1) # 100 °/s * 0,1 s = 10°
|
||||||
|
self.assertEqual(self.arm.atual[6], 100)
|
||||||
|
for i in range(10):
|
||||||
|
self.arm.passo(agora + .1 * (i + 1), .1)
|
||||||
|
self.assertEqual(self.arm.atual[6], 130)
|
||||||
|
self.assertIsNone(self.arm.alvo)
|
||||||
|
|
||||||
|
def test_lease_vencido_interrompe_movimento(self):
|
||||||
|
self.arm.move([90] * 7)
|
||||||
|
self.arm.move([90] * 6 + [170], velocidade=50, ttl_ms=300)
|
||||||
|
self.arm.passo(self.arm.lease + .01, .1)
|
||||||
|
self.assertIsNone(self.arm.alvo)
|
||||||
|
self.assertEqual(self.arm.atual[6], 90)
|
||||||
|
# O watchdog desliga os canais no mesmo prazo.
|
||||||
|
self.servos.expire(self.arm.lease + .01)
|
||||||
|
self.assertEqual(self.servos.active, {})
|
||||||
|
self.assertIsNone(self.servos.dono)
|
||||||
|
|
||||||
|
def test_parametros_invalidos(self):
|
||||||
|
for kwargs in (dict(velocidade=1000), dict(velocidade=True), dict(ttl_ms=50), dict(ttl_ms=5000)):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.arm.move([90] * 7, **kwargs)
|
||||||
|
self.assertEqual(self.hw.calls, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
+15
-7
@@ -1,6 +1,7 @@
|
|||||||
"""Rotina, diário e reações procedurais. Não treina pesos nem controla motores."""
|
"""Rotina, diário e reações procedurais. Não treina pesos nem controla motores."""
|
||||||
import json,sqlite3,threading,time,math
|
import json,sqlite3,threading,time,math
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from comum import ler_json
|
||||||
from gemini import gerar_json
|
from gemini import gerar_json
|
||||||
from figurinhas import criar_desenho,validar_desenho,montar_prompt
|
from figurinhas import criar_desenho,validar_desenho,montar_prompt
|
||||||
|
|
||||||
@@ -28,8 +29,20 @@ def validar_receita(r):
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
class Treinamento:
|
class Treinamento:
|
||||||
|
# Mudanças de expressão/ocupação viram eventos SSE sem caçar cada atribuição.
|
||||||
|
@property
|
||||||
|
def expressao(self):return self._expressao
|
||||||
|
@expressao.setter
|
||||||
|
def expressao(self,valor):
|
||||||
|
self._expressao=valor;self.publicar('reacao',valor)
|
||||||
|
@property
|
||||||
|
def busy(self):return self._busy
|
||||||
|
@busy.setter
|
||||||
|
def busy(self,valor):
|
||||||
|
self._busy=valor;self.publicar('treinamento',dict(ocupado=valor,erro=self.erro))
|
||||||
def __init__(self,path,iniciar=True):
|
def __init__(self,path,iniciar=True):
|
||||||
self.path=str(path);self.lock=threading.RLock();self.busy=False;self.erro='';self.expressao=None
|
self.publicar=lambda tipo,dados:None
|
||||||
|
self.path=str(path);self.lock=threading.RLock();self.erro='';self.busy=False;self.expressao=None
|
||||||
with self.db() as db:
|
with self.db() as db:
|
||||||
db.execute('CREATE TABLE IF NOT EXISTS rotina(id INTEGER PRIMARY KEY,texto TEXT)')
|
db.execute('CREATE TABLE IF NOT EXISTS rotina(id INTEGER PRIMARY KEY,texto TEXT)')
|
||||||
db.execute('CREATE TABLE IF NOT EXISTS diario(id INTEGER PRIMARY KEY,horario REAL,papel TEXT,texto TEXT)')
|
db.execute('CREATE TABLE IF NOT EXISTS diario(id INTEGER PRIMARY KEY,horario REAL,papel TEXT,texto TEXT)')
|
||||||
@@ -178,12 +191,7 @@ class Treinamento:
|
|||||||
elif method=='GET' and route=='/treinamento/expressao':
|
elif method=='GET' and route=='/treinamento/expressao':
|
||||||
with self.lock:result=self.expressao
|
with self.lock:result=self.expressao
|
||||||
elif method=='POST':
|
elif method=='POST':
|
||||||
origin=h.headers.get('Origin')
|
d=ler_json(h,4096)
|
||||||
if origin and origin not in ('http://'+h.headers.get('Host',''),'https://'+h.headers.get('Host','')):raise ValueError('Origem inválida')
|
|
||||||
size=int(h.headers.get('Content-Length','0'))
|
|
||||||
if not 0<size<=4096 or h.headers.get('Content-Type','').split(';')[0]!='application/json':raise ValueError('Use JSON até 4096 bytes')
|
|
||||||
d=json.loads(h.rfile.read(size))
|
|
||||||
if not isinstance(d,dict):raise ValueError('Objeto obrigatório')
|
|
||||||
with self.lock,self.db() as db:
|
with self.lock,self.db() as db:
|
||||||
if route=='/treinamento/biblioteca':self.alterar_reacao(d.get('acao'),d.get('id'),d)
|
if route=='/treinamento/biblioteca':self.alterar_reacao(d.get('acao'),d.get('id'),d)
|
||||||
elif route=='/treinamento/parar':self.expressao=dict(id=time.time_ns(),horario=time.time(),receita=dict(limpar=True))
|
elif route=='/treinamento/parar':self.expressao=dict(id=time.time_ns(),horario=time.time(),receita=dict(limpar=True))
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0b1017" />
|
||||||
|
<title>Carinha</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%230b1017'/%3E%3Crect x='14' y='20' width='12' height='22' rx='6' fill='%235cd6ff'/%3E%3Crect x='38' y='20' width='12' height='22' rx='6' fill='%235cd6ff'/%3E%3C/svg%3E" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1250
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "carinha-painel",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.8.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||||
|
"@tsconfig/svelte": "^5.0.8",
|
||||||
|
"@types/node": "^26.6.4",
|
||||||
|
"svelte": "^5.57.1",
|
||||||
|
"svelte-check": "^4.7.6",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.3.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
|
"@fontsource-variable/outfit": "^5.3.0",
|
||||||
|
"@lucide/svelte": "^1.52.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
|
||||||
|
import { onMount, type Component } from 'svelte';
|
||||||
|
import Avisos from './lib/componentes/Avisos.svelte';
|
||||||
|
import EStop from './lib/componentes/EStop.svelte';
|
||||||
|
import MiniRosto from './lib/componentes/MiniRosto.svelte';
|
||||||
|
import Paleta from './lib/componentes/Paleta.svelte';
|
||||||
|
import Selo from './lib/componentes/Selo.svelte';
|
||||||
|
import Token from './lib/componentes/Token.svelte';
|
||||||
|
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
|
||||||
|
import { conectar, estado } from './lib/estado.svelte';
|
||||||
|
import { paleta } from './lib/paleta.svelte';
|
||||||
|
import { nav, ROTAS, type Rota } from './lib/rotas.svelte';
|
||||||
|
import AoVivo from './telas/AoVivo.svelte';
|
||||||
|
import Ajustes from './telas/Ajustes.svelte';
|
||||||
|
import Conversa from './telas/Conversa.svelte';
|
||||||
|
import Movimento from './telas/Movimento.svelte';
|
||||||
|
import Reacoes from './telas/Reacoes.svelte';
|
||||||
|
|
||||||
|
const TELAS: Record<Rota, Component> = {
|
||||||
|
'ao-vivo': AoVivo,
|
||||||
|
conversa: Conversa,
|
||||||
|
reacoes: Reacoes,
|
||||||
|
movimento: Movimento,
|
||||||
|
ajustes: Ajustes,
|
||||||
|
};
|
||||||
|
const ICONES: Record<Rota, Component<any>> = {
|
||||||
|
'ao-vivo': Video,
|
||||||
|
conversa: MessageCircle,
|
||||||
|
reacoes: Smile,
|
||||||
|
movimento: Hand,
|
||||||
|
ajustes: Settings,
|
||||||
|
};
|
||||||
|
const Tela = $derived(TELAS[nav.atual]);
|
||||||
|
|
||||||
|
// ---------- tema
|
||||||
|
let tema = $state<'dark' | 'light'>('dark');
|
||||||
|
function aplicarTema(t: 'dark' | 'light') {
|
||||||
|
tema = t;
|
||||||
|
document.documentElement.dataset.theme = t;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('carinha_tema', t);
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- atalhos: Ctrl+K paleta · 1–9 gestos favoritos · Shift+Espaço ou Esc Esc = E-Stop · g+letra navega
|
||||||
|
let ultimoEsc = 0;
|
||||||
|
let prefixoG = 0;
|
||||||
|
function editando(e: KeyboardEvent) {
|
||||||
|
const alvo = e.target as HTMLElement;
|
||||||
|
return alvo.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(alvo.tagName);
|
||||||
|
}
|
||||||
|
function teclado(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
paleta.aberta ? paleta.fechar() : paleta.abrir();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.shiftKey && e.code === 'Space' && !editando(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
pararTudo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape' && !paleta.aberta) {
|
||||||
|
const agora = Date.now();
|
||||||
|
if (agora - ultimoEsc < 600) pararTudo();
|
||||||
|
ultimoEsc = agora;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editando(e) || paleta.aberta || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
if (e.key === 'g') {
|
||||||
|
prefixoG = Date.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - prefixoG < 800) {
|
||||||
|
const rota = ROTAS.find((r) => r.atalho === 'g ' + e.key);
|
||||||
|
prefixoG = 0;
|
||||||
|
if (rota) nav.ir(rota.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^[1-9]$/.test(e.key)) {
|
||||||
|
const g = favoritos.lista[Number(e.key) - 1];
|
||||||
|
if (g) gesto(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let salvo: string | null = null;
|
||||||
|
try {
|
||||||
|
salvo = localStorage.getItem('carinha_tema');
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
aplicarTema(salvo === 'light' || (!salvo && matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark');
|
||||||
|
conectar();
|
||||||
|
});
|
||||||
|
|
||||||
|
const conexaoTexto = $derived(
|
||||||
|
{ conectando: 'Conectando…', aberta: 'Servidor', caiu: 'Sem servidor', token: 'Token necessário' }[estado.conexao],
|
||||||
|
);
|
||||||
|
const bateria = $derived.by(() => {
|
||||||
|
const b = estado.app?.estado?.bateria;
|
||||||
|
const n = typeof b === 'number' ? b : b?.nivel;
|
||||||
|
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={teclado} />
|
||||||
|
|
||||||
|
{#if estado.conexao === 'token'}
|
||||||
|
<Token />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<nav class="lateral" aria-label="Seções">
|
||||||
|
<div class="marca">
|
||||||
|
<span class="logo" aria-hidden="true"><Bot size={20} /></span>
|
||||||
|
<div>
|
||||||
|
<strong>Carinha</strong>
|
||||||
|
<small class="fraco mono">{estado.versao ? 'v' + estado.versao : ''}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{#each ROTAS as r (r.id)}
|
||||||
|
{@const Icone = ICONES[r.id]}
|
||||||
|
<li>
|
||||||
|
<a href={'#/' + r.id} aria-current={nav.atual === r.id ? 'page' : undefined}>
|
||||||
|
<Icone size={20} /> <span>{r.titulo}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<button class="btn fantasma paleta-btn" onclick={paleta.abrir}>
|
||||||
|
<Command size={16} /> Comandos <kbd>Ctrl K</kbd>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="topo">
|
||||||
|
<h1 class="titulo">{ROTAS.find((r) => r.id === nav.atual)?.titulo}</h1>
|
||||||
|
<div class="status linha">
|
||||||
|
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
|
||||||
|
{conexaoTexto}
|
||||||
|
</Selo>
|
||||||
|
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>
|
||||||
|
Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
|
||||||
|
</Selo>
|
||||||
|
{#if estado.conversa}
|
||||||
|
<Selo tom="destaque">IA: {estado.conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'}</Selo>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="acoes linha">
|
||||||
|
<button class="btn fantasma icone so-celular" onclick={paleta.abrir} aria-label="Comandos">
|
||||||
|
<Command size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn fantasma icone"
|
||||||
|
onclick={() => aplicarTema(tema === 'dark' ? 'light' : 'dark')}
|
||||||
|
aria-label={tema === 'dark' ? 'Usar tema claro' : 'Usar tema escuro'}
|
||||||
|
>
|
||||||
|
{#if tema === 'dark'}<Sun size={18} />{:else}<Moon size={18} />{/if}
|
||||||
|
</button>
|
||||||
|
<EStop />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="conteudo">
|
||||||
|
<Tela />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if nav.atual !== 'ao-vivo'}
|
||||||
|
<MiniRosto />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paleta />
|
||||||
|
<Avisos />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: var(--topo-altura) 1fr;
|
||||||
|
grid-template-areas: 'topo' 'conteudo';
|
||||||
|
}
|
||||||
|
/* ---------- navegação: barra inferior no celular, lateral a partir de 1024px */
|
||||||
|
.lateral {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 60;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
background: var(--vidro);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-top: 1px solid var(--borda);
|
||||||
|
}
|
||||||
|
.marca,
|
||||||
|
.paleta-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--e-1);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
li a {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--e-2) 0;
|
||||||
|
border-radius: var(--raio);
|
||||||
|
color: var(--texto-3);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
li a[aria-current='page'] {
|
||||||
|
color: var(--destaque);
|
||||||
|
}
|
||||||
|
.topo {
|
||||||
|
grid-area: topo;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-3);
|
||||||
|
padding: 0 var(--e-4);
|
||||||
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
height: calc(var(--topo-altura) + env(safe-area-inset-top, 0px));
|
||||||
|
background: var(--vidro);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-bottom: 1px solid var(--borda);
|
||||||
|
}
|
||||||
|
.titulo {
|
||||||
|
font-size: var(--t-lg);
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.status :global(.selo:nth-child(n + 2)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.acoes {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.conteudo {
|
||||||
|
grid-area: conteudo;
|
||||||
|
padding: var(--e-4);
|
||||||
|
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
kbd {
|
||||||
|
margin-left: auto;
|
||||||
|
font: 500 11px var(--fonte-mono);
|
||||||
|
color: var(--texto-3);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.status :global(.selo:nth-child(n + 2)) {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.conteudo {
|
||||||
|
padding: var(--e-5);
|
||||||
|
padding-bottom: calc(96px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: var(--nav-largura) 1fr;
|
||||||
|
grid-template-areas: 'nav topo' 'nav conteudo';
|
||||||
|
/* a coluna da navegação continua pintada quando a página rola além da viewport */
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--fundo-2) calc(var(--nav-largura) - 1px),
|
||||||
|
var(--borda) 0 var(--nav-largura),
|
||||||
|
transparent 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.lateral {
|
||||||
|
grid-area: nav;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
bottom: auto;
|
||||||
|
height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--e-4);
|
||||||
|
padding: var(--e-4) var(--e-3);
|
||||||
|
border-top: 0;
|
||||||
|
border-right: 1px solid var(--borda);
|
||||||
|
background: var(--fundo-2);
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
.marca {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-3);
|
||||||
|
padding: var(--e-2);
|
||||||
|
}
|
||||||
|
.marca div {
|
||||||
|
display: grid;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: var(--raio);
|
||||||
|
background: var(--destaque-suave);
|
||||||
|
color: var(--destaque);
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-3);
|
||||||
|
padding: var(--e-2) var(--e-3);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
color: var(--texto-2);
|
||||||
|
}
|
||||||
|
li a:hover {
|
||||||
|
background: var(--superficie);
|
||||||
|
color: var(--texto);
|
||||||
|
}
|
||||||
|
li a[aria-current='page'] {
|
||||||
|
background: var(--destaque-suave);
|
||||||
|
color: var(--destaque);
|
||||||
|
}
|
||||||
|
.paleta-btn {
|
||||||
|
display: flex;
|
||||||
|
margin-top: auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
color: var(--texto-2);
|
||||||
|
}
|
||||||
|
.so-celular {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.conteudo {
|
||||||
|
padding-bottom: var(--e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/** Ajustes do app do celular (/app/config): salvamento otimista e campos agrupados. */
|
||||||
|
import { pedir, postar } from './api';
|
||||||
|
import { avisos } from './avisos.svelte';
|
||||||
|
import { estado } from './estado.svelte';
|
||||||
|
import type { AppConfig } from './tipos';
|
||||||
|
|
||||||
|
/** Atualiza na hora; se o servidor recusar, volta ao valor anterior e avisa. Não trava o formulário. */
|
||||||
|
export async function salvarApp(parcial: AppConfig) {
|
||||||
|
const antes = estado.appConfig ? { ...estado.appConfig } : null;
|
||||||
|
if (estado.appConfig) estado.appConfig = { ...estado.appConfig, ...parcial };
|
||||||
|
try {
|
||||||
|
const r = await postar<{ config: AppConfig }>('/app/config', parcial);
|
||||||
|
estado.appConfig = r.config;
|
||||||
|
} catch (e) {
|
||||||
|
estado.appConfig = antes;
|
||||||
|
avisos.erro(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restaurarPadrao() {
|
||||||
|
const padrao = await pedir<AppConfig>('/app/padrao');
|
||||||
|
// A transmissão não é "ajuste": continua como está.
|
||||||
|
delete padrao.preview;
|
||||||
|
await salvarApp(padrao);
|
||||||
|
avisos.ok('Ajustes do celular restaurados para o padrão.');
|
||||||
|
}
|
||||||
|
|
||||||
|
type Opcoes = [string | number, string][];
|
||||||
|
export interface Campo {
|
||||||
|
chave: string;
|
||||||
|
rotulo: string;
|
||||||
|
ajuda?: string;
|
||||||
|
tipo: 'toggle' | 'slider' | 'escolha';
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
passo?: number;
|
||||||
|
unidade?: string;
|
||||||
|
opcoes?: Opcoes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] = [
|
||||||
|
{
|
||||||
|
titulo: 'Tela e personalidade',
|
||||||
|
descricao: 'Como o rosto aparece e se comporta sozinho.',
|
||||||
|
campos: [
|
||||||
|
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
|
||||||
|
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
|
||||||
|
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
|
||||||
|
{ chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
|
||||||
|
{ chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
|
||||||
|
{ chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' },
|
||||||
|
{ chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Sensores e reações',
|
||||||
|
descricao: 'O que o celular percebe e como reage.',
|
||||||
|
campos: [
|
||||||
|
{ chave: 'sensorMic', rotulo: 'Microfone', ajuda: 'Só o nível de som é usado, nunca o áudio', tipo: 'toggle' },
|
||||||
|
{ chave: 'ouvir', rotulo: 'Reagir a sons', tipo: 'toggle' },
|
||||||
|
{ chave: 'micSono', rotulo: 'Microfone durante o sono', tipo: 'toggle' },
|
||||||
|
{ chave: 'sompausa', rotulo: 'Silêncio para dormir', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' s' },
|
||||||
|
{ chave: 'sensorCamera', rotulo: 'Câmera como sensor', ajuda: 'Detecta movimento e permite o vídeo', tipo: 'toggle' },
|
||||||
|
{ chave: 'reagir', rotulo: 'Reagir a movimento', tipo: 'toggle' },
|
||||||
|
{ chave: 'cameraSono', rotulo: 'Câmera durante o sono', tipo: 'toggle' },
|
||||||
|
{ chave: 'movolhar', rotulo: 'Quanto os olhos acompanham', tipo: 'slider', min: 0, max: 100, passo: 1, unidade: '%' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Vídeo e rastreamento',
|
||||||
|
descricao: 'Qualidade da transmissão e rastreamento feito neste PC.',
|
||||||
|
campos: [
|
||||||
|
{ chave: 'cameraLado', rotulo: 'Câmera', tipo: 'escolha', opcoes: [['user', 'Frontal'], ['environment', 'Traseira']] },
|
||||||
|
{
|
||||||
|
chave: 'videoResolucao',
|
||||||
|
rotulo: 'Resolução',
|
||||||
|
tipo: 'escolha',
|
||||||
|
opcoes: [['auto', 'Automática'], ['max', 'Máxima'], ['480', '480p'], ['720', '720p'], ['1080', '1080p'], ['2160', '4K'], ['4320', '8K']],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chave: 'videoFPS',
|
||||||
|
rotulo: 'Quadros por segundo',
|
||||||
|
tipo: 'escolha',
|
||||||
|
opcoes: [[0, 'Automático'], [-1, 'Máximo'], [15, '15'], [24, '24'], [30, '30'], [60, '60'], [120, '120'], [240, '240']],
|
||||||
|
},
|
||||||
|
{ chave: 'rastrearRosto', rotulo: 'Rastrear rosto', tipo: 'toggle' },
|
||||||
|
{ chave: 'rastrearMaos', rotulo: 'Rastrear mãos', tipo: 'toggle' },
|
||||||
|
{ chave: 'seguirRastreamento', rotulo: 'Olhos seguem o alvo', tipo: 'toggle' },
|
||||||
|
{ chave: 'mostrarRastreamento', rotulo: 'Desenhar rastreamento no vídeo', tipo: 'toggle' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Sensibilidade (avançado)',
|
||||||
|
descricao: 'Limiares dos sustos e intervalos entre reações.',
|
||||||
|
campos: [
|
||||||
|
{ chave: 'somlim', rotulo: 'Nível para perceber som', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'somaltolim', rotulo: 'Som mínimo para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'somganho', rotulo: 'Aumento súbito para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'somcooldown', rotulo: 'Intervalo entre sustos por som', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
|
||||||
|
{ chave: 'movlim', rotulo: 'Movimento mínimo', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'movforte', rotulo: 'Movimento para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
|
||||||
|
{ chave: 'movcooldown', rotulo: 'Intervalo entre sustos por movimento', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/** Cliente HTTP único do painel: token, JSON, tempo limite e erro legível. */
|
||||||
|
|
||||||
|
const CHAVE_TOKEN = 'carinha_token';
|
||||||
|
|
||||||
|
export class ErroApi extends Error {
|
||||||
|
constructor(public status: number, mensagem: string) {
|
||||||
|
super(mensagem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lerToken(): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(CHAVE_TOKEN) || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gravarToken(token: string) {
|
||||||
|
try {
|
||||||
|
if (token) localStorage.setItem(CHAVE_TOKEN, token);
|
||||||
|
else localStorage.removeItem(CHAVE_TOKEN);
|
||||||
|
} catch {
|
||||||
|
/* navegação privada: vale só nesta aba */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Avisado quando o servidor recusa o token (abre o diálogo de token). */
|
||||||
|
let aoNegar: () => void = () => {};
|
||||||
|
export function quandoNegado(fn: () => void) {
|
||||||
|
aoNegar = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pedir<T = any>(caminho: string, dados?: unknown, tempo = 8000): Promise<T> {
|
||||||
|
const cabecalhos: Record<string, string> = {};
|
||||||
|
const token = lerToken();
|
||||||
|
if (token) cabecalhos.Authorization = 'Bearer ' + token;
|
||||||
|
const opcoes: RequestInit = { cache: 'no-store', headers: cabecalhos, signal: AbortSignal.timeout(tempo) };
|
||||||
|
if (dados !== undefined) {
|
||||||
|
opcoes.method = 'POST';
|
||||||
|
cabecalhos['Content-Type'] = 'application/json';
|
||||||
|
opcoes.body = JSON.stringify(dados);
|
||||||
|
}
|
||||||
|
let resposta: Response;
|
||||||
|
try {
|
||||||
|
resposta = await fetch(caminho, opcoes);
|
||||||
|
} catch (e) {
|
||||||
|
const nome = (e as Error).name;
|
||||||
|
throw new ErroApi(0, nome === 'TimeoutError' ? 'O servidor demorou a responder' : 'Sem conexão com o servidor');
|
||||||
|
}
|
||||||
|
const texto = await resposta.text();
|
||||||
|
let corpo: any = null;
|
||||||
|
try {
|
||||||
|
corpo = texto ? JSON.parse(texto) : null;
|
||||||
|
} catch {
|
||||||
|
corpo = null;
|
||||||
|
}
|
||||||
|
if (!resposta.ok) {
|
||||||
|
if (resposta.status === 401) aoNegar();
|
||||||
|
throw new ErroApi(resposta.status, corpo?.erro || `Erro ${resposta.status}`);
|
||||||
|
}
|
||||||
|
return corpo as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const postar = <T = any>(caminho: string, dados: unknown = {}) => pedir<T>(caminho, dados);
|
||||||
|
|
||||||
|
export function urlEventos(): string {
|
||||||
|
const token = lerToken();
|
||||||
|
return token ? '/eventos?t=' + encodeURIComponent(token) : '/eventos';
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/** Toasts no lugar de alert/confirm: mensagem curta, com ação opcional (ex.: Desfazer). */
|
||||||
|
|
||||||
|
export type TipoAviso = 'info' | 'ok' | 'erro';
|
||||||
|
|
||||||
|
export interface Aviso {
|
||||||
|
id: number;
|
||||||
|
texto: string;
|
||||||
|
tipo: TipoAviso;
|
||||||
|
acao?: { rotulo: string; fazer: () => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
class Avisos {
|
||||||
|
lista = $state<Aviso[]>([]);
|
||||||
|
private seq = 0;
|
||||||
|
|
||||||
|
mostrar(texto: string, tipo: TipoAviso = 'info', acao?: Aviso['acao'], ms = tipo === 'erro' ? 6000 : 3500) {
|
||||||
|
const id = ++this.seq;
|
||||||
|
this.lista = [...this.lista, { id, texto, tipo, acao }].slice(-4);
|
||||||
|
setTimeout(() => this.fechar(id), acao ? Math.max(ms, 6000) : ms);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
fechar(id: number) {
|
||||||
|
this.lista = this.lista.filter((a) => a.id !== id);
|
||||||
|
}
|
||||||
|
ok = (t: string, acao?: Aviso['acao']) => this.mostrar(t, 'ok', acao);
|
||||||
|
erro = (e: unknown) => this.mostrar(e instanceof Error ? e.message : String(e), 'erro');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const avisos = new Avisos();
|
||||||
|
|
||||||
|
/** Executa uma ação do painel mostrando o erro num toast em vez de engolir. */
|
||||||
|
export async function tentar<T>(fn: () => Promise<T>, sucesso?: string): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
const r = await fn();
|
||||||
|
if (sucesso) avisos.ok(sucesso);
|
||||||
|
return r;
|
||||||
|
} catch (e) {
|
||||||
|
avisos.erro(e);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/** Ações que qualquer tela pode disparar: gestos, rosto, cor, E-Stop. */
|
||||||
|
import { postar } from './api';
|
||||||
|
import { avisos, tentar } from './avisos.svelte';
|
||||||
|
import { estado } from './estado.svelte';
|
||||||
|
|
||||||
|
/** Nome amigável e emoji de cada gesto do rosto ('parar' fecha o kiosk: não vira botão). */
|
||||||
|
export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
|
||||||
|
feliz: { nome: 'Feliz', emoji: '😊' },
|
||||||
|
alegre: { nome: 'Alegre', emoji: '😄' },
|
||||||
|
coracao: { nome: 'Coração', emoji: '❤️' },
|
||||||
|
piscar: { nome: 'Piscar', emoji: '😌' },
|
||||||
|
piscar2: { nome: 'Piscar duplo', emoji: '😑' },
|
||||||
|
piscadinha: { nome: 'Piscadinha', emoji: '😉' },
|
||||||
|
surpreso: { nome: 'Surpreso', emoji: '😮' },
|
||||||
|
triste: { nome: 'Triste', emoji: '😢' },
|
||||||
|
bravo: { nome: 'Bravo', emoji: '😠' },
|
||||||
|
olhar_esq: { nome: 'Olhar à esquerda', emoji: '👈' },
|
||||||
|
olhar_dir: { nome: 'Olhar à direita', emoji: '👉' },
|
||||||
|
olhar_baixo: { nome: 'Olhar para baixo', emoji: '👇' },
|
||||||
|
dormir: { nome: 'Dormir', emoji: '😴' },
|
||||||
|
acordar: { nome: 'Acordar', emoji: '🌅' },
|
||||||
|
aleatorio: { nome: 'Aleatório', emoji: '🎲' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
|
||||||
|
|
||||||
|
const CHAVE_FAVORITOS = 'carinha_favoritos';
|
||||||
|
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'piscadinha', 'surpreso', 'triste', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
|
||||||
|
|
||||||
|
function lerFavoritos(): string[] {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
|
||||||
|
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return v.slice(0, 9);
|
||||||
|
} catch {
|
||||||
|
/* sem armazenamento */
|
||||||
|
}
|
||||||
|
return PADRAO_FAVORITOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Favoritos {
|
||||||
|
lista = $state<string[]>(lerFavoritos());
|
||||||
|
alternar(g: string) {
|
||||||
|
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, 9);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const favoritos = new Favoritos();
|
||||||
|
|
||||||
|
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
|
||||||
|
export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome }));
|
||||||
|
export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb }));
|
||||||
|
export const recarregarCelular = () =>
|
||||||
|
tentar(async () => {
|
||||||
|
const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
|
||||||
|
avisos.ok(r.mensagem);
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function pararTudo() {
|
||||||
|
try {
|
||||||
|
await postar('/parar-tudo');
|
||||||
|
avisos.mostrar('E-Stop acionado: motores desligados e travados.', 'erro');
|
||||||
|
} catch (e) {
|
||||||
|
avisos.erro(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rearmar = () => tentar(() => postar('/rearmar'), 'Motores rearmados.');
|
||||||
|
|
||||||
|
export function hex(rgb: number[]) {
|
||||||
|
return '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
export function rgb(hexCor: string): [number, number, number] {
|
||||||
|
const n = parseInt(hexCor.slice(1), 16);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
export const CORES: { nome: string; rgb: [number, number, number] }[] = [
|
||||||
|
{ nome: 'Ciano', rgb: [92, 214, 255] },
|
||||||
|
{ nome: 'Verde', rgb: [74, 222, 128] },
|
||||||
|
{ nome: 'Amarelo', rgb: [251, 191, 36] },
|
||||||
|
{ nome: 'Laranja', rgb: [255, 140, 50] },
|
||||||
|
{ nome: 'Rosa', rgb: [255, 110, 190] },
|
||||||
|
{ nome: 'Roxo', rgb: [167, 139, 250] },
|
||||||
|
{ nome: 'Vermelho', rgb: [248, 90, 90] },
|
||||||
|
{ nome: 'Branco', rgb: [235, 240, 245] },
|
||||||
|
];
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X } from '@lucide/svelte';
|
||||||
|
import { avisos } from '../avisos.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="avisos" role="status" aria-live="polite">
|
||||||
|
{#each avisos.lista as a (a.id)}
|
||||||
|
<div class="aviso {a.tipo}">
|
||||||
|
<span>{a.texto}</span>
|
||||||
|
{#if a.acao}
|
||||||
|
<button
|
||||||
|
class="btn pequeno"
|
||||||
|
onclick={() => {
|
||||||
|
a.acao!.fazer();
|
||||||
|
avisos.fechar(a.id);
|
||||||
|
}}>{a.acao.rotulo}</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<button class="btn fantasma pequeno icone" aria-label="Fechar aviso" onclick={() => avisos.fechar(a.id)}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.avisos {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 90;
|
||||||
|
right: var(--e-4);
|
||||||
|
bottom: calc(var(--e-4) + env(safe-area-inset-bottom, 0px));
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-2);
|
||||||
|
width: min(420px, calc(100vw - 2 * var(--e-4)));
|
||||||
|
}
|
||||||
|
.aviso {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: var(--e-2) var(--e-2) var(--e-2) var(--e-4);
|
||||||
|
border-radius: var(--raio);
|
||||||
|
background: var(--superficie-2);
|
||||||
|
border: 1px solid var(--borda-forte);
|
||||||
|
border-left: 4px solid var(--destaque);
|
||||||
|
box-shadow: var(--sombra-flutuante);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
.aviso span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
border-left-color: var(--ok);
|
||||||
|
}
|
||||||
|
.erro {
|
||||||
|
border-left-color: var(--perigo);
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.avisos {
|
||||||
|
bottom: calc(76px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
let { titulo = '', descricao = '', acoes, children, class: classe = '' }: {
|
||||||
|
titulo?: string;
|
||||||
|
descricao?: string;
|
||||||
|
acoes?: Snippet;
|
||||||
|
children: Snippet;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="card {classe}">
|
||||||
|
{#if titulo || acoes}
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
{#if titulo}<h2>{titulo}</h2>{/if}
|
||||||
|
{#if descricao}<p class="fraco pequeno-texto">{descricao}</p>{/if}
|
||||||
|
</div>
|
||||||
|
{#if acoes}<div class="linha">{@render acoes()}</div>{/if}
|
||||||
|
</header>
|
||||||
|
{/if}
|
||||||
|
{@render children()}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
background: var(--superficie);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: var(--raio-lg);
|
||||||
|
padding: var(--e-4);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
align-content: start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--e-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: var(--t-md);
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.card {
|
||||||
|
padding: var(--e-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { OctagonX, RotateCcw } from '@lucide/svelte';
|
||||||
|
import { pararTudo, rearmar } from '../comandos.svelte';
|
||||||
|
import { estado } from '../estado.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if estado.estop.ativo}
|
||||||
|
<button class="btn estop parado" onclick={rearmar} title="Motores travados pelo E-Stop. Clique para rearmar.">
|
||||||
|
<RotateCcw size={18} /> <span>PARADO · Rearmar</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn perigo estop" onclick={pararTudo} title="Parar tudo (Shift+Espaço ou Esc duas vezes)">
|
||||||
|
<OctagonX size={18} /> <span>Parar tudo</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.estop {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.parado {
|
||||||
|
background: var(--aviso-suave);
|
||||||
|
border-color: var(--aviso);
|
||||||
|
color: var(--aviso);
|
||||||
|
animation: pulso 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulso {
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 4px var(--aviso-suave);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.estop {
|
||||||
|
width: 44px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Star } from '@lucide/svelte';
|
||||||
|
import { favoritos, gesto, GESTOS_INFO, gestosDisponiveis } from '../comandos.svelte';
|
||||||
|
|
||||||
|
// compacto: só os favoritos com atalho 1–9 (dock). Completo: todos, com estrela para fixar.
|
||||||
|
let { completo = false }: { completo?: boolean } = $props();
|
||||||
|
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => g in GESTOS_INFO));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="gestos" class:completo>
|
||||||
|
{#each lista as g, i (g)}
|
||||||
|
{@const info = GESTOS_INFO[g]}
|
||||||
|
<div class="item">
|
||||||
|
<button class="gesto" onclick={() => gesto(g)} title={completo ? info.nome : `${info.nome} (tecla ${i + 1})`}>
|
||||||
|
<span class="emoji" aria-hidden="true">{info.emoji}</span>
|
||||||
|
<span class="nome">{info.nome}</span>
|
||||||
|
{#if !completo}<kbd>{i + 1}</kbd>{/if}
|
||||||
|
</button>
|
||||||
|
{#if completo}
|
||||||
|
<button
|
||||||
|
class="estrela"
|
||||||
|
class:ativa={favoritos.lista.includes(g)}
|
||||||
|
aria-label={favoritos.lista.includes(g) ? `Tirar ${info.nome} dos favoritos` : `Fixar ${info.nome} nos favoritos`}
|
||||||
|
aria-pressed={favoritos.lista.includes(g)}
|
||||||
|
onclick={() => favoritos.alternar(g)}
|
||||||
|
>
|
||||||
|
<Star size={14} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.gestos {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
||||||
|
gap: var(--e-2);
|
||||||
|
}
|
||||||
|
.item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.gesto {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--e-3) var(--e-1) var(--e-2);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: var(--raio);
|
||||||
|
background: var(--superficie-2);
|
||||||
|
color: var(--texto);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
|
||||||
|
}
|
||||||
|
.gesto:hover {
|
||||||
|
border-color: var(--destaque);
|
||||||
|
background: var(--destaque-suave);
|
||||||
|
}
|
||||||
|
.gesto:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
.emoji {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.nome {
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
color: var(--texto-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
kbd {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 6px;
|
||||||
|
font: 500 10px var(--fonte-mono);
|
||||||
|
color: var(--texto-3);
|
||||||
|
}
|
||||||
|
.estrela {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--texto-3);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.estrela.ativa {
|
||||||
|
color: var(--aviso);
|
||||||
|
}
|
||||||
|
.estrela.ativa :global(svg) {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ChevronDown, ChevronUp } from '@lucide/svelte';
|
||||||
|
import { estado } from '../estado.svelte';
|
||||||
|
import RostoPrevia from './RostoPrevia.svelte';
|
||||||
|
|
||||||
|
// Um só mini-rosto em todas as telas (exceto Ao vivo, que já tem o rosto grande).
|
||||||
|
let aberto = $state(true);
|
||||||
|
try {
|
||||||
|
aberto = localStorage.getItem('carinha_mini') !== '0';
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
function alternar() {
|
||||||
|
aberto = !aberto;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('carinha_mini', aberto ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="mini" class:aberto aria-label="Rosto ao vivo">
|
||||||
|
<button class="barra" onclick={alternar} aria-expanded={aberto}>
|
||||||
|
<span class="ponto" class:online={estado.celularOnline} aria-hidden="true"></span>
|
||||||
|
<span>Rosto {estado.celularOnline ? 'ao vivo' : '· celular offline'}</span>
|
||||||
|
{#if aberto}<ChevronDown size={16} />{:else}<ChevronUp size={16} />{/if}
|
||||||
|
</button>
|
||||||
|
{#if aberto}
|
||||||
|
<div class="tela"><RostoPrevia /></div>
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.mini {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 55;
|
||||||
|
right: var(--e-4);
|
||||||
|
bottom: calc(84px + env(safe-area-inset-bottom, 0px));
|
||||||
|
width: 220px;
|
||||||
|
border-radius: var(--raio);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--vidro);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--borda-forte);
|
||||||
|
box-shadow: var(--sombra-flutuante);
|
||||||
|
}
|
||||||
|
.barra {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: var(--e-2) var(--e-3);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--texto-2);
|
||||||
|
font: 500 var(--t-xs) var(--fonte);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.barra span:nth-child(2) {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.ponto {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--texto-3);
|
||||||
|
}
|
||||||
|
.ponto.online {
|
||||||
|
background: var(--ok);
|
||||||
|
}
|
||||||
|
.tela {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.mini {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mini {
|
||||||
|
bottom: var(--e-4);
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Search } from '@lucide/svelte';
|
||||||
|
import { itensDaPaleta, paleta, type ItemPaleta } from '../paleta.svelte';
|
||||||
|
|
||||||
|
let busca = $state('');
|
||||||
|
let ativo = $state(0);
|
||||||
|
let campo = $state<HTMLInputElement>();
|
||||||
|
|
||||||
|
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||||
|
const itens = $derived.by(() => {
|
||||||
|
const termos = normal(busca).split(/\s+/).filter(Boolean);
|
||||||
|
return itensDaPaleta()
|
||||||
|
.filter((i) => termos.every((t) => normal(i.grupo + ' ' + i.titulo).includes(t)))
|
||||||
|
.slice(0, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (paleta.aberta) {
|
||||||
|
busca = '';
|
||||||
|
ativo = 0;
|
||||||
|
queueMicrotask(() => campo?.focus());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
void busca;
|
||||||
|
ativo = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function executar(i: ItemPaleta | undefined) {
|
||||||
|
if (!i) return;
|
||||||
|
paleta.fechar();
|
||||||
|
i.fazer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tecla(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
ativo = Math.min(itens.length - 1, ativo + 1);
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
ativo = Math.max(0, ativo - 1);
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
executar(itens[ativo]);
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
paleta.fechar();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if paleta.aberta}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||||
|
<div class="fundo" onclick={paleta.fechar}>
|
||||||
|
<div class="caixa" role="dialog" tabindex="-1" aria-modal="true" aria-label="Comandos" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="busca">
|
||||||
|
<Search size={18} />
|
||||||
|
<input
|
||||||
|
bind:this={campo}
|
||||||
|
bind:value={busca}
|
||||||
|
onkeydown={tecla}
|
||||||
|
placeholder="Gesto, rosto, tela ou ação…"
|
||||||
|
aria-label="Buscar comando"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="paleta-lista"
|
||||||
|
aria-activedescendant={itens[ativo] ? 'pal-' + itens[ativo].id : undefined}
|
||||||
|
/>
|
||||||
|
<kbd>Esc</kbd>
|
||||||
|
</div>
|
||||||
|
<ul id="paleta-lista" role="listbox">
|
||||||
|
{#each itens as item, i (item.id)}
|
||||||
|
{#if i === 0 || itens[i - 1].grupo !== item.grupo}
|
||||||
|
<li class="grupo" role="presentation">{item.grupo}</li>
|
||||||
|
{/if}
|
||||||
|
<li
|
||||||
|
id={'pal-' + item.id}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === ativo}
|
||||||
|
class:ativo={i === ativo}
|
||||||
|
onmousemove={() => (ativo = i)}
|
||||||
|
onclick={() => executar(item)}
|
||||||
|
>
|
||||||
|
<span>{item.titulo}</span>
|
||||||
|
{#if item.dica}<kbd>{item.dica}</kbd>{/if}
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="vazio">Nada encontrado.</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fundo {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 95;
|
||||||
|
background: rgb(0 0 0 / 0.45);
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
align-items: start;
|
||||||
|
padding: 12vh var(--e-4) var(--e-4);
|
||||||
|
}
|
||||||
|
.caixa {
|
||||||
|
width: min(560px, 100%);
|
||||||
|
max-height: 70vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
background: var(--vidro);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border: 1px solid var(--borda-forte);
|
||||||
|
border-radius: var(--raio-lg);
|
||||||
|
box-shadow: var(--sombra-flutuante);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.busca {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-3);
|
||||||
|
padding: var(--e-3) var(--e-4);
|
||||||
|
border-bottom: 1px solid var(--borda);
|
||||||
|
color: var(--texto-3);
|
||||||
|
}
|
||||||
|
.busca input {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
min-height: 36px;
|
||||||
|
font-size: var(--t-md);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.busca input:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--e-2);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
li[role='option'] {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--e-2) var(--e-3);
|
||||||
|
border-radius: var(--raio-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
li.ativo {
|
||||||
|
background: var(--destaque-suave);
|
||||||
|
color: var(--destaque);
|
||||||
|
}
|
||||||
|
.grupo {
|
||||||
|
padding: var(--e-3) var(--e-3) var(--e-1);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
color: var(--texto-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.vazio {
|
||||||
|
padding: var(--e-4);
|
||||||
|
color: var(--texto-3);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
kbd {
|
||||||
|
font: 500 11px var(--fonte-mono);
|
||||||
|
color: var(--texto-3);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Prévia do rosto: o próprio carinha.html em modo ?painel=1 (sem polling).
|
||||||
|
* O painel repassa por postMessage o que chega no SSE, então a prévia mostra
|
||||||
|
* exatamente as mesmas ordens que o celular recebe.
|
||||||
|
*/
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { estado, ouvir } from '../estado.svelte';
|
||||||
|
|
||||||
|
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props();
|
||||||
|
let quadro: HTMLIFrameElement;
|
||||||
|
let pronto = $state(false);
|
||||||
|
|
||||||
|
function enviar(msg: unknown) {
|
||||||
|
if (pronto) quadro?.contentWindow?.postMessage(msg, location.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aoCarregar() {
|
||||||
|
pronto = true;
|
||||||
|
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
|
||||||
|
}
|
||||||
|
|
||||||
|
let ultimaConversa = estado.conversa?.ultima?.id;
|
||||||
|
const cancelar = [
|
||||||
|
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
|
||||||
|
ouvir('app_config', (c) => enviar({ tipo: 'config', config: c })),
|
||||||
|
ouvir('reacao', (r) => r?.receita && enviar({ tipo: 'previa-reacao', receita: r.receita })),
|
||||||
|
// O celular reage à resposta da conversa com acordar + gesto; a prévia imita.
|
||||||
|
ouvir('conversa', (c) => {
|
||||||
|
if (c.ultima && c.ultima.id !== ultimaConversa) {
|
||||||
|
ultimaConversa = c.ultima.id;
|
||||||
|
enviar({ tipo: 'ordem', ordem: 'acordar' });
|
||||||
|
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
onDestroy(() => cancelar.forEach((c) => c()));
|
||||||
|
|
||||||
|
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
|
||||||
|
export function previa(receita: unknown) {
|
||||||
|
enviar({ tipo: 'previa-reacao', receita });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
bind:this={quadro}
|
||||||
|
src="/carinha.html?espelho=1&painel=1&auto=0&camera=0&audio=0"
|
||||||
|
title={titulo}
|
||||||
|
tabindex="-1"
|
||||||
|
onload={aoCarregar}
|
||||||
|
></iframe>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
iframe {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: #000;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
// Estado nunca só por cor: o selo sempre tem texto (e o ponto é decorativo).
|
||||||
|
let { tom = 'neutro', children }: { tom?: 'ok' | 'aviso' | 'perigo' | 'neutro' | 'destaque'; children: Snippet } =
|
||||||
|
$props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="selo {tom}"><span class="ponto" aria-hidden="true"></span>{@render children()}</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.selo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--raio-pill);
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--superficie-2);
|
||||||
|
color: var(--texto-2);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ponto {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
background: var(--ok-suave);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.aviso {
|
||||||
|
color: var(--aviso);
|
||||||
|
background: var(--aviso-suave);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.perigo {
|
||||||
|
color: var(--perigo);
|
||||||
|
background: var(--perigo-suave);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.destaque {
|
||||||
|
color: var(--destaque);
|
||||||
|
background: var(--destaque-suave);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { rotulo, valor, min, max, passo = 1, unidade = '', disabled = false, oninput, onchange }: {
|
||||||
|
rotulo: string;
|
||||||
|
valor: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
passo?: number;
|
||||||
|
unidade?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
oninput?: (v: number) => void;
|
||||||
|
onchange?: (v: number) => void;
|
||||||
|
} = $props();
|
||||||
|
const id = 's' + Math.random().toString(36).slice(2, 9);
|
||||||
|
let local = $state<number | null>(null);
|
||||||
|
const mostrado = $derived(local ?? valor);
|
||||||
|
const casas = $derived(passo < 1 ? Math.min(2, String(passo).split('.')[1]?.length ?? 2) : 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="slider">
|
||||||
|
<label for={id}>
|
||||||
|
<span>{rotulo}</span>
|
||||||
|
<output for={id} class="mono">{mostrado.toFixed(casas)}{unidade}</output>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{id}
|
||||||
|
type="range"
|
||||||
|
{min}
|
||||||
|
{max}
|
||||||
|
step={passo}
|
||||||
|
value={mostrado}
|
||||||
|
{disabled}
|
||||||
|
style="--p: {((mostrado - min) / (max - min)) * 100}%"
|
||||||
|
oninput={(e) => {
|
||||||
|
local = Number(e.currentTarget.value);
|
||||||
|
oninput?.(local);
|
||||||
|
}}
|
||||||
|
onchange={(e) => {
|
||||||
|
const v = Number(e.currentTarget.value);
|
||||||
|
local = null;
|
||||||
|
onchange?.(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.slider {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-1);
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
gap: var(--e-2);
|
||||||
|
}
|
||||||
|
output {
|
||||||
|
color: var(--texto-2);
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 28px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
input::-webkit-slider-runnable-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
|
||||||
|
}
|
||||||
|
input::-moz-range-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
|
||||||
|
}
|
||||||
|
input::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: -7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--texto);
|
||||||
|
border: 3px solid var(--destaque);
|
||||||
|
}
|
||||||
|
input::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--texto);
|
||||||
|
border: 3px solid var(--destaque);
|
||||||
|
}
|
||||||
|
input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { rotulo, ajuda = '', checked = false, disabled = false, onchange }: {
|
||||||
|
rotulo: string;
|
||||||
|
ajuda?: string;
|
||||||
|
checked?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onchange: (v: boolean) => void;
|
||||||
|
} = $props();
|
||||||
|
const id = 't' + Math.random().toString(36).slice(2, 9);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label class="toggle" for={id}>
|
||||||
|
<span class="texto">
|
||||||
|
<span>{rotulo}</span>
|
||||||
|
{#if ajuda}<small class="fraco">{ajuda}</small>{/if}
|
||||||
|
</span>
|
||||||
|
<input {id} type="checkbox" role="switch" {checked} {disabled} onchange={(e) => onchange(e.currentTarget.checked)} />
|
||||||
|
<span class="trilho" aria-hidden="true"></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--e-3);
|
||||||
|
min-height: 44px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.texto {
|
||||||
|
display: grid;
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.trilho {
|
||||||
|
flex: none;
|
||||||
|
width: 42px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: var(--raio-pill);
|
||||||
|
background: var(--superficie-3);
|
||||||
|
border: 1px solid var(--borda-forte);
|
||||||
|
position: relative;
|
||||||
|
transition: background var(--transicao);
|
||||||
|
}
|
||||||
|
.trilho::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--texto-2);
|
||||||
|
transition: transform var(--transicao), background var(--transicao);
|
||||||
|
}
|
||||||
|
input:checked + .trilho {
|
||||||
|
background: var(--destaque);
|
||||||
|
border-color: var(--destaque);
|
||||||
|
}
|
||||||
|
input:checked + .trilho::after {
|
||||||
|
transform: translateX(18px);
|
||||||
|
background: var(--destaque-texto);
|
||||||
|
}
|
||||||
|
input:focus-visible + .trilho {
|
||||||
|
outline: 2px solid var(--destaque);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
input:disabled + .trilho {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { KeyRound } from '@lucide/svelte';
|
||||||
|
import { gravarToken } from '../api';
|
||||||
|
import { conectar } from '../estado.svelte';
|
||||||
|
|
||||||
|
let token = $state('');
|
||||||
|
async function entrar(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
gravarToken(token.trim());
|
||||||
|
await conectar();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fundo">
|
||||||
|
<form class="caixa" onsubmit={entrar}>
|
||||||
|
<KeyRound size={28} />
|
||||||
|
<h1>Token do Carinha</h1>
|
||||||
|
<p class="fraco pequeno-texto">
|
||||||
|
Este servidor exige um token para controlar o robô. Ele fica em <span class="mono">CARINHA_TOKEN</span> ou
|
||||||
|
<span class="mono">seguranca.json</span> na placa e é guardado só neste navegador.
|
||||||
|
</p>
|
||||||
|
<label class="campo">
|
||||||
|
<span>Token</span>
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input type="password" bind:value={token} autocomplete="current-password" required minlength="8" autofocus />
|
||||||
|
</label>
|
||||||
|
<button class="btn primario" type="submit">Entrar</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fundo {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--e-4);
|
||||||
|
background: var(--fundo);
|
||||||
|
}
|
||||||
|
.caixa {
|
||||||
|
width: min(400px, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
padding: var(--e-6);
|
||||||
|
border-radius: var(--raio-lg);
|
||||||
|
background: var(--superficie);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
color: var(--destaque);
|
||||||
|
}
|
||||||
|
h1,
|
||||||
|
p,
|
||||||
|
label {
|
||||||
|
color: var(--texto);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Estado do painel: uma foto inicial (/estado) + um único fluxo SSE (/eventos).
|
||||||
|
* Nenhuma tela faz polling; todas leem daqui.
|
||||||
|
*/
|
||||||
|
import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
|
||||||
|
import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
|
||||||
|
|
||||||
|
export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
|
||||||
|
|
||||||
|
export interface ItemFeed {
|
||||||
|
id: number;
|
||||||
|
quando: number;
|
||||||
|
tipo: string;
|
||||||
|
texto: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Estado {
|
||||||
|
conexao = $state<Conexao>('conectando');
|
||||||
|
versao = $state('');
|
||||||
|
autenticacao = $state(false);
|
||||||
|
estop = $state({ ativo: false, desde: null as number | null });
|
||||||
|
aparencia = $state({ rosto: 'carinha', cor: [92, 214, 255] as [number, number, number] });
|
||||||
|
gestos = $state<string[]>([]);
|
||||||
|
rostos = $state<string[]>([]);
|
||||||
|
app = $state<Telemetria | null>(null);
|
||||||
|
appRecebidoEm = $state(0);
|
||||||
|
appConfig = $state<AppConfig | null>(null);
|
||||||
|
motores = $state<Motores | null>(null);
|
||||||
|
conversa = $state<Conversa | null>(null);
|
||||||
|
ia = $state<IA | null>(null);
|
||||||
|
reacao = $state<Reacao | null>(null);
|
||||||
|
treinamento = $state({ ocupado: false, erro: '' });
|
||||||
|
api = $state<EstadoServidor['api'] | null>(null);
|
||||||
|
feed = $state<ItemFeed[]>([]);
|
||||||
|
/** Relógio de 1 s para derivar "online" sem esperar evento novo. */
|
||||||
|
agora = $state(Date.now());
|
||||||
|
|
||||||
|
celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const estado = new Estado();
|
||||||
|
|
||||||
|
type Ouvinte = (dados: any) => void;
|
||||||
|
const ouvintes = new Map<string, Set<Ouvinte>>();
|
||||||
|
|
||||||
|
/** Assina um tipo de evento do servidor; devolve a função para cancelar. */
|
||||||
|
export function ouvir(tipo: string, fn: Ouvinte): () => void {
|
||||||
|
if (!ouvintes.has(tipo)) ouvintes.set(tipo, new Set());
|
||||||
|
ouvintes.get(tipo)!.add(fn);
|
||||||
|
return () => ouvintes.get(tipo)!.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
let seqFeed = 0;
|
||||||
|
function noFeed(tipo: string, texto: string) {
|
||||||
|
estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aplicarFoto(f: EstadoServidor) {
|
||||||
|
estado.versao = f.versao;
|
||||||
|
estado.autenticacao = f.autenticacao;
|
||||||
|
estado.estop = f.estop;
|
||||||
|
estado.aparencia = f.aparencia;
|
||||||
|
estado.gestos = f.gestos;
|
||||||
|
estado.rostos = f.rostos;
|
||||||
|
if (f.app) {
|
||||||
|
estado.app = f.app;
|
||||||
|
estado.appRecebidoEm = Date.now();
|
||||||
|
}
|
||||||
|
if (f.app_config) estado.appConfig = f.app_config;
|
||||||
|
if (f.motores) estado.motores = f.motores;
|
||||||
|
if (f.conversa) estado.conversa = f.conversa;
|
||||||
|
if (f.ia) estado.ia = f.ia;
|
||||||
|
estado.reacao = f.reacao ?? null;
|
||||||
|
if (f.treinamento) estado.treinamento = f.treinamento;
|
||||||
|
if (f.api) estado.api = f.api;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tratadores: Record<string, (d: any) => void> = {
|
||||||
|
expressao: (d: { ordem: string }) => {
|
||||||
|
if (d.ordem !== 'calma') noFeed('expressao', 'Expressão: ' + d.ordem);
|
||||||
|
},
|
||||||
|
telemetria: (d: Telemetria) => {
|
||||||
|
estado.app = d;
|
||||||
|
estado.appRecebidoEm = Date.now();
|
||||||
|
},
|
||||||
|
app_config: (d: AppConfig) => (estado.appConfig = d),
|
||||||
|
aparencia: (d) => (estado.aparencia = d),
|
||||||
|
motores: (d: Motores) => (estado.motores = d),
|
||||||
|
estop: (d) => {
|
||||||
|
estado.estop = d;
|
||||||
|
noFeed('estop', d.ativo ? 'E-Stop acionado' : 'Motores rearmados');
|
||||||
|
},
|
||||||
|
conversa: (d: Conversa) => {
|
||||||
|
const nova = d.ultima && d.ultima.id !== estado.conversa?.ultima?.id;
|
||||||
|
estado.conversa = d;
|
||||||
|
if (nova) noFeed('conversa', 'Carinha: ' + d.ultima!.texto);
|
||||||
|
},
|
||||||
|
ia: (d: IA) => {
|
||||||
|
const nova = d.ultima && d.ultima.horario !== estado.ia?.ultima?.horario;
|
||||||
|
estado.ia = d;
|
||||||
|
if (nova) noFeed('ia', `IA local: ${d.ultima!.evento} → ${d.ultima!.gesto}`);
|
||||||
|
},
|
||||||
|
reacao: (d: Reacao | null) => {
|
||||||
|
estado.reacao = d;
|
||||||
|
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
|
||||||
|
},
|
||||||
|
treinamento: (d) => (estado.treinamento = d),
|
||||||
|
};
|
||||||
|
|
||||||
|
let fonte: EventSource | null = null;
|
||||||
|
let primeiraAbertura = true;
|
||||||
|
|
||||||
|
export async function carregarFoto() {
|
||||||
|
aplicarFoto(await pedir<EstadoServidor>('/estado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirEventos() {
|
||||||
|
fonte?.close();
|
||||||
|
fonte = new EventSource(urlEventos());
|
||||||
|
fonte.addEventListener('pronto', async () => {
|
||||||
|
estado.conexao = 'aberta';
|
||||||
|
// Ao reconectar, ressincroniza: pode ter perdido eventos além do buffer do servidor.
|
||||||
|
if (!primeiraAbertura) carregarFoto().catch(() => {});
|
||||||
|
primeiraAbertura = false;
|
||||||
|
});
|
||||||
|
fonte.onerror = () => {
|
||||||
|
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
||||||
|
};
|
||||||
|
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
||||||
|
'reacao', 'treinamento']) {
|
||||||
|
fonte.addEventListener(tipo, (e) => {
|
||||||
|
const dados = JSON.parse((e as MessageEvent).data);
|
||||||
|
tratadores[tipo]?.(dados);
|
||||||
|
ouvintes.get(tipo)?.forEach((fn) => fn(dados));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function conectar() {
|
||||||
|
estado.conexao = 'conectando';
|
||||||
|
try {
|
||||||
|
await carregarFoto();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ErroApi && e.status === 401) {
|
||||||
|
estado.conexao = 'token';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
estado.conexao = 'caiu';
|
||||||
|
setTimeout(conectar, 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
abrirEventos();
|
||||||
|
}
|
||||||
|
|
||||||
|
quandoNegado(() => {
|
||||||
|
estado.conexao = 'token';
|
||||||
|
fonte?.close();
|
||||||
|
fonte = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => (estado.agora = Date.now()), 1000);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */
|
||||||
|
import { cor, CORES, gesto, GESTOS_INFO, gestosDisponiveis, pararTudo, rearmar, recarregarCelular, rosto } from './comandos.svelte';
|
||||||
|
import { estado } from './estado.svelte';
|
||||||
|
import { nav, ROTAS } from './rotas.svelte';
|
||||||
|
|
||||||
|
export interface ItemPaleta {
|
||||||
|
id: string;
|
||||||
|
grupo: string;
|
||||||
|
titulo: string;
|
||||||
|
dica?: string;
|
||||||
|
fazer: () => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Fonte = () => ItemPaleta[];
|
||||||
|
const fontes = new Set<Fonte>();
|
||||||
|
|
||||||
|
export function registrarNaPaleta(fonte: Fonte): () => void {
|
||||||
|
fontes.add(fonte);
|
||||||
|
return () => fontes.delete(fonte);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Paleta {
|
||||||
|
aberta = $state(false);
|
||||||
|
abrir = () => (this.aberta = true);
|
||||||
|
fechar = () => (this.aberta = false);
|
||||||
|
}
|
||||||
|
export const paleta = new Paleta();
|
||||||
|
|
||||||
|
export function itensDaPaleta(): ItemPaleta[] {
|
||||||
|
return [...fontes].flatMap((f) => f());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Itens base: navegação, ações gerais, gestos, rostos e cores.
|
||||||
|
registrarNaPaleta(() => [
|
||||||
|
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
|
||||||
|
estado.estop.ativo
|
||||||
|
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
|
||||||
|
: { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo },
|
||||||
|
{ id: 'recarregar', grupo: 'Ações', titulo: 'Recarregar tela do celular', fazer: recarregarCelular },
|
||||||
|
...gestosDisponiveis().map((g) => ({
|
||||||
|
id: 'gesto-' + g,
|
||||||
|
grupo: 'Gestos',
|
||||||
|
titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`,
|
||||||
|
fazer: () => gesto(g),
|
||||||
|
})),
|
||||||
|
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + r, fazer: () => rosto(r) })),
|
||||||
|
...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* Rastreamento de rosto/mãos no navegador do PC (MediaPipe num Worker servido pelo
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
interface Ponto {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
interface Face extends Ponto {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
score: number;
|
||||||
|
pontos?: Ponto[];
|
||||||
|
}
|
||||||
|
interface Resultado {
|
||||||
|
faces: Face[];
|
||||||
|
hands: Ponto[][];
|
||||||
|
ms: number;
|
||||||
|
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.');
|
||||||
|
|
||||||
|
private worker: Worker | null = null;
|
||||||
|
private pronto = false;
|
||||||
|
private pendente = false;
|
||||||
|
private geracao = 0;
|
||||||
|
private ativo = false;
|
||||||
|
private config: AppConfig = {};
|
||||||
|
private resultado: Resultado | null = null;
|
||||||
|
private alvo: AlvoRastreio | null = null;
|
||||||
|
private alvoEm = 0;
|
||||||
|
private em = 0;
|
||||||
|
private intervalo = 50;
|
||||||
|
private proximo = 0;
|
||||||
|
private ultimoVideo = -1;
|
||||||
|
private tentarEm = 0;
|
||||||
|
private amostraAnterior = 0;
|
||||||
|
private rascunho = document.createElement('canvas');
|
||||||
|
private relogio: number;
|
||||||
|
private ctx: CanvasRenderingContext2D;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private video: HTMLVideoElement,
|
||||||
|
private tela: HTMLCanvasElement,
|
||||||
|
private saida: VideoAoVivo,
|
||||||
|
) {
|
||||||
|
this.ctx = tela.getContext('2d')!;
|
||||||
|
this.relogio = window.setInterval(() => this.passo(), 20);
|
||||||
|
addEventListener('resize', this.desenhar);
|
||||||
|
}
|
||||||
|
|
||||||
|
private limpar() {
|
||||||
|
this.ctx.clearRect(0, 0, this.tela.width, this.tela.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parar(texto: string) {
|
||||||
|
if (this.ativo) this.saida.rastrear(null);
|
||||||
|
this.ativo = false;
|
||||||
|
this.geracao++;
|
||||||
|
this.worker?.terminate();
|
||||||
|
this.worker = null;
|
||||||
|
this.pendente = this.pronto = false;
|
||||||
|
this.resultado = this.alvo = null;
|
||||||
|
this.ultimoVideo = -1;
|
||||||
|
this.amostraAnterior = 0;
|
||||||
|
this.limpar();
|
||||||
|
this.texto = texto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private desenhar = () => {
|
||||||
|
const caixa = this.tela.parentElement!.getBoundingClientRect();
|
||||||
|
if (this.tela.width !== Math.round(caixa.width) || this.tela.height !== Math.round(caixa.height)) {
|
||||||
|
this.tela.width = Math.round(caixa.width);
|
||||||
|
this.tela.height = Math.round(caixa.height);
|
||||||
|
}
|
||||||
|
this.limpar();
|
||||||
|
this.tela.hidden = !this.config.mostrarRastreamento;
|
||||||
|
const r = this.resultado;
|
||||||
|
if (this.tela.hidden || !r || performance.now() - this.em > 350 || !this.video.videoWidth) return;
|
||||||
|
const f = encaixe(this.tela.width, this.tela.height, this.video.videoWidth, this.video.videoHeight);
|
||||||
|
const pt = (p: Ponto) => ({ x: f.x + p.x * f.w, y: f.y + p.y * f.h });
|
||||||
|
const c = this.ctx;
|
||||||
|
c.lineWidth = 2;
|
||||||
|
c.font = '13px system-ui';
|
||||||
|
const bola = (p: Ponto, raio = 3) => {
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(p.x, p.y, raio, 0, Math.PI * 2);
|
||||||
|
c.fill();
|
||||||
|
};
|
||||||
|
for (const rosto of r.faces) {
|
||||||
|
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));
|
||||||
|
for (const q of rosto.pontos || []) bola(pt(q));
|
||||||
|
}
|
||||||
|
for (const mao of r.hands) {
|
||||||
|
c.strokeStyle = c.fillStyle = '#4ade80';
|
||||||
|
for (const [a, b] of LIGACOES) {
|
||||||
|
const p = pt(mao[a]);
|
||||||
|
const q = pt(mao[b]);
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(p.x, p.y);
|
||||||
|
c.lineTo(q.x, q.y);
|
||||||
|
c.stroke();
|
||||||
|
}
|
||||||
|
for (const q of mao) bola(pt(q));
|
||||||
|
}
|
||||||
|
if (this.alvo) {
|
||||||
|
const p = pt(this.alvo);
|
||||||
|
c.strokeStyle = '#fbbf24';
|
||||||
|
c.beginPath();
|
||||||
|
c.arc(p.x, p.y, 12, 0, Math.PI * 2);
|
||||||
|
c.moveTo(p.x - 18, p.y);
|
||||||
|
c.lineTo(p.x + 18, p.y);
|
||||||
|
c.moveTo(p.x, p.y - 18);
|
||||||
|
c.lineTo(p.x, p.y + 18);
|
||||||
|
c.stroke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private iniciar() {
|
||||||
|
this.ativo = true;
|
||||||
|
const geracao = ++this.geracao;
|
||||||
|
this.worker = new Worker('/rastreador-worker.js');
|
||||||
|
this.texto = 'Carregando rastreadores locais…';
|
||||||
|
this.worker.onerror = (e) => {
|
||||||
|
this.tentarEm = Date.now() + 10000;
|
||||||
|
this.parar('Falha no rastreador: ' + e.message);
|
||||||
|
};
|
||||||
|
this.worker.onmessage = ({ data }) => {
|
||||||
|
if (geracao !== this.geracao) return;
|
||||||
|
this.pendente = false;
|
||||||
|
if (data.tipo === 'erro') {
|
||||||
|
this.tentarEm = Date.now() + 10000;
|
||||||
|
this.parar('Rastreamento indisponível: ' + data.erro);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pronto = true;
|
||||||
|
if (data.tipo === 'pronto') {
|
||||||
|
this.texto = 'Rastreadores prontos. Aguardando imagem…';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = data as Resultado;
|
||||||
|
this.resultado = r;
|
||||||
|
this.em = r.timestamp;
|
||||||
|
const anterior = this.alvo;
|
||||||
|
const alvo = escolherAlvo(r.faces, r.hands, anterior);
|
||||||
|
if (alvo) alvo.atividade = !!anterior && alvo.tipo === anterior.tipo && Math.hypot(alvo.x - anterior.x, alvo.y - anterior.y) > 0.008;
|
||||||
|
this.alvo = alvo;
|
||||||
|
this.alvoEm = r.timestamp;
|
||||||
|
this.intervalo = Math.max(50, Math.min(200, r.ms * 1.2));
|
||||||
|
const hz = this.amostraAnterior ? Math.round(1000 / (r.timestamp - this.amostraAnterior)) : 0;
|
||||||
|
this.amostraAnterior = r.timestamp;
|
||||||
|
this.texto = `${r.faces.length} rosto(s) · ${r.hands.length} mão(s) · ${hz} análises/s · ${Math.round(r.ms)} ms${
|
||||||
|
alvo ? ' · alvo: ' + (alvo.tipo === 'mao' ? 'mão' : 'rosto') : ' · procurando…'
|
||||||
|
}`;
|
||||||
|
if (this.config.seguirRastreamento) this.saida.rastrear(alvo);
|
||||||
|
this.desenhar();
|
||||||
|
};
|
||||||
|
this.pendente = true;
|
||||||
|
this.worker.postMessage({ config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async passo() {
|
||||||
|
const agora = performance.now();
|
||||||
|
if (this.alvo && agora - this.alvoEm > 350) {
|
||||||
|
this.alvo = null;
|
||||||
|
if (this.config.seguirRastreamento) this.saida.rastrear(null);
|
||||||
|
}
|
||||||
|
if (this.resultado && agora - this.em > 350) {
|
||||||
|
this.resultado = null;
|
||||||
|
this.limpar();
|
||||||
|
}
|
||||||
|
const v = this.video;
|
||||||
|
if (!this.ativo || !this.pronto || this.pendente || agora < this.proximo || v.readyState < 2 || v.currentTime === this.ultimoVideo)
|
||||||
|
return;
|
||||||
|
this.pendente = true;
|
||||||
|
this.proximo = agora + this.intervalo;
|
||||||
|
this.ultimoVideo = v.currentTime;
|
||||||
|
const geracao = this.geracao;
|
||||||
|
try {
|
||||||
|
const escala = Math.min(1, 640 / v.videoWidth);
|
||||||
|
this.rascunho.width = Math.round(v.videoWidth * escala);
|
||||||
|
this.rascunho.height = Math.round(v.videoHeight * escala);
|
||||||
|
this.rascunho.getContext('2d')!.drawImage(v, 0, 0, this.rascunho.width, this.rascunho.height);
|
||||||
|
const bitmap = await createImageBitmap(this.rascunho);
|
||||||
|
if (geracao !== this.geracao) return bitmap.close();
|
||||||
|
this.worker!.postMessage(
|
||||||
|
{ bitmap, timestamp: agora, config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } },
|
||||||
|
[bitmap],
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (geracao === this.geracao) this.parar('Falha ao analisar vídeo: ' + (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
atualizar(config: AppConfig | null, permitido: boolean) {
|
||||||
|
if (!config) return;
|
||||||
|
const mudou = this.config.rastrearRosto !== config.rastrearRosto || this.config.rastrearMaos !== config.rastrearMaos;
|
||||||
|
if (this.config.seguirRastreamento && !config.seguirRastreamento) this.saida.rastrear(null);
|
||||||
|
this.config = config;
|
||||||
|
if (!permitido || (!config.rastrearRosto && !config.rastrearMaos)) {
|
||||||
|
this.parar(!permitido ? 'Ligue a transmissão para rastrear no PC.' : 'Rastreamento desligado.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mudou && this.ativo) this.parar('Atualizando rastreadores…');
|
||||||
|
if (!this.ativo && Date.now() >= this.tentarEm) this.iniciar();
|
||||||
|
this.desenhar();
|
||||||
|
}
|
||||||
|
|
||||||
|
destruir() {
|
||||||
|
clearInterval(this.relogio);
|
||||||
|
removeEventListener('resize', this.desenhar);
|
||||||
|
this.parar('Painel fechado.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
|
||||||
|
|
||||||
|
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes';
|
||||||
|
|
||||||
|
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||||
|
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
|
||||||
|
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
|
||||||
|
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
|
||||||
|
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
|
||||||
|
{ id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function lerHash(): Rota {
|
||||||
|
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
|
||||||
|
return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo';
|
||||||
|
}
|
||||||
|
|
||||||
|
class Navegacao {
|
||||||
|
atual = $state<Rota>(lerHash());
|
||||||
|
ir(rota: Rota) {
|
||||||
|
location.hash = '/' + rota;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nav = new Navegacao();
|
||||||
|
addEventListener('hashchange', () => (nav.atual = lerHash()));
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* Design system do painel Carinha: tokens + poucos utilitários. Escuro por padrão, claro opcional. */
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--fundo: #0b1017;
|
||||||
|
--fundo-2: #0f1620;
|
||||||
|
--superficie: #141c27;
|
||||||
|
--superficie-2: #1a2431;
|
||||||
|
--superficie-3: #223041;
|
||||||
|
--borda: #263447;
|
||||||
|
--borda-forte: #34475f;
|
||||||
|
--texto: #e8eef6;
|
||||||
|
--texto-2: #a9b8c9;
|
||||||
|
--texto-3: #8396ab; /* AA sobre --superficie */
|
||||||
|
--destaque: #5cd6ff;
|
||||||
|
--destaque-forte: #2fbff0;
|
||||||
|
--destaque-texto: #04202b;
|
||||||
|
--destaque-suave: rgb(92 214 255 / 0.12);
|
||||||
|
--ok: #4ade80;
|
||||||
|
--ok-suave: rgb(74 222 128 / 0.12);
|
||||||
|
--aviso: #fbbf24;
|
||||||
|
--aviso-suave: rgb(251 191 36 / 0.12);
|
||||||
|
--perigo: #f87171;
|
||||||
|
--perigo-forte: #ef4444;
|
||||||
|
--perigo-suave: rgb(248 113 113 / 0.14);
|
||||||
|
--vidro: rgb(15 22 32 / 0.72);
|
||||||
|
|
||||||
|
--fonte: 'Outfit Variable', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--fonte-mono: 'JetBrains Mono Variable', ui-monospace, Consolas, monospace;
|
||||||
|
--t-xs: 0.75rem; --t-sm: 0.875rem; --t-md: 1rem; --t-lg: 1.25rem; --t-xl: 1.6rem;
|
||||||
|
|
||||||
|
--e-1: 4px; --e-2: 8px; --e-3: 12px; --e-4: 16px; --e-5: 24px; --e-6: 32px;
|
||||||
|
--raio-sm: 8px; --raio: 12px; --raio-lg: 18px; --raio-pill: 999px;
|
||||||
|
--sombra: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 24px rgb(0 0 0 / 0.25);
|
||||||
|
--sombra-flutuante: 0 12px 40px rgb(0 0 0 / 0.45);
|
||||||
|
--transicao: 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
|
||||||
|
--nav-largura: 232px;
|
||||||
|
--topo-altura: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
--fundo: #f4f6f9;
|
||||||
|
--fundo-2: #eef2f6;
|
||||||
|
--superficie: #ffffff;
|
||||||
|
--superficie-2: #f3f6fa;
|
||||||
|
--superficie-3: #e7edf4;
|
||||||
|
--borda: #dbe3ec;
|
||||||
|
--borda-forte: #c3cfdc;
|
||||||
|
--texto: #0f1722;
|
||||||
|
--texto-2: #3d4d60;
|
||||||
|
--texto-3: #5a6b7f;
|
||||||
|
--destaque: #0284c7;
|
||||||
|
--destaque-forte: #0369a1;
|
||||||
|
--destaque-texto: #ffffff;
|
||||||
|
--destaque-suave: rgb(2 132 199 / 0.1);
|
||||||
|
--ok: #15803d; --ok-suave: rgb(21 128 61 / 0.1);
|
||||||
|
--aviso: #b45309; --aviso-suave: rgb(180 83 9 / 0.1);
|
||||||
|
--perigo: #dc2626; --perigo-forte: #b91c1c; --perigo-suave: rgb(220 38 38 / 0.1);
|
||||||
|
--vidro: rgb(255 255 255 / 0.78);
|
||||||
|
--sombra: 0 1px 2px rgb(15 23 34 / 0.06), 0 6px 20px rgb(15 23 34 / 0.08);
|
||||||
|
--sombra-flutuante: 0 12px 40px rgb(15 23 34 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; height: 100%; }
|
||||||
|
body {
|
||||||
|
background: var(--fundo);
|
||||||
|
color: var(--texto);
|
||||||
|
font: 400 var(--t-md)/1.5 var(--fonte);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4 { margin: 0; line-height: 1.2; font-weight: 600; }
|
||||||
|
h1 { font-size: var(--t-xl); }
|
||||||
|
h2 { font-size: var(--t-lg); }
|
||||||
|
h3 { font-size: var(--t-md); }
|
||||||
|
p { margin: 0; }
|
||||||
|
a { color: var(--destaque); }
|
||||||
|
img, video, canvas, iframe { max-width: 100%; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
:focus-visible { outline: 2px solid var(--destaque); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ---------- botões ---------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: var(--e-2);
|
||||||
|
min-height: 40px; padding: 0 var(--e-4);
|
||||||
|
border: 1px solid var(--borda-forte); border-radius: var(--raio-sm);
|
||||||
|
background: var(--superficie-2); color: var(--texto);
|
||||||
|
font: 500 var(--t-sm) var(--fonte); cursor: pointer; white-space: nowrap;
|
||||||
|
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
|
||||||
|
touch-action: manipulation; user-select: none;
|
||||||
|
}
|
||||||
|
.btn:hover:not(:disabled) { background: var(--superficie-3); }
|
||||||
|
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn.primario { background: var(--destaque); border-color: var(--destaque); color: var(--destaque-texto); }
|
||||||
|
.btn.primario:hover:not(:disabled) { background: var(--destaque-forte); }
|
||||||
|
.btn.perigo { background: var(--perigo-forte); border-color: var(--perigo-forte); color: #fff; }
|
||||||
|
.btn.perigo:hover:not(:disabled) { background: var(--perigo); }
|
||||||
|
.btn.fantasma { background: transparent; border-color: transparent; }
|
||||||
|
.btn.fantasma:hover:not(:disabled) { background: var(--superficie-2); }
|
||||||
|
.btn.icone { padding: 0; width: 40px; }
|
||||||
|
.btn.pequeno { min-height: 32px; padding: 0 var(--e-3); font-size: var(--t-xs); }
|
||||||
|
.btn.pequeno.icone { width: 32px; }
|
||||||
|
|
||||||
|
/* ---------- formulários ---------- */
|
||||||
|
.campo { display: grid; gap: var(--e-1); }
|
||||||
|
.campo > span, .rotulo { font-size: var(--t-sm); color: var(--texto-2); }
|
||||||
|
input[type='text'], input[type='password'], input[type='search'], input[type='number'], select, textarea {
|
||||||
|
width: 100%; min-height: 40px; padding: var(--e-2) var(--e-3);
|
||||||
|
border: 1px solid var(--borda-forte); border-radius: var(--raio-sm);
|
||||||
|
background: var(--fundo-2); color: var(--texto); font: inherit; font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
textarea { resize: vertical; min-height: 80px; }
|
||||||
|
input::placeholder, textarea::placeholder { color: var(--texto-3); }
|
||||||
|
|
||||||
|
/* ---------- utilitários ---------- */
|
||||||
|
.grade { display: grid; gap: var(--e-4); }
|
||||||
|
.linha { display: flex; gap: var(--e-2); align-items: center; flex-wrap: wrap; }
|
||||||
|
.entre { justify-content: space-between; }
|
||||||
|
.mono { font-family: var(--fonte-mono); font-size: 0.92em; }
|
||||||
|
.fraco { color: var(--texto-3); }
|
||||||
|
.pequeno-texto { font-size: var(--t-sm); }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { transition-duration: 0ms !important; animation-duration: 0ms !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/** Formato do que o servidor manda em /estado e nos eventos SSE. */
|
||||||
|
|
||||||
|
export type AppConfig = Record<string, boolean | number | string>;
|
||||||
|
|
||||||
|
export interface Telemetria {
|
||||||
|
online: boolean;
|
||||||
|
idade: number | null;
|
||||||
|
celular_ip: string | null;
|
||||||
|
servidor_ip: string;
|
||||||
|
recarga: { id: string | null; confirmada: boolean };
|
||||||
|
estado: {
|
||||||
|
rosto?: string;
|
||||||
|
dormindo?: boolean;
|
||||||
|
microfone?: number;
|
||||||
|
microfone_ativo?: boolean;
|
||||||
|
movimento?: number;
|
||||||
|
camera_ativa?: boolean;
|
||||||
|
bateria?: { nivel?: number; carregando?: boolean } | number | null;
|
||||||
|
brilho?: number;
|
||||||
|
video?: { qualidade?: string } | null;
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
preview: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Motores {
|
||||||
|
disponivel: boolean;
|
||||||
|
erro: string;
|
||||||
|
ativos: number[];
|
||||||
|
dono: 'braco' | 'diagnostico' | null;
|
||||||
|
travado: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mensagem {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversa {
|
||||||
|
provedor: 'local' | 'gemini';
|
||||||
|
modelo: string;
|
||||||
|
gemini_configurado: boolean;
|
||||||
|
ocupado: boolean;
|
||||||
|
ultima: { id: number; texto: string; gesto: string; horario: number; segundos: number; fonte?: string } | null;
|
||||||
|
erro: string;
|
||||||
|
historico: Mensagem[];
|
||||||
|
lembrancas: { id: number; texto: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IA {
|
||||||
|
ativo: boolean;
|
||||||
|
ocupado: boolean;
|
||||||
|
modelo: string;
|
||||||
|
executor: string;
|
||||||
|
ultima: { evento: string; gesto: string; segundos: number; horario: number } | null;
|
||||||
|
erro: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Reacao {
|
||||||
|
id: number;
|
||||||
|
horario: number;
|
||||||
|
receita: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstadoServidor {
|
||||||
|
versao: string;
|
||||||
|
seq: number;
|
||||||
|
autenticacao: boolean;
|
||||||
|
estop: { ativo: boolean; desde: number | null };
|
||||||
|
aparencia: { rosto: string; cor: [number, number, number] };
|
||||||
|
gestos: string[];
|
||||||
|
rostos: string[];
|
||||||
|
app?: Telemetria;
|
||||||
|
app_config?: AppConfig;
|
||||||
|
motores?: Motores;
|
||||||
|
conversa?: Conversa;
|
||||||
|
ia?: IA;
|
||||||
|
reacao?: Reacao | null;
|
||||||
|
treinamento?: { ocupado: boolean; erro: string };
|
||||||
|
api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* Vídeo ao vivo do celular por WebRTC P2P. A sinalização passa pelo servidor (/rtc/*);
|
||||||
|
* o vídeo vai direto do celular para este navegador. Porta de painel-video.js.
|
||||||
|
* 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 { AppConfig, Telemetria } from './tipos';
|
||||||
|
|
||||||
|
export interface AlvoRastreio {
|
||||||
|
tipo: 'mao' | 'rosto';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
atividade?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VideoAoVivo {
|
||||||
|
mensagem = $state('Transmissão desligada.');
|
||||||
|
exibindo = $state(false);
|
||||||
|
qualidade = $state('');
|
||||||
|
|
||||||
|
private pc: RTCPeerConnection | null = null;
|
||||||
|
private id: string | null = null;
|
||||||
|
private desde = 0;
|
||||||
|
private ice: RTCIceCandidateInit[] = [];
|
||||||
|
private querido = false;
|
||||||
|
private abrindo = false;
|
||||||
|
private consultando = false;
|
||||||
|
private geracao = 0;
|
||||||
|
private tentarEm = 0;
|
||||||
|
private ultimoStats: any = null;
|
||||||
|
private outroPainel = false;
|
||||||
|
private canal: RTCDataChannel | null = null;
|
||||||
|
private seqRastreio = 0;
|
||||||
|
private qualidadeEfetiva = '';
|
||||||
|
private config: AppConfig = {};
|
||||||
|
private relogios: number[] = [];
|
||||||
|
|
||||||
|
constructor(private video: HTMLVideoElement) {
|
||||||
|
video.onloadeddata = video.onplaying = () => this.mostrar();
|
||||||
|
this.relogios.push(
|
||||||
|
window.setInterval(() => {
|
||||||
|
this.iniciar();
|
||||||
|
this.consultar();
|
||||||
|
}, 400),
|
||||||
|
window.setInterval(() => this.estatisticas(), 1000),
|
||||||
|
);
|
||||||
|
addEventListener('pagehide', this.aoSair);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mostrar() {
|
||||||
|
this.exibindo = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private texto(t: string) {
|
||||||
|
this.mensagem = t;
|
||||||
|
this.exibindo = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parar(fechar = true) {
|
||||||
|
this.geracao++;
|
||||||
|
const antiga = this.id;
|
||||||
|
this.id = null;
|
||||||
|
this.pc?.close();
|
||||||
|
this.pc = null;
|
||||||
|
this.canal = null;
|
||||||
|
this.ice = [];
|
||||||
|
this.desde = 0;
|
||||||
|
this.ultimoStats = null;
|
||||||
|
this.video.pause();
|
||||||
|
this.video.srcObject = null;
|
||||||
|
this.exibindo = false;
|
||||||
|
if (antiga && fechar) pedir('/rtc/session', { acao: 'fechar', id: antiga }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private enviar(mensagem: unknown, sessao?: string) {
|
||||||
|
return pedir('/rtc/send', { id: sessao || this.id, de: 'pc', mensagem }, 3500);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async iniciar() {
|
||||||
|
if (!this.querido || this.pc || this.abrindo || this.outroPainel || Date.now() < this.tentarEm) return;
|
||||||
|
this.abrindo = true;
|
||||||
|
const geracao = this.geracao;
|
||||||
|
this.texto('Conectando vídeo ao vivo… O rosto continua no celular.');
|
||||||
|
try {
|
||||||
|
const sessao = await pedir<{ id: string }>('/rtc/session', { acao: 'abrir' }, 3500);
|
||||||
|
if (geracao !== this.geracao || !this.querido) {
|
||||||
|
pedir('/rtc/session', { acao: 'fechar', id: sessao.id }).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.id = sessao.id;
|
||||||
|
const id = sessao.id;
|
||||||
|
const pc = new RTCPeerConnection({ iceServers: [] });
|
||||||
|
this.pc = pc;
|
||||||
|
this.canal = pc.createDataChannel('rastreamento', { ordered: false, maxRetransmits: 0 });
|
||||||
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||||
|
pc.onicecandidate = (e) => {
|
||||||
|
if (this.pc === pc && e.candidate) this.enviar({ tipo: 'candidate', candidate: e.candidate.toJSON() }, id).catch(() => {});
|
||||||
|
};
|
||||||
|
pc.ontrack = (e) => {
|
||||||
|
if (this.pc !== pc) return;
|
||||||
|
this.video.srcObject = e.streams[0] || new MediaStream([e.track]);
|
||||||
|
this.video.play().catch((err) => this.texto('Clique no vídeo para permitir a reprodução: ' + err.message));
|
||||||
|
};
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
if (this.pc === pc && pc.connectionState === 'failed') {
|
||||||
|
this.parar();
|
||||||
|
this.tentarEm = Date.now() + 2000;
|
||||||
|
this.texto('Reconectando o vídeo…');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await pc.setLocalDescription(await pc.createOffer());
|
||||||
|
if (this.pc !== pc) return;
|
||||||
|
await this.enviar({ tipo: 'offer', sdp: pc.localDescription!.sdp }, id);
|
||||||
|
} catch (e) {
|
||||||
|
this.parar();
|
||||||
|
this.tentarEm = Date.now() + 2500;
|
||||||
|
this.texto('Falha ao iniciar vídeo: ' + (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
this.abrindo = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consultar() {
|
||||||
|
if (!this.id || !this.pc || this.consultando) return;
|
||||||
|
this.consultando = true;
|
||||||
|
const pc = this.pc;
|
||||||
|
const sid = this.id;
|
||||||
|
try {
|
||||||
|
const pacote = await pedir<{ id: string | null; mensagens: { seq: number; mensagem: any }[] }>(
|
||||||
|
`/rtc/poll?lado=pc&desde=${this.desde}&id=${sid}`,
|
||||||
|
undefined,
|
||||||
|
3500,
|
||||||
|
);
|
||||||
|
if (this.pc !== pc) return;
|
||||||
|
if (pacote.id !== sid) {
|
||||||
|
this.outroPainel = !!pacote.id;
|
||||||
|
this.parar(false);
|
||||||
|
this.texto(this.outroPainel ? 'Vídeo aberto em outro painel. Recarregue para assumir.' : 'Aguardando reconexão do vídeo…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const { seq, mensagem: m } of pacote.mensagens) {
|
||||||
|
if (m.tipo === 'answer') {
|
||||||
|
await pc.setRemoteDescription({ type: 'answer', sdp: m.sdp });
|
||||||
|
for (const c of this.ice) await pc.addIceCandidate(c);
|
||||||
|
this.ice = [];
|
||||||
|
} else if (m.tipo === 'candidate') {
|
||||||
|
if (pc.remoteDescription) await pc.addIceCandidate(m.candidate);
|
||||||
|
else this.ice.push(m.candidate);
|
||||||
|
} else if (m.tipo === 'erro') {
|
||||||
|
this.parar();
|
||||||
|
this.tentarEm = Date.now() + 3500;
|
||||||
|
this.texto('Câmera no celular: ' + m.erro);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.desde = Math.max(this.desde, seq);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (this.pc === pc) {
|
||||||
|
this.parar();
|
||||||
|
this.tentarEm = Date.now() + 2500;
|
||||||
|
this.texto('Sinalização interrompida. Reconectando…');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.consultando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async estatisticas() {
|
||||||
|
const pc = this.pc;
|
||||||
|
if (!pc || pc.connectionState !== 'connected') return;
|
||||||
|
try {
|
||||||
|
const relatorio = await pc.getStats();
|
||||||
|
relatorio.forEach((s: any) => {
|
||||||
|
if (s.type !== 'inbound-rtp' || (s.kind !== 'video' && s.mediaType !== 'video')) return;
|
||||||
|
const u = this.ultimoStats;
|
||||||
|
const fps = u ? ((s.framesDecoded - u.framesDecoded) * 1000) / (s.timestamp - u.timestamp) : s.framesPerSecond || 0;
|
||||||
|
const kbps = u ? ((s.bytesReceived - u.bytesReceived) * 8) / (s.timestamp - u.timestamp) : 0;
|
||||||
|
this.ultimoStats = s;
|
||||||
|
const auto =
|
||||||
|
this.qualidadeEfetiva === 'auto' || (!this.qualidadeEfetiva && this.config.videoResolucao === 'auto' && !this.config.videoFPS);
|
||||||
|
this.qualidade = `${auto ? 'Automática' : 'Manual'} · ${s.frameWidth || this.video.videoWidth}×${s.frameHeight || this.video.videoHeight} · ${Math.round(fps)} fps · ${Math.round(kbps)} kb/s`;
|
||||||
|
if (fps > 0) this.mostrar();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* conexão em transição */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rastrear(alvo: AlvoRastreio | null) {
|
||||||
|
if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096)
|
||||||
|
this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo }));
|
||||||
|
}
|
||||||
|
|
||||||
|
atualizar(status: Telemetria | null, config: AppConfig | null, online: boolean) {
|
||||||
|
if (!config) return;
|
||||||
|
this.config = config;
|
||||||
|
this.qualidadeEfetiva = (status?.estado?.video as any)?.qualidade || '';
|
||||||
|
const dormindo = !!status?.estado?.dormindo;
|
||||||
|
const liberada = config.sensorCamera !== false && (!dormindo || config.cameraSono === true);
|
||||||
|
const quer = !!config.preview && online && liberada;
|
||||||
|
if (!quer) {
|
||||||
|
if (this.querido || this.pc) this.parar();
|
||||||
|
this.querido = false;
|
||||||
|
this.texto(
|
||||||
|
!liberada
|
||||||
|
? 'Câmera desativada pelos ajustes de sensores.'
|
||||||
|
: config.preview
|
||||||
|
? 'Celular sem conexão. Abra o app Carinha.'
|
||||||
|
: 'Transmissão desligada. O rosto e os sensores continuam funcionando.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.querido = true;
|
||||||
|
this.iniciar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private aoSair = () => {
|
||||||
|
if (this.id) {
|
||||||
|
const t = lerToken();
|
||||||
|
navigator.sendBeacon(
|
||||||
|
'/rtc/session' + (t ? '?t=' + encodeURIComponent(t) : ''),
|
||||||
|
new Blob([JSON.stringify({ acao: 'fechar', id: this.id })], { type: 'application/json' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.querido = false;
|
||||||
|
this.parar(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
destruir() {
|
||||||
|
this.relogios.forEach(clearInterval);
|
||||||
|
removeEventListener('pagehide', this.aoSair);
|
||||||
|
this.querido = false;
|
||||||
|
this.outroPainel = false;
|
||||||
|
this.parar();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import '@fontsource-variable/outfit';
|
||||||
|
import '@fontsource-variable/jetbrains-mono';
|
||||||
|
import './lib/tema.css';
|
||||||
|
import { mount } from 'svelte';
|
||||||
|
import App from './App.svelte';
|
||||||
|
|
||||||
|
mount(App, { target: document.getElementById('app')! });
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Download, KeyRound, RefreshCw, RotateCcw, Search, ShieldCheck, ShieldOff } from '@lucide/svelte';
|
||||||
|
import { gravarToken, lerToken, postar } from '../lib/api';
|
||||||
|
import { GRUPOS, restaurarPadrao, salvarApp, type Campo } from '../lib/ajustes.svelte';
|
||||||
|
import { avisos, tentar } from '../lib/avisos.svelte';
|
||||||
|
import { recarregarCelular } from '../lib/comandos.svelte';
|
||||||
|
import Card from '../lib/componentes/Card.svelte';
|
||||||
|
import Selo from '../lib/componentes/Selo.svelte';
|
||||||
|
import Slider from '../lib/componentes/Slider.svelte';
|
||||||
|
import Toggle from '../lib/componentes/Toggle.svelte';
|
||||||
|
import { conectar, estado } from '../lib/estado.svelte';
|
||||||
|
|
||||||
|
// ---------- busca nos ajustes do celular
|
||||||
|
let busca = $state('');
|
||||||
|
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||||
|
const grupos = $derived(
|
||||||
|
GRUPOS.map((g) => ({
|
||||||
|
...g,
|
||||||
|
campos: g.campos.filter((c) => !busca || normal(c.rotulo + ' ' + (c.ajuda ?? '')).includes(normal(busca))),
|
||||||
|
})).filter((g) => g.campos.length),
|
||||||
|
);
|
||||||
|
const valor = (c: Campo) => estado.appConfig?.[c.chave];
|
||||||
|
|
||||||
|
let confirmarPadrao = $state(false);
|
||||||
|
|
||||||
|
// ---------- IA
|
||||||
|
let modelo = $state('');
|
||||||
|
let chave = $state('');
|
||||||
|
let testando = $state(false);
|
||||||
|
let resultadoTeste = $state<{ ok: boolean; mensagem: string; segundos?: number } | null>(null);
|
||||||
|
$effect(() => {
|
||||||
|
if (estado.api && !modelo) modelo = estado.api.modelo;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function trocarProvedor(provedor: string) {
|
||||||
|
await tentar(async () => {
|
||||||
|
estado.conversa = await postar('/conversa/config', { provedor });
|
||||||
|
}, provedor === 'gemini' ? 'Conversa usando Gemini.' : 'Conversa usando Qwen local.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function salvarGemini(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dados: Record<string, string> = { modelo: modelo.trim() };
|
||||||
|
if (chave.trim()) dados.chave = chave.trim();
|
||||||
|
const r = await tentar(() => postar('/api/configuracao/salvar', dados), 'Configuração do Gemini salva.');
|
||||||
|
if (r) {
|
||||||
|
estado.api = r;
|
||||||
|
chave = '';
|
||||||
|
resultadoTeste = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testarGemini() {
|
||||||
|
testando = true;
|
||||||
|
resultadoTeste = (await tentar(() => postar('/api/configuracao/testar'))) ?? null;
|
||||||
|
testando = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- segurança
|
||||||
|
let tokenLocal = $state(!!lerToken());
|
||||||
|
function esquecerToken() {
|
||||||
|
gravarToken('');
|
||||||
|
tokenLocal = false;
|
||||||
|
avisos.ok('Token removido deste navegador.');
|
||||||
|
if (estado.autenticacao) conectar();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ajustes">
|
||||||
|
<Card titulo="Celular" descricao="Mudanças valem na hora no aparelho. Nada trava enquanto salva.">
|
||||||
|
{#snippet acoes()}
|
||||||
|
<label class="busca">
|
||||||
|
<Search size={16} />
|
||||||
|
<input type="search" bind:value={busca} placeholder="Buscar ajuste" aria-label="Buscar ajuste" />
|
||||||
|
</label>
|
||||||
|
{#if confirmarPadrao}
|
||||||
|
<span class="pequeno-texto">Restaurar todos?</span>
|
||||||
|
<button
|
||||||
|
class="btn pequeno perigo"
|
||||||
|
onclick={async () => {
|
||||||
|
confirmarPadrao = false;
|
||||||
|
await tentar(restaurarPadrao);
|
||||||
|
}}>Sim, restaurar</button
|
||||||
|
>
|
||||||
|
<button class="btn pequeno" onclick={() => (confirmarPadrao = false)}>Cancelar</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn pequeno" onclick={() => (confirmarPadrao = true)} disabled={!estado.appConfig}>
|
||||||
|
<RotateCcw size={14} /> Restaurar padrão
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if !estado.appConfig}
|
||||||
|
<p class="fraco">Carregando ajustes…</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grupos">
|
||||||
|
{#each grupos as g (g.titulo)}
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
<strong>{g.titulo}</strong>
|
||||||
|
<span class="fraco pequeno-texto">{g.descricao}</span>
|
||||||
|
</legend>
|
||||||
|
{#each g.campos as c (c.chave)}
|
||||||
|
{#if c.tipo === 'toggle'}
|
||||||
|
<Toggle rotulo={c.rotulo} ajuda={c.ajuda} checked={!!valor(c)} onchange={(v) => salvarApp({ [c.chave]: v })} />
|
||||||
|
{:else if c.tipo === 'slider'}
|
||||||
|
<Slider
|
||||||
|
rotulo={c.rotulo}
|
||||||
|
valor={Number(valor(c) ?? 0)}
|
||||||
|
min={c.min!}
|
||||||
|
max={c.max!}
|
||||||
|
passo={c.passo}
|
||||||
|
unidade={c.unidade}
|
||||||
|
onchange={(v) => salvarApp({ [c.chave]: v })}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<label class="campo escolha">
|
||||||
|
<span>{c.rotulo}</span>
|
||||||
|
<select
|
||||||
|
value={String(valor(c))}
|
||||||
|
onchange={(e) => {
|
||||||
|
const v = e.currentTarget.value;
|
||||||
|
salvarApp({ [c.chave]: typeof c.opcoes![0][0] === 'number' ? Number(v) : v });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#each c.opcoes! as [v, t] (v)}<option value={String(v)}>{t}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
{:else}
|
||||||
|
<p class="fraco">Nenhum ajuste com “{busca}”.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="coluna">
|
||||||
|
<Card titulo="Inteligência artificial">
|
||||||
|
<div class="campo">
|
||||||
|
<span>Motor da conversa</span>
|
||||||
|
<div class="segmentado" role="radiogroup" aria-label="Motor da conversa">
|
||||||
|
<button role="radio" aria-checked={estado.conversa?.provedor === 'local'} onclick={() => trocarProvedor('local')}>
|
||||||
|
Qwen local
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="radio"
|
||||||
|
aria-checked={estado.conversa?.provedor === 'gemini'}
|
||||||
|
disabled={!estado.api?.configurada}
|
||||||
|
title={estado.api?.configurada ? '' : 'Configure a chave do Gemini abaixo'}
|
||||||
|
onclick={() => trocarProvedor('gemini')}
|
||||||
|
>
|
||||||
|
Gemini
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="fraco">Local roda no celular/placa, sem custo. Gemini responde melhor e cria reações.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="gemini" onsubmit={salvarGemini}>
|
||||||
|
<div class="linha entre">
|
||||||
|
<strong class="pequeno-texto">Gemini</strong>
|
||||||
|
<Selo tom={estado.api?.configurada ? 'ok' : 'aviso'}>{estado.api?.configurada ? 'Chave configurada' : 'Sem chave'}</Selo>
|
||||||
|
</div>
|
||||||
|
<label class="campo">
|
||||||
|
<span>Modelo</span>
|
||||||
|
<input type="text" bind:value={modelo} list="modelos-gemini" required pattern="[a-zA-Z0-9._\-]{'{'}3,100{'}'}" />
|
||||||
|
<datalist id="modelos-gemini">
|
||||||
|
<option value="gemini-flash-lite-latest"></option>
|
||||||
|
<option value="gemini-flash-latest"></option>
|
||||||
|
<option value="gemini-2.5-flash-lite"></option>
|
||||||
|
<option value="gemini-2.5-flash"></option>
|
||||||
|
<option value="gemini-2.5-pro"></option>
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
<label class="campo">
|
||||||
|
<span>Chave de API {estado.api?.configurada ? '(deixe vazio para manter)' : ''}</span>
|
||||||
|
<input type="password" bind:value={chave} autocomplete="off" placeholder="AIza…" />
|
||||||
|
</label>
|
||||||
|
<div class="linha">
|
||||||
|
<button class="btn primario" type="submit"><KeyRound size={16} /> Salvar</button>
|
||||||
|
<button class="btn" type="button" onclick={testarGemini} disabled={testando || !estado.api?.configurada}>
|
||||||
|
{testando ? 'Testando…' : 'Testar conexão'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if resultadoTeste}
|
||||||
|
<p class="pequeno-texto" class:ok={resultadoTeste.ok} class:erro={!resultadoTeste.ok}>
|
||||||
|
{resultadoTeste.mensagem}{resultadoTeste.segundos ? ` (${resultadoTeste.segundos}s)` : ''}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card titulo="Segurança">
|
||||||
|
<div class="linha">
|
||||||
|
{#if estado.autenticacao}
|
||||||
|
<Selo tom="ok"><ShieldCheck size={12} /> Servidor exige token</Selo>
|
||||||
|
{:else}
|
||||||
|
<Selo tom="aviso"><ShieldOff size={12} /> Servidor aberto na rede</Selo>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="fraco pequeno-texto">
|
||||||
|
{#if estado.autenticacao}
|
||||||
|
Só quem tem o token controla o robô. Ele fica guardado apenas neste navegador.
|
||||||
|
{:else}
|
||||||
|
Qualquer aparelho da rede local pode controlar o robô. Defina <span class="mono">CARINHA_TOKEN</span> na placa
|
||||||
|
(veja docs/SEGURANCA.md) depois de atualizar o app do celular.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if tokenLocal}
|
||||||
|
<button class="btn pequeno" onclick={esquecerToken}>Esquecer token neste navegador</button>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card titulo="Sistema">
|
||||||
|
<dl class="info">
|
||||||
|
<div><dt>Versão do servidor</dt><dd class="mono">{estado.versao || '—'}</dd></div>
|
||||||
|
<div><dt>Celular</dt><dd class="mono">{estado.app?.celular_ip ?? 'offline'}</dd></div>
|
||||||
|
<div><dt>Servidor</dt><dd class="mono">{location.host}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div class="linha">
|
||||||
|
<button class="btn pequeno" onclick={recarregarCelular} disabled={!estado.celularOnline}>
|
||||||
|
<RefreshCw size={14} /> Recarregar tela do celular
|
||||||
|
</button>
|
||||||
|
<a class="btn pequeno" href="/app.apk" download><Download size={14} /> Baixar app Android</a>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ajustes {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.coluna {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
}
|
||||||
|
.busca {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding-left: var(--e-3);
|
||||||
|
border: 1px solid var(--borda-forte);
|
||||||
|
border-radius: var(--raio-sm);
|
||||||
|
background: var(--fundo-2);
|
||||||
|
color: var(--texto-3);
|
||||||
|
}
|
||||||
|
.busca input {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
min-height: 32px;
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
.grupos {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-5);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
|
||||||
|
}
|
||||||
|
fieldset {
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-2);
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
legend {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0 0 var(--e-2);
|
||||||
|
margin-bottom: var(--e-2);
|
||||||
|
border-bottom: 1px solid var(--borda);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.escolha {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.segmentado {
|
||||||
|
display: flex;
|
||||||
|
background: var(--fundo-2);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: var(--raio-sm);
|
||||||
|
padding: 3px;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.segmentado button {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--texto-2);
|
||||||
|
font: 500 var(--t-sm) var(--fonte);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.segmentado button[aria-checked='true'] {
|
||||||
|
background: var(--superficie-3);
|
||||||
|
color: var(--texto);
|
||||||
|
}
|
||||||
|
.segmentado button:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.gemini {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-3);
|
||||||
|
padding-top: var(--e-3);
|
||||||
|
border-top: 1px solid var(--borda);
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.erro {
|
||||||
|
color: var(--perigo);
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-2);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.info div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--e-3);
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
color: var(--texto-3);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.ajustes {
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Battery, Brain, Camera, Download, Ear, Moon, RefreshCw, ScanFace } from '@lucide/svelte';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { postar } from '../lib/api';
|
||||||
|
import { salvarApp } from '../lib/ajustes.svelte';
|
||||||
|
import { tentar } from '../lib/avisos.svelte';
|
||||||
|
import { cor, CORES, hex, recarregarCelular, rgb, rosto } from '../lib/comandos.svelte';
|
||||||
|
import Card from '../lib/componentes/Card.svelte';
|
||||||
|
import Gestos from '../lib/componentes/Gestos.svelte';
|
||||||
|
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||||
|
import Selo from '../lib/componentes/Selo.svelte';
|
||||||
|
import Toggle from '../lib/componentes/Toggle.svelte';
|
||||||
|
import { estado } from '../lib/estado.svelte';
|
||||||
|
import { Rastreamento } from '../lib/rastreamento.svelte';
|
||||||
|
import { VideoAoVivo } from '../lib/video.svelte';
|
||||||
|
|
||||||
|
let elVideo: HTMLVideoElement;
|
||||||
|
let elTela: HTMLCanvasElement;
|
||||||
|
let video = $state<VideoAoVivo>();
|
||||||
|
let rastreio = $state<Rastreamento>();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
video = new VideoAoVivo(elVideo);
|
||||||
|
rastreio = new Rastreamento(elVideo, elTela, video);
|
||||||
|
});
|
||||||
|
onDestroy(() => {
|
||||||
|
rastreio?.destruir();
|
||||||
|
video?.destruir();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vídeo e rastreamento seguem o estado do servidor (SSE), sem polling próprio.
|
||||||
|
$effect(() => {
|
||||||
|
const cfg = estado.appConfig ? { ...estado.appConfig } : null;
|
||||||
|
const online = estado.celularOnline;
|
||||||
|
video?.atualizar(estado.app, cfg, online);
|
||||||
|
const dormindo = !!estado.app?.estado?.dormindo;
|
||||||
|
const permitido = !!cfg?.preview && online && cfg.sensorCamera !== false && (!dormindo || !!cfg.cameraSono);
|
||||||
|
rastreio?.atualizar(cfg, permitido);
|
||||||
|
});
|
||||||
|
|
||||||
|
const sensores = $derived(estado.app?.estado ?? {});
|
||||||
|
const bateria = $derived.by(() => {
|
||||||
|
const b = sensores.bateria;
|
||||||
|
const n = typeof b === 'number' ? b : b?.nivel;
|
||||||
|
return typeof n === 'number' ? Math.round(n <= 1 ? n * 100 : n) : null;
|
||||||
|
});
|
||||||
|
const pct = (v: unknown) => (typeof v === 'number' ? Math.round(Math.min(1, v) * 100) : 0);
|
||||||
|
const hora = (t: number) => new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
|
||||||
|
async function foto() {
|
||||||
|
if (!elVideo.videoWidth) return;
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = elVideo.videoWidth;
|
||||||
|
c.height = elVideo.videoHeight;
|
||||||
|
c.getContext('2d')!.drawImage(elVideo, 0, 0);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = c.toDataURL('image/png');
|
||||||
|
a.download = `carinha-${new Date().toISOString().replace(/[:.]/g, '-')}.png`;
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const iaLocal = (ativo: boolean) => tentar(() => postar('/ia/config', { ativo }), ativo ? 'Personalidade local ligada.' : 'Personalidade local desligada.');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="ao-vivo">
|
||||||
|
<section class="palco">
|
||||||
|
<div class="quadro camera">
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video bind:this={elVideo} muted playsinline autoplay hidden={!video?.exibindo}></video>
|
||||||
|
<canvas bind:this={elTela} class="sobreposicao" aria-hidden="true"></canvas>
|
||||||
|
{#if !video?.exibindo}
|
||||||
|
<div class="vazio">
|
||||||
|
<Camera size={32} />
|
||||||
|
<p>{video?.mensagem ?? 'Carregando…'}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="rodape">
|
||||||
|
<Selo tom={video?.exibindo ? 'ok' : 'neutro'}>Câmera</Selo>
|
||||||
|
{#if video?.qualidade && video.exibindo}<span class="mono fraco">{video.qualidade}</span>{/if}
|
||||||
|
<span class="espaco"></span>
|
||||||
|
{#if video?.exibindo}
|
||||||
|
<button class="btn pequeno" onclick={foto}><Download size={14} /> Foto</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="quadro rosto">
|
||||||
|
<RostoPrevia titulo="Rosto ao vivo" />
|
||||||
|
<div class="rodape">
|
||||||
|
<Selo tom={estado.celularOnline ? 'ok' : 'neutro'}>Rosto {estado.celularOnline ? 'no celular' : '· prévia'}</Selo>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="controles">
|
||||||
|
<div class="transmissao">
|
||||||
|
<Toggle
|
||||||
|
rotulo="Transmitir câmera do celular"
|
||||||
|
ajuda="Vídeo direto do celular para este PC (WebRTC)"
|
||||||
|
checked={!!estado.appConfig?.preview}
|
||||||
|
disabled={!estado.appConfig}
|
||||||
|
onchange={(v) => salvarApp({ preview: v })}
|
||||||
|
/>
|
||||||
|
{#if rastreio && estado.appConfig?.preview}
|
||||||
|
<p class="fraco pequeno-texto linha"><ScanFace size={14} /> {rastreio.texto}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grade-cards">
|
||||||
|
<Card titulo="Gestos" descricao="Favoritos com atalho de 1 a 9. Fixe outros na tela de Reações ou pelo Ctrl+K.">
|
||||||
|
<Gestos />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card titulo="Aparência">
|
||||||
|
<div class="campo">
|
||||||
|
<span>Rosto</span>
|
||||||
|
<div class="segmentado" role="radiogroup" aria-label="Rosto">
|
||||||
|
{#each estado.rostos as r (r)}
|
||||||
|
<button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="campo">
|
||||||
|
<span>Cor dos olhos</span>
|
||||||
|
<div class="cores">
|
||||||
|
{#each CORES as c (c.nome)}
|
||||||
|
<button
|
||||||
|
class="cor"
|
||||||
|
style="--c: {hex(c.rgb)}"
|
||||||
|
aria-label={c.nome}
|
||||||
|
aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)}
|
||||||
|
onclick={() => cor(c.rgb)}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
<label class="cor livre" style="--c: {hex(estado.aparencia.cor)}" title="Outra cor">
|
||||||
|
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
|
||||||
|
<span class="sr-only">Escolher outra cor</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card titulo="Celular">
|
||||||
|
{#snippet acoes()}
|
||||||
|
<button class="btn pequeno" onclick={recarregarCelular} disabled={!estado.celularOnline}>
|
||||||
|
<RefreshCw size={14} /> Recarregar tela
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
{#if estado.celularOnline}
|
||||||
|
<dl class="sensores">
|
||||||
|
<div>
|
||||||
|
<dt><Battery size={16} /> Bateria</dt>
|
||||||
|
<dd>{bateria !== null ? bateria + '%' : '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><Moon size={16} /> Estado</dt>
|
||||||
|
<dd>{sensores.dormindo ? 'Dormindo' : 'Acordado'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><Ear size={16} /> Som</dt>
|
||||||
|
<dd>
|
||||||
|
{#if sensores.microfone_ativo}
|
||||||
|
<meter min="0" max="100" value={pct(sensores.microfone)} aria-label="Nível de som"></meter>
|
||||||
|
{:else}<span class="fraco">desligado</span>{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><Camera size={16} /> Movimento</dt>
|
||||||
|
<dd>
|
||||||
|
{#if sensores.camera_ativa}
|
||||||
|
<meter min="0" max="100" value={pct((sensores.movimento as number) * 5)} aria-label="Movimento"></meter>
|
||||||
|
{:else}<span class="fraco">desligado</span>{/if}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="fraco pequeno-texto mono">{estado.app?.celular_ip ?? ''}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="fraco pequeno-texto">
|
||||||
|
O celular não está enviando sinais. Abra o app Carinha no aparelho e confira se ele aponta para este servidor.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card titulo="Personalidade local" descricao="Qwen escolhe expressões a partir de sons e movimentos.">
|
||||||
|
<Toggle
|
||||||
|
rotulo="Reagir sozinho com IA local"
|
||||||
|
ajuda={estado.ia ? `${estado.ia.modelo} · ${estado.ia.executor}` : ''}
|
||||||
|
checked={!!estado.ia?.ativo}
|
||||||
|
disabled={!estado.ia}
|
||||||
|
onchange={iaLocal}
|
||||||
|
/>
|
||||||
|
{#if estado.ia?.ultima}
|
||||||
|
<p class="pequeno-texto linha">
|
||||||
|
<Brain size={14} /> Último: {estado.ia.ultima.evento} → <strong>{estado.ia.ultima.gesto}</strong>
|
||||||
|
<span class="fraco">({estado.ia.ultima.segundos}s)</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if estado.ia?.erro}<p class="erro pequeno-texto">{estado.ia.erro}</p>{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card titulo="Acontecimentos" class="feed">
|
||||||
|
{#if estado.feed.length}
|
||||||
|
<ol>
|
||||||
|
{#each estado.feed as item (item.id)}
|
||||||
|
<li class={item.tipo}><time class="mono fraco">{hora(item.quando)}</time> {item.texto}</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{:else}
|
||||||
|
<p class="fraco pequeno-texto">Gestos, respostas e reações aparecem aqui assim que acontecerem.</p>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ao-vivo {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
}
|
||||||
|
.palco {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-3);
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.quadro {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: var(--raio-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
}
|
||||||
|
.camera video,
|
||||||
|
.sobreposicao {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.sobreposicao {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.vazio {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: var(--e-5);
|
||||||
|
text-align: center;
|
||||||
|
color: #8396ab;
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
.rodape {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--e-2);
|
||||||
|
padding: var(--e-2) var(--e-3);
|
||||||
|
background: linear-gradient(transparent, rgb(0 0 0 / 0.7));
|
||||||
|
color: #e8eef6;
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.rodape button {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.espaco {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.controles {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--e-4);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.transmissao {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-1);
|
||||||
|
min-width: min(100%, 420px);
|
||||||
|
}
|
||||||
|
.grade-cards {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-4);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
|
||||||
|
}
|
||||||
|
.segmentado {
|
||||||
|
display: flex;
|
||||||
|
background: var(--fundo-2);
|
||||||
|
border: 1px solid var(--borda);
|
||||||
|
border-radius: var(--raio-sm);
|
||||||
|
padding: 3px;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.segmentado button {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 34px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--texto-2);
|
||||||
|
font: 500 var(--t-sm) var(--fonte);
|
||||||
|
text-transform: capitalize;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.segmentado button[aria-checked='true'] {
|
||||||
|
background: var(--superficie-3);
|
||||||
|
color: var(--texto);
|
||||||
|
}
|
||||||
|
.cores {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--e-2);
|
||||||
|
}
|
||||||
|
.cor {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--borda-forte);
|
||||||
|
background: var(--c);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cor[aria-pressed='true'] {
|
||||||
|
outline: 2px solid var(--texto);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.livre {
|
||||||
|
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||||
|
}
|
||||||
|
.livre input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sensores {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--e-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sensores div {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
dt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: var(--t-xs);
|
||||||
|
color: var(--texto-3);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
meter {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
.erro {
|
||||||
|
color: var(--perigo);
|
||||||
|
}
|
||||||
|
:global(.feed) ol {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--e-2);
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: var(--t-sm);
|
||||||
|
}
|
||||||
|
:global(.feed) li.estop {
|
||||||
|
color: var(--aviso);
|
||||||
|
}
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.palco {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Card from '../lib/componentes/Card.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card titulo="Conversa" descricao="Chat, voz e memória migram na fase 3.">
|
||||||
|
<p class="pequeno-texto">Enquanto isso, use a tela antiga: <a href="/conversa">abrir Conversa (versão anterior)</a>.</p>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Card from '../lib/componentes/Card.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card titulo="Movimento" descricao="Braço, calibração e diagnóstico migram na fase 4.">
|
||||||
|
<p class="pequeno-texto">Enquanto isso, use a tela antiga: <a href="/braco">abrir Movimento (versão anterior)</a>.</p>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Card from '../lib/componentes/Card.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card titulo="Reações" descricao="Biblioteca e criação com IA migram na fase 3.">
|
||||||
|
<p class="pequeno-texto">Enquanto isso, use a tela antiga: <a href="/emojis">abrir Reações (versão anterior)</a>.</p>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
export default { preprocess: vitePreprocess() };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client", "node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
// Em desenvolvimento, a API vem do servidor Python (local ou a placa):
|
||||||
|
// CARINHA_SERVIDOR=http://192.168.1.35:8100 npm run dev
|
||||||
|
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'];
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
base: '/',
|
||||||
|
build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' },
|
||||||
|
server: { proxy: Object.fromEntries(api.map((p) => [p, { target: alvo, changeOrigin: false }])) },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user