实现行为树执行器、任务协调和技能接口
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user