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:
@@ -3,11 +3,25 @@ import copy
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from comum import Conflito, gravar_atomico, ler_json
|
||||
from servos import integer
|
||||
|
||||
NAMES=['Garra','Punho 1','Punho 2','Braço superior','Braço inferior','Antebraço','Base']
|
||||
CHANNELS=[0,1,2,3,4,5,7]
|
||||
LEASE_PADRAO=2000 # ms; o painel novo manda prazos curtos e renova enquanto o botão está pressionado
|
||||
PASSO=.02 # 50 Hz de interpolação no servidor
|
||||
|
||||
def velocidade_valida(v):
|
||||
if v is None: return None
|
||||
if isinstance(v,bool) or not isinstance(v,(int,float)) or not math.isfinite(v) or not 5<=v<=360:
|
||||
raise ValueError('Velocidade deve ficar entre 5 e 360 graus/s')
|
||||
return float(v)
|
||||
|
||||
def prazo_valido(ms):
|
||||
if ms is None: return LEASE_PADRAO
|
||||
return integer(ms,100,2000)
|
||||
|
||||
class Braco:
|
||||
def __init__(self,path,servos):
|
||||
@@ -15,6 +29,8 @@ class Braco:
|
||||
self.config=dict(calibracao=[dict(canal=i,minimo=1400,maximo=1600) for i in range(8)],posturas=[])
|
||||
try: self.config=self.validate(json.loads(self.path.read_text(encoding='utf-8')))
|
||||
except (OSError,ValueError,TypeError): pass
|
||||
self.atual=None; self.alvo=None; self.velocidade=None; self.lease=0
|
||||
threading.Thread(target=self.loop,daemon=True).start()
|
||||
def validate_pose(self,pose,calibration):
|
||||
if not isinstance(pose,list) or len(pose)!=7: raise ValueError('Esperava sete ângulos')
|
||||
pulses={}
|
||||
@@ -53,27 +69,64 @@ class Braco:
|
||||
with self.servos.lock:
|
||||
result=copy.deepcopy(self.config)
|
||||
result.update(disponivel=self.servos.hw is not None,erro=self.servos.error,
|
||||
dono=self.servos.dono,travado=self.servos.travado,atual=self.atual,
|
||||
movendo=self.alvo is not None,
|
||||
articulacoes=[dict(nome=name,canal=ch) for name,ch in zip(NAMES,CHANNELS)])
|
||||
return result
|
||||
def save(self,data):
|
||||
clean=self.validate(data)
|
||||
with self.servos.lock:
|
||||
if self.servos.active: raise ValueError('Pare os motores antes de salvar')
|
||||
tmp=self.path.with_suffix('.tmp'); tmp.write_text(json.dumps(clean,ensure_ascii=False,indent=2),encoding='utf-8'); tmp.replace(self.path)
|
||||
gravar_atomico(self.path,clean)
|
||||
self.config=clean
|
||||
return self.status()
|
||||
def move(self,pose):
|
||||
def escrever(self,pulses,prazo):
|
||||
try:
|
||||
for channel,pulse in pulses.items():
|
||||
self.servos.hw.pulse(channel,pulse)
|
||||
self.servos.active[channel]=prazo
|
||||
except OSError:
|
||||
self.alvo=None; self.servos.stop()
|
||||
raise
|
||||
def move(self,pose,velocidade=None,ttl_ms=None):
|
||||
"""Sem velocidade: vai direto (compatível). Com velocidade: o servidor interpola até o alvo.
|
||||
Em ambos os casos os motores desligam sozinhos quando o prazo (ttl_ms) vence sem renovação."""
|
||||
velocidade=velocidade_valida(velocidade); ttl=prazo_valido(ttl_ms)
|
||||
with self.servos.lock:
|
||||
pulses=self.validate_pose(pose,self.config['calibracao'])
|
||||
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
|
||||
try:
|
||||
for channel,pulse in pulses.items():
|
||||
self.servos.hw.pulse(channel,pulse)
|
||||
self.servos.active[channel]=time.monotonic()+2
|
||||
except OSError:
|
||||
self.servos.stop()
|
||||
raise
|
||||
return dict(ok=True,pulsos=pulses)
|
||||
self.servos.reservar('braco')
|
||||
prazo=time.monotonic()+ttl/1000
|
||||
if velocidade is None or self.atual is None:
|
||||
self.alvo=None
|
||||
self.escrever(pulses,prazo)
|
||||
self.atual=[float(a) for a in pose]
|
||||
return dict(ok=True,pulsos=pulses,movendo=False)
|
||||
self.alvo=[float(a) for a in pose]; self.velocidade=velocidade; self.lease=prazo
|
||||
for channel in list(self.servos.active): self.servos.active[channel]=prazo
|
||||
return dict(ok=True,pulsos=pulses,movendo=True)
|
||||
def cancelar(self):
|
||||
with self.servos.lock: self.alvo=None
|
||||
def passo(self,agora,dt):
|
||||
with self.servos.lock:
|
||||
if self.alvo is None: return
|
||||
if agora>=self.lease or self.servos.travado or (self.servos.dono!='braco' and self.servos.active):
|
||||
self.alvo=None; return
|
||||
delta=self.velocidade*dt
|
||||
nova=[a+max(-delta,min(delta,t-a)) for a,t in zip(self.atual,self.alvo)]
|
||||
try: pulses=self.validate_pose(nova,self.config['calibracao'])
|
||||
except ValueError:
|
||||
self.alvo=None; return
|
||||
self.escrever(pulses,self.lease)
|
||||
self.atual=nova
|
||||
if nova==self.alvo: self.alvo=None
|
||||
def loop(self):
|
||||
anterior=time.monotonic()
|
||||
while not self.servos.closed.wait(PASSO):
|
||||
agora=time.monotonic()
|
||||
try: self.passo(agora,agora-anterior)
|
||||
except OSError as e: self.servos.error=str(e)
|
||||
anterior=agora
|
||||
@staticmethod
|
||||
def angle_pulse(angle,center):
|
||||
return round(500+angle*(center-500)/90 if angle<=90 else center+(angle-90)*(2500-center)/90)
|
||||
@@ -86,6 +139,7 @@ class Braco:
|
||||
outputs[other]=integer(center_sum-pulse,500,2500)
|
||||
with self.servos.lock:
|
||||
if self.servos.hw is None: raise OSError('PCA9685 indisponível')
|
||||
self.servos.reservar('braco'); self.alvo=None; self.atual=None
|
||||
for ch,us in outputs.items():
|
||||
self.servos.hw.pulse(ch,us)
|
||||
self.servos.active[ch]=time.monotonic()+2
|
||||
@@ -96,21 +150,16 @@ class Braco:
|
||||
try:
|
||||
if method=='GET' and route=='/braco/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<=65536: 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,65536)
|
||||
if route=='/braco/config': result=self.save(data)
|
||||
elif route=='/braco/mover': result=self.move(data.get('angulos'))
|
||||
elif route=='/braco/mover': result=self.move(data.get('angulos'),data.get('velocidade'),data.get('ttl_ms'))
|
||||
elif route=='/braco/calibrar': result=self.calibrate(data.get('canal'),data.get('pulso'))
|
||||
elif route=='/braco/parar': result=self.servos.stop()
|
||||
elif route=='/braco/parar': self.cancelar(); result=self.servos.stop()
|
||||
else: raise ValueError('Rota inválida')
|
||||
else:
|
||||
h.enviar(json.dumps(dict(erro='Método inválido')),codigo=405); return True
|
||||
h.enviar(json.dumps(result,ensure_ascii=False))
|
||||
except Conflito as e: h.enviar(json.dumps(dict(erro=str(e)),ensure_ascii=False),codigo=409)
|
||||
except (ValueError,TypeError) as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=400)
|
||||
except OSError as e: h.enviar(json.dumps(dict(erro=str(e))),codigo=503)
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user