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
+20 -9
View File
@@ -6,14 +6,18 @@ import threading
import time
import unicodedata
import re
from collections import deque
from datetime import datetime, timezone, timedelta
from contextlib import contextmanager
from urllib.request import Request, urlopen
from cerebro import GESTOS
from pathlib import Path
from gemini import responder_gemini
from comum import gravar_atomico, ler_json
import gemini
FIGURINHAS_POR_HORA = 6 # cada figurinha automática é mais uma chamada paga ao Gemini
def resposta_factual(texto,agora=None):
normal=''.join(c for c in unicodedata.normalize('NFD',texto.lower()) if not unicodedata.combining(c))
normal=' '.join(normal.strip(' ?!.').split())
@@ -93,6 +97,14 @@ class Conversa:
if saved.get('provedor') in ('local','gemini'):self.provedor=saved['provedor']
except (OSError,ValueError,AttributeError):pass
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):
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):
@@ -108,7 +120,9 @@ class Conversa:
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.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()
return dict(ok=True)
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))
if self.provedor=='gemini' and treino:
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'])
except Exception as e:
with self.lock:self.erro=str(e)[:200] if generation==self.generation else ''
finally:
with self.lock,self.cerebro.lock:
self.busy=False;self.cerebro.conversando=False;self.cerebro.last=time.monotonic()
self.avisar()
def rotas(self,h,method):
route=h.path.split('?',1)[0]
if not route.startswith('/conversa/'):return False
try:
if method=='GET' and route=='/conversa/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')
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')
data=ler_json(h,4096)
if route=='/conversa/config':
with self.lock:
if self.busy:raise ValueError('Aguarde a resposta antes de trocar a IA')
provider=data.get('provedor')
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')
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
result=self.status()
elif route=='/conversa/enviar':result=self.enviar(data.get('texto'))
@@ -183,6 +193,7 @@ class Conversa:
else:self.memoria.apagar(data.get('id'))
result=dict(ok=True)
else:raise ValueError('Rota inválida')
if route!='/conversa/enviar':self.avisar()
else:raise ValueError('Método inválido')
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)