"""La cara: sirve el HUD y lo mantiene al dia por WebSocket. ## Por que un servidor y no una ventana En Newelle el HUD era un widget WebKit dentro de la aplicacion. Sacandolo a un servidor local se gana mas de lo que parece: - el panel es una pagina normal: se abre con `chromium --app=`, en cualquier navegador, o desde el movil o una tablet en la misma red; - se puede mirar el diseño sin arrancar nada, que es como se ha trabajado en el todo el tiempo; - desaparece GTK de la cara, que era la ultima atadura al framework. El formato de mensajes no cambia —{tipo, datos}, igual que empujaba Python por evaluate_javascript— asi que `hud.js` se queda sin tocar. Lo unico nuevo es `puente.js`, que suplanta `window.webkit` con el socket. ## Escucha solo en local 127.0.0.1 a proposito. Este panel ejecuta ordenes en la maquina: abrirlo a la red seria dar una terminal a cualquiera del wifi. Para verlo desde el movil, un tunel ssh, que ademas obliga a autenticarse. """ import asyncio import json import mimetypes import os import threading HUD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hud") PUERTO = int(os.environ.get("JARVIS_PUERTO", "8790")) FICHEROS = { "/": "index.html", "/index.html": "index.html", "/hud.js": "hud.js", "/puente.js": "puente.js", "/style.css": "style.css", } class Cara: """El panel: se le empujan eventos y devuelve lo que se pulsa.""" # Segundos que se espera tras cerrarse la ultima pestaña antes de apagar. # Da margen a una RECARGA (que cierra el socket y lo reabre enseguida) para # no confundirla con cerrar la ventana de verdad. GRACIA = 4 def __init__(self, al_pulsar=None, al_cerrar=None, puerto=PUERTO): self.al_pulsar = al_pulsar # (canal, dato) -> None self.al_cerrar = al_cerrar # se cerro la ultima pestaña -> apagar self.puerto = puerto self._clientes = set() self._hubo_cliente = False # para no apagar antes de abrir el panel self._bucle = None self._hilo = None self._ultimo = {} # el estado, para el que llegue tarde # ------------------------------------------------------------- arrancar def arranca(self): """Levanta el servidor en un hilo aparte y vuelve enseguida. Devuelve la URL, o None si no se pudo. El motivo mas comun es que ya haya otro JARVIS abierto: sin este manejo, el fallo salia como una traza de asyncio dentro de un hilo, con "address already in use" enterrado en la linea veinte, y el proceso seguia vivo sin panel. """ listo = threading.Event() def corre(): self._bucle = asyncio.new_event_loop() asyncio.set_event_loop(self._bucle) try: self._bucle.run_until_complete(self._sirve(listo)) except OSError as e: self._error = e listo.set() return self._bucle.run_forever() self._error = None self._hilo = threading.Thread(target=corre, daemon=True) self._hilo.start() listo.wait(timeout=10) if self._error is not None: import errno if self._error.errno == errno.EADDRINUSE: print(f" el puerto {self.puerto} ya esta cogido: ¿hay otro " f"JARVIS abierto? (arranca.sh --stop, o JARVIS_PUERTO=otro)") else: print(f" no se pudo levantar el panel: {self._error}") return None return f"http://127.0.0.1:{self.puerto}/" async def _sirve(self, listo): import websockets from websockets.http11 import Response from websockets.datastructures import Headers async def http(conexion, peticion): """Sirve los ficheros del HUD; deja pasar el WebSocket.""" ruta = peticion.path.split("?")[0] if ruta == "/puente": return None # que siga el apreton de manos nombre = FICHEROS.get(ruta) if not nombre: return Response(404, "Not Found", Headers(), b"no esta\n") try: with open(os.path.join(HUD, nombre), "rb") as f: cuerpo = f.read() except OSError: return Response(404, "Not Found", Headers(), b"no esta\n") tipo = mimetypes.guess_type(nombre)[0] or "application/octet-stream" cabeceras = Headers({ "Content-Type": tipo + ("; charset=utf-8" if "text" in tipo or "javascript" in tipo else ""), "Content-Length": str(len(cuerpo)), # Sin cache: el HUD se edita en caliente y recargar tiene que # traer lo nuevo, no lo de hace media hora. "Cache-Control": "no-store", }) return Response(200, "OK", cabeceras, cuerpo) self._servidor = await websockets.serve( self._cliente, "127.0.0.1", self.puerto, process_request=http) listo.set() # -------------------------------------------------------------- clientes async def _cliente(self, ws): self._clientes.add(ws) self._hubo_cliente = True try: # Al conectar se le manda el estado que ya habia. Sin esto, abrir el # panel a media sesion enseña un HUD vacio hasta que algo cambie. for mensaje in self._ultimo.values(): await ws.send(json.dumps(mensaje)) async for crudo in ws: try: m = json.loads(crudo) except json.JSONDecodeError: continue if self.al_pulsar: # En otro hilo: atender una pulsacion puede tardar (hablar, # cambiar de voz, lanzar una tarea) y el bucle del socket no # puede quedarse esperando o el panel se congela entero. threading.Thread( target=self.al_pulsar, args=(m.get("canal", ""), m.get("dato", "")), daemon=True).start() except Exception: pass finally: self._clientes.discard(ws) # Se cerro una pestaña. Si no queda ninguna, se espera un poco (por # si es una recarga) y, si sigue sin haber panel abierto, se apaga # JARVIS: cerrar la ventana suelta el microfono y la grafica. if self._hubo_cliente and not self._clientes and self.al_cerrar: try: asyncio.get_running_loop().call_later( self.GRACIA, self._quiza_cerrar) except RuntimeError: pass def _quiza_cerrar(self): if not self._clientes and self.al_cerrar: threading.Thread(target=self.al_cerrar, daemon=True).start() # --------------------------------------------------------------- empujar def manda(self, tipo: str, datos): """Empuja un evento al panel. Se puede llamar desde cualquier hilo.""" mensaje = {"tipo": tipo, "datos": datos} # Se recuerda lo ultimo de cada tipo salvo lo que caduca al instante: # el pulso de la voz a 15 por segundo no tiene sentido reenviarlo. if tipo not in ("pulso",): self._ultimo[tipo] = mensaje if not self._bucle or not self._clientes: return crudo = json.dumps(mensaje, ensure_ascii=False) asyncio.run_coroutine_threadsafe(self._reparte(crudo), self._bucle) async def _reparte(self, crudo): for ws in list(self._clientes): try: await ws.send(crudo) except Exception: self._clientes.discard(ws) @property def conectados(self) -> int: return len(self._clientes)