agmen 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agmen/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Agmen para Python.
2
+
3
+ Por ahora contiene la implementación de referencia del formato de cable en
4
+ Python puro (`agmen.wire`). Más adelante este paquete se compilará desde el
5
+ núcleo de C++ (nanobind) y `agmen.wire` quedará como referencia y oráculo de pruebas.
6
+ """
7
+
8
+ import sys
9
+
10
+ from . import wire
11
+ from ._version import __version__
12
+
13
+ __all__ = ["wire", "__version__", "ensure_utf8_output"]
14
+
15
+
16
+ def ensure_utf8_output() -> None:
17
+ """Hace que print() nunca truene por acentos, "→" o "µs".
18
+
19
+ En Windows, cuando la salida va a un archivo o a otro programa (no a la
20
+ consola), Python usa la codificación vieja cp1252 y falla con
21
+ UnicodeEncodeError. Llama a esto al inicio de tu nodo.
22
+ """
23
+ for stream in (sys.stdout, sys.stderr):
24
+ if hasattr(stream, "reconfigure"):
25
+ try:
26
+ stream.reconfigure(encoding="utf-8", errors="replace")
27
+ except (ValueError, OSError):
28
+ pass
29
+
agmen/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Permite `python -m agmen run robot.toml` (igual que el comando `agmen`)."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
agmen/_version.py ADDED
@@ -0,0 +1,2 @@
1
+ # Debe coincidir con el archivo VERSION de la raíz (lo verifica scripts/check_version.py).
2
+ __version__ = "0.2.0"
agmen/cli.py ADDED
@@ -0,0 +1,121 @@
1
+ """El comando `agmen`.
2
+
3
+ agmen run [robot.toml] lanza todo el robot + panel web, con un solo comando
4
+ agmen web solo el panel web (sin administrar nodos)
5
+ agmen version muestra la versión
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import signal
12
+ import sys
13
+ import threading
14
+
15
+ from ._version import __version__
16
+
17
+
18
+ def _wait_for_ctrl_c(stop: threading.Event) -> None:
19
+ def handler(signum, frame):
20
+ stop.set()
21
+
22
+ signal.signal(signal.SIGINT, handler)
23
+ signal.signal(signal.SIGTERM, handler)
24
+ if hasattr(signal, "SIGBREAK"): # Windows
25
+ signal.signal(signal.SIGBREAK, handler)
26
+ while not stop.wait(0.3):
27
+ pass
28
+
29
+
30
+ def cmd_run(args) -> int:
31
+ from .panel import Panel, Teleop
32
+ from .run import ConfigError, Launcher, load_config
33
+
34
+ try:
35
+ config = load_config(args.archivo)
36
+ except ConfigError as e:
37
+ print(f"error en {args.archivo}: {e}", file=sys.stderr)
38
+ return 2
39
+
40
+ launcher = Launcher(config)
41
+ launcher.say(f"Agmen {__version__} · robot '{config.name}' · {len(config.nodes)} nodos")
42
+ if not launcher.run_before():
43
+ return 1
44
+
45
+ panel = None
46
+ if config.web.enabled and not args.no_web:
47
+ teleop = Teleop(config.web.teleop.target, config.web.teleop.max_speed) if config.web.teleop else None
48
+ try:
49
+ panel = Panel(args.http or config.web.http, config.web.listen, config.name, launcher, teleop)
50
+ except OSError as e:
51
+ print(f"no se pudo abrir el panel web en {args.http or config.web.http}: {e}. "
52
+ f"¿Ya hay otro 'agmen run' corriendo? Usa --http 127.0.0.1:8081", file=sys.stderr)
53
+ return 1
54
+ panel.start(open_browser=config.web.open_browser and not args.no_browser)
55
+ launcher.say(f"panel web: {panel.url}")
56
+
57
+ launcher.start_all()
58
+ launcher.say("todo arrancado. Ctrl+C para detener todo.")
59
+ stop = threading.Event()
60
+ try:
61
+ _wait_for_ctrl_c(stop)
62
+ finally:
63
+ launcher.stop_all()
64
+ if panel:
65
+ panel.shutdown()
66
+ return 0
67
+
68
+
69
+ def cmd_web(args) -> int:
70
+ from .panel import Panel, Teleop
71
+
72
+ teleop = Teleop(args.teleop, 1.0) if args.teleop else None
73
+ try:
74
+ panel = Panel(args.http, args.listen, "", None, teleop)
75
+ except OSError as e:
76
+ print(f"no se pudo abrir el panel en {args.http}: {e}", file=sys.stderr)
77
+ return 1
78
+ panel.start(open_browser=not args.no_browser)
79
+ print(f"[agmen web] recibiendo Agmen en udp://{args.listen}")
80
+ print(f"[agmen web] abre {panel.url} en tu navegador")
81
+ if args.http.startswith("0.0.0.0"):
82
+ print("[agmen web] también accesible desde otros equipos de tu red (sin contraseña)")
83
+ stop = threading.Event()
84
+ try:
85
+ _wait_for_ctrl_c(stop)
86
+ finally:
87
+ panel.shutdown()
88
+ return 0
89
+
90
+
91
+ def main(argv: list[str] | None = None) -> int:
92
+ ap = argparse.ArgumentParser(prog="agmen", description="Agmen: el sistema nervioso para robótica, IA e IoT")
93
+ ap.add_argument("--version", action="version", version=f"agmen {__version__}")
94
+ sub = ap.add_subparsers(dest="cmd", required=True, metavar="comando")
95
+
96
+ p = sub.add_parser("run", help="lanza todos los nodos de robot.toml + el panel web")
97
+ p.add_argument("archivo", nargs="?", default="robot.toml", help="archivo del robot (default: robot.toml)")
98
+ p.add_argument("--no-web", action="store_true", help="no abrir el panel web")
99
+ p.add_argument("--no-browser", action="store_true", help="no abrir el navegador automáticamente")
100
+ p.add_argument("--http", help="dónde servir el panel (ej. 0.0.0.0:8080 para verlo desde el celular)")
101
+ p.set_defaults(func=cmd_run)
102
+
103
+ p = sub.add_parser("web", help="solo el panel web (sin administrar nodos)")
104
+ p.add_argument("--listen", default="127.0.0.1:7400", help="dónde recibir mensajes Agmen (UDP)")
105
+ p.add_argument("--http", default="127.0.0.1:8080", help="dónde servir el panel")
106
+ p.add_argument("--teleop", metavar="IP:PUERTO", help="habilita manejar el robot: destino de MotorCommand")
107
+ p.add_argument("--no-browser", action="store_true", help="no abrir el navegador automáticamente")
108
+ p.set_defaults(func=cmd_web)
109
+
110
+ p = sub.add_parser("version", help="muestra la versión")
111
+ p.set_defaults(func=lambda a: print(f"agmen {__version__}") or 0)
112
+
113
+ from . import ensure_utf8_output
114
+
115
+ ensure_utf8_output()
116
+ args = ap.parse_args(argv)
117
+ return args.func(args)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main())
@@ -0,0 +1,5 @@
1
+ """Panel web de Agmen: el navegador dibuja, el robot solo publica."""
2
+
3
+ from .server import MotorCommand, Panel, Teleop
4
+
5
+ __all__ = ["Panel", "Teleop", "MotorCommand"]
agmen/panel/index.html ADDED
@@ -0,0 +1,461 @@
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Agmen · panel</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0e1115; --panel: #161a20; --panel2: #1c2129; --line: #272e38; --text: #e6e9ee; --muted: #8b95a3;
10
+ --x: #ef5a5a; --y: #4cc38a; --z: #4f8ff7; --ok: #4cc38a; --warn: #e9b949; --bad: #ef5a5a; --accent: #4f8ff7;
11
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
12
+ }
13
+ @media (prefers-color-scheme: light) {
14
+ :root { --bg: #f4f5f7; --panel: #ffffff; --panel2: #f6f7f9; --line: #e2e5ea; --text: #1b1f24; --muted: #66707d; }
15
+ }
16
+ * { box-sizing: border-box; }
17
+ body { margin: 0; background: var(--bg); color: var(--text);
18
+ font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
19
+ header { display: flex; align-items: center; gap: 12px; padding: 12px 20px; border-bottom: 1px solid var(--line);
20
+ position: sticky; top: 0; background: var(--bg); z-index: 5; }
21
+ .logo { font-weight: 700; font-size: 16px; letter-spacing: .02em; }
22
+ .logo .robot { font-weight: 500; }
23
+ .logo span.dim { color: var(--muted); font-weight: 400; }
24
+ .pill { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px;
25
+ border: 1px solid var(--line); border-radius: 999px; font-size: 12px; color: var(--muted); white-space: nowrap; }
26
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
27
+ .dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.bad { background: var(--bad); }
28
+ main { padding: 16px 20px 28px; display: grid; gap: 16px; max-width: 1400px; margin: 0 auto;
29
+ grid-template-columns: 360px minmax(0, 1fr); grid-template-areas: "side data"; }
30
+ @media (max-width: 1000px) { main { grid-template-columns: 1fr; grid-template-areas: "side" "data"; padding: 12px; } }
31
+ .side { grid-area: side; display: grid; gap: 16px; align-content: start; }
32
+ .data { grid-area: data; display: grid; gap: 16px; align-content: start; min-width: 0; }
33
+ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px; min-width: 0; }
34
+ .panel h2 { margin: 0 0 10px; font-size: 13px; font-weight: 650; display: flex; gap: 10px; align-items: center; }
35
+ .panel h2 .unit, .panel h2 .hint { color: var(--muted); font-weight: 400; }
36
+ .panel h2 .right { margin-left: auto; display: flex; gap: 10px; align-items: center; font-weight: 400; color: var(--muted); font-size: 12px; }
37
+ .note { color: var(--muted); font-size: 12px; }
38
+ code { font-family: var(--mono); font-size: 12px; }
39
+ button { font: inherit; color: var(--text); background: var(--panel2); border: 1px solid var(--line); border-radius: 8px;
40
+ padding: 4px 9px; cursor: pointer; }
41
+ button:hover { border-color: var(--muted); }
42
+ button:disabled { opacity: .35; cursor: default; }
43
+
44
+ /* nodos */
45
+ .node { display: grid; grid-template-columns: auto 1fr auto; gap: 4px 10px; align-items: center; padding: 8px 6px;
46
+ border-radius: 8px; cursor: pointer; }
47
+ .node + .node { border-top: 1px solid var(--line); }
48
+ .node:hover, .node.sel { background: var(--panel2); }
49
+ .node .name { font-weight: 600; font-family: var(--mono); }
50
+ .node .meta { grid-column: 2; color: var(--muted); font-size: 12px; }
51
+ .node .acts { grid-row: 1 / span 2; grid-column: 3; display: flex; gap: 6px; }
52
+ .node .acts button { width: 32px; padding: 4px 0; }
53
+ .tag { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
54
+
55
+ /* manejar */
56
+ .drive { display: grid; grid-template-columns: auto 1fr; gap: 14px; align-items: center; }
57
+ .pad { display: grid; grid-template-columns: repeat(3, 54px); grid-template-rows: repeat(3, 54px); gap: 6px;
58
+ touch-action: none; user-select: none; -webkit-user-select: none; }
59
+ .pad button { font-size: 20px; border-radius: 10px; padding: 0; }
60
+ .pad button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
61
+ .pad .stop { color: #fff; background: var(--bad); border-color: var(--bad); font-size: 12px; font-weight: 700; }
62
+ .pad .up { grid-area: 1 / 2; } .pad .left { grid-area: 2 / 1; } .pad .stop { grid-area: 2 / 2; }
63
+ .pad .right { grid-area: 2 / 3; } .pad .down { grid-area: 3 / 2; }
64
+ .wheels { display: flex; gap: 14px; align-items: flex-end; height: 120px; }
65
+ .wheel { display: grid; gap: 4px; justify-items: center; font-size: 11px; color: var(--muted); }
66
+ .bar { width: 22px; height: 96px; background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; position: relative; overflow: hidden; }
67
+ .bar i { position: absolute; left: 0; right: 0; background: var(--accent); }
68
+ .bar::after { content: ""; position: absolute; left: 0; right: 0; top: 50%; border-top: 1px solid var(--line); }
69
+ .speed { display: flex; gap: 8px; align-items: center; margin-top: 10px; font-size: 12px; color: var(--muted); }
70
+ .speed input { flex: 1; }
71
+ .drive-state { margin-top: 8px; font-size: 12px; }
72
+ .drive-state.on { color: var(--accent); font-weight: 600; }
73
+
74
+ /* sensores */
75
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; }
76
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; min-width: 0; }
77
+ .card .k { color: var(--muted); font-size: 12px; }
78
+ .card .v { font: 600 20px/1.3 var(--mono); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
79
+ .card .v.small { font-size: 13px; padding-top: 5px; }
80
+ .charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
81
+ @media (max-width: 1250px) { .charts { grid-template-columns: 1fr; } }
82
+ .legend { display: flex; gap: 10px; }
83
+ .legend i { display: inline-block; width: 10px; height: 3px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
84
+ canvas { width: 100%; height: 190px; display: block; }
85
+ .banner { display: none; border-radius: 10px; padding: 10px 12px; font: 12px/1.5 var(--mono); }
86
+ .banner.warn { border: 1px solid color-mix(in srgb, var(--warn) 50%, var(--line)); color: var(--warn); }
87
+ .banner.error { border: 1px solid color-mix(in srgb, var(--bad) 50%, var(--line)); color: var(--bad); }
88
+ .empty { color: var(--muted); font-size: 12px; padding: 4px 0 0; }
89
+
90
+ /* logs */
91
+ .chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
92
+ .chip { border-radius: 999px; padding: 3px 10px; font-size: 12px; }
93
+ .chip.on { border-color: var(--text); }
94
+ .loglist { height: 320px; overflow: auto; background: var(--panel2); border: 1px solid var(--line); border-radius: 8px;
95
+ padding: 8px 10px; font: 12px/1.55 var(--mono); }
96
+ .ln { white-space: pre-wrap; word-break: break-word; }
97
+ .ln .ts { color: var(--muted); }
98
+ .ln .who { font-weight: 600; }
99
+ .ln.k-agmen .txt { color: var(--muted); }
100
+ .ln.k-error .txt, .ln.err .txt { color: var(--bad); }
101
+ .ln.wrn .txt { color: var(--warn); }
102
+ .toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--bad); color: #fff;
103
+ padding: 8px 14px; border-radius: 8px; font-size: 13px; display: none; z-index: 10; }
104
+ </style>
105
+ </head>
106
+ <body>
107
+ <header>
108
+ <div class="logo">Agmen <span class="robot" id="robot"></span> <span class="dim" id="ver"></span></div>
109
+ <div class="pill"><span class="dot" id="dot"></span><span id="status">conectando…</span></div>
110
+ </header>
111
+
112
+ <main>
113
+ <div class="side">
114
+ <section class="panel">
115
+ <h2>Nodos <span class="right" id="nodesum"></span></h2>
116
+ <div id="nodes"><div class="empty">cargando…</div></div>
117
+ </section>
118
+
119
+ <section class="panel" id="drivepanel">
120
+ <h2>Manejar <span class="hint" id="drivetarget"></span></h2>
121
+ <div id="drive"></div>
122
+ </section>
123
+ </div>
124
+
125
+ <div class="data">
126
+ <section class="cards">
127
+ <div class="card"><div class="k">Sensor IMU</div><div class="v" id="rate">—</div></div>
128
+ <div class="card"><div class="k">Latencia</div><div class="v" id="lat">—</div></div>
129
+ <div class="card"><div class="k">Perdidos</div><div class="v" id="lost">—</div></div>
130
+ <div class="card"><div class="k">Errores</div><div class="v" id="errors">—</div></div>
131
+ <div class="card"><div class="k">Origen</div><div class="v small" id="source">—</div></div>
132
+ </section>
133
+ <div class="banner warn" id="warnbox"></div>
134
+ <div class="banner error" id="errbox"></div>
135
+ <section class="charts">
136
+ <div class="panel">
137
+ <h2>Acelerómetro <span class="unit">m/s²</span>
138
+ <span class="right legend"><span><i style="background:var(--x)"></i>x</span><span><i style="background:var(--y)"></i>y</span><span><i style="background:var(--z)"></i>z</span></span></h2>
139
+ <canvas id="accel"></canvas>
140
+ </div>
141
+ <div class="panel">
142
+ <h2>Giroscopio <span class="unit">rad/s</span>
143
+ <span class="right legend"><span><i style="background:var(--x)"></i>x</span><span><i style="background:var(--y)"></i>y</span><span><i style="background:var(--z)"></i>z</span></span></h2>
144
+ <canvas id="gyro"></canvas>
145
+ </div>
146
+ </section>
147
+
148
+ <section class="panel logs">
149
+ <h2>Logs <span class="hint">de todos los nodos</span>
150
+ <span class="right">
151
+ <label><input type="checkbox" id="follow" checked> seguir</label>
152
+ <button id="clear">limpiar</button>
153
+ </span>
154
+ </h2>
155
+ <div class="chips" id="chips"></div>
156
+ <div class="loglist" id="loglist"></div>
157
+ </section>
158
+ </div>
159
+ </main>
160
+ <div class="toast" id="toast"></div>
161
+
162
+ <script>
163
+ "use strict";
164
+ const $ = (id) => document.getElementById(id);
165
+ const css = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
166
+ const PALETTE = ["#4f8ff7", "#c678dd", "#e9b949", "#4cc38a", "#56b6c2", "#ef8a5a", "#e06c9f", "#9aa5ce"];
167
+ const nodeColor = {};
168
+ let state = { nodes: [], teleop: null, managed: false };
169
+ let filter = null; // nodo seleccionado en los logs (null = todos)
170
+
171
+ // ---------- utilidades ----------
172
+ function toast(msg) {
173
+ const t = $("toast"); t.textContent = msg; t.style.display = "block";
174
+ clearTimeout(toast.h); toast.h = setTimeout(() => (t.style.display = "none"), 3500);
175
+ }
176
+ async function post(url, body) {
177
+ try {
178
+ const r = await fetch(url, { method: "POST", headers: { "X-Agmen": "1", "Content-Type": "application/json" },
179
+ body: JSON.stringify(body || {}) });
180
+ const j = await r.json();
181
+ if (!r.ok) toast(j.error || "error");
182
+ return j;
183
+ } catch (e) { toast("sin conexión con el panel"); }
184
+ }
185
+ function fmtUptime(s) {
186
+ if (s == null) return "";
187
+ s = Math.floor(s);
188
+ if (s < 60) return s + " s";
189
+ if (s < 3600) return Math.floor(s / 60) + " min " + (s % 60) + " s";
190
+ return Math.floor(s / 3600) + " h " + Math.floor((s % 3600) / 60) + " min";
191
+ }
192
+ function colorOf(name) {
193
+ if (name === "agmen") return css("--muted");
194
+ if (!(name in nodeColor)) nodeColor[name] = PALETTE[Object.keys(nodeColor).length % PALETTE.length];
195
+ return nodeColor[name];
196
+ }
197
+
198
+ // ---------- nodos ----------
199
+ const STATE_DOT = { "corriendo": "ok", "reiniciando": "warn", "falló": "bad", "detenido": "", "terminó": "" };
200
+ function renderNodes() {
201
+ const box = $("nodes");
202
+ if (!state.managed) {
203
+ box.innerHTML = '<div class="empty">Panel abierto con <code>agmen web</code>. Para ver y controlar los nodos, ' +
204
+ 'lanza tu robot con <code>agmen run robot.toml</code>.</div>';
205
+ $("nodesum").textContent = "";
206
+ return;
207
+ }
208
+ const running = state.nodes.filter((n) => n.state === "corriendo").length;
209
+ $("nodesum").textContent = `${running}/${state.nodes.length} corriendo`;
210
+ box.innerHTML = "";
211
+ for (const n of state.nodes) {
212
+ const row = document.createElement("div");
213
+ row.className = "node" + (filter === n.name ? " sel" : "");
214
+ row.title = n.cmd;
215
+ const alive = n.state === "corriendo" || n.state === "reiniciando";
216
+ let meta = n.state;
217
+ if (n.state === "corriendo") meta += ` · ${fmtUptime(n.uptime)} · pid ${n.pid}`;
218
+ if (n.exit_code != null && (n.state === "falló" || n.state === "terminó")) meta += ` (código ${n.exit_code})`;
219
+ if (n.restarts) meta += ` · reinicios ${n.restarts}`;
220
+ row.innerHTML = `<span class="dot ${STATE_DOT[n.state] || ""}"></span>
221
+ <span class="name"><span class="tag" style="background:${colorOf(n.name)}"></span>${n.name}</span>
222
+ <span class="acts"></span><span class="meta"></span>`;
223
+ row.querySelector(".meta").textContent = meta;
224
+ const acts = row.querySelector(".acts");
225
+ const mk = (label, title, action, enabled) => {
226
+ const b = document.createElement("button");
227
+ b.textContent = label; b.title = title; b.disabled = !enabled;
228
+ b.onclick = (ev) => { ev.stopPropagation(); post(`/api/node/${encodeURIComponent(n.name)}/${action}`); };
229
+ acts.appendChild(b);
230
+ };
231
+ mk("↻", "reiniciar", "restart", alive);
232
+ mk(alive ? "■" : "▶", alive ? "detener" : "iniciar", alive ? "stop" : "start", true);
233
+ row.onclick = () => { filter = filter === n.name ? null : n.name; renderChips(); rerenderLogs(); renderNodes(); };
234
+ box.appendChild(row);
235
+ }
236
+ }
237
+
238
+ // ---------- manejar (teleoperación) ----------
239
+ const keys = new Set(); // direcciones activas: up/down/left/right
240
+ let speed = 0.5, driveTimer = null, lastCmd = [0, 0];
241
+ function renderDrive() {
242
+ const box = $("drive");
243
+ if (!state.teleop) {
244
+ $("drivetarget").textContent = "";
245
+ box.innerHTML = state.managed
246
+ ? '<div class="empty">Para manejar el robot desde aquí agrega a tu <code>robot.toml</code>:<br>' +
247
+ '<code>[web.teleop]<br>target = "127.0.0.1:7500"</code></div>'
248
+ : '<div class="empty">Para manejar el robot desde aquí abre el panel con<br>' +
249
+ '<code>agmen web --teleop 127.0.0.1:7500</code></div>';
250
+ return;
251
+ }
252
+ if (box.dataset.ready) return;
253
+ box.dataset.ready = "1";
254
+ $("drivetarget").textContent = "→ " + state.teleop.target;
255
+ box.innerHTML = `
256
+ <div class="drive">
257
+ <div class="pad" id="pad">
258
+ <button class="up" data-d="up" aria-label="adelante">↑</button>
259
+ <button class="left" data-d="left" aria-label="izquierda">←</button>
260
+ <button class="stop" id="stopbtn" aria-label="alto">ALTO</button>
261
+ <button class="right" data-d="right" aria-label="derecha">→</button>
262
+ <button class="down" data-d="down" aria-label="atrás">↓</button>
263
+ </div>
264
+ <div class="wheels">
265
+ <div class="wheel"><div class="bar"><i id="barL"></i></div>izq <span id="valL">0.00</span></div>
266
+ <div class="wheel"><div class="bar"><i id="barR"></i></div>der <span id="valR">0.00</span></div>
267
+ </div>
268
+ </div>
269
+ <div class="speed">velocidad <input type="range" id="spd" min="10" max="100" value="50"> <span id="spdv">50%</span></div>
270
+ <div class="drive-state" id="dstate">Mantén presionado: <b>W A S D</b> o flechas (o los botones). Suelta = el robot frena.</div>
271
+ <div class="note" style="margin-top:6px">Si otro nodo también manda comandos (ej. <code>cerebro</code>), detenlo primero con ■.</div>`;
272
+ for (const b of box.querySelectorAll("[data-d]")) {
273
+ const d = b.dataset.d;
274
+ b.addEventListener("pointerdown", (e) => { e.preventDefault(); b.setPointerCapture(e.pointerId); press(d, true); });
275
+ for (const ev of ["pointerup", "pointercancel", "lostpointercapture"]) b.addEventListener(ev, () => press(d, false));
276
+ }
277
+ $("stopbtn").addEventListener("pointerdown", (e) => { e.preventDefault(); allStop(); });
278
+ $("spd").addEventListener("input", (e) => { speed = e.target.value / 100; $("spdv").textContent = e.target.value + "%"; });
279
+ }
280
+ function command() {
281
+ const s = speed * (state.teleop ? state.teleop.max_speed : 1);
282
+ const f = (keys.has("up") ? 1 : 0) - (keys.has("down") ? 1 : 0); // adelante/atrás
283
+ const t = (keys.has("right") ? 1 : 0) - (keys.has("left") ? 1 : 0); // derecha/izquierda
284
+ if (f === 0) return [t * s * 0.6, -t * s * 0.6]; // girar en su lugar
285
+ const clamp = (v) => Math.max(-s, Math.min(s, v));
286
+ return [clamp(f * s + 0.5 * s * t), clamp(f * s - 0.5 * s * t)]; // avanzar y doblar
287
+ }
288
+ function showCmd([l, r], active) {
289
+ for (const [id, v] of [["L", l], ["R", r]]) {
290
+ const bar = $("bar" + id); if (!bar) return;
291
+ const h = Math.abs(v) * 50;
292
+ bar.style.height = h + "%"; bar.style.top = v >= 0 ? (50 - h) + "%" : "50%";
293
+ $("val" + id).textContent = (v >= 0 ? "+" : "") + v.toFixed(2);
294
+ }
295
+ const st = $("dstate");
296
+ st.className = "drive-state" + (active ? " on" : "");
297
+ st.innerHTML = active ? "● Control manual ACTIVO"
298
+ : "Mantén presionado: <b>W A S D</b> o flechas (o los botones). Suelta = el robot frena.";
299
+ for (const b of document.querySelectorAll("#pad [data-d]")) b.classList.toggle("on", keys.has(b.dataset.d));
300
+ }
301
+ function press(dir, down) {
302
+ if (!state.teleop) return;
303
+ if (down) keys.add(dir); else keys.delete(dir);
304
+ if (keys.size && !driveTimer) {
305
+ tick(); driveTimer = setInterval(tick, 50); // 20 Hz mientras se presiona
306
+ } else if (!keys.size) {
307
+ allStop();
308
+ } else { tick(); }
309
+ }
310
+ function tick() { lastCmd = command(); post("/api/teleop", { left: lastCmd[0], right: lastCmd[1] }); showCmd(lastCmd, true); }
311
+ function allStop() {
312
+ keys.clear();
313
+ if (driveTimer) { clearInterval(driveTimer); driveTimer = null; }
314
+ if (state.teleop) post("/api/teleop", { left: 0, right: 0 });
315
+ showCmd([0, 0], false);
316
+ }
317
+ const KEYMAP = { w: "up", arrowup: "up", s: "down", arrowdown: "down", a: "left", arrowleft: "left", d: "right", arrowright: "right" };
318
+ addEventListener("keydown", (e) => {
319
+ if (e.target.tagName === "INPUT" && e.target.type !== "range") return;
320
+ const k = e.key.toLowerCase();
321
+ if (k === " ") { e.preventDefault(); allStop(); return; }
322
+ if (KEYMAP[k] && !e.repeat) { e.preventDefault(); press(KEYMAP[k], true); }
323
+ else if (KEYMAP[k]) e.preventDefault();
324
+ });
325
+ addEventListener("keyup", (e) => { const k = KEYMAP[e.key.toLowerCase()]; if (k) press(k, false); });
326
+ addEventListener("blur", allStop); // si cambias de ventana, se frena
327
+ document.addEventListener("visibilitychange", () => { if (document.hidden) allStop(); });
328
+
329
+ // ---------- sensores ----------
330
+ const WINDOW_S = 10, buf = [], lastSeen = {};
331
+ let followSrc = null, lastData = 0;
332
+ function updateStats(s) {
333
+ $("rate").textContent = s.rate_hz ? s.rate_hz.toFixed(0) + " Hz" : "0 Hz";
334
+ $("lat").textContent = s.latency_ms == null ? "—" : s.latency_ms.toFixed(2) + " ms";
335
+ $("lost").textContent = s.lost; $("errors").textContent = s.errors;
336
+ $("source").textContent = s.type ? `${s.type} · ${s.source}` : `esperando en ${s.listening}`;
337
+ const many = (s.sources || []).length > 1;
338
+ $("warnbox").style.display = many ? "block" : "none";
339
+ $("warnbox").textContent = many ? `⚠ ${s.sources.length} emisores mandan al mismo puerto (${s.sources.join(", ")}). ` +
340
+ `Las gráficas muestran solo ${followSrc}. ¿Hay dos publishers corriendo?` : "";
341
+ $("errbox").style.display = s.last_error ? "block" : "none";
342
+ $("errbox").textContent = s.last_error ? "Último error: " + s.last_error : "";
343
+ }
344
+ function draw(canvas, key) {
345
+ const dpr = devicePixelRatio || 1, w = canvas.clientWidth, h = canvas.clientHeight;
346
+ if (canvas.width !== w * dpr || canvas.height !== h * dpr) { canvas.width = w * dpr; canvas.height = h * dpr; }
347
+ const ctx = canvas.getContext("2d");
348
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, w, h);
349
+ const padL = 48, padR = 8, padT = 8, padB = 22, pw = w - padL - padR, ph = h - padT - padB;
350
+ if (!buf.length) {
351
+ ctx.fillStyle = css("--muted"); ctx.font = "12px system-ui"; ctx.textAlign = "center";
352
+ ctx.fillText("sin datos de sensores todavía", w / 2, h / 2); return;
353
+ }
354
+ const tEnd = buf[buf.length - 1].t, tStart = tEnd - WINDOW_S;
355
+ let lo = Infinity, hi = -Infinity;
356
+ for (const s of buf) for (const v of s[key]) { if (v < lo) lo = v; if (v > hi) hi = v; }
357
+ const span = Math.max(hi - lo, 0.5), mid = (hi + lo) / 2; lo = mid - span * 0.6; hi = mid + span * 0.6;
358
+ const X = (t) => padL + ((t - tStart) / WINDOW_S) * pw, Y = (v) => padT + (1 - (v - lo) / (hi - lo)) * ph;
359
+ ctx.strokeStyle = css("--line"); ctx.fillStyle = css("--muted"); ctx.lineWidth = 1;
360
+ ctx.font = "11px " + css("--mono"); ctx.textAlign = "right"; ctx.textBaseline = "middle";
361
+ for (let i = 0; i <= 4; i++) { const v = lo + (hi - lo) * i / 4, y = Y(v);
362
+ ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.stroke(); ctx.fillText(v.toFixed(2), padL - 6, y); }
363
+ ctx.textBaseline = "top";
364
+ for (let s = 0; s <= WINDOW_S; s += 2) {
365
+ ctx.textAlign = s === 0 ? "left" : s === WINDOW_S ? "right" : "center";
366
+ ctx.fillText(s === WINDOW_S ? "ahora" : `-${WINDOW_S - s}s`, X(tStart + s), h - padB + 6);
367
+ }
368
+ ["--x", "--y", "--z"].forEach((c, axis) => {
369
+ ctx.strokeStyle = css(c); ctx.lineWidth = 1.6; ctx.beginPath(); let started = false;
370
+ for (const s of buf) { if (s.t < tStart) continue; const px = X(s.t), py = Y(s[key][axis]);
371
+ if (!started) { ctx.moveTo(px, py); started = true; } else ctx.lineTo(px, py); }
372
+ ctx.stroke();
373
+ });
374
+ }
375
+ function addSamples(samples) {
376
+ if (!samples.length) return;
377
+ const now = performance.now();
378
+ for (const s of samples) lastSeen[s.src] = now;
379
+ if (followSrc === null || now - (lastSeen[followSrc] || 0) > 500) { followSrc = samples[samples.length - 1].src; buf.length = 0; }
380
+ for (const s of samples) if (s.src === followSrc) buf.push(s);
381
+ const tEnd = buf.length ? buf[buf.length - 1].t : 0;
382
+ while (buf.length && buf[0].t < tEnd - WINDOW_S - 1) buf.shift();
383
+ lastData = now;
384
+ }
385
+
386
+ // ---------- logs ----------
387
+ const logLines = []; // todas las líneas recibidas (máx 3000)
388
+ function renderChips() {
389
+ const box = $("chips"); box.innerHTML = "";
390
+ const names = ["todos", ...state.nodes.map((n) => n.name), "agmen"];
391
+ for (const name of names) {
392
+ const b = document.createElement("button");
393
+ const isAll = name === "todos";
394
+ b.className = "chip" + ((isAll && filter === null) || filter === name ? " on" : "");
395
+ b.innerHTML = isAll ? "todos" : `<span class="tag" style="background:${colorOf(name)}"></span>${name}`;
396
+ b.onclick = () => { filter = isAll ? null : name; renderChips(); rerenderLogs(); renderNodes(); };
397
+ box.appendChild(b);
398
+ }
399
+ }
400
+ function lineEl(l) {
401
+ const d = document.createElement("div");
402
+ const err = /ERROR|✖|falló/.test(l.text), wrn = /⛔|⚠|ALTO|reiniciando/.test(l.text);
403
+ d.className = `ln k-${l.kind}` + (err ? " err" : wrn ? " wrn" : "");
404
+ const ts = new Date(l.t * 1000).toLocaleTimeString("es-MX", { hour12: false });
405
+ d.innerHTML = `<span class="ts">${ts}</span> <span class="who"></span> <span class="txt"></span>`;
406
+ const who = d.querySelector(".who"); who.textContent = l.node; who.style.color = colorOf(l.node);
407
+ d.querySelector(".txt").textContent = l.text;
408
+ return d;
409
+ }
410
+ function appendLogs(lines) {
411
+ if (!lines.length) return;
412
+ const list = $("loglist");
413
+ for (const l of lines) {
414
+ logLines.push(l);
415
+ if (filter === null || filter === l.node) list.appendChild(lineEl(l));
416
+ }
417
+ if (logLines.length > 3000) { logLines.splice(0, logLines.length - 3000); }
418
+ while (list.childElementCount > 1500) list.firstElementChild.remove();
419
+ if ($("follow").checked) list.scrollTop = list.scrollHeight;
420
+ }
421
+ function rerenderLogs() {
422
+ const list = $("loglist"); list.innerHTML = "";
423
+ const shown = logLines.filter((l) => filter === null || filter === l.node).slice(-1500);
424
+ const frag = document.createDocumentFragment(); for (const l of shown) frag.appendChild(lineEl(l));
425
+ list.appendChild(frag); list.scrollTop = list.scrollHeight;
426
+ }
427
+ $("clear").onclick = () => { logLines.length = 0; $("loglist").innerHTML = ""; };
428
+
429
+ // ---------- conexión ----------
430
+ let lastNodesKey = "", connected = false;
431
+ function setStatus(kind, text) { $("dot").className = "dot " + kind; $("status").textContent = text; }
432
+ function connect() {
433
+ const es = new EventSource("/api/stream");
434
+ es.onopen = () => { connected = true; };
435
+ es.onerror = () => { connected = false; setStatus("bad", "sin conexión con Agmen · reintentando…"); allStop(); };
436
+ es.onmessage = (ev) => {
437
+ const m = JSON.parse(ev.data);
438
+ $("robot").textContent = m.robot ? "· " + m.robot : "";
439
+ $("ver").textContent = "v" + m.version;
440
+ document.title = `Agmen · ${m.robot || "panel"}`;
441
+ const prevTeleop = !!state.teleop;
442
+ state = { nodes: m.nodes, teleop: m.teleop, managed: m.managed };
443
+ document.querySelector(".logs").style.display = m.managed ? "" : "none"; // agmen web: no hay nodos que mostrar
444
+ const key = JSON.stringify(m.nodes.map((n) => [n.name, n.state, n.restarts, n.pid, Math.floor((n.uptime || 0))]));
445
+ if (key !== lastNodesKey) {
446
+ const namesChanged = lastNodesKey === "" || JSON.stringify(m.nodes.map((n) => n.name)) !== JSON.stringify(JSON.parse(lastNodesKey || "[]").map((x) => x[0]));
447
+ lastNodesKey = key; renderNodes(); if (namesChanged) renderChips();
448
+ }
449
+ if (!!state.teleop !== prevTeleop || !$("drive").dataset.ready) renderDrive();
450
+ addSamples(m.samples); updateStats(m.stats); appendLogs(m.logs);
451
+ const bad = m.nodes.filter((n) => n.state === "falló").length;
452
+ if (bad) setStatus("bad", `${bad} nodo${bad > 1 ? "s" : ""} con falla`);
453
+ else if (lastData && performance.now() - lastData < 1500) setStatus("ok", "conectado · recibiendo datos");
454
+ else setStatus("warn", "conectado · sin datos de sensores");
455
+ };
456
+ }
457
+ function frame() { draw($("accel"), "accel"); draw($("gyro"), "gyro"); requestAnimationFrame(frame); }
458
+ renderChips(); connect(); requestAnimationFrame(frame);
459
+ </script>
460
+ </body>
461
+ </html>