better authenticarion
This commit is contained in:
+10
-4
@@ -1,12 +1,19 @@
|
|||||||
# Registered Netatmo application: GetNetatmoData v2
|
# Registered Netatmo application: GetNetatmoData v2
|
||||||
NETATMO_CLIENT_ID=67d5431a2b6d32c9ba066152
|
# Mount every route below this path. Leave empty to serve from /.
|
||||||
NETATMO_CLIENT_SECRET=hT10jvt6vQs1V7lKicFr7LDIbv8
|
URL_PREFIX=/w
|
||||||
|
|
||||||
|
NETATMO_CLIENT_ID=6a93db3d7cdb0e40ea0c2655
|
||||||
|
NETATMO_CLIENT_SECRET=c6uRbAzZ9e8STPtXas6PYKaZZiD68Y6QHFaKq2GEIa8
|
||||||
NETATMO_REFRESH_TOKEN=
|
NETATMO_REFRESH_TOKEN=
|
||||||
|
# Rotated OAuth tokens are persisted here with mode 0600.
|
||||||
|
NETATMO_TOKEN_FILE=/var/lib/rrd/netatmo_tokens.json
|
||||||
|
# Must exactly match a redirect URI configured for the Netatmo application.
|
||||||
|
NETATMO_REDIRECT_URI=http://www.suy.nl
|
||||||
# Alternatively, useful for a short-lived test (refresh credentials are preferred):
|
# Alternatively, useful for a short-lived test (refresh credentials are preferred):
|
||||||
# NETATMO_ACCESS_TOKEN=
|
# NETATMO_ACCESS_TOKEN=
|
||||||
# NETATMO_DEVICE_ID=
|
# NETATMO_DEVICE_ID=
|
||||||
|
|
||||||
RRD_FOLDER=./rrd
|
RRD_FOLDER=/var/lib/rrd
|
||||||
POLL_INTERVAL=600
|
POLL_INTERVAL=600
|
||||||
START_COLLECTOR=true
|
START_COLLECTOR=true
|
||||||
|
|
||||||
@@ -16,4 +23,3 @@ MODULE_WIND=Wind
|
|||||||
MODULE_BEDROOM=Bedroom
|
MODULE_BEDROOM=Bedroom
|
||||||
MODULE_STUDY=Study
|
MODULE_STUDY=Study
|
||||||
MODULE_LIVING=Living
|
MODULE_LIVING=Living
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,4 +3,4 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.env
|
.env
|
||||||
rrd/
|
rrd/
|
||||||
|
netatmo_tokens.json
|
||||||
|
|||||||
@@ -30,9 +30,26 @@ cp .env.example .env
|
|||||||
```
|
```
|
||||||
|
|
||||||
Add the client ID and secret for the Netatmo application **GetNetatmoData v2**
|
Add the client ID and secret for the Netatmo application **GetNetatmoData v2**
|
||||||
and a refresh token to `.env`. Netatmo's OAuth authorization step must be used to
|
to `.env`. Ensure `NETATMO_REDIRECT_URI` exactly matches a redirect URI configured
|
||||||
obtain the initial refresh token with the `read_station` scope. Secrets are read
|
for that application, then perform the initial `read_station` authorization:
|
||||||
from environment variables and are never stored in an RRD.
|
|
||||||
|
```sh
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
.venv/bin/python scripts/get_netatmo_token.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper opens Netatmo's consent page. After approval, paste the complete URL
|
||||||
|
from the browser's address bar back into the prompt. This works even if the local
|
||||||
|
callback page itself cannot be reached. The script exchanges the authorization
|
||||||
|
code and writes the token file with mode `0600`.
|
||||||
|
|
||||||
|
The service loads `NETATMO_TOKEN_FILE` at startup. Before an access token expires,
|
||||||
|
it refreshes it and atomically saves both the new access token and any rotated
|
||||||
|
refresh token. Values in the token file take precedence over initial token values
|
||||||
|
in `.env`. Client credentials remain environment-only and are never written to
|
||||||
|
the token file or an RRD.
|
||||||
|
|
||||||
Export the file and run Flask:
|
Export the file and run Flask:
|
||||||
|
|
||||||
@@ -53,6 +70,9 @@ scheduler runs inside the service process. Alternatively set
|
|||||||
|
|
||||||
`RRD_FOLDER` configures the database directory (default `./rrd`). The module
|
`RRD_FOLDER` configures the database directory (default `./rrd`). The module
|
||||||
variables in `.env.example` allow the five Netatmo display names to be changed.
|
variables in `.env.example` allow the five Netatmo display names to be changed.
|
||||||
|
Set `URL_PREFIX=/w` to mount the dashboard, static assets, and every API endpoint
|
||||||
|
below `/w`; leave it empty to serve from the site root. When a prefix is set,
|
||||||
|
open <http://localhost:5000/w/> instead.
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
|
|||||||
+29
-2
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Flask, Response, abort, jsonify, render_template
|
from flask import Flask, Response, abort, jsonify, render_template, url_for
|
||||||
|
|
||||||
from .collector import Collector
|
from .collector import Collector
|
||||||
from .config import Config
|
from .config import Config
|
||||||
@@ -11,12 +11,34 @@ from .netatmo import NetatmoClient
|
|||||||
from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS
|
from .rrd import ALIASES, PERIODS, RRD_ERROR, RRDStore, SCHEMAS
|
||||||
|
|
||||||
|
|
||||||
|
class PrefixMiddleware:
|
||||||
|
"""Mount a WSGI application below a fixed URL path."""
|
||||||
|
|
||||||
|
def __init__(self, application, prefix: str):
|
||||||
|
self.application = application
|
||||||
|
self.prefix = prefix
|
||||||
|
|
||||||
|
def __call__(self, environ, start_response):
|
||||||
|
path = environ.get("PATH_INFO", "")
|
||||||
|
if path == self.prefix or path.startswith(f"{self.prefix}/"):
|
||||||
|
environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME", "") + self.prefix
|
||||||
|
environ["PATH_INFO"] = path[len(self.prefix):] or "/"
|
||||||
|
return self.application(environ, start_response)
|
||||||
|
start_response("404 Not Found", [("Content-Type", "text/plain; charset=utf-8")])
|
||||||
|
return [b"Not Found\n"]
|
||||||
|
|
||||||
|
|
||||||
def create_app(test_config: dict | None = None) -> Flask:
|
def create_app(test_config: dict | None = None) -> Flask:
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
if test_config:
|
if test_config:
|
||||||
app.config.update(test_config)
|
app.config.update(test_config)
|
||||||
|
|
||||||
|
prefix = app.config["URL_PREFIX"]
|
||||||
|
app.config["APPLICATION_ROOT"] = prefix or "/"
|
||||||
|
if prefix:
|
||||||
|
app.wsgi_app = PrefixMiddleware(app.wsgi_app, prefix)
|
||||||
|
|
||||||
logging.basicConfig(level=app.config.get("LOG_LEVEL", "INFO"))
|
logging.basicConfig(level=app.config.get("LOG_LEVEL", "INFO"))
|
||||||
store = app.config.get("RRD_STORE") or RRDStore(
|
store = app.config.get("RRD_STORE") or RRDStore(
|
||||||
app.config["RRD_FOLDER"], app.config["GRAPH_WIDTH"], app.config["GRAPH_HEIGHT"]
|
app.config["RRD_FOLDER"], app.config["GRAPH_WIDTH"], app.config["GRAPH_HEIGHT"]
|
||||||
@@ -37,7 +59,12 @@ def create_app(test_config: dict | None = None) -> Flask:
|
|||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def dashboard():
|
def dashboard():
|
||||||
return render_template("dashboard.html", rrd_names=list(SCHEMAS), periods=list(PERIODS))
|
return render_template(
|
||||||
|
"dashboard.html",
|
||||||
|
rrd_names=list(SCHEMAS),
|
||||||
|
periods=list(PERIODS),
|
||||||
|
graph_url_template=url_for("graph", rrd_name="__name__", period="__period__"),
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
|
|||||||
@@ -9,8 +9,19 @@ def _bool(name: str, default: bool) -> bool:
|
|||||||
return default if value is None else value.lower() in {"1", "true", "yes", "on"}
|
return default if value is None else value.lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix(value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value or value == "/":
|
||||||
|
return ""
|
||||||
|
return "/" + value.strip("/")
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
URL_PREFIX = _prefix(os.getenv("URL_PREFIX", ""))
|
||||||
RRD_FOLDER = Path(os.getenv("RRD_FOLDER", "./rrd")).expanduser().resolve()
|
RRD_FOLDER = Path(os.getenv("RRD_FOLDER", "./rrd")).expanduser().resolve()
|
||||||
|
NETATMO_TOKEN_FILE = Path(
|
||||||
|
os.getenv("NETATMO_TOKEN_FILE", str(RRD_FOLDER / "netatmo_tokens.json"))
|
||||||
|
).expanduser().resolve()
|
||||||
GRAPH_WIDTH = int(os.getenv("GRAPH_WIDTH", "900"))
|
GRAPH_WIDTH = int(os.getenv("GRAPH_WIDTH", "900"))
|
||||||
GRAPH_HEIGHT = int(os.getenv("GRAPH_HEIGHT", "240"))
|
GRAPH_HEIGHT = int(os.getenv("GRAPH_HEIGHT", "240"))
|
||||||
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "600"))
|
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "600"))
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -14,6 +17,43 @@ class NetatmoError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TokenStore:
|
||||||
|
"""Persist OAuth tokens atomically in a file readable only by its owner."""
|
||||||
|
|
||||||
|
def __init__(self, path: Path | str):
|
||||||
|
self.path = Path(path)
|
||||||
|
|
||||||
|
def load(self) -> dict:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with self.path.open(encoding="utf-8") as token_file:
|
||||||
|
data = json.load(token_file)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise NetatmoError(f"Cannot read token file {self.path}: {exc}") from exc
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
def save(self, tokens: dict) -> None:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
|
prefix=f".{self.path.name}.", dir=self.path.parent
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.fchmod(descriptor, 0o600)
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8") as token_file:
|
||||||
|
json.dump(tokens, token_file, indent=2, sort_keys=True)
|
||||||
|
token_file.write("\n")
|
||||||
|
token_file.flush()
|
||||||
|
os.fsync(token_file.fileno())
|
||||||
|
os.replace(temporary_name, self.path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
os.unlink(temporary_name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class NetatmoClient:
|
class NetatmoClient:
|
||||||
"""Small dependency-free Netatmo OAuth and Weather API client."""
|
"""Small dependency-free Netatmo OAuth and Weather API client."""
|
||||||
|
|
||||||
@@ -22,10 +62,14 @@ class NetatmoClient:
|
|||||||
self.client_secret = config["NETATMO_CLIENT_SECRET"]
|
self.client_secret = config["NETATMO_CLIENT_SECRET"]
|
||||||
self.refresh_token = config["NETATMO_REFRESH_TOKEN"]
|
self.refresh_token = config["NETATMO_REFRESH_TOKEN"]
|
||||||
self.access_token = config["NETATMO_ACCESS_TOKEN"]
|
self.access_token = config["NETATMO_ACCESS_TOKEN"]
|
||||||
|
self.token_store = TokenStore(config["NETATMO_TOKEN_FILE"])
|
||||||
self.device_id = config["NETATMO_DEVICE_ID"]
|
self.device_id = config["NETATMO_DEVICE_ID"]
|
||||||
self.token_url = config["NETATMO_TOKEN_URL"]
|
self.token_url = config["NETATMO_TOKEN_URL"]
|
||||||
self.stations_url = config["NETATMO_STATIONS_URL"]
|
self.stations_url = config["NETATMO_STATIONS_URL"]
|
||||||
self._expires_at = 0.0
|
stored = self.token_store.load()
|
||||||
|
self.access_token = stored.get("access_token", self.access_token)
|
||||||
|
self.refresh_token = stored.get("refresh_token", self.refresh_token)
|
||||||
|
self._expires_at = float(stored.get("expires_at", 0))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
@@ -49,10 +93,37 @@ class NetatmoClient:
|
|||||||
}
|
}
|
||||||
).encode()
|
).encode()
|
||||||
result = self._request(urllib.request.Request(self.token_url, data=data, method="POST"))
|
result = self._request(urllib.request.Request(self.token_url, data=data, method="POST"))
|
||||||
|
self._accept_tokens(result)
|
||||||
|
|
||||||
|
def exchange_code(self, code: str, redirect_uri: str) -> dict:
|
||||||
|
"""Exchange a first-time OAuth authorization code and persist its tokens."""
|
||||||
|
data = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
result = self._request(urllib.request.Request(self.token_url, data=data, method="POST"))
|
||||||
|
self._accept_tokens(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _accept_tokens(self, result: dict) -> None:
|
||||||
|
if "access_token" not in result:
|
||||||
|
raise NetatmoError("Netatmo token response contains no access token")
|
||||||
self.access_token = result["access_token"]
|
self.access_token = result["access_token"]
|
||||||
# Netatmo may rotate refresh tokens; retain the new value for this process.
|
|
||||||
self.refresh_token = result.get("refresh_token", self.refresh_token)
|
self.refresh_token = result.get("refresh_token", self.refresh_token)
|
||||||
self._expires_at = time.time() + int(result.get("expires_in", 10800)) - 60
|
self._expires_at = time.time() + int(result.get("expires_in", 10800)) - 60
|
||||||
|
persisted = {
|
||||||
|
"access_token": self.access_token,
|
||||||
|
"refresh_token": self.refresh_token,
|
||||||
|
"expires_at": self._expires_at,
|
||||||
|
}
|
||||||
|
if result.get("scope"):
|
||||||
|
persisted["scope"] = result["scope"]
|
||||||
|
self.token_store.save(persisted)
|
||||||
|
|
||||||
def stations_data(self) -> dict:
|
def stations_data(self) -> dict:
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
@@ -74,4 +145,3 @@ class NetatmoClient:
|
|||||||
self._refresh()
|
self._refresh()
|
||||||
request.headers["Authorization"] = f"Bearer {self.access_token}"
|
request.headers["Authorization"] = f"Bearer {self.access_token}"
|
||||||
return self._request(request)
|
return self._request(request)
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,17 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
|
const graphUrlTemplate = {{ graph_url_template|tojson }};
|
||||||
const buttons = document.querySelectorAll('button[data-period]');
|
const buttons = document.querySelectorAll('button[data-period]');
|
||||||
buttons.forEach(button => button.addEventListener('click', () => {
|
buttons.forEach(button => button.addEventListener('click', () => {
|
||||||
buttons.forEach(item => item.classList.toggle('active', item === button));
|
buttons.forEach(item => item.classList.toggle('active', item === button));
|
||||||
document.querySelectorAll('img[data-name]').forEach(image => {
|
document.querySelectorAll('img[data-name]').forEach(image => {
|
||||||
image.src = `/graph/${image.dataset.name}/${button.dataset.period}?t=${Date.now()}`;
|
const graphUrl = graphUrlTemplate
|
||||||
|
.replace('__name__', encodeURIComponent(image.dataset.name))
|
||||||
|
.replace('__period__', encodeURIComponent(button.dataset.period));
|
||||||
|
image.src = `${graphUrl}?t=${Date.now()}`;
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Perform Netatmo's initial OAuth authorization-code exchange."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Allow direct execution from a source checkout without installing the package.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from netatmo_service.netatmo import NetatmoClient, NetatmoError
|
||||||
|
|
||||||
|
AUTHORIZE_URL = "https://api.netatmo.com/oauth2/authorize"
|
||||||
|
|
||||||
|
|
||||||
|
def arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--client-id", default=os.getenv("NETATMO_CLIENT_ID"))
|
||||||
|
parser.add_argument("--client-secret", default=os.getenv("NETATMO_CLIENT_SECRET"))
|
||||||
|
parser.add_argument(
|
||||||
|
"--redirect-uri", default=os.getenv("NETATMO_REDIRECT_URI", "http://localhost:8080")
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--token-file",
|
||||||
|
type=Path,
|
||||||
|
default=Path(os.getenv("NETATMO_TOKEN_FILE", "./rrd/netatmo_tokens.json")),
|
||||||
|
)
|
||||||
|
parser.add_argument("--no-browser", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def authorization_code(value: str, expected_state: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if "://" not in value:
|
||||||
|
return value
|
||||||
|
query = urllib.parse.parse_qs(urllib.parse.urlparse(value).query)
|
||||||
|
if query.get("state", [None])[0] != expected_state:
|
||||||
|
raise ValueError("The callback state does not match; restart authorization")
|
||||||
|
if "error" in query:
|
||||||
|
raise ValueError(f"Authorization failed: {query['error'][0]}")
|
||||||
|
if not query.get("code"):
|
||||||
|
raise ValueError("The callback URL contains no authorization code")
|
||||||
|
return query["code"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = arguments()
|
||||||
|
if not args.client_id or not args.client_secret:
|
||||||
|
print("Set NETATMO_CLIENT_ID and NETATMO_CLIENT_SECRET first.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
state = secrets.token_urlsafe(24)
|
||||||
|
url = f"{AUTHORIZE_URL}?" + urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"client_id": args.client_id,
|
||||||
|
"redirect_uri": args.redirect_uri,
|
||||||
|
"scope": "read_station",
|
||||||
|
"state": state,
|
||||||
|
"response_type": "code",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print("\nOpen this URL and approve access:\n")
|
||||||
|
print(url)
|
||||||
|
if not args.no_browser:
|
||||||
|
webbrowser.open(url)
|
||||||
|
print("\nAfter redirect, paste the complete callback URL (or just its code).")
|
||||||
|
|
||||||
|
try:
|
||||||
|
code = authorization_code(input("> "), state)
|
||||||
|
config = {
|
||||||
|
"NETATMO_CLIENT_ID": args.client_id,
|
||||||
|
"NETATMO_CLIENT_SECRET": args.client_secret,
|
||||||
|
"NETATMO_REFRESH_TOKEN": "",
|
||||||
|
"NETATMO_ACCESS_TOKEN": "",
|
||||||
|
"NETATMO_TOKEN_FILE": args.token_file.expanduser().resolve(),
|
||||||
|
"NETATMO_DEVICE_ID": "",
|
||||||
|
"NETATMO_TOKEN_URL": "https://api.netatmo.com/oauth2/token",
|
||||||
|
"NETATMO_STATIONS_URL": "https://api.netatmo.com/api/getstationsdata",
|
||||||
|
}
|
||||||
|
client = NetatmoClient(config)
|
||||||
|
client.exchange_code(code, args.redirect_uri)
|
||||||
|
except (EOFError, ValueError, NetatmoError) as exc:
|
||||||
|
print(f"Token setup failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Tokens saved to {args.token_file} with owner-only permissions.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from netatmo_service import create_app
|
from netatmo_service import create_app
|
||||||
from netatmo_service.data import extract_samples
|
from netatmo_service.data import extract_samples
|
||||||
|
from netatmo_service.netatmo import NetatmoClient, TokenStore
|
||||||
|
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
@@ -28,6 +29,24 @@ def test_routes():
|
|||||||
assert client.get("/graph/windyear").status_code == 200
|
assert client.get("/graph/windyear").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_url_prefix_mounts_all_routes():
|
||||||
|
prefixed = create_app({
|
||||||
|
"TESTING": True,
|
||||||
|
"START_COLLECTOR": False,
|
||||||
|
"URL_PREFIX": "/w",
|
||||||
|
"RRD_STORE": FakeStore(),
|
||||||
|
"NETATMO_CLIENT": FakeClient(),
|
||||||
|
}).test_client()
|
||||||
|
response = prefixed.get("/w/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b'/w/static/dashboard.css' in response.data
|
||||||
|
assert b'/w/graph/outdoor/day' in response.data
|
||||||
|
assert prefixed.get("/w/health").status_code == 200
|
||||||
|
assert prefixed.get("/w/last/outdoor/humidity").status_code == 200
|
||||||
|
assert prefixed.get("/w/graph/wind/year").status_code == 200
|
||||||
|
assert prefixed.get("/").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_extract_sample_payload():
|
def test_extract_sample_payload():
|
||||||
namespace = {}
|
namespace = {}
|
||||||
exec(Path("payload_dict.py").read_text(), namespace)
|
exec(Path("payload_dict.py").read_text(), namespace)
|
||||||
@@ -36,3 +55,33 @@ def test_extract_sample_payload():
|
|||||||
assert samples["outdoor"].values["pressure"] == 1004.8
|
assert samples["outdoor"].values["pressure"] == 1004.8
|
||||||
assert samples["wind"].values["angle"] == 236.0
|
assert samples["wind"].values["angle"] == 236.0
|
||||||
assert "bedroom" not in samples # unreachable module has no dashboard_data
|
assert "bedroom" not in samples # unreachable module has no dashboard_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotated_refresh_token_is_persisted(tmp_path):
|
||||||
|
token_file = tmp_path / "tokens.json"
|
||||||
|
config = {
|
||||||
|
"NETATMO_CLIENT_ID": "client",
|
||||||
|
"NETATMO_CLIENT_SECRET": "secret",
|
||||||
|
"NETATMO_REFRESH_TOKEN": "old-refresh",
|
||||||
|
"NETATMO_ACCESS_TOKEN": "",
|
||||||
|
"NETATMO_TOKEN_FILE": token_file,
|
||||||
|
"NETATMO_DEVICE_ID": "",
|
||||||
|
"NETATMO_TOKEN_URL": "https://example.test/token",
|
||||||
|
"NETATMO_STATIONS_URL": "https://example.test/stations",
|
||||||
|
}
|
||||||
|
client = NetatmoClient(config)
|
||||||
|
client._request = lambda request: {
|
||||||
|
"access_token": "new-access",
|
||||||
|
"refresh_token": "new-refresh",
|
||||||
|
"expires_in": 3600,
|
||||||
|
}
|
||||||
|
client._refresh()
|
||||||
|
|
||||||
|
stored = TokenStore(token_file).load()
|
||||||
|
assert stored["access_token"] == "new-access"
|
||||||
|
assert stored["refresh_token"] == "new-refresh"
|
||||||
|
assert token_file.stat().st_mode & 0o777 == 0o600
|
||||||
|
|
||||||
|
restarted = NetatmoClient(config)
|
||||||
|
assert restarted.access_token == "new-access"
|
||||||
|
assert restarted.refresh_token == "new-refresh"
|
||||||
|
|||||||
Reference in New Issue
Block a user