#!/usr/bin/env python3
"""ISS-SSTV über OpenWebRX+ mitschneiden, mit Doppler-Nachführung.

Das Skript meldet sich wie ein Besucher per WebSocket bei OpenWebRX+ an, stellt
NFM + SSTV ein, führt die Frequenz jede Sekunde anhand der Bahndaten (TLE) nach
und speichert jedes dekodierte Bild als PNG, auch unvollständige. Zusätzlich
schreibt es den S-Meter-Pegel je Sekunde nach pegel.csv.

Beitrag: https://www.bk99.de/blog/2026/iss-sstv-series-33/

Voraussetzungen:
  pip install websockets pillow skyfield
  curl -o iss.tle 'https://celestrak.org/NORAD/elements/gp.php?CATNR=25544&FORMAT=TLE'

Aufruf (Beispiel):
  iss_sstv_capture.py --url ws://openwebrx.local:8073/ws/ --lat 50.85 --lon 12.47 --alt 260 \
      --tle iss.tle --out bilder --start "2026-10-02 13:22" --end "2026-10-02 13:37"
  --test verbindet nur und zeigt das aktive Profil.
  --profile "<sdr>|<profil>" schaltet zu Beginn auf dieses Profil um. Das betrifft
  alle Zuhörer, denn ein SDR kann nur ein Profil gleichzeitig.
"""
import argparse, asyncio, base64, json, math, os, sys, time
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

import websockets
from PIL import Image
from skyfield.api import load, wgs84, EarthSatellite

URL = None
PROFILE = None
F0 = 437_550_000
C = 299_792.458
TZ = ZoneInfo("Europe/Berlin")
LAT = LON = ASL = None


def log(*a):
    print(datetime.now(TZ).strftime("%H:%M:%S"), *a, flush=True)


class Capture:
    def __init__(self, tle, out):
        L = open(tle).read().splitlines()
        self.ts = load.timescale()
        self.sat = EarthSatellite(L[1], L[2], L[0].strip(), self.ts)
        self.obs = wgs84.latlon(LAT, LON, ASL)
        self.out = out
        os.makedirs(out, exist_ok=True)
        self.center = None
        self.profile = None
        self.img = None
        self.meta = None
        self.lines = 0
        self.count = 0
        self.sm = []
        self.csv = open(os.path.join(out, "pegel.csv"), "a")
        if self.csv.tell() == 0:
            self.csv.write("zeit_mesz;elevation_grad;azimut_grad;empfang_hz;smeter_db_mittel;smeter_db_max;n;bild\n")

    def rx_freq(self):
        t = self.ts.from_datetime(datetime.now(timezone.utc))
        p = (self.sat - self.obs).at(t)
        alt, az, _ = p.altaz()
        rr = p.frame_latlon_and_rates(self.obs)[5].km_per_s
        self.az = az.degrees
        return F0 * (1 - rr / C), alt.degrees

    def save(self, final):
        if self.img is None or self.lines == 0:
            return
        name = "{}_{}_{}.png".format(self.meta["stamp"], self.meta["mode"].replace(" ", ""),
                                     "voll" if final and self.lines >= self.img.height else "teil")
        path = os.path.join(self.out, name)
        self.img.save(path)
        log("Bild gespeichert:", path, "Zeilen", self.lines, "/", self.img.height)

    def handle_sstv(self, v):
        if "pixels" in v and self.img is not None:
            px = base64.b64decode(v["pixels"])
            w = self.img.width
            row = bytes(b for x in range(w) for b in (px[x*3+2], px[x*3+1], px[x*3]))
            if 0 <= v["line"] < self.img.height:
                self.img.paste(Image.frombytes("RGB", (w, 1), row), (0, v["line"]))
                self.lines = max(self.lines, v["line"] + 1)
                if self.lines % 40 == 0:
                    self.save(False)
                if self.lines >= self.img.height:
                    self.save(True)
                    self.img = None
        elif v.get("width", 0) > 0 and "line" not in v:
            if self.img is not None:
                self.save(True)
            self.count += 1
            self.img = Image.new("RGB", (v["width"], v["height"]))
            self.lines = 0
            self.meta = {"mode": v.get("sstvMode", "SSTV"),
                         "stamp": datetime.now(TZ).strftime("%Y%m%d-%H%M%S")}
            log("Neues Bild:", v["width"], "x", v["height"], self.meta["mode"])
        elif "message" in v:
            log("Decoder:", str(v["message"])[:120])


async def run(a):
    cap = Capture(a.tle, a.out)
    async with websockets.connect(URL, max_size=None, ping_interval=None) as ws:
        await ws.send("SERVER DE CLIENT client=iss_sstv_capture.py type=receiver")
        await ws.send(json.dumps({"type": "connectionproperties",
                                  "params": {"output_rate": 12000, "hd_output_rate": 48000}}))
        started = False
        last_off = None
        last_tick = 0
        end = a.end.timestamp() if a.end else time.time() + 8
        while time.time() < end:
            try:
                msg = await asyncio.wait_for(ws.recv(), timeout=1.0)
            except asyncio.TimeoutError:
                msg = None
            if isinstance(msg, str):
                try:
                    j = json.loads(msg)
                except ValueError:
                    j = None
                if j and j.get("type") == "config":
                    v = j["value"]
                    if "center_freq" in v:
                        cap.center = v["center_freq"]
                    if "sdr_id" in v and "profile_id" in v:
                        cap.profile = "{}|{}".format(v["sdr_id"], v["profile_id"])
                    log("config: profil", cap.profile, "center", cap.center)
                    if PROFILE and started and cap.profile is not None and cap.profile != PROFILE:
                        log("Profil wurde gewechselt, Demodulator neu aufsetzen")
                        started = False
                        last_off = None
                elif j and j.get("type") == "secondary_demod":
                    v = j["value"]
                    if isinstance(v, dict) and v.get("mode") == "SSTV":
                        cap.handle_sstv(v)
                elif j and j.get("type") == "smeter":
                    try:
                        if j["value"] > 0:
                            cap.sm.append(10 * math.log10(j["value"]))
                    except (TypeError, ValueError):
                        pass
                elif j and j.get("type") in ("sdr_error", "demodulator_error", "backoff"):
                    log("Fehler vom Server:", j)
            if a.test:
                continue
            if not started and cap.center is not None:
                if PROFILE and cap.profile != PROFILE:
                    log("Aktives Profil ist", cap.profile, "-> wähle", PROFILE)
                    await ws.send(json.dumps({"type": "selectprofile", "params": {"profile": PROFILE}}))
                    cap.center = None
                    continue
                await ws.send(json.dumps({"type": "dspcontrol", "params": {
                    "mod": "nfm", "secondary_mod": "sstv", "low_cut": -6000, "high_cut": 6000,
                    "squelch_level": -150, "offset_freq": F0 - cap.center}}))
                await ws.send(json.dumps({"type": "dspcontrol", "action": "start"}))
                started = True
                log("Demodulator gestartet (NFM + SSTV)")
            if started and time.time() - last_tick >= 1:
                last_tick = time.time()
                f, el = cap.rx_freq()
                off = int(round(f - cap.center))
                sm = cap.sm; cap.sm = []
                cap.csv.write("{};{:.1f};{:.0f};{:.0f};{};{};{};{}\n".format(
                    datetime.now(TZ).strftime("%H:%M:%S"), el, cap.az, f,
                    "{:.1f}".format(sum(sm) / len(sm)) if sm else "", "{:.1f}".format(max(sm)) if sm else "",
                    len(sm), cap.lines if cap.img is not None else ""))
                cap.csv.flush()
                if last_off is None or abs(off - last_off) >= 100:
                    await ws.send(json.dumps({"type": "dspcontrol", "params": {"offset_freq": off}}))
                    last_off = off
                if int(last_tick) % 30 == 0:
                    log("Elevation {:5.1f}°  Empfang {:.3f} MHz".format(el, f / 1e6))
        cap.save(True)
        log("Ende. Bilder begonnen:", cap.count)


def main():
    p = argparse.ArgumentParser()
    p.add_argument("--url", required=True, help="WebSocket-Adresse, z. B. ws://host:8073/ws/")
    p.add_argument("--lat", type=float, required=True)
    p.add_argument("--lon", type=float, required=True)
    p.add_argument("--alt", type=float, default=0, help="Höhe über NN in m")
    p.add_argument("--freq", type=int, default=437_550_000, help="Sendefrequenz in Hz")
    p.add_argument("--profile", help="optional: Profil, das zu Beginn gewählt wird")
    p.add_argument("--tle", required=True)
    p.add_argument("--out", required=True)
    p.add_argument("--start")
    p.add_argument("--end")
    p.add_argument("--test", action="store_true")
    a = p.parse_args()
    global URL, PROFILE, F0, LAT, LON, ASL
    URL, PROFILE, F0, LAT, LON, ASL = a.url, a.profile, a.freq, a.lat, a.lon, a.alt
    a.end = datetime.strptime(a.end, "%Y-%m-%d %H:%M").replace(tzinfo=TZ) if a.end else None
    if a.start:
        st = datetime.strptime(a.start, "%Y-%m-%d %H:%M").replace(tzinfo=TZ).timestamp()
        wait = st - time.time()
        if wait > 0:
            log("Warte {:.0f} s bis {}".format(wait, a.start))
            time.sleep(wait)
    asyncio.run(run(a))


if __name__ == "__main__":
    main()
