#!/usr/bin/env python3
"""
Stream Sky — referanseagent for egenbygde IRL-bokser.

Dette er et UTGANGSPUNKT, ikke et ferdig produkt. Den viser hele samtalen mot enhets-API-et
vaart — parering, polling, oppstart/stopp av senderen, og rapportering tilbake — saa du kan
bygge din egen boks uten aa lese deg gjennom dokumentasjonen fra bunn.

Det ENESTE du maa fylle ut selv er `sender_kommando()`: hva som faktisk koder og sender video.
Alt annet her er ferdig.

  Ingen avhengigheter utenfor Python-standardbiblioteket. Testet mot API-versjon 1.
  Lisens: samme som resten av Stream Sky-verktoeyene — bruk, endre og del videre.

BRUK
  1. Lag en parekode i portalen (Min side -> stroemmen din).
  2. sudo python3 streamsky-irl-agent.py par SSKY-XXXX-XXXX-XXXX
  3. sudo python3 streamsky-irl-agent.py kjor      (eller legg den i en systemd-tjeneste)

HVORFOR DEN POLLER OSS, OG IKKE OMVENDT
  Boksen din staar paa mobilnett bak CGNAT og har ingen adresse vi kunne naadd. Derfor henter
  DEN oensket tilstand fra oss. Saa lenge den kommer ut paa HTTPS virker fjernstyringen — uten
  tunnel, uten aapne porter, uten fast IP.

  Merk at vi sender TILSTAND, ikke kommandoer i koe. Du sammenligner det vi oensker med det du
  faktisk gjoer, og retter opp. Har boksen vaert borte en time, gjoer du bare det som trengs
  naa — du spiller ikke av gamle kommandoer.
"""
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request

BASE = os.environ.get('STREAMSKY_BASE', 'https://panel.streamsky.no')
KONFIG = os.environ.get('STREAMSKY_KONFIG', '/etc/streamsky/irl.json')
POLL_SEK = 2.0
RAPPORT_HVER = 10          # runder mellom hver rapport naar ingenting endrer seg
API_VERSJON_BYGD_FOR = 1


def logg(*a):
    print('[%s]' % time.strftime('%H:%M:%S'), *a, flush=True)


# --------------------------------------------------------------------------- konfig
def les_konfig():
    try:
        with open(KONFIG, encoding='utf-8') as f:
            return json.load(f)
    except (FileNotFoundError, ValueError):
        return {}


def skriv_konfig(c):
    os.makedirs(os.path.dirname(KONFIG), exist_ok=True)
    # Tokenet er enhetens legitimasjon. 0600 saa det ikke ligger lesbart for alle paa kortet.
    tmp = KONFIG + '.tmp'
    with open(tmp, 'w', encoding='utf-8') as f:
        json.dump(c, f, indent=2)
    os.chmod(tmp, 0o600)
    os.replace(tmp, KONFIG)


# --------------------------------------------------------------------------- HTTP
def kall(sti, kropp=None, token=None, timeout=10):
    data = json.dumps(kropp).encode() if kropp is not None else None
    hoder = {'Content-Type': 'application/json', 'User-Agent': 'streamsky-irl-agent/1'}
    if token:
        # I headeren, ikke i URL-en: mellomledd logger gjerne hele URI-en med query.
        hoder['Authorization'] = 'Bearer ' + token
    req = urllib.request.Request(BASE + sti, data=data, headers=hoder,
                                 method='POST' if data is not None else 'GET')
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode('utf-8') or '{}')


# --------------------------------------------------------------------------- parering
def par(kode):
    """Bytter engangskoden i et langlevd enhetstoken."""
    modell = 'ukjent'
    try:
        with open('/proc/device-tree/model', 'rb') as f:
            modell = f.read().decode('utf-8', 'replace').strip('\x00').strip()
    except OSError:
        pass
    svar = kall('/api/enhet/par', {
        'kode': kode.strip().upper(), 'modell': modell,
        'versjon': '1.0.0', 'navn': os.uname().nodename,
    })
    c = les_konfig()
    c.update({'token': svar['token'], 'stream': svar['stream'],
              'srtla': svar['srtla'], 'srt': svar['srt']})
    skriv_konfig(c)
    logg('paret mot stroemmen «%s». Token lagret i %s' % (svar['stream'], KONFIG))
    return svar


# --------------------------------------------------------------------------- telemetri
def temperatur():
    for sti in ('/sys/class/thermal/thermal_zone0/temp',):
        try:
            with open(sti) as f:
                return round(int(f.read().strip()) / 1000.0, 1)
        except (OSError, ValueError):
            pass
    return None


_forrige_tall = {}


def lenker():
    """Bonding-linjene dine, med maalt gjennomstroemning siden forrige runde.

    Vises i kundens fjernstyring, saa hen ser paa mobilen hvilke modem som faktisk baerer.
    Vi teller bytes fra /proc/net/dev — bytt gjerne dette mot tall fra srtla, som vet mer.
    """
    ut = []
    naa = time.time()
    try:
        with open('/proc/net/dev') as f:
            linjer = f.readlines()[2:]
    except OSError:
        return ut
    for linje in linjer:
        navn, _, rest = linje.partition(':')
        navn = navn.strip()
        # Bare det som ligner uplink. Juster til dine egne grensesnittnavn.
        if not navn.startswith(('wwan', 'usb', 'eth', 'wlan', 'enx')):
            continue
        felt = rest.split()
        if len(felt) < 9:
            continue
        sendt = int(felt[8])
        forrige = _forrige_tall.get(navn)
        _forrige_tall[navn] = (sendt, naa)
        kbps = None
        if forrige and naa > forrige[1]:
            kbps = round((sendt - forrige[0]) * 8 / (naa - forrige[1]) / 1000, 1)
        oppe = False
        try:
            with open('/sys/class/net/%s/operstate' % navn) as f:
                oppe = f.read().strip() == 'up'
        except OSError:
            pass
        ut.append({'navn': navn, 'oppe': oppe, 'kbps': max(0, kbps) if kbps is not None else 0})
    return ut[:8]


# --------------------------------------------------------------------------- senderen
class Sender:
    """Prosessen som faktisk koder og sender. DET ER HER DU FYLLER INN DITT EGET."""

    def __init__(self):
        self.proc = None

    def sender_kommando(self, publisering):
        """Returner kommandoen som sender video til Stream Sky.

        `publisering` kommer fra hver poll og inneholder:
            srtla    -> 'srtla://srt.streamsky.no:5050'
            srt      -> 'srt://srt.streamsky.no:8890'
            streamid -> 'publish:<stroem>:<bruker>:<hemmelighet>'

        🔴 LES DEN PAA HVER POLL, IKKE BARE VED OPPSTART. Bytter kunden noekler, endrer
        streamid seg — en boks som har bakt den inn blir stum uten at noe sier fra.

        Under er et ENKELT eksempel med ffmpeg fra en USB-opptaksenhet, ment som en start.
        For ekte IRL vil du sannsynligvis bruke belacoder + srtla_send, som justerer bitraten
        loepende etter hvordan hver linje har det — det er det som faar IRL til aa fungere paa
        ustabilt mobilnett. Se https://github.com/BELABOX/belacoder
        """
        url = '%s?streamid=%s' % (publisering['srt'], publisering['streamid'])
        return [
            'ffmpeg', '-hide_banner', '-loglevel', 'warning',
            '-f', 'v4l2', '-input_format', 'mjpeg', '-video_size', '1280x720', '-i', '/dev/video0',
            '-f', 'alsa', '-i', 'default',
            '-c:v', 'h264_v4l2m2m', '-b:v', '4000k',      # Pi 4: maskinvarekoder
            '-c:a', 'aac', '-b:a', '128k',
            '-f', 'mpegts', url,
        ]

    def gaar(self):
        return self.proc is not None and self.proc.poll() is None

    def start(self, publisering):
        if self.gaar():
            return
        kmd = self.sender_kommando(publisering)
        logg('starter senderen')
        self.proc = subprocess.Popen(kmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    def stopp(self):
        if not self.gaar():
            self.proc = None
            return
        logg('stopper senderen')
        self.proc.terminate()
        try:
            self.proc.wait(timeout=8)
        except subprocess.TimeoutExpired:
            self.proc.kill()
        self.proc = None


# --------------------------------------------------------------------------- hovedsloeyfe
def kjor():
    c = les_konfig()
    if not c.get('token'):
        sys.exit('Ikke paret. Kjor: %s par SSKY-XXXX-XXXX-XXXX' % sys.argv[0])
    token = c['token']
    sender = Sender()
    sist_nonce = None
    runde = 0
    advart_versjon = False

    logg('starter. base=%s stroem=%s' % (BASE, c.get('stream')))
    while True:
        runde += 1
        try:
            t = kall('/api/agent/state', token=token)
        except urllib.error.HTTPError as e:
            if e.code == 401:
                # Tokenet er dodt — kunden har paret en annen boks, eller meldt denne av.
                # Da er det riktige aa stoppe sendingen og vente, ikke aa hamre videre.
                logg('tokenet er ikke lenger gyldig. Stopper og venter. Par paa nytt.')
                sender.stopp()
                time.sleep(60)
                continue
            logg('tilstand feilet: HTTP %s' % e.code)
            time.sleep(POLL_SEK * 3)
            continue
        except Exception as e:                      # nett nede, DNS, tidsavbrudd
            logg('tilstand feilet: %s' % str(e)[:100])
            time.sleep(POLL_SEK * 3)
            continue

        if t.get('apiVersjon') != API_VERSJON_BYGD_FOR and not advart_versjon:
            logg('ADVARSEL: API-versjon %s, denne agenten er bygd for %s'
                 % (t.get('apiVersjon'), API_VERSJON_BYGD_FOR))
            advart_versjon = True

        pub = t.get('publisering') or {}
        k = t.get('kontroll') or {}
        kvittering = None

        # Engangshandling foerst, saa en restart ikke blir spist av tilstands-sjekken under.
        h = k.get('handling')
        if isinstance(h, dict) and h.get('nonce') and h['nonce'] != sist_nonce:
            sist_nonce = h['nonce']
            if h.get('type') == 'restart' and pub:
                logg('restart bedt om')
                sender.stopp()
                time.sleep(1)
                sender.start(pub)
            kvittering = h['nonce']

        # Deretter tilstanden: sammenlign og rett opp.
        elif k.get('sending') == 'paa' and pub and not sender.gaar():
            sender.start(pub)
        elif k.get('sending') == 'av' and sender.gaar():
            sender.stopp()

        # Doede senderen av seg selv mens den skulle staa paa? Start den igjen.
        if k.get('sending') == 'paa' and pub and not sender.gaar():
            time.sleep(2)
            sender.start(pub)

        if kvittering or runde % RAPPORT_HVER == 1:
            try:
                kall('/api/agent/rapport', {
                    'sender': sender.gaar(),
                    'kvittering': kvittering,
                    'enhet': {
                        'versjon': '1.0.0',
                        'tempC': temperatur(),
                        'lenker': lenker(),
                    },
                }, token=token)
            except Exception as e:
                logg('rapport feilet: %s' % str(e)[:80])

        time.sleep(POLL_SEK)


if __name__ == '__main__':
    if len(sys.argv) >= 3 and sys.argv[1] == 'par':
        par(sys.argv[2])
    elif len(sys.argv) >= 2 and sys.argv[1] == 'kjor':
        try:
            kjor()
        except KeyboardInterrupt:
            pass
    else:
        sys.exit(__doc__.strip().split('BRUK')[1])
