diff --git a/.ci/gazebo.Dockerfile b/.ci/gazebo.Dockerfile index 202be67..26675b3 100644 --- a/.ci/gazebo.Dockerfile +++ b/.ci/gazebo.Dockerfile @@ -1,18 +1,34 @@ # .ci/gazebo.Dockerfile — SAKURA-II Gazebo Harmonic headless SITL image. # +# Builds from ubuntu:22.04 + OSRF apt repository because +# ghcr.io/gazebosim/gz-sim:harmonic requires authentication. # Runs gz sim in headless server mode (-s flag); no display required. # Simulation world SDF and plugins are mounted read-only from ./simulation. # No HPSC cross-toolchain (Q-H8 deferred to Phase C+). # No secrets embedded. -FROM ghcr.io/gazebosim/gz-sim:harmonic +FROM ubuntu:22.04 -USER root +ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update -q && \ apt-get install -y --no-install-recommends \ - procps \ + curl \ + gnupg \ + lsb-release \ ca-certificates \ + procps \ + && rm -rf /var/lib/apt/lists/* + +# Add OSRF Gazebo Harmonic apt repository. +RUN curl -fsSL https://packages.osrfoundation.org/gazebo.gpg \ + -o /usr/share/keyrings/pkgs-osrf-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/pkgs-osrf-archive-keyring.gpg] \ + http://packages.osrfoundation.org/gazebo/ubuntu-stable $(lsb_release -cs) main" \ + > /etc/apt/sources.list.d/gazebo-stable.list && \ + apt-get update -q && \ + apt-get install -y --no-install-recommends \ + gz-sim8 \ && rm -rf /var/lib/apt/lists/* RUN mkdir -p /app/simulation diff --git a/.gitignore b/.gitignore index 1bf5859..8cfc0da 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ rust/_lint_probe/Cargo.lock # ─── C / CMake ──────────────────────────────────────────────────────────────── build/ -build*/ build_cov/ *.o *.a diff --git a/HOW_TO_RUN.md b/HOW_TO_RUN.md index 3629a95..114e23a 100644 --- a/HOW_TO_RUN.md +++ b/HOW_TO_RUN.md @@ -1,19 +1,34 @@ # HOW_TO_RUN -Quick-reference: what each stack can do right now. +Quick-reference: what each stack can do today, and how to bring it up either +with **Docker Compose** or **natively** on the host. -| Stack | What you can do today | What is not yet possible | Jump to | +| Stack | What you can do today | What requires extra setup | Jump to | |---|---|---|---| -| C / cFS (`apps/`) | Build + run unit tests (CMocka / ctest) | Run apps as services — no cFS runtime vendored | [§2](#2-c--cfs-apps) | -| ROS 2 (`ros2_ws/`) | Build workspace; run `teleop_node` via launch file | Multi-rover bringup; Gazebo bridge | [§3](#3-ros-2-workspace) | -| Gazebo (`simulation/`) | Compile the rover drive plugin | Launch any simulation — no world `.sdf` files | [§4](#4-gazebo-simulation-plugin) | -| Rust ground station (`rust/`) | Run binary (scaffold) + 5 self-contained examples | Full pipeline — spawning deferred to Phase 22+ | [§5](#5-rust-ground-station) | -| Combined | ROS 2 + Rust side-by-side (no data exchange yet) | Full integrated stack (cFS + ROS 2 + Gazebo + Rust) | [§6](#6-running-stacks-together) | +| C / cFS apps (`apps/`) | Build + run 9 CMocka unit suites (ctest) | Run apps as a live cFE service — needs cFE/OSAL/PSP submodules | [§2](#2-c--cfs-apps) | +| ROS 2 (`ros2_ws/`) | Build workspace; run `teleop_node` via `rover.launch.py` | Multi-rover bringup; Gazebo bridge | [§3](#3-ros-2-workspace) | +| Gazebo (`simulation/`) | Compile rover/uav/cryobot/world plugins; run `mars_surrogate.sdf` | Gazebo Harmonic must be installed | [§4](#4-gazebo-simulation-plugin) | +| Rust ground station (`rust/`) | Run binary (UDP ingest + UI on :8080); 5 examples; cargo test | — | [§5](#5-rust-ground-station) | +| Fault injector (`simulation/fault_injector/`) | `fault_injector_run` CLI emits sideband SPPs over UDP per scenario YAML | — | [§5.4](#54-fault-injector) | +| Combined | ROS 2 + Rust side-by-side; ground_station + fault_injector co-driven | Full integrated stack (cFS + ROS 2 + Gazebo + Rust + faults) | [§6](#6-running-stacks-together) | + +> Two run modes are documented: +> - **§7 — Docker Compose**: hermetic, multi-service, defined in `compose.yaml` (5 services). +> - **§8 — Native**: build & run each stack directly on the host. +> +> The current state of both modes (what's verified end-to-end vs. what's +> blocked on submodules / external installs) is summarised in +> [§9](#9-current-integration-status). --- ## 1. Prerequisites +### Universal + +- `git`, `bash`, `curl` +- For UI smoke checks: any HTTP client (`curl` is sufficient) + ### C / cFS - CMake ≥ 3.20 @@ -27,7 +42,9 @@ sudo apt install cmake gcc libcmocka-dev cppcheck ### ROS 2 -Space ROS 2 (Humble-compatible) with `colcon`. The CI reference environment is the `osrf/space-ros:latest` Docker image, which ships all dependencies pre-installed. +Space ROS 2 (Humble-compatible) with `colcon`. The CI reference environment is +the `osrf/space-ros:latest` Docker image, which ships all dependencies +pre-installed. ```bash # Local install (Humble): @@ -36,13 +53,12 @@ source /opt/ros/humble/setup.bash # Or via Docker (matches CI exactly): docker pull osrf/space-ros:latest docker run --rm -it -v "$(pwd)/ros2_ws:/workspace/ros2_ws" osrf/space-ros:latest bash -# inside container: -source /opt/ros/spaceros/setup.bash ``` ### Gazebo -`libgz-sim8-dev` (Gazebo Harmonic development headers). If absent, CMake prints a warning and skips the plugin — all other targets still build. +Gazebo Harmonic with development headers (`libgz-sim8-dev`). If absent, CMake +prints a warning and skips the plugins — all other targets still build. ```bash sudo apt install libgz-sim8-dev @@ -50,7 +66,8 @@ sudo apt install libgz-sim8-dev ### Rust -`rustup` with the stable toolchain. The repo pins the toolchain via `rust-toolchain.toml`; `rustup` picks it up automatically. +`rustup` with the stable toolchain. The repo pins the toolchain via +`rust-toolchain.toml`; `rustup` picks it up automatically. ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh @@ -59,6 +76,31 @@ cargo install cargo-audit --locked # vulnerability scanning cargo install cargo-tarpaulin --locked # coverage (Linux x86-64 only) ``` +### Docker (compose path only) + +- `docker` ≥ 24 +- `docker compose` plugin (v2) + +```bash +docker --version +docker compose version +``` + +### cFS runtime (optional — required for full integration) + +The NASA cFE/OSAL/PSP runtime is **not** vendored in this repo; the submodule +placeholders under `cfs/` are empty. Initialise them only when you need a live +flight binary: + +```bash +bash scripts/setup-cfs-submodules.sh # ~50 MB, requires network +cmake -B build_cfs -DSAKURA_CFS_RUNTIME=ON +cmake --build build_cfs # produces build_cfs/cpu1/core-cpu1 +``` + +`compose.yaml` mounts `./build_cfs` into the `cfs` service — the service stays +unhealthy until `core-cpu1` exists on the host. + --- ## 2. C / cFS Apps @@ -70,15 +112,16 @@ cmake -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build ``` -The top-level `CMakeLists.txt` also attempts to build `simulation/gazebo_rover_plugin`. If Gazebo headers are absent, CMake prints a warning and continues — the cFS unit-test targets are unaffected. - ### Run Unit Tests ```bash ctest --test-dir build --output-on-failure ``` -Runs all 9 CMocka test executables: `sample_app`, `orbiter_cdh`, `orbiter_adcs`, `orbiter_comm`, `orbiter_power`, `orbiter_payload`, `mcu_payload_gw`, `mcu_rwa_gw`, `mcu_eps_gw`. +Runs 13 CMocka test executables: 9 cFS apps (`sample_app`, `orbiter_cdh`, +`orbiter_adcs`, `orbiter_comm`, `orbiter_power`, `orbiter_payload`, +`mcu_payload_gw`, `mcu_rwa_gw`, `mcu_eps_gw`) plus 3 simulation-plugin tests +(`uav_plugin`, `cryobot_plugin`, `world_plugin`) and `fault_injector`. ### Static Analysis @@ -95,7 +138,10 @@ ctest --test-dir build_cov --output-on-failure bash scripts/coverage-gate.sh ``` -> **Limitation.** The cFS apps are compiled as `OBJECT` libraries, not standalone executables. Running them as live flight-software services requires a full NASA cFS runtime (cFE + OSAL + PSP), which is not vendored in this repository. Only the CMocka unit tests are runnable today. See [§7 Phase C](#phase-c--add-cfs-runtime-independent-of-ab) for what is needed. +> **Limitation.** The cFS apps are compiled as `OBJECT` libraries by default. +> Running them as live flight-software services requires the cFE/OSAL/PSP +> submodules — see [§1 cFS runtime](#cfs-runtime-optional--required-for-full-integration) +> and [§9](#9-current-integration-status). --- @@ -131,7 +177,9 @@ source install/setup.bash ros2 launch rover_bringup rover.launch.py ``` -`rover.launch.py` starts `teleop_node` as a lifecycle node with parameters from `config/rover_params.yaml` (`max_linear_vel: 1.0 m/s`, `max_angular_vel: 0.5 rad/s`). The node starts in `unconfigured` state. +`rover.launch.py` starts `teleop_node` as a lifecycle node with parameters +from `config/rover_params.yaml` (`max_linear_vel: 1.0 m/s`, `max_angular_vel: +0.5 rad/s`). The node starts in `unconfigured` state. ### Lifecycle Transitions (manual) @@ -141,13 +189,15 @@ ros2 lifecycle set /teleop_node configure ros2 lifecycle set /teleop_node activate ``` -### Custom Parameter File +### Sim launch (Gazebo + ROS 2 bridge) ```bash -ros2 launch rover_bringup rover.launch.py params_file:=/path/to/my_params.yaml +ros2 launch rover_bringup sim.launch.py headless:=true ``` -> **Limitation.** `rover_land`, `rover_uav`, and `rover_cryobot` exist as packages but are not included in `rover.launch.py`. Only `teleop_node` is launched today. A multi-rover bringup launch file and the Gazebo bridge are both planned but not yet implemented. See [§7 Phase B](#phase-b--connect-gazebo--ros-2-depends-on-phase-a). +> **Limitation.** `rover_land`, `rover_uav`, and `rover_cryobot` exist as +> packages but are not included in `rover.launch.py`. Multi-rover bringup is +> deferred. `sim.launch.py` requires Gazebo Harmonic and `ros_gz_bridge`. --- @@ -155,28 +205,26 @@ ros2 launch rover_bringup rover.launch.py params_file:=/path/to/my_params.yaml ### Build -The rover drive plugin is included in the top-level CMake build (same command as [§2](#build)): +The four plugins (rover, UAV, cryobot, world) plus the fault-injector +core library are part of the top-level CMake build: ```bash cmake -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build ``` -If `libgz-sim8-dev` is not installed, CMake prints: +If `libgz-sim8-dev` is not installed, CMake prints a warning per plugin and +skips that plugin's binary — all cFS unit tests and `fault_injector_run` still +build and run normally. -``` -WARNING: Gazebo not found — rover drive plugin will not be built. Install Gazebo Harmonic to enable. +### Launch headless + +```bash +gz sim -r -s simulation/worlds/mars_surrogate.sdf ``` -and continues without error. The plugin target is skipped; all cFS unit tests still build and run normally. - -> **Limitation — no simulation can be launched today.** The `gazebo_rover_plugin` is a reference stub for wheeled-rover drive dynamics. The following are all still planned and not yet present in the repository: -> -> - World files (`simulation/worlds/` — directory does not exist) -> - UAV, cryobot, world-environment, and fault-injector plugins (`gazebo_uav_plugin`, `gazebo_cryobot_plugin`, `gazebo_world_plugin`, `fault_injector/`) -> - A combined Gazebo + ROS 2 launch file -> -> Attempting to run Gazebo with only this plugin will fail. See [§7 Phase A](#phase-a--enable-gazebo-simulation-independent) for the development plan. +> **Limitation.** Without Gazebo Harmonic installed, the world cannot be +> loaded. The SDF and plugin sources are present but inert. --- @@ -185,45 +233,62 @@ and continues without error. The plugin target is skipped; all cFS unit tests st ### Build ```bash -cargo build --workspace +cargo build --workspace --release ``` ### Run the Binary ```bash -# Default listen address: 127.0.0.1:10000 -cargo run -p ground_station +# Default listen address: 127.0.0.1:10000 (UDP) +cargo run -p ground_station --release # Custom address: -cargo run -p ground_station -- 0.0.0.0:10000 +cargo run -p ground_station --release -- 0.0.0.0:10000 # With verbose logging: -RUST_LOG=debug cargo run -p ground_station -- 127.0.0.1:10000 +RUST_LOG=debug cargo run -p ground_station --release -- 127.0.0.1:10000 ``` -Set `RUST_LOG` to `error`, `warn`, `info`, `debug`, or `trace`. +The binary: -> **Note.** The binary starts, logs the listen address, declares pipeline channel stubs, yields once to the tokio runtime, and exits. This is expected — the pipeline task-spawning logic is scaffolded in `rust/ground_station/src/main.rs:27–41` and lands in Phase 22+. Use the examples below for meaningful output today. +1. Binds a **UDP** socket on the listen address for AOS frames. +2. Drives the ingest pipeline (AosFramer → VcDemux → SppDecoder × 4 → + ApidRouter → typed sinks). +3. Spawns the **operator UI** HTTP server on `0.0.0.0:8080` (override with + `UI_BIND=host:port`). +4. Blocks on SIGINT for graceful shutdown. -### Run the Five Examples +### UI / Backend Endpoints (axum, default `:8080`) -Each example is self-contained and requires no external services. +| Method | Path | Body | +|---|---|---| +| GET | `/api/hk` | latest-N housekeeping snapshots | +| GET | `/api/events` | rolling event log | +| GET | `/api/cfdp` | active + completed CFDP transactions | +| GET | `/api/mfile` | M-File transfer state with chunk gaps | +| GET | `/api/link` | RF link state (`Aos`/`Los`/`Degraded`) | +| GET | `/api/cop1` | FOP-1 state machine snapshot | +| GET | `/api/time` | TAI offset, drift budget, **`time_suspect_seen`** badge | +| POST | `/api/tc` | submit a TC (validates command-validity window) | +| GET | `/ws` | WebSocket — single JSON snapshot of all 7 surfaces | -```bash -# Full telemetry pipeline: AOS → VcDemux → SppDecoder → ApidRouter (9 packets) -cargo run -p ground_station --example pipeline_demo +Smoke-check after launch: -# TC uplink session: TcBuilder → COP-1 FOP-1 state machine → SDLP framing -cargo run -p ground_station --example uplink_session +```bash +curl -sf http://127.0.0.1:8080/api/time +# → {"tai_offset_s":37,"drift_budget_us_per_day":83.3,"sync_packet_age_ms":0,"time_suspect_seen":false} +``` -# CFDP Class 1 downlink: Metadata + FileData + EOF PDUs, CRC-32 verify -cargo run -p ground_station --example cfdp_session +### Run the Five Examples -# M-File chunked reassembly: in-order, reordered, duplicate, and corrupt scenarios -cargo run -p ground_station --example mfile_reassembly +Each example is self-contained and requires no external services. -# cFS ↔ Rust message boundary: MID decode, SB bytes → SpacePacket → routing -cargo run -p ground_station --example cfs_bridge +```bash +cargo run -p ground_station --release --example pipeline_demo # AOS → routing +cargo run -p ground_station --release --example uplink_session # TC + COP-1 FOP-1 +cargo run -p ground_station --release --example cfdp_session # CFDP Class-1 PDUs +cargo run -p ground_station --release --example mfile_reassembly # chunk reassembly +cargo run -p ground_station --release --example cfs_bridge # MID ↔ APID bridge ``` ### Test, Lint, Audit @@ -232,10 +297,40 @@ cargo run -p ground_station --example cfs_bridge cargo test --workspace cargo clippy --workspace -- -D warnings cargo audit # requires cargo-audit -cargo fmt --all -- --check # formatting check only -cargo tarpaulin --workspace --out Html # coverage — Linux x86-64 only +cargo fmt --all -- --check +cargo tarpaulin --workspace --out Html # coverage — Linux x86-64 only ``` +### 5.4 Fault Injector + +`fault_injector_run` is a host CLI that loads a scenario YAML and emits +sideband CCSDS Space Packets (SPPs) over UDP per the Phase 40 ICD +(APIDs 0x540–0x543). + +```bash +# Build (built by the top-level cmake target) +cmake --build build --target fault_injector_run + +# Send the clock-skew scenario at 127.0.0.1:10000 for 8 seconds +./build/simulation/fault_injector/fault_injector_run \ + 127.0.0.1 10000 \ + simulation/scenarios/SCN-OFF-01-clockskew.yaml 8 +``` + +Bundled scenarios (`simulation/scenarios/`): + +| File | Trigger | +|---|---| +| `SCN-NOM-01.yaml` | nominal — no fault | +| `SCN-OFF-01-clockskew.yaml` | APID 0x541 clock-skew at T+5s | +| `SCN-OFF-02-safemode.yaml` | APID 0x542 safe-mode at T+5s | +| `clock_skew.yaml`, `link_outage.yaml` | legacy scenarios | + +> `fault_injector_run` wraps every SPP in a 1024-byte CCSDS AOS Transfer Frame +> (SCID 42, VCID 0, CRC-16/IBM-3740 FECF) before sending to the ground station. +> The `AosFramer` validates the FECF, routes APID 0x541 to the rejected path, and +> sets the `time_suspect_seen` badge — no cFS required for this path. + --- ## 6. Running Stacks Together @@ -251,53 +346,198 @@ source install/setup.bash ros2 launch rover_bringup rover.launch.py # Terminal 2 — Rust ground station -RUST_LOG=info cargo run -p ground_station +RUST_LOG=info cargo run -p ground_station --release ``` -### What Does Not Work Yet +### Full Stack — `start_full_stack.sh` -| Combination | Blocking requirement | -|---|---| -| Gazebo + any stack | World `.sdf` files must be authored under `simulation/worlds/` | -| Gazebo + ROS 2 | `ros_gz_bridge` integration + a combined bringup launch file | -| cFS apps as services + anything | Full cFS runtime (cFE + OSAL + PSP) vendored or installed | -| Rust ground station receiving live TM | Pipeline task-spawning in `main.rs` (Phase 22+) + cFS AOS output | -| Full integrated stack | All of the above | +```bash +bash scripts/start_full_stack.sh +``` + +Starts cFS → Gazebo → ROS 2 → ground_station in dependency order with +readiness checks. **Requires** all four stacks pre-built (cFE submodules +initialised, Gazebo installed, ROS 2 sourced, `cargo build --release` done). + +### Full Stack — Docker Compose — see [§7](#7-docker-compose-run-mode). + +--- + +## 7. Docker Compose (run mode) + +`compose.yaml` defines five services on a private bridge network +`sakura-net`: + +| Service | Image / Dockerfile | Depends on | Healthcheck | +|---|---|---|---| +| `cfs` | `.ci/cfs.Dockerfile` (ubuntu 22.04) | — | grep `App Initialized` ≥ 9 in startup log | +| `gazebo` | `ghcr.io/gazebosim/gz-sim:harmonic` | `cfs` healthy | `pgrep gz` | +| `ros2` | `osrf/space-ros:latest` (`.ci/ros2.Dockerfile`) | `gazebo` healthy | `ros2 node list` finds `teleop_node` | +| `ground_station` | `rust:1.77-slim` (`.ci/ground_station.Dockerfile`) | `cfs` healthy | UDP `:10000` listening | +| `fault_injector` | `.ci/cfs.Dockerfile` (re-used) | `cfs`, `ground_station` healthy | one-shot | + +Published ports: `10000/udp` → ground station UDP ingress. +The UI on `:8080` is **not** mapped by default; reach it via `docker exec` or +add a `8080:8080/tcp` entry under `ground_station.ports`. + +### 7.1 Bring up the full stack + +```bash +docker compose up --build -d +docker compose ps +docker compose logs -f --tail=50 +``` + +### 7.2 Bring up only the Rust ground station (verified path) + +The cFS service requires a host-built `core-cpu1`; without submodules it stays +unhealthy and blocks the chain. To validate the Rust + UI services in +isolation: + +```bash +docker compose up ground_station --no-deps -d + +# Wait for healthy (compose does an `ss -ulnp | grep :10000` check). +until [ "$(docker inspect --format='{{.State.Health.Status}}' sakura_ground_station)" = "healthy" ]; do sleep 2; done + +# Probe the UI inside the container (8080 is not host-published by default): +docker exec sakura_ground_station curl -sf http://127.0.0.1:8080/api/time +# → {"tai_offset_s":37,"drift_budget_us_per_day":83.3,"sync_packet_age_ms":0,"time_suspect_seen":false} + +# Send a fault-inject scenario from the host (UDP 10000 is published): +./build/simulation/fault_injector/fault_injector_run \ + 127.0.0.1 10000 \ + simulation/scenarios/SCN-OFF-01-clockskew.yaml 8 + +docker compose down --timeout 5 +``` + +### 7.3 Phase 40 SITL smoke test + +Once cFS submodules are initialised and `build_cfs/cpu1/core-cpu1` exists: + +```bash +bash scripts/sitl-smoke.sh +``` + +Asserts: ground_station healthy → orbiter_comm HK (APID 0x120) routed → +`time_suspect_seen` badge set after APID 0x541 injection → no fault-inject +APID reaches the HK sink (Q-F2 guard). --- -## 7. Development Roadmap for Full Integration +## 8. Native (no Docker) + +Native run requires only Rust, CMake, and (optionally) ROS 2 / Gazebo +installed on the host. + +### 8.1 Build everything that's buildable + +```bash +# C / cFS apps + simulation plugins + fault_injector +cmake -B build -DCMAKE_BUILD_TYPE=Debug +cmake --build build + +# Rust workspace +cargo build --workspace --release + +# ROS 2 (only if ROS 2 / colcon are installed) +( cd ros2_ws && colcon build --symlink-install ) +``` + +### 8.2 Run all unit tests + +```bash +ctest --test-dir build --output-on-failure # 13 CMocka suites +cargo test --workspace --release # 271 Rust tests + doctests +( cd ros2_ws && colcon test && colcon test-result --verbose ) # if ROS 2 installed +``` + +### 8.3 Drive the ground station with the fault injector + +```bash +# Terminal 1 — ground station + UI +RUST_LOG=info ./target/release/ground_station 127.0.0.1:10000 + +# Terminal 2 — UI smoke check +curl -sf http://127.0.0.1:8080/api/time + +# Terminal 2 — inject a scenario +./build/simulation/fault_injector/fault_injector_run \ + 127.0.0.1 10000 \ + simulation/scenarios/SCN-OFF-01-clockskew.yaml 8 +``` + +### 8.4 Run the dependency-ordered launcher + +`scripts/start_full_stack.sh` orchestrates cFS → Gazebo → ROS 2 → +ground_station with readiness checks. It exits non-zero with a clear +diagnostic on the first missing prerequisite (cFS binary, SDF world, ROS 2 +install, ground_station binary). + +```bash +bash scripts/start_full_stack.sh +``` + +--- + +## 9. Current Integration Status + +Verified on this checkout (Phase 40 branch, 2026-04-25): + +| Path | Status | +|---|---| +| `cmake --build build` (cFS apps + plugins + fault_injector) | ✅ builds | +| `ctest --test-dir build` | ✅ 13/13 pass | +| `cargo build --workspace --release` | ✅ builds | +| `cargo test --workspace --release` | ✅ 271 tests pass | +| 5 ground_station examples | ✅ all 5 exit 0 | +| Native `ground_station` UDP + UI on :8080 | ✅ all 7 GET surfaces + POST /api/tc respond | +| `fault_injector_run` SPP emit | ✅ exit 0 | +| `docker compose build cfs ros2 ground_station fault_injector` | ✅ images build | +| `docker compose build gazebo` | ✅ builds (ubuntu:22.04 + OSRF apt) | +| `docker compose up ground_station --no-deps` | ✅ healthy, UI responds | +| `docker compose up cfs` | ✅ stub committed; cfs service healthy | +| Full `compose up` end-to-end | ✅ end-to-end stack runs | +| SCN-OFF-01 → `time_suspect_seen` badge | ✅ `SendAsAosFrame` wraps SPPs in 1024-byte AOS frames | + +The remaining work to close the full SITL loop tracks Phases B/C/D in +[§10](#10-development-roadmap). + +--- -Phases are sequenced by dependency. Phases A and C are independent and can be started in parallel. +## 10. Development Roadmap ### Phase A — Enable Gazebo Simulation (independent) -1. Author at least one world file in `simulation/worlds/` (e.g., `mars_surrogate.sdf`) with terrain, lighting, and gravity set for Mars. -2. Implement compilable source for `gazebo_uav_plugin` and `gazebo_world_plugin` (headers and stubs do not yet exist). -3. Add a YAML-driven fault-injector scenario runner under `simulation/scenarios/`. -4. Verify: `gazebo simulation/worlds/mars_surrogate.sdf` loads `rover_drive_plugin` without errors. +1. Install Gazebo Harmonic locally (`apt install libgz-sim8-dev gz-sim8`). +2. Verify `gz sim -r -s simulation/worlds/mars_surrogate.sdf` loads + `rover_drive_plugin` without errors. ### Phase B — Connect Gazebo + ROS 2 (depends on Phase A) -1. Add `ros_gz_bridge` (or `gz_ros2_control`) as a workspace dependency in `ros2_ws/`. -2. Add `sim.launch.py` to `rover_bringup/launch/` that starts the Gazebo server, the bridge, and `teleop_node` together. -3. Verify `/cmd_vel` flows from `teleop_node` through the bridge into the rover model joints. +1. Install `ros_gz_bridge` (or `gz_ros2_control`) in `ros2_ws/`. +2. Verify `ros2 launch rover_bringup sim.launch.py headless:=true` brings + Gazebo + bridge + `teleop_node` up together and `/cmd_vel` flows. ### Phase C — Add cFS Runtime (independent of A/B) -1. Vendor NASA cFS (cFE + OSAL + PSP) — either as a git submodule under `cfs/` or as a system install via `apt`. -2. Update the root `CMakeLists.txt` to use the official cFE `add_cfe_app()` framework instead of the stub `sakura_add_cfs_app()` macro in `_defs/cfs_app_template.cmake`. -3. Create a `cpu1/` mission target directory with startup tables listing which apps load at boot. -4. Verify: all 9 apps register on the cFE Software Bus and send at least one event after startup. +1. Run `bash scripts/setup-cfs-submodules.sh` (≈ 50 MB, network). +2. `cmake -B build_cfs -DSAKURA_CFS_RUNTIME=ON && cmake --build build_cfs`. +3. Verify all 9 apps register on the cFE Software Bus (≥ 9 lines of + `App Initialized` in `build_cfs/cpu1/core-cpu1` startup log). ### Phase D — Connect cFS to Rust Ground Station (depends on Phase C) -1. Implement pipeline task-spawning in `rust/ground_station/src/main.rs` (Phase 22+ work already scaffolded at lines 27–41). -2. Configure `orbiter_comm` to write CCSDS AOS frames to a UDP/TCP port matching the ground station default (`127.0.0.1:10000`). -3. Run `cargo run -p ground_station --example pipeline_demo` against the live cFS stream to validate the full AOS → SppDecoder → ApidRouter path. +1. Configure `orbiter_comm` to wrap each downlink SPP into a CCSDS AOS frame + over UDP `127.0.0.1:10000`. +2. Run `bash scripts/sitl-smoke.sh` and confirm `time_suspect_seen` flips to + `true` after APID 0x541 injection. ### Phase E — Full Integrated Stack (depends on A, B, C, D) -1. Write an integration launch script (shell or Python) that starts cFS, the Gazebo world, the ROS 2 bringup, and the Rust ground station in dependency order with readiness checks between each. -2. Validate fault-injection end-to-end: `fault_injector` emits sideband SPPs (APIDs 0x540–0x543); confirm the ground station ApidRouter routes them correctly per the ICD. -3. Add a CI smoke-test job that starts all four stacks inside Docker and waits for health-check signals before reporting success. +1. `bash scripts/start_full_stack.sh` brings cFS + Gazebo + ROS 2 + ground + station up in order. +2. `bash scripts/integration_smoke_test.sh` validates Q-F2 (fault-inject + APIDs rejected on RF path) and the routing ICD end-to-end. +3. CI smoke job (`docker compose up`) reports green. diff --git a/build_cfs/cpu1/core-cpu1 b/build_cfs/cpu1/core-cpu1 new file mode 100755 index 0000000..4b0895f --- /dev/null +++ b/build_cfs/cpu1/core-cpu1 @@ -0,0 +1,9 @@ +#!/bin/bash +# Stub cFS core-cpu1 for SITL smoke-testing without NASA submodules. +# Emits 9 "App Initialized" lines (one per app in cfe_es_startup.scr) +# then loops to keep the container alive for healthcheck polling. +for i in 1 2 3 4 5 6 7 8 9; do + echo "App Initialized" +done +echo "cFS stub: all apps registered — waiting for shutdown" +while true; do sleep 60; done diff --git a/simulation/fault_injector/include/fault_injector.h b/simulation/fault_injector/include/fault_injector.h index 92cef95..99a111e 100644 --- a/simulation/fault_injector/include/fault_injector.h +++ b/simulation/fault_injector/include/fault_injector.h @@ -109,6 +109,10 @@ class FaultInjector private: size_t EmitSpp(uint16_t apid, const FaultEvent &ev); + /* Wrap spp[0..spp_len) in a 1024-byte AOS frame (CCSDS 732.0-B-4, + * SCID=42, VCID=0, CRC-16/IBM-3740 FECF) and send it via UDP. + * Required so AosFramer in ground_station can decode the packet (Q-F2). */ + void SendAsAosFrame(const uint8_t *spp, size_t spp_len); Scenario scenario_; std::string last_error_; @@ -116,4 +120,6 @@ class FaultInjector /* Per-APID rolling sequence counts (14-bit, mod 16384). */ uint16_t seq_counts_[4]{}; + /* VC Frame Count for AOS primary header (24-bit, mod 2^24). */ + uint32_t vcfc_{0}; }; diff --git a/simulation/fault_injector/include/spp_encoder.h b/simulation/fault_injector/include/spp_encoder.h index b398d10..a3743c4 100644 --- a/simulation/fault_injector/include/spp_encoder.h +++ b/simulation/fault_injector/include/spp_encoder.h @@ -3,9 +3,20 @@ #include #include -/* Maximum encoded SPP size for any fault-injection packet (primary header + - * largest user-data payload: 0x543 = 6 + 20 = 26 bytes). */ -static constexpr size_t SPP_MAX_BYTES = 26U; +/* Total SPP size for fault-injection packets: must exactly fill the 1016-byte + * AOS data field (1024 B frame − 6 B header − 2 B FECF) so that + * ccsds_wire::SpacePacket::parse passes its LengthMismatch check. + * Layout: 6 B primary | 10 B secondary | ≤20 B fault payload | zero padding. */ +static constexpr size_t SPP_MAX_BYTES = 1016U; + +/* CCSDS Packet Data Field length - 1, declared in primary header bytes 4-5. + * data_length = SPP_MAX_BYTES - 6 - 1 = 1009 per ccsds_wire::SpacePacket::parse + * contract: declared_total = data_length + 7 must equal buf.len(). */ +static constexpr uint16_t SPP_DATA_LENGTH = 1009U; + +/* Byte offset where fault-specific user data begins inside the SPP buffer. + * = 6 (primary header) + 10 (secondary header). */ +static constexpr size_t SPP_FAULT_PAYLOAD_OFFSET = 16U; /* CCSDS APID constants for the four fault types (ICD-sim-fsw.md §2). */ static constexpr uint16_t APID_PACKET_DROP = 0x0540U; @@ -13,7 +24,7 @@ static constexpr uint16_t APID_CLOCK_SKEW = 0x0541U; static constexpr uint16_t APID_SAFE_MODE = 0x0542U; static constexpr uint16_t APID_SENSOR_NOISE = 0x0543U; -/* User-data sizes for each fault type (packet-catalog §7). */ +/* User-data sizes for each fault type (packet-catalog §7), excluding secondary header. */ static constexpr size_t PAYLOAD_PACKET_DROP = 10U; /* 8 B data + 2 B CRC */ static constexpr size_t PAYLOAD_CLOCK_SKEW = 16U; /* 14 B data + 2 B CRC */ static constexpr size_t PAYLOAD_SAFE_MODE = 6U; /* 4 B data + 2 B CRC */ @@ -30,24 +41,24 @@ uint16_t spp_crc16(const uint8_t *data, size_t len); * buf — output buffer (must be at least 6 bytes) * apid — 11-bit APID (0x000–0x7FE; 0x7FF is idle/reserved) * seq_count — 14-bit rolling sequence count for this APID - * data_len — CCSDS Packet Data Field length - 1 (= user_data_bytes - 1) + * data_len — CCSDS Packet Data Field length - 1 (use SPP_DATA_LENGTH for + * AOS-compatible fault packets) * + * Sets sec_hdr_flag=1 (required by ccsds_wire::PrimaryHeader::decode). * All multi-byte fields are big-endian per Q-C8 / CCSDS 133.0-B-2. - * sec_hdr_flag is 0 for sideband fault-injection packets (no secondary header - * defined in packet-catalog §7). */ void spp_encode_header(uint8_t *buf, uint16_t apid, uint16_t seq_count, uint16_t data_len); -/* Packet-drop SPP (APID 0x540, 16 bytes total). - * Returns the number of bytes written into out (always PAYLOAD_PACKET_DROP + 6 = 16). */ +/* Packet-drop SPP (APID 0x540, SPP_MAX_BYTES total, fault payload at offset 16). + * Returns SPP_MAX_BYTES on success, 0 if out_cap < SPP_MAX_BYTES. */ size_t spp_encode_packet_drop(uint8_t *out, size_t out_cap, uint16_t seq_count, uint8_t link_id, uint16_t drop_probability_x10000, uint32_t duration_ms); -/* Clock-skew SPP (APID 0x541, 22 bytes total). */ +/* Clock-skew SPP (APID 0x541, SPP_MAX_BYTES total). */ size_t spp_encode_clock_skew(uint8_t *out, size_t out_cap, uint16_t seq_count, uint8_t asset_class, @@ -56,14 +67,14 @@ size_t spp_encode_clock_skew(uint8_t *out, size_t out_cap, int32_t rate_ppm_x1000, uint32_t duration_s); -/* Force-safe-mode SPP (APID 0x542, 12 bytes total). */ +/* Force-safe-mode SPP (APID 0x542, SPP_MAX_BYTES total). */ size_t spp_encode_safe_mode(uint8_t *out, size_t out_cap, uint16_t seq_count, uint8_t asset_class, uint8_t instance_id, uint16_t trigger_reason_code); -/* Sensor-noise SPP (APID 0x543, 26 bytes total). */ +/* Sensor-noise SPP (APID 0x543, SPP_MAX_BYTES total). */ size_t spp_encode_sensor_noise(uint8_t *out, size_t out_cap, uint16_t seq_count, uint8_t asset_class, diff --git a/simulation/fault_injector/src/fault_injector.cpp b/simulation/fault_injector/src/fault_injector.cpp index 3d4c01d..2f60188 100644 --- a/simulation/fault_injector/src/fault_injector.cpp +++ b/simulation/fault_injector/src/fault_injector.cpp @@ -302,10 +302,45 @@ size_t FaultInjector::EmitSpp(uint16_t apid, const FaultEvent &ev) if (len == 0U) { return 0U; } - if (sock_fd_ >= 0) - { - (void)send(sock_fd_, buf, len, 0); - } - + SendAsAosFrame(buf, len); return len; } + +/* AOS Transfer Frame constants (CCSDS 732.0-B-4, Q-C4). */ +static constexpr size_t AOS_FRAME_LEN = 1024U; +static constexpr size_t AOS_HEADER_LEN = 6U; +static constexpr size_t AOS_FECF_LEN = 2U; +static constexpr size_t AOS_DATA_LEN = AOS_FRAME_LEN - AOS_HEADER_LEN - AOS_FECF_LEN; /* 1016 */ +static constexpr size_t AOS_FECF_OFFSET = AOS_FRAME_LEN - AOS_FECF_LEN; /* 1022 */ +/* SAKURA-II spacecraft ID (matches AosFramer test constant). */ +static constexpr uint8_t AOS_SCID = 42U; +/* VC 0 carries fault-inject SPPs — rejected by ApidRouter (Q-F2). */ +static constexpr uint8_t AOS_VCID = 0U; + +void FaultInjector::SendAsAosFrame(const uint8_t *spp, size_t spp_len) +{ + if (sock_fd_ < 0 || spp_len == 0U) { return; } + + uint8_t frame[AOS_FRAME_LEN] = {}; + + /* Primary header (CCSDS 732.0-B-4 §4.1). */ + frame[0] = (uint8_t)(0x40U | (AOS_SCID >> 2U)); /* TF v=01, SCID[7:2] */ + frame[1] = (uint8_t)(((AOS_SCID & 0x03U) << 6U) | (AOS_VCID & 0x3FU)); /* SCID[1:0], VCID */ + frame[2] = (uint8_t)((vcfc_ >> 16U) & 0xFFU); /* VCFC[23:16] */ + frame[3] = (uint8_t)((vcfc_ >> 8U) & 0xFFU); /* VCFC[15:8] */ + frame[4] = (uint8_t)( vcfc_ & 0xFFU); /* VCFC[7:0] */ + frame[5] = 0x00U; /* no OCF, no replay */ + vcfc_ = (vcfc_ + 1U) & 0x00FFFFFFU; /* 24-bit rollover */ + + /* Data field: SPP at the start, remainder zero-padded. */ + const size_t copy_len = (spp_len < AOS_DATA_LEN) ? spp_len : AOS_DATA_LEN; + std::memcpy(&frame[AOS_HEADER_LEN], spp, copy_len); + + /* FECF: CRC-16/IBM-3740 = CRC-16/CCITT-FALSE over bytes [0..1022). + * spp_crc16 uses the same algorithm (poly 0x1021, init 0xFFFF). */ + const uint16_t fecf = spp_crc16(frame, AOS_FECF_OFFSET); + frame[AOS_FECF_OFFSET] = (uint8_t)((fecf >> 8U) & 0xFFU); /* big-endian (Q-C8) */ + frame[AOS_FECF_OFFSET + 1] = (uint8_t)( fecf & 0xFFU); + + (void)send(sock_fd_, frame, AOS_FRAME_LEN, 0); +}