#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (C) 2026 Christian Wirtz <doc@snowheaven.de>
# This file is part of the OpenDTU Checkmk extension.
# License: GPL-2.0-only, see LICENSE in the repository root.
"""Checkmk special agent for OpenDTU and OpenDTU-OnBattery.

Talks to an OpenDTU device's built-in local HTTP REST API and merges the useful
parts of two data sources the device exposes:

  * ``/api/livedata/status``  -- structured live data: total AC power / daily &
    lifetime yield, the four operational "hints" (time sync, radio problem,
    default password still set, pin-mapping issue), and per-inverter
    reachability / production state / current limit / radio statistics. On
    OpenDTU-OnBattery this response also advertises which sub-systems are
    configured (``solarcharger``, ``huawei`` grid charger, ``battery``,
    ``power_meter``).
  * ``/api/prometheus/metrics``  -- the device's Prometheus exporter output,
    which is the only endpoint that returns *all* inverter channel values
    (AC phase power/voltage/current/frequency/power-factor/reactive-power, the
    per-PV-string DC values, and the inverter totals: DC power, yield,
    temperature, efficiency) in a single request, plus DTU health (uptime,
    heap statistics, WiFi RSSI).

It additionally queries ``/api/system/status`` (firmware version, CPU
temperature, reset reason) and -- only when OpenDTU-OnBattery features are
present -- ``/api/batterylivedata/status`` and
``/api/solarchargerlivedata/status``.

Design notes
------------
* Standard library only (``urllib``); no third-party HTTP client, nothing is
  vendored into the .mkp.
* Read-only: the agent never calls any of OpenDTU's control endpoints (no limit
  changes, no restarts, no firmware actions).
* Defensive: OpenDTU's read-only endpoints are open by default
  (``Security.AllowReadonly``), but each optional endpoint is allowed to fail
  (missing feature, auth required, older firmware) without taking the run down.
  If the one essential endpoint (``/api/livedata/status``) cannot be reached at
  all, the agent still emits an ``opendtu_dtu`` section that marks the device
  unreachable, so the "OpenDTU System" service goes CRIT with a clear reason
  instead of the host just showing "no agent output".
* Optional HTTP authentication (Basic and Digest are both tried) for sites that
  have turned OpenDTU's read-only access off. The password is resolved from the
  Checkmk password store via ``--password-id`` exactly like the official
  special agents; a literal ``--password`` exists only for manual CLI testing.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from collections import defaultdict
from collections.abc import Mapping, Sequence
from typing import Any
from urllib import error as urlerror
from urllib import request as urlrequest

TIMEOUT_DEFAULT = 10

# Prometheus metric name -> our own short field name, for the DTU-level gauges.
_DTU_METRICS = {
    "uptime": "uptime_s",
    "heap_size": "heap_total_bytes",
    "free_heap_size": "heap_free_bytes",
    "biggest_heap_block": "heap_biggest_block_bytes",
    "heap_min_free": "heap_min_free_bytes",
}


# --------------------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------------------
def parse_arguments(argv: list[str]) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Checkmk special agent for OpenDTU / OpenDTU-OnBattery"
    )
    parser.add_argument(
        "--host",
        required=True,
        help="OpenDTU device IP address or hostname (or a full base URL)",
    )
    parser.add_argument(
        "--protocol",
        choices=("http", "https"),
        default="http",
        help="Protocol to reach the device with (default: http)",
    )
    parser.add_argument("--port", type=int, default=None, help="Override the HTTP port")
    parser.add_argument(
        "--timeout",
        type=int,
        default=TIMEOUT_DEFAULT,
        help=f"Per-request timeout in seconds (default: {TIMEOUT_DEFAULT})",
    )
    parser.add_argument("--user", default=None, help="HTTP auth user (only if read-only access is disabled)")
    pw_group = parser.add_mutually_exclusive_group()
    pw_group.add_argument(
        "--password-id",
        help="Password-store reference ('id:path'), resolved at runtime -- what Checkmk passes",
    )
    pw_group.add_argument(
        "--password",
        help="Literal password. Only for manual CLI testing outside Checkmk.",
    )
    parser.add_argument(
        "--onbattery",
        choices=("auto", "yes", "no"),
        default="auto",
        help=(
            "Whether to query OpenDTU-OnBattery endpoints (battery / solar charger / "
            "grid charger). 'auto' (default) probes /api/livedata/status for the feature "
            "flags; 'yes' always queries them; 'no' never does."
        ),
    )
    parser.add_argument(
        "--no-tls-verify",
        action="store_true",
        help="Do not verify the TLS certificate when --protocol=https (self-signed devices)",
    )
    return parser.parse_args(argv)


def resolve_password(args: argparse.Namespace) -> str | None:
    if args.password_id:
        from cmk.password_store.v1_unstable import dereference_secret

        return dereference_secret(args.password_id).reveal()
    return args.password


def build_base_url(args: argparse.Namespace) -> str:
    host = args.host.strip()
    if host.startswith(("http://", "https://")):
        return host.rstrip("/")
    netloc = host
    if args.port:
        netloc = f"{host}:{args.port}"
    return f"{args.protocol}://{netloc}"


# --------------------------------------------------------------------------------------
# HTTP
# --------------------------------------------------------------------------------------
class OpenDTUClient:
    def __init__(
        self,
        base_url: str,
        timeout: int,
        user: str | None = None,
        password: str | None = None,
        tls_verify: bool = True,
    ) -> None:
        self._base_url = base_url.rstrip("/")
        self._timeout = timeout

        handlers: list[urlrequest.BaseHandler] = []
        if user and password is not None:
            mgr = urlrequest.HTTPPasswordMgrWithDefaultRealm()
            mgr.add_password(None, self._base_url, user, password)
            handlers.append(urlrequest.HTTPDigestAuthHandler(mgr))
            handlers.append(urlrequest.HTTPBasicAuthHandler(mgr))
        if not tls_verify:
            import ssl

            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            handlers.append(urlrequest.HTTPSHandler(context=ctx))

        self._opener = urlrequest.build_opener(*handlers)
        self._opener.addheaders = [("User-Agent", "checkmk-agent-opendtu")]

    def _get(self, path: str) -> bytes:
        url = f"{self._base_url}{path}"
        with self._opener.open(url, timeout=self._timeout) as response:
            return response.read()

    def get_json(self, path: str) -> Any:
        return json.loads(self._get(path).decode("utf-8"))

    def get_text(self, path: str) -> str:
        return self._get(path).decode("utf-8", errors="replace")

    def try_json(self, path: str) -> Any | None:
        """Fetch + parse JSON, returning None on any error (endpoint absent / auth / offline)."""
        try:
            return self.get_json(path)
        except (urlerror.URLError, OSError, ValueError):
            return None


# --------------------------------------------------------------------------------------
# Prometheus text parsing
# --------------------------------------------------------------------------------------
_PROM_LINE = re.compile(
    r"^(?P<metric>[A-Za-z_][A-Za-z0-9_]*)"
    r"(?:\{(?P<labels>[^}]*)\})?"
    r"\s+(?P<value>[-+]?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)\s*$"
)
_PROM_LABEL = re.compile(r'([A-Za-z_][A-Za-z0-9_]*)="((?:[^"\\]|\\.)*)"')


def _parse_labels(raw: str | None) -> dict[str, str]:
    if not raw:
        return {}
    return {key: val for key, val in _PROM_LABEL.findall(raw)}


def parse_prometheus_metrics(text: str) -> dict[str, Any]:
    """Turn OpenDTU's Prometheus exporter output into a structured dict.

    Returns::

        {
          "dtu": {"uptime_s": ..., "heap_free_bytes": ..., "wifi_rssi_dbm": ...,
                  "build": {...}, "platform": {...}},
          "inverters": {
             "<serial>": {
                "name": ..., "unit": ...,
                "last_update_s": ..., "limit_relative": ..., "limit_absolute_w": ...,
                "ac": {"Power": .., "Voltage": .., "Current": .., "Frequency": ..,
                       "PowerFactor": .., "ReactivePower": ..},
                "inv": {"Power DC": .., "YieldDay": .., "YieldTotal": .., "Temperature": ..,
                        "Efficiency": ..},
                "panels": {"<channel>": {"Power": .., "Voltage": .., "Current": ..,
                           "YieldDay": .., "YieldTotal": .., "Irradiation": ..,
                           "MaxPower": .., "name": ..}},
             },
          },
        }
    """
    dtu: dict[str, Any] = {}
    inverters: dict[str, dict[str, Any]] = defaultdict(
        lambda: {"ac": {}, "inv": {}, "panels": defaultdict(dict)}
    )

    for raw_line in text.splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        match = _PROM_LINE.match(line)
        if not match:
            continue

        name = match.group("metric")
        labels = _parse_labels(match.group("labels"))
        raw_value = match.group("value")
        try:
            value = float(raw_value)
        except ValueError:
            continue
        short = name[len("opendtu_") :] if name.startswith("opendtu_") else name

        if short == "build":
            dtu["build"] = labels
            continue
        if short == "platform":
            dtu["platform"] = labels
            continue
        if name == "wifi_rssi":
            dtu["wifi_rssi_dbm"] = value
            continue
        if name == "wifi_station":
            dtu["wifi_bssid"] = labels.get("bssid")
            continue
        if short in _DTU_METRICS and not labels:
            dtu[_DTU_METRICS[short]] = value
            continue

        serial = labels.get("serial")
        if not serial:
            continue
        inv = inverters[serial]
        inv.setdefault("name", labels.get("name"))
        inv.setdefault("unit", labels.get("unit"))

        if short == "last_update":
            inv["last_update_s"] = value
            continue
        if short == "inverter_limit_relative":
            inv["limit_relative"] = value
            continue
        if short == "inverter_limit_absolute":
            inv["limit_absolute_w"] = value
            continue

        channel = labels.get("channel", "0")
        ctype = labels.get("type", "")

        if short == "PanelInfo":
            panel = inv["panels"][channel]
            if labels.get("panelname"):
                panel["name"] = labels["panelname"]
            continue
        if short in ("MaxPower", "YieldTotalOffset"):
            inv["panels"][channel][short] = value
            continue

        if ctype == "AC":
            inv["ac"][short] = value
        elif ctype == "INV":
            inv["inv"][short] = value
        elif ctype == "DC":
            inv["panels"][channel][short] = value
        # anything else (unknown channel type) is ignored on purpose

    # de-defaultdict for clean JSON
    clean_inverters: dict[str, Any] = {}
    for serial, inv in inverters.items():
        inv["panels"] = {ch: dict(p) for ch, p in inv["panels"].items()}
        clean_inverters[serial] = inv
    return {"dtu": dtu, "inverters": clean_inverters}


# --------------------------------------------------------------------------------------
# Section building
# --------------------------------------------------------------------------------------
def _f(value: Any) -> float | None:
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _as_bool(value: Any) -> bool | None:
    if isinstance(value, bool):
        return value
    if isinstance(value, str):
        return value.strip().lower() in ("true", "1", "yes")
    if isinstance(value, (int, float)):
        return bool(value)
    return None


def build_dtu_section(
    live: Mapping[str, Any] | None,
    prom: Mapping[str, Any] | None,
    system: Mapping[str, Any] | None,
    base_url: str,
    error: str | None = None,
) -> dict[str, Any]:
    if error is not None or live is None:
        return {"reachable": False, "base_url": base_url, "error": error or "no data"}

    prom_dtu = dict((prom or {}).get("dtu", {}))
    system = system or {}
    build = prom_dtu.get("build", {})

    heap_total = _f(prom_dtu.get("heap_total_bytes"))
    heap_free = _f(prom_dtu.get("heap_free_bytes"))
    heap_used_pct = None
    if heap_total and heap_total > 0 and heap_free is not None:
        heap_used_pct = round((heap_total - heap_free) / heap_total * 100.0, 1)

    section: dict[str, Any] = {
        "reachable": True,
        "base_url": base_url,
        "prometheus_available": bool(prom_dtu),
        "hostname": system.get("hostname") or build.get("name"),
        "firmware_version": system.get("config_version") or build.get("version"),
        "git_hash": system.get("git_hash") or build.get("id"),
        "git_branch": system.get("git_branch"),
        "chip_model": (prom_dtu.get("platform") or {}).get("arch") or system.get("chipmodel"),
        "mac": (prom_dtu.get("platform") or {}).get("mac"),
        "uptime_s": _f(prom_dtu.get("uptime_s")) or _f(system.get("uptime")),
        "heap_total_bytes": heap_total,
        "heap_free_bytes": heap_free,
        "heap_used_pct": heap_used_pct,
        "heap_min_free_bytes": _f(prom_dtu.get("heap_min_free_bytes")),
        "wifi_rssi_dbm": _f(prom_dtu.get("wifi_rssi_dbm")),
        "wifi_bssid": prom_dtu.get("wifi_bssid"),
        "cpu_temp_c": _f(system.get("cputemp")),
        "reset_reason": system.get("resetreason_0"),
        "config_save_count": _f(system.get("cfgsavecount")),
    }
    # radio wiring health, when /api/system/status is available
    for key in ("nrf_configured", "nrf_connected", "nrf_pvariant", "cmt_configured", "cmt_connected"):
        if key in system:
            section[key] = _as_bool(system[key])
    return {k: v for k, v in section.items() if v is not None}


def _feature_enabled(live: Mapping[str, Any], key: str) -> bool | None:
    """OpenDTU-OnBattery's /api/livedata/status carries a sub-object per sub-system,
    e.g. ``"battery": {"enabled": true, "soc": {...}}``. Older firmware used a bare
    boolean at the same key. Normalise both to a plain bool, or None if absent."""
    node = live.get(key)
    if isinstance(node, Mapping):
        return _as_bool(node.get("enabled"))
    return _as_bool(node)


def build_status_section(live: Mapping[str, Any]) -> dict[str, Any]:
    hints = live.get("hints") or {}
    section = {
        "time_sync_issue": _as_bool(hints.get("time_sync")),
        "radio_problem": _as_bool(hints.get("radio_problem")),
        "default_password": _as_bool(hints.get("default_password")),
        "pin_mapping_issue": _as_bool(hints.get("pin_mapping_issue")),
    }
    for feature, alias in (
        ("solarcharger", "solarcharger"),
        ("gridcharger", "huawei"),
        ("huawei", "huawei"),
        ("battery", "battery"),
        ("power_meter", "power_meter"),
    ):
        enabled = _feature_enabled(live, feature)
        if enabled is not None:
            section[f"feature_{alias}"] = enabled
    return {k: v for k, v in section.items() if v is not None}


def _total_field(node: Any, *keys: str) -> float | None:
    """Read a ``{"v": .., "u": .., "d": ..}`` value out of an OpenDTU 'total field'
    sub-object, trying each candidate key name in order."""
    if not isinstance(node, Mapping):
        return None
    for key in keys:
        entry = node.get(key)
        if isinstance(entry, Mapping) and _f(entry.get("v")) is not None:
            return _f(entry["v"])
    return None


def build_total_section(live: Mapping[str, Any]) -> dict[str, Any] | None:
    total = live.get("total") or {}
    out: dict[str, Any] = {}
    for key, field in (("Power", "power_w"), ("YieldDay", "yield_day_wh"), ("YieldTotal", "yield_total_kwh")):
        node = total.get(key)
        if isinstance(node, Mapping) and _f(node.get("v")) is not None:
            out[field] = _f(node["v"])
    return out or None


def _inverter_display_name(common: Mapping[str, Any], prom_inv: Mapping[str, Any]) -> str:
    name = (common.get("name") or prom_inv.get("name") or "").strip()
    return name or str(common.get("serial") or "unknown")


def build_inverter_rows(
    live: Mapping[str, Any], prom: Mapping[str, Any]
) -> list[dict[str, Any]]:
    prom_inv_by_serial = prom.get("inverters", {})
    rows: list[dict[str, Any]] = []

    live_inverters = live.get("inverters") or []
    seen: set[str] = set()

    for common in live_inverters:
        if not isinstance(common, Mapping):
            continue
        serial = str(common.get("serial") or "")
        seen.add(serial)
        prom_inv = prom_inv_by_serial.get(serial, {})
        radio = common.get("radio_stats") or {}
        ac = prom_inv.get("ac", {})
        inv_ch = prom_inv.get("inv", {})

        row: dict[str, Any] = {
            "serial": serial,
            "name": _inverter_display_name(common, prom_inv),
            "reachable": _as_bool(common.get("reachable")),
            "producing": _as_bool(common.get("producing")),
            "poll_enabled": _as_bool(common.get("poll_enabled")),
            "data_age_s": _f(common.get("data_age")),
            "events": _f(common.get("events")),
            "limit_relative_pct": _pct(common.get("limit_relative"), prom_inv.get("limit_relative")),
            "limit_absolute_w": _f(common.get("limit_absolute")) or _f(prom_inv.get("limit_absolute_w")),
            "last_update_s": _f(prom_inv.get("last_update_s")),
            "ac_power_w": _f(ac.get("Power")),
            "ac_voltage_v": _f(ac.get("Voltage")),
            "ac_current_a": _f(ac.get("Current")),
            "frequency_hz": _f(ac.get("Frequency")),
            "power_factor": _f(ac.get("PowerFactor")),
            "reactive_power_var": _f(ac.get("ReactivePower")),
            "power_dc_w": _f(inv_ch.get("Power DC")) or _f(inv_ch.get("PowerDC")),
            "yield_day_wh": _f(inv_ch.get("YieldDay")),
            "yield_total_kwh": _f(inv_ch.get("YieldTotal")),
            "temperature_c": _f(inv_ch.get("Temperature")),
            "efficiency_pct": _f(inv_ch.get("Efficiency")),
            "radio_rx_success": _f(radio.get("rx_success")),
            "radio_tx_request": _f(radio.get("tx_request")),
            "radio_tx_re_request": _f(radio.get("tx_re_request")),
            "radio_rx_fail_nothing": _f(radio.get("rx_fail_nothing")),
            "radio_rx_fail_partial": _f(radio.get("rx_fail_partial")),
            "radio_rx_fail_corrupt": _f(radio.get("rx_fail_corrupt")),
            "radio_rssi_dbm": _f(radio.get("rssi")),
        }
        rows.append({k: v for k, v in row.items() if v is not None})

    # Inverters that the Prometheus endpoint knows about but the common livedata list
    # did not include (should be rare) still get a row so their channel data is not lost.
    for serial, prom_inv in prom_inv_by_serial.items():
        if serial in seen:
            continue
        ac = prom_inv.get("ac", {})
        inv_ch = prom_inv.get("inv", {})
        row = {
            "serial": serial,
            "name": (prom_inv.get("name") or serial).strip() or serial,
            "last_update_s": _f(prom_inv.get("last_update_s")),
            "limit_relative_pct": _pct(None, prom_inv.get("limit_relative")),
            "limit_absolute_w": _f(prom_inv.get("limit_absolute_w")),
            "ac_power_w": _f(ac.get("Power")),
            "ac_voltage_v": _f(ac.get("Voltage")),
            "ac_current_a": _f(ac.get("Current")),
            "frequency_hz": _f(ac.get("Frequency")),
            "power_factor": _f(ac.get("PowerFactor")),
            "reactive_power_var": _f(ac.get("ReactivePower")),
            "power_dc_w": _f(inv_ch.get("Power DC")) or _f(inv_ch.get("PowerDC")),
            "yield_day_wh": _f(inv_ch.get("YieldDay")),
            "yield_total_kwh": _f(inv_ch.get("YieldTotal")),
            "temperature_c": _f(inv_ch.get("Temperature")),
            "efficiency_pct": _f(inv_ch.get("Efficiency")),
        }
        rows.append({k: v for k, v in row.items() if v is not None})

    return rows


def _pct(livedata_value: Any, prom_value: Any) -> float | None:
    """OpenDTU's livedata reports the relative limit as a percentage (0..100); the
    Prometheus endpoint reports the same thing as a fraction (0..1). Normalise to %."""
    val = _f(livedata_value)
    if val is not None:
        return val
    frac = _f(prom_value)
    if frac is None:
        return None
    return round(frac * 100.0, 2)


def build_panel_rows(prom: Mapping[str, Any]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for serial, prom_inv in prom.get("inverters", {}).items():
        inv_name = (prom_inv.get("name") or serial).strip() or serial
        for channel, panel in sorted(prom_inv.get("panels", {}).items(), key=lambda kv: kv[0]):
            label = panel.get("name") or f"DC {channel}"
            row = {
                "serial": serial,
                "inverter_name": inv_name,
                "channel": channel,
                "label": f"{inv_name} {label}",
                "power_w": _f(panel.get("Power")),
                "voltage_v": _f(panel.get("Voltage")),
                "current_a": _f(panel.get("Current")),
                "yield_day_wh": _f(panel.get("YieldDay")),
                "yield_total_kwh": _f(panel.get("YieldTotal")),
                "irradiation_pct": _f(panel.get("Irradiation")),
                "max_power_w": _f(panel.get("MaxPower")),
            }
            # A completely empty panel (no electrical values at all) is not worth a service.
            if any(row.get(k) is not None for k in ("power_w", "voltage_v", "current_a")):
                rows.append({k: v for k, v in row.items() if v is not None})
    return rows


def _flatten_liveview_values(node: Any) -> dict[str, dict[str, Any]]:
    """OpenDTU-OnBattery's battery / solar-charger live views nest numeric readings as
    ``values.<section>.<name> = {"v": .., "u": .., "d": ..}`` (text ones use ``value``).
    Flatten every numeric one into ``{"<section>.<name>": {"value": float, "unit": str}}``.

    The solar-charger response wraps this one level deeper, under
    ``instances.<id>.values.*`` -- that layer is descended into as well.
    """
    out: dict[str, dict[str, Any]] = {}
    if not isinstance(node, Mapping):
        return out

    value_maps: list[Mapping[str, Any]] = []
    if isinstance(node.get("values"), Mapping):
        value_maps.append(node["values"])
    instances = node.get("instances")
    if isinstance(instances, Mapping):
        for inst in instances.values():
            if isinstance(inst, Mapping) and isinstance(inst.get("values"), Mapping):
                value_maps.append(inst["values"])

    for values in value_maps:
        for sect_name, sect in values.items():
            if not isinstance(sect, Mapping):
                continue
            for name, entry in sect.items():
                if not isinstance(entry, Mapping):
                    continue
                num = _f(entry.get("v"))
                if num is None:
                    continue
                out[f"{sect_name}.{name}"] = {"value": num, "unit": str(entry.get("u") or "")}
    return out


def _pick_flat(flat: Mapping[str, Mapping[str, Any]], *names: str) -> float | None:
    lowered = [n.lower() for n in names]
    for name in lowered:
        for key, entry in flat.items():
            if key.split(".")[-1].lower() == name:
                return entry.get("value")
    return None


def build_battery_section(
    embedded: Mapping[str, Any] | None, detail: Mapping[str, Any] | None
) -> dict[str, Any]:
    """Merge the battery figures OpenDTU-OnBattery embeds in /api/livedata/status
    (soc / voltage / current / power -- always present) with the richer
    /api/batterylivedata/status response (temperature, manufacturer, firmware, BMS
    warning/alarm 'issues', and any provider-specific extra readings)."""
    embedded = embedded or {}
    detail = detail or {}
    flat = _flatten_liveview_values(detail)

    voltage = _total_field(embedded, "voltage") or _pick_flat(flat, "voltage")
    current = _total_field(embedded, "current") or _pick_flat(flat, "current")
    power = _total_field(embedded, "power")
    if power is None and voltage is not None and current is not None:
        power = round(voltage * current, 1)

    issues = detail.get("issues") if isinstance(detail.get("issues"), Mapping) else {}
    section = {
        "reachable": True,
        "manufacturer": detail.get("manufacturer"),
        "serial": detail.get("serial"),
        "firmware_version": detail.get("fwversion"),
        "hardware_version": detail.get("hwversion"),
        "data_age_s": _f(detail.get("data_age")),
        "max_age_s": _f(detail.get("max_age")),
        "soc_pct": _total_field(embedded, "soc") or _pick_flat(flat, "SoC"),
        "voltage_v": voltage,
        "current_a": current,
        "power_w": power,
        "temperature_c": _pick_flat(flat, "temperature"),
        "charge_current_limit_a": _pick_flat(flat, "chargeCurrentLimitation"),
        "discharge_current_limit_a": _pick_flat(flat, "dischargeCurrentLimitation"),
        "extra": flat,
        "issues": {str(k): int(v) for k, v in issues.items() if _f(v) is not None},
    }
    return {k: v for k, v in section.items() if v not in (None, {}, [])}


def build_solarcharger_section(
    embedded: Mapping[str, Any] | None, detail: Mapping[str, Any] | None
) -> dict[str, Any]:
    embedded = embedded or {}
    detail = detail or {}
    flat = _flatten_liveview_values(detail)
    section = {
        "reachable": True,
        # embedded /api/livedata/status reports yieldDay in Wh, yieldTotal in kWh
        "power_w": _total_field(embedded, "power")
        or _pick_flat(flat, "power", "outputPowerWatts", "P"),
        "yield_day_wh": _total_field(embedded, "yieldDay"),
        "yield_total_kwh": _total_field(embedded, "yieldTotal")
        or _pick_flat(flat, "yieldTotal", "H19"),
        "panel_power_w": _total_field(embedded, "panelPower"),
        "voltage_v": _pick_flat(flat, "outputVoltage", "V"),
        "current_a": _pick_flat(flat, "outputCurrent", "I"),
        "extra": flat,
    }
    return {k: v for k, v in section.items() if v not in (None, {}, [])}


def build_gridcharger_section(embedded: Mapping[str, Any]) -> dict[str, Any]:
    return {
        "reachable": True,
        "enabled": _as_bool(embedded.get("enabled")),
        "power_w": _total_field(embedded, "Power", "power"),
    }


def build_powermeter_section(embedded: Mapping[str, Any]) -> dict[str, Any]:
    return {
        "reachable": True,
        "enabled": _as_bool(embedded.get("enabled")),
        "power_w": _total_field(embedded, "Power", "power"),
    }


# --------------------------------------------------------------------------------------
# Orchestration
# --------------------------------------------------------------------------------------
def _emit(section: str, payload: Any) -> None:
    print(f"<<<{section}:sep(0)>>>")
    print(json.dumps(payload, sort_keys=True))


def run(args: argparse.Namespace) -> int:
    base_url = build_base_url(args)
    password = resolve_password(args)
    client = OpenDTUClient(
        base_url,
        timeout=args.timeout,
        user=args.user,
        password=password,
        tls_verify=not args.no_tls_verify,
    )

    # The one essential request. If this fails, the device is effectively down for us.
    try:
        live = client.get_json("/api/livedata/status")
    except (urlerror.URLError, OSError, ValueError) as exc:
        _emit("opendtu_dtu", build_dtu_section(None, None, None, base_url, error=str(exc)))
        return 0

    prom_text = None
    try:
        prom_text = client.get_text("/api/prometheus/metrics")
    except (urlerror.URLError, OSError):
        prom_text = None
    prom = parse_prometheus_metrics(prom_text) if prom_text else {"dtu": {}, "inverters": {}}

    system = client.try_json("/api/system/status")

    _emit("opendtu_dtu", build_dtu_section(live, prom, system, base_url))
    _emit("opendtu_status", build_status_section(live))

    total = build_total_section(live)
    if total is not None:
        _emit("opendtu_total", total)

    for row in build_inverter_rows(live, prom):
        _emit("opendtu_inverter", row)

    for row in build_panel_rows(prom):
        _emit("opendtu_panel", row)

    # ----- OpenDTU-OnBattery ---------------------------------------------------------
    # Current OpenDTU-OnBattery firmware embeds the core battery / solar charger / grid
    # charger / power meter figures directly in /api/livedata/status, as sub-objects each
    # carrying an "enabled" flag (older firmware used a bare boolean at the same key;
    # plain OpenDTU has no such keys at all). That flag -- not "a detail endpoint
    # replied" -- is authoritative: /api/batterylivedata/status and
    # /api/solarchargerlivedata/status answer with a non-empty stub object even when
    # nothing is configured.
    def _subsystem_active(key: str) -> bool | None:
        node = live.get(key)
        if isinstance(node, Mapping):
            return _as_bool(node.get("enabled"))
        if node is not None:
            return _as_bool(node)  # legacy bare-boolean firmware
        return None  # key absent -> plain OpenDTU, or feature not compiled in

    onbattery_keys = ("solarcharger", "gridcharger", "huawei", "battery", "power_meter")
    force = args.onbattery == "yes"
    want_onbattery = force or (
        args.onbattery == "auto"
        and any(_subsystem_active(key) for key in onbattery_keys)
    )

    if want_onbattery:
        battery_active = _subsystem_active("battery")
        if battery_active or (battery_active is None and force):
            embedded_battery = live.get("battery") if isinstance(live.get("battery"), Mapping) else None
            battery_detail = client.try_json("/api/batterylivedata/status")
            if battery_active or (isinstance(battery_detail, Mapping) and battery_detail):
                _emit("opendtu_battery", build_battery_section(embedded_battery, battery_detail))

        sc_active = _subsystem_active("solarcharger")
        if sc_active or (sc_active is None and force):
            embedded_sc = live.get("solarcharger") if isinstance(live.get("solarcharger"), Mapping) else None
            sc_detail = client.try_json("/api/solarchargerlivedata/status")
            if sc_active or (isinstance(sc_detail, Mapping) and sc_detail):
                _emit("opendtu_solarcharger", build_solarcharger_section(embedded_sc, sc_detail))

        emitted: set[str] = set()
        for live_key, section_name, builder in (
            ("gridcharger", "opendtu_gridcharger", build_gridcharger_section),
            ("huawei", "opendtu_gridcharger", build_gridcharger_section),
            ("power_meter", "opendtu_powermeter", build_powermeter_section),
        ):
            if section_name in emitted:
                continue
            node = live.get(live_key)
            if isinstance(node, Mapping) and _as_bool(node.get("enabled")):
                _emit(section_name, builder(node))
                emitted.add(section_name)

    return 0


def main(argv: Sequence[str] | None = None) -> int:
    args = parse_arguments(list(sys.argv[1:] if argv is None else argv))
    return run(args)


if __name__ == "__main__":
    sys.exit(main())
