diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4853479 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.venv +__pycache__ +*.pyc +build +dist +tests +deploy +redpanda-connect +config.toml +oauth.toml +*.env +token.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec02a2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.venv/ +venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.coverage +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +config.toml +oauth.toml +*.env +token.json + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fdf664a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY src ./src + +ENV PYTHONPATH=/app/src + +USER 10001:10001 + +ENTRYPOINT ["python", "-m", "windfree_poller.main"] +CMD ["--config", "/etc/smartthings-windfree/config.toml"] diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..ae3db05 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,12 @@ +@Library('vulcan-ci') _ + +pythonApplicationPipeline( + applicationName: 'windfree-poller', + pythonImage: 'python:3.11-slim-bookworm', + imageRepository: 'repo.attilabito.com/apps/windfree-poller', + targetPlatform: 'linux/arm64', + validationCommands: [ + '.venv/bin/smartthings-windfree-poller --help', + '.venv/bin/smartthings-windfree-oauth --help' + ] +) diff --git a/Jenkinsfile.deploy b/Jenkinsfile.deploy new file mode 100644 index 0000000..5553cac --- /dev/null +++ b/Jenkinsfile.deploy @@ -0,0 +1,14 @@ +@Library('vulcan-ci') _ + +containerDeploymentPipeline( + applicationName: 'windfree-poller', + imageRepository: 'repo.attilabito.com/apps/windfree-poller', + deploymentTargets: [ + 'raspberry-pi-4b' + ], + dockerArguments: [ + '--env-file /etc/smartthings-windfree/secrets', + '--mount type=bind,src=/etc/smartthings-windfree/config.toml,dst=/etc/smartthings-windfree/config.toml,readonly', + '--mount type=bind,src=/var/lib/smartthings-windfree,dst=/var/lib/smartthings-windfree' + ] +) diff --git a/README.md b/README.md index e69de29..f42aeea 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,98 @@ +# SmartThings WindFree poller + +Kis erőforrásigényű, Python 3.11-es daemon egy SmartThings-eszköz teljes +állapotának ötpercenkénti lekérésére és Redpanda Connect felé továbbítására. +Nincs közvetlen külső runtime függősége. + +## Hitelesítés + +Tartós futtatáshoz OAuth-integráció szükséges. Az első engedélyezést külső, +böngészős folyamatban kell elvégezni; az abból kapott tokeneket a daemon +automatikusan frissíti. Rövid teszthez `access_token` (PAT) is megadható, de a +daemon működése ne függjön kézzel cserélendő tokentől. + +A PAT külön fájlból is olvasható az `access_token_file` beállítással. A fájl +legyen csak a service user számára olvasható (`0600`). A PAT scope-jai nem adnak +refresh tokent; refresh token csak OAuth authorization-code flow során keletkezik. + +## Repository szerkezete + +```text +deploy/ systemd unitok a DS1 telepítéséhez +redpanda-connect/ a hozzá tartozó ingest pipeline mintája +src/ Python csomag +tests/ standard library unit tesztek +Jenkinsfile Python 3.11 CI pipeline Kubernetes agenten +``` + +A repository nem tartalmaz virtuális környezetet, build artifactot, futásidejű +konfigurációt vagy tokent. Ezeket a `.gitignore` is kizárja. + +A szükséges jogosultság legalább az engedélyezett eszköz állapotának olvasása. +A titkokat ne írd a TOML-ba; használd a systemd environment fájlt: + +```text +SMARTTHINGS_CLIENT_ID=... +SMARTTHINGS_CLIENT_SECRET=... +SMARTTHINGS_REFRESH_TOKEN=... +``` + +## Telepítés Raspberry Pi OS-en + +```bash +sudo useradd --system --home /nonexistent --shell /usr/sbin/nologin smartthings-poller +sudo mkdir -p /opt/smartthings-windfree /etc/smartthings-windfree +sudo cp -r pyproject.toml src /opt/smartthings-windfree/ +sudo python3.11 -m venv /opt/smartthings-windfree/venv +sudo /opt/smartthings-windfree/venv/bin/pip install /opt/smartthings-windfree +sudo cp config.example.toml /etc/smartthings-windfree/config.toml +sudo cp deploy/smartthings-windfree.service /etc/systemd/system/ +sudo chmod 600 /etc/smartthings-windfree/config.toml /etc/smartthings-windfree/secrets +sudo systemctl daemon-reload +sudo systemctl enable --now smartthings-windfree +``` + +Egyszeri próba: `smartthings-windfree-poller --config config.toml --once`. + +## Mezők és események + +A SmartThings `GET /v1/devices/{deviceId}/status` válaszában minden érték a +`components...` útvonalon található. Minden +`[[fields]]` blokk egy kimeneti mezőt ír le. Az opcionális `equals` logikai +értékké alakítja az összehasonlítást; ez alkalmas például a `windFree` módra. +A Samsung modellek capability-kiosztása eltérhet, ezért először ellenőrizd a +saját eszköz teljes status-válaszát, majd igazítsd a TOML-t. + +Az esemény `info/ok`, ha minden konfigurált mező megvan. Ha akár egy hiányzik, +`warning/no_data`, és a `missing_fields` felsorolja őket. SmartThings HTTP- vagy +API-hibánál `error/error`, az `error.api_response` pedig változtatás nélkül +tartalmazza a dekódolt API-választ. A Redpanda Connect elérhetetlensége helyben +naplózott hiba; nincs lemezspool, így a daemon kicsi és egyszerű marad. + +## Redpanda Connect és teszt + +A `redpanda-connect/windfree-ingest.yaml` validál, ingest időt ad hozzá, az +eszközazonosítót Kafka-kulcsként használja, majd az eseményt a +`smartthings.device-status.v1` topicba írja. A broker, topic és TLS environment +változókkal állítható. Ha a klaszter SASL-t kér, egészítsd ki az outputot a +telepítésedhez tartozó Redpanda Connect `sasl` blokkal; az alapkonfiguráció nem +kényszerít üres felhasználóneves hitelesítést. + +```bash +python3.11 -m unittest discover -s tests -v +``` + +A Jenkins pipeline ugyanezeket a teszteket futtatja Python 3.11-en. Ha a +repositoryban még nincs unit teszt, a teszt stage sikeresen, kihagyottként fut +le. Sikeres ellenőrzés után rootless BuildKit készít ARM64 container image-et, +majd commit hash és `latest` taggel feltölti ide: + +```text +repo.attilabito.com/apps/windfree-poller +``` + +A `Jenkinsfile.deploy` egy külön, kizárólag kézzel indítható job definíciója. +A célgép előre engedélyezett listából választható; jelenleg csak a +`raspberry-pi-4b` szerepel benne. A Raspberry Pi-n a Dockernek, a runtime +konfigurációnak és a `/var/lib/smartthings-windfree` útvonalnak a telepítés +előtt rendelkezésre kell állnia. diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..265c1d5 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,47 @@ +[poller] +collector_id = "pi-zero-living-room" +device_id = "00000000-0000-0000-0000-000000000000" +interval_seconds = 300 +request_timeout_seconds = 20 + +[smartthings] +base_url = "https://api.smartthings.com/v1" +# For production, set these via SMARTTHINGS_* environment variables. +client_id = "" +client_secret = "" +refresh_token = "" +token_file = "/var/lib/smartthings-windfree/token.json" +# Testing only; current PATs expire after 24 hours: +access_token = "" +access_token_file = "" + +[redpanda_connect] +url = "http://127.0.0.1:4195/windfree" +timeout_seconds = 15 + +[[fields]] +name = "temperature" +component = "main" +capability = "temperatureMeasurement" +attribute = "temperature" + +[[fields]] +name = "humidity" +component = "main" +capability = "relativeHumidityMeasurement" +attribute = "humidity" + +[[fields]] +name = "mode" +component = "main" +capability = "airConditionerMode" +attribute = "airConditionerMode" + +# Samsung models expose WindFree differently. Inspect the complete device status +# and adjust this mapping. Missing mapped fields produce warning/no_data. +[[fields]] +name = "windfree" +component = "main" +capability = "airConditionerFanMode" +attribute = "fanMode" +equals = "windFree" diff --git a/deploy/smartthings-windfree-oauth.service b/deploy/smartthings-windfree-oauth.service new file mode 100644 index 0000000..b9479cd --- /dev/null +++ b/deploy/smartthings-windfree-oauth.service @@ -0,0 +1,26 @@ +[Unit] +Description=SmartThings WindFree OAuth linking helper +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=smartthings-poller +Group=smartthings-poller +EnvironmentFile=/etc/smartthings-windfree/oauth-secrets +ExecStart=/opt/smartthings-windfree/venv/bin/smartthings-windfree-oauth --config /etc/smartthings-windfree/oauth.toml +Restart=on-failure +RestartSec=10 +StateDirectory=smartthings-windfree +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/smartthings-windfree.service b/deploy/smartthings-windfree.service new file mode 100644 index 0000000..2d2c655 --- /dev/null +++ b/deploy/smartthings-windfree.service @@ -0,0 +1,22 @@ +[Unit] +Description=SmartThings WindFree status poller +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=smartthings-poller +Group=smartthings-poller +EnvironmentFile=-/etc/smartthings-windfree/secrets +EnvironmentFile=-/etc/smartthings-windfree/oauth-secrets +ExecStart=/opt/smartthings-windfree/venv/bin/smartthings-windfree-poller --config /etc/smartthings-windfree/config.toml +Restart=on-failure +RestartSec=15 +StateDirectory=smartthings-windfree +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/oauth.example.toml b/oauth.example.toml new file mode 100644 index 0000000..fbb522a --- /dev/null +++ b/oauth.example.toml @@ -0,0 +1,19 @@ +[server] +listen = "0.0.0.0" +port = 8088 +public_url = "https://smartthings-oauth.attilabito.com" + +[keycloak] +issuer = "https://sso.attilabito.com/realms/homelab" +client_id = "smartthings-oauth-bridge" +required_group = "smartthings-admin" +redirect_uri = "https://smartthings-oauth.attilabito.com/oauth/keycloak/callback" + +[smartthings] +client_id = "" +redirect_uri = "https://smartthings-oauth.attilabito.com/oauth/smartthings/callback" +scopes = ["r:devices:$"] + +[storage] +token_file = "/var/lib/smartthings-windfree/token.json" + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fd68aa3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "smartthings-windfree-poller" +version = "1.0.0" +description = "Small SmartThings device status poller for Python 3.11" +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +smartthings-windfree-poller = "windfree_poller.main:main" +smartthings-windfree-oauth = "windfree_poller.oauth_server:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/redpanda-connect/windfree-ingest.yaml b/redpanda-connect/windfree-ingest.yaml new file mode 100644 index 0000000..7720d65 --- /dev/null +++ b/redpanda-connect/windfree-ingest.yaml @@ -0,0 +1,38 @@ +input: + http_server: + path: /windfree + allowed_verbs: [POST] + timeout: 10s + sync_response: + status: "202" + headers: + Content-Type: application/json + +pipeline: + processors: + - mapping: | + root = if this.type() != "object" { + throw("request body must be a JSON object") + } else if this.schema_version != 1 || this.event_type != "smartthings.device_status" { + throw("unsupported event schema") + } else if !["info", "warning", "error"].contains(this.level) { + throw("invalid level") + } else if !["ok", "no_data", "error"].contains(this.status) { + throw("invalid status") + } else if this.device_id.type() != "string" || this.observed_at.type() != "string" { + throw("device_id and observed_at are required") + } else { + this + } + - mutation: | + root.ingested_at = now() + root.ingest_source = "redpanda-connect" + meta kafka_key = this.device_id + +output: + redpanda: + seed_brokers: [ "${KAFKA_BROKERS:127.0.0.1:9092}" ] + topic: "${KAFKA_TOPIC:smartthings.device-status.v1}" + key: '${! meta("kafka_key") }' + tls: + enabled: ${KAFKA_TLS_ENABLED:false} diff --git a/src/windfree_poller/__init__.py b/src/windfree_poller/__init__.py new file mode 100644 index 0000000..7f29f36 --- /dev/null +++ b/src/windfree_poller/__init__.py @@ -0,0 +1,2 @@ +"""SmartThings WindFree poller.""" + diff --git a/src/windfree_poller/app.py b/src/windfree_poller/app.py new file mode 100644 index 0000000..ffa6d9b --- /dev/null +++ b/src/windfree_poller/app.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import json +import logging +import time +import urllib.request + +from .config import Config +from .events import error_event, status_event +from .http import HttpFailure, request_json +from .smartthings import SmartThingsClient, TokenManager + +LOG = logging.getLogger(__name__) + + +class Poller: + def __init__(self, config: Config): + self.config = config + tokens = TokenManager(config.client_id, config.client_secret, config.refresh_token, + config.access_token, config.token_file, config.request_timeout_seconds) + self.client = SmartThingsClient(config.api_base_url, config.device_id, tokens, config.request_timeout_seconds) + + def poll_once(self) -> dict: + try: + event = status_event(self.config, self.client.get_status()) + except Exception as error: + event = error_event(self.config, error) + self.publish(event) + return event + + def publish(self, event: dict) -> None: + data = json.dumps(event, ensure_ascii=False, separators=(",", ":")).encode() + request = urllib.request.Request(self.config.redpanda_url, data=data, method="POST", + headers={"Content-Type": "application/json", "Accept": "application/json"}) + try: + request_json(request, self.config.redpanda_timeout_seconds) + except HttpFailure: + LOG.exception("Could not publish event to Redpanda Connect") + raise + + def run(self) -> None: + while True: + started = time.monotonic() + try: + event = self.poll_once() + LOG.log(logging.WARNING if event["level"] != "info" else logging.INFO, + "Poll result: %s", event["status"]) + except Exception: + LOG.exception("Poll cycle failed") + time.sleep(max(0, self.config.interval_seconds - (time.monotonic() - started))) + diff --git a/src/windfree_poller/config.py b/src/windfree_poller/config.py new file mode 100644 index 0000000..5d23114 --- /dev/null +++ b/src/windfree_poller/config.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import os +import json +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass(frozen=True) +class Field: + name: str + component: str + capability: str + attribute: str + equals: Any | None = None + + +@dataclass(frozen=True) +class Config: + collector_id: str + device_id: str + interval_seconds: int + request_timeout_seconds: float + api_base_url: str + client_id: str + client_secret: str + refresh_token: str + access_token: str + token_file: Path + redpanda_url: str + redpanda_timeout_seconds: float + fields: tuple[Field, ...] + + +def _secret(data: dict[str, Any], key: str) -> str: + env_name = f"SMARTTHINGS_{key.upper()}" + return os.environ.get(env_name, str(data.get(key, ""))).strip() + + +def _access_token(data: dict[str, Any]) -> str: + token = _secret(data, "access_token") + token_path = os.environ.get( + "SMARTTHINGS_ACCESS_TOKEN_FILE", + str(data.get("access_token_file", "")), + ).strip() + if token: + return token + if not token_path: + return "" + try: + return Path(token_path).read_text(encoding="utf-8").strip() + except OSError as error: + raise ValueError(f"cannot read SmartThings access token file: {token_path}") from error + + +def _has_saved_oauth_tokens(path: Path) -> bool: + try: + saved = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + return False + return bool(saved.get("access_token") and saved.get("refresh_token")) + + +def load_config(path: str | Path) -> Config: + with Path(path).open("rb") as handle: + raw = tomllib.load(handle) + + poller = raw["poller"] + smartthings = raw["smartthings"] + redpanda = raw["redpanda_connect"] + fields = tuple(Field(**item) for item in raw.get("fields", [])) + if not fields: + raise ValueError("at least one [[fields]] entry is required") + + config = Config( + collector_id=str(poller["collector_id"]), + device_id=str(poller["device_id"]), + interval_seconds=int(poller.get("interval_seconds", 300)), + request_timeout_seconds=float(poller.get("request_timeout_seconds", 20)), + api_base_url=str(smartthings.get("base_url", "https://api.smartthings.com/v1")).rstrip("/"), + client_id=_secret(smartthings, "client_id"), + client_secret=_secret(smartthings, "client_secret"), + refresh_token=_secret(smartthings, "refresh_token"), + access_token=_access_token(smartthings), + token_file=Path(smartthings.get("token_file", "/var/lib/smartthings-windfree/token.json")), + redpanda_url=str(redpanda["url"]), + redpanda_timeout_seconds=float(redpanda.get("timeout_seconds", 15)), + fields=fields, + ) + if config.interval_seconds < 1: + raise ValueError("interval_seconds must be positive") + configured_refresh = config.client_id and config.client_secret and config.refresh_token + saved_refresh = config.client_id and config.client_secret and _has_saved_oauth_tokens(config.token_file) + if not config.access_token and not configured_refresh and not saved_refresh: + raise ValueError("configure access_token, or client_id + client_secret + refresh_token") + return config diff --git a/src/windfree_poller/events.py b/src/windfree_poller/events.py new file mode 100644 index 0000000..10743e5 --- /dev/null +++ b/src/windfree_poller/events.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from .config import Config, Field + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def extract(status: dict[str, Any], field: Field) -> tuple[bool, Any, str | None, str | None]: + try: + item = status["components"][field.component][field.capability][field.attribute] + value = item["value"] + except (KeyError, TypeError): + return False, None, None, None + if value is None: + return False, None, item.get("unit"), item.get("timestamp") + if field.equals is not None: + value = value == field.equals + return True, value, item.get("unit"), item.get("timestamp") + + +def status_event(config: Config, status: dict[str, Any]) -> dict[str, Any]: + measurements: dict[str, Any] = {} + missing: list[str] = [] + for field in config.fields: + found, value, unit, updated_at = extract(status, field) + if not found: + missing.append(field.name) + continue + measurements[field.name] = {"value": value, "unit": unit, "updated_at": updated_at} + + no_data = bool(missing) + return base_event(config) | { + "level": "warning" if no_data else "info", + "status": "no_data" if no_data else "ok", + "measurements": measurements, + "missing_fields": missing, + } + + +def error_event(config: Config, error: Exception) -> dict[str, Any]: + return base_event(config) | { + "level": "error", "status": "error", "measurements": {}, + "error": {"message": str(error), "http_status": getattr(error, "status", None), "api_response": getattr(error, "body", None)}, + } + + +def base_event(config: Config) -> dict[str, Any]: + return {"schema_version": 1, "event_type": "smartthings.device_status", "collector_id": config.collector_id, + "device_id": config.device_id, "observed_at": now_iso()} + diff --git a/src/windfree_poller/http.py b/src/windfree_poller/http.py new file mode 100644 index 0000000..faee270 --- /dev/null +++ b/src/windfree_poller/http.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class HttpResult: + status: int + body: Any + + +class HttpFailure(Exception): + def __init__(self, message: str, status: int | None = None, body: Any = None): + super().__init__(message) + self.status = status + self.body = body + + +def request_json(request: urllib.request.Request, timeout: float) -> HttpResult: + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + content = response.read().decode("utf-8") + return HttpResult(response.status, json.loads(content) if content else None) + except urllib.error.HTTPError as error: + content = error.read().decode("utf-8", errors="replace") + try: + body = json.loads(content) if content else None + except json.JSONDecodeError: + body = content + raise HttpFailure(f"HTTP {error.code}", error.code, body) from error + except (urllib.error.URLError, TimeoutError, OSError) as error: + raise HttpFailure(str(error)) from error + diff --git a/src/windfree_poller/main.py b/src/windfree_poller/main.py new file mode 100644 index 0000000..c1af30e --- /dev/null +++ b/src/windfree_poller/main.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import argparse +import logging + +from .app import Poller +from .config import load_config + + +def main() -> None: + parser = argparse.ArgumentParser(description="Poll one SmartThings device") + parser.add_argument("--config", default="/etc/smartthings-windfree/config.toml") + parser.add_argument("--once", action="store_true") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + poller = Poller(load_config(args.config)) + poller.poll_once() if args.once else poller.run() + + +if __name__ == "__main__": + main() + diff --git a/src/windfree_poller/oauth_config.py b/src/windfree_poller/oauth_config.py new file mode 100644 index 0000000..15ead0c --- /dev/null +++ b/src/windfree_poller/oauth_config.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import os +import tomllib +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class OAuthConfig: + listen: str + port: int + public_url: str + keycloak_issuer: str + keycloak_client_id: str + keycloak_client_secret: str + required_group: str + keycloak_redirect_uri: str + smartthings_client_id: str + smartthings_client_secret: str + smartthings_redirect_uri: str + smartthings_scopes: tuple[str, ...] + session_secret: str + token_file: Path + + +def load_oauth_config(path: str | Path) -> OAuthConfig: + with Path(path).open("rb") as handle: + raw = tomllib.load(handle) + server = raw["server"] + keycloak = raw["keycloak"] + smartthings = raw["smartthings"] + config = OAuthConfig( + listen=str(server.get("listen", "127.0.0.1")), + port=int(server.get("port", 8088)), + public_url=str(server["public_url"]).rstrip("/"), + keycloak_issuer=str(keycloak["issuer"]).rstrip("/"), + keycloak_client_id=str(keycloak["client_id"]), + keycloak_client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET", "").strip(), + required_group=str(keycloak.get("required_group", "smartthings-admin")).lstrip("/"), + keycloak_redirect_uri=str(keycloak["redirect_uri"]), + smartthings_client_id=str(smartthings["client_id"]), + smartthings_client_secret=os.environ.get("SMARTTHINGS_CLIENT_SECRET", "").strip(), + smartthings_redirect_uri=str(smartthings["redirect_uri"]), + smartthings_scopes=tuple(str(item) for item in smartthings.get("scopes", ["r:devices:$"])), + session_secret=os.environ.get("OAUTH_SESSION_SECRET", "").strip(), + token_file=Path(raw["storage"]["token_file"]), + ) + missing = [] + for name, value in ( + ("KEYCLOAK_CLIENT_SECRET", config.keycloak_client_secret), + ("SMARTTHINGS_CLIENT_SECRET", config.smartthings_client_secret), + ("OAUTH_SESSION_SECRET", config.session_secret), + ("smartthings.client_id", config.smartthings_client_id), + ): + if not value: + missing.append(name) + if missing: + raise ValueError("missing OAuth configuration: " + ", ".join(missing)) + if len(config.session_secret) < 32: + raise ValueError("OAUTH_SESSION_SECRET must contain at least 32 characters") + if not 1 <= config.port <= 65535: + raise ValueError("server.port is invalid") + return config + diff --git a/src/windfree_poller/oauth_server.py b/src/windfree_poller/oauth_server.py new file mode 100644 index 0000000..5588934 --- /dev/null +++ b/src/windfree_poller/oauth_server.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import html +import json +import logging +import os +import secrets +import time +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from http import HTTPStatus +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +from .http import HttpFailure, request_json +from .oauth_config import OAuthConfig, load_oauth_config + +LOG = logging.getLogger(__name__) +SESSION_TTL_SECONDS = 3600 + + +@dataclass +class Session: + expires_at: float + username: str | None = None + groups: tuple[str, ...] = () + keycloak_state: str | None = None + smartthings_state: str | None = None + + +@dataclass +class State: + config: OAuthConfig + sessions: dict[str, Session] = field(default_factory=dict) + + def new_session(self) -> tuple[str, Session]: + self.cleanup() + session_id = secrets.token_urlsafe(32) + session = Session(time.time() + SESSION_TTL_SECONDS) + self.sessions[session_id] = session + return session_id, session + + def cleanup(self) -> None: + now = time.time() + for session_id in [key for key, value in self.sessions.items() if value.expires_at < now]: + self.sessions.pop(session_id, None) + + def cookie_value(self, session_id: str) -> str: + signature = hmac.new(self.config.session_secret.encode(), session_id.encode(), hashlib.sha256).hexdigest() + return f"{session_id}.{signature}" + + def verify_cookie(self, value: str) -> tuple[str, Session] | None: + try: + session_id, supplied = value.rsplit(".", 1) + except ValueError: + return None + expected = hmac.new(self.config.session_secret.encode(), session_id.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(supplied, expected): + return None + session = self.sessions.get(session_id) + if session is None or session.expires_at < time.time(): + self.sessions.pop(session_id, None) + return None + return session_id, session + + +class OAuthServer(ThreadingHTTPServer): + def __init__(self, address: tuple[str, int], state: State): + self.state = state + super().__init__(address, OAuthHandler) + + +class OAuthHandler(BaseHTTPRequestHandler): + server: OAuthServer + + def do_GET(self) -> None: + parsed = urllib.parse.urlsplit(self.path) + routes = { + "/": self.home, + "/health/live": self.health, + "/login": self.login, + "/logout": self.logout, + "/oauth/keycloak/callback": self.keycloak_callback, + "/connect": self.connect_smartthings, + "/oauth/smartthings/callback": self.smartthings_callback, + "/status": self.status, + } + handler = routes.get(parsed.path) + if handler is None: + self.send_error(HTTPStatus.NOT_FOUND) + return + try: + handler(urllib.parse.parse_qs(parsed.query)) + except (HttpFailure, ValueError, KeyError) as error: + LOG.exception("OAuth request failed on %s", parsed.path) + self.page("Request failed", f"

{html.escape(str(error))}

", HTTPStatus.BAD_REQUEST) + + def log_message(self, message: str, *args: Any) -> None: + LOG.info("%s - %s", self.client_address[0], message % args) + + @property + def config(self) -> OAuthConfig: + return self.server.state.config + + def current_session(self) -> tuple[str, Session] | None: + cookie = SimpleCookie(self.headers.get("Cookie", "")) + item = cookie.get("windfree_oauth_session") + return self.server.state.verify_cookie(item.value) if item else None + + def require_admin(self) -> tuple[str, Session] | None: + current = self.current_session() + if current is None or self.config.required_group not in current[1].groups: + self.redirect("/login") + return None + return current + + def set_session_cookie(self, session_id: str) -> None: + value = self.server.state.cookie_value(session_id) + self.send_header( + "Set-Cookie", + f"windfree_oauth_session={value}; Path=/; Max-Age={SESSION_TTL_SECONDS}; Secure; HttpOnly; SameSite=Lax", + ) + + def redirect(self, location: str, session_id: str | None = None) -> None: + self.send_response(HTTPStatus.FOUND) + if session_id: + self.set_session_cookie(session_id) + self.send_header("Location", location) + self.send_header("Cache-Control", "no-store") + self.end_headers() + + def page(self, title: str, body: str, status: HTTPStatus = HTTPStatus.OK) -> None: + content = ( + "" + f"{html.escape(title)}" + f"

{html.escape(title)}

{body}" + ).encode() + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(content))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(content) + + def health(self, _query: dict[str, list[str]]) -> None: + body = b'{"status":"ok"}' + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def home(self, _query: dict[str, list[str]]) -> None: + current = self.current_session() + if current is None: + self.page("WindFree OAuth", "

Sign in with Keycloak

") + return + session = current[1] + connected = token_metadata(self.config.token_file).get("connected", False) + body = ( + f"

Signed in as {html.escape(session.username or 'unknown')}.

" + f"

SmartThings connected: {str(connected).lower()}

" + "

Connect SmartThings

" + "

Status · Logout

" + ) + self.page("WindFree OAuth", body) + + def login(self, _query: dict[str, list[str]]) -> None: + current = self.current_session() + if current is None: + session_id, session = self.server.state.new_session() + else: + session_id, session = current + session.keycloak_state = secrets.token_urlsafe(32) + params = { + "client_id": self.config.keycloak_client_id, + "response_type": "code", + "scope": "openid profile email", + "redirect_uri": self.config.keycloak_redirect_uri, + "state": session.keycloak_state, + } + url = self.config.keycloak_issuer + "/protocol/openid-connect/auth?" + urllib.parse.urlencode(params) + self.redirect(url, session_id) + + def keycloak_callback(self, query: dict[str, list[str]]) -> None: + current = self.current_session() + if current is None: + raise ValueError("login session is missing or expired") + session_id, session = current + if not session.keycloak_state or not hmac.compare_digest(single(query, "state"), session.keycloak_state): + raise ValueError("invalid Keycloak state") + session.keycloak_state = None + token = post_form( + self.config.keycloak_issuer + "/protocol/openid-connect/token", + { + "grant_type": "authorization_code", + "code": single(query, "code"), + "client_id": self.config.keycloak_client_id, + "client_secret": self.config.keycloak_client_secret, + "redirect_uri": self.config.keycloak_redirect_uri, + }, + ) + user = bearer_json( + self.config.keycloak_issuer + "/protocol/openid-connect/userinfo", + token["access_token"], + ) + groups = tuple(str(group).lstrip("/") for group in user.get("groups", [])) + if self.config.required_group not in groups: + raise ValueError("Keycloak user is not in the required group") + session.username = str(user.get("preferred_username", user.get("sub", "unknown"))) + session.groups = groups + session.expires_at = time.time() + SESSION_TTL_SECONDS + self.redirect("/", session_id) + + def connect_smartthings(self, _query: dict[str, list[str]]) -> None: + current = self.require_admin() + if current is None: + return + session_id, session = current + session.smartthings_state = secrets.token_urlsafe(32) + params = { + "client_id": self.config.smartthings_client_id, + "scope": " ".join(self.config.smartthings_scopes), + "response_type": "code", + "redirect_uri": self.config.smartthings_redirect_uri, + "state": session.smartthings_state, + } + self.redirect("https://api.smartthings.com/v1/oauth/authorize?" + urllib.parse.urlencode(params), session_id) + + def smartthings_callback(self, query: dict[str, list[str]]) -> None: + current = self.current_session() + if current is None: + raise ValueError("linking session is missing or expired") + session_id, session = current + if self.config.required_group not in session.groups: + raise ValueError("administrator login is required") + if not session.smartthings_state or not hmac.compare_digest( + single(query, "state"), session.smartthings_state + ): + raise ValueError("invalid SmartThings state") + session.smartthings_state = None + if "error" in query: + raise ValueError("SmartThings authorization failed: " + single(query, "error")) + token = post_form( + "https://api.smartthings.com/v1/oauth/token", + { + "grant_type": "authorization_code", + "code": single(query, "code"), + "client_id": self.config.smartthings_client_id, + "redirect_uri": self.config.smartthings_redirect_uri, + }, + basic=(self.config.smartthings_client_id, self.config.smartthings_client_secret), + ) + save_tokens(self.config.token_file, token) + self.redirect("/status", session_id) + + def status(self, _query: dict[str, list[str]]) -> None: + if self.require_admin() is None: + return + body = json.dumps(token_metadata(self.config.token_file), separators=(",", ":")).encode() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def logout(self, _query: dict[str, list[str]]) -> None: + current = self.current_session() + if current: + self.server.state.sessions.pop(current[0], None) + self.send_response(HTTPStatus.FOUND) + self.send_header("Set-Cookie", "windfree_oauth_session=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax") + self.send_header("Location", "/") + self.end_headers() + + +def single(query: dict[str, list[str]], key: str) -> str: + values = query.get(key, []) + if len(values) != 1 or not values[0]: + raise ValueError(f"missing or repeated query parameter: {key}") + return values[0] + + +def post_form(url: str, form: dict[str, str], basic: tuple[str, str] | None = None) -> dict[str, Any]: + headers = {"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"} + if basic: + encoded = base64.b64encode(f"{basic[0]}:{basic[1]}".encode()).decode() + headers["Authorization"] = f"Basic {encoded}" + request = urllib.request.Request(url, data=urllib.parse.urlencode(form).encode(), method="POST", headers=headers) + result = request_json(request, 20).body + if not isinstance(result, dict): + raise HttpFailure("OAuth token response is not a JSON object", body=result) + return result + + +def bearer_json(url: str, token: str) -> dict[str, Any]: + request = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}) + result = request_json(request, 20).body + if not isinstance(result, dict): + raise HttpFailure("userinfo response is not a JSON object", body=result) + return result + + +def save_tokens(path: Path, token: dict[str, Any]) -> None: + if not token.get("access_token") or not token.get("refresh_token"): + raise ValueError("SmartThings response does not contain both tokens") + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp") + document = { + "access_token": token["access_token"], + "refresh_token": token["refresh_token"], + "expires_at": time.time() + int(token.get("expires_in", 86399)), + "installed_app_id": token.get("installed_app_id"), + "scope": token.get("scope"), + } + temporary.write_text(json.dumps(document, separators=(",", ":")), encoding="utf-8") + os.chmod(temporary, 0o600) + temporary.replace(path) + + +def token_metadata(path: Path) -> dict[str, Any]: + try: + token = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + return {"connected": False} + return { + "connected": bool(token.get("access_token") and token.get("refresh_token")), + "expires_at": token.get("expires_at"), + "installed_app_id": token.get("installed_app_id"), + "scope": token.get("scope"), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="WindFree OAuth linking helper") + parser.add_argument("--config", default="/etc/smartthings-windfree/oauth.toml") + parser.add_argument("--check-config", action="store_true") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + config = load_oauth_config(args.config) + if args.check_config: + return + server = OAuthServer((config.listen, config.port), State(config)) + LOG.info("OAuth helper listening on %s:%d", config.listen, config.port) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/src/windfree_poller/smartthings.py b/src/windfree_poller/smartthings.py new file mode 100644 index 0000000..c3c2784 --- /dev/null +++ b/src/windfree_poller/smartthings.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import base64 +import json +import os +import time +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +from .http import HttpFailure, request_json + + +class TokenManager: + def __init__(self, client_id: str, client_secret: str, refresh_token: str, + access_token: str, token_file: Path, timeout: float): + self.client_id = client_id + self.client_secret = client_secret + self.refresh_token = refresh_token + self.access_token = access_token + self.token_file = token_file + self.timeout = timeout + self.expires_at = 0.0 + self._load() + + def _load(self) -> None: + try: + saved = json.loads(self.token_file.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + return + self.access_token = saved.get("access_token", self.access_token) + self.refresh_token = saved.get("refresh_token", self.refresh_token) + self.expires_at = float(saved.get("expires_at", 0)) + + def token(self, force_refresh: bool = False) -> str: + can_refresh = bool(self.client_id and self.client_secret and self.refresh_token) + if not force_refresh and self.access_token and (not can_refresh or time.time() < self.expires_at - 300): + return self.access_token + if not can_refresh: + return self.access_token + self._refresh() + return self.access_token + + def _refresh(self) -> None: + form = urllib.parse.urlencode({ + "grant_type": "refresh_token", + "refresh_token": self.refresh_token, + "client_id": self.client_id, + }).encode() + basic = base64.b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode() + request = urllib.request.Request( + "https://api.smartthings.com/v1/oauth/token", data=form, method="POST", + headers={"Authorization": f"Basic {basic}", "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + ) + result = request_json(request, self.timeout).body + self.access_token = result["access_token"] + self.refresh_token = result.get("refresh_token", self.refresh_token) + self.expires_at = time.time() + int(result.get("expires_in", 86399)) + self._save() + + def _save(self) -> None: + self.token_file.parent.mkdir(parents=True, exist_ok=True) + temporary = self.token_file.with_suffix(".tmp") + temporary.write_text(json.dumps({ + "access_token": self.access_token, + "refresh_token": self.refresh_token, + "expires_at": self.expires_at, + }), encoding="utf-8") + os.chmod(temporary, 0o600) + temporary.replace(self.token_file) + + +class SmartThingsClient: + def __init__(self, base_url: str, device_id: str, tokens: TokenManager, timeout: float): + self.url = f"{base_url}/devices/{urllib.parse.quote(device_id, safe='')}/status" + self.tokens = tokens + self.timeout = timeout + + def get_status(self) -> dict[str, Any]: + try: + return self._get(self.tokens.token()) + except HttpFailure as error: + if error.status != 401 or not self.tokens.refresh_token: + raise + return self._get(self.tokens.token(force_refresh=True)) + + def _get(self, token: str) -> dict[str, Any]: + request = urllib.request.Request(self.url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}) + body = request_json(request, self.timeout).body + if not isinstance(body, dict): + raise HttpFailure("SmartThings response is not a JSON object", body=body) + return body + diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..aa0f5db --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,80 @@ +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from windfree_poller.config import load_config + + +class ConfigTests(unittest.TestCase): + def test_environment_secrets_override_toml(self): + text = ''' +[poller] +collector_id="pi" +device_id="id" +[smartthings] +access_token="file-token" +[redpanda_connect] +url="http://localhost/windfree" +[[fields]] +name="temperature" +component="main" +capability="temperatureMeasurement" +attribute="temperature" +''' + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "config.toml" + path.write_text(text, encoding="utf-8") + with patch.dict(os.environ, {"SMARTTHINGS_ACCESS_TOKEN": "env-token"}): + self.assertEqual(load_config(path).access_token, "env-token") + + def test_access_token_can_be_read_from_file(self): + with tempfile.TemporaryDirectory() as folder: + token_path = Path(folder) / "token" + token_path.write_text("file-token\n", encoding="utf-8") + config_path = Path(folder) / "config.toml" + config_path.write_text(f''' +[poller] +collector_id="pi" +device_id="id" +[smartthings] +access_token_file="{token_path.as_posix()}" +[redpanda_connect] +url="http://localhost/windfree" +[[fields]] +name="temperature" +component="main" +capability="temperatureMeasurement" +attribute="temperature" +''', encoding="utf-8") + self.assertEqual(load_config(config_path).access_token, "file-token") + + def test_saved_oauth_token_state_is_accepted(self): + with tempfile.TemporaryDirectory() as folder: + token_path = Path(folder) / "token.json" + token_path.write_text('{"access_token":"access","refresh_token":"refresh"}', encoding="utf-8") + config_path = Path(folder) / "config.toml" + config_path.write_text(f''' +[poller] +collector_id="pi" +device_id="id" +[smartthings] +client_id="client" +client_secret="secret" +token_file="{token_path.as_posix()}" +[redpanda_connect] +url="http://localhost/windfree" +[[fields]] +name="temperature" +component="main" +capability="temperatureMeasurement" +attribute="temperature" +''', encoding="utf-8") + loaded = load_config(config_path) + self.assertEqual(loaded.client_id, "client") + self.assertEqual(loaded.access_token, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..78e2076 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,38 @@ +import unittest +from pathlib import Path + +from windfree_poller.config import Config, Field +from windfree_poller.events import extract, status_event + + +def config(*fields): + return Config("pi", "device", 300, 10, "https://example/v1", "", "", "", "token", Path("token.json"), "http://localhost", 10, fields) + + +class EventTests(unittest.TestCase): + def setUp(self): + self.status = {"components": {"main": { + "temperatureMeasurement": {"temperature": {"value": 23.5, "unit": "C", "timestamp": "2026-01-01T00:00:00Z"}}, + "airConditionerFanMode": {"fanMode": {"value": "windFree"}}, + }}} + + def test_extract_and_equals_conversion(self): + found, value, _, _ = extract(self.status, Field("windfree", "main", "airConditionerFanMode", "fanMode", "windFree")) + self.assertTrue(found) + self.assertIs(value, True) + + def test_missing_field_is_warning(self): + event = status_event(config(Field("temperature", "main", "temperatureMeasurement", "temperature"), + Field("humidity", "main", "relativeHumidityMeasurement", "humidity")), self.status) + self.assertEqual(event["status"], "no_data") + self.assertEqual(event["level"], "warning") + self.assertEqual(event["missing_fields"], ["humidity"]) + + def test_all_fields_is_ok(self): + event = status_event(config(Field("temperature", "main", "temperatureMeasurement", "temperature")), self.status) + self.assertEqual((event["level"], event["status"]), ("info", "ok")) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 0000000..3327d4c --- /dev/null +++ b/tests/test_oauth.py @@ -0,0 +1,52 @@ +import json +import os +import tempfile +import time +import unittest +from pathlib import Path + +from windfree_poller.oauth_server import State, save_tokens, token_metadata +from windfree_poller.oauth_config import OAuthConfig + + +def config(path: Path) -> OAuthConfig: + return OAuthConfig( + "127.0.0.1", 8088, "https://example.com", + "https://sso.example/realms/test", "client", "secret", "admin", + "https://example.com/oauth/keycloak/callback", "st-client", "st-secret", + "https://example.com/oauth/smartthings/callback", ("r:devices:$",), + "x" * 32, path, + ) + + +class OAuthTests(unittest.TestCase): + def test_signed_session_cookie_cannot_be_modified(self): + with tempfile.TemporaryDirectory() as folder: + state = State(config(Path(folder) / "token.json")) + session_id, session = state.new_session() + self.assertIs(state.verify_cookie(state.cookie_value(session_id))[1], session) + self.assertIsNone(state.verify_cookie(state.cookie_value(session_id) + "changed")) + + def test_tokens_are_saved_atomically_without_exposing_them_in_metadata(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "token.json" + save_tokens(path, { + "access_token": "access", "refresh_token": "refresh", + "expires_in": 60, "installed_app_id": "installed", "scope": "r:devices:$", + }) + saved = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(saved["refresh_token"], "refresh") + self.assertGreater(saved["expires_at"], time.time()) + metadata = token_metadata(path) + self.assertTrue(metadata["connected"]) + self.assertNotIn("access_token", metadata) + self.assertNotIn("refresh_token", metadata) + if os.name != "nt": + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + + def test_missing_token_file_is_disconnected(self): + self.assertEqual(token_metadata(Path("missing-token-file")), {"connected": False}) + + +if __name__ == "__main__": + unittest.main()