#!/usr/bin/env python3
"""
Innerscene fixture client (Python) - set color temperature & brightness over Wi-Fi.

  pip install requests
  python fixture_client.py 192.168.1.42 4500 0.5           # 4500 K at 50%
  python fixture_client.py 192.168.1.42 4500 0.5 mypass    # with a custom password

------------------------------------------------------------------------------
Wi-Fi HTTP authentication
------------------------------------------------------------------------------
Fixture firmware 2.0.253 added a per-request authentication gate on the Wi-Fi
(LAN) HTTP interface, and it is ON BY DEFAULT. On 2.0.253+ every control/status
request must be signed with an HMAC derived from the fixture password (an
installer can turn it off via "Require Password over Wi-Fi" on the fixture's
developer screen). Firmware older than 2.0.253 has no gate at all.

This client is backward compatible and needs no configuration:

  * Firmware before 2.0.253 (no gate)             -> never challenged -> plain HTTP.
  * 2.0.253+ with the gate ON (the default)       -> the fixture answers 401
    {"auth_required":true}; we transparently run the handshake, derive the
    signing key, and retry the request signed. Subsequent requests in the same
    run are signed automatically.
  * 2.0.253+ with the gate OFF (installer disabled it) -> plain HTTP.

The default password is "innerscene" (the same value used for the fixture's
Wi-Fi setup AP). If the installer changed the fixture password, pass it in.
------------------------------------------------------------------------------
"""

import sys
import hmac
import hashlib
import requests

# Context string baked into the firmware key-derivation. Must match exactly.
SIG_CONTEXT = b"a2-sig-v1"


class FixtureClient:
    def __init__(self, ip, password="innerscene", timeout=5):
        self.base = f"http://{ip}"
        self.password = password.encode("utf-8")
        self.timeout = timeout
        self._sid = None          # hex session id, once authenticated
        self._ksig = None         # 32-byte per-session signing key
        self._ctr = 0             # monotonic per-request counter

    # -- low-level: one HTTP GET, signing only when we hold a session ----------
    def _raw_get(self, path):
        headers = {}
        if self._ksig is not None:
            self._ctr += 1
            # canonical = METHOD "\n" URI "\n" CTR "\n" CONTENT_LENGTH
            # (GET has no body, so CONTENT_LENGTH is 0). URI is the exact
            # request target the fixture sees, including the query string.
            canonical = f"GET\n{path}\n{self._ctr}\n0".encode("utf-8")
            sig = hmac.new(self._ksig, canonical, hashlib.sha256).hexdigest()
            headers = {
                "X-Sig-Sid": self._sid,
                "X-Sig-Ctr": str(self._ctr),
                "X-Sig": sig,
            }
        return requests.get(self.base + path, headers=headers, timeout=self.timeout)

    # -- the handshake: only runs when the fixture demands authentication ------
    def _authenticate(self):
        # 1. Ask the fixture for a challenge.
        ch = requests.get(self.base + "/auth/request", timeout=self.timeout).json()
        nonce = bytes.fromhex(ch["nonce"])
        salt = bytes.fromhex(ch["salt"])
        sid = ch["sid"]

        # 2. Prove we know the password: HMAC(password, nonce || salt).
        response = hmac.new(self.password, nonce + salt, hashlib.sha256).hexdigest()
        verify = requests.get(
            f"{self.base}/auth/verify2?sid={sid}&response={response}",
            timeout=self.timeout,
        ).json()
        if verify.get("a") != 1:
            raise PermissionError(
                "fixture rejected the password - check the fixture's Wi-Fi "
                "password (default 'innerscene')"
            )

        # 3. Derive the per-request signing key both sides now share:
        #    K_sig = HMAC(password, nonce || salt || "a2-sig-v1").
        self._ksig = hmac.new(
            self.password, nonce + salt + SIG_CONTEXT, hashlib.sha256
        ).digest()
        self._sid = sid
        self._ctr = 0

    # -- public: GET a path, authenticating on demand and retrying once --------
    def get(self, path):
        r = self._raw_get(path)
        if r.status_code == 401:
            # Gate is on (or our session expired). Handshake and retry signed.
            self._ksig = None
            self._authenticate()
            r = self._raw_get(path)
        r.raise_for_status()
        text = r.text
        try:
            return r.json()
        except ValueError:
            return text

    # -- convenience wrappers --------------------------------------------------
    def set_cct(self, cct, intensity):
        """Set color temperature (Kelvin) and brightness (0-1). Exits schedule."""
        return self.get(f"/setCCT?cct={cct}&i={intensity}")

    def get_status(self):
        return self.get("/getStatus")

    def identify(self):
        return self.get("/identify")


def main():
    if len(sys.argv) < 4:
        raise SystemExit(
            "Usage: python fixture_client.py <ip> <cct> <intensity> [password]"
        )
    ip, cct, intensity = sys.argv[1], int(sys.argv[2]), float(sys.argv[3])
    password = sys.argv[4] if len(sys.argv) > 4 else "innerscene"

    fx = FixtureClient(ip, password)

    status = fx.get_status()
    print(f"Before : {status.get('cct')} K  {status.get('lux')} lux")

    fx.set_cct(cct, intensity)
    print(f"Set    : {cct} K  {intensity * 100:.0f}%")

    status = fx.get_status()
    print(f"After  : {status.get('cct')} K  {status.get('lux')} lux")


if __name__ == "__main__":
    main()
