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.
- glucoglance/__init__.py +11 -0
- glucoglance/alerts/__init__.py +1 -0
- glucoglance/alerts/base.py +7 -0
- glucoglance/assets/icon.png +0 -0
- glucoglance/client/__init__.py +1 -0
- glucoglance/client/errors.py +28 -0
- glucoglance/client/librelinkup.py +275 -0
- glucoglance/config/__init__.py +1 -0
- glucoglance/config/autostart.py +64 -0
- glucoglance/config/credentials.py +27 -0
- glucoglance/config/settings.py +102 -0
- glucoglance/domain/__init__.py +1 -0
- glucoglance/domain/range.py +38 -0
- glucoglance/domain/reading.py +29 -0
- glucoglance/domain/trend.py +41 -0
- glucoglance/domain/units.py +29 -0
- glucoglance/main.py +205 -0
- glucoglance/poller/__init__.py +1 -0
- glucoglance/poller/poller.py +136 -0
- glucoglance/ui/__init__.py +1 -0
- glucoglance/ui/about_dialog.py +49 -0
- glucoglance/ui/app_icon.py +17 -0
- glucoglance/ui/credential_prompt.py +80 -0
- glucoglance/ui/disclaimer.py +14 -0
- glucoglance/ui/display.py +33 -0
- glucoglance/ui/icon_renderer.py +87 -0
- glucoglance/ui/tray.py +243 -0
- glucoglance-0.3.0.dist-info/METADATA +327 -0
- glucoglance-0.3.0.dist-info/RECORD +33 -0
- glucoglance-0.3.0.dist-info/WHEEL +5 -0
- glucoglance-0.3.0.dist-info/entry_points.txt +2 -0
- glucoglance-0.3.0.dist-info/licenses/LICENSE +21 -0
- glucoglance-0.3.0.dist-info/top_level.txt +1 -0
glucoglance/__init__.py
ADDED
|
@@ -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
|