Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 45 additions & 8 deletions src/hubblenetwork/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
_handler.setFormatter(logging.Formatter("%(levelname)s - %(message)s"))
logger.addHandler(_handler)

# Symbol time length in ms
SYMBOL_TIME = 8.0

# Symbol time tolerance. With our envelope detection, we can expect the symbol
# to be off by 1/2 of the length of the symbol before that envelope
# reads the adjacent symbol, Any deviation from the nominal 8ms will see a dB
# loss, but the frequency will be correct
TIMING_TOLERANCE = 4.0

def _parse_key(key_str: str) -> bytes:
"""Parse an encryption key from hex or base64. Returns raw bytes.
Expand Down Expand Up @@ -513,6 +521,10 @@ 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,
"sym_mean_ms": pkt.sym_mean_ms,
"gap_mean_ms": pkt.gap_mean_ms,
"payload": _format_payload(pkt.payload, payload_format),
}
if pkt.auth_tag is not None:
Expand All @@ -534,20 +546,22 @@ class _SatStreamingTablePrinter(_StreamingPrinterBase):
"RSSI_DB": 8,
"CHANNEL": 8,
"FREQ_OFFSET": 12,
"RS_CORR": 8,
"SYM_MS": 10,
"GAP_MS": 10,
"PAYLOAD": 20,
}

_BASE_HEADERS = ["DEVICE_ID", "SEQ", "TYPE", "TIME", "RSSI_DB", "CHANNEL", "FREQ_OFFSET", "PAYLOAD"]
_DEBUG_HEADERS = ["RS_CORR", "SYM_MS", "GAP_MS"]

def __init__(self, payload_format: str = "base64", show_decrypt_status: bool = False):
def __init__(self, payload_format: str = "base64", show_decrypt_status: bool = False, show_debug_cols: bool = False):
super().__init__(show_decrypt_status=show_decrypt_status)
self._header_printed = False
self._payload_format = payload_format
self._headers = (
["DECRYPT", *self._BASE_HEADERS]
if show_decrypt_status
else list(self._BASE_HEADERS)
)
self._show_debug_cols = show_debug_cols
headers = list(self._BASE_HEADERS) + (self._DEBUG_HEADERS if show_debug_cols else [])
self._headers = ["DECRYPT", *headers] if show_decrypt_status else headers

def _format_row(self, values: List) -> str:
parts = []
Expand All @@ -563,6 +577,11 @@ def _make_separator(self) -> str:
parts.append("-" * width)
return "+-" + "-+-".join(parts) + "-+"

def _make_warning_row(self, msg: str) -> str:
inner_width = len(self._make_separator()) - 4 # subtract "+-" and "-+"
text = f"! {msg}"
return "| " + text.ljust(inner_width) + " |"

def print_row(self, pkt: SatellitePacket, decrypt_status: Optional[str] = None) -> None:
if not self._header_printed:
click.echo("")
Expand All @@ -585,7 +604,21 @@ def print_row(self, pkt: SatellitePacket, decrypt_status: Optional[str] = None)
f"{pkt.freq_offset_hz:.1f}",
_format_payload(pkt.payload, self._payload_format),
])
if self._show_debug_cols:
rs = "-"
Comment thread
HongNguyen635 marked this conversation as resolved.
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.append(rs)
row.append(f"{pkt.sym_mean_ms:.2f}" if pkt.sym_mean_ms is not None else "-")
row.append(f"{pkt.gap_mean_ms:.2f}" if pkt.gap_mean_ms is not None else "-")
click.echo(self._format_row(row))
if self._show_debug_cols:
warnings = []
if pkt.sym_mean_ms is not None and abs(pkt.sym_mean_ms - SYMBOL_TIME) > TIMING_TOLERANCE:
warnings.append(f"sym {pkt.sym_mean_ms:.2f} ms (expected {SYMBOL_TIME:.2f} ms)")
if warnings:
click.echo(self._make_separator())
click.secho(self._make_warning_row(f"WARNING: timing off — {', '.join(warnings)}"), fg="yellow")
click.echo(self._make_separator())
self._packet_count += 1

Expand Down Expand Up @@ -3061,9 +3094,10 @@ def _run_sat_scan(
output_format: str,
poll_interval: float,
payload_format: str,
debug: bool = False,
key: Optional[str] = None,
days: int = 2,
pluto_uri: Optional[str] = None,
debug: bool = False,
counter_mode: str = UNIX_TIME,
auto_detect_ctr: bool = False,
show_failed_decryption: bool = False,
Expand All @@ -3077,6 +3111,7 @@ def _run_sat_scan(
printer = printer_class(
payload_format=payload_format,
show_decrypt_status=show_failed_decryption,
**( {"show_debug_cols": debug} if output_format.lower() == "tabular" else {}),
)

# Pre-decode the key if provided.
Expand Down Expand Up @@ -3145,7 +3180,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,
):
if decoded_key is not None:
# With a key, the user wants only packets the key can decrypt.
Expand Down Expand Up @@ -3224,6 +3259,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"),
]):
Expand Down
4 changes: 4 additions & 0 deletions src/hubblenetwork/packets.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,7 @@ class SatellitePacket:
# 4-byte CMAC auth tag, when reported by the receiver. Needed to locally
# decrypt the payload (identifies the day counter); None if unavailable.
auth_tag: Optional[bytes] = None
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
sym_mean_ms: Optional[float] = None # average symbol duration from envelope analysis
gap_mean_ms: Optional[float] = None # average inter-symbol gap duration
25 changes: 24 additions & 1 deletion src/hubblenetwork/sat.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ def _status_url(port: int = API_PORT) -> str:
return f"http://localhost:{port}/api/status"


def _timedomain_url(port: int = API_PORT) -> str:
return f"http://localhost:{port}/api/timedomain"


# ---------------------------------------------------------------------------
# Docker helpers
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -243,13 +247,27 @@ def _parse_jsonl(text: str) -> List[SatellitePacket]:
freq_offset_hz=obj["freq_offset_hz"],
payload=payload,
auth_tag=auth_tag,
pdu_n_corr=obj.get("pdu_n_corr"),
header_n_corr=obj.get("header_n_corr"),
sym_mean_ms=obj.get("sym_mean_ms"),
gap_mean_ms=obj.get("gap_mean_ms"),
)
)
except (KeyError, TypeError, json.JSONDecodeError) as exc:
logger.warning("Skipping malformed packet line: %s (%s)", line, exc)
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_packets(port: int = API_PORT) -> List[SatellitePacket]:
"""Fetch the current packet buffer from the satellite receiver API."""
url = _packets_url(port)
Expand Down Expand Up @@ -278,6 +296,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.
Expand All @@ -302,7 +321,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,
Expand Down
Loading