#!/usr/bin/env python3
"""Thuum Stats — a device recipe: reports this computer's vitals to one thing, every few seconds.

Python 3 standard library only: runs as is on a Raspberry Pi (or any Linux; on others, the
Linux-only readings are skipped).

First run, with the claim code shown when the device was added in Stats:

    STATS_URL=https://stats.thuum.dev STATS_CLAIM_CODE=ABCD-EFGH python3 stats-report.py

The secret it gets is saved (STATS_SECRET_FILE, default ~/.thuum-stats-secret) and used from then
on: later runs need only STATS_URL. STATS_INTERVAL sets the seconds between reports (default 10).

Each report: one State Broker `set` per property on the thing's topic (its live value, with its
age), then one Event Broker event `reading` with all of them on its history topic.
"""

import json
import os
import shutil
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

STATS_URL = os.environ.get("STATS_URL", "https://stats.thuum.dev").rstrip("/")
SECRET_FILE = os.path.expanduser(
    os.environ.get("STATS_SECRET_FILE", "~/.thuum-stats-secret")
)
INTERVAL = float(os.environ.get("STATS_INTERVAL", "10"))


def call(method, url, body=None, token=None):
    headers = {"content-type": "application/json", "accept": "application/json"}
    if token:
        headers["authorization"] = f"Bearer {token}"
    data = json.dumps(body).encode() if body is not None else None
    request = urllib.request.Request(url, data=data, headers=headers, method=method)
    with urllib.request.urlopen(request, timeout=15) as response:
        text = response.read().decode()
        return json.loads(text) if text else None


def secret():
    """The saved secret, or a new one from the claim code (saved, readable by this user only)."""
    if os.path.exists(SECRET_FILE):
        with open(SECRET_FILE) as f:
            return f.read().strip()
    code = os.environ.get("STATS_CLAIM_CODE")
    if not code:
        sys.exit("No saved secret: set STATS_CLAIM_CODE to the code shown in Stats.")
    try:
        claimed = call("POST", f"{STATS_URL}/device/claim", {"code": code})
    except urllib.error.HTTPError as e:
        sys.exit(f"Claiming failed ({e.code}): {e.read().decode()}")
    except urllib.error.URLError as e:
        sys.exit(f"Stats cannot be reached at {STATS_URL}: {e.reason}")
    fd = os.open(SECRET_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as f:
        f.write(claimed["secret"])
    print(f"Claimed; secret saved in {SECRET_FILE}", flush=True)
    return claimed["secret"]


def grant(device_secret):
    """A Thuum Token for this device's thing (1 h), with where to send what."""
    try:
        g = call("POST", f"{STATS_URL}/device/token", {}, token=device_secret)
    except urllib.error.HTTPError as e:
        if e.code == 401:
            sys.exit(f"The secret no longer works: claim again (delete {SECRET_FILE}).")
        raise
    g["until"] = time.time() + g["expires_in"] - 60
    return g


def readings():
    """What this computer can tell: load, memory, disk, temperature, uptime."""
    r = {}
    try:
        r["load_1m"] = round(os.getloadavg()[0], 2)
    except OSError:
        pass
    usage = shutil.disk_usage("/")
    r["disk_used_pct"] = round(100 * usage.used / usage.total, 1)
    try:
        with open("/proc/meminfo") as f:
            info = {line.split(":")[0]: int(line.split()[1]) for line in f}
        r["mem_used_pct"] = round(
            100 * (1 - info["MemAvailable"] / info["MemTotal"]), 1
        )
    except (OSError, KeyError, ValueError):
        pass
    try:
        with open("/sys/class/thermal/thermal_zone0/temp") as f:
            r["cpu_temp_c"] = round(int(f.read()) / 1000, 1)
    except (OSError, ValueError):
        pass
    try:
        with open("/proc/uptime") as f:
            r["uptime_h"] = round(float(f.read().split()[0]) / 3600, 1)
    except (OSError, ValueError):
        pass
    return r


def topic_url(g, broker, topic, key=None):
    path = f"{g['api_url']}/api/{g['domain']}/{broker}/data/{urllib.parse.quote(topic, safe=':')}"
    return f"{path}/{urllib.parse.quote(key, safe='')}" if key else path


def report(g, values, event_type="reading"):
    for key, value in values.items():
        call(
            "PUT",
            topic_url(g, "state", g["state_topic"], key),
            {"value": value},
            g["token"],
        )
    call(
        "POST",
        topic_url(g, "event", g["history_topic"]),
        {"type": event_type, "data": values},
        g["token"],
    )


def main():
    device_secret = secret()
    g = grant(device_secret)
    call(
        "POST",
        topic_url(g, "event", g["history_topic"]),
        {"type": "boot", "data": {"interval_s": INTERVAL}},
        g["token"],
    )
    print(
        f"Reporting to {g['state_topic']} every {INTERVAL:g} s (Ctrl+C to stop)",
        flush=True,
    )
    while True:
        try:
            if time.time() > g["until"]:
                g = grant(device_secret)
            values = readings()
            report(g, values)
            print(time.strftime("%H:%M:%S"), json.dumps(values), flush=True)
        except urllib.error.HTTPError as e:
            print(f"Thuum answered {e.code}; a new Token next time", file=sys.stderr)
            g["until"] = 0
        except (urllib.error.URLError, TimeoutError) as e:
            print(f"Unreachable ({e}); retrying", file=sys.stderr)
        time.sleep(INTERVAL)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        pass
