mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 17:21:13 +00:00
[AZ-390] [AZ-392] C8 FC/GCS adapter foundation + covariance projector
Adds the C8 foundation: - FcAdapter / GcsAdapter / ReplaySink Protocols + contract DTOs in _types/fc.py (PortConfig, FcKind, FlightState, GpsStatus, Severity, TelemetryKind, FcTelemetryFrame, FlightStateSignal, GpsHealth, OperatorCommand, Subscription, Imu/Attitude samples). - Disjoint FcAdapterError / GcsAdapterError trees with SourceSetSwitchNotSupportedError <: SourceSetSwitchError per AC-9. - FcConfig + GcsConfig cross-cutting Config blocks with config-load validation (unknown strategy rejected at __post_init__). - runtime_root/fc_factory.py: build_fc_adapter / build_gcs_adapter with BUILD_FC_*/BUILD_GCS_* flag gating + INFO log on load + single-writer outbound-thread binding. - CovarianceProjector (helper, AZ-392): 6x6 -> 3x3 -> 2x2 -> sqrt(lambda_max) reduction; AP returns float m, iNav returns int mm with uint16 clamp + WARN + FDR record. Non-SPD / NaN / wrong-shape raise FcEmitError and emit an FDR ERROR record carrying frame_id. Contracts: - composition_root_protocol.md 1.1.0 -> 1.2.0 (added fc/gcs blocks + build_fc_adapter / build_gcs_adapter + outbound-thread binding). - fc_adapter_protocol.md unchanged (this batch implements v1.0.0). Tests: 410 pass / 2 skip / 0 fail (+53 new tests in batch 8). AZ-391 (inbound subscription) deferred to batch 9 — pulls YAMSPy as a new external dependency (iNav MSP2 decode). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
"""Composition root (AZ-270 / E-CC-CONF; ADR-009 interface-first DI).
|
||||
|
||||
The single module allowed to import concrete strategy implementations across
|
||||
components. Per-binary ``compose_*`` functions resolve the ``Config``-selected
|
||||
strategies, validate them against the registry (ADR-002 gate #3), and assemble
|
||||
the component graph in dependency order.
|
||||
|
||||
Per-binary entrypoints:
|
||||
|
||||
* :func:`compose_root` - airborne runtime
|
||||
* :func:`compose_operator` - operator-side tooling (pre-flight, post-landing)
|
||||
* :func:`compose_replay` - replay-cli runtime (extension owned by AZ-401)
|
||||
|
||||
Public surface frozen by
|
||||
``_docs/02_document/contracts/shared_config/composition_root_protocol.md`` v1.0.0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, get_args
|
||||
|
||||
from gps_denied_onboard.config import Config, load_config
|
||||
from gps_denied_onboard.runtime_root.fc_factory import (
|
||||
OutboundThreadAlreadyBoundError,
|
||||
bind_outbound_emit_thread,
|
||||
build_fc_adapter,
|
||||
build_gcs_adapter,
|
||||
clear_outbound_thread_binding,
|
||||
clear_strategy_registries,
|
||||
list_registered_fc_strategies,
|
||||
list_registered_gcs_strategies,
|
||||
register_fc_adapter,
|
||||
register_gcs_adapter,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.components.c13_fdr.headers import FlightHeader
|
||||
from gps_denied_onboard.components.c13_fdr.writer import FileFdrWriter
|
||||
|
||||
__all__ = [
|
||||
"EXIT_FDR_OPEN_FAILURE",
|
||||
"EXIT_GENERIC_FAILURE",
|
||||
"REQUIRED_ENV_VARS",
|
||||
"ConfigurationError",
|
||||
"OperatorRoot",
|
||||
"OutboundThreadAlreadyBoundError",
|
||||
"RuntimeRoot",
|
||||
"StrategyNotLinkedError",
|
||||
"StrategyTier",
|
||||
"TakeoffResult",
|
||||
"bind_outbound_emit_thread",
|
||||
"build_fc_adapter",
|
||||
"build_gcs_adapter",
|
||||
"clear_outbound_thread_binding",
|
||||
"clear_strategy_registries",
|
||||
"clear_strategy_registry",
|
||||
"compose_operator",
|
||||
"compose_replay",
|
||||
"compose_root",
|
||||
"list_registered_fc_strategies",
|
||||
"list_registered_gcs_strategies",
|
||||
"list_registered_strategies",
|
||||
"main",
|
||||
"register_fc_adapter",
|
||||
"register_gcs_adapter",
|
||||
"register_strategy",
|
||||
"take_off",
|
||||
]
|
||||
|
||||
|
||||
EXIT_GENERIC_FAILURE: Final[int] = 1
|
||||
EXIT_FDR_OPEN_FAILURE: Final[int] = 2
|
||||
|
||||
StrategyTier = Literal["airborne", "operator", "shared"]
|
||||
_ALL_TIERS: tuple[StrategyTier, ...] = get_args(StrategyTier)
|
||||
|
||||
|
||||
class ConfigurationError(RuntimeError):
|
||||
"""Raised when a required environment variable is missing.
|
||||
|
||||
AC-8 (Bootstrap / AZ-263).
|
||||
"""
|
||||
|
||||
|
||||
class StrategyNotLinkedError(RuntimeError):
|
||||
"""Raised when the config selects a strategy that is not linked into this binary.
|
||||
|
||||
Carries the offending strategy name, the owning component slug, and the
|
||||
actually-linked strategies for that component — gives the operator a
|
||||
clear next step (build with the right flag, or pick a different strategy).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
strategy_name: str,
|
||||
component_slug: str,
|
||||
available_strategies: Iterable[str],
|
||||
*,
|
||||
reason: str = "not linked",
|
||||
) -> None:
|
||||
self.strategy_name: str = strategy_name
|
||||
self.component_slug: str = component_slug
|
||||
self.available_strategies: list[str] = sorted(set(available_strategies))
|
||||
self.reason: str = reason
|
||||
available_text = ", ".join(self.available_strategies) or "<none>"
|
||||
super().__init__(
|
||||
f"strategy {strategy_name!r} requested for component {component_slug!r} is "
|
||||
f"{reason}; available strategies: [{available_text}]"
|
||||
)
|
||||
|
||||
|
||||
REQUIRED_ENV_VARS: tuple[str, ...] = (
|
||||
"GPS_DENIED_FC_PROFILE",
|
||||
"GPS_DENIED_TIER",
|
||||
"DB_URL",
|
||||
"CAMERA_CALIBRATION_PATH",
|
||||
"LOG_LEVEL",
|
||||
"LOG_SINK",
|
||||
"INFERENCE_BACKEND",
|
||||
"FDR_PATH",
|
||||
"TILE_CACHE_PATH",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Registration:
|
||||
component_slug: str
|
||||
strategy_name: str
|
||||
factory: Callable[..., Any]
|
||||
tier: StrategyTier
|
||||
depends_on: tuple[str, ...]
|
||||
|
||||
|
||||
_STRATEGY_REGISTRY: dict[tuple[str, str], _Registration] = {}
|
||||
|
||||
|
||||
def register_strategy(
|
||||
component_slug: str,
|
||||
strategy_name: str,
|
||||
factory: Callable[..., Any],
|
||||
*,
|
||||
tier: StrategyTier = "shared",
|
||||
depends_on: Iterable[str] = (),
|
||||
) -> None:
|
||||
"""Register a concrete strategy implementation for ``component_slug``.
|
||||
|
||||
The single allowed call site is the composition root or a binary-specific
|
||||
bootstrap module (one module per ``BUILD_*`` flag combination). Calling
|
||||
this from a component module is an architecture violation; AC-6 catches
|
||||
that statically.
|
||||
"""
|
||||
if tier not in _ALL_TIERS:
|
||||
raise ValueError(f"tier must be one of {_ALL_TIERS}; got {tier!r}")
|
||||
key = (component_slug, strategy_name)
|
||||
existing = _STRATEGY_REGISTRY.get(key)
|
||||
incoming = _Registration(
|
||||
component_slug=component_slug,
|
||||
strategy_name=strategy_name,
|
||||
factory=factory,
|
||||
tier=tier,
|
||||
depends_on=tuple(depends_on),
|
||||
)
|
||||
if existing is not None and existing != incoming:
|
||||
raise StrategyNotLinkedError(
|
||||
strategy_name=strategy_name,
|
||||
component_slug=component_slug,
|
||||
available_strategies=list_registered_strategies(component_slug),
|
||||
reason="duplicate registration with conflicting attributes",
|
||||
)
|
||||
_STRATEGY_REGISTRY[key] = incoming
|
||||
|
||||
|
||||
def clear_strategy_registry() -> None:
|
||||
"""Reset the global registry; intended for unit-test isolation only."""
|
||||
_STRATEGY_REGISTRY.clear()
|
||||
|
||||
|
||||
def list_registered_strategies(component_slug: str) -> list[str]:
|
||||
"""Return the strategy names registered for ``component_slug`` (sorted)."""
|
||||
return sorted(
|
||||
reg.strategy_name
|
||||
for (slug, _name), reg in _STRATEGY_REGISTRY.items()
|
||||
if slug == component_slug
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeRoot:
|
||||
"""Composed airborne runtime graph (every component slot populated)."""
|
||||
|
||||
binary: str
|
||||
profile: str
|
||||
components: Mapping[str, Any] = field(default_factory=dict)
|
||||
construction_order: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperatorRoot:
|
||||
"""Composed operator-side runtime graph (operator-tier components only)."""
|
||||
|
||||
binary: str
|
||||
profile: str
|
||||
components: Mapping[str, Any] = field(default_factory=dict)
|
||||
construction_order: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _check_required_env(extra_required: Iterable[str] = ()) -> None:
|
||||
missing = [name for name in (*REQUIRED_ENV_VARS, *extra_required) if name not in os.environ]
|
||||
if missing:
|
||||
raise ConfigurationError(
|
||||
"Missing required environment variable(s): "
|
||||
+ ", ".join(missing)
|
||||
+ ". See `.env.example` for the full list."
|
||||
)
|
||||
|
||||
|
||||
def _resolve_strategy(
|
||||
component_slug: str,
|
||||
strategy_name: str,
|
||||
allowed_tiers: frozenset[StrategyTier],
|
||||
) -> _Registration:
|
||||
"""Look up ``(component_slug, strategy_name)`` in the registry and gate by tier."""
|
||||
key = (component_slug, strategy_name)
|
||||
registration = _STRATEGY_REGISTRY.get(key)
|
||||
if registration is None:
|
||||
raise StrategyNotLinkedError(
|
||||
strategy_name=strategy_name,
|
||||
component_slug=component_slug,
|
||||
available_strategies=list_registered_strategies(component_slug),
|
||||
)
|
||||
if registration.tier not in allowed_tiers:
|
||||
raise StrategyNotLinkedError(
|
||||
strategy_name=strategy_name,
|
||||
component_slug=component_slug,
|
||||
available_strategies=list_registered_strategies(component_slug),
|
||||
reason=(
|
||||
f"registered under tier={registration.tier!r}; "
|
||||
f"not allowed in this binary (allowed tiers: {sorted(allowed_tiers)})"
|
||||
),
|
||||
)
|
||||
return registration
|
||||
|
||||
|
||||
def _topo_order(
|
||||
target_slugs: Iterable[str], registrations: Mapping[str, _Registration]
|
||||
) -> list[str]:
|
||||
"""Return ``target_slugs`` in a dependency-respecting order (Kahn's algorithm)."""
|
||||
ordered: list[str] = []
|
||||
remaining = set(target_slugs)
|
||||
visited: set[str] = set()
|
||||
|
||||
def visit(slug: str, stack: tuple[str, ...]) -> None:
|
||||
if slug in visited:
|
||||
return
|
||||
if slug in stack:
|
||||
raise StrategyNotLinkedError(
|
||||
strategy_name=registrations[slug].strategy_name,
|
||||
component_slug=slug,
|
||||
available_strategies=list_registered_strategies(slug),
|
||||
reason=f"dependency cycle detected: {' -> '.join((*stack, slug))}",
|
||||
)
|
||||
if slug not in registrations:
|
||||
return
|
||||
for dep in registrations[slug].depends_on:
|
||||
visit(dep, (*stack, slug))
|
||||
visited.add(slug)
|
||||
ordered.append(slug)
|
||||
|
||||
for slug in sorted(remaining):
|
||||
visit(slug, ())
|
||||
return [slug for slug in ordered if slug in remaining]
|
||||
|
||||
|
||||
def _compose(
|
||||
config: Config,
|
||||
*,
|
||||
binary: str,
|
||||
allowed_tiers: frozenset[StrategyTier],
|
||||
extra_required_env: Iterable[str],
|
||||
) -> tuple[dict[str, Any], tuple[str, ...]]:
|
||||
"""Shared composition path used by ``compose_root`` / ``compose_operator``."""
|
||||
_check_required_env(extra_required=extra_required_env)
|
||||
selections = _resolve_component_strategies(config, allowed_tiers)
|
||||
resolved: dict[str, _Registration] = {
|
||||
slug: _resolve_strategy(slug, strategy, allowed_tiers)
|
||||
for slug, strategy in selections.items()
|
||||
}
|
||||
order = _topo_order(resolved.keys(), resolved)
|
||||
constructed: dict[str, Any] = {}
|
||||
for slug in order:
|
||||
registration = resolved[slug]
|
||||
try:
|
||||
constructed[slug] = registration.factory(config, constructed)
|
||||
except Exception:
|
||||
# All-or-nothing: close anything already built before re-raising.
|
||||
_close_partial_instances(constructed)
|
||||
raise
|
||||
_ = binary # documented but unused beyond labelling the returned root
|
||||
return constructed, tuple(order)
|
||||
|
||||
|
||||
def _close_partial_instances(instances: Mapping[str, Any]) -> None:
|
||||
"""Best-effort cleanup of partially-constructed components on failure.
|
||||
|
||||
Calls ``.close()`` on each instance that exposes one; swallows individual
|
||||
close failures so the original exception propagates to the caller.
|
||||
"""
|
||||
for inst in instances.values():
|
||||
close = getattr(inst, "close", None)
|
||||
if callable(close):
|
||||
try:
|
||||
close()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _resolve_component_strategies(
|
||||
config: Config, allowed_tiers: frozenset[StrategyTier]
|
||||
) -> dict[str, str]:
|
||||
"""Translate ``config.components[slug]`` into ``{slug: strategy_name}``.
|
||||
|
||||
Two recognised shapes for a per-component block:
|
||||
|
||||
* a dataclass with a ``strategy`` field (preferred);
|
||||
* a mapping with a ``"strategy"`` key (fallback for raw YAML).
|
||||
|
||||
Blocks without a ``strategy`` field are skipped — they configure a
|
||||
component whose strategy was hard-wired by the binary's bootstrap.
|
||||
"""
|
||||
_ = allowed_tiers # tier filtering happens in ``_resolve_strategy``
|
||||
selections: dict[str, str] = {}
|
||||
for slug, block in (config.components or {}).items():
|
||||
strategy = _read_strategy_attr(block)
|
||||
if strategy is None:
|
||||
continue
|
||||
if not isinstance(strategy, str) or not strategy:
|
||||
raise StrategyNotLinkedError(
|
||||
strategy_name=str(strategy),
|
||||
component_slug=slug,
|
||||
available_strategies=list_registered_strategies(slug),
|
||||
reason="config.components[slug].strategy must be a non-empty string",
|
||||
)
|
||||
selections[slug] = strategy
|
||||
return selections
|
||||
|
||||
|
||||
def _read_strategy_attr(block: Any) -> Any:
|
||||
if hasattr(block, "strategy"):
|
||||
return block.strategy
|
||||
if isinstance(block, Mapping):
|
||||
return block.get("strategy")
|
||||
return None
|
||||
|
||||
|
||||
def compose_root(config: Config) -> RuntimeRoot:
|
||||
"""Compose the airborne runtime graph (per contract v1.0.0)."""
|
||||
components, order = _compose(
|
||||
config,
|
||||
binary="airborne",
|
||||
allowed_tiers=frozenset({"airborne", "shared"}),
|
||||
extra_required_env=("MAVLINK_SIGNING_KEY",),
|
||||
)
|
||||
return RuntimeRoot(
|
||||
binary="airborne",
|
||||
profile=os.environ["GPS_DENIED_FC_PROFILE"],
|
||||
components=components,
|
||||
construction_order=order,
|
||||
)
|
||||
|
||||
|
||||
def compose_operator(config: Config) -> OperatorRoot:
|
||||
"""Compose the operator-tooling runtime graph (per contract v1.0.0)."""
|
||||
components, order = _compose(
|
||||
config,
|
||||
binary="operator-tooling",
|
||||
allowed_tiers=frozenset({"operator", "shared"}),
|
||||
extra_required_env=("SATELLITE_PROVIDER_URL",),
|
||||
)
|
||||
return OperatorRoot(
|
||||
binary="operator-tooling",
|
||||
profile=os.environ["GPS_DENIED_FC_PROFILE"],
|
||||
components=components,
|
||||
construction_order=order,
|
||||
)
|
||||
|
||||
|
||||
def compose_replay(config: Config) -> RuntimeRoot:
|
||||
"""Compose the replay-cli runtime graph. Concrete wiring is owned by AZ-401."""
|
||||
components, order = _compose(
|
||||
config,
|
||||
binary="replay-cli",
|
||||
allowed_tiers=frozenset({"airborne", "shared"}),
|
||||
extra_required_env=(),
|
||||
)
|
||||
return RuntimeRoot(
|
||||
binary="replay-cli",
|
||||
profile=os.environ["GPS_DENIED_FC_PROFILE"],
|
||||
components=components,
|
||||
construction_order=order,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TakeoffResult:
|
||||
"""Successful takeoff: writer is open, FC adapter is wired, components started.
|
||||
|
||||
Returned by :func:`take_off` on the success path. The abort path
|
||||
never returns — it calls :func:`sys.exit` with
|
||||
:data:`EXIT_FDR_OPEN_FAILURE`.
|
||||
"""
|
||||
|
||||
writer: Any
|
||||
fc_adapter: Any
|
||||
other_components: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def take_off(
|
||||
config: Config,
|
||||
*,
|
||||
writer_factory: Callable[[Config], FileFdrWriter],
|
||||
flight_header_factory: Callable[[Config], FlightHeader],
|
||||
fc_adapter_factory: Callable[[Config, Any], Any],
|
||||
other_components_factory: Callable[[Config, Any, Any], Mapping[str, Any]] | None = None,
|
||||
flight_root_for_message: str | None = None,
|
||||
) -> TakeoffResult:
|
||||
"""Run the strict airborne takeoff sequence (AZ-296).
|
||||
|
||||
Order: ``writer_factory`` → ``writer.start()`` →
|
||||
``writer.open_flight(header)`` → (only on success) ``fc_adapter_factory``
|
||||
→ ``other_components_factory``.
|
||||
|
||||
On :exc:`FdrOpenError` from ``open_flight``, this function logs ONE
|
||||
structured ERROR, calls ``writer.stop()`` (best-effort), prints the
|
||||
fixed FATAL line to stderr, and exits the process with
|
||||
:data:`EXIT_FDR_OPEN_FAILURE`. It never returns on that path.
|
||||
|
||||
Other exceptions propagate up unchanged; they reach :func:`main`
|
||||
which exits with :data:`EXIT_GENERIC_FAILURE`.
|
||||
|
||||
Tests inject factories; production wiring builds factories from
|
||||
:func:`compose_root`.
|
||||
"""
|
||||
from gps_denied_onboard.components.c13_fdr.errors import FdrOpenError
|
||||
|
||||
writer = writer_factory(config)
|
||||
writer.start()
|
||||
try:
|
||||
writer.open_flight(flight_header_factory(config))
|
||||
except FdrOpenError as exc:
|
||||
_abort_takeoff_on_fdr_open_error(
|
||||
writer=writer,
|
||||
config=config,
|
||||
exc=exc,
|
||||
flight_root=flight_root_for_message,
|
||||
)
|
||||
raise AssertionError( # pragma: no cover — abort helper must exit
|
||||
"unreachable: _abort_takeoff_on_fdr_open_error must exit"
|
||||
) from None
|
||||
fc_adapter = fc_adapter_factory(config, writer)
|
||||
other: Mapping[str, Any] = {}
|
||||
if other_components_factory is not None:
|
||||
other = other_components_factory(config, writer, fc_adapter)
|
||||
return TakeoffResult(writer=writer, fc_adapter=fc_adapter, other_components=other)
|
||||
|
||||
|
||||
def _abort_takeoff_on_fdr_open_error(
|
||||
*,
|
||||
writer: Any,
|
||||
config: Config,
|
||||
exc: BaseException,
|
||||
flight_root: str | None,
|
||||
) -> None:
|
||||
"""Execute the documented abort path; never returns."""
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
resolved_root = flight_root if flight_root is not None else _read_flight_root(config)
|
||||
underlying = str(exc)
|
||||
log = get_logger("composition_root")
|
||||
try:
|
||||
log.error(
|
||||
"composition_root.takeoff_aborted",
|
||||
extra={
|
||||
"kind": "composition_root.takeoff_aborted",
|
||||
"kv": {
|
||||
"reason": "fdr_open_error",
|
||||
"underlying": underlying,
|
||||
"flight_root": resolved_root,
|
||||
},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
# Logging must never block the abort path.
|
||||
pass
|
||||
try:
|
||||
writer.stop()
|
||||
except Exception as stop_exc:
|
||||
try:
|
||||
log.error(
|
||||
"composition_root.takeoff_abort_stop_failed",
|
||||
extra={
|
||||
"kind": "composition_root.takeoff_abort_stop_failed",
|
||||
"kv": {"error": repr(stop_exc)},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
f"FATAL: cannot open FDR at {resolved_root}: {underlying}; aborting takeoff (exit 2)",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
# sys.exit raises SystemExit, which propagates to the process boundary.
|
||||
# In the unlikely event that some intermediate frame catches SystemExit
|
||||
# (e.g. a misbehaving test harness), the fallback below ensures the
|
||||
# process still terminates with the documented exit code.
|
||||
sys.exit(EXIT_FDR_OPEN_FAILURE)
|
||||
os._exit(EXIT_FDR_OPEN_FAILURE) # pragma: no cover — only reached if SystemExit is intercepted
|
||||
|
||||
|
||||
def _read_flight_root(config: Config) -> str:
|
||||
fdr = getattr(config, "fdr", None)
|
||||
if fdr is None:
|
||||
return "<unknown>"
|
||||
path = getattr(fdr, "path", None)
|
||||
return str(path) if path is not None else "<unknown>"
|
||||
|
||||
|
||||
def main() -> int: # pragma: no cover — guarded entrypoint
|
||||
try:
|
||||
config = load_config(env=os.environ, paths=())
|
||||
compose_root(config)
|
||||
except (ConfigurationError, StrategyNotLinkedError, RuntimeError) as exc:
|
||||
print(f"runtime_root: {exc}", file=sys.stderr)
|
||||
return EXIT_GENERIC_FAILURE
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Composition-root factories for C8 (AZ-390 / E-C8).
|
||||
|
||||
Lazy-imports the per-variant adapter classes so the ADR-002 build-flag
|
||||
gate stays honest: the binary's bootstrap (one module per
|
||||
``BUILD_FC_*`` / ``BUILD_GCS_*`` combination) registers the concrete
|
||||
strategy via :func:`register_fc_adapter` / :func:`register_gcs_adapter`
|
||||
ahead of `build_fc_adapter` / `build_gcs_adapter`.
|
||||
|
||||
A second binding to the outbound emit thread is rejected (AC-6); the
|
||||
single-writer invariant for outbound is enforced statically by the
|
||||
composition root, not by the adapter itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Final
|
||||
|
||||
from gps_denied_onboard.components.c8_fc_adapter.errors import (
|
||||
FcAdapterConfigError,
|
||||
GcsAdapterConfigError,
|
||||
)
|
||||
from gps_denied_onboard.components.c8_fc_adapter.interface import (
|
||||
FcAdapter,
|
||||
GcsAdapter,
|
||||
)
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
|
||||
__all__ = [
|
||||
"OutboundThreadAlreadyBoundError",
|
||||
"bind_outbound_emit_thread",
|
||||
"build_fc_adapter",
|
||||
"build_gcs_adapter",
|
||||
"clear_outbound_thread_binding",
|
||||
"clear_strategy_registries",
|
||||
"list_registered_fc_strategies",
|
||||
"list_registered_gcs_strategies",
|
||||
"register_fc_adapter",
|
||||
"register_gcs_adapter",
|
||||
]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Strategy registries (single source of truth; populated by binary
|
||||
# bootstrap modules per ADR-002).
|
||||
|
||||
FcAdapterFactory = Callable[..., FcAdapter]
|
||||
GcsAdapterFactory = Callable[..., GcsAdapter]
|
||||
|
||||
_FC_REGISTRY: dict[str, FcAdapterFactory] = {}
|
||||
_GCS_REGISTRY: dict[str, GcsAdapterFactory] = {}
|
||||
|
||||
# Mapping from strategy slug -> documented BUILD_*_ flag name. The
|
||||
# build-flag gate (AC-4) checks ``os.environ`` for the canonical name
|
||||
# because flag state is a build-time artifact, not a config-time
|
||||
# artifact.
|
||||
_FC_BUILD_FLAGS: Final[dict[str, str]] = {
|
||||
"ardupilot_plane": "BUILD_FC_ARDUPILOT_PLANE",
|
||||
"inav": "BUILD_FC_INAV",
|
||||
}
|
||||
_GCS_BUILD_FLAGS: Final[dict[str, str]] = {
|
||||
"qgc_mavlink": "BUILD_GCS_QGC_MAVLINK",
|
||||
}
|
||||
|
||||
|
||||
def register_fc_adapter(strategy: str, factory: FcAdapterFactory) -> None:
|
||||
"""Register a concrete `FcAdapter` strategy.
|
||||
|
||||
Called from the per-binary bootstrap module (e.g.
|
||||
``runtime_root._bootstrap_ap.py``) under the matching
|
||||
``BUILD_FC_<VARIANT>`` flag. Duplicate registration with a
|
||||
different factory is a build error.
|
||||
"""
|
||||
existing = _FC_REGISTRY.get(strategy)
|
||||
if existing is not None and existing is not factory:
|
||||
raise FcAdapterConfigError(f"duplicate FcAdapter registration for strategy {strategy!r}")
|
||||
_FC_REGISTRY[strategy] = factory
|
||||
|
||||
|
||||
def register_gcs_adapter(strategy: str, factory: GcsAdapterFactory) -> None:
|
||||
existing = _GCS_REGISTRY.get(strategy)
|
||||
if existing is not None and existing is not factory:
|
||||
raise GcsAdapterConfigError(f"duplicate GcsAdapter registration for strategy {strategy!r}")
|
||||
_GCS_REGISTRY[strategy] = factory
|
||||
|
||||
|
||||
def clear_strategy_registries() -> None:
|
||||
"""Reset both registries; intended for unit-test isolation only."""
|
||||
_FC_REGISTRY.clear()
|
||||
_GCS_REGISTRY.clear()
|
||||
|
||||
|
||||
def list_registered_fc_strategies() -> list[str]:
|
||||
return sorted(_FC_REGISTRY)
|
||||
|
||||
|
||||
def list_registered_gcs_strategies() -> list[str]:
|
||||
return sorted(_GCS_REGISTRY)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Single-writer outbound thread enforcement (Invariant 8 / AC-6).
|
||||
|
||||
|
||||
class OutboundThreadAlreadyBoundError(RuntimeError):
|
||||
"""Raised on a second :func:`bind_outbound_emit_thread` call."""
|
||||
|
||||
|
||||
_outbound_lock = threading.Lock()
|
||||
_outbound_bound_thread: int | None = None
|
||||
|
||||
|
||||
def bind_outbound_emit_thread(thread_ident: int | None = None) -> int:
|
||||
"""Bind ``thread_ident`` (defaults to the caller) as the sole emit thread.
|
||||
|
||||
A second call from any thread raises
|
||||
:class:`OutboundThreadAlreadyBoundError`. The runtime root calls
|
||||
this once per process before wiring outbound emit; the result is
|
||||
the canonical thread id the adapter checks on every outbound call.
|
||||
"""
|
||||
global _outbound_bound_thread
|
||||
ident = thread_ident if thread_ident is not None else threading.get_ident()
|
||||
with _outbound_lock:
|
||||
if _outbound_bound_thread is not None and _outbound_bound_thread != ident:
|
||||
raise OutboundThreadAlreadyBoundError(
|
||||
f"outbound emit thread already bound to {_outbound_bound_thread}; "
|
||||
f"refused to re-bind to {ident}"
|
||||
)
|
||||
_outbound_bound_thread = ident
|
||||
return ident
|
||||
|
||||
|
||||
def clear_outbound_thread_binding() -> None:
|
||||
"""Reset the outbound-thread binding; intended for unit-test isolation."""
|
||||
global _outbound_bound_thread
|
||||
with _outbound_lock:
|
||||
_outbound_bound_thread = None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Build helpers — invoked by `compose_root` after C5 (FC) and after
|
||||
# the FC adapter (GCS).
|
||||
|
||||
|
||||
def build_fc_adapter(config: Config, **deps: Any) -> FcAdapter:
|
||||
"""Resolve and build the configured `FcAdapter` strategy.
|
||||
|
||||
Validates the build-flag gate (AC-4); raises
|
||||
:class:`FcAdapterConfigError` with the disabled-flag name when the
|
||||
requested strategy is not linked into the running binary.
|
||||
"""
|
||||
strategy = config.fc.adapter
|
||||
flag_name = _FC_BUILD_FLAGS.get(strategy)
|
||||
if flag_name is None:
|
||||
# config.fc.adapter went through FcConfig validation, so an
|
||||
# unknown strategy here means we forgot to add it to the
|
||||
# build-flag table — fail loudly.
|
||||
raise FcAdapterConfigError(f"FC strategy {strategy!r} has no BUILD_FC_* flag mapping")
|
||||
if os.environ.get(flag_name, "ON").upper() == "OFF":
|
||||
raise FcAdapterConfigError(
|
||||
f"{flag_name} is OFF — strategy {strategy!r} is not linked into this binary"
|
||||
)
|
||||
factory = _FC_REGISTRY.get(strategy)
|
||||
if factory is None:
|
||||
raise FcAdapterConfigError(
|
||||
f"FC strategy {strategy!r} is selected by config.fc.adapter but "
|
||||
f"not registered; registered strategies: "
|
||||
f"{list_registered_fc_strategies()}"
|
||||
)
|
||||
adapter = factory(config=config, **deps)
|
||||
_log_strategy_loaded(
|
||||
kind="c8.adapter.strategy_loaded",
|
||||
strategy=strategy,
|
||||
port_device=config.fc.port_device,
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
def build_gcs_adapter(config: Config, **deps: Any) -> GcsAdapter:
|
||||
"""Resolve and build the configured `GcsAdapter` strategy (AC-7)."""
|
||||
strategy = config.gcs.adapter
|
||||
flag_name = _GCS_BUILD_FLAGS.get(strategy)
|
||||
if flag_name is None:
|
||||
raise GcsAdapterConfigError(f"GCS strategy {strategy!r} has no BUILD_GCS_* flag mapping")
|
||||
if os.environ.get(flag_name, "ON").upper() == "OFF":
|
||||
raise GcsAdapterConfigError(
|
||||
f"{flag_name} is OFF — strategy {strategy!r} is not linked into this binary"
|
||||
)
|
||||
factory = _GCS_REGISTRY.get(strategy)
|
||||
if factory is None:
|
||||
raise GcsAdapterConfigError(
|
||||
f"GCS strategy {strategy!r} is selected by config.gcs.adapter but "
|
||||
f"not registered; registered strategies: "
|
||||
f"{list_registered_gcs_strategies()}"
|
||||
)
|
||||
adapter = factory(config=config, **deps)
|
||||
_log_strategy_loaded(
|
||||
kind="c8.gcs.strategy_loaded",
|
||||
strategy=strategy,
|
||||
port_device=config.gcs.port_device,
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
def _log_strategy_loaded(*, kind: str, strategy: str, port_device: str) -> None:
|
||||
log = get_logger("runtime_root.fc_factory")
|
||||
log.info(
|
||||
f"{kind}: strategy={strategy} port_device={port_device}",
|
||||
extra={
|
||||
"kind": kind,
|
||||
"kv": {"strategy": strategy, "port_device": port_device},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user