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:
parent
696d2dd387
commit
bbdcb243dc
175 changed files with 56794 additions and 56794 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
584
tools/fw_dump.py
584
tools/fw_dump.py
|
|
@ -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()
|
||||
|
|
|
|||
714
tools/h21cm.py
714
tools/h21cm.py
|
|
@ -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()
|
||||
|
|
|
|||
1082
tools/motor.py
1082
tools/motor.py
File diff suppressed because it is too large
Load diff
1546
tools/qo100.py
1546
tools/qo100.py
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
2172
tools/skywalker.py
2172
tools/skywalker.py
File diff suppressed because it is too large
Load diff
910
tools/survey.py
910
tools/survey.py
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
2558
tools/ts_analyze.py
2558
tools/ts_analyze.py
File diff suppressed because it is too large
Load diff
1080
tools/tune.py
1080
tools/tune.py
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue