|
| 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