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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user