Skip to content

Commit 41a94c1

Browse files
committed
Add Glucose Monitor Widget
1 parent 8eadfa1 commit 41a94c1

File tree

5 files changed

+379
-0
lines changed

5 files changed

+379
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ https://github.com/user-attachments/assets/aab8d8e8-248f-46a1-919c-9b0601236ac1
9999
- **[GlazeWM Binding Mode](https://github.com/amnweb/yasb/wiki/(Widget)-GlazeWM-Binding-Mode)**: GlazeWM binding mode widget.
100100
- **[GlazeWM Tiling Direction](https://github.com/amnweb/yasb/wiki/(Widget)-GlazeWM-Tiling-Direction)**: GlazeWM tiling direction widget.
101101
- **[GlazeWM Workspaces](https://github.com/amnweb/yasb/wiki/(Widget)-GlazeWM-Workspaces)**: GlazeWM workspaces widget.
102+
- **[Glucose Monitor](https://github.com/amnweb/yasb/wiki/(Widget)-Glucose-Monitor)**: Nightscout CGM Widget.
102103
- **[Grouper](https://github.com/amnweb/yasb/wiki/(Widget)-Grouper)**: Groups multiple widgets together in a container.
103104
- **[GPU](https://github.com/amnweb/yasb/wiki/(Widget)-GPU)**: Displays GPU utilization, temperature, and memory usage.
104105
- **[Home](https://github.com/amnweb/yasb/wiki/(Widget)-Home)**: A customizable home widget menu.

docs/assets/glucose_monitor_01.png

17.9 KB
Loading
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Glucose Monitor Widget
2+
3+
Nightscout (also known as CGM in the Cloud) is an open-source cloud application used by people with diabetes and parents of kids with diabetes to visualize, store and share the data from their Continuous Glucose Monitoring sensors in real-time. Once setup, Nightscout acts as a central repository of blood glucose and insulin dosing/treatment data for a single person, allowing you to view the CGM graph and treatment data anywhere using just a web browser connected to the internet.
4+
5+
There are several parts to this system. You need somewhere online to store, process and visualize this data (a Nightscout Site), something to upload CGM data to your Nightscout (an Uploader), and then optionally you can use other devices to access or view this data (one - or more - Follower).
6+
7+
Go to [Nightscout documentation](https://nightscout.github.io/nightscout/new_user/) for the details.
8+
9+
This widget allows you to monitor someone's blood sugar level through [Nightscout CGM remote monitor](https://github.com/nightscout/cgm-remote-monitor) API.
10+
11+
12+
| Option | Type | Default | Description |
13+
|-------------------------|--------|------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
14+
| `label` | string | `🩸$sgv$direction` | The format string for the widget. |
15+
| `tooltip` | string | `($sgv_delta) $delta_time_in_minutes min` | The format string for the tooltip. |
16+
| `host` | string | `...` | The URL for your [Nightscout CGM remote monitor](https://github.com/nightscout/cgm-remote-monitor). |
17+
| `secret` | string | `...` | The secret key for the CGM API. |
18+
| `secret_env_name` | string | `...` | If the secret variable is equals to `env` then widget will try to get secret from the environment variable with a name of the `secret_env_name` value. |
19+
| `direction_icons` | dict | `{"double_up": '⬆️⬆️', "single_up": '⬆️', "forty_five_up": '↗️', "flat": '➡️', "forty_five_down": '↘️', "single_down": '⬇️', "double_down": '⬇️⬇️'}` | Direction icon settings. |
20+
| `sgv_measurement_units` | string | `mg/dl` | SGV measurement units can be `mg/dl` or `mmol/l`. |
21+
| `callbacks` | dict | `{"on_left": "open_cgm", "on_middle": "do_nothing", "on_right": "do_nothing"}` | Callbacks for mouse events on the glucose monitor widget. |
22+
23+
## Example Configuration
24+
25+
```yaml
26+
glucose_monitor:
27+
type: "yasb.glucose_monitor.GlucoseMonitor"
28+
options:
29+
label: "🩸$sgv$direction"
30+
tooltip: "($sgv_delta) $delta_time_in_minutes min"
31+
host: "https://your-domain.com"
32+
secret: "your-secret"
33+
sgv_measurement_units: "mg/dl"
34+
```
35+
36+
## Description of Options
37+
38+
- **label:** The format string for the widget.
39+
- **tooltip:** The format string for the tooltip
40+
- **host:** The URL for the CGM
41+
- **secret:** The secret key for the CGM API
42+
- **direction_icons:** Direction icon settings
43+
- **sgv_measurement_units:** SGV measurement units can be `mg/dl` or `mmol/l`
44+
- **callbacks:** Callbacks for mouse events on the glucose monitor widget
45+
46+
47+
## Example Style
48+
```css
49+
.cgm-widget {
50+
padding: 0 4px 0 4px;
51+
}
52+
.cgm-widget .widget-container {
53+
}
54+
.cgm-widget .label {
55+
}
56+
```
57+
58+
## Preview of the Widget
59+
![Glucose Monitor YASB Widget](assets/glucose_monitor_01.png)
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
DEFAULTS = {
2+
"label": "🩸$sgv$direction",
3+
"tooltip": "($sgv_delta) $delta_time_in_minutes min",
4+
"host": "",
5+
"secret": "",
6+
"secret_env_name": "",
7+
"direction_icons": {
8+
"double_up": "⬆️⬆️",
9+
"single_up": "⬆️",
10+
"forty_five_up": "↗️",
11+
"flat": "➡️",
12+
"forty_five_down": "↘️",
13+
"single_down": "⬇️",
14+
"double_down": "⬇️⬇️",
15+
},
16+
"sgv_measurement_units": "mg/dl", # "mg/dl" or "mmol/l"
17+
"callbacks": {"on_left": "open_cgm", "on_middle": "do_nothing", "on_right": "do_nothing"},
18+
"container_padding": {"top": 0, "left": 0, "bottom": 0, "right": 0},
19+
}
20+
21+
VALIDATION_SCHEMA = {
22+
"label": {
23+
"type": "string",
24+
"required": False,
25+
"default": DEFAULTS["label"],
26+
},
27+
"tooltip": {
28+
"type": "string",
29+
"required": False,
30+
"default": DEFAULTS["tooltip"],
31+
},
32+
"host": {
33+
"type": "string",
34+
"default": DEFAULTS["host"],
35+
},
36+
"secret": {
37+
"type": "string",
38+
"default": DEFAULTS["secret"],
39+
},
40+
"secret_env_name": {
41+
"type": "string",
42+
"required": False,
43+
"default": DEFAULTS["secret_env_name"],
44+
},
45+
"direction_icons": {
46+
"type": "dict",
47+
"required": False,
48+
"schema": {
49+
"double_up": {
50+
"type": "string",
51+
"default": DEFAULTS["direction_icons"]["double_up"],
52+
},
53+
"single_up": {
54+
"type": "string",
55+
"default": DEFAULTS["direction_icons"]["single_up"],
56+
},
57+
"forty_five_up": {
58+
"type": "string",
59+
"default": DEFAULTS["direction_icons"]["forty_five_up"],
60+
},
61+
"flat": {
62+
"type": "string",
63+
"default": DEFAULTS["direction_icons"]["flat"],
64+
},
65+
"forty_five_down": {
66+
"type": "string",
67+
"default": DEFAULTS["direction_icons"]["forty_five_down"],
68+
},
69+
"single_down": {
70+
"type": "string",
71+
"default": DEFAULTS["direction_icons"]["single_down"],
72+
},
73+
"double_down": {
74+
"type": "string",
75+
"default": DEFAULTS["direction_icons"]["double_down"],
76+
},
77+
},
78+
"default": DEFAULTS["direction_icons"],
79+
},
80+
"sgv_measurement_units": {
81+
"type": "string",
82+
"required": False,
83+
"default": DEFAULTS["sgv_measurement_units"],
84+
},
85+
"callbacks": {
86+
"type": "dict",
87+
"schema": {
88+
"on_left": {
89+
"type": "string",
90+
"default": DEFAULTS["callbacks"]["on_left"],
91+
},
92+
"on_middle": {
93+
"type": "string",
94+
"default": DEFAULTS["callbacks"]["on_middle"],
95+
},
96+
"on_right": {
97+
"type": "string",
98+
"default": DEFAULTS["callbacks"]["on_right"],
99+
},
100+
},
101+
"default": DEFAULTS["callbacks"],
102+
},
103+
"container_padding": {
104+
"type": "dict",
105+
"required": False,
106+
"schema": {
107+
"top": {"type": "integer", "default": DEFAULTS["container_padding"]["top"]},
108+
"left": {"type": "integer", "default": DEFAULTS["container_padding"]["left"]},
109+
"bottom": {"type": "integer", "default": DEFAULTS["container_padding"]["bottom"]},
110+
"right": {"type": "integer", "default": DEFAULTS["container_padding"]["right"]},
111+
},
112+
"default": DEFAULTS["container_padding"],
113+
},
114+
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import datetime
2+
import hashlib
3+
import json
4+
import os
5+
import urllib.request
6+
import webbrowser
7+
from typing import Callable
8+
9+
from PyQt6.QtCore import QThread, QTimer, pyqtSignal
10+
from PyQt6.QtWidgets import QFrame, QHBoxLayout
11+
12+
from core.utils.tooltip import set_tooltip
13+
from core.utils.utilities import build_widget_label
14+
from core.validation.widgets.yasb.glucose_monitor import VALIDATION_SCHEMA
15+
from core.widgets.base import BaseWidget
16+
17+
18+
class GlucoseMonitorWorker(QThread):
19+
@classmethod
20+
def get_instance(cls):
21+
return cls()
22+
23+
status_updated = pyqtSignal(int, float, str, str)
24+
error_signal = pyqtSignal(str)
25+
26+
def __init__(self, parent=None) -> None:
27+
super().__init__(parent)
28+
29+
self._url: str | None = None
30+
self.running = True
31+
32+
def set_url(self, host: str, secret: str) -> None:
33+
secret_hash = hashlib.sha1(secret.encode()).hexdigest()
34+
self._url = f"{host}/api/v1/entries/current.json?secret={secret_hash}"
35+
36+
def stop(self) -> None:
37+
self.running = False
38+
self.wait()
39+
40+
def run(self) -> None:
41+
if not self.running:
42+
return
43+
44+
try:
45+
with urllib.request.urlopen(self._url) as response:
46+
data = json.loads(response.read().decode("utf-8"))
47+
status = response.status
48+
49+
if status != 200:
50+
raise RuntimeError(f"Response status code should be 200 but got {status}")
51+
52+
resp_json = data[0]
53+
self.status_updated.emit(
54+
resp_json["sgv"],
55+
resp_json["delta"],
56+
resp_json["dateString"],
57+
resp_json["direction"],
58+
)
59+
except Exception as e:
60+
self.error_signal.emit(str(e))
61+
62+
63+
class GlucoseMonitor(BaseWidget):
64+
validation_schema = VALIDATION_SCHEMA
65+
66+
update_interval_in_milliseconds = 1 * 60 * 1_000
67+
datetime_format = "%Y-%m-%dT%H:%M:%S.%f%z"
68+
69+
direction_icons_mapping = {
70+
"double_up": "DoubleUp",
71+
"single_up": "SingleUp",
72+
"forty_five_up": "FortyFiveUp",
73+
"flat": "Flat",
74+
"forty_five_down": "FortyFiveDown",
75+
"single_down": "SingleDown",
76+
"double_down": "DoubleDown",
77+
}
78+
79+
def __init__(
80+
self,
81+
label: str,
82+
tooltip: str,
83+
host: str,
84+
secret: str,
85+
secret_env_name: str,
86+
direction_icons: dict[str, str],
87+
sgv_measurement_units: str,
88+
callbacks: dict[str, str],
89+
container_padding: dict[str, str],
90+
) -> None:
91+
super().__init__(timer_interval=self.update_interval_in_milliseconds, class_name="cgm-widget")
92+
93+
self._label_content = label
94+
self._label_template = label
95+
96+
self._tooltip_label_content = tooltip
97+
self._tooltip_label_template = tooltip
98+
99+
self._host = host
100+
self._secret = secret != "env" and secret or os.getenv(secret_env_name)
101+
102+
self._direction_icons = {self.direction_icons_mapping[key]: value for key, value in direction_icons.items()}
103+
104+
self._sgv_measurement_units = sgv_measurement_units
105+
self._available_sgv_measurement_units: dict[str, Callable[[int | float], str]] = {
106+
"mg/dl": lambda sgv: str(round(sgv)),
107+
"mmol/l": lambda sgv: f"{sgv / 18:.1f}",
108+
}
109+
self._padding = container_padding
110+
111+
self._widget_container_layout = QHBoxLayout()
112+
self._widget_container_layout.setSpacing(0)
113+
self._widget_container_layout.setContentsMargins(
114+
self._padding["left"],
115+
self._padding["top"],
116+
self._padding["right"],
117+
self._padding["bottom"],
118+
)
119+
120+
self._widget_container = QFrame()
121+
self._widget_container.setLayout(self._widget_container_layout)
122+
self._widget_container.setProperty("class", "widget-container")
123+
self.widget_layout.addWidget(self._widget_container)
124+
125+
self._label_content = self._label_template.format_map(
126+
{
127+
"sgv": "...",
128+
"sgv_delta": "...",
129+
"delta_time_in_minutes": "..",
130+
"direction": "..",
131+
}
132+
)
133+
build_widget_label(self, self._label_content)
134+
135+
self.register_callback("open_cgm", self._open_cgm)
136+
137+
self.callback_left = callbacks["on_left"]
138+
self.callback_right = callbacks["on_right"]
139+
self.callback_middle = callbacks["on_middle"]
140+
141+
self._worker = GlucoseMonitorWorker.get_instance()
142+
self._worker.set_url(self._host, self._secret)
143+
self._worker.status_updated.connect(self._handle_status_update)
144+
self._worker.error_signal.connect(self._handle_error_signal)
145+
146+
self._update_timer = QTimer()
147+
self._update_timer.timeout.connect(self._worker.start)
148+
self._update_timer.start(self.update_interval_in_milliseconds)
149+
150+
self._worker.start()
151+
152+
def _open_cgm(self) -> None:
153+
webbrowser.open(self._host)
154+
155+
def _update_label(self, text: str) -> None:
156+
self._widgets[0].setText(text)
157+
158+
def _handle_error_signal(self, message: str) -> None:
159+
message = f"❌{message}❌"
160+
self._update_label(message)
161+
set_tooltip(
162+
widget=self._widget_container,
163+
text=message,
164+
)
165+
166+
def _handle_status_update(
167+
self,
168+
sgv: int,
169+
sgv_delta: float,
170+
date_string: str,
171+
direction: str,
172+
) -> None:
173+
now = datetime.datetime.now(tz=datetime.timezone.utc)
174+
last_update_time = datetime.datetime.strptime(date_string, self.datetime_format)
175+
delta_time_in_minutes = int((now - last_update_time).total_seconds() // 60)
176+
direction = self._direction_icons[direction]
177+
178+
if not (convert_sgv := self._available_sgv_measurement_units.get(self._sgv_measurement_units)):
179+
self._handle_error_signal("Wrong measurement units")
180+
181+
sgv = convert_sgv(sgv)
182+
sgv_delta = convert_sgv(sgv_delta)
183+
184+
self._label_content = self._label_template.format_map(
185+
{
186+
"sgv": sgv,
187+
"sgv_delta": sgv_delta,
188+
"delta_time_in_minutes": delta_time_in_minutes,
189+
"direction": direction,
190+
}
191+
)
192+
self._update_label(self._label_content)
193+
194+
self._tooltip_label_content = self._tooltip_label_template.format_map(
195+
{
196+
"sgv": sgv,
197+
"sgv_delta": sgv_delta,
198+
"delta_time_in_minutes": delta_time_in_minutes,
199+
"direction": direction,
200+
}
201+
)
202+
set_tooltip(
203+
widget=self._widget_container,
204+
text=self._tooltip_label_content,
205+
)

0 commit comments

Comments
 (0)