diff --git a/nucleo/cara/hud/hud.js b/nucleo/cara/hud/hud.js
index 40ce5c4..3185de6 100644
--- a/nucleo/cara/hud/hud.js
+++ b/nucleo/cara/hud/hud.js
@@ -758,6 +758,13 @@
} else if (tipo === "medidas") {
ponMedidas(datos);
+ } else if (tipo === "cerrando") {
+ // JARVIS se esta apagando: cerrar la ventana del panel. En chromium
+ // --app window.close() cierra la ventana de la aplicacion.
+ document.body.classList.add("parado");
+ ponEstado("parado");
+ setTimeout(() => { try { window.close(); } catch (e) {} }, 300);
+
} else if (tipo === "telemetria") {
if (datos.nucleos) ponNucleos(datos.nucleos);
ponConstantes(datos);
@@ -851,6 +858,35 @@
}
});
+ // Cerrar JARVIS del todo. Dos clics para no apagarlo por un roce: el primero
+ // arma ("pulsa otra vez"), el segundo lo manda. Si no se confirma en 3 s,
+ // vuelve a como estaba. Al confirmar, Python contesta "cerrando" y la ventana
+ // se cierra sola.
+ (function () {
+ const b = document.getElementById("cerrar");
+ if (!b) return;
+ const rotulo = b.querySelector(".rotulo");
+ let armado = false, reloj = null;
+ b.addEventListener("click", () => {
+ if (!armado) {
+ armado = true;
+ b.classList.add("armado");
+ rotulo.textContent = "¿seguro?";
+ reloj = setTimeout(() => {
+ armado = false;
+ b.classList.remove("armado");
+ rotulo.textContent = "cerrar";
+ }, 3000);
+ return;
+ }
+ clearTimeout(reloj);
+ rotulo.textContent = "cerrando";
+ try {
+ window.webkit.messageHandlers.accion.postMessage("cerrar");
+ } catch (e) { /* suelto en el navegador: no hay Python que apagar */ }
+ });
+ })();
+
// Lanza la tanda de prueba. No cambia nada en el boton: lo que hay que mirar
// son las tarjetas, que es justo lo que se quiere comprobar.
document.getElementById("prueba").addEventListener("click", (ev) => {
@@ -944,14 +980,5 @@
{ pid: 1201, nombre: "pipewire", cpu: 2.0, mem: 0.3 },
],
}});
-
- window.jarvis.recibe({ tipo: "medidas", datos: {
- n: 240,
- lat_p50: 2.1, lat_p95: 4.8, lat_max: 7.2,
- lat_cerebro: 2.7, lat_catalogo: 0.2,
- pct_cerebro: 44, pct_catalogo: 53, pct_ruido: 3,
- vol24: 87, porhora: [2, 0, 1, 4, 6, 3, 5, 8, 4, 2, 1, 3],
- rag: { hit5: 95, hit1: 82 },
- }});
}
})();
diff --git a/nucleo/cara/hud/index.html b/nucleo/cara/hud/index.html
index 502b874..dc3f5c7 100644
--- a/nucleo/cara/hud/index.html
+++ b/nucleo/cara/hud/index.html
@@ -159,6 +159,13 @@
+
+
+
diff --git a/nucleo/cara/hud/style.css b/nucleo/cara/hud/style.css
index 0b76517..417e512 100644
--- a/nucleo/cara/hud/style.css
+++ b/nucleo/cara/hud/style.css
@@ -659,6 +659,13 @@ h2.separado { margin-top: 26px; }
border-color: color-mix(in srgb, var(--mal) 60%, transparent);
}
+/* El boton de cerrar, armado tras el primer clic: rojo, para que se vea que el
+ segundo clic apaga de verdad. */
+.controles button#cerrar.armado {
+ color: var(--mal, #ff7a6b);
+ border-color: var(--mal, #ff7a6b);
+}
+
.controles button:hover {
color: var(--brillo);
border-color: var(--acento);
diff --git a/nucleo/cara/panel.py b/nucleo/cara/panel.py
index 10b22fd..522f65a 100644
--- a/nucleo/cara/panel.py
+++ b/nucleo/cara/panel.py
@@ -18,6 +18,7 @@ mirando: sin panel abierto son quince mensajes por segundo a nadie.
"""
import os
import shutil
+import signal
import subprocess
import threading
import time
@@ -360,6 +361,20 @@ class Panel:
except Exception:
pass
+ def cerrar(self):
+ """Apaga JARVIS entero: la escucha, la voz, whisper-server y el proceso.
+
+ Cerrar la pestaña o pulsar «cerrar» tiene que soltar de verdad el
+ microfono y la grafica, no dejar el proceso escuchando en segundo plano.
+ Se manda SIGINT al propio proceso: eso dispara el mismo apagado que
+ Ctrl+C —el finally del bucle de escucha para whisper, y atexit lo remata—
+ asi que se reutiliza la limpieza que ya estaba probada, sin duplicarla.
+ Corre desde un hilo del servidor; la señal la atiende el hilo principal.
+ """
+ self.parado = True # que la escucha no atienda nada mas ya
+ self.para() # corta los hilos y las tareas del panel
+ os.kill(os.getpid(), signal.SIGINT)
+
# --------------------------------------------------------------- pulsar
def pulsado(self, canal: str, dato: str):
@@ -417,6 +432,13 @@ class Panel:
self.manda_controles()
self.estado("escuchando" if not self.centinela.activo else "dormido")
+ elif dato == "cerrar":
+ # Apagar JARVIS del todo. Se responde primero al HUD para que la
+ # ventana sepa cerrarse, y luego se mata el proceso.
+ self.cara.manda("cerrando", True)
+ self.cerrar()
+ return
+
elif dato.startswith("parar"):
# El boton alterna PAUSA. No es solo "cortar lo de ahora": el titulo
# promete "cierra el microfono y corta la voz; pulsa otra vez para
diff --git a/nucleo/cara/servidor.py b/nucleo/cara/servidor.py
index c5ab75a..0c626be 100644
--- a/nucleo/cara/servidor.py
+++ b/nucleo/cara/servidor.py
@@ -42,10 +42,17 @@ FICHEROS = {
class Cara:
"""El panel: se le empujan eventos y devuelve lo que se pulsa."""
- def __init__(self, al_pulsar=None, puerto=PUERTO):
+ # 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
@@ -124,6 +131,7 @@ class Cara:
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.
@@ -146,6 +154,19 @@ class Cara:
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
diff --git a/nucleo/jarvis.py b/nucleo/jarvis.py
index 3c99716..dade051 100755
--- a/nucleo/jarvis.py
+++ b/nucleo/jarvis.py
@@ -227,6 +227,7 @@ def panel(boca: Boca, cerebro=None, escuchando=False, modelo="small",
p = Panel(cara, boca, manos, cerebro, candado=not sin_candado,
centinela=centinela)
cara.al_pulsar = p.pulsado
+ cara.al_cerrar = p.cerrar # cerrar la ultima pestaña apaga JARVIS
url = cara.arranca()
if not url:
return 1