diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..755f4ee --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.env +.venv/ +__pycache__/ +*.pyc +.git/ +.pytest_cache/ +.ruff_cache/ +tests/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7ac7315 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Kopieer dit bestand naar .env en vul je eigen Nightscout-gegevens in. +# .env wordt niet meegecommit (zie .gitignore). + +NIGHTSCOUT_URL=https://jouw-nightscout.example.com + +# Gebruik precies EEN van de twee onderstaande auth-opties. +# Optie 1: een Nightscout API-token (rol-gebaseerd, aan te raden — kan +# read-only worden ingesteld in Nightscout onder Admin Tools > Manage Tokens). +NIGHTSCOUT_TOKEN= + +# Optie 2: het klassieke "API secret" van je Nightscout-instantie. +# NIGHTSCOUT_API_SECRET= diff --git a/.gitea/workflows/pipeline.yml b/.gitea/workflows/pipeline.yml new file mode 100644 index 0000000..7916f9c --- /dev/null +++ b/.gitea/workflows/pipeline.yml @@ -0,0 +1,84 @@ +name: CI/CD + +on: + push: + branches: [main] + +jobs: + # --------------------------------------------------------------------- + # 1. Unit tests — moeten slagen voordat er iets gebouwd wordt + # --------------------------------------------------------------------- + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Installeer dependencies + run: pip install -r requirements.txt + - name: Lint (ruff) + run: ruff check . + - name: Unit tests (pytest) + run: pytest tests/ -v + + # --------------------------------------------------------------------- + # 2. SonarQube — codekwaliteit-gate + # --------------------------------------------------------------------- + sonarqube: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # SonarQube wil de volledige git-historie zien + - name: SonarQube scan + uses: sonarsource/sonarqube-scan-action@v4 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }} + + # --------------------------------------------------------------------- + # 3. Image bouwen + Trivy security-scan + naar Gitea-registry pushen + # --------------------------------------------------------------------- + build-and-scan: + runs-on: ubuntu-latest + needs: [test, sonarqube] + steps: + - uses: actions/checkout@v4 + - name: Build Docker image + run: docker build -t gitea.zb.local/willem/glucose-dashboard:${{ gitea.sha }} . + + - name: Trivy scan + uses: aquasecurity/trivy-action@master + with: + image-ref: gitea.zb.local/willem/glucose-dashboard:${{ gitea.sha }} + severity: CRITICAL,HIGH + exit-code: "1" # laat de pipeline falen bij kritieke kwetsbaarheden + + - name: Login bij Gitea-registry + run: echo "${{ secrets.GLUCOSE_TOKEN }}" | docker login gitea.zb.local -u willem --password-stdin + + - name: Tag en push + run: | + docker tag gitea.zb.local/willem/glucose-dashboard:${{ gitea.sha }} gitea.zb.local/willem/glucose-dashboard:latest + docker push gitea.zb.local/willem/glucose-dashboard:${{ gitea.sha }} + docker push gitea.zb.local/willem/glucose-dashboard:latest + + # --------------------------------------------------------------------- + # 4. Deploy — SSH naar de LXC en herstart met de nieuwe image + # --------------------------------------------------------------------- + deploy: + runs-on: ubuntu-latest + needs: build-and-scan + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + cd /opt/glucose-dashboard + docker compose pull + docker compose up -d diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21e5d76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.pyc +.env +.pytest_cache/ +.ruff_cache/ +homelab-cicd-werkinstructie.md \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dbdf951 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Systeem-dependency voor de HEALTHCHECK hieronder +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8501 + +HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health || exit 1 + +ENTRYPOINT ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"] diff --git a/README.md b/README.md index 4182a11..439c486 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ # glucose-dashboard -dashboard van glucose data \ No newline at end of file +Dashboard van glucosedata, live opgehaald uit een [Nightscout](https://nightscout.github.io/)-instantie. +Toont een rollend venster (standaard 24 uur) van je CGM-curve met insuline, +koolhydraten en pomp-/sensorwissels overlay, plus time-in-range-cijfers. + +## Instellen + +1. Zorg voor een draaiende Nightscout-instantie en een API-token (Admin Tools + > Manage Tokens in Nightscout — geef het token minimaal `readable`-rechten) + of het klassieke API secret. +2. Kopieer `.env.example` naar `.env` en vul `NIGHTSCOUT_URL` en één van de + twee auth-opties in. `app.py` laadt `.env` zelf in (via `python-dotenv`), + dus dit werkt hetzelfde onder PowerShell, cmd of bash — je hoeft de + variabelen niet zelf in je shell te zetten. +3. Lokaal draaien: + + ```powershell + uv venv + uv pip install -r requirements.txt --python .venv + .venv\Scripts\streamlit run app.py + ``` + +4. Met Docker Compose: + + ```bash + docker compose up -d + ``` + + `docker-compose.yml` leest `.env` via `env_file` — dat bestand wordt niet + meegecommit (zie `.gitignore`). + +## Tests en lint + +```bash +pytest tests/ -v +ruff check . +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..f8d6250 --- /dev/null +++ b/app.py @@ -0,0 +1,241 @@ +import plotly.graph_objects as go +import requests +import streamlit as st +from dotenv import load_dotenv + +from data_processing import ( + TARGET_HIGH, + TARGET_LOW, + classify_treatments, + summarize_time_in_range, +) +from nightscout_client import NightscoutConfigError, fetch_entries, fetch_treatments + +load_dotenv() # laadt .env als die bestaat; bestaande env vars blijven leidend + +st.set_page_config(page_title="Glucose dashboard", page_icon="📊", layout="wide") + +st.title("Glucose dashboard") + +HOURS_OPTIONS = {"6 uur": 6, "12 uur": 12, "24 uur": 24, "48 uur": 48} + +# Kleuren per rol, licht/donker — zie de dataviz-conventies in dit project: +# categorale kleuren voor identiteit (glucose/insuline/carbs), status-kleuren +# (vast, nooit hergebruikt) voor te laag/te hoog. +PALETTE = { + "light": { + "glucose": "#2a78d6", + "insulin": "#1baf7a", + "carbs": "#eb6834", + "low": "#d03b3b", + "high": "#fab219", + "band": "rgba(12,163,12,0.08)", + "grid": "#e1e0d9", + "muted": "#898781", + "text": "#52514e", + }, + "dark": { + "glucose": "#3987e5", + "insulin": "#199e70", + "carbs": "#d95926", + "low": "#d03b3b", + "high": "#fab219", + "band": "rgba(12,163,12,0.15)", + "grid": "#2c2c2a", + "muted": "#898781", + "text": "#c3c2b7", + }, +} + + +@st.cache_data(ttl=60, show_spinner="Data ophalen van Nightscout...") +def load_nightscout_data(hours: int): + glucose = fetch_entries(hours=hours) + treatments = fetch_treatments(hours=hours) + return glucose, treatments + + +MMOL_HOVERTEMPLATE = "%{x|%d-%m %H:%M} · %{y:.1f} mmol/L" + + +def build_chart(glucose, insulin, carbs, site_changes, sensor_changes, colors) -> go.Figure: + glucose_min = min(glucose["mmol"].min(), TARGET_LOW) + glucose_max = max(glucose["mmol"].max(), TARGET_HIGH) + y_top = glucose_max + 1.0 + lane_base = glucose_min - 2.5 + y_bottom = lane_base - 0.5 + insulin_lane_y = lane_base + 1.5 + carbs_lane_y = lane_base + 0.7 + + fig = go.Figure() + + # Streefbereik als achtergrondband, geen losse legenda-entry nodig. + fig.add_hrect( + y0=TARGET_LOW, + y1=TARGET_HIGH, + fillcolor=colors["band"], + line_width=0, + layer="below", + ) + + fig.add_trace( + go.Scatter( + x=glucose["timestamp"], + y=glucose["mmol"], + mode="lines", + name="Glucose", + line={"color": colors["glucose"], "width": 2, "shape": "spline"}, + hovertemplate=MMOL_HOVERTEMPLATE, + ) + ) + + low_points = glucose[glucose["mmol"] < TARGET_LOW] + high_points = glucose[glucose["mmol"] > TARGET_HIGH] + if not low_points.empty: + fig.add_trace( + go.Scatter( + x=low_points["timestamp"], + y=low_points["mmol"], + mode="markers", + name=f"Laag (<{TARGET_LOW})", + marker={"color": colors["low"], "size": 6}, + hovertemplate=MMOL_HOVERTEMPLATE, + ) + ) + if not high_points.empty: + fig.add_trace( + go.Scatter( + x=high_points["timestamp"], + y=high_points["mmol"], + mode="markers", + name=f"Hoog (>{TARGET_HIGH})", + marker={"color": colors["high"], "size": 6}, + hovertemplate=MMOL_HOVERTEMPLATE, + ) + ) + + if not insulin.empty: + fig.add_trace( + go.Scatter( + x=insulin["timestamp"], + y=[insulin_lane_y] * len(insulin), + mode="markers", + name="Insuline", + marker={"color": colors["insulin"], "size": 10, "symbol": "triangle-up"}, + customdata=insulin["insulin"], + hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.1f} E", + ) + ) + + if not carbs.empty: + fig.add_trace( + go.Scatter( + x=carbs["timestamp"], + y=[carbs_lane_y] * len(carbs), + mode="markers", + name="Koolhydraten", + marker={"color": colors["carbs"], "size": 10, "symbol": "circle"}, + customdata=carbs["carbs"], + hovertemplate="%{x|%d-%m %H:%M} · %{customdata:.0f} g", + ) + ) + + # Let op: annotation_text/-position via add_vline() zelf triggert een bug + # in plotly's shapeannotation-helper met pandas Timestamp-x-waarden, dus + # de lijn en het label worden hier los toegevoegd. + for _, row in site_changes.iterrows(): + fig.add_vline(x=row["timestamp"], line_dash="dot", line_color=colors["muted"], opacity=0.6) + fig.add_annotation( + x=row["timestamp"], + y=y_top, + yanchor="bottom", + text="Pomp", + showarrow=False, + font={"color": colors["muted"], "size": 10}, + ) + for _, row in sensor_changes.iterrows(): + fig.add_vline(x=row["timestamp"], line_dash="dash", line_color=colors["muted"], opacity=0.6) + fig.add_annotation( + x=row["timestamp"], + y=y_top, + yanchor="bottom", + text="Sensor", + showarrow=False, + font={"color": colors["muted"], "size": 10}, + ) + + fig.update_layout( + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font_color=colors["text"], + legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "left", "x": 0}, + margin={"l": 10, "r": 10, "t": 10, "b": 10}, + height=480, + yaxis={ + "title": "mmol/L", + "range": [y_bottom, y_top], + "gridcolor": colors["grid"], + "zeroline": False, + }, + xaxis={"gridcolor": colors["grid"]}, + ) + return fig + + +with st.sidebar: + st.header("Instellingen") + hours_label = st.radio("Periode", list(HOURS_OPTIONS.keys()), index=2) + hours = HOURS_OPTIONS[hours_label] + if st.button("Vernieuwen"): + st.cache_data.clear() + +try: + glucose, treatments = load_nightscout_data(hours) +except NightscoutConfigError as exc: + st.error(str(exc)) + st.stop() +except (requests.RequestException, ValueError) as exc: # netwerk-/API-fouten van Nightscout + st.error(f"Kon geen data ophalen van Nightscout: {exc}") + st.stop() + +if glucose.empty: + st.info("Geen glucosemetingen gevonden in deze periode.") + st.stop() + +events = classify_treatments(treatments) +tir = summarize_time_in_range(glucose) + +latest = glucose.iloc[-1] +previous = glucose.iloc[-2] if len(glucose) > 1 else None +delta = round(latest["mmol"] - previous["mmol"], 1) if previous is not None else None + +col1, col2, col3, col4 = st.columns(4) +col1.metric("Actuele glucose", f"{latest['mmol']:.1f} mmol/L", delta=delta, delta_color="off") +col2.metric("Tijd in bereik", f"{tir['in_range']:.0f}%") +col3.metric("Tijd laag", f"{tir['low']:.0f}%") +col4.metric("Tijd hoog", f"{tir['high']:.0f}%") +st.caption( + f"Laatste meting: {latest['timestamp'].strftime('%d-%m-%Y %H:%M')} · " + f"streefbereik {TARGET_LOW}-{TARGET_HIGH} mmol/L" +) + +theme_type = "light" +try: + theme_type = st.context.theme.type or "light" +except AttributeError: + pass +colors = PALETTE["dark" if theme_type == "dark" else "light"] + +fig = build_chart( + glucose, + events["insulin"], + events["carbs"], + events["site_changes"], + events["sensor_changes"], + colors, +) +st.plotly_chart(fig, width="stretch") +st.caption( + "Driehoekjes = insuline · cirkels = koolhydraten · stippellijn = pompwissel · " + "streepjeslijn = sensorwissel" +) diff --git a/data_processing.py b/data_processing.py new file mode 100644 index 0000000..9ae5f63 --- /dev/null +++ b/data_processing.py @@ -0,0 +1,62 @@ +""" +Data processing logic voor het glucose-dashboard. + +Bewust gescheiden van app.py: Streamlit-UI-code kun je niet zomaar los +unit-testen (die heeft een draaiende Streamlit-context nodig). Door de +logica (classificeren, samenvatten) in een los module te zetten, kun je die +logica wel gewoon met pytest testen, en blijft app.py alleen de UI-laag. +""" + +from __future__ import annotations + +import pandas as pd + +from nightscout_client import SENSOR_CHANGE_EVENT_TYPES, SITE_CHANGE_EVENT_TYPES + +# Streefbereik in mmol/L — pas aan naar je eigen behandeldoelen. +TARGET_LOW = 3.9 +TARGET_HIGH = 10.0 + + +def classify_treatments(treatments: pd.DataFrame) -> dict[str, pd.DataFrame]: + """Splits ruwe Nightscout-treatments op in aparte, tijd-gesorteerde subsets. + + Een insuline- en koolhydraat-registratie kunnen dezelfde treatment zijn + (bijv. een "Meal Bolus" met zowel insuline als carbs) en verschijnen dan + in beide subsets. + """ + insulin = treatments[treatments["insulin"].fillna(0) > 0] + carbs = treatments[treatments["carbs"].fillna(0) > 0] + site_changes = treatments[treatments["eventType"].isin(SITE_CHANGE_EVENT_TYPES)] + sensor_changes = treatments[treatments["eventType"].isin(SENSOR_CHANGE_EVENT_TYPES)] + return { + "insulin": insulin.reset_index(drop=True), + "carbs": carbs.reset_index(drop=True), + "site_changes": site_changes.reset_index(drop=True), + "sensor_changes": sensor_changes.reset_index(drop=True), + } + + +def glucose_status(mmol: float, low: float = TARGET_LOW, high: float = TARGET_HIGH) -> str: + """Classificeer een glucosewaarde t.o.v. het streefbereik.""" + if mmol < low: + return "low" + if mmol > high: + return "high" + return "in_range" + + +def summarize_time_in_range( + glucose: pd.DataFrame, low: float = TARGET_LOW, high: float = TARGET_HIGH +) -> dict[str, float]: + """Bereken het percentage metingen onder/in/boven het streefbereik.""" + if glucose.empty: + return {"low": 0.0, "in_range": 0.0, "high": 0.0} + + statuses = glucose["mmol"].apply(lambda v: glucose_status(v, low, high)) + counts = statuses.value_counts(normalize=True) * 100 + return { + "low": round(counts.get("low", 0.0), 1), + "in_range": round(counts.get("in_range", 0.0), 1), + "high": round(counts.get("high", 0.0), 1), + } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d840770 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + glucose-dashboard: + image: gitea.zb.local/willem/glucose-dashboard:latest + container_name: glucose-dashboard + restart: unless-stopped + ports: + - "8501:8501" + env_file: + - .env # zie .env.example — NIGHTSCOUT_URL + NIGHTSCOUT_TOKEN of NIGHTSCOUT_API_SECRET diff --git a/nightscout_client.py b/nightscout_client.py new file mode 100644 index 0000000..f302613 --- /dev/null +++ b/nightscout_client.py @@ -0,0 +1,129 @@ +""" +Nightscout API client. + +Praat met een bestaande Nightscout-instantie (CGM-data, insuline/koolhydraten +en pomp-/sensorwissels). Authenticatie loopt via environment variables: + +- NIGHTSCOUT_URL: basis-URL van je Nightscout-instantie, bijv. + "https://jouw-nightscout.example.com" (verplicht). +- NIGHTSCOUT_TOKEN: een Nightscout API-token (rol-gebaseerd), OF +- NIGHTSCOUT_API_SECRET: het "API secret" van je instantie (wordt lokaal + ge-hashed, nooit in plaintext verstuurd). + +Gebruik precies één van de twee auth-opties; als beide gezet zijn heeft het +token voorrang. +""" + +from __future__ import annotations + +import hashlib +import os +from datetime import datetime, timedelta, timezone + +import pandas as pd +import requests + +MGDL_PER_MMOL = 18.0182 + +INSULIN_EVENT_TYPES = {"Bolus", "Meal Bolus", "Correction Bolus", "Combo Bolus", "SMB"} +SITE_CHANGE_EVENT_TYPES = {"Site Change"} +SENSOR_CHANGE_EVENT_TYPES = {"Sensor Change", "Sensor Start"} + +_REQUEST_TIMEOUT = 10 + + +class NightscoutConfigError(RuntimeError): + """NIGHTSCOUT_URL ontbreekt of is ongeldig.""" + + +def mgdl_to_mmol(value: float) -> float: + """Reken mg/dL (Nightscout's interne eenheid) om naar mmol/L.""" + return round(value / MGDL_PER_MMOL, 1) + + +def _base_url() -> str: + url = os.environ.get("NIGHTSCOUT_URL", "").strip().rstrip("/") + if not url: + raise NightscoutConfigError( + "NIGHTSCOUT_URL is niet ingesteld. Zet deze environment variable, " + "bijv. https://jouw-nightscout.example.com" + ) + return url + + +def _auth() -> tuple[dict, dict]: + """Geeft (headers, params) terug voor authenticatie met Nightscout.""" + token = os.environ.get("NIGHTSCOUT_TOKEN", "").strip() + api_secret = os.environ.get("NIGHTSCOUT_API_SECRET", "").strip() + if token: + return {}, {"token": token} + if api_secret: + hashed = hashlib.sha1(api_secret.encode("utf-8")).hexdigest() + return {"API-SECRET": hashed}, {} + return {}, {} + + +def _get(path: str, params: dict) -> list: + headers, auth_params = _auth() + response = requests.get( + f"{_base_url()}{path}", + headers=headers, + params={**params, **auth_params}, + timeout=_REQUEST_TIMEOUT, + ) + response.raise_for_status() + return response.json() + + +def fetch_entries(hours: int = 24) -> pd.DataFrame: + """Haal glucosemetingen (SGV) op van de laatste `hours` uur, in mmol/L. + + Geeft een DataFrame terug met kolommen 'timestamp' en 'mmol', oplopend + gesorteerd op tijd. + """ + since = datetime.now(timezone.utc) - timedelta(hours=hours) + # Nightscout geeft resultaten terug in aflopende volgorde; vraag ruim + # genoeg entries op (CGM meet doorgaans elke 5 min) en filter lokaal op + # tijd. Dit is robuuster dan server-side find[]-query's, die niet op elke + # Nightscout-versie identiek werken. + count = max(300, hours * 15) + data = _get("/api/v1/entries/sgv.json", {"count": count}) + + if not data: + return pd.DataFrame(columns=["timestamp", "mmol"]) + + df = pd.DataFrame(data) + df["timestamp"] = pd.to_datetime(df["date"], unit="ms", utc=True).dt.tz_localize(None) + df["mmol"] = df["sgv"].astype(float).apply(mgdl_to_mmol) + df = df[df["timestamp"] >= since.replace(tzinfo=None)] + return df[["timestamp", "mmol"]].sort_values("timestamp").reset_index(drop=True) + + +def fetch_treatments(hours: int = 24) -> pd.DataFrame: + """Haal behandelingen op van de laatste `hours` uur: insuline, koolhydraten + en pomp-/sensorwissels. + + Geeft een DataFrame terug met kolommen 'timestamp', 'eventType', + 'insulin' en 'carbs' (de laatste twee NaN waar niet van toepassing). + """ + since = datetime.now(timezone.utc) - timedelta(hours=hours) + count = max(200, hours * 10) + data = _get("/api/v1/treatments.json", {"count": count}) + + if not data: + return pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"]) + + df = pd.DataFrame(data) + df["timestamp"] = pd.to_datetime(df["created_at"], utc=True).dt.tz_localize(None) + for col in ("insulin", "carbs"): + if col not in df.columns: + df[col] = pd.NA + df[col] = pd.to_numeric(df[col], errors="coerce") + if "eventType" not in df.columns: + df["eventType"] = "" + df = df[df["timestamp"] >= since.replace(tzinfo=None)] + return ( + df[["timestamp", "eventType", "insulin", "carbs"]] + .sort_values("timestamp") + .reset_index(drop=True) + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8338c4d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +streamlit>=1.38,<2.0 +pandas>=2.2,<3.0 +requests>=2.32,<3.0 +plotly>=5.24,<6.0 +python-dotenv>=1.0,<2.0 +pytest>=8.0,<9.0 +ruff>=0.6,<1.0 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..7c9f4dc --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,6 @@ +sonar.projectKey=glucose-dashboard +sonar.projectName=Glucose Dashboard +sonar.sources=. +sonar.exclusions=data/**,tests/** +sonar.tests=tests +sonar.python.version=3.12 diff --git a/tests/test_data_processing.py b/tests/test_data_processing.py new file mode 100644 index 0000000..a6358d7 --- /dev/null +++ b/tests/test_data_processing.py @@ -0,0 +1,74 @@ +import pandas as pd +import pytest + +from data_processing import classify_treatments, glucose_status, summarize_time_in_range + + +@pytest.fixture +def sample_glucose(): + return pd.DataFrame( + { + "timestamp": pd.to_datetime( + ["2026-07-25 08:00", "2026-07-25 09:00", "2026-07-25 10:00", "2026-07-25 11:00"] + ), + "mmol": [3.5, 6.0, 11.0, 7.0], # laag, in bereik, hoog, in bereik + } + ) + + +@pytest.fixture +def sample_treatments(): + return pd.DataFrame( + { + "timestamp": pd.to_datetime( + [ + "2026-07-25 08:00", + "2026-07-25 12:00", + "2026-07-24 09:00", + "2026-07-20 09:00", + ] + ), + "eventType": ["Meal Bolus", "Correction Bolus", "Site Change", "Sensor Change"], + "insulin": [4.0, 1.5, None, None], + "carbs": [45.0, None, None, None], + } + ) + + +def test_glucose_status_classifies_correctly(): + assert glucose_status(3.5) == "low" + assert glucose_status(6.0) == "in_range" + assert glucose_status(11.0) == "high" + + +def test_summarize_time_in_range(sample_glucose): + result = summarize_time_in_range(sample_glucose) + assert result == {"low": 25.0, "in_range": 50.0, "high": 25.0} + + +def test_summarize_time_in_range_handles_empty_input(): + empty = pd.DataFrame(columns=["timestamp", "mmol"]) + result = summarize_time_in_range(empty) + assert result == {"low": 0.0, "in_range": 0.0, "high": 0.0} + + +def test_classify_treatments_splits_into_expected_subsets(sample_treatments): + events = classify_treatments(sample_treatments) + + assert len(events["insulin"]) == 2 + assert set(events["insulin"]["insulin"]) == {4.0, 1.5} + + assert len(events["carbs"]) == 1 + assert events["carbs"].iloc[0]["carbs"] == 45.0 + + assert len(events["site_changes"]) == 1 + assert events["site_changes"].iloc[0]["eventType"] == "Site Change" + + assert len(events["sensor_changes"]) == 1 + assert events["sensor_changes"].iloc[0]["eventType"] == "Sensor Change" + + +def test_classify_treatments_handles_empty_input(): + empty = pd.DataFrame(columns=["timestamp", "eventType", "insulin", "carbs"]) + events = classify_treatments(empty) + assert all(df.empty for df in events.values()) diff --git a/tests/test_nightscout_client.py b/tests/test_nightscout_client.py new file mode 100644 index 0000000..a828b5d --- /dev/null +++ b/tests/test_nightscout_client.py @@ -0,0 +1,98 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +import nightscout_client as ns + + +class FakeResponse: + def __init__(self, payload): + self._payload = payload + + def raise_for_status(self): + pass + + def json(self): + return self._payload + + +def test_mgdl_to_mmol_converts_correctly(): + assert ns.mgdl_to_mmol(126.0) == pytest.approx(7.0, abs=0.05) + + +def test_base_url_requires_nightscout_url(monkeypatch): + monkeypatch.delenv("NIGHTSCOUT_URL", raising=False) + with pytest.raises(ns.NightscoutConfigError): + ns._base_url() + + +def test_base_url_strips_trailing_slash(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com/") + assert ns._base_url() == "https://example.com" + + +def test_auth_prefers_token_over_api_secret(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_TOKEN", "my-token") + monkeypatch.setenv("NIGHTSCOUT_API_SECRET", "my-secret") + headers, params = ns._auth() + assert params == {"token": "my-token"} + assert headers == {} + + +def test_auth_hashes_api_secret(monkeypatch): + monkeypatch.delenv("NIGHTSCOUT_TOKEN", raising=False) + monkeypatch.setenv("NIGHTSCOUT_API_SECRET", "my-secret") + headers, params = ns._auth() + assert params == {} + assert headers["API-SECRET"] != "my-secret" # nooit in plaintext + assert len(headers["API-SECRET"]) == 40 # sha1 hexdigest + + +def test_fetch_entries_converts_and_filters_by_time(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com") + monkeypatch.delenv("NIGHTSCOUT_TOKEN", raising=False) + monkeypatch.delenv("NIGHTSCOUT_API_SECRET", raising=False) + + now = datetime.now(timezone.utc) + recent = now - timedelta(hours=1) + too_old = now - timedelta(hours=48) + payload = [ + {"date": int(recent.timestamp() * 1000), "sgv": 126}, + {"date": int(too_old.timestamp() * 1000), "sgv": 90}, + ] + + monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload)) + + result = ns.fetch_entries(hours=24) + assert len(result) == 1 + assert result.iloc[0]["mmol"] == pytest.approx(7.0, abs=0.05) + + +def test_fetch_entries_handles_empty_response(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com") + monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse([])) + + result = ns.fetch_entries(hours=24) + assert result.empty + assert list(result.columns) == ["timestamp", "mmol"] + + +def test_fetch_treatments_fills_missing_columns(monkeypatch): + monkeypatch.setenv("NIGHTSCOUT_URL", "https://example.com") + now = datetime.now(timezone.utc) + payload = [ + {"created_at": now.isoformat(), "eventType": "Site Change"}, + ] + monkeypatch.setattr(ns.requests, "get", lambda *a, **k: FakeResponse(payload)) + + result = ns.fetch_treatments(hours=24) + assert len(result) == 1 + assert result.iloc[0]["eventType"] == "Site Change" + assert pd_isna(result.iloc[0]["insulin"]) + assert pd_isna(result.iloc[0]["carbs"]) + + +def pd_isna(value): + import pandas as pd + + return pd.isna(value)