Firmware v3.03.0: DiSEqC Manchester encoder (cmd 0x8D extended), parameterized spectrum sweep (0xBA), adaptive blind scan (0xBB), error code reporting (0xBC). All new function locals moved to XDATA to fit within FX2LP 256-byte internal RAM constraint. Motor control: DiSEqC 1.2 positioner with USALS GotoX, stored positions, interactive keyboard jog, 30-second safety auto-halt. QO-100 DATV: Es'hail-2 wideband transponder tools — LNB IF calculator, narrowband scan, tune, and TS-to-video pipe (ffplay/mpv). Carrier survey: six-stage pipeline (coarse sweep → peak detection → fine sweep → blind scan → TS sample → catalog). JSON catalog with differential analysis, QO-100 optimized mode, CSV/text export. TUI: F9 Motor screen (3-column layout with signal gauge), F10 Survey screen (Full Band + QO-100 tabs). Bridge, demo, and theme updated. Docs: motor.mdx, survey.mdx, qo100-datv.mdx guide, tui.mdx updated for 10 screens. Site builds 41 pages, all links valid.
1280 lines
43 KiB
Python
Executable File
1280 lines
43 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Genpix SkyWalker-1 MPEG-2 Transport Stream analyzer.
|
|
|
|
Parses and analyzes 188-byte MPEG-2 TS packets from .ts files captured
|
|
by tune.py, stdin pipes, or any standard transport stream source.
|
|
|
|
Supports PID analysis, PAT/PMT parsing, continuity counter checking,
|
|
scrambling detection, hex packet dumps, and live stream monitoring.
|
|
|
|
Reference: ISO/IEC 13818-1 (MPEG-2 Systems)
|
|
TS packet: 188 bytes, sync byte 0x47
|
|
"""
|
|
|
|
import sys
|
|
import struct
|
|
import argparse
|
|
import time
|
|
import os
|
|
|
|
TS_PACKET_SIZE = 188
|
|
TS_SYNC_BYTE = 0x47
|
|
|
|
# Well-known PID assignments (ISO 13818-1 Table 2-3)
|
|
KNOWN_PIDS = {
|
|
0x0000: "PAT",
|
|
0x0001: "CAT",
|
|
0x0002: "TSDT",
|
|
0x0010: "NIT/ST",
|
|
0x0011: "SDT/BAT/ST",
|
|
0x0012: "EIT/ST",
|
|
0x0013: "RST/ST",
|
|
0x0014: "TDT/TOT/ST",
|
|
0x001E: "DIT",
|
|
0x001F: "SIT",
|
|
0x1FFF: "Null",
|
|
}
|
|
|
|
# Stream type identifiers (ISO 13818-1 Table 2-36)
|
|
STREAM_TYPES = {
|
|
0x00: "Reserved",
|
|
0x01: "MPEG-1 Video (11172-2)",
|
|
0x02: "MPEG-2 Video (13818-2)",
|
|
0x03: "MPEG-1 Audio (11172-3)",
|
|
0x04: "MPEG-2 Audio (13818-3)",
|
|
0x05: "Private Sections (13818-1)",
|
|
0x06: "PES Private Data",
|
|
0x07: "MHEG",
|
|
0x08: "DSM-CC",
|
|
0x09: "H.222.1",
|
|
0x0A: "DSM-CC Type A",
|
|
0x0B: "DSM-CC Type B",
|
|
0x0C: "DSM-CC Type C",
|
|
0x0D: "DSM-CC Type D",
|
|
0x0E: "Auxiliary",
|
|
0x0F: "MPEG-2 AAC Audio",
|
|
0x10: "MPEG-4 Visual",
|
|
0x11: "MPEG-4 AAC Audio (LATM)",
|
|
0x15: "Metadata in PES",
|
|
0x1B: "H.264/AVC Video",
|
|
0x24: "H.265/HEVC Video",
|
|
0x42: "AVS Video",
|
|
0x81: "AC-3 Audio (ATSC)",
|
|
0x82: "DTS Audio",
|
|
0x83: "Dolby TrueHD",
|
|
0x84: "Dolby Digital Plus (EAC-3)",
|
|
0x85: "DTS-HD",
|
|
0x86: "DTS-HD Master Audio",
|
|
0x87: "EAC-3 Audio (ATSC)",
|
|
0xEA: "VC-1 Video",
|
|
}
|
|
|
|
|
|
class TSPacket:
|
|
"""Parsed MPEG-2 transport stream packet header."""
|
|
|
|
__slots__ = (
|
|
'sync', 'tei', 'pusi', 'priority', 'pid',
|
|
'scrambling', 'adaptation', 'continuity',
|
|
'adaptation_field', 'payload', 'raw',
|
|
)
|
|
|
|
def __init__(self, data: bytes):
|
|
if len(data) != TS_PACKET_SIZE:
|
|
raise ValueError(f"Packet must be {TS_PACKET_SIZE} bytes, got {len(data)}")
|
|
|
|
self.raw = data
|
|
self.sync = data[0]
|
|
self.tei = bool(data[1] & 0x80)
|
|
self.pusi = bool(data[1] & 0x40)
|
|
self.priority = bool(data[1] & 0x20)
|
|
self.pid = ((data[1] & 0x1F) << 8) | data[2]
|
|
self.scrambling = (data[3] >> 6) & 0x03
|
|
self.adaptation = (data[3] >> 4) & 0x03
|
|
self.continuity = data[3] & 0x0F
|
|
|
|
# Parse adaptation field and payload boundaries
|
|
offset = 4
|
|
self.adaptation_field = None
|
|
self.payload = None
|
|
|
|
if self.adaptation & 0x02:
|
|
# Adaptation field present
|
|
if offset < TS_PACKET_SIZE:
|
|
af_len = data[offset]
|
|
af_end = offset + 1 + af_len
|
|
if af_end <= TS_PACKET_SIZE:
|
|
self.adaptation_field = data[offset:af_end]
|
|
offset = af_end
|
|
|
|
if self.adaptation & 0x01:
|
|
# Payload present
|
|
if offset < TS_PACKET_SIZE:
|
|
self.payload = data[offset:]
|
|
|
|
def has_pcr(self) -> bool:
|
|
"""Check if adaptation field contains a PCR."""
|
|
if self.adaptation_field is None or len(self.adaptation_field) < 7:
|
|
return False
|
|
af_flags = self.adaptation_field[1] if len(self.adaptation_field) > 1 else 0
|
|
return bool(af_flags & 0x10)
|
|
|
|
def get_pcr(self) -> int:
|
|
"""Extract PCR value (in 27 MHz clock ticks). Returns -1 if no PCR."""
|
|
if not self.has_pcr():
|
|
return -1
|
|
# PCR is 6 bytes starting at adaptation_field[2]
|
|
af = self.adaptation_field
|
|
pcr_base = (af[2] << 25) | (af[3] << 17) | (af[4] << 9) | \
|
|
(af[5] << 1) | ((af[6] >> 7) & 0x01)
|
|
pcr_ext = ((af[6] & 0x01) << 8) | af[7]
|
|
return pcr_base * 300 + pcr_ext
|
|
|
|
|
|
class TSReader:
|
|
"""Reads TS packets from a file or stream, handling sync alignment."""
|
|
|
|
def __init__(self, source, verbose: bool = False):
|
|
self.source = source
|
|
self.verbose = verbose
|
|
self.offset = 0
|
|
self._sync_offset = -1
|
|
|
|
def find_sync(self, data: bytes) -> int:
|
|
"""Find sync byte alignment in raw data. Returns byte offset or -1."""
|
|
# Need at least 3 consecutive sync bytes to confirm alignment
|
|
for i in range(min(len(data), TS_PACKET_SIZE)):
|
|
if data[i] != TS_SYNC_BYTE:
|
|
continue
|
|
# Check for consecutive sync bytes at 188-byte intervals
|
|
ok = True
|
|
for check in range(1, 4):
|
|
pos = i + check * TS_PACKET_SIZE
|
|
if pos >= len(data):
|
|
# Not enough data to confirm, accept if at least one more matches
|
|
if check >= 2:
|
|
break
|
|
ok = False
|
|
break
|
|
if data[pos] != TS_SYNC_BYTE:
|
|
ok = False
|
|
break
|
|
if ok:
|
|
return i
|
|
return -1
|
|
|
|
def iter_packets(self, max_packets: int = 0):
|
|
"""Yield TSPacket objects from the source."""
|
|
buf = b''
|
|
synced = False
|
|
count = 0
|
|
|
|
while True:
|
|
chunk = self.source.read(65536)
|
|
if not chunk:
|
|
break
|
|
buf += chunk
|
|
|
|
if not synced:
|
|
sync_off = self.find_sync(buf)
|
|
if sync_off < 0:
|
|
# Keep last 187 bytes in case sync straddles chunk boundary
|
|
if len(buf) > TS_PACKET_SIZE * 4:
|
|
buf = buf[-(TS_PACKET_SIZE - 1):]
|
|
continue
|
|
self._sync_offset = sync_off + self.offset
|
|
if self.verbose and sync_off > 0:
|
|
print(f" Sync found at byte offset {sync_off}", file=sys.stderr)
|
|
buf = buf[sync_off:]
|
|
synced = True
|
|
|
|
while len(buf) >= TS_PACKET_SIZE:
|
|
pkt_data = buf[:TS_PACKET_SIZE]
|
|
buf = buf[TS_PACKET_SIZE:]
|
|
|
|
if pkt_data[0] != TS_SYNC_BYTE:
|
|
# Lost sync, try to re-acquire
|
|
synced = False
|
|
if self.verbose:
|
|
print(f" Sync lost, re-scanning...", file=sys.stderr)
|
|
break
|
|
|
|
count += 1
|
|
yield TSPacket(pkt_data)
|
|
|
|
if max_packets and count >= max_packets:
|
|
return
|
|
|
|
self.offset += len(chunk)
|
|
|
|
@property
|
|
def sync_offset(self) -> int:
|
|
return self._sync_offset
|
|
|
|
|
|
class PSIParser:
|
|
"""Parse PSI sections from TS packet payloads."""
|
|
|
|
def __init__(self):
|
|
self._section_bufs = {} # pid -> accumulated bytes
|
|
|
|
def feed(self, pkt: TSPacket) -> dict:
|
|
"""Feed a packet, return parsed section dict or None."""
|
|
if pkt.payload is None:
|
|
return None
|
|
|
|
pid = pkt.pid
|
|
payload = pkt.payload
|
|
|
|
if pkt.pusi:
|
|
# Payload Unit Start Indicator set
|
|
if len(payload) < 1:
|
|
return None
|
|
pointer = payload[0]
|
|
payload = payload[1 + pointer:]
|
|
self._section_bufs[pid] = payload
|
|
elif pid in self._section_bufs:
|
|
self._section_bufs[pid] += payload
|
|
else:
|
|
return None
|
|
|
|
return self._try_parse(pid)
|
|
|
|
def _try_parse(self, pid: int) -> dict:
|
|
"""Try to parse a complete section from the buffer."""
|
|
buf = self._section_bufs.get(pid, b'')
|
|
if len(buf) < 3:
|
|
return None
|
|
|
|
table_id = buf[0]
|
|
section_length = ((buf[1] & 0x0F) << 8) | buf[2]
|
|
total_len = 3 + section_length
|
|
|
|
if len(buf) < total_len:
|
|
return None # Incomplete, wait for more data
|
|
|
|
section = buf[:total_len]
|
|
# Clear buffer for next section
|
|
self._section_bufs[pid] = buf[total_len:]
|
|
|
|
if section_length < 5:
|
|
return None
|
|
|
|
result = {
|
|
"table_id": table_id,
|
|
"section_syntax": bool(buf[1] & 0x80),
|
|
"section_length": section_length,
|
|
"raw": section,
|
|
}
|
|
|
|
if result["section_syntax"]:
|
|
result["table_id_ext"] = (section[3] << 8) | section[4]
|
|
result["version"] = (section[5] >> 1) & 0x1F
|
|
result["current_next"] = section[5] & 0x01
|
|
result["section_number"] = section[6]
|
|
result["last_section_number"] = section[7]
|
|
result["data"] = section[8:-4]
|
|
result["crc32"] = struct.unpack_from('>I', section, total_len - 4)[0]
|
|
|
|
return result
|
|
|
|
|
|
def parse_pat(section: dict) -> dict:
|
|
"""Parse a Program Association Table section."""
|
|
if section is None or section["table_id"] != 0x00:
|
|
return None
|
|
|
|
transport_stream_id = section["table_id_ext"]
|
|
data = section["data"]
|
|
programs = {}
|
|
|
|
for i in range(0, len(data), 4):
|
|
if i + 4 > len(data):
|
|
break
|
|
prog_num = (data[i] << 8) | data[i + 1]
|
|
pmt_pid = ((data[i + 2] & 0x1F) << 8) | data[i + 3]
|
|
programs[prog_num] = pmt_pid
|
|
|
|
return {
|
|
"transport_stream_id": transport_stream_id,
|
|
"version": section["version"],
|
|
"programs": programs,
|
|
}
|
|
|
|
|
|
def parse_pmt(section: dict) -> dict:
|
|
"""Parse a Program Map Table section."""
|
|
if section is None or section["table_id"] != 0x02:
|
|
return None
|
|
|
|
program_number = section["table_id_ext"]
|
|
data = section["raw"]
|
|
|
|
if len(data) < 12:
|
|
return None
|
|
|
|
pcr_pid = ((data[8] & 0x1F) << 8) | data[9]
|
|
prog_info_len = ((data[10] & 0x0F) << 8) | data[11]
|
|
|
|
offset = 12 + prog_info_len
|
|
streams = []
|
|
|
|
while offset + 5 <= len(data) - 4: # -4 for CRC
|
|
stream_type = data[offset]
|
|
elementary_pid = ((data[offset + 1] & 0x1F) << 8) | data[offset + 2]
|
|
es_info_len = ((data[offset + 3] & 0x0F) << 8) | data[offset + 4]
|
|
|
|
streams.append({
|
|
"stream_type": stream_type,
|
|
"elementary_pid": elementary_pid,
|
|
"es_info_length": es_info_len,
|
|
"type_name": STREAM_TYPES.get(stream_type, f"Unknown (0x{stream_type:02X})"),
|
|
})
|
|
offset += 5 + es_info_len
|
|
|
|
return {
|
|
"program_number": program_number,
|
|
"version": section["version"],
|
|
"pcr_pid": pcr_pid,
|
|
"streams": streams,
|
|
}
|
|
|
|
|
|
def parse_sdt(section: dict) -> dict:
|
|
"""
|
|
Parse a Service Description Table section.
|
|
|
|
Table IDs: 0x42 = SDT actual transport stream,
|
|
0x46 = SDT other transport stream.
|
|
Carried on PID 0x0011.
|
|
|
|
Returns dict with:
|
|
transport_stream_id - TS ID from the table extension
|
|
original_network_id - ONID from bytes [0:2] of section data
|
|
services - list of service dicts, each containing:
|
|
service_id - program number
|
|
service_type - numeric type (1=digital TV, 2=digital radio, etc)
|
|
service_name - decoded service name string
|
|
provider_name - decoded provider name string
|
|
eit_schedule - bool, EIT schedule flag
|
|
eit_present - bool, EIT present/following flag
|
|
running_status - numeric running status
|
|
free_ca - bool, free/scrambled flag
|
|
|
|
Descriptor parsing: looks for tag 0x48 (service_descriptor) which
|
|
encodes service_type (1 byte), provider_name_length + provider_name,
|
|
service_name_length + service_name.
|
|
"""
|
|
if section is None:
|
|
return None
|
|
if section["table_id"] not in (0x42, 0x46):
|
|
return None
|
|
if not section.get("section_syntax"):
|
|
return None
|
|
|
|
transport_stream_id = section["table_id_ext"]
|
|
data = section.get("data", b'')
|
|
|
|
if len(data) < 2:
|
|
return None
|
|
|
|
original_network_id = (data[0] << 8) | data[1]
|
|
# Byte 2 is reserved_future_use
|
|
offset = 3
|
|
|
|
services = []
|
|
while offset + 5 <= len(data):
|
|
service_id = (data[offset] << 8) | data[offset + 1]
|
|
# byte 2: EIT flags and running status
|
|
flags_byte = data[offset + 2]
|
|
eit_schedule = bool(flags_byte & 0x02)
|
|
eit_present = bool(flags_byte & 0x01)
|
|
|
|
status_byte = data[offset + 3]
|
|
running_status = (status_byte >> 5) & 0x07
|
|
free_ca = bool(status_byte & 0x10)
|
|
descriptors_loop_length = ((status_byte & 0x0F) << 8) | data[offset + 4]
|
|
|
|
offset += 5
|
|
|
|
# Parse descriptors for this service
|
|
service_type = 0
|
|
service_name = ""
|
|
provider_name = ""
|
|
|
|
desc_end = offset + descriptors_loop_length
|
|
if desc_end > len(data):
|
|
desc_end = len(data)
|
|
|
|
while offset + 2 <= desc_end:
|
|
desc_tag = data[offset]
|
|
desc_len = data[offset + 1]
|
|
desc_data = data[offset + 2:offset + 2 + desc_len]
|
|
offset += 2 + desc_len
|
|
|
|
if desc_tag == 0x48 and len(desc_data) >= 1:
|
|
# service_descriptor
|
|
service_type = desc_data[0]
|
|
pos = 1
|
|
|
|
# Provider name
|
|
if pos < len(desc_data):
|
|
prov_len = desc_data[pos]
|
|
pos += 1
|
|
if pos + prov_len <= len(desc_data):
|
|
provider_name = _decode_dvb_string(desc_data[pos:pos + prov_len])
|
|
pos += prov_len
|
|
|
|
# Service name
|
|
if pos < len(desc_data):
|
|
svc_len = desc_data[pos]
|
|
pos += 1
|
|
if pos + svc_len <= len(desc_data):
|
|
service_name = _decode_dvb_string(desc_data[pos:pos + svc_len])
|
|
|
|
# Advance past any unprocessed descriptor bytes
|
|
offset = max(offset, desc_end)
|
|
|
|
services.append({
|
|
"service_id": service_id,
|
|
"service_type": service_type,
|
|
"service_name": service_name,
|
|
"provider_name": provider_name,
|
|
"eit_schedule": eit_schedule,
|
|
"eit_present": eit_present,
|
|
"running_status": running_status,
|
|
"free_ca": free_ca,
|
|
})
|
|
|
|
return {
|
|
"table_id": section["table_id"],
|
|
"transport_stream_id": transport_stream_id,
|
|
"original_network_id": original_network_id,
|
|
"version": section["version"],
|
|
"services": services,
|
|
}
|
|
|
|
|
|
def parse_nit(section: dict) -> dict:
|
|
"""
|
|
Parse a Network Information Table section.
|
|
|
|
Table IDs: 0x40 = NIT actual network,
|
|
0x41 = NIT other network.
|
|
Carried on PID 0x0010.
|
|
|
|
Returns dict with:
|
|
network_id - network ID from the table extension
|
|
network_name - decoded network name string (from descriptor 0x40)
|
|
transports - list of transport dicts, each containing:
|
|
ts_id - transport stream ID
|
|
original_network_id - ONID
|
|
frequency_ghz - satellite frequency in GHz (from 0x43)
|
|
polarization - string: 'H', 'V', 'L', or 'R'
|
|
symbol_rate - symbol rate in sps
|
|
fec - FEC inner code rate string
|
|
orbital_position - orbital position in degrees (+ east, - west)
|
|
modulation - modulation string
|
|
roll_off - roll-off factor string
|
|
|
|
Descriptor parsing: looks for tag 0x43 (satellite_delivery_system_descriptor)
|
|
which is 11 bytes of BCD-encoded satellite parameters, and tag 0x40
|
|
(network_name_descriptor) for the network name.
|
|
"""
|
|
if section is None:
|
|
return None
|
|
if section["table_id"] not in (0x40, 0x41):
|
|
return None
|
|
if not section.get("section_syntax"):
|
|
return None
|
|
|
|
network_id = section["table_id_ext"]
|
|
data = section.get("data", b'')
|
|
|
|
if len(data) < 2:
|
|
return None
|
|
|
|
# Network descriptors loop
|
|
network_desc_length = ((data[0] & 0x0F) << 8) | data[1]
|
|
offset = 2
|
|
|
|
network_name = ""
|
|
|
|
nd_end = offset + network_desc_length
|
|
if nd_end > len(data):
|
|
nd_end = len(data)
|
|
|
|
while offset + 2 <= nd_end:
|
|
desc_tag = data[offset]
|
|
desc_len = data[offset + 1]
|
|
desc_data = data[offset + 2:offset + 2 + desc_len]
|
|
offset += 2 + desc_len
|
|
|
|
if desc_tag == 0x40:
|
|
# network_name_descriptor
|
|
network_name = _decode_dvb_string(desc_data)
|
|
|
|
offset = nd_end
|
|
|
|
# Transport stream loop
|
|
if offset + 2 > len(data):
|
|
return {
|
|
"table_id": section["table_id"],
|
|
"network_id": network_id,
|
|
"network_name": network_name,
|
|
"version": section["version"],
|
|
"transports": [],
|
|
}
|
|
|
|
ts_loop_length = ((data[offset] & 0x0F) << 8) | data[offset + 1]
|
|
offset += 2
|
|
|
|
transports = []
|
|
ts_end = offset + ts_loop_length
|
|
if ts_end > len(data):
|
|
ts_end = len(data)
|
|
|
|
while offset + 6 <= ts_end:
|
|
ts_id = (data[offset] << 8) | data[offset + 1]
|
|
original_network_id = (data[offset + 2] << 8) | data[offset + 3]
|
|
td_length = ((data[offset + 4] & 0x0F) << 8) | data[offset + 5]
|
|
offset += 6
|
|
|
|
# Parse transport descriptors
|
|
frequency_ghz = 0.0
|
|
polarization = ""
|
|
symbol_rate = 0
|
|
fec = ""
|
|
orbital_position = 0.0
|
|
modulation = ""
|
|
roll_off = ""
|
|
|
|
td_end = offset + td_length
|
|
if td_end > ts_end:
|
|
td_end = ts_end
|
|
|
|
while offset + 2 <= td_end:
|
|
desc_tag = data[offset]
|
|
desc_len = data[offset + 1]
|
|
desc_data = data[offset + 2:offset + 2 + desc_len]
|
|
offset += 2 + desc_len
|
|
|
|
if desc_tag == 0x43 and len(desc_data) >= 11:
|
|
# satellite_delivery_system_descriptor (11 bytes BCD)
|
|
frequency_ghz = _bcd_freq(desc_data[0:4])
|
|
orbital_position = _bcd_orbital(desc_data[4:6])
|
|
# Byte 6: west/east flag (bit 7), polarization (bits 6-5),
|
|
# roll-off (bits 4-3), modulation system (bit 2),
|
|
# modulation type (bits 1-0)
|
|
flag_byte = desc_data[6]
|
|
if not (flag_byte & 0x80):
|
|
orbital_position = -orbital_position # West
|
|
pol_bits = (flag_byte >> 5) & 0x03
|
|
polarization = ["H", "V", "L", "R"][pol_bits]
|
|
ro_bits = (flag_byte >> 3) & 0x03
|
|
roll_off = ["0.35", "0.25", "0.20", "reserved"][ro_bits]
|
|
mod_sys = (flag_byte >> 2) & 0x01
|
|
mod_type = flag_byte & 0x03
|
|
if mod_sys == 0:
|
|
modulation = ["auto", "QPSK", "8PSK", "16QAM"][mod_type]
|
|
else:
|
|
modulation = ["auto", "QPSK", "8PSK", "16APSK"][mod_type]
|
|
symbol_rate = _bcd_sr(desc_data[7:11])
|
|
fec_inner = desc_data[10] & 0x0F
|
|
fec = _fec_inner_str(fec_inner)
|
|
|
|
offset = max(offset, td_end)
|
|
|
|
transports.append({
|
|
"ts_id": ts_id,
|
|
"original_network_id": original_network_id,
|
|
"frequency_ghz": frequency_ghz,
|
|
"polarization": polarization,
|
|
"symbol_rate": symbol_rate,
|
|
"fec": fec,
|
|
"orbital_position": orbital_position,
|
|
"modulation": modulation,
|
|
"roll_off": roll_off,
|
|
})
|
|
|
|
return {
|
|
"table_id": section["table_id"],
|
|
"network_id": network_id,
|
|
"network_name": network_name,
|
|
"version": section["version"],
|
|
"transports": transports,
|
|
}
|
|
|
|
|
|
def _decode_dvb_string(data: bytes) -> str:
|
|
"""
|
|
Decode a DVB text string per EN 300 468 Annex A.
|
|
|
|
If the first byte is a character table selector (0x01-0x1F),
|
|
select the appropriate encoding. Otherwise assume ISO 8859-1.
|
|
"""
|
|
if not data:
|
|
return ""
|
|
|
|
first = data[0]
|
|
if first < 0x20:
|
|
# Character table selector byte
|
|
if first == 0x01:
|
|
return data[1:].decode('iso-8859-5', errors='replace')
|
|
elif first == 0x02:
|
|
return data[1:].decode('iso-8859-6', errors='replace')
|
|
elif first == 0x03:
|
|
return data[1:].decode('iso-8859-7', errors='replace')
|
|
elif first == 0x04:
|
|
return data[1:].decode('iso-8859-8', errors='replace')
|
|
elif first == 0x05:
|
|
return data[1:].decode('iso-8859-9', errors='replace')
|
|
elif first == 0x06:
|
|
return data[1:].decode('iso-8859-10', errors='replace')
|
|
elif first == 0x07:
|
|
return data[1:].decode('iso-8859-11', errors='replace')
|
|
elif first == 0x09:
|
|
return data[1:].decode('iso-8859-13', errors='replace')
|
|
elif first == 0x0A:
|
|
return data[1:].decode('iso-8859-14', errors='replace')
|
|
elif first == 0x0B:
|
|
return data[1:].decode('iso-8859-15', errors='replace')
|
|
elif first == 0x10:
|
|
# Two more selector bytes follow
|
|
if len(data) >= 3:
|
|
sub = (data[1] << 8) | data[2]
|
|
try:
|
|
return data[3:].decode(f'iso-8859-{sub}', errors='replace')
|
|
except (LookupError, ValueError):
|
|
return data[3:].decode('iso-8859-1', errors='replace')
|
|
return data[1:].decode('iso-8859-1', errors='replace')
|
|
elif first == 0x11:
|
|
return data[1:].decode('utf-16-be', errors='replace')
|
|
elif first == 0x13:
|
|
return data[1:].decode('gb2312', errors='replace')
|
|
elif first == 0x15:
|
|
return data[1:].decode('utf-8', errors='replace')
|
|
else:
|
|
# Unknown selector, skip it
|
|
return data[1:].decode('iso-8859-1', errors='replace')
|
|
|
|
return data.decode('iso-8859-1', errors='replace')
|
|
|
|
|
|
def _bcd_freq(data: bytes) -> float:
|
|
"""
|
|
Decode 4-byte BCD frequency from satellite_delivery_system_descriptor.
|
|
Per EN 300 468, the 8 BCD digits encode the frequency such that
|
|
the integer value divided by 10^5 yields GHz.
|
|
e.g., 0x11 0x72 0x75 0x00 -> digits 11727500 -> 11.72750 GHz.
|
|
"""
|
|
value = 0
|
|
for b in data:
|
|
value = value * 100 + ((b >> 4) & 0x0F) * 10 + (b & 0x0F)
|
|
return value / 1_000_000.0
|
|
|
|
|
|
def _bcd_orbital(data: bytes) -> float:
|
|
"""
|
|
Decode 2-byte BCD orbital position per EN 300 468.
|
|
4 BCD digits: XX.XX degrees (2 integer + 2 fractional).
|
|
e.g., 0x28 0x20 = 28.20 degrees (Astra 28.2E).
|
|
"""
|
|
value = 0
|
|
for b in data:
|
|
value = value * 100 + ((b >> 4) & 0x0F) * 10 + (b & 0x0F)
|
|
return value / 100.0
|
|
|
|
|
|
def _bcd_sr(data: bytes) -> int:
|
|
"""
|
|
Decode symbol rate from satellite_delivery_system_descriptor.
|
|
4 bytes: upper 28 bits = 7 BCD digits of symbol rate (XXXX.XXX Msps),
|
|
lower 4 bits = FEC inner code (handled separately by caller).
|
|
e.g., 0x00 0x27 0x50 0x03 -> digits 0027500 -> 27.500 Msps = 27,500,000 sps.
|
|
"""
|
|
# Extract 7 BCD digits from the upper 28 bits (ignore last nibble = FEC)
|
|
value = 0
|
|
for b in data[:4]:
|
|
value = value * 100 + ((b >> 4) & 0x0F) * 10 + (b & 0x0F)
|
|
# value now has 8 BCD digits decoded; drop the last one (FEC nibble)
|
|
value = value // 10
|
|
# value = XXXX.XXX Msps as integer XXXXXXX, divide by 1000 for Msps
|
|
# Multiply by 1000 to get sps: (value / 1000) * 1e6 = value * 1000
|
|
return value * 1000
|
|
|
|
|
|
def _fec_inner_str(code: int) -> str:
|
|
"""Convert FEC inner code rate nibble to string."""
|
|
fec_map = {
|
|
0: "not defined",
|
|
1: "1/2",
|
|
2: "2/3",
|
|
3: "3/4",
|
|
4: "5/6",
|
|
5: "7/8",
|
|
6: "8/9",
|
|
7: "3/5",
|
|
8: "4/5",
|
|
9: "9/10",
|
|
15: "none",
|
|
}
|
|
return fec_map.get(code, f"reserved({code})")
|
|
|
|
|
|
def open_input(path: str):
|
|
"""Open TS input from a file path or stdin ('-')."""
|
|
if path == '-':
|
|
return sys.stdin.buffer
|
|
if not os.path.exists(path):
|
|
print(f"File not found: {path}")
|
|
sys.exit(1)
|
|
return open(path, 'rb')
|
|
|
|
|
|
def format_pid(pid: int, known: dict = None) -> str:
|
|
"""Format a PID with its known name if available."""
|
|
if known is None:
|
|
known = KNOWN_PIDS
|
|
name = known.get(pid, "")
|
|
if name:
|
|
return f"0x{pid:04X} ({name})"
|
|
return f"0x{pid:04X}"
|
|
|
|
|
|
# -- Subcommand handlers --
|
|
|
|
def cmd_analyze(args: argparse.Namespace) -> None:
|
|
"""Full transport stream analysis."""
|
|
source = open_input(args.input)
|
|
reader = TSReader(source, verbose=args.verbose)
|
|
|
|
pid_counts = {}
|
|
pid_cc = {} # Last continuity counter per PID
|
|
cc_errors = {}
|
|
tei_count = 0
|
|
scrambled_count = 0
|
|
total_packets = 0
|
|
first_pcr = None
|
|
first_pcr_pkt = 0
|
|
last_pcr = None
|
|
last_pcr_pkt = 0
|
|
|
|
print(f"MPEG-2 Transport Stream Analysis")
|
|
print(f"{'=' * 60}")
|
|
if args.input != '-':
|
|
file_size = os.path.getsize(args.input)
|
|
print(f"File: {args.input} ({file_size:,} bytes)")
|
|
print()
|
|
|
|
try:
|
|
for pkt in reader.iter_packets(max_packets=args.max_packets):
|
|
total_packets += 1
|
|
pid = pkt.pid
|
|
|
|
# PID counting
|
|
pid_counts[pid] = pid_counts.get(pid, 0) + 1
|
|
|
|
# TEI
|
|
if pkt.tei:
|
|
tei_count += 1
|
|
|
|
# Scrambling
|
|
if pkt.scrambling != 0:
|
|
scrambled_count += 1
|
|
|
|
# Continuity counter check (only for PIDs carrying payload)
|
|
if pkt.adaptation & 0x01 and pid != 0x1FFF:
|
|
if pid in pid_cc:
|
|
expected = (pid_cc[pid] + 1) & 0x0F
|
|
if pkt.continuity != expected and pkt.continuity != pid_cc[pid]:
|
|
cc_errors[pid] = cc_errors.get(pid, 0) + 1
|
|
pid_cc[pid] = pkt.continuity
|
|
|
|
# PCR extraction for bitrate calculation
|
|
if pkt.has_pcr():
|
|
pcr = pkt.get_pcr()
|
|
if pcr >= 0:
|
|
if first_pcr is None:
|
|
first_pcr = pcr
|
|
first_pcr_pkt = total_packets
|
|
last_pcr = pcr
|
|
last_pcr_pkt = total_packets
|
|
except KeyboardInterrupt:
|
|
print("\n (interrupted)")
|
|
finally:
|
|
if source is not sys.stdin.buffer:
|
|
source.close()
|
|
|
|
if total_packets == 0:
|
|
print("No valid TS packets found.")
|
|
return
|
|
|
|
# Summary
|
|
if reader.sync_offset > 0:
|
|
print(f"Sync offset: {reader.sync_offset} bytes (skipped leading garbage)")
|
|
print(f"Total packets: {total_packets:,}")
|
|
print(f"Total bytes: {total_packets * TS_PACKET_SIZE:,}")
|
|
print(f"Unique PIDs: {len(pid_counts)}")
|
|
print(f"TEI errors: {tei_count}")
|
|
print(f"Scrambled: {scrambled_count}")
|
|
|
|
# Bitrate
|
|
if first_pcr is not None and last_pcr is not None and last_pcr != first_pcr:
|
|
pcr_delta = last_pcr - first_pcr
|
|
pkt_delta = last_pcr_pkt - first_pcr_pkt
|
|
if pcr_delta > 0 and pkt_delta > 0:
|
|
duration = pcr_delta / 27_000_000.0 # PCR is 27 MHz clock
|
|
byte_count = pkt_delta * TS_PACKET_SIZE
|
|
bitrate = (byte_count * 8) / duration
|
|
if bitrate >= 1e6:
|
|
rate_str = f"{bitrate / 1e6:.2f} Mbps"
|
|
else:
|
|
rate_str = f"{bitrate / 1e3:.1f} kbps"
|
|
print(f"Duration: {duration:.2f}s (from PCR)")
|
|
print(f"Bitrate: {rate_str} (PCR-based)")
|
|
elif args.input != '-':
|
|
# File size estimate
|
|
file_size = os.path.getsize(args.input)
|
|
print(f"Bitrate: (no PCR found, cannot calculate from timing)")
|
|
|
|
# PID table
|
|
print(f"\n{'=' * 60}")
|
|
print(f"PID Distribution")
|
|
print(f"{'=' * 60}")
|
|
print(f" {'PID':>6} {'Count':>10} {'%':>7} {'CC Err':>6} Name")
|
|
print(f" {'---':>6} {'-----':>10} {'--':>7} {'------':>6} ----")
|
|
|
|
for pid in sorted(pid_counts.keys()):
|
|
count = pid_counts[pid]
|
|
pct = (count / total_packets) * 100
|
|
cc_err = cc_errors.get(pid, 0)
|
|
name = KNOWN_PIDS.get(pid, "")
|
|
cc_str = str(cc_err) if cc_err > 0 else "-"
|
|
print(f" 0x{pid:04X} {count:>10,} {pct:>6.2f}% {cc_str:>6} {name}")
|
|
|
|
# CC error summary
|
|
total_cc_errors = sum(cc_errors.values())
|
|
if total_cc_errors > 0:
|
|
print(f"\nContinuity errors: {total_cc_errors} total across "
|
|
f"{len(cc_errors)} PID(s)")
|
|
|
|
|
|
def cmd_pids(args: argparse.Namespace) -> None:
|
|
"""Quick PID summary table."""
|
|
source = open_input(args.input)
|
|
reader = TSReader(source, verbose=args.verbose)
|
|
|
|
pid_counts = {}
|
|
total = 0
|
|
|
|
try:
|
|
for pkt in reader.iter_packets(max_packets=args.max_packets):
|
|
total += 1
|
|
pid_counts[pkt.pid] = pid_counts.get(pkt.pid, 0) + 1
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
if source is not sys.stdin.buffer:
|
|
source.close()
|
|
|
|
if total == 0:
|
|
print("No TS packets found.")
|
|
return
|
|
|
|
print(f"PID Table ({total:,} packets)")
|
|
print(f"{'=' * 50}")
|
|
print(f" {'PID':>6} {'Count':>10} {'%':>7} Name")
|
|
print(f" {'---':>6} {'-----':>10} {'--':>7} ----")
|
|
|
|
for pid in sorted(pid_counts.keys()):
|
|
count = pid_counts[pid]
|
|
pct = (count / total) * 100
|
|
name = KNOWN_PIDS.get(pid, "")
|
|
print(f" 0x{pid:04X} {count:>10,} {pct:>6.2f}% {name}")
|
|
|
|
|
|
def cmd_pat(args: argparse.Namespace) -> None:
|
|
"""Parse and display the Program Association Table."""
|
|
source = open_input(args.input)
|
|
reader = TSReader(source, verbose=args.verbose)
|
|
psi = PSIParser()
|
|
pat_found = False
|
|
|
|
try:
|
|
for pkt in reader.iter_packets():
|
|
if pkt.pid != 0x0000:
|
|
continue
|
|
section = psi.feed(pkt)
|
|
if section is None:
|
|
continue
|
|
pat = parse_pat(section)
|
|
if pat is None:
|
|
continue
|
|
|
|
pat_found = True
|
|
print(f"Program Association Table (PAT)")
|
|
print(f"{'=' * 50}")
|
|
print(f" Transport Stream ID: 0x{pat['transport_stream_id']:04X} "
|
|
f"({pat['transport_stream_id']})")
|
|
print(f" Version: {pat['version']}")
|
|
print(f" Programs: {len(pat['programs'])}")
|
|
print()
|
|
print(f" {'Program':>10} {'PMT PID':>10} Note")
|
|
print(f" {'-------':>10} {'-------':>10} ----")
|
|
for prog, pmt_pid in sorted(pat['programs'].items()):
|
|
note = "NIT" if prog == 0 else ""
|
|
print(f" {prog:>10} 0x{pmt_pid:04X} {note}")
|
|
break
|
|
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
if source is not sys.stdin.buffer:
|
|
source.close()
|
|
|
|
if not pat_found:
|
|
print("No PAT (PID 0x0000) found in stream.")
|
|
|
|
|
|
def cmd_pmt(args: argparse.Namespace) -> None:
|
|
"""Parse and display Program Map Tables."""
|
|
source = open_input(args.input)
|
|
reader = TSReader(source, verbose=args.verbose)
|
|
psi_pat = PSIParser()
|
|
psi_pmt = PSIParser()
|
|
|
|
pat = None
|
|
pmt_pids = set()
|
|
pmts_found = {}
|
|
|
|
try:
|
|
for pkt in reader.iter_packets():
|
|
# First, collect PAT to learn PMT PIDs
|
|
if pkt.pid == 0x0000 and pat is None:
|
|
section = psi_pat.feed(pkt)
|
|
if section is not None:
|
|
pat = parse_pat(section)
|
|
if pat is not None:
|
|
for prog, pid in pat['programs'].items():
|
|
if prog != 0: # Skip NIT reference
|
|
pmt_pids.add(pid)
|
|
|
|
# Then collect PMT sections
|
|
if pkt.pid in pmt_pids and pkt.pid not in pmts_found:
|
|
section = psi_pmt.feed(pkt)
|
|
if section is not None:
|
|
pmt = parse_pmt(section)
|
|
if pmt is not None:
|
|
pmts_found[pkt.pid] = pmt
|
|
|
|
# Done when we have all PMTs
|
|
if pat is not None and len(pmts_found) >= len(pmt_pids):
|
|
break
|
|
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
if source is not sys.stdin.buffer:
|
|
source.close()
|
|
|
|
if pat is None:
|
|
print("No PAT found -- cannot locate PMTs.")
|
|
return
|
|
|
|
if not pmts_found:
|
|
print("PAT found but no PMT sections could be parsed.")
|
|
return
|
|
|
|
print(f"Program Map Tables")
|
|
print(f"{'=' * 60}")
|
|
print(f"Transport Stream ID: 0x{pat['transport_stream_id']:04X}")
|
|
print()
|
|
|
|
for pmt_pid in sorted(pmts_found.keys()):
|
|
pmt = pmts_found[pmt_pid]
|
|
print(f" Program {pmt['program_number']} (PMT PID 0x{pmt_pid:04X}, "
|
|
f"version {pmt['version']})")
|
|
print(f" PCR PID: 0x{pmt['pcr_pid']:04X}")
|
|
print(f" Streams:")
|
|
print(f" {'Type':>6} {'PID':>6} Description")
|
|
print(f" {'----':>6} {'---':>6} -----------")
|
|
for s in pmt['streams']:
|
|
print(f" 0x{s['stream_type']:02X} 0x{s['elementary_pid']:04X} "
|
|
f"{s['type_name']}")
|
|
print()
|
|
|
|
|
|
def cmd_dump(args: argparse.Namespace) -> None:
|
|
"""Hex dump of individual TS packets."""
|
|
source = open_input(args.input)
|
|
reader = TSReader(source, verbose=args.verbose)
|
|
filter_pid = args.pid
|
|
max_count = args.count
|
|
shown = 0
|
|
|
|
try:
|
|
for pkt in reader.iter_packets():
|
|
if filter_pid is not None and pkt.pid != filter_pid:
|
|
continue
|
|
|
|
shown += 1
|
|
scrambling_str = ["none", "reserved", "even key", "odd key"][pkt.scrambling]
|
|
adapt_str = ["reserved", "payload only", "adapt only", "adapt+payload"][pkt.adaptation]
|
|
|
|
print(f"Packet #{shown}")
|
|
print(f" PID: 0x{pkt.pid:04X} ({KNOWN_PIDS.get(pkt.pid, '')})")
|
|
print(f" TEI: {int(pkt.tei)} PUSI: {int(pkt.pusi)} "
|
|
f"Priority: {int(pkt.priority)}")
|
|
print(f" Scrambling: {scrambling_str} "
|
|
f"Adaptation: {adapt_str} CC: {pkt.continuity}")
|
|
|
|
if pkt.adaptation_field is not None and len(pkt.adaptation_field) > 1:
|
|
af_len = pkt.adaptation_field[0]
|
|
af_flags = pkt.adaptation_field[1] if len(pkt.adaptation_field) > 1 else 0
|
|
flags = []
|
|
if af_flags & 0x80: flags.append("discontinuity")
|
|
if af_flags & 0x40: flags.append("random_access")
|
|
if af_flags & 0x20: flags.append("ES_priority")
|
|
if af_flags & 0x10: flags.append("PCR")
|
|
if af_flags & 0x08: flags.append("OPCR")
|
|
if af_flags & 0x04: flags.append("splice_point")
|
|
if af_flags & 0x02: flags.append("private_data")
|
|
if af_flags & 0x01: flags.append("extension")
|
|
print(f" Adaptation field: {af_len} bytes, "
|
|
f"flags=[{', '.join(flags) if flags else 'none'}]")
|
|
if pkt.has_pcr():
|
|
pcr = pkt.get_pcr()
|
|
pcr_secs = pcr / 27_000_000.0
|
|
print(f" PCR: {pcr} ({pcr_secs:.6f}s)")
|
|
|
|
# Hex dump
|
|
data = pkt.raw
|
|
print(f" Hex:")
|
|
for row_off in range(0, len(data), 16):
|
|
row = data[row_off:row_off + 16]
|
|
hex_part = ' '.join(f'{b:02X}' for b in row)
|
|
ascii_part = ''.join(chr(b) if 0x20 <= b < 0x7F else '.' for b in row)
|
|
print(f" {row_off:04X}: {hex_part:<48} {ascii_part}")
|
|
print()
|
|
|
|
if max_count and shown >= max_count:
|
|
break
|
|
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
if source is not sys.stdin.buffer:
|
|
source.close()
|
|
|
|
if shown == 0:
|
|
if filter_pid is not None:
|
|
print(f"No packets found with PID 0x{filter_pid:04X}")
|
|
else:
|
|
print("No TS packets found.")
|
|
|
|
|
|
def cmd_monitor(args: argparse.Namespace) -> None:
|
|
"""Live stream monitoring from stdin or file."""
|
|
source = open_input(args.input)
|
|
reader = TSReader(source, verbose=args.verbose)
|
|
|
|
pid_counts = {}
|
|
known_pids = set()
|
|
cc_last = {}
|
|
cc_errors = 0
|
|
tei_count = 0
|
|
total_packets = 0
|
|
interval_packets = 0
|
|
start_time = time.time()
|
|
last_report = start_time
|
|
|
|
print(f"MPEG-2 TS Live Monitor")
|
|
print(f"{'=' * 60}")
|
|
print(f"Ctrl-C to stop\n")
|
|
|
|
try:
|
|
for pkt in reader.iter_packets():
|
|
total_packets += 1
|
|
interval_packets += 1
|
|
pid = pkt.pid
|
|
|
|
pid_counts[pid] = pid_counts.get(pid, 0) + 1
|
|
|
|
# New PID detection
|
|
if pid not in known_pids:
|
|
known_pids.add(pid)
|
|
name = KNOWN_PIDS.get(pid, "")
|
|
label = f" ({name})" if name else ""
|
|
elapsed = time.time() - start_time
|
|
print(f" [{elapsed:>7.1f}s] New PID: 0x{pid:04X}{label}")
|
|
|
|
# TEI
|
|
if pkt.tei:
|
|
tei_count += 1
|
|
elapsed = time.time() - start_time
|
|
print(f" [{elapsed:>7.1f}s] TEI error on PID 0x{pid:04X}")
|
|
|
|
# CC check
|
|
if pkt.adaptation & 0x01 and pid != 0x1FFF:
|
|
if pid in cc_last:
|
|
expected = (cc_last[pid] + 1) & 0x0F
|
|
if pkt.continuity != expected and pkt.continuity != cc_last[pid]:
|
|
cc_errors += 1
|
|
elapsed = time.time() - start_time
|
|
print(f" [{elapsed:>7.1f}s] CC error PID 0x{pid:04X}: "
|
|
f"expected {expected}, got {pkt.continuity}")
|
|
cc_last[pid] = pkt.continuity
|
|
|
|
# Periodic status
|
|
now = time.time()
|
|
if now - last_report >= 1.0:
|
|
elapsed = now - start_time
|
|
total_bytes = total_packets * TS_PACKET_SIZE
|
|
bitrate = (interval_packets * TS_PACKET_SIZE * 8)
|
|
if bitrate >= 1e6:
|
|
rate_str = f"{bitrate / 1e6:.2f} Mbps"
|
|
else:
|
|
rate_str = f"{bitrate / 1e3:.1f} kbps"
|
|
sys.stderr.write(
|
|
f"\r {total_packets:>10,} pkts "
|
|
f"{total_bytes:>12,} bytes "
|
|
f"{rate_str:>12} "
|
|
f"PIDs:{len(known_pids):>3} "
|
|
f"CCerr:{cc_errors} "
|
|
f"TEI:{tei_count} "
|
|
f"({elapsed:.0f}s) "
|
|
)
|
|
sys.stderr.flush()
|
|
interval_packets = 0
|
|
last_report = now
|
|
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
if source is not sys.stdin.buffer:
|
|
source.close()
|
|
|
|
elapsed = time.time() - start_time
|
|
total_bytes = total_packets * TS_PACKET_SIZE
|
|
print(f"\n\nMonitor Summary")
|
|
print(f"{'=' * 40}")
|
|
print(f" Duration: {elapsed:.1f}s")
|
|
print(f" Packets: {total_packets:,}")
|
|
print(f" Bytes: {total_bytes:,}")
|
|
print(f" Unique PIDs: {len(known_pids)}")
|
|
print(f" CC errors: {cc_errors}")
|
|
print(f" TEI errors: {tei_count}")
|
|
|
|
if elapsed > 0:
|
|
avg_bitrate = (total_bytes * 8) / elapsed
|
|
if avg_bitrate >= 1e6:
|
|
print(f" Avg bitrate: {avg_bitrate / 1e6:.2f} Mbps")
|
|
else:
|
|
print(f" Avg bitrate: {avg_bitrate / 1e3:.1f} kbps")
|
|
|
|
|
|
# -- CLI --
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="MPEG-2 Transport Stream analyzer for Genpix SkyWalker-1",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""\
|
|
examples:
|
|
%(prog)s capture.ts
|
|
%(prog)s analyze capture.ts
|
|
%(prog)s pids capture.ts
|
|
%(prog)s pat capture.ts
|
|
%(prog)s pmt capture.ts
|
|
%(prog)s dump capture.ts --pid 0x100 --count 5
|
|
%(prog)s monitor -
|
|
tune.py stream --stdout | %(prog)s monitor -
|
|
""")
|
|
parser.add_argument('-v', '--verbose', action='store_true',
|
|
help="Show sync search details and debug info")
|
|
|
|
sub = parser.add_subparsers(dest='command')
|
|
|
|
# analyze (default)
|
|
p_analyze = sub.add_parser('analyze', help="Full stream analysis (default)")
|
|
p_analyze.add_argument('input', help="TS file path or '-' for stdin")
|
|
p_analyze.add_argument('--max-packets', type=int, default=0,
|
|
help="Max packets to analyze (0 = all)")
|
|
|
|
# pids
|
|
p_pids = sub.add_parser('pids', help="Quick PID summary table")
|
|
p_pids.add_argument('input', help="TS file path or '-' for stdin")
|
|
p_pids.add_argument('--max-packets', type=int, default=0,
|
|
help="Max packets to analyze (0 = all)")
|
|
|
|
# pat
|
|
p_pat = sub.add_parser('pat', help="Parse Program Association Table")
|
|
p_pat.add_argument('input', help="TS file path or '-' for stdin")
|
|
|
|
# pmt
|
|
p_pmt = sub.add_parser('pmt', help="Parse Program Map Tables")
|
|
p_pmt.add_argument('input', help="TS file path or '-' for stdin")
|
|
|
|
# dump
|
|
p_dump = sub.add_parser('dump', help="Hex dump of TS packets")
|
|
p_dump.add_argument('input', help="TS file path or '-' for stdin")
|
|
p_dump.add_argument('--pid', type=lambda x: int(x, 0), default=None,
|
|
help="Filter by PID (hex: 0x100 or decimal: 256)")
|
|
p_dump.add_argument('--count', type=int, default=10,
|
|
help="Max packets to dump (default: 10)")
|
|
|
|
# monitor
|
|
p_monitor = sub.add_parser('monitor', help="Live stream monitoring")
|
|
p_monitor.add_argument('input', help="TS file path or '-' for stdin")
|
|
|
|
return parser
|
|
|
|
|
|
def main():
|
|
parser = build_parser()
|
|
|
|
# Handle bare filename without subcommand: default to 'analyze'
|
|
# Insert 'analyze' after any global flags but before the filename
|
|
subcmds = {'analyze', 'pids', 'pat', 'pmt', 'dump', 'monitor'}
|
|
argv = sys.argv[1:]
|
|
if argv:
|
|
first_pos = None
|
|
insert_idx = 0
|
|
for i, a in enumerate(argv):
|
|
if not a.startswith('-'):
|
|
first_pos = a
|
|
insert_idx = i
|
|
break
|
|
# Skip flag and its value if it takes one (currently none do)
|
|
insert_idx = i + 1
|
|
if first_pos is not None and first_pos not in subcmds:
|
|
argv.insert(insert_idx, 'analyze')
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
if not args.command:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
dispatch = {
|
|
'analyze': cmd_analyze,
|
|
'pids': cmd_pids,
|
|
'pat': cmd_pat,
|
|
'pmt': cmd_pmt,
|
|
'dump': cmd_dump,
|
|
'monitor': cmd_monitor,
|
|
}
|
|
|
|
handler = dispatch.get(args.command)
|
|
if handler is None:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
handler(args)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|