Windfree poller application & jenkins pipeline
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
16
.gitignore
vendored
Normal 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
15
Dockerfile
Normal 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
12
Jenkinsfile
vendored
Normal 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
14
Jenkinsfile.deploy
Normal 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'
|
||||
]
|
||||
)
|
||||
98
README.md
98
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.<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
47
config.example.toml
Normal 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"
|
||||
26
deploy/smartthings-windfree-oauth.service
Normal file
26
deploy/smartthings-windfree-oauth.service
Normal 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
|
||||
|
||||
22
deploy/smartthings-windfree.service
Normal file
22
deploy/smartthings-windfree.service
Normal 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
19
oauth.example.toml
Normal 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
17
pyproject.toml
Normal 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"]
|
||||
38
redpanda-connect/windfree-ingest.yaml
Normal file
38
redpanda-connect/windfree-ingest.yaml
Normal 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}
|
||||
2
src/windfree_poller/__init__.py
Normal file
2
src/windfree_poller/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""SmartThings WindFree poller."""
|
||||
|
||||
51
src/windfree_poller/app.py
Normal file
51
src/windfree_poller/app.py
Normal 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)))
|
||||
|
||||
98
src/windfree_poller/config.py
Normal file
98
src/windfree_poller/config.py
Normal 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
|
||||
55
src/windfree_poller/events.py
Normal file
55
src/windfree_poller/events.py
Normal 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()}
|
||||
|
||||
37
src/windfree_poller/http.py
Normal file
37
src/windfree_poller/http.py
Normal 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
|
||||
|
||||
22
src/windfree_poller/main.py
Normal file
22
src/windfree_poller/main.py
Normal 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()
|
||||
|
||||
65
src/windfree_poller/oauth_config.py
Normal file
65
src/windfree_poller/oauth_config.py
Normal 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
|
||||
|
||||
358
src/windfree_poller/oauth_server.py
Normal file
358
src/windfree_poller/oauth_server.py
Normal 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()
|
||||
94
src/windfree_poller/smartthings.py
Normal file
94
src/windfree_poller/smartthings.py
Normal 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
80
tests/test_config.py
Normal 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
38
tests/test_events.py
Normal 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
52
tests/test_oauth.py
Normal 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()
|
||||
Reference in New Issue
Block a user