Skip to content

Commit 908aa69

Browse files
committed
Add Glucose Monitor Widget
1 parent 8eadfa1 commit 908aa69

File tree

5 files changed

+356
-0
lines changed

5 files changed

+356
-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: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import datetime
2+
import hashlib
3+
import json
4+
import os
5+
import re
6+
import urllib.request
7+
import webbrowser
8+
from typing import Callable
9+
10+
from PyQt6.QtCore import QThread, QTimer, pyqtSignal
11+
from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel
12+
13+
from core.utils.tooltip import set_tooltip
14+
from core.utils.utilities import ToastNotifier, build_widget_label
15+
from core.validation.widgets.yasb.glucose_monitor import VALIDATION_SCHEMA
16+
from core.widgets.base import BaseWidget
17+
from settings import SCRIPT_PATH
18+
19+
20+
class GlucoseMonitorWorker(QThread):
21+
@classmethod
22+
def get_instance(cls):
23+
return cls()
24+
25+
status_updated = pyqtSignal(int, float, str, str)
26+
error_signal = pyqtSignal(str)
27+
28+
def __init__(self, parent=None) -> None:
29+
super().__init__(parent)
30+
31+
self._url: str | None = None
32+
self.running = True
33+
34+
def set_url(self, host: str, secret: str) -> None:
35+
secret_hash = hashlib.sha1(secret.encode()).hexdigest()
36+
self._url = f"{host}/api/v1/entries/current.json?secret={secret_hash}"
37+
38+
def stop(self) -> None:
39+
self.running = False
40+
self.wait()
41+
42+
def run(self) -> None:
43+
if not self.running:
44+
return
45+
46+
try:
47+
with urllib.request.urlopen(self._url) as response:
48+
data = json.loads(response.read().decode("utf-8"))
49+
status = response.status
50+
51+
if status != 200:
52+
raise RuntimeError(f"Response status code should be 200 but got {status}")
53+
54+
resp_json = data[0]
55+
self.status_updated.emit(
56+
resp_json["sgv"],
57+
resp_json["delta"],
58+
resp_json["dateString"],
59+
resp_json["direction"],
60+
)
61+
except Exception as e:
62+
self.error_signal.emit(str(e))
63+
64+
65+
class GlucoseMonitor(BaseWidget):
66+
validation_schema = VALIDATION_SCHEMA
67+
68+
update_interval_in_milliseconds = 1 * 60 * 1_000
69+
datetime_format = "%Y-%m-%dT%H:%M:%S.%f%z"
70+
71+
direction_icons_mapping = {
72+
"double_up": "DoubleUp",
73+
"single_up": "SingleUp",
74+
"forty_five_up": "FortyFiveUp",
75+
"flat": "Flat",
76+
"forty_five_down": "FortyFiveDown",
77+
"single_down": "SingleDown",
78+
"double_down": "DoubleDown",
79+
}
80+
81+
def __init__(
82+
self,
83+
label: str,
84+
tooltip: str,
85+
host: str,
86+
secret: str,
87+
secret_env_name: str,
88+
direction_icons: dict[str, str],
89+
sgv_measurement_units: str,
90+
callbacks: dict[str, str],
91+
) -> None:
92+
super().__init__(timer_interval=self.update_interval_in_milliseconds, class_name="cgm-widget")
93+
94+
self._label_content = label
95+
self._tooltip = tooltip
96+
self._host = host
97+
self._secret = secret != "env" and secret or os.getenv(secret_env_name)
98+
99+
self._direction_icons = {self.direction_icons_mapping[key]: value for key, value in direction_icons.items()}
100+
101+
self._sgv_measurement_units = sgv_measurement_units
102+
self._available_sgv_measurement_units: dict[str, Callable[[int | float], str]] = {
103+
"mg/dl": lambda sgv: str(round(sgv)),
104+
"mmol/l": lambda sgv: f"{sgv / 18:.1f}",
105+
}
106+
self._icon_path = os.path.join(SCRIPT_PATH, "assets", "images", "app_transparent.png")
107+
self._status_data = {}
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+
build_widget_label(self, self._label_content)
118+
119+
self.register_callback("open_cgm", self._open_cgm)
120+
121+
self.callback_left = callbacks["on_left"]
122+
self.callback_right = callbacks["on_right"]
123+
self.callback_middle = callbacks["on_middle"]
124+
125+
self._worker = GlucoseMonitorWorker.get_instance()
126+
self._worker.set_url(self._host, self._secret)
127+
self._worker.status_updated.connect(self._handle_status_update)
128+
self._worker.error_signal.connect(self._handle_error_signal)
129+
130+
self._update_timer = QTimer()
131+
self._update_timer.timeout.connect(self._worker.start)
132+
self._update_timer.start(self.update_interval_in_milliseconds)
133+
134+
self._worker.start()
135+
136+
def _open_cgm(self) -> None:
137+
webbrowser.open(self._host)
138+
139+
def _update_label(self) -> None:
140+
active_widgets = self._widgets
141+
active_label_content = self._label_content
142+
label_parts = re.split("(<span.*?>.*?</span>)", active_label_content)
143+
label_parts = list(filter(None, label_parts))
144+
widget_index = 0
145+
146+
for part in label_parts:
147+
part = part.strip()
148+
if not part or widget_index >= len(active_widgets) or not isinstance(active_widgets[widget_index], QLabel):
149+
continue
150+
151+
if "<span" in part and "</span>" in part:
152+
icon = re.sub(r"<span.*?>|</span>", "", part).strip()
153+
active_widgets[widget_index].setText(icon)
154+
else:
155+
formatted_text = part.format_map(self._status_data)
156+
active_widgets[widget_index].setText(formatted_text)
157+
widget_index += 1
158+
159+
if self._tooltip:
160+
set_tooltip(
161+
widget=self._widget_container,
162+
text=self._tooltip.format_map(self._status_data),
163+
)
164+
165+
def _handle_error_signal(self, message: str) -> None:
166+
toaster = ToastNotifier()
167+
toaster.show(self._icon_path, "Glucose Monitor", message)
168+
169+
def _handle_status_update(
170+
self,
171+
sgv: int,
172+
sgv_delta: float,
173+
date_string: str,
174+
direction: str,
175+
) -> None:
176+
now = datetime.datetime.now(tz=datetime.timezone.utc)
177+
last_update_time = datetime.datetime.strptime(date_string, self.datetime_format)
178+
delta_time_in_minutes = int((now - last_update_time).total_seconds() // 60)
179+
direction = self._direction_icons[direction]
180+
181+
if not (convert_sgv := self._available_sgv_measurement_units.get(self._sgv_measurement_units)):
182+
self._handle_error_signal("Wrong measurement units")
183+
184+
sgv = convert_sgv(sgv)
185+
sgv_delta = convert_sgv(sgv_delta)
186+
187+
self._status_data = {
188+
"sgv": sgv,
189+
"sgv_delta": sgv_delta,
190+
"delta_time_in_minutes": delta_time_in_minutes,
191+
"direction": direction,
192+
}
193+
self._update_label()

0 commit comments

Comments
 (0)