Update 2026-07-23 11:13:07
This commit is contained in:
parent
1d354ad2a3
commit
28ef7a6893
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,3 +2,5 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
Logs/
|
Logs/
|
||||||
*.log
|
*.log
|
||||||
|
.pudu_token
|
||||||
|
access.log
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
{"yaml": "/home/zedx/Robotics_workspace/yslootahtech/Project/G1/Nav2_Projects/sanad_nav3/maps/pudu_modified/map.yaml"}
|
{"yaml": "/home/zedx/Robotics_workspace/yslootahtech/Project/Other/pudu_map_gui/maps/pudu_modified/map.yaml"}
|
||||||
11
maps/pudu_modified/annotations.yaml
Normal file
11
maps/pudu_modified/annotations.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
home_points:
|
||||||
|
- name: W
|
||||||
|
x: 0
|
||||||
|
y: 0.01
|
||||||
|
yaw: 0.00999633
|
||||||
|
- name: N
|
||||||
|
x: 1.27
|
||||||
|
y: -4.19
|
||||||
|
yaw: -0.38
|
||||||
|
charge_stations: []
|
||||||
|
areas: []
|
||||||
BIN
maps/pudu_modified/map.pgm
Normal file
BIN
maps/pudu_modified/map.pgm
Normal file
Binary file not shown.
BIN
maps/pudu_modified/map.pgm.orig
Normal file
BIN
maps/pudu_modified/map.pgm.orig
Normal file
Binary file not shown.
7
maps/pudu_modified/map.yaml
Normal file
7
maps/pudu_modified/map.yaml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
image: map.pgm
|
||||||
|
mode: trinary
|
||||||
|
resolution: 0.05
|
||||||
|
origin: [-5.85, -11.8, 0.0]
|
||||||
|
negate: 0
|
||||||
|
occupied_thresh: 0.65
|
||||||
|
free_thresh: 0.196
|
||||||
BIN
maps/pudu_modified/map_keepout_baked.pgm
Normal file
BIN
maps/pudu_modified/map_keepout_baked.pgm
Normal file
Binary file not shown.
7
maps/pudu_modified/map_keepout_baked.yaml
Normal file
7
maps/pudu_modified/map_keepout_baked.yaml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
image: map_keepout_baked.pgm
|
||||||
|
mode: trinary
|
||||||
|
resolution: 0.05
|
||||||
|
origin: [-5.85, -11.8, 0.0]
|
||||||
|
negate: 0
|
||||||
|
occupied_thresh: 0.65
|
||||||
|
free_thresh: 0.196
|
||||||
165
pudu_gui.py
165
pudu_gui.py
@ -17,12 +17,14 @@ Pipeline (all local, per user click):
|
|||||||
exactly like start_r1_nav.sh; G1/Go2 = plain RViz on the local ROS).
|
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
|
Requires: pillow numpy pyyaml (same as the original converter). Server binds
|
||||||
127.0.0.1 only. Conversion logic is vendored from pudu_to_ros2_map.py (verified
|
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).
|
against real exports: origin = bottom-left meters, PNG row 0 = top, 0/128/255).
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import hmac
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -2255,9 +2257,119 @@ def open_rviz(robot, ip):
|
|||||||
|
|
||||||
# ───────────────────────── http plumbing ─────────────────────────
|
# ───────────────────────── 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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
def log_message(self, *a):
|
def log_message(self, fmt, *a):
|
||||||
pass
|
# 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):
|
def _json(self, obj, code=200):
|
||||||
body = json.dumps(obj).encode()
|
body = json.dumps(obj).encode()
|
||||||
@ -2270,6 +2382,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def _html(self, body):
|
def _html(self, body):
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
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
|
# no-store: a stale cached dashboard once ran OLD JS against a NEW
|
||||||
# backend — "button does nothing" with zero errors. Never again.
|
# backend — "button does nothing" with zero errors. Never again.
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
@ -2278,6 +2395,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
|
if not self._authed():
|
||||||
|
return self._deny()
|
||||||
path = self.path.split("?")[0] # ?v=... cache-busters must still match
|
path = self.path.split("?")[0] # ?v=... cache-busters must still match
|
||||||
if path in ("/", "/index.html"):
|
if path in ("/", "/index.html"):
|
||||||
with open(os.path.join(HERE, "index.html"), "rb") as f:
|
with open(os.path.join(HERE, "index.html"), "rb") as f:
|
||||||
@ -2291,6 +2410,28 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._json({"error": "not found"}, 404)
|
self._json({"error": "not found"}, 404)
|
||||||
|
|
||||||
def do_POST(self):
|
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))
|
n = int(self.headers.get("Content-Length", 0))
|
||||||
try:
|
try:
|
||||||
req = json.loads(self.rfile.read(n) or b"{}")
|
req = json.loads(self.rfile.read(n) or b"{}")
|
||||||
@ -2435,9 +2576,21 @@ def main():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[pudu_gui] current-map restore skipped: {e}")
|
print(f"[pudu_gui] current-map restore skipped: {e}")
|
||||||
threading.Thread(target=_reap_children, daemon=True).start()
|
threading.Thread(target=_reap_children, daemon=True).start()
|
||||||
srv = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
|
# BIND: localhost by default. Set PUDU_BIND to expose it on the LAN, e.g.
|
||||||
url = f"http://127.0.0.1:{PORT}"
|
# PUDU_BIND=10.255.254.83 (this workstation's wifi address)
|
||||||
print(f"[pudu_gui] serving {url} (Ctrl+C to stop)")
|
# 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()
|
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
|
||||||
try:
|
try:
|
||||||
srv.serve_forever()
|
srv.serve_forever()
|
||||||
|
|||||||
16
start.sh
16
start.sh
@ -32,7 +32,18 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
IMG=pudu-map-gui
|
IMG=pudu-map-gui
|
||||||
NAME=pudu-map-gui
|
NAME=pudu-map-gui
|
||||||
PORT=8777
|
PORT=8777
|
||||||
URL="http://127.0.0.1:$PORT"
|
# PUDU_BIND: which address the dashboard listens on. Default localhost-only.
|
||||||
|
# PUDU_BIND=10.255.254.83 ./start.sh restart -> reachable from the LAN
|
||||||
|
# PUDU_BIND=0.0.0.0 ./start.sh restart -> every interface
|
||||||
|
# The container already runs --net=host, so this is the ONLY thing that decides
|
||||||
|
# reachability. NO AUTH: anyone who can reach the port can drive the robots.
|
||||||
|
PUDU_BIND="${PUDU_BIND:-127.0.0.1}"
|
||||||
|
# health-check a REACHABLE address: 0.0.0.0 is a bind wildcard, not a destination
|
||||||
|
case "$PUDU_BIND" in
|
||||||
|
0.0.0.0|::) HOSTADDR=127.0.0.1 ;;
|
||||||
|
*) HOSTADDR="$PUDU_BIND" ;;
|
||||||
|
esac
|
||||||
|
URL="http://$HOSTADDR:$PORT"
|
||||||
|
|
||||||
_stop() {
|
_stop() {
|
||||||
# the GUI's own viewer processes live INSIDE the container, so removing it
|
# the GUI's own viewer processes live INSIDE the container, so removing it
|
||||||
@ -62,6 +73,9 @@ RUN=(docker run --rm --net=host --name "$NAME"
|
|||||||
-e DISPLAY="${DISPLAY:-:0}"
|
-e DISPLAY="${DISPLAY:-:0}"
|
||||||
-e HOME="$HOME"
|
-e HOME="$HOME"
|
||||||
-e PUDU_GUI_DIR="$HERE"
|
-e PUDU_GUI_DIR="$HERE"
|
||||||
|
-e PUDU_BIND="$PUDU_BIND"
|
||||||
|
-e PUDU_TOKEN="${PUDU_TOKEN:-}"
|
||||||
|
-e PUDU_OPEN="${PUDU_OPEN:-}"
|
||||||
-v /tmp/.X11-unix:/tmp/.X11-unix
|
-v /tmp/.X11-unix:/tmp/.X11-unix
|
||||||
-v "$HOME/Robotics_workspace:$HOME/Robotics_workspace"
|
-v "$HOME/Robotics_workspace:$HOME/Robotics_workspace"
|
||||||
-v "$HOME/Downloads:$HOME/Downloads"
|
-v "$HOME/Downloads:$HOME/Downloads"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user