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
+30 -11
View File
@@ -7,8 +7,16 @@ from pathlib import Path
import subprocess
import threading
import time
from comum import origem_valida
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,
ouvir=True, reagir=True, somlim=0.35, somganho=0.4,
somaltolim=0.55, sompausa=20, somcooldown=5,
@@ -61,6 +69,7 @@ class ControlePC:
self.frame_at = 0
self.frame_seq = 0
self.rtc = Sinalizacao()
self.publicar = lambda tipo, dados: None
try:
self.config.update(validar(json.loads(self.arquivo.read_text(encoding='utf-8'))))
except (OSError, ValueError):
@@ -80,6 +89,17 @@ class ControlePC:
self.frame_at = 0
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):
if self.rtc.atender(h, metodo, self.config['preview']):
return True
@@ -92,6 +112,10 @@ class ControlePC:
try:
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'))
limite = 1_000_000 if rota == '/camera/frame' else 16384
if tamanho <= 0 or tamanho > limite:
@@ -105,10 +129,12 @@ class ControlePC:
dados = None
if rota == '/app/health':
enviar({'projeto': 'carinha-pc', 'versao': '0.7.0'})
enviar({'projeto': 'carinha-pc', 'versao': versao()})
elif rota == '/app/config':
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:
with self.lock:
config = self.config.copy()
@@ -123,17 +149,10 @@ class ControlePC:
if self.recarga["id"] and str(dados.get("tela_atualizada", "")) == self.recarga["id"]:
self.recarga["confirmada"] = True
self.status_at = time.monotonic()
self.publicar('telemetria', self.resumo(h.headers.get("Host", "").split(":")[0]))
enviar({'ok': True})
else:
with self.lock:
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)
enviar(self.resumo(h.headers.get("Host", "").split(":")[0]))
elif rota == '/camera/frame':
with self.lock:
if metodo == 'POST':