Normalize line endings to LF across entire repository

Apply .gitattributes normalization to convert all CRLF line
endings inherited from Windows-origin source files to Unix LF.
175 files, zero content changes.
This commit is contained in:
Ryan Malloy 2026-02-20 10:55:50 -07:00
parent 696d2dd387
commit bbdcb243dc
175 changed files with 56794 additions and 56794 deletions

View file

@ -1,451 +1,451 @@
#!/usr/bin/env python3
"""
Multi-satellite arc survey for the Genpix SkyWalker-1.
Automated "satellite census": points the dish motor to each known GEO
longitude, runs a full-band carrier survey at each position, and aggregates
results into a comprehensive sky map. The diff capability tracks changes
between survey runs.
Usage:
python arc_survey.py --observer-lon -96.8 --slots "97W,99W,101W,103W"
python arc_survey.py --observer-lon -96.8 --file slots.json
python arc_survey.py --observer-lon -96.8 --arc -120 -60 --step 3
python arc_survey.py --resume arc-survey-2026-02-17.json
The tool saves progress after each orbital slot, so interrupted surveys
can be resumed. Each slot's catalog is saved individually, and a summary
report covers the entire arc.
"""
import sys
import os
import argparse
import time
import json
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1, usals_angle
from survey_engine import SurveyEngine
from carrier_catalog import CarrierCatalog, CATALOG_DIR
# Common North American GEO orbital slots
NA_ORBITAL_SLOTS = {
"129W": -129.0, "125W": -125.0, "123W": -123.0, "121W": -121.0,
"119W": -119.0, "118.7W": -118.7, "116.8W": -116.8, "114.9W": -114.9,
"113W": -113.0, "111.1W": -111.1, "110W": -110.0, "107.3W": -107.3,
"105W": -105.0, "103W": -103.0, "101W": -101.0, "99W": -99.0,
"97W": -97.0, "95W": -95.0, "93W": -93.0, "91W": -91.0,
"89W": -89.0, "87W": -87.0, "85W": -85.0, "83W": -83.0,
"82W": -82.0, "79W": -79.0, "77W": -77.0, "75W": -75.0,
"72.7W": -72.7, "70W": -70.0, "67W": -67.0, "65W": -65.0,
"63W": -63.0, "61.5W": -61.5, "58W": -58.0, "55.5W": -55.5,
}
ARC_SURVEY_DIR = CATALOG_DIR.parent / "arc-surveys"
class ArcSurvey:
"""Multi-position orbital arc survey with persistence and resume."""
def __init__(self, sw: SkyWalker1, observer_lon: float,
observer_lat: float = 0.0, settle_time: float = 15.0):
self.sw = sw
self.observer_lon = observer_lon
self.observer_lat = observer_lat
self.settle_time = settle_time
def survey_slot(self, name: str, sat_lon: float,
coarse_step: float = 5.0,
band: str = "", pol: str = "",
callback=None) -> CarrierCatalog:
"""Survey a single orbital slot: move dish, wait, run survey."""
# Calculate motor angle
angle = usals_angle(self.observer_lon, sat_lon, self.observer_lat)
direction = "west" if angle < 0 else "east"
if callback:
callback("moving", 0,
f"Moving to {name} ({sat_lon:.1f}), "
f"angle {abs(angle):.1f} deg {direction}")
# Command the motor
self.sw.motor_goto_x(self.observer_lon, sat_lon)
# Wait for motor to settle (larger angles need more time)
settle = max(self.settle_time, abs(angle) * 0.3)
if callback:
callback("settling", 20, f"Settling {settle:.0f}s...")
time.sleep(settle)
# Verify we have signal (check AGC for any RF energy)
sig = self.sw.signal_monitor()
if callback:
callback("signal_check", 30,
f"AGC1={sig['agc1']}, power={sig['power_db']:.1f} dB")
# Run the six-stage survey
def survey_cb(stage, pct, msg):
overall_pct = 30 + int(pct * 0.7)
if callback:
callback(stage, overall_pct, msg)
engine = SurveyEngine(self.sw, callback=survey_cb)
catalog = engine.run_full_scan(
coarse_step=coarse_step,
ts_capture_secs=2.0,
)
catalog.name = f"{name} ({sat_lon:.1f})"
catalog.band = band
catalog.pol = pol
catalog.notes = (f"Arc survey position: {name}, "
f"observer: {self.observer_lon:.2f} lon, "
f"motor angle: {angle:.2f} deg")
if callback:
callback("complete", 100,
f"{name}: {len(catalog.carriers)} carriers, "
f"{sum(1 for c in catalog.carriers if c.locked)} locked")
return catalog
def run_arc(self, slots: list[tuple[str, float]],
coarse_step: float = 5.0,
band: str = "", pol: str = "",
save_individual: bool = True,
resume_state: dict | None = None) -> dict:
"""Survey an entire arc of orbital slots.
slots: list of (name, sat_lon) tuples
resume_state: previous arc survey state dict for resuming
Returns a complete arc survey result dict.
"""
ARC_SURVEY_DIR.mkdir(parents=True, exist_ok=True)
date_str = datetime.now().strftime("%Y-%m-%d")
# Initialize or resume state
if resume_state:
state = resume_state
completed_names = set(state.get("completed_slots", {}).keys())
else:
state = {
"started": datetime.now(timezone.utc).isoformat(),
"observer_lon": self.observer_lon,
"observer_lat": self.observer_lat,
"total_slots": len(slots),
"completed_slots": {},
"skipped_slots": {},
"summary": {
"total_carriers": 0,
"total_locked": 0,
"total_services": 0,
},
}
completed_names = set()
state_path = ARC_SURVEY_DIR / f"arc-survey-{date_str}.json"
for i, (name, sat_lon) in enumerate(slots):
if name in completed_names:
print(f" [{i+1}/{len(slots)}] Skipping {name} (already surveyed)")
continue
print(f"\n [{i+1}/{len(slots)}] Surveying {name} ({sat_lon:.1f} lon)")
def progress_cb(stage, pct, msg):
print(f" [{pct:3d}%] {stage}: {msg}")
try:
catalog = self.survey_slot(
name, sat_lon,
coarse_step=coarse_step,
band=band, pol=pol,
callback=progress_cb,
)
# Save individual catalog
if save_individual:
slot_filename = f"arc-{date_str}-{name.replace('.', '_')}.json"
cat_path = catalog.save(slot_filename)
print(f" Saved: {cat_path}")
# Update state
carrier_count = len(catalog.carriers)
locked_count = sum(1 for c in catalog.carriers if c.locked)
service_count = sum(len(c.services) for c in catalog.carriers)
state["completed_slots"][name] = {
"sat_lon": sat_lon,
"completed": datetime.now(timezone.utc).isoformat(),
"carriers": carrier_count,
"locked": locked_count,
"services": service_count,
"catalog_file": slot_filename if save_individual else None,
}
state["summary"]["total_carriers"] += carrier_count
state["summary"]["total_locked"] += locked_count
state["summary"]["total_services"] += service_count
except KeyboardInterrupt:
print(f"\n Survey interrupted at {name}")
try:
self.sw.motor_halt()
except Exception:
pass
state["interrupted_at"] = name
_save_state(state, state_path)
print(f" Motor halted. Progress saved to {state_path}")
print(f" Resume with: python arc_survey.py --resume {state_path}")
return state
except Exception as e:
print(f" Error at {name}: {e}")
try:
self.sw.motor_halt()
except Exception:
pass
state["skipped_slots"][name] = {
"sat_lon": sat_lon,
"error": str(e),
}
# Save state after each slot for resume capability
_save_state(state, state_path)
# Final summary
state["completed"] = datetime.now(timezone.utc).isoformat()
_save_state(state, state_path)
return state
def _save_state(state: dict, path: Path) -> None:
"""Save arc survey state to JSON."""
with open(path, 'w') as f:
json.dump(state, f, indent=2)
def parse_slot_string(slot_str: str) -> list[tuple[str, float]]:
"""Parse a comma-separated slot string like '97W,99W,101W'.
Accepts formats: '97W', '97.5W', '3E', '-97', '-97.5'
"""
slots = []
for part in slot_str.split(','):
part = part.strip()
if not part:
continue
if part in NA_ORBITAL_SLOTS:
slots.append((part, NA_ORBITAL_SLOTS[part]))
elif part.upper().endswith('W'):
lon = -float(part[:-1])
slots.append((part.upper(), lon))
elif part.upper().endswith('E'):
lon = float(part[:-1])
slots.append((part.upper(), lon))
else:
lon = float(part)
name = f"{abs(lon):.1f}{'W' if lon < 0 else 'E'}"
slots.append((name, lon))
return slots
def generate_arc_range(start_lon: float, stop_lon: float,
step: float) -> list[tuple[str, float]]:
"""Generate orbital slots at regular intervals across an arc."""
slots = []
lon = start_lon
while lon <= stop_lon:
name = f"{abs(lon):.1f}{'W' if lon < 0 else 'E'}"
slots.append((name, lon))
lon += step
return slots
def print_summary(state: dict) -> None:
"""Print a human-readable arc survey summary."""
print(f"\n Arc Survey Summary")
print(f" ==================")
print(f" Observer: {state['observer_lon']:.2f} lon")
print(f" Slots surveyed: {len(state['completed_slots'])} / {state['total_slots']}")
print(f" Total carriers: {state['summary']['total_carriers']}")
print(f" Total locked: {state['summary']['total_locked']}")
print(f" Total services: {state['summary']['total_services']}")
if state.get("skipped_slots"):
print(f" Skipped: {len(state['skipped_slots'])}")
print(f"\n Per-slot results:")
for name, info in sorted(state["completed_slots"].items(),
key=lambda x: x[1]["sat_lon"]):
lock_str = f"{info['locked']}/{info['carriers']}"
svc_str = f"{info['services']} svc" if info['services'] else ""
print(f" {name:>8s} ({info['sat_lon']:+7.1f}): "
f"{lock_str:>7s} locked {svc_str}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="arc_survey.py",
description="Multi-satellite arc survey for SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
# Survey specific slots (North American arc)
%(prog)s --observer-lon -96.8 --slots "97W,99W,101W,103W"
# Survey an arc range at 3-degree intervals
%(prog)s --observer-lon -96.8 --arc -120 -60 --step 3
# Load slots from a JSON file
%(prog)s --observer-lon -96.8 --file my-slots.json
# Resume an interrupted survey
%(prog)s --resume ~/.skywalker1/arc-surveys/arc-survey-2026-02-17.json
# List common North American orbital slots
%(prog)s --list-slots
slot file format (JSON):
[
{"name": "97W", "lon": -97.0},
{"name": "99W", "lon": -99.0}
]
notes:
- Motor settle time scales with angle (min 15s, + 0.3s per degree)
- Each slot takes 5-15 minutes depending on carrier density
- Progress is saved after each slot; Ctrl-C to pause safely
- Individual catalogs saved to ~/.skywalker1/surveys/
- Arc survey state saved to ~/.skywalker1/arc-surveys/
""",
)
parser.add_argument('-v', '--verbose', action='store_true')
parser.add_argument('--observer-lon', type=float,
help="Observer longitude (negative=west, e.g. -96.8)")
parser.add_argument('--observer-lat', type=float, default=0.0,
help="Observer latitude (default: 0.0)")
source = parser.add_mutually_exclusive_group()
source.add_argument('--slots', type=str,
help="Comma-separated slot list (e.g. '97W,99W,101W')")
source.add_argument('--file', type=str,
help="JSON file with slot definitions")
source.add_argument('--arc', nargs=2, type=float, metavar=('START', 'STOP'),
help="Arc range in degrees longitude")
source.add_argument('--resume', type=str,
help="Resume from a saved arc survey state file")
source.add_argument('--list-slots', action='store_true',
help="List common NA orbital slots and exit")
parser.add_argument('--step', type=float, default=3.0,
help="Step size for --arc mode (default: 3.0 degrees)")
parser.add_argument('--coarse-step', type=float, default=5.0,
help="Coarse sweep step in MHz (default: 5.0)")
parser.add_argument('--settle-time', type=float, default=15.0,
help="Minimum motor settle time in seconds (default: 15)")
parser.add_argument('--pol', type=str, default="",
help="Polarization label (H/V, for catalog metadata)")
parser.add_argument('--band', type=str, default="",
help="Band label (low/high, for catalog metadata)")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if args.list_slots:
print("Common North American GEO orbital slots:")
for name in sorted(NA_ORBITAL_SLOTS, key=lambda n: NA_ORBITAL_SLOTS[n]):
lon = NA_ORBITAL_SLOTS[name]
print(f" {name:>8s} {lon:+7.1f}")
return
# Determine slot list
resume_state = None
if args.resume:
with open(args.resume) as f:
resume_state = json.load(f)
observer_lon = resume_state["observer_lon"]
observer_lat = resume_state.get("observer_lat", 0.0)
# Reconstruct slots from state
all_slot_names = (
list(resume_state.get("completed_slots", {}).keys()) +
list(resume_state.get("skipped_slots", {}).keys())
)
# We need the original slot list — reconstruct from completed + remaining
slots = []
for name, info in resume_state.get("completed_slots", {}).items():
slots.append((name, info["sat_lon"]))
for name, info in resume_state.get("skipped_slots", {}).items():
slots.append((name, info["sat_lon"]))
# Sort by longitude
slots.sort(key=lambda x: x[1])
print(f"Resuming arc survey: {len(resume_state.get('completed_slots', {}))} "
f"of {len(slots)} slots completed")
else:
if not args.observer_lon and args.observer_lon != 0:
parser.error("--observer-lon is required (or use --resume)")
observer_lon = args.observer_lon
observer_lat = args.observer_lat
if args.slots:
slots = parse_slot_string(args.slots)
elif args.file:
with open(args.file) as f:
data = json.load(f)
slots = [(d["name"], d["lon"]) for d in data]
elif args.arc:
start, stop = sorted(args.arc)
slots = generate_arc_range(start, stop, args.step)
else:
parser.error("Specify --slots, --file, --arc, or --resume")
if not slots:
print("No orbital slots to survey", file=sys.stderr)
sys.exit(1)
print(f"Arc Survey")
print(f" Observer: {observer_lon:.2f} lon, {observer_lat:.2f} lat")
print(f" Orbital slots: {len(slots)}")
for name, lon in slots:
angle = usals_angle(observer_lon, lon, observer_lat)
direction = "W" if angle < 0 else "E"
print(f" {name:>8s} {lon:+7.1f} (motor: {abs(angle):.1f} deg {direction})")
print()
with SkyWalker1(verbose=args.verbose) as sw:
sw.ensure_booted()
survey = ArcSurvey(
sw, observer_lon, observer_lat,
settle_time=args.settle_time,
)
state = survey.run_arc(
slots,
coarse_step=args.coarse_step,
band=args.band, pol=args.pol,
resume_state=resume_state,
)
print_summary(state)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Multi-satellite arc survey for the Genpix SkyWalker-1.
Automated "satellite census": points the dish motor to each known GEO
longitude, runs a full-band carrier survey at each position, and aggregates
results into a comprehensive sky map. The diff capability tracks changes
between survey runs.
Usage:
python arc_survey.py --observer-lon -96.8 --slots "97W,99W,101W,103W"
python arc_survey.py --observer-lon -96.8 --file slots.json
python arc_survey.py --observer-lon -96.8 --arc -120 -60 --step 3
python arc_survey.py --resume arc-survey-2026-02-17.json
The tool saves progress after each orbital slot, so interrupted surveys
can be resumed. Each slot's catalog is saved individually, and a summary
report covers the entire arc.
"""
import sys
import os
import argparse
import time
import json
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1, usals_angle
from survey_engine import SurveyEngine
from carrier_catalog import CarrierCatalog, CATALOG_DIR
# Common North American GEO orbital slots
NA_ORBITAL_SLOTS = {
"129W": -129.0, "125W": -125.0, "123W": -123.0, "121W": -121.0,
"119W": -119.0, "118.7W": -118.7, "116.8W": -116.8, "114.9W": -114.9,
"113W": -113.0, "111.1W": -111.1, "110W": -110.0, "107.3W": -107.3,
"105W": -105.0, "103W": -103.0, "101W": -101.0, "99W": -99.0,
"97W": -97.0, "95W": -95.0, "93W": -93.0, "91W": -91.0,
"89W": -89.0, "87W": -87.0, "85W": -85.0, "83W": -83.0,
"82W": -82.0, "79W": -79.0, "77W": -77.0, "75W": -75.0,
"72.7W": -72.7, "70W": -70.0, "67W": -67.0, "65W": -65.0,
"63W": -63.0, "61.5W": -61.5, "58W": -58.0, "55.5W": -55.5,
}
ARC_SURVEY_DIR = CATALOG_DIR.parent / "arc-surveys"
class ArcSurvey:
"""Multi-position orbital arc survey with persistence and resume."""
def __init__(self, sw: SkyWalker1, observer_lon: float,
observer_lat: float = 0.0, settle_time: float = 15.0):
self.sw = sw
self.observer_lon = observer_lon
self.observer_lat = observer_lat
self.settle_time = settle_time
def survey_slot(self, name: str, sat_lon: float,
coarse_step: float = 5.0,
band: str = "", pol: str = "",
callback=None) -> CarrierCatalog:
"""Survey a single orbital slot: move dish, wait, run survey."""
# Calculate motor angle
angle = usals_angle(self.observer_lon, sat_lon, self.observer_lat)
direction = "west" if angle < 0 else "east"
if callback:
callback("moving", 0,
f"Moving to {name} ({sat_lon:.1f}), "
f"angle {abs(angle):.1f} deg {direction}")
# Command the motor
self.sw.motor_goto_x(self.observer_lon, sat_lon)
# Wait for motor to settle (larger angles need more time)
settle = max(self.settle_time, abs(angle) * 0.3)
if callback:
callback("settling", 20, f"Settling {settle:.0f}s...")
time.sleep(settle)
# Verify we have signal (check AGC for any RF energy)
sig = self.sw.signal_monitor()
if callback:
callback("signal_check", 30,
f"AGC1={sig['agc1']}, power={sig['power_db']:.1f} dB")
# Run the six-stage survey
def survey_cb(stage, pct, msg):
overall_pct = 30 + int(pct * 0.7)
if callback:
callback(stage, overall_pct, msg)
engine = SurveyEngine(self.sw, callback=survey_cb)
catalog = engine.run_full_scan(
coarse_step=coarse_step,
ts_capture_secs=2.0,
)
catalog.name = f"{name} ({sat_lon:.1f})"
catalog.band = band
catalog.pol = pol
catalog.notes = (f"Arc survey position: {name}, "
f"observer: {self.observer_lon:.2f} lon, "
f"motor angle: {angle:.2f} deg")
if callback:
callback("complete", 100,
f"{name}: {len(catalog.carriers)} carriers, "
f"{sum(1 for c in catalog.carriers if c.locked)} locked")
return catalog
def run_arc(self, slots: list[tuple[str, float]],
coarse_step: float = 5.0,
band: str = "", pol: str = "",
save_individual: bool = True,
resume_state: dict | None = None) -> dict:
"""Survey an entire arc of orbital slots.
slots: list of (name, sat_lon) tuples
resume_state: previous arc survey state dict for resuming
Returns a complete arc survey result dict.
"""
ARC_SURVEY_DIR.mkdir(parents=True, exist_ok=True)
date_str = datetime.now().strftime("%Y-%m-%d")
# Initialize or resume state
if resume_state:
state = resume_state
completed_names = set(state.get("completed_slots", {}).keys())
else:
state = {
"started": datetime.now(timezone.utc).isoformat(),
"observer_lon": self.observer_lon,
"observer_lat": self.observer_lat,
"total_slots": len(slots),
"completed_slots": {},
"skipped_slots": {},
"summary": {
"total_carriers": 0,
"total_locked": 0,
"total_services": 0,
},
}
completed_names = set()
state_path = ARC_SURVEY_DIR / f"arc-survey-{date_str}.json"
for i, (name, sat_lon) in enumerate(slots):
if name in completed_names:
print(f" [{i+1}/{len(slots)}] Skipping {name} (already surveyed)")
continue
print(f"\n [{i+1}/{len(slots)}] Surveying {name} ({sat_lon:.1f} lon)")
def progress_cb(stage, pct, msg):
print(f" [{pct:3d}%] {stage}: {msg}")
try:
catalog = self.survey_slot(
name, sat_lon,
coarse_step=coarse_step,
band=band, pol=pol,
callback=progress_cb,
)
# Save individual catalog
if save_individual:
slot_filename = f"arc-{date_str}-{name.replace('.', '_')}.json"
cat_path = catalog.save(slot_filename)
print(f" Saved: {cat_path}")
# Update state
carrier_count = len(catalog.carriers)
locked_count = sum(1 for c in catalog.carriers if c.locked)
service_count = sum(len(c.services) for c in catalog.carriers)
state["completed_slots"][name] = {
"sat_lon": sat_lon,
"completed": datetime.now(timezone.utc).isoformat(),
"carriers": carrier_count,
"locked": locked_count,
"services": service_count,
"catalog_file": slot_filename if save_individual else None,
}
state["summary"]["total_carriers"] += carrier_count
state["summary"]["total_locked"] += locked_count
state["summary"]["total_services"] += service_count
except KeyboardInterrupt:
print(f"\n Survey interrupted at {name}")
try:
self.sw.motor_halt()
except Exception:
pass
state["interrupted_at"] = name
_save_state(state, state_path)
print(f" Motor halted. Progress saved to {state_path}")
print(f" Resume with: python arc_survey.py --resume {state_path}")
return state
except Exception as e:
print(f" Error at {name}: {e}")
try:
self.sw.motor_halt()
except Exception:
pass
state["skipped_slots"][name] = {
"sat_lon": sat_lon,
"error": str(e),
}
# Save state after each slot for resume capability
_save_state(state, state_path)
# Final summary
state["completed"] = datetime.now(timezone.utc).isoformat()
_save_state(state, state_path)
return state
def _save_state(state: dict, path: Path) -> None:
"""Save arc survey state to JSON."""
with open(path, 'w') as f:
json.dump(state, f, indent=2)
def parse_slot_string(slot_str: str) -> list[tuple[str, float]]:
"""Parse a comma-separated slot string like '97W,99W,101W'.
Accepts formats: '97W', '97.5W', '3E', '-97', '-97.5'
"""
slots = []
for part in slot_str.split(','):
part = part.strip()
if not part:
continue
if part in NA_ORBITAL_SLOTS:
slots.append((part, NA_ORBITAL_SLOTS[part]))
elif part.upper().endswith('W'):
lon = -float(part[:-1])
slots.append((part.upper(), lon))
elif part.upper().endswith('E'):
lon = float(part[:-1])
slots.append((part.upper(), lon))
else:
lon = float(part)
name = f"{abs(lon):.1f}{'W' if lon < 0 else 'E'}"
slots.append((name, lon))
return slots
def generate_arc_range(start_lon: float, stop_lon: float,
step: float) -> list[tuple[str, float]]:
"""Generate orbital slots at regular intervals across an arc."""
slots = []
lon = start_lon
while lon <= stop_lon:
name = f"{abs(lon):.1f}{'W' if lon < 0 else 'E'}"
slots.append((name, lon))
lon += step
return slots
def print_summary(state: dict) -> None:
"""Print a human-readable arc survey summary."""
print(f"\n Arc Survey Summary")
print(f" ==================")
print(f" Observer: {state['observer_lon']:.2f} lon")
print(f" Slots surveyed: {len(state['completed_slots'])} / {state['total_slots']}")
print(f" Total carriers: {state['summary']['total_carriers']}")
print(f" Total locked: {state['summary']['total_locked']}")
print(f" Total services: {state['summary']['total_services']}")
if state.get("skipped_slots"):
print(f" Skipped: {len(state['skipped_slots'])}")
print(f"\n Per-slot results:")
for name, info in sorted(state["completed_slots"].items(),
key=lambda x: x[1]["sat_lon"]):
lock_str = f"{info['locked']}/{info['carriers']}"
svc_str = f"{info['services']} svc" if info['services'] else ""
print(f" {name:>8s} ({info['sat_lon']:+7.1f}): "
f"{lock_str:>7s} locked {svc_str}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="arc_survey.py",
description="Multi-satellite arc survey for SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
# Survey specific slots (North American arc)
%(prog)s --observer-lon -96.8 --slots "97W,99W,101W,103W"
# Survey an arc range at 3-degree intervals
%(prog)s --observer-lon -96.8 --arc -120 -60 --step 3
# Load slots from a JSON file
%(prog)s --observer-lon -96.8 --file my-slots.json
# Resume an interrupted survey
%(prog)s --resume ~/.skywalker1/arc-surveys/arc-survey-2026-02-17.json
# List common North American orbital slots
%(prog)s --list-slots
slot file format (JSON):
[
{"name": "97W", "lon": -97.0},
{"name": "99W", "lon": -99.0}
]
notes:
- Motor settle time scales with angle (min 15s, + 0.3s per degree)
- Each slot takes 5-15 minutes depending on carrier density
- Progress is saved after each slot; Ctrl-C to pause safely
- Individual catalogs saved to ~/.skywalker1/surveys/
- Arc survey state saved to ~/.skywalker1/arc-surveys/
""",
)
parser.add_argument('-v', '--verbose', action='store_true')
parser.add_argument('--observer-lon', type=float,
help="Observer longitude (negative=west, e.g. -96.8)")
parser.add_argument('--observer-lat', type=float, default=0.0,
help="Observer latitude (default: 0.0)")
source = parser.add_mutually_exclusive_group()
source.add_argument('--slots', type=str,
help="Comma-separated slot list (e.g. '97W,99W,101W')")
source.add_argument('--file', type=str,
help="JSON file with slot definitions")
source.add_argument('--arc', nargs=2, type=float, metavar=('START', 'STOP'),
help="Arc range in degrees longitude")
source.add_argument('--resume', type=str,
help="Resume from a saved arc survey state file")
source.add_argument('--list-slots', action='store_true',
help="List common NA orbital slots and exit")
parser.add_argument('--step', type=float, default=3.0,
help="Step size for --arc mode (default: 3.0 degrees)")
parser.add_argument('--coarse-step', type=float, default=5.0,
help="Coarse sweep step in MHz (default: 5.0)")
parser.add_argument('--settle-time', type=float, default=15.0,
help="Minimum motor settle time in seconds (default: 15)")
parser.add_argument('--pol', type=str, default="",
help="Polarization label (H/V, for catalog metadata)")
parser.add_argument('--band', type=str, default="",
help="Band label (low/high, for catalog metadata)")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if args.list_slots:
print("Common North American GEO orbital slots:")
for name in sorted(NA_ORBITAL_SLOTS, key=lambda n: NA_ORBITAL_SLOTS[n]):
lon = NA_ORBITAL_SLOTS[name]
print(f" {name:>8s} {lon:+7.1f}")
return
# Determine slot list
resume_state = None
if args.resume:
with open(args.resume) as f:
resume_state = json.load(f)
observer_lon = resume_state["observer_lon"]
observer_lat = resume_state.get("observer_lat", 0.0)
# Reconstruct slots from state
all_slot_names = (
list(resume_state.get("completed_slots", {}).keys()) +
list(resume_state.get("skipped_slots", {}).keys())
)
# We need the original slot list — reconstruct from completed + remaining
slots = []
for name, info in resume_state.get("completed_slots", {}).items():
slots.append((name, info["sat_lon"]))
for name, info in resume_state.get("skipped_slots", {}).items():
slots.append((name, info["sat_lon"]))
# Sort by longitude
slots.sort(key=lambda x: x[1])
print(f"Resuming arc survey: {len(resume_state.get('completed_slots', {}))} "
f"of {len(slots)} slots completed")
else:
if not args.observer_lon and args.observer_lon != 0:
parser.error("--observer-lon is required (or use --resume)")
observer_lon = args.observer_lon
observer_lat = args.observer_lat
if args.slots:
slots = parse_slot_string(args.slots)
elif args.file:
with open(args.file) as f:
data = json.load(f)
slots = [(d["name"], d["lon"]) for d in data]
elif args.arc:
start, stop = sorted(args.arc)
slots = generate_arc_range(start, stop, args.step)
else:
parser.error("Specify --slots, --file, --arc, or --resume")
if not slots:
print("No orbital slots to survey", file=sys.stderr)
sys.exit(1)
print(f"Arc Survey")
print(f" Observer: {observer_lon:.2f} lon, {observer_lat:.2f} lat")
print(f" Orbital slots: {len(slots)}")
for name, lon in slots:
angle = usals_angle(observer_lon, lon, observer_lat)
direction = "W" if angle < 0 else "E"
print(f" {name:>8s} {lon:+7.1f} (motor: {abs(angle):.1f} deg {direction})")
print()
with SkyWalker1(verbose=args.verbose) as sw:
sw.ensure_booted()
survey = ArcSurvey(
sw, observer_lon, observer_lat,
settle_time=args.settle_time,
)
state = survey.run_arc(
slots,
coarse_step=args.coarse_step,
band=args.band, pol=args.pol,
resume_state=resume_state,
)
print_summary(state)
if __name__ == "__main__":
main()

View file

@ -1,376 +1,376 @@
#!/usr/bin/env python3
"""
Long-term satellite beacon logger for the Genpix SkyWalker-1.
Locks onto a stable Ku-band transponder and logs SNR/AGC at configurable
intervals for hours, days, or weeks. Produces propagation datasets useful
for rain fade analysis, diurnal thermal drift measurement, antenna mount
stability assessment, and ITU propagation model validation.
Usage:
python beacon_logger.py --freq 12015 --sr 20000 # log to stdout
python beacon_logger.py --freq 12015 --sr 20000 -o log.csv # log to CSV
python beacon_logger.py --freq 12015 --sr 20000 --daemon # background mode
python beacon_logger.py --generate-systemd # print unit file
The tool automatically re-locks on signal loss and logs statistics per
reporting interval (min/max/mean/stddev of SNR over each window).
"""
import sys
import os
import argparse
import time
import csv
import math
import json
import signal
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1, MODULATIONS, MOD_FEC_GROUP, FEC_RATES
def compute_stats(values: list[float]) -> dict:
"""Compute min/max/mean/stddev for a list of measurements."""
if not values:
return {"min": 0, "max": 0, "mean": 0, "stddev": 0, "count": 0}
n = len(values)
mean = sum(values) / n
variance = sum((v - mean) ** 2 for v in values) / n if n > 1 else 0
return {
"min": round(min(values), 3),
"max": round(max(values), 3),
"mean": round(mean, 3),
"stddev": round(math.sqrt(variance), 3),
"count": n,
}
class BeaconLogger:
"""Persistent signal logger with auto-relock and statistics."""
def __init__(self, sw: SkyWalker1, freq_khz: int, sr_sps: int,
mod_index: int = 0, fec_index: int = 5,
sample_interval: float = 1.0, report_interval: float = 60.0):
self.sw = sw
self.freq_khz = freq_khz
self.sr_sps = sr_sps
self.mod_index = mod_index
self.fec_index = fec_index
self.sample_interval = sample_interval
self.report_interval = report_interval
self._running = False
self._relock_count = 0
self._total_samples = 0
def tune_and_lock(self) -> bool:
"""Tune to the beacon frequency and check for lock."""
self.sw.tune(self.sr_sps, self.freq_khz, self.mod_index, self.fec_index)
time.sleep(0.5)
sig = self.sw.signal_monitor()
return sig.get("locked", False)
def run(self, duration_secs: float, csv_path: str | None = None,
json_path: str | None = None, quiet: bool = False) -> None:
"""Main logging loop.
Samples signal at sample_interval, computes statistics over
report_interval, outputs to CSV/JSON/stdout.
"""
self._running = True
# Register signal handlers for clean shutdown
def _stop(signum, frame):
self._running = False
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
# Initial tune
locked = self.tune_and_lock()
if not locked:
print(f"Warning: no lock at {self.freq_khz} kHz, will keep trying",
file=sys.stderr)
# Open CSV
csv_file = None
csv_writer = None
if csv_path:
csv_file = open(csv_path, 'w', newline='')
csv_writer = csv.writer(csv_file)
csv_writer.writerow([
"timestamp", "elapsed_s", "snr_db", "agc1", "agc2",
"power_db", "locked", "relock_count",
])
# Open JSON log (append mode, one JSON object per report line)
json_file = None
if json_path:
json_file = open(json_path, 'a')
start_time = time.time()
last_report = start_time
window_snr = []
window_power = []
window_agc1 = []
lock_count_window = 0
sample_count_window = 0
try:
while self._running and (time.time() - start_time) < duration_secs:
now = time.time()
elapsed = now - start_time
# Sample
try:
sig = self.sw.signal_monitor()
except Exception as e:
if not quiet:
print(f" USB error: {e}", file=sys.stderr)
time.sleep(self.sample_interval)
continue
self._total_samples += 1
sample_count_window += 1
snr_db = sig["snr_db"]
agc1 = sig["agc1"]
agc2 = sig["agc2"]
power_db = sig["power_db"]
locked = sig["locked"]
if locked:
lock_count_window += 1
window_snr.append(snr_db)
window_power.append(power_db)
window_agc1.append(agc1)
# Write raw sample to CSV
if csv_writer:
csv_writer.writerow([
datetime.now(timezone.utc).isoformat(),
f"{elapsed:.1f}",
f"{snr_db:.3f}",
agc1, agc2,
f"{power_db:.3f}",
int(locked),
self._relock_count,
])
csv_file.flush()
# Auto-relock
if not locked:
if not quiet:
print(f" [{elapsed:.0f}s] Signal lost, attempting relock...",
file=sys.stderr)
if self.tune_and_lock():
self._relock_count += 1
if not quiet:
print(f" [{elapsed:.0f}s] Relocked (count: {self._relock_count})",
file=sys.stderr)
# Periodic report
if now - last_report >= self.report_interval:
report = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"elapsed_s": round(elapsed, 1),
"samples": sample_count_window,
"lock_pct": round(100 * lock_count_window / max(sample_count_window, 1), 1),
"snr": compute_stats(window_snr),
"power": compute_stats(window_power),
"agc1": compute_stats(window_agc1),
"relock_count": self._relock_count,
}
if not quiet:
snr_s = report["snr"]
print(f" [{elapsed:7.0f}s] SNR {snr_s['mean']:5.1f} dB "
f"(min {snr_s['min']:.1f}, max {snr_s['max']:.1f}, "
f"std {snr_s['stddev']:.2f}) "
f"lock {report['lock_pct']:.0f}% "
f"relocks {self._relock_count}")
if json_file:
json_file.write(json.dumps(report) + "\n")
json_file.flush()
# Reset window
window_snr.clear()
window_power.clear()
window_agc1.clear()
lock_count_window = 0
sample_count_window = 0
last_report = now
time.sleep(self.sample_interval)
finally:
if csv_file:
csv_file.close()
if json_file:
json_file.close()
total_elapsed = time.time() - start_time
if not quiet:
print(f"\n Session complete: {self._total_samples} samples in "
f"{total_elapsed:.0f}s, {self._relock_count} relocks")
def generate_systemd_unit(args) -> str:
"""Generate a systemd unit file for daemon operation."""
cmd_parts = ["python3", os.path.abspath(__file__)]
cmd_parts.extend(["--freq", str(args.freq)])
cmd_parts.extend(["--sr", str(args.sr)])
if args.output:
cmd_parts.extend(["--output", os.path.abspath(args.output)])
if args.json_output:
cmd_parts.extend(["--json-output", os.path.abspath(args.json_output)])
cmd_parts.extend(["--duration", str(args.duration)])
cmd_parts.extend(["--sample-interval", str(args.sample_interval)])
cmd_parts.extend(["--report-interval", str(args.report_interval)])
cmd_parts.append("--quiet")
return f"""[Unit]
Description=SkyWalker-1 Beacon Logger ({args.freq} kHz)
After=network.target
[Service]
Type=simple
ExecStart={' '.join(cmd_parts)}
Restart=on-failure
RestartSec=30
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
"""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="beacon_logger.py",
description="Long-term satellite beacon logger for SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s --freq 12015 --sr 20000 # Ku-band beacon, stdout
%(prog)s --freq 12015 --sr 20000 -o beacon.csv # log to CSV
%(prog)s --freq 12015 --sr 20000 --json-output beacon.jsonl # per-minute JSON
%(prog)s --freq 12015 --sr 20000 --duration 86400 # 24-hour log
%(prog)s --freq 12015 --sr 20000 --daemon # background
%(prog)s --generate-systemd --freq 12015 --sr 20000 # print unit file
The --freq is in kHz (IF frequency), not MHz. For Ku-band with a universal
LNB at LO 10750 MHz, a transponder at 12015 MHz has IF = 12015 - 10750 = 1265 MHz,
so you'd use --freq 1265000.
For IF frequencies, multiply MHz by 1000 (e.g., 1265 MHz = 1265000 kHz).
""",
)
parser.add_argument('-v', '--verbose', action='store_true')
parser.add_argument('--freq', type=int, required=True,
help="IF frequency in kHz (e.g., 1265000 for 1265 MHz)")
parser.add_argument('--sr', type=int, default=20000000,
help="Symbol rate in sps (default: 20000000)")
parser.add_argument('--mod', type=str, default="qpsk",
help="Modulation type (default: qpsk)")
parser.add_argument('--fec', type=str, default="auto",
help="FEC rate (default: auto)")
parser.add_argument('--output', '-o', type=str, default=None,
help="CSV output file (raw samples)")
parser.add_argument('--json-output', type=str, default=None,
help="JSONL output file (per-interval statistics)")
parser.add_argument('--duration', type=float, default=3600,
help="Logging duration in seconds (default: 3600)")
parser.add_argument('--sample-interval', type=float, default=1.0,
help="Seconds between samples (default: 1.0)")
parser.add_argument('--report-interval', type=float, default=60.0,
help="Seconds between summary reports (default: 60)")
parser.add_argument('--pol', type=str, default=None, choices=['H', 'V'],
help="LNB polarization (H=18V, V=13V)")
parser.add_argument('--band', type=str, default=None, choices=['low', 'high'],
help="LNB band (low=no tone, high=22kHz)")
parser.add_argument('--daemon', action='store_true',
help="Run as daemon (suppress stdout)")
parser.add_argument('--quiet', action='store_true',
help="Suppress progress output to stderr")
parser.add_argument('--generate-systemd', action='store_true',
help="Print a systemd unit file and exit")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if args.generate_systemd:
print(generate_systemd_unit(args))
return
# Resolve modulation/FEC indices
mod_entry = MODULATIONS.get(args.mod)
if mod_entry is None:
print(f"Unknown modulation '{args.mod}'. Valid: {list(MODULATIONS.keys())}",
file=sys.stderr)
sys.exit(1)
mod_idx = mod_entry[0]
fec_group = MOD_FEC_GROUP.get(args.mod, "dvbs")
fec_table = FEC_RATES.get(fec_group, {})
fec_idx = fec_table.get(args.fec, fec_table.get("auto", 0))
quiet = args.daemon or args.quiet
with SkyWalker1(verbose=args.verbose) as sw:
sw.ensure_booted()
# Configure LNB
if args.pol:
sw.set_lnb_voltage(args.pol.upper() in ("H", "L"))
if args.band:
sw.set_22khz_tone(args.band == "high")
freq_mhz = args.freq / 1000.0
sr_msps = args.sr / 1e6
if not quiet:
print(f"Beacon Logger")
print(f" Frequency: {freq_mhz:.3f} MHz IF ({args.freq} kHz)")
print(f" Symbol rate: {sr_msps:.3f} Msps")
print(f" Modulation: {args.mod}, FEC: {args.fec}")
print(f" Sample interval: {args.sample_interval}s")
print(f" Report interval: {args.report_interval}s")
print(f" Duration: {args.duration}s ({args.duration/3600:.1f}h)")
if args.output:
print(f" CSV output: {args.output}")
if args.json_output:
print(f" JSON output: {args.json_output}")
print()
logger = BeaconLogger(
sw, args.freq, args.sr,
mod_index=mod_idx, fec_index=fec_idx,
sample_interval=args.sample_interval,
report_interval=args.report_interval,
)
logger.run(
duration_secs=args.duration,
csv_path=args.output,
json_path=args.json_output,
quiet=quiet,
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Long-term satellite beacon logger for the Genpix SkyWalker-1.
Locks onto a stable Ku-band transponder and logs SNR/AGC at configurable
intervals for hours, days, or weeks. Produces propagation datasets useful
for rain fade analysis, diurnal thermal drift measurement, antenna mount
stability assessment, and ITU propagation model validation.
Usage:
python beacon_logger.py --freq 12015 --sr 20000 # log to stdout
python beacon_logger.py --freq 12015 --sr 20000 -o log.csv # log to CSV
python beacon_logger.py --freq 12015 --sr 20000 --daemon # background mode
python beacon_logger.py --generate-systemd # print unit file
The tool automatically re-locks on signal loss and logs statistics per
reporting interval (min/max/mean/stddev of SNR over each window).
"""
import sys
import os
import argparse
import time
import csv
import math
import json
import signal
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1, MODULATIONS, MOD_FEC_GROUP, FEC_RATES
def compute_stats(values: list[float]) -> dict:
"""Compute min/max/mean/stddev for a list of measurements."""
if not values:
return {"min": 0, "max": 0, "mean": 0, "stddev": 0, "count": 0}
n = len(values)
mean = sum(values) / n
variance = sum((v - mean) ** 2 for v in values) / n if n > 1 else 0
return {
"min": round(min(values), 3),
"max": round(max(values), 3),
"mean": round(mean, 3),
"stddev": round(math.sqrt(variance), 3),
"count": n,
}
class BeaconLogger:
"""Persistent signal logger with auto-relock and statistics."""
def __init__(self, sw: SkyWalker1, freq_khz: int, sr_sps: int,
mod_index: int = 0, fec_index: int = 5,
sample_interval: float = 1.0, report_interval: float = 60.0):
self.sw = sw
self.freq_khz = freq_khz
self.sr_sps = sr_sps
self.mod_index = mod_index
self.fec_index = fec_index
self.sample_interval = sample_interval
self.report_interval = report_interval
self._running = False
self._relock_count = 0
self._total_samples = 0
def tune_and_lock(self) -> bool:
"""Tune to the beacon frequency and check for lock."""
self.sw.tune(self.sr_sps, self.freq_khz, self.mod_index, self.fec_index)
time.sleep(0.5)
sig = self.sw.signal_monitor()
return sig.get("locked", False)
def run(self, duration_secs: float, csv_path: str | None = None,
json_path: str | None = None, quiet: bool = False) -> None:
"""Main logging loop.
Samples signal at sample_interval, computes statistics over
report_interval, outputs to CSV/JSON/stdout.
"""
self._running = True
# Register signal handlers for clean shutdown
def _stop(signum, frame):
self._running = False
signal.signal(signal.SIGTERM, _stop)
signal.signal(signal.SIGINT, _stop)
# Initial tune
locked = self.tune_and_lock()
if not locked:
print(f"Warning: no lock at {self.freq_khz} kHz, will keep trying",
file=sys.stderr)
# Open CSV
csv_file = None
csv_writer = None
if csv_path:
csv_file = open(csv_path, 'w', newline='')
csv_writer = csv.writer(csv_file)
csv_writer.writerow([
"timestamp", "elapsed_s", "snr_db", "agc1", "agc2",
"power_db", "locked", "relock_count",
])
# Open JSON log (append mode, one JSON object per report line)
json_file = None
if json_path:
json_file = open(json_path, 'a')
start_time = time.time()
last_report = start_time
window_snr = []
window_power = []
window_agc1 = []
lock_count_window = 0
sample_count_window = 0
try:
while self._running and (time.time() - start_time) < duration_secs:
now = time.time()
elapsed = now - start_time
# Sample
try:
sig = self.sw.signal_monitor()
except Exception as e:
if not quiet:
print(f" USB error: {e}", file=sys.stderr)
time.sleep(self.sample_interval)
continue
self._total_samples += 1
sample_count_window += 1
snr_db = sig["snr_db"]
agc1 = sig["agc1"]
agc2 = sig["agc2"]
power_db = sig["power_db"]
locked = sig["locked"]
if locked:
lock_count_window += 1
window_snr.append(snr_db)
window_power.append(power_db)
window_agc1.append(agc1)
# Write raw sample to CSV
if csv_writer:
csv_writer.writerow([
datetime.now(timezone.utc).isoformat(),
f"{elapsed:.1f}",
f"{snr_db:.3f}",
agc1, agc2,
f"{power_db:.3f}",
int(locked),
self._relock_count,
])
csv_file.flush()
# Auto-relock
if not locked:
if not quiet:
print(f" [{elapsed:.0f}s] Signal lost, attempting relock...",
file=sys.stderr)
if self.tune_and_lock():
self._relock_count += 1
if not quiet:
print(f" [{elapsed:.0f}s] Relocked (count: {self._relock_count})",
file=sys.stderr)
# Periodic report
if now - last_report >= self.report_interval:
report = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"elapsed_s": round(elapsed, 1),
"samples": sample_count_window,
"lock_pct": round(100 * lock_count_window / max(sample_count_window, 1), 1),
"snr": compute_stats(window_snr),
"power": compute_stats(window_power),
"agc1": compute_stats(window_agc1),
"relock_count": self._relock_count,
}
if not quiet:
snr_s = report["snr"]
print(f" [{elapsed:7.0f}s] SNR {snr_s['mean']:5.1f} dB "
f"(min {snr_s['min']:.1f}, max {snr_s['max']:.1f}, "
f"std {snr_s['stddev']:.2f}) "
f"lock {report['lock_pct']:.0f}% "
f"relocks {self._relock_count}")
if json_file:
json_file.write(json.dumps(report) + "\n")
json_file.flush()
# Reset window
window_snr.clear()
window_power.clear()
window_agc1.clear()
lock_count_window = 0
sample_count_window = 0
last_report = now
time.sleep(self.sample_interval)
finally:
if csv_file:
csv_file.close()
if json_file:
json_file.close()
total_elapsed = time.time() - start_time
if not quiet:
print(f"\n Session complete: {self._total_samples} samples in "
f"{total_elapsed:.0f}s, {self._relock_count} relocks")
def generate_systemd_unit(args) -> str:
"""Generate a systemd unit file for daemon operation."""
cmd_parts = ["python3", os.path.abspath(__file__)]
cmd_parts.extend(["--freq", str(args.freq)])
cmd_parts.extend(["--sr", str(args.sr)])
if args.output:
cmd_parts.extend(["--output", os.path.abspath(args.output)])
if args.json_output:
cmd_parts.extend(["--json-output", os.path.abspath(args.json_output)])
cmd_parts.extend(["--duration", str(args.duration)])
cmd_parts.extend(["--sample-interval", str(args.sample_interval)])
cmd_parts.extend(["--report-interval", str(args.report_interval)])
cmd_parts.append("--quiet")
return f"""[Unit]
Description=SkyWalker-1 Beacon Logger ({args.freq} kHz)
After=network.target
[Service]
Type=simple
ExecStart={' '.join(cmd_parts)}
Restart=on-failure
RestartSec=30
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
"""
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="beacon_logger.py",
description="Long-term satellite beacon logger for SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s --freq 12015 --sr 20000 # Ku-band beacon, stdout
%(prog)s --freq 12015 --sr 20000 -o beacon.csv # log to CSV
%(prog)s --freq 12015 --sr 20000 --json-output beacon.jsonl # per-minute JSON
%(prog)s --freq 12015 --sr 20000 --duration 86400 # 24-hour log
%(prog)s --freq 12015 --sr 20000 --daemon # background
%(prog)s --generate-systemd --freq 12015 --sr 20000 # print unit file
The --freq is in kHz (IF frequency), not MHz. For Ku-band with a universal
LNB at LO 10750 MHz, a transponder at 12015 MHz has IF = 12015 - 10750 = 1265 MHz,
so you'd use --freq 1265000.
For IF frequencies, multiply MHz by 1000 (e.g., 1265 MHz = 1265000 kHz).
""",
)
parser.add_argument('-v', '--verbose', action='store_true')
parser.add_argument('--freq', type=int, required=True,
help="IF frequency in kHz (e.g., 1265000 for 1265 MHz)")
parser.add_argument('--sr', type=int, default=20000000,
help="Symbol rate in sps (default: 20000000)")
parser.add_argument('--mod', type=str, default="qpsk",
help="Modulation type (default: qpsk)")
parser.add_argument('--fec', type=str, default="auto",
help="FEC rate (default: auto)")
parser.add_argument('--output', '-o', type=str, default=None,
help="CSV output file (raw samples)")
parser.add_argument('--json-output', type=str, default=None,
help="JSONL output file (per-interval statistics)")
parser.add_argument('--duration', type=float, default=3600,
help="Logging duration in seconds (default: 3600)")
parser.add_argument('--sample-interval', type=float, default=1.0,
help="Seconds between samples (default: 1.0)")
parser.add_argument('--report-interval', type=float, default=60.0,
help="Seconds between summary reports (default: 60)")
parser.add_argument('--pol', type=str, default=None, choices=['H', 'V'],
help="LNB polarization (H=18V, V=13V)")
parser.add_argument('--band', type=str, default=None, choices=['low', 'high'],
help="LNB band (low=no tone, high=22kHz)")
parser.add_argument('--daemon', action='store_true',
help="Run as daemon (suppress stdout)")
parser.add_argument('--quiet', action='store_true',
help="Suppress progress output to stderr")
parser.add_argument('--generate-systemd', action='store_true',
help="Print a systemd unit file and exit")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if args.generate_systemd:
print(generate_systemd_unit(args))
return
# Resolve modulation/FEC indices
mod_entry = MODULATIONS.get(args.mod)
if mod_entry is None:
print(f"Unknown modulation '{args.mod}'. Valid: {list(MODULATIONS.keys())}",
file=sys.stderr)
sys.exit(1)
mod_idx = mod_entry[0]
fec_group = MOD_FEC_GROUP.get(args.mod, "dvbs")
fec_table = FEC_RATES.get(fec_group, {})
fec_idx = fec_table.get(args.fec, fec_table.get("auto", 0))
quiet = args.daemon or args.quiet
with SkyWalker1(verbose=args.verbose) as sw:
sw.ensure_booted()
# Configure LNB
if args.pol:
sw.set_lnb_voltage(args.pol.upper() in ("H", "L"))
if args.band:
sw.set_22khz_tone(args.band == "high")
freq_mhz = args.freq / 1000.0
sr_msps = args.sr / 1e6
if not quiet:
print(f"Beacon Logger")
print(f" Frequency: {freq_mhz:.3f} MHz IF ({args.freq} kHz)")
print(f" Symbol rate: {sr_msps:.3f} Msps")
print(f" Modulation: {args.mod}, FEC: {args.fec}")
print(f" Sample interval: {args.sample_interval}s")
print(f" Report interval: {args.report_interval}s")
print(f" Duration: {args.duration}s ({args.duration/3600:.1f}h)")
if args.output:
print(f" CSV output: {args.output}")
if args.json_output:
print(f" JSON output: {args.json_output}")
print()
logger = BeaconLogger(
sw, args.freq, args.sr,
mod_index=mod_idx, fec_index=fec_idx,
sample_interval=args.sample_interval,
report_interval=args.report_interval,
)
logger.run(
duration_secs=args.duration,
csv_path=args.output,
json_path=args.json_output,
quiet=quiet,
)
if __name__ == "__main__":
main()

View file

@ -1,377 +1,377 @@
#!/usr/bin/env python3
"""
Carrier catalog: persistent JSON storage for survey results.
Stores detected carriers with their parameters, services, and timestamps
in ~/.skywalker1/surveys/ for historical comparison and diff reporting.
"""
import json
import os
from datetime import datetime, timezone
from pathlib import Path
CATALOG_DIR = Path.home() / ".skywalker1" / "surveys"
class CarrierEntry:
"""Single carrier identification from a survey."""
def __init__(self, freq_khz: int = 0, sr_sps: int = 0,
modulation: str = "", fec: str = "",
power_db: float = 0.0, snr_db: float = 0.0,
locked: bool = False, services: list = None,
first_seen: str = None, last_seen: str = None,
scan_count: int = 1, bw_mhz: float = 0.0,
classification: dict = None):
self.freq_khz = freq_khz
self.sr_sps = sr_sps
self.modulation = modulation
self.fec = fec
self.power_db = power_db
self.snr_db = snr_db
self.locked = locked
self.services = services or []
now = datetime.now(timezone.utc).isoformat()
self.first_seen = first_seen or now
self.last_seen = last_seen or now
self.scan_count = scan_count
self.bw_mhz = bw_mhz
self.classification = classification or {}
def to_dict(self) -> dict:
return {
"freq_khz": self.freq_khz,
"sr_sps": self.sr_sps,
"modulation": self.modulation,
"fec": self.fec,
"power_db": self.power_db,
"snr_db": self.snr_db,
"locked": self.locked,
"services": self.services,
"first_seen": self.first_seen,
"last_seen": self.last_seen,
"scan_count": self.scan_count,
"bw_mhz": self.bw_mhz,
"classification": self.classification,
}
@classmethod
def from_dict(cls, d: dict) -> "CarrierEntry":
return cls(
freq_khz=d.get("freq_khz", 0),
sr_sps=d.get("sr_sps", 0),
modulation=d.get("modulation", ""),
fec=d.get("fec", ""),
power_db=d.get("power_db", 0.0),
snr_db=d.get("snr_db", 0.0),
locked=d.get("locked", False),
services=d.get("services", []),
first_seen=d.get("first_seen"),
last_seen=d.get("last_seen"),
scan_count=d.get("scan_count", 1),
bw_mhz=d.get("bw_mhz", 0.0),
classification=d.get("classification", {}),
)
@property
def freq_mhz(self) -> float:
return self.freq_khz / 1000.0
@property
def sr_ksps(self) -> float:
return self.sr_sps / 1000.0
def key(self) -> str:
"""Unique key for diffing: frequency rounded to nearest 500 kHz."""
rounded = round(self.freq_khz / 500) * 500
return str(rounded)
def summary(self) -> str:
"""One-line human-readable summary."""
lock_str = "LOCKED" if self.locked else "no lock"
sr_str = f"{self.sr_sps / 1e6:.3f} Msps" if self.sr_sps else "SR unknown"
mod_str = self.modulation if self.modulation else "mod unknown"
svc_str = f", {len(self.services)} svc" if self.services else ""
return (f"{self.freq_mhz:.1f} MHz {self.power_db:+.1f} dB "
f"{sr_str} {mod_str} {lock_str}{svc_str}")
def __repr__(self):
return f"<CarrierEntry {self.freq_mhz:.1f} MHz {self.sr_sps} sps>"
class CarrierCatalog:
"""Collection of carriers from a survey."""
def __init__(self, name: str = "", band: str = "", pol: str = "",
lnb_lo_mhz: float = 0.0, notes: str = ""):
self.name = name
self.band = band
self.pol = pol
self.lnb_lo_mhz = lnb_lo_mhz
self.notes = notes
self.created = datetime.now(timezone.utc).isoformat()
self.carriers: list[CarrierEntry] = []
self.sweep_params: dict = {}
def add_carrier(self, entry: CarrierEntry) -> None:
"""Add a carrier entry, merging with existing if frequency matches."""
for existing in self.carriers:
if existing.key() == entry.key():
# Update existing entry
existing.last_seen = entry.last_seen
existing.scan_count += 1
existing.power_db = entry.power_db
existing.snr_db = entry.snr_db
existing.locked = entry.locked
if entry.sr_sps:
existing.sr_sps = entry.sr_sps
if entry.modulation:
existing.modulation = entry.modulation
if entry.fec:
existing.fec = entry.fec
if entry.services:
existing.services = entry.services
if entry.bw_mhz:
existing.bw_mhz = entry.bw_mhz
if entry.classification:
existing.classification = entry.classification
return
self.carriers.append(entry)
def to_dict(self) -> dict:
return {
"name": self.name,
"band": self.band,
"pol": self.pol,
"lnb_lo_mhz": self.lnb_lo_mhz,
"notes": self.notes,
"created": self.created,
"sweep_params": self.sweep_params,
"carrier_count": len(self.carriers),
"locked_count": sum(1 for c in self.carriers if c.locked),
"carriers": [c.to_dict() for c in self.carriers],
}
@classmethod
def from_dict(cls, d: dict) -> "CarrierCatalog":
cat = cls(
name=d.get("name", ""),
band=d.get("band", ""),
pol=d.get("pol", ""),
lnb_lo_mhz=d.get("lnb_lo_mhz", 0.0),
notes=d.get("notes", ""),
)
cat.created = d.get("created", cat.created)
cat.sweep_params = d.get("sweep_params", {})
for cd in d.get("carriers", []):
cat.carriers.append(CarrierEntry.from_dict(cd))
return cat
def save(self, filename: str = None) -> Path:
"""
Save catalog to JSON in CATALOG_DIR.
If filename is not given, generates one from date/band/pol:
survey-YYYY-MM-DD-{band}-{pol}.json
"""
CATALOG_DIR.mkdir(parents=True, exist_ok=True)
if filename is None:
date_str = datetime.now().strftime("%Y-%m-%d")
parts = ["survey", date_str]
if self.band:
parts.append(self.band)
if self.pol:
parts.append(self.pol)
filename = "-".join(parts) + ".json"
path = CATALOG_DIR / filename
with open(path, 'w') as f:
json.dump(self.to_dict(), f, indent=2)
return path
@classmethod
def load(cls, filename: str) -> "CarrierCatalog":
"""Load a catalog from JSON. Accepts filename or full path."""
path = Path(filename)
if not path.is_absolute():
path = CATALOG_DIR / filename
with open(path) as f:
data = json.load(f)
return cls.from_dict(data)
@classmethod
def list_surveys(cls) -> list:
"""List saved survey files in CATALOG_DIR, newest first."""
if not CATALOG_DIR.exists():
return []
files = sorted(CATALOG_DIR.glob("survey-*.json"), reverse=True)
results = []
for f in files:
try:
with open(f) as fh:
data = json.load(fh)
results.append({
"filename": f.name,
"path": str(f),
"created": data.get("created", ""),
"carrier_count": data.get("carrier_count", 0),
"locked_count": data.get("locked_count", 0),
"band": data.get("band", ""),
"pol": data.get("pol", ""),
})
except (json.JSONDecodeError, OSError):
results.append({
"filename": f.name,
"path": str(f),
"created": "",
"carrier_count": -1,
"locked_count": -1,
"band": "",
"pol": "",
})
return results
def summary(self) -> str:
"""Multi-line text summary of the catalog."""
lines = []
lines.append(f"Survey: {self.name or '(unnamed)'}")
lines.append(f"Created: {self.created}")
if self.band or self.pol:
lines.append(f"Band: {self.band} Pol: {self.pol}")
if self.lnb_lo_mhz:
lines.append(f"LNB LO: {self.lnb_lo_mhz} MHz")
lines.append(f"Carriers: {len(self.carriers)} total, "
f"{sum(1 for c in self.carriers if c.locked)} locked")
lines.append("")
for i, c in enumerate(sorted(self.carriers, key=lambda x: x.freq_khz), 1):
lines.append(f" {i:3d}. {c.summary()}")
return "\n".join(lines)
class CatalogDiff:
"""Compare two catalog snapshots to find changes."""
@staticmethod
def diff(old_catalog: CarrierCatalog,
new_catalog: CarrierCatalog) -> dict:
"""
Compare old and new catalogs.
Returns dict with:
new - carriers in new but not old
missing - carriers in old but not new
changed - carriers at same freq but different SR/power/services
stable - carriers unchanged between scans
"""
old_map = {c.key(): c for c in old_catalog.carriers}
new_map = {c.key(): c for c in new_catalog.carriers}
old_keys = set(old_map.keys())
new_keys = set(new_map.keys())
result = {
"new": [],
"missing": [],
"changed": [],
"stable": [],
}
# New carriers
for key in sorted(new_keys - old_keys):
result["new"].append(new_map[key].to_dict())
# Missing carriers
for key in sorted(old_keys - new_keys):
result["missing"].append(old_map[key].to_dict())
# Compare common carriers
for key in sorted(old_keys & new_keys):
old_c = old_map[key]
new_c = new_map[key]
changes = _find_changes(old_c, new_c)
if changes:
result["changed"].append({
"carrier": new_c.to_dict(),
"previous": old_c.to_dict(),
"changes": changes,
})
else:
result["stable"].append(new_c.to_dict())
return result
@staticmethod
def format_diff(diff_result: dict) -> str:
"""Format a diff result as human-readable text."""
lines = []
if diff_result["new"]:
lines.append(f"NEW CARRIERS ({len(diff_result['new'])}):")
for c in diff_result["new"]:
entry = CarrierEntry.from_dict(c)
lines.append(f" + {entry.summary()}")
lines.append("")
if diff_result["missing"]:
lines.append(f"MISSING CARRIERS ({len(diff_result['missing'])}):")
for c in diff_result["missing"]:
entry = CarrierEntry.from_dict(c)
lines.append(f" - {entry.summary()}")
lines.append("")
if diff_result["changed"]:
lines.append(f"CHANGED CARRIERS ({len(diff_result['changed'])}):")
for item in diff_result["changed"]:
entry = CarrierEntry.from_dict(item["carrier"])
lines.append(f" ~ {entry.summary()}")
for change in item["changes"]:
lines.append(f" {change}")
lines.append("")
stable_count = len(diff_result["stable"])
lines.append(f"STABLE: {stable_count} carrier(s) unchanged")
return "\n".join(lines)
def _find_changes(old: CarrierEntry, new: CarrierEntry) -> list:
"""Compare two carriers at the same frequency, return list of change descriptions."""
changes = []
# Frequency drift (within the 500 kHz key bucket)
if abs(old.freq_khz - new.freq_khz) > 100:
changes.append(f"freq: {old.freq_khz} -> {new.freq_khz} kHz")
# Symbol rate change
if old.sr_sps and new.sr_sps and old.sr_sps != new.sr_sps:
changes.append(f"SR: {old.sr_sps} -> {new.sr_sps} sps")
# Power change (>2 dB is significant)
if abs(old.power_db - new.power_db) > 2.0:
changes.append(f"power: {old.power_db:+.1f} -> {new.power_db:+.1f} dB")
# Lock state change
if old.locked != new.locked:
changes.append(f"lock: {old.locked} -> {new.locked}")
# Modulation change
if old.modulation and new.modulation and old.modulation != new.modulation:
changes.append(f"mod: {old.modulation} -> {new.modulation}")
# Service list change
old_svcs = set(old.services)
new_svcs = set(new.services)
if old_svcs != new_svcs:
added = new_svcs - old_svcs
removed = old_svcs - new_svcs
parts = []
if added:
parts.append(f"+{list(added)}")
if removed:
parts.append(f"-{list(removed)}")
changes.append(f"services: {', '.join(parts)}")
return changes
#!/usr/bin/env python3
"""
Carrier catalog: persistent JSON storage for survey results.
Stores detected carriers with their parameters, services, and timestamps
in ~/.skywalker1/surveys/ for historical comparison and diff reporting.
"""
import json
import os
from datetime import datetime, timezone
from pathlib import Path
CATALOG_DIR = Path.home() / ".skywalker1" / "surveys"
class CarrierEntry:
"""Single carrier identification from a survey."""
def __init__(self, freq_khz: int = 0, sr_sps: int = 0,
modulation: str = "", fec: str = "",
power_db: float = 0.0, snr_db: float = 0.0,
locked: bool = False, services: list = None,
first_seen: str = None, last_seen: str = None,
scan_count: int = 1, bw_mhz: float = 0.0,
classification: dict = None):
self.freq_khz = freq_khz
self.sr_sps = sr_sps
self.modulation = modulation
self.fec = fec
self.power_db = power_db
self.snr_db = snr_db
self.locked = locked
self.services = services or []
now = datetime.now(timezone.utc).isoformat()
self.first_seen = first_seen or now
self.last_seen = last_seen or now
self.scan_count = scan_count
self.bw_mhz = bw_mhz
self.classification = classification or {}
def to_dict(self) -> dict:
return {
"freq_khz": self.freq_khz,
"sr_sps": self.sr_sps,
"modulation": self.modulation,
"fec": self.fec,
"power_db": self.power_db,
"snr_db": self.snr_db,
"locked": self.locked,
"services": self.services,
"first_seen": self.first_seen,
"last_seen": self.last_seen,
"scan_count": self.scan_count,
"bw_mhz": self.bw_mhz,
"classification": self.classification,
}
@classmethod
def from_dict(cls, d: dict) -> "CarrierEntry":
return cls(
freq_khz=d.get("freq_khz", 0),
sr_sps=d.get("sr_sps", 0),
modulation=d.get("modulation", ""),
fec=d.get("fec", ""),
power_db=d.get("power_db", 0.0),
snr_db=d.get("snr_db", 0.0),
locked=d.get("locked", False),
services=d.get("services", []),
first_seen=d.get("first_seen"),
last_seen=d.get("last_seen"),
scan_count=d.get("scan_count", 1),
bw_mhz=d.get("bw_mhz", 0.0),
classification=d.get("classification", {}),
)
@property
def freq_mhz(self) -> float:
return self.freq_khz / 1000.0
@property
def sr_ksps(self) -> float:
return self.sr_sps / 1000.0
def key(self) -> str:
"""Unique key for diffing: frequency rounded to nearest 500 kHz."""
rounded = round(self.freq_khz / 500) * 500
return str(rounded)
def summary(self) -> str:
"""One-line human-readable summary."""
lock_str = "LOCKED" if self.locked else "no lock"
sr_str = f"{self.sr_sps / 1e6:.3f} Msps" if self.sr_sps else "SR unknown"
mod_str = self.modulation if self.modulation else "mod unknown"
svc_str = f", {len(self.services)} svc" if self.services else ""
return (f"{self.freq_mhz:.1f} MHz {self.power_db:+.1f} dB "
f"{sr_str} {mod_str} {lock_str}{svc_str}")
def __repr__(self):
return f"<CarrierEntry {self.freq_mhz:.1f} MHz {self.sr_sps} sps>"
class CarrierCatalog:
"""Collection of carriers from a survey."""
def __init__(self, name: str = "", band: str = "", pol: str = "",
lnb_lo_mhz: float = 0.0, notes: str = ""):
self.name = name
self.band = band
self.pol = pol
self.lnb_lo_mhz = lnb_lo_mhz
self.notes = notes
self.created = datetime.now(timezone.utc).isoformat()
self.carriers: list[CarrierEntry] = []
self.sweep_params: dict = {}
def add_carrier(self, entry: CarrierEntry) -> None:
"""Add a carrier entry, merging with existing if frequency matches."""
for existing in self.carriers:
if existing.key() == entry.key():
# Update existing entry
existing.last_seen = entry.last_seen
existing.scan_count += 1
existing.power_db = entry.power_db
existing.snr_db = entry.snr_db
existing.locked = entry.locked
if entry.sr_sps:
existing.sr_sps = entry.sr_sps
if entry.modulation:
existing.modulation = entry.modulation
if entry.fec:
existing.fec = entry.fec
if entry.services:
existing.services = entry.services
if entry.bw_mhz:
existing.bw_mhz = entry.bw_mhz
if entry.classification:
existing.classification = entry.classification
return
self.carriers.append(entry)
def to_dict(self) -> dict:
return {
"name": self.name,
"band": self.band,
"pol": self.pol,
"lnb_lo_mhz": self.lnb_lo_mhz,
"notes": self.notes,
"created": self.created,
"sweep_params": self.sweep_params,
"carrier_count": len(self.carriers),
"locked_count": sum(1 for c in self.carriers if c.locked),
"carriers": [c.to_dict() for c in self.carriers],
}
@classmethod
def from_dict(cls, d: dict) -> "CarrierCatalog":
cat = cls(
name=d.get("name", ""),
band=d.get("band", ""),
pol=d.get("pol", ""),
lnb_lo_mhz=d.get("lnb_lo_mhz", 0.0),
notes=d.get("notes", ""),
)
cat.created = d.get("created", cat.created)
cat.sweep_params = d.get("sweep_params", {})
for cd in d.get("carriers", []):
cat.carriers.append(CarrierEntry.from_dict(cd))
return cat
def save(self, filename: str = None) -> Path:
"""
Save catalog to JSON in CATALOG_DIR.
If filename is not given, generates one from date/band/pol:
survey-YYYY-MM-DD-{band}-{pol}.json
"""
CATALOG_DIR.mkdir(parents=True, exist_ok=True)
if filename is None:
date_str = datetime.now().strftime("%Y-%m-%d")
parts = ["survey", date_str]
if self.band:
parts.append(self.band)
if self.pol:
parts.append(self.pol)
filename = "-".join(parts) + ".json"
path = CATALOG_DIR / filename
with open(path, 'w') as f:
json.dump(self.to_dict(), f, indent=2)
return path
@classmethod
def load(cls, filename: str) -> "CarrierCatalog":
"""Load a catalog from JSON. Accepts filename or full path."""
path = Path(filename)
if not path.is_absolute():
path = CATALOG_DIR / filename
with open(path) as f:
data = json.load(f)
return cls.from_dict(data)
@classmethod
def list_surveys(cls) -> list:
"""List saved survey files in CATALOG_DIR, newest first."""
if not CATALOG_DIR.exists():
return []
files = sorted(CATALOG_DIR.glob("survey-*.json"), reverse=True)
results = []
for f in files:
try:
with open(f) as fh:
data = json.load(fh)
results.append({
"filename": f.name,
"path": str(f),
"created": data.get("created", ""),
"carrier_count": data.get("carrier_count", 0),
"locked_count": data.get("locked_count", 0),
"band": data.get("band", ""),
"pol": data.get("pol", ""),
})
except (json.JSONDecodeError, OSError):
results.append({
"filename": f.name,
"path": str(f),
"created": "",
"carrier_count": -1,
"locked_count": -1,
"band": "",
"pol": "",
})
return results
def summary(self) -> str:
"""Multi-line text summary of the catalog."""
lines = []
lines.append(f"Survey: {self.name or '(unnamed)'}")
lines.append(f"Created: {self.created}")
if self.band or self.pol:
lines.append(f"Band: {self.band} Pol: {self.pol}")
if self.lnb_lo_mhz:
lines.append(f"LNB LO: {self.lnb_lo_mhz} MHz")
lines.append(f"Carriers: {len(self.carriers)} total, "
f"{sum(1 for c in self.carriers if c.locked)} locked")
lines.append("")
for i, c in enumerate(sorted(self.carriers, key=lambda x: x.freq_khz), 1):
lines.append(f" {i:3d}. {c.summary()}")
return "\n".join(lines)
class CatalogDiff:
"""Compare two catalog snapshots to find changes."""
@staticmethod
def diff(old_catalog: CarrierCatalog,
new_catalog: CarrierCatalog) -> dict:
"""
Compare old and new catalogs.
Returns dict with:
new - carriers in new but not old
missing - carriers in old but not new
changed - carriers at same freq but different SR/power/services
stable - carriers unchanged between scans
"""
old_map = {c.key(): c for c in old_catalog.carriers}
new_map = {c.key(): c for c in new_catalog.carriers}
old_keys = set(old_map.keys())
new_keys = set(new_map.keys())
result = {
"new": [],
"missing": [],
"changed": [],
"stable": [],
}
# New carriers
for key in sorted(new_keys - old_keys):
result["new"].append(new_map[key].to_dict())
# Missing carriers
for key in sorted(old_keys - new_keys):
result["missing"].append(old_map[key].to_dict())
# Compare common carriers
for key in sorted(old_keys & new_keys):
old_c = old_map[key]
new_c = new_map[key]
changes = _find_changes(old_c, new_c)
if changes:
result["changed"].append({
"carrier": new_c.to_dict(),
"previous": old_c.to_dict(),
"changes": changes,
})
else:
result["stable"].append(new_c.to_dict())
return result
@staticmethod
def format_diff(diff_result: dict) -> str:
"""Format a diff result as human-readable text."""
lines = []
if diff_result["new"]:
lines.append(f"NEW CARRIERS ({len(diff_result['new'])}):")
for c in diff_result["new"]:
entry = CarrierEntry.from_dict(c)
lines.append(f" + {entry.summary()}")
lines.append("")
if diff_result["missing"]:
lines.append(f"MISSING CARRIERS ({len(diff_result['missing'])}):")
for c in diff_result["missing"]:
entry = CarrierEntry.from_dict(c)
lines.append(f" - {entry.summary()}")
lines.append("")
if diff_result["changed"]:
lines.append(f"CHANGED CARRIERS ({len(diff_result['changed'])}):")
for item in diff_result["changed"]:
entry = CarrierEntry.from_dict(item["carrier"])
lines.append(f" ~ {entry.summary()}")
for change in item["changes"]:
lines.append(f" {change}")
lines.append("")
stable_count = len(diff_result["stable"])
lines.append(f"STABLE: {stable_count} carrier(s) unchanged")
return "\n".join(lines)
def _find_changes(old: CarrierEntry, new: CarrierEntry) -> list:
"""Compare two carriers at the same frequency, return list of change descriptions."""
changes = []
# Frequency drift (within the 500 kHz key bucket)
if abs(old.freq_khz - new.freq_khz) > 100:
changes.append(f"freq: {old.freq_khz} -> {new.freq_khz} kHz")
# Symbol rate change
if old.sr_sps and new.sr_sps and old.sr_sps != new.sr_sps:
changes.append(f"SR: {old.sr_sps} -> {new.sr_sps} sps")
# Power change (>2 dB is significant)
if abs(old.power_db - new.power_db) > 2.0:
changes.append(f"power: {old.power_db:+.1f} -> {new.power_db:+.1f} dB")
# Lock state change
if old.locked != new.locked:
changes.append(f"lock: {old.locked} -> {new.locked}")
# Modulation change
if old.modulation and new.modulation and old.modulation != new.modulation:
changes.append(f"mod: {old.modulation} -> {new.modulation}")
# Service list change
old_svcs = set(old.services)
new_svcs = set(new.services)
if old_svcs != new_svcs:
added = new_svcs - old_svcs
removed = old_svcs - new_svcs
parts = []
if added:
parts.append(f"+{list(added)}")
if removed:
parts.append(f"-{list(removed)}")
changes.append(f"services: {', '.join(parts)}")
return changes

View file

@ -1,101 +1,101 @@
#!/usr/bin/env python3
"""Probe I2C EEPROM address setting on Genpix SkyWalker-1."""
import usb.core, usb.util, time
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
for cfg in dev:
for intf in cfg:
if dev.is_kernel_driver_active(intf.bInterfaceNumber):
dev.detach_kernel_driver(intf.bInterfaceNumber)
break
try:
dev.set_configuration()
except:
pass
I2C_READ = 0x84
I2C_WRITE = 0x83
def vin(req, value=0, index=0, length=64):
try:
return dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
req, value, index, length, 2000)
except:
return None
def vout(req, value=0, index=0, data=b''):
try:
return dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_OUT,
req, value, index, data, 2000)
except Exception as e:
print(f" OUT error: {e}")
return None
# Known first 8 bytes at offset 0: c2 c0 09 03 02 00 00 40
# Known bytes at offset 8 (from our read): 03 ff 00 00 02 18 8d 30
REF_0 = "c2 c0 09 03 02 00 00 40"
REF_8 = "03 ff 00 00 02 18 8d 30"
def show(label, data):
if data is not None:
h = bytes(data[:8]).hex(' ')
match = ""
if h == REF_0: match = " <-- OFFSET 0"
elif h == REF_8: match = " <-- OFFSET 8"
print(f" {label}: {h}{match}")
else:
print(f" {label}: FAILED")
print("=== Approach 1: I2C_WRITE data=[addr_h, addr_l], then I2C_READ ===")
vout(I2C_WRITE, 0x51, 0, bytes([0x00, 0x08]))
show("After set 0x0008", vin(I2C_READ, 0x51, 0, 8))
vout(I2C_WRITE, 0x51, 0, bytes([0x00, 0x00]))
show("After set 0x0000", vin(I2C_READ, 0x51, 0, 8))
print("\n=== Approach 2: wValue=addr, wIndex=slave ===")
vout(I2C_WRITE, 0x0008, 0x51)
show("After set 0x0008", vin(I2C_READ, 0x51, 0, 8))
vout(I2C_WRITE, 0x0000, 0x51)
show("After set 0x0000", vin(I2C_READ, 0x51, 0, 8))
print("\n=== Approach 3: wValue=slave, wIndex=addr ===")
vout(I2C_WRITE, 0x51, 0x0008)
show("After set 0x0008", vin(I2C_READ, 0x51, 0, 8))
vout(I2C_WRITE, 0x51, 0x0000)
show("After set 0x0000", vin(I2C_READ, 0x51, 0, 8))
print("\n=== Approach 4: I2C_READ with wIndex=offset ===")
show("wIndex=0x0000", vin(I2C_READ, 0x51, 0x0000, 8))
show("wIndex=0x0008", vin(I2C_READ, 0x51, 0x0008, 8))
show("wIndex=0x0040", vin(I2C_READ, 0x51, 0x0040, 8))
print("\n=== Approach 5: I2C_READ with (slave<<8|offset) in wValue ===")
show("wValue=0x5100", vin(I2C_READ, 0x5100, 0, 8))
show("wValue=0x5108", vin(I2C_READ, 0x5108, 0, 8))
print("\n=== Approach 6: I2C_READ with wValue=offset (no slave) ===")
show("wValue=0x0000", vin(I2C_READ, 0x0000, 0, 8))
show("wValue=0x0008", vin(I2C_READ, 0x0008, 0, 8))
show("wValue=0x0040", vin(I2C_READ, 0x0040, 0, 8))
print("\n=== Approach 7: Larger reads to check page boundaries ===")
data = vin(I2C_READ, 0x51, 0, 64)
if data:
show("First 8 of 64", data[:8])
show("Bytes 8-15", data[8:16])
show("Bytes 56-63", data[56:64])
# Check if bytes 8-15 match REF_8
if bytes(data[8:16]).hex(' ') == REF_8:
print(" ** Bytes 8-15 match expected offset 8 data!")
print(" ** The 64-byte read IS returning sequential EEPROM data!")
# Reattach
for cfg in dev:
for intf in cfg:
try:
usb.util.release_interface(dev, intf.bInterfaceNumber)
dev.attach_kernel_driver(intf.bInterfaceNumber)
except:
pass
#!/usr/bin/env python3
"""Probe I2C EEPROM address setting on Genpix SkyWalker-1."""
import usb.core, usb.util, time
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
for cfg in dev:
for intf in cfg:
if dev.is_kernel_driver_active(intf.bInterfaceNumber):
dev.detach_kernel_driver(intf.bInterfaceNumber)
break
try:
dev.set_configuration()
except:
pass
I2C_READ = 0x84
I2C_WRITE = 0x83
def vin(req, value=0, index=0, length=64):
try:
return dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
req, value, index, length, 2000)
except:
return None
def vout(req, value=0, index=0, data=b''):
try:
return dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_OUT,
req, value, index, data, 2000)
except Exception as e:
print(f" OUT error: {e}")
return None
# Known first 8 bytes at offset 0: c2 c0 09 03 02 00 00 40
# Known bytes at offset 8 (from our read): 03 ff 00 00 02 18 8d 30
REF_0 = "c2 c0 09 03 02 00 00 40"
REF_8 = "03 ff 00 00 02 18 8d 30"
def show(label, data):
if data is not None:
h = bytes(data[:8]).hex(' ')
match = ""
if h == REF_0: match = " <-- OFFSET 0"
elif h == REF_8: match = " <-- OFFSET 8"
print(f" {label}: {h}{match}")
else:
print(f" {label}: FAILED")
print("=== Approach 1: I2C_WRITE data=[addr_h, addr_l], then I2C_READ ===")
vout(I2C_WRITE, 0x51, 0, bytes([0x00, 0x08]))
show("After set 0x0008", vin(I2C_READ, 0x51, 0, 8))
vout(I2C_WRITE, 0x51, 0, bytes([0x00, 0x00]))
show("After set 0x0000", vin(I2C_READ, 0x51, 0, 8))
print("\n=== Approach 2: wValue=addr, wIndex=slave ===")
vout(I2C_WRITE, 0x0008, 0x51)
show("After set 0x0008", vin(I2C_READ, 0x51, 0, 8))
vout(I2C_WRITE, 0x0000, 0x51)
show("After set 0x0000", vin(I2C_READ, 0x51, 0, 8))
print("\n=== Approach 3: wValue=slave, wIndex=addr ===")
vout(I2C_WRITE, 0x51, 0x0008)
show("After set 0x0008", vin(I2C_READ, 0x51, 0, 8))
vout(I2C_WRITE, 0x51, 0x0000)
show("After set 0x0000", vin(I2C_READ, 0x51, 0, 8))
print("\n=== Approach 4: I2C_READ with wIndex=offset ===")
show("wIndex=0x0000", vin(I2C_READ, 0x51, 0x0000, 8))
show("wIndex=0x0008", vin(I2C_READ, 0x51, 0x0008, 8))
show("wIndex=0x0040", vin(I2C_READ, 0x51, 0x0040, 8))
print("\n=== Approach 5: I2C_READ with (slave<<8|offset) in wValue ===")
show("wValue=0x5100", vin(I2C_READ, 0x5100, 0, 8))
show("wValue=0x5108", vin(I2C_READ, 0x5108, 0, 8))
print("\n=== Approach 6: I2C_READ with wValue=offset (no slave) ===")
show("wValue=0x0000", vin(I2C_READ, 0x0000, 0, 8))
show("wValue=0x0008", vin(I2C_READ, 0x0008, 0, 8))
show("wValue=0x0040", vin(I2C_READ, 0x0040, 0, 8))
print("\n=== Approach 7: Larger reads to check page boundaries ===")
data = vin(I2C_READ, 0x51, 0, 64)
if data:
show("First 8 of 64", data[:8])
show("Bytes 8-15", data[8:16])
show("Bytes 56-63", data[56:64])
# Check if bytes 8-15 match REF_8
if bytes(data[8:16]).hex(' ') == REF_8:
print(" ** Bytes 8-15 match expected offset 8 data!")
print(" ** The 64-byte read IS returning sequential EEPROM data!")
# Reattach
for cfg in dev:
for intf in cfg:
try:
usb.util.release_interface(dev, intf.bInterfaceNumber)
dev.attach_kernel_driver(intf.bInterfaceNumber)
except:
pass

View file

@ -1,292 +1,292 @@
#!/usr/bin/env python3
"""
Genpix SkyWalker-1 firmware probe and dump tool.
The SkyWalker-1 uses a Cypress FX2 (EZ-USB) microcontroller.
FX2 devices support reading internal RAM (8KB at 0x0000-0x1FFF)
and external RAM via standard vendor requests:
- bRequest=0xA0 (FX2 firmware load/read)
- wValue=address, wIndex=0
This tool also queries Genpix-specific vendor commands to gather
device info before attempting a firmware dump.
"""
import sys
import struct
import argparse
from datetime import datetime
try:
import usb.core
import usb.util
except ImportError:
print("pyusb required: pip install pyusb")
sys.exit(1)
VENDOR_ID = 0x09C0
PRODUCT_ID = 0x0203
# Genpix vendor commands (from SkyWalker1Control.h)
CMD_GET_USB_SPEED = 0x07
CMD_FW_VERSION_READ = 0x0B
CMD_VENDOR_STRING_READ = 0x0C
CMD_PRODUCT_STRING_READ = 0x0D
CMD_RESET_FX2 = 0x13
CMD_FW_BCD_VERSION_READ = 0x14
CMD_GET_8PSK_CONFIG = 0x80
CMD_GET_SIGNAL_STRENGTH = 0x87
CMD_GET_SIGNAL_LOCK = 0x90
CMD_GET_SERIAL_NUMBER = 0x93
# FX2 standard vendor request for RAM access
FX2_RAM_REQUEST = 0xA0
# FX2 memory map
FX2_INTERNAL_RAM_SIZE = 0x2000 # 8KB internal RAM
FX2_EXTERNAL_RAM_SIZE = 0x10000 # Up to 64KB external
# Config status bits
CONFIG_BITS = {
0x01: "8PSK Started",
0x02: "BCM4500 FW Loaded",
0x04: "Intersil LNB On",
0x08: "DVB Mode",
0x10: "22kHz Tone",
0x20: "18V Selected",
0x40: "DC Tuned",
0x80: "Armed (streaming)",
}
def find_device():
dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
if dev is None:
print("SkyWalker-1 not found. Is it plugged in?")
sys.exit(1)
return dev
def vendor_in(dev, request, value=0, index=0, length=64, timeout=2000):
"""Send a vendor IN control transfer (device-to-host)."""
try:
return dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
request, value, index, length, timeout
)
except usb.core.USBError as e:
return None
def detach_kernel_driver(dev):
"""Detach kernel driver if attached."""
for cfg in dev:
for intf in cfg:
if dev.is_kernel_driver_active(intf.bInterfaceNumber):
try:
dev.detach_kernel_driver(intf.bInterfaceNumber)
print(f" Detached kernel driver from interface {intf.bInterfaceNumber}")
return intf.bInterfaceNumber
except usb.core.USBError as e:
print(f" Warning: Could not detach kernel driver: {e}")
print(" Try running with sudo, or: sudo modprobe -r dvb_usb_gp8psk")
sys.exit(1)
return None
def probe_device_info(dev):
"""Query all known Genpix info commands."""
print("\n=== Genpix SkyWalker-1 Device Info ===\n")
# Firmware version (6 bytes)
data = vendor_in(dev, CMD_FW_VERSION_READ, length=6)
if data is not None and len(data) == 6:
fw_int = (data[2] << 16) | (data[1] << 8) | data[0]
build_date = f"20{data[5]:02d}-{data[4]:02d}-{data[3]:02d}"
print(f" FW Version: {data[2]}.{data[1]:02d}.{data[0]} (0x{fw_int:06x})")
print(f" FW Build: {build_date}")
else:
print(f" FW Version: (failed: {data})")
# BCD version
data = vendor_in(dev, CMD_FW_BCD_VERSION_READ, length=2)
if data is not None:
print(f" BCD Version: {bytes(data).hex()}")
# Vendor string
data = vendor_in(dev, CMD_VENDOR_STRING_READ, length=64)
if data is not None:
s = bytes(data).rstrip(b'\x00').decode('ascii', errors='replace')
print(f" Vendor: {s}")
# Product string
data = vendor_in(dev, CMD_PRODUCT_STRING_READ, length=64)
if data is not None:
s = bytes(data).rstrip(b'\x00').decode('ascii', errors='replace')
print(f" Product: {s}")
# USB speed
data = vendor_in(dev, CMD_GET_USB_SPEED, length=1)
if data is not None:
speeds = {0: "Low", 1: "Full (12Mbps)", 2: "High (480Mbps)"}
print(f" USB Speed: {speeds.get(data[0], f'Unknown ({data[0]})')}")
# Serial number
data = vendor_in(dev, CMD_GET_SERIAL_NUMBER, length=8)
if data is not None:
print(f" Serial: {bytes(data).hex()} ({bytes(data).rstrip(b'\\x00').decode('ascii', errors='replace')})")
# 8PSK config/status
data = vendor_in(dev, CMD_GET_8PSK_CONFIG, length=1)
if data is not None:
status = data[0]
print(f" Config: 0x{status:02x}")
for bit, desc in CONFIG_BITS.items():
state = "ON" if status & bit else "off"
print(f" [{state:>3}] {desc}")
print()
def dump_fx2_ram(dev, output_file, start=0x0000, size=FX2_INTERNAL_RAM_SIZE, chunk=64):
"""
Attempt to read FX2 internal RAM using the standard FX2 vendor request 0xA0.
The Cypress FX2 bootloader/firmware typically supports:
- bRequest = 0xA0
- wValue = start address
- wIndex = 0
- Direction = IN (device to host)
"""
print(f"=== Attempting FX2 RAM dump: 0x{start:04X} - 0x{start+size-1:04X} ({size} bytes) ===\n")
firmware = bytearray()
addr = start
errors = 0
consecutive_errors = 0
while addr < start + size:
remaining = (start + size) - addr
read_len = min(chunk, remaining)
data = vendor_in(dev, FX2_RAM_REQUEST, value=addr, index=0, length=read_len)
if data is None:
errors += 1
consecutive_errors += 1
firmware.extend(b'\xff' * read_len)
if consecutive_errors >= 5:
print(f"\n Stopped: {consecutive_errors} consecutive read failures at 0x{addr:04X}")
print(" Device may not support FX2 RAM readback (EEPROM firmware)")
break
else:
consecutive_errors = 0
firmware.extend(data)
if (addr - start) % 0x400 == 0:
pct = ((addr - start) / size) * 100
print(f" 0x{addr:04X} [{pct:5.1f}%] {'OK' if data is not None else 'FAIL'}", end='\r')
addr += read_len
print(f"\n\n Read {len(firmware)} bytes, {errors} chunk errors")
if firmware and any(b != 0xFF for b in firmware):
with open(output_file, 'wb') as f:
f.write(firmware)
print(f" Saved to: {output_file}")
# Quick analysis
non_ff = sum(1 for b in firmware if b != 0xFF)
non_zero = sum(1 for b in firmware if b != 0x00)
print(f" Non-0xFF bytes: {non_ff}/{len(firmware)}")
print(f" Non-0x00 bytes: {non_zero}/{len(firmware)}")
# Check for FX2 reset vector
if len(firmware) >= 3:
print(f" First 16 bytes: {firmware[:16].hex(' ')}")
if firmware[0] == 0x02:
jump_addr = (firmware[1] << 8) | firmware[2]
print(f" Reset vector: LJMP 0x{jump_addr:04X} (typical FX2 firmware)")
else:
print(" No valid data read — dump appears empty")
return firmware
def scan_vendor_commands(dev, start=0x00, end=0xFF):
"""Brute-force scan all vendor IN commands to find undocumented ones."""
print(f"=== Scanning vendor commands 0x{start:02X}-0x{end:02X} ===\n")
found = []
for cmd in range(start, end + 1):
data = vendor_in(dev, cmd, length=64, timeout=500)
if data is not None and len(data) > 0:
preview = bytes(data[:16]).hex(' ')
is_known = cmd in (
CMD_GET_USB_SPEED, CMD_FW_VERSION_READ, CMD_VENDOR_STRING_READ,
CMD_PRODUCT_STRING_READ, CMD_FW_BCD_VERSION_READ, CMD_GET_8PSK_CONFIG,
CMD_GET_SIGNAL_STRENGTH, CMD_GET_SIGNAL_LOCK, CMD_GET_SERIAL_NUMBER,
FX2_RAM_REQUEST,
)
marker = " [KNOWN]" if is_known else " [NEW!]"
print(f" 0x{cmd:02X}: [{len(data):3d} bytes] {preview}...{marker}")
found.append((cmd, data))
print(f"\n Found {len(found)} responding commands")
return found
def main():
parser = argparse.ArgumentParser(description="Genpix SkyWalker-1 firmware probe/dump tool")
parser.add_argument('--info', action='store_true', help="Query device info")
parser.add_argument('--dump', metavar='FILE', help="Dump FX2 RAM to file")
parser.add_argument('--scan', action='store_true', help="Scan all vendor commands")
parser.add_argument('--start', type=lambda x: int(x, 0), default=0x0000,
help="RAM dump start address (default: 0x0000)")
parser.add_argument('--size', type=lambda x: int(x, 0), default=FX2_INTERNAL_RAM_SIZE,
help=f"RAM dump size (default: 0x{FX2_INTERNAL_RAM_SIZE:X})")
parser.add_argument('--external', action='store_true',
help="Try to dump external RAM (64KB)")
args = parser.parse_args()
if not any([args.info, args.dump, args.scan]):
args.info = True
args.scan = True
print(f"Genpix SkyWalker-1 Firmware Tool")
print(f"{'=' * 40}")
dev = find_device()
print(f"\nFound device: Bus {dev.bus} Addr {dev.address}")
intf = detach_kernel_driver(dev)
try:
dev.set_configuration()
except usb.core.USBError:
pass # May already be configured
try:
if args.info:
probe_device_info(dev)
if args.scan:
scan_vendor_commands(dev)
print()
if args.dump:
if args.external:
dump_fx2_ram(dev, args.dump, args.start, FX2_EXTERNAL_RAM_SIZE)
else:
dump_fx2_ram(dev, args.dump, args.start, args.size)
finally:
if intf is not None:
try:
usb.util.release_interface(dev, intf)
dev.attach_kernel_driver(intf)
print("\nRe-attached kernel driver")
except usb.core.USBError:
print("\nNote: Run 'sudo modprobe dvb_usb_gp8psk' to reload driver")
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Genpix SkyWalker-1 firmware probe and dump tool.
The SkyWalker-1 uses a Cypress FX2 (EZ-USB) microcontroller.
FX2 devices support reading internal RAM (8KB at 0x0000-0x1FFF)
and external RAM via standard vendor requests:
- bRequest=0xA0 (FX2 firmware load/read)
- wValue=address, wIndex=0
This tool also queries Genpix-specific vendor commands to gather
device info before attempting a firmware dump.
"""
import sys
import struct
import argparse
from datetime import datetime
try:
import usb.core
import usb.util
except ImportError:
print("pyusb required: pip install pyusb")
sys.exit(1)
VENDOR_ID = 0x09C0
PRODUCT_ID = 0x0203
# Genpix vendor commands (from SkyWalker1Control.h)
CMD_GET_USB_SPEED = 0x07
CMD_FW_VERSION_READ = 0x0B
CMD_VENDOR_STRING_READ = 0x0C
CMD_PRODUCT_STRING_READ = 0x0D
CMD_RESET_FX2 = 0x13
CMD_FW_BCD_VERSION_READ = 0x14
CMD_GET_8PSK_CONFIG = 0x80
CMD_GET_SIGNAL_STRENGTH = 0x87
CMD_GET_SIGNAL_LOCK = 0x90
CMD_GET_SERIAL_NUMBER = 0x93
# FX2 standard vendor request for RAM access
FX2_RAM_REQUEST = 0xA0
# FX2 memory map
FX2_INTERNAL_RAM_SIZE = 0x2000 # 8KB internal RAM
FX2_EXTERNAL_RAM_SIZE = 0x10000 # Up to 64KB external
# Config status bits
CONFIG_BITS = {
0x01: "8PSK Started",
0x02: "BCM4500 FW Loaded",
0x04: "Intersil LNB On",
0x08: "DVB Mode",
0x10: "22kHz Tone",
0x20: "18V Selected",
0x40: "DC Tuned",
0x80: "Armed (streaming)",
}
def find_device():
dev = usb.core.find(idVendor=VENDOR_ID, idProduct=PRODUCT_ID)
if dev is None:
print("SkyWalker-1 not found. Is it plugged in?")
sys.exit(1)
return dev
def vendor_in(dev, request, value=0, index=0, length=64, timeout=2000):
"""Send a vendor IN control transfer (device-to-host)."""
try:
return dev.ctrl_transfer(
usb.util.CTRL_TYPE_VENDOR | usb.util.CTRL_IN,
request, value, index, length, timeout
)
except usb.core.USBError as e:
return None
def detach_kernel_driver(dev):
"""Detach kernel driver if attached."""
for cfg in dev:
for intf in cfg:
if dev.is_kernel_driver_active(intf.bInterfaceNumber):
try:
dev.detach_kernel_driver(intf.bInterfaceNumber)
print(f" Detached kernel driver from interface {intf.bInterfaceNumber}")
return intf.bInterfaceNumber
except usb.core.USBError as e:
print(f" Warning: Could not detach kernel driver: {e}")
print(" Try running with sudo, or: sudo modprobe -r dvb_usb_gp8psk")
sys.exit(1)
return None
def probe_device_info(dev):
"""Query all known Genpix info commands."""
print("\n=== Genpix SkyWalker-1 Device Info ===\n")
# Firmware version (6 bytes)
data = vendor_in(dev, CMD_FW_VERSION_READ, length=6)
if data is not None and len(data) == 6:
fw_int = (data[2] << 16) | (data[1] << 8) | data[0]
build_date = f"20{data[5]:02d}-{data[4]:02d}-{data[3]:02d}"
print(f" FW Version: {data[2]}.{data[1]:02d}.{data[0]} (0x{fw_int:06x})")
print(f" FW Build: {build_date}")
else:
print(f" FW Version: (failed: {data})")
# BCD version
data = vendor_in(dev, CMD_FW_BCD_VERSION_READ, length=2)
if data is not None:
print(f" BCD Version: {bytes(data).hex()}")
# Vendor string
data = vendor_in(dev, CMD_VENDOR_STRING_READ, length=64)
if data is not None:
s = bytes(data).rstrip(b'\x00').decode('ascii', errors='replace')
print(f" Vendor: {s}")
# Product string
data = vendor_in(dev, CMD_PRODUCT_STRING_READ, length=64)
if data is not None:
s = bytes(data).rstrip(b'\x00').decode('ascii', errors='replace')
print(f" Product: {s}")
# USB speed
data = vendor_in(dev, CMD_GET_USB_SPEED, length=1)
if data is not None:
speeds = {0: "Low", 1: "Full (12Mbps)", 2: "High (480Mbps)"}
print(f" USB Speed: {speeds.get(data[0], f'Unknown ({data[0]})')}")
# Serial number
data = vendor_in(dev, CMD_GET_SERIAL_NUMBER, length=8)
if data is not None:
print(f" Serial: {bytes(data).hex()} ({bytes(data).rstrip(b'\\x00').decode('ascii', errors='replace')})")
# 8PSK config/status
data = vendor_in(dev, CMD_GET_8PSK_CONFIG, length=1)
if data is not None:
status = data[0]
print(f" Config: 0x{status:02x}")
for bit, desc in CONFIG_BITS.items():
state = "ON" if status & bit else "off"
print(f" [{state:>3}] {desc}")
print()
def dump_fx2_ram(dev, output_file, start=0x0000, size=FX2_INTERNAL_RAM_SIZE, chunk=64):
"""
Attempt to read FX2 internal RAM using the standard FX2 vendor request 0xA0.
The Cypress FX2 bootloader/firmware typically supports:
- bRequest = 0xA0
- wValue = start address
- wIndex = 0
- Direction = IN (device to host)
"""
print(f"=== Attempting FX2 RAM dump: 0x{start:04X} - 0x{start+size-1:04X} ({size} bytes) ===\n")
firmware = bytearray()
addr = start
errors = 0
consecutive_errors = 0
while addr < start + size:
remaining = (start + size) - addr
read_len = min(chunk, remaining)
data = vendor_in(dev, FX2_RAM_REQUEST, value=addr, index=0, length=read_len)
if data is None:
errors += 1
consecutive_errors += 1
firmware.extend(b'\xff' * read_len)
if consecutive_errors >= 5:
print(f"\n Stopped: {consecutive_errors} consecutive read failures at 0x{addr:04X}")
print(" Device may not support FX2 RAM readback (EEPROM firmware)")
break
else:
consecutive_errors = 0
firmware.extend(data)
if (addr - start) % 0x400 == 0:
pct = ((addr - start) / size) * 100
print(f" 0x{addr:04X} [{pct:5.1f}%] {'OK' if data is not None else 'FAIL'}", end='\r')
addr += read_len
print(f"\n\n Read {len(firmware)} bytes, {errors} chunk errors")
if firmware and any(b != 0xFF for b in firmware):
with open(output_file, 'wb') as f:
f.write(firmware)
print(f" Saved to: {output_file}")
# Quick analysis
non_ff = sum(1 for b in firmware if b != 0xFF)
non_zero = sum(1 for b in firmware if b != 0x00)
print(f" Non-0xFF bytes: {non_ff}/{len(firmware)}")
print(f" Non-0x00 bytes: {non_zero}/{len(firmware)}")
# Check for FX2 reset vector
if len(firmware) >= 3:
print(f" First 16 bytes: {firmware[:16].hex(' ')}")
if firmware[0] == 0x02:
jump_addr = (firmware[1] << 8) | firmware[2]
print(f" Reset vector: LJMP 0x{jump_addr:04X} (typical FX2 firmware)")
else:
print(" No valid data read — dump appears empty")
return firmware
def scan_vendor_commands(dev, start=0x00, end=0xFF):
"""Brute-force scan all vendor IN commands to find undocumented ones."""
print(f"=== Scanning vendor commands 0x{start:02X}-0x{end:02X} ===\n")
found = []
for cmd in range(start, end + 1):
data = vendor_in(dev, cmd, length=64, timeout=500)
if data is not None and len(data) > 0:
preview = bytes(data[:16]).hex(' ')
is_known = cmd in (
CMD_GET_USB_SPEED, CMD_FW_VERSION_READ, CMD_VENDOR_STRING_READ,
CMD_PRODUCT_STRING_READ, CMD_FW_BCD_VERSION_READ, CMD_GET_8PSK_CONFIG,
CMD_GET_SIGNAL_STRENGTH, CMD_GET_SIGNAL_LOCK, CMD_GET_SERIAL_NUMBER,
FX2_RAM_REQUEST,
)
marker = " [KNOWN]" if is_known else " [NEW!]"
print(f" 0x{cmd:02X}: [{len(data):3d} bytes] {preview}...{marker}")
found.append((cmd, data))
print(f"\n Found {len(found)} responding commands")
return found
def main():
parser = argparse.ArgumentParser(description="Genpix SkyWalker-1 firmware probe/dump tool")
parser.add_argument('--info', action='store_true', help="Query device info")
parser.add_argument('--dump', metavar='FILE', help="Dump FX2 RAM to file")
parser.add_argument('--scan', action='store_true', help="Scan all vendor commands")
parser.add_argument('--start', type=lambda x: int(x, 0), default=0x0000,
help="RAM dump start address (default: 0x0000)")
parser.add_argument('--size', type=lambda x: int(x, 0), default=FX2_INTERNAL_RAM_SIZE,
help=f"RAM dump size (default: 0x{FX2_INTERNAL_RAM_SIZE:X})")
parser.add_argument('--external', action='store_true',
help="Try to dump external RAM (64KB)")
args = parser.parse_args()
if not any([args.info, args.dump, args.scan]):
args.info = True
args.scan = True
print(f"Genpix SkyWalker-1 Firmware Tool")
print(f"{'=' * 40}")
dev = find_device()
print(f"\nFound device: Bus {dev.bus} Addr {dev.address}")
intf = detach_kernel_driver(dev)
try:
dev.set_configuration()
except usb.core.USBError:
pass # May already be configured
try:
if args.info:
probe_device_info(dev)
if args.scan:
scan_vendor_commands(dev)
print()
if args.dump:
if args.external:
dump_fx2_ram(dev, args.dump, args.start, FX2_EXTERNAL_RAM_SIZE)
else:
dump_fx2_ram(dev, args.dump, args.start, args.size)
finally:
if intf is not None:
try:
usb.util.release_interface(dev, intf)
dev.attach_kernel_driver(intf)
print("\nRe-attached kernel driver")
except usb.core.USBError:
print("\nNote: Run 'sudo modprobe dvb_usb_gp8psk' to reload driver")
if __name__ == '__main__':
main()

View file

@ -1,357 +1,357 @@
#!/usr/bin/env python3
"""
Hydrogen 21 cm drift-scan radiometer for the Genpix SkyWalker-1.
Detects neutral hydrogen emission at 1420.405 MHz directly in the IF range
with no LNB required. Connect an L-band antenna (patch, helical, or horn)
directly to the F-connector.
The Milky Way's spiral arms create a velocity-dispersed emission profile
detectable even with the BCM4500's ~346 kHz RBW. Earth's rotation provides
a natural drift-scan across the sky.
Usage:
python h21cm.py # single sweep, print spectrum
python h21cm.py --drift --duration 3600 # 1-hour drift scan
python h21cm.py --drift --motor-step 5 # step motor between sweeps
python h21cm.py --output data.csv # log to CSV
The c in 21 cm stands for centimeters. The frequency (1420.405 MHz) comes from
the hyperfine transition in neutral hydrogen when the electron's spin flips
relative to the proton. This is the most fundamental spectral line in radio
astronomy, and you can detect it with a $30 DVB-S dongle.
"""
import sys
import os
import argparse
import time
import csv
import math
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1, agc_to_power_db
# Physical constants
H1_FREQ_MHZ = 1420.405751 # Hydrogen 21 cm rest frequency
C_KM_S = 299792.458 # Speed of light
def freq_to_velocity(freq_mhz: float) -> float:
"""Convert observed frequency to radial velocity via Doppler shift.
v = c * (f_rest - f_obs) / f_rest
Positive velocity = receding (redshifted, lower frequency).
Negative velocity = approaching (blueshifted, higher frequency).
"""
return C_KM_S * (H1_FREQ_MHZ - freq_mhz) / H1_FREQ_MHZ
def sweep_h1_band(sw: SkyWalker1, center_mhz: float = H1_FREQ_MHZ,
span_mhz: float = 4.0, step_mhz: float = 0.5,
dwell_ms: int = 50, averages: int = 1) -> dict:
"""Sweep the hydrogen line band and return power measurements.
Higher dwell_ms and multiple averages improve SNR for this weak signal.
Default 50ms dwell is 5x longer than typical satellite sweeps.
Returns dict with frequencies, powers, velocities, and statistics.
"""
start = center_mhz - span_mhz / 2
stop = center_mhz + span_mhz / 2
# Accumulate multiple sweeps for averaging
all_powers = None
for avg in range(averages):
freqs, powers, raw = sw.sweep_spectrum(
start, stop, step_mhz=step_mhz, dwell_ms=dwell_ms,
sr_ksps=1000, mod_index=0, fec_index=5,
)
if all_powers is None:
all_powers = [0.0] * len(powers)
for i in range(len(powers)):
all_powers[i] += powers[i]
# Average
avg_powers = [p / averages for p in all_powers]
# Calculate velocities
velocities = [freq_to_velocity(f) for f in freqs]
# Baseline: edges of the band should be "empty" (no hydrogen)
edge_count = max(2, len(avg_powers) // 5)
baseline = (sum(avg_powers[:edge_count]) + sum(avg_powers[-edge_count:])) / (2 * edge_count)
# Excess power above baseline
excess = [p - baseline for p in avg_powers]
# Find peak excess (the hydrogen line center)
peak_idx = max(range(len(excess)), key=lambda i: excess[i])
peak_freq = freqs[peak_idx]
peak_excess = excess[peak_idx]
peak_velocity = velocities[peak_idx]
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"freqs_mhz": freqs,
"powers_db": avg_powers,
"velocities_km_s": velocities,
"excess_db": excess,
"baseline_db": baseline,
"peak_freq_mhz": peak_freq,
"peak_excess_db": peak_excess,
"peak_velocity_km_s": peak_velocity,
"averages": averages,
"dwell_ms": dwell_ms,
}
def sweep_control_band(sw: SkyWalker1, step_mhz: float = 0.5,
dwell_ms: int = 50) -> dict:
"""Sweep a control band (1430-1434 MHz) where no hydrogen is expected.
Comparing the control band to the hydrogen band reveals whether a
detected power bump is real emission or just system noise variation.
"""
freqs, powers, _ = sw.sweep_spectrum(
1430.0, 1434.0, step_mhz=step_mhz, dwell_ms=dwell_ms,
sr_ksps=1000, mod_index=0, fec_index=5,
)
mean_power = sum(powers) / len(powers) if powers else 0
return {
"control_freqs_mhz": freqs,
"control_powers_db": powers,
"control_mean_db": mean_power,
}
def print_spectrum(result: dict, show_velocity: bool = True) -> None:
"""Print an ASCII spectrum of the hydrogen band."""
freqs = result["freqs_mhz"]
excess = result["excess_db"]
velocities = result["velocities_km_s"]
baseline = result["baseline_db"]
# Scale for display
max_excess = max(excess) if excess else 1.0
min_excess = min(excess)
span = max(max_excess - min_excess, 0.5)
print(f"\n Hydrogen 21 cm Spectrum")
print(f" Baseline: {baseline:.2f} dB | Peak excess: {result['peak_excess_db']:.2f} dB")
print(f" Peak at {result['peak_freq_mhz']:.3f} MHz ({result['peak_velocity_km_s']:+.1f} km/s)")
print()
bar_width = 50
for i in range(len(freqs)):
f = freqs[i]
e = excess[i]
v = velocities[i]
# Normalize to bar width
filled = int((e - min_excess) / span * bar_width)
filled = max(0, min(filled, bar_width))
bar = '#' * filled + '-' * (bar_width - filled)
# Mark the hydrogen rest frequency
marker = " *" if abs(f - H1_FREQ_MHZ) < 0.3 else " "
if show_velocity:
print(f" {f:8.3f} MHz {v:+7.1f} km/s [{bar}] {e:+.2f} dB{marker}")
else:
print(f" {f:8.3f} MHz [{bar}] {e:+.2f} dB{marker}")
print()
print(" * = hydrogen rest frequency (1420.405 MHz)")
def drift_scan(sw: SkyWalker1, duration_secs: float, interval_secs: float,
step_mhz: float, dwell_ms: int, averages: int,
motor_step: int, output_path: str | None) -> None:
"""Run a drift scan: repeated sweeps over time.
Earth's rotation naturally scans the sky. Each sweep captures the
hydrogen profile at the current sky position. Over hours, you trace
out the galactic plane.
"""
csv_writer = None
csv_file = None
header_written = False
if output_path:
csv_file = open(output_path, 'w', newline='')
csv_writer = csv.writer(csv_file)
start_time = time.time()
scan_num = 0
try:
while time.time() - start_time < duration_secs:
scan_num += 1
elapsed = time.time() - start_time
remaining = duration_secs - elapsed
print(f"\n--- Scan #{scan_num} (elapsed {elapsed:.0f}s, "
f"remaining {remaining:.0f}s) ---")
# Motor step between scans (for declination scanning)
if motor_step and scan_num > 1:
print(f" Stepping motor {motor_step} steps east...")
sw.motor_drive_east(motor_step)
time.sleep(1.0)
result = sweep_h1_band(sw, step_mhz=step_mhz,
dwell_ms=dwell_ms, averages=averages)
print_spectrum(result, show_velocity=True)
# Write CSV
if csv_writer:
if not header_written:
csv_writer.writerow([
"timestamp", "scan_num", "freq_mhz", "power_db",
"excess_db", "velocity_km_s", "baseline_db",
])
header_written = True
for i in range(len(result["freqs_mhz"])):
csv_writer.writerow([
result["timestamp"],
scan_num,
f"{result['freqs_mhz'][i]:.3f}",
f"{result['powers_db'][i]:.3f}",
f"{result['excess_db'][i]:.3f}",
f"{result['velocities_km_s'][i]:.1f}",
f"{result['baseline_db']:.3f}",
])
csv_file.flush()
# Wait for next scan
if remaining > interval_secs:
print(f" Next scan in {interval_secs:.0f}s...")
time.sleep(interval_secs)
except KeyboardInterrupt:
print("\n Drift scan interrupted")
finally:
if csv_file:
csv_file.close()
print(f" Data saved to {output_path}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="h21cm.py",
description="Hydrogen 21 cm line radiometer for SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s # single sweep, print spectrum
%(prog)s --averages 8 # 8x averaging for better SNR
%(prog)s --drift --duration 3600 # 1-hour drift scan
%(prog)s --drift --motor-step 5 # step motor between sweeps
%(prog)s --output h21cm-data.csv # log to CSV
%(prog)s --control # include control band comparison
notes:
- Connect an L-band antenna directly to the F-connector (no LNB)
- LNB power is disabled automatically for direct input
- Hydrogen emission is weak; use --averages 4-16 for best results
- The --dwell option increases per-step integration time (default 50ms)
- Earth rotation provides natural sky drift at ~15 deg/hour
""",
)
parser.add_argument('-v', '--verbose', action='store_true',
help="Show raw USB traffic")
parser.add_argument('--center', type=float, default=H1_FREQ_MHZ,
help=f"Center frequency in MHz (default: {H1_FREQ_MHZ})")
parser.add_argument('--span', type=float, default=4.0,
help="Frequency span in MHz (default: 4.0)")
parser.add_argument('--step', type=float, default=0.5,
help="Frequency step in MHz (default: 0.5)")
parser.add_argument('--dwell', type=int, default=50,
help="Dwell time per step in ms (default: 50)")
parser.add_argument('--averages', type=int, default=1,
help="Number of sweeps to average (default: 1)")
parser.add_argument('--output', '-o', type=str, default=None,
help="CSV output file path")
parser.add_argument('--control', action='store_true',
help="Include control band (1430-1434 MHz) for comparison")
parser.add_argument('--no-velocity', action='store_true',
help="Don't show velocity axis in spectrum display")
drift_group = parser.add_argument_group('drift scan')
drift_group.add_argument('--drift', action='store_true',
help="Enable drift scan mode (repeated sweeps)")
drift_group.add_argument('--duration', type=float, default=3600,
help="Drift scan duration in seconds (default: 3600)")
drift_group.add_argument('--interval', type=float, default=60,
help="Seconds between sweeps (default: 60)")
drift_group.add_argument('--motor-step', type=int, default=0,
help="Motor steps between sweeps (0=no motor, default: 0)")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
with SkyWalker1(verbose=args.verbose) as sw:
sw.ensure_booted()
# Disable LNB power for direct input
sw.start_intersil(on=False)
print("LNB power disabled (direct L-band input mode)")
if args.drift:
drift_scan(sw, duration_secs=args.duration,
interval_secs=args.interval,
step_mhz=args.step, dwell_ms=args.dwell,
averages=args.averages, motor_step=args.motor_step,
output_path=args.output)
else:
# Single sweep
print(f"\nSweeping {args.center - args.span/2:.1f} - "
f"{args.center + args.span/2:.1f} MHz "
f"(step={args.step} MHz, dwell={args.dwell}ms, "
f"avg={args.averages}x)")
result = sweep_h1_band(sw, center_mhz=args.center,
span_mhz=args.span, step_mhz=args.step,
dwell_ms=args.dwell, averages=args.averages)
print_spectrum(result, show_velocity=not args.no_velocity)
if args.control:
print(" Control band (1430-1434 MHz, no hydrogen expected):")
ctrl = sweep_control_band(sw, step_mhz=args.step, dwell_ms=args.dwell)
print(f" Control mean: {ctrl['control_mean_db']:.2f} dB")
print(f" H1 baseline: {result['baseline_db']:.2f} dB")
diff = result["peak_excess_db"]
print(f" H1 peak excess above baseline: {diff:+.2f} dB")
# Write single sweep to CSV if requested
if args.output:
with open(args.output, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow([
"freq_mhz", "power_db", "excess_db",
"velocity_km_s", "baseline_db",
])
for i in range(len(result["freqs_mhz"])):
writer.writerow([
f"{result['freqs_mhz'][i]:.3f}",
f"{result['powers_db'][i]:.3f}",
f"{result['excess_db'][i]:.3f}",
f"{result['velocities_km_s'][i]:.1f}",
f"{result['baseline_db']:.3f}",
])
print(f" Data saved to {args.output}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Hydrogen 21 cm drift-scan radiometer for the Genpix SkyWalker-1.
Detects neutral hydrogen emission at 1420.405 MHz directly in the IF range
with no LNB required. Connect an L-band antenna (patch, helical, or horn)
directly to the F-connector.
The Milky Way's spiral arms create a velocity-dispersed emission profile
detectable even with the BCM4500's ~346 kHz RBW. Earth's rotation provides
a natural drift-scan across the sky.
Usage:
python h21cm.py # single sweep, print spectrum
python h21cm.py --drift --duration 3600 # 1-hour drift scan
python h21cm.py --drift --motor-step 5 # step motor between sweeps
python h21cm.py --output data.csv # log to CSV
The c in 21 cm stands for centimeters. The frequency (1420.405 MHz) comes from
the hyperfine transition in neutral hydrogen when the electron's spin flips
relative to the proton. This is the most fundamental spectral line in radio
astronomy, and you can detect it with a $30 DVB-S dongle.
"""
import sys
import os
import argparse
import time
import csv
import math
from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1, agc_to_power_db
# Physical constants
H1_FREQ_MHZ = 1420.405751 # Hydrogen 21 cm rest frequency
C_KM_S = 299792.458 # Speed of light
def freq_to_velocity(freq_mhz: float) -> float:
"""Convert observed frequency to radial velocity via Doppler shift.
v = c * (f_rest - f_obs) / f_rest
Positive velocity = receding (redshifted, lower frequency).
Negative velocity = approaching (blueshifted, higher frequency).
"""
return C_KM_S * (H1_FREQ_MHZ - freq_mhz) / H1_FREQ_MHZ
def sweep_h1_band(sw: SkyWalker1, center_mhz: float = H1_FREQ_MHZ,
span_mhz: float = 4.0, step_mhz: float = 0.5,
dwell_ms: int = 50, averages: int = 1) -> dict:
"""Sweep the hydrogen line band and return power measurements.
Higher dwell_ms and multiple averages improve SNR for this weak signal.
Default 50ms dwell is 5x longer than typical satellite sweeps.
Returns dict with frequencies, powers, velocities, and statistics.
"""
start = center_mhz - span_mhz / 2
stop = center_mhz + span_mhz / 2
# Accumulate multiple sweeps for averaging
all_powers = None
for avg in range(averages):
freqs, powers, raw = sw.sweep_spectrum(
start, stop, step_mhz=step_mhz, dwell_ms=dwell_ms,
sr_ksps=1000, mod_index=0, fec_index=5,
)
if all_powers is None:
all_powers = [0.0] * len(powers)
for i in range(len(powers)):
all_powers[i] += powers[i]
# Average
avg_powers = [p / averages for p in all_powers]
# Calculate velocities
velocities = [freq_to_velocity(f) for f in freqs]
# Baseline: edges of the band should be "empty" (no hydrogen)
edge_count = max(2, len(avg_powers) // 5)
baseline = (sum(avg_powers[:edge_count]) + sum(avg_powers[-edge_count:])) / (2 * edge_count)
# Excess power above baseline
excess = [p - baseline for p in avg_powers]
# Find peak excess (the hydrogen line center)
peak_idx = max(range(len(excess)), key=lambda i: excess[i])
peak_freq = freqs[peak_idx]
peak_excess = excess[peak_idx]
peak_velocity = velocities[peak_idx]
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"freqs_mhz": freqs,
"powers_db": avg_powers,
"velocities_km_s": velocities,
"excess_db": excess,
"baseline_db": baseline,
"peak_freq_mhz": peak_freq,
"peak_excess_db": peak_excess,
"peak_velocity_km_s": peak_velocity,
"averages": averages,
"dwell_ms": dwell_ms,
}
def sweep_control_band(sw: SkyWalker1, step_mhz: float = 0.5,
dwell_ms: int = 50) -> dict:
"""Sweep a control band (1430-1434 MHz) where no hydrogen is expected.
Comparing the control band to the hydrogen band reveals whether a
detected power bump is real emission or just system noise variation.
"""
freqs, powers, _ = sw.sweep_spectrum(
1430.0, 1434.0, step_mhz=step_mhz, dwell_ms=dwell_ms,
sr_ksps=1000, mod_index=0, fec_index=5,
)
mean_power = sum(powers) / len(powers) if powers else 0
return {
"control_freqs_mhz": freqs,
"control_powers_db": powers,
"control_mean_db": mean_power,
}
def print_spectrum(result: dict, show_velocity: bool = True) -> None:
"""Print an ASCII spectrum of the hydrogen band."""
freqs = result["freqs_mhz"]
excess = result["excess_db"]
velocities = result["velocities_km_s"]
baseline = result["baseline_db"]
# Scale for display
max_excess = max(excess) if excess else 1.0
min_excess = min(excess)
span = max(max_excess - min_excess, 0.5)
print(f"\n Hydrogen 21 cm Spectrum")
print(f" Baseline: {baseline:.2f} dB | Peak excess: {result['peak_excess_db']:.2f} dB")
print(f" Peak at {result['peak_freq_mhz']:.3f} MHz ({result['peak_velocity_km_s']:+.1f} km/s)")
print()
bar_width = 50
for i in range(len(freqs)):
f = freqs[i]
e = excess[i]
v = velocities[i]
# Normalize to bar width
filled = int((e - min_excess) / span * bar_width)
filled = max(0, min(filled, bar_width))
bar = '#' * filled + '-' * (bar_width - filled)
# Mark the hydrogen rest frequency
marker = " *" if abs(f - H1_FREQ_MHZ) < 0.3 else " "
if show_velocity:
print(f" {f:8.3f} MHz {v:+7.1f} km/s [{bar}] {e:+.2f} dB{marker}")
else:
print(f" {f:8.3f} MHz [{bar}] {e:+.2f} dB{marker}")
print()
print(" * = hydrogen rest frequency (1420.405 MHz)")
def drift_scan(sw: SkyWalker1, duration_secs: float, interval_secs: float,
step_mhz: float, dwell_ms: int, averages: int,
motor_step: int, output_path: str | None) -> None:
"""Run a drift scan: repeated sweeps over time.
Earth's rotation naturally scans the sky. Each sweep captures the
hydrogen profile at the current sky position. Over hours, you trace
out the galactic plane.
"""
csv_writer = None
csv_file = None
header_written = False
if output_path:
csv_file = open(output_path, 'w', newline='')
csv_writer = csv.writer(csv_file)
start_time = time.time()
scan_num = 0
try:
while time.time() - start_time < duration_secs:
scan_num += 1
elapsed = time.time() - start_time
remaining = duration_secs - elapsed
print(f"\n--- Scan #{scan_num} (elapsed {elapsed:.0f}s, "
f"remaining {remaining:.0f}s) ---")
# Motor step between scans (for declination scanning)
if motor_step and scan_num > 1:
print(f" Stepping motor {motor_step} steps east...")
sw.motor_drive_east(motor_step)
time.sleep(1.0)
result = sweep_h1_band(sw, step_mhz=step_mhz,
dwell_ms=dwell_ms, averages=averages)
print_spectrum(result, show_velocity=True)
# Write CSV
if csv_writer:
if not header_written:
csv_writer.writerow([
"timestamp", "scan_num", "freq_mhz", "power_db",
"excess_db", "velocity_km_s", "baseline_db",
])
header_written = True
for i in range(len(result["freqs_mhz"])):
csv_writer.writerow([
result["timestamp"],
scan_num,
f"{result['freqs_mhz'][i]:.3f}",
f"{result['powers_db'][i]:.3f}",
f"{result['excess_db'][i]:.3f}",
f"{result['velocities_km_s'][i]:.1f}",
f"{result['baseline_db']:.3f}",
])
csv_file.flush()
# Wait for next scan
if remaining > interval_secs:
print(f" Next scan in {interval_secs:.0f}s...")
time.sleep(interval_secs)
except KeyboardInterrupt:
print("\n Drift scan interrupted")
finally:
if csv_file:
csv_file.close()
print(f" Data saved to {output_path}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="h21cm.py",
description="Hydrogen 21 cm line radiometer for SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s # single sweep, print spectrum
%(prog)s --averages 8 # 8x averaging for better SNR
%(prog)s --drift --duration 3600 # 1-hour drift scan
%(prog)s --drift --motor-step 5 # step motor between sweeps
%(prog)s --output h21cm-data.csv # log to CSV
%(prog)s --control # include control band comparison
notes:
- Connect an L-band antenna directly to the F-connector (no LNB)
- LNB power is disabled automatically for direct input
- Hydrogen emission is weak; use --averages 4-16 for best results
- The --dwell option increases per-step integration time (default 50ms)
- Earth rotation provides natural sky drift at ~15 deg/hour
""",
)
parser.add_argument('-v', '--verbose', action='store_true',
help="Show raw USB traffic")
parser.add_argument('--center', type=float, default=H1_FREQ_MHZ,
help=f"Center frequency in MHz (default: {H1_FREQ_MHZ})")
parser.add_argument('--span', type=float, default=4.0,
help="Frequency span in MHz (default: 4.0)")
parser.add_argument('--step', type=float, default=0.5,
help="Frequency step in MHz (default: 0.5)")
parser.add_argument('--dwell', type=int, default=50,
help="Dwell time per step in ms (default: 50)")
parser.add_argument('--averages', type=int, default=1,
help="Number of sweeps to average (default: 1)")
parser.add_argument('--output', '-o', type=str, default=None,
help="CSV output file path")
parser.add_argument('--control', action='store_true',
help="Include control band (1430-1434 MHz) for comparison")
parser.add_argument('--no-velocity', action='store_true',
help="Don't show velocity axis in spectrum display")
drift_group = parser.add_argument_group('drift scan')
drift_group.add_argument('--drift', action='store_true',
help="Enable drift scan mode (repeated sweeps)")
drift_group.add_argument('--duration', type=float, default=3600,
help="Drift scan duration in seconds (default: 3600)")
drift_group.add_argument('--interval', type=float, default=60,
help="Seconds between sweeps (default: 60)")
drift_group.add_argument('--motor-step', type=int, default=0,
help="Motor steps between sweeps (0=no motor, default: 0)")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
with SkyWalker1(verbose=args.verbose) as sw:
sw.ensure_booted()
# Disable LNB power for direct input
sw.start_intersil(on=False)
print("LNB power disabled (direct L-band input mode)")
if args.drift:
drift_scan(sw, duration_secs=args.duration,
interval_secs=args.interval,
step_mhz=args.step, dwell_ms=args.dwell,
averages=args.averages, motor_step=args.motor_step,
output_path=args.output)
else:
# Single sweep
print(f"\nSweeping {args.center - args.span/2:.1f} - "
f"{args.center + args.span/2:.1f} MHz "
f"(step={args.step} MHz, dwell={args.dwell}ms, "
f"avg={args.averages}x)")
result = sweep_h1_band(sw, center_mhz=args.center,
span_mhz=args.span, step_mhz=args.step,
dwell_ms=args.dwell, averages=args.averages)
print_spectrum(result, show_velocity=not args.no_velocity)
if args.control:
print(" Control band (1430-1434 MHz, no hydrogen expected):")
ctrl = sweep_control_band(sw, step_mhz=args.step, dwell_ms=args.dwell)
print(f" Control mean: {ctrl['control_mean_db']:.2f} dB")
print(f" H1 baseline: {result['baseline_db']:.2f} dB")
diff = result["peak_excess_db"]
print(f" H1 peak excess above baseline: {diff:+.2f} dB")
# Write single sweep to CSV if requested
if args.output:
with open(args.output, 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow([
"freq_mhz", "power_db", "excess_db",
"velocity_km_s", "baseline_db",
])
for i in range(len(result["freqs_mhz"])):
writer.writerow([
f"{result['freqs_mhz'][i]:.3f}",
f"{result['powers_db'][i]:.3f}",
f"{result['excess_db'][i]:.3f}",
f"{result['velocities_km_s'][i]:.1f}",
f"{result['baseline_db']:.3f}",
])
print(f" Data saved to {args.output}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,239 +1,239 @@
#!/usr/bin/env python3
"""
Enhanced signal analysis for carrier detection and characterization.
Goes beyond the basic detect_peaks() in skywalker_lib by using robust
noise floor estimation (median + MAD), peak width measurement at -3dB,
peak merging within estimated carrier bandwidth, and carrier classification.
"""
import math
import statistics
def adaptive_noise_floor(powers: list) -> tuple:
"""
Robust noise floor estimation using median + MAD.
The median is insensitive to strong carriers in the sweep, and the
Median Absolute Deviation (MAD) provides a robust spread measure
that won't be pulled by a few dominant peaks.
Returns (noise_floor_db, mad_db).
"""
if not powers:
return (0.0, 0.0)
median_power = statistics.median(powers)
deviations = [abs(p - median_power) for p in powers]
mad = statistics.median(deviations) if deviations else 0.0
# The noise floor sits at the median -- most bins are noise in a
# typical satellite IF sweep. MAD gives us an idea of how "bumpy"
# the noise is, useful for setting adaptive thresholds.
return (median_power, mad)
def detect_peaks_enhanced(freqs: list, powers: list,
threshold_db: float = 6.0) -> list:
"""
Enhanced peak detection with width estimation and merging.
Returns list of dicts, each containing:
freq - center frequency in MHz
power - peak power in dB (relative)
index - index into freqs/powers arrays
width_mhz - estimated carrier bandwidth at -3dB
prominence_db - peak power above noise floor
Steps:
1. Compute adaptive noise floor (median + MAD)
2. Find local maxima above noise_floor + threshold_db
3. Estimate -3dB width around each peak
4. Merge peaks whose -3dB extents overlap (same carrier)
"""
if len(powers) < 3 or len(freqs) != len(powers):
return []
noise_floor, mad = adaptive_noise_floor(powers)
# Effective threshold: user threshold, but never below 3x MAD to
# avoid chasing noise ripples.
effective_threshold = max(threshold_db, 3.0 * mad) if mad > 0 else threshold_db
min_power = noise_floor + effective_threshold
# Step 1: find raw local maxima
raw_peaks = []
for i in range(1, len(powers) - 1):
if powers[i] > powers[i - 1] and powers[i] > powers[i + 1]:
if powers[i] >= min_power:
raw_peaks.append(i)
# Also check endpoints if they are strong
if len(powers) >= 2:
if powers[0] > powers[1] and powers[0] >= min_power:
raw_peaks.insert(0, 0)
if powers[-1] > powers[-2] and powers[-1] >= min_power:
raw_peaks.append(len(powers) - 1)
if not raw_peaks:
return []
# Step 2: measure width and build peak dicts
peaks = []
for idx in raw_peaks:
bw = estimate_carrier_bw(freqs, powers, idx)
prominence = powers[idx] - noise_floor
peaks.append({
"freq": freqs[idx],
"power": powers[idx],
"index": idx,
"width_mhz": bw,
"prominence_db": prominence,
})
# Step 3: merge overlapping peaks (keep the stronger one)
merged = _merge_peaks(peaks)
return merged
def _merge_peaks(peaks: list) -> list:
"""
Merge peaks whose -3dB extents overlap.
When two peaks are closer together than the sum of their half-widths
they likely belong to the same carrier. Keep the stronger peak and
take the wider bandwidth.
"""
if len(peaks) <= 1:
return peaks
# Sort by frequency
peaks = sorted(peaks, key=lambda p: p["freq"])
merged = [peaks[0]]
for peak in peaks[1:]:
prev = merged[-1]
# Half-widths
prev_upper = prev["freq"] + prev["width_mhz"] / 2
peak_lower = peak["freq"] - peak["width_mhz"] / 2
if peak_lower <= prev_upper:
# Overlap: keep the stronger peak, widen the bandwidth
if peak["power"] > prev["power"]:
wider = max(prev["width_mhz"], peak["width_mhz"],
(peak["freq"] + peak["width_mhz"] / 2) -
(prev["freq"] - prev["width_mhz"] / 2))
peak["width_mhz"] = wider
merged[-1] = peak
else:
wider = max(prev["width_mhz"], peak["width_mhz"],
(peak["freq"] + peak["width_mhz"] / 2) -
(prev["freq"] - prev["width_mhz"] / 2))
prev["width_mhz"] = wider
else:
merged.append(peak)
return merged
def estimate_carrier_bw(freqs: list, powers: list,
peak_idx: int) -> float:
"""
Estimate carrier bandwidth by walking from peak until power drops
3 dB below the peak value (the -3dB bandwidth).
Walks left and right from the peak index, interpolating between
adjacent frequency bins when the -3dB crossing falls between them.
Returns estimated bandwidth in MHz. Minimum return is one frequency
step width (avoids zero-width artifacts on single-bin peaks).
"""
if peak_idx < 0 or peak_idx >= len(powers):
return 0.0
peak_power = powers[peak_idx]
cutoff = peak_power - 3.0
# Minimum step size for fallback
if len(freqs) >= 2:
step = abs(freqs[1] - freqs[0])
else:
return 0.0
# Walk left
left_freq = freqs[peak_idx]
for i in range(peak_idx - 1, -1, -1):
if powers[i] <= cutoff:
# Interpolate between bin i and bin i+1
if powers[i + 1] != powers[i]:
frac = (cutoff - powers[i]) / (powers[i + 1] - powers[i])
else:
frac = 0.5
left_freq = freqs[i] + frac * (freqs[i + 1] - freqs[i])
break
left_freq = freqs[i]
# Walk right
right_freq = freqs[peak_idx]
for i in range(peak_idx + 1, len(powers)):
if powers[i] <= cutoff:
if powers[i - 1] != powers[i]:
frac = (cutoff - powers[i]) / (powers[i - 1] - powers[i])
else:
frac = 0.5
right_freq = freqs[i] - frac * (freqs[i] - freqs[i - 1])
break
right_freq = freqs[i]
bw = right_freq - left_freq
return max(bw, step)
def classify_carrier(bw_mhz: float, power_db: float) -> dict:
"""
Classify a detected carrier based on bandwidth and power.
Uses empirical ranges for DVB-S symbol rates: SR (Msps) is roughly
BW (MHz) / 1.35 for QPSK with roll-off 0.35.
Returns dict with:
estimated_sr_range - (min_sps, max_sps) tuple
likely_modulation - list of plausible modulation names
signal_quality - 'strong', 'moderate', or 'weak'
"""
# Roll-off factor for DVB-S is typically 0.35, so BW ~ SR * 1.35.
# Allow some tolerance on both sides.
sr_center = bw_mhz / 1.35 # Msps
sr_min = int(max(256_000, (bw_mhz / 1.5) * 1_000_000))
sr_max = int(min(30_000_000, (bw_mhz / 1.2) * 1_000_000))
if sr_max < sr_min:
sr_max = sr_min
# Guess modulation based on bandwidth
likely_mods = []
if bw_mhz < 3.0:
# Narrow carrier: low-SR data channels, SCPC, DCII split
likely_mods = ["qpsk", "dcii-i", "dcii-q", "dss"]
elif bw_mhz < 10.0:
# Medium: typical SCPC, small MCPC
likely_mods = ["qpsk", "turbo-qpsk", "dcii-combo"]
elif bw_mhz < 20.0:
# Wide: MCPC transponders
likely_mods = ["qpsk", "turbo-qpsk", "turbo-8psk"]
else:
# Very wide: full transponder, high-SR
likely_mods = ["qpsk", "turbo-qpsk", "turbo-8psk", "dcii-combo"]
# Signal quality heuristic (relative power, device-dependent)
if power_db > -10.0:
quality = "strong"
elif power_db > -25.0:
quality = "moderate"
else:
quality = "weak"
return {
"estimated_sr_range": (sr_min, sr_max),
"likely_modulation": likely_mods,
"signal_quality": quality,
}
#!/usr/bin/env python3
"""
Enhanced signal analysis for carrier detection and characterization.
Goes beyond the basic detect_peaks() in skywalker_lib by using robust
noise floor estimation (median + MAD), peak width measurement at -3dB,
peak merging within estimated carrier bandwidth, and carrier classification.
"""
import math
import statistics
def adaptive_noise_floor(powers: list) -> tuple:
"""
Robust noise floor estimation using median + MAD.
The median is insensitive to strong carriers in the sweep, and the
Median Absolute Deviation (MAD) provides a robust spread measure
that won't be pulled by a few dominant peaks.
Returns (noise_floor_db, mad_db).
"""
if not powers:
return (0.0, 0.0)
median_power = statistics.median(powers)
deviations = [abs(p - median_power) for p in powers]
mad = statistics.median(deviations) if deviations else 0.0
# The noise floor sits at the median -- most bins are noise in a
# typical satellite IF sweep. MAD gives us an idea of how "bumpy"
# the noise is, useful for setting adaptive thresholds.
return (median_power, mad)
def detect_peaks_enhanced(freqs: list, powers: list,
threshold_db: float = 6.0) -> list:
"""
Enhanced peak detection with width estimation and merging.
Returns list of dicts, each containing:
freq - center frequency in MHz
power - peak power in dB (relative)
index - index into freqs/powers arrays
width_mhz - estimated carrier bandwidth at -3dB
prominence_db - peak power above noise floor
Steps:
1. Compute adaptive noise floor (median + MAD)
2. Find local maxima above noise_floor + threshold_db
3. Estimate -3dB width around each peak
4. Merge peaks whose -3dB extents overlap (same carrier)
"""
if len(powers) < 3 or len(freqs) != len(powers):
return []
noise_floor, mad = adaptive_noise_floor(powers)
# Effective threshold: user threshold, but never below 3x MAD to
# avoid chasing noise ripples.
effective_threshold = max(threshold_db, 3.0 * mad) if mad > 0 else threshold_db
min_power = noise_floor + effective_threshold
# Step 1: find raw local maxima
raw_peaks = []
for i in range(1, len(powers) - 1):
if powers[i] > powers[i - 1] and powers[i] > powers[i + 1]:
if powers[i] >= min_power:
raw_peaks.append(i)
# Also check endpoints if they are strong
if len(powers) >= 2:
if powers[0] > powers[1] and powers[0] >= min_power:
raw_peaks.insert(0, 0)
if powers[-1] > powers[-2] and powers[-1] >= min_power:
raw_peaks.append(len(powers) - 1)
if not raw_peaks:
return []
# Step 2: measure width and build peak dicts
peaks = []
for idx in raw_peaks:
bw = estimate_carrier_bw(freqs, powers, idx)
prominence = powers[idx] - noise_floor
peaks.append({
"freq": freqs[idx],
"power": powers[idx],
"index": idx,
"width_mhz": bw,
"prominence_db": prominence,
})
# Step 3: merge overlapping peaks (keep the stronger one)
merged = _merge_peaks(peaks)
return merged
def _merge_peaks(peaks: list) -> list:
"""
Merge peaks whose -3dB extents overlap.
When two peaks are closer together than the sum of their half-widths
they likely belong to the same carrier. Keep the stronger peak and
take the wider bandwidth.
"""
if len(peaks) <= 1:
return peaks
# Sort by frequency
peaks = sorted(peaks, key=lambda p: p["freq"])
merged = [peaks[0]]
for peak in peaks[1:]:
prev = merged[-1]
# Half-widths
prev_upper = prev["freq"] + prev["width_mhz"] / 2
peak_lower = peak["freq"] - peak["width_mhz"] / 2
if peak_lower <= prev_upper:
# Overlap: keep the stronger peak, widen the bandwidth
if peak["power"] > prev["power"]:
wider = max(prev["width_mhz"], peak["width_mhz"],
(peak["freq"] + peak["width_mhz"] / 2) -
(prev["freq"] - prev["width_mhz"] / 2))
peak["width_mhz"] = wider
merged[-1] = peak
else:
wider = max(prev["width_mhz"], peak["width_mhz"],
(peak["freq"] + peak["width_mhz"] / 2) -
(prev["freq"] - prev["width_mhz"] / 2))
prev["width_mhz"] = wider
else:
merged.append(peak)
return merged
def estimate_carrier_bw(freqs: list, powers: list,
peak_idx: int) -> float:
"""
Estimate carrier bandwidth by walking from peak until power drops
3 dB below the peak value (the -3dB bandwidth).
Walks left and right from the peak index, interpolating between
adjacent frequency bins when the -3dB crossing falls between them.
Returns estimated bandwidth in MHz. Minimum return is one frequency
step width (avoids zero-width artifacts on single-bin peaks).
"""
if peak_idx < 0 or peak_idx >= len(powers):
return 0.0
peak_power = powers[peak_idx]
cutoff = peak_power - 3.0
# Minimum step size for fallback
if len(freqs) >= 2:
step = abs(freqs[1] - freqs[0])
else:
return 0.0
# Walk left
left_freq = freqs[peak_idx]
for i in range(peak_idx - 1, -1, -1):
if powers[i] <= cutoff:
# Interpolate between bin i and bin i+1
if powers[i + 1] != powers[i]:
frac = (cutoff - powers[i]) / (powers[i + 1] - powers[i])
else:
frac = 0.5
left_freq = freqs[i] + frac * (freqs[i + 1] - freqs[i])
break
left_freq = freqs[i]
# Walk right
right_freq = freqs[peak_idx]
for i in range(peak_idx + 1, len(powers)):
if powers[i] <= cutoff:
if powers[i - 1] != powers[i]:
frac = (cutoff - powers[i]) / (powers[i - 1] - powers[i])
else:
frac = 0.5
right_freq = freqs[i] - frac * (freqs[i] - freqs[i - 1])
break
right_freq = freqs[i]
bw = right_freq - left_freq
return max(bw, step)
def classify_carrier(bw_mhz: float, power_db: float) -> dict:
"""
Classify a detected carrier based on bandwidth and power.
Uses empirical ranges for DVB-S symbol rates: SR (Msps) is roughly
BW (MHz) / 1.35 for QPSK with roll-off 0.35.
Returns dict with:
estimated_sr_range - (min_sps, max_sps) tuple
likely_modulation - list of plausible modulation names
signal_quality - 'strong', 'moderate', or 'weak'
"""
# Roll-off factor for DVB-S is typically 0.35, so BW ~ SR * 1.35.
# Allow some tolerance on both sides.
sr_center = bw_mhz / 1.35 # Msps
sr_min = int(max(256_000, (bw_mhz / 1.5) * 1_000_000))
sr_max = int(min(30_000_000, (bw_mhz / 1.2) * 1_000_000))
if sr_max < sr_min:
sr_max = sr_min
# Guess modulation based on bandwidth
likely_mods = []
if bw_mhz < 3.0:
# Narrow carrier: low-SR data channels, SCPC, DCII split
likely_mods = ["qpsk", "dcii-i", "dcii-q", "dss"]
elif bw_mhz < 10.0:
# Medium: typical SCPC, small MCPC
likely_mods = ["qpsk", "turbo-qpsk", "dcii-combo"]
elif bw_mhz < 20.0:
# Wide: MCPC transponders
likely_mods = ["qpsk", "turbo-qpsk", "turbo-8psk"]
else:
# Very wide: full transponder, high-SR
likely_mods = ["qpsk", "turbo-qpsk", "turbo-8psk", "dcii-combo"]
# Signal quality heuristic (relative power, device-dependent)
if power_db > -10.0:
quality = "strong"
elif power_db > -25.0:
quality = "moderate"
else:
quality = "weak"
return {
"estimated_sr_range": (sr_min, sr_max),
"likely_modulation": likely_mods,
"signal_quality": quality,
}

File diff suppressed because it is too large Load diff

View file

@ -1,455 +1,455 @@
#!/usr/bin/env python3
"""
Carrier survey CLI for the Genpix SkyWalker-1.
Subcommands:
full-scan Full six-stage carrier survey
quick-scan Fast sweep + peak detection only
diff Compare two saved survey catalogs
export Export a survey to CSV, JSON, or text
view View the latest or a specified survey
qo100 QO-100 narrowband transponder survey with optimized params
"""
import sys
import os
import argparse
import csv
import io
import json
import time
# Ensure the tools directory is on the import path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1
from signal_analysis import adaptive_noise_floor, detect_peaks_enhanced, classify_carrier
from carrier_catalog import CarrierCatalog, CarrierEntry, CatalogDiff, CATALOG_DIR
from survey_engine import SurveyEngine
def progress_callback(verbose: bool):
"""Return a callback function for SurveyEngine progress reporting."""
def cb(stage, pct, msg):
if verbose:
print(f" [{stage:>17s}] {pct:5.1f}% {msg}", file=sys.stderr)
else:
sys.stderr.write(f"\r {stage}: {pct:.0f}% {msg[:60]:<60s}")
sys.stderr.flush()
return cb
# -- Subcommand handlers --
def cmd_full_scan(args: argparse.Namespace) -> None:
"""Run a full six-stage carrier survey."""
print(f"SkyWalker-1 Full Carrier Survey")
print(f" Range: {args.start}-{args.stop} MHz")
print(f" Coarse step: {args.coarse_step} MHz, Fine step: {args.fine_step} MHz")
print(f" SR range: {args.sr_min / 1e6:.1f} - {args.sr_max / 1e6:.1f} Msps")
print()
cb = progress_callback(args.verbose)
with SkyWalker1(verbose=args.verbose) as dev:
dev.ensure_booted()
if args.pol or args.band:
dev.configure_lnb(pol=args.pol, band=args.band)
engine = SurveyEngine(dev, callback=cb)
catalog = engine.run_full_scan(
start_mhz=args.start,
stop_mhz=args.stop,
coarse_step=args.coarse_step,
fine_step=args.fine_step,
sr_min=args.sr_min,
sr_max=args.sr_max,
sr_step=args.sr_step,
)
if not args.verbose:
sys.stderr.write("\r" + " " * 80 + "\r")
sys.stderr.flush()
# Set catalog metadata
catalog.band = args.band or ""
catalog.pol = args.pol or ""
if args.name:
catalog.name = args.name
# Save
if args.output:
path = catalog.save(args.output)
else:
path = catalog.save()
print()
print(catalog.summary())
print()
print(f"Saved to: {path}")
def cmd_quick_scan(args: argparse.Namespace) -> None:
"""Quick sweep + peak detection, no blind scan."""
print(f"SkyWalker-1 Quick Scan")
print(f" Range: {args.start}-{args.stop} MHz, step: {args.step} MHz")
print()
cb = progress_callback(args.verbose)
with SkyWalker1(verbose=args.verbose) as dev:
dev.ensure_booted()
if args.pol or args.band:
dev.configure_lnb(pol=args.pol, band=args.band)
engine = SurveyEngine(dev, callback=cb)
peaks = engine.run_quick_scan(
start_mhz=args.start,
stop_mhz=args.stop,
step=args.step,
)
if not args.verbose:
sys.stderr.write("\r" + " " * 80 + "\r")
sys.stderr.flush()
if not peaks:
print("No peaks detected above noise floor.")
return
print(f"\nDetected {len(peaks)} carrier(s):\n")
print(f" {'#':>3} {'Freq (MHz)':>10} {'Power (dB)':>10} "
f"{'BW (MHz)':>8} {'Prominence':>10} Quality")
print(f" {'---':>3} {'----------':>10} {'----------':>10} "
f"{'--------':>8} {'----------':>10} -------")
for i, p in enumerate(sorted(peaks, key=lambda x: x["freq"]), 1):
cls = p.get("classification", classify_carrier(p["width_mhz"], p["power"]))
quality = cls.get("signal_quality", "?")
print(f" {i:3d} {p['freq']:>10.1f} {p['power']:>+10.1f} "
f"{p['width_mhz']:>8.1f} {p['prominence_db']:>+10.1f} {quality}")
def cmd_diff(args: argparse.Namespace) -> None:
"""Compare two survey catalog files."""
try:
old_cat = CarrierCatalog.load(args.file1)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Cannot load {args.file1}: {e}", file=sys.stderr)
sys.exit(1)
try:
new_cat = CarrierCatalog.load(args.file2)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Cannot load {args.file2}: {e}", file=sys.stderr)
sys.exit(1)
print(f"Comparing surveys:")
print(f" Old: {args.file1} ({old_cat.created})")
print(f" New: {args.file2} ({new_cat.created})")
print()
diff = CatalogDiff.diff(old_cat, new_cat)
print(CatalogDiff.format_diff(diff))
if args.output:
with open(args.output, 'w') as f:
json.dump(diff, f, indent=2)
print(f"\nDiff saved to: {args.output}")
def cmd_export(args: argparse.Namespace) -> None:
"""Export a survey catalog to CSV, JSON, or text."""
try:
catalog = CarrierCatalog.load(args.file)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Cannot load {args.file}: {e}", file=sys.stderr)
sys.exit(1)
fmt = args.format
if fmt == "json":
output = json.dumps(catalog.to_dict(), indent=2)
elif fmt == "csv":
output = _catalog_to_csv(catalog)
else:
output = catalog.summary()
if args.output:
with open(args.output, 'w') as f:
f.write(output)
print(f"Exported to: {args.output}")
else:
print(output)
def cmd_view(args: argparse.Namespace) -> None:
"""View a specific survey or the latest one."""
if args.file:
filename = args.file
else:
surveys = CarrierCatalog.list_surveys()
if not surveys:
print(f"No surveys found in {CATALOG_DIR}")
sys.exit(1)
filename = surveys[0]["path"]
print(f"(Showing latest: {surveys[0]['filename']})\n")
try:
catalog = CarrierCatalog.load(filename)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Cannot load {filename}: {e}", file=sys.stderr)
sys.exit(1)
print(catalog.summary())
if args.verbose and catalog.carriers:
print(f"\nDetailed carrier info:")
for i, c in enumerate(sorted(catalog.carriers, key=lambda x: x.freq_khz), 1):
print(f"\n --- Carrier {i} ---")
print(f" Frequency: {c.freq_mhz:.3f} MHz ({c.freq_khz} kHz)")
print(f" Power: {c.power_db:+.1f} dB")
print(f" SNR: {c.snr_db:.1f} dB")
if c.sr_sps:
print(f" Symbol rate: {c.sr_sps} sps ({c.sr_sps / 1e6:.3f} Msps)")
if c.modulation:
print(f" Modulation: {c.modulation}")
if c.fec:
print(f" FEC: {c.fec}")
print(f" Locked: {c.locked}")
print(f" Bandwidth: {c.bw_mhz:.1f} MHz")
if c.services:
print(f" Services: {', '.join(c.services)}")
print(f" First seen: {c.first_seen}")
print(f" Last seen: {c.last_seen}")
print(f" Scan count: {c.scan_count}")
if c.classification:
cls = c.classification
if "estimated_sr_range" in cls:
sr_lo, sr_hi = cls["estimated_sr_range"]
print(f" Est. SR: {sr_lo / 1e6:.1f} - {sr_hi / 1e6:.1f} Msps")
if "likely_modulation" in cls:
print(f" Likely mod: {', '.join(cls['likely_modulation'])}")
if "signal_quality" in cls:
print(f" Quality: {cls['signal_quality']}")
def cmd_qo100(args: argparse.Namespace) -> None:
"""
QO-100 narrowband transponder survey with optimized parameters.
QO-100 (Es'hail-2) narrowband transponder: 10489.500 - 10489.800 MHz
With a typical LNB LO of 9750 MHz, the IF range is ~739.5 - 739.8 MHz.
Since most QO-100 NB signals are very narrow (< 3 kHz audio, 1-2.7 ksps
digital), this mode uses the finest practical sweep resolution and
restricted SR range.
"""
lnb_lo = args.lnb_lo
# QO-100 NB transponder: 10489.500 - 10489.800 MHz
rf_start = 10489.5
rf_stop = 10489.8
if_start = rf_start - lnb_lo
if_stop = rf_stop - lnb_lo
# Validate IF range is within device capability
if if_start < 950 or if_stop > 2150:
print(f"QO-100 IF range ({if_start:.1f} - {if_stop:.1f} MHz) is outside "
f"the 950-2150 MHz hardware range with LNB LO={lnb_lo} MHz.",
file=sys.stderr)
print(f"Check your LNB LO frequency.", file=sys.stderr)
sys.exit(1)
print(f"QO-100 Narrowband Transponder Survey")
print(f" LNB LO: {lnb_lo} MHz")
print(f" RF range: {rf_start:.3f} - {rf_stop:.3f} MHz")
print(f" IF range: {if_start:.3f} - {if_stop:.3f} MHz")
print()
# QO-100 NB uses very low symbol rates (1-33 ksps typical for DVB-S)
# The SkyWalker-1 minimum is 256 ksps, so we set a narrow range
sr_min = 256_000
sr_max = 2_000_000
sr_step = 100_000
cb = progress_callback(args.verbose)
with SkyWalker1(verbose=args.verbose) as dev:
dev.ensure_booted()
# QO-100 is H-pol on most setups, high band for 10 GHz
dev.configure_lnb(pol="H", band="high", lnb_lo=lnb_lo)
engine = SurveyEngine(dev, callback=cb)
catalog = engine.run_full_scan(
start_mhz=if_start,
stop_mhz=if_stop,
coarse_step=0.5, # 500 kHz steps for the narrow band
fine_step=0.1, # 100 kHz fine resolution
sr_min=sr_min,
sr_max=sr_max,
sr_step=sr_step,
)
if not args.verbose:
sys.stderr.write("\r" + " " * 80 + "\r")
sys.stderr.flush()
catalog.name = "QO-100 Narrowband"
catalog.band = "high"
catalog.pol = "H"
catalog.lnb_lo_mhz = lnb_lo
catalog.notes = (f"QO-100 Es'hail-2 narrowband transponder. "
f"RF {rf_start}-{rf_stop} MHz, LNB LO {lnb_lo} MHz.")
if args.output:
path = catalog.save(args.output)
else:
path = catalog.save(f"survey-qo100-nb-{time.strftime('%Y-%m-%d')}.json")
print()
print(catalog.summary())
print()
print(f"Saved to: {path}")
# -- Helpers --
def _catalog_to_csv(catalog: CarrierCatalog) -> str:
"""Convert a catalog to CSV format."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow([
"freq_khz", "freq_mhz", "sr_sps", "modulation", "fec",
"power_db", "snr_db", "locked", "bw_mhz", "services",
"first_seen", "last_seen", "scan_count",
])
for c in sorted(catalog.carriers, key=lambda x: x.freq_khz):
writer.writerow([
c.freq_khz, f"{c.freq_mhz:.3f}", c.sr_sps,
c.modulation, c.fec,
f"{c.power_db:.1f}", f"{c.snr_db:.1f}",
c.locked, f"{c.bw_mhz:.1f}",
"|".join(c.services),
c.first_seen, c.last_seen, c.scan_count,
])
return buf.getvalue()
# -- CLI --
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Carrier survey tool for the Genpix SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s full-scan
%(prog)s full-scan --start 1100 --stop 1200 --output my-scan.json
%(prog)s quick-scan
%(prog)s diff survey-2026-02-14-low-V.json survey-2026-02-15-low-V.json
%(prog)s export survey-2026-02-15-low-V.json --format csv
%(prog)s view
%(prog)s qo100 --lnb-lo 9750
""")
parser.add_argument('-v', '--verbose', action='store_true',
help="Verbose progress and debug output")
sub = parser.add_subparsers(dest='command')
# full-scan
p_full = sub.add_parser('full-scan', help="Full six-stage carrier survey")
p_full.add_argument('--start', type=float, default=950,
help="Start frequency in MHz (default: 950)")
p_full.add_argument('--stop', type=float, default=2150,
help="Stop frequency in MHz (default: 2150)")
p_full.add_argument('--coarse-step', type=float, default=5.0,
help="Coarse sweep step in MHz (default: 5.0)")
p_full.add_argument('--fine-step', type=float, default=1.0,
help="Fine sweep step in MHz (default: 1.0)")
p_full.add_argument('--sr-min', type=int, default=1_000_000,
help="Min symbol rate for blind scan in sps (default: 1000000)")
p_full.add_argument('--sr-max', type=int, default=30_000_000,
help="Max symbol rate for blind scan in sps (default: 30000000)")
p_full.add_argument('--sr-step', type=int, default=1_000_000,
help="Symbol rate step for blind scan in sps (default: 1000000)")
p_full.add_argument('--pol', choices=['H', 'V', 'L', 'R'],
help="LNB polarization")
p_full.add_argument('--band', choices=['low', 'high'],
help="LNB band (low/high)")
p_full.add_argument('--name', type=str, default="",
help="Survey name/label")
p_full.add_argument('--output', '-o', type=str, default=None,
help="Output filename (default: auto-generated)")
# quick-scan
p_quick = sub.add_parser('quick-scan', help="Quick sweep + peak detection")
p_quick.add_argument('--start', type=float, default=950,
help="Start frequency in MHz (default: 950)")
p_quick.add_argument('--stop', type=float, default=2150,
help="Stop frequency in MHz (default: 2150)")
p_quick.add_argument('--step', type=float, default=5.0,
help="Sweep step in MHz (default: 5.0)")
p_quick.add_argument('--pol', choices=['H', 'V', 'L', 'R'],
help="LNB polarization")
p_quick.add_argument('--band', choices=['low', 'high'],
help="LNB band (low/high)")
# diff
p_diff = sub.add_parser('diff', help="Compare two survey catalogs")
p_diff.add_argument('file1', help="Older survey file")
p_diff.add_argument('file2', help="Newer survey file")
p_diff.add_argument('--output', '-o', type=str, default=None,
help="Save diff as JSON to this file")
# export
p_export = sub.add_parser('export', help="Export survey to CSV/JSON/text")
p_export.add_argument('file', help="Survey file to export")
p_export.add_argument('--format', '-f', choices=['csv', 'json', 'text'],
default='text', help="Output format (default: text)")
p_export.add_argument('--output', '-o', type=str, default=None,
help="Output file (default: stdout)")
# view
p_view = sub.add_parser('view', help="View a survey (latest if no file given)")
p_view.add_argument('file', nargs='?', default=None,
help="Survey file to view (default: latest)")
# qo100
p_qo100 = sub.add_parser('qo100',
help="QO-100 narrowband transponder survey")
p_qo100.add_argument('--lnb-lo', type=float, required=True,
help="LNB local oscillator frequency in MHz "
"(e.g., 9750 for universal LNB low band)")
p_qo100.add_argument('--output', '-o', type=str, default=None,
help="Output filename (default: auto-generated)")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
dispatch = {
'full-scan': cmd_full_scan,
'quick-scan': cmd_quick_scan,
'diff': cmd_diff,
'export': cmd_export,
'view': cmd_view,
'qo100': cmd_qo100,
}
handler = dispatch.get(args.command)
if handler is None:
parser.print_help()
sys.exit(1)
handler(args)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Carrier survey CLI for the Genpix SkyWalker-1.
Subcommands:
full-scan Full six-stage carrier survey
quick-scan Fast sweep + peak detection only
diff Compare two saved survey catalogs
export Export a survey to CSV, JSON, or text
view View the latest or a specified survey
qo100 QO-100 narrowband transponder survey with optimized params
"""
import sys
import os
import argparse
import csv
import io
import json
import time
# Ensure the tools directory is on the import path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1
from signal_analysis import adaptive_noise_floor, detect_peaks_enhanced, classify_carrier
from carrier_catalog import CarrierCatalog, CarrierEntry, CatalogDiff, CATALOG_DIR
from survey_engine import SurveyEngine
def progress_callback(verbose: bool):
"""Return a callback function for SurveyEngine progress reporting."""
def cb(stage, pct, msg):
if verbose:
print(f" [{stage:>17s}] {pct:5.1f}% {msg}", file=sys.stderr)
else:
sys.stderr.write(f"\r {stage}: {pct:.0f}% {msg[:60]:<60s}")
sys.stderr.flush()
return cb
# -- Subcommand handlers --
def cmd_full_scan(args: argparse.Namespace) -> None:
"""Run a full six-stage carrier survey."""
print(f"SkyWalker-1 Full Carrier Survey")
print(f" Range: {args.start}-{args.stop} MHz")
print(f" Coarse step: {args.coarse_step} MHz, Fine step: {args.fine_step} MHz")
print(f" SR range: {args.sr_min / 1e6:.1f} - {args.sr_max / 1e6:.1f} Msps")
print()
cb = progress_callback(args.verbose)
with SkyWalker1(verbose=args.verbose) as dev:
dev.ensure_booted()
if args.pol or args.band:
dev.configure_lnb(pol=args.pol, band=args.band)
engine = SurveyEngine(dev, callback=cb)
catalog = engine.run_full_scan(
start_mhz=args.start,
stop_mhz=args.stop,
coarse_step=args.coarse_step,
fine_step=args.fine_step,
sr_min=args.sr_min,
sr_max=args.sr_max,
sr_step=args.sr_step,
)
if not args.verbose:
sys.stderr.write("\r" + " " * 80 + "\r")
sys.stderr.flush()
# Set catalog metadata
catalog.band = args.band or ""
catalog.pol = args.pol or ""
if args.name:
catalog.name = args.name
# Save
if args.output:
path = catalog.save(args.output)
else:
path = catalog.save()
print()
print(catalog.summary())
print()
print(f"Saved to: {path}")
def cmd_quick_scan(args: argparse.Namespace) -> None:
"""Quick sweep + peak detection, no blind scan."""
print(f"SkyWalker-1 Quick Scan")
print(f" Range: {args.start}-{args.stop} MHz, step: {args.step} MHz")
print()
cb = progress_callback(args.verbose)
with SkyWalker1(verbose=args.verbose) as dev:
dev.ensure_booted()
if args.pol or args.band:
dev.configure_lnb(pol=args.pol, band=args.band)
engine = SurveyEngine(dev, callback=cb)
peaks = engine.run_quick_scan(
start_mhz=args.start,
stop_mhz=args.stop,
step=args.step,
)
if not args.verbose:
sys.stderr.write("\r" + " " * 80 + "\r")
sys.stderr.flush()
if not peaks:
print("No peaks detected above noise floor.")
return
print(f"\nDetected {len(peaks)} carrier(s):\n")
print(f" {'#':>3} {'Freq (MHz)':>10} {'Power (dB)':>10} "
f"{'BW (MHz)':>8} {'Prominence':>10} Quality")
print(f" {'---':>3} {'----------':>10} {'----------':>10} "
f"{'--------':>8} {'----------':>10} -------")
for i, p in enumerate(sorted(peaks, key=lambda x: x["freq"]), 1):
cls = p.get("classification", classify_carrier(p["width_mhz"], p["power"]))
quality = cls.get("signal_quality", "?")
print(f" {i:3d} {p['freq']:>10.1f} {p['power']:>+10.1f} "
f"{p['width_mhz']:>8.1f} {p['prominence_db']:>+10.1f} {quality}")
def cmd_diff(args: argparse.Namespace) -> None:
"""Compare two survey catalog files."""
try:
old_cat = CarrierCatalog.load(args.file1)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Cannot load {args.file1}: {e}", file=sys.stderr)
sys.exit(1)
try:
new_cat = CarrierCatalog.load(args.file2)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Cannot load {args.file2}: {e}", file=sys.stderr)
sys.exit(1)
print(f"Comparing surveys:")
print(f" Old: {args.file1} ({old_cat.created})")
print(f" New: {args.file2} ({new_cat.created})")
print()
diff = CatalogDiff.diff(old_cat, new_cat)
print(CatalogDiff.format_diff(diff))
if args.output:
with open(args.output, 'w') as f:
json.dump(diff, f, indent=2)
print(f"\nDiff saved to: {args.output}")
def cmd_export(args: argparse.Namespace) -> None:
"""Export a survey catalog to CSV, JSON, or text."""
try:
catalog = CarrierCatalog.load(args.file)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Cannot load {args.file}: {e}", file=sys.stderr)
sys.exit(1)
fmt = args.format
if fmt == "json":
output = json.dumps(catalog.to_dict(), indent=2)
elif fmt == "csv":
output = _catalog_to_csv(catalog)
else:
output = catalog.summary()
if args.output:
with open(args.output, 'w') as f:
f.write(output)
print(f"Exported to: {args.output}")
else:
print(output)
def cmd_view(args: argparse.Namespace) -> None:
"""View a specific survey or the latest one."""
if args.file:
filename = args.file
else:
surveys = CarrierCatalog.list_surveys()
if not surveys:
print(f"No surveys found in {CATALOG_DIR}")
sys.exit(1)
filename = surveys[0]["path"]
print(f"(Showing latest: {surveys[0]['filename']})\n")
try:
catalog = CarrierCatalog.load(filename)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Cannot load {filename}: {e}", file=sys.stderr)
sys.exit(1)
print(catalog.summary())
if args.verbose and catalog.carriers:
print(f"\nDetailed carrier info:")
for i, c in enumerate(sorted(catalog.carriers, key=lambda x: x.freq_khz), 1):
print(f"\n --- Carrier {i} ---")
print(f" Frequency: {c.freq_mhz:.3f} MHz ({c.freq_khz} kHz)")
print(f" Power: {c.power_db:+.1f} dB")
print(f" SNR: {c.snr_db:.1f} dB")
if c.sr_sps:
print(f" Symbol rate: {c.sr_sps} sps ({c.sr_sps / 1e6:.3f} Msps)")
if c.modulation:
print(f" Modulation: {c.modulation}")
if c.fec:
print(f" FEC: {c.fec}")
print(f" Locked: {c.locked}")
print(f" Bandwidth: {c.bw_mhz:.1f} MHz")
if c.services:
print(f" Services: {', '.join(c.services)}")
print(f" First seen: {c.first_seen}")
print(f" Last seen: {c.last_seen}")
print(f" Scan count: {c.scan_count}")
if c.classification:
cls = c.classification
if "estimated_sr_range" in cls:
sr_lo, sr_hi = cls["estimated_sr_range"]
print(f" Est. SR: {sr_lo / 1e6:.1f} - {sr_hi / 1e6:.1f} Msps")
if "likely_modulation" in cls:
print(f" Likely mod: {', '.join(cls['likely_modulation'])}")
if "signal_quality" in cls:
print(f" Quality: {cls['signal_quality']}")
def cmd_qo100(args: argparse.Namespace) -> None:
"""
QO-100 narrowband transponder survey with optimized parameters.
QO-100 (Es'hail-2) narrowband transponder: 10489.500 - 10489.800 MHz
With a typical LNB LO of 9750 MHz, the IF range is ~739.5 - 739.8 MHz.
Since most QO-100 NB signals are very narrow (< 3 kHz audio, 1-2.7 ksps
digital), this mode uses the finest practical sweep resolution and
restricted SR range.
"""
lnb_lo = args.lnb_lo
# QO-100 NB transponder: 10489.500 - 10489.800 MHz
rf_start = 10489.5
rf_stop = 10489.8
if_start = rf_start - lnb_lo
if_stop = rf_stop - lnb_lo
# Validate IF range is within device capability
if if_start < 950 or if_stop > 2150:
print(f"QO-100 IF range ({if_start:.1f} - {if_stop:.1f} MHz) is outside "
f"the 950-2150 MHz hardware range with LNB LO={lnb_lo} MHz.",
file=sys.stderr)
print(f"Check your LNB LO frequency.", file=sys.stderr)
sys.exit(1)
print(f"QO-100 Narrowband Transponder Survey")
print(f" LNB LO: {lnb_lo} MHz")
print(f" RF range: {rf_start:.3f} - {rf_stop:.3f} MHz")
print(f" IF range: {if_start:.3f} - {if_stop:.3f} MHz")
print()
# QO-100 NB uses very low symbol rates (1-33 ksps typical for DVB-S)
# The SkyWalker-1 minimum is 256 ksps, so we set a narrow range
sr_min = 256_000
sr_max = 2_000_000
sr_step = 100_000
cb = progress_callback(args.verbose)
with SkyWalker1(verbose=args.verbose) as dev:
dev.ensure_booted()
# QO-100 is H-pol on most setups, high band for 10 GHz
dev.configure_lnb(pol="H", band="high", lnb_lo=lnb_lo)
engine = SurveyEngine(dev, callback=cb)
catalog = engine.run_full_scan(
start_mhz=if_start,
stop_mhz=if_stop,
coarse_step=0.5, # 500 kHz steps for the narrow band
fine_step=0.1, # 100 kHz fine resolution
sr_min=sr_min,
sr_max=sr_max,
sr_step=sr_step,
)
if not args.verbose:
sys.stderr.write("\r" + " " * 80 + "\r")
sys.stderr.flush()
catalog.name = "QO-100 Narrowband"
catalog.band = "high"
catalog.pol = "H"
catalog.lnb_lo_mhz = lnb_lo
catalog.notes = (f"QO-100 Es'hail-2 narrowband transponder. "
f"RF {rf_start}-{rf_stop} MHz, LNB LO {lnb_lo} MHz.")
if args.output:
path = catalog.save(args.output)
else:
path = catalog.save(f"survey-qo100-nb-{time.strftime('%Y-%m-%d')}.json")
print()
print(catalog.summary())
print()
print(f"Saved to: {path}")
# -- Helpers --
def _catalog_to_csv(catalog: CarrierCatalog) -> str:
"""Convert a catalog to CSV format."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow([
"freq_khz", "freq_mhz", "sr_sps", "modulation", "fec",
"power_db", "snr_db", "locked", "bw_mhz", "services",
"first_seen", "last_seen", "scan_count",
])
for c in sorted(catalog.carriers, key=lambda x: x.freq_khz):
writer.writerow([
c.freq_khz, f"{c.freq_mhz:.3f}", c.sr_sps,
c.modulation, c.fec,
f"{c.power_db:.1f}", f"{c.snr_db:.1f}",
c.locked, f"{c.bw_mhz:.1f}",
"|".join(c.services),
c.first_seen, c.last_seen, c.scan_count,
])
return buf.getvalue()
# -- CLI --
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Carrier survey tool for the Genpix SkyWalker-1",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
examples:
%(prog)s full-scan
%(prog)s full-scan --start 1100 --stop 1200 --output my-scan.json
%(prog)s quick-scan
%(prog)s diff survey-2026-02-14-low-V.json survey-2026-02-15-low-V.json
%(prog)s export survey-2026-02-15-low-V.json --format csv
%(prog)s view
%(prog)s qo100 --lnb-lo 9750
""")
parser.add_argument('-v', '--verbose', action='store_true',
help="Verbose progress and debug output")
sub = parser.add_subparsers(dest='command')
# full-scan
p_full = sub.add_parser('full-scan', help="Full six-stage carrier survey")
p_full.add_argument('--start', type=float, default=950,
help="Start frequency in MHz (default: 950)")
p_full.add_argument('--stop', type=float, default=2150,
help="Stop frequency in MHz (default: 2150)")
p_full.add_argument('--coarse-step', type=float, default=5.0,
help="Coarse sweep step in MHz (default: 5.0)")
p_full.add_argument('--fine-step', type=float, default=1.0,
help="Fine sweep step in MHz (default: 1.0)")
p_full.add_argument('--sr-min', type=int, default=1_000_000,
help="Min symbol rate for blind scan in sps (default: 1000000)")
p_full.add_argument('--sr-max', type=int, default=30_000_000,
help="Max symbol rate for blind scan in sps (default: 30000000)")
p_full.add_argument('--sr-step', type=int, default=1_000_000,
help="Symbol rate step for blind scan in sps (default: 1000000)")
p_full.add_argument('--pol', choices=['H', 'V', 'L', 'R'],
help="LNB polarization")
p_full.add_argument('--band', choices=['low', 'high'],
help="LNB band (low/high)")
p_full.add_argument('--name', type=str, default="",
help="Survey name/label")
p_full.add_argument('--output', '-o', type=str, default=None,
help="Output filename (default: auto-generated)")
# quick-scan
p_quick = sub.add_parser('quick-scan', help="Quick sweep + peak detection")
p_quick.add_argument('--start', type=float, default=950,
help="Start frequency in MHz (default: 950)")
p_quick.add_argument('--stop', type=float, default=2150,
help="Stop frequency in MHz (default: 2150)")
p_quick.add_argument('--step', type=float, default=5.0,
help="Sweep step in MHz (default: 5.0)")
p_quick.add_argument('--pol', choices=['H', 'V', 'L', 'R'],
help="LNB polarization")
p_quick.add_argument('--band', choices=['low', 'high'],
help="LNB band (low/high)")
# diff
p_diff = sub.add_parser('diff', help="Compare two survey catalogs")
p_diff.add_argument('file1', help="Older survey file")
p_diff.add_argument('file2', help="Newer survey file")
p_diff.add_argument('--output', '-o', type=str, default=None,
help="Save diff as JSON to this file")
# export
p_export = sub.add_parser('export', help="Export survey to CSV/JSON/text")
p_export.add_argument('file', help="Survey file to export")
p_export.add_argument('--format', '-f', choices=['csv', 'json', 'text'],
default='text', help="Output format (default: text)")
p_export.add_argument('--output', '-o', type=str, default=None,
help="Output file (default: stdout)")
# view
p_view = sub.add_parser('view', help="View a survey (latest if no file given)")
p_view.add_argument('file', nargs='?', default=None,
help="Survey file to view (default: latest)")
# qo100
p_qo100 = sub.add_parser('qo100',
help="QO-100 narrowband transponder survey")
p_qo100.add_argument('--lnb-lo', type=float, required=True,
help="LNB local oscillator frequency in MHz "
"(e.g., 9750 for universal LNB low band)")
p_qo100.add_argument('--output', '-o', type=str, default=None,
help="Output filename (default: auto-generated)")
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
dispatch = {
'full-scan': cmd_full_scan,
'quick-scan': cmd_quick_scan,
'diff': cmd_diff,
'export': cmd_export,
'view': cmd_view,
'qo100': cmd_qo100,
}
handler = dispatch.get(args.command)
if handler is None:
parser.print_help()
sys.exit(1)
handler(args)
if __name__ == '__main__':
main()

View file

@ -1,440 +1,440 @@
#!/usr/bin/env python3
"""
Automated carrier survey engine -- six-stage pipeline.
Orchestrates spectrum sweep, peak detection, blind scan, and TS
sampling to build a complete carrier catalog from the IF band.
"""
import sys
import time
import io
from skywalker_lib import SkyWalker1, MODULATIONS, MOD_FEC_GROUP, FEC_RATES
from signal_analysis import (
adaptive_noise_floor,
detect_peaks_enhanced,
estimate_carrier_bw,
classify_carrier,
)
from carrier_catalog import CarrierEntry, CarrierCatalog
from ts_analyze import TSReader, PSIParser, parse_pat, parse_pmt, parse_sdt
# Modulation index table for reverse lookup
_MOD_BY_INDEX = {}
for name, (idx, desc) in MODULATIONS.items():
_MOD_BY_INDEX[idx] = name
class SurveyEngine:
"""
Six-stage carrier survey pipeline:
1. Coarse sweep -- full IF range at configurable step size
2. Peak detection -- adaptive noise floor, peak merging
3. Fine sweep -- +/-10 MHz around each peak at 1 MHz steps
4. Blind scan -- try symbol rate range at each refined peak
5. TS sample -- for locked carriers, short capture + PAT/PMT/SDT
6. Catalog assembly -- aggregate everything into a CarrierCatalog
"""
STAGE_COARSE = "coarse_sweep"
STAGE_PEAKS = "peak_detection"
STAGE_FINE = "fine_sweep"
STAGE_BLIND = "blind_scan"
STAGE_TS = "ts_sample"
STAGE_CATALOG = "catalog_assembly"
def __init__(self, device: SkyWalker1, callback=None):
"""
device -- open SkyWalker1 instance
callback -- optional function(stage, progress_pct, message)
called at each major step for progress reporting
"""
self.dev = device
self.callback = callback
def _report(self, stage: str, pct: float, msg: str) -> None:
if self.callback:
self.callback(stage, pct, msg)
# ------------------------------------------------------------------
# Public entry points
# ------------------------------------------------------------------
def run_full_scan(self, start_mhz: float = 950, stop_mhz: float = 2150,
coarse_step: float = 5.0, fine_step: float = 1.0,
sr_min: int = 1_000_000, sr_max: int = 30_000_000,
sr_step: int = 1_000_000,
ts_capture_secs: float = 3.0) -> CarrierCatalog:
"""
Run all six stages and return a populated CarrierCatalog.
"""
# Stage 1: coarse sweep
self._report(self.STAGE_COARSE, 0, "Starting coarse sweep")
freqs, powers = self._coarse_sweep(start_mhz, stop_mhz, coarse_step)
self._report(self.STAGE_COARSE, 100, f"Coarse sweep done: {len(freqs)} points")
# Stage 2: peak detection
self._report(self.STAGE_PEAKS, 0, "Detecting peaks")
peaks = self._detect_peaks(freqs, powers)
self._report(self.STAGE_PEAKS, 100, f"Found {len(peaks)} candidate peaks")
if not peaks:
self._report(self.STAGE_CATALOG, 100, "No peaks found, empty catalog")
return self._assemble_catalog([], start_mhz, stop_mhz,
coarse_step, fine_step)
# Stage 3: fine sweep around each peak
self._report(self.STAGE_FINE, 0, "Starting fine sweeps")
refined = self._fine_sweep(peaks, fine_step)
self._report(self.STAGE_FINE, 100, f"Refined to {len(refined)} carriers")
# Stage 4: blind scan at each refined peak
self._report(self.STAGE_BLIND, 0, "Starting blind scan")
scanned = self._blind_scan_peaks(refined, sr_min, sr_max, sr_step)
self._report(self.STAGE_BLIND, 100,
f"Blind scan done: {sum(1 for s in scanned if s.get('locked'))} locked")
# Stage 5: TS sample for locked carriers
locked = [s for s in scanned if s.get("locked")]
self._report(self.STAGE_TS, 0, f"Sampling TS from {len(locked)} locked carriers")
sampled = self._sample_ts(locked, capture_secs=ts_capture_secs)
self._report(self.STAGE_TS, 100, "TS sampling done")
# Stage 6: assemble catalog
self._report(self.STAGE_CATALOG, 0, "Assembling catalog")
catalog = self._assemble_catalog(sampled, start_mhz, stop_mhz,
coarse_step, fine_step)
self._report(self.STAGE_CATALOG, 100,
f"Catalog ready: {len(catalog.carriers)} carriers")
return catalog
def run_quick_scan(self, start_mhz: float = 950, stop_mhz: float = 2150,
step: float = 5.0) -> list:
"""
Quick scan: coarse sweep + peak detection only.
Returns list of peak dicts from detect_peaks_enhanced.
No blind scan or TS capture.
"""
self._report(self.STAGE_COARSE, 0, "Quick scan: coarse sweep")
freqs, powers = self._coarse_sweep(start_mhz, stop_mhz, step)
self._report(self.STAGE_COARSE, 100, f"Sweep done: {len(freqs)} points")
self._report(self.STAGE_PEAKS, 0, "Quick scan: peak detection")
peaks = self._detect_peaks(freqs, powers)
self._report(self.STAGE_PEAKS, 100, f"Found {len(peaks)} peaks")
return peaks
# ------------------------------------------------------------------
# Internal stage methods
# ------------------------------------------------------------------
def _coarse_sweep(self, start_mhz: float, stop_mhz: float,
step: float) -> tuple:
"""
Stage 1: sweep the IF band and collect power measurements.
Returns (freqs_mhz[], powers_db[]).
"""
total_steps = int((stop_mhz - start_mhz) / step) + 1
def sweep_cb(freq, step_num, total, result):
pct = (step_num / max(total, 1)) * 100
self._report(self.STAGE_COARSE, pct,
f"{freq:.0f} MHz {result['power_db']:+.1f} dB")
freqs, powers, _ = self.dev.sweep_spectrum(
start_mhz, stop_mhz, step_mhz=step,
dwell_ms=15, callback=sweep_cb
)
return freqs, powers
def _detect_peaks(self, freqs: list, powers: list) -> list:
"""
Stage 2: enhanced peak detection with adaptive noise floor.
Returns list of peak dicts.
"""
noise_floor, mad = adaptive_noise_floor(powers)
self._report(self.STAGE_PEAKS, 50,
f"Noise floor: {noise_floor:.1f} dB, MAD: {mad:.2f} dB")
peaks = detect_peaks_enhanced(freqs, powers, threshold_db=6.0)
# Annotate each peak with classification
for p in peaks:
p["classification"] = classify_carrier(p["width_mhz"], p["power"])
return peaks
def _fine_sweep(self, peaks: list, fine_step: float = 1.0) -> list:
"""
Stage 3: sweep +/-10 MHz around each peak at fine resolution.
Returns list of refined peak dicts with updated freq/power/width.
"""
refined = []
for i, peak in enumerate(peaks):
pct = (i / max(len(peaks), 1)) * 100
center = peak["freq"]
margin = max(peak["width_mhz"] * 1.5, 10.0)
fine_start = max(950.0, center - margin)
fine_stop = min(2150.0, center + margin)
self._report(self.STAGE_FINE, pct,
f"Fine sweep {center:.0f} MHz ({fine_start:.0f}-{fine_stop:.0f})")
freqs, powers, _ = self.dev.sweep_spectrum(
fine_start, fine_stop, step_mhz=fine_step,
dwell_ms=20
)
# Re-detect peaks in the fine data
fine_peaks = detect_peaks_enhanced(freqs, powers, threshold_db=4.0)
if fine_peaks:
# Take the strongest peak from the fine sweep
best = max(fine_peaks, key=lambda p: p["power"])
best["classification"] = classify_carrier(
best["width_mhz"], best["power"]
)
refined.append(best)
else:
# Keep the coarse peak if fine sweep didn't improve it
refined.append(peak)
return refined
def _blind_scan_peaks(self, refined_peaks: list,
sr_min: int, sr_max: int,
sr_step: int) -> list:
"""
Stage 4: attempt blind scan at each refined peak frequency.
Returns list of result dicts, each with the peak info plus
blind scan results (locked, sr_sps, etc).
"""
results = []
for i, peak in enumerate(refined_peaks):
pct = (i / max(len(refined_peaks), 1)) * 100
freq_khz = int(peak["freq"] * 1000)
self._report(self.STAGE_BLIND, pct,
f"Blind scan {peak['freq']:.1f} MHz")
# Use classification to narrow SR range if possible
cls = peak.get("classification", {})
sr_range = cls.get("estimated_sr_range", (sr_min, sr_max))
scan_min = max(sr_min, sr_range[0])
scan_max = min(sr_max, sr_range[1])
result = {
"freq_mhz": peak["freq"],
"freq_khz": freq_khz,
"power_db": peak["power"],
"width_mhz": peak["width_mhz"],
"prominence_db": peak.get("prominence_db", 0),
"classification": cls,
"locked": False,
"sr_sps": 0,
"mod_index": -1,
"fec_index": -1,
}
# Try adaptive blind scan first (firmware-assisted)
try:
lock = self.dev.adaptive_blind_scan(
freq_khz, scan_min, scan_max, sr_step
)
if lock and lock.get("locked"):
result["locked"] = True
result["sr_sps"] = lock["sr_sps"]
result["freq_khz"] = lock.get("freq_khz", freq_khz)
# Read signal quality
time.sleep(0.1)
sig = self.dev.signal_monitor()
result["snr_db"] = sig.get("snr_db", 0)
result["agc1"] = sig.get("agc1", 0)
except Exception as e:
self._report(self.STAGE_BLIND, pct,
f"Blind scan error at {peak['freq']:.1f} MHz: {e}")
results.append(result)
return results
def _sample_ts(self, locked_carriers: list,
capture_secs: float = 3.0) -> list:
"""
Stage 5: for each locked carrier, tune + arm + capture TS data,
then parse PAT/PMT/SDT for service information.
"""
results = []
for i, carrier in enumerate(locked_carriers):
pct = (i / max(len(locked_carriers), 1)) * 100
freq_khz = carrier["freq_khz"]
sr_sps = carrier["sr_sps"]
self._report(self.STAGE_TS, pct,
f"Sampling {carrier['freq_mhz']:.1f} MHz "
f"SR={sr_sps / 1e6:.3f} Msps")
carrier["services"] = []
carrier["pat"] = None
carrier["pmt"] = {}
if sr_sps <= 0:
results.append(carrier)
continue
try:
# Tune with QPSK auto-FEC as a safe default
self.dev.tune(sr_sps, freq_khz, 0, 5)
time.sleep(0.3)
# Verify lock
sig = self.dev.signal_monitor()
if not sig.get("locked"):
results.append(carrier)
continue
carrier["snr_db"] = sig.get("snr_db", 0)
# Arm and capture TS data
self.dev.arm_transfer(True)
ts_data = bytearray()
deadline = time.time() + capture_secs
while time.time() < deadline:
chunk = self.dev.read_stream(timeout=500)
if chunk:
ts_data.extend(chunk)
self.dev.arm_transfer(False)
# Parse the captured TS
if ts_data:
services = _parse_ts_services(bytes(ts_data))
carrier["services"] = services.get("service_names", [])
carrier["pat"] = services.get("pat")
carrier["pmt"] = services.get("pmts", {})
carrier["sdt"] = services.get("sdt")
except Exception as e:
self._report(self.STAGE_TS, pct,
f"TS capture error at {carrier['freq_mhz']:.1f} MHz: {e}")
try:
self.dev.arm_transfer(False)
except Exception:
pass
results.append(carrier)
return results
def _assemble_catalog(self, all_results: list,
start_mhz: float = 950,
stop_mhz: float = 2150,
coarse_step: float = 5.0,
fine_step: float = 1.0) -> CarrierCatalog:
"""
Stage 6: build a CarrierCatalog from the collected results.
"""
catalog = CarrierCatalog()
catalog.sweep_params = {
"start_mhz": start_mhz,
"stop_mhz": stop_mhz,
"coarse_step_mhz": coarse_step,
"fine_step_mhz": fine_step,
}
for r in all_results:
mod_name = ""
if r.get("mod_index", -1) >= 0:
mod_name = _MOD_BY_INDEX.get(r["mod_index"], "")
entry = CarrierEntry(
freq_khz=r.get("freq_khz", int(r.get("freq_mhz", 0) * 1000)),
sr_sps=r.get("sr_sps", 0),
modulation=mod_name,
fec="",
power_db=r.get("power_db", 0),
snr_db=r.get("snr_db", 0),
locked=r.get("locked", False),
services=r.get("services", []),
bw_mhz=r.get("width_mhz", 0),
classification=r.get("classification", {}),
)
catalog.add_carrier(entry)
return catalog
def _parse_ts_services(ts_data: bytes) -> dict:
"""
Parse PAT, PMT, and SDT from a chunk of TS data.
Returns dict with:
pat - parsed PAT or None
pmts - {pmt_pid: parsed PMT}
sdt - parsed SDT or None
service_names - list of service name strings from SDT
"""
result = {
"pat": None,
"pmts": {},
"sdt": None,
"service_names": [],
}
source = io.BytesIO(ts_data)
reader = TSReader(source)
psi_pat = PSIParser()
psi_pmt = PSIParser()
psi_sdt = PSIParser()
pat = None
pmt_pids = set()
pmts_found = {}
try:
for pkt in reader.iter_packets(max_packets=50000):
# PAT on PID 0x0000
if pkt.pid == 0x0000 and pat is None:
section = psi_pat.feed(pkt)
if section is not None:
pat = parse_pat(section)
if pat:
result["pat"] = pat
for prog, pid in pat["programs"].items():
if prog != 0:
pmt_pids.add(pid)
# 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:
pmts_found[pkt.pid] = pmt
# SDT on PID 0x0011
if pkt.pid == 0x0011 and result["sdt"] is None:
section = psi_sdt.feed(pkt)
if section is not None:
sdt = parse_sdt(section)
if sdt:
result["sdt"] = sdt
for svc in sdt.get("services", []):
name = svc.get("service_name", "")
if name:
result["service_names"].append(name)
# Stop early once we have everything
if (pat is not None
and len(pmts_found) >= len(pmt_pids)
and result["sdt"] is not None):
break
except Exception:
pass
result["pmts"] = pmts_found
return result
#!/usr/bin/env python3
"""
Automated carrier survey engine -- six-stage pipeline.
Orchestrates spectrum sweep, peak detection, blind scan, and TS
sampling to build a complete carrier catalog from the IF band.
"""
import sys
import time
import io
from skywalker_lib import SkyWalker1, MODULATIONS, MOD_FEC_GROUP, FEC_RATES
from signal_analysis import (
adaptive_noise_floor,
detect_peaks_enhanced,
estimate_carrier_bw,
classify_carrier,
)
from carrier_catalog import CarrierEntry, CarrierCatalog
from ts_analyze import TSReader, PSIParser, parse_pat, parse_pmt, parse_sdt
# Modulation index table for reverse lookup
_MOD_BY_INDEX = {}
for name, (idx, desc) in MODULATIONS.items():
_MOD_BY_INDEX[idx] = name
class SurveyEngine:
"""
Six-stage carrier survey pipeline:
1. Coarse sweep -- full IF range at configurable step size
2. Peak detection -- adaptive noise floor, peak merging
3. Fine sweep -- +/-10 MHz around each peak at 1 MHz steps
4. Blind scan -- try symbol rate range at each refined peak
5. TS sample -- for locked carriers, short capture + PAT/PMT/SDT
6. Catalog assembly -- aggregate everything into a CarrierCatalog
"""
STAGE_COARSE = "coarse_sweep"
STAGE_PEAKS = "peak_detection"
STAGE_FINE = "fine_sweep"
STAGE_BLIND = "blind_scan"
STAGE_TS = "ts_sample"
STAGE_CATALOG = "catalog_assembly"
def __init__(self, device: SkyWalker1, callback=None):
"""
device -- open SkyWalker1 instance
callback -- optional function(stage, progress_pct, message)
called at each major step for progress reporting
"""
self.dev = device
self.callback = callback
def _report(self, stage: str, pct: float, msg: str) -> None:
if self.callback:
self.callback(stage, pct, msg)
# ------------------------------------------------------------------
# Public entry points
# ------------------------------------------------------------------
def run_full_scan(self, start_mhz: float = 950, stop_mhz: float = 2150,
coarse_step: float = 5.0, fine_step: float = 1.0,
sr_min: int = 1_000_000, sr_max: int = 30_000_000,
sr_step: int = 1_000_000,
ts_capture_secs: float = 3.0) -> CarrierCatalog:
"""
Run all six stages and return a populated CarrierCatalog.
"""
# Stage 1: coarse sweep
self._report(self.STAGE_COARSE, 0, "Starting coarse sweep")
freqs, powers = self._coarse_sweep(start_mhz, stop_mhz, coarse_step)
self._report(self.STAGE_COARSE, 100, f"Coarse sweep done: {len(freqs)} points")
# Stage 2: peak detection
self._report(self.STAGE_PEAKS, 0, "Detecting peaks")
peaks = self._detect_peaks(freqs, powers)
self._report(self.STAGE_PEAKS, 100, f"Found {len(peaks)} candidate peaks")
if not peaks:
self._report(self.STAGE_CATALOG, 100, "No peaks found, empty catalog")
return self._assemble_catalog([], start_mhz, stop_mhz,
coarse_step, fine_step)
# Stage 3: fine sweep around each peak
self._report(self.STAGE_FINE, 0, "Starting fine sweeps")
refined = self._fine_sweep(peaks, fine_step)
self._report(self.STAGE_FINE, 100, f"Refined to {len(refined)} carriers")
# Stage 4: blind scan at each refined peak
self._report(self.STAGE_BLIND, 0, "Starting blind scan")
scanned = self._blind_scan_peaks(refined, sr_min, sr_max, sr_step)
self._report(self.STAGE_BLIND, 100,
f"Blind scan done: {sum(1 for s in scanned if s.get('locked'))} locked")
# Stage 5: TS sample for locked carriers
locked = [s for s in scanned if s.get("locked")]
self._report(self.STAGE_TS, 0, f"Sampling TS from {len(locked)} locked carriers")
sampled = self._sample_ts(locked, capture_secs=ts_capture_secs)
self._report(self.STAGE_TS, 100, "TS sampling done")
# Stage 6: assemble catalog
self._report(self.STAGE_CATALOG, 0, "Assembling catalog")
catalog = self._assemble_catalog(sampled, start_mhz, stop_mhz,
coarse_step, fine_step)
self._report(self.STAGE_CATALOG, 100,
f"Catalog ready: {len(catalog.carriers)} carriers")
return catalog
def run_quick_scan(self, start_mhz: float = 950, stop_mhz: float = 2150,
step: float = 5.0) -> list:
"""
Quick scan: coarse sweep + peak detection only.
Returns list of peak dicts from detect_peaks_enhanced.
No blind scan or TS capture.
"""
self._report(self.STAGE_COARSE, 0, "Quick scan: coarse sweep")
freqs, powers = self._coarse_sweep(start_mhz, stop_mhz, step)
self._report(self.STAGE_COARSE, 100, f"Sweep done: {len(freqs)} points")
self._report(self.STAGE_PEAKS, 0, "Quick scan: peak detection")
peaks = self._detect_peaks(freqs, powers)
self._report(self.STAGE_PEAKS, 100, f"Found {len(peaks)} peaks")
return peaks
# ------------------------------------------------------------------
# Internal stage methods
# ------------------------------------------------------------------
def _coarse_sweep(self, start_mhz: float, stop_mhz: float,
step: float) -> tuple:
"""
Stage 1: sweep the IF band and collect power measurements.
Returns (freqs_mhz[], powers_db[]).
"""
total_steps = int((stop_mhz - start_mhz) / step) + 1
def sweep_cb(freq, step_num, total, result):
pct = (step_num / max(total, 1)) * 100
self._report(self.STAGE_COARSE, pct,
f"{freq:.0f} MHz {result['power_db']:+.1f} dB")
freqs, powers, _ = self.dev.sweep_spectrum(
start_mhz, stop_mhz, step_mhz=step,
dwell_ms=15, callback=sweep_cb
)
return freqs, powers
def _detect_peaks(self, freqs: list, powers: list) -> list:
"""
Stage 2: enhanced peak detection with adaptive noise floor.
Returns list of peak dicts.
"""
noise_floor, mad = adaptive_noise_floor(powers)
self._report(self.STAGE_PEAKS, 50,
f"Noise floor: {noise_floor:.1f} dB, MAD: {mad:.2f} dB")
peaks = detect_peaks_enhanced(freqs, powers, threshold_db=6.0)
# Annotate each peak with classification
for p in peaks:
p["classification"] = classify_carrier(p["width_mhz"], p["power"])
return peaks
def _fine_sweep(self, peaks: list, fine_step: float = 1.0) -> list:
"""
Stage 3: sweep +/-10 MHz around each peak at fine resolution.
Returns list of refined peak dicts with updated freq/power/width.
"""
refined = []
for i, peak in enumerate(peaks):
pct = (i / max(len(peaks), 1)) * 100
center = peak["freq"]
margin = max(peak["width_mhz"] * 1.5, 10.0)
fine_start = max(950.0, center - margin)
fine_stop = min(2150.0, center + margin)
self._report(self.STAGE_FINE, pct,
f"Fine sweep {center:.0f} MHz ({fine_start:.0f}-{fine_stop:.0f})")
freqs, powers, _ = self.dev.sweep_spectrum(
fine_start, fine_stop, step_mhz=fine_step,
dwell_ms=20
)
# Re-detect peaks in the fine data
fine_peaks = detect_peaks_enhanced(freqs, powers, threshold_db=4.0)
if fine_peaks:
# Take the strongest peak from the fine sweep
best = max(fine_peaks, key=lambda p: p["power"])
best["classification"] = classify_carrier(
best["width_mhz"], best["power"]
)
refined.append(best)
else:
# Keep the coarse peak if fine sweep didn't improve it
refined.append(peak)
return refined
def _blind_scan_peaks(self, refined_peaks: list,
sr_min: int, sr_max: int,
sr_step: int) -> list:
"""
Stage 4: attempt blind scan at each refined peak frequency.
Returns list of result dicts, each with the peak info plus
blind scan results (locked, sr_sps, etc).
"""
results = []
for i, peak in enumerate(refined_peaks):
pct = (i / max(len(refined_peaks), 1)) * 100
freq_khz = int(peak["freq"] * 1000)
self._report(self.STAGE_BLIND, pct,
f"Blind scan {peak['freq']:.1f} MHz")
# Use classification to narrow SR range if possible
cls = peak.get("classification", {})
sr_range = cls.get("estimated_sr_range", (sr_min, sr_max))
scan_min = max(sr_min, sr_range[0])
scan_max = min(sr_max, sr_range[1])
result = {
"freq_mhz": peak["freq"],
"freq_khz": freq_khz,
"power_db": peak["power"],
"width_mhz": peak["width_mhz"],
"prominence_db": peak.get("prominence_db", 0),
"classification": cls,
"locked": False,
"sr_sps": 0,
"mod_index": -1,
"fec_index": -1,
}
# Try adaptive blind scan first (firmware-assisted)
try:
lock = self.dev.adaptive_blind_scan(
freq_khz, scan_min, scan_max, sr_step
)
if lock and lock.get("locked"):
result["locked"] = True
result["sr_sps"] = lock["sr_sps"]
result["freq_khz"] = lock.get("freq_khz", freq_khz)
# Read signal quality
time.sleep(0.1)
sig = self.dev.signal_monitor()
result["snr_db"] = sig.get("snr_db", 0)
result["agc1"] = sig.get("agc1", 0)
except Exception as e:
self._report(self.STAGE_BLIND, pct,
f"Blind scan error at {peak['freq']:.1f} MHz: {e}")
results.append(result)
return results
def _sample_ts(self, locked_carriers: list,
capture_secs: float = 3.0) -> list:
"""
Stage 5: for each locked carrier, tune + arm + capture TS data,
then parse PAT/PMT/SDT for service information.
"""
results = []
for i, carrier in enumerate(locked_carriers):
pct = (i / max(len(locked_carriers), 1)) * 100
freq_khz = carrier["freq_khz"]
sr_sps = carrier["sr_sps"]
self._report(self.STAGE_TS, pct,
f"Sampling {carrier['freq_mhz']:.1f} MHz "
f"SR={sr_sps / 1e6:.3f} Msps")
carrier["services"] = []
carrier["pat"] = None
carrier["pmt"] = {}
if sr_sps <= 0:
results.append(carrier)
continue
try:
# Tune with QPSK auto-FEC as a safe default
self.dev.tune(sr_sps, freq_khz, 0, 5)
time.sleep(0.3)
# Verify lock
sig = self.dev.signal_monitor()
if not sig.get("locked"):
results.append(carrier)
continue
carrier["snr_db"] = sig.get("snr_db", 0)
# Arm and capture TS data
self.dev.arm_transfer(True)
ts_data = bytearray()
deadline = time.time() + capture_secs
while time.time() < deadline:
chunk = self.dev.read_stream(timeout=500)
if chunk:
ts_data.extend(chunk)
self.dev.arm_transfer(False)
# Parse the captured TS
if ts_data:
services = _parse_ts_services(bytes(ts_data))
carrier["services"] = services.get("service_names", [])
carrier["pat"] = services.get("pat")
carrier["pmt"] = services.get("pmts", {})
carrier["sdt"] = services.get("sdt")
except Exception as e:
self._report(self.STAGE_TS, pct,
f"TS capture error at {carrier['freq_mhz']:.1f} MHz: {e}")
try:
self.dev.arm_transfer(False)
except Exception:
pass
results.append(carrier)
return results
def _assemble_catalog(self, all_results: list,
start_mhz: float = 950,
stop_mhz: float = 2150,
coarse_step: float = 5.0,
fine_step: float = 1.0) -> CarrierCatalog:
"""
Stage 6: build a CarrierCatalog from the collected results.
"""
catalog = CarrierCatalog()
catalog.sweep_params = {
"start_mhz": start_mhz,
"stop_mhz": stop_mhz,
"coarse_step_mhz": coarse_step,
"fine_step_mhz": fine_step,
}
for r in all_results:
mod_name = ""
if r.get("mod_index", -1) >= 0:
mod_name = _MOD_BY_INDEX.get(r["mod_index"], "")
entry = CarrierEntry(
freq_khz=r.get("freq_khz", int(r.get("freq_mhz", 0) * 1000)),
sr_sps=r.get("sr_sps", 0),
modulation=mod_name,
fec="",
power_db=r.get("power_db", 0),
snr_db=r.get("snr_db", 0),
locked=r.get("locked", False),
services=r.get("services", []),
bw_mhz=r.get("width_mhz", 0),
classification=r.get("classification", {}),
)
catalog.add_carrier(entry)
return catalog
def _parse_ts_services(ts_data: bytes) -> dict:
"""
Parse PAT, PMT, and SDT from a chunk of TS data.
Returns dict with:
pat - parsed PAT or None
pmts - {pmt_pid: parsed PMT}
sdt - parsed SDT or None
service_names - list of service name strings from SDT
"""
result = {
"pat": None,
"pmts": {},
"sdt": None,
"service_names": [],
}
source = io.BytesIO(ts_data)
reader = TSReader(source)
psi_pat = PSIParser()
psi_pmt = PSIParser()
psi_sdt = PSIParser()
pat = None
pmt_pids = set()
pmts_found = {}
try:
for pkt in reader.iter_packets(max_packets=50000):
# PAT on PID 0x0000
if pkt.pid == 0x0000 and pat is None:
section = psi_pat.feed(pkt)
if section is not None:
pat = parse_pat(section)
if pat:
result["pat"] = pat
for prog, pid in pat["programs"].items():
if prog != 0:
pmt_pids.add(pid)
# 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:
pmts_found[pkt.pid] = pmt
# SDT on PID 0x0011
if pkt.pid == 0x0011 and result["sdt"] is None:
section = psi_sdt.feed(pkt)
if section is not None:
sdt = parse_sdt(section)
if sdt:
result["sdt"] = sdt
for svc in sdt.get("services", []):
name = svc.get("service_name", "")
if name:
result["service_names"].append(name)
# Stop early once we have everything
if (pat is not None
and len(pmts_found) >= len(pmt_pids)
and result["sdt"] is not None):
break
except Exception:
pass
result["pmts"] = pmts_found
return result

View file

@ -1,171 +1,171 @@
#!/usr/bin/env python3
"""Test BOOT_8PSK on SkyWalker-1 with custom firmware v3.01.0"""
import usb.core
import usb.util
import sys
import time
def find_device():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("Device not found!")
sys.exit(1)
return dev
def setup_device(dev):
"""Detach kernel driver and set configuration."""
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
print("Detached kernel driver from interface 0")
except Exception as e:
print(f"Driver detach note: {e}")
try:
dev.set_configuration()
except usb.core.USBError:
# Already configured, that's fine
pass
def main():
dev = find_device()
setup_device(dev)
# GET_FW_VERS (0x92)
print("=" * 50)
ret = dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6)
major, minor, patch = ret[2], ret[1], ret[0]
day, month, year = ret[3], ret[4], ret[5] + 2000
print(f"Firmware: v{major}.{minor:02d}.{patch} ({year}-{month:02d}-{day:02d})")
# GET_8PSK_CONFIG (0x80)
ret = dev.ctrl_transfer(0xC0, 0x80, 0, 0, 1)
print(f"Config before boot: 0x{ret[0]:02X}")
# BOOT_8PSK (0x89) with wValue=1
print()
print("=" * 50)
print("Sending BOOT_8PSK(1)...")
print(" (This triggers: P0.5 reset, power on, 3-block register init)")
print()
try:
ret = dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000)
except usb.core.USBError as e:
print(f"BOOT_8PSK USB error: {e}")
print("The device may have timed out during init.")
print("Trying to read config status anyway...")
try:
ret = dev.ctrl_transfer(0xC0, 0x80, 0, 0, 1)
print(f"Config after attempted boot: 0x{ret[0]:02X}")
except:
print("Device not responding. May need power cycle.")
sys.exit(1)
status = ret[0]
stage = ret[1] if len(ret) > 1 else 0
stage_names = {
0: "NOT_STARTED", 1: "GPIO_SETUP", 2: "PWR_SETTLED",
3: "I2C_PROBE", 4: "INIT_BLK0", 5: "INIT_BLK1",
6: "INIT_BLK2", 0xFF: "COMPLETE"
}
flags = []
if status & 0x01: flags.append("STARTED")
if status & 0x02: flags.append("FW_LOADED")
if status & 0x04: flags.append("INTERSIL")
if status & 0x08: flags.append("DVB_MODE")
if status & 0x10: flags.append("22KHZ")
if status & 0x20: flags.append("SEL18V")
if status & 0x40: flags.append("DC_TUNED")
if status & 0x80: flags.append("ARMED")
print(f"BOOT_8PSK response: 0x{status:02X} [{' | '.join(flags) if flags else 'none'}]")
print(f"Boot stage: 0x{stage:02X} [{stage_names.get(stage, 'UNKNOWN')}]")
if status & 0x03 == 0x03:
print()
print("*** BCM4500 BOOT SUCCESS! ***")
print()
# Read direct I2C registers
print("BCM4500 direct registers (via I2C_RAW_READ 0xB5):")
for reg in [0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8]:
try:
r = dev.ctrl_transfer(0xC0, 0xB5, 0x08, reg, 1)
print(f" Reg 0x{reg:02X} = 0x{r[0]:02X}")
except Exception as e:
print(f" Reg 0x{reg:02X}: ERROR {e}")
# Read indirect registers through our protocol
print()
print("BCM4500 indirect registers (via RAW_DEMOD_READ 0xB1):")
for page in range(16):
try:
r = dev.ctrl_transfer(0xC0, 0xB1, page, 0, 1)
print(f" Page 0x{page:02X} = 0x{r[0]:02X}")
except Exception as e:
print(f" Page 0x{page:02X}: ERROR {e}")
# I2C diagnostic
print()
print("I2C diagnostic (0xB6) for page 0x00:")
try:
r = dev.ctrl_transfer(0xC0, 0xB6, 0x00, 0, 8)
labels = ["wr_A6", "rb_A6", "wr_A8", "rb_A8",
"rb_A7", "fin_A6", "fin_A7", "fin_A8"]
for i, lab in enumerate(labels):
print(f" {lab}: 0x{r[i]:02X}")
except Exception as e:
print(f" ERROR: {e}")
# Signal strength
print()
try:
r = dev.ctrl_transfer(0xC0, 0x87, 0, 0, 6)
print(f"Signal strength: {' '.join(f'{b:02X}' for b in r)}")
except Exception as e:
print(f"Signal strength ERROR: {e}")
# Signal lock
try:
r = dev.ctrl_transfer(0xC0, 0x90, 0, 0, 1)
print(f"Signal lock: 0x{r[0]:02X}")
except Exception as e:
print(f"Signal lock ERROR: {e}")
else:
print()
print("*** BOOT FAILED ***")
print()
# I2C bus scan
print("I2C bus scan:")
try:
r = dev.ctrl_transfer(0xC0, 0xB4, 0, 0, 16)
addrs = []
for bi in range(16):
for bit in range(8):
if r[bi] & (1 << bit):
addrs.append(bi * 8 + bit)
if addrs:
print(f" Found devices at: {[f'0x{a:02X}' for a in addrs]}")
else:
print(" No I2C devices found!")
except Exception as e:
print(f" Scan error: {e}")
# Try raw I2C reads anyway
print()
print("Raw I2C reads to BCM4500 (0x08):")
for reg in [0xA2, 0xA4, 0xA6, 0xA7, 0xA8]:
try:
r = dev.ctrl_transfer(0xC0, 0xB5, 0x08, reg, 1)
print(f" Reg 0x{reg:02X} = 0x{r[0]:02X}")
except Exception as e:
print(f" Reg 0x{reg:02X}: ERROR {e}")
print()
print("=" * 50)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Test BOOT_8PSK on SkyWalker-1 with custom firmware v3.01.0"""
import usb.core
import usb.util
import sys
import time
def find_device():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("Device not found!")
sys.exit(1)
return dev
def setup_device(dev):
"""Detach kernel driver and set configuration."""
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
print("Detached kernel driver from interface 0")
except Exception as e:
print(f"Driver detach note: {e}")
try:
dev.set_configuration()
except usb.core.USBError:
# Already configured, that's fine
pass
def main():
dev = find_device()
setup_device(dev)
# GET_FW_VERS (0x92)
print("=" * 50)
ret = dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6)
major, minor, patch = ret[2], ret[1], ret[0]
day, month, year = ret[3], ret[4], ret[5] + 2000
print(f"Firmware: v{major}.{minor:02d}.{patch} ({year}-{month:02d}-{day:02d})")
# GET_8PSK_CONFIG (0x80)
ret = dev.ctrl_transfer(0xC0, 0x80, 0, 0, 1)
print(f"Config before boot: 0x{ret[0]:02X}")
# BOOT_8PSK (0x89) with wValue=1
print()
print("=" * 50)
print("Sending BOOT_8PSK(1)...")
print(" (This triggers: P0.5 reset, power on, 3-block register init)")
print()
try:
ret = dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000)
except usb.core.USBError as e:
print(f"BOOT_8PSK USB error: {e}")
print("The device may have timed out during init.")
print("Trying to read config status anyway...")
try:
ret = dev.ctrl_transfer(0xC0, 0x80, 0, 0, 1)
print(f"Config after attempted boot: 0x{ret[0]:02X}")
except:
print("Device not responding. May need power cycle.")
sys.exit(1)
status = ret[0]
stage = ret[1] if len(ret) > 1 else 0
stage_names = {
0: "NOT_STARTED", 1: "GPIO_SETUP", 2: "PWR_SETTLED",
3: "I2C_PROBE", 4: "INIT_BLK0", 5: "INIT_BLK1",
6: "INIT_BLK2", 0xFF: "COMPLETE"
}
flags = []
if status & 0x01: flags.append("STARTED")
if status & 0x02: flags.append("FW_LOADED")
if status & 0x04: flags.append("INTERSIL")
if status & 0x08: flags.append("DVB_MODE")
if status & 0x10: flags.append("22KHZ")
if status & 0x20: flags.append("SEL18V")
if status & 0x40: flags.append("DC_TUNED")
if status & 0x80: flags.append("ARMED")
print(f"BOOT_8PSK response: 0x{status:02X} [{' | '.join(flags) if flags else 'none'}]")
print(f"Boot stage: 0x{stage:02X} [{stage_names.get(stage, 'UNKNOWN')}]")
if status & 0x03 == 0x03:
print()
print("*** BCM4500 BOOT SUCCESS! ***")
print()
# Read direct I2C registers
print("BCM4500 direct registers (via I2C_RAW_READ 0xB5):")
for reg in [0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8]:
try:
r = dev.ctrl_transfer(0xC0, 0xB5, 0x08, reg, 1)
print(f" Reg 0x{reg:02X} = 0x{r[0]:02X}")
except Exception as e:
print(f" Reg 0x{reg:02X}: ERROR {e}")
# Read indirect registers through our protocol
print()
print("BCM4500 indirect registers (via RAW_DEMOD_READ 0xB1):")
for page in range(16):
try:
r = dev.ctrl_transfer(0xC0, 0xB1, page, 0, 1)
print(f" Page 0x{page:02X} = 0x{r[0]:02X}")
except Exception as e:
print(f" Page 0x{page:02X}: ERROR {e}")
# I2C diagnostic
print()
print("I2C diagnostic (0xB6) for page 0x00:")
try:
r = dev.ctrl_transfer(0xC0, 0xB6, 0x00, 0, 8)
labels = ["wr_A6", "rb_A6", "wr_A8", "rb_A8",
"rb_A7", "fin_A6", "fin_A7", "fin_A8"]
for i, lab in enumerate(labels):
print(f" {lab}: 0x{r[i]:02X}")
except Exception as e:
print(f" ERROR: {e}")
# Signal strength
print()
try:
r = dev.ctrl_transfer(0xC0, 0x87, 0, 0, 6)
print(f"Signal strength: {' '.join(f'{b:02X}' for b in r)}")
except Exception as e:
print(f"Signal strength ERROR: {e}")
# Signal lock
try:
r = dev.ctrl_transfer(0xC0, 0x90, 0, 0, 1)
print(f"Signal lock: 0x{r[0]:02X}")
except Exception as e:
print(f"Signal lock ERROR: {e}")
else:
print()
print("*** BOOT FAILED ***")
print()
# I2C bus scan
print("I2C bus scan:")
try:
r = dev.ctrl_transfer(0xC0, 0xB4, 0, 0, 16)
addrs = []
for bi in range(16):
for bit in range(8):
if r[bi] & (1 << bit):
addrs.append(bi * 8 + bit)
if addrs:
print(f" Found devices at: {[f'0x{a:02X}' for a in addrs]}")
else:
print(" No I2C devices found!")
except Exception as e:
print(f" Scan error: {e}")
# Try raw I2C reads anyway
print()
print("Raw I2C reads to BCM4500 (0x08):")
for reg in [0xA2, 0xA4, 0xA6, 0xA7, 0xA8]:
try:
r = dev.ctrl_transfer(0xC0, 0xB5, 0x08, reg, 1)
print(f" Reg 0x{reg:02X} = 0x{r[0]:02X}")
except Exception as e:
print(f" Reg 0x{reg:02X}: ERROR {e}")
print()
print("=" * 50)
if __name__ == "__main__":
main()

View file

@ -1,127 +1,127 @@
#!/usr/bin/env python3
"""Incremental BOOT_8PSK debug tester for SkyWalker-1.
Sends debug boot modes (wValue=0x80..0x83) one at a time to isolate
which stage of the BCM4500 boot sequence hangs the FX2 firmware.
Usage:
sudo python3 test_boot_debug.py # run all debug stages
sudo python3 test_boot_debug.py 0x82 # run only stage 0x82
"""
import usb.core
import usb.util
import sys
import time
BOOT_8PSK = 0x89
def find_device():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("Device not found!")
sys.exit(1)
return dev
def setup_device(dev):
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception:
pass
try:
dev.set_configuration()
except usb.core.USBError:
pass
def decode_stage(stage):
names = {
0x00: "NOT_STARTED",
0x01: "GPIO_SETUP",
0x02: "PWR_SETTLED",
0x03: "I2C_PROBE",
0x04: "INIT_BLK0",
0x05: "INIT_BLK1",
0x06: "INIT_BLK2",
0xA1: "DEBUG_GPIO_OK",
0xA2: "DEBUG_PROBE_OK",
0xA3: "DEBUG_BLK0_OK",
0xE3: "DEBUG_PROBE_FAIL",
0xE4: "DEBUG_BLK0_FAIL",
0xFF: "COMPLETE",
}
return names.get(stage, f"UNKNOWN(0x{stage:02X})")
def test_mode(dev, wval, label, timeout_ms=3000):
"""Send a debug boot mode and read 3-byte response."""
print(f"\n{'' * 50}")
print(f" Testing wValue=0x{wval:02X}: {label}")
print(f"{'' * 50}")
t0 = time.monotonic()
try:
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, wval, 0, 3, timeout=timeout_ms)
except usb.core.USBError as e:
elapsed = (time.monotonic() - t0) * 1000
print(f" FAILED after {elapsed:.0f}ms: {e}")
# Try to see if device is still alive
try:
dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6, timeout=1000)
print(" Device still responds to GET_FW_VERS")
except:
print(" Device is HUNG (no response to GET_FW_VERS)")
return None
elapsed = (time.monotonic() - t0) * 1000
status = ret[0]
stage = ret[1] if len(ret) > 1 else 0
probe = ret[2] if len(ret) > 2 else 0
print(f" Response in {elapsed:.0f}ms:")
print(f" config_status: 0x{status:02X}")
print(f" boot_stage: 0x{stage:02X} [{decode_stage(stage)}]")
print(f" probe_byte: 0x{probe:02X}")
return ret
def main():
dev = find_device()
setup_device(dev)
# Verify firmware is responding
try:
ret = dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6, timeout=2000)
major, minor, patch = ret[2], ret[1], ret[0]
print(f"Firmware: v{major}.{minor:02d}.{patch}")
except usb.core.USBError as e:
print(f"GET_FW_VERS failed: {e}")
print("Device may be hung. Try reloading firmware with fw_load.py.")
sys.exit(1)
ret = dev.ctrl_transfer(0xC0, 0x80, 0, 0, 1)
print(f"Config: 0x{ret[0]:02X}")
# Parse optional argument for single-stage testing
single_stage = None
if len(sys.argv) > 1:
single_stage = int(sys.argv[1], 0)
stages = [
(0x80, "No-op: return current state only"),
(0x81, "GPIO setup + power + delays (no I2C)"),
(0x82, "GPIO + I2C bus reset + BCM4500 probe read"),
(0x83, "GPIO + I2C probe + write init block 0"),
]
for wval, label in stages:
if single_stage is not None and wval != single_stage:
continue
result = test_mode(dev, wval, label)
if result is None:
print("\n*** STOPPING: device not responding ***")
break
print(f"\n{'=' * 50}")
print("Debug complete.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Incremental BOOT_8PSK debug tester for SkyWalker-1.
Sends debug boot modes (wValue=0x80..0x83) one at a time to isolate
which stage of the BCM4500 boot sequence hangs the FX2 firmware.
Usage:
sudo python3 test_boot_debug.py # run all debug stages
sudo python3 test_boot_debug.py 0x82 # run only stage 0x82
"""
import usb.core
import usb.util
import sys
import time
BOOT_8PSK = 0x89
def find_device():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("Device not found!")
sys.exit(1)
return dev
def setup_device(dev):
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception:
pass
try:
dev.set_configuration()
except usb.core.USBError:
pass
def decode_stage(stage):
names = {
0x00: "NOT_STARTED",
0x01: "GPIO_SETUP",
0x02: "PWR_SETTLED",
0x03: "I2C_PROBE",
0x04: "INIT_BLK0",
0x05: "INIT_BLK1",
0x06: "INIT_BLK2",
0xA1: "DEBUG_GPIO_OK",
0xA2: "DEBUG_PROBE_OK",
0xA3: "DEBUG_BLK0_OK",
0xE3: "DEBUG_PROBE_FAIL",
0xE4: "DEBUG_BLK0_FAIL",
0xFF: "COMPLETE",
}
return names.get(stage, f"UNKNOWN(0x{stage:02X})")
def test_mode(dev, wval, label, timeout_ms=3000):
"""Send a debug boot mode and read 3-byte response."""
print(f"\n{'' * 50}")
print(f" Testing wValue=0x{wval:02X}: {label}")
print(f"{'' * 50}")
t0 = time.monotonic()
try:
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, wval, 0, 3, timeout=timeout_ms)
except usb.core.USBError as e:
elapsed = (time.monotonic() - t0) * 1000
print(f" FAILED after {elapsed:.0f}ms: {e}")
# Try to see if device is still alive
try:
dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6, timeout=1000)
print(" Device still responds to GET_FW_VERS")
except:
print(" Device is HUNG (no response to GET_FW_VERS)")
return None
elapsed = (time.monotonic() - t0) * 1000
status = ret[0]
stage = ret[1] if len(ret) > 1 else 0
probe = ret[2] if len(ret) > 2 else 0
print(f" Response in {elapsed:.0f}ms:")
print(f" config_status: 0x{status:02X}")
print(f" boot_stage: 0x{stage:02X} [{decode_stage(stage)}]")
print(f" probe_byte: 0x{probe:02X}")
return ret
def main():
dev = find_device()
setup_device(dev)
# Verify firmware is responding
try:
ret = dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6, timeout=2000)
major, minor, patch = ret[2], ret[1], ret[0]
print(f"Firmware: v{major}.{minor:02d}.{patch}")
except usb.core.USBError as e:
print(f"GET_FW_VERS failed: {e}")
print("Device may be hung. Try reloading firmware with fw_load.py.")
sys.exit(1)
ret = dev.ctrl_transfer(0xC0, 0x80, 0, 0, 1)
print(f"Config: 0x{ret[0]:02X}")
# Parse optional argument for single-stage testing
single_stage = None
if len(sys.argv) > 1:
single_stage = int(sys.argv[1], 0)
stages = [
(0x80, "No-op: return current state only"),
(0x81, "GPIO setup + power + delays (no I2C)"),
(0x82, "GPIO + I2C bus reset + BCM4500 probe read"),
(0x83, "GPIO + I2C probe + write init block 0"),
]
for wval, label in stages:
if single_stage is not None and wval != single_stage:
continue
result = test_mode(dev, wval, label)
if result is None:
print("\n*** STOPPING: device not responding ***")
break
print(f"\n{'=' * 50}")
print("Debug complete.")
if __name__ == "__main__":
main()

View file

@ -1,444 +1,444 @@
#!/usr/bin/env python3
"""
Hamilton Adversarial Test Suite SkyWalker-1 v3.05.0
"What happens if the astronaut pushes the wrong button?"
Tests operator error, invalid inputs, state machine violations,
boundary conditions, and rapid-fire stress to verify all safety
fixes from the Phase E Margaret Hamilton review.
"""
import sys
import os
import time
import struct
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1
import usb.core
ERR_NAMES = {
0x00: 'OK', 0x01: 'I2C_TIMEOUT', 0x02: 'I2C_NAK', 0x03: 'BCM_TIMEOUT',
0x04: 'BCM_NOT_READY', 0x05: 'BCM_VERIFY', 0x06: 'TUNE_FAIL',
0x07: 'EP0_TIMEOUT', 0x08: 'GPIF_TIMEOUT', 0x09: 'EP2_TIMEOUT',
0x0A: 'NOT_SUPPORTED', 0x0B: 'DISEQC_LEN', 0x0C: 'DISEQC_TIMER',
0x0D: 'WDT_FIRED'
}
passed = 0
failed = 0
def get_err(sw):
return sw.dev.ctrl_transfer(0xC0, 0xBC, 0, 0, 1)[0]
def err_name(code):
return ERR_NAMES.get(code, f'0x{code:02X}')
def device_alive(sw):
try:
fw = sw.get_fw_version()
return fw['version'] == '3.05.0'
except Exception:
return False
def test(sw, label, fn, expect_err=None, expect_no_hang=False):
"""Run test, track error changes, verify device survives."""
global passed, failed
err_before = get_err(sw)
usb_err = None
try:
fn()
except usb.core.USBError as e:
usb_err = e
except Exception as e:
usb_err = e
time.sleep(0.15)
if not device_alive(sw):
print(f' [FAIL] {label}: DEVICE DIED!')
failed += 1
return False
err_after = get_err(sw)
changed = (err_after != err_before)
suffix = f' (USB: {usb_err})' if usb_err else ''
if expect_err is not None:
if err_after == expect_err:
print(f' [PASS] {label}: err={err_name(expect_err)} as expected{suffix}')
passed += 1
else:
print(f' [FAIL] {label}: expected {err_name(expect_err)}, got {err_name(err_after)}{suffix}')
failed += 1
elif expect_no_hang:
print(f' [PASS] {label}: no hang, err={err_name(err_after)}{suffix}')
passed += 1
else:
if changed:
print(f' [INFO] {label}: err changed {err_name(err_before)} -> {err_name(err_after)}{suffix}')
else:
print(f' [PASS] {label}: no new error{suffix}')
passed += 1
return True
def main():
global passed, failed
with SkyWalker1() as sw:
print('=' * 64)
print(' HAMILTON ADVERSARIAL TEST SUITE — SkyWalker-1 v3.05.0')
print(' "What if the astronaut pushes the wrong button?"')
print('=' * 64)
print()
# Ensure clean starting state
sw.dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000)
sw.start_intersil(True)
time.sleep(0.5)
# ============================================================
print('=== CAT 1: DiSEqC Message Abuse ===')
print()
test(sw, '1a. Tone burst B (M3: NOT_SUPPORTED)',
lambda: sw.send_diseqc_tone_burst(1),
expect_err=0x0A)
test(sw, '1b. Tone burst wValue=0xFF',
lambda: sw.dev.ctrl_transfer(0x40, 0x8D, 0xFF, 0, None, timeout=3000),
expect_err=0x0A)
test(sw, '1c. DiSEqC 2 bytes (too short)',
lambda: sw.dev.ctrl_transfer(0x40, 0x8D, 0xE0, 0, bytes([0xE0, 0x10]), timeout=3000),
expect_no_hang=True)
test(sw, '1d. DiSEqC 8 bytes (too long)',
lambda: sw.dev.ctrl_transfer(0x40, 0x8D, 0xE0, 0, bytes([0xE0] * 8), timeout=3000),
expect_no_hang=True)
test(sw, '1e. DiSEqC empty payload',
lambda: sw.dev.ctrl_transfer(0x40, 0x8D, 0xE0, 0, bytes([]), timeout=3000),
expect_no_hang=True)
test(sw, '1f. Valid 4-byte DiSEqC (recovery)',
lambda: sw.send_diseqc_message(bytes([0xE0, 0x10, 0x38, 0xF0])),
expect_no_hang=True)
test(sw, '1g. DiSEqC 1.2 motor halt (no motor)',
lambda: sw.send_diseqc_message(bytes([0xE0, 0x31, 0x60])),
expect_no_hang=True)
test(sw, '1h. DiSEqC 1.2 drive east 255 steps',
lambda: sw.send_diseqc_message(bytes([0xE0, 0x31, 0x68, 0xFF])),
expect_no_hang=True)
test(sw, '1i. DiSEqC 1.2 USALS GotoX (bogus angle)',
lambda: sw.send_diseqc_message(bytes([0xE0, 0x31, 0x6E, 0xFF, 0xFF])),
expect_no_hang=True)
print()
# ============================================================
print('=== CAT 2: Tune Parameter Abuse ===')
print()
test(sw, '2a. SR=0',
lambda: [sw.tune(0, 1000000, 0, 0), time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2b. SR=0xFFFFFFFF',
lambda: [sw.dev.ctrl_transfer(0x40, 0x86, 0, 0,
struct.pack('<II', 0xFFFFFFFF, 1000000) + bytes([0, 0]), timeout=5000),
time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2c. Freq=0',
lambda: [sw.tune(20000000, 0, 0, 0), time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2d. Freq=0xFFFFFFFF',
lambda: [sw.dev.ctrl_transfer(0x40, 0x86, 0, 0,
struct.pack('<II', 20000000, 0xFFFFFFFF) + bytes([0, 0]), timeout=5000),
time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2e. Mod=0xFF',
lambda: [sw.tune(20000000, 1000000, 0xFF, 0), time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2f. FEC=0xFF',
lambda: [sw.tune(20000000, 1000000, 0, 0xFF), time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2g. Truncated payload (4 of 10 bytes)',
lambda: sw.dev.ctrl_transfer(0x40, 0x86, 0, 0, bytes([1, 2, 3, 4]), timeout=5000),
expect_err=0x07) # EP0_TIMEOUT
test(sw, '2h. Single-byte payload',
lambda: sw.dev.ctrl_transfer(0x40, 0x86, 0, 0, bytes([0xAA]), timeout=5000),
expect_err=0x07)
test(sw, '2i. All-zeros payload (10 bytes)',
lambda: [sw.dev.ctrl_transfer(0x40, 0x86, 0, 0, bytes(10), timeout=5000),
time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2j. All-0xFF payload (10 bytes)',
lambda: [sw.dev.ctrl_transfer(0x40, 0x86, 0, 0, bytes([0xFF] * 10), timeout=5000),
time.sleep(0.5)],
expect_no_hang=True)
print()
# ============================================================
print('=== CAT 3: I2C Address Space Abuse ===')
print()
test(sw, '3a. Raw read addr 0x7F (nonexistent)',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB5, 0x7F, 0, 1, timeout=3000),
expect_err=0x02) # I2C_NAK
test(sw, '3b. Raw read addr 0x00 (general call)',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB5, 0x00, 0, 1, timeout=3000),
expect_no_hang=True)
test(sw, '3c. Indirect read page 0xFF',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB1, 0xFF, 0, 1, timeout=3000),
expect_no_hang=True)
test(sw, '3d. Multi-reg count=0 (edge)',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB9, 0, 0, 1, timeout=3000),
expect_no_hang=True)
test(sw, '3e. Multi-reg count=255 (over max 64)',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB9, 0, 255, 64, timeout=5000),
expect_no_hang=True)
test(sw, '3f. Raw write to bogus addr 0x7F',
lambda: sw.dev.ctrl_transfer(0x40, 0xB2, 0x7F, 0, bytes([0x00, 0xAA]), timeout=3000),
expect_no_hang=True)
test(sw, '3g. Raw read from BCM4500 reserved reg 0xFF',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB5, 0x08, 0xFF, 1, timeout=3000),
expect_no_hang=True)
print()
# ============================================================
print('=== CAT 4: State Machine Violations ===')
print()
# 4a. Double boot
test(sw, '4a. Double boot (already booted)',
lambda: sw.dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000),
expect_no_hang=True)
# 4b-4e: Power off BCM, then try everything
print(' >> Powering off BCM4500...')
sw.dev.ctrl_transfer(0xC0, 0x89, 0, 0, 3, timeout=5000)
time.sleep(0.5)
test(sw, '4b. Tune with BCM off',
lambda: [sw.tune(20000000, 1000000, 0, 0), time.sleep(0.5)],
expect_err=0x04) # BCM_NOT_READY
test(sw, '4c. Signal monitor with BCM off',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB7, 0, 0, 8, timeout=3000),
expect_no_hang=True)
test(sw, '4d. I2C bus scan with BCM off',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB4, 0, 0, 16, timeout=5000),
expect_no_hang=True)
test(sw, '4e. Hotplug rescan with BCM off',
lambda: sw.dev.ctrl_transfer(0xC0, 0xBE, 2, 0, 36, timeout=5000),
expect_no_hang=True)
# 4f. Recovery
print(' >> Re-booting BCM4500...')
r = sw.dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000)
time.sleep(0.5)
cfg = sw.get_config()
if cfg & 0x03 == 0x03:
print(f' [PASS] 4f. Recovery: config=0x{cfg:02X} (STARTED|FW_LOADED)')
passed += 1
else:
print(f' [FAIL] 4f. No recovery: config=0x{cfg:02X}')
failed += 1
# 4g. Arm/disarm rapid toggle
test(sw, '4g. Arm + immediate disarm',
lambda: [sw.arm_transfer(True), sw.arm_transfer(False)],
expect_no_hang=True)
# 4h. Disarm when not armed
test(sw, '4h. Disarm when not armed',
lambda: sw.arm_transfer(False),
expect_no_hang=True)
# 4i. Boot off/on/off/on rapid
test(sw, '4i. Rapid boot toggle (off-on-off-on)',
lambda: [
sw.dev.ctrl_transfer(0xC0, 0x89, 0, 0, 3, timeout=5000),
time.sleep(0.2),
sw.dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000),
time.sleep(0.3),
sw.dev.ctrl_transfer(0xC0, 0x89, 0, 0, 3, timeout=5000),
time.sleep(0.2),
sw.dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000),
time.sleep(0.3),
],
expect_no_hang=True)
print()
# ============================================================
print('=== CAT 5: Boundary & Buffer Abuse ===')
print()
# 5a. Request 0 bytes from GET_CONFIG
test(sw, '5a. GET_CONFIG request 0 bytes',
lambda: sw.dev.ctrl_transfer(0xC0, 0x80, 0, 0, 0, timeout=2000),
expect_no_hang=True)
# 5b. Request 64 bytes from GET_CONFIG (returns 1)
test(sw, '5b. GET_CONFIG request 64 bytes',
lambda: sw.dev.ctrl_transfer(0xC0, 0x80, 0, 0, 64, timeout=2000),
expect_no_hang=True)
# 5c. Request 64 bytes from GET_LAST_ERROR (returns 1)
test(sw, '5c. GET_LAST_ERROR request 64 bytes',
lambda: sw.dev.ctrl_transfer(0xC0, 0xBC, 0, 0, 64, timeout=2000),
expect_no_hang=True)
# 5d. GET_FW_VERS request 1 byte (returns 6)
test(sw, '5d. GET_FW_VERS request 1 byte',
lambda: sw.dev.ctrl_transfer(0xC0, 0x92, 0, 0, 1, timeout=2000),
expect_no_hang=True)
# 5e. GET_STREAM_DIAG request 1 byte (returns 12)
test(sw, '5e. GET_STREAM_DIAG request 1 byte',
lambda: sw.dev.ctrl_transfer(0xC0, 0xBD, 0, 0, 1, timeout=2000),
expect_no_hang=True)
# 5f. GET_STREAM_DIAG with wval=0xFFFF (reset flag, but not 1)
test(sw, '5f. GET_STREAM_DIAG wval=0xFFFF',
lambda: sw.dev.ctrl_transfer(0xC0, 0xBD, 0xFFFF, 0, 12, timeout=2000),
expect_no_hang=True)
# 5g. GET_HOTPLUG with wval=0xFFFF (unknown sub-command)
test(sw, '5g. GET_HOTPLUG wval=0xFFFF',
lambda: sw.dev.ctrl_transfer(0xC0, 0xBE, 0xFFFF, 0, 36, timeout=2000),
expect_no_hang=True)
print()
# ============================================================
print('=== CAT 6: Rapid-Fire Stress ===')
print()
t0 = time.time()
for i in range(200):
sw.get_config()
dt = time.time() - t0
print(f' [PASS] 6a. 200 config reads: {dt * 1000:.0f}ms ({dt / 200 * 1000:.1f}ms/read)')
passed += 1
t0 = time.time()
for i in range(50):
get_err(sw)
dt = time.time() - t0
print(f' [PASS] 6b. 50 error reads: {dt * 1000:.0f}ms ({dt / 50 * 1000:.1f}ms/read)')
passed += 1
t0 = time.time()
errs = 0
for i in range(30):
try:
sw.signal_monitor()
except Exception:
errs += 1
dt = time.time() - t0
print(f' [PASS] 6c. 30 signal monitors: {dt * 1000:.0f}ms ({errs} errors)')
passed += 1
sw.start_intersil(True)
time.sleep(0.1)
t0 = time.time()
for i in range(40):
sw.set_lnb_voltage(i % 2 == 0)
dt = time.time() - t0
print(f' [PASS] 6d. 40 voltage toggles: {dt * 1000:.0f}ms')
passed += 1
t0 = time.time()
for i in range(10):
try:
sw.send_diseqc_message(bytes([0xE0, 0x10, 0x38, 0xF0 | (i & 3)]))
time.sleep(0.05)
except Exception:
pass
dt = time.time() - t0
print(f' [PASS] 6e. 10 DiSEqC msgs: {dt * 1000:.0f}ms')
passed += 1
print()
# ============================================================
print('=== CAT 7: Invalid Vendor Commands ===')
print()
for cmd, name in [(0xFF, '0xFF'), (0x01, '0x01'), (0x50, '0x50'),
(0xFE, '0xFE'), (0x00, '0x00'), (0x79, '0x79')]:
try:
r = sw.dev.ctrl_transfer(0xC0, cmd, 0, 0, 1, timeout=2000)
print(f' [INFO] 7. Cmd {name}: accepted (0x{r[0]:02X})')
except usb.core.USBError:
print(f' [PASS] 7. Cmd {name}: STALL (rejected)')
passed += 1
test(sw, '7g. GET_CONFIG as OUT direction',
lambda: sw.dev.ctrl_transfer(0x40, 0x80, 0, 0, bytes([0xAA]), timeout=2000),
expect_no_hang=True)
test(sw, '7h. 64-byte payload to GET_CONFIG',
lambda: sw.dev.ctrl_transfer(0x40, 0x80, 0, 0, bytes(64), timeout=2000),
expect_no_hang=True)
print()
# ============================================================
# CLEANUP
# ============================================================
sw.set_22khz_tone(False)
sw.set_lnb_voltage(False)
sw.start_intersil(False)
time.sleep(0.2)
alive = device_alive(sw)
final_err = get_err(sw)
print('=' * 64)
print(f' HAMILTON ADVERSARIAL TEST — FINAL RESULTS')
print(f' -----------------------------------------')
print(f' Tests passed: {passed}')
print(f' Tests failed: {failed}')
print(f' Device alive: {alive}')
print(f' Final error: 0x{final_err:02X} [{err_name(final_err)}]')
print(f' Watchdog fired: {"YES!" if final_err == 0x0D else "No"}')
verdict = 'PASS' if failed == 0 and alive else 'FAIL'
print(f' Verdict: {verdict}')
print('=' * 64)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Hamilton Adversarial Test Suite SkyWalker-1 v3.05.0
"What happens if the astronaut pushes the wrong button?"
Tests operator error, invalid inputs, state machine violations,
boundary conditions, and rapid-fire stress to verify all safety
fixes from the Phase E Margaret Hamilton review.
"""
import sys
import os
import time
import struct
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from skywalker_lib import SkyWalker1
import usb.core
ERR_NAMES = {
0x00: 'OK', 0x01: 'I2C_TIMEOUT', 0x02: 'I2C_NAK', 0x03: 'BCM_TIMEOUT',
0x04: 'BCM_NOT_READY', 0x05: 'BCM_VERIFY', 0x06: 'TUNE_FAIL',
0x07: 'EP0_TIMEOUT', 0x08: 'GPIF_TIMEOUT', 0x09: 'EP2_TIMEOUT',
0x0A: 'NOT_SUPPORTED', 0x0B: 'DISEQC_LEN', 0x0C: 'DISEQC_TIMER',
0x0D: 'WDT_FIRED'
}
passed = 0
failed = 0
def get_err(sw):
return sw.dev.ctrl_transfer(0xC0, 0xBC, 0, 0, 1)[0]
def err_name(code):
return ERR_NAMES.get(code, f'0x{code:02X}')
def device_alive(sw):
try:
fw = sw.get_fw_version()
return fw['version'] == '3.05.0'
except Exception:
return False
def test(sw, label, fn, expect_err=None, expect_no_hang=False):
"""Run test, track error changes, verify device survives."""
global passed, failed
err_before = get_err(sw)
usb_err = None
try:
fn()
except usb.core.USBError as e:
usb_err = e
except Exception as e:
usb_err = e
time.sleep(0.15)
if not device_alive(sw):
print(f' [FAIL] {label}: DEVICE DIED!')
failed += 1
return False
err_after = get_err(sw)
changed = (err_after != err_before)
suffix = f' (USB: {usb_err})' if usb_err else ''
if expect_err is not None:
if err_after == expect_err:
print(f' [PASS] {label}: err={err_name(expect_err)} as expected{suffix}')
passed += 1
else:
print(f' [FAIL] {label}: expected {err_name(expect_err)}, got {err_name(err_after)}{suffix}')
failed += 1
elif expect_no_hang:
print(f' [PASS] {label}: no hang, err={err_name(err_after)}{suffix}')
passed += 1
else:
if changed:
print(f' [INFO] {label}: err changed {err_name(err_before)} -> {err_name(err_after)}{suffix}')
else:
print(f' [PASS] {label}: no new error{suffix}')
passed += 1
return True
def main():
global passed, failed
with SkyWalker1() as sw:
print('=' * 64)
print(' HAMILTON ADVERSARIAL TEST SUITE — SkyWalker-1 v3.05.0')
print(' "What if the astronaut pushes the wrong button?"')
print('=' * 64)
print()
# Ensure clean starting state
sw.dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000)
sw.start_intersil(True)
time.sleep(0.5)
# ============================================================
print('=== CAT 1: DiSEqC Message Abuse ===')
print()
test(sw, '1a. Tone burst B (M3: NOT_SUPPORTED)',
lambda: sw.send_diseqc_tone_burst(1),
expect_err=0x0A)
test(sw, '1b. Tone burst wValue=0xFF',
lambda: sw.dev.ctrl_transfer(0x40, 0x8D, 0xFF, 0, None, timeout=3000),
expect_err=0x0A)
test(sw, '1c. DiSEqC 2 bytes (too short)',
lambda: sw.dev.ctrl_transfer(0x40, 0x8D, 0xE0, 0, bytes([0xE0, 0x10]), timeout=3000),
expect_no_hang=True)
test(sw, '1d. DiSEqC 8 bytes (too long)',
lambda: sw.dev.ctrl_transfer(0x40, 0x8D, 0xE0, 0, bytes([0xE0] * 8), timeout=3000),
expect_no_hang=True)
test(sw, '1e. DiSEqC empty payload',
lambda: sw.dev.ctrl_transfer(0x40, 0x8D, 0xE0, 0, bytes([]), timeout=3000),
expect_no_hang=True)
test(sw, '1f. Valid 4-byte DiSEqC (recovery)',
lambda: sw.send_diseqc_message(bytes([0xE0, 0x10, 0x38, 0xF0])),
expect_no_hang=True)
test(sw, '1g. DiSEqC 1.2 motor halt (no motor)',
lambda: sw.send_diseqc_message(bytes([0xE0, 0x31, 0x60])),
expect_no_hang=True)
test(sw, '1h. DiSEqC 1.2 drive east 255 steps',
lambda: sw.send_diseqc_message(bytes([0xE0, 0x31, 0x68, 0xFF])),
expect_no_hang=True)
test(sw, '1i. DiSEqC 1.2 USALS GotoX (bogus angle)',
lambda: sw.send_diseqc_message(bytes([0xE0, 0x31, 0x6E, 0xFF, 0xFF])),
expect_no_hang=True)
print()
# ============================================================
print('=== CAT 2: Tune Parameter Abuse ===')
print()
test(sw, '2a. SR=0',
lambda: [sw.tune(0, 1000000, 0, 0), time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2b. SR=0xFFFFFFFF',
lambda: [sw.dev.ctrl_transfer(0x40, 0x86, 0, 0,
struct.pack('<II', 0xFFFFFFFF, 1000000) + bytes([0, 0]), timeout=5000),
time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2c. Freq=0',
lambda: [sw.tune(20000000, 0, 0, 0), time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2d. Freq=0xFFFFFFFF',
lambda: [sw.dev.ctrl_transfer(0x40, 0x86, 0, 0,
struct.pack('<II', 20000000, 0xFFFFFFFF) + bytes([0, 0]), timeout=5000),
time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2e. Mod=0xFF',
lambda: [sw.tune(20000000, 1000000, 0xFF, 0), time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2f. FEC=0xFF',
lambda: [sw.tune(20000000, 1000000, 0, 0xFF), time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2g. Truncated payload (4 of 10 bytes)',
lambda: sw.dev.ctrl_transfer(0x40, 0x86, 0, 0, bytes([1, 2, 3, 4]), timeout=5000),
expect_err=0x07) # EP0_TIMEOUT
test(sw, '2h. Single-byte payload',
lambda: sw.dev.ctrl_transfer(0x40, 0x86, 0, 0, bytes([0xAA]), timeout=5000),
expect_err=0x07)
test(sw, '2i. All-zeros payload (10 bytes)',
lambda: [sw.dev.ctrl_transfer(0x40, 0x86, 0, 0, bytes(10), timeout=5000),
time.sleep(0.5)],
expect_no_hang=True)
test(sw, '2j. All-0xFF payload (10 bytes)',
lambda: [sw.dev.ctrl_transfer(0x40, 0x86, 0, 0, bytes([0xFF] * 10), timeout=5000),
time.sleep(0.5)],
expect_no_hang=True)
print()
# ============================================================
print('=== CAT 3: I2C Address Space Abuse ===')
print()
test(sw, '3a. Raw read addr 0x7F (nonexistent)',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB5, 0x7F, 0, 1, timeout=3000),
expect_err=0x02) # I2C_NAK
test(sw, '3b. Raw read addr 0x00 (general call)',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB5, 0x00, 0, 1, timeout=3000),
expect_no_hang=True)
test(sw, '3c. Indirect read page 0xFF',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB1, 0xFF, 0, 1, timeout=3000),
expect_no_hang=True)
test(sw, '3d. Multi-reg count=0 (edge)',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB9, 0, 0, 1, timeout=3000),
expect_no_hang=True)
test(sw, '3e. Multi-reg count=255 (over max 64)',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB9, 0, 255, 64, timeout=5000),
expect_no_hang=True)
test(sw, '3f. Raw write to bogus addr 0x7F',
lambda: sw.dev.ctrl_transfer(0x40, 0xB2, 0x7F, 0, bytes([0x00, 0xAA]), timeout=3000),
expect_no_hang=True)
test(sw, '3g. Raw read from BCM4500 reserved reg 0xFF',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB5, 0x08, 0xFF, 1, timeout=3000),
expect_no_hang=True)
print()
# ============================================================
print('=== CAT 4: State Machine Violations ===')
print()
# 4a. Double boot
test(sw, '4a. Double boot (already booted)',
lambda: sw.dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000),
expect_no_hang=True)
# 4b-4e: Power off BCM, then try everything
print(' >> Powering off BCM4500...')
sw.dev.ctrl_transfer(0xC0, 0x89, 0, 0, 3, timeout=5000)
time.sleep(0.5)
test(sw, '4b. Tune with BCM off',
lambda: [sw.tune(20000000, 1000000, 0, 0), time.sleep(0.5)],
expect_err=0x04) # BCM_NOT_READY
test(sw, '4c. Signal monitor with BCM off',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB7, 0, 0, 8, timeout=3000),
expect_no_hang=True)
test(sw, '4d. I2C bus scan with BCM off',
lambda: sw.dev.ctrl_transfer(0xC0, 0xB4, 0, 0, 16, timeout=5000),
expect_no_hang=True)
test(sw, '4e. Hotplug rescan with BCM off',
lambda: sw.dev.ctrl_transfer(0xC0, 0xBE, 2, 0, 36, timeout=5000),
expect_no_hang=True)
# 4f. Recovery
print(' >> Re-booting BCM4500...')
r = sw.dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000)
time.sleep(0.5)
cfg = sw.get_config()
if cfg & 0x03 == 0x03:
print(f' [PASS] 4f. Recovery: config=0x{cfg:02X} (STARTED|FW_LOADED)')
passed += 1
else:
print(f' [FAIL] 4f. No recovery: config=0x{cfg:02X}')
failed += 1
# 4g. Arm/disarm rapid toggle
test(sw, '4g. Arm + immediate disarm',
lambda: [sw.arm_transfer(True), sw.arm_transfer(False)],
expect_no_hang=True)
# 4h. Disarm when not armed
test(sw, '4h. Disarm when not armed',
lambda: sw.arm_transfer(False),
expect_no_hang=True)
# 4i. Boot off/on/off/on rapid
test(sw, '4i. Rapid boot toggle (off-on-off-on)',
lambda: [
sw.dev.ctrl_transfer(0xC0, 0x89, 0, 0, 3, timeout=5000),
time.sleep(0.2),
sw.dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000),
time.sleep(0.3),
sw.dev.ctrl_transfer(0xC0, 0x89, 0, 0, 3, timeout=5000),
time.sleep(0.2),
sw.dev.ctrl_transfer(0xC0, 0x89, 1, 0, 3, timeout=10000),
time.sleep(0.3),
],
expect_no_hang=True)
print()
# ============================================================
print('=== CAT 5: Boundary & Buffer Abuse ===')
print()
# 5a. Request 0 bytes from GET_CONFIG
test(sw, '5a. GET_CONFIG request 0 bytes',
lambda: sw.dev.ctrl_transfer(0xC0, 0x80, 0, 0, 0, timeout=2000),
expect_no_hang=True)
# 5b. Request 64 bytes from GET_CONFIG (returns 1)
test(sw, '5b. GET_CONFIG request 64 bytes',
lambda: sw.dev.ctrl_transfer(0xC0, 0x80, 0, 0, 64, timeout=2000),
expect_no_hang=True)
# 5c. Request 64 bytes from GET_LAST_ERROR (returns 1)
test(sw, '5c. GET_LAST_ERROR request 64 bytes',
lambda: sw.dev.ctrl_transfer(0xC0, 0xBC, 0, 0, 64, timeout=2000),
expect_no_hang=True)
# 5d. GET_FW_VERS request 1 byte (returns 6)
test(sw, '5d. GET_FW_VERS request 1 byte',
lambda: sw.dev.ctrl_transfer(0xC0, 0x92, 0, 0, 1, timeout=2000),
expect_no_hang=True)
# 5e. GET_STREAM_DIAG request 1 byte (returns 12)
test(sw, '5e. GET_STREAM_DIAG request 1 byte',
lambda: sw.dev.ctrl_transfer(0xC0, 0xBD, 0, 0, 1, timeout=2000),
expect_no_hang=True)
# 5f. GET_STREAM_DIAG with wval=0xFFFF (reset flag, but not 1)
test(sw, '5f. GET_STREAM_DIAG wval=0xFFFF',
lambda: sw.dev.ctrl_transfer(0xC0, 0xBD, 0xFFFF, 0, 12, timeout=2000),
expect_no_hang=True)
# 5g. GET_HOTPLUG with wval=0xFFFF (unknown sub-command)
test(sw, '5g. GET_HOTPLUG wval=0xFFFF',
lambda: sw.dev.ctrl_transfer(0xC0, 0xBE, 0xFFFF, 0, 36, timeout=2000),
expect_no_hang=True)
print()
# ============================================================
print('=== CAT 6: Rapid-Fire Stress ===')
print()
t0 = time.time()
for i in range(200):
sw.get_config()
dt = time.time() - t0
print(f' [PASS] 6a. 200 config reads: {dt * 1000:.0f}ms ({dt / 200 * 1000:.1f}ms/read)')
passed += 1
t0 = time.time()
for i in range(50):
get_err(sw)
dt = time.time() - t0
print(f' [PASS] 6b. 50 error reads: {dt * 1000:.0f}ms ({dt / 50 * 1000:.1f}ms/read)')
passed += 1
t0 = time.time()
errs = 0
for i in range(30):
try:
sw.signal_monitor()
except Exception:
errs += 1
dt = time.time() - t0
print(f' [PASS] 6c. 30 signal monitors: {dt * 1000:.0f}ms ({errs} errors)')
passed += 1
sw.start_intersil(True)
time.sleep(0.1)
t0 = time.time()
for i in range(40):
sw.set_lnb_voltage(i % 2 == 0)
dt = time.time() - t0
print(f' [PASS] 6d. 40 voltage toggles: {dt * 1000:.0f}ms')
passed += 1
t0 = time.time()
for i in range(10):
try:
sw.send_diseqc_message(bytes([0xE0, 0x10, 0x38, 0xF0 | (i & 3)]))
time.sleep(0.05)
except Exception:
pass
dt = time.time() - t0
print(f' [PASS] 6e. 10 DiSEqC msgs: {dt * 1000:.0f}ms')
passed += 1
print()
# ============================================================
print('=== CAT 7: Invalid Vendor Commands ===')
print()
for cmd, name in [(0xFF, '0xFF'), (0x01, '0x01'), (0x50, '0x50'),
(0xFE, '0xFE'), (0x00, '0x00'), (0x79, '0x79')]:
try:
r = sw.dev.ctrl_transfer(0xC0, cmd, 0, 0, 1, timeout=2000)
print(f' [INFO] 7. Cmd {name}: accepted (0x{r[0]:02X})')
except usb.core.USBError:
print(f' [PASS] 7. Cmd {name}: STALL (rejected)')
passed += 1
test(sw, '7g. GET_CONFIG as OUT direction',
lambda: sw.dev.ctrl_transfer(0x40, 0x80, 0, 0, bytes([0xAA]), timeout=2000),
expect_no_hang=True)
test(sw, '7h. 64-byte payload to GET_CONFIG',
lambda: sw.dev.ctrl_transfer(0x40, 0x80, 0, 0, bytes(64), timeout=2000),
expect_no_hang=True)
print()
# ============================================================
# CLEANUP
# ============================================================
sw.set_22khz_tone(False)
sw.set_lnb_voltage(False)
sw.start_intersil(False)
time.sleep(0.2)
alive = device_alive(sw)
final_err = get_err(sw)
print('=' * 64)
print(f' HAMILTON ADVERSARIAL TEST — FINAL RESULTS')
print(f' -----------------------------------------')
print(f' Tests passed: {passed}')
print(f' Tests failed: {failed}')
print(f' Device alive: {alive}')
print(f' Final error: 0x{final_err:02X} [{err_name(final_err)}]')
print(f' Watchdog fired: {"YES!" if final_err == 0x0D else "No"}')
verdict = 'PASS' if failed == 0 and alive else 'FAIL'
print(f' Verdict: {verdict}')
print('=' * 64)
if __name__ == '__main__':
main()

View file

@ -1,118 +1,118 @@
#!/usr/bin/env python3
"""I2C debug tool for SkyWalker-1.
First powers on the BCM4500 via GPIO debug mode (0x81), then:
1. Runs I2C bus scan (0xB4) to find any devices
2. Tries raw I2C reads (0xB5) to common BCM4500 addresses
3. Tests different post-reset delays
"""
import usb.core
import usb.util
import sys
import time
BOOT_8PSK = 0x89
def find_device():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("Device not found!")
sys.exit(1)
return dev
def setup_device(dev):
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception:
pass
try:
dev.set_configuration()
except usb.core.USBError:
pass
def main():
dev = find_device()
setup_device(dev)
# Verify firmware
ret = dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6, timeout=2000)
major, minor, patch = ret[2], ret[1], ret[0]
print(f"Firmware: v{major}.{minor:02d}.{patch}")
# Step 1: Power on BCM4500 via GPIO-only debug mode
print("\n--- Step 1: Power on BCM4500 (GPIO mode 0x81) ---")
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x81, 0, 3, timeout=3000)
print(f" GPIO setup: stage=0x{ret[1]:02X}")
# Step 2: I2C bus scan immediately
print("\n--- Step 2: I2C bus scan (immediately after power-on) ---")
try:
ret = dev.ctrl_transfer(0xC0, 0xB4, 0, 0, 16, timeout=5000)
addrs = []
for bi in range(16):
for bit in range(8):
if ret[bi] & (1 << bit):
addrs.append(bi * 8 + bit)
if addrs:
print(f" Found devices at: {[f'0x{a:02X}' for a in addrs]}")
else:
print(" No I2C devices found!")
except usb.core.USBError as e:
print(f" Bus scan error: {e}")
# Step 3: Wait longer and scan again
print("\n--- Step 3: Wait 500ms and scan again ---")
time.sleep(0.5)
try:
ret = dev.ctrl_transfer(0xC0, 0xB4, 0, 0, 16, timeout=5000)
addrs = []
for bi in range(16):
for bit in range(8):
if ret[bi] & (1 << bit):
addrs.append(bi * 8 + bit)
if addrs:
print(f" Found devices at: {[f'0x{a:02X}' for a in addrs]}")
else:
print(" No I2C devices found!")
except usb.core.USBError as e:
print(f" Bus scan error: {e}")
# Step 4: Try raw I2C reads to various addresses
print("\n--- Step 4: Raw I2C reads (0xB5) to likely BCM4500 addresses ---")
# BCM4500 could be at different addresses depending on pin strapping
# Common: 0x08 (AD=low), 0x0A (AD=high), or even other addresses
candidates = [0x08, 0x09, 0x0A, 0x0B, 0x10, 0x11, 0x68, 0x69, 0x60, 0x61]
for addr in candidates:
for reg in [0xA2, 0x00]:
try:
r = dev.ctrl_transfer(0xC0, 0xB5, addr, reg, 1, timeout=1000)
print(f" Addr 0x{addr:02X} Reg 0x{reg:02X} = 0x{r[0]:02X} <--- RESPONDS!")
except usb.core.USBError:
print(f" Addr 0x{addr:02X} Reg 0x{reg:02X} = (no response)")
# Step 5: Check I2C bus state
print("\n--- Step 5: I2C controller state ---")
try:
# Read I2CTL and I2CS by inspecting them through a known-working address
# Actually, we can just observe what happens when we try reads
print(" (Bus scan and raw reads above show bus health)")
except Exception as e:
print(f" Error: {e}")
# Step 6: Try I2C probe via debug mode 0x82 again with a delay
print("\n--- Step 6: Debug probe (0x82) after additional 1s delay ---")
time.sleep(1.0)
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x82, 0, 3, timeout=3000)
stage = ret[1]
probe = ret[2]
if stage == 0xA2:
print(f" PROBE SUCCESS! BCM4500 status = 0x{probe:02X}")
else:
print(f" Probe failed: stage=0x{stage:02X} probe=0x{probe:02X}")
print("\nDone.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""I2C debug tool for SkyWalker-1.
First powers on the BCM4500 via GPIO debug mode (0x81), then:
1. Runs I2C bus scan (0xB4) to find any devices
2. Tries raw I2C reads (0xB5) to common BCM4500 addresses
3. Tests different post-reset delays
"""
import usb.core
import usb.util
import sys
import time
BOOT_8PSK = 0x89
def find_device():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("Device not found!")
sys.exit(1)
return dev
def setup_device(dev):
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception:
pass
try:
dev.set_configuration()
except usb.core.USBError:
pass
def main():
dev = find_device()
setup_device(dev)
# Verify firmware
ret = dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6, timeout=2000)
major, minor, patch = ret[2], ret[1], ret[0]
print(f"Firmware: v{major}.{minor:02d}.{patch}")
# Step 1: Power on BCM4500 via GPIO-only debug mode
print("\n--- Step 1: Power on BCM4500 (GPIO mode 0x81) ---")
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x81, 0, 3, timeout=3000)
print(f" GPIO setup: stage=0x{ret[1]:02X}")
# Step 2: I2C bus scan immediately
print("\n--- Step 2: I2C bus scan (immediately after power-on) ---")
try:
ret = dev.ctrl_transfer(0xC0, 0xB4, 0, 0, 16, timeout=5000)
addrs = []
for bi in range(16):
for bit in range(8):
if ret[bi] & (1 << bit):
addrs.append(bi * 8 + bit)
if addrs:
print(f" Found devices at: {[f'0x{a:02X}' for a in addrs]}")
else:
print(" No I2C devices found!")
except usb.core.USBError as e:
print(f" Bus scan error: {e}")
# Step 3: Wait longer and scan again
print("\n--- Step 3: Wait 500ms and scan again ---")
time.sleep(0.5)
try:
ret = dev.ctrl_transfer(0xC0, 0xB4, 0, 0, 16, timeout=5000)
addrs = []
for bi in range(16):
for bit in range(8):
if ret[bi] & (1 << bit):
addrs.append(bi * 8 + bit)
if addrs:
print(f" Found devices at: {[f'0x{a:02X}' for a in addrs]}")
else:
print(" No I2C devices found!")
except usb.core.USBError as e:
print(f" Bus scan error: {e}")
# Step 4: Try raw I2C reads to various addresses
print("\n--- Step 4: Raw I2C reads (0xB5) to likely BCM4500 addresses ---")
# BCM4500 could be at different addresses depending on pin strapping
# Common: 0x08 (AD=low), 0x0A (AD=high), or even other addresses
candidates = [0x08, 0x09, 0x0A, 0x0B, 0x10, 0x11, 0x68, 0x69, 0x60, 0x61]
for addr in candidates:
for reg in [0xA2, 0x00]:
try:
r = dev.ctrl_transfer(0xC0, 0xB5, addr, reg, 1, timeout=1000)
print(f" Addr 0x{addr:02X} Reg 0x{reg:02X} = 0x{r[0]:02X} <--- RESPONDS!")
except usb.core.USBError:
print(f" Addr 0x{addr:02X} Reg 0x{reg:02X} = (no response)")
# Step 5: Check I2C bus state
print("\n--- Step 5: I2C controller state ---")
try:
# Read I2CTL and I2CS by inspecting them through a known-working address
# Actually, we can just observe what happens when we try reads
print(" (Bus scan and raw reads above show bus health)")
except Exception as e:
print(f" Error: {e}")
# Step 6: Try I2C probe via debug mode 0x82 again with a delay
print("\n--- Step 6: Debug probe (0x82) after additional 1s delay ---")
time.sleep(1.0)
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x82, 0, 3, timeout=3000)
stage = ret[1]
probe = ret[2]
if stage == 0xA2:
print(f" PROBE SUCCESS! BCM4500 status = 0x{probe:02X}")
else:
print(f" Probe failed: stage=0x{stage:02X} probe=0x{probe:02X}")
print("\nDone.")
if __name__ == "__main__":
main()

View file

@ -1,126 +1,126 @@
#!/usr/bin/env python3
"""Isolate whether bcm_direct_read is broken or if re-reset causes the failure.
Test sequence:
1. Power on BCM4500 with 0x81 (GPIO only)
2. Wait 1s for chip to settle
3. Confirm chip alive via raw read 0xB5
4. Try bcm_direct_read via debug mode 0x82 (which RE-RESETS the chip)
5. Immediately try raw read 0xB5 again (is chip alive after 0x82's reset?)
6. Wait various delays and retry raw reads
This tells us if the issue is bcm_direct_read vs insufficient post-reset delay.
"""
import usb.core
import usb.util
import sys
import time
BOOT_8PSK = 0x89
def find_device():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("Device not found!")
sys.exit(1)
return dev
def setup_device(dev):
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception:
pass
try:
dev.set_configuration()
except usb.core.USBError:
pass
def raw_read(dev, addr, reg, label=""):
"""Read via 0xB5 raw I2C handler."""
try:
r = dev.ctrl_transfer(0xC0, 0xB5, addr, reg, 1, timeout=1000)
val = r[0]
ok = val != 0xFF
mark = "OK" if ok else "no-resp"
print(f" {label}Raw read addr=0x{addr:02X} reg=0x{reg:02X} → 0x{val:02X} ({mark})")
return val, ok
except usb.core.USBError as e:
print(f" {label}Raw read addr=0x{addr:02X} reg=0x{reg:02X} → USB ERROR: {e}")
return None, False
def main():
dev = find_device()
setup_device(dev)
ret = dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6, timeout=2000)
major, minor, patch = ret[2], ret[1], ret[0]
print(f"Firmware: v{major}.{minor:02d}.{patch}\n")
# --- Test A: Verify BCM4500 alive from cold ---
print("=" * 55)
print("TEST A: Power on BCM4500, wait, then raw read")
print("=" * 55)
print(" Sending 0x81 (GPIO power on + reset release)...")
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x81, 0, 3, timeout=3000)
print(f" GPIO done: stage=0x{ret[1]:02X}")
print(" Waiting 1000ms for BCM4500 to settle...")
time.sleep(1.0)
raw_read(dev, 0x08, 0xA2, "After 1s: ")
# --- Test B: Now try bcm_direct_read (which re-resets) ---
print()
print("=" * 55)
print("TEST B: Run debug mode 0x82 (re-resets + probe via bcm_direct_read)")
print("=" * 55)
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x82, 0, 3, timeout=3000)
stage = ret[1]
probe = ret[2]
if stage == 0xA2:
print(f" bcm_direct_read SUCCEEDED: status=0x{probe:02X}")
else:
print(f" bcm_direct_read FAILED: stage=0x{stage:02X} probe=0x{probe:02X}")
# --- Test C: Immediately try raw read after 0x82 (same I2C function, no reset) ---
print()
print("=" * 55)
print("TEST C: Immediately try raw read 0xB5 (same i2c_combined_read)")
print("=" * 55)
raw_read(dev, 0x08, 0xA2, "Immediate: ")
# --- Test D: Wait and retry at various intervals ---
print()
print("=" * 55)
print("TEST D: Raw reads with increasing delays after 0x82's reset")
print("=" * 55)
for delay_ms in [100, 200, 500, 1000, 2000]:
time.sleep(delay_ms / 1000.0)
raw_read(dev, 0x08, 0xA2, f"After {delay_ms}ms: ")
# --- Test E: Redo power-on without reset, then probe ---
print()
print("=" * 55)
print("TEST E: Run 0x81 again (re-power), wait 1s, then 0x82")
print("=" * 55)
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x81, 0, 3, timeout=3000)
print(f" GPIO done: stage=0x{ret[1]:02X}")
time.sleep(1.0)
raw_read(dev, 0x08, 0xA2, "After 0x81+1s: ")
print(" Now running 0x82 (re-reset + probe)...")
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x82, 0, 3, timeout=3000)
stage = ret[1]
probe = ret[2]
if stage == 0xA2:
print(f" bcm_direct_read SUCCEEDED: status=0x{probe:02X}")
else:
print(f" bcm_direct_read FAILED: stage=0x{stage:02X} probe=0x{probe:02X}")
print("\n" + "=" * 55)
print("Analysis complete.")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Isolate whether bcm_direct_read is broken or if re-reset causes the failure.
Test sequence:
1. Power on BCM4500 with 0x81 (GPIO only)
2. Wait 1s for chip to settle
3. Confirm chip alive via raw read 0xB5
4. Try bcm_direct_read via debug mode 0x82 (which RE-RESETS the chip)
5. Immediately try raw read 0xB5 again (is chip alive after 0x82's reset?)
6. Wait various delays and retry raw reads
This tells us if the issue is bcm_direct_read vs insufficient post-reset delay.
"""
import usb.core
import usb.util
import sys
import time
BOOT_8PSK = 0x89
def find_device():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("Device not found!")
sys.exit(1)
return dev
def setup_device(dev):
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception:
pass
try:
dev.set_configuration()
except usb.core.USBError:
pass
def raw_read(dev, addr, reg, label=""):
"""Read via 0xB5 raw I2C handler."""
try:
r = dev.ctrl_transfer(0xC0, 0xB5, addr, reg, 1, timeout=1000)
val = r[0]
ok = val != 0xFF
mark = "OK" if ok else "no-resp"
print(f" {label}Raw read addr=0x{addr:02X} reg=0x{reg:02X} → 0x{val:02X} ({mark})")
return val, ok
except usb.core.USBError as e:
print(f" {label}Raw read addr=0x{addr:02X} reg=0x{reg:02X} → USB ERROR: {e}")
return None, False
def main():
dev = find_device()
setup_device(dev)
ret = dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6, timeout=2000)
major, minor, patch = ret[2], ret[1], ret[0]
print(f"Firmware: v{major}.{minor:02d}.{patch}\n")
# --- Test A: Verify BCM4500 alive from cold ---
print("=" * 55)
print("TEST A: Power on BCM4500, wait, then raw read")
print("=" * 55)
print(" Sending 0x81 (GPIO power on + reset release)...")
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x81, 0, 3, timeout=3000)
print(f" GPIO done: stage=0x{ret[1]:02X}")
print(" Waiting 1000ms for BCM4500 to settle...")
time.sleep(1.0)
raw_read(dev, 0x08, 0xA2, "After 1s: ")
# --- Test B: Now try bcm_direct_read (which re-resets) ---
print()
print("=" * 55)
print("TEST B: Run debug mode 0x82 (re-resets + probe via bcm_direct_read)")
print("=" * 55)
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x82, 0, 3, timeout=3000)
stage = ret[1]
probe = ret[2]
if stage == 0xA2:
print(f" bcm_direct_read SUCCEEDED: status=0x{probe:02X}")
else:
print(f" bcm_direct_read FAILED: stage=0x{stage:02X} probe=0x{probe:02X}")
# --- Test C: Immediately try raw read after 0x82 (same I2C function, no reset) ---
print()
print("=" * 55)
print("TEST C: Immediately try raw read 0xB5 (same i2c_combined_read)")
print("=" * 55)
raw_read(dev, 0x08, 0xA2, "Immediate: ")
# --- Test D: Wait and retry at various intervals ---
print()
print("=" * 55)
print("TEST D: Raw reads with increasing delays after 0x82's reset")
print("=" * 55)
for delay_ms in [100, 200, 500, 1000, 2000]:
time.sleep(delay_ms / 1000.0)
raw_read(dev, 0x08, 0xA2, f"After {delay_ms}ms: ")
# --- Test E: Redo power-on without reset, then probe ---
print()
print("=" * 55)
print("TEST E: Run 0x81 again (re-power), wait 1s, then 0x82")
print("=" * 55)
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x81, 0, 3, timeout=3000)
print(f" GPIO done: stage=0x{ret[1]:02X}")
time.sleep(1.0)
raw_read(dev, 0x08, 0xA2, "After 0x81+1s: ")
print(" Now running 0x82 (re-reset + probe)...")
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x82, 0, 3, timeout=3000)
stage = ret[1]
probe = ret[2]
if stage == 0xA2:
print(f" bcm_direct_read SUCCEEDED: status=0x{probe:02X}")
else:
print(f" bcm_direct_read FAILED: stage=0x{stage:02X} probe=0x{probe:02X}")
print("\n" + "=" * 55)
print("Analysis complete.")
if __name__ == "__main__":
main()

View file

@ -1,122 +1,122 @@
#!/usr/bin/env python3
"""Pinpoint which element in mode 0x82 causes bcm_direct_read to fail.
Test sequence:
1. Power on via 0x81, confirm alive with raw read
2. 0x84: bcm_direct_read ONLY (no GPIO, no reset, no bus reset)
3. 0x85: GPIO + reset + power but NO I2C bus reset (no bmSTOP)
4. 0x82: GPIO + I2C bus reset + reset + power + probe (the one that fails)
"""
import usb.core
import usb.util
import sys
import time
BOOT_8PSK = 0x89
def find_device():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("Device not found!")
sys.exit(1)
return dev
def setup_device(dev):
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception:
pass
try:
dev.set_configuration()
except usb.core.USBError:
pass
def decode_stage(stage):
names = {
0x00: "NOT_STARTED", 0xA1: "GPIO_OK", 0xA2: "PROBE_OK(0x82)",
0xA3: "BLK0_OK", 0xA4: "PROBE_OK(0x84)", 0xA5: "PROBE_OK(0x85)",
0xE3: "PROBE_FAIL", 0xE4: "BLK0_FAIL",
}
return names.get(stage, f"0x{stage:02X}")
def test_boot_mode(dev, wval, label, timeout_ms=3000):
print(f"\n{'' * 55}")
print(f" Mode 0x{wval:02X}: {label}")
print(f"{'' * 55}")
t0 = time.monotonic()
try:
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, wval, 0, 3, timeout=timeout_ms)
except usb.core.USBError as e:
elapsed = (time.monotonic() - t0) * 1000
print(f" TIMEOUT after {elapsed:.0f}ms: {e}")
return None
elapsed = (time.monotonic() - t0) * 1000
stage = ret[1]
probe = ret[2]
ok = stage not in (0xE3, 0xE4)
status_str = "SUCCESS" if ok else "FAILED"
print(f" {status_str} in {elapsed:.0f}ms")
print(f" stage=0x{stage:02X} [{decode_stage(stage)}] probe=0x{probe:02X}")
return ret
def raw_read(dev, addr, reg):
try:
r = dev.ctrl_transfer(0xC0, 0xB5, addr, reg, 1, timeout=1000)
return r[0]
except:
return None
def main():
dev = find_device()
setup_device(dev)
ret = dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6, timeout=2000)
major, minor, patch = ret[2], ret[1], ret[0]
print(f"Firmware: v{major}.{minor:02d}.{patch}")
# Step 1: Power on via GPIO-only mode
print("\n=== STEP 1: Power on BCM4500 (mode 0x81) ===")
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x81, 0, 3, timeout=3000)
print(f" GPIO setup done, stage=0x{ret[1]:02X}")
time.sleep(1.0)
# Confirm alive
val = raw_read(dev, 0x08, 0xA2)
print(f" Raw read 0x08:0xA2 = 0x{val:02X}" if val is not None else " Raw read FAILED")
# Step 2: Test 0x84 (I2C read ONLY, no GPIO manipulation)
test_boot_mode(dev, 0x84, "bcm_direct_read ONLY (no GPIO, chip already powered)")
# Confirm still alive
val = raw_read(dev, 0x08, 0xA2)
print(f" Raw read after 0x84: 0x{val:02X}" if val is not None else " Raw read FAILED")
# Step 3: Test 0x85 (GPIO + reset but NO I2C bus reset)
test_boot_mode(dev, 0x85, "GPIO + reset + power, NO bmSTOP (no I2C bus reset)")
# Confirm still alive
time.sleep(0.1)
val = raw_read(dev, 0x08, 0xA2)
print(f" Raw read after 0x85: 0x{val:02X}" if val is not None else " Raw read FAILED")
# Step 4: For comparison, test 0x82 (the one that fails)
test_boot_mode(dev, 0x82, "GPIO + I2C bmSTOP + reset + power + probe")
# Confirm still alive
val = raw_read(dev, 0x08, 0xA2)
print(f" Raw read after 0x82: 0x{val:02X}" if val is not None else " Raw read FAILED")
print(f"\n{'=' * 55}")
print("Analysis complete.")
print()
print("If 0x84 works → bcm_direct_read is fine, issue is in reset/GPIO sequence")
print("If 0x84 fails → bcm_direct_read itself has a bug")
print("If 0x85 works → I2CS bmSTOP (I2C bus reset) is the culprit in 0x82")
print("If 0x85 fails → re-reset of BCM4500 needs more delay")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Pinpoint which element in mode 0x82 causes bcm_direct_read to fail.
Test sequence:
1. Power on via 0x81, confirm alive with raw read
2. 0x84: bcm_direct_read ONLY (no GPIO, no reset, no bus reset)
3. 0x85: GPIO + reset + power but NO I2C bus reset (no bmSTOP)
4. 0x82: GPIO + I2C bus reset + reset + power + probe (the one that fails)
"""
import usb.core
import usb.util
import sys
import time
BOOT_8PSK = 0x89
def find_device():
dev = usb.core.find(idVendor=0x09C0, idProduct=0x0203)
if not dev:
print("Device not found!")
sys.exit(1)
return dev
def setup_device(dev):
try:
if dev.is_kernel_driver_active(0):
dev.detach_kernel_driver(0)
except Exception:
pass
try:
dev.set_configuration()
except usb.core.USBError:
pass
def decode_stage(stage):
names = {
0x00: "NOT_STARTED", 0xA1: "GPIO_OK", 0xA2: "PROBE_OK(0x82)",
0xA3: "BLK0_OK", 0xA4: "PROBE_OK(0x84)", 0xA5: "PROBE_OK(0x85)",
0xE3: "PROBE_FAIL", 0xE4: "BLK0_FAIL",
}
return names.get(stage, f"0x{stage:02X}")
def test_boot_mode(dev, wval, label, timeout_ms=3000):
print(f"\n{'' * 55}")
print(f" Mode 0x{wval:02X}: {label}")
print(f"{'' * 55}")
t0 = time.monotonic()
try:
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, wval, 0, 3, timeout=timeout_ms)
except usb.core.USBError as e:
elapsed = (time.monotonic() - t0) * 1000
print(f" TIMEOUT after {elapsed:.0f}ms: {e}")
return None
elapsed = (time.monotonic() - t0) * 1000
stage = ret[1]
probe = ret[2]
ok = stage not in (0xE3, 0xE4)
status_str = "SUCCESS" if ok else "FAILED"
print(f" {status_str} in {elapsed:.0f}ms")
print(f" stage=0x{stage:02X} [{decode_stage(stage)}] probe=0x{probe:02X}")
return ret
def raw_read(dev, addr, reg):
try:
r = dev.ctrl_transfer(0xC0, 0xB5, addr, reg, 1, timeout=1000)
return r[0]
except:
return None
def main():
dev = find_device()
setup_device(dev)
ret = dev.ctrl_transfer(0xC0, 0x92, 0, 0, 6, timeout=2000)
major, minor, patch = ret[2], ret[1], ret[0]
print(f"Firmware: v{major}.{minor:02d}.{patch}")
# Step 1: Power on via GPIO-only mode
print("\n=== STEP 1: Power on BCM4500 (mode 0x81) ===")
ret = dev.ctrl_transfer(0xC0, BOOT_8PSK, 0x81, 0, 3, timeout=3000)
print(f" GPIO setup done, stage=0x{ret[1]:02X}")
time.sleep(1.0)
# Confirm alive
val = raw_read(dev, 0x08, 0xA2)
print(f" Raw read 0x08:0xA2 = 0x{val:02X}" if val is not None else " Raw read FAILED")
# Step 2: Test 0x84 (I2C read ONLY, no GPIO manipulation)
test_boot_mode(dev, 0x84, "bcm_direct_read ONLY (no GPIO, chip already powered)")
# Confirm still alive
val = raw_read(dev, 0x08, 0xA2)
print(f" Raw read after 0x84: 0x{val:02X}" if val is not None else " Raw read FAILED")
# Step 3: Test 0x85 (GPIO + reset but NO I2C bus reset)
test_boot_mode(dev, 0x85, "GPIO + reset + power, NO bmSTOP (no I2C bus reset)")
# Confirm still alive
time.sleep(0.1)
val = raw_read(dev, 0x08, 0xA2)
print(f" Raw read after 0x85: 0x{val:02X}" if val is not None else " Raw read FAILED")
# Step 4: For comparison, test 0x82 (the one that fails)
test_boot_mode(dev, 0x82, "GPIO + I2C bmSTOP + reset + power + probe")
# Confirm still alive
val = raw_read(dev, 0x08, 0xA2)
print(f" Raw read after 0x82: 0x{val:02X}" if val is not None else " Raw read FAILED")
print(f"\n{'=' * 55}")
print("Analysis complete.")
print()
print("If 0x84 works → bcm_direct_read is fine, issue is in reset/GPIO sequence")
print("If 0x84 fails → bcm_direct_read itself has a bug")
print("If 0x85 works → I2CS bmSTOP (I2C bus reset) is the culprit in 0x82")
print("If 0x85 fails → re-reset of BCM4500 needs more delay")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,346 +1,346 @@
#!/usr/bin/env python3
"""
Run a Windows PE under Wine and dump its process memory after unpacking.
Launches the EXE, waits for it to unpack, then reads /proc/PID/mem
guided by /proc/PID/maps to capture the unpacked code and data sections.
Searches the dump for FX2 firmware signatures.
"""
import subprocess
import time
import os
import sys
import signal
import struct
import re
import glob
def find_wine_pid(exe_basename, timeout=10):
"""Find the Wine process PID by looking for the .exe in /proc."""
deadline = time.time() + timeout
while time.time() < deadline:
for pid_dir in glob.glob('/proc/[0-9]*'):
try:
cmdline = open(f'{pid_dir}/cmdline', 'rb').read()
if exe_basename.lower().encode() in cmdline.lower():
pid = int(os.path.basename(pid_dir))
# Skip if it's our own python process
if pid == os.getpid():
continue
return pid
except (PermissionError, FileNotFoundError, ProcessLookupError):
continue
time.sleep(0.2)
return None
def dump_process_memory(pid, output_dir):
"""Dump all readable memory regions of a process."""
maps_path = f'/proc/{pid}/maps'
mem_path = f'/proc/{pid}/mem'
regions = []
try:
with open(maps_path, 'r') as f:
for line in f:
parts = line.split()
addr_range = parts[0]
perms = parts[1]
# Only dump readable regions
if 'r' not in perms:
continue
start_s, end_s = addr_range.split('-')
start = int(start_s, 16)
end = int(end_s, 16)
size = end - start
# Skip huge regions (> 64MB) and tiny ones
if size > 64 * 1024 * 1024 or size < 64:
continue
pathname = parts[5].strip() if len(parts) > 5 else ""
regions.append((start, end, perms, pathname))
except (PermissionError, FileNotFoundError) as e:
print(f" Cannot read maps: {e}")
return None
print(f" Found {len(regions)} readable memory regions")
all_data = bytearray()
region_info = []
try:
with open(mem_path, 'rb') as mem:
for start, end, perms, pathname in regions:
size = end - start
try:
mem.seek(start)
chunk = mem.read(size)
offset_in_dump = len(all_data)
all_data.extend(chunk)
region_info.append({
'va_start': start,
'va_end': end,
'perms': perms,
'pathname': pathname,
'dump_offset': offset_in_dump,
'size': len(chunk)
})
except (OSError, ValueError):
pass
except PermissionError as e:
print(f" Cannot read mem: {e}")
print(" Try running with sudo or as the same user as Wine")
return None
# Save full dump
dump_file = os.path.join(output_dir, 'wine_memdump.bin')
with open(dump_file, 'wb') as f:
f.write(all_data)
print(f" Saved {len(all_data)} bytes to {dump_file}")
# Save region map
map_file = os.path.join(output_dir, 'wine_memdump_regions.txt')
with open(map_file, 'w') as f:
for r in region_info:
f.write(f"0x{r['va_start']:08X}-0x{r['va_end']:08X} "
f"{r['perms']:5s} dump_off=0x{r['dump_offset']:08X} "
f"size=0x{r['size']:06X} {r['pathname']}\n")
print(f" Saved region map to {map_file}")
return all_data, region_info
def search_firmware(data, region_info):
"""Search dumped memory for FX2 firmware signatures."""
print(f"\n{'=' * 50}")
print("Searching for firmware signatures...")
print(f"{'=' * 50}")
# 1. C2 EEPROM header with Genpix VID
print("\n[1] C2 EEPROM headers (C2 C0 09 03 02):")
c2_genpix = bytes([0xC2, 0xC0, 0x09, 0x03, 0x02])
pos = 0
while True:
idx = data.find(c2_genpix, pos)
if idx < 0:
break
region = find_region(region_info, idx)
ctx = bytes(data[idx:idx + 32])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
# Parse the full C2 header
if idx + 8 <= len(data):
vid = data[idx + 1] | (data[idx + 2] << 8)
pid = data[idx + 3] | (data[idx + 4] << 8)
did = data[idx + 5] | (data[idx + 6] << 8)
config = data[idx + 7]
print(f" VID=0x{vid:04X} PID=0x{pid:04X} DID=0x{did:04X} Config=0x{config:02X}")
pos = idx + 1
# 2. FX2 RAM clear init sequence
print("\n[2] FX2 init sequence (78 7F E4 F6 D8 FD 75 81):")
fx2_init = bytes([0x78, 0x7F, 0xE4, 0xF6, 0xD8, 0xFD, 0x75, 0x81])
pos = 0
while True:
idx = data.find(fx2_init, pos)
if idx < 0:
break
region = find_region(region_info, idx)
ctx = bytes(data[max(0, idx - 4):idx + 16])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
pos = idx + 1
# 3. Partial RAM clear pattern
print("\n[3] RAM clear pattern (78 7F E4 F6 D8 FD):")
ram_clear = bytes([0x78, 0x7F, 0xE4, 0xF6, 0xD8, 0xFD])
pos = 0
hits = 0
while True:
idx = data.find(ram_clear, pos)
if idx < 0:
break
region = find_region(region_info, idx)
ctx = bytes(data[max(0, idx - 4):idx + 16])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
hits += 1
if hits >= 10:
break
pos = idx + 1
# 4. LJMP at what could be code address 0x0000 (start of firmware)
# Look for 02 XX XX where XX XX is 0x0100-0x3FFF
print("\n[4] C2 load records (LEN_H LEN_L 00 00 02 = record at addr 0x0000):")
for off in range(len(data) - 8):
rec_len = (data[off] << 8) | data[off + 1]
if 0x0100 <= rec_len <= 0x4000:
if data[off + 2] == 0x00 and data[off + 3] == 0x00 and data[off + 4] == 0x02:
target = (data[off + 5] << 8) | data[off + 6]
if 0x0100 <= target <= 0x3FFF:
# Check if this looks like a valid C2 record chain
region = find_region(region_info, off)
ctx = bytes(data[off:off + 16])
# Also check 8 bytes before for C2 header
has_c2_header = (off >= 8 and data[off - 8] == 0xC2)
header_note = " ** C2 HEADER 8 BYTES BEFORE! **" if has_c2_header else ""
print(f" 0x{off:08X} (VA: {region}): len={rec_len} "
f"addr=0x0000 LJMP 0x{target:04X} -- {ctx.hex(' ')}{header_note}")
# 5. Known VID/PID bytes near potential firmware data
print("\n[5] VID 0x09C0 references:")
vid_bytes = b'\xC0\x09'
pos = 0
hits = 0
while True:
idx = data.find(vid_bytes, pos)
if idx < 0:
break
# Check if followed by PID within 4 bytes
if idx + 4 < len(data):
nearby = data[idx:idx + 8]
if b'\x03\x02' in nearby:
region = find_region(region_info, idx)
ctx = bytes(data[max(0, idx - 4):idx + 16])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
hits += 1
if hits >= 20:
break
pos = idx + 1
# 6. Search for the firmware version string "2.13"
print("\n[6] Version strings:")
for pattern in [b'2.13', b'2.06', b'2.10', b'SkyWalker', b'Genpix',
b'8PSK', b'EEPROM', b'firmware', b'I2C']:
pos = 0
while True:
idx = data.find(pattern, pos)
if idx < 0:
break
region = find_region(region_info, idx)
# Get surrounding context as ascii
start = max(0, idx - 16)
end = min(len(data), idx + 48)
ctx_bytes = bytes(data[start:end])
ctx_ascii = ctx_bytes.decode('ascii', errors='replace')
ctx_ascii = re.sub(r'[^\x20-\x7e]', '.', ctx_ascii)
print(f" 0x{idx:08X} (VA: {region}): '{ctx_ascii}'")
pos = idx + 1
# 7. Look for USB vendor request setup patterns
# The updater will set bRequest=0x83 (I2C_WRITE) or 0xA0 to write firmware
print("\n[7] USB transfer setup (IOCTL/vendor request patterns):")
# WinUSB_ControlTransfer uses WINUSB_SETUP_PACKET:
# RequestType(1), Request(1), Value(2), Index(2), Length(2)
# For vendor OUT: RequestType=0x40, Request=0x83/0xA0
for req_type, req, desc in [(0x40, 0xA0, "FX2 RAM write"),
(0x40, 0x83, "I2C_WRITE"),
(0x40, 0x84, "I2C_READ")]:
pattern = bytes([req_type, req])
pos = 0
hits_count = 0
while True:
idx = data.find(pattern, pos)
if idx < 0:
break
# Check if followed by reasonable wValue/wIndex
if idx + 8 <= len(data):
wval = struct.unpack_from('<H', data, idx + 2)[0]
widx = struct.unpack_from('<H', data, idx + 4)[0]
wlen = struct.unpack_from('<H', data, idx + 6)[0]
if wlen > 0 and wlen < 0x4000:
region = find_region(region_info, idx)
print(f" 0x{idx:08X} ({desc}): "
f"ReqType=0x{req_type:02X} Req=0x{req:02X} "
f"wVal=0x{wval:04X} wIdx=0x{widx:04X} wLen=0x{wlen:04X} "
f"(VA: {region})")
hits_count += 1
if hits_count >= 10:
break
pos = idx + 1
def find_region(region_info, dump_offset):
"""Find the VA region for a given dump offset."""
for r in region_info:
if r['dump_offset'] <= dump_offset < r['dump_offset'] + r['size']:
va = r['va_start'] + (dump_offset - r['dump_offset'])
return f"0x{va:08X} [{r['pathname'] or 'anon'}]"
return "unknown"
def main():
import argparse
parser = argparse.ArgumentParser(description="Wine memory dump for firmware extraction")
parser.add_argument('exe', help='Windows PE executable to run under Wine')
parser.add_argument('-o', '--output-dir', default='.',
help='Output directory for dumps')
parser.add_argument('--wait', type=float, default=3.0,
help='Seconds to wait after launch for unpacking (default: 3)')
parser.add_argument('--skip-launch', action='store_true',
help='Skip launching Wine, just attach to existing process')
args = parser.parse_args()
exe_path = os.path.abspath(args.exe)
exe_basename = os.path.basename(exe_path)
os.makedirs(args.output_dir, exist_ok=True)
wine_proc = None
pid = None
if not args.skip_launch:
print(f"Launching {exe_basename} under Wine...")
# Use WINEDEBUG=-all to reduce noise
env = os.environ.copy()
env['WINEDEBUG'] = '-all'
wine_proc = subprocess.Popen(
['wine', exe_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env
)
print(f" Wine wrapper PID: {wine_proc.pid}")
# Wait for the actual .exe process to appear
print(f" Waiting {args.wait}s for unpacking...")
time.sleep(args.wait)
# Find the Windows process PID
print(f" Looking for {exe_basename} process...")
pid = find_wine_pid(exe_basename, timeout=5)
if pid is None:
# Try looking for wine-preloader or wine64-preloader
print(" Couldn't find by exe name, searching all wine processes...")
for pid_dir in glob.glob('/proc/[0-9]*'):
try:
cmdline = open(f'{pid_dir}/cmdline', 'rb').read()
if b'wine' in cmdline.lower() and pid_dir != f'/proc/{os.getpid()}':
p = int(os.path.basename(pid_dir))
if wine_proc and p == wine_proc.pid:
continue
print(f" Found wine process PID {p}: {cmdline[:100]}")
except:
pass
if pid is None and wine_proc:
pid = wine_proc.pid
print(f" Using Wine wrapper PID: {pid}")
if pid:
print(f"\n Target PID: {pid}")
result = dump_process_memory(pid, args.output_dir)
if result:
data, region_info = result
search_firmware(data, region_info)
else:
print(" ERROR: Could not find process")
# Cleanup
if wine_proc:
print("\nTerminating Wine process...")
try:
wine_proc.terminate()
wine_proc.wait(timeout=5)
except:
wine_proc.kill()
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
Run a Windows PE under Wine and dump its process memory after unpacking.
Launches the EXE, waits for it to unpack, then reads /proc/PID/mem
guided by /proc/PID/maps to capture the unpacked code and data sections.
Searches the dump for FX2 firmware signatures.
"""
import subprocess
import time
import os
import sys
import signal
import struct
import re
import glob
def find_wine_pid(exe_basename, timeout=10):
"""Find the Wine process PID by looking for the .exe in /proc."""
deadline = time.time() + timeout
while time.time() < deadline:
for pid_dir in glob.glob('/proc/[0-9]*'):
try:
cmdline = open(f'{pid_dir}/cmdline', 'rb').read()
if exe_basename.lower().encode() in cmdline.lower():
pid = int(os.path.basename(pid_dir))
# Skip if it's our own python process
if pid == os.getpid():
continue
return pid
except (PermissionError, FileNotFoundError, ProcessLookupError):
continue
time.sleep(0.2)
return None
def dump_process_memory(pid, output_dir):
"""Dump all readable memory regions of a process."""
maps_path = f'/proc/{pid}/maps'
mem_path = f'/proc/{pid}/mem'
regions = []
try:
with open(maps_path, 'r') as f:
for line in f:
parts = line.split()
addr_range = parts[0]
perms = parts[1]
# Only dump readable regions
if 'r' not in perms:
continue
start_s, end_s = addr_range.split('-')
start = int(start_s, 16)
end = int(end_s, 16)
size = end - start
# Skip huge regions (> 64MB) and tiny ones
if size > 64 * 1024 * 1024 or size < 64:
continue
pathname = parts[5].strip() if len(parts) > 5 else ""
regions.append((start, end, perms, pathname))
except (PermissionError, FileNotFoundError) as e:
print(f" Cannot read maps: {e}")
return None
print(f" Found {len(regions)} readable memory regions")
all_data = bytearray()
region_info = []
try:
with open(mem_path, 'rb') as mem:
for start, end, perms, pathname in regions:
size = end - start
try:
mem.seek(start)
chunk = mem.read(size)
offset_in_dump = len(all_data)
all_data.extend(chunk)
region_info.append({
'va_start': start,
'va_end': end,
'perms': perms,
'pathname': pathname,
'dump_offset': offset_in_dump,
'size': len(chunk)
})
except (OSError, ValueError):
pass
except PermissionError as e:
print(f" Cannot read mem: {e}")
print(" Try running with sudo or as the same user as Wine")
return None
# Save full dump
dump_file = os.path.join(output_dir, 'wine_memdump.bin')
with open(dump_file, 'wb') as f:
f.write(all_data)
print(f" Saved {len(all_data)} bytes to {dump_file}")
# Save region map
map_file = os.path.join(output_dir, 'wine_memdump_regions.txt')
with open(map_file, 'w') as f:
for r in region_info:
f.write(f"0x{r['va_start']:08X}-0x{r['va_end']:08X} "
f"{r['perms']:5s} dump_off=0x{r['dump_offset']:08X} "
f"size=0x{r['size']:06X} {r['pathname']}\n")
print(f" Saved region map to {map_file}")
return all_data, region_info
def search_firmware(data, region_info):
"""Search dumped memory for FX2 firmware signatures."""
print(f"\n{'=' * 50}")
print("Searching for firmware signatures...")
print(f"{'=' * 50}")
# 1. C2 EEPROM header with Genpix VID
print("\n[1] C2 EEPROM headers (C2 C0 09 03 02):")
c2_genpix = bytes([0xC2, 0xC0, 0x09, 0x03, 0x02])
pos = 0
while True:
idx = data.find(c2_genpix, pos)
if idx < 0:
break
region = find_region(region_info, idx)
ctx = bytes(data[idx:idx + 32])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
# Parse the full C2 header
if idx + 8 <= len(data):
vid = data[idx + 1] | (data[idx + 2] << 8)
pid = data[idx + 3] | (data[idx + 4] << 8)
did = data[idx + 5] | (data[idx + 6] << 8)
config = data[idx + 7]
print(f" VID=0x{vid:04X} PID=0x{pid:04X} DID=0x{did:04X} Config=0x{config:02X}")
pos = idx + 1
# 2. FX2 RAM clear init sequence
print("\n[2] FX2 init sequence (78 7F E4 F6 D8 FD 75 81):")
fx2_init = bytes([0x78, 0x7F, 0xE4, 0xF6, 0xD8, 0xFD, 0x75, 0x81])
pos = 0
while True:
idx = data.find(fx2_init, pos)
if idx < 0:
break
region = find_region(region_info, idx)
ctx = bytes(data[max(0, idx - 4):idx + 16])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
pos = idx + 1
# 3. Partial RAM clear pattern
print("\n[3] RAM clear pattern (78 7F E4 F6 D8 FD):")
ram_clear = bytes([0x78, 0x7F, 0xE4, 0xF6, 0xD8, 0xFD])
pos = 0
hits = 0
while True:
idx = data.find(ram_clear, pos)
if idx < 0:
break
region = find_region(region_info, idx)
ctx = bytes(data[max(0, idx - 4):idx + 16])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
hits += 1
if hits >= 10:
break
pos = idx + 1
# 4. LJMP at what could be code address 0x0000 (start of firmware)
# Look for 02 XX XX where XX XX is 0x0100-0x3FFF
print("\n[4] C2 load records (LEN_H LEN_L 00 00 02 = record at addr 0x0000):")
for off in range(len(data) - 8):
rec_len = (data[off] << 8) | data[off + 1]
if 0x0100 <= rec_len <= 0x4000:
if data[off + 2] == 0x00 and data[off + 3] == 0x00 and data[off + 4] == 0x02:
target = (data[off + 5] << 8) | data[off + 6]
if 0x0100 <= target <= 0x3FFF:
# Check if this looks like a valid C2 record chain
region = find_region(region_info, off)
ctx = bytes(data[off:off + 16])
# Also check 8 bytes before for C2 header
has_c2_header = (off >= 8 and data[off - 8] == 0xC2)
header_note = " ** C2 HEADER 8 BYTES BEFORE! **" if has_c2_header else ""
print(f" 0x{off:08X} (VA: {region}): len={rec_len} "
f"addr=0x0000 LJMP 0x{target:04X} -- {ctx.hex(' ')}{header_note}")
# 5. Known VID/PID bytes near potential firmware data
print("\n[5] VID 0x09C0 references:")
vid_bytes = b'\xC0\x09'
pos = 0
hits = 0
while True:
idx = data.find(vid_bytes, pos)
if idx < 0:
break
# Check if followed by PID within 4 bytes
if idx + 4 < len(data):
nearby = data[idx:idx + 8]
if b'\x03\x02' in nearby:
region = find_region(region_info, idx)
ctx = bytes(data[max(0, idx - 4):idx + 16])
print(f" 0x{idx:08X} (VA: {region}): {ctx.hex(' ')}")
hits += 1
if hits >= 20:
break
pos = idx + 1
# 6. Search for the firmware version string "2.13"
print("\n[6] Version strings:")
for pattern in [b'2.13', b'2.06', b'2.10', b'SkyWalker', b'Genpix',
b'8PSK', b'EEPROM', b'firmware', b'I2C']:
pos = 0
while True:
idx = data.find(pattern, pos)
if idx < 0:
break
region = find_region(region_info, idx)
# Get surrounding context as ascii
start = max(0, idx - 16)
end = min(len(data), idx + 48)
ctx_bytes = bytes(data[start:end])
ctx_ascii = ctx_bytes.decode('ascii', errors='replace')
ctx_ascii = re.sub(r'[^\x20-\x7e]', '.', ctx_ascii)
print(f" 0x{idx:08X} (VA: {region}): '{ctx_ascii}'")
pos = idx + 1
# 7. Look for USB vendor request setup patterns
# The updater will set bRequest=0x83 (I2C_WRITE) or 0xA0 to write firmware
print("\n[7] USB transfer setup (IOCTL/vendor request patterns):")
# WinUSB_ControlTransfer uses WINUSB_SETUP_PACKET:
# RequestType(1), Request(1), Value(2), Index(2), Length(2)
# For vendor OUT: RequestType=0x40, Request=0x83/0xA0
for req_type, req, desc in [(0x40, 0xA0, "FX2 RAM write"),
(0x40, 0x83, "I2C_WRITE"),
(0x40, 0x84, "I2C_READ")]:
pattern = bytes([req_type, req])
pos = 0
hits_count = 0
while True:
idx = data.find(pattern, pos)
if idx < 0:
break
# Check if followed by reasonable wValue/wIndex
if idx + 8 <= len(data):
wval = struct.unpack_from('<H', data, idx + 2)[0]
widx = struct.unpack_from('<H', data, idx + 4)[0]
wlen = struct.unpack_from('<H', data, idx + 6)[0]
if wlen > 0 and wlen < 0x4000:
region = find_region(region_info, idx)
print(f" 0x{idx:08X} ({desc}): "
f"ReqType=0x{req_type:02X} Req=0x{req:02X} "
f"wVal=0x{wval:04X} wIdx=0x{widx:04X} wLen=0x{wlen:04X} "
f"(VA: {region})")
hits_count += 1
if hits_count >= 10:
break
pos = idx + 1
def find_region(region_info, dump_offset):
"""Find the VA region for a given dump offset."""
for r in region_info:
if r['dump_offset'] <= dump_offset < r['dump_offset'] + r['size']:
va = r['va_start'] + (dump_offset - r['dump_offset'])
return f"0x{va:08X} [{r['pathname'] or 'anon'}]"
return "unknown"
def main():
import argparse
parser = argparse.ArgumentParser(description="Wine memory dump for firmware extraction")
parser.add_argument('exe', help='Windows PE executable to run under Wine')
parser.add_argument('-o', '--output-dir', default='.',
help='Output directory for dumps')
parser.add_argument('--wait', type=float, default=3.0,
help='Seconds to wait after launch for unpacking (default: 3)')
parser.add_argument('--skip-launch', action='store_true',
help='Skip launching Wine, just attach to existing process')
args = parser.parse_args()
exe_path = os.path.abspath(args.exe)
exe_basename = os.path.basename(exe_path)
os.makedirs(args.output_dir, exist_ok=True)
wine_proc = None
pid = None
if not args.skip_launch:
print(f"Launching {exe_basename} under Wine...")
# Use WINEDEBUG=-all to reduce noise
env = os.environ.copy()
env['WINEDEBUG'] = '-all'
wine_proc = subprocess.Popen(
['wine', exe_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env
)
print(f" Wine wrapper PID: {wine_proc.pid}")
# Wait for the actual .exe process to appear
print(f" Waiting {args.wait}s for unpacking...")
time.sleep(args.wait)
# Find the Windows process PID
print(f" Looking for {exe_basename} process...")
pid = find_wine_pid(exe_basename, timeout=5)
if pid is None:
# Try looking for wine-preloader or wine64-preloader
print(" Couldn't find by exe name, searching all wine processes...")
for pid_dir in glob.glob('/proc/[0-9]*'):
try:
cmdline = open(f'{pid_dir}/cmdline', 'rb').read()
if b'wine' in cmdline.lower() and pid_dir != f'/proc/{os.getpid()}':
p = int(os.path.basename(pid_dir))
if wine_proc and p == wine_proc.pid:
continue
print(f" Found wine process PID {p}: {cmdline[:100]}")
except:
pass
if pid is None and wine_proc:
pid = wine_proc.pid
print(f" Using Wine wrapper PID: {pid}")
if pid:
print(f"\n Target PID: {pid}")
result = dump_process_memory(pid, args.output_dir)
if result:
data, region_info = result
search_firmware(data, region_info)
else:
print(" ERROR: Could not find process")
# Cleanup
if wine_proc:
print("\nTerminating Wine process...")
try:
wine_proc.terminate()
wine_proc.wait(timeout=5)
except:
wine_proc.kill()
if __name__ == '__main__':
main()