Skip to content

Commit ad6feb5

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

File tree

5 files changed

+370
-0
lines changed

5 files changed

+370
-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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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+
| `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 |
10+
| `direction_icons` | dict | `{"double_up": '⬆️⬆️', "single_up": '⬆️', "forty_five_up": '↗️', "flat": '➡️', "forty_five_down": '↘️', "single_down": '⬇️', "double_down": '⬇️⬇️'}` | Direction icon settings |
11+
| `sgv_measurement_units` | string | `mg/dl` | SGV measurement units can be `mg/dl` or `mmol/l` |
12+
| `callbacks` | dict | `{"on_left": "open_cgm", "on_middle": "do_nothing", "on_right": "do_nothing"}` | Callbacks for mouse events on the glucose monitor widget |
13+
14+
## Example Configuration
15+
16+
```yaml
17+
glucose_monitor:
18+
type: "yasb.glucose_monitor.GlucoseMonitor"
19+
options:
20+
label: "🩸$sgv$direction"
21+
tooltip: "($sgv_delta) $delta_time_in_minutes min"
22+
host: "https://your-domain.com"
23+
secret: "your-secret"
24+
sgv_measurement_units: "mg/dl"
25+
```
26+
27+
## Description of Options
28+
29+
- **label:** The format string for the widget.
30+
- **tooltip:** The format string for the tooltip
31+
- **host:** The URL for the CGM
32+
- **secret:** The secret key for the CGM API
33+
- **direction_icons:** Direction icon settings
34+
- **sgv_measurement_units:** SGV measurement units can be `mg/dl` or `mmol/l`
35+
- **callbacks:** Callbacks for mouse events on the glucose monitor widget
36+
37+
38+
## Example Style
39+
```css
40+
.cgm-widget {
41+
padding: 0 4px 0 4px;
42+
}
43+
.cgm-widget .widget-container {
44+
}
45+
.cgm-widget .label {
46+
}
47+
```
48+
49+
## Preview of the Widget
50+
![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)