mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 07:11:12 +00:00
362e93c626
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>
217 lines
7.6 KiB
Python
217 lines
7.6 KiB
Python
"""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},
|
|
},
|
|
)
|