diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12485b1..a98c074 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,8 +42,8 @@ jobs: - name: Lint run: ruff check src - - name: Run unit tests (exclude integration and BLE) - run: pytest -m "not integration and not ble" -v --tb=short + - name: Run unit tests (exclude integration, BLE, and Docker) + run: pytest -m "not integration and not ble and not docker" -v --tb=short - name: Build sdist and wheel run: | diff --git a/.github/workflows/sat-mock-test.yml b/.github/workflows/sat-mock-test.yml new file mode 100644 index 0000000..0f2a939 --- /dev/null +++ b/.github/workflows/sat-mock-test.yml @@ -0,0 +1,49 @@ +name: Satellite Mock Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 0 * * 1' # Weekly on Monday at midnight UTC + workflow_dispatch: + +jobs: + sat-mock-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Cache Docker image + id: docker-cache + uses: actions/cache@v4 + with: + path: /tmp/sdr-docker-image.tar + key: sdr-docker-${{ hashFiles('.github/workflows/sat-mock-test.yml') }} + + - name: Load cached Docker image + if: steps.docker-cache.outputs.cache-hit == 'true' + run: docker load -i /tmp/sdr-docker-image.tar + + - name: Pull Docker image + if: steps.docker-cache.outputs.cache-hit != 'true' + run: docker pull ghcr.io/hubblenetwork/sdr-docker:latest + + - name: Save Docker image to cache + if: steps.docker-cache.outputs.cache-hit != 'true' + run: docker save ghcr.io/hubblenetwork/sdr-docker:latest -o /tmp/sdr-docker-image.tar + + - name: Run satellite mock integration tests + run: pytest tests/test_sat_integration.py -v --tb=short diff --git a/pyproject.toml b/pyproject.toml index fd4e471..1c5a85a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ testpaths = ["tests"] pythonpath = ["src"] markers = [ "ble: tests that require BLE hardware/permissions", + "docker: tests that require a running Docker daemon", "integration: slow or environment-dependent tests", ] diff --git a/src/hubblenetwork/cli.py b/src/hubblenetwork/cli.py index 6a1006a..c597bf0 100644 --- a/src/hubblenetwork/cli.py +++ b/src/hubblenetwork/cli.py @@ -2795,63 +2795,18 @@ def sat() -> None: """Satellite (PlutoSDR) utilities.""" -@sat.command("scan") -@click.option( - "--timeout", - "-t", - type=int, - show_default=False, - help="Timeout in seconds (default: no timeout)", -) -@click.option( - "--count", - "-n", - type=int, - default=None, - show_default=False, - help="Stop after receiving N packets", -) -@click.option( - "--format", - "-o", - "output_format", - type=click.Choice(["tabular", "json"], case_sensitive=False), - default="tabular", - show_default=True, - help="Output format for packets", -) -@click.option( - "--poll-interval", - type=float, - default=2.0, - show_default=True, - help="Seconds between API polls", -) -@click.option( - "--payload-format", - "payload_format", - type=click.Choice(["base64", "hex", "string"], case_sensitive=False), - default="base64", - show_default=True, - help="Encoding format for packet payload", -) -def sat_scan( - timeout: Optional[int] = None, - count: Optional[int] = None, - output_format: str = "tabular", - poll_interval: float = 2.0, - payload_format: str = "base64", +def _run_sat_scan( + *, + mock: bool, + timeout: Optional[int], + count: Optional[int], + output_format: str, + poll_interval: float, + payload_format: str, ) -> None: - """ - Start the satellite receiver and stream decoded packets. + """Shared implementation for ``sat scan`` and ``sat mock-scan``.""" + mode_label = "mock satellite receiver" if mock else "satellite receiver" - Requires Docker and a PlutoSDR device connected via USB. - - Example: - hubblenetwork sat scan --timeout 30 - hubblenetwork sat scan -o json --timeout 10 - hubblenetwork sat scan -n 5 - """ printer_class = _SAT_STREAMING_PRINTERS.get( output_format.lower(), _SatStreamingTablePrinter ) @@ -2870,7 +2825,7 @@ def sat_scan( if not printer.suppress_info_messages: click.secho( - "[INFO] Starting satellite receiver... (Press Ctrl+C to stop)" + f"[INFO] Starting {mode_label}... (Press Ctrl+C to stop)" ) _stop_msg_shown = [False] @@ -2878,7 +2833,7 @@ def sat_scan( def _on_interrupt(sig, frame): if not _stop_msg_shown[0] and not printer.suppress_info_messages: click.secho( - "\n[INFO] Stopping satellite receiver...", fg="yellow", err=True + f"\n[INFO] Stopping {mode_label}...", fg="yellow", err=True ) _stop_msg_shown[0] = True raise KeyboardInterrupt() @@ -2887,7 +2842,7 @@ def _on_interrupt(sig, frame): error_occurred = False try: for pkt in sat_mod.scan( - timeout=timeout, poll_interval=poll_interval + timeout=timeout, poll_interval=poll_interval, mock=mock ): printer.print_row(pkt) if count is not None and printer.packet_count >= count: @@ -2921,6 +2876,87 @@ def _on_interrupt(sig, frame): ) +def _sat_scan_options(fn): + """Apply the common sat scan/mock-scan Click options.""" + for decorator in reversed([ + click.option("--timeout", "-t", type=int, show_default=False, + help="Timeout in seconds (default: no timeout)"), + click.option("--count", "-n", type=int, default=None, + show_default=False, help="Stop after receiving N packets"), + click.option("--format", "-o", "output_format", + type=click.Choice(["tabular", "json"], case_sensitive=False), + default="tabular", show_default=True, + help="Output format for packets"), + click.option("--poll-interval", type=float, default=2.0, + show_default=True, help="Seconds between API polls"), + click.option("--payload-format", "payload_format", + type=click.Choice(["base64", "hex", "string"], + case_sensitive=False), + default="base64", show_default=True, + help="Encoding format for packet payload"), + ]): + fn = decorator(fn) + return fn + + +@sat.command("scan") +@_sat_scan_options +def sat_scan( + timeout: Optional[int] = None, + count: Optional[int] = None, + output_format: str = "tabular", + poll_interval: float = 2.0, + payload_format: str = "base64", +) -> None: + """ + Start the satellite receiver and stream decoded packets. + + Requires Docker and a PlutoSDR device connected via USB. + + Example: + hubblenetwork sat scan --timeout 30 + hubblenetwork sat scan -o json --timeout 10 + hubblenetwork sat scan -n 5 + """ + _run_sat_scan( + mock=False, + timeout=timeout, + count=count, + output_format=output_format, + poll_interval=poll_interval, + payload_format=payload_format, + ) + + +@sat.command("mock-scan") +@_sat_scan_options +def sat_mock_scan( + timeout: Optional[int] = None, + count: Optional[int] = None, + output_format: str = "tabular", + poll_interval: float = 2.0, + payload_format: str = "base64", +) -> None: + """ + Start the satellite receiver in mock mode and stream synthetic packets. + + Uses simulated data -- no PlutoSDR hardware required. Useful for testing + the satellite scanning interface. + + Example: + hubblenetwork sat mock-scan --timeout 30 + hubblenetwork sat mock-scan -o json -n 5 + """ + _run_sat_scan( + mock=True, + timeout=timeout, + count=count, + output_format=output_format, + poll_interval=poll_interval, + payload_format=payload_format, + ) + + def main(argv: Optional[list[str]] = None) -> int: """ Entry point used by console_scripts. diff --git a/src/hubblenetwork/sat.py b/src/hubblenetwork/sat.py index 88b1458..538bbf6 100644 --- a/src/hubblenetwork/sat.py +++ b/src/hubblenetwork/sat.py @@ -14,7 +14,7 @@ import logging import time from pathlib import Path -from typing import Generator, List, Optional, Set, Tuple +from typing import Dict, Generator, List, Optional, Set, Tuple import httpx @@ -25,6 +25,7 @@ DOCKER_IMAGE = "ghcr.io/hubblenetwork/sdr-docker:latest" CONTAINER_NAME = "hubble-pluto-sdr" +MOCK_CONTAINER_NAME = "hubble-pluto-sdr-mock" API_PORT = 8050 _CONTAINER_INTERNAL_PORT = 8050 # fixed by the Docker image @@ -106,6 +107,10 @@ def pull_image(image: str = DOCKER_IMAGE) -> None: def start_container( image: str = DOCKER_IMAGE, port: int = API_PORT, + *, + environment: Optional[Dict[str, str]] = None, + privileged: bool = True, + name: str = CONTAINER_NAME, ) -> str: """Start the PlutoSDR container and return the container ID. @@ -119,8 +124,9 @@ def start_container( detach=True, auto_remove=True, ports={f"{_CONTAINER_INTERNAL_PORT}/tcp": port}, - name=CONTAINER_NAME, - privileged=True, + name=name, + privileged=privileged, + environment=environment or {}, ) logger.debug("Started container %s", container.short_id) return container.id @@ -214,17 +220,32 @@ def scan( poll_interval: float = 2.0, port: int = API_PORT, image: str = DOCKER_IMAGE, + *, + mock: bool = False, ) -> Generator[SatellitePacket, None, None]: """Scan for satellite packets, managing the Docker container lifecycle. Yields new ``SatellitePacket`` objects as they are discovered. The container is guaranteed to be stopped when the generator is closed or an exception occurs. + + When *mock* is ``True`` the container is started in mock mode + (``SDR_TYPE=mock``) which emits synthetic packets without requiring + PlutoSDR hardware. """ ensure_docker_available() pull_image(image) - container_id = start_container(image=image, port=port) + container_name = MOCK_CONTAINER_NAME if mock else CONTAINER_NAME + environment: Optional[Dict[str, str]] = {"SDR_TYPE": "mock"} if mock else None + + container_id = start_container( + image=image, + port=port, + environment=environment, + privileged=not mock, + name=container_name, + ) try: _wait_for_api(port=port) diff --git a/tests/test_sat.py b/tests/test_sat.py index 534e927..8b94b74 100644 --- a/tests/test_sat.py +++ b/tests/test_sat.py @@ -256,6 +256,90 @@ def test_pull_image_failure(self, mock_get_client): sat.pull_image("test:latest") +class TestStartContainer: + @patch("hubblenetwork.sat._get_client") + def test_default_privileged(self, mock_get_client): + mock_client = MagicMock() + mock_container = MagicMock() + mock_container.id = "abc123" + mock_client.containers.run.return_value = mock_container + mock_get_client.return_value = mock_client + + result = sat.start_container("test:latest", 8050) + call_kwargs = mock_client.containers.run.call_args[1] + assert call_kwargs["privileged"] is True + assert call_kwargs["name"] == sat.CONTAINER_NAME + assert result == "abc123" + + @patch("hubblenetwork.sat._get_client") + def test_mock_mode_params(self, mock_get_client): + mock_client = MagicMock() + mock_container = MagicMock() + mock_container.id = "mock123" + mock_client.containers.run.return_value = mock_container + mock_get_client.return_value = mock_client + + result = sat.start_container( + "test:latest", + 8050, + environment={"SDR_TYPE": "mock"}, + privileged=False, + name=sat.MOCK_CONTAINER_NAME, + ) + call_kwargs = mock_client.containers.run.call_args[1] + assert call_kwargs["privileged"] is False + assert call_kwargs["environment"] == {"SDR_TYPE": "mock"} + assert call_kwargs["name"] == "hubble-pluto-sdr-mock" + assert result == "mock123" + + +class TestScanMockMode: + @patch("hubblenetwork.sat.stop_container") + @patch("hubblenetwork.sat.fetch_packets") + @patch("hubblenetwork.sat._wait_for_api") + @patch("hubblenetwork.sat.start_container") + @patch("hubblenetwork.sat.pull_image") + @patch("hubblenetwork.sat.ensure_docker_available") + def test_scan_mock_passes_correct_params( + self, mock_ensure, mock_pull, mock_start, mock_wait, mock_fetch, mock_stop + ): + mock_start.return_value = "container123" + mock_fetch.return_value = [] + + list(sat.scan(timeout=0.1, mock=True)) + + mock_start.assert_called_once_with( + image=sat.DOCKER_IMAGE, + port=sat.API_PORT, + environment={"SDR_TYPE": "mock"}, + privileged=False, + name=sat.MOCK_CONTAINER_NAME, + ) + mock_stop.assert_called_once_with("container123") + + @patch("hubblenetwork.sat.stop_container") + @patch("hubblenetwork.sat.fetch_packets") + @patch("hubblenetwork.sat._wait_for_api") + @patch("hubblenetwork.sat.start_container") + @patch("hubblenetwork.sat.pull_image") + @patch("hubblenetwork.sat.ensure_docker_available") + def test_scan_real_passes_correct_params( + self, mock_ensure, mock_pull, mock_start, mock_wait, mock_fetch, mock_stop + ): + mock_start.return_value = "container456" + mock_fetch.return_value = [] + + list(sat.scan(timeout=0.1, mock=False)) + + mock_start.assert_called_once_with( + image=sat.DOCKER_IMAGE, + port=sat.API_PORT, + environment=None, + privileged=True, + name=sat.CONTAINER_NAME, + ) + + # --------------------------------------------------------------------------- # CLI - sat scan # --------------------------------------------------------------------------- @@ -346,3 +430,79 @@ def test_docker_not_available(self, mock_sat, runner): result = runner.invoke(cli, ["sat", "scan", "--timeout", "1"]) assert result.exit_code != 0 assert "Docker is not installed" in result.output + + +# --------------------------------------------------------------------------- +# CLI - sat mock-scan +# --------------------------------------------------------------------------- + + +class TestSatMockScanCli: + @pytest.fixture + def runner(self): + return CliRunner() + + def test_help(self, runner): + result = runner.invoke(cli, ["sat", "mock-scan", "--help"]) + assert result.exit_code == 0 + assert "--timeout" in result.output + assert "--count" in result.output + assert "--format" in result.output + assert "mock" in result.output.lower() + + def test_listed_in_sat_help(self, runner): + result = runner.invoke(cli, ["sat", "--help"]) + assert result.exit_code == 0 + assert "mock-scan" in result.output + + @patch("hubblenetwork.cli.sat_mod") + def test_mock_scan_tabular_output(self, mock_sat, runner): + mock_sat.scan.return_value = iter([_make_sat_pkt()]) + mock_sat.DockerError = DockerError + mock_sat.SatelliteError = SatelliteError + + result = runner.invoke( + cli, ["sat", "mock-scan", "--timeout", "1", "--poll-interval", "0.1"] + ) + assert result.exit_code == 0 + assert "0xBB2973BD" in result.output + # Verify mock=True was passed + mock_sat.scan.assert_called_once() + assert mock_sat.scan.call_args[1].get("mock") is True + + @patch("hubblenetwork.cli.sat_mod") + def test_mock_scan_json_output(self, mock_sat, runner): + mock_sat.scan.return_value = iter([_make_sat_pkt()]) + mock_sat.DockerError = DockerError + mock_sat.SatelliteError = SatelliteError + + result = runner.invoke( + cli, + ["sat", "mock-scan", "-o", "json", "--timeout", "1", "--poll-interval", "0.1"], + ) + assert result.exit_code == 0 + assert "0xBB2973BD" in result.output + + @patch("hubblenetwork.cli.sat_mod") + def test_mock_scan_count_limit(self, mock_sat, runner): + pkts = [_make_sat_pkt(seq_num=i) for i in range(10)] + mock_sat.scan.return_value = iter(pkts) + mock_sat.DockerError = DockerError + mock_sat.SatelliteError = SatelliteError + + result = runner.invoke( + cli, + ["sat", "mock-scan", "-n", "3", "--timeout", "5", "--poll-interval", "0.1"], + ) + assert result.exit_code == 0 + assert "3 packet(s) received" in result.output + + @patch("hubblenetwork.cli.sat_mod") + def test_docker_not_available(self, mock_sat, runner): + mock_sat.scan.side_effect = DockerError("Docker is not installed") + mock_sat.DockerError = DockerError + mock_sat.SatelliteError = SatelliteError + + result = runner.invoke(cli, ["sat", "mock-scan", "--timeout", "1"]) + assert result.exit_code != 0 + assert "Docker is not installed" in result.output diff --git a/tests/test_sat_integration.py b/tests/test_sat_integration.py new file mode 100644 index 0000000..55429c6 --- /dev/null +++ b/tests/test_sat_integration.py @@ -0,0 +1,72 @@ +"""Integration tests for satellite mock scanning (requires Docker).""" + +from __future__ import annotations + +import json + +import pytest +from click.testing import CliRunner + +from hubblenetwork import sat +from hubblenetwork.cli import cli + +pytestmark = pytest.mark.docker + + +class TestMockScanAPI: + """End-to-end tests using the Python API with a real Docker container.""" + + @pytest.fixture(scope="class") + def mock_packets(self): + """Run a single mock scan and share the result across all tests.""" + return list(sat.scan(timeout=20, poll_interval=1.0, mock=True)) + + def test_receives_packets(self, mock_packets): + assert len(mock_packets) >= 1 + pkt = mock_packets[0] + assert pkt.device_id + assert isinstance(pkt.seq_num, int) + assert isinstance(pkt.device_type, str) + assert isinstance(pkt.timestamp, float) + assert isinstance(pkt.rssi_dB, float) + assert isinstance(pkt.channel_num, int) + assert isinstance(pkt.freq_offset_hz, float) + + def test_deduplication(self, mock_packets): + keys = [(p.device_id, p.seq_num) for p in mock_packets] + assert len(keys) == len(set(keys)), "Duplicate packets detected" + + def test_multiple_devices(self, mock_packets): + device_ids = {p.device_id for p in mock_packets} + assert len(device_ids) >= 2, f"Expected multiple mock devices, got {device_ids}" + + +class TestMockScanCLI: + """End-to-end tests using the CLI with a real Docker container.""" + + @pytest.fixture + def runner(self): + return CliRunner() + + def test_json_output(self, runner): + result = runner.invoke( + cli, + ["sat", "mock-scan", "-o", "json", "--timeout", "15", "-n", "2"], + ) + assert result.exit_code == 0 + # JSON mode wraps output in an array + data = json.loads(result.output) + assert len(data) >= 1 + pkt = data[0] + assert "device_id" in pkt + assert "seq_num" in pkt + assert "timestamp" in pkt + assert "payload" in pkt + + def test_tabular_output(self, runner): + result = runner.invoke( + cli, + ["sat", "mock-scan", "--timeout", "15", "-n", "2"], + ) + assert result.exit_code == 0 + assert "packet(s) received" in result.output