diff --git a/libreview/libreview.py b/libreview/libreview.py index c28a495..29db203 100644 --- a/libreview/libreview.py +++ b/libreview/libreview.py @@ -1,4 +1,5 @@ import logging +from typing import Dict import requests @@ -89,7 +90,7 @@ class LibreViewSession: print(response.content) self.connections = [] - def getGraph(self, patientId): + def getGraph(self, patientId) -> Dict[str, Dict]: """ getGraph gets the LibreView graph for the {patientID} """ @@ -108,7 +109,7 @@ class LibreViewSession: if 'data' in json: return json['data'] else: - return None + return {} diff --git a/main.py b/main.py index 63f7b83..defaa36 100644 --- a/main.py +++ b/main.py @@ -14,10 +14,11 @@ from datetime import datetime from libreview import LibreViewSession from screens import DebugScreen -from screens import TerminalScreen +from screens import Screen import requests +import yaml # CONSTANTS @@ -25,7 +26,7 @@ DEFAULT_INTERVAL = 5 DEFAULT_FONT_SIZE = 9 DEFAULT_DEBUG_SCREEN_WIDTH = 128 DEFAULT_DEBUG_SCREEN_HEIGHT = 32 -DEFAULT_DEBUG_SCREEN_NUMBER_OF_COLOUR_BITS = '1' +DEFAULT_DEBUG_SCREEN_NUMBER_OF_COLOUR_BITS = "1" SUPPORTED_SCREENS = [ "debug", "terminal", @@ -37,14 +38,119 @@ SUPPORTED_SCREENS = [ SHOULD_EXIT = False -def file_exists(file_path): +def file_exists(file_path) -> bool: + """ + file_exists: + Checks if a file exists + + Arguments: + file_path: + The file that we are looking for + + Returns: + boolean: + Returns true if the file exists, and false if it doesn"t + """ return os.path.isfile(file_path) def directory_exists(directory_path): + """ + directory_exists: + Checks if a directory exists + + Arguments: + directory_path: + The directory that we are looking for + + Returns: + boolean: + Returns true if the file exists and is a directory, and false if it"s not + """ return os.path.isdir(directory_path) +def ini_to_yaml(ini_file_path: str, yaml_file_path: str, lower_case: bool = False): + """ + ini_to_yaml: + Takes an ini file and produces a yaml file from it. + + Arguments: + ini_file_path: + The path to the ini file that you want to convert to a yaml file + + yaml_file_path: + The path to the yaml file that you wish to create + + lower_case = False + Change all the sections and keys to lower case, if false leaves the case alone + + Returns: + None + """ + config = configparser.ConfigParser() + config.read(ini_file_path) + + d = {} + for section in config.sections(): + if lower_case: + d[section.lower()] = {} + else: + d[section] = {} + for key, value in config[section].items(): + if lower_case: + d[section.lower()][key.lower()] = value + else: + d[section][key] = value + + with open(yaml_file_path, "w+") as file: + yaml.dump(d, file, default_flow_style=False) + + +def get_current_time(fmt: str = "%H:%M %d/%m/%Y") -> str: + """ + get_current_time: + Returns a string containing the date and/or time in the format specified in fmt + + Arguments: + fmt: + The format of the date and/or time to be returned in the string + + Returns: + Returns a string with the date and/or time in the format specified + """ + now = datetime.now() + + return now.strftime(fmt) + + +def time_since(dt: datetime) -> str: + """ + time_since: + Returns a string with the difference between the datetime passed and today + + Arguments: + dt: + A datetime class to get the time since + + Returns: + A string in the with minutes if difference can be calculated in minutes otherwise will just return seconds + Will be in the format of "2m 30s" or "40s" + """ + now = datetime.now() + + difference = now - dt + + total_seconds = difference.total_seconds() + + minutes = total_seconds // 60 + seconds = int(total_seconds % 60) + + if minutes > 0: + return f"{minutes}m {seconds}s" + return f"{seconds}s" + + def signal_handler(signum, frame): global SHOULD_EXIT if SHOULD_EXIT: @@ -63,19 +169,20 @@ def main(): logging.basicConfig( stream=sys.stdout, level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' + format="%(asctime)s - %(levelname)s - %(message)s" ) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) parser = argparse.ArgumentParser( - prog='glucose-monitor', - description='A program to display glucose levels and if you blood sugar is high' + prog="glucose-monitor", + description="A program to display glucose levels and if you blood sugar is high" ) - parser.add_argument('-t', '--token') - parser.add_argument('-c', '--config') + parser.add_argument("-m", "--migrate", action="store_true") + parser.add_argument("-t", "--token") + parser.add_argument("-c", "--config") args = parser.parse_args() @@ -87,28 +194,43 @@ def main(): config_file_location = args.config else: possible_config_file_locations = [ - str(os.getenv("HOME")) + "/.config/glucose-monitor/config.ini", - "/etc/glucose-monitor/config.ini", - "config.ini" + str(os.getenv("HOME")) + "/.config/glucose-monitor/config.yaml", + "/etc/glucose-monitor/config.yaml", + "config.yaml" ] - config_file_location = "" + found_config_file = False for possible_config_file_location in possible_config_file_locations: if file_exists(possible_config_file_location): config_file_location = possible_config_file_location + found_config_file = True logging.info("config file found at " + config_file_location) break - if config_file_location == "": - logging.fatal("could not find a config file") - sys.exit(1) + if not found_config_file: + old_config_file_locations = [ + str(os.getenv("HOME")) + "/.config/glucose-monitor/config.ini", + "/etc/glucose-monitor/config.ini", + "config.ini" + ] + for old_config_file_location in old_config_file_locations: + if file_exists(old_config_file_location): + if not args.migrate: + logging.fatal("Sorry, ini files are no longer support, run glucose-monitor --migrate to move to yaml") + sys.exit(1) + else: + ini_to_yaml(old_config_file_location, old_config_file_location[:-4]+".yaml", lower_case=True) + config_file_location = old_config_file_location[:-4]+".yaml" + if config_file_location == "": + logging.fatal("could not find a config file") + sys.exit(1) - config = configparser.ConfigParser() - config.read(config_file_location) + with open(config_file_location, "r") as config_file: + config = yaml.safe_load(config_file) - if 'LOG' in config: - if 'LEVEL' in config['LOG']: - level = config['LOG']['LEVEL'].lower() + if "log" in config: + if "level" in config["log"]: + level = config["log"]["level"].lower() if level == "debug": logging.getLogger().setLevel(logging.DEBUG) elif level == "info": @@ -125,15 +247,15 @@ def main(): logging.debug("set log level to debug") if args.token is None: - if 'CREDENTIALS' not in config: + if "credentials" not in config: # TODO: support credentials in environmental variables logging.fatal("no credentials specified in config file") sys.exit(1) - if 'EMAIL' not in config['CREDENTIALS']: + if "email" not in config["credentials"]: # TODO: support credentials in environmental variables logging.fatal("no email specified in config file") sys.exit(1) - if 'PASSWORD' not in config['CREDENTIALS']: + if "password" not in config["credentials"]: # TODO: support credentials in environmental variables logging.fatal("no password specified in config file") sys.exit(1) @@ -141,35 +263,54 @@ def main(): delay = DEFAULT_INTERVAL font = "DEFAULT" font_size = DEFAULT_FONT_SIZE - if 'GENERAL' in config: - general_config = config['GENERAL'] - if 'INTERVAL' in general_config: - delay = int(config['GENERAL']['INTERVAL']) - - if 'UPPER_BOUND' in general_config: - upper_bound = config['GENERAL']['UPPER_BOUND'] - if 'LOWER_BOUND' in general_config: - lower_bound = config['GENERAL']['LOWER_BOUND'] - - if 'FONT' in general_config: - font = general_config['FONT'] + lines = [ + "%T", + "GLUCOSE UNITS: %U" + ] + if "general" in config: + general_config = config["general"] + if "interval" in general_config: + delay = int(config["general"]["interval"]) + + # TODO: Add format specifier to check if high and low + # if "upper_bound" in general_config: + # upper_bound = config["general"]["upper_bound"] + # if "lower_bound" in general_config: + # lower_bound = config["general"]["lower_bound"] + + if "font" in general_config: + font = general_config["font"] logging.debug("found font in config, checking that it exists") if not file_exists(font): logging.fatal(f"font does not exist at path {font}") sys.exit(1) else: logging.debug(f"found font at {font}") - if 'FONT_SIZE' in general_config: - font_size = general_config['FONT_SIZE'] - - display = TerminalScreen() - if 'SCREEN' in config: - screen_config = config['SCREEN'] - if 'TYPE' not in screen_config: + if "font_size" in general_config: + font_size = general_config["font_size"] + + if "lines" in general_config: + lines = general_config["lines"] + + output_lines = [] + for line in lines: + temp = "" + if "%T" in line: + temp += line.replace("%T", '{get_current_time()}') + if "%U" in line: + temp += line.replace("%U", '{glucose_measurement["Value"]}') + if "%S" in line: + temp += line.replace("%S", '{time_since(glucose_measurement_timestamp_datetime)}') + output_lines.append(temp) + + display = Screen() + if "screen" in config: + screen_config = config["screen"] + if "type" not in screen_config: logging.fatal("no screen type in config file") sys.exit(1) - screen_type = screen_config['TYPE'].lower() + screen_type = screen_config["type"].lower() if screen_type not in SUPPORTED_SCREENS: logging.fatal("sorry screen type not supported") sys.exit(1) @@ -181,12 +322,12 @@ def main(): width = DEFAULT_DEBUG_SCREEN_WIDTH height = DEFAULT_DEBUG_SCREEN_HEIGHT number_of_colour_bits = DEFAULT_DEBUG_SCREEN_NUMBER_OF_COLOUR_BITS - if 'WIDTH' in screen_config: - width = int(screen_config['WIDTH']) - if 'HEIGHT' in screen_config: - height = int(screen_config['HEIGHT']) - if 'NUMBER_OF_COLOUR_BITS' in screen_config: - number_of_colour_bits = screen_config['NUMBER_OF_COLOUR_BITS'] + if "width" in screen_config: + width = int(screen_config["width"]) + if "height" in screen_config: + height = int(screen_config["height"]) + if "number_of_colour_bits" in screen_config: + number_of_colour_bits = screen_config["number_of_colour_bits"] display = DebugScreen(width, height, number_of_colour_bits) @@ -194,7 +335,7 @@ def main(): if args.token is not None: libreview_session.useToken(args.token) else: - libreview_session.authenticate(config['CREDENTIALS']['EMAIL'], config['CREDENTIALS']['PASSWORD']) + libreview_session.authenticate(config["credentials"]["email"], config["credentials"]["password"]) if libreview_session.authenticated and not SHOULD_EXIT: logging.info("successfully authenticated with LibreView") @@ -216,7 +357,7 @@ def main(): logging.fatal("to use glucose-monitor your libreview account must have at least one connection") sys.exit(1) - patient_id = libreview_session.connections[0]['patientId'] + patient_id = libreview_session.connections[0]["patientId"] if display.supports_custom_fonts: if font != "DEFAULT": @@ -229,28 +370,26 @@ def main(): try: graph = libreview_session.getGraph(patient_id) - graph['connection']['glucoseMeasurement'] + graph["connection"]["glucoseMeasurement"] + glucose_measurement = graph["connection"]["glucoseMeasurement"] + glucose_measurement_timestamp = glucose_measurement["Timestamp"] + glucose_measurement_timestamp_datetime = datetime.strptime( + glucose_measurement_timestamp, + "%m/%d/%Y %I:%M:%S %p" + ) except requests.exceptions.ConnectionError: logging.error("failed to get details about patient, connection error") - continue except requests.exceptions.HTTPError: logging.error("failed to get details about patient, API returned invalid response") - continue except requests.exceptions.RequestException: logging.error("failed to get details about patient an unknown error occurred") - continue except KeyError: logging.error("the data returned from the API was not in an expected format") - continue - glucoseMeasurement = graph['connection']['glucoseMeasurement'] - - now = datetime.now() - lines = [ - f"{now.strftime("%H:%M:%S")}", - str(glucoseMeasurement['Value']) - ] - display.displayLines(lines) + computed_output_lines = [] + for output_line in output_lines: + computed_output_lines.append(eval(f"f'{output_line}'")) + display.displayLines(computed_output_lines) time.sleep(delay) diff --git a/screens/__init__.py b/screens/__init__.py index 093751d..4c455b6 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -1,3 +1,4 @@ +from .screen import Screen from .terminal_screen import TerminalScreen from .debug_screens import DebugScreen from .waveshare_233_ssd1305 import Waveshare_233_SSD1305 diff --git a/screens/debug_screens.py b/screens/debug_screens.py index 6352eb8..ce10481 100644 --- a/screens/debug_screens.py +++ b/screens/debug_screens.py @@ -1,6 +1,6 @@ -import os -import logging from typing import List +import logging +import os import psutil @@ -23,13 +23,12 @@ class DebugScreen(Screen): def setDefaultFont(self): logging.debug("setting font to default font") - ImageFont.load_default() + self.font = ImageFont.load_default() def setFont(self, font: str, size: int): logging.debug(f"setting font to {font} with size, {size}") - self.font = font self.font_size = size - ImageFont.truetype(font, size) + self.font = ImageFont.truetype(font, size) def clearBuffer(self): self.draw.rectangle((0, 0, self.width, self.height), outline=0, fill=0) @@ -37,15 +36,35 @@ class DebugScreen(Screen): def displayOnScreen(self): self.image.show() - def displayLines(self, lines: List[str], x_padding: int = 0, y_padding: int = 4): + def displayLines(self, lines: List[str], x_margin: int = 2): # displays text on the screen, the line that is first in the list is displayed on the top self.clearBuffer() self.clearScreen() - x_start = 0 + x_start = x_margin y_start = 0 + x_padding = 0 + y_padding = 0 for i, line in enumerate(lines): - self.draw.text((x_start+(i*x_padding), y_start+int(i*self.font_size)+(i*y_padding)), line, fill=255) + self.draw.text( + ( + x_start+(i*x_padding), + y_start+y_padding + ), + line, + font=self.font, + fill=255 + ) + bounding_box = self.draw.textbbox( + ( + (x_start+x_padding), + y_start+y_padding, + ), + line, + font=self.font, + ) + bottom = bounding_box[3] + y_padding = bottom self.displayOnScreen() def clearScreen(self): diff --git a/screens/screen.py b/screens/screen.py index 5e2ae6b..a176e67 100644 --- a/screens/screen.py +++ b/screens/screen.py @@ -1,3 +1,7 @@ +from typing import List +import logging + + class Screen: def __init__(self): self.type = None @@ -8,7 +12,14 @@ class Screen: return self.type def clearScreen(self): - self.__clearScreen() + raise NotImplementedError("Subclass must implement this method") + + def setFont(self, font: str, size: int): + raise NotImplementedError("Subclass must implement this method") + + def setDefaultFont(self): + raise NotImplementedError("Subclass must implement this method") - def __clearScreen(self): + def displayLines(self, lines: List[str], x_margin: int = 2): + logging.debug(f"Not implemented, not using {lines}, {x_margin}") raise NotImplementedError("Subclass must implement this method") diff --git a/screens/terminal_screen.py b/screens/terminal_screen.py index 1794d07..77a13db 100644 --- a/screens/terminal_screen.py +++ b/screens/terminal_screen.py @@ -1,4 +1,5 @@ from typing import List +import logging import os @@ -7,11 +8,19 @@ from .screen import Screen class TerminalScreen(Screen): def __init__(self): + super().__init__() self.type = "Terminal Screen" self.width, self.height = os.get_terminal_size() - def displayLines(self, lines: List[str], x_padding: int = 0, y_padding: int = 4): + def setDefaultFont(self): + return 0 + + def setFont(self, font: str, size: int): + logging.debug(f"TerminalScreen doesn't support custom fonts, can't set font to {font}, or size to {size}") + raise NotImplementedError("TerminalScreen doesn't support custom fonts") + + def displayLines(self, lines: List[str], x_padding: int = 0, y_padding: int = 0): # displays text on the screen self.clearScreen() diff --git a/screens/waveshare_233_ssd1305.py b/screens/waveshare_233_ssd1305.py index 55450f5..08bdadd 100644 --- a/screens/waveshare_233_ssd1305.py +++ b/screens/waveshare_233_ssd1305.py @@ -1,4 +1,5 @@ from typing import List +import logging from PIL import Image, ImageDraw, ImageFont @@ -24,12 +25,12 @@ class Waveshare_233_SSD1305(Screen): self.draw = ImageDraw.Draw(self.image) def setDefaultFont(self): - ImageFont.load_default() + logging.debug("setting font to default font") + self.font = ImageFont.load_default() def setFont(self, font, size): - self.font = font self.font_size = size - ImageFont.truetype(font, size) + self.font = ImageFont.truetype(font, size) def clearBuffer(self): self.draw.rectangle((0, 0, self.width, self.height), outline=0, fill=0) @@ -38,12 +39,35 @@ class Waveshare_233_SSD1305(Screen): self.ssd1305.getbuffer(self.image) self.ssd1305.ShowImage() - def displayLines(self, lines: List[str]): + def displayLines(self, lines: List[str], x_margin: int = 2): # displays text on the screen, the line that is first in the list is displayed on the top - y = 0 - x = 0 - for i, line in lines: - self.draw.text((x, y+(i*self.font_size)), line, font=self.font, fill=255) + self.clearBuffer() + self.clearScreen() + + x_start = x_margin + y_start = 0 + x_padding = 0 + y_padding = 0 + for i, line in enumerate(lines): + self.draw.text( + ( + x_start+(i*x_padding), + y_start+y_padding + ), + line, + font=self.font, + fill=255 + ) + bounding_box = self.draw.textbbox( + ( + (x_start+x_padding), + y_start+y_padding, + ), + line, + font=self.font, + ) + bottom = bounding_box[3] + y_padding = bottom self.displayOnScreen() def clearScreen(self): diff --git a/version b/version new file mode 100644 index 0000000..0e81df0 --- /dev/null +++ b/version @@ -0,0 +1 @@ +0.0.0.1