Skip to content

Commit 07b4cdb

Browse files
committed
Add Glucose Monitor Widget
1 parent 8eadfa1 commit 07b4cdb

File tree

5 files changed

+361
-0
lines changed

5 files changed

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

0 commit comments

Comments
 (0)