#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Start and stop the GDDR6 memory temperature reading.

    skillfish-gddr6-helper avvia   start it
    skillfish-gddr6-helper ferma   stop it
    skillfish-gddr6-helper stato   JSON: running, seconds since it started, why not

Root only, reached through pkexec. The window never needs this to SHOW anything:
the collector publishes /run/bc250-memory/telemetry world-readable, so reading
is unprivileged. Root is needed only to START the reading, because it patches the
SMU.

⚠️ THERE USED TO BE A TEN MINUTE CAP AND A SIXTY SECOND COOLDOWN HERE, AND IT
MATTERS THAT YOU KNOW WHY THEY WENT. The reading used to hang the SMU: at one
second it lasted 31 min 39 s, at three seconds 55 min 15 s, and once five
minutes. The cap was a guess at a safe distance from a failure nobody
understood - and on 20/09/2026 it failed inside its own ceiling, which is what
a guess does.

The cause turned out to be two unbounded waits in the SMU payload: one MR3 read
that never completed and the firmware spun inside the message handler for ever,
taking queue 2, amdgpu's metrics table, every hwmon reading and finally the
board with it. The waits are counted now and a wait that runs out answers
SMU_RETURN_FAILED, so the handler always returns. With the failure gone the cap
defends against nothing, and a reading that closes itself while somebody is
watching a game is its own small bug. See payload/main.c and UPSTREAM.md.

⚠️ WHAT DID NOT CHANGE: THE STOP MUST BE A SIGTERM, NEVER A KILL. The collector
handles SIGTERM, ends its loop and leaves its guard file at 'ready'. Killed
instead, the guard stays at 'failed' and nothing can read the memory again until
the machine reboots - the "reboot before retrying" message is that guard, not
dead silicon.

The collector itself is BC250-Telemetry by onlinermm and fansteori, MIT licensed,
shipped under /usr/lib/skillfish/gddr6 with its own LICENSE alongside, with our
payload and our bounded waits.
"""
import json
import os
import subprocess
import sys
import time

UNITA = "skillfish-gddr6"
COLLETTORE = "/usr/lib/skillfish/gddr6/collector.py"
SNAPSHOT = "/run/bc250-memory/telemetry"
GUARDIA = "/run/bc250-memory/patch-state.json"
# Seconds between rounds. Each round is two messages now, not eight: our payload
# packs four chips into the word the queue returns, so this costs the SMU a
# quarter of what it used to.
INTERVALLO = "3"


def esci(ok, **resto):
    """One JSON object on stdout, always. The window parses nothing else."""
    resto["ok"] = ok
    print(json.dumps(resto))
    raise SystemExit(0 if ok else 1)


def systemctl(*args):
    return subprocess.run(["systemctl"] + list(args),
                          capture_output=True, text=True, timeout=25)


def attiva():
    return systemctl("is-active", "--quiet", UNITA).returncode == 0


def _proprieta(*nomi):
    """Unit properties as a dict.

    ⚠️ ASKED BY NAME, NEVER BY POSITION. `systemctl show -p A -p B --value`
    returns them in systemd's own order, not the order they were asked in, so
    reading the first line as A was simply wrong.
    """
    p = systemctl("show", UNITA, *["-p" + n for n in nomi])
    fuori = {}
    for riga in p.stdout.splitlines():
        if "=" in riga:
            chiave, valore = riga.split("=", 1)
            fuori[chiave.strip()] = valore.strip()
    return fuori


def secondi_da_quando():
    """How long the reading has been running, or None when it is not.

    ⚠️ THE UNIT LINGERS AFTER IT STOPS, and its start timestamp lingers with it,
    so this has to be gated on the unit actually being active. Counting from a
    timestamp left behind by a session that ended reports a reading nobody
    started.
    """
    if not attiva():
        return None
    try:
        prop = _proprieta("ExecMainStartTimestampMonotonic")
        inizio = int(prop.get("ExecMainStartTimestampMonotonic") or 0)
        if not inizio:
            return None
        adesso = time.clock_gettime(time.CLOCK_MONOTONIC)
        return max(0, int(adesso - inizio / 1e6))
    except Exception:
        return None


def perche_no():
    """The reason the reading cannot be offered, or None when it can.

    Checked here and not only in the window: a helper that trusts its caller to
    have checked is a helper that gets called by something else one day.
    """
    if not os.path.exists(COLLETTORE):
        return "collettore non installato"
    try:
        with open("/sys/class/dmi/id/product_name") as fh:
            if "BC-250" not in fh.read():
                return "non e' una BC-250"
    except OSError:
        return "non e' una BC-250"
    # The guard survives a crash and blocks every further attempt until reboot.
    # Saying so plainly beats letting the collector fail with a message about
    # rebooting that sounds like the silicon is broken.
    try:
        with open(GUARDIA) as fh:
            stato = json.load(fh).get("state")
        if stato and stato != "ready":
            return "lettura precedente interrotta male: serve un riavvio"
    except (OSError, ValueError):
        # Niente file di guardia, o illeggibile: e' il caso normale prima della
        # prima lettura dopo un avvio. Non e' una ragione per rifiutare.
        pass
    # ⚠️ THE PAYLOAD IS PINNED TO ONE BIOS. patcher.py refuses anything that is
    # not P3.0/P3.00, and the SMU offsets it writes are only right for that build.
    # Asking the collector's own --check is better than repeating its list here:
    # it touches no hardware, and a copy of that list would drift the first time
    # upstream adds a BIOS.
    #
    # Refusing before the click also matters: a button that fails after being
    # pressed reads as a broken board, and a disabled button with a reason does
    # not.
    try:
        p = subprocess.run([sys.executable, COLLETTORE, "--check"],
                           capture_output=True, text=True, timeout=25,
                           cwd=os.path.dirname(COLLETTORE))
        if p.returncode != 0:
            motivo = (p.stderr or p.stdout or "").strip().splitlines()
            return motivo[-1][:200] if motivo else "il collettore rifiuta questa macchina"
    except Exception:
        # Il preflight del collettore e' un di piu': dice in anticipo cio' che
        # l'avvio direbbe comunque. Se non si lascia eseguire, si prova lo
        # stesso invece di bloccare il pulsante per un controllo mancato.
        pass
    return None


def avvia():
    motivo = perche_no()
    if motivo:
        esci(False, errore=motivo)
    if attiva():
        esci(True, gia_attiva=True, da=secondi_da_quando())
    # A unit that ended, even well, stays in view until it is cleared. Without
    # this the next start is refused as "unit already exists".
    systemctl("reset-failed", UNITA)
    # It runs until somebody stops it. There is no timeout(1) and no
    # RuntimeMaxSec any more: see the note at the top for what they were for and
    # why the payload made them pointless.
    p = subprocess.run([
        "systemd-run", "--unit", UNITA,
        "--description", "SkillFishOS - GDDR6 memory temperature",
        # SIGTERM and time to answer it: the clean exit is what keeps the guard
        # at 'ready' so the next reading can start without a reboot.
        "--property", "KillSignal=SIGTERM",
        "--property", "TimeoutStopSec=20",
        "--property", "WorkingDirectory=%s" % os.path.dirname(COLLETTORE),
        sys.executable, COLLETTORE, "--interval", INTERVALLO,
    ], capture_output=True, text=True, timeout=30)
    if p.returncode != 0:
        esci(False, errore=(p.stderr or p.stdout or "systemd-run rifiutato").strip()[:300])
    esci(True, da=0)


def ferma():
    if not attiva():
        esci(True, gia_ferma=True)
    p = systemctl("stop", UNITA)
    if p.returncode != 0:
        esci(False, errore=(p.stderr or "stop rifiutato").strip()[:300])
    esci(True)


def stato():
    esci(True, attiva=attiva(), da=secondi_da_quando(),
         perche_no=perche_no(), snapshot=os.path.exists(SNAPSHOT))


def main():
    azione = sys.argv[1] if len(sys.argv) > 1 else ""
    # ⚠️ ONLY avvia AND ferma NEED root. `stato` reads systemctl properties and
    # two files under /run, all of which any user may read, and the window polls
    # it every three seconds without pkexec on purpose: a password prompt to look
    # at how long a reading has been going would be absurd. Requiring root here
    # meant the window never saw the state at all, and silently fell back to the
    # default message as if nothing were running.
    if azione in ("avvia", "ferma") and os.geteuid() != 0:
        esci(False, errore="serve root")
    if azione == "avvia":
        # An argument used to set the length of the session. Accepted and
        # ignored, so an old .desktop file or a script does not break.
        avvia()
    elif azione == "ferma":
        ferma()
    elif azione == "stato":
        stato()
    else:
        esci(False, errore="azioni: avvia [minuti] | ferma | stato")


if __name__ == "__main__":
    main()
