glucoglance 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ """GlucoGlance: an Ubuntu tray application that displays a live glucose
2
+ reading fetched from the LibreLinkUp cloud API.
3
+
4
+ Package layout:
5
+ client - talks to the LibreLinkUp API and turns its responses into domain objects.
6
+ domain - plain data types (GlucoseReading, TrendArrow, unit conversion) with no I/O.
7
+ poller - background polling loop that publishes new readings/errors as events.
8
+ alerts - placeholder for phase 2 (threshold alarms); empty for now.
9
+ ui - the on-screen display (tray indicator today, possibly others later).
10
+ config - user settings and credential storage.
11
+ """
@@ -0,0 +1 @@
1
+ """Placeholder for phase 2 (glucose threshold alarms). Empty in phase 1."""
@@ -0,0 +1,7 @@
1
+ """Placeholder for phase 2.
2
+
3
+ Threshold alarms will subscribe to the same `poller.EventBus` the tray
4
+ display uses today (see `poller.poller.EventBus`), reacting to
5
+ `ReadingUpdatedEvent`/`PollErrorEvent` events without any changes needed to the
6
+ client, domain, or poller code. Nothing is implemented here yet.
7
+ """
Binary file
@@ -0,0 +1 @@
1
+ """LibreLinkUp API client: authentication and fetching the latest glucose reading."""
@@ -0,0 +1,28 @@
1
+ """Exceptions raised by the LibreLinkUp client.
2
+
3
+ `LibreLinkUpClient.get_latest_reading()` only ever raises one of these three
4
+ types - it never lets a raw `requests` exception, `KeyError`, or JSON parsing
5
+ error escape - so callers (the poller) can handle failures uniformly without
6
+ knowing anything about the client's internals.
7
+ """
8
+
9
+
10
+ class LibreLinkUpError(Exception):
11
+ """Base class for all errors raised by the LibreLinkUp client."""
12
+
13
+
14
+ class AuthError(LibreLinkUpError):
15
+ """Login failed: bad credentials, or the API rejected/expired our session."""
16
+
17
+
18
+ class NetworkError(LibreLinkUpError):
19
+ """A network-level failure (timeout, DNS, connection refused, HTTP 5xx, ...)."""
20
+
21
+
22
+ class StaleDataError(LibreLinkUpError):
23
+ """The request succeeded, but there's no usable reading.
24
+
25
+ Raised when the follower account has no connections, or the latest
26
+ reading's timestamp is too old - both indicate the sensor wearer's phone
27
+ has stopped relaying data to LibreLinkUp, not a problem with our request.
28
+ """
@@ -0,0 +1,275 @@
1
+ """Client for the unofficial LibreLinkUp API.
2
+
3
+ LibreLinkUp is Abbott's "follower" service: the sensor wearer's phone app
4
+ uploads readings to it, and a follower account (the one this client logs in
5
+ as) can poll it for the latest reading. There is no official public API or
6
+ documentation for this - the request/response shapes here are based on
7
+ community-documented behavior of the LibreLinkUp mobile app and may drift if
8
+ Abbott changes their backend. If this client starts failing, the response
9
+ shapes below are the first thing to re-check against a real request.
10
+
11
+ Everything the rest of the app needs is exposed through one method:
12
+ `get_latest_reading()`. It always returns a fully-formed GlucoseReading or
13
+ raises one of the errors in `client.errors` - callers never see raw HTTP or
14
+ JSON errors.
15
+
16
+ Response field reference (GET /llu/connections, one connection object in the
17
+ `data` array). Only `glucoseMeasurement`'s fields marked USED below are
18
+ actually parsed today, by `_parse_connections_response()`. Everything else
19
+ is kept here so a future feature (e.g. phase 2 alerts wanting `alarmRules`,
20
+ or a "sensor days left" display wanting `sensor`) doesn't need to rediscover
21
+ the API's shape from scratch.
22
+
23
+ Connection object:
24
+ id - this connection record's own ID (unused)
25
+ patientId - sensor wearer's patient ID (unused)
26
+ firstName, lastName - sensor wearer's name (unused)
27
+ status - connection status code (unused)
28
+ country - wearer's registered country, e.g. "ES" (unused)
29
+ created - unix timestamp the connection was created (unused)
30
+ uom - account-level unit of measure, 1=mg/dL (unused;
31
+ redundant with glucoseMeasurement.GlucoseUnits)
32
+ targetHigh, targetLow - wearer's target range from their own LibreLinkUp
33
+ app settings (unused - range classification uses
34
+ domain/range.py + Settings-configured thresholds
35
+ instead, not this)
36
+ glucoseAlarm - active alarm state, if any (unused)
37
+ alarmRules - wearer's configured alarm thresholds, with h/l/f
38
+ sub-blocks for high/low/fast-drop (unused; likely
39
+ relevant to phase 2 alerts)
40
+ patientDevice - reading device metadata: did/dtid (device
41
+ id/type), v (firmware version), h/l/hl/ll
42
+ (device-side high/low flags+values),
43
+ fixedLowAlarmValues, alarms (bool), u (timestamp)
44
+ (unused)
45
+ sensor - physical sensor metadata: sn (serial), a
46
+ (activation timestamp), w (wear duration), pt,
47
+ lj, s, deviceId (unused)
48
+ glucoseItem - duplicate of glucoseMeasurement, same shape
49
+ (unused)
50
+ glucoseMeasurement - the current reading, see below
51
+
52
+ glucoseMeasurement:
53
+ Value - glucose value in the account's display unit (USED ->
54
+ GlucoseReading.value_mgdl)
55
+ Timestamp - reading time, account-local naive datetime (USED ->
56
+ GlucoseReading.timestamp; also drives the staleness
57
+ check)
58
+ TrendArrow - direction code 1-5 (USED -> GlucoseReading.trend, via
59
+ TrendArrow.from_api_value)
60
+ isHigh, isLow - API's own range flags (USED -> GlucoseReading.is_high
61
+ / is_low)
62
+ ValueInMgPerDl - same value, always in mg/dL regardless of account unit
63
+ (unused - NOTE: `Value` is only actually mg/dL because
64
+ this account's `GlucoseUnits` is 1; an mmol/L account
65
+ would make `Value` diverge from `ValueInMgPerDl`, and
66
+ GlucoseReading's mg/dL assumption would silently break)
67
+ FactoryTimestamp - sensor-side timestamp, vs `Timestamp` being
68
+ account-local (unused)
69
+ GlucoseUnits - unit code for `Value`: 1=mg/dL, 2=mmol/L (unused - see
70
+ ValueInMgPerDl note above)
71
+ MeasurementColor - API's own in/out-of-range color code (unused -
72
+ superseded by domain/range.py's own classification)
73
+ TrendMessage - optional human-readable trend text (unused)
74
+ type - measurement type code, 1=normal reading (unused)
75
+ """
76
+
77
+ import hashlib
78
+ from datetime import datetime, timedelta
79
+ from typing import Any
80
+
81
+ import requests
82
+
83
+ from glucoglance.client.errors import AuthError, NetworkError, StaleDataError
84
+ from glucoglance.domain.reading import GlucoseReading
85
+ from glucoglance.domain.trend import TrendArrow
86
+
87
+ # Default entry point; a login response can redirect us to a region-specific
88
+ # host instead (e.g. api-eu.libreview.io), which we then remember.
89
+ DEFAULT_BASE_HOST = "api.libreview.io"
90
+
91
+ # LibreLinkUp's backend rejects requests that don't look like they came from
92
+ # the official mobile app, and version-gates the API: too low a version
93
+ # here gets a 403 with {"status": 920, "data": {"minimumVersion": "X"}}
94
+ # (confirmed live - 4.12.0 was rejected demanding 4.16.0). Bump this if that
95
+ # starts happening again.
96
+ _REQUEST_HEADERS = {
97
+ "Content-Type": "application/json",
98
+ "product": "llu.android",
99
+ "version": "4.16.0",
100
+ }
101
+
102
+ # A reading older than this is treated as stale: the wearer's phone has
103
+ # likely stopped relaying data to LibreLinkUp.
104
+ _STALE_AFTER = timedelta(minutes=5)
105
+
106
+ # The token LibreLinkUp issues is short-lived; refresh a little before it
107
+ # actually expires rather than racing the clock.
108
+ _TOKEN_REFRESH_MARGIN = timedelta(minutes=1)
109
+
110
+
111
+ class LibreLinkUpClient:
112
+ """Fetches the latest glucose reading for a single LibreLinkUp follower account."""
113
+
114
+ def __init__(self, email: str, password: str, *, base_host: str | None = None):
115
+ """Create a client. `base_host` can be a previously-cached region host
116
+ (see `_login`) to skip the redirect round trip; if omitted, the
117
+ default host is tried first."""
118
+ self._email = email
119
+ self._password = password
120
+ self._base_host = base_host or DEFAULT_BASE_HOST
121
+ self._session = requests.Session()
122
+ self._token: str | None = None
123
+ self._token_expires_at: datetime | None = None
124
+ self._account_id_header: str | None = None
125
+
126
+ @property
127
+ def base_host(self) -> str:
128
+ """The (possibly region-redirected) host currently in use, for callers
129
+ that want to cache it in config and pass it back in next time."""
130
+ return self._base_host
131
+
132
+ def get_latest_reading(self) -> GlucoseReading:
133
+ """Fetch the wearer's most recent glucose reading.
134
+
135
+ Raises AuthError, NetworkError, or StaleDataError - never returns
136
+ None and never lets a lower-level exception escape.
137
+ """
138
+ self._ensure_authenticated()
139
+ connections = self._get_connections()
140
+ return _parse_connections_response(connections)
141
+
142
+ def _ensure_authenticated(self) -> None:
143
+ """Log in if we don't yet have a token, or it's about to expire."""
144
+ if self._token and self._token_expires_at:
145
+ if datetime.now() < self._token_expires_at - _TOKEN_REFRESH_MARGIN:
146
+ return
147
+ self._login()
148
+
149
+ def _login(self) -> None:
150
+ """POST credentials to /llu/auth/login, following a region redirect once."""
151
+ response_data = self._post_login(self._base_host)
152
+
153
+ if response_data.get("redirect"):
154
+ region = response_data.get("region")
155
+ if not region:
156
+ raise AuthError("LibreLinkUp requested a redirect but gave no region")
157
+ self._base_host = f"api-{region}.libreview.io"
158
+ response_data = self._post_login(self._base_host)
159
+
160
+ try:
161
+ ticket = response_data["authTicket"]
162
+ self._token = ticket["token"]
163
+ self._token_expires_at = datetime.fromtimestamp(ticket["expires"])
164
+ # Abbott's backend requires this header on later calls; it's the
165
+ # SHA-256 hex digest of the account's user id.
166
+ user_id = response_data["user"]["id"]
167
+ self._account_id_header = hashlib.sha256(user_id.encode("utf-8")).hexdigest()
168
+ except (KeyError, TypeError) as exc:
169
+ raise AuthError(f"Unexpected login response shape: {exc}") from exc
170
+
171
+ def _post_login(self, host: str) -> dict[str, Any]:
172
+ """Send the login request to the given host and return its `data` field."""
173
+ try:
174
+ resp = self._session.post(
175
+ f"https://{host}/llu/auth/login",
176
+ json={"email": self._email, "password": self._password},
177
+ headers=_REQUEST_HEADERS,
178
+ timeout=10,
179
+ )
180
+ except requests.RequestException as exc:
181
+ raise NetworkError(f"Login request failed: {exc}") from exc
182
+
183
+ body = _parse_json(resp, NetworkError)
184
+ if resp.status_code == 401 or body.get("status") not in (0, None):
185
+ raise AuthError(f"Login rejected: {_response_error_detail(resp, body)}")
186
+ if resp.status_code != 200:
187
+ detail = _response_error_detail(resp, body)
188
+ raise NetworkError(f"Login returned unexpected HTTP {detail}")
189
+
190
+ return body.get("data", {})
191
+
192
+ def _get_connections(self) -> dict[str, Any]:
193
+ """GET /llu/connections, which includes each connected patient's latest reading."""
194
+ headers = dict(_REQUEST_HEADERS, Authorization=f"Bearer {self._token}")
195
+ if self._account_id_header:
196
+ headers["Account-Id"] = self._account_id_header
197
+
198
+ try:
199
+ resp = self._session.get(
200
+ f"https://{self._base_host}/llu/connections",
201
+ headers=headers,
202
+ timeout=10,
203
+ )
204
+ except requests.RequestException as exc:
205
+ raise NetworkError(f"Connections request failed: {exc}") from exc
206
+
207
+ if resp.status_code == 401:
208
+ # Token was rejected outright (e.g. revoked server-side); force a
209
+ # fresh login on the next call rather than retrying here.
210
+ self._token = None
211
+ raise AuthError("Connections request was unauthorized")
212
+
213
+ body = _parse_json(resp, NetworkError)
214
+ if resp.status_code != 200:
215
+ detail = _response_error_detail(resp, body)
216
+ raise NetworkError(f"Connections returned unexpected HTTP {detail}")
217
+
218
+ return body
219
+
220
+
221
+ def _response_error_detail(resp: requests.Response, body: dict[str, Any]) -> str:
222
+ """Build a short diagnostic string for a non-2xx response: the status
223
+ code plus whatever error info the body offers, so failures are
224
+ debuggable from logs instead of just "HTTP 403" with no context."""
225
+ return f"{resp.status_code} (body={body!r})"
226
+
227
+
228
+ def _parse_json(resp: requests.Response, on_error: type[Exception]) -> dict[str, Any]:
229
+ """Decode a response body as JSON, wrapping decode failures in `on_error`."""
230
+ try:
231
+ return resp.json()
232
+ except ValueError as exc:
233
+ raise on_error(f"Response was not valid JSON: {exc}") from exc
234
+
235
+
236
+ def _parse_connections_response(body: dict[str, Any]) -> GlucoseReading:
237
+ """Turn a /llu/connections response body into a GlucoseReading for the
238
+ first connected patient (phase 1 assumes a single sensor wearer)."""
239
+ connections = body.get("data") or []
240
+ if not connections:
241
+ raise StaleDataError("Follower account has no active connections")
242
+
243
+ measurement = connections[0].get("glucoseMeasurement")
244
+ if not measurement:
245
+ raise StaleDataError("Connection has no glucose measurement yet")
246
+
247
+ try:
248
+ value_mgdl = int(measurement["Value"])
249
+ timestamp = _parse_api_timestamp(measurement["Timestamp"])
250
+ is_high = bool(measurement.get("isHigh", False))
251
+ is_low = bool(measurement.get("isLow", False))
252
+ trend = TrendArrow.from_api_value(measurement.get("TrendArrow"))
253
+ except (KeyError, ValueError, TypeError) as exc:
254
+ raise StaleDataError(f"Malformed glucose measurement: {exc}") from exc
255
+
256
+ if datetime.now() - timestamp > _STALE_AFTER:
257
+ raise StaleDataError(f"Latest reading is older than {_STALE_AFTER}")
258
+
259
+ return GlucoseReading(
260
+ value_mgdl=value_mgdl,
261
+ trend=trend,
262
+ timestamp=timestamp,
263
+ is_high=is_high,
264
+ is_low=is_low,
265
+ )
266
+
267
+
268
+ def _parse_api_timestamp(raw: str) -> datetime:
269
+ """Parse the API's timestamp format, e.g. '9/16/2026 3:04:12 PM'.
270
+
271
+ The API does not include timezone info, so this is naive local time -
272
+ consistent with `datetime.now()` as long as both run in the same
273
+ timezone, which is fine for our staleness check.
274
+ """
275
+ return datetime.strptime(raw, "%m/%d/%Y %I:%M:%S %p")
@@ -0,0 +1 @@
1
+ """User settings (TOML file) and credential storage (system keyring)."""
@@ -0,0 +1,64 @@
1
+ """Manages the XDG autostart entry that launches this app at login.
2
+
3
+ Installing/removing `~/.config/autostart/glucoglance.desktop` is exactly
4
+ what enables/disables autostart. That path is the standard XDG autostart
5
+ location on Ubuntu (and GNOME/most other Linux desktops generally), but
6
+ this project is only built and tested against Ubuntu.
7
+ """
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from glucoglance.ui.app_icon import app_icon_path
13
+
14
+ _AUTOSTART_DIR = Path.home() / ".config" / "autostart"
15
+ _DESKTOP_FILE_NAME = "glucoglance.desktop"
16
+
17
+ # Cosmetic only: GNOME's AppIndicator extension inserts new tray icons
18
+ # ahead of existing ones rather than appending, so this delay lets other
19
+ # autostart tray apps register first, aiming to land us at the left edge
20
+ # of the right-side icon group. Not user-configurable; tune by hand.
21
+ #
22
+ # Applied via X-GNOME-Autostart-Delay (native gnome-session-binary key),
23
+ # not a shell-wrapped Exec=. A `sh -c "sleep N && exec \"...\""` version
24
+ # passed desktop-file-validate but was rejected at real login by both
25
+ # systemd-xdg-autostart-generator and gnome-session-binary (confirmed via
26
+ # journalctl) - their Exec= parsers are stricter than the validator.
27
+ _STARTUP_DELAY_SECONDS = 6
28
+
29
+
30
+ def autostart_desktop_path() -> Path:
31
+ """Where the autostart entry lives, whether or not it's installed."""
32
+ return _AUTOSTART_DIR / _DESKTOP_FILE_NAME
33
+
34
+
35
+ def set_autostart_enabled(enabled: bool) -> None:
36
+ """Install or remove the autostart entry to match `enabled`."""
37
+ if enabled:
38
+ _AUTOSTART_DIR.mkdir(parents=True, exist_ok=True)
39
+ autostart_desktop_path().write_text(_desktop_entry_content())
40
+ else:
41
+ autostart_desktop_path().unlink(missing_ok=True)
42
+
43
+
44
+ def _desktop_entry_content() -> str:
45
+ """Build the .desktop file content.
46
+
47
+ Exec points at the actual installed script next to the currently
48
+ running interpreter, not just the bare command name "glucoglance" -
49
+ that name isn't guaranteed to be on PATH in a login session (e.g. when
50
+ installed into a plain venv rather than via pipx). See
51
+ `_STARTUP_DELAY_SECONDS` above for why the delay is a separate key
52
+ rather than part of this Exec= value.
53
+ """
54
+ exec_path = Path(sys.executable).with_name("glucoglance")
55
+ return (
56
+ "[Desktop Entry]\n"
57
+ "Type=Application\n"
58
+ "Name=GlucoGlance\n"
59
+ f"Exec={exec_path}\n"
60
+ f"Icon={app_icon_path()}\n"
61
+ "X-GNOME-Autostart-enabled=true\n"
62
+ f"X-GNOME-Autostart-Delay={_STARTUP_DELAY_SECONDS}\n"
63
+ "Comment=Shows current blood glucose reading in the top bar\n"
64
+ )
@@ -0,0 +1,27 @@
1
+ """LibreLinkUp password storage via the system keyring (GNOME Keyring on Ubuntu).
2
+
3
+ The account email is treated as a non-secret identifier and lives in
4
+ `config.settings`; only the password goes through here.
5
+ """
6
+
7
+ import keyring
8
+
9
+ _SERVICE_NAME = "glucoglance"
10
+
11
+
12
+ def get_password(email: str) -> str | None:
13
+ """Return the stored password for `email`, or None if none is stored."""
14
+ return keyring.get_password(_SERVICE_NAME, email)
15
+
16
+
17
+ def set_password(email: str, password: str) -> None:
18
+ """Store `password` for `email` in the system keyring."""
19
+ keyring.set_password(_SERVICE_NAME, email, password)
20
+
21
+
22
+ def delete_password(email: str) -> None:
23
+ """Remove the stored password for `email`, if any (used by "Log out")."""
24
+ try:
25
+ keyring.delete_password(_SERVICE_NAME, email)
26
+ except keyring.errors.PasswordDeleteError:
27
+ pass # nothing was stored for this email - already effectively logged out
@@ -0,0 +1,102 @@
1
+ """User-facing settings, stored as TOML at an XDG-compliant config path.
2
+
3
+ Only non-secret configuration lives here (polling interval, display unit,
4
+ which UI frontend to use, the account email, a cached region host to skip
5
+ LibreLinkUp's redirect round trip, the glucose-range thresholds/colors the
6
+ tray icon uses, and whether to start at login). The account password is
7
+ never written to this file - see `config.credentials` for that.
8
+ """
9
+
10
+ import os
11
+ import tomllib
12
+ from dataclasses import asdict, dataclass
13
+ from pathlib import Path
14
+
15
+ import tomli_w
16
+
17
+ from glucoglance.domain.range import DEFAULT_HIGH_THRESHOLD_MGDL, DEFAULT_LOW_THRESHOLD_MGDL
18
+ from glucoglance.domain.units import GlucoseUnit
19
+
20
+ _APP_DIR_NAME = "glucoglance"
21
+ _CONFIG_FILE_NAME = "config.toml"
22
+
23
+ # Defaults for the tray icon's range colors, as '#rrggbb' hex - the format
24
+ # stored in config.toml and understood by ui.icon_renderer.parse_hex_color.
25
+ _DEFAULT_COLOR_LOW = "#ED4343"
26
+ _DEFAULT_COLOR_NORMAL = "#4DCC66"
27
+ _DEFAULT_COLOR_HIGH = "#F5D334"
28
+
29
+
30
+ @dataclass
31
+ class Settings:
32
+ """The full set of user-configurable, non-secret settings."""
33
+
34
+ interval_seconds: int = 60
35
+ unit: GlucoseUnit = GlucoseUnit.MGDL
36
+ frontend: str = "tray"
37
+ account_email: str | None = None
38
+ base_host: str | None = None
39
+ low_threshold_mgdl: int = DEFAULT_LOW_THRESHOLD_MGDL
40
+ high_threshold_mgdl: int = DEFAULT_HIGH_THRESHOLD_MGDL
41
+ color_low: str = _DEFAULT_COLOR_LOW
42
+ color_normal: str = _DEFAULT_COLOR_NORMAL
43
+ color_high: str = _DEFAULT_COLOR_HIGH
44
+ autostart_enabled: bool = True
45
+
46
+ def to_toml_dict(self) -> dict:
47
+ """Convert to a plain dict of TOML-serializable types.
48
+
49
+ TOML has no `null`: fields that are still unset (`account_email`,
50
+ `base_host` before first login) are omitted entirely rather than
51
+ written as None, which `tomli_w` would reject.
52
+ """
53
+ data = asdict(self)
54
+ data["unit"] = self.unit.value
55
+ return {key: value for key, value in data.items() if value is not None}
56
+
57
+ @classmethod
58
+ def from_toml_dict(cls, data: dict) -> "Settings":
59
+ """Build a Settings from a dict loaded from TOML, tolerating missing keys."""
60
+ defaults = cls()
61
+ return cls(
62
+ interval_seconds=data.get("interval_seconds", defaults.interval_seconds),
63
+ unit=GlucoseUnit(data.get("unit", defaults.unit.value)),
64
+ frontend=data.get("frontend", defaults.frontend),
65
+ account_email=data.get("account_email"),
66
+ base_host=data.get("base_host"),
67
+ low_threshold_mgdl=data.get("low_threshold_mgdl", defaults.low_threshold_mgdl),
68
+ high_threshold_mgdl=data.get("high_threshold_mgdl", defaults.high_threshold_mgdl),
69
+ color_low=data.get("color_low", defaults.color_low),
70
+ color_normal=data.get("color_normal", defaults.color_normal),
71
+ color_high=data.get("color_high", defaults.color_high),
72
+ autostart_enabled=data.get("autostart_enabled", defaults.autostart_enabled),
73
+ )
74
+
75
+
76
+ def config_dir() -> Path:
77
+ """The directory settings live in, honoring $XDG_CONFIG_HOME if set."""
78
+ xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
79
+ base = Path(xdg_config_home) if xdg_config_home else Path.home() / ".config"
80
+ return base / _APP_DIR_NAME
81
+
82
+
83
+ def config_path() -> Path:
84
+ """The full path to the settings TOML file."""
85
+ return config_dir() / _CONFIG_FILE_NAME
86
+
87
+
88
+ def load_settings() -> Settings:
89
+ """Load settings from disk, or return defaults if the file doesn't exist yet."""
90
+ path = config_path()
91
+ if not path.exists():
92
+ return Settings()
93
+ with path.open("rb") as f:
94
+ data = tomllib.load(f)
95
+ return Settings.from_toml_dict(data)
96
+
97
+
98
+ def save_settings(settings: Settings) -> None:
99
+ """Write settings to disk, creating the config directory if needed."""
100
+ config_dir().mkdir(parents=True, exist_ok=True)
101
+ with config_path().open("wb") as f:
102
+ tomli_w.dump(settings.to_toml_dict(), f)
@@ -0,0 +1 @@
1
+ """Domain types: GlucoseReading, TrendArrow, and unit conversion. Pure data, no I/O."""
@@ -0,0 +1,38 @@
1
+ """Classifies a glucose value into a coarse clinical range (low/normal/high).
2
+
3
+ Thresholds are always in mg/dL, matching GlucoseReading's canonical storage
4
+ unit. The values here are just the defaults - `config.settings.Settings`
5
+ carries the user's actual configured thresholds, so they can be tuned via
6
+ config.toml without a code change. For now this only drives the tray
7
+ icon's color; phase 2's threshold alarms will likely want their own,
8
+ separately configurable thresholds rather than assuming these exact
9
+ cutoffs.
10
+ """
11
+
12
+ from enum import Enum
13
+
14
+ DEFAULT_LOW_THRESHOLD_MGDL = 80
15
+ DEFAULT_HIGH_THRESHOLD_MGDL = 180
16
+
17
+
18
+ class GlucoseRange(Enum):
19
+ """A coarse classification of a glucose value."""
20
+
21
+ LOW = "low"
22
+ NORMAL = "normal"
23
+ HIGH = "high"
24
+
25
+
26
+ def classify_mgdl(
27
+ value_mgdl: float,
28
+ *,
29
+ low_threshold: float = DEFAULT_LOW_THRESHOLD_MGDL,
30
+ high_threshold: float = DEFAULT_HIGH_THRESHOLD_MGDL,
31
+ ) -> GlucoseRange:
32
+ """Classify a glucose value (given in mg/dL) as low, normal, or high,
33
+ against the given thresholds (defaulting to this module's defaults)."""
34
+ if value_mgdl < low_threshold:
35
+ return GlucoseRange.LOW
36
+ if value_mgdl > high_threshold:
37
+ return GlucoseRange.HIGH
38
+ return GlucoseRange.NORMAL
@@ -0,0 +1,29 @@
1
+ """The GlucoseReading model: a single point-in-time glucose measurement."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+
6
+ from glucoglance.domain.trend import TrendArrow
7
+ from glucoglance.domain.units import GlucoseUnit, mgdl_to_mmol
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class GlucoseReading:
12
+ """A single glucose measurement.
13
+
14
+ The value is always stored in mg/dL (the unit the API returns), so that
15
+ conversion logic lives only in `value_in()` / `domain.units` and is never
16
+ duplicated between the client and the UI.
17
+ """
18
+
19
+ value_mgdl: int
20
+ trend: TrendArrow
21
+ timestamp: datetime
22
+ is_high: bool
23
+ is_low: bool
24
+
25
+ def value_in(self, unit: GlucoseUnit) -> float:
26
+ """Return this reading's value converted to the requested display unit."""
27
+ if unit is GlucoseUnit.MGDL:
28
+ return float(self.value_mgdl)
29
+ return mgdl_to_mmol(self.value_mgdl)
@@ -0,0 +1,41 @@
1
+ """The glucose trend arrow, as reported by the LibreLinkUp API.
2
+
3
+ The API encodes the trend as a small integer (1-5). We translate that into a
4
+ named enum so the rest of the codebase never has to remember what "3" means,
5
+ and attach the display glyph used by the tray label directly to each value.
6
+ """
7
+
8
+ from enum import Enum
9
+
10
+
11
+ class TrendArrow(Enum):
12
+ """Direction glucose is currently moving, from the wearer's sensor."""
13
+
14
+ UNKNOWN = 0
15
+ RAPIDLY_FALLING = 1
16
+ FALLING = 2
17
+ STABLE = 3
18
+ RISING = 4
19
+ RAPIDLY_RISING = 5
20
+
21
+ @property
22
+ def glyph(self) -> str:
23
+ """A single-character arrow suitable for a compact tray label."""
24
+ return {
25
+ TrendArrow.UNKNOWN: "?",
26
+ TrendArrow.RAPIDLY_FALLING: "⇊", # ⇊
27
+ TrendArrow.FALLING: "↓", # ↓
28
+ TrendArrow.STABLE: "→", # →
29
+ TrendArrow.RISING: "↑", # ↑
30
+ TrendArrow.RAPIDLY_RISING: "⇈", # ⇈
31
+ }[self]
32
+
33
+ @classmethod
34
+ def from_api_value(cls, raw: object) -> "TrendArrow":
35
+ """Map the API's raw trend integer to a TrendArrow, defaulting to UNKNOWN
36
+ for anything missing or out of the 1-5 range (the API is unofficial and
37
+ could send us something we don't recognize)."""
38
+ try:
39
+ return cls(int(raw)) # type: ignore[arg-type]
40
+ except (TypeError, ValueError):
41
+ return cls.UNKNOWN