mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 20:51:14 +00:00
[AZ-402] Replay — gps-denied-replay console-script + shared main(config)
Implements the replay-mode CLI dispatcher per ADR-011 (replay-as- configuration): - src/gps_denied_onboard/cli/replay.py: argparse with all 6 required args (--video, --tlog, --output, --camera-calibration, --config, --mavlink-signing-key) plus --pace and --time-offset-ms; path validation, calibration JSON schema-validation, config mutation (mode='replay' + replay sub-block + signing-key hex on dev_static field), dispatch into runtime_root.main(config). - runtime_root.main() now accepts an optional Config (additive, backward-compat). Adds dedicated catch for ReplayInputAdapterError mapping to EXIT_FDR_OPEN_FAILURE (2) so the CLI's exit-code matrix holds end-to-end (AC-9 + epic AZ-265 AC-8). - Signing-key contents stored as hex; redacted in startup banner. - Top-level except logs full traceback via logger.exception + stderr print and exits 1. The CLI does NOT call compose_root directly — it builds a Config and hands it to the shared airborne main, which calls compose_root, which branches on config.mode (AZ-401 / replay protocol Invariant 11). Tests: 22 unit tests covering AC-1..AC-10 + extras (signing-key redaction, file-not-dir validation, dev_static propagation, unhandled exception traceback). Full regression: 2085 passed (+22) green; no new flaky tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,18 +1,316 @@
|
||||
"""`gps-denied-replay` CLI entrypoint — STUB.
|
||||
"""``gps-denied-replay`` console-script — replay-mode dispatcher (AZ-402).
|
||||
|
||||
Owned by AZ-402. Bootstrap exposes a callable so `[project.scripts]` in
|
||||
pyproject.toml resolves.
|
||||
Per ADR-011 the replay CLI is **not** a standalone composition root. It
|
||||
parses operator arguments, validates their files, mutates a loaded
|
||||
:class:`~gps_denied_onboard.config.Config` to ``mode == "replay"``, and
|
||||
dispatches into the same airborne ``main(config)`` entry point that the
|
||||
live binary uses. The single composition root in
|
||||
:mod:`gps_denied_onboard.runtime_root` branches on ``config.mode`` per
|
||||
AZ-401.
|
||||
|
||||
Exit codes (AC-9):
|
||||
|
||||
* ``0`` — success.
|
||||
* ``2`` — replay auto-sync impossible (epic AZ-265 AC-8) OR argparse
|
||||
reported missing required argument (stdlib default).
|
||||
* ``1`` — any other error (calibration malformed, file missing,
|
||||
configuration invalid, unhandled exception).
|
||||
|
||||
Implements ``_docs/02_document/contracts/replay/replay_protocol.md``
|
||||
v2.0.0 — CLI surface + Invariant 11 (signing key mandatory).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
from gps_denied_onboard.config import (
|
||||
Config,
|
||||
ReplayConfig,
|
||||
load_config,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Replay-CLI entrypoint."""
|
||||
print("gps-denied-replay is not yet implemented (AZ-402 / E-DEMO-REPLAY)", file=sys.stderr)
|
||||
return 2
|
||||
__all__ = [
|
||||
"EXIT_GENERIC_FAILURE",
|
||||
"EXIT_SUCCESS",
|
||||
"EXIT_SYNC_IMPOSSIBLE",
|
||||
"ReplayCliError",
|
||||
"main",
|
||||
]
|
||||
|
||||
|
||||
EXIT_SUCCESS: Final[int] = 0
|
||||
EXIT_GENERIC_FAILURE: Final[int] = 1
|
||||
EXIT_SYNC_IMPOSSIBLE: Final[int] = 2
|
||||
|
||||
_REQUIRED_CALIBRATION_KEYS: Final[tuple[tuple[str, str], ...]] = (
|
||||
# (json key, error label per AC-7 phrasing)
|
||||
("intrinsics_3x3", "intrinsics"),
|
||||
("distortion", "distortion"),
|
||||
("body_to_camera_se3", "body_to_camera_se3"),
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger("gps_denied_onboard.cli.replay")
|
||||
|
||||
|
||||
class ReplayCliError(RuntimeError):
|
||||
"""Operator-facing CLI error (file missing, calibration malformed, etc.).
|
||||
|
||||
Surfaces as exit code :data:`EXIT_GENERIC_FAILURE` with a
|
||||
human-readable stderr message; the underlying cause (if any) is
|
||||
chained via ``__cause__`` for debug logs.
|
||||
"""
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Argument parsing
|
||||
|
||||
|
||||
def _build_argparser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="gps-denied-replay",
|
||||
description=(
|
||||
"Replay-mode dispatcher for the airborne binary. Loads a "
|
||||
"config, sets mode='replay', and runs the same composition "
|
||||
"root the live binary uses (ADR-011)."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--video", required=True, type=Path, metavar="PATH")
|
||||
parser.add_argument("--tlog", required=True, type=Path, metavar="PATH")
|
||||
parser.add_argument("--output", required=True, type=Path, metavar="PATH")
|
||||
parser.add_argument(
|
||||
"--camera-calibration",
|
||||
dest="camera_calibration",
|
||||
required=True,
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config", dest="config_path", required=True, type=Path, metavar="PATH"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mavlink-signing-key",
|
||||
dest="mavlink_signing_key",
|
||||
required=True,
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"MAVLink signing key file (binary). Required even in replay "
|
||||
"mode per replay protocol Invariant 11; the signing handshake "
|
||||
"still runs on the encoder path."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pace",
|
||||
choices=("realtime", "asap"),
|
||||
default="asap",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--time-offset-ms",
|
||||
dest="time_offset_ms",
|
||||
type=int,
|
||||
default=None,
|
||||
help=(
|
||||
"Manual offset between video and tlog clocks. When omitted, "
|
||||
"ReplayInputAdapter (AZ-405) auto-detects via IMU take-off."
|
||||
),
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# File validation
|
||||
|
||||
|
||||
def _validate_paths(args: argparse.Namespace) -> None:
|
||||
"""Fail fast if any required-file argument is missing or unreadable."""
|
||||
paths: tuple[tuple[str, Path], ...] = (
|
||||
("video", args.video),
|
||||
("tlog", args.tlog),
|
||||
("camera-calibration", args.camera_calibration),
|
||||
("config", args.config_path),
|
||||
("mavlink-signing-key", args.mavlink_signing_key),
|
||||
)
|
||||
for label, path in paths:
|
||||
if not path.exists():
|
||||
raise ReplayCliError(f"--{label} path does not exist: {path}")
|
||||
if not path.is_file():
|
||||
raise ReplayCliError(f"--{label} path is not a file: {path}")
|
||||
|
||||
|
||||
def _load_calibration_json(path: Path) -> dict[str, Any]:
|
||||
"""Load + schema-validate the camera calibration JSON.
|
||||
|
||||
The CLI validates here so a corrupt or schema-incomplete calibration
|
||||
surfaces with a single clean error before the airborne main runs.
|
||||
The calibration file is re-read inside ``compose_root``'s replay
|
||||
branch from ``config.runtime.camera_calibration_path``; this loader
|
||||
is only an early-fail gate.
|
||||
"""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise ReplayCliError(
|
||||
f"camera-calibration file unreadable: {exc!r}"
|
||||
) from exc
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ReplayCliError(
|
||||
f"camera-calibration JSON malformed: {exc.msg} at line {exc.lineno}"
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ReplayCliError(
|
||||
"camera-calibration schema invalid: expected JSON object at top level"
|
||||
)
|
||||
for key, label in _REQUIRED_CALIBRATION_KEYS:
|
||||
if key not in data:
|
||||
raise ReplayCliError(
|
||||
f"camera-calibration schema invalid: missing {label!r}"
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Config mutation
|
||||
|
||||
|
||||
def _build_replay_config(
|
||||
args: argparse.Namespace, base_config: Config
|
||||
) -> Config:
|
||||
"""Return a new :class:`Config` mutated to replay mode.
|
||||
|
||||
Per ADR-011 the CLI's only job after loading is to set
|
||||
``config.mode = "replay"`` and populate ``config.replay`` from the
|
||||
operator's CLI args. Composition logic stays in ``compose_root``.
|
||||
"""
|
||||
new_replay = ReplayConfig(
|
||||
video_path=str(args.video),
|
||||
tlog_path=str(args.tlog),
|
||||
output_path=str(args.output),
|
||||
pace=args.pace,
|
||||
time_offset_ms=args.time_offset_ms,
|
||||
target_fc_dialect=base_config.replay.target_fc_dialect,
|
||||
auto_sync=base_config.replay.auto_sync,
|
||||
)
|
||||
new_runtime = replace(
|
||||
base_config.runtime,
|
||||
camera_calibration_path=str(args.camera_calibration),
|
||||
)
|
||||
# MAVLink signing key contents are stored as hex on the dev-static
|
||||
# field. In replay the NoopMavlinkTransport never actually transmits,
|
||||
# but `compose_root` still wires the signing-handshake path so the
|
||||
# code path is symmetric with live (replay protocol Invariant 5).
|
||||
try:
|
||||
signing_key_bytes = args.mavlink_signing_key.read_bytes()
|
||||
except OSError as exc:
|
||||
raise ReplayCliError(
|
||||
f"--mavlink-signing-key file unreadable: {exc!r}"
|
||||
) from exc
|
||||
new_fc = replace(
|
||||
base_config.fc,
|
||||
signing_key_source="dev_static",
|
||||
dev_static_signing_key=signing_key_bytes.hex(),
|
||||
)
|
||||
return replace(
|
||||
base_config,
|
||||
mode="replay",
|
||||
replay=new_replay,
|
||||
runtime=new_runtime,
|
||||
fc=new_fc,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Startup banner
|
||||
|
||||
|
||||
def _print_startup_banner(args: argparse.Namespace) -> None:
|
||||
"""Print a sanitised one-line banner to stderr before logging boots.
|
||||
|
||||
Logging is bootstrapped inside the airborne main; this banner gives
|
||||
the operator a single line confirming what the CLI parsed before any
|
||||
further output.
|
||||
"""
|
||||
sanitised = vars(args).copy()
|
||||
sanitised["mavlink_signing_key"] = "<redacted>"
|
||||
print(
|
||||
f"gps-denied-replay starting with args: {sanitised}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Entrypoint
|
||||
|
||||
|
||||
def main(
|
||||
argv: Sequence[str] | None = None,
|
||||
*,
|
||||
shared_main: Callable[[Config], int] | None = None,
|
||||
) -> int:
|
||||
"""``gps-denied-replay`` entrypoint.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
argv:
|
||||
Argument vector to parse. ``None`` (default) means
|
||||
``sys.argv[1:]`` per stdlib argparse convention.
|
||||
shared_main:
|
||||
Test-injection seam. ``None`` resolves to
|
||||
``runtime_root.main`` lazily (avoids a circular import at module
|
||||
load) so unit tests can swap in a fake without monkeypatching.
|
||||
"""
|
||||
parser = _build_argparser()
|
||||
args = parser.parse_args(argv)
|
||||
_print_startup_banner(args)
|
||||
|
||||
if shared_main is None:
|
||||
from gps_denied_onboard.runtime_root import main as shared_main
|
||||
|
||||
# Local import to keep module-load cheap and avoid cycles with the
|
||||
# replay_input package while also letting tests trigger AC-9 paths.
|
||||
from gps_denied_onboard.replay_input import ReplayInputAdapterError
|
||||
|
||||
try:
|
||||
_validate_paths(args)
|
||||
_load_calibration_json(args.camera_calibration)
|
||||
base_config = load_config(env=os.environ, paths=(args.config_path,))
|
||||
config = _build_replay_config(args, base_config)
|
||||
return int(shared_main(config))
|
||||
except ReplayCliError as exc:
|
||||
print(f"gps-denied-replay: {exc}", file=sys.stderr, flush=True)
|
||||
return EXIT_GENERIC_FAILURE
|
||||
except ReplayInputAdapterError as exc:
|
||||
# AC-8 hard-fail: auto-sync detected an offset that violates the
|
||||
# match-window threshold, or the tlog is missing required fields.
|
||||
# Operator must fix the inputs.
|
||||
print(
|
||||
f"gps-denied-replay: replay sync impossible: {exc}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return EXIT_SYNC_IMPOSSIBLE
|
||||
except SystemExit:
|
||||
# argparse / shared_main may raise SystemExit for clean shutdown
|
||||
# paths (--help, --version, fatal abort). Re-raise so the
|
||||
# process exit code is preserved verbatim.
|
||||
raise
|
||||
except Exception:
|
||||
_LOGGER.exception("gps-denied-replay: unhandled exception")
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
return EXIT_GENERIC_FAILURE
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -618,10 +618,39 @@ def _read_flight_root(config: Config) -> str:
|
||||
return str(path) if path is not None else "<unknown>"
|
||||
|
||||
|
||||
def main() -> int: # pragma: no cover — guarded entrypoint
|
||||
def main(config: Config | None = None) -> int:
|
||||
"""Shared airborne-binary entrypoint.
|
||||
|
||||
Both the live ``gps-denied-onboard`` console-script and the replay
|
||||
``gps-denied-replay`` console-script (AZ-402) dispatch here. When
|
||||
``config`` is ``None`` the live binary's behaviour is preserved: load
|
||||
from environment + default paths and compose. When a pre-built
|
||||
``Config`` is supplied (replay CLI), it is used directly so the CLI
|
||||
can mutate ``config.mode = "replay"`` + populate the replay sub-block
|
||||
before the airborne main runs.
|
||||
|
||||
Per ADR-011 there is one composition root, ``compose_root``, which
|
||||
branches on ``config.mode``. The CLI MUST NOT call ``compose_root``
|
||||
directly (replay protocol Invariant 11).
|
||||
|
||||
Exit codes:
|
||||
|
||||
* ``0`` — success.
|
||||
* ``EXIT_FDR_OPEN_FAILURE`` (``2``) — operator-visible startup hard-fail:
|
||||
FDR cannot open OR replay auto-sync impossible (AZ-405 AC-8 / epic
|
||||
AZ-265 AC-8). Both share the code because both demand operator
|
||||
action before the binary can run.
|
||||
* ``EXIT_GENERIC_FAILURE`` (``1``) — any other error.
|
||||
"""
|
||||
from gps_denied_onboard.replay_input import ReplayInputAdapterError
|
||||
|
||||
try:
|
||||
config = load_config(env=os.environ, paths=())
|
||||
if config is None:
|
||||
config = load_config(env=os.environ, paths=())
|
||||
compose_root(config)
|
||||
except ReplayInputAdapterError as exc:
|
||||
print(f"runtime_root: replay sync impossible: {exc}", file=sys.stderr)
|
||||
return EXIT_FDR_OPEN_FAILURE
|
||||
except (ConfigurationError, StrategyNotLinkedError, RuntimeError) as exc:
|
||||
print(f"runtime_root: {exc}", file=sys.stderr)
|
||||
return EXIT_GENERIC_FAILURE
|
||||
|
||||
Reference in New Issue
Block a user