Windfree poller application & jenkins pipeline

This commit is contained in:
Bitó Attila
2026-08-15 23:40:58 +02:00
parent 41f76ae794
commit 9a633abc5c
24 changed files with 1290 additions and 0 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.gitignore
.venv
__pycache__
*.pyc
build
dist
tests
deploy
redpanda-connect
config.toml
oauth.toml
*.env
token.json

16
.gitignore vendored Normal file
View File

@@ -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

15
Dockerfile Normal file
View File

@@ -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"]

12
Jenkinsfile vendored Normal file
View File

@@ -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'
]
)

14
Jenkinsfile.deploy Normal file
View File

@@ -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'
]
)

View File

@@ -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.<component>.<capability>.<attribute>` ú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.

47
config.example.toml Normal file
View File

@@ -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"

View File

@@ -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

View File

@@ -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

19
oauth.example.toml Normal file
View File

@@ -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"

17
pyproject.toml Normal file
View File

@@ -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"]

View File

@@ -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}

View File

@@ -0,0 +1,2 @@
"""SmartThings WindFree poller."""

View File

@@ -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)))

View File

@@ -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

View File

@@ -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()}

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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"<p>{html.escape(str(error))}</p>", 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 = (
"<!doctype html><html><head><meta charset='utf-8'>"
f"<title>{html.escape(title)}</title></head><body>"
f"<h1>{html.escape(title)}</h1>{body}</body></html>"
).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", "<p><a href='/login'>Sign in with Keycloak</a></p>")
return
session = current[1]
connected = token_metadata(self.config.token_file).get("connected", False)
body = (
f"<p>Signed in as {html.escape(session.username or 'unknown')}.</p>"
f"<p>SmartThings connected: {str(connected).lower()}</p>"
"<p><a href='/connect'>Connect SmartThings</a></p>"
"<p><a href='/status'>Status</a> · <a href='/logout'>Logout</a></p>"
)
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()

View File

@@ -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

80
tests/test_config.py Normal file
View File

@@ -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()

38
tests/test_events.py Normal file
View File

@@ -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()

52
tests/test_oauth.py Normal file
View File

@@ -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()