feat(servidor): adiciona SSE, token, E-Stop real e trava de hardware

- comum.py: ler_json (Origin/JSON/tamanho), gravar_atomico e token
  (CARINHA_TOKEN ou seguranca.json), substituindo 6 copias nos modulos
- eventos.py: hub SSE em /eventos com heartbeat e Last-Event-ID;
  modulos publicam expressao, telemetria, conversa, reacao, ia, motores
- /estado agrega o snapshot inicial; /comando unifica gesto/rosto/cor/reacao
- /parar-tudo desliga o PCA9685, para reacoes e trava motores ate /rearmar
- braco: lease (ttl_ms) e interpolacao com limite de velocidade no servidor
- braco e diagnostico nao disputam o PCA9685 (409 para o segundo dono)
- fila limitada (20) com validade de 10 s; POST /face usa prefixo "rosto"
- /app/config passa a checar Origin e JSON; versao vem de version.json
- figurinhas automaticas do Gemini limitadas a 6 por hora
- rosto entende a ordem "calma" enviada pelo E-Stop

Token so e exigido quando configurado (docs/SEGURANCA.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-05 19:34:35 -03:00
co-authored by Claude Opus 5.5
parent 6c5bd6f432
commit 35d443a358
15 changed files with 875 additions and 191 deletions
+7 -9
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import threading
import time
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')
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.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.publicar=lambda tipo,dados:None
try:self.ativo=json.loads(self.path.read_text())['ativo'] is True
except (OSError,ValueError,KeyError,TypeError):pass
threading.Thread(target=self.loop,daemon=True).start()
def config(self,ativo):
if type(ativo) is not bool:raise ValueError('ativo deve ser booleano')
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
return self.status()
status=self.status();self.publicar('ia',status)
return status
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)
def event(self,event):
@@ -77,6 +80,7 @@ class Cerebro:
finally:
self.last=time.monotonic()
with self.lock:self.busy=False
self.publicar('ia',self.status())
def rotas(self,h,method):
route=h.path.split('?',1)[0]
if route=='/ia/modelo' and method=='GET':
@@ -95,13 +99,7 @@ class Cerebro:
try:
if method=='GET' and route=='/ia/status':result=self.status()
elif method=='POST':
origin=h.headers.get('Origin')
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')
data=ler_json(h,2048)
if route=='/ia/config':result=self.config(data.get('ativo'))
elif route=='/ia/evento':result=self.event(data.get('evento'))
else:raise ValueError('Rota inválida')