Skip to content

Commit 70ddba4

Browse files
committed
Add Glucose Monitor Widget
Remove requests from dependencies
1 parent 8eadfa1 commit 70ddba4

File tree

5 files changed

+362
-0
lines changed

5 files changed

+362
-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: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import datetime
2+
import hashlib
3+
import json
4+
import urllib.request
5+
import webbrowser
6+
from string import Template
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+
direction_icons: dict[str, str],
86+
sgv_measurement_units: str,
87+
callbacks: dict[str, str],
88+
container_padding: 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 = Template(label)
94+
95+
self._tooltip_label_content = tooltip
96+
self._tooltip_label_template = Template(tooltip)
97+
98+
self._host = host
99+
self._secret = secret
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+
self._padding = container_padding
109+
110+
self._widget_container_layout = QHBoxLayout()
111+
self._widget_container_layout.setSpacing(0)
112+
self._widget_container_layout.setContentsMargins(
113+
self._padding["left"],
114+
self._padding["top"],
115+
self._padding["right"],
116+
self._padding["bottom"],
117+
)
118+
119+
self._widget_container = QFrame()
120+
self._widget_container.setLayout(self._widget_container_layout)
121+
self._widget_container.setProperty("class", "widget-container")
122+
self.widget_layout.addWidget(self._widget_container)
123+
124+
self._label_content = self._label_template.substitute(
125+
{
126+
"sgv": "...",
127+
"sgv_delta": "...",
128+
"delta_time_in_minutes": "..",
129+
"direction": "..",
130+
}
131+
)
132+
build_widget_label(self, self._label_content)
133+
134+
self.register_callback("open_cgm", self._open_cgm)
135+
136+
self.callback_left = callbacks["on_left"]
137+
self.callback_right = callbacks["on_right"]
138+
self.callback_middle = callbacks["on_middle"]
139+
140+
self._worker = GlucoseMonitorWorker.get_instance()
141+
self._worker.set_url(self._host, self._secret)
142+
self._worker.status_updated.connect(self._handle_status_update)
143+
self._worker.error_signal.connect(self._handle_error_signal)
144+
145+
self._update_timer = QTimer()
146+
self._update_timer.timeout.connect(self._worker.start)
147+
self._update_timer.start(self.update_interval_in_milliseconds)
148+
149+
self._worker.start()
150+
151+
def _open_cgm(self) -> None:
152+
webbrowser.open(self._host)
153+
154+
def _update_label(self, text: str) -> None:
155+
self._widgets[0].setText(text)
156+
157+
def _handle_error_signal(self, message: str) -> None:
158+
message = f"❌{message}❌"
159+
self._update_label(message)
160+
set_tooltip(
161+
widget=self._widget_container,
162+
text=message,
163+
)
164+
165+
def _handle_status_update(
166+
self,
167+
sgv: int,
168+
sgv_delta: float,
169+
date_string: str,
170+
direction: str,
171+
) -> None:
172+
now = datetime.datetime.now(tz=datetime.timezone.utc)
173+
last_update_time = datetime.datetime.strptime(date_string, self.datetime_format)
174+
delta_time_in_minutes = int((now - last_update_time).total_seconds() // 60)
175+
direction = self._direction_icons[direction]
176+
177+
if not (convert_sgv := self._available_sgv_measurement_units.get(self._sgv_measurement_units)):
178+
self._handle_error_signal("Wrong measurement units")
179+
180+
sgv = convert_sgv(sgv)
181+
sgv_delta = convert_sgv(sgv_delta)
182+
183+
self._label_content = self._label_template.substitute(
184+
{
185+
"sgv": sgv,
186+
"sgv_delta": sgv_delta,
187+
"delta_time_in_minutes": delta_time_in_minutes,
188+
"direction": direction,
189+
}
190+
)
191+
self._update_label(self._label_content)
192+
193+
self._tooltip_label_content = self._tooltip_label_template.safe_substitute(
194+
{
195+
"sgv": sgv,
196+
"sgv_delta": sgv_delta,
197+
"delta_time_in_minutes": delta_time_in_minutes,
198+
"direction": direction,
199+
}
200+
)
201+
set_tooltip(
202+
widget=self._widget_container,
203+
text=self._tooltip_label_content,
204+
)

0 commit comments

Comments
 (0)