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
+29 -12
View File
@@ -4,6 +4,7 @@ import os
from pathlib import Path
import threading
import time
from comum import Conflito, gravar_atomico, ler_json
def integer(value, lo, hi):
if type(value) is not int or not lo <= value <= hi:
@@ -46,6 +47,9 @@ class ControleServos:
self.hw = hardware
self.error = ''
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.config = [dict(nome=f'Servo {i+1}', canal=i, minimo=1400, maximo=1600, centro=1500) for i in range(6)]
try:
@@ -78,14 +82,24 @@ class ControleServos:
def status(self):
with self.lock:
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):
rows = self.validate(rows)
with self.lock:
if self.active: raise ValueError('Desligue os sinais antes de salvar')
tmp = self.path.with_suffix('.tmp')
tmp.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding='utf-8')
tmp.replace(self.path)
gravar_atomico(self.path, rows)
self.config = rows
return self.status()
def move(self, index, pulse):
@@ -94,20 +108,28 @@ class ControleServos:
row = self.config[index]
pulse = integer(pulse, row['minimo'], row['maximo'])
if self.hw is None: raise OSError('PCA9685 indisponível neste servidor')
self.reservar('diagnostico')
self.hw.pulse(row['canal'], pulse)
self.active[row['canal']] = time.monotonic() + 2
return self.status()
def expire(self, now):
with self.lock:
liberou = False
for channel, deadline in list(self.active.items()):
if now >= deadline:
self.hw.off(channel)
del self.active[channel]
liberou = True
if liberou and not self.active:
self.dono = None
self.publicar('motores', self.motores())
def stop(self):
with self.lock:
if self.hw:
for channel in range(16): self.hw.off(channel)
self.active.clear()
self.dono = None
self.publicar('motores', self.motores())
return self.status()
def watch(self):
while not self.closed.wait(.1):
@@ -122,14 +144,7 @@ class ControleServos:
try:
if method == 'GET' and route == '/servos/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 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')
data = ler_json(h, 8192)
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/parar': result = self.stop()
@@ -138,6 +153,8 @@ class ControleServos:
h.enviar(json.dumps({'erro':'Método não permitido'}), codigo=405)
return True
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:
h.enviar(json.dumps({'erro':str(e)}), codigo=400)
except OSError as e: