2603 lines
126 KiB
Python
2603 lines
126 KiB
Python
#!/usr/bin/env python3
|
|
"""Pudu Map GUI — convert a Pudu export, preview it live, stage/deploy to a robot, open RViz.
|
|
|
|
python3 pudu_gui.py # opens http://127.0.0.1:8777 in the browser
|
|
|
|
Pipeline (all local, per user click):
|
|
1. CONVERT — path to a Pudu export (folder with map.png+map.json, a .zip, or a
|
|
folder of zips → newest wins). Produces map.pgm/.yaml, annotations,
|
|
keepout mask + a keepout-BAKED map when forbidden zones exist
|
|
(all our robot stacks are Nav2 Foxy = no KeepoutFilter).
|
|
2. PREVIEW — the converted grid + keepout + route graph + poses, rendered live
|
|
in the browser (pan/zoom, world-meter readout).
|
|
3. STAGE — copy into the chosen robot's workstation-canonical maps dir
|
|
(existing maps dir is backed up first, never silently replaced).
|
|
4. DEPLOY — scp to the robot over the eth/wifi IP you specify (BatchMode ssh).
|
|
5. RVIZ — launch the workstation RViz viewer for that robot (R1 = relay+RViz
|
|
exactly like start_r1_nav.sh; G1/Go2 = plain RViz on the local ROS).
|
|
|
|
Requires: pillow numpy pyyaml (same as the original converter). Server binds
|
|
127.0.0.1 by default; set PUDU_BIND (an address, or 0.0.0.0) to expose it on the
|
|
LAN — it has NO auth and can drive the robots, so only on a trusted network. Conversion logic is vendored from pudu_to_ros2_map.py (verified
|
|
against real exports: origin = bottom-left meters, PNG row 0 = top, 0/128/255).
|
|
"""
|
|
import base64
|
|
import io
|
|
import json
|
|
import hmac
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import webbrowser
|
|
import zipfile
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
import numpy as np
|
|
import yaml
|
|
from PIL import Image, ImageDraw
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
WS = os.path.abspath(os.path.join(HERE, "..", "..", "..")) # yslootahtech root
|
|
PORT = 8777
|
|
|
|
ROBOTS = {
|
|
"g1": {
|
|
"label": "G1 (nav_g1 · MID-360 + AMCL)",
|
|
"stage_dir": os.path.join(WS, "Project/G1/Nav2_Projects/sanad_nav3/maps/pudu"),
|
|
"deploy_dir": "/home/unitree/nav_g1/maps/pudu",
|
|
# NO auto-filled IP — 'eth' is a SUGGESTION chip (click-to-use), never applied
|
|
"presets": {"eth": "192.168.123.164", "wifi": ""},
|
|
# nav_g1 runs its OWN rosbridge on :9091 (:9090 on that Jetson belongs to
|
|
# an unrelated pre-existing system — never used, never touched).
|
|
"rosbridge_port": 9091,
|
|
"note": "Dockerized ~/nav_g1 stack, image g1-nav2 (livox driver + ICP odom + "
|
|
"AMCL, rosbridge :9091). Typical eth "
|
|
"192.168.123.164, wifi DHCP — type yours. STANDING HOLD: no deploys "
|
|
"until lifted. Run: STATIC_MAP=1 bringup.sh.",
|
|
},
|
|
"go2": {
|
|
"label": "Go2 (go2-nav2 container · L1 + AMCL)",
|
|
"stage_dir": os.path.join(WS, "Project/GO2/nav/nav2/maps"),
|
|
"deploy_dir": "/home/unitree/nav_go2/maps",
|
|
# NO auto-filled IP — 'eth' is a SUGGESTION chip (click-to-use), never applied
|
|
# (EDU expansion computer; .161 = control board)
|
|
"presets": {"eth": "192.168.123.18", "wifi": ""},
|
|
"note": "Verified live 2026-07-16. Loop: run.sh slam → walk → save-map <n> → "
|
|
"use-map <n> → nav → initialpose → goal. RViz live works in both modes.",
|
|
"rosbridge_port": 9090,
|
|
},
|
|
"r1": {
|
|
"label": "R1 (r1-nav2 container · VSLAM alignment)",
|
|
"stage_dir": os.path.join(WS, "Project/R1/nav/nav2/maps"),
|
|
"deploy_dir": "/home/unitree/nav_r1/maps",
|
|
# NO auto-filled IP — 'eth' is a SUGGESTION chip (click-to-use), never applied
|
|
# (wifi is typically 10.255.254.82)
|
|
"presets": {"eth": "192.168.123.164", "wifi": ""},
|
|
"note": "After deploy: start with --pudu (needs the map_align fit). "
|
|
"Typical eth 192.168.123.164, wifi 10.255.254.82 — type yours.",
|
|
"rosbridge_port": 9090,
|
|
},
|
|
}
|
|
|
|
|
|
def _rb_port(robot):
|
|
"""Per-robot rosbridge port. g1 = 9091: nav_g1's own bridge (:9090 on that
|
|
Jetson belongs to an unrelated pre-existing service the GUI never uses)."""
|
|
return ROBOTS.get(robot, {}).get("rosbridge_port", 9090)
|
|
|
|
|
|
# Robot-side start commands for the "live" RViz buttons: if the nav stack is not
|
|
# running, the GUI starts it over ssh (detached, logged) and waits for its
|
|
# rosbridge. Bring-up alone NEVER moves the robot — it only makes the stack
|
|
# available; motion still requires a goal you click (and on the R1, FSM 811).
|
|
START_CMDS = {
|
|
# {pose} becomes INITIAL_POSE='x y yaw' when we remember where the robot was.
|
|
# It matters more than it looks: planner_server's global costmap blocks in
|
|
# activate() until map->base_link exists, and that TF only appears once AMCL
|
|
# has an initial pose — so an unseeded start leaves the planner wedged and the
|
|
# lifecycle driver kicking it, which starves the very rosbridge handshake the
|
|
# dashboard needs to send the pose. Handing it over at start breaks that loop.
|
|
# nav-drive, NOT nav: `nav` starts NO /cmd_vel consumer, so goals plan a route
|
|
# and the robot never moves — the single most repeated confusion in this project.
|
|
# nav-drive attaches the consumer but comes up DISARMED, so bring-up still cannot
|
|
# move the robot; arming is a separate, explicit act (/api/arm).
|
|
"g1": ("cd $HOME/nav_g1/docker && mkdir -p $HOME/nav_g1/logs && {pose}setsid ./run.sh nav-drive "
|
|
"> $HOME/nav_g1/logs/run_auto.log 2>&1 < /dev/null & sleep 4; echo started"),
|
|
# nav-avoid, NOT nav: `nav` routes Move() straight to SportClient with NO
|
|
# obstacle handling beyond Nav2's costmap — and the Go2's synthetic /scan was
|
|
# MEASURED at 158 valid beams of 720 (78% of the circle blind), so the costmap
|
|
# misses obstacles and the dog walks into them. `nav-avoid` additionally routes
|
|
# every Move() through the firmware ObstaclesAvoidClient (L1 hard-stop), which
|
|
# sees the raw sensor rather than our sliced scan. It is a safety net under
|
|
# Nav2, not a replacement for fixing the scan.
|
|
"go2": ("cd $HOME/nav_go2 && setsid ./run.sh nav-avoid "
|
|
"> $HOME/nav_go2/run_auto.log 2>&1 < /dev/null & sleep 4; echo started"),
|
|
# the R1 needs the two-container vslam+nav2 orchestration in start_r1_nav.sh
|
|
# (workstation side), so it is not a single robot-side command
|
|
"r1": None,
|
|
}
|
|
START_LOGS = {"g1": "$HOME/nav_g1/logs/run_auto.log",
|
|
"go2": "$HOME/nav_go2/run_auto.log"}
|
|
|
|
|
|
def stack_start(robot, ip, user="unitree"):
|
|
"""Start the robot's nav stack over ssh (idempotent: no-op when its rosbridge
|
|
already answers). Detached + logged, so it survives the ssh session."""
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
port = _rb_port(robot)
|
|
# WHO is on this IP? G1 and R1 share 192.168.123.164 (one cable), and a
|
|
# stray :9090 from an unrelated service would otherwise read as "already
|
|
# running" — never auto-start or attach to the wrong machine.
|
|
try:
|
|
ident = _identify(ip, user)
|
|
except Exception:
|
|
ident = None
|
|
if ident and ident.get("robot") and ident["robot"] != robot:
|
|
raise RuntimeError(f"the machine at {ip} is a {ident['robot'].upper()} "
|
|
f"(host '{ident['hostname']}'), not the {robot.upper()} you "
|
|
"selected — connect the right robot or fix the IP")
|
|
if _port_open(ip, port, timeout=1.5):
|
|
return {"ok": True, "already": True, "port": port}
|
|
cmd = START_CMDS.get(robot)
|
|
if not cmd:
|
|
raise RuntimeError(f"{robot}: no one-command start — run {_start_hint(robot)}")
|
|
if "{pose}" in cmd:
|
|
pose = ""
|
|
try:
|
|
# last_pose() returns the pose dict ITSELF; the {"ok":..,"pose":..}
|
|
# envelope is added by the HTTP handler, not here.
|
|
lp = last_pose(robot) or {}
|
|
if lp:
|
|
pose = "INITIAL_POSE='%s %s %s' " % (
|
|
float(lp.get("x", 0.0)), float(lp.get("y", 0.0)), float(lp.get("yaw", 0.0)))
|
|
except Exception:
|
|
pose = ""
|
|
cmd = cmd.replace("{pose}", pose)
|
|
# FIRE AND FORGET: the remote command backgrounds the stack, but the ssh
|
|
# channel does not always close when it does (seen live: the stack came up
|
|
# while this call sat in a 45 s timeout and then reported failure). The
|
|
# caller polls rosbridge_up for the real answer, so never block on the ssh.
|
|
p = subprocess.Popen(["ssh", "-n"] + _ssh_base(ip) + [f"{user}@{ip}", cmd],
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
try:
|
|
out, _ = p.communicate(timeout=8) # only catches an INSTANT failure
|
|
if p.returncode not in (0, None):
|
|
raise RuntimeError(f"could not start the {robot} stack: "
|
|
f"{(out or '').strip()[-200:]}")
|
|
except subprocess.TimeoutExpired:
|
|
pass # still running = normal: leave it
|
|
return {"ok": True, "already": False, "port": port,
|
|
"note": f"{robot} nav stack starting on {ip} (log: {START_LOGS.get(robot, '')})"}
|
|
|
|
|
|
# Stopping a robot's nav stack = removing OUR containers on it. Exact names only:
|
|
# nothing else running on the robot is ever matched. An estop is attempted first
|
|
# where the stack can drive the robot (Go2/R1) so a moving robot halts cleanly
|
|
# before its controller disappears; on the G1 v1 there is no motion path at all.
|
|
STOP_CMDS = {
|
|
# estop FIRST (latches the flag + zeroes), then remove. A bare docker rm -f
|
|
# is a SIGKILL: with the bounded-duration driver the robot still stops on
|
|
# its own within 0.4 s, but sending the stop is strictly better than relying
|
|
# on the fallback — and it matches what Go2/R1 do.
|
|
"g1": ("docker exec g1-nav2 /entrypoint.sh estop >/dev/null 2>&1; "
|
|
"docker ps -a --format '{{.Names}}' | grep -E '^g1-nav2(-cli-[0-9]+)?$' "
|
|
"| xargs -r docker rm -f; echo '[stop] g1-nav2 stopped (estop sent first)'"),
|
|
"go2": ("docker exec go2-nav2 /entrypoint.sh estop >/dev/null 2>&1; "
|
|
"docker ps -a --format '{{.Names}}' | grep -E '^go2-nav2(-cli-[0-9]+)?$' "
|
|
"| xargs -r docker rm -f; echo '[stop] go2-nav2 stopped (estop sent first)'"),
|
|
"r1": ("docker exec r1-nav2 /entrypoint.sh estop >/dev/null 2>&1; "
|
|
"docker ps -a --format '{{.Names}}' "
|
|
"| grep -E '^(r1-nav2(-cli-[0-9]+)?|r1-imu|r1-vslam-map|r1-rosbridge|r1-cam)$' "
|
|
"| xargs -r docker rm -f; echo '[stop] r1 stack stopped (estop sent first)'"),
|
|
}
|
|
|
|
|
|
def stack_stop(robot, ip, user="unitree", timeout=60):
|
|
"""Stop the robot's nav stack (LiDAR driver, localization, rosbridge — all of
|
|
it, since they live in the container). Software only: no motion command
|
|
beyond the estop that halts a moving robot before its controller goes away."""
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
cmd = STOP_CMDS.get(robot)
|
|
if not cmd:
|
|
raise RuntimeError(f"no stop command for '{robot}'")
|
|
r = subprocess.run(["ssh"] + _ssh_base(ip) + [f"{user}@{ip}", cmd],
|
|
capture_output=True, text=True, timeout=timeout)
|
|
out = (r.stdout or r.stderr).strip().splitlines()
|
|
return {"ok": r.returncode == 0, "robot": robot, "ip": ip,
|
|
"note": out[-1] if out else f"stop rc={r.returncode}"}
|
|
|
|
|
|
# ── per-robot motion model ──────────────────────────────────────────────────
|
|
# These robots do NOT share a motion design, and pretending they do produced a
|
|
# dashboard that reported "no drive consumer" for a Go2 whose consumer was running
|
|
# perfectly well (it probed the G1's container and process name).
|
|
# G1 : g1_drive_consumer holds a LocoClient behind an ARM GATE — the stack comes
|
|
# up disarmed and a goal cannot move the robot until `arm` is run.
|
|
# Go2 : cmd_vel_sport_consumer holds a SportClient with NO GATE OF ANY KIND —
|
|
# verified, zero 'armed' references. A non-zero /cmd_vel moves the dog the
|
|
# instant it arrives, so THE GOAL IS THE MOTION COMMAND.
|
|
# R1 : motion is user-operated only; the dashboard must never offer to arm it.
|
|
DRIVE_MODELS = {
|
|
"g1": {"container": "g1-nav2", "consumer": "g1_drive_consumer",
|
|
"flags": "/tmp/g1_drive", "gate": True,
|
|
"actions": ("arm", "disarm", "estop", "clear-estop"),
|
|
"note": "arm gate: the stack starts disarmed"},
|
|
"go2": {"container": "go2-nav2", "consumer": "cmd_vel_sport_consumer",
|
|
"flags": None, "gate": False, "actions": ("estop",),
|
|
"note": "NO arm gate — a goal moves the robot immediately"},
|
|
"r1": {"container": None, "consumer": None, "flags": None, "gate": False,
|
|
"actions": (), "note": "motion is user-operated; not driven from here"},
|
|
}
|
|
|
|
|
|
def drive_action(robot, ip, action, user="unitree", timeout=60):
|
|
"""arm / disarm / estop / clear-estop, where the robot supports them.
|
|
|
|
ARMING IS THE ONLY GATE between a planned route and a walking humanoid, so it
|
|
is deliberately its own call — never folded into stack_start. `run.sh` reaches
|
|
the LIVE container with docker exec, so this stops a moving robot in place
|
|
rather than restarting anything."""
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
m = DRIVE_MODELS.get(robot)
|
|
if not m or not m["actions"]:
|
|
raise RuntimeError(f"{robot}: no drive actions ({(m or {}).get('note', 'unknown robot')})")
|
|
if action not in m["actions"]:
|
|
raise RuntimeError(f"{robot} does not support '{action}' — {m['note']}. "
|
|
f"Supported: {', '.join(m['actions'])}")
|
|
d = ROBOT_DIRS.get(robot, f"$HOME/nav_{robot}")
|
|
# `ssh -n` + DEVNULL: run.sh's aux commands exec `docker exec`, which waits on
|
|
# stdin. Inheriting the server's stdin wedged the whole HTTP request until the
|
|
# client gave up (stack_start carries the same -n for the same reason).
|
|
r = subprocess.run(["ssh", "-n"] + _ssh_base(ip) +
|
|
[f"{user}@{ip}", f"cd {d} && ./run.sh {action}"],
|
|
capture_output=True, text=True, timeout=timeout,
|
|
stdin=subprocess.DEVNULL)
|
|
out = [l for l in (r.stdout or "").strip().splitlines() if l.strip()]
|
|
err = (r.stderr or "").strip().splitlines()
|
|
return {"ok": r.returncode == 0, "action": action,
|
|
"note": (out[0] if out else (err[-1] if err else f"rc={r.returncode}"))}
|
|
|
|
|
|
def drive_state(robot, ip, user="unitree", timeout=45):
|
|
"""Is a /cmd_vel consumer attached, and (where there is a gate) is it armed?
|
|
|
|
Read-only. Reports `gate` so the UI can tell the user the truth instead of
|
|
implying every robot has an arm button."""
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
m = DRIVE_MODELS.get(robot) or {}
|
|
base = {"ok": True, "robot": robot, "gate": bool(m.get("gate")),
|
|
"note": m.get("note", ""), "armed": False, "estop": False,
|
|
"consumer": False, "actions": list(m.get("actions", ()))}
|
|
if not m.get("container"):
|
|
return base
|
|
# ONE single-quoted sh -c, no nested double quotes: an earlier version escaped
|
|
# quotes inside the quoted string and wedged the request.
|
|
if m.get("flags"):
|
|
probe = (f"docker exec {m['container']} sh -c '"
|
|
f"[ -f {m['flags']}/armed ] && echo armed || echo disarmed; "
|
|
f"[ -f {m['flags']}/estop ] && echo estop || echo noestop; "
|
|
f"pgrep -c -f {m['consumer']} || true'")
|
|
else:
|
|
probe = (f"docker exec {m['container']} sh -c '"
|
|
f"pgrep -c -f {m['consumer']} || true'")
|
|
r = subprocess.run(["ssh", "-n"] + _ssh_base(ip) + [f"{user}@{ip}", probe],
|
|
capture_output=True, text=True, timeout=timeout,
|
|
stdin=subprocess.DEVNULL)
|
|
t = (r.stdout or "").split()
|
|
# `pgrep -c -f` also matches the probe's own shell, so a bare 1 means "nobody
|
|
# but me" — the consumer is only really there at 2+.
|
|
n = max([int(x) for x in t if x.isdigit()] or [0])
|
|
base.update({"armed": "armed" in t, "estop": "estop" in t, "consumer": n >= 2})
|
|
return base
|
|
|
|
|
|
# where each robot's stack keeps its maps (the dirs run.sh mounts as /maps)
|
|
ROBOT_MAP_DIRS = {"g1": "$HOME/nav_g1/maps", "go2": "$HOME/nav_go2/maps",
|
|
"r1": "$HOME/nav_r1/maps"}
|
|
|
|
|
|
def _active_map_name(robot, active, names=()):
|
|
"""Which map set is the robot's nav2 map_server ACTUALLY reading?
|
|
|
|
Getting this wrong is not cosmetic: deleting or renaming the served map makes
|
|
map_server fail in configure, the lifecycle driver never reaches AMCL, and the
|
|
whole stack is dead until someone selects another map. That incident already
|
|
happened once here, so this resolves the real served path rather than guessing.
|
|
|
|
G1 — <maps>/active.env, written by `use-map`. Its FIRST LINE IS A COMMENT and
|
|
the values are absolute paths (MAP_YAML=/maps/<set>/map_keepout_baked.yaml),
|
|
so we scan all lines and reduce the path to its set folder.
|
|
With no active.env the entrypoint falls back to the NEWEST discovered
|
|
*/map_keepout_baked.yaml — still a served map, so flag that too.
|
|
Go2 — `use-map` COPIES the chosen pair onto BOTH map.yaml/map.pgm AND
|
|
map_keepout_baked.yaml/.pgm, and the launch serves
|
|
STATIC_MAP_YAML=/maps/map_keepout_baked.yaml (it sys.exit()s if that file
|
|
is missing). So map_keepout_baked is the FATAL one; map matters too.
|
|
Returns a SET of set-names considered in use (possibly empty).
|
|
"""
|
|
out = set()
|
|
a = (active or "").strip()
|
|
for chunk in a.replace(";", "\n").splitlines():
|
|
chunk = chunk.strip()
|
|
if not chunk or chunk.startswith("#"):
|
|
continue
|
|
val = chunk.split("=", 1)[1] if "=" in chunk else chunk
|
|
val = val.strip().strip('"').strip("'").strip("/")
|
|
if not val:
|
|
continue
|
|
if "/" in val: # /maps/<set>/map_keepout_baked.yaml
|
|
parts = [q for q in val.split("/") if q and q != "maps"]
|
|
if len(parts) >= 2:
|
|
out.add(parts[-2]) # the set FOLDER
|
|
elif parts:
|
|
out.add(os.path.splitext(parts[-1])[0]) # flat: file stem
|
|
else:
|
|
out.add(os.path.splitext(val)[0])
|
|
if robot == "go2":
|
|
# canonical names the Go2 launch reads, whether or not active.env exists
|
|
for n in ("map_keepout_baked", "map"):
|
|
if not names or n in names:
|
|
out.add(n)
|
|
if not out and names:
|
|
# no active.env at all: the G1 entrypoint discovers the newest set. We
|
|
# cannot see mtimes here, so flag nothing rather than flag the wrong one —
|
|
# but never claim "not in use" for a single-set robot, where it must be it.
|
|
if len(names) == 1:
|
|
out.add(next(iter(names)))
|
|
return out
|
|
|
|
|
|
def robot_maps(robot, ip, user="unitree"):
|
|
"""List the map sets ALREADY on the robot — so a map that is there is not
|
|
deployed again. Reports each set's PGM md5 so the GUI can mark the one that
|
|
is byte-identical to the map loaded here. Read-only."""
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
d = ROBOT_MAP_DIRS.get(robot)
|
|
if not d:
|
|
raise RuntimeError(f"no maps dir known for '{robot}'")
|
|
probe = (
|
|
f'cd {d} 2>/dev/null || {{ echo "NODIR"; exit 0; }}; '
|
|
"for y in $(find . -maxdepth 2 -name '*.yaml' ! -name 'keepout_mask*' | sort); do "
|
|
' img=$(sed -n "s/^image: *//p" "$y" | tr -d "\\r"); dir=$(dirname "$y"); p="$dir/$img"; '
|
|
' [ -f "$p" ] || continue; '
|
|
# PGM header: P5, then OPTIONAL '#' comment lines (map_saver writes one),
|
|
# then W H. Strip comments before reading the numbers, or a saved map
|
|
# reports its dims as '#xCreated'.
|
|
' dims=$(head -c 200 "$p" | sed "s/#.*//" | tr -s " \\n" " " '
|
|
' | awk "{print \\$2 \\"x\\" \\$3}"); '
|
|
' echo "MAP|$y|$(stat -c %Y "$p")|$(md5sum "$p" | cut -c1-32)|$dims|$(stat -c %s "$p")"; '
|
|
"done; "
|
|
# NOT `head -1`: entrypoint.sh writes a `# written by: ...` COMMENT as the
|
|
# first line, so head -1 returned the comment and the guard was dead.
|
|
'[ -f active.env ] && echo "ACTIVE|$(cat active.env | tr -d "\\r" | tr "\\n" ";")"; true')
|
|
r = subprocess.run(["ssh"] + _ssh_base(ip) + [f"{user}@{ip}", probe],
|
|
capture_output=True, text=True, timeout=45)
|
|
if r.returncode != 0:
|
|
raise RuntimeError((r.stderr or "ssh failed").strip().splitlines()[-1])
|
|
|
|
# md5 of the map loaded HERE — the whole point is spotting duplicates
|
|
local_md5 = ""
|
|
try:
|
|
import hashlib
|
|
c = STATE.get("converted")
|
|
if c and os.path.isfile(c["pgm"]):
|
|
local_md5 = hashlib.md5(open(c["pgm"], "rb").read()).hexdigest()
|
|
except Exception:
|
|
pass
|
|
|
|
maps, active, now = [], "", time.time()
|
|
for line in r.stdout.splitlines():
|
|
if line.startswith("MAP|"):
|
|
_, y, mtime, md5, dims, size = (line.split("|") + [""] * 5)[:6]
|
|
try:
|
|
age = now - float(mtime)
|
|
except ValueError:
|
|
age = 0
|
|
maps.append({
|
|
"yaml": y.lstrip("./"), "name": os.path.splitext(os.path.basename(y))[0],
|
|
"dir": os.path.dirname(y).lstrip("./") or ".", "dims": dims,
|
|
"md5": md5, "size": int(size or 0), "same_as_loaded": bool(local_md5) and md5 == local_md5,
|
|
"age": (f"{int(age)}s" if age < 60 else f"{int(age/60)}m" if age < 3600
|
|
else f"{int(age/3600)}h" if age < 86400 else f"{int(age/86400)}d"),
|
|
})
|
|
elif line.startswith("ACTIVE|"):
|
|
active = line.split("|", 1)[1]
|
|
|
|
# ONE ROW PER MAP SET. A deployed set is 5 files — map.yaml/.pgm (raw, for
|
|
# AMCL) AND map_keepout_baked.yaml/.pgm (keepouts burned in, for the costmap,
|
|
# because Foxy has no KeepoutFilter) — so listing every yaml made a single
|
|
# deploy look duplicated. Group by folder and name the variants instead.
|
|
sets, flat = {}, []
|
|
for m in maps:
|
|
if m["dir"] and m["dir"] != ".":
|
|
s = sets.setdefault(m["dir"], {**m, "name": m["dir"], "variants": []})
|
|
s["variants"].append(os.path.basename(m["yaml"]).replace(".yaml", ""))
|
|
# prefer the raw map as the set's representative (that is what AMCL reads)
|
|
if os.path.basename(m["yaml"]) == "map.yaml":
|
|
s.update({k: m[k] for k in ("yaml", "md5", "dims", "size", "age",
|
|
"same_as_loaded")})
|
|
s["same_as_loaded"] = s["same_as_loaded"] or m["same_as_loaded"]
|
|
else:
|
|
flat.append({**m, "variants": [m["name"]]})
|
|
# FLAT LAYOUTS (Go2) HAVE NO FOLDER TO GROUP BY, so the canonical pair that
|
|
# `use-map` writes — map.yaml/.pgm AND map_keepout_baked.yaml/.pgm — showed up
|
|
# as two extra cards next to the named map they were copied from: one logical
|
|
# map, three identical 53 KB cards. Collapse the canonical pair into ONE entry
|
|
# (it is a single selection, and both halves are equally fatal to delete) and
|
|
# say which named map it is a copy of, so the duplication is explained rather
|
|
# than merely hidden.
|
|
canon = [m for m in flat if m["name"] in ("map", "map_keepout_baked")]
|
|
if len(canon) > 1:
|
|
flat = [m for m in flat if m not in canon]
|
|
rep = next((m for m in canon if m["name"] == "map"), canon[0])
|
|
merged = {**rep, "name": "map",
|
|
"variants": sorted(m["name"] for m in canon),
|
|
"canonical": True}
|
|
src = next((m["name"] for m in flat if m["md5"] and m["md5"] == rep["md5"]), None)
|
|
if src:
|
|
merged["copy_of"] = src
|
|
flat.append(merged)
|
|
grouped = sorted(sets.values(), key=lambda m: m["name"]) + \
|
|
sorted(flat, key=lambda m: m["name"])
|
|
# WHICH SET IS NAV2 ACTUALLY SERVING? Deleting that one kills the stack, so
|
|
# every consumer gets an explicit per-set in_use flag instead of re-deriving it.
|
|
keys = {(g["dir"] if g["dir"] not in ("", ".") else g["name"]) for g in grouped}
|
|
served = _active_map_name(robot, active, keys)
|
|
for g in grouped:
|
|
g["variants"] = sorted(set(g["variants"]))
|
|
key = g["dir"] if g["dir"] not in ("", ".") else g["name"]
|
|
g["in_use"] = key in served
|
|
# The guard must also cover the VARIANT names of an in-use set. Collapsing the
|
|
# Go2's canonical pair into one card removed map_keepout_baked as a key, and a
|
|
# direct API call naming it would otherwise have slipped past the check and
|
|
# killed the stack — the very thing the guard exists to prevent.
|
|
for g in grouped:
|
|
if g["in_use"]:
|
|
served.update(g.get("variants") or [])
|
|
return {"ok": True, "dir": d, "maps": grouped, "active": active,
|
|
"active_name": ", ".join(sorted(served)),
|
|
"served": sorted(served),
|
|
"local_md5": local_md5,
|
|
"files": len(maps),
|
|
"duplicate": any(m["same_as_loaded"] for m in grouped)}
|
|
|
|
|
|
_RTHUMBS = {} # md5 -> png b64 (a map's pixels never change under one md5)
|
|
|
|
|
|
def _fetch_robot_set(robot, ip, yaml_rel, user="unitree"):
|
|
"""Pull ONE map set off the robot: (yaml text, pgm bytes). base64 over ssh —
|
|
no scp round-trip, and it works the same from inside the GUI container."""
|
|
_ident(ip, "ip"); _ident(user, "user"); _ident(yaml_rel, "dest")
|
|
d = ROBOT_MAP_DIRS.get(robot)
|
|
if not d:
|
|
raise RuntimeError(f"no maps dir known for '{robot}'")
|
|
cmd = (f'cd {d} || exit 1; y="{yaml_rel}"; [ -f "$y" ] || exit 3; '
|
|
'img=$(sed -n "s/^image: *//p" "$y" | tr -d "\\r"); p="$(dirname "$y")/$img"; '
|
|
'[ -f "$p" ] || exit 4; '
|
|
'echo "---YAML---"; cat "$y"; echo "---PGM---"; base64 -w0 "$p"')
|
|
r = subprocess.run(["ssh"] + _ssh_base(ip) + [f"{user}@{ip}", cmd],
|
|
capture_output=True, text=True, timeout=180)
|
|
if r.returncode != 0 or "---PGM---" not in r.stdout:
|
|
raise RuntimeError((r.stderr or "fetch failed").strip().splitlines()[-1]
|
|
if r.stderr else f"fetch rc={r.returncode}")
|
|
head, _, b64 = r.stdout.partition("---PGM---")
|
|
yml = head.split("---YAML---", 1)[-1].strip()
|
|
return yml, base64.b64decode(b64.strip())
|
|
|
|
|
|
def robot_map_thumb(robot, ip, yaml_rel, md5="", user="unitree", size=220):
|
|
"""Thumbnail of a map that lives ON THE ROBOT (for the Gallery's robot half)."""
|
|
if md5 and md5 in _RTHUMBS:
|
|
return {"ok": True, "png_b64": _RTHUMBS[md5], "cached": True}
|
|
_, pgm = _fetch_robot_set(robot, ip, yaml_rel, user)
|
|
gray = np.array(Image.open(io.BytesIO(pgm)).convert("L"))
|
|
pr = (255.0 - gray.astype(np.float32)) / 255.0
|
|
rgb = np.zeros((*gray.shape, 3), np.uint8)
|
|
rgb[:] = (52, 56, 62)
|
|
rgb[pr <= FREE_THRESH] = (245, 244, 240)
|
|
rgb[pr >= OCCUPIED_THRESH] = (24, 27, 32)
|
|
im = Image.fromarray(rgb)
|
|
im.thumbnail((int(size), int(size)), Image.NEAREST)
|
|
buf = io.BytesIO()
|
|
im.save(buf, "PNG", optimize=True)
|
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
|
if md5:
|
|
if len(_RTHUMBS) > 48:
|
|
_RTHUMBS.clear()
|
|
_RTHUMBS[md5] = b64
|
|
return {"ok": True, "png_b64": b64, "cached": False}
|
|
|
|
|
|
def robot_map_import(robot, ip, yaml_rel, user="unitree"):
|
|
"""Copy a map OFF the robot into the workstation library, then load it — the
|
|
'view a map that is on the robot' path (full pan/zoom, edit, re-deploy)."""
|
|
import hashlib
|
|
yml, pgm = _fetch_robot_set(robot, ip, yaml_rel, user)
|
|
# already in the library? load THAT instead of piling up a new folder every
|
|
# time the same robot map is viewed / Used
|
|
digest = hashlib.md5(pgm).hexdigest()
|
|
for entry in sorted(os.listdir(PROJECT_MAPS)) if os.path.isdir(PROJECT_MAPS) else []:
|
|
cand = os.path.join(PROJECT_MAPS, entry, "map.pgm")
|
|
y = os.path.join(PROJECT_MAPS, entry, "map.yaml")
|
|
if os.path.isfile(cand) and os.path.isfile(y):
|
|
try:
|
|
if hashlib.md5(open(cand, "rb").read()).hexdigest() == digest:
|
|
payload = load_map(y)
|
|
payload["imported"] = os.path.dirname(y)
|
|
payload["reused"] = True
|
|
return payload
|
|
except OSError:
|
|
continue
|
|
base = os.path.basename(os.path.dirname(yaml_rel)) or \
|
|
os.path.splitext(os.path.basename(yaml_rel))[0]
|
|
name = _safe_name(f"{base}_from_{robot}")
|
|
out = os.path.join(PROJECT_MAPS, name)
|
|
if os.path.isdir(out):
|
|
out += time.strftime("_%Y%m%d_%H%M%S")
|
|
os.makedirs(out, exist_ok=True)
|
|
with open(os.path.join(out, "map.pgm"), "wb") as f:
|
|
f.write(pgm)
|
|
m = yaml.safe_load(yml) or {}
|
|
res = float(m.get("resolution", 0.05))
|
|
ox, oy = (list(m.get("origin", [0, 0, 0])) + [0, 0, 0])[:2]
|
|
_write_map_yaml(os.path.join(out, "map.yaml"), "map.pgm", res, float(ox), float(oy),
|
|
m.get("mode", "trinary"))
|
|
shutil.copy(os.path.join(out, "map.pgm"), os.path.join(out, "map_keepout_baked.pgm"))
|
|
_write_map_yaml(os.path.join(out, "map_keepout_baked.yaml"), "map_keepout_baked.pgm",
|
|
res, float(ox), float(oy), "trinary")
|
|
payload = load_map(os.path.join(out, "map.yaml"))
|
|
payload["imported"] = out
|
|
return payload
|
|
|
|
|
|
def _map_name(n):
|
|
"""A map name is ONE path segment inside the robot's maps dir. No '/', no
|
|
'..', no leading '-' — this string goes into rm/mv on the robot."""
|
|
import re
|
|
n = str(n).strip().strip("/")
|
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", n) or ".." in n:
|
|
raise ValueError(f"invalid map name: {n!r}")
|
|
return n
|
|
|
|
|
|
def robot_map_delete(robot, ip, name, user="unitree", force=False):
|
|
"""Delete ONE map set from the robot's maps dir (a <name>/ folder, or the
|
|
flat <name>.pgm/.yaml pair). The maps dir itself is never removed.
|
|
|
|
IN-USE GUARD: deleting the map nav2 is serving leaves map_server failing
|
|
forever (this already happened once and killed the whole stack silently).
|
|
The check lives HERE, not only in the page, so a stale browser tab cannot
|
|
bypass it — the caller must pass force=True after warning the human.
|
|
"""
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
name = _map_name(name)
|
|
d = ROBOT_MAP_DIRS.get(robot)
|
|
if not d:
|
|
raise RuntimeError(f"no maps dir known for '{robot}'")
|
|
# Matching only the CARD KEY missed variant names: after the Go2's canonical
|
|
# pair was collapsed into one card keyed "map", a delete naming
|
|
# "map_keepout_baked" matched no card and was allowed through — it removed the
|
|
# exact file the Go2 launch sys.exit()s without. _refuse_if_served checks the
|
|
# served set, which includes every variant of an in-use set.
|
|
_refuse_if_served(robot, ip, name, user, "Deleting", force)
|
|
cmd = (f'cd {d} 2>/dev/null || {{ echo "no maps dir"; exit 1; }}; '
|
|
f'if [ -d "{name}" ]; then rm -rf -- "{name}"; echo "removed folder {name}"; '
|
|
f'elif [ -f "{name}.yaml" ]; then rm -f -- "{name}.yaml" "{name}.pgm"; '
|
|
f'echo "removed {name}.yaml/.pgm"; else echo "not found: {name}"; exit 1; fi')
|
|
r = subprocess.run(["ssh"] + _ssh_base(ip) + [f"{user}@{ip}", cmd],
|
|
capture_output=True, text=True, timeout=45)
|
|
out = (r.stdout or r.stderr).strip().splitlines()
|
|
if r.returncode != 0:
|
|
raise RuntimeError(out[-1] if out else f"delete rc={r.returncode}")
|
|
return {"ok": True, "note": out[-1]}
|
|
|
|
|
|
def nav_ready(robot, ip, user="unitree", timeout=45):
|
|
"""Can this robot ACCEPT a goal yet?
|
|
|
|
bt_navigator is the node that receives /goal_pose. It is the LAST thing to
|
|
activate (2-5 minutes after start on these robots), and a goal sent before it
|
|
is up is silently DISCARDED — no error anywhere, the robot simply never moves.
|
|
That cost an hour on both the G1 and the Go2 today, so the dashboard checks
|
|
before sending instead of letting the goal evaporate.
|
|
|
|
The run log is truncated on every start, so a marker in it refers to THIS run."""
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
path = START_LOGS.get(robot)
|
|
if not path:
|
|
return {"ok": True, "ready": True, "why": "no log known — not gating"}
|
|
# -F (fixed string) so the [lifecycle] brackets are not a character class and
|
|
# need no escaping — the escaped version matched nothing and made every
|
|
# not-ready answer claim "the stack isn't running".
|
|
probe = (f'grep -ac "/bt_navigator ACTIVE" {path} 2>/dev/null || echo 0; '
|
|
f'grep -acF "[lifecycle]" {path} 2>/dev/null || echo 0')
|
|
r = subprocess.run(["ssh", "-n"] + _ssh_base(ip) + [f"{user}@{ip}", probe],
|
|
capture_output=True, text=True, timeout=timeout,
|
|
stdin=subprocess.DEVNULL)
|
|
nums = [int(x) for x in (r.stdout or "").split() if x.strip().isdigit()]
|
|
active = bool(nums and nums[0] > 0)
|
|
starting = bool(len(nums) > 1 and nums[1] > 0)
|
|
return {"ok": True, "ready": active,
|
|
"why": ("bt_navigator is ACTIVE" if active else
|
|
("the nav stack is still bringing up — bt_navigator has not activated "
|
|
"yet, and a goal sent now is silently discarded"
|
|
if starting else
|
|
"no lifecycle activity in the robot's log — is the stack running?"))}
|
|
|
|
|
|
def _refuse_if_served(robot, ip, name, user, verb, force):
|
|
"""Renaming or deleting the SERVED map kills the stack — same failure either way.
|
|
|
|
active.env stores ABSOLUTE paths, so a rename leaves MAP_YAML dangling and
|
|
map_server dies in configure exactly as a delete does. The UI warns, but a
|
|
stale tab bypasses the UI; this is the check that cannot be bypassed.
|
|
A failed scan must NOT block the operation — an ssh hiccup should not lock the
|
|
user out of their own maps."""
|
|
if force:
|
|
return
|
|
try:
|
|
served = set(robot_maps(robot, ip, user).get("served") or [])
|
|
except Exception:
|
|
return
|
|
if name in served:
|
|
raise RuntimeError(
|
|
f"'{name}' is the map {robot.upper()}'s nav2 map_server is serving right now. "
|
|
f"{verb} it makes the stack fail to start and stay dead until another map is "
|
|
f'selected with Use. Re-send with "force": true to do it anyway.')
|
|
|
|
|
|
def robot_map_rename(robot, ip, name, new, user="unitree", force=False):
|
|
"""Rename a map set on the robot. Folder sets just move; a flat pair also
|
|
gets its yaml's image: line rewritten so it keeps pointing at its own pgm."""
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
name, new = _map_name(name), _map_name(new)
|
|
_refuse_if_served(robot, ip, name, user, "Renaming", force)
|
|
d = ROBOT_MAP_DIRS.get(robot)
|
|
if not d:
|
|
raise RuntimeError(f"no maps dir known for '{robot}'")
|
|
cmd = (f'cd {d} 2>/dev/null || {{ echo "no maps dir"; exit 1; }}; '
|
|
f'[ -e "{new}" ] || [ -e "{new}.yaml" ] && {{ echo "already exists: {new}"; exit 1; }}; '
|
|
f'if [ -d "{name}" ]; then mv -- "{name}" "{new}"; echo "renamed folder {name} -> {new}"; '
|
|
f'elif [ -f "{name}.yaml" ]; then mv -- "{name}.yaml" "{new}.yaml"; '
|
|
f'mv -- "{name}.pgm" "{new}.pgm"; '
|
|
f'sed -i "s|^image: .*|image: {new}.pgm|" "{new}.yaml"; '
|
|
f'echo "renamed {name} -> {new}"; else echo "not found: {name}"; exit 1; fi')
|
|
r = subprocess.run(["ssh"] + _ssh_base(ip) + [f"{user}@{ip}", cmd],
|
|
capture_output=True, text=True, timeout=45)
|
|
out = (r.stdout or r.stderr).strip().splitlines()
|
|
if r.returncode != 0:
|
|
raise RuntimeError(out[-1] if out else f"rename rc={r.returncode}")
|
|
return {"ok": True, "note": out[-1]}
|
|
|
|
|
|
def rename_map(yaml_path, new):
|
|
"""Rename a map set in the LOCAL library (the folder that holds it)."""
|
|
new = _map_name(new)
|
|
p = os.path.realpath(os.path.expanduser(str(yaml_path)))
|
|
roots = [os.path.realpath(r) for r in MAP_ROOTS if os.path.isdir(r)]
|
|
if not any(p.startswith(r + os.sep) for r in roots):
|
|
raise ValueError("refusing to rename outside the known map roots")
|
|
d = os.path.dirname(p)
|
|
if os.path.realpath(d) in roots:
|
|
raise RuntimeError("this set sits directly in a maps root — rename it on disk")
|
|
tgt = os.path.join(os.path.dirname(d), new)
|
|
if os.path.exists(tgt):
|
|
raise RuntimeError(f"already exists: {new}")
|
|
shutil.move(d, tgt)
|
|
c = STATE.get("converted")
|
|
if c and os.path.realpath(c["yaml"]).startswith(os.path.realpath(d) + os.sep):
|
|
for k in ("out", "pgm", "yaml"): # keep the loaded map pointing right
|
|
c[k] = c[k].replace(d, tgt, 1)
|
|
if STATE.get("payload"):
|
|
STATE["payload"]["out_dir"] = tgt
|
|
STATE["payload"]["yaml"] = c["yaml"]
|
|
_save_current()
|
|
return {"ok": True, "from": os.path.basename(d), "to": new, "dir": tgt}
|
|
|
|
|
|
def robot_use_map(robot, ip, name, user="unitree"):
|
|
"""Point the robot's nav mode at a map that is ALREADY on it (no transfer)."""
|
|
_ident(ip, "ip"); _ident(user, "user"); _ident(name, "dest")
|
|
runner = {"g1": "$HOME/nav_g1/docker/run.sh", "go2": "$HOME/nav_go2/run.sh"}.get(robot)
|
|
if not runner:
|
|
raise RuntimeError(f"{robot}: no use-map command (its nav reads maps/ directly)")
|
|
# Selecting the map that is ALREADY serving is a no-op, not a failure. On the
|
|
# Go2 `use-map` COPIES <name>.pgm onto the canonical map.pgm, so asking for the
|
|
# set literally called "map" makes cp refuse ("are the same file") and the whole
|
|
# call looked broken to the user.
|
|
try:
|
|
if name in set(robot_maps(robot, ip, user).get("served") or []):
|
|
return {"ok": True, "already": True,
|
|
"note": f"{name} is already the map nav2 is serving"}
|
|
except Exception:
|
|
pass
|
|
r = subprocess.run(["ssh", "-n"] + _ssh_base(ip) +
|
|
[f"{user}@{ip}", f"{runner} use-map {name}"],
|
|
capture_output=True, text=True, timeout=60,
|
|
stdin=subprocess.DEVNULL)
|
|
# BOTH streams: the entrypoint prints a banner on stdout while the real failure
|
|
# goes to stderr, so `stdout or stderr` reported "[entrypoint] GO2_IFACE auto ->
|
|
# eth0" as the error and hid the actual cause.
|
|
merged = [l.strip() for l in ((r.stdout or "") + "\n" + (r.stderr or "")).splitlines()
|
|
if l.strip()]
|
|
signal = [l for l in merged if not l.startswith("[entrypoint]")]
|
|
if r.returncode != 0:
|
|
raise RuntimeError((signal or merged or [f"use-map rc={r.returncode}"])[-1])
|
|
return {"ok": True, "note": (signal or merged or [f"{name} selected"])[-1]}
|
|
|
|
|
|
def robot_ips(ip, user="unitree"):
|
|
"""Ask the robot for ITS OWN addresses — so the wifi slot can be filled from
|
|
an eth connection (and vice versa) instead of hunting for the DHCP lease."""
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
r = subprocess.run(["ssh"] + _ssh_base(ip, timeout=6) + [f"{user}@{ip}",
|
|
"ip -4 -o addr show | awk '{print $2, $4}'"],
|
|
capture_output=True, text=True, timeout=25)
|
|
if r.returncode != 0:
|
|
raise RuntimeError((r.stderr or "ssh failed").strip().splitlines()[-1])
|
|
eth = wifi = ""
|
|
other = []
|
|
for line in r.stdout.splitlines():
|
|
parts = line.split()
|
|
if len(parts) != 2:
|
|
continue
|
|
dev, cidr = parts[0], parts[1].split("/")[0]
|
|
if dev.startswith(("lo", "docker", "br-", "veth")):
|
|
continue
|
|
if cidr.startswith("192.168.123."):
|
|
eth = eth or cidr # the robot LAN
|
|
elif dev.startswith(("wl", "wlan")):
|
|
wifi = wifi or cidr
|
|
else:
|
|
other.append(f"{dev}:{cidr}")
|
|
return {"ok": True, "eth": eth, "wifi": wifi, "other": other}
|
|
|
|
|
|
def conn_status(robot, ip, user="unitree"):
|
|
"""Fast 'is this robot connected' for the header/deploy indicator."""
|
|
if not ip:
|
|
return {"ok": True, "ping": False, "ssh": False, "running": False,
|
|
"rosbridge": False, "detail": "no IP"}
|
|
_ident(ip, "ip")
|
|
if not ping(ip)["ok"]:
|
|
return {"ok": True, "ping": False, "ssh": False, "running": False,
|
|
"rosbridge": False, "detail": "no ping"}
|
|
try:
|
|
st = install_status(robot, ip, user)
|
|
except Exception as e:
|
|
return {"ok": True, "ping": True, "ssh": False, "running": False,
|
|
"rosbridge": False, "detail": f"{type(e).__name__}"}
|
|
return {"ok": True, "ping": True, "ssh": st["reachable"],
|
|
"installed": st["installed"], "running": st["running"],
|
|
"rosbridge": _port_open(ip, _rb_port(robot), timeout=1.2),
|
|
"detail": st["detail"]}
|
|
|
|
|
|
def rosbridge_up(robot, ip):
|
|
"""Is the robot's nav rosbridge answering yet? (used while waiting for a start)"""
|
|
port = _rb_port(robot)
|
|
return {"ok": True, "up": _port_open(ip, port, timeout=1.5), "port": port}
|
|
|
|
|
|
def stack_log(robot, ip, user="unitree", lines=25):
|
|
"""Tail the auto-start log so a failed bring-up explains itself."""
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
path = START_LOGS.get(robot)
|
|
if not path:
|
|
return {"ok": True, "log": ""}
|
|
r = subprocess.run(["ssh"] + _ssh_base(ip) + [f"{user}@{ip}",
|
|
f"tail -n {int(lines)} {path} 2>/dev/null"],
|
|
capture_output=True, text=True, timeout=25)
|
|
return {"ok": True, "log": r.stdout.strip()}
|
|
|
|
|
|
def _start_hint(robot):
|
|
return {"r1": "start_r1_nav.sh --nav --ip <addr>",
|
|
"go2": "~/nav_go2/run.sh nav (or slam)",
|
|
"g1": "~/nav_g1/docker/run.sh nav-loc (docker container g1-nav2)"
|
|
}.get(robot, "the robot's nav stack")
|
|
|
|
STATE = {"converted": None} # set by /api/convert; consumed by stage/deploy
|
|
|
|
|
|
def pick_export(mode="dir", start=""):
|
|
"""Open a NATIVE OS picker on the server (== the user's desktop, localhost)
|
|
and return the chosen absolute path. A folder OR a .zip is valid input to
|
|
convert() — it unzips as needed. Run in a subprocess so tkinter never touches
|
|
the HTTP server's worker thread."""
|
|
is_file = str(mode).lower() in ("file", "zip")
|
|
code = (
|
|
"import sys, tkinter as tk\n"
|
|
"from tkinter import filedialog\n"
|
|
"r = tk.Tk(); r.withdraw(); r.attributes('-topmost', True)\n"
|
|
"start = sys.argv[1] or None\n"
|
|
+ ("p = filedialog.askopenfilename(title='Choose a Pudu .zip export',"
|
|
" filetypes=[('Zip', '*.zip'), ('All', '*.*')], initialdir=start)\n"
|
|
if is_file else
|
|
"p = filedialog.askdirectory(title='Choose the Pudu export folder', initialdir=start)\n")
|
|
+ "sys.stdout.write(p or '')\n"
|
|
)
|
|
env = dict(os.environ)
|
|
env.setdefault("DISPLAY", ":1")
|
|
try:
|
|
r = subprocess.run([sys.executable, "-c", code, start or ""],
|
|
capture_output=True, text=True, timeout=180, env=env)
|
|
except Exception as e:
|
|
return _pick_zenity(is_file, start, env, f"tkinter spawn failed: {e}")
|
|
if r.returncode != 0:
|
|
# tkinter missing/crashed exits nonzero with empty stdout — that is NOT a
|
|
# user cancel; fall back to zenity (GNOME's native dialog).
|
|
return _pick_zenity(is_file, start, env, (r.stderr or "").strip()[-200:])
|
|
path = (r.stdout or "").strip()
|
|
if not path:
|
|
return {"ok": True, "path": "", "cancelled": True}
|
|
return {"ok": True, "path": path}
|
|
|
|
|
|
def _pick_zenity(is_file, start, env, why):
|
|
cmd = ["zenity", "--file-selection",
|
|
"--title", "Choose a Pudu .zip export" if is_file else "Choose the Pudu export folder"]
|
|
if is_file:
|
|
cmd += ["--file-filter=Pudu export (*.zip) | *.zip", "--file-filter=All files | *"]
|
|
else:
|
|
cmd += ["--directory"]
|
|
if start and os.path.isdir(start):
|
|
cmd += [f"--filename={start.rstrip('/')}/"]
|
|
try:
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180, env=env)
|
|
except FileNotFoundError:
|
|
return {"ok": False, "error": f"no picker available ({why}; zenity not installed) — type the path"}
|
|
except Exception as e:
|
|
return {"ok": False, "error": f"picker failed: {e}"}
|
|
if r.returncode == 0 and r.stdout.strip():
|
|
return {"ok": True, "path": r.stdout.strip()}
|
|
if r.returncode == 1:
|
|
return {"ok": True, "path": "", "cancelled": True}
|
|
return {"ok": False, "error": f"picker failed (zenity rc={r.returncode}; {why})"}
|
|
OCCUPIED_THRESH, FREE_THRESH = 0.65, 0.196
|
|
|
|
|
|
# ───────────────────────── conversion (vendored, verified) ─────────────────────────
|
|
|
|
def _find_export(path):
|
|
"""Resolve user path -> (png, json) extracting zips if needed."""
|
|
path = os.path.expanduser(path.strip())
|
|
if not os.path.exists(path):
|
|
raise FileNotFoundError(f"path does not exist: {path}")
|
|
if os.path.isfile(path) and path.endswith(".zip"):
|
|
zips = [path]
|
|
elif os.path.isdir(path):
|
|
direct_png = os.path.join(path, "map.png")
|
|
direct_json = os.path.join(path, "map.json")
|
|
if os.path.isfile(direct_png) and os.path.isfile(direct_json):
|
|
return direct_png, direct_json, path
|
|
zips = sorted(
|
|
(os.path.join(path, f) for f in os.listdir(path) if f.endswith(".zip")),
|
|
key=os.path.getmtime, reverse=True)
|
|
if not zips:
|
|
raise FileNotFoundError(
|
|
f"no map.png+map.json and no .zip in {path}")
|
|
else:
|
|
raise ValueError(f"give a folder or a .zip (got: {path})")
|
|
z = zips[0]
|
|
out = os.path.join(os.path.dirname(z), "gui_extracted")
|
|
os.makedirs(out, exist_ok=True)
|
|
with zipfile.ZipFile(z) as f:
|
|
f.extractall(out)
|
|
png, js = os.path.join(out, "map.png"), os.path.join(out, "map.json")
|
|
if not (os.path.isfile(png) and os.path.isfile(js)):
|
|
raise FileNotFoundError(f"{z} does not contain map.png + map.json")
|
|
return png, js, os.path.dirname(z)
|
|
|
|
|
|
def _poly_pairs(v):
|
|
return [(v[i], v[i + 1]) for i in range(0, len(v) - 1, 2)]
|
|
|
|
|
|
def _world_to_px(x, y, ox, oy, res, H, row0_top=True):
|
|
col = (x - ox) / res
|
|
row = (H - (y - oy) / res) if row0_top else ((y - oy) / res)
|
|
return col, row
|
|
|
|
|
|
def _verify_row_order(m, gray, ox, oy, res, W, H):
|
|
areas = [e for e in m.get("element_list", []) if e.get("type") == "area"]
|
|
if not areas:
|
|
return True
|
|
poly = _poly_pairs(areas[0]["vector_list"])
|
|
scores = {}
|
|
for row0_top in (True, False):
|
|
img = Image.new("L", (W, H), 0)
|
|
pts = [_world_to_px(x, y, ox, oy, res, H, row0_top) for x, y in poly]
|
|
ImageDraw.Draw(img).polygon(pts, fill=255)
|
|
inside = gray[np.array(img) > 0]
|
|
scores[row0_top] = float((inside != 128).mean()) if inside.size else 0.0
|
|
return scores[True] >= scores[False]
|
|
|
|
|
|
def _write_map_yaml(path, image_name, res, ox, oy, mode):
|
|
with open(path, "w") as f:
|
|
f.write(f"image: {image_name}\nmode: {mode}\nresolution: {res}\n"
|
|
f"origin: [{ox}, {oy}, 0.0]\nnegate: 0\n"
|
|
f"occupied_thresh: {OCCUPIED_THRESH}\nfree_thresh: {FREE_THRESH}\n")
|
|
|
|
|
|
def _png_b64(arr_rgba):
|
|
buf = io.BytesIO()
|
|
Image.fromarray(arr_rgba, "RGBA").save(buf, "PNG", optimize=True)
|
|
return base64.b64encode(buf.getvalue()).decode()
|
|
|
|
|
|
def convert(path):
|
|
png, js, base_dir = _find_export(path)
|
|
with open(js) as f:
|
|
data = json.load(f)
|
|
m = data.get("map", data)
|
|
res = float(m["resolution"])
|
|
ox, oy = float(m["origin"][0]), float(m["origin"][1])
|
|
gray = np.array(Image.open(png).convert("L"))
|
|
H, W = gray.shape
|
|
if not _verify_row_order(m, gray, ox, oy, res, W, H):
|
|
gray = gray[::-1, :]
|
|
|
|
# save into the PROJECT map library, one folder per export name. Reconverting
|
|
# the same export overwrites its set — unless it was hand-edited (map.pgm.orig
|
|
# marker), which is preserved as a .bak_<ts> folder first.
|
|
name = _safe_name(os.path.basename(
|
|
os.path.expanduser(path.strip()).rstrip("/")).removesuffix(".zip"))
|
|
out = os.path.join(PROJECT_MAPS, name)
|
|
if os.path.isdir(out):
|
|
if os.path.isfile(os.path.join(out, "map.pgm.orig")):
|
|
shutil.move(out, f"{out}.bak_{time.strftime('%Y%m%d_%H%M%S')}")
|
|
else:
|
|
for f in MAP_FILES + ["map.pgm.orig"]:
|
|
q = os.path.join(out, f)
|
|
if os.path.isfile(q):
|
|
os.remove(q)
|
|
os.makedirs(out, exist_ok=True)
|
|
Image.fromarray(gray, "L").save(os.path.join(out, "map.pgm"))
|
|
_write_map_yaml(os.path.join(out, "map.yaml"), "map.pgm", res, ox, oy, "trinary")
|
|
|
|
els = m.get("element_list", [])
|
|
# keepout mask + baked variant (only when forbidden zones exist)
|
|
forbidden = [e for e in els if e.get("type") == "forbidden"]
|
|
baked = None
|
|
if forbidden:
|
|
mask = Image.new("L", (W, H), 255)
|
|
dr = ImageDraw.Draw(mask)
|
|
for e in forbidden:
|
|
pts = [_world_to_px(x, y, ox, oy, res, H) for x, y in _poly_pairs(e["vector_list"])]
|
|
dr.polygon(pts, fill=0)
|
|
mask.save(os.path.join(out, "keepout_mask.pgm"))
|
|
_write_map_yaml(os.path.join(out, "keepout_mask.yaml"), "keepout_mask.pgm", res, ox, oy, "scale")
|
|
mask_a = np.array(mask)
|
|
baked = gray.copy()
|
|
baked[mask_a == 0] = 0
|
|
Image.fromarray(baked, "L").save(os.path.join(out, "map_keepout_baked.pgm"))
|
|
_write_map_yaml(os.path.join(out, "map_keepout_baked.yaml"),
|
|
"map_keepout_baked.pgm", res, ox, oy, "trinary")
|
|
else:
|
|
# no forbidden zones: baked == plain, but ALWAYS emit the baked pair — the
|
|
# robot stacks' contract is "serve map_keepout_baked.yaml" (R1 launch
|
|
# auto-detects that exact filename).
|
|
shutil.copy(os.path.join(out, "map.pgm"), os.path.join(out, "map_keepout_baked.pgm"))
|
|
_write_map_yaml(os.path.join(out, "map_keepout_baked.yaml"),
|
|
"map_keepout_baked.pgm", res, ox, oy, "trinary")
|
|
|
|
ann = {"homes": [], "chargers": [], "areas": [], "tracks": [], "nodes": []}
|
|
for e in els:
|
|
v, t = e.get("vector_list", []), e.get("type")
|
|
if t == "source" and len(v) >= 3:
|
|
ann["homes"].append({"name": e.get("name", ""), "x": v[0], "y": v[1], "yaw": v[2]})
|
|
elif t == "chargeWorkStation" and len(v) >= 3:
|
|
ann["chargers"].append({"name": e.get("name", ""), "x": v[0], "y": v[1], "yaw": v[2]})
|
|
elif t == "area":
|
|
ann["areas"].append([list(p) for p in _poly_pairs(v)])
|
|
elif t == "track" and len(v) >= 4:
|
|
ann["tracks"].append(v[:4])
|
|
elif t == "node" and len(v) >= 2:
|
|
ann["nodes"].append(v[:2])
|
|
with open(os.path.join(out, "annotations.yaml"), "w") as f:
|
|
yaml.safe_dump({"home_points": ann["homes"], "charge_stations": ann["chargers"],
|
|
"areas": ann["areas"]}, f, sort_keys=False, allow_unicode=True)
|
|
|
|
# preview layers
|
|
layers = _preview_layers(gray, os.path.join(out, "keepout_mask.pgm") if forbidden else None)
|
|
|
|
free_m2 = float(_free_mask(gray).sum()) * res * res
|
|
STATE["converted"] = {"out": out, "pgm": os.path.join(out, "map.pgm"),
|
|
"yaml": os.path.join(out, "map.yaml"), "baked": baked is not None}
|
|
payload = {
|
|
"ok": True, "out_dir": out, "yaml": os.path.join(out, "map.yaml"),
|
|
"meta": {"W": W, "H": H, "res": res, "ox": ox, "oy": oy,
|
|
"free_m2": round(free_m2), "n_forbidden": len(forbidden),
|
|
"size_m": [round(W * res, 1), round(H * res, 1)]},
|
|
"layers": layers, "ann": ann,
|
|
}
|
|
STATE["payload"] = payload
|
|
_save_current()
|
|
return payload
|
|
|
|
|
|
def _free_mask(gray):
|
|
"""Trinary-threshold free-space mask (map_server semantics, negate=0)."""
|
|
p = (255.0 - gray.astype(np.float32)) / 255.0
|
|
return p <= FREE_THRESH
|
|
|
|
|
|
def _preview_layers(gray, mask_path):
|
|
"""Colorize by THRESHOLDS, not exact values — the map editor writes the
|
|
map_saver palette (0/205/254), the Pudu converter writes 0/128/255; both
|
|
must render identically."""
|
|
p = (255.0 - gray.astype(np.float32)) / 255.0
|
|
H, W = gray.shape
|
|
base = np.zeros((H, W, 4), np.uint8)
|
|
base[p <= FREE_THRESH] = (245, 244, 240, 255)
|
|
base[p >= OCCUPIED_THRESH] = (24, 27, 32, 255)
|
|
layers = {"base": _png_b64(base)}
|
|
if mask_path and os.path.isfile(mask_path):
|
|
ko = np.zeros((H, W, 4), np.uint8)
|
|
m = np.array(Image.open(mask_path))
|
|
if m.shape == gray.shape:
|
|
ko[m == 0] = (209, 72, 62, 110)
|
|
layers["keepout"] = _png_b64(ko)
|
|
return layers
|
|
|
|
|
|
# ───────────────────────── map library (latest maps) ─────────────────────────
|
|
# CANONICAL storage: every converted and every edited map set lives in the
|
|
# PROJECT, one folder per set — not scattered next to whatever export was
|
|
# converted. The other roots are still scanned (robot save-maps in the stage
|
|
# dirs, old gui_converted sets in Downloads) so nothing disappears.
|
|
PROJECT_MAPS = os.path.join(WS, "Project/Other/pudu_map_gui/maps")
|
|
os.makedirs(PROJECT_MAPS, exist_ok=True)
|
|
|
|
MAP_ROOTS = [
|
|
PROJECT_MAPS,
|
|
os.path.join(WS, "Project/R1/nav/nav2/maps"),
|
|
os.path.join(WS, "Project/GO2/nav/nav2/maps"),
|
|
os.path.join(WS, "Project/G1/Nav2_Projects/sanad_nav3/maps"),
|
|
os.path.expanduser("~/Downloads"),
|
|
]
|
|
|
|
|
|
def _safe_name(s):
|
|
import re
|
|
s = re.sub(r"[^A-Za-z0-9_-]+", "_", s).strip("_")
|
|
return s or "map"
|
|
|
|
|
|
# the loaded map survives GUI restarts: a tiny pointer file, reloaded at startup
|
|
# (an in-memory-only STATE lost the session whenever the container restarted)
|
|
CURRENT_PTR = os.path.join(PROJECT_MAPS, ".current.json")
|
|
|
|
|
|
def _save_current():
|
|
try:
|
|
c = STATE.get("converted")
|
|
if c:
|
|
with open(CURRENT_PTR, "w") as f:
|
|
json.dump({"yaml": c["yaml"]}, f)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _pgm_dims(path):
|
|
with open(path, "rb") as f:
|
|
head = f.read(64).split()
|
|
if head and head[0] in (b"P5", b"P2") and len(head) >= 3:
|
|
return int(head[1]), int(head[2])
|
|
return 0, 0
|
|
|
|
|
|
def list_maps():
|
|
"""Newest map sets (a *.yaml whose image file exists) under the known roots."""
|
|
found = {}
|
|
for root in MAP_ROOTS:
|
|
if not os.path.isdir(root):
|
|
continue
|
|
for dirpath, dirs, files in os.walk(root):
|
|
if dirpath[len(root):].count(os.sep) >= 4:
|
|
dirs[:] = []
|
|
continue
|
|
# hidden dirs + .bak backups (stage/reconvert safety copies) are NOT
|
|
# maps — listing them made every Stage look like it "saved a new map"
|
|
dirs[:] = [d for d in dirs if not d.startswith(".") and ".bak" not in d]
|
|
for f in files:
|
|
# skip DERIVED files of a set: the keepout mask and the baked
|
|
# variant would list every set twice
|
|
if not (f.endswith(".yaml") or f.endswith(".yml")) or "keepout" in f:
|
|
continue
|
|
p = os.path.join(dirpath, f)
|
|
try:
|
|
with open(p) as fh:
|
|
m = yaml.safe_load(fh)
|
|
if not isinstance(m, dict) or "image" not in m or "resolution" not in m:
|
|
continue
|
|
img = m["image"]
|
|
img = img if os.path.isabs(img) else os.path.join(dirpath, img)
|
|
if not os.path.isfile(img):
|
|
continue
|
|
W, H = _pgm_dims(img)
|
|
# display name: 'map.yaml' says nothing — use the set's
|
|
# folder name (its parent too when the folder is generic)
|
|
name = os.path.splitext(f)[0]
|
|
if name == "map":
|
|
d1 = os.path.basename(dirpath)
|
|
if d1 in ("gui_converted", "converted", "extracted",
|
|
"raw", "common", "maps"):
|
|
d0 = os.path.basename(os.path.dirname(dirpath))
|
|
d1 = f"{d0}/{d1}" if d0 else d1
|
|
name = d1
|
|
found[os.path.realpath(p)] = {
|
|
"yaml": p, "name": name,
|
|
"dir": dirpath.replace(os.path.expanduser("~"), "~"),
|
|
"mtime": os.path.getmtime(img), "W": W, "H": H,
|
|
"res": float(m["resolution"]),
|
|
}
|
|
except Exception:
|
|
continue
|
|
maps = sorted(found.values(), key=lambda x: -x["mtime"])[:20]
|
|
now = time.time()
|
|
for it in maps:
|
|
d = now - it["mtime"]
|
|
it["age"] = (f"{int(d)}s" if d < 60 else f"{int(d/60)}m" if d < 3600
|
|
else f"{int(d/3600)}h" if d < 86400 else f"{int(d/86400)}d")
|
|
return {"ok": True, "maps": maps}
|
|
|
|
|
|
def load_map(yaml_path):
|
|
"""Load an EXISTING converted set (map.yaml + image) as the current map."""
|
|
yaml_path = os.path.abspath(os.path.expanduser(yaml_path))
|
|
with open(yaml_path) as f:
|
|
m = yaml.safe_load(f)
|
|
d = os.path.dirname(yaml_path)
|
|
img = m["image"]
|
|
pgm = img if os.path.isabs(img) else os.path.join(d, img)
|
|
gray = np.array(Image.open(pgm).convert("L"))
|
|
H, W = gray.shape
|
|
res = float(m["resolution"])
|
|
ox, oy = float(m["origin"][0]), float(m["origin"][1])
|
|
|
|
ann = {"homes": [], "chargers": [], "areas": [], "tracks": [], "nodes": []}
|
|
ann_file = os.path.join(d, "annotations.yaml")
|
|
if os.path.isfile(ann_file):
|
|
try:
|
|
a = yaml.safe_load(open(ann_file)) or {}
|
|
ann["homes"] = a.get("home_points") or []
|
|
ann["chargers"] = a.get("charge_stations") or []
|
|
ann["areas"] = a.get("areas") or []
|
|
except Exception:
|
|
pass
|
|
|
|
mask = os.path.join(d, "keepout_mask.pgm")
|
|
layers = _preview_layers(gray, mask if os.path.isfile(mask) else None)
|
|
STATE["converted"] = {"out": d, "pgm": pgm, "yaml": yaml_path,
|
|
"baked": os.path.isfile(os.path.join(d, "map_keepout_baked.yaml"))}
|
|
payload = {
|
|
"ok": True, "out_dir": d, "yaml": yaml_path,
|
|
"meta": {"W": W, "H": H, "res": res, "ox": ox, "oy": oy,
|
|
"free_m2": round(float(_free_mask(gray).sum()) * res * res),
|
|
"n_forbidden": "mask" if "keepout" in layers else 0,
|
|
"size_m": [round(W * res, 1), round(H * res, 1)]},
|
|
"layers": layers, "ann": ann,
|
|
}
|
|
STATE["payload"] = payload
|
|
_save_current()
|
|
return payload
|
|
|
|
|
|
_THUMBS = {} # (yaml_path, image_mtime) -> png b64 — thumbnails are pure cache
|
|
|
|
|
|
def map_thumb(yaml_path, size=220):
|
|
"""Small square-fitting preview of a map set for the Gallery tab."""
|
|
p = os.path.realpath(os.path.expanduser(str(yaml_path)))
|
|
roots = [os.path.realpath(r) for r in MAP_ROOTS if os.path.isdir(r)]
|
|
if not any(p.startswith(r + os.sep) for r in roots):
|
|
raise ValueError("not under a known maps root")
|
|
with open(p) as f:
|
|
m = yaml.safe_load(f)
|
|
img = m.get("image", "")
|
|
img = img if os.path.isabs(img) else os.path.join(os.path.dirname(p), img)
|
|
key = (p, os.path.getmtime(img))
|
|
if key not in _THUMBS:
|
|
gray = np.array(Image.open(img).convert("L"))
|
|
pr = (255.0 - gray.astype(np.float32)) / 255.0
|
|
rgb = np.zeros((*gray.shape, 3), np.uint8)
|
|
rgb[:] = (52, 56, 62) # unknown
|
|
rgb[pr <= FREE_THRESH] = (245, 244, 240) # free
|
|
rgb[pr >= OCCUPIED_THRESH] = (24, 27, 32) # occupied
|
|
im = Image.fromarray(rgb)
|
|
im.thumbnail((int(size), int(size)), Image.NEAREST)
|
|
buf = io.BytesIO()
|
|
im.save(buf, "PNG", optimize=True)
|
|
if len(_THUMBS) > 64:
|
|
_THUMBS.clear()
|
|
_THUMBS[key] = base64.b64encode(buf.getvalue()).decode()
|
|
return {"ok": True, "png_b64": _THUMBS[key]}
|
|
|
|
|
|
def delete_map(yaml_path):
|
|
"""Delete a map SET from disk (the Recent-maps 🗑). Guards: only inside the
|
|
known map roots, only the files that belong to THIS set, and a root dir is
|
|
never deleted itself (robot save-maps sit as <name>.pgm/.yaml pairs directly
|
|
in a maps root, next to other sets)."""
|
|
p = os.path.realpath(os.path.expanduser(str(yaml_path)))
|
|
roots = [os.path.realpath(r) for r in MAP_ROOTS if os.path.isdir(r)]
|
|
if not any(p.startswith(r + os.sep) for r in roots):
|
|
raise ValueError("refusing to delete outside the known map roots")
|
|
if not os.path.isfile(p):
|
|
raise FileNotFoundError(p)
|
|
d = os.path.dirname(p)
|
|
with open(p) as f:
|
|
m = yaml.safe_load(f)
|
|
img = m.get("image", "") if isinstance(m, dict) else ""
|
|
img = img if os.path.isabs(img) else os.path.join(d, img)
|
|
stem = os.path.splitext(os.path.basename(p))[0]
|
|
victims = {p}
|
|
if img and os.path.isfile(img):
|
|
victims.add(os.path.realpath(img))
|
|
if stem in ("map", "map_keepout_baked"):
|
|
# a full converted set — its companions go with it
|
|
for f in MAP_FILES + ["map.pgm.orig"]:
|
|
q = os.path.join(d, f)
|
|
if os.path.isfile(q):
|
|
victims.add(os.path.realpath(q))
|
|
else:
|
|
# a named save-map pair: exactly <stem>.pgm/.yaml
|
|
for ext in (".pgm", ".yaml", ".yml"):
|
|
q = os.path.join(d, stem + ext)
|
|
if os.path.isfile(q):
|
|
victims.add(os.path.realpath(q))
|
|
deleted = []
|
|
for v in sorted(victims):
|
|
os.remove(v)
|
|
deleted.append(os.path.basename(v))
|
|
dir_removed = False
|
|
if os.path.realpath(d) not in roots and os.path.isdir(d) and not os.listdir(d):
|
|
os.rmdir(d)
|
|
dir_removed = True
|
|
c = STATE.get("converted")
|
|
if c and os.path.realpath(c["yaml"]) == p: # the loaded map is gone
|
|
STATE["converted"] = None
|
|
STATE.pop("payload", None)
|
|
try:
|
|
os.remove(CURRENT_PTR)
|
|
except OSError:
|
|
pass
|
|
return {"ok": True, "deleted": deleted, "dir_removed": dir_removed}
|
|
|
|
|
|
# ───────────────────────── stage / deploy / rviz ─────────────────────────
|
|
|
|
MAP_FILES = ["map.pgm", "map.yaml", "map_keepout_baked.pgm", "map_keepout_baked.yaml",
|
|
"keepout_mask.pgm", "keepout_mask.yaml", "annotations.yaml"]
|
|
|
|
|
|
def _require_map():
|
|
"""The loaded map, validated. STATE can outlive the files (deleted with 🗑,
|
|
re-staged elsewhere, removed outside the GUI) — every consumer must fail with
|
|
a clear message instead of half-working."""
|
|
c = STATE.get("converted")
|
|
if not c:
|
|
raise RuntimeError("convert or load a map first")
|
|
if not (os.path.isfile(c["yaml"]) and os.path.isfile(c["pgm"])):
|
|
raise RuntimeError(f"the loaded map's files are gone ({_short(c['out'])}) — "
|
|
"pick another set in the Map library")
|
|
return c
|
|
|
|
|
|
def _short(p):
|
|
return str(p).replace(os.path.expanduser("~"), "~")
|
|
|
|
|
|
def _converted_files():
|
|
c = _require_map()
|
|
files = [os.path.join(c["out"], f) for f in MAP_FILES
|
|
if os.path.isfile(os.path.join(c["out"], f))]
|
|
if not files:
|
|
raise RuntimeError(f"no map files in {_short(c['out'])}")
|
|
return files
|
|
|
|
|
|
def stage(robot):
|
|
dst = ROBOTS[robot]["stage_dir"]
|
|
c = _require_map()
|
|
files = _converted_files()
|
|
# SAME-DIR GUARD: staging a map that already IS the stage dir used to delete
|
|
# every file and then fail copying them onto themselves — it destroyed the
|
|
# staged map (hit live 2026-07-20). Nothing to do in that case.
|
|
if os.path.realpath(c["out"]) == os.path.realpath(dst):
|
|
return {"ok": True, "staged": dst, "backup": None, "noop": True,
|
|
"files": [os.path.basename(f) for f in files],
|
|
"note": "already staged — the loaded map IS this robot's stage dir"}
|
|
bak = None
|
|
if os.path.isdir(dst) and os.listdir(dst):
|
|
# ONE rolling backup — a timestamped copy per Stage click piled up
|
|
# pseudo-maps ("why does loading save new maps?")
|
|
bak = f"{dst}.bak"
|
|
shutil.rmtree(bak, ignore_errors=True)
|
|
shutil.copytree(dst, bak)
|
|
os.makedirs(dst, exist_ok=True)
|
|
# clear ALL map files first — a previous map's leftovers (e.g. an old
|
|
# map_keepout_baked.*) must never mix with the new map's frame
|
|
for f in MAP_FILES:
|
|
p = os.path.join(dst, f)
|
|
if os.path.isfile(p):
|
|
os.remove(p)
|
|
copied = []
|
|
try:
|
|
for f in files:
|
|
shutil.copy(f, dst)
|
|
copied.append(os.path.basename(f))
|
|
except Exception as e: # never leave the dst half-wiped
|
|
if bak:
|
|
for f in os.listdir(bak):
|
|
src = os.path.join(bak, f)
|
|
if os.path.isfile(src):
|
|
shutil.copy(src, dst)
|
|
raise RuntimeError(f"stage failed ({e}) — {_short(dst)} restored from the backup")
|
|
return {"ok": True, "staged": dst, "backup": bak, "files": copied}
|
|
|
|
|
|
def _ssh_base(ip, timeout=8):
|
|
# Docker-proof ssh: root-in-container resolves ~ from passwd (/root), not $HOME,
|
|
# so pass the identity files EXPLICITLY from the mounted $HOME/.ssh; -F /dev/null
|
|
# skips the user config (group-writable config would abort root's ssh).
|
|
# No host-key pinning ON PURPOSE: G1 and R1 share 192.168.123.164 (one eth
|
|
# cable, physically swapped), so a remembered key bricks every op with
|
|
# HOST IDENTIFICATION CHANGED after each swap. These are direct robot LANs.
|
|
# ServerAlive: without it a dead link mid-job wedges ssh (and the job lock)
|
|
# for the ~2h kernel keepalive — 15s x 4 bounds it at ~1 min.
|
|
opts = ["-F", "/dev/null", "-o", "BatchMode=yes", "-o", f"ConnectTimeout={timeout}",
|
|
"-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=4",
|
|
"-o", "StrictHostKeyChecking=no",
|
|
"-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR"]
|
|
for k in ("id_ed25519", "id_rsa", "id_ecdsa"):
|
|
p = os.path.expanduser(f"~/.ssh/{k}")
|
|
if os.path.isfile(p):
|
|
opts += ["-i", p]
|
|
return opts
|
|
|
|
|
|
def robot_dest_for(robot, name=""):
|
|
"""Where a map lands on the robot: <maps root>/<THIS map's name>. The old
|
|
default hardcoded '.../maps/pudu', so renaming a map locally still deployed
|
|
it into the old folder — the robot kept the stale name."""
|
|
root = os.path.dirname(ROBOTS[robot]["deploy_dir"].rstrip("/"))
|
|
if not name:
|
|
c = STATE.get("converted")
|
|
name = os.path.basename(os.path.dirname(c["yaml"])) if c else ""
|
|
return f"{root}/{_safe_name(name)}" if name else ROBOTS[robot]["deploy_dir"]
|
|
|
|
|
|
def deploy(robot, ip, user="unitree", dest=""):
|
|
if not ip:
|
|
raise ValueError("robot IP required")
|
|
dest = dest or robot_dest_for(robot)
|
|
# these reach ssh argv + the REMOTE shell (mkdir -p {dest}) — path chars only
|
|
_ident(ip, "ip"); _ident(user, "user"); _ident(dest, "dest")
|
|
files = _converted_files()
|
|
r = subprocess.run(["ssh"] + _ssh_base(ip) + [f"{user}@{ip}", f"mkdir -p {dest}"],
|
|
capture_output=True, text=True, timeout=30)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(f"ssh failed: {r.stderr.strip() or r.stdout.strip()}"
|
|
" (key auth required — BatchMode)")
|
|
r = subprocess.run(["scp"] + _ssh_base(ip) + files + [f"{user}@{ip}:{dest}/"],
|
|
capture_output=True, text=True, timeout=120)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(f"scp failed: {r.stderr.strip()}")
|
|
return {"ok": True, "deployed": f"{user}@{ip}:{dest}",
|
|
"files": [os.path.basename(f) for f in files]}
|
|
|
|
|
|
def ping(ip):
|
|
r = subprocess.run(["ping", "-c1", "-W2", ip], capture_output=True, text=True)
|
|
return {"ok": r.returncode == 0}
|
|
|
|
|
|
# ───────────────────────── install / uninstall / detect ─────────────────────────
|
|
# Per-robot nav-stack lifecycle, driven over the same key-auth ssh as deploy().
|
|
# Install runs the stack's own one-shot installer (deploy + docker build ON the
|
|
# robot); uninstall removes ONLY what that installer created — the Sanad
|
|
# containers (sanad-api-*) and, on the R1, the separate VSLAM install are NEVER
|
|
# touched. Neither ever starts navigation or moves a robot.
|
|
|
|
INSTALLERS = {
|
|
"g1": {
|
|
"script": os.path.join(WS, "Project/G1/Nav2_Projects/sanad_nav3/docker/install.sh"),
|
|
# fully docker now (image g1-nav2): files + image + is the container up
|
|
"probe": ("test -d $HOME/nav_g1 && echo P_DIR; "
|
|
"test -d $HOME/marcus_nav2_test && echo P_OLD; " # legacy name
|
|
"docker image inspect g1-nav2 >/dev/null 2>&1 && echo P_IMG; "
|
|
"docker ps --format '{{.Names}}' 2>/dev/null | grep -qx g1-nav2 && echo P_RUN; true"),
|
|
"needs_img": True,
|
|
"uninstall": ("docker ps -a --format '{{.Names}}' | grep -E '^g1-nav2(-cli-[0-9]+)?$' "
|
|
"| xargs -r docker rm -f; "
|
|
"docker rmi g1-nav2 2>/dev/null; rm -rf $HOME/nav_g1 $HOME/marcus_nav2_test; "
|
|
"echo '[uninstall] g1-nav2 image+containers+~/nav_g1 (+legacy dir) removed — "
|
|
"nothing else on the robot touched'"),
|
|
"what": "g1-nav2 image + containers + ~/nav_g1",
|
|
},
|
|
"go2": {
|
|
"script": os.path.join(WS, "Project/GO2/nav/nav2/docker/install.sh"),
|
|
"probe": ("test -d $HOME/nav_go2 && echo P_DIR; "
|
|
"test -d $HOME/go2_nav2_docker && echo P_OLD; " # legacy name
|
|
"docker image inspect go2-nav2 >/dev/null 2>&1 && echo P_IMG; "
|
|
"docker ps --format '{{.Names}}' 2>/dev/null | grep -qx go2-nav2 && echo P_RUN; true"),
|
|
"needs_img": True,
|
|
# exact-name match ONLY — sanad-api-go2 must never match
|
|
"uninstall": ("docker ps -a --format '{{.Names}}' | grep -E '^go2-nav2(-cli-[0-9]+)?$' "
|
|
"| xargs -r docker rm -f; "
|
|
"docker rmi go2-nav2 2>/dev/null; rm -rf $HOME/nav_go2 $HOME/go2_nav2_docker; "
|
|
"echo '[uninstall] go2-nav2 image+containers+~/nav_go2 (+legacy dir) removed — "
|
|
"nothing else on the robot touched'"),
|
|
"what": "go2-nav2 image + containers + ~/nav_go2",
|
|
},
|
|
"r1": {
|
|
"script": os.path.join(WS, "Project/R1/nav/nav2/docker/install.sh"),
|
|
"probe": ("test -d $HOME/nav_r1 && echo P_DIR; "
|
|
"test -d $HOME/r1_nav2_docker && echo P_OLD; " # legacy name
|
|
"docker image inspect r1-nav2 >/dev/null 2>&1 && echo P_IMG; "
|
|
"docker image inspect r1-vslam >/dev/null 2>&1 && echo P_VIMG; "
|
|
"docker ps --format '{{.Names}}' 2>/dev/null | grep -qx r1-nav2 && echo P_RUN; "
|
|
"docker ps --format '{{.Names}}' 2>/dev/null | grep -qx r1-vslam-map && echo P_VSLAM; true"),
|
|
"needs_img": True,
|
|
# install ships BOTH halves (nav2 + vslam), so uninstall removes both:
|
|
# r1-imu/r1-vslam-map/r1-rosbridge/r1-cam run from these images+dirs.
|
|
# sanadr1 / sanad-api-r1 are NOT in the pattern — never touched.
|
|
"uninstall": ("docker ps -a --format '{{.Names}}' "
|
|
"| grep -E '^(r1-nav2(-cli-[0-9]+)?|r1-imu|r1-vslam-map|r1-rosbridge|r1-cam)$' "
|
|
"| xargs -r docker rm -f; "
|
|
"docker rmi r1-nav2 r1-vslam 2>/dev/null; "
|
|
"rm -rf $HOME/nav_r1 $HOME/r1_nav2_docker $HOME/nav_r1_vslam $HOME/r1_vslam_docker; "
|
|
"echo '[uninstall] r1-nav2 + r1-vslam images, r1-* containers, "
|
|
"~/nav_r1 + ~/nav_r1_vslam (+legacy dirs) removed — "
|
|
"nothing else on the robot touched'"),
|
|
"what": "r1-nav2 + r1-vslam images, r1-* containers, ~/nav_r1 + ~/nav_r1_vslam",
|
|
},
|
|
}
|
|
|
|
|
|
def _ident(val, what):
|
|
"""ip/user/dest go into ssh argv and remote shell strings — allow only safe
|
|
chars (defense in depth; the server is localhost-only)."""
|
|
import re
|
|
pat = r"[A-Za-z0-9_.:/~-]+" if what == "dest" else r"[A-Za-z0-9_.-]+"
|
|
if not val or not re.fullmatch(pat, val):
|
|
raise ValueError(f"invalid {what}: {val!r}")
|
|
return val
|
|
|
|
|
|
def install_status(robot, ip, user="unitree"):
|
|
"""One ssh round-trip; marker lines say what exists on the robot."""
|
|
if not ip:
|
|
raise ValueError("robot IP required")
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
spec = INSTALLERS[robot]
|
|
r = subprocess.run(["ssh"] + _ssh_base(ip, timeout=4) + [f"{user}@{ip}", spec["probe"]],
|
|
capture_output=True, text=True, timeout=20)
|
|
# every probe ends in '; true' — a completed probe ALWAYS exits 0, so rc!=0
|
|
# means the transport failed. Partial marker output from a half-dead session
|
|
# must not be trusted (a missing P_RUN would green-light install mid-session).
|
|
if r.returncode != 0:
|
|
return {"ok": True, "reachable": False, "installed": False, "running": False,
|
|
"marks": [],
|
|
"detail": (r.stderr.strip().splitlines() or ["ssh failed"])[-1]}
|
|
marks = set(r.stdout.split())
|
|
has_files = "P_DIR" in marks or "P_OLD" in marks
|
|
installed = has_files and ("P_IMG" in marks or not spec["needs_img"])
|
|
detail = []
|
|
detail.append("files ✓" if "P_DIR" in marks else
|
|
("legacy folder — Install again to migrate to the nav_* name"
|
|
if "P_OLD" in marks else "files ✗"))
|
|
if spec["needs_img"]:
|
|
detail.append("image ✓" if "P_IMG" in marks else "image ✗")
|
|
if "P_RUN" in marks:
|
|
detail.append("stack RUNNING")
|
|
if robot == "r1":
|
|
detail.append("vslam image ✓" if "P_VIMG" in marks else "vslam image ✗")
|
|
detail.append("vslam up" if "P_VSLAM" in marks else "vslam down")
|
|
return {"ok": True, "reachable": True, "installed": installed,
|
|
"running": "P_RUN" in marks, "marks": sorted(marks),
|
|
"detail": ", ".join(detail)}
|
|
|
|
|
|
# WHO is on this IP? Read-only identity markers — needed because G1 and R1
|
|
# share 192.168.123.164 (one cable, physically swapped): the scan must report
|
|
# the robot that actually answers, not every configured target on that IP.
|
|
ID_PROBE = (
|
|
"ip link show eth10 >/dev/null 2>&1 && echo I_R1_IF; " # R1 backpack NIC
|
|
"D=\"$(docker ps -a --format '{{.Names}}' 2>/dev/null)\"; "
|
|
"echo \"$D\" | grep -qx 'sanad-api-r1' && echo I_R1_API; "
|
|
"echo \"$D\" | grep -qx 'sanad-api-g1' && echo I_G1_API; "
|
|
"echo \"$D\" | grep -qx 'sanad-api-go2' && echo I_GO2_API; "
|
|
"test -d $HOME/marcus_lio_ws && echo I_G1_LIO; "
|
|
"test -d $HOME/g1plus_pc4_unitree_install && echo I_G1_DIR; "
|
|
"test -d $HOME/unitree_slam && echo I_R1_SLAM; "
|
|
"test -d $HOME/nav_go2 && echo I_GO2_DIR; "
|
|
"hostname 2>/dev/null; true")
|
|
|
|
_ID_WEIGHTS = {"I_R1_IF": ("r1", 3), "I_R1_API": ("r1", 3), "I_R1_SLAM": ("r1", 2),
|
|
"I_G1_API": ("g1", 3), "I_G1_LIO": ("g1", 2), "I_G1_DIR": ("g1", 2),
|
|
"I_GO2_API": ("go2", 3), "I_GO2_DIR": ("go2", 1)}
|
|
|
|
|
|
def _identify(ip, user="unitree"):
|
|
"""ssh once; return {'robot': 'g1'|'go2'|'r1'|None, 'hostname', 'markers'}
|
|
or None when ssh itself fails."""
|
|
r = subprocess.run(["ssh"] + _ssh_base(ip, timeout=4) + [f"{user}@{ip}", ID_PROBE],
|
|
capture_output=True, text=True, timeout=25)
|
|
if r.returncode != 0:
|
|
return None
|
|
marks, host = [], ""
|
|
for line in r.stdout.split():
|
|
if line in _ID_WEIGHTS:
|
|
marks.append(line)
|
|
else:
|
|
host = line # last non-marker token = hostname
|
|
score = {"g1": 0, "go2": 0, "r1": 0}
|
|
for m in marks:
|
|
robot, w = _ID_WEIGHTS[m]
|
|
score[robot] += w
|
|
best = max(score, key=score.get)
|
|
return {"robot": best if score[best] > 0 else None,
|
|
"hostname": host, "markers": marks}
|
|
|
|
|
|
def shutdown_all(targets=None):
|
|
"""⏻ Shutdown: stop EVERYTHING — the robots' nav stacks (LiDAR driver,
|
|
localization, rosbridge: they all live in our container) for every target
|
|
given, then the workstation viewers (local /map publisher, relay, RViz),
|
|
then the GUI server itself (in docker that ends the container).
|
|
Only OUR containers are removed on the robots; nothing else is touched."""
|
|
stopped = []
|
|
for t in (targets or []):
|
|
rb, ip = str(t.get("robot", "")).lower(), str(t.get("ip", "")).strip()
|
|
if rb not in STOP_CMDS or not ip:
|
|
continue
|
|
try:
|
|
if not _port_open(ip, 22, timeout=1.5): # unreachable: skip fast
|
|
continue
|
|
r = stack_stop(rb, ip, timeout=45)
|
|
stopped.append(f"{rb}@{ip} nav stack")
|
|
except Exception as e:
|
|
stopped.append(f"{rb}@{ip} stop failed ({type(e).__name__})")
|
|
for label, pat in (("local map publisher", "[l]ocal_map_pub.py"),
|
|
("rosbridge relay", "[r]1_rosbridge_relay.py"),
|
|
("RViz", "[r]viz2")):
|
|
try:
|
|
if subprocess.run(["pkill", "-f", pat], capture_output=True).returncode == 0:
|
|
stopped.append(label)
|
|
except Exception:
|
|
pass
|
|
|
|
def _bye():
|
|
time.sleep(0.7) # let the HTTP response flush to the browser first
|
|
os._exit(0)
|
|
threading.Thread(target=_bye, daemon=True).start()
|
|
return {"ok": True, "stopped": stopped}
|
|
|
|
|
|
def status(targets=None):
|
|
"""Fleet + GUI status for the Status tab. `targets` is the user's DYNAMIC
|
|
robot list [{name, robot, ip}], but the result shows only what is REALLY
|
|
connected: unique IPs are scanned once, the machine answering is
|
|
IDENTIFIED (G1/R1 share .164), and offline targets collapse to one-line
|
|
reasons. Read-only everywhere. None = seed defaults; an EMPTY list means
|
|
the user emptied their registry on purpose — scan nothing."""
|
|
if targets is None:
|
|
# suggestions are click-to-use only — a default scan carries NO static IPs
|
|
targets = [{"name": k.upper(), "robot": k, "ip": ""} for k in ROBOTS]
|
|
targets = targets[:16]
|
|
out = {"ok": True, "ts": time.strftime("%H:%M:%S"), "connected": [],
|
|
"offline": [], "map": None, "viewers": {}}
|
|
c, p = STATE.get("converted"), STATE.get("payload")
|
|
if c and p:
|
|
out["map"] = {"yaml": c["yaml"], "out_dir": p.get("out_dir"), "meta": p["meta"]}
|
|
|
|
by_ip = {} # unique IPs, first-seen order
|
|
for t in targets:
|
|
by_ip.setdefault(str(t.get("ip", "")).strip(), []).append(t)
|
|
lock = threading.Lock()
|
|
|
|
def names_of(tlist):
|
|
return ", ".join(str(t.get("name") or t.get("robot") or "?") for t in tlist)
|
|
|
|
def scan_ip(ip, tlist):
|
|
cfg = names_of(tlist)
|
|
try:
|
|
if not ip:
|
|
with lock:
|
|
out["offline"].append({"targets": cfg, "ip": "", "reason": "no IP set"})
|
|
return
|
|
if not ping(ip)["ok"]:
|
|
with lock:
|
|
out["offline"].append({"targets": cfg, "ip": ip, "reason": "no ping"})
|
|
return
|
|
ident = _identify(ip)
|
|
if ident is None:
|
|
with lock:
|
|
out["offline"].append({"targets": cfg, "ip": ip,
|
|
"reason": "ping OK but ssh failed (key auth?)"})
|
|
return
|
|
detected = ident["robot"] or str(tlist[0].get("robot", "")).lower()
|
|
if detected not in INSTALLERS:
|
|
with lock:
|
|
out["offline"].append({"targets": cfg, "ip": ip,
|
|
"reason": f"could not identify the robot (host '{ident['hostname']}')"})
|
|
return
|
|
st = install_status(detected, ip)
|
|
match = next((t for t in tlist
|
|
if str(t.get("robot", "")).lower() == detected), None)
|
|
row = {"name": str((match or {}).get("name") or detected.upper()),
|
|
"robot": detected, "ip": ip, "hostname": ident["hostname"],
|
|
"identified": bool(ident["robot"]),
|
|
"installed": st["installed"], "running": st["running"],
|
|
"rosbridge": _port_open(ip, _rb_port(detected), timeout=1.5),
|
|
"detail": st["detail"]}
|
|
if ident["robot"] and not match:
|
|
row["detail"] += (f" — NOTE: this IP is configured as "
|
|
f"'{cfg}' but the machine answering is a {detected.upper()}")
|
|
with lock:
|
|
out["connected"].append(row)
|
|
except Exception as e:
|
|
with lock:
|
|
out["offline"].append({"targets": cfg, "ip": ip,
|
|
"reason": f"{type(e).__name__}: {e}"})
|
|
|
|
threads = []
|
|
for ip, tlist in by_ip.items():
|
|
th = threading.Thread(target=scan_ip, args=(ip, tlist), daemon=True)
|
|
th.start()
|
|
threads.append(th)
|
|
for th in threads:
|
|
th.join(timeout=25)
|
|
# viewer processes live in THIS container (open_rviz* spawns them here)
|
|
for name, pat in (("local_map_pub", "[l]ocal_map_pub.py"),
|
|
("relay", "[r]1_rosbridge_relay.py"),
|
|
("rviz2", "[r]viz2")):
|
|
try:
|
|
out["viewers"][name] = subprocess.run(
|
|
["pgrep", "-f", pat], capture_output=True).returncode == 0
|
|
except Exception:
|
|
out["viewers"][name] = None
|
|
return out
|
|
|
|
|
|
# ── background job (install/uninstall stream their output here) ──
|
|
JOB = {"lines": [], "running": False, "rc": None, "kind": "", "robot": ""}
|
|
JOB_LOCK = threading.Lock()
|
|
|
|
|
|
def _ssh_shim_dir():
|
|
"""ssh/scp wrappers that inject the docker-proof options (-i identity,
|
|
-F /dev/null ...). The robot installers call plain `ssh`/`scp`/`rsync`; with
|
|
this dir first in PATH they work unmodified inside the GUI container.
|
|
(scp execs its OWN compiled-in ssh, hence a scp shim too; rsync finds `ssh`
|
|
via PATH.)"""
|
|
d = "/tmp/pudu_gui_sshshim"
|
|
os.makedirs(d, exist_ok=True)
|
|
opts = " ".join(_ssh_base(""))
|
|
for tool in ("ssh", "scp"):
|
|
p = os.path.join(d, tool)
|
|
with open(p, "w") as f:
|
|
f.write(f"#!/bin/sh\nexec /usr/bin/{tool} {opts} \"$@\"\n")
|
|
os.chmod(p, 0o755)
|
|
return d
|
|
|
|
|
|
def _job_start(kind, robot, argv):
|
|
with JOB_LOCK:
|
|
if JOB["running"]:
|
|
raise RuntimeError(f"a {JOB['kind']} job is already running ({JOB['robot']}) — wait for it")
|
|
JOB.update({"lines": [f"$ {' '.join(argv)}"], "running": True,
|
|
"rc": None, "kind": kind, "robot": robot})
|
|
env = dict(os.environ)
|
|
env["PATH"] = _ssh_shim_dir() + os.pathsep + env.get("PATH", "")
|
|
|
|
def run():
|
|
p = None
|
|
try:
|
|
# errors='replace': one non-UTF-8 byte from a docker build must not
|
|
# kill the reader (it would orphan the installer on a filling pipe)
|
|
p = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
encoding="utf-8", errors="replace", bufsize=1, env=env)
|
|
for line in p.stdout:
|
|
JOB["lines"].append(line.rstrip("\n"))
|
|
JOB["rc"] = p.wait()
|
|
except Exception as e:
|
|
JOB["lines"].append(f"[job] {type(e).__name__}: {e}")
|
|
JOB["rc"] = -1
|
|
if p is not None:
|
|
try:
|
|
p.kill(); p.wait(timeout=10)
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
JOB["lines"].append(f"[job] {kind} {robot}: "
|
|
+ ("DONE ✓" if JOB["rc"] == 0 else f"FAILED (rc={JOB['rc']})"))
|
|
JOB["running"] = False
|
|
threading.Thread(target=run, daemon=True).start()
|
|
return {"ok": True, "started": kind, "robot": robot}
|
|
|
|
|
|
def job_tail(since=0):
|
|
since = max(0, int(since))
|
|
# slice + count from ONE snapshot — computing len() separately would skip
|
|
# lines appended between the two reads (client resumes from n)
|
|
chunk = list(JOB["lines"][since:])
|
|
return {"ok": True, "lines": chunk, "n": since + len(chunk),
|
|
"running": JOB["running"], "rc": JOB["rc"], "kind": JOB["kind"],
|
|
"robot": JOB["robot"]}
|
|
|
|
|
|
def install(robot, ip, user="unitree"):
|
|
"""Run the robot's one-shot installer (deploy + build ON the robot). Refuses
|
|
UNCONDITIONALLY while that robot's nav stack is live — the Go2 installer's
|
|
smoke step would docker-rm the running session. (No force bypass on purpose.)"""
|
|
spec = INSTALLERS[robot]
|
|
if not os.path.isfile(spec["script"]):
|
|
raise RuntimeError(f"installer missing: {spec['script']}")
|
|
st = install_status(robot, ip, user)
|
|
if not st["reachable"]:
|
|
raise RuntimeError(f"{robot} unreachable at {ip} ({st['detail']})")
|
|
if st["running"]:
|
|
raise RuntimeError(f"{robot} nav stack is RUNNING right now — stop it first "
|
|
"(installing would kill the live session)")
|
|
if robot == "r1" and "P_VSLAM" in st.get("marks", []):
|
|
raise RuntimeError("r1 vslam is RUNNING — install redeploys the mounted vslam "
|
|
"dirs; stop the stack first")
|
|
return _job_start("install", robot, ["bash", spec["script"], ip, user])
|
|
|
|
|
|
def uninstall(robot, ip, user="unitree"):
|
|
"""Remove ONLY what install() created. Always refuses while the stack runs —
|
|
stop it on the robot first (never yanks a live session, never sends motion)."""
|
|
spec = INSTALLERS[robot]
|
|
st = install_status(robot, ip, user)
|
|
if not st["reachable"]:
|
|
raise RuntimeError(f"{robot} unreachable at {ip} ({st['detail']})")
|
|
if st["running"]:
|
|
raise RuntimeError(f"{robot} nav stack is RUNNING — stop it first, then uninstall")
|
|
if robot == "r1" and "P_VSLAM" in st.get("marks", []):
|
|
raise RuntimeError("r1 vslam is RUNNING — uninstall removes the vslam side too; "
|
|
"stop the stack first")
|
|
if not any(m in st.get("marks", []) for m in ("P_DIR", "P_OLD", "P_IMG")):
|
|
raise RuntimeError(f"nothing to uninstall on {robot} @ {ip}")
|
|
argv = ["ssh"] + _ssh_base(ip) + [f"{user}@{ip}", spec["uninstall"]]
|
|
return _job_start("uninstall", robot, argv)
|
|
|
|
|
|
LAST_POSE_FILE = os.path.join(PROJECT_MAPS, ".last_pose.json")
|
|
|
|
|
|
def _remember_pose(robot, x, y, yaw):
|
|
try:
|
|
d = {}
|
|
if os.path.isfile(LAST_POSE_FILE):
|
|
d = json.load(open(LAST_POSE_FILE)) or {}
|
|
d[robot or "?"] = {"x": float(x), "y": float(y), "yaw": float(yaw),
|
|
"at": time.strftime("%Y-%m-%d %H:%M")}
|
|
with open(LAST_POSE_FILE, "w") as f:
|
|
json.dump(d, f)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def last_pose(robot):
|
|
try:
|
|
return (json.load(open(LAST_POSE_FILE)) or {}).get(robot or "?")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def localized(robot, ip, user="unitree"):
|
|
"""Is AMCL seeded? map->odom only exists after an /initialpose. Without it
|
|
RViz says 'Frame [map] does not exist' and the robot has no place on the
|
|
map — which happens after EVERY stack restart until you set the pose."""
|
|
_ident(ip, "ip"); _ident(user, "user")
|
|
name = {"g1": "g1-nav2", "go2": "go2-nav2", "r1": "r1-nav2"}.get(robot, "")
|
|
if not name:
|
|
return {"ok": True, "seeded": None, "note": "unknown robot"}
|
|
probe = (f"docker exec {name} bash -c 'source /opt/ros/foxy/setup.bash; "
|
|
"export ROS_DOMAIN_ID=42 RMW_IMPLEMENTATION=rmw_cyclonedds_cpp "
|
|
"CYCLONEDDS_URI=file:///cfg/cyclonedds.xml ROS_LOCALHOST_ONLY=1; "
|
|
"timeout 5 ros2 run tf2_ros tf2_echo map odom 2>/dev/null | grep -m1 Translation'")
|
|
r = subprocess.run(["ssh"] + _ssh_base(ip, timeout=6) + [f"{user}@{ip}", probe],
|
|
capture_output=True, text=True, timeout=40)
|
|
seeded = "Translation" in (r.stdout or "")
|
|
return {"ok": True, "seeded": seeded, "last_pose": last_pose(robot)}
|
|
|
|
|
|
def send_initialpose(ip, x, y, yaw=0.0, robot=""):
|
|
"""📍 Localize: publish /initialpose (frame 'map') via the robot's rosbridge —
|
|
same as RViz's 2D Pose Estimate. Tells AMCL/the localizer where the robot
|
|
ACTUALLY stands; never causes motion."""
|
|
import math
|
|
if not ip:
|
|
raise ValueError("robot IP required")
|
|
port = _rb_port(robot)
|
|
if not _port_open(ip, port):
|
|
raise RuntimeError(f"nothing is listening on rosbridge :{port} at {ip} — "
|
|
f"the {robot or 'robot'} nav stack is not running. "
|
|
f"Start it on the robot: {_start_hint(robot)}")
|
|
import roslibpy
|
|
# 8 s was too short whenever the stack is mid-lifecycle: rosbridge listens
|
|
# but the handshake waits behind the transition storm. Retry with a FRESH
|
|
# client each time — a roslibpy client whose run() failed cannot be reused
|
|
# (its factory stays half-torn-down and every later run() fails too).
|
|
client = None
|
|
for attempt, tmo in ((1, 12), (2, 20), (3, 30)):
|
|
c = roslibpy.Ros(host=ip, port=port)
|
|
try:
|
|
c.run(timeout=tmo)
|
|
if c.is_connected:
|
|
client = c
|
|
break
|
|
raise RuntimeError("connected=False")
|
|
except Exception:
|
|
try:
|
|
c.terminate()
|
|
except Exception:
|
|
pass
|
|
if attempt == 3:
|
|
raise RuntimeError(
|
|
f"rosbridge at {ip}:{port} accepted the port but never completed the "
|
|
"websocket handshake (the stack may still be starting) — try again")
|
|
time.sleep(1.5)
|
|
try:
|
|
topic = roslibpy.Topic(client, "/initialpose",
|
|
"geometry_msgs/PoseWithCovarianceStamped")
|
|
topic.advertise()
|
|
time.sleep(0.3)
|
|
cov = [0.0] * 36
|
|
cov[0] = cov[7] = 0.25
|
|
cov[35] = 0.068
|
|
topic.publish(roslibpy.Message({
|
|
"header": {"frame_id": "map"},
|
|
"pose": {"pose": {"position": {"x": float(x), "y": float(y), "z": 0.0},
|
|
"orientation": {"z": math.sin(float(yaw) / 2.0),
|
|
"w": math.cos(float(yaw) / 2.0)}},
|
|
"covariance": cov}}))
|
|
time.sleep(0.3)
|
|
topic.unadvertise()
|
|
finally:
|
|
client.terminate()
|
|
_remember_pose(robot, x, y, yaw)
|
|
return {"ok": True, "x": round(float(x), 3), "y": round(float(y), 3),
|
|
"yaw": round(float(yaw), 3)}
|
|
|
|
|
|
def send_goal(ip, x, y, yaw=0.0, robot=""):
|
|
"""Publish a nav goal (frame 'map') to the robot via its rosbridge — the same
|
|
path r1_map.html uses. The robot only MOVES if the user armed it (R1: FSM 811)
|
|
and Nav2 is running; every goal is a deliberate user click in the GUI."""
|
|
import math
|
|
if not ip:
|
|
raise ValueError("robot IP required")
|
|
port = _rb_port(robot)
|
|
if not _port_open(ip, port):
|
|
raise RuntimeError(f"nothing is listening on rosbridge :{port} at {ip} — "
|
|
f"the {robot or 'robot'} nav stack is not running. "
|
|
f"Start it on the robot: {_start_hint(robot)}")
|
|
import roslibpy
|
|
# 8 s was too short whenever the stack is mid-lifecycle: rosbridge listens
|
|
# but the handshake waits behind the transition storm. Retry with a FRESH
|
|
# client each time — a roslibpy client whose run() failed cannot be reused
|
|
# (its factory stays half-torn-down and every later run() fails too).
|
|
client = None
|
|
for attempt, tmo in ((1, 12), (2, 20), (3, 30)):
|
|
c = roslibpy.Ros(host=ip, port=port)
|
|
try:
|
|
c.run(timeout=tmo)
|
|
if c.is_connected:
|
|
client = c
|
|
break
|
|
raise RuntimeError("connected=False")
|
|
except Exception:
|
|
try:
|
|
c.terminate()
|
|
except Exception:
|
|
pass
|
|
if attempt == 3:
|
|
raise RuntimeError(
|
|
f"rosbridge at {ip}:{port} accepted the port but never completed the "
|
|
"websocket handshake (the stack may still be starting) — try again")
|
|
time.sleep(1.5)
|
|
try:
|
|
topic = roslibpy.Topic(client, "/goal_pose", "geometry_msgs/PoseStamped")
|
|
topic.advertise()
|
|
time.sleep(0.3) # let the advertise register robot-side
|
|
topic.publish(roslibpy.Message({
|
|
"header": {"frame_id": "map"},
|
|
"pose": {"position": {"x": float(x), "y": float(y), "z": 0.0},
|
|
"orientation": {"z": math.sin(float(yaw) / 2.0),
|
|
"w": math.cos(float(yaw) / 2.0)}}}))
|
|
time.sleep(0.3) # flush before teardown
|
|
topic.unadvertise()
|
|
finally:
|
|
client.terminate()
|
|
return {"ok": True, "x": round(float(x), 3), "y": round(float(y), 3),
|
|
"yaw": round(float(yaw), 3)}
|
|
|
|
|
|
# ───────────────────────── map editor integration ─────────────────────────
|
|
# The full Office Map Editor already exists in the R1 tree (self-contained HTML,
|
|
# wall/free/unknown/line/room/fill tools, Nav2 palette). We serve THAT file,
|
|
# un-forked, with a shim injected at serve time: auto-load the GUI's current
|
|
# converted map + a "Save to Pudu GUI" button that posts the edit back.
|
|
EDITOR_HTML = os.path.join(WS, "Project/R1/nav/viz/web/map_editor.html")
|
|
|
|
EDITOR_SHIM = """
|
|
<script>
|
|
/* ── Pudu Map GUI shim (injected at serve time; the editor file is untouched) ── */
|
|
(async () => {
|
|
const b64ToBuf = b => Uint8Array.from(atob(b), c => c.charCodeAt(0)).buffer;
|
|
const bufToB64 = u8 => { let s = ''; for (let i = 0; i < u8.length; i += 32768)
|
|
s += String.fromCharCode.apply(null, u8.subarray(i, i + 32768)); return btoa(s); };
|
|
try {
|
|
const r = await fetch('/api/editor_map', { method: 'POST', body: '{}' });
|
|
const j = await r.json();
|
|
if (j.ok && typeof importPGM === 'function') {
|
|
// mirror the editor's own file-load done() sequence EXACTLY — importPGM only
|
|
// fills the grid; without rebuild()+fit() the canvas keeps the seed bitmap
|
|
importPGM(b64ToBuf(j.pgm_b64));
|
|
if (typeof parseYAML === 'function') parseYAML(j.yaml_text);
|
|
if (typeof undoStack !== 'undefined') { undoStack.length = 0; redoStack.length = 0; }
|
|
if (typeof rebuild === 'function') rebuild();
|
|
if (typeof flushBuffer === 'function') flushBuffer();
|
|
if (typeof fit === 'function') fit();
|
|
if (typeof syncMetaUI === 'function') syncMetaUI();
|
|
if (typeof updateHudStatic === 'function') updateHudStatic();
|
|
if (typeof draw === 'function') draw();
|
|
const mn = document.querySelector('#mapname'); if (mn) mn.value = 'pudu_map';
|
|
}
|
|
} catch (e) { console.warn('pudu shim: map load skipped', e); }
|
|
|
|
/* ── Transform panel: move + rotate the whole map (Ctrl+C toggles) ───────── */
|
|
const canXform = typeof map !== 'undefined' && typeof rebuild === 'function';
|
|
if (canXform) {
|
|
const UNKV = typeof UNK !== 'undefined' ? UNK : 205;
|
|
const refresh = (clearUndo) => {
|
|
if (clearUndo && typeof undoStack !== 'undefined') { undoStack.length = 0; redoStack.length = 0; }
|
|
rebuild(); if (typeof fit === 'function') fit();
|
|
if (typeof updateHudStatic === 'function') updateHudStatic();
|
|
if (typeof syncMetaUI === 'function') syncMetaUI();
|
|
if (typeof draw === 'function') draw();
|
|
};
|
|
const remap = (nw, nh, srcOf) => { // inverse-mapping resample
|
|
const out = new Uint8Array(nw * nh).fill(UNKV);
|
|
const { w, h, cells } = map;
|
|
for (let ny = 0; ny < nh; ny++) for (let nx = 0; nx < nw; nx++) {
|
|
const s = srcOf(nx, ny);
|
|
if (s && s[0] >= 0 && s[0] < w && s[1] >= 0 && s[1] < h)
|
|
out[ny * nw + nx] = cells[s[1] * w + s[0]];
|
|
}
|
|
map.w = nw; map.h = nh; map.cells = out;
|
|
};
|
|
const rot90cw = () => { const { w, h } = map; remap(h, w, (nx, ny) => [ny, h - 1 - nx]); refresh(true); };
|
|
const rot90ccw = () => { const { w, h } = map; remap(h, w, (nx, ny) => [w - 1 - ny, nx]); refresh(true); };
|
|
const rot180 = () => {
|
|
if (typeof snapshot === 'function') snapshot();
|
|
const { w, h } = map; remap(w, h, (nx, ny) => [w - 1 - nx, h - 1 - ny]); refresh(false);
|
|
};
|
|
const rotAngle = (deg) => { // screen-CW, canvas grows to fit, nearest-neighbor
|
|
const t = deg * Math.PI / 180, c = Math.cos(t), s = Math.sin(t);
|
|
const { w, h } = map;
|
|
const nw = Math.ceil(Math.abs(w * c) + Math.abs(h * s));
|
|
const nh = Math.ceil(Math.abs(w * s) + Math.abs(h * c));
|
|
const cxo = w / 2, cyo = h / 2, cxn = nw / 2, cyn = nh / 2;
|
|
remap(nw, nh, (nx, ny) => {
|
|
const dx = nx + 0.5 - cxn, dy = ny + 0.5 - cyn;
|
|
return [Math.floor(cxo + (c * dx + s * dy)), Math.floor(cyo + (-s * dx + c * dy))];
|
|
});
|
|
refresh(true);
|
|
};
|
|
const nudge = (dx, dy) => {
|
|
if (typeof snapshot === 'function') snapshot();
|
|
const { w, h } = map; remap(w, h, (nx, ny) => [nx - dx, ny - dy]); refresh(false);
|
|
};
|
|
|
|
const pnl = document.createElement('div');
|
|
pnl.style.cssText = 'position:fixed;left:16px;bottom:62px;z-index:9999;display:none;' +
|
|
'background:#1d2026;color:#e8e6e1;border:1px solid #2e323a;border-radius:9px;' +
|
|
'padding:12px 14px;font:12.5px system-ui;box-shadow:0 4px 16px rgba(0,0,0,.4);width:230px';
|
|
pnl.innerHTML =
|
|
'<div style="font-weight:700;margin-bottom:8px">Map transform <span style="float:right;opacity:.55;font-weight:400">Ctrl+C</span></div>' +
|
|
'<div style="display:flex;gap:6px;margin-bottom:7px">' +
|
|
'<button id="pxCCW">⟲ 90°</button><button id="pxCW">⟳ 90°</button><button id="px180">180°</button></div>' +
|
|
'<div style="display:flex;gap:6px;margin-bottom:7px;align-items:center">' +
|
|
'<input id="pxAng" type="number" value="5" step="0.5" style="width:62px"> °' +
|
|
'<button id="pxRotP">⟳</button><button id="pxRotM">⟲</button>' +
|
|
'<span style="opacity:.6">rotate</span></div>' +
|
|
'<div style="display:grid;grid-template-columns:repeat(3,34px);gap:4px;justify-content:start;margin-bottom:7px">' +
|
|
'<span></span><button id="pxUp">▲</button><span></span>' +
|
|
'<button id="pxLeft">◀</button><input id="pxStep" type="number" value="5" style="width:34px;text-align:center" title="cells per nudge">' +
|
|
'<button id="pxRight">▶</button><span></span><button id="pxDown">▼</button><span></span></div>' +
|
|
'<div style="opacity:.6;line-height:1.45">Rotations resize the canvas + clear undo. ' +
|
|
'Origin (X/Y) is NOT auto-adjusted — re-check Map geometry after transforming.</div>';
|
|
pnl.querySelectorAll('button').forEach(b => b.style.cssText =
|
|
'background:#262a31;color:#e8e6e1;border:1px solid #2e323a;border-radius:6px;padding:5px 9px;cursor:pointer;font:inherit');
|
|
pnl.querySelectorAll('input').forEach(i => i.style.cssText +=
|
|
';background:#0e1013;color:#e8e6e1;border:1px solid #2e323a;border-radius:5px;padding:3px 5px');
|
|
document.body.appendChild(pnl);
|
|
const q = id => pnl.querySelector('#' + id);
|
|
q('pxCW').onclick = rot90cw; q('pxCCW').onclick = rot90ccw; q('px180').onclick = rot180;
|
|
q('pxRotP').onclick = () => rotAngle(+q('pxAng').value || 0);
|
|
q('pxRotM').onclick = () => rotAngle(-(+q('pxAng').value || 0));
|
|
const st = () => Math.max(1, Math.round(+q('pxStep').value || 1));
|
|
q('pxUp').onclick = () => nudge(0, -st()); q('pxDown').onclick = () => nudge(0, st());
|
|
q('pxLeft').onclick = () => nudge(-st(), 0); q('pxRight').onclick = () => nudge(st(), 0);
|
|
addEventListener('keydown', e => {
|
|
if (e.ctrlKey && (e.key === 'c' || e.key === 'C') &&
|
|
!/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
|
|
e.preventDefault();
|
|
pnl.style.display = pnl.style.display === 'none' ? 'block' : 'none';
|
|
}
|
|
if (e.key === 'Escape') pnl.style.display = 'none';
|
|
});
|
|
const tbtn = document.createElement('button');
|
|
tbtn.textContent = '⤨ Transform';
|
|
tbtn.style.cssText = 'position:fixed;left:16px;bottom:16px;z-index:9998;' +
|
|
'background:#262a31;color:#e8e6e1;border:1px solid #2e323a;border-radius:7px;' +
|
|
'padding:9px 13px;font:600 12.5px system-ui;cursor:pointer';
|
|
tbtn.onclick = () => { pnl.style.display = pnl.style.display === 'none' ? 'block' : 'none'; };
|
|
document.body.appendChild(tbtn);
|
|
}
|
|
|
|
const btn = document.createElement('button');
|
|
btn.textContent = '💾 Save to Pudu GUI';
|
|
btn.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:9999;' +
|
|
'background:#e8a13d;color:#14161a;border:0;border-radius:7px;padding:10px 16px;' +
|
|
'font:600 13px system-ui;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.35)';
|
|
btn.onclick = async () => {
|
|
try {
|
|
if (typeof readMeta === 'function') readMeta();
|
|
const pgm = buildPGM();
|
|
const u8 = pgm instanceof Uint8Array ? pgm : new Uint8Array(pgm);
|
|
const body = JSON.stringify({ pgm_b64: bufToB64(u8), yaml: buildYAML('map') });
|
|
const r = await fetch('/api/editor_save', { method: 'POST', body });
|
|
const j = await r.json();
|
|
if (!j.ok) throw new Error(j.error || 'save failed');
|
|
btn.textContent = '💾 Saved ✓';
|
|
setTimeout(() => btn.textContent = '💾 Save to Pudu GUI', 1500);
|
|
if (window.parent !== window) window.parent.postMessage('pudu-map-saved', '*');
|
|
} catch (e) { alert('Save to Pudu GUI failed: ' + e.message); }
|
|
};
|
|
document.body.appendChild(btn);
|
|
})();
|
|
</script>
|
|
"""
|
|
|
|
|
|
def editor_page():
|
|
with open(EDITOR_HTML, encoding="utf-8") as f:
|
|
html = f.read()
|
|
if "</body>" in html:
|
|
html = html.replace("</body>", EDITOR_SHIM + "</body>", 1)
|
|
else:
|
|
html += EDITOR_SHIM
|
|
return html.encode()
|
|
|
|
|
|
def editor_map():
|
|
c = _require_map()
|
|
with open(c["pgm"], "rb") as f:
|
|
pgm = f.read()
|
|
with open(c["yaml"], encoding="utf-8") as f:
|
|
yml = f.read()
|
|
return {"ok": True, "pgm_b64": base64.b64encode(pgm).decode(), "yaml_text": yml}
|
|
|
|
|
|
def editor_save(pgm_b64, yaml_text):
|
|
"""Write the edited map back into the converted set: map.pgm/.yaml, rebuild the
|
|
keepout-baked pair, refresh the preview payload. First save keeps map_orig.pgm."""
|
|
c = _require_map()
|
|
payload = STATE.get("payload")
|
|
if not payload:
|
|
raise RuntimeError("convert or load a map first")
|
|
# MODIFIED maps always live in the project library: a set loaded from a stage
|
|
# dir / Downloads / a robot save is relocated there before the edit lands
|
|
# (editing a staged copy in place would silently mutate a robot deliverable —
|
|
# re-stage/deploy from the library copy instead).
|
|
lib = os.path.realpath(PROJECT_MAPS)
|
|
d = os.path.realpath(os.path.dirname(c["pgm"]))
|
|
if d != lib and not d.startswith(lib + os.sep):
|
|
base = os.path.basename(d)
|
|
if base in ("gui_converted", "maps"):
|
|
base = os.path.basename(os.path.dirname(d))
|
|
tgt = os.path.join(PROJECT_MAPS, _safe_name(base))
|
|
if os.path.isdir(tgt):
|
|
tgt += time.strftime("_%Y%m%d_%H%M%S")
|
|
os.makedirs(tgt, exist_ok=True)
|
|
for f in dict.fromkeys(MAP_FILES + ["map.pgm.orig",
|
|
os.path.basename(c["pgm"]),
|
|
os.path.basename(c["yaml"])]):
|
|
q = os.path.join(d, f)
|
|
if os.path.isfile(q):
|
|
shutil.copy(q, tgt)
|
|
c["out"] = tgt
|
|
c["pgm"] = os.path.join(tgt, os.path.basename(c["pgm"]))
|
|
c["yaml"] = os.path.join(tgt, os.path.basename(c["yaml"]))
|
|
payload["out_dir"] = tgt
|
|
payload["yaml"] = c["yaml"]
|
|
out = os.path.dirname(c["pgm"])
|
|
orig = c["pgm"] + ".orig"
|
|
if not os.path.exists(orig) and os.path.exists(c["pgm"]):
|
|
shutil.copy(c["pgm"], orig)
|
|
|
|
with open(c["pgm"], "wb") as f:
|
|
f.write(base64.b64decode(pgm_b64))
|
|
m = yaml.safe_load(yaml_text)
|
|
res = float(m["resolution"])
|
|
ox, oy = float(m["origin"][0]), float(m["origin"][1])
|
|
_write_map_yaml(c["yaml"], os.path.basename(c["pgm"]), res, ox, oy, "trinary")
|
|
|
|
gray = np.array(Image.open(c["pgm"]).convert("L"))
|
|
H, W = gray.shape
|
|
mask_path = os.path.join(out, "keepout_mask.pgm")
|
|
baked = gray.copy()
|
|
mask_ok = False
|
|
if os.path.isfile(mask_path):
|
|
mask = np.array(Image.open(mask_path))
|
|
if mask.shape == gray.shape:
|
|
baked[mask == 0] = 0
|
|
mask_ok = True
|
|
# canvas was resized in the editor -> old mask no longer aligns; skip it
|
|
Image.fromarray(baked, "L").save(os.path.join(out, "map_keepout_baked.pgm"))
|
|
_write_map_yaml(os.path.join(out, "map_keepout_baked.yaml"),
|
|
"map_keepout_baked.pgm", res, ox, oy, "trinary")
|
|
|
|
payload["meta"].update({"W": W, "H": H, "res": res, "ox": ox, "oy": oy,
|
|
"free_m2": round(float(_free_mask(gray).sum()) * res * res),
|
|
"size_m": [round(W * res, 1), round(H * res, 1)]})
|
|
payload["layers"] = _preview_layers(gray, mask_path if mask_ok else None)
|
|
_save_current()
|
|
return payload
|
|
|
|
|
|
ENV_CLEAN = ("unset PYTHONHOME PYTHONPATH CONDA_PREFIX; "
|
|
"source /opt/ros/jazzy/setup.bash; unset ROS_DOMAIN_ID; ")
|
|
|
|
|
|
def _port_open(ip, port, timeout=2.0):
|
|
import socket
|
|
try:
|
|
with socket.create_connection((ip, port), timeout=timeout):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def open_rviz_local():
|
|
"""Preview the CONVERTED map in RViz with zero robot dependency: a small
|
|
local rclpy node republishes the map on /map, RViz just displays it."""
|
|
c = _require_map()
|
|
map_yaml = c["yaml"]
|
|
pub = os.path.join(HERE, "local_map_pub.py")
|
|
rviz_cfg = os.path.join(WS, "Project/R1/nav/viz/vslam_lite.rviz")
|
|
subprocess.run(["pkill", "-f", "[l]ocal_map_pub.py"], capture_output=True)
|
|
subprocess.run(["pkill", "-f", "[r]1_rosbridge_relay.py"], capture_output=True)
|
|
subprocess.run(["pkill", "-x", "rviz2"], capture_output=True)
|
|
cmd = (ENV_CLEAN +
|
|
f"nohup /usr/bin/python3.12 '{pub}' '{map_yaml}' >/tmp/pudu_local_map.log 2>&1 & "
|
|
f"sleep 1; DISPLAY=${{DISPLAY:-:1}} nohup rviz2 -d '{rviz_cfg}' >/tmp/pudu_rviz_local.log 2>&1 &")
|
|
subprocess.Popen(["bash", "-c", cmd])
|
|
return {"ok": True,
|
|
"note": f"local preview: publishing {os.path.basename(c['out'])}/map.yaml on /map + RViz "
|
|
"(no robot needed; TF warnings in RViz are normal here)"}
|
|
|
|
|
|
def open_rviz_combo(robot, ip):
|
|
"""RViz with the GUI's LOADED map + the LIVE robot: a local publisher owns /map
|
|
(whatever map is loaded in the dashboard), the relay brings TF/odom and forwards
|
|
RViz's 2D Pose Estimate + 2D Goal Pose to the robot — map choice stays yours."""
|
|
c = _require_map()
|
|
if not ip:
|
|
raise ValueError("robot IP required")
|
|
port = _rb_port(robot)
|
|
if not _port_open(ip, port):
|
|
raise RuntimeError(f"rosbridge :{port} not reachable at {ip} — the {robot or 'robot'} "
|
|
f"stack is down. Start it ({_start_hint(robot)}) for the live view, "
|
|
"or use 'RViz: preview converted map' which needs no robot")
|
|
pub = os.path.join(HERE, "local_map_pub.py")
|
|
relay = os.path.join(WS, "Project/R1/nav/viz/r1_rosbridge_relay.py")
|
|
rviz_cfg = os.path.join(WS, "Project/R1/nav/viz/vslam_lite.rviz")
|
|
subprocess.run(["pkill", "-f", "[l]ocal_map_pub.py"], capture_output=True)
|
|
subprocess.run(["pkill", "-f", "[r]1_rosbridge_relay.py"], capture_output=True)
|
|
subprocess.run(["pkill", "-f", "[l]ocal_map_pub.py"], capture_output=True)
|
|
subprocess.run(["pkill", "-f", "[r]1_rosbridge_relay.py"], capture_output=True)
|
|
subprocess.run(["pkill", "-x", "rviz2"], capture_output=True)
|
|
cmd = (ENV_CLEAN +
|
|
f"nohup /usr/bin/python3.12 '{pub}' '{c['yaml']}' >/tmp/combo_map.log 2>&1 & "
|
|
f"nohup /usr/bin/python3.12 '{relay}' {ip} --no-map --port {port} >/tmp/combo_relay.log 2>&1 & "
|
|
f"sleep 3; DISPLAY=${{DISPLAY:-:1}} nohup rviz2 -d '{rviz_cfg}' >/tmp/combo_rviz.log 2>&1 &")
|
|
subprocess.Popen(["bash", "-c", cmd])
|
|
return {"ok": True,
|
|
"note": f"combo view: LOADED map ({os.path.basename(os.path.dirname(c['yaml']))}/"
|
|
f"{os.path.basename(c['yaml'])}) + live robot from {ip} — goals/initialpose "
|
|
"forwarded; make sure the robot actually RUNS this map in nav mode, or poses "
|
|
"won't line up"}
|
|
|
|
|
|
def open_rviz(robot, ip):
|
|
"""Workstation-side live viewer: rosbridge relay + RViz for ALL robots
|
|
(live map, 2D Pose Estimate -> localizer, 2D Goal Pose -> Nav2).
|
|
g1 = nav_g1's own rosbridge :9091 — Sanad's :9090 is never used."""
|
|
if not ip:
|
|
raise ValueError(f"{robot} RViz needs the robot IP (rosbridge relay)")
|
|
port = _rb_port(robot)
|
|
if not _port_open(ip, port):
|
|
hint = {"r1": "start_r1_nav.sh",
|
|
"go2": "~/nav_go2/run.sh nav (or slam) — rosbridge runs inside it",
|
|
"g1": "~/nav_g1/scripts/bringup.sh — its rosbridge (:9091) starts with it"
|
|
}.get(robot, "the robot nav stack")
|
|
raise RuntimeError(
|
|
f"rosbridge :{port} not reachable at {ip} — the robot stack is down. "
|
|
f"Start it ({hint}) for the LIVE view, or use "
|
|
"'RViz: preview converted map' which needs no robot")
|
|
relay = os.path.join(WS, "Project/R1/nav/viz/r1_rosbridge_relay.py")
|
|
rviz_cfg = os.path.join(WS, "Project/R1/nav/viz/vslam_lite.rviz")
|
|
subprocess.run(["pkill", "-f", "[l]ocal_map_pub.py"], capture_output=True)
|
|
subprocess.run(["pkill", "-f", "[r]1_rosbridge_relay.py"], capture_output=True)
|
|
subprocess.run(["pkill", "-x", "rviz2"], capture_output=True)
|
|
cmd = (ENV_CLEAN +
|
|
f"nohup /usr/bin/python3.12 '{relay}' {ip} --port {port} >/tmp/{robot}_relay_gui.log 2>&1 & "
|
|
f"sleep 3; DISPLAY=${{DISPLAY:-:1}} nohup rviz2 -d '{rviz_cfg}' >/tmp/{robot}_rviz_gui.log 2>&1 &")
|
|
subprocess.Popen(["bash", "-c", cmd])
|
|
return {"ok": True, "note": f"relay + RViz launched ({robot} rosbridge :{port} confirmed reachable)"}
|
|
|
|
|
|
# ───────────────────────── http plumbing ─────────────────────────
|
|
|
|
# ── AUTH ─────────────────────────────────────────────────────────────────────
|
|
# This dashboard ssh's into the robots, starts/stops their nav stacks, deletes
|
|
# maps, opens RViz on THIS machine's X display and (G1) arms the gait. Exposed on
|
|
# a LAN with no auth, anyone on the wifi can do all of that — which is exactly
|
|
# what happened: a stranger drove this dashboard and it looked like the laptop
|
|
# being remote-controlled. So: no token, no LAN.
|
|
# PUDU_TOKEN=<secret> required whenever PUDU_BIND is not loopback.
|
|
# Loopback stays open with no token: a local browser is already the trust boundary.
|
|
AUTH_TOKEN = os.environ.get("PUDU_TOKEN", "").strip()
|
|
BIND_ADDR = os.environ.get("PUDU_BIND", "127.0.0.1").strip() or "127.0.0.1"
|
|
# PUDU_OPEN=1 = deliberately serve the LAN with NO key (operator's explicit choice).
|
|
# Without it, exposing the port with no token FAILS CLOSED rather than silently
|
|
# handing the robots to the network — an accident should never open the door.
|
|
AUTH_OPEN = os.environ.get("PUDU_OPEN", "").strip() in ("1", "true", "yes")
|
|
AUTH_REQUIRED = (BIND_ADDR not in ("127.0.0.1", "localhost", "::1")) and not AUTH_OPEN
|
|
|
|
|
|
def _client_ok(headers, path, cookie_hdr):
|
|
"""Token from the Authorization header, ?t=, or the pudu_t cookie."""
|
|
if not AUTH_REQUIRED:
|
|
return True
|
|
if not AUTH_TOKEN:
|
|
return False # exposed without a token: refuse everything
|
|
got = ""
|
|
auth = headers.get("Authorization", "")
|
|
if auth.startswith("Bearer "):
|
|
got = auth[7:].strip()
|
|
if not got and "t=" in (path or ""):
|
|
from urllib.parse import urlparse, parse_qs
|
|
got = (parse_qs(urlparse(path).query).get("t") or [""])[0]
|
|
if not got and cookie_hdr:
|
|
for part in cookie_hdr.split(";"):
|
|
k, _, v = part.strip().partition("=")
|
|
if k == "pudu_t":
|
|
got = v
|
|
break
|
|
return bool(got) and hmac.compare_digest(got, AUTH_TOKEN)
|
|
|
|
|
|
LOGIN_PAGE = """<!doctype html><meta charset=utf-8>
|
|
<title>Pudu Map GUI - sign in</title>
|
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
|
<style>
|
|
body{background:#14161a;color:#e8e6e3;font:14px/1.5 system-ui,sans-serif;
|
|
display:grid;place-items:center;height:100vh;margin:0}
|
|
form{background:#1c1f24;border:1px solid #2a2f36;border-radius:10px;padding:26px 28px;
|
|
width:min(90vw,340px)}
|
|
h1{font-size:15px;margin:0 0 4px} p{color:#8b939e;font-size:12px;margin:0 0 16px}
|
|
input{width:100%;box-sizing:border-box;background:#0f1114;border:1px solid #2a2f36;
|
|
color:#e8e6e3;border-radius:6px;padding:9px 11px;font-size:14px}
|
|
button{width:100%;margin-top:11px;background:#e8a33d;color:#14161a;border:0;
|
|
border-radius:6px;padding:9px;font-weight:600;font-size:14px;cursor:pointer}
|
|
.err{color:#e05c5c;font-size:12px;margin-top:10px;display:none}
|
|
</style>
|
|
<form onsubmit="go(event)">
|
|
<h1>Pudu Map GUI</h1>
|
|
<p>This dashboard can drive the robots. Enter the access key.</p>
|
|
<input id=k type=password autofocus autocomplete=current-password placeholder="access key">
|
|
<button>Sign in</button>
|
|
<div class=err id=e>Wrong key.</div>
|
|
</form>
|
|
<script>
|
|
async function go(ev){
|
|
ev.preventDefault();
|
|
const k = document.getElementById('k').value.trim();
|
|
if(!k) return;
|
|
// Ask the server to validate + set the cookie, then land on the clean URL.
|
|
const r = await fetch('/login', {method:'POST', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({key:k})});
|
|
if(r.ok){ location.replace('/'); }
|
|
else { document.getElementById('e').style.display='block'; }
|
|
}
|
|
</script>"""
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def log_message(self, fmt, *a):
|
|
# ACCESS LOG. The container runs --rm, so an in-memory-only log dies with
|
|
# it — that is why "who was pressing my buttons?" was unanswerable. Write
|
|
# to the project dir so it survives.
|
|
try:
|
|
line = "%s %s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
self.client_address[0], fmt % a)
|
|
with open(os.path.join(HERE, "access.log"), "a") as f:
|
|
f.write(line)
|
|
except Exception:
|
|
pass
|
|
|
|
def _deny(self):
|
|
# A BROWSER asking for a PAGE gets the login form, so the plain
|
|
# http://<host>:8777/ URL works — type the key once, the cookie carries it
|
|
# from then on. Anything else (fetch/XHR/curl) gets a clean 401.
|
|
wants_page = ("text/html" in (self.headers.get("Accept") or "")
|
|
and self.command == "GET")
|
|
if wants_page:
|
|
body = LOGIN_PAGE.encode()
|
|
self.send_response(401)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
else:
|
|
body = b'{"error":"unauthorized - open the dashboard and sign in"}'
|
|
self.send_response(401)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
self.log_message("DENIED %s", self.path)
|
|
|
|
def _authed(self):
|
|
return _client_ok(self.headers, self.path, self.headers.get("Cookie", ""))
|
|
|
|
def _json(self, obj, code=200):
|
|
body = json.dumps(obj).encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def _html(self, body):
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
# Opening the page as ...:8777/?t=TOKEN stores it, so the app's own
|
|
# fetch() calls (which cannot know the token) stay authorised.
|
|
if AUTH_REQUIRED and AUTH_TOKEN and "t=" in self.path:
|
|
self.send_header("Set-Cookie",
|
|
f"pudu_t={AUTH_TOKEN}; Path=/; SameSite=Strict; Max-Age=86400")
|
|
# no-store: a stale cached dashboard once ran OLD JS against a NEW
|
|
# backend — "button does nothing" with zero errors. Never again.
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
if not self._authed():
|
|
return self._deny()
|
|
path = self.path.split("?")[0] # ?v=... cache-busters must still match
|
|
if path in ("/", "/index.html"):
|
|
with open(os.path.join(HERE, "index.html"), "rb") as f:
|
|
self._html(f.read())
|
|
elif path == "/editor":
|
|
self._html(editor_page())
|
|
elif path == "/api/robots":
|
|
self._json({k: {kk: vv for kk, vv in v.items() if kk != "stage_dir"}
|
|
| {"stage_dir": v["stage_dir"]} for k, v in ROBOTS.items()})
|
|
else:
|
|
self._json({"error": "not found"}, 404)
|
|
|
|
def do_POST(self):
|
|
if self.path.split("?")[0] == "/login":
|
|
# the ONE unauthenticated endpoint: it grants the cookie, nothing else
|
|
try:
|
|
n = int(self.headers.get("Content-Length", 0))
|
|
key = (json.loads(self.rfile.read(n) or b"{}").get("key") or "").strip()
|
|
except Exception:
|
|
key = ""
|
|
if AUTH_TOKEN and hmac.compare_digest(key, AUTH_TOKEN):
|
|
self.send_response(204)
|
|
self.send_header("Set-Cookie",
|
|
f"pudu_t={AUTH_TOKEN}; Path=/; SameSite=Strict; Max-Age=604800")
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
self.log_message("LOGIN ok")
|
|
else:
|
|
self.log_message("LOGIN FAILED")
|
|
self.send_response(403)
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
return
|
|
if not self._authed():
|
|
return self._deny()
|
|
n = int(self.headers.get("Content-Length", 0))
|
|
try:
|
|
req = json.loads(self.rfile.read(n) or b"{}")
|
|
if self.path == "/api/pick":
|
|
self._json(pick_export(req.get("mode", "dir"), req.get("start", "")))
|
|
elif self.path == "/api/convert":
|
|
self._json(convert(req["path"]))
|
|
elif self.path == "/api/stage":
|
|
self._json(stage(req["robot"]))
|
|
elif self.path == "/api/deploy":
|
|
self._json(deploy(req["robot"], req.get("ip", ""),
|
|
req.get("user", "unitree"), req.get("dest", "")))
|
|
elif self.path == "/api/ping":
|
|
self._json(ping(req["ip"]))
|
|
elif self.path == "/api/install_status":
|
|
self._json(install_status(req["robot"], req.get("ip", ""),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/install":
|
|
self._json(install(req["robot"], req.get("ip", ""),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/uninstall":
|
|
self._json(uninstall(req["robot"], req.get("ip", ""),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/job":
|
|
self._json(job_tail(req.get("since", 0)))
|
|
elif self.path == "/api/stack_start":
|
|
self._json(stack_start(req["robot"], req.get("ip", ""),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/rosbridge_up":
|
|
self._json(rosbridge_up(req["robot"], req.get("ip", "")))
|
|
elif self.path == "/api/stack_log":
|
|
self._json(stack_log(req["robot"], req.get("ip", ""),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/arm":
|
|
self._json(drive_action(req.get("robot", "g1"), req.get("ip", ""),
|
|
req.get("action", "arm"),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/nav_ready":
|
|
self._json(nav_ready(req.get("robot", "g1"), req.get("ip", ""),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/drive_state":
|
|
self._json(drive_state(req.get("robot", "g1"), req.get("ip", ""),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/stack_stop":
|
|
self._json(stack_stop(req["robot"], req.get("ip", ""),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/robot_dest":
|
|
self._json({"ok": True, "dest": robot_dest_for(req["robot"])})
|
|
elif self.path == "/api/robot_map_thumb":
|
|
self._json(robot_map_thumb(req["robot"], req.get("ip", ""), req["yaml"],
|
|
req.get("md5", ""), req.get("user", "unitree")))
|
|
elif self.path == "/api/robot_map_import":
|
|
self._json(robot_map_import(req["robot"], req.get("ip", ""), req["yaml"],
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/robot_maps":
|
|
self._json(robot_maps(req["robot"], req.get("ip", ""),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/robot_map_delete":
|
|
self._json(robot_map_delete(req["robot"], req.get("ip", ""), req["name"],
|
|
req.get("user", "unitree"),
|
|
bool(req.get("force"))))
|
|
elif self.path == "/api/robot_map_rename":
|
|
self._json(robot_map_rename(req["robot"], req.get("ip", ""), req["name"],
|
|
req["new"], req.get("user", "unitree"),
|
|
bool(req.get("force"))))
|
|
elif self.path == "/api/map_rename":
|
|
self._json(rename_map(req["yaml"], req["new"]))
|
|
elif self.path == "/api/robot_use_map":
|
|
self._json(robot_use_map(req["robot"], req.get("ip", ""), req["name"],
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/robot_ips":
|
|
self._json(robot_ips(req.get("ip", ""), req.get("user", "unitree")))
|
|
elif self.path == "/api/localized":
|
|
self._json(localized(req["robot"], req.get("ip", ""),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/last_pose":
|
|
self._json({"ok": True, "pose": last_pose(req.get("robot", ""))})
|
|
elif self.path == "/api/conn":
|
|
self._json(conn_status(req["robot"], req.get("ip", ""),
|
|
req.get("user", "unitree")))
|
|
elif self.path == "/api/shutdown":
|
|
self._json(shutdown_all(req.get("targets")))
|
|
elif self.path == "/api/status":
|
|
self._json(status(req.get("targets")))
|
|
elif self.path == "/api/rviz":
|
|
self._json(open_rviz(req["robot"], req.get("ip", "")))
|
|
elif self.path == "/api/rviz_local":
|
|
self._json(open_rviz_local())
|
|
elif self.path == "/api/rviz_combo":
|
|
self._json(open_rviz_combo(req.get("robot", ""), req.get("ip", "")))
|
|
elif self.path == "/api/goal":
|
|
self._json(send_goal(req.get("ip", ""), req["x"], req["y"],
|
|
req.get("yaw", 0.0), req.get("robot", "")))
|
|
elif self.path == "/api/initialpose":
|
|
self._json(send_initialpose(req.get("ip", ""), req["x"], req["y"],
|
|
req.get("yaw", 0.0), req.get("robot", "")))
|
|
elif self.path == "/api/editor_map":
|
|
self._json(editor_map())
|
|
elif self.path == "/api/editor_save":
|
|
self._json(editor_save(req["pgm_b64"], req["yaml"]))
|
|
elif self.path == "/api/current":
|
|
self._json(STATE.get("payload") or {"ok": False, "error": "no map converted yet"})
|
|
elif self.path == "/api/maps":
|
|
self._json(list_maps())
|
|
elif self.path == "/api/load":
|
|
self._json(load_map(req["yaml"]))
|
|
elif self.path == "/api/map_delete":
|
|
self._json(delete_map(req["yaml"]))
|
|
elif self.path == "/api/map_thumb":
|
|
self._json(map_thumb(req["yaml"], req.get("size", 220)))
|
|
else:
|
|
self._json({"error": "not found"}, 404)
|
|
except Exception as e:
|
|
traceback.print_exc()
|
|
self._json({"ok": False, "error": f"{type(e).__name__}: {e}"}, 500)
|
|
|
|
|
|
def _reap_children():
|
|
"""The viewer launchers fire-and-forget; their exited shells (and any RViz
|
|
re-parented onto us) would sit as zombies forever because this process is
|
|
the container's init. Reap them periodically — cosmetic, but it keeps
|
|
`pgrep rviz2` honest."""
|
|
while True:
|
|
try:
|
|
while os.waitpid(-1, os.WNOHANG)[0]:
|
|
pass
|
|
except ChildProcessError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
time.sleep(20)
|
|
|
|
|
|
def main():
|
|
# restore the last loaded map — container restarts must not lose the session
|
|
try:
|
|
if os.path.isfile(CURRENT_PTR):
|
|
y = (json.load(open(CURRENT_PTR)) or {}).get("yaml", "")
|
|
if y and os.path.isfile(y):
|
|
load_map(y)
|
|
print(f"[pudu_gui] restored current map: {y}")
|
|
except Exception as e:
|
|
print(f"[pudu_gui] current-map restore skipped: {e}")
|
|
threading.Thread(target=_reap_children, daemon=True).start()
|
|
# BIND: localhost by default. Set PUDU_BIND to expose it on the LAN, e.g.
|
|
# PUDU_BIND=10.255.254.83 (this workstation's wifi address)
|
|
# PUDU_BIND=0.0.0.0 (every interface)
|
|
# ⚠ THIS DASHBOARD HAS NO AUTHENTICATION and it can ssh into the robots, start
|
|
# and stop their nav stacks, delete maps and — on the G1 — arm the gait. Anyone
|
|
# who can reach this port can do all of that. Only expose it on a network you
|
|
# trust, and prefer naming ONE interface over 0.0.0.0.
|
|
bind = os.environ.get("PUDU_BIND", "127.0.0.1").strip() or "127.0.0.1"
|
|
srv = ThreadingHTTPServer((bind, PORT), Handler)
|
|
shown = "127.0.0.1" if bind in ("0.0.0.0", "::") else bind
|
|
url = f"http://{shown}:{PORT}"
|
|
print(f"[pudu_gui] serving {url} (bound {bind}:{PORT}; Ctrl+C to stop)")
|
|
if bind not in ("127.0.0.1", "localhost"):
|
|
print("[pudu_gui] WARNING: reachable from the network and NOT authenticated — "
|
|
"it can drive the robots. Keep it on a trusted LAN.")
|
|
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
|
|
try:
|
|
srv.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\n[pudu_gui] bye")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|