added rain, updated graph styles
This commit is contained in:
@@ -5,15 +5,19 @@ the readings in RRDtool databases, and serves a responsive graph dashboard.
|
|||||||
|
|
||||||
## What it stores
|
## What it stores
|
||||||
|
|
||||||
The service creates `netatmo_outdoor.rrd`, `netatmo_wind.rrd`,
|
The service creates `netatmo_outdoor.rrd`, `netatmo_pressure.rrd`,
|
||||||
|
`netatmo_wind.rrd`, `netatmo_rain.rrd`,
|
||||||
`netatmo_bedroom.rrd`, `netatmo_study.rrd`, and `netatmo_living.rrd`. Each has
|
`netatmo_bedroom.rrd`, `netatmo_study.rrd`, and `netatmo_living.rrd`. Each has
|
||||||
ten-minute archives for the last two weeks and six-hour archives covering a
|
ten-minute archives for the last two weeks and six-hour archives covering a
|
||||||
year. Day, two-week, and year PNG graphs are rendered on demand. The wind graph
|
year. Day, two-week, and year PNG graphs are rendered on demand. The wind graph
|
||||||
splits average wind into 12 colored direction bands and draws gusts as a line.
|
splits average wind into 12 colored direction bands and draws gusts as a line.
|
||||||
|
The rain graph stores Netatmo's cumulative `sum_rain_24` reading and shows the
|
||||||
|
rain accumulated since midnight in millimetres. Its daily reset is preserved;
|
||||||
|
MAX consolidation retains daily totals in the longer-term archive.
|
||||||
|
|
||||||
The Outdoor module does not contain a pressure sensor, so `outdoor.rrd` combines
|
The Outdoor module does not contain a pressure sensor, so the dedicated pressure
|
||||||
its temperature/humidity with pressure from the main station. A module without
|
graph reads it from the main station. Its Y axis is fixed at 900–1100 mbar. A
|
||||||
`dashboard_data` is skipped until it becomes reachable again.
|
module without `dashboard_data` is skipped until it becomes reachable again.
|
||||||
|
|
||||||
## Prerequisites and setup
|
## Prerequisites and setup
|
||||||
|
|
||||||
@@ -71,7 +75,7 @@ scheduler runs inside the service process. Alternatively set
|
|||||||
```
|
```
|
||||||
|
|
||||||
`storage.rrd_folder` configures the database directory (default `./rrd`). The
|
`storage.rrd_folder` configures the database directory (default `./rrd`). The
|
||||||
`modules` section allows the five Netatmo display names to be changed.
|
`modules` section allows the Netatmo display names to be changed.
|
||||||
Set `server.url_prefix: /w` to mount the dashboard, static assets, and every API endpoint
|
Set `server.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,
|
below `/w`; leave it empty to serve from the site root. When a prefix is set,
|
||||||
open <http://localhost:5000/w/> instead.
|
open <http://localhost:5000/w/> instead.
|
||||||
@@ -140,7 +144,9 @@ curl --output wind.png http://localhost:5000/graph/wind/2weeks
|
|||||||
Data points are:
|
Data points are:
|
||||||
|
|
||||||
- `outdoor`: `min_temp`, `max_temp`, `humidity`, `pressure`
|
- `outdoor`: `min_temp`, `max_temp`, `humidity`, `pressure`
|
||||||
|
- `pressure`: `pressure`
|
||||||
- `wind`: `gust`, `average`, `angle`
|
- `wind`: `gust`, `average`, `angle`
|
||||||
|
- `rain`: `sum_rain_24`
|
||||||
- `bedroom`, `study`, `living`: `temperature`, `co2`, `humidity`
|
- `bedroom`, `study`, `living`: `temperature`, `co2`, `humidity`
|
||||||
|
|
||||||
The service creates missing RRDs at startup but deliberately does not alter an
|
The service creates missing RRDs at startup but deliberately does not alter an
|
||||||
|
|||||||
+2
-1
@@ -21,7 +21,7 @@ collector:
|
|||||||
|
|
||||||
graphs:
|
graphs:
|
||||||
width: 900
|
width: 900
|
||||||
height: 240
|
height: 420
|
||||||
|
|
||||||
netatmo:
|
netatmo:
|
||||||
client_id: REPLACE_ME
|
client_id: REPLACE_ME
|
||||||
@@ -37,6 +37,7 @@ netatmo:
|
|||||||
modules:
|
modules:
|
||||||
outdoor: Outdoor
|
outdoor: Outdoor
|
||||||
wind: Wind
|
wind: Wind
|
||||||
|
rain: Rain
|
||||||
bedroom: Bedroom
|
bedroom: Bedroom
|
||||||
study: Study
|
study: Study
|
||||||
living: Living
|
living: Living
|
||||||
|
|||||||
@@ -103,7 +103,13 @@ def create_app(test_config: dict | None = None, config_path=None) -> Flask:
|
|||||||
)
|
)
|
||||||
store.ensure_all()
|
store.ensure_all()
|
||||||
client = app.config.get("NETATMO_CLIENT") or NetatmoClient(app.config)
|
client = app.config.get("NETATMO_CLIENT") or NetatmoClient(app.config)
|
||||||
names = {key: app.config[f"MODULE_{key.upper()}"] for key in SCHEMAS}
|
# Pressure is read directly from the main station and has no configurable
|
||||||
|
# module display name.
|
||||||
|
names = {
|
||||||
|
key: app.config[f"MODULE_{key.upper()}"]
|
||||||
|
for key in SCHEMAS
|
||||||
|
if key != "pressure"
|
||||||
|
}
|
||||||
collector = Collector(client, store, names, app.config["POLL_INTERVAL"])
|
collector = Collector(client, store, names, app.config["POLL_INTERVAL"])
|
||||||
app.extensions["rrd_store"] = store
|
app.extensions["rrd_store"] = store
|
||||||
app.extensions["netatmo_collector"] = collector
|
app.extensions["netatmo_collector"] = collector
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ def default_config(base: Path | None = None) -> dict[str, Any]:
|
|||||||
"LOG_MAX_BYTES": 5242880, "LOG_BACKUP_COUNT": 5, "LOG_CONSOLE": True,
|
"LOG_MAX_BYTES": 5242880, "LOG_BACKUP_COUNT": 5, "LOG_CONSOLE": True,
|
||||||
"NETATMO_TOKEN_FILE": rrd / "netatmo_tokens.json",
|
"NETATMO_TOKEN_FILE": rrd / "netatmo_tokens.json",
|
||||||
"NETATMO_REDIRECT_URI": "http://localhost:5000/",
|
"NETATMO_REDIRECT_URI": "http://localhost:5000/",
|
||||||
"GRAPH_WIDTH": 900, "GRAPH_HEIGHT": 240, "POLL_INTERVAL": 600,
|
"GRAPH_WIDTH": 900, "GRAPH_HEIGHT": 420, "POLL_INTERVAL": 600,
|
||||||
"START_COLLECTOR": True, "NETATMO_CLIENT_ID": "", "NETATMO_CLIENT_SECRET": "",
|
"START_COLLECTOR": True, "NETATMO_CLIENT_ID": "", "NETATMO_CLIENT_SECRET": "",
|
||||||
"NETATMO_REFRESH_TOKEN": "", "NETATMO_ACCESS_TOKEN": "", "NETATMO_DEVICE_ID": "",
|
"NETATMO_REFRESH_TOKEN": "", "NETATMO_ACCESS_TOKEN": "", "NETATMO_DEVICE_ID": "",
|
||||||
"NETATMO_TOKEN_URL": "https://api.netatmo.com/oauth2/token",
|
"NETATMO_TOKEN_URL": "https://api.netatmo.com/oauth2/token",
|
||||||
"NETATMO_STATIONS_URL": "https://api.netatmo.com/api/getstationsdata",
|
"NETATMO_STATIONS_URL": "https://api.netatmo.com/api/getstationsdata",
|
||||||
"MODULE_OUTDOOR": "Outdoor", "MODULE_WIND": "Wind", "MODULE_BEDROOM": "Bedroom",
|
"MODULE_OUTDOOR": "Outdoor", "MODULE_WIND": "Wind", "MODULE_RAIN": "Rain", "MODULE_BEDROOM": "Bedroom",
|
||||||
"MODULE_STUDY": "Study", "MODULE_LIVING": "Living",
|
"MODULE_STUDY": "Study", "MODULE_LIVING": "Living",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +87,6 @@ def load_config(filename: str | Path | None = None) -> dict[str, Any]:
|
|||||||
result["NETATMO_TOKEN_FILE"] = _path(
|
result["NETATMO_TOKEN_FILE"] = _path(
|
||||||
netatmo.get("token_file", result["RRD_FOLDER"] / "netatmo_tokens.json"), base
|
netatmo.get("token_file", result["RRD_FOLDER"] / "netatmo_tokens.json"), base
|
||||||
)
|
)
|
||||||
for name in ("outdoor", "wind", "bedroom", "study", "living"):
|
for name in ("outdoor", "wind", "rain", "bedroom", "study", "living"):
|
||||||
result[f"MODULE_{name.upper()}"] = str(modules.get(name, result[f"MODULE_{name.upper()}"]))
|
result[f"MODULE_{name.upper()}"] = str(modules.get(name, result[f"MODULE_{name.upper()}"]))
|
||||||
return result
|
return result
|
||||||
|
|||||||
+13
-2
@@ -15,7 +15,7 @@ def _dashboard(item: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def extract_samples(payload: dict, names: dict[str, str]) -> dict[str, Sample]:
|
def extract_samples(payload: dict, names: dict[str, str]) -> dict[str, Sample]:
|
||||||
"""Translate a getstationsdata response into the service's five RRD samples."""
|
"""Translate a getstationsdata response into the service's RRD samples."""
|
||||||
devices = payload.get("body", {}).get("devices", [])
|
devices = payload.get("body", {}).get("devices", [])
|
||||||
if not devices:
|
if not devices:
|
||||||
raise ValueError("Netatmo response contains no weather station")
|
raise ValueError("Netatmo response contains no weather station")
|
||||||
@@ -39,6 +39,18 @@ def extract_samples(payload: dict, names: dict[str, str]) -> dict[str, Sample]:
|
|||||||
sample("study", {"temperature": "Temperature", "co2": "CO2", "humidity": "Humidity"})
|
sample("study", {"temperature": "Temperature", "co2": "CO2", "humidity": "Humidity"})
|
||||||
sample("living", {"temperature": "Temperature", "co2": "CO2", "humidity": "Humidity"})
|
sample("living", {"temperature": "Temperature", "co2": "CO2", "humidity": "Humidity"})
|
||||||
sample("wind", {"gust": "GustStrength", "average": "WindStrength", "angle": "WindAngle"})
|
sample("wind", {"gust": "GustStrength", "average": "WindStrength", "angle": "WindAngle"})
|
||||||
|
# Netatmo resets this cumulative millimetre total at local midnight. Store
|
||||||
|
# it unchanged so historical graphs show the total accumulated each day.
|
||||||
|
sample("rain", {"sum_rain_24": "sum_rain_24"})
|
||||||
|
|
||||||
|
# Pressure belongs to the main indoor station rather than a separate
|
||||||
|
# module, so give it its own sample and use that station's timestamp.
|
||||||
|
station_data = _dashboard(device)
|
||||||
|
if station_data:
|
||||||
|
result["pressure"] = Sample(
|
||||||
|
int(station_data["time_utc"]),
|
||||||
|
{"pressure": _number(station_data.get("Pressure"))},
|
||||||
|
)
|
||||||
|
|
||||||
outdoor = by_name.get(names["outdoor"])
|
outdoor = by_name.get(names["outdoor"])
|
||||||
if outdoor and _dashboard(outdoor):
|
if outdoor and _dashboard(outdoor):
|
||||||
@@ -62,4 +74,3 @@ def _number(value: Any) -> float | None:
|
|||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
return float(value)
|
return float(value)
|
||||||
|
|
||||||
|
|||||||
+63
-11
@@ -19,7 +19,9 @@ SAFE_NAME = re.compile(r"^[a-z][a-z0-9_]*$")
|
|||||||
|
|
||||||
SCHEMAS = {
|
SCHEMAS = {
|
||||||
"outdoor": {"min_temp": "GAUGE:-60:70", "max_temp": "GAUGE:-60:70", "humidity": "GAUGE:0:100", "pressure": "GAUGE:800:1200"},
|
"outdoor": {"min_temp": "GAUGE:-60:70", "max_temp": "GAUGE:-60:70", "humidity": "GAUGE:0:100", "pressure": "GAUGE:800:1200"},
|
||||||
|
"pressure": {"pressure": "GAUGE:800:1200"},
|
||||||
"wind": {"gust": "GAUGE:0:300", "average": "GAUGE:0:300", "angle": "GAUGE:0:360"},
|
"wind": {"gust": "GAUGE:0:300", "average": "GAUGE:0:300", "angle": "GAUGE:0:360"},
|
||||||
|
"rain": {"sum_rain_24": "GAUGE:0:1000"},
|
||||||
"bedroom": {"temperature": "GAUGE:-20:60", "co2": "GAUGE:0:10000", "humidity": "GAUGE:0:100"},
|
"bedroom": {"temperature": "GAUGE:-20:60", "co2": "GAUGE:0:10000", "humidity": "GAUGE:0:100"},
|
||||||
"study": {"temperature": "GAUGE:-20:60", "co2": "GAUGE:0:10000", "humidity": "GAUGE:0:100"},
|
"study": {"temperature": "GAUGE:-20:60", "co2": "GAUGE:0:10000", "humidity": "GAUGE:0:100"},
|
||||||
"living": {"temperature": "GAUGE:-20:60", "co2": "GAUGE:0:10000", "humidity": "GAUGE:0:100"},
|
"living": {"temperature": "GAUGE:-20:60", "co2": "GAUGE:0:10000", "humidity": "GAUGE:0:100"},
|
||||||
@@ -29,8 +31,18 @@ PERIODS = {"day": ("1 day", "-1d"), "2weeks": ("2 weeks", "-14d"), "year": ("1 y
|
|||||||
ALIASES = {"1day": "day", "14days": "2weeks", "2week": "2weeks", "1year": "year"}
|
ALIASES = {"1day": "day", "14days": "2weeks", "2week": "2weeks", "1year": "year"}
|
||||||
|
|
||||||
|
|
||||||
|
def rainbow(angle: float) -> tuple[int, int, int]:
|
||||||
|
"""Map a compass angle to an RGB rainbow color."""
|
||||||
|
hue = (angle - 180) / 360
|
||||||
|
hue %= 1.0
|
||||||
|
return tuple(
|
||||||
|
round(channel * 255)
|
||||||
|
for channel in colorsys.hsv_to_rgb(hue, 1.0, 1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RRDStore:
|
class RRDStore:
|
||||||
def __init__(self, folder: Path, width: int = 900, height: int = 240):
|
def __init__(self, folder: Path, width: int = 900, height: int = 420):
|
||||||
self.folder = Path(folder)
|
self.folder = Path(folder)
|
||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
@@ -95,15 +107,52 @@ class RRDStore:
|
|||||||
title, start = PERIODS[period]
|
title, start = PERIODS[period]
|
||||||
common = ["--imgformat", "PNG", "--start", start, "--end", "now",
|
common = ["--imgformat", "PNG", "--start", start, "--end", "now",
|
||||||
"--width", str(self.width), "--height", str(self.height), "--title", f"{name.title()} - {title}",
|
"--width", str(self.width), "--height", str(self.height), "--title", f"{name.title()} - {title}",
|
||||||
"--slope-mode", "--watermark", "GetNetatmoData v2"]
|
"--slope-mode", "--watermark", "GetNetatmoData v2",
|
||||||
definitions = [f"DEF:{field}={self.path(name)}:{field}:AVERAGE" for field in SCHEMAS[name]]
|
"--color", "BACK#F4F1E8", "--color", "CANVAS#FAF8F2",
|
||||||
|
"--color", "FONT#111111", "--color", "AXIS#111111",
|
||||||
|
"--color", "FRAME#111111", "--color", "ARROW#111111",
|
||||||
|
"--color", "GRID#D8D3C7", "--color", "MGRID#AAA397"]
|
||||||
|
if name == "pressure":
|
||||||
|
common += ["--lower-limit", "900", "--upper-limit", "1100", "--rigid",
|
||||||
|
"--vertical-label", "Pressure (mbar)", "--units-exponent", "0"]
|
||||||
|
elif name in {"outdoor", "bedroom", "study", "living"}:
|
||||||
|
common += ["--lower-limit", "0", "--upper-limit", "40", "--rigid",
|
||||||
|
"--vertical-label", "Temperature (C) / humidity (% / 2.5)"]
|
||||||
|
if name != "outdoor":
|
||||||
|
# CO2 is divided by 50 onto the 0-40 plotting range; the right
|
||||||
|
# axis converts those positions back to their 0-2000 ppm labels.
|
||||||
|
common += ["--right-axis", "50:0", "--right-axis-label", "CO2 (ppm)",
|
||||||
|
"--right-axis-format", "%.0lf",
|
||||||
|
"--y-grid", "4:1",
|
||||||
|
"--units-exponent", "0"]
|
||||||
|
# MAX retains each day's highest cumulative rain reading in the coarser
|
||||||
|
# archive, rather than averaging away the total when the counter resets.
|
||||||
|
consolidation = "MAX" if name == "rain" else "AVERAGE"
|
||||||
|
definitions = [f"DEF:{field}={self.path(name)}:{field}:{consolidation}" for field in SCHEMAS[name]]
|
||||||
|
if "humidity" in SCHEMAS[name]:
|
||||||
|
definitions.append("CDEF:humidity_scaled=humidity,2.5,/")
|
||||||
|
if "co2" in SCHEMAS[name]:
|
||||||
|
definitions.append("CDEF:co2_scaled=co2,50,/")
|
||||||
|
if name == "pressure":
|
||||||
|
definitions += ["CDEF:pressure_floor=pressure,UN,UNKN,900,IF",
|
||||||
|
"CDEF:pressure_above_floor=pressure,900,-"]
|
||||||
if name == "wind":
|
if name == "wind":
|
||||||
drawings = self._wind_drawings()
|
drawings = self._wind_drawings()
|
||||||
|
elif name == "rain":
|
||||||
|
drawings = ["AREA:sum_rain_24#3498DB80:Rain today (mm)",
|
||||||
|
"LINE2:sum_rain_24#2471A3:Rain today (mm)"]
|
||||||
|
elif name == "pressure":
|
||||||
|
drawings = ["AREA:pressure_floor#00000000",
|
||||||
|
"AREA:pressure_above_floor#77787280:Pressure (mbar):STACK",
|
||||||
|
"LINE1:pressure#111111"]
|
||||||
elif name == "outdoor":
|
elif name == "outdoor":
|
||||||
drawings = ["LINE2:min_temp#3488DB:Minimum temperature (C)", "LINE2:max_temp#E74C3C:Maximum temperature (C)",
|
drawings = ["LINE2:min_temp#3488DB:Minimum temperature (C)", "LINE2:max_temp#E74C3C:Maximum temperature (C)",
|
||||||
"LINE1:humidity#27AE60:Humidity (%)", "LINE1:pressure#8E44AD:Pressure (hPa):dashes"]
|
"LINE1:humidity_scaled#27AE60:Humidity (% / 2.5)"]
|
||||||
else:
|
else:
|
||||||
drawings = ["LINE2:temperature#E74C3C:Temperature (C)", "LINE1:co2#8E44AD:CO2 (ppm)", "LINE1:humidity#27AE60:Humidity (%):dashes"]
|
drawings = ["AREA:temperature#E74C3C70:Temperature (C)",
|
||||||
|
"LINE1:temperature#A93226",
|
||||||
|
"LINE2:co2_scaled#8E44AD:CO2 (ppm, right axis)",
|
||||||
|
"LINE2:humidity_scaled#27AE60:Humidity (% / 2.5):dashes"]
|
||||||
# The Python binding returns graph metadata, not the encoded image, so
|
# The Python binding returns graph metadata, not the encoded image, so
|
||||||
# render to an isolated temporary file and return its contents.
|
# render to an isolated temporary file and return its contents.
|
||||||
with tempfile.TemporaryDirectory(prefix="netatmo-graph-") as folder:
|
with tempfile.TemporaryDirectory(prefix="netatmo-graph-") as folder:
|
||||||
@@ -114,11 +163,14 @@ class RRDStore:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _wind_drawings() -> list[str]:
|
def _wind_drawings() -> list[str]:
|
||||||
result: list[str] = []
|
result: list[str] = []
|
||||||
# Split average wind into 12 angle bands. Each AREA starts at zero and
|
# Give every compass degree its own color. The AREA entries deliberately
|
||||||
# only exists for its direction, producing rainbow-colored bars/areas.
|
# have no labels, keeping the 360-color key out of the graph legend.
|
||||||
for index in range(12):
|
for angle in range(360):
|
||||||
low, high = index * 30, (index + 1) * 30
|
color = "#%02X%02X%02X80" % rainbow(angle)
|
||||||
color = "#%02X%02X%02X80" % tuple(round(channel * 255) for channel in colorsys.hsv_to_rgb(index / 12, .85, .9))
|
upper_comparison = "LE" if angle == 359 else "LT"
|
||||||
result += [f"CDEF:dir{index}=angle,{low},GE,angle,{high},LT,*,average,UNKN,IF", f"AREA:dir{index}{color}:{low:03d}-{high:03d} degrees"]
|
result += [
|
||||||
|
f"CDEF:dir{angle}=angle,{angle},GE,angle,{angle + 1},{upper_comparison},*,average,UNKN,IF",
|
||||||
|
f"AREA:dir{angle}{color}",
|
||||||
|
]
|
||||||
result += ["LINE2:gust#111111:Gust", "LINE1:average#555555:Average"]
|
result += ["LINE2:gust#111111:Gust", "LINE1:average#555555:Average"]
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
:root { color-scheme: dark; --ink:#eef3ec; --muted:#a7b3a8; --panel:#17221e; --accent:#b6e36f; }
|
:root { color-scheme: light; --ink:#111; --muted:#4f4c45; --panel:#faf8f2; --accent:#111; --paper:#f4f1e8; }
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
body { margin:0; padding:2rem; background:#0c1411; color:var(--ink); font:16px/1.5 system-ui,sans-serif; }
|
body { margin:0; padding:2rem; background:var(--paper); color:var(--ink); font:16px/1.5 system-ui,sans-serif; }
|
||||||
header { max-width:1200px; margin:auto auto 2rem; display:flex; align-items:end; justify-content:space-between; gap:1.5rem; }
|
header { max-width:1000px; margin:auto auto 2rem; display:flex; align-items:end; justify-content:space-between; gap:1.5rem; }
|
||||||
h1 { margin:.1rem 0 0; font-size:clamp(2rem,5vw,4rem); letter-spacing:-.05em; }
|
h1 { margin:.1rem 0 0; font-size:clamp(2rem,5vw,4rem); letter-spacing:-.05em; }
|
||||||
.eyebrow { color:var(--accent); text-transform:uppercase; letter-spacing:.15em; font-size:.75rem; }
|
.eyebrow { color:var(--muted); text-transform:uppercase; letter-spacing:.15em; font-size:.75rem; }
|
||||||
nav { display:flex; padding:.3rem; border:1px solid #304139; border-radius:999px; background:#111b17; }
|
nav { display:flex; padding:.3rem; border:1px solid #bdb7aa; border-radius:999px; background:#e9e5db; }
|
||||||
button { border:0; border-radius:999px; padding:.65rem 1rem; color:var(--muted); background:transparent; cursor:pointer; }
|
button { border:0; border-radius:999px; padding:.65rem 1rem; color:var(--muted); background:transparent; cursor:pointer; }
|
||||||
button.active { color:#172012; background:var(--accent); }
|
button.active { color:#f8f5ed; background:var(--accent); }
|
||||||
main { max-width:1200px; margin:auto; display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,480px),1fr)); gap:1rem; }
|
main { max-width:1000px; margin:auto; display:grid; grid-template-columns:minmax(0,1fr); gap:1rem; }
|
||||||
.card { overflow:hidden; padding:1rem; border:1px solid #26362f; border-radius:1rem; background:var(--panel); box-shadow:0 18px 45px #0004; }
|
.card { overflow:hidden; padding:1rem; border:1px solid #c9c3b7; border-radius:1rem; background:var(--panel); box-shadow:0 12px 30px #3d382b18; }
|
||||||
h2 { margin:0 0 .75rem; font-size:1rem; color:var(--muted); font-weight:600; }
|
h2 { margin:0 0 .75rem; font-size:1rem; color:var(--muted); font-weight:600; }
|
||||||
img { display:block; width:100%; min-height:180px; object-fit:contain; background:white; border-radius:.45rem; }
|
img { display:block; width:100%; height:auto; object-fit:contain; background:#faf8f2; border-radius:.45rem; }
|
||||||
@media (max-width:700px) { body{padding:1rem} header{align-items:start;flex-direction:column} nav{width:100%} button{flex:1} }
|
@media (max-width:700px) { body{padding:1rem} header{align-items:start;flex-direction:column} nav{width:100%} button{flex:1} }
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -1,11 +1,27 @@
|
|||||||
from netatmo_service.data import Sample
|
from netatmo_service.data import Sample
|
||||||
from netatmo_service.rrd import RRDStore
|
from netatmo_service.rrd import RRDStore, rainbow
|
||||||
|
|
||||||
|
|
||||||
|
def test_wind_rainbow_uses_compass_angle():
|
||||||
|
assert rainbow(0) == (0, 255, 255)
|
||||||
|
assert rainbow(90) == (128, 0, 255)
|
||||||
|
assert rainbow(180) == (255, 0, 0)
|
||||||
|
assert rainbow(270) == (128, 255, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_wind_angle_has_an_unlabelled_color():
|
||||||
|
drawings = RRDStore._wind_drawings()
|
||||||
|
assert len(drawings) == 722
|
||||||
|
assert sum(item.startswith("AREA:dir") for item in drawings) == 360
|
||||||
|
assert not any("degrees" in item for item in drawings)
|
||||||
|
|
||||||
|
|
||||||
def test_rrd_files_have_netatmo_prefix(tmp_path):
|
def test_rrd_files_have_netatmo_prefix(tmp_path):
|
||||||
store = RRDStore(tmp_path)
|
store = RRDStore(tmp_path)
|
||||||
assert store.path("outdoor") == tmp_path / "netatmo_outdoor.rrd"
|
assert store.path("outdoor") == tmp_path / "netatmo_outdoor.rrd"
|
||||||
assert store.path("wind") == tmp_path / "netatmo_wind.rrd"
|
assert store.path("wind") == tmp_path / "netatmo_wind.rrd"
|
||||||
|
assert store.path("rain") == tmp_path / "netatmo_rain.rrd"
|
||||||
|
assert store.path("pressure") == tmp_path / "netatmo_pressure.rrd"
|
||||||
|
|
||||||
|
|
||||||
def test_rrd_is_updated_only_once_per_collection_interval(tmp_path):
|
def test_rrd_is_updated_only_once_per_collection_interval(tmp_path):
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ def test_url_prefix_mounts_all_routes():
|
|||||||
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)
|
||||||
names = {name: name.title() for name in ("outdoor", "wind", "bedroom", "study", "living")}
|
names = {name: name.title() for name in ("outdoor", "wind", "rain", "bedroom", "study", "living")}
|
||||||
samples = extract_samples(namespace["payload"], names)
|
samples = extract_samples(namespace["payload"], names)
|
||||||
assert samples["outdoor"].values["pressure"] == 1004.8
|
assert samples["outdoor"].values["pressure"] == 1004.8
|
||||||
|
assert samples["pressure"].values["pressure"] == 1004.8
|
||||||
assert samples["wind"].values["angle"] == 236.0
|
assert samples["wind"].values["angle"] == 236.0
|
||||||
|
assert samples["rain"].values["sum_rain_24"] == 9.8
|
||||||
assert "bedroom" not in samples # unreachable module has no dashboard_data
|
assert "bedroom" not in samples # unreachable module has no dashboard_data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user