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 +29 -0
- agmen/__main__.py +7 -0
- agmen/_version.py +2 -0
- agmen/cli.py +121 -0
- agmen/panel/__init__.py +5 -0
- agmen/panel/index.html +461 -0
- agmen/panel/server.py +327 -0
- agmen/run.py +442 -0
- agmen/wire.py +122 -0
- agmen-0.2.0.dist-info/METADATA +55 -0
- agmen-0.2.0.dist-info/RECORD +14 -0
- agmen-0.2.0.dist-info/WHEEL +4 -0
- agmen-0.2.0.dist-info/entry_points.txt +2 -0
- agmen-0.2.0.dist-info/licenses/LICENSE +21 -0
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
agmen/_version.py
ADDED
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())
|
agmen/panel/__init__.py
ADDED
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>
|