Beispiele
Beispiel: Python
Ein kleiner, vollständiger Python-3-Client für deine eigene Hardware, der sich koppelt, die Konfiguration aktuell hält, Messwerte in SQLite puffert und sie der Reihe nach hochlädt.
Dieser kleine Client schickt die Messwerte deiner eigenen Hardware an BoilerBuddy: Ersetze die Funktion, die deine Sensoren ausliest, und pass den Rest an, wo nötig. Er koppelt sich bei Bedarf selbst, hält die Konfiguration aktuell, puffert in SQLite und lädt der Reihe nach hoch.
Was du brauchst
- Python 3.10 oder neuer, mit
requests(pip install requests). - Ein Gerät im Portal mit einem Regelkreis. Siehe Dein eigenes BoilerBuddy bauen.
- Eine Uhr, die per NTP synchron gehalten wird (unter Linux:
timedatectl). Der Client verlässt sich darauf; siehe Puffern und die Uhr.
Starten
Mit einem Schlüssel aus dem Portal:
export BB_TOKEN=bb_live_42_...
python3 boilerbuddy_client.py
Ohne BB_TOKEN koppelt sich der Client selbst. Er zeigt einen Kopplungscode, den du auf der Geräteseite bei Mit Code koppeln eingibst, und speichert den Token in bb-state/token.
| Variable | Standard | Zweck |
|---|---|---|
BB_TOKEN |
(leer) | Der API-Schlüssel des Geräts |
BB_API_URL |
https://boilerbuddy.eu/api/v1 |
Die Basis-URL |
BB_STATE_DIR |
bb-state |
Ordner für Token, UUID, Konfiguration und Puffer |
Der Code
#!/usr/bin/env python3
"""Minimal BoilerBuddy device client: pair, fetch config, measure, buffer, upload.
An example to adapt, not finished device software. Python 3.10+ and requests.
Contract: https://boilerbuddy.eu/developers/openapi-v1.yaml"""
import json
import logging
import os
import random
import sqlite3
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
import requests
API = os.environ.get("BB_API_URL", "https://boilerbuddy.eu/api/v1").rstrip("/")
STATE = Path(os.environ.get("BB_STATE_DIR", "bb-state"))
FIRMWARE = "diy-python-0.1"
BATCH_SIZE = 500 # the API takes up to 1000 readings per batch
UPLOADS_PER_CYCLE = 8 # and 10 uploads a minute
CONFIG_EVERY_S, STATUS_EVERY_S = 900, 300
TIMEOUT = (5, 20) # connect, read
log = logging.getLogger("boilerbuddy")
def read_sensors(sensors: list[dict]) -> dict[str, float | None]:
"""Replace this stub. Return {sensor code: value} for the sensors of the config
that you can read, with None for a sensor that did not answer."""
return {}
class Later(Exception):
"""Not now: try again later. retry_after is what the server asked for."""
def __init__(self, reason: str, retry_after: float | None = None):
super().__init__(reason)
self.retry_after = retry_after
def retry_after(response: requests.Response) -> float | None:
value = response.headers.get("Retry-After", "")
return float(value) if value.isdigit() else None
def write_private(path: Path, text: str) -> None:
"""Write atomically, readable by this user only."""
tmp = path.with_name(path.name + ".tmp")
with os.fdopen(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as file:
file.write(text)
file.flush()
os.fsync(file.fileno())
os.replace(tmp, path)
def get_token() -> str:
"""BB_TOKEN, else the stored token, else pair with a code (POST /device/hello)."""
if os.environ.get("BB_TOKEN"):
return os.environ["BB_TOKEN"]
token_file, uuid_file = STATE / "token", STATE / "device-uuid"
if token_file.exists():
return token_file.read_text().strip()
if not uuid_file.exists():
write_private(uuid_file, str(uuid.uuid4())) # once; secret until the token is used
body = {"device_uuid": uuid_file.read_text().strip(), "firmware_version": FIRMWARE}
shown = None
while True:
try:
r = requests.post(f"{API}/device/hello", json=body, timeout=TIMEOUT)
except requests.RequestException as error:
log.warning("hello failed: %s", error)
time.sleep(30)
continue
if r.status_code == 422:
raise SystemExit(f"hello refused: {r.text}")
if r.status_code != 200:
time.sleep(retry_after(r) or 30)
continue
answer = r.json()
if answer.get("token"):
write_private(token_file, answer["token"]) # store first, then use it
return answer["token"]
if answer.get("claimed"):
raise SystemExit(f"The token was used already. Delete {uuid_file}, run again "
"and enter the new code on the same device page.")
if answer["pairing_code"] != shown:
shown = answer["pairing_code"]
print(f"Pairing code: {shown} (portal: device page, 'Pair with code')")
time.sleep(15)
class Buffer:
"""Every reading goes here first; it is deleted once the server confirmed it."""
def __init__(self, path: Path):
self.db = sqlite3.connect(path)
self.db.execute("CREATE TABLE IF NOT EXISTS readings (id INTEGER PRIMARY KEY,"
" sensor TEXT NOT NULL, value REAL, quality TEXT NOT NULL,"
" measured_at TEXT NOT NULL, batch TEXT, parked INTEGER DEFAULT 0)")
def add(self, rows: list[tuple]) -> None:
with self.db:
self.db.executemany("INSERT INTO readings (sensor, value, quality, measured_at)"
" VALUES (?, ?, ?, ?)", rows)
def claim(self, limit: int):
"""The oldest readings. A batch that was sent but never confirmed (crash,
timeout) comes back first, with the same uuid."""
with self.db:
row = self.db.execute("SELECT batch FROM readings WHERE batch IS NOT NULL"
" AND parked = 0 ORDER BY id LIMIT 1").fetchone()
batch = row[0] if row else str(uuid.uuid4())
if row is None:
self.db.execute("UPDATE readings SET batch = ? WHERE id IN (SELECT id FROM"
" readings WHERE batch IS NULL ORDER BY id LIMIT ?)", (batch, limit))
rows = self.db.execute("SELECT id, sensor, value, quality, measured_at FROM readings"
" WHERE batch = ? ORDER BY id", (batch,)).fetchall()
readings = [{"sensor": s, "value": v, "quality": q, "measured_at": t} for _, s, v, q, t in rows]
return (batch, [row[0] for row in rows], readings) if rows else None
def confirm(self, batch: str, ids: list[int], retry: set[int]) -> None:
with self.db: # delete the batch, except the readings the server wants again
self.db.executemany("UPDATE readings SET batch = NULL WHERE id = ?",
[(ids[i],) for i in retry if 0 <= i < len(ids)])
self.db.execute("DELETE FROM readings WHERE batch = ?", (batch,))
def park(self, batch: str) -> None:
with self.db: # kept for inspection, no longer sent
self.db.execute("UPDATE readings SET parked = 1 WHERE batch = ?", (batch,))
def pending(self) -> int:
return self.db.execute("SELECT COUNT(*) FROM readings WHERE parked = 0").fetchone()[0]
class Device:
def __init__(self, token: str):
self.http = requests.Session()
self.http.headers.update({"Authorization": f"Bearer {token}", "Accept": "application/json"})
self.buffer = Buffer(STATE / "buffer.sqlite3")
cache = STATE / "config.json" # so it can measure after a reboot without internet
cached = json.loads(cache.read_text()) if cache.exists() else {}
self.config, self.etag = cached.get("config"), cached.get("etag")
def call(self, method: str, path: str, **kwargs) -> requests.Response:
# No redirects: a redirect could carry the token to another host.
return self.http.request(method, API + path, timeout=TIMEOUT, allow_redirects=False, **kwargs)
def refresh_config(self) -> None:
r = self.call("GET", "/device/config", headers={"If-None-Match": self.etag} if self.etag else {})
if r.status_code == 304:
return
if r.status_code != 200:
raise Later(f"config: HTTP {r.status_code}", retry_after(r))
self.config, self.etag = r.json(), r.headers.get("ETag")
write_private(STATE / "config.json", json.dumps({"config": self.config, "etag": self.etag}))
if self.config["controller"] is None:
log.info("No circuit chosen yet: nothing to measure")
def measure(self) -> None:
sensors = {s["sensor"]: s for s in (self.config or {}).get("sensors", [])}
at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
rows = []
for code, value in read_sensors(list(sensors.values())).items():
if code not in sensors:
continue # only codes from the config: others end up in quarantine
low, high = sensors[code]["plausible_min"], sensors[code]["plausible_max"]
if value is None:
quality = "no_response"
elif (low is not None and value < low) or (high is not None and value > high):
quality = "out_of_range" # stored, but not in charts and averages
else:
quality = "ok"
rows.append((code, value, quality, at))
self.buffer.add(rows)
def upload(self) -> None:
for _ in range(UPLOADS_PER_CYCLE):
claim = self.buffer.claim(BATCH_SIZE)
if claim is None:
return
batch, ids, readings = claim
r = self.call("POST", "/device/readings", headers={"Idempotency-Key": batch},
json={"batch_uuid": batch, "readings": readings})
if r.status_code == 422: # malformed as a whole: resending will not help
log.error("batch %s refused: %s", batch, r.text[:500])
self.buffer.park(batch)
continue
if r.status_code == 401:
raise Later("token refused (401): replaced, disabled or device inactive", 900)
try:
result = r.json() if r.status_code == 200 else {}
except ValueError:
result = {} # a 200 that is not the API's answer confirms nothing
if "accepted" not in result:
raise Later(f"readings: HTTP {r.status_code}", retry_after(r))
retry = {item["index"] for item in result["rejected"] if item["retry"]}
self.buffer.confirm(batch, ids, retry)
if result["unmapped"] or result["rejected"]:
log.warning("batch %s: unknown sensors %s, rejected %s",
batch, result["unmapped"], result["rejected"])
if retry:
return # back in the queue, for the next cycle
def send_status(self) -> None:
self.call("POST", "/device/status",
json={"firmware_version": FIRMWARE, "buffered": self.buffer.pending()})
def guard(name: str, action) -> None:
try:
action()
except Exception as error: # one failing step must not stop the loop
log.warning("%s failed: %s", name, error)
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
STATE.mkdir(parents=True, exist_ok=True)
device = Device(get_token())
next_config = next_status = next_upload = 0.0
failures = 0
while True:
cycle = time.monotonic()
if cycle >= next_config:
next_config = cycle + CONFIG_EVERY_S
guard("config", device.refresh_config)
guard("measure", device.measure)
if cycle >= next_upload:
try:
device.upload()
failures = 0
except Exception as error: # Later, network trouble, anything: keep the buffer
failures += 1 # back off: 5 s, doubling up to 15 min, with jitter
delay = min(5 * 2 ** min(failures - 1, 10), 900) * random.uniform(0.5, 1)
delay = max(delay, getattr(error, "retry_after", None) or 0)
next_upload = time.monotonic() + delay
log.warning("upload: %s; next try in %.0f s", error, delay)
if cycle >= next_status:
next_status = cycle + STATUS_EVERY_S
guard("status", device.send_status)
scan = ((device.config or {}).get("intervals") or {}).get("scan_seconds") or 60
time.sleep(max(0.0, cycle + min(max(scan, 10), 3600) - time.monotonic()))
if __name__ == "__main__":
main()
Was er macht
- Koppeln: erzeugt einmalig eine UUID, schickt alle 15 Sekunden
hellound speichert den Token, bevor er ihn nutzt. Nur der Benutzer, unter dem der Client läuft, kann diese Dateien lesen. - Konfiguration: alle 15 Minuten mit
If-None-Match, inconfig.jsongespeichert für einen Neustart ohne Internet. - Messen: ruft alle
scan_secondsdeine Funktionread_sensorsauf. Codes, die nicht in der Konfiguration stehen, überspringt er; ein Wert außerhalb des plausiblen Bereichs bekommtout_of_range, kein Wert bekommtno_response. - Puffer: Alles geht zuerst in
buffer.sqlite3. Ein Batch behält seine UUID, auch nach einem Absturz. - Hochladen: höchstens 8 Batches à 500 pro Minute, die ältesten zuerst. Nach
200wird der Batch entfernt, außer Messwerten mitretry: true; ein Batch mit422wird beiseitegelegt. Bei401,429,5xxoder ohne Antwort wartet er jedes Mal länger, bis zu 15 Minuten und mindestens so lange wieRetry-After. - Status: alle 5 Minuten, mit der Firmware-Version und der Anzahl Messwerte im Puffer.
Deine Sensoren auslesen
read_sensors bekommt die Sensoren aus der Konfiguration und gibt pro Code einen Wert zurück, oder None, wenn der Sensor nicht geantwortet hat. Zum Beispiel für DS18B20-Sensoren an einem Raspberry Pi mit aktiviertem 1-Wire, mit den IDs aus /sys/bus/w1/devices/ neben den Codes deines Regelkreises:
PROBES = {"T1": "28-0000075f8c2a", "T2": "28-0000075fa1b3", "T3": "28-0000075fb7c4"}
def read_sensors(sensors: list[dict]) -> dict[str, float | None]:
values = {}
for code, probe in PROBES.items():
try:
lines = Path(f"/sys/bus/w1/devices/{probe}/w1_slave").read_text().splitlines()
values[code] = int(lines[1].split("t=")[1]) / 1000 if lines[0].endswith("YES") else None
except (OSError, IndexError, ValueError):
values[code] = None # sends no_response
return values
Was dieses Beispiel nicht macht
- Die Uhr prüfen oder etwas für die Uhrkorrektur senden. Lass es auf einem System mit NTP laufen.
- Den Puffer begrenzen. Möchtest du ein Maximum, verwirf die ältesten Messwerte.
- Als Dienst laufen. Unter Linux kann systemd es starten und neu starten.
Die Regeln hinter diesem Verhalten stehen in Messwerte senden und Limits und Fehlercodes.