diff --git a/.gitignore b/.gitignore index 900aa93..71afbcc 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ api-privada.json api-privada.tmp ia-config.json +seguranca.json diff --git a/api_config.py b/api_config.py index 2fb596c..38dd525 100644 --- a/api_config.py +++ b/api_config.py @@ -2,6 +2,7 @@ import json,os,re,threading,time from pathlib import Path import gemini +from comum import gravar_atomico, ler_json class ConfigAPI: def __init__(self,path): @@ -25,9 +26,7 @@ class ConfigAPI: key=d.get('chave') or os.environ.get('GEMINI_API_KEY','') saved=dict(modelo=d['modelo']) if key:saved['chave']=key - tmp=self.path.with_suffix('.tmp');fd=os.open(str(tmp),os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600) - with os.fdopen(fd,'w',encoding='utf-8') as f:json.dump(saved,f) - os.chmod(tmp,0o600);tmp.replace(self.path);os.chmod(self.path,0o600) + gravar_atomico(self.path,saved,privado=True) gemini.MODELO=d['modelo'] if key:os.environ['GEMINI_API_KEY']=key self.ultimo_teste=None @@ -47,11 +46,7 @@ class ConfigAPI: try: if method=='GET' and route.endswith('/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=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 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': diff --git a/conversa.py b/conversa.py index 5cfb27a..5504dd0 100644 --- a/conversa.py +++ b/conversa.py @@ -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` +em todo POST e nas leituras de dados (`/eventos`, `/estado`, `/fila`, `/app/*`, `/rtc/*`, +`/camera/*`, `/conversa/*`, `/treinamento/*`, `/braco/*`, `/servos/*`, `/ia/status`, `/api/*`). +EventSource e o app Android podem mandar `?t=` na URL. + +Continuam públicos apenas: páginas estáticas, `/app/health`, `/faces`, `/gestos`, +`GET /config` (aparência), `/ia/modelo` e `/app.apk`. + +Configure **um** dos dois (mínimo 8 caracteres): + +``` +# /etc/carinha.env, carregado pelo carinha-pc.service (EnvironmentFile=) +CARINHA_TOKEN=troque-por-um-segredo-longo +``` + +ou `seguranca.json` na pasta do servidor (fora do git): + +```json +{"token": "troque-por-um-segredo-longo"} +``` + +Sem token configurado o servidor fica aberto na rede local, como antes. + +> **Ordem de ativação.** O painel antigo e o `carinha.html`/APK atuais ainda não enviam token. +> Ative o token na placa só depois de publicar o painel novo e o rosto/APK com suporte a token +> (fases 2–5 do plano de modernização). Até lá o servidor já aplica as demais proteções: +> checagem de Origin em todos os POST, comandos validados e E-Stop. + +## E-Stop + +`POST /parar-tudo` desliga as 16 saídas do PCA9685, cancela a interpolação do braço, +para reações e manda `calma` ao rosto. Braço e servos ficam **travados** (HTTP 409) até +`POST /rearmar`. + +## Trava de hardware + +Braço (`/braco/*`) e diagnóstico (`/servos/*`) usam o mesmo PCA9685 com limites +diferentes. Quem estiver com motores ativos é o dono; o outro recebe 409 até os motores +desligarem (parar, ou ~2 s sem comando). diff --git a/eventos.py b/eventos.py new file mode 100644 index 0000000..234e90a --- /dev/null +++ b/eventos.py @@ -0,0 +1,70 @@ +"""Hub Server-Sent Events: um único fluxo /eventos substitui os pollings do painel.""" +from collections import deque +import json +import threading +import time +from urllib.parse import parse_qs, urlsplit + +PING = 15 + + +class Eventos: + def __init__(self, buffer=200): + self.cond = threading.Condition() + self.seq = 0 + self.buffer = deque(maxlen=buffer) + self.fechado = False + + def publicar(self, tipo, dados): + corpo = json.dumps(dados, ensure_ascii=False) + with self.cond: + self.seq += 1 + self.buffer.append((self.seq, tipo, corpo)) + self.cond.notify_all() + return self.seq + + def desde(self, seq): + with self.cond: + return [e for e in self.buffer if e[0] > seq] + + def fechar(self): + with self.cond: + self.fechado = True + self.cond.notify_all() + + def atender(self, h): + url = urlsplit(h.path) + try: + ultimo = int(h.headers.get('Last-Event-ID') or parse_qs(url.query).get('desde', ['0'])[0]) + except ValueError: + ultimo = 0 + with self.cond: + # Cliente novo (ou que perdeu demais): começa do agora e lê /estado. + if ultimo <= 0 or (self.buffer and ultimo < self.buffer[0][0] - 1) or ultimo > self.seq: + ultimo = self.seq + h.send_response(200) + h.send_header('Content-Type', 'text/event-stream; charset=utf-8') + h.send_header('Cache-Control', 'no-store') + h.send_header('X-Accel-Buffering', 'no') + h.end_headers() + try: + h.wfile.write(f'retry: 3000\nevent: pronto\nid: {ultimo}\ndata: {{"seq": {ultimo}}}\n\n'.encode()) + while True: + limite = time.monotonic() + PING + with self.cond: + while not self.fechado and (not self.buffer or self.buffer[-1][0] <= ultimo): + resta = limite - time.monotonic() + if resta <= 0: + break + self.cond.wait(resta) + if self.fechado: + return + novos = [e for e in self.buffer if e[0] > ultimo] + if not novos: + h.wfile.write(b': ping\n\n') + continue + for seq, tipo, corpo in novos: + h.wfile.write(f'id: {seq}\nevent: {tipo}\ndata: {corpo}\n\n'.encode()) + ultimo = seq + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError): + return diff --git a/kiosk-server.py b/kiosk-server.py index c87cfa7..c9888c7 100644 --- a/kiosk-server.py +++ b/kiosk-server.py @@ -1,38 +1,39 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -kiosk-server.py — servidor local do PC: painel, carinha e fila de ordens. +kiosk-server.py — servidor do Carinha (Orange Pi, porta 8100): painel, rosto e controle. -Alem de entregar os arquivos da pasta (padrao /root/carinha), ele expõe: +Estado em tempo real: + GET /eventos -> Server-Sent Events (expressao, telemetria, conversa, reacao, + ia, motores, estop, aparencia, app_config, treinamento) + GET /estado -> foto de tudo para o primeiro carregamento (inclui 'seq') - GET /fila -> devolve e limpa a fila, em JSON: ["feliz","piscar"] - GET /gesto/ -> poe na fila (ex.: /gesto/coracao) - POST /gesto -> corpo = nome do gesto +Comandos: + POST /comando -> {"tipo": "gesto"|"rosto"|"cor"|"reacao"|"parar_reacao"|"recarregar", "valor": ...} + POST /parar-tudo -> E-Stop: desliga todos os servos, para reações e acalma o rosto + POST /rearmar -> libera os motores depois do E-Stop + GET /fila?aparelho=1 -> ordens pendentes para o celular (até o rosto migrar para /eventos) -A pagina da carinha pergunta /fila a cada meio segundo; assim da para mandar -um gesto do PC com um curl, igual ao antigo FIFO /tmp/eilik.fifo: +Autenticação: se CARINHA_TOKEN (ambiente) ou seguranca.json {"token": ...} existir, toda +rota de controle/dados exige "Authorization: Bearer " (ou ?t= no EventSource +e no app). Sem token configurado o servidor fica aberto como antes. - curl http://orangepi:8099/gesto/feliz - -Alem disso guarda a APARENCIA da carinha (rosto + cor dos olhos), para o painel -personalizar e a pagina aplicar sozinha ao carregar: - - GET /config -> {"rosto":"carinha","cor":[92,214,255]} - POST /config -> corpo JSON {"rosto":"miudo","cor":[255,120,0]} - -E serve o painel de controle em http://:8099/painel . - -So usa a biblioteca padrao (http.server). Nada de instalacao extra. +Só usa a biblioteca padrão. Nada de instalação extra. """ import argparse +from collections import deque import json import os import threading +import time +import uuid from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import unquote from pathlib import Path -from controle_pc import ControlePC +from comum import autorizado, carregar_token, gravar_atomico, ler_json +from controle_pc import ControlePC, versao +from eventos import Eventos from servos import ControleServos from braco import Braco from cerebro import Cerebro @@ -41,7 +42,6 @@ from treinamento import Treinamento from api_config import ConfigAPI TRAVA = threading.Lock() -FILA = [] # Espelho da biblioteca de rostos do carinha.html (para /faces funcionar) ROSTOS = ["eilik", "carinha", "miudo"] @@ -50,12 +50,65 @@ ROSTOS = ["eilik", "carinha", "miudo"] GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz", "surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre", "dormir", "acordar", "aleatorio", "parar"] +# Ordens internas que o rosto entende mas não viram botão. +ORDENS_INTERNAS = {"calma"} -# Apar┌┐ncia atual da carinha (personalizacao pelo painel). Fica gravada num +# Aparência atual da carinha (personalizacao pelo painel). Fica gravada num # arquivo ao lado do servidor, para sobreviver a reinicios. APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255]} ARQUIVO_APARENCIA = "aparencia.json" +# Rotas que não exigem token: só o necessário para a página do rosto abrir e o app se achar. +PUBLICAS_GET = {"/app/health", "/faces", "/gestos", "/config", "/ia/modelo", "/app.apk"} +PREFIXOS_API = ("/eventos", "/estado", "/fila", "/rtc/", "/camera/", "/conversa/", "/treinamento/", + "/app/", "/braco/", "/servos/", "/ia/", "/api/", "/gesto/", "/face/", "/diag/") + + +class Fila: + """Ordens para o celular: limitada e com validade, para não repetir ordens velhas + quando o aparelho volta depois de um tempo offline.""" + + def __init__(self, maximo=20, validade=10.0): + self.lock = threading.Lock() + self.itens = deque(maxlen=maximo) + self.validade = validade + + def adicionar(self, ordem, agora=None): + with self.lock: + if ordem == "recarregar" and any(o == ordem for _, o in self.itens): + return + self.itens.append((agora if agora is not None else time.monotonic(), ordem)) + + def consumir(self, aparelho=True, agora=None): + """O celular leva tudo; um rosto aberto no navegador (kiosk) leva tudo menos 'recarregar'.""" + agora = agora if agora is not None else time.monotonic() + with self.lock: + validos = [(t, o) for t, o in self.itens if agora - t <= self.validade] + pendentes = [o for _, o in validos if aparelho or o != "recarregar"] + self.itens.clear() + if not aparelho: + self.itens.extend((t, o) for t, o in validos if o == "recarregar") + return pendentes + + def ordens(self): + with self.lock: + return [o for _, o in self.itens] + + def limpar(self): + with self.lock: + self.itens.clear() + + +FILA = Fila() +EVENTOS = Eventos() +ESTOP = {"ativo": False, "desde": None} + + +def enfileirar(ordem): + """Caminho único de uma ordem para o rosto: fila do celular + evento para os painéis.""" + FILA.adicionar(ordem) + EVENTOS.publicar("expressao", {"ordem": ordem}) + def carregar_aparencia(): try: @@ -73,14 +126,20 @@ def carregar_aparencia(): def salvar_aparencia(): try: - with open(ARQUIVO_APARENCIA, "w", encoding="utf-8") as f: - json.dump(APARENCIA, f, ensure_ascii=False, indent=2) - except Exception: + gravar_atomico(ARQUIVO_APARENCIA, APARENCIA) + except OSError: pass +def validar_cor(valor): + cor = [max(0, min(255, int(v))) for v in list(valor)[:3]] + if len(cor) != 3: + raise ValueError("Cor precisa de três números") + return cor + + def aplicar_aparencia(dados): - """Guarda rosto/cor e poe as ordens na fila para a pagina aplicar na hora.""" + """Guarda rosto/cor e manda as ordens para a página aplicar na hora.""" ordens = [] with TRAVA: if "rosto" in dados: @@ -90,24 +149,43 @@ def aplicar_aparencia(dados): ordens.append("rosto " + nome) if "cor" in dados: try: - cor = [max(0, min(255, int(v))) for v in list(dados["cor"])[:3]] - if len(cor) == 3: - APARENCIA["cor"] = cor - ordens.append("cor %d,%d,%d" % tuple(cor)) - except Exception: + APARENCIA["cor"] = validar_cor(dados["cor"]) + ordens.append("cor %d,%d,%d" % tuple(APARENCIA["cor"])) + except (TypeError, ValueError): pass - FILA.extend(ordens) - salvar_aparencia() + aparencia = dict(APARENCIA) + for ordem in ordens: + enfileirar(ordem) + if ordens: + salvar_aparencia() + EVENTOS.publicar("aparencia", aparencia) return ordens +def ordem_de_comando(tipo, valor): + """Traduz {tipo, valor} para a ordem de texto que o rosto entende; ValueError se inválido.""" + if tipo == "gesto": + nome = str(valor or "").strip().lower() + if nome not in GESTOS and nome not in ORDENS_INTERNAS: + raise ValueError("Gesto desconhecido: " + nome) + return nome + if tipo == "rosto": + nome = str(valor or "").strip().lower() + if nome not in ROSTOS: + raise ValueError("Rosto desconhecido: " + nome) + return "rosto " + nome + raise ValueError("Tipo de comando inválido") + + class Handler(SimpleHTTPRequestHandler): extensoes_map = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", + ".woff2": "font/woff2", ".mp4": "video/mp4"} def guess_type(self, caminho): @@ -120,32 +198,71 @@ class Handler(SimpleHTTPRequestHandler): self.send_header("Content-Type", tipo) self.send_header("Content-Length", str(len(dados))) self.send_header("Cache-Control", "no-store") + if codigo == 401: + self.send_header("WWW-Authenticate", 'Bearer realm="carinha"') self.end_headers() self.wfile.write(dados) + def json(self, dados, codigo=200): + self.enviar(json.dumps(dados, ensure_ascii=False), codigo=codigo) + + def rota(self): + return self.path.split("?", 1)[0] + + def acesso_negado(self, metodo): + """True (e já respondeu 401) se a rota exige token e o pedido não trouxe o certo.""" + rota = self.rota() + protegida = metodo == "POST" or (rota not in PUBLICAS_GET and rota.startswith(PREFIXOS_API)) + if protegida and not autorizado(self): + self.json({"erro": "Token ausente ou inválido"}, 401) + return True + return False + + def modulos(self): + s = self.server + return [getattr(s, nome) for nome in ("api_config", "treinamento", "conversa", "cerebro", + "braco", "servos", "controle") if hasattr(s, nome)] + + def estado(self): + s = self.server + host = self.headers.get("Host", "").split(":")[0] + with TRAVA: + aparencia = dict(APARENCIA) + dados = dict(versao=versao(), seq=EVENTOS.seq, autenticacao=bool(getattr(s, "token", None)), + estop=dict(ESTOP), aparencia=aparencia, gestos=GESTOS, rostos=ROSTOS) + if hasattr(s, "controle"): + dados.update(app=s.controle.resumo(host), app_config=s.controle.config.copy()) + if hasattr(s, "servos"): + dados["motores"] = s.servos.motores() + if hasattr(s, "conversa"): + dados["conversa"] = s.conversa.status() + if hasattr(s, "cerebro"): + dados["ia"] = s.cerebro.status() + if hasattr(s, "treinamento"): + dados["reacao"] = s.treinamento.expressao + dados["treinamento"] = dict(ocupado=s.treinamento.busy, erro=s.treinamento.erro) + if hasattr(s, "api_config"): + dados["api"] = s.api_config.status() + return dados + def do_GET(self): - if hasattr(self.server, "api_config") and self.server.api_config.rotas(self, "GET"):return - if hasattr(self.server, 'treinamento') and self.server.treinamento.rotas(self, 'GET'): + if self.acesso_negado("GET"): return - if hasattr(self.server, 'conversa') and self.server.conversa.rotas(self, 'GET'): - return - if hasattr(self.server, 'cerebro') and self.server.cerebro.rotas(self, 'GET'): - return - if hasattr(self.server, 'braco') and self.server.braco.rotas(self, 'GET'): - return - if hasattr(self.server, 'servos') and self.server.servos.rotas(self, 'GET'): - return - if self.server.controle.rotas(self, 'GET'): - return - caminho = self.path.split("?", 1)[0] + for modulo in self.modulos(): + if modulo.rotas(self, "GET"): + return + caminho = self.rota() partes = [p for p in caminho.split("/") if p] + if caminho == "/eventos": + return EVENTOS.atender(self) + + if caminho == "/estado": + return self.json(self.estado()) + if partes and partes[0] == "fila": - with TRAVA: - aparelho = 'aparelho=1' in self.path - pendentes = [x for x in FILA if x!='recarregar' or aparelho] - FILA[:] = [x for x in FILA if x=='recarregar' and not aparelho] - return self.enviar(json.dumps(pendentes)) + # Painéis não leem a fila: observam o rosto por /eventos. + return self.enviar(json.dumps(FILA.consumir(aparelho="aparelho=1" in self.path))) if partes and partes[0] == "faces": return self.enviar(json.dumps({"rostos": ROSTOS})) @@ -156,12 +273,6 @@ class Handler(SimpleHTTPRequestHandler): if partes and partes[0] == "gestos": return self.enviar(json.dumps({"gestos": GESTOS})) - if len(partes) == 2 and partes[0] == "face": - nome = unquote(partes[1]).lower() - with TRAVA: - FILA.append("rosto " + nome) - return self.enviar(json.dumps({"ok": True, "rosto": nome})) - if partes and partes[0] == "diag": # a propria pagina relata o tamanho que recebeu (ajuda a achar # barras/enquadramento errado no kiosk). Aparece no journal. @@ -169,11 +280,15 @@ class Handler(SimpleHTTPRequestHandler): print("DIAG kiosk:", info, flush=True) return self.enviar(json.dumps({"ok": True})) - if len(partes) == 2 and partes[0] == "gesto": - nome = unquote(partes[1]).lower() # aceita %20 para espacos - with TRAVA: - FILA.append(nome) - return self.enviar(json.dumps({"ok": True, "gesto": nome})) + # Obsoletos (GET que muda estado); ficam até o painel antigo sair. Use POST /comando. + if len(partes) == 2 and partes[0] in ("gesto", "face"): + tipo = "gesto" if partes[0] == "gesto" else "rosto" + try: + ordem = ordem_de_comando(tipo, unquote(partes[1])) + except ValueError as e: + return self.json({"erro": str(e)}, 400) + enfileirar(ordem) + return self.json({"ok": True, partes[0]: ordem}) if caminho in ("/dashboard", "/emojis", "/api"): self.path = caminho + ".html" @@ -201,60 +316,103 @@ class Handler(SimpleHTTPRequestHandler): elif caminho in ('/dashboard.html','/emojis.html','/api.html','/dashboard.css','/dashboard.js','/overview.js','/biblioteca.js','/api-painel.js','/painel.css','/conversa.css','/treinamento.css','/servos.css','/braco.css','/audio-robot.js'): pass elif caminho not in ('/conversa.html', '/treinamento.html', '/treinamento.js', '/reacoes-render.js', '/painel-conversa.js', '/braco-calibracao.js', '/movimentos-core.js', '/braco-movimentos.js', '/braco.html', '/braco.js', '/servos.html', '/servos.js', '/carinha.html', '/camera.html', '/painel.html', '/painel-controle.js', '/painel-video.js', - '/rastreamento-core.js', '/rastreador-worker.js', '/painel-rastreamento.js', '/audio-robot.js'): + '/rastreamento-core.js', '/rastreador-worker.js', '/painel-rastreamento.js'): return self.enviar(json.dumps({'erro': 'Arquivo não disponível'}), codigo=404) return super().do_GET() def do_POST(self): - if hasattr(self.server, "api_config") and self.server.api_config.rotas(self, "POST"):return - if hasattr(self.server, 'treinamento') and self.server.treinamento.rotas(self, 'POST'): + if self.acesso_negado("POST"): return - if hasattr(self.server, 'conversa') and self.server.conversa.rotas(self, 'POST'): - return - if hasattr(self.server, 'cerebro') and self.server.cerebro.rotas(self, 'POST'): - return - if hasattr(self.server, 'braco') and self.server.braco.rotas(self, 'POST'): - return - if hasattr(self.server, 'servos') and self.server.servos.rotas(self, 'POST'): - return - if self.server.controle.rotas(self, 'POST'): - return - rota = self.path.split("?", 1)[0].rstrip("/") - tamanho = int(self.headers.get("Content-Length") or 0) + for modulo in self.modulos(): + if modulo.rotas(self, "POST"): + return + rota = self.rota().rstrip("/") + try: + if rota == "/comando": + return self.comando(ler_json(self, 2048)) + if rota == "/parar-tudo": + ler_json(self, 2048) + return self.json(self.parar_tudo()) + if rota == "/rearmar": + ler_json(self, 2048) + return self.json(self.rearmar()) + if rota == '/app/recarregar': + ler_json(self, 2048) + return self.recarregar() + if rota == "/config": + return self.json({"ok": True, "ordens": aplicar_aparencia(ler_json(self, 2048)), + "aparencia": APARENCIA}) + if rota in ("/gesto", "/face"): + # Obsoleto: corpo é o nome cru. Use POST /comando. + tamanho = int(self.headers.get("Content-Length") or 0) + nome = self.rfile.read(min(tamanho, 200)).decode("utf-8", "ignore") + ordem = ordem_de_comando("gesto" if rota == "/gesto" else "rosto", nome) + enfileirar(ordem) + return self.json({"ok": True, "ordem": ordem}) + except ValueError as e: + return self.json({"erro": str(e)}, 400) + return self.json({"erro": "Rota POST desconhecida"}, 404) - if rota == '/app/recarregar': - origin=self.headers.get('Origin') - if origin and origin not in ('http://'+self.headers.get('Host',''),'https://'+self.headers.get('Host','')): - return self.enviar(json.dumps(dict(erro='Origem inválida')),codigo=400) - import time - with self.server.controle.lock: - online=time.monotonic()-self.server.controle.status_at<5 - import uuid - self.server.controle.recarga = dict(id=uuid.uuid4().hex, confirmada=False) - if not online:return self.enviar(json.dumps(dict(erro='Abra o Carinha e conecte ao servidor primeiro')),codigo=409) - with TRAVA: - if 'recarregar' not in FILA:FILA.append('recarregar') - return self.enviar(json.dumps(dict(ok=True,mensagem='Solicitação enviada. Aguardando o celular recarregar a tela.'))) + def comando(self, dados): + tipo, valor = dados.get("tipo"), dados.get("valor") + s = self.server + if tipo == "cor": + if not aplicar_aparencia({"cor": valor}): + raise ValueError("Cor inválida") + return self.json({"ok": True}) + if tipo == "recarregar": + return self.recarregar() + if tipo in ("reacao", "parar_reacao"): + if not hasattr(s, "treinamento"): + raise ValueError("Reações indisponíveis") + if tipo == "reacao": + if type(valor) is not int: + raise ValueError("ID de reação inválido") + s.treinamento.testar(valor) + else: + with s.treinamento.lock: + s.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=dict(limpar=True)) + return self.json({"ok": True}) + ordem = ordem_de_comando(tipo, valor) + enfileirar(ordem) + return self.json({"ok": True, "ordem": ordem}) - if rota == "/config": - try: - dados = json.loads(self.rfile.read(tamanho).decode("utf-8", "ignore") or "{}") - if not isinstance(dados, dict): - raise ValueError("esperava um objeto JSON") - except Exception as e: - return self.enviar(json.dumps({"erro": "json invalido: %s" % e}), codigo=400) - ordens = aplicar_aparencia(dados) - return self.enviar(json.dumps({"ok": True, "aparencia": APARENCIA, - "ordens": ordens})) + def parar_tudo(self): + s = self.server + if hasattr(s, "braco"): + s.braco.cancelar() + if hasattr(s, "servos"): + with s.servos.lock: + s.servos.travado = True + s.servos.stop() + if hasattr(s, "treinamento"): + with s.treinamento.lock: + s.treinamento.expressao = dict(id=time.time_ns(), horario=time.time(), receita=dict(limpar=True)) + FILA.limpar() + enfileirar("calma") + ESTOP.update(ativo=True, desde=time.time()) + EVENTOS.publicar("estop", dict(ESTOP)) + return {"ok": True, "estop": dict(ESTOP)} - if rota not in ("/gesto", "/face"): - return self.enviar(json.dumps({"erro": "rotas POST: /gesto, /face, /config"}), codigo=404) - corpo = self.rfile.read(tamanho).decode("utf-8", "ignore").strip().lower() - if not corpo: - return self.enviar(json.dumps({"erro": "corpo vazio"}), codigo=400) - with TRAVA: - FILA.append(corpo) - return self.enviar(json.dumps({"ok": True, "ordem": corpo})) + def rearmar(self): + s = self.server + if hasattr(s, "servos"): + with s.servos.lock: + s.servos.travado = False + EVENTOS.publicar("motores", s.servos.motores()) + ESTOP.update(ativo=False, desde=None) + EVENTOS.publicar("estop", dict(ESTOP)) + return {"ok": True, "estop": dict(ESTOP)} + + def recarregar(self): + controle = self.server.controle + with controle.lock: + online = time.monotonic() - controle.status_at < 5 + controle.recarga = dict(id=uuid.uuid4().hex, confirmada=False) + if not online: + return self.json({"erro": "Abra o Carinha e conecte ao servidor primeiro"}, 409) + FILA.adicionar("recarregar") + return self.json({"ok": True, "mensagem": "Solicitação enviada. Aguardando o celular recarregar a tela."}) def log_message(self, formato, *args): # silencio por padrao (roda em segundo plano); --log liga o registro @@ -270,6 +428,13 @@ class Servidor(ThreadingHTTPServer): so o desligamos no Windows.""" daemon_threads = True allow_reuse_address = (os.name != "nt") + token = None + + +def ligar_eventos(servidor): + for nome in ("controle", "servos", "cerebro", "conversa", "treinamento"): + if hasattr(servidor, nome): + getattr(servidor, nome).publicar = EVENTOS.publicar def main(): @@ -282,21 +447,24 @@ def main(): os.chdir(args.pasta) carregar_aparencia() servidor = Servidor(("0.0.0.0", args.porta), Handler) + servidor.token = carregar_token(Path.cwd()) servidor.api_config = ConfigAPI(Path.cwd() / 'api-privada.json') servidor.controle = ControlePC(Path.cwd() / 'app-config.json') servidor.servos = ControleServos(Path.cwd() / 'servos.json') servidor.braco = Braco(Path.cwd() / 'braco.json', servidor.servos) - def expressao_ia(gesto): - with TRAVA: FILA.append(gesto) - servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, expressao_ia) + servidor.cerebro = Cerebro(Path.cwd() / 'ia-config.json', servidor.controle, enfileirar) servidor.conversa = Conversa(Path.cwd() / 'memoria.db', servidor.cerebro) servidor.treinamento = Treinamento(Path.cwd() / 'treinamento.db') servidor.conversa.memoria.on_registrar = servidor.treinamento.registrar servidor.conversa.treinamento = servidor.treinamento + ligar_eventos(servidor) servidor.registrar = args.log - print(f"servindo {args.pasta} em http://0.0.0.0:{args.porta}/carinha.html") - print(f"painel de controle em http://0.0.0.0:{args.porta}/painel") - servidor.serve_forever() + print(f"servindo {args.pasta} em http://0.0.0.0:{args.porta}/") + print("autenticação:", "token obrigatório" if servidor.token else "DESLIGADA (defina CARINHA_TOKEN)") + try: + servidor.serve_forever() + finally: + EVENTOS.fechar() if __name__ == "__main__": diff --git a/servos.py b/servos.py index 33a5e0c..13e885c 100644 --- a/servos.py +++ b/servos.py @@ -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: diff --git a/tests/test_controle_pc.py b/tests/test_controle_pc.py index 07f3b7f..70ef892 100644 --- a/tests/test_controle_pc.py +++ b/tests/test_controle_pc.py @@ -46,7 +46,7 @@ class ControleTest(unittest.TestCase): self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() self.url = 'http://127.0.0.1:%d' % self.server.server_port - kiosk.FILA.clear() + kiosk.FILA.limpar() def tearDown(self): self.server.shutdown() @@ -64,12 +64,12 @@ class ControleTest(unittest.TestCase): return e.code, e.read() def test_config_is_persisted_without_consuming_gestures(self): - kiosk.FILA.append('feliz') + kiosk.FILA.adicionar('feliz') code, body = self.request('/app/config', {'volume': 0.7, 'sompausa': 40}) self.assertEqual(code, 200) self.assertEqual(json.loads(body)['config']['volume'], 0.7) self.assertEqual(self.request('/app/config')[0], 200) - self.assertEqual(kiosk.FILA, ['feliz']) + self.assertEqual(kiosk.FILA.ordens(), ['feliz']) self.assertEqual(json.loads((Path(self.tmp.name) / 'app.json').read_text())['sompausa'], 40) def test_invalid_configuration_does_not_partially_apply(self): diff --git a/tests/test_servidor.py b/tests/test_servidor.py new file mode 100644 index 0000000..8516da8 --- /dev/null +++ b/tests/test_servidor.py @@ -0,0 +1,226 @@ +"""Servidor base: token, /eventos (SSE), /estado, /comando, E-Stop, trava de hardware e fila.""" +import http.client +import importlib.util +import json +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +spec = importlib.util.spec_from_file_location('kiosk', ROOT / 'kiosk-server.py') +kiosk = importlib.util.module_from_spec(spec) +spec.loader.exec_module(kiosk) +from braco import Braco +from comum import carregar_token +from servos import ControleServos + +TOKEN = 'segredo-de-teste' + + +class HW: + def __init__(self): self.calls = [] + def pulse(self, ch, us): self.calls.append((ch, us)) + def off(self, ch): self.calls.append((ch, None)) + + +class ServidorTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + pasta = Path(self.tmp.name) + self.hw = HW() + self.server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler) + self.server.token = TOKEN + self.server.controle = kiosk.ControlePC(pasta / 'app.json') + self.server.servos = ControleServos(pasta / 'servos.json', self.hw) + self.server.braco = Braco(pasta / 'braco.json', self.server.servos) + kiosk.ligar_eventos(self.server) + kiosk.FILA.limpar() + kiosk.ESTOP.update(ativo=False, desde=None) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.porta = self.server.server_port + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.server.servos.close() + self.thread.join() + self.tmp.cleanup() + + def pedir(self, metodo, rota, dados=None, token=TOKEN, cabecalhos=None): + conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5) + h = {'Content-Type': 'application/json'} + if token: + h['Authorization'] = 'Bearer ' + token + h.update(cabecalhos or {}) + corpo = None if dados is None else json.dumps(dados) + conn.request(metodo, rota, body=corpo, headers=h) + r = conn.getresponse() + texto = r.read() + conn.close() + return r.status, json.loads(texto) if texto else None + + # ------------------------------------------------------------- token + def test_rotas_de_controle_exigem_token(self): + for metodo, rota, dados in [('POST', '/braco/mover', {'angulos': [90] * 7}), + ('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'}), + ('POST', '/parar-tudo', {}), + ('GET', '/estado', None), ('GET', '/fila?aparelho=1', None), + ('GET', '/app/status', None), ('GET', '/gesto/feliz', None)]: + for token in (None, 'errado'): + codigo, corpo = self.pedir(metodo, rota, dados, token=token) + self.assertEqual(codigo, 401, (metodo, rota, token)) + self.assertEqual(self.hw.calls, []) + self.assertEqual(kiosk.FILA.ordens(), []) + + def test_rotas_publicas_e_token_na_query(self): + self.assertEqual(self.pedir('GET', '/gestos', token=None)[0], 200) + self.assertEqual(self.pedir('GET', '/app/health', token=None)[0], 200) + self.assertEqual(self.pedir('GET', '/estado?t=' + TOKEN, token=None)[0], 200) + + def test_sem_token_configurado_fica_aberto(self): + self.server.token = None + self.assertEqual(self.pedir('GET', '/estado', token=None)[0], 200) + + def test_carregar_token(self): + pasta = Path(self.tmp.name) + self.assertIsNone(carregar_token(pasta)) + (pasta / 'seguranca.json').write_text(json.dumps({'token': 'abcdefgh123'})) + self.assertEqual(carregar_token(pasta), 'abcdefgh123') + (pasta / 'seguranca.json').write_text(json.dumps({'token': 'curto'})) + with self.assertRaises(ValueError): + carregar_token(pasta) + + def test_origem_estrangeira_recusada(self): + codigo, _ = self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'}, + cabecalhos={'Origin': 'http://malicioso.example'}) + self.assertEqual(codigo, 400) + + # ------------------------------------------------------------- comando e fila + def test_comando_valida_e_enfileira(self): + self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'feliz'})[0], 200) + self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'miudo'})[0], 200) + self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400) + self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400) + self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo']) + self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo']) + self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], []) + + def test_post_face_usa_prefixo_rosto(self): + conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5) + conn.request('POST', '/face', body='miudo', headers={'Authorization': 'Bearer ' + TOKEN}) + self.assertEqual(conn.getresponse().status, 200) + conn.close() + self.assertEqual(kiosk.FILA.ordens(), ['rosto miudo']) + + def test_fila_limitada_e_com_validade(self): + fila = kiosk.Fila(maximo=3, validade=10) + for i in range(5): + fila.adicionar(f'g{i}', agora=0) + self.assertEqual(fila.ordens(), ['g2', 'g3', 'g4']) + self.assertEqual(fila.consumir(agora=11), []) + fila.adicionar('recarregar'); fila.adicionar('recarregar'); fila.adicionar('feliz') + self.assertEqual(fila.ordens(), ['recarregar', 'feliz']) + # Rosto no navegador não rouba o 'recarregar' do celular. + self.assertEqual(fila.consumir(aparelho=False), ['feliz']) + self.assertEqual(fila.consumir(aparelho=True), ['recarregar']) + + # ------------------------------------------------------------- SSE + def test_eventos_entrega_comando(self): + conn = http.client.HTTPConnection('127.0.0.1', self.porta, timeout=5) + conn.request('GET', '/eventos?t=' + TOKEN) + r = conn.getresponse() + self.assertEqual(r.status, 200) + self.assertIn('text/event-stream', r.getheader('Content-Type')) + linhas = [] + while not linhas or linhas[-1] != '': + linhas.append(r.fp.readline().decode().rstrip('\n')) + self.assertIn('event: pronto', linhas) + self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'coracao'}) + bloco = [] + while not bloco or bloco[-1] != '': + bloco.append(r.fp.readline().decode().rstrip('\n')) + conn.close() + self.assertIn('event: expressao', bloco) + self.assertIn('data: {"ordem": "coracao"}', bloco) + + def test_estado_agrega(self): + codigo, estado = self.pedir('GET', '/estado') + self.assertEqual(codigo, 200) + for chave in ('versao', 'seq', 'estop', 'aparencia', 'gestos', 'app', 'motores'): + self.assertIn(chave, estado) + self.assertTrue(estado['autenticacao']) + + # ------------------------------------------------------------- E-Stop e trava + def test_parar_tudo_desliga_e_trava_ate_rearmar(self): + self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200) + self.hw.calls.clear() + codigo, corpo = self.pedir('POST', '/parar-tudo', {}) + self.assertEqual(codigo, 200) + self.assertTrue(corpo['estop']['ativo']) + self.assertEqual({c for c, us in self.hw.calls if us is None}, set(range(16))) + self.assertEqual(kiosk.FILA.ordens(), ['calma']) + self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 409) + self.assertEqual(self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})[0], 409) + self.assertEqual(self.pedir('POST', '/rearmar', {})[0], 200) + self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200) + + def test_braco_e_diagnostico_nao_disputam_motores(self): + self.assertEqual(self.pedir('POST', '/braco/mover', {'angulos': [90] * 7})[0], 200) + codigo, corpo = self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500}) + self.assertEqual(codigo, 409) + self.assertIn('braco', corpo['erro']) + self.pedir('POST', '/braco/parar', {}) + self.assertEqual(self.pedir('POST', '/servos/mover', {'indice': 0, 'pulso': 1500})[0], 200) + + +class BracoMovimentoTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.hw = HW() + self.servos = ControleServos(Path(self.tmp.name) / 's.json', self.hw) + self.servos.closed.set() # sem threads: os passos são chamados à mão + self.arm = Braco(Path(self.tmp.name) / 'b.json', self.servos) + cal = self.arm.status() + for row in cal['calibracao']: + row.update(minimo=500, maximo=2500, centro=1500) + self.arm.save(dict(calibracao=cal['calibracao'], posturas=[])) + + def tearDown(self): + self.tmp.cleanup() + + def test_interpolacao_no_servidor_respeita_velocidade(self): + self.arm.move([90] * 7) + resposta = self.arm.move([90, 90, 90, 90, 90, 90, 130], velocidade=100, ttl_ms=2000) + self.assertTrue(resposta['movendo']) + agora = self.arm.lease - 1.5 + self.arm.passo(agora, .1) # 100 °/s * 0,1 s = 10° + self.assertEqual(self.arm.atual[6], 100) + for i in range(10): + self.arm.passo(agora + .1 * (i + 1), .1) + self.assertEqual(self.arm.atual[6], 130) + self.assertIsNone(self.arm.alvo) + + def test_lease_vencido_interrompe_movimento(self): + self.arm.move([90] * 7) + self.arm.move([90] * 6 + [170], velocidade=50, ttl_ms=300) + self.arm.passo(self.arm.lease + .01, .1) + self.assertIsNone(self.arm.alvo) + self.assertEqual(self.arm.atual[6], 90) + # O watchdog desliga os canais no mesmo prazo. + self.servos.expire(self.arm.lease + .01) + self.assertEqual(self.servos.active, {}) + self.assertIsNone(self.servos.dono) + + def test_parametros_invalidos(self): + for kwargs in (dict(velocidade=1000), dict(velocidade=True), dict(ttl_ms=50), dict(ttl_ms=5000)): + with self.assertRaises(ValueError): + self.arm.move([90] * 7, **kwargs) + self.assertEqual(self.hw.calls, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/treinamento.py b/treinamento.py index 23c9a0d..8d91c9f 100644 --- a/treinamento.py +++ b/treinamento.py @@ -1,6 +1,7 @@ """Rotina, diário e reações procedurais. Não treina pesos nem controla motores.""" import json,sqlite3,threading,time,math from contextlib import contextmanager +from comum import ler_json from gemini import gerar_json from figurinhas import criar_desenho,validar_desenho,montar_prompt @@ -28,8 +29,20 @@ def validar_receita(r): return r class Treinamento: + # Mudanças de expressão/ocupação viram eventos SSE sem caçar cada atribuição. + @property + def expressao(self):return self._expressao + @expressao.setter + def expressao(self,valor): + self._expressao=valor;self.publicar('reacao',valor) + @property + def busy(self):return self._busy + @busy.setter + def busy(self,valor): + self._busy=valor;self.publicar('treinamento',dict(ocupado=valor,erro=self.erro)) def __init__(self,path,iniciar=True): - self.path=str(path);self.lock=threading.RLock();self.busy=False;self.erro='';self.expressao=None + self.publicar=lambda tipo,dados:None + self.path=str(path);self.lock=threading.RLock();self.erro='';self.busy=False;self.expressao=None with self.db() as db: db.execute('CREATE TABLE IF NOT EXISTS rotina(id INTEGER PRIMARY KEY,texto TEXT)') db.execute('CREATE TABLE IF NOT EXISTS diario(id INTEGER PRIMARY KEY,horario REAL,papel TEXT,texto TEXT)') @@ -178,12 +191,7 @@ class Treinamento: elif method=='GET' and route=='/treinamento/expressao': with self.lock:result=self.expressao 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