实现行为树执行器、任务协调和技能接口

This commit is contained in:
2026-09-20 12:18:52 +08:00
commit 492676344a
143 changed files with 13010 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
"""Authenticated stdlib HTTP server; ROS imports are optional and isolated."""
import argparse
import hmac
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import ipaddress
import json
import os
from pathlib import Path
import signal
import threading
import time
from urllib.parse import urlsplit
from .backends import MockBackend, Ros1MoveBaseBackend
from .gateway import Gateway, GatewayError, SafetyConfig
def make_server(gateway, token, host="127.0.0.1", port=8766):
if not isinstance(token, str) or len(token) < 16: raise ValueError("bearer token must contain at least 16 characters")
class Handler(BaseHTTPRequestHandler):
server_version = "NavigationGateway/1"
def setup(self):
super().setup()
self.connection.settimeout(5.0) # HTTP resource bound, not a robot safety threshold.
def log_message(self, format, *args):
return # Never log bearer headers or raw task input.
def respond(self, status, body):
raw = json.dumps(body, allow_nan=False, separators=(",", ":")).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(raw)
def body(self):
if self.headers.get("Transfer-Encoding"): raise GatewayError("chunked bodies are unsupported")
try: length = int(self.headers.get("Content-Length", "0"))
except ValueError: raise GatewayError("invalid Content-Length")
if not 0 < length <= 65536: raise GatewayError("body must contain 1..65536 bytes", 413)
if self.headers.get("Content-Type", "").split(";")[0].strip() != "application/json": raise GatewayError("application/json required", 415)
def duplicate_safe(pairs):
obj = {}
for key, value in pairs:
if key in obj: raise GatewayError("duplicate JSON field")
obj[key] = value
return obj
try:
return json.loads(self.rfile.read(length), object_pairs_hook=duplicate_safe,
parse_constant=lambda value: (_ for _ in ()).throw(GatewayError("non-finite JSON value")))
except (ValueError, UnicodeError): raise GatewayError("invalid JSON")
def handle_request(self):
try:
expected = "Bearer " + token
if not hmac.compare_digest(self.headers.get("Authorization", ""), expected):
raise GatewayError("valid bearer authorization required", 401)
parts = urlsplit(self.path)
if parts.query or parts.fragment: raise GatewayError("query parameters unsupported")
path = parts.path.rstrip("/")
if self.command == "GET" and path == "/healthz": return self.respond(200, gateway.health())
if self.command == "POST" and path == "/v1/goals": return self.respond(202, gateway.submit(self.body()))
split = path.split("/")
if len(split) in (4, 5) and split[1:3] == ["v1", "goals"]:
goal_id = split[3]
if self.command == "GET" and len(split) == 4: return self.respond(200, gateway.poll(goal_id))
if self.command == "POST" and len(split) == 5 and split[4] == "cancel":
if self.body() != {}: raise GatewayError("cancel body must be {}")
return self.respond(202, gateway.cancel(goal_id))
raise GatewayError("route not found", 404)
except GatewayError as exc:
self.respond(exc.http_status, {"error": str(exc)})
except (TimeoutError, ConnectionError, BrokenPipeError):
self.close_connection = True
except Exception:
self.respond(500, {"error": "internal error; query original UUID; never resubmit with a new UUID"})
do_GET = handle_request
do_POST = handle_request
class Server(ThreadingHTTPServer):
daemon_threads = True
request_queue_size = 16
return Server((host, port), Handler)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", required=True, help="JSON config with explicit safety thresholds")
parser.add_argument("--journal", required=True, help="durable SQLite journal; one per physical robot")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8766)
args = parser.parse_args()
if not ipaddress.ip_address(args.host).is_loopback:
parser.error("HTTP bearer transport listens only on loopback; use an authenticated SSH/TLS tunnel")
token = os.environ.get("NAV_GATEWAY_TOKEN", "")
if len(token) < 16: parser.error("set NAV_GATEWAY_TOKEN to at least 16 characters")
config = json.loads(Path(args.config).read_text())
safety = SafetyConfig(**config["safety"])
if config["mode"] == "simulation":
backend = MockBackend(map_id=config["map_id"], auto_complete_sec=config["simulation_complete_sec"])
elif config["mode"] == "ros1_move_base":
if config.get("production_enable") is not True: parser.error("production_enable must be explicitly true after site acceptance")
import rospy
rospy.init_node("navigation_http_gateway", disable_signals=True)
backend = Ros1MoveBaseBackend(map_id=config["map_id"], **config["ros1"])
else: parser.error("mode must be simulation or ros1_move_base")
period = config["poll_interval_sec"]
if isinstance(period, bool) or not isinstance(period, (int, float)) or not 0 < period <= safety.odom_max_age_sec:
parser.error("poll_interval_sec must be positive and <= odom_max_age_sec")
gateway = Gateway(args.journal, backend, safety)
server = make_server(gateway, token, args.host, args.port)
stop = threading.Event()
def monitor():
while not stop.wait(period):
try: gateway.poll_all()
except Exception:
# Persist/telemetry errors must not release resource ownership.
stop.set()
threading.Thread(target=server.shutdown, daemon=True).start()
thread = threading.Thread(target=monitor, daemon=True); thread.start()
def shutdown(signum, frame):
stop.set()
threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
try: server.serve_forever(poll_interval=0.1)
finally:
stop.set(); thread.join(timeout=2)
# Best effort cancel cannot turn shutdown into stop confirmation.
for goal_id in list(gateway.records):
try: gateway.cancel(goal_id)
except Exception: pass
server.server_close(); gateway.close()
if __name__ == "__main__": main()