From 0b2c69239216780bea976581a703c8bb47d600c5 Mon Sep 17 00:00:00 2001 From: Ronald Date: Sun, 17 Nov 2024 22:05:30 +0000 Subject: [PATCH 1/4] Major rewrite and refactor under way Nearly there, needs some more testing around the WaveShare and Terminal screen classes. --- .gitignore | 3 + 04B_08__.TTF | Bin 19020 -> 0 bytes libreview/__init__.py | 2 + libreview/libreview.py | 114 ++++++++++ main.py | 340 ++++++++++------------------ screens/__init__.py | 3 + screens/debug_screens.py | 65 ++++++ {drive => screens/drive}/SSD1305.py | 0 {drive => screens/drive}/config.py | 0 screens/screen.py | 14 ++ screens/terminal_screen.py | 28 +++ screens/waveshare_233_ssd1305.py | 56 +++++ 12 files changed, 409 insertions(+), 216 deletions(-) create mode 100644 .gitignore delete mode 100644 04B_08__.TTF create mode 100644 libreview/__init__.py create mode 100644 libreview/libreview.py create mode 100644 screens/__init__.py create mode 100644 screens/debug_screens.py rename {drive => screens/drive}/SSD1305.py (100%) rename {drive => screens/drive}/config.py (100%) create mode 100644 screens/screen.py create mode 100644 screens/terminal_screen.py create mode 100644 screens/waveshare_233_ssd1305.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ebd098 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +screens/__pycache__ +libreview/__pycache__ +screens/04B_08__.TTF diff --git a/04B_08__.TTF b/04B_08__.TTF deleted file mode 100644 index aadf20e3b988f0b78db5a4a40c78f8ce504fb58e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19020 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 1: logging.fatal("currently glucose-monitor only supports accounts with one connection") + sys.exit(1) if len(libreview_session.connections) < 1: - logging.fatal("to use glucose-monitor your libreview account must have one connection") + 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'] - first_run = True - while not SHOULD_EXIT: - if not SHOULD_EXIT and not first_run: - time.sleep(delay) - elif first_run: - if show_in_terminal: - clear_terminal() - - if show_on_screen: - clear_display() + if display.supports_custom_fonts: + if font != "DEFAULT": + display.setFont(font, int(font_size)) + else: + display.setDefaultFont() - first_run = False + while not SHOULD_EXIT: + display.clearScreen() try: graph = libreview_session.getGraph(patient_id) + graph['connection']['glucoseMeasurement'] except requests.exceptions.ConnectionError: logging.error("failed to get details about patient, connection error") continue @@ -318,34 +239,21 @@ def main(): except requests.exceptions.RequestException: logging.error("failed to get details about patient an unknown error occurred") continue - - try: - graph['connection']['glucoseMeasurement'] except KeyError: logging.error("the data returned from the API was not in an expected format") continue glucoseMeasurement = graph['connection']['glucoseMeasurement'] - if show_in_terminal: - clear_terminal() - - print(f"Timestamp: {glucoseMeasurement['Timestamp']}\n") - print(f"Glucose units: {glucoseMeasurement['Value']}") - print(f"Is high: {glucoseMeasurement['isHigh']}") - print(f"Is low: {glucoseMeasurement['isLow']}") - print(f"Refresh rate: {delay} seconds") - - if show_on_screen: - clear_display() + now = datetime.now() + lines = [ + f"{now.strftime("%H:%M:%S")}", + str(glucoseMeasurement['Value']) + ] + display.displayLines(lines) - draw.text((x, top), glucoseMeasurement['Timestamp'], font=font, fill=255) - draw.text((x, top+8), "Glucose units: " + str(glucoseMeasurement['Value']), font=font, fill=255) - # Don't know why these two need to have more spaces to line up with the line ebovee - draw.text((x, top+16), "Is high: " + str(glucoseMeasurement['isHigh']), font=font, fill=255) - draw.text((x, top+24), "Is low: " + str(glucoseMeasurement['isLow']), font=font, fill=255) + time.sleep(delay) - display_on_screen(image) logging.debug("exiting now...") diff --git a/screens/__init__.py b/screens/__init__.py new file mode 100644 index 0000000..093751d --- /dev/null +++ b/screens/__init__.py @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..6352eb8 --- /dev/null +++ b/screens/debug_screens.py @@ -0,0 +1,65 @@ +import os +import logging +from typing import List + + +import psutil +from PIL import Image, ImageDraw, ImageFont + + +from .screen import Screen + + +class DebugScreen(Screen): + def __init__(self, width: int, height: int, number_of_colour_bits: str): + self.type = "Debug Screen" + + self.supports_custom_fonts = True + + self.width = width + self.height = height + self.image = Image.new(number_of_colour_bits, (self.width, self.height)) # Create a new image with 1 bit colour + self.draw = ImageDraw.Draw(self.image) + + def setDefaultFont(self): + logging.debug("setting font to default 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) + + def clearBuffer(self): + self.draw.rectangle((0, 0, self.width, self.height), outline=0, fill=0) + + def displayOnScreen(self): + self.image.show() + + def displayLines(self, lines: List[str], x_padding: int = 0, y_padding: int = 4): + # 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 + y_start = 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.displayOnScreen() + + def clearScreen(self): + xdg_open_pid = -1 + for p in psutil.process_iter(): + if p.ppid() == os.getpid() and p.name() == "xdg-open": + logging.debug(f"found xdg-open process with a process id {p.pid}") + xdg_open_pid = p.pid + elif xdg_open_pid != -1 and p.ppid() == xdg_open_pid: + logging.debug(f"killing process, {p.name()} {p.cmdline()}") + p.terminate() + p.kill() + + self.clearBuffer() + draw = ImageDraw.Draw(self.image) + # black rectangle to clear screen, without clearing buffer + draw.rectangle((0, 0, self.width, self.height), outline=0, fill=0) diff --git a/drive/SSD1305.py b/screens/drive/SSD1305.py similarity index 100% rename from drive/SSD1305.py rename to screens/drive/SSD1305.py diff --git a/drive/config.py b/screens/drive/config.py similarity index 100% rename from drive/config.py rename to screens/drive/config.py diff --git a/screens/screen.py b/screens/screen.py new file mode 100644 index 0000000..5e2ae6b --- /dev/null +++ b/screens/screen.py @@ -0,0 +1,14 @@ +class Screen: + def __init__(self): + self.type = None + + self.supports_custom_fonts = False + + def getScreenType(self): + return self.type + + def clearScreen(self): + self.__clearScreen() + + def __clearScreen(self): + raise NotImplementedError("Subclass must implement this method") diff --git a/screens/terminal_screen.py b/screens/terminal_screen.py new file mode 100644 index 0000000..1794d07 --- /dev/null +++ b/screens/terminal_screen.py @@ -0,0 +1,28 @@ +from typing import List +import os + + +from .screen import Screen + + +class TerminalScreen(Screen): + def __init__(self): + 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): + # displays text on the screen + self.clearScreen() + + y = 0 + x = 0 + for i, line in enumerate(lines): + # TODO: Calculate x and y before printing to check if we are going to go off the terminal + print('\n' * int(y+(y_padding*i)), end='') + print(' ' * int(x+(x_padding*i)), end='') + print(line, end='') + + + def clearScreen(self): + print("\033c", end='') diff --git a/screens/waveshare_233_ssd1305.py b/screens/waveshare_233_ssd1305.py new file mode 100644 index 0000000..55450f5 --- /dev/null +++ b/screens/waveshare_233_ssd1305.py @@ -0,0 +1,56 @@ +from typing import List + + +from PIL import Image, ImageDraw, ImageFont + + +from .screen import Screen + + +class Waveshare_233_SSD1305(Screen): + def __init__(self): + from drive import SSD1305 + + self.type = "Waveshare 2.33 SSD1305 OLED" + + self.supports_custom_fonts = True + + self.ssd1305 = SSD1305.SSD1305() + self.ssd1305.Init() + + self.width = self.ssd1305.width + self.height = self.ssd1305.height + self.image = Image.new('1', (self.width, self.height)) # Create new image with 1 bit colour + self.draw = ImageDraw.Draw(self.image) + + def setDefaultFont(self): + ImageFont.load_default() + + def setFont(self, font, size): + self.font = font + self.font_size = size + ImageFont.truetype(font, size) + + def clearBuffer(self): + self.draw.rectangle((0, 0, self.width, self.height), outline=0, fill=0) + + def displayOnScreen(self): + self.ssd1305.getbuffer(self.image) + self.ssd1305.ShowImage() + + def displayLines(self, lines: List[str]): + # 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.displayOnScreen() + + def clearScreen(self): + # Used by parent class Screen + buffer = Image.new('1', (self.width, self.height)) + draw = ImageDraw.Draw(self.image) + # black rectangle to clear screen, without clearing buffer + draw.rectangle((0, 0, self.width, self.height), outline=0, fill=0) + self.ssd1305.getbuffer(buffer) + self.ssd1305.ShowImage() -- 2.47.3 From 6ae804cd15d8b838edd608386484f1fd56ef4612 Mon Sep 17 00:00:00 2001 From: Ronald Date: Sun, 8 Dec 2024 00:31:36 +0000 Subject: [PATCH 2/4] Major progress, custom format now supported! Plus various tidy up bits and bug fixes --- libreview/libreview.py | 5 +- main.py | 267 +++++++++++++++++++++++-------- screens/__init__.py | 1 + screens/debug_screens.py | 35 +++- screens/screen.py | 15 +- screens/terminal_screen.py | 11 +- screens/waveshare_233_ssd1305.py | 40 ++++- version | 1 + 8 files changed, 290 insertions(+), 85 deletions(-) create mode 100644 version 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 -- 2.47.3 From d67a0ae76ffad310de816c8df8e283803f2cfc60 Mon Sep 17 00:00:00 2001 From: Ronald Date: Sun, 8 Dec 2024 00:36:12 +0000 Subject: [PATCH 3/4] Bug fix. Ensure that string returned by time_since contains integers not floats --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index defaa36..75ef881 100644 --- a/main.py +++ b/main.py @@ -143,7 +143,7 @@ def time_since(dt: datetime) -> str: total_seconds = difference.total_seconds() - minutes = total_seconds // 60 + minutes = int(total_seconds // 60) seconds = int(total_seconds % 60) if minutes > 0: -- 2.47.3 From b3a11599ef45f873d322b0573d36e60989e34a59 Mon Sep 17 00:00:00 2001 From: Ronald Date: Mon, 23 Dec 2024 17:46:56 +0000 Subject: [PATCH 4/4] Rename main.py, add new interval config This commit also tidies up a few things. --- main.py => glucose-monitor.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) rename main.py => glucose-monitor.py (93%) diff --git a/main.py b/glucose-monitor.py similarity index 93% rename from main.py rename to glucose-monitor.py index 75ef881..0aca2fe 100644 --- a/main.py +++ b/glucose-monitor.py @@ -1,5 +1,11 @@ #!/usr/bin/env python3 +""" +glucose_monitor.py - A script to display glucose levels on a Raspberry Pi + +Configurable with a config file +""" + import argparse import configparser @@ -22,7 +28,8 @@ import yaml # CONSTANTS -DEFAULT_INTERVAL = 5 +DEFAULT_DATA_REFRESH_INTERVAL = 5 +DEFAULT_SCREEN_REFRESH_INTERVAL = 1 DEFAULT_FONT_SIZE = 9 DEFAULT_DEBUG_SCREEN_WIDTH = 128 DEFAULT_DEBUG_SCREEN_HEIGHT = 32 @@ -260,7 +267,8 @@ def main(): logging.fatal("no password specified in config file") sys.exit(1) - delay = DEFAULT_INTERVAL + data_refresh_interval = DEFAULT_DATA_REFRESH_INTERVAL + screen_refresh_interval = DEFAULT_SCREEN_REFRESH_INTERVAL font = "DEFAULT" font_size = DEFAULT_FONT_SIZE lines = [ @@ -269,8 +277,10 @@ def main(): ] if "general" in config: general_config = config["general"] - if "interval" in general_config: - delay = int(config["general"]["interval"]) + if "data_refresh_interval" in general_config: + data_refresh_interval = int(config["general"]["data_refresh_interval"]) + if "screen_refresh_interval" in general_config: + screen_refresh_interval = int(config["general"]["screen_refresh_interval"]) # TODO: Add format specifier to check if high and low # if "upper_bound" in general_config: @@ -366,8 +376,6 @@ def main(): display.setDefaultFont() while not SHOULD_EXIT: - display.clearScreen() - try: graph = libreview_session.getGraph(patient_id) graph["connection"]["glucoseMeasurement"] @@ -386,13 +394,14 @@ def main(): except KeyError: logging.error("the data returned from the API was not in an expected format") - 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) + for _ in range(data_refresh_interval//screen_refresh_interval): + display.clearScreen() + 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(screen_refresh_interval) logging.debug("exiting now...") -- 2.47.3