diff --git a/.bin/Scripts/functions/ddrescue.py b/.bin/Scripts/functions/ddrescue.py index d37e3207..545d08e0 100644 --- a/.bin/Scripts/functions/ddrescue.py +++ b/.bin/Scripts/functions/ddrescue.py @@ -1,7 +1,9 @@ # Wizard Kit: Functions - ddrescue-tui +import datetime import pathlib import psutil +import pytz import re import signal import stat @@ -254,6 +256,7 @@ class RecoveryState(): self.block_pairs = [] self.current_pass = 0 self.current_pass_str = '0: Initializing' + self.etoc = '' self.settings = DDRESCUE_SETTINGS.copy() self.finished = False self.panes = {} @@ -261,6 +264,8 @@ class RecoveryState(): self.rescued = 0 self.resumed = False self.started = False + self.status = 'Inactive' + self.timezone = pytz.timezone(LINUX_TIME_ZONE) self.total_size = 0 if mode not in ('clone', 'image'): raise GenericError('Unsupported mode') @@ -399,6 +404,66 @@ class RecoveryState(): elif self.current_pass == 2: self.current_pass_str = '3 "Scraping bad areas"' + def update_etoc(self): + """Search ddrescue output for the current EToC, returns str.""" + now = datetime.datetime.now(tz=self.timezone) + + # Bail early + if 'NEEDS ATTENTION' in self.status: + # Just set to N/A (NOTE: this overrules the refresh rate below) + self.etoc = 'N/A' + return + elif 'In Progress' not in self.status: + # Don't update when EToC is hidden + return + if now.second % ETOC_REFRESH_RATE != 0: + # Limit updates based on settings/ddrescue.py + return + + self.etoc = 'Unknown' + etoc_delta = None + text = '' + + # Capture main tmux pane + try: + text = tmux_capture_pane() + except Exception: + # Ignore + pass + + # Search for EToC delta + matches = re.findall(r'remaining time:.*$', text, re.MULTILINE) + if matches: + r = REGEX_REMAINING_TIME.search(matches[-1]) + if r.group('na'): + self.etoc = 'N/A' + else: + self.etoc = r.string + days = r.group('days') if r.group('days') else 0 + hours = r.group('hours') if r.group('hours') else 0 + minutes = r.group('minutes') if r.group('minutes') else 0 + seconds = r.group('seconds') if r.group('seconds') else 0 + try: + etoc_delta = datetime.timedelta( + days=int(days), + hours=int(hours), + minutes=int(minutes), + seconds=int(seconds), + ) + except Exception: + # Ignore and leave as raw string + pass + + # Calc finish time if EToC delta found + if etoc_delta: + try: + now = datetime.datetime.now(tz=self.timezone) + _etoc = now + etoc_delta + self.etoc = _etoc.strftime('%Y-%m-%d %H:%M %Z') + except Exception: + # Ignore and leave as current string + pass + def update_progress(self): """Update overall progress using block_pairs.""" self.rescued = 0 @@ -768,6 +833,13 @@ def menu_main(state): # Show menu while True: + # Update status + if state.finished: + state.status = ' Finished' + else: + state.status = ' Inactive' + update_sidepane(state) + # Update entries for opt in main_options: opt['Name'] = '[{}] {}'.format( @@ -922,6 +994,7 @@ def run_ddrescue(state, pass_settings): """Run ddrescue pass.""" return_code = -1 aborted = False + state.status = ' In Progress' if state.finished: clear_screen() @@ -1036,6 +1109,9 @@ def run_ddrescue(state, pass_settings): # Done if str(return_code) != '0': # Pause on errors + state.status = ' {YELLOW}NEEDS ATTENTION{CLEAR}'.format(**COLORS) + state.status = state.status.replace('33m', '33;5m') + update_sidepane(state) pause('Press Enter to return to main menu... ') # Cleanup @@ -1292,6 +1368,7 @@ def update_sidepane(state): output.append(' {BLUE}Cloning Status{CLEAR}'.format(**COLORS)) else: output.append(' {BLUE}Imaging Status{CLEAR}'.format(**COLORS)) + output.append(state.status) output.append('─────────────────────') # Overall progress @@ -1311,6 +1388,19 @@ def update_sidepane(state): output.extend(bp.status) output.append(' ') + # EToC + if re.search(r'(In Progress|NEEDS ATTENTION)', state.status): + if not output[-1].strip(): + # Last line is empty + output.pop() + output.append('─────────────────────') + output.append('{BLUE}Estimated Pass Finish{CLEAR}'.format(**COLORS)) + state.update_etoc() + if 'N/A' in state.etoc.upper(): + output.append('{YELLOW}N/A{CLEAR}'.format(**COLORS)) + else: + output.append(state.etoc) + # Add line-endings output = ['{}\n'.format(line) for line in output] diff --git a/.bin/Scripts/functions/tmux.py b/.bin/Scripts/functions/tmux.py index e2d1b333..81522268 100644 --- a/.bin/Scripts/functions/tmux.py +++ b/.bin/Scripts/functions/tmux.py @@ -10,6 +10,21 @@ def create_file(filepath): f.write('') +def tmux_capture_pane(pane_id=None): + """Capture text from target, or current, pane, returns str.""" + cmd = ['tmux', 'capture-pane', '-p'] + if pane_id: + cmd.extend(['-t', pane_id]) + text = '' + + # Capture + result = run_program(cmd, check=False, encoding='utf-8', errors='ignore') + text = result.stdout + + # Done + return str(text) + + def tmux_get_pane_size(pane_id=None): """Get target, or current, pane size, returns tuple.""" x = -1 diff --git a/.bin/Scripts/settings/ddrescue.py b/.bin/Scripts/settings/ddrescue.py index 620b7214..675019ca 100644 --- a/.bin/Scripts/settings/ddrescue.py +++ b/.bin/Scripts/settings/ddrescue.py @@ -1,5 +1,9 @@ # Wizard Kit: Settings - ddrescue-tui +import re + +from collections import OrderedDict + # General RECOMMENDED_FSTYPES = ['ext3', 'ext4', 'xfs'] USAGE = """ {script_name} clone [source [destination]] @@ -31,6 +35,16 @@ DDRESCUE_SETTINGS = { '--timeout': {'Enabled': True, 'Value': '5m', }, '-vvvv': {'Enabled': True, 'Hidden': True, }, } +ETOC_REFRESH_RATE = 30 # in seconds +REGEX_REMAINING_TIME = re.compile( + r'remaining time:' + r'\s*((?P\d+)d)?' + r'\s*((?P\d+)h)?' + r'\s*((?P\d+)m)?' + r'\s*((?P\d+)s)?' + r'\s*(?Pn/a)?', + re.IGNORECASE + ) if __name__ == '__main__':