From ab9eaf43b7bba648d6d13a45866bf90ae69196cf Mon Sep 17 00:00:00 2001 From: hunterhubble Date: Fri, 5 Jun 2026 21:16:07 +0000 Subject: [PATCH 1/3] feat: Add RS Corrections per packet - Added total RS corrections (header + payload) to the table when you run 'hubblenetwork sat scan' per packet - Also added an optional --pluto-uri parameter to sat scan. This is heplful if a pluto device is passed over USB, I can add the flag '--pluto-uri :usb' Coincides with commit 8a06c83 in sdr-docker Signed-off-by: hunterhubble --- src/hubblenetwork/cli.py | 14 ++++++++++++-- src/hubblenetwork/packets.py | 2 ++ src/hubblenetwork/sat.py | 9 ++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/hubblenetwork/cli.py b/src/hubblenetwork/cli.py index 340dbd6..9ce0597 100644 --- a/src/hubblenetwork/cli.py +++ b/src/hubblenetwork/cli.py @@ -602,6 +602,8 @@ def _sat_packet_to_dict( "rssi_dB": pkt.rssi_dB, "channel_num": pkt.channel_num, "freq_offset_hz": pkt.freq_offset_hz, + "pdu_n_corr": pkt.pdu_n_corr, + "header_n_corr": pkt.header_n_corr, "payload": _format_payload(pkt.payload, payload_format), } @@ -617,10 +619,11 @@ class _SatStreamingTablePrinter(_StreamingPrinterBase): "RSSI_DB": 8, "CHANNEL": 8, "FREQ_OFFSET": 12, + "RS_CORR": 8, "PAYLOAD": 20, } - _HEADERS = ["DEVICE_ID", "SEQ", "TYPE", "TIME", "RSSI_DB", "CHANNEL", "FREQ_OFFSET", "PAYLOAD"] + _HEADERS = ["DEVICE_ID", "SEQ", "TYPE", "TIME", "RSSI_DB", "CHANNEL", "FREQ_OFFSET", "RS_CORR", "PAYLOAD"] def __init__(self, payload_format: str = "base64"): super().__init__() @@ -650,6 +653,9 @@ def print_row(self, pkt: SatellitePacket) -> None: self._header_printed = True ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c") + rs = "-" + if pkt.pdu_n_corr is not None and pkt.header_n_corr is not None: + rs = str(pkt.pdu_n_corr + pkt.header_n_corr) row = [ pkt.device_id, pkt.seq_num, @@ -658,6 +664,7 @@ def print_row(self, pkt: SatellitePacket) -> None: f"{pkt.rssi_dB:.1f}", pkt.channel_num, f"{pkt.freq_offset_hz:.1f}", + rs, _format_payload(pkt.payload, self._payload_format), ] click.echo(self._format_row(row)) @@ -3130,6 +3137,7 @@ def _run_sat_scan( output_format: str, poll_interval: float, payload_format: str, + pluto_uri: Optional[str] = None, debug: bool = False, ) -> None: """Shared implementation for ``sat scan`` and ``sat mock-scan``.""" @@ -3180,7 +3188,7 @@ def _on_interrupt(sig, frame): try: for pkt in sat_mod.scan( timeout=timeout, poll_interval=poll_interval, mock=mock, - on_status=_on_status, + pluto_uri=pluto_uri, on_status=_on_status, ): printer.print_row(pkt) if count is not None and printer.packet_count >= count: @@ -3232,6 +3240,8 @@ def _sat_scan_options(fn): case_sensitive=False), default="base64", show_default=True, help="Encoding format for packet payload"), + click.option("--pluto-uri", "pluto_uri", type=str, default=None, + help="PlutoSDR URI passed to the container (e.g. usb:, ip:192.168.2.1)"), click.option("--debug", is_flag=True, default=False, help="Enable debug logging to stderr"), ]): diff --git a/src/hubblenetwork/packets.py b/src/hubblenetwork/packets.py index b7f5306..b07ac7f 100644 --- a/src/hubblenetwork/packets.py +++ b/src/hubblenetwork/packets.py @@ -99,3 +99,5 @@ class SatellitePacket: channel_num: int freq_offset_hz: float payload: bytes # encrypted payload bytes (base64-decoded from API) + pdu_n_corr: Optional[int] = None # Reed-Solomon corrections on PDU (None for OOK/v-1) + header_n_corr: Optional[int] = None # Reed-Solomon corrections on header diff --git a/src/hubblenetwork/sat.py b/src/hubblenetwork/sat.py index cdf2843..267b3fd 100644 --- a/src/hubblenetwork/sat.py +++ b/src/hubblenetwork/sat.py @@ -187,6 +187,8 @@ def _parse_jsonl(text: str) -> List[SatellitePacket]: channel_num=obj["channel_num"], freq_offset_hz=obj["freq_offset_hz"], payload=payload, + pdu_n_corr=obj.get("pdu_n_corr"), + header_n_corr=obj.get("header_n_corr"), ) ) except (KeyError, TypeError, json.JSONDecodeError) as exc: @@ -222,6 +224,7 @@ def scan( image: str = DOCKER_IMAGE, *, mock: bool = False, + pluto_uri: Optional[str] = None, on_status: Optional[Callable[[str], None]] = None, ) -> Generator[SatellitePacket, None, None]: """Scan for satellite packets, managing the Docker container lifecycle. @@ -246,7 +249,11 @@ def scan( _emit("Starting container...") container_name = MOCK_CONTAINER_NAME if mock else CONTAINER_NAME - environment: Optional[Dict[str, str]] = {"SDR_TYPE": "mock"} if mock else None + environment: Dict[str, str] = {} + if mock: + environment["SDR_TYPE"] = "mock" + if pluto_uri is not None: + environment["PLUTO_URI"] = pluto_uri container_id = start_container( image=image, From f18ce16a554a6f426688fe63f4b3f22e15a58c74 Mon Sep 17 00:00:00 2001 From: hunterhubble Date: Fri, 5 Jun 2026 22:20:40 +0000 Subject: [PATCH 2/3] feat: key parameter to sat scan to filter packets - Added --key parameter to hubblenetwork sat scan so users only see packets that are registered with that key. - Also added --days parameter to match the ble scan function - Helpful for users to debug device key mismatches Signed-off-by: hunterhubble --- src/hubblenetwork/cli.py | 28 +++++++++++++++++++++++++++- src/hubblenetwork/crypto.py | 20 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/hubblenetwork/cli.py b/src/hubblenetwork/cli.py index 9ce0597..53b770b 100644 --- a/src/hubblenetwork/cli.py +++ b/src/hubblenetwork/cli.py @@ -22,7 +22,7 @@ from hubblenetwork import ready as ready_mod from hubblenetwork import sat as sat_mod from hubblenetwork import decrypt, UNIX_TIME, DEVICE_UPTIME -from hubblenetwork.crypto import find_time_counter_delta +from hubblenetwork.crypto import find_time_counter_delta, derive_device_id_set from hubblenetwork import cloud from hubblenetwork import InvalidCredentialsError from hubblenetwork.errors import BackendError @@ -3137,6 +3137,8 @@ def _run_sat_scan( output_format: str, poll_interval: float, payload_format: str, + key: Optional[str] = None, + days: int = 2, pluto_uri: Optional[str] = None, debug: bool = False, ) -> None: @@ -3153,6 +3155,24 @@ def _run_sat_scan( sat_logger.setLevel(logging.DEBUG) sat_logger.addHandler(_handler) + # Parse key and derive expected device IDs if provided. + device_id_filter: Optional[set] = None + if key: + try: + decoded_key = _parse_key(key) + except ValueError as e: + if printer.suppress_info_messages: + click.echo(json.dumps({"error": f"Invalid key: {e}"})) + else: + click.secho(f"[ERROR] Invalid key: {e}", fg="red", err=True) + sys.exit(1) + device_id_filter = derive_device_id_set(decoded_key, days=days) + if not printer.suppress_info_messages: + click.secho( + f"[INFO] Filtering to device IDs: {', '.join(sorted(device_id_filter))}", + fg="cyan", err=True, + ) + # Fail fast: verify Docker is available before printing anything. try: sat_mod.ensure_docker_available() @@ -3190,6 +3210,8 @@ def _on_interrupt(sig, frame): timeout=timeout, poll_interval=poll_interval, mock=mock, pluto_uri=pluto_uri, on_status=_on_status, ): + if device_id_filter is not None and pkt.device_id not in device_id_filter: + continue printer.print_row(pkt) if count is not None and printer.packet_count >= count: break @@ -3240,6 +3262,10 @@ def _sat_scan_options(fn): case_sensitive=False), default="base64", show_default=True, help="Encoding format for packet payload"), + click.option("--key", "-k", type=str, default=None, + help="Device key (hex or base64) — only show packets from the matching device"), + click.option("--days", "-d", type=int, default=2, show_default=True, + help="Day window around today to derive device IDs from key"), click.option("--pluto-uri", "pluto_uri", type=str, default=None, help="PlutoSDR URI passed to the container (e.g. usb:, ip:192.168.2.1)"), click.option("--debug", is_flag=True, default=False, diff --git a/src/hubblenetwork/crypto.py b/src/hubblenetwork/crypto.py index 88ada71..7a02fd9 100644 --- a/src/hubblenetwork/crypto.py +++ b/src/hubblenetwork/crypto.py @@ -204,6 +204,26 @@ def decrypt( return None +def derive_device_id(key: bytes, time_counter: int) -> int: + """Derive the 32-bit device ID for *key* at *time_counter* (UTC day counter).""" + device_key = _generate_kdf_key(key, len(key), "DeviceKey", time_counter) + device_id_bytes = _generate_kdf_key(device_key, 4, "DeviceID", 0) + return int.from_bytes(device_id_bytes, "big") + + +def derive_device_id_set(key: bytes, days: int = 2) -> set: + """Return the set of hex device ID strings expected for *key* over a day window. + + Covers today ± *days* to account for clock skew and day boundaries. + IDs are formatted as the sdr-docker API returns them (e.g. '0xBB2973BD'). + """ + tc = int(datetime.now(timezone.utc).timestamp()) // 86400 + return { + f"0x{derive_device_id(key, tc + delta):08X}" + for delta in range(-days, days + 1) + } + + def find_time_counter_delta( key: bytes, encrypted_pkt: EncryptedPacket, max_days_back: int = 365 ) -> Optional[int]: From b88ac0b781fde42bc94deea7bad846c4b4ac6528 Mon Sep 17 00:00:00 2001 From: hunterhubble Date: Fri, 5 Jun 2026 23:16:00 +0000 Subject: [PATCH 3/3] feat: Added symbol timing info to sat scan - When using sat scan and providing a key, the average symbol length and gap length in ms will print in the row per packet decoded Coincides with commit 492f5b6 from sdr-docker Signed-off-by: hunterhubble --- src/hubblenetwork/cli.py | 69 +++++++++++++++++++++++++++++++++---- src/hubblenetwork/crypto.py | 4 +-- src/hubblenetwork/sat.py | 31 +++++++++++++++++ 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/hubblenetwork/cli.py b/src/hubblenetwork/cli.py index 53b770b..e192289 100644 --- a/src/hubblenetwork/cli.py +++ b/src/hubblenetwork/cli.py @@ -22,7 +22,7 @@ from hubblenetwork import ready as ready_mod from hubblenetwork import sat as sat_mod from hubblenetwork import decrypt, UNIX_TIME, DEVICE_UPTIME -from hubblenetwork.crypto import find_time_counter_delta, derive_device_id_set +from hubblenetwork.crypto import find_time_counter_delta, derive_device_id, derive_device_id_set from hubblenetwork import cloud from hubblenetwork import InvalidCredentialsError from hubblenetwork.errors import BackendError @@ -589,11 +589,12 @@ def finalize(self) -> None: def _sat_packet_to_dict( pkt: SatellitePacket, payload_format: str = "base64", + td_stats: Optional[dict] = None, **_: object, ) -> dict: """Convert a SatellitePacket to a dictionary for JSON serialization.""" ts = datetime.fromtimestamp(pkt.timestamp).strftime("%c") - return { + d = { "device_id": pkt.device_id, "seq_num": pkt.seq_num, "device_type": pkt.device_type, @@ -606,6 +607,10 @@ def _sat_packet_to_dict( "header_n_corr": pkt.header_n_corr, "payload": _format_payload(pkt.payload, payload_format), } + if td_stats is not None: + d["sym_mean_ms"] = td_stats.get("sym_mean_ms") + d["gap_mean_ms"] = td_stats.get("gap_mean_ms") + return d class _SatStreamingTablePrinter(_StreamingPrinterBase): @@ -620,15 +625,27 @@ class _SatStreamingTablePrinter(_StreamingPrinterBase): "CHANNEL": 8, "FREQ_OFFSET": 12, "RS_CORR": 8, + "SYM_MS": 10, + "GAP_MS": 10, "PAYLOAD": 20, } - _HEADERS = ["DEVICE_ID", "SEQ", "TYPE", "TIME", "RSSI_DB", "CHANNEL", "FREQ_OFFSET", "RS_CORR", "PAYLOAD"] + _BASE_HEADERS = ["DEVICE_ID", "SEQ", "TYPE", "TIME", "RSSI_DB", "CHANNEL", "FREQ_OFFSET", "RS_CORR"] + _TD_HEADERS = ["SYM_MS", "GAP_MS"] - def __init__(self, payload_format: str = "base64"): + def __init__(self, payload_format: str = "base64", show_td_stats: bool = False): super().__init__() self._header_printed = False self._payload_format = payload_format + self._show_td_stats = show_td_stats + self._HEADERS = self._BASE_HEADERS + (self._TD_HEADERS if show_td_stats else []) + ["PAYLOAD"] + self._sym_mean_ms: Optional[float] = None + self._gap_mean_ms: Optional[float] = None + + def update_td_stats(self, td: dict) -> None: + """Update the latest TD stats to be stamped on subsequent rows.""" + self._sym_mean_ms = td.get("sym_mean_ms") + self._gap_mean_ms = td.get("gap_mean_ms") def _format_row(self, values: List) -> str: parts = [] @@ -665,8 +682,11 @@ def print_row(self, pkt: SatellitePacket) -> None: pkt.channel_num, f"{pkt.freq_offset_hz:.1f}", rs, - _format_payload(pkt.payload, self._payload_format), ] + if self._show_td_stats: + row.append(f"{self._sym_mean_ms:.2f}" if self._sym_mean_ms is not None else "-") + row.append(f"{self._gap_mean_ms:.2f}" if self._gap_mean_ms is not None else "-") + row.append(_format_payload(pkt.payload, self._payload_format)) click.echo(self._format_row(row)) click.echo(self._make_separator()) self._packet_count += 1 @@ -3145,10 +3165,11 @@ def _run_sat_scan( """Shared implementation for ``sat scan`` and ``sat mock-scan``.""" mode_label = "mock satellite receiver" if mock else "satellite receiver" + show_td = key is not None and output_format.lower() == "tabular" printer_class = _SAT_STREAMING_PRINTERS.get( output_format.lower(), _SatStreamingTablePrinter ) - printer = printer_class(payload_format=payload_format) + printer = printer_class(payload_format=payload_format, **( {"show_td_stats": True} if show_td else {})) if debug: sat_logger = logging.getLogger("hubblenetwork.sat") @@ -3189,9 +3210,33 @@ def _run_sat_scan( f"[INFO] Starting {mode_label}... (Press Ctrl+C to stop)" ) + # Today's integer device ID for TD mode (derived from key at today's counter). + _td_device_id: Optional[int] = None + if key and device_id_filter is not None: + from datetime import datetime, timezone as _tz + _tc = int(datetime.now(_tz.utc).timestamp()) // 86400 + _td_device_id = derive_device_id(decoded_key, _tc) + + _td_started = [False] + _last_td_sig: Optional[float] = [None] + _last_td_check = [0.0] # monotonic timestamp of last fetch_td_info call + def _on_status(msg: str) -> None: if not printer.suppress_info_messages: click.secho(f"[INFO] {msg}", fg="cyan", err=True) + # Once receiver is ready, kick off TD capture for our device. + if "ready" in msg.lower() and _td_device_id is not None and not _td_started[0]: + try: + sat_mod.start_timedomain(_td_device_id) + _td_started[0] = True + if not printer.suppress_info_messages: + click.secho( + f"[INFO] Time-domain capture started for device " + f"0x{_td_device_id:08X}", + fg="cyan", err=True, + ) + except sat_mod.SatelliteError: + pass # non-fatal _stop_msg_shown = [False] @@ -3213,6 +3258,18 @@ def _on_interrupt(sig, frame): if device_id_filter is not None and pkt.device_id not in device_id_filter: continue printer.print_row(pkt) + + # Check for updated TD stats at most once per poll cycle. + if _td_started[0] and (time.monotonic() - _last_td_check[0]) >= poll_interval: + _last_td_check[0] = time.monotonic() + td = sat_mod.fetch_td_info() + if td and td.get("sym_mean_ms") is not None: + sig = td["sym_mean_ms"] + if sig != _last_td_sig[0]: + _last_td_sig[0] = sig + if isinstance(printer, _SatStreamingTablePrinter): + printer.update_td_stats(td) + if count is not None and printer.packet_count >= count: break except sat_mod.DockerError as exc: diff --git a/src/hubblenetwork/crypto.py b/src/hubblenetwork/crypto.py index 7a02fd9..838c501 100644 --- a/src/hubblenetwork/crypto.py +++ b/src/hubblenetwork/crypto.py @@ -205,7 +205,7 @@ def decrypt( def derive_device_id(key: bytes, time_counter: int) -> int: - """Derive the 32-bit device ID for *key* at *time_counter* (UTC day counter).""" + """Derive the 32-bit device ID for key at time_counter (UTC day counter).""" device_key = _generate_kdf_key(key, len(key), "DeviceKey", time_counter) device_id_bytes = _generate_kdf_key(device_key, 4, "DeviceID", 0) return int.from_bytes(device_id_bytes, "big") @@ -215,7 +215,7 @@ def derive_device_id_set(key: bytes, days: int = 2) -> set: """Return the set of hex device ID strings expected for *key* over a day window. Covers today ± *days* to account for clock skew and day boundaries. - IDs are formatted as the sdr-docker API returns them (e.g. '0xBB2973BD'). + IDs are formatted as the sdr-docker API returns them """ tc = int(datetime.now(timezone.utc).timestamp()) // 86400 return { diff --git a/src/hubblenetwork/sat.py b/src/hubblenetwork/sat.py index 267b3fd..3a95efa 100644 --- a/src/hubblenetwork/sat.py +++ b/src/hubblenetwork/sat.py @@ -34,6 +34,14 @@ def _packets_url(port: int = API_PORT) -> str: return f"http://localhost:{port}/api/packets" +def _timedomain_url(port: int = API_PORT) -> str: + return f"http://localhost:{port}/api/timedomain" + + +def _td_info_url(port: int = API_PORT) -> str: + return f"http://localhost:{port}/api/td_info" + + # --------------------------------------------------------------------------- # Docker helpers # --------------------------------------------------------------------------- @@ -196,6 +204,29 @@ def _parse_jsonl(text: str) -> List[SatellitePacket]: return packets +def start_timedomain(device_id: int, port: int = API_PORT) -> None: + """Enable time-domain capture for *device_id* on the running container.""" + url = _timedomain_url(port) + try: + resp = httpx.post(url, json={"action": "start", "device_id": device_id}, timeout=5) + resp.raise_for_status() + except httpx.HTTPError as exc: + raise SatelliteError(f"Failed to start time-domain capture: {exc}") + + +def fetch_td_info(port: int = API_PORT) -> Optional[dict]: + """Return the latest time-domain stats dict, or None if not yet available.""" + url = _td_info_url(port) + try: + resp = httpx.get(url, timeout=5) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + except httpx.HTTPError: + return None + + def fetch_packets(port: int = API_PORT) -> List[SatellitePacket]: """Fetch the current packet buffer from the satellite receiver API.""" url = _packets_url(port)