Skip to content

Commit 6fcb3a4

Browse files
committed
Add Glucose Monitor Widget
1 parent 8eadfa1 commit 6fcb3a4

File tree

5 files changed

+360
-0
lines changed

5 files changed

+360
-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: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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 | `\ud83e\ude78{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": "\u2b06\ufe0f\u2b06\ufe0f", "single_up": "\u2b06\ufe0f", "forty_five_up": "\u2197\ufe0f", "flat": "\u27a1\ufe0f", "forty_five_down": "\u2198\ufe0f", "single_down": "\u2b07\ufe0f", "double_down": "\u2b07\ufe0f\u2b07\ufe0f"}` | 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: "\ud83e\ude78{sgv}{direction}"
30+
tooltip: "({sgv_delta}) {delta_time_in_minutes} min"
31+
host: "https://your-domain.com"
32+
secret: "env"
33+
secret_env_name: "YASB_CGM_YOUR_SECRET_ENV_NAME"
34+
sgv_measurement_units: "mg/dl"
35+
```
36+
37+
## Description of Options
38+
39+
- **label:** The format string for the widget.
40+
- **tooltip:** The format string for the tooltip
41+
- **host:** The URL for the CGM
42+
- **secret:** The secret key for the CGM API
43+
- **direction_icons:** Direction icon settings
44+
- **sgv_measurement_units:** SGV measurement units can be `mg/dl` or `mmol/l`
45+
- **callbacks:** Callbacks for mouse events on the glucose monitor widget
46+
47+
48+
## Example Style
49+
```css
50+
.cgm-widget {
51+
padding: 0 4px 0 4px;
52+
}
53+
.cgm-widget .widget-container {
54+
}
55+
.cgm-widget .label {
56+
}
57+
```
58+
59+
## Preview of the Widget
60+
![Glucose Monitor YASB Widget](assets/glucose_monitor_01.png)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
DEFAULTS = {
2+
"label": "\ud83e\ude78{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": "\u2b06\ufe0f\u2b06\ufe0f",
9+
"single_up": "\u2b06\ufe0f",
10+
"forty_five_up": "\u2197\ufe0f",
11+
"flat": "\u27a1\ufe0f",
12+
"forty_five_down": "\u2198\ufe0f",
13+
"single_down": "\u2b07\ufe0f",
14+
"double_down": "\u2b07\ufe0f\u2b07\ufe0f",
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+
}
19+
20+
VALIDATION_SCHEMA = {
21+
"label": {
22+
"type": "string",
23+
"required": False,
24+
"default": DEFAULTS["label"],
25+
},
26+
"tooltip": {
27+
"type": "string",
28+
"required": False,
29+
"default": DEFAULTS["tooltip"],
30+
},
31+
"host": {
32+
"type": "string",
33+
"default": DEFAULTS["host"],
34+
},
35+
"secret": {
36+
"type": "string",
37+
"default": DEFAULTS["secret"],
38+
},
39+
"secret_env_name": {
40+
"type": "string",
41+
"required": False,
42+
"default": DEFAULTS["secret_env_name"],
43+
},
44+
"direction_icons": {
45+
"type": "dict",
46+
"required": False,
47+
"schema": {
48+
"double_up": {
49+
"type": "string",
50+
"default": DEFAULTS["direction_icons"]["double_up"],
51+
},
52+
"single_up": {
53+
"type": "string",
54+
"default": DEFAULTS["direction_icons"]["single_up"],
55+
},
56+
"forty_five_up": {
57+
"type": "string",
58+
"default": DEFAULTS["direction_icons"]["forty_five_up"],
59+
},
60+
"flat": {
61+
"type": "string",
62+
"default": DEFAULTS["direction_icons"]["flat"],
63+
},
64+
"forty_five_down": {
65+
"type": "string",
66+
"default": DEFAULTS["direction_icons"]["forty_five_down"],
67+
},
68+
"single_down": {
69+
"type": "string",
70+
"default": DEFAULTS["direction_icons"]["single_down"],
71+
},
72+
"double_down": {
73+
"type": "string",
74+
"default": DEFAULTS["direction_icons"]["double_down"],
75+
},
76+
},
77+
"default": DEFAULTS["direction_icons"],
78+
},
79+
"sgv_measurement_units": {
80+
"type": "string",
81+
"required": False,
82+
"default": DEFAULTS["sgv_measurement_units"],
83+
},
84+
"callbacks": {
85+
"type": "dict",
86+
"schema": {
87+
"on_left": {
88+
"type": "string",
89+
"default": DEFAULTS["callbacks"]["on_left"],
90+
},
91+
"on_middle": {
92+
"type": "string",
93+
"default": DEFAULTS["callbacks"]["on_middle"],
94+
},
95+
"on_right": {
96+
"type": "string",
97+
"default": DEFAULTS["callbacks"]["on_right"],
98+
},
99+
},
100+
"default": DEFAULTS["callbacks"],
101+
},
102+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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+
) -> None:
90+
super().__init__(timer_interval=self.update_interval_in_milliseconds, class_name="cgm-widget")
91+
92+
self._label_content = label
93+
self._label_template = label
94+
95+
self._tooltip_label_content = tooltip
96+
self._tooltip_label_template = tooltip
97+
98+
self._host = host
99+
self._secret = secret != "env" and secret or os.getenv(secret_env_name)
100+
101+
self._direction_icons = {self.direction_icons_mapping[key]: value for key, value in direction_icons.items()}
102+
103+
self._sgv_measurement_units = sgv_measurement_units
104+
self._available_sgv_measurement_units: dict[str, Callable[[int | float], str]] = {
105+
"mg/dl": lambda sgv: str(round(sgv)),
106+
"mmol/l": lambda sgv: f"{sgv / 18:.1f}",
107+
}
108+
109+
self._widget_container_layout = QHBoxLayout()
110+
self._widget_container_layout.setSpacing(0)
111+
112+
self._widget_container = QFrame()
113+
self._widget_container.setLayout(self._widget_container_layout)
114+
self._widget_container.setProperty("class", "widget-container")
115+
self.widget_layout.addWidget(self._widget_container)
116+
117+
self._label_content = self._label_template.format_map(
118+
{
119+
"sgv": "...",
120+
"sgv_delta": "...",
121+
"delta_time_in_minutes": "..",
122+
"direction": "..",
123+
}
124+
)
125+
build_widget_label(self, self._label_content)
126+
127+
self.register_callback("open_cgm", self._open_cgm)
128+
129+
self.callback_left = callbacks["on_left"]
130+
self.callback_right = callbacks["on_right"]
131+
self.callback_middle = callbacks["on_middle"]
132+
133+
self._worker = GlucoseMonitorWorker.get_instance()
134+
self._worker.set_url(self._host, self._secret)
135+
self._worker.status_updated.connect(self._handle_status_update)
136+
self._worker.error_signal.connect(self._handle_error_signal)
137+
138+
self._update_timer = QTimer()
139+
self._update_timer.timeout.connect(self._worker.start)
140+
self._update_timer.start(self.update_interval_in_milliseconds)
141+
142+
self._worker.start()
143+
144+
def _open_cgm(self) -> None:
145+
webbrowser.open(self._host)
146+
147+
def _update_label(self, text: str) -> None:
148+
self._widgets[0].setText(text)
149+
150+
def _handle_error_signal(self, message: str) -> None:
151+
message = f"❌{message}❌"
152+
self._update_label(message)
153+
set_tooltip(
154+
widget=self._widget_container,
155+
text=message,
156+
)
157+
158+
def _handle_status_update(
159+
self,
160+
sgv: int,
161+
sgv_delta: float,
162+
date_string: str,
163+
direction: str,
164+
) -> None:
165+
now = datetime.datetime.now(tz=datetime.timezone.utc)
166+
last_update_time = datetime.datetime.strptime(date_string, self.datetime_format)
167+
delta_time_in_minutes = int((now - last_update_time).total_seconds() // 60)
168+
direction = self._direction_icons[direction]
169+
170+
if not (convert_sgv := self._available_sgv_measurement_units.get(self._sgv_measurement_units)):
171+
self._handle_error_signal("Wrong measurement units")
172+
173+
sgv = convert_sgv(sgv)
174+
sgv_delta = convert_sgv(sgv_delta)
175+
176+
self._label_content = self._label_template.format_map(
177+
{
178+
"sgv": sgv,
179+
"sgv_delta": sgv_delta,
180+
"delta_time_in_minutes": delta_time_in_minutes,
181+
"direction": direction,
182+
}
183+
)
184+
self._update_label(self._label_content)
185+
186+
self._tooltip_label_content = self._tooltip_label_template.format_map(
187+
{
188+
"sgv": sgv,
189+
"sgv_delta": sgv_delta,
190+
"delta_time_in_minutes": delta_time_in_minutes,
191+
"direction": direction,
192+
}
193+
)
194+
set_tooltip(
195+
widget=self._widget_container,
196+
text=self._tooltip_label_content,
197+
)

0 commit comments

Comments
 (0)