diff --git a/database.py b/database.py index 4df161c..356c360 100644 --- a/database.py +++ b/database.py @@ -2,19 +2,14 @@ import sqlite3 import random from dials.base_logger import logger +from vu_filesystem import VU_FileSystem class DialsDB: connection = None database_changes = 0 - def __init__(self, database_file='vudials.db', init_if_missing=False): - # database_path = os.path.join(os.path.expanduser('~'), 'KaranovicResearch', 'vudials') - database_path = os.path.join(os.path.dirname(__file__)) - - if not os.path.exists(database_path): - os.makedirs(database_path) - - self.database_file = os.path.join(database_path, database_file) + def __init__(self, init_if_missing=False): + self.database_file = VU_FileSystem.get_database_file_path() logger.info(f"VU1 Database file: {self.database_file}") if not os.path.exists(self.database_file) and not init_if_missing: diff --git a/dials/base_logger.py b/dials/base_logger.py index c102a60..48fb0d0 100644 --- a/dials/base_logger.py +++ b/dials/base_logger.py @@ -4,6 +4,7 @@ import logging from logging.handlers import RotatingFileHandler from functools import partial, partialmethod +from vu_filesystem import VU_FileSystem def colorize(data, color): colors = {'none': "0", @@ -72,17 +73,7 @@ def set_logger_level(level='info'): ''' # Basic logger setup log_formatter = logging.Formatter('%(asctime)s %(levelname)s %(funcName)s(%(lineno)d) %(message)s') -# Linux -if sys.platform in ["linux", "linux2"]: - logFile = f'/home/{getpass.getuser()}/KaranovicResearch/vudials/server.log' - -# MacOS -elif sys.platform == "darwin": - logFile = f'~/Library/Logs/KaranovicResearch/vudials/server.log' - -# Windows -elif sys.platform == "win32": - logFile = os.path.join(os.path.expanduser(os.getenv('USERPROFILE')), 'KaranovicResearch', 'vudials', 'server.log') +logFile = VU_FileSystem.get_log_file_path(); os.makedirs(os.path.dirname(logFile), exist_ok=True) log_file_handler = RotatingFileHandler(logFile, mode='a', maxBytes=1*1024*1024, backupCount=2, encoding=None, delay=0) diff --git a/server.py b/server.py index 7a940fe..1880741 100644 --- a/server.py +++ b/server.py @@ -6,13 +6,14 @@ import time import re from mimetypes import guess_type -from dials.base_logger import logger, set_logger_level from tornado.web import Application, RequestHandler, Finish, StaticFileHandler from tornado.ioloop import IOLoop, PeriodicCallback +from dials.base_logger import logger, set_logger_level from dial_driver import DialSerialDriver from server_config import ServerConfig from server_dial_handler import ServerDialHandler from vu_notifications import show_error_msg, show_info_msg +from vu_filesystem import VU_FileSystem BASEDIR_NAME = os.path.dirname(__file__) BASEDIR_PATH = os.path.abspath(BASEDIR_NAME) @@ -20,7 +21,7 @@ def pid_lock(service_name, create=True): file_name = "service.{}.pid.lock".format(service_name) - pid_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), file_name) + pid_file = os.path.join(VU_FileSystem.get_pid_lock_file_path(), file_name) if create: pid = os.getpid() @@ -34,7 +35,7 @@ class BaseHandler(RequestHandler): def initialize(self, handler, config): self.handler = handler # pylint: disable=attribute-defined-outside-init self.config = config # pylint: disable=attribute-defined-outside-init - self.upload_path = os.path.join(os.path.dirname(__file__), 'upload') # pylint: disable=attribute-defined-outside-init + self.upload_path = VU_FileSystem.get_upload_directory_path() # pylint: disable=attribute-defined-outside-init def set_default_headers(self): self.set_header("Access-Control-Allow-Origin", "*") @@ -186,7 +187,6 @@ def post(self, dial_uid): return self.send_response(status='ok', message='Image CRC already maches existing one. Skipping update.') def handle_image_upload(self, dial_uid): - self.make_upload_folder() image_data = self.request.files.get('imgfile', None) if image_data is None: logger.error("imgfile field missing from request.") @@ -205,22 +205,18 @@ def different_image_uploaded(self, old, new): return True return False - def make_upload_folder(self): - if not os.path.exists(self.upload_path): - os.makedirs(self.upload_path) - class Dial_Get_Image(BaseHandler): def get(self, gaugeUID): self.set_header("Content-Type", "image/png") logger.debug("Request: GET_IMAGE") - dial_image = os.path.join(os.path.dirname(__file__), 'upload', f'img_{gaugeUID}') + dial_image = os.path.join(VU_FileSystem.get_upload_directory_path(), f'img_{gaugeUID}') if os.path.exists(dial_image): filepath = dial_image logger.debug(f"Serving image from {filepath}") else: - filepath = os.path.join(os.path.dirname(__file__), 'upload', 'img_blank') + filepath = os.path.join(VU_FileSystem.get_upload_directory_path(), 'img_blank') logger.debug(f"Serving DEFAULT image from {filepath}") try: @@ -236,7 +232,7 @@ class Dial_Get_Image_CRC(BaseHandler): def get(self, gaugeUID): logger.debug("Request: GET_IMAGE_CRC") - img_file = os.path.join(os.path.dirname(__file__), 'upload', f'img_{gaugeUID}') + img_file = os.path.join(VU_FileSystem.get_upload_directory_path(), f'img_{gaugeUID}') crc = self.get_file_crc(img_file) return self.send_response(status='ok', data=crc) @@ -392,7 +388,6 @@ def get(self, gaugeUID): if not self.is_valid_api_key(): return self.send_response(status='fail', message='Unauthorized', status_code=401) - # TODO: Implement in dial handler return self.send_response(status='ok', message="not supported yet") # -- Keys -- @@ -525,7 +520,7 @@ def __init__(self): signal.signal(signal.SIGINT, self.signal_handler) logger.info("Loading server config...") - self.config = ServerConfig('config.yaml') + self.config = ServerConfig() # If config contains COM port, use it. Otherwise try to find it hardware_config = self.config.get_hardware_config() @@ -638,7 +633,7 @@ def run_forever(self): show_error_msg(title='Key missing from config', message='Entry "master_key" is missing from the "config.yaml"!') logger.error("Master Key is MISSING from config.yaml") logger.error("Check your 'config.yaml' or add it manually under 'server' section.") - sys.exit(0) + sys.exit(-1) pc = PeriodicCallback(self.dial_handler.periodic_dial_update, dial_update_period) pc.start() @@ -656,7 +651,8 @@ def main(cmd_args=None): except Exception: logger.exception("VU Dials API service crashed during setup.") show_error_msg("Crashed", "VU Server has crashed unexpectedly!\r\nPlease check log files for more information.") - os._exit(0) + sys.exit(-1) + sys.exit(0) if __name__ == '__main__': parser = argparse.ArgumentParser(description='Karanovic Research - VU Dials API service') diff --git a/server_config.py b/server_config.py index c4404cf..23ba84a 100644 --- a/server_config.py +++ b/server_config.py @@ -4,6 +4,7 @@ # import yaml from dials.base_logger import logger from vu_notifications import show_error_msg, show_warning_msg +from vu_filesystem import VU_FileSystem import database as db class ServerConfig: @@ -16,8 +17,8 @@ class ServerConfig: api_keys = {} database = None - def __init__(self, config_file='config.yaml'): - self.config_path = os.path.join(os.path.dirname(__file__), config_file) + def __init__(self): + self.config_path = VU_FileSystem.get_config_file_path() logger.info(f"VU1 config yaml file: {self.config_path}") self.database = db.DialsDB(init_if_missing=True) self._load_config() # Load configuration from .yaml file diff --git a/vu_filesystem.py b/vu_filesystem.py new file mode 100644 index 0000000..975814d --- /dev/null +++ b/vu_filesystem.py @@ -0,0 +1,231 @@ +# pylint: disable=pointless-string-statement +import os +import sys +import shutil +import getpass +from vu_notifications import show_error_msg + +''' +VU Server file system class + +This class determines base directory for all files/paths consumed by VU Server. +It also provides a way for user/system to specify where VU Server should store data. + +Note: All data files will be placed on a same/shared path. +Data files might be separated into subdirectories where it makes sense. + +Base directory is defined from on of the following: + + First: System Environment Variable `VU_SERVER_DATA_PATH` + Requirement: path is valid/exists and is writable by current process + + Second: Current user home directory + Windows : c:/Users/USERNAME/KaranovicResearch + Linux : /home/USERNAME/KaranovicResearch + Mac Os : /Users/USERNAME/Library/KaranovicResearch + Requirement: Path is writable by current process + + Last: VU Server install directory + This is last-resort for storing VU server data. + Note that application data will get removed if install directory is removed. + +Transition fix: + This change breaks current app behaviour. In order to prevent data loss and offer + seamless transition, this class will try to migrate (copy) data from old paths + and also mark the old files as `_migrated` (instead of removing). + +''' +class FileSystem: + base_path = None + + def __init__(self): + potential_paths = [ os.environ.get('VU_SERVER_DATA_PATH', None), + self._get_user_directory(), + self._get_app_directory() ] + + for test_path in potential_paths: + print(f"Testing path: {test_path}") + if self._is_useable_path(test_path): + self.base_path = os.path.join(test_path, 'KaranovicResearch', 'VUServer') + break + + if self.base_path is None: + print(f"Tested paths: {potential_paths}") + print("Could not find useable directory for VU Server data!") + print("Please make sure VU Server application is running with administrative privileges!") + show_error_msg("File System Error!", + "Could not find useable directory for VU Server data!\n" + "Please make sure VU Server application is running with administrative privileges!") + sys.exit(-1) + # We can abort and shut-down gracefully here + # Or try to run and pray for a miracle + + # Create required directories and files + self._create_default_directories() + self._create_empty_config_file() + self._create_empty_dial_image() + + # Migrate files from pre 20240222 build + # We should do this only once. + # The old folder/file will have `_migrated` added to the name + self._migrate_upload_folder() + self._migrate_config_file() + self._migrate_database_file() + + def _get_user_directory(self): + # Linux + if sys.platform in ["linux", "linux2"]: + return f'/home/{getpass.getuser()}/KaranovicResearch' + + # MacOS + if sys.platform == "darwin": + return '~/Library/KaranovicResearch' + + # Windows + if sys.platform == "win32": + return os.path.join(os.path.expanduser(os.getenv('USERPROFILE')), 'KaranovicResearch') + return None + + def _get_app_directory(self): + return os.path.abspath(os.path.dirname(__file__)) + + + def _is_useable_path(self, test_path): + if test_path is None: + return False + + try: + os.makedirs(test_path, exist_ok=True) + if not os.path.isdir(test_path): + print(f"{test_path} is not directory!") + return False + + return self._is_writeable_path(test_path) + except OSError as error: + show_error_msg("Error while creating path!", f"Could not create`{test_path}`.\n{error}") + print("Error while creating path!") + print(error) + return False + + return False + + def _is_writeable_path(self, test_path): + try: + # Create test file + test_file = os.path.join(test_path, 'tmp.txt') + with open(test_file, 'w', encoding='utf-8') as f: + f.write('ok') + + # Remove test file + os.remove(test_file) + + # By this point we should assume read/write access + return True + except Exception as e: + print(e) + return False + + def _create_default_directories(self): + os.makedirs(os.path.dirname(self.get_log_file_path()), exist_ok=True) + os.makedirs(self.get_upload_directory_path(), exist_ok=True) + + def _create_empty_config_file(self): + if not os.path.isfile(self.get_config_file_path()): + with open(self.get_config_file_path(), 'w', encoding='utf-8') as f: + f.write('server:\n') + f.write(' hostname: localhost\n') + f.write(' port: 5340\n') + f.write(' communication_timeout: 10\n') + f.write(' dial_update_period: 200\n') + f.write(' master_key: cTpAWYuRpA2zx75Yh961Cg\n') + f.write('\n') + f.write('hardware:\n') + f.write(' port: \n') + f.write('\n') + + def _create_empty_dial_image(self): + blank_img_path = os.path.join(self.get_upload_directory_path(), 'img_blank') + img_data = "89504e470d0a1a0a0000000d49484452000000c800000090080000000068ee7bd8000000097048597300001ce900001\ +ce901e1d0b8e5000000c649444154789cedcf410dc03010c0b03e8ec2ca1fdf508c44a55a538c209967fdc3acf776c2\ +117b6e179cd288a6114d239a46348d681ad134a26944d388a6114d239a46348d681ad134a26944d388a6114d239a463\ +48d681ad134a26944d388a6114d239a46348d681ad134a26944d388a6114d239a46348d681ad134a26944d388a6114d\ +239a46348d681ad134a26944d388a6114d239a46348d681ad134a26944d388a6114d239a46348d681ad134a26944d38\ +8a6114d239a46348d681ad1ccdab713cef8001f27036cd2b3091e0000000049454e44ae426082" + + if not os.path.isfile(blank_img_path): + with open(blank_img_path, 'wb') as f: + f.write(bytes.fromhex(img_data)) + + def _migrate_upload_folder(self): + # Old upload folder is at: os.path.dirname(__file__) + `upload` + + old_upload_folder = os.path.join(os.path.dirname(__file__), 'upload') + if os.path.isdir(old_upload_folder): + try: + shutil.copytree(old_upload_folder, self.get_upload_directory_path(), dirs_exist_ok=True) + shutil.move(old_upload_folder, f"{old_upload_folder}_migrated") + print(f"Migrating upload folder: {old_upload_folder}") + return True + except Exception as e: + print("Failed to copy old upload directory!") + print(e) + raise e + return False # Keep pylint happy + + def _migrate_config_file(self): + # Old config file is at: os.path.dirname(__file__) + + old_config_file = os.path.join(os.path.dirname(__file__), 'config.yaml') + if os.path.isfile(old_config_file): + try: + shutil.copy(old_config_file, self.get_config_file_path()) + shutil.move(old_config_file, f"{old_config_file}_migrated") + print(f"Migrating config file: {old_config_file}") + return True + except Exception as e: + print("Failed to copy old config file!") + print(e) + raise e + return False + + def _migrate_database_file(self): + # Old config file is at: os.path.dirname(__file__) + + old_db_file = os.path.join(os.path.dirname(__file__), 'vudials.db') + if os.path.isfile(old_db_file): + try: + shutil.copy(old_db_file, self.get_config_file_path()) + shutil.move(old_db_file, f"{old_db_file}_migrated") + print(f"Migrating db file: {old_db_file}") + return True + except Exception as e: + print("Failed to copy old db file!") + print(e) + raise e + return False + + def get_pid_lock_file_path(self): + return os.path.join(self.base_path) + + def get_log_file_path(self): + return os.path.join(self.base_path, 'server.log') + + def get_database_file_path(self): + return os.path.join(self.base_path, 'vudials.db') + + def get_config_file_path(self): + return os.path.join(self.base_path, 'config.yaml') + + def get_upload_directory_path(self): + return os.path.join(self.base_path, 'upload') + +VU_FileSystem = FileSystem() + + +if __name__ == '__main__': + vfs = FileSystem() + + print(vfs.get_log_file_path()) + print(vfs.get_database_file_path()) + print(vfs.get_config_file_path()) + print(vfs.get_upload_directory_path()) diff --git a/vu_notifications.py b/vu_notifications.py index cf33c84..be0eb9c 100644 --- a/vu_notifications.py +++ b/vu_notifications.py @@ -17,8 +17,13 @@ def show_info_msg(title, message): else: def show_error_msg(title, message): - pass + print(f"VU Server - {title}") + print(message) + def show_warning_msg(title, message): - pass + print(f"VU Server - {title}") + print(message) + def show_info_msg(title, message): - pass + print(f"VU Server - {title}") + print(message)