diff --git a/config.rc.in b/config.rc.in index 87dc2dc..3db7bb5 100644 --- a/config.rc.in +++ b/config.rc.in @@ -1,23 +1,39 @@ [files] # relative path to where log is kept -log_file = canute.log -# book directory +log_file = 'canute.log' + # where usb sticks and sd-cards will be automounted -media_dir = /media -# the exact sub mount-point directory name of the sd-card -sd_card_dir = sd-card - -# Additional books made available on the USB ports will (for now at -# least) be visible in the library. The current state will be written -# to all mount points that are active, however the state file on the -# SD card will take precedence, followed by lib_1 and finally lib_2. -# These behaviours may change at any time so shouldn't be relied upon. -# -# These paths are relative to media_dir. -# These config values are optional. -additional_lib_1 = front-usb -additional_lib_2 = back-usb +media_dir = '/media' + +# Book Directories +# Additional books made available on the USB ports will be made visible +# in the library. The current state will be written to all mount points +# that are active. The first state file found (based on the order in the +# list) will take precedence if they differ. +# These paths can be relative to media_dir or absolute. The mountpoint +# flag should be used if they point to mountpoints that may not be present +# and the swappable flag means USB media that is interchangeable. +library = [ + { name = 'SD', path = 'sd-card', mountpoint = true }, + { name = 'USB1', path = 'front-usb', mountpoint = true, swappable = true }, + { name = 'USB2', path = 'back-usb', mountpoint = true, swappable = true } +] + +# if required, a program to look for changes to removable media +media_helper = './media.py' [comms] # serial timeout in seconds timeout = 1000 + +[hardware] +# count row actuations (motor wear) when running on Canute 360 +log_duty = true + +# minumum number of samples of button down to count as a press +button_debounce = 1 + +# shutdown mode +shutdown_on_exit = true + +# versions \ No newline at end of file diff --git a/config.rc.travis b/config.rc.travis index 01e1419..03d4289 100644 --- a/config.rc.travis +++ b/config.rc.travis @@ -1,11 +1,16 @@ [files] # relative path to where log is kept log_file = canute.log -# book directory -# where usb sticks and sd-cards will be automounted media_dir = ~/canute-media -# the exact sub mount-point directory name of the sd-card -sd_card_dir = sd-card + +# Book Directories +# Additional books made available on the USB ports will be made visible +# in the library. The current state will be written to all mount points +# that are active. The first state file found (based on the order in the +# list) will take precedence if they differ. +library = [ + { name = 'SD', path = 'sd-card' } +] [comms] # serial timeout in seconds diff --git a/tests/test_book_file.py b/tests/test_book_file.py index 074d288..ea68377 100644 --- a/tests/test_book_file.py +++ b/tests/test_book_file.py @@ -1,6 +1,6 @@ import unittest from ui.book.book_file import BookFile -from ui.book.handlers import _read_pages, get_page_data +from ui.book.handlers import _read_pages2, get_page_data from .util import async_test @@ -12,7 +12,7 @@ async def setUpClass(self): self.filename = ('books/A_balance_between_technology_and_Braille_Addin' + 'g_Value_and_Creating_a_Love_of_Reading.BRF') book = BookFile(self.filename, 40, 9) - self.book = await _read_pages(book) + self.book = await _read_pages2(book) def test_filename(self): self.assertEqual(self.book.filename, self.filename) @@ -21,7 +21,7 @@ def test_title(self): self.assertIsNotNone(self.book.title) def test_has_len(self): - self.assertGreater(len(self.book.pages), 0) + self.assertGreater(self.book.num_pages, 0) @async_test async def test_get_line(self): @@ -45,7 +45,7 @@ class TestBookFilePef(unittest.TestCase): async def setUpClass(self): self.filename = "books/g2 AESOP'S FABLES.pef" book = BookFile(self.filename, 40, 9) - self.book = await _read_pages(book) + self.book = await _read_pages2(book) def test_filename(self): self.assertEqual(self.book.filename, self.filename) @@ -54,7 +54,7 @@ def test_title(self): self.assertIsNotNone(self.book.title) def test_has_len(self): - self.assertGreater(len(self.book.pages), 0) + self.assertGreater(self.book.num_pages, 0) @async_test async def test_get_line(self): diff --git a/tests/test_utility.py b/tests/test_utility.py index 004f9a5..63a3d7c 100644 --- a/tests/test_utility.py +++ b/tests/test_utility.py @@ -4,7 +4,7 @@ from ui.library.explorer import Library dir_path = os.path.dirname(os.path.realpath(__file__)) -test_books_dir = [('test-books', 'test dir')] +test_books_dir = [{ 'path': 'test-books', 'name': 'test dir' }] class TestUtility(unittest.TestCase): diff --git a/ui/config_loader.py b/ui/config_loader.py index ed82be1..78743e1 100644 --- a/ui/config_loader.py +++ b/ui/config_loader.py @@ -1,18 +1,18 @@ import os.path -from configparser import ConfigParser -config_file = 'config.rc' +import toml +config_file = 'config.rc' def load(config_file=config_file): - config = ConfigParser() - c = config.read(config_file) - if len(c) == 0: + if not os.path.exists(config_file): raise ValueError('Please provide a config.rc') - media_dir = config.get('files', 'media_dir') - config.set('files', 'media_dir', os.path.expanduser(media_dir)) - if not config.has_section('comms'): - config.add_section('comms') - if not config.has_option('comms', 'timeout'): - config.set('comms', 'timeout', 60) + + config = toml.load(config_file) + + # expand any ~ home dirs in media_dir + files_section = config.get('files', {}) + media_dir = files_section.get('media_dir', '/media') + files_section['media_dir'] = os.path.expanduser(media_dir) + return config diff --git a/ui/driver/driver_pi.py b/ui/driver/driver_pi.py index c957c88..0d2ee43 100644 --- a/ui/driver/driver_pi.py +++ b/ui/driver/driver_pi.py @@ -37,8 +37,9 @@ class Pi(Driver): :param port: the serial port the display is plugged into """ - def __init__(self, port=None, timeout=60): + def __init__(self, port=None, timeout=60, button_threshold=1): self.timeout = timeout + self.button_threshold = button_threshold # get serial connection if port is None: ports = serial.tools.list_ports.comports() @@ -53,7 +54,7 @@ def __init__(self, port=None, timeout=60): else: self.port = None - self.previous_buttons = tuple() + self.previous_buttons = dict() self.row_actuations = [0] * N_ROWS @@ -121,17 +122,19 @@ def get_buttons(self): buttons = {} self.send_data(comms.CMD_SEND_BUTTONS) read_buttons = self.get_data(comms.CMD_SEND_BUTTONS) - down = list(self.previous_buttons) for i, n in enumerate(reversed(list('{:0>14b}'.format(read_buttons)))): name = mapping[str(i)] - if n == '1' and (name not in self.previous_buttons): - buttons[name] = 'down' - down.append(name) + if n == '1': + if name in self.previous_buttons: + self.previous_buttons[name] += 1 + else: + self.previous_buttons[name] = 1 + if self.previous_buttons[name] == self.button_threshold: + buttons[name] = 'down' elif n == '0' and (name in self.previous_buttons): - buttons[name] = 'up' - down.remove(name) - - self.previous_buttons = tuple(down) + if self.previous_buttons[name] >= self.button_threshold: + buttons[name] = 'up' + del self.previous_buttons[name] return buttons diff --git a/ui/initial_state.py b/ui/initial_state.py index 6c04634..0b40afe 100644 --- a/ui/initial_state.py +++ b/ui/initial_state.py @@ -28,32 +28,57 @@ def to_state_file(book_path): def configured_source_dirs(): config = config_loader.load() - state_sources = [('sd_card_dir', 'SD')] - if config.has_option('files', 'additional_lib_1'): - state_sources.append(('additional_lib_1', 'USB1')) - if config.has_option('files', 'additional_lib_2'): - state_sources.append(('additional_lib_2', 'USB2')) - return [(config.get('files', source), name) for source, name in state_sources] + return config.get('files', {}).get('library', []) def mounted_source_paths(media_dir): - for source_dir, name in configured_source_dirs(): - source_path = os.path.join(media_dir, source_dir) - if os.path.ismount(source_path) or 'TRAVIS' in os.environ: - yield source_path + for source_dir in configured_source_dirs(): + source_path = os.path.join(media_dir, source_dir.get('path')) + if not source_dir.get('mountpoint', False) or os.path.ismount(source_path): + yield source_path, source_dir.get('swappable', False) + + +def swappable_usb(data): + """ + The state file contains part of the book path (e.g. usb0) and so if we want + to maintain compatibility with the old standalone setup, we need to convert + it to something it understands. So here we fix any 'swappable' path (i.e. + USB removable media) to 'front-usb' and rely on the function below to find + the correct prefix on app startup. + """ + book = data.get('current_book') + for source_dir in configured_source_dirs(): + if source_dir.get('swappable', False): + prefix = source_dir.get('path') + os.sep + if book.startswith(prefix): + book = os.path.join('front-usb', book[len(prefix):]) + # modifying data is safe as a new dict is created each time + data.set('current_book', book) + break + return data -def swap_library(current_book): +def swap_library(current_book, books): + """ + The current_book path includes a mount-point subpath (if on removable media) + so try to cope with user accidentally swapping slots + """ config = config_loader.load() - if config.has_option('files', 'additional_lib_1') and \ - config.has_option('files', 'additional_lib_2'): - lib1 = config.get('files', 'additional_lib_1') - lib2 = config.get('files', 'additional_lib_2') - if current_book.startswith(lib1): - return lib2 + current_book[len(lib1):] - elif current_book.startswith(lib2): - return lib1 + current_book[len(lib2):] - return current_book + library = config.get('files', {}).get('library', []) + + # check for expected path, for backward compatibility with standalone unit + for path in ['front-usb' + os.path.sep, 'back-usb' + os.path.sep]: + if current_book.startswith(path): + rel_book = current_book[len(path):] + break + + # see if we can find it on a different swappable device path + for lib in library: + if lib.get('swappable', False): + path = lib['path'] + book = os.path.join(path, rel_book) + if book in books: + return book async def read_user_state(media_dir, state): @@ -66,7 +91,7 @@ async def read_user_state(media_dir, state): book_files = library.book_files() source_paths = mounted_source_paths(media_dir) - for source_path in source_paths: + for source_path, swappable in source_paths: main_toml = os.path.join(source_path, USER_STATE_FILE) if os.path.exists(main_toml): try: @@ -129,7 +154,7 @@ async def read_user_state(media_dir, state): if current_book not in books: # let's check that they're not just using a different USB port log.info('current book not in original library {}'.format(current_book)) - current_book = swap_library(current_book) + current_book = swap_library(current_book, books) if current_book not in books: log.warn('current book not found {}, ignoring'.format(current_book)) current_book = manual_filename @@ -148,12 +173,12 @@ async def write(media_dir, queue): log.info('save file worker started') while True: (filename, data) = await queue.get() - s = toml.dumps(data) + s = toml.dumps(swappable_usb(data)) if filename is None: # main user state file source_paths = mounted_source_paths(media_dir) - for source_path in source_paths: + for source_path, swappable in source_paths: path = os.path.join(source_path, USER_STATE_FILE) log.debug('writing user state file save to {path}') async with aiofiles.open(path, 'w') as f: diff --git a/ui/library/explorer.py b/ui/library/explorer.py index 9843749..b571496 100644 --- a/ui/library/explorer.py +++ b/ui/library/explorer.py @@ -87,14 +87,15 @@ def __init__(self, media_dir, source_dirs, file_exts): self.file_exts = file_exts self.dirs = [] - for source_dir, name in source_dirs: - # if os.path.ismount(os.path.join(self.media_dir, source_dir)): - # not needed as unmounted devices will be empty and so pruned - root = Directory(source_dir, display=name) - self.walk(root) - self.prune(root) - if len(root.dirs) > 0 or root.files_count > 0: - self.flatten(root) + for source_dir in source_dirs: + source_path = source_dir.get('path') + if not source_dir.get('mountpoint', False) or \ + os.path.ismount(os.path.join(self.media_dir, source_path)): + root = Directory(source_path, display=source_dir.get('name')) + self.walk(root) + self.prune(root) + if len(root.dirs) > 0 or root.files_count > 0: + self.flatten(root) self.dir_count = len(self.dirs) self.files_dir_index = None diff --git a/ui/main.py b/ui/main.py index 269fc1d..f47ac49 100644 --- a/ui/main.py +++ b/ui/main.py @@ -25,7 +25,7 @@ def main(): args = argparser.parser.parse_args() config = config_loader.load() log = setup_logs(config, args.loglevel) - timeout = config.get('comms', 'timeout') + timeout = config.get('comm', {}).get('timeout', 60) if args.fuzz_duration: log.info('running fuzz test') @@ -82,7 +82,8 @@ def main(): log.info('running with real hardware on port %s, timeout %s' % (args.tty, timeout)) try: - with Pi(port=args.tty, timeout=timeout) as driver: + debounce = config.get('hardware', {}).get('button_debounce', 1) + with Pi(port=args.tty, timeout=timeout, button_threshold=debounce) as driver: run(driver, config) except RuntimeError as err: if err.args[0] == 'readFrame timeout': @@ -112,12 +113,7 @@ async def run_async_timeout(driver, config, duration, loop): # This is a task in its own right that listens to an external process for media # change notifications, and handles them. -async def handle_media_changes(): - # For now, under Travis, don't launch it. It requires pygi which is hard - # to make accessible to a virtualenv. - media_helper = './media.py' - if 'TRAVIS' in os.environ: - media_helper = '/bin/cat' +async def handle_media_changes(media_helper): proc = await asyncio.create_subprocess_exec( media_helper, stdout=asyncio.subprocess.PIPE) @@ -160,8 +156,17 @@ async def run_async(driver, config, loop): while not driver.is_motion_complete(): await asyncio.sleep(0.01) log.debug('motion complete') - media_handler = asyncio.ensure_future(handle_media_changes()) - duty_logger = asyncio.ensure_future(driver.track_duty()) + + media_helper = config.get('filese', {}).get('media_helper') + if media_helper is not None: + media_handler = asyncio.ensure_future(handle_media_changes(media_helper)) + else: + media_handler = None + + if config.get('hardware', {}).get('log_duty', False): + duty_logger = asyncio.ensure_future(driver.track_duty()) + else: + duty_logger = None width, height = driver.get_dimensions() state.app.set_dimensions((width, height)) @@ -169,7 +174,7 @@ async def run_async(driver, config, loop): queue, save_worker = handle_save_events(config, state) load_worker = handle_display_events(config, state) - media_dir = config.get('files', 'media_dir') + media_dir = config.get('files', {}).get('media_dir') log.info(f'reading initial state from {media_dir}') await initial_state.read(media_dir, state) log.info('created store') @@ -179,16 +184,18 @@ async def run_async(driver, config, loop): try: while 1: if (await handle_hardware(driver, state, media_dir)): - media_handler.cancel() - try: - await media_handler - except asyncio.CancelledError: - pass - duty_logger.cancel() - try: - await duty_logger - except asyncio.CancelledError: - pass + if media_handler is not None: + media_handler.cancel() + try: + await media_handler + except asyncio.CancelledError: + pass + if duty_logger is not None: + duty_logger.cancel() + try: + await duty_logger + except asyncio.CancelledError: + pass break @@ -203,8 +210,10 @@ async def run_async(driver, config, loop): else: await asyncio.sleep(0) except asyncio.CancelledError: - media_handler.cancel() - duty_logger.cancel() + if media_handler is not None: + media_handler.cancel() + if duty_logger is not None: + duty_logger.cancel() await queue.join() save_worker.cancel() load_worker.cancel() @@ -212,7 +221,7 @@ async def run_async(driver, config, loop): def handle_save_events(config, state): - media_dir = config.get('files', 'media_dir') + media_dir = config.get('files', {}).get('media_dir') queue = asyncio.Queue() worker = asyncio.create_task(initial_state.write(media_dir, queue)) @@ -242,7 +251,7 @@ def on_backup_log(): def handle_display_events(config, state): from .book.handlers import load_book, load_book_worker - media_dir = config.get('files', 'media_dir') + media_dir = config.get('files', {}).get('media_dir') queue = asyncio.Queue() worker = asyncio.create_task(load_book_worker(state, queue)) @@ -291,8 +300,8 @@ async def handle_hardware(driver, state, media_dir): if state.app.shutting_down: if isinstance(driver, Pi): driver.port.close() - os.system('/home/pi/util/shutdown-stage-1.py') if not sys.stdout.isatty(): + os.system('/home/pi/util/shutdown-stage-1.py') os.system('sudo shutdown -h now') # never exit from Dummy driver elif isinstance(driver, Dummy): @@ -308,18 +317,16 @@ async def handle_hardware(driver, state, media_dir): def backup_log(config): - sd_card_dir = config.get('files', 'sd_card_dir') - media_dir = config.get('files', 'media_dir') - sd_card_dir = os.path.join(media_dir, sd_card_dir) - log_file = config.get('files', 'log_file') - # make a filename based on the date - backup_file = os.path.join(sd_card_dir, time.strftime('%Y%m%d%M_log.txt')) - log.debug('backing up log to USB stick: {}'.format(backup_file)) - try: - import shutil - shutil.copyfile(log_file, backup_file) - except IOError as e: - log.warning("couldn't backup log file: {}".format(e)) + mounted_dirs = initial_state.mounted_source_paths() + if len(mounted_dirs) > 0: + # make a filename based on the date and save to first path + backup_file = os.path.join(mounted_dirs[0][0], time.strftime('%Y%m%d%M_log.txt')) + log.debug('backing up log to USB stick: {}'.format(backup_file)) + try: + import shutil + shutil.copyfile(log_file, backup_file) + except IOError as e: + log.warning("couldn't backup log file: {}".format(e)) async def log_markers(): diff --git a/ui/setup_logs.py b/ui/setup_logs.py index 1d4b0a8..45689e4 100644 --- a/ui/setup_logs.py +++ b/ui/setup_logs.py @@ -4,7 +4,7 @@ def setup_logs(config, loglevel): - log_file = config.get('files', 'log_file') + log_file = config.get('files', {}).get('log_file') log_format = logging.Formatter( '%(asctime)s - %(name)-16s - %(levelname)-8s - %(message)s') # configure the client logging diff --git a/ui/system_menu/system_menu.py b/ui/system_menu/system_menu.py index d58b23f..2cff3fa 100644 --- a/ui/system_menu/system_menu.py +++ b/ui/system_menu/system_menu.py @@ -43,10 +43,14 @@ def brailleify(rel): # This exists on a Pi and reading it yields a useful board identifier. # But existence will do for right now. if os.path.exists('/sys/firmware/devicetree/base/model'): - with open('/etc/canute_release') as x: - release = brailleify(x.read().strip()) - with open('/etc/canute_serial') as x: - serial = brailleify(x.read().strip()) + if os.path.exists('/etc/canute_release'): + with open('/etc/canute_release') as x: + release = brailleify(x.read().strip()) + with open('/etc/canute_serial') as x: + serial = brailleify(x.read().strip()) + else: + release = brailleify(_('run in standalone mode')) + serial = release else: # Assume we're being emulated. release = brailleify(_('emulated'))