From c95e63ea3a431818d967c32270658dd781f0204b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Tue, 22 Oct 2024 18:16:13 +0200 Subject: [PATCH 01/51] Add resuming waiting jobs after quit --- spooler.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/spooler.py b/spooler.py index 9bb4a9f..6f1c7b3 100644 --- a/spooler.py +++ b/spooler.py @@ -24,7 +24,8 @@ KEY_HOME = [ 'h', '(H)ome' ] REPEAT_JOBS = True # Ask to repeat a plot after a sucessful print -TESTING = True # Don't actually connect to AxiDraw, just simulate plotting +TESTING = False # Don't actually connect to AxiDraw, just simulate plotting +RESUME_QUEUE = True # Resume plotting queue after quitting/restarting queue_size_cb = None queue = asyncio.Queue() # an async FIFO queue @@ -95,7 +96,8 @@ def save_svg(job, status): def save_svg_async(*args): return asyncio.to_thread(save_svg, *args) -# job: 'client', 'lines' +# job {'type': 'plot, 'client', 'id', 'svg', stats, timestamp, hash, speed, format, size, received?} +# adds to job: { 'cancel', time_estimate', 'layers', received } # todo: don't wait on callbacks async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = None, error_cb = None): # the client might be in queue (or currently plotting) @@ -109,7 +111,8 @@ async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = Non job['done_cb'] = done_cb job['cancel_cb'] = cancel_cb job['error_cb'] = error_cb - job['received'] = timestamp() + if 'received' not in job or job['received'] == None: + job['received'] = timestamp() # speed if 'speed' in job: job['speed'] = max( min(job['speed'], 100), MIN_SPEED ) # limit speed (MIN_SPEED, 100) @@ -121,7 +124,7 @@ async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = Non jobs[ job['client'] ] = job await _notify_queue_size() # notify new queue size await _notify_queue_positions() - print(f'New job [{job["client"]}]') + print(f'New job [{job["client"]}] {job["hash"][0:5]}') sim = await simulate_async(job) # run simulation job['time_estimate'] = sim['time_estimate'] job['layers'] = sim['layers'] @@ -158,7 +161,7 @@ async def finish_current_job(): return True def job_str(job): - info = '[' + str(job["client"])[0:10] + ']' + info = '[' + str(job["client"])[0:10] + '] ' + job['hash'][0:5] speed_and_format = f'{job["speed"]}%, {job["format"]}, {math.floor(job["time_estimate"]/60)}:{round(job["time_estimate"]%60):02} min' if 'stats' in job: stats = job['stats'] @@ -380,6 +383,54 @@ async def prompt_setup(message = 'Setup Plotter:'): elif res == KEY_DONE[0] : # Finish return True +async def resume_queue(): + import os + import xml.etree.ElementTree as ElementTree + import hashlib + import re + list = os.listdir(FOLDER_WAITING) + list = [ os.path.join(FOLDER_WAITING, x) for x in list if x.endswith('.svg') ] + resumable_jobs = [] + for filename in list: + # print('Loading ', filename) + try: + with open(filename, 'r') as file: + svg = file.read() + root = ElementTree.fromstring(svg) + def attr(attr, ns = 'https://sketch.process.studio/turtle-graphics'): + return root.get(attr if ns == None else "{" + ns + "}" + attr) + match = re.search('\\d{8}_\\d{6}.\\d{6}_UTC[+-]\\d{4}', os.path.basename(filename)) + received_ts = None if match == None else match.group(0) + job = { + 'resumed': True, + 'client': attr('author'), + 'id': "XYZ", + 'svg': svg, + 'stats': { + 'count': int(attr('count')), + 'layer_count': int(attr('layer_count')), + 'oob_count': int(attr('oob_count')), + 'short_count': int(attr('short_count')), + 'travel': int(attr('travel')), + 'travel_ink': int(attr('travel_ink')), + 'travel_blank': int(attr('travel_blank')) + }, + 'timestamp': attr('timestamp'), + 'speed': int(attr('speed')), + 'format': attr('format'), + 'size': [int(attr('width_mm')), int(attr('height_mm'))], + 'hash': hashlib.sha1(svg.encode('utf-8')).hexdigest(), + 'received': received_ts + } + resumable_jobs.append(job) + except: + # print('Error loading ', filename) + pass + + if len(resumable_jobs) > 0: print(f"Resuming {len(resumable_jobs)} jobs...") + else: print("No jobs to resume") + for job in resumable_jobs: + await enqueue(job) async def start(_prompt, print_status): @@ -391,6 +442,7 @@ async def start(_prompt, print_status): print = prompt.print # replace global print function if TESTING: print(f'{COL.YELLOW}TESTING MODE enabled{COL.OFF}') + if RESUME_QUEUE: await resume_queue() await align_async() await prompt_setup() From f74656dd94a72339487074ec7b24ba806e58624e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Tue, 22 Oct 2024 23:48:48 +0200 Subject: [PATCH 02/51] Support pre v4 SVGs when resuming from file --- spooler.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/spooler.py b/spooler.py index 6f1c7b3..c0eee7b 100644 --- a/spooler.py +++ b/spooler.py @@ -5,6 +5,8 @@ import math import os from capture_output import capture_output +import re +import hashlib FOLDER_WAITING ='svgs/0_waiting' FOLDER_CANCELED ='svgs/1_canceled' @@ -96,6 +98,21 @@ def save_svg(job, status): def save_svg_async(*args): return asyncio.to_thread(save_svg, *args) + +# Updated pre version 4 SVGs, so they are compatible with resume queue +def update_svg(job): + match = re.search('tg:version="(\\d+)"', job['svg']) + if match != None and int(match.group(1)) >= 4: return + + MARKER = 'xmlns:tg="https://sketch.process.studio/turtle-graphics"' + idx = job['svg'].find(MARKER) + if idx == -1: return + idx += len(MARKER) + insert = f'\n tg:version="4" tg:layer_count="1" tg:oob_count="{job['stats']['oob_count']}" tg:short_count="{job['stats']['short_count']}" tg:format="{job['format']}" tg:width_mm="{job['size'][0]}" tg:height_mm="{job['size'][1]}" tg:speed="{job['speed']}" tg:author="{job['client']}" tg:timestamp="{job['timestamp']}"' + + job['svg'] = job['svg'][:idx] + insert + job['svg'][idx:] + job['hash'] = hashlib.sha1(job['svg'].encode('utf-8')).hexdigest() + # job {'type': 'plot, 'client', 'id', 'svg', stats, timestamp, hash, speed, format, size, received?} # adds to job: { 'cancel', time_estimate', 'layers', received } # todo: don't wait on callbacks @@ -128,6 +145,8 @@ async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = Non sim = await simulate_async(job) # run simulation job['time_estimate'] = sim['time_estimate'] job['layers'] = sim['layers'] + + update_svg(job) await queue.put(job) await save_svg_async(job, 'waiting') return True @@ -384,10 +403,7 @@ async def prompt_setup(message = 'Setup Plotter:'): return True async def resume_queue(): - import os import xml.etree.ElementTree as ElementTree - import hashlib - import re list = os.listdir(FOLDER_WAITING) list = [ os.path.join(FOLDER_WAITING, x) for x in list if x.endswith('.svg') ] resumable_jobs = [] @@ -402,7 +418,7 @@ def attr(attr, ns = 'https://sketch.process.studio/turtle-graphics'): match = re.search('\\d{8}_\\d{6}.\\d{6}_UTC[+-]\\d{4}', os.path.basename(filename)) received_ts = None if match == None else match.group(0) job = { - 'resumed': True, + 'loaded_from_file': True, 'client': attr('author'), 'id': "XYZ", 'svg': svg, @@ -424,8 +440,7 @@ def attr(attr, ns = 'https://sketch.process.studio/turtle-graphics'): } resumable_jobs.append(job) except: - # print('Error loading ', filename) - pass + print('Error resuming ', filename) if len(resumable_jobs) > 0: print(f"Resuming {len(resumable_jobs)} jobs...") else: print("No jobs to resume") @@ -503,6 +518,6 @@ async def start(_prompt, print_status): if not ready: await cancel(current_job['client'], force = True) break - + _status = 'waiting' current_job = None From f4015dc869257338434aee4381b49579ad25ccc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Wed, 23 Oct 2024 10:13:10 +0200 Subject: [PATCH 03/51] Remove unused id --- main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index fb38a6f..cb10c5c 100755 --- a/main.py +++ b/main.py @@ -95,13 +95,13 @@ async def send_current_queue_size(ws): async def handle_message(message, ws): async def on_queue_position(pos, job): - await send_msg( {'type': 'queue_position', 'position': pos, 'id': job['id']}, ws ) + await send_msg( {'type': 'queue_position', 'position': pos}, ws ) async def on_done(job): - await send_msg( {'type': 'job_done', 'id': job['id']}, ws ) + await send_msg( {'type': 'job_done'}, ws ) async def on_cancel(job): - await send_msg( {'type': 'job_canceled', 'id': job['id']}, ws ) + await send_msg( {'type': 'job_canceled'}, ws ) async def on_error(msg, job): - await send_msg( {'type': 'error', 'msg': msg, 'id': job['id']}, ws ) + await send_msg( {'type': 'error', 'msg': msg}, ws ) msg = json.loads(message) if msg['type'] == 'echo': await ws.send(message) From 6326f2d6974f263a91d5940fa0bf3f8f97da7c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Wed, 23 Oct 2024 10:13:54 +0200 Subject: [PATCH 04/51] Add RESUME_AFTER and RESUME_AFTER_PAUSE options --- spooler.py | 47 ++++++++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/spooler.py b/spooler.py index c0eee7b..d2e6440 100644 --- a/spooler.py +++ b/spooler.py @@ -25,9 +25,11 @@ KEY_RESUME = [ 'r', '(R)esume' ] KEY_HOME = [ 'h', '(H)ome' ] +TESTING = True # Don't actually connect to AxiDraw, just simulate plotting REPEAT_JOBS = True # Ask to repeat a plot after a sucessful print -TESTING = False # Don't actually connect to AxiDraw, just simulate plotting RESUME_QUEUE = True # Resume plotting queue after quitting/restarting +ALIGN_AFTER = True # Align plotter after success or error +ALIGN_AFTER_PAUSE = False # Align plotter after pause (programmatic, stop button, keyboard interrupt) queue_size_cb = None queue = asyncio.Queue() # an async FIFO queue @@ -95,8 +97,8 @@ def save_svg(job, status): pass return True -def save_svg_async(*args): - return asyncio.to_thread(save_svg, *args) +def save_svg_async(*args, **kwargs): + return asyncio.to_thread(save_svg, *args, **kwargs) # Updated pre version 4 SVGs, so they are compatible with resume queue @@ -201,6 +203,7 @@ def job_str(job): 103: 'Stopped by keyboard interrupt', 104: 'Lost USB connectivity' } +PLOTTER_PAUSED = [ 1, 102, 103 ]; def get_error_msg(code): if code in PLOTTER_ERRORS: @@ -238,7 +241,7 @@ def cycle(): ad.plot_run() return ad.errors.code -def plot(job, align_after = True, options_cb = None, return_ad = False): +def plot(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, options_cb = None, return_ad = False): if 'svg' not in job: return 0 speed = job['speed'] / 100 with capture_output(print_axidraw, print_axidraw): @@ -257,11 +260,13 @@ def plot(job, align_after = True, options_cb = None, return_ad = False): if callable(options_cb): options_cb(ad.options) if TESTING: ad.options.preview = True job['output_svg'] = ad.plot_run(output=True) - if align_after: align() + if (ad.errors.code in PLOTTER_PAUSED and align_after_pause) or \ + (ad.errors.code not in PLOTTER_PAUSED and align_after): + align() if return_ad: return ad else: return ad.errors.code -def resume_home(job, align_after = True, options_cb = None, return_ad = False): +def resume_home(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, options_cb = None, return_ad = False): if 'output_svg' not in job: return 0 orig_svg = job['svg'] # save original svg job['svg'] = job['output_svg'] # set last output svg as input @@ -270,11 +275,11 @@ def _options_cb(options): if callable(options_cb): options_cb(options) options.mode = 'res_home' - res = plot(job, align_after, _options_cb, return_ad) + res = plot(job, align_after, align_after_pause, _options_cb, return_ad) job['svg'] = orig_svg # restore original svg return res -def resume_plot(job, align_after = True, options_cb = None, return_ad = False): +def resume_plot(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, options_cb = None, return_ad = False): if 'output_svg' not in job: return 0 orig_svg = job['svg'] # save original svg job['svg'] = job['output_svg'] # set last output svg as input @@ -282,8 +287,8 @@ def resume_plot(job, align_after = True, options_cb = None, return_ad = False): def _options_cb(options): if callable(options_cb): options_cb(options) options.mode = 'res_plot' - - res = plot(job, align_after, _options_cb, return_ad) + + res = plot(job, align_after, align_after_pause, _options_cb, return_ad) job['svg'] = orig_svg # restore original svg return res @@ -311,21 +316,21 @@ def update_stats(ad): def _options_cb(options): options.preview = True - ad = plot(job, align_after=False, options_cb=_options_cb, return_ad=True) + ad = plot(job, align_after=False, align_after_pause=False, options_cb=_options_cb, return_ad=True) update_stats(ad) while ad.errors.code == 1: # Paused programmatically - ad = resume_plot(job, align_after=False, options_cb=_options_cb, return_ad=True) + ad = resume_plot(job, align_after=False, align_after_pause=False, options_cb=_options_cb, return_ad=True) update_stats(ad) return stats -async def plot_async(*args): - return await asyncio.to_thread(plot, *args) +async def plot_async(*args, **kwargs): + return await asyncio.to_thread(plot, *args, **kwargs) -async def simulate_async(*args): - return await asyncio.to_thread(simulate, *args) +async def simulate_async(*args, **kwargs): + return await asyncio.to_thread(simulate, *args, **kwargs) async def align_async(): return await asyncio.to_thread(align) @@ -333,11 +338,11 @@ async def align_async(): async def cycle_async(): return await asyncio.to_thread(cycle) -async def resume_plot_async(*args): - return await asyncio.to_thread(resume_plot, *args) +async def resume_plot_async(*args, **kwargs): + return await asyncio.to_thread(resume_plot, *args, **kwargs) -async def resume_home_async(*args): - return await asyncio.to_thread(resume_home, *args) +async def resume_home_async(*args, **kwargs): + return await asyncio.to_thread(resume_home, *args, **kwargs) async def prompt_start_plot(message): message += f' {KEY_START_PLOT[1]}, {KEY_ALIGN[1]}, {KEY_CYCLE[1]}, {KEY_CANCEL[1]} ?' @@ -502,7 +507,7 @@ async def start(_prompt, print_status): await finish_current_job() break # Paused programmatically (1), Stopped by pause button press (102) or Stopped by keyboard interrupt (103) - elif error in [1, 102, 103]: + elif error in PLOTTER_PAUSED: print(f'{COL.YELLOW}Plotter: {get_error_msg(error)}{COL.OFF}') _status = 'confirm_plot' ready = await prompt_resume_plot(f'{COL.YELLOW}Resume{COL.OFF} job [{current_job["client"]}] ?', current_job) From 7045afc914dbbce416f6d5625e0276054852e2bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Wed, 23 Oct 2024 10:43:05 +0200 Subject: [PATCH 05/51] Make sure resume is sorted --- main.py | 1 + spooler.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index cb10c5c..5206ed7 100755 --- a/main.py +++ b/main.py @@ -177,6 +177,7 @@ def quit(): if USE_ZEROCONF: zc.add_zeroconf_service(ZEROCONF_HOSTNAME, PORT) asyncio.run(main()) except KeyboardInterrupt: + print('*** Ctrl-C pressed ***') pass except: traceback.print_exception( sys.exception() ) diff --git a/spooler.py b/spooler.py index d2e6440..4ff49e1 100644 --- a/spooler.py +++ b/spooler.py @@ -25,7 +25,7 @@ KEY_RESUME = [ 'r', '(R)esume' ] KEY_HOME = [ 'h', '(H)ome' ] -TESTING = True # Don't actually connect to AxiDraw, just simulate plotting +TESTING = False # Don't actually connect to AxiDraw, just simulate plotting REPEAT_JOBS = True # Ask to repeat a plot after a sucessful print RESUME_QUEUE = True # Resume plotting queue after quitting/restarting ALIGN_AFTER = True # Align plotter after success or error @@ -409,7 +409,7 @@ async def prompt_setup(message = 'Setup Plotter:'): async def resume_queue(): import xml.etree.ElementTree as ElementTree - list = os.listdir(FOLDER_WAITING) + list = sorted(os.listdir(FOLDER_WAITING)) list = [ os.path.join(FOLDER_WAITING, x) for x in list if x.endswith('.svg') ] resumable_jobs = [] for filename in list: From bd1911ae2437a50d95e32e043d0f2227e7e23975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 3 Nov 2024 14:01:14 +0100 Subject: [PATCH 06/51] Add textual UI --- async_interval.py | 66 ++++++ async_prompt.py | 71 ------- async_queue.py | 229 ++++++++++++++++++++ hotkey_button.py | 46 ++++ main.py | 511 +++++++++++++++++++++++++++++++++----------- main_old.py | 186 ++++++++++++++++ spooler.py | 253 ++++++++++++---------- spooler_old.py | 528 ++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1594 insertions(+), 296 deletions(-) create mode 100644 async_interval.py delete mode 100644 async_prompt.py create mode 100644 async_queue.py create mode 100644 hotkey_button.py mode change 100755 => 100644 main.py create mode 100755 main_old.py create mode 100644 spooler_old.py diff --git a/async_interval.py b/async_interval.py new file mode 100644 index 0000000..8f9cfcf --- /dev/null +++ b/async_interval.py @@ -0,0 +1,66 @@ +import asyncio +import types + +# Calls fn in intervals given by the interval parameter (in seconds) +# fn is called with an argument giving the elapsed time since start +# Returns a Task object + +def async_interval(fn, interval, end = 0): + async def run(fn, interval): + try: + start = loop.time() + while True: + await asyncio.sleep(interval) + fn( loop.time() - start ) + except asyncio.CancelledError: + pass + + def stop(): + try: task.cancel() + except asyncio.CancelledError: pass + + loop = asyncio.get_running_loop() + task = asyncio.create_task( run(fn, interval) ) + if (end > 0): loop.call_later(end, stop) + return task + +if __name__ == '__main__': + import unittest + + class Test(unittest.IsolatedAsyncioTestCase): + async def test_cancel(self): + def empty(): pass + i = async_interval(empty, 1) + await asyncio.sleep(0) # need to wait, otherwise CancelledError is raised anyway + i.cancel() + await i # wait for interval completion + self.assertEqual(i.done(), True) + # cancelled() is False since the wrapped coroutine doesn't propagate the CancelledError + self.assertEqual(i.cancelled(), False) + + async def test_interval(self): + times = [] + def fn(time): times.append(time) + + i = async_interval(fn, 1/4) + await asyncio.sleep(1) + i.cancel() + await i + # print(times) + self.assertEqual(len(times), 3) + self.assertAlmostEqual(times[0], 0.25, places=2) + self.assertAlmostEqual(times[1], 0.50, places=2) + self.assertAlmostEqual(times[2], 0.75, places=2) + + async def test_end(self): + times = [] + def fn(time): times.append(time) + + i = async_interval(fn, 1/4, 1) + await i + self.assertEqual(len(times), 3) + self.assertAlmostEqual(times[0], 0.25, places=2) + self.assertAlmostEqual(times[1], 0.50, places=2) + self.assertAlmostEqual(times[2], 0.75, places=2) + + unittest.main() \ No newline at end of file diff --git a/async_prompt.py b/async_prompt.py deleted file mode 100644 index 78f2a68..0000000 --- a/async_prompt.py +++ /dev/null @@ -1,71 +0,0 @@ -import asyncio -import sys -import termios -import tty -import builtins -import io - -# async prompt for a single keystroke -class AsyncPrompt: - def __init__(self): - self.echo = True - self.echo_end = '\n' - self.waiting_for_input = False - self.queue = asyncio.Queue() - asyncio.get_running_loop().add_reader(sys.stdin, self.on_input) - - def __del__(self): - # restore tty if object goes away - if self.waiting_for_input: - self.tty_restore() - - def tty_input(self): - fd = sys.stdin.fileno() - self.original_ttyattrs = termios.tcgetattr(fd) # save ttyattrs - tty.setraw(fd) # set raw input mode on tty - - def tty_restore(self): - fd = sys.stdin.fileno() - termios.tcsetattr(fd, termios.TCSADRAIN, self.original_ttyattrs) - - def on_input(self): - if self.waiting_for_input: - key = sys.stdin.read(1) - sys.stdin.seek(0, io.SEEK_END) # disard rest of input (by seeking to end of stream) - self.tty_restore() - if ord(key) == 3: # catch Control-C - raise KeyboardInterrupt() - if self.echo: - if ord(key) == 27: - print('^[', end=self.echo_end) # Don't echo ESC as it is, this would start an escape sequence in the terminal - else: - print(key, end=self.echo_end) # echo the input character (with newline) - self.queue.put_nowait(key) - self.waiting_for_input = False - else: - sys.stdin.readline() # discard input - - async def prompt(self, message = '? ', echo = True, echo_end = '\n'): - self.echo = echo - # print prompt - print(message, end = '', flush=True) - self.tty_input() - # set flag to capture input - self.waiting_for_input = True - # wait until input is received - return await self.queue.get() - - async def wait_for(self, chars, message = '? ', echo = True, echo_end = '\n'): - res = None - while res not in chars: - res = await self.prompt(message, echo) - return res - - def print(self, *objects, sep=' ', end='\n', file=None, flush=False): - if self.waiting_for_input: - self.tty_restore() - builtins.print() # newline - builtins.print(*objects, sep=sep, end=end, file=file, flush=flush) - self.tty_input() - else: - builtins.print(*objects, sep=sep, end=end, file=file, flush=flush) \ No newline at end of file diff --git a/async_queue.py b/async_queue.py new file mode 100644 index 0000000..9999198 --- /dev/null +++ b/async_queue.py @@ -0,0 +1,229 @@ +import asyncio + +# Like asyncio.Queue with support for reordering and removing elements +class Queue(asyncio.Queue): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.order = [] + + def _rebuild(self): + # remove all items + try: + while True: super().get_nowait() + except asyncio.QueueEmpty: + pass + # add items in new order + for item in self.order: + try: + super().put_nowait(item) + except asyncio.QueueFull: + pass + + # get the current queue as list + def list(self): + return self.order.copy() + + # swap two items by index; supports negative indices + def swap(self, idx1, idx2): + if idx1 < -len(self.order) or idx1 > len(self.order)-1: + raise IndexError('index 1 out of bounds') + if idx2 < -len(self.order) or idx2 > len(self.order)-1: + raise IndexError('index 2 out of bounds') + if (idx1 == idx2): return + self.order[idx1], self.order[idx2] = self.order[idx2], self.order[idx1] + self._rebuild() + + # Not necessary (use swap): + + # def swap_to_front(self, idx): + # if idx < -len(self.order) or idx > len(self.order)-1: + # raise IndexError('index out of bounds') + # self.swap(idx, 0) + # + # def swap_to_back(self, idx): + # if idx < -len(self.order) or idx > len(self.order)-1: + # raise IndexError('index out of bounds') + # self.swap(idx, -1) + + # remove an item from the queue; supports negative indices + def pop(self, idx = -1): + if idx < -len(self.order) or idx > len(self.order)-1: + raise IndexError('index out of bounds') + item = self.order.pop(idx) + # print('remove item', item) + self._rebuild() + return item + + # insert an item at an arbitrary position into the queue + def insert(self, idx, item): + if idx < -len(self.order) or idx > len(self.order): + raise IndexError('index out of bounds') + self.order.insert(idx, item) + self._rebuild() + + def put_nowait(self, item): + # print('put_nowait') + super().put_nowait(item) + self.order.append(item) + + def get_nowait(self): + # print('get_nowait') + item = super().get_nowait() + self.order.pop(0) + return item + + # no need to implement get() and put() + # as these are implemented in terms of get_nowait() and put_nowait() + + +if __name__ == '__main__': + import unittest + + class Test(unittest.IsolatedAsyncioTestCase): + def get_all(self, q): + out = [] + while not q.empty(): + out.append( q.get_nowait() ) + return out + + async def test_put(self): + q = Queue() + await q.put('one') + await q.put('two') + await q.put('three') + self.assertEqual(q.list(), ['one', 'two', 'three']) + self.assertEqual( self.get_all(q), ['one', 'two', 'three']) + + async def test_put_nowait(self): + q = Queue() + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + self.assertEqual(q.list(), ['one', 'two', 'three']) + self.assertEqual( self.get_all(q), ['one', 'two', 'three']) + + async def test_get(self): + q = Queue() + get_task = asyncio.gather( + asyncio.create_task(q.get()), + asyncio.create_task(q.get()), + asyncio.create_task(q.get()) + ) + await q.put('one') + await q.put('two') + await q.put('three') + self.assertEqual( await get_task, ['one', 'two', 'three'] ) + + async def test_get_nowait(self): + q = Queue() + with self.assertRaises(asyncio.QueueEmpty): + q.get_nowait() + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + self.assertEqual( q.get_nowait(), 'one' ) + self.assertEqual( q.get_nowait(), 'two' ) + self.assertEqual( q.get_nowait(), 'three' ) + with self.assertRaises(asyncio.QueueEmpty): + q.get_nowait() + + async def test_pop(self): + q = Queue() + with self.assertRaises(IndexError): + q.pop(0) + + q.put_nowait('one') + with self.assertRaises(IndexError): q.pop(1) + with self.assertRaises(IndexError): q.pop(-2) + self.assertEqual(q.pop(0), 'one') + self.assertEqual(q.list(), []) + + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + self.assertEqual(q.pop(1), 'two') + self.assertEqual(q.list(), ['one', 'three']) + self.assertEqual(self.get_all(q), ['one', 'three']) + self.assertEqual(q.empty(), True) + + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + self.assertEqual(q.pop(0), 'one') + self.assertEqual(q.pop(-1), 'three') + self.assertEqual(q.list(), ['two']) + self.assertEqual(self.get_all(q), ['two']) + + async def test_insert(self): + q = Queue() + q.put_nowait('one') + q.put_nowait('three') + q.insert(1, 'two') + self.assertEqual(q.list(), ['one', 'two', 'three']) + self.assertEqual(self.get_all(q), ['one', 'two', 'three']) + + q.insert(0, 'one') + q.insert(1, 'two') + q.insert(2, 'three') + q.insert(0, 'zero') + self.assertEqual(q.list(), ['zero', 'one', 'two', 'three']) + self.assertEqual(self.get_all(q), ['zero', 'one', 'two', 'three']) + + with self.assertRaises(IndexError): q.insert(-1, 'one') + with self.assertRaises(IndexError): q.insert(1, 'one') + q.insert(0, 'one') + self.assertEqual(q.list(), ['one']) + self.assertEqual(self.get_all(q), ['one']) + + async def test_swap(self): + q = Queue() + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + q.swap(1, 2) + self.assertEqual(q.list(), ['zero', 'two', 'one', 'three']) + self.assertEqual(self.get_all(q), ['zero', 'two', 'one', 'three']) + + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + q.swap(-1, 1) + self.assertEqual(q.list(), ['zero', 'three', 'two', 'one']) + self.assertEqual(self.get_all(q), ['zero', 'three', 'two', 'one']) + + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + with self.assertRaises(IndexError): q.swap(0, 4) + with self.assertRaises(IndexError): q.swap(4, 0) + with self.assertRaises(IndexError): q.swap(0, -5) + with self.assertRaises(IndexError): q.swap(-5, 0) + + q.swap(0, 0) + q.swap(1, 1) + q.swap(2, 2) + q.swap(3, 3) + self.assertEqual(q.list(), ['zero', 'one', 'two', 'three']) + self.assertEqual(self.get_all(q), ['zero', 'one', 'two', 'three']) + + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + q.swap(1, 0) + self.assertEqual(q.list(), ['one', 'zero', 'two', 'three']) + self.assertEqual(self.get_all(q), ['one', 'zero', 'two', 'three']) + + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + q.swap(1, -1) + self.assertEqual(q.list(), ['zero', 'three', 'two', 'one']) + self.assertEqual(self.get_all(q), ['zero', 'three', 'two', 'one']) + + unittest.main() + \ No newline at end of file diff --git a/hotkey_button.py b/hotkey_button.py new file mode 100644 index 0000000..50241c7 --- /dev/null +++ b/hotkey_button.py @@ -0,0 +1,46 @@ +from textual.widgets import Button + +class HotkeyButton(Button, can_focus=True): + idx = 0 + + def __init__(self, hotkey=None, hotkey_description = None, **kwargs): + if 'label' not in kwargs and hotkey_description != None: + kwargs['label'] = hotkey_description + super().__init__(**kwargs) + + self.hotkey = hotkey + self.hotkey_description = hotkey_description + HotkeyButton.idx += 1 + self.idx = HotkeyButton.idx + self.app_action = 'press_hotkeybutton_' + str(self.idx) + + def update_hotkey(self, hotkey = None, hotkey_description = None, label = None): + if label == None and hotkey_description != None: self.label = hotkey_description + self.unbind_hotkey() + self.hotkey = hotkey + self.hotkey_description = hotkey_description + self.bind_hotkey() + + def bind_hotkey(self): + if self.hotkey == None: return + self.app.bind(self.hotkey, self.app_action, description=self.hotkey_description) + print("hotkey BOUND for " + str(self.idx)) + + def unbind_hotkey(self): + if self.hotkey == None: return + self.app.unbind(self.hotkey) + print("hotkey UNBOUND for " + str(self.idx)) + + def on_mount(self): + def press_me(): self.press() + setattr(self.app, 'action_' + self.app_action, press_me) + self.bind_hotkey() + + def on_button_pressed(self): + print('pressed button ' + str(self.idx)) + + def watch_disabled(self, disabled_state): + super().watch_disabled(disabled_state) + print('disabled', disabled_state) + if disabled_state: self.unbind_hotkey() + else: self.bind_hotkey() \ No newline at end of file diff --git a/main.py b/main.py old mode 100755 new mode 100644 index 5206ed7..bc0b207 --- a/main.py +++ b/main.py @@ -1,25 +1,7 @@ -#!/usr/bin/env python - -import asyncio -import websockets -import ssl -import os.path -import time -import json -import traceback -import sys -import signal -import spooler -import async_prompt -from tty_colors import COL -import zc -import porkbun - - USE_ZEROCONF = 0 ZEROCONF_HOSTNAME = 'plotter' -USE_PORKBUN = 1 +USE_PORKBUN = 0 PORKBUN_ROOT_DOMAIN = 'process.tools' PORKBUN_SUBDOMAIN = 'plotter-local' PORKBUN_TTL = 600 @@ -35,47 +17,80 @@ PING_INTERVAL = 10 PING_TIMEOUT = 5 -SHOW_CONNECTION_EVENTS = 0 # Print when clients connect/disconnect +SHOW_CONNECTION_EVENTS = 1 # Print when clients connect/disconnect MAX_MESSAGE_SIZE_MB = 5 # in MB (Default in websockets lib is 2) -prompt = None +QUEUE_HEADERS = ['#', 'Client', 'Hash', 'Lines', 'Layers', 'Travel', 'Ink', 'Format', 'Speed', 'Duration'] + + +import textual +from textual import on +from textual.app import App as TextualApp +from textual.widgets import Button, DataTable, RichLog, Footer, Header, Static, ProgressBar, Rule +from textual.containers import Horizontal, Vertical +from hotkey_button import HotkeyButton + +import asyncio +import websockets +import spooler +import json +import math +import subprocess +import porkbun + + +app = None +ssl_context = None num_clients = 0 clients = [] -ssl_context = None -def status_str(status): - match status['status']: - case 'setup': - return(f'{COL.BOLD}{COL.BLUE}Setup{COL.OFF}') - case 'waiting': - return(f'{COL.BOLD}Waiting for jobs{COL.OFF}') - case 'confirm_plot': - return(f'{COL.BOLD}{COL.YELLOW}Confirm to plot {status["job_str"]}{COL.OFF}') - case 'plotting': - return(f'{COL.BOLD}{COL.GREEN}Plotting [{status["job"]}]{COL.OFF}') - -def col_num(n): - if n > 0: - return f'{COL.BOLD}{COL.GREEN}{n}{COL.OFF}' - else: - return f'{COL.BOLD}{n}{COL.OFF}' +# Status simply shows up in the header def print_status(): - s = spooler.status() - print(f' Jobs: {col_num(s["queue_size"])} | Clients: {col_num(len(clients))} | Status: {status_str(s)}\n') - -def setup_prompt(): - global prompt - global print - prompt = async_prompt.AsyncPrompt() - print = prompt.print # replace global print function + app.update_header() -def remove_prompt(): - global prompt - del prompt # force destructor, causes terminal to restore +def setup_ssl(): + import ssl + import os.path + + if USE_SSL: + global ssl_context + try: + cert_file = os.path.join( os.path.dirname(__file__), SSL_CERT ) + key_file = None if SSL_KEY == None else os.path.join( os.path.dirname(__file__), SSL_KEY ) + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_context.load_cert_chain(cert_file, key_file) + print(f'TLS enabled with certificate: {SSL_CERT}{"" if SSL_KEY == None else " + " + SSL_KEY}') + except FileNotFoundError: + print(f'Certificate not found, TLS disabled') + ssl_context = None + except: + print(f'Error establishing TLS context, TLS disabled') + ssl_context = None + global PORT + if PORT == 0: PORT = 80 if ssl_context == None else 443 -def disable_sigint(): - signal.signal(signal.SIGINT, lambda *args: None) +async def handle_connection(ws): + global num_clients + num_clients += 1 + clients.append(ws) + remote_address = ws.remote_address # store remote address (might not be available on disconnect) + if SHOW_CONNECTION_EVENTS: + print(f'({num_clients}) Connected: {remote_address[0]}:{remote_address[1]}') + print_status() + # await send_current_queue_size(ws) + try: + # The iterator exits normally when the connection is closed with close code 1000 (OK) or 1001 (going away). It raises a ConnectionClosedError when the connection is closed with any other code. + async for message in ws: + # print(f'Message ({ws.remote_address[0]}:{ws.remote_address[1]}):', message) + await handle_message(message, ws) + except websockets.exceptions.ConnectionClosedError: + pass + num_clients -= 1 + clients.remove(ws) + if SHOW_CONNECTION_EVENTS: + print(f'({num_clients}) Disconnected: {remote_address[0]}:{remote_address[1]} ({ws.close_code}{(" " + ws.close_reason).rstrip()})') + print_status() async def send_msg(msg, ws): if type(msg) is dict: msg = json.dumps(msg) @@ -83,15 +98,17 @@ async def send_msg(msg, ws): await ws.send(msg) except (websockets.exceptions.ConnectionClosedError, websockets.exceptions.ConnectionClosedOK): pass - + async def on_queue_size(size): + app.update_job_queue() + app.update_header() cbs = [] for ws in clients: cbs.append( send_msg({'type': 'queue_length', 'length': size}, ws) ) await asyncio.gather(*cbs) async def send_current_queue_size(ws): - await send_msg( {'type': 'queue_length', 'length': spooler.queue_size()}, ws ) + await send_msg( {'type': 'queue_length', 'length': spooler.num_jobs()}, ws ) async def handle_message(message, ws): async def on_queue_position(pos, job): @@ -102,85 +119,343 @@ async def on_cancel(job): await send_msg( {'type': 'job_canceled'}, ws ) async def on_error(msg, job): await send_msg( {'type': 'error', 'msg': msg}, ws ) - msg = json.loads(message) + + try: + msg = json.loads(message) + except JSONDecodeError: + return + if msg['type'] == 'echo': await ws.send(message) elif msg['type'] == 'plot': - qsize = spooler.queue_size() + qsize = spooler.num_jobs() result = await spooler.enqueue(msg, on_queue_position, on_done, on_cancel, on_error) if result and qsize > 0: print_status() # Don't print status if queue is empty -> Status will be printed by spooler elif msg['type'] == 'cancel': result = await spooler.cancel(msg['client']) if result: print_status() -async def handle_connection(ws): - global num_clients - num_clients += 1 - clients.append(ws) - remote_address = ws.remote_address # store remote address (might not be available on disconnect) - if SHOW_CONNECTION_EVENTS: - print(f'({num_clients}) Connected: {remote_address[0]}:{remote_address[1]}') - print_status() - await send_current_queue_size(ws) - try: - # The iterator exits normally when the connection is closed with close code 1000 (OK) or 1001 (going away). It raises a ConnectionClosedError when the connection is closed with any other code. - async for message in ws: - # print(f'Message ({ws.remote_address[0]}:{ws.remote_address[1]}):', message) - await handle_message(message, ws) - except websockets.exceptions.ConnectionClosedError: - pass - num_clients -= 1 - clients.remove(ws) - if SHOW_CONNECTION_EVENTS: - print(f'({num_clients}) Disconnected: {remote_address[0]}:{remote_address[1]} ({ws.close_code}{(" " + ws.close_reason).rstrip()})') - print_status() - -def setup_ssl(): - if USE_SSL: - global ssl_context - try: - cert_file = os.path.join( os.path.dirname(__file__), SSL_CERT ) - key_file = None if SSL_KEY == None else os.path.join( os.path.dirname(__file__), SSL_KEY ) - ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ssl_context.load_cert_chain(cert_file, key_file) - print(f'TLS enabled with certificate: {SSL_CERT}{"" if SSL_KEY == None else " + " + SSL_KEY}') - except FileNotFoundError: - print(f'Certificate not found, TLS disabled') - ssl_context = None - except: - print(f'Error establishing TLS context, TLS disabled') - ssl_context = None - global PORT - if PORT == 0: PORT = 80 if ssl_context == None else 443 - -async def main(): - setup_prompt() # needs to be called within event loop +async def run_server(app): async with websockets.serve(handle_connection, BIND_IP, PORT, ping_interval=PING_INTERVAL, ping_timeout=PING_TIMEOUT, ssl=ssl_context, max_size=MAX_MESSAGE_SIZE_MB*(2**20)): print(f'Server running on {"ws" if ssl_context == None else "wss"}://{BIND_IP}:{PORT}') print() spooler.set_queue_size_cb(on_queue_size) # await asyncio.Future() # run forever - await spooler.start(prompt, print_status) # run forever + await spooler.start(app) # run forever -def quit(): - print('Quitting...') - remove_prompt() - if USE_ZEROCONF: zc.remove_zeroconf_service() -if __name__ == '__main__': - try: - if USE_PORKBUN: - porkbun.ddns_update(PORKBUN_ROOT_DOMAIN, PORKBUN_SUBDOMAIN, PORKBUN_TTL) - porkbun.cert_update(PORKBUN_ROOT_DOMAIN, PORKBUN_SSL_OUTFILE) - print() - setup_ssl() # Updates global PORT - if USE_ZEROCONF: zc.add_zeroconf_service(ZEROCONF_HOSTNAME, PORT) - asyncio.run(main()) - except KeyboardInterrupt: - print('*** Ctrl-C pressed ***') + +class App(TextualApp): + prompt_future = None + + def compose(self): + global header, queue, log, footer + header = Header(icon = '🖨️', show_clock = True, time_format = '%H:%M') + queue = DataTable() + log = RichLog(markup=True) + footer = Footer(id="footer", show_command_palette=True) + + global job_current, job_status,job_progress + job_current = DataTable() + job_status = Static("Status: Waiting") + job_progress = ProgressBar() + + global col_left, col_right, job, commands, commands_1, commands_2, commands_3, commands_4, commands_5 + global b_pos, b_neg, b_align, b_cycle, b_home, b_plus, b_minus, b_preview + + yield header + # yield HotkeyButton('p', 'Press') + # yield HotkeyButton('x', 'Something') + with Horizontal(): + with Vertical() as col_left: + with Vertical() as job: + yield job_current + yield job_status + yield job_progress + # yield Rule() + with Horizontal(id='commands') as commands: + with Vertical() as commands_1: + yield (b_pos := HotkeyButton(label='Plot', id="pos")) + with Vertical() as commands_2: + yield (b_align := HotkeyButton('a', 'Align', label='Align', id='align')) + yield (b_cycle := HotkeyButton('c', 'Cycle', label='Cycle', id='cycle')) + yield (b_home := HotkeyButton('h', 'Home', label='Home', id='home')) + with Vertical() as commands_3: + yield (b_plus := HotkeyButton(label='+10', id='plus')) + yield (b_minus := HotkeyButton(label='-10', id='minus')) + with Vertical() as commands_4: + yield (b_preview := HotkeyButton(label='Preview', id='preview')) + with Vertical() as commands_5: + yield (b_neg := HotkeyButton(label='Cancel', id='neg')) + yield queue + with Vertical() as col_right: + yield log + yield footer + + def on_mount(self): + self.title = "Plotter" + header.tall = True + col_left.styles.width = '3fr' + col_right.styles.width = '2fr' + + # self.query_one('#footer').show_command_palette=False + log.border_title = 'Log' + log.styles.border = ('solid', 'white') + + job.border_title = 'Job' + job.styles.border = ('solid', 'white') + job.styles.height = 22 + + job_current.styles.height = 3 + job_current.add_columns(*QUEUE_HEADERS) + job_current.cursor_type = 'none' + job_status.styles.margin = 1 + job_progress.styles.margin = 1 + job_progress.styles.width = '100%' + job_progress.query_one('#bar').styles.width = '1fr' + + commands.styles.margin = (3, 0, 0, 0) + + for button in commands.query('Button'): + button.styles.width = '100%' + button.styles.margin = (0, 1); + + for col in commands.query('Vertical'): + col.styles.align_horizontal = 'center' + # col.styles.border = ('vkey', 'white') + + commands_2.styles.width = '0.5625fr' + for button in commands_2.query('Button'): + button.styles.min_width = 9 + + commands_3.styles.width = '0.3125fr' + for button in commands_3.query('Button'): + button.styles.min_width = 5 + + commands_4.styles.width = '0.6875fr' + for button in commands_4.query('Button'): + button.styles.min_width = 11 + + queue.border_title = 'Queue' + queue.styles.border = ('solid', 'white') + queue.styles.height = '1fr' + queue.add_columns(*QUEUE_HEADERS) + queue.cursor_type = 'row' + + self.update_header() + + setup_ssl() + # log.write(log.styles.height) + + global server_task + server_task = asyncio.create_task(run_server(self)) + + def on_server_task_exit(task): + print('[red]SERVER TASK EXIT') + if not task.cancelled(): + ex = task.exception() + if ex != None: + print(ex) + raise ex + self.quit() + + server_task.add_done_callback(on_server_task_exit) + + # global spooler_task + # spooler_task = asyncio.create_task(spooler.start(self)) + + def on_resize(self, event): + pass + + def on_key(self): pass - except: - traceback.print_exception( sys.exception() ) - finally: - disable_sigint() # prevent another Control-C - quit() \ No newline at end of file + + def print(self, *args, sep=' ', end='\n'): + if len(args) == 1: log.write(args[0]) + else: log.write( sep.join(map(str, args)) + end) + + def update_header(self): + status = spooler.status() + self.title = status['status_desc'] + self.sub_title = f'{num_clients} Clients – {spooler.num_jobs()} Jobs' + + def bind(self, *args, **kwargs): + super().bind(*args, **kwargs) + self.refresh_bindings() + + def unbind(self, key): + # self._bindings.key_to_bindings is a dict of keys to lists of Binding objects + self._bindings.key_to_bindings.pop(key, None) + self.refresh_bindings() + + # bindings: [ (key, desc), ... ] + # This not a coroutine (no async). It returns a future, which can be awaited from coroutines + def prompt(self, bindings, message): + # setup bindings + self.print(message) + self.print(bindings) + self.update_bindings([ ('y', 'prompt_response("y")', 'Yes'), ('n', 'prompt_response("n")', 'No') ]) + + # return a future that eventually resolves to the result + loop = asyncio.get_running_loop() + self.prompt_future = loop.create_future() + return self.prompt_future + + def preview_job(self, job): + if job != None and 'save_path' in job: + print(f'Preview job \\[{job["client"]}]: {job["save_path"]}') + sub_coro = asyncio.create_subprocess_exec('qlmanage', '-p', job['save_path'], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + asyncio.create_task(sub_coro) + + def adjust_job_speed(self, job, delta): + if job != None: + speed = job['speed'] if 'speed' in job else 100 + speed += delta + speed = int(speed / 10) * 10 + speed = max( min(speed, 100), 10 ) + print(f'Adjust job speed \\[{job["client"]}]: {speed}') + job['speed'] = speed + if (job == spooler.current_job()): self.update_current_job() + + @on(Button.Pressed, '#commands Button') + def on_button(self, event): + id = event.button.id + if (id == 'preview'): + self.preview_job( spooler.current_job() ) + return + if (id == 'plus'): + self.adjust_job_speed( spooler.current_job(), 10 ) + return + if (id == 'minus'): + self.adjust_job_speed( spooler.current_job(), -10 ) + return + + if self.prompt_future != None and not self.prompt_future.done(): + if id == None and event.button.hotkey_description: + id = str(event.button.hotkey_description).lower() + if id == None and event.button.label: + id = str(event.button.label).lower() + self.prompt_future.set_result({ + 'id': id, # use button id, hotkey description (lowercase), or button label (lowercase) + 'button': event.button + }) + + def job_to_row(self, job, idx): + return (idx, job['client'], job['hash'][:5], job['stats']['count'], job['stats']['layer_count'], int(job['stats']['travel'])/1000, int(job['stats']['travel_ink'])/1000, job['format'], job['speed'], f'{math.floor(job["time_estimate"]/60)}:{round(job["time_estimate"]%60):02}') + + def update_current_job(self): + job = spooler.current_job() + job_current.clear() + if job != None: + job_current.add_row( *self.job_to_row(job, 1) ) + + def update_job_queue(self): + queue.clear() + for idx, job in enumerate(spooler.jobs()): + queue.add_row( *self.job_to_row(job, idx+1) ) + + # This not a coroutine (no async). It returns a future, which can be awaited from coroutines + def prompt_ui(self, variant, message = '', ): + print('PROMPT', variant) + + if len(message) > 0: message = ' – ' + message + job_status.update(spooler.status()['status_desc'] + message) + self.update_current_job() + + match variant: + case 'setup': + b_pos.variant = 'default' + b_pos.disabled = True + + b_neg.update_hotkey('d', 'Done') + b_neg.variant = 'success' + b_neg.disabled = False + + b_align.disabled = False + b_cycle.disabled = False + b_home.disabled = True + b_plus.disabled = True + b_minus.disabled = True + b_preview.disabled = True + + case 'waiting': + b_pos.disabled = True + b_neg.disabled = True + + b_align.disabled = True + b_cycle.disabled = True + b_home.disabled = True + b_plus.disabled = True + b_minus.disabled = True + b_preview.disabled = True + case 'start_plot': + b_pos.update_hotkey('p', 'Plot') + b_pos.variant = 'success' + b_pos.disabled = False + + b_neg.update_hotkey('c', 'Cancel') + b_neg.variant = 'error' + b_neg.disabled = False + + b_align.disabled = False + b_cycle.disabled = False + b_home.disabled = True + b_plus.disabled = False + b_minus.disabled = False + b_preview.disabled = False + case 'repeat_plot': + b_pos.update_hotkey('p', 'Plot again') + b_pos.variant = 'primary' + b_pos.disabled = False + + b_neg.update_hotkey('d', 'Done') + b_neg.variant = 'success' + b_neg.disabled = False + + b_align.disabled = False + b_cycle.disabled = False + b_home.disabled = True + b_plus.disabled = False + b_minus.disabled = False + b_preview.disabled = False + case 'resume_plot': + b_pos.update_hotkey('p', 'Resume') + b_pos.variant = 'primary' + b_pos.disabled = False + + b_neg.update_hotkey('c', 'Cancel') + b_neg.variant = 'error' + b_neg.disabled = False + + b_align.disabled = False + b_cycle.disabled = False + b_home.disabled = False + b_plus.disabled = True + b_minus.disabled = True + b_preview.disabled = False + case _: + raise ValueError('Invalid variant') + + # return a future that eventually resolves to the result + loop = asyncio.get_running_loop() + self.prompt_future = loop.create_future() + return self.prompt_future + + + +if __name__ == "__main__": + global print + global tprint + tprint = print + + if USE_PORKBUN: + porkbun.ddns_update(PORKBUN_ROOT_DOMAIN, PORKBUN_SUBDOMAIN, PORKBUN_TTL) + porkbun.cert_update(PORKBUN_ROOT_DOMAIN, PORKBUN_SSL_OUTFILE) + print() + + if USE_ZEROCONF: zc.add_zeroconf_service(ZEROCONF_HOSTNAME, PORT) + + app = App() + print = app.print + + app.run() \ No newline at end of file diff --git a/main_old.py b/main_old.py new file mode 100755 index 0000000..5206ed7 --- /dev/null +++ b/main_old.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python + +import asyncio +import websockets +import ssl +import os.path +import time +import json +import traceback +import sys +import signal +import spooler +import async_prompt +from tty_colors import COL +import zc +import porkbun + + +USE_ZEROCONF = 0 +ZEROCONF_HOSTNAME = 'plotter' + +USE_PORKBUN = 1 +PORKBUN_ROOT_DOMAIN = 'process.tools' +PORKBUN_SUBDOMAIN = 'plotter-local' +PORKBUN_TTL = 600 +PORKBUN_SSL_OUTFILE = 'cert/process.tools.pem' + +BIND_IP = '0.0.0.0' +PORT = 0 # Use 0 for default ports (80 for http, 443 for ssl/tls) +USE_SSL = 1 +# SSL_CERT = 'cert/localhost.pem' # Certificate file in pem format (can contain private key as well) +# SSL_KEY = None # Private key file in pem format (If None, the key needs to be contained in SSL_CERT) +SSL_CERT = 'cert/process.tools.pem' +SSL_KEY = None + +PING_INTERVAL = 10 +PING_TIMEOUT = 5 +SHOW_CONNECTION_EVENTS = 0 # Print when clients connect/disconnect +MAX_MESSAGE_SIZE_MB = 5 # in MB (Default in websockets lib is 2) + +prompt = None +num_clients = 0 +clients = [] +ssl_context = None + +def status_str(status): + match status['status']: + case 'setup': + return(f'{COL.BOLD}{COL.BLUE}Setup{COL.OFF}') + case 'waiting': + return(f'{COL.BOLD}Waiting for jobs{COL.OFF}') + case 'confirm_plot': + return(f'{COL.BOLD}{COL.YELLOW}Confirm to plot {status["job_str"]}{COL.OFF}') + case 'plotting': + return(f'{COL.BOLD}{COL.GREEN}Plotting [{status["job"]}]{COL.OFF}') + +def col_num(n): + if n > 0: + return f'{COL.BOLD}{COL.GREEN}{n}{COL.OFF}' + else: + return f'{COL.BOLD}{n}{COL.OFF}' + +def print_status(): + s = spooler.status() + print(f' Jobs: {col_num(s["queue_size"])} | Clients: {col_num(len(clients))} | Status: {status_str(s)}\n') + +def setup_prompt(): + global prompt + global print + prompt = async_prompt.AsyncPrompt() + print = prompt.print # replace global print function + +def remove_prompt(): + global prompt + del prompt # force destructor, causes terminal to restore + +def disable_sigint(): + signal.signal(signal.SIGINT, lambda *args: None) + +async def send_msg(msg, ws): + if type(msg) is dict: msg = json.dumps(msg) + try: + await ws.send(msg) + except (websockets.exceptions.ConnectionClosedError, websockets.exceptions.ConnectionClosedOK): + pass + +async def on_queue_size(size): + cbs = [] + for ws in clients: + cbs.append( send_msg({'type': 'queue_length', 'length': size}, ws) ) + await asyncio.gather(*cbs) + +async def send_current_queue_size(ws): + await send_msg( {'type': 'queue_length', 'length': spooler.queue_size()}, ws ) + +async def handle_message(message, ws): + async def on_queue_position(pos, job): + await send_msg( {'type': 'queue_position', 'position': pos}, ws ) + async def on_done(job): + await send_msg( {'type': 'job_done'}, ws ) + async def on_cancel(job): + await send_msg( {'type': 'job_canceled'}, ws ) + async def on_error(msg, job): + await send_msg( {'type': 'error', 'msg': msg}, ws ) + msg = json.loads(message) + if msg['type'] == 'echo': + await ws.send(message) + elif msg['type'] == 'plot': + qsize = spooler.queue_size() + result = await spooler.enqueue(msg, on_queue_position, on_done, on_cancel, on_error) + if result and qsize > 0: print_status() # Don't print status if queue is empty -> Status will be printed by spooler + elif msg['type'] == 'cancel': + result = await spooler.cancel(msg['client']) + if result: print_status() + +async def handle_connection(ws): + global num_clients + num_clients += 1 + clients.append(ws) + remote_address = ws.remote_address # store remote address (might not be available on disconnect) + if SHOW_CONNECTION_EVENTS: + print(f'({num_clients}) Connected: {remote_address[0]}:{remote_address[1]}') + print_status() + await send_current_queue_size(ws) + try: + # The iterator exits normally when the connection is closed with close code 1000 (OK) or 1001 (going away). It raises a ConnectionClosedError when the connection is closed with any other code. + async for message in ws: + # print(f'Message ({ws.remote_address[0]}:{ws.remote_address[1]}):', message) + await handle_message(message, ws) + except websockets.exceptions.ConnectionClosedError: + pass + num_clients -= 1 + clients.remove(ws) + if SHOW_CONNECTION_EVENTS: + print(f'({num_clients}) Disconnected: {remote_address[0]}:{remote_address[1]} ({ws.close_code}{(" " + ws.close_reason).rstrip()})') + print_status() + +def setup_ssl(): + if USE_SSL: + global ssl_context + try: + cert_file = os.path.join( os.path.dirname(__file__), SSL_CERT ) + key_file = None if SSL_KEY == None else os.path.join( os.path.dirname(__file__), SSL_KEY ) + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_context.load_cert_chain(cert_file, key_file) + print(f'TLS enabled with certificate: {SSL_CERT}{"" if SSL_KEY == None else " + " + SSL_KEY}') + except FileNotFoundError: + print(f'Certificate not found, TLS disabled') + ssl_context = None + except: + print(f'Error establishing TLS context, TLS disabled') + ssl_context = None + global PORT + if PORT == 0: PORT = 80 if ssl_context == None else 443 + +async def main(): + setup_prompt() # needs to be called within event loop + async with websockets.serve(handle_connection, BIND_IP, PORT, ping_interval=PING_INTERVAL, ping_timeout=PING_TIMEOUT, ssl=ssl_context, max_size=MAX_MESSAGE_SIZE_MB*(2**20)): + print(f'Server running on {"ws" if ssl_context == None else "wss"}://{BIND_IP}:{PORT}') + print() + spooler.set_queue_size_cb(on_queue_size) + # await asyncio.Future() # run forever + await spooler.start(prompt, print_status) # run forever + +def quit(): + print('Quitting...') + remove_prompt() + if USE_ZEROCONF: zc.remove_zeroconf_service() + +if __name__ == '__main__': + try: + if USE_PORKBUN: + porkbun.ddns_update(PORKBUN_ROOT_DOMAIN, PORKBUN_SUBDOMAIN, PORKBUN_TTL) + porkbun.cert_update(PORKBUN_ROOT_DOMAIN, PORKBUN_SSL_OUTFILE) + print() + setup_ssl() # Updates global PORT + if USE_ZEROCONF: zc.add_zeroconf_service(ZEROCONF_HOSTNAME, PORT) + asyncio.run(main()) + except KeyboardInterrupt: + print('*** Ctrl-C pressed ***') + pass + except: + traceback.print_exception( sys.exception() ) + finally: + disable_sigint() # prevent another Control-C + quit() \ No newline at end of file diff --git a/spooler.py b/spooler.py index 4ff49e1..8576681 100644 --- a/spooler.py +++ b/spooler.py @@ -1,12 +1,12 @@ import asyncio from pyaxidraw import axidraw -from tty_colors import COL from datetime import datetime, timezone import math import os from capture_output import capture_output import re import hashlib +import async_queue FOLDER_WAITING ='svgs/0_waiting' FOLDER_CANCELED ='svgs/1_canceled' @@ -15,26 +15,34 @@ PEN_POS_DOWN = 40 # Default: 40 MIN_SPEED = 10 # percent -KEY_DONE = [ 'd', '(D)one' ] -KEY_REPEAT = [ 'r', '(R)epeat' ] -KEY_START_PLOT = [ 'p', '(P)lot' ] -KEY_RESTART_PLOT = [ 'p', '(P)lot from start' ] -KEY_ALIGN = [ 'a', '(A)lign' ] -KEY_CYCLE = [ 'c', '(C)ycle' ] -KEY_CANCEL = [ chr(27), '(Esc) Cancel Job' ] -KEY_RESUME = [ 'r', '(R)esume' ] -KEY_HOME = [ 'h', '(H)ome' ] - -TESTING = False # Don't actually connect to AxiDraw, just simulate plotting +KEY_DONE = ( 'd', '(D)one' ) +KEY_REPEAT = ( 'r', '(R)epeat' ) +KEY_START_PLOT = ( 'p', '(P)lot' ) +KEY_RESTART_PLOT = ( 'p', '(P)lot from start' ) +KEY_ALIGN = ( 'a', '(A)lign' ) +KEY_CYCLE = ( 'c', '(C)ycle' ) +KEY_CANCEL = ( chr(27), '(Esc) Cancel Job' ) +KEY_RESUME = ( 'r', '(R)esume' ) +KEY_HOME = ( 'h', '(H)ome' ) + +STATUS_DESC = { + 'setup': 'Setting up', + 'waiting': 'Waiting for jobs', + 'confirm_plot': 'Confirm job', + 'plotting': 'Plotting' +} + +TESTING = True # Don't actually connect to AxiDraw, just simulate plotting REPEAT_JOBS = True # Ask to repeat a plot after a sucessful print RESUME_QUEUE = True # Resume plotting queue after quitting/restarting ALIGN_AFTER = True # Align plotter after success or error ALIGN_AFTER_PAUSE = False # Align plotter after pause (programmatic, stop button, keyboard interrupt) queue_size_cb = None -queue = asyncio.Queue() # an async FIFO queue -jobs = {} # an index to all unfinished jobs by client id (in queue or current_job) (insertion order is preserved in dict since python 3.7) -current_job = None +# queue = asyncio.Queue() # an async FIFO queue +queue = async_queue.Queue() # an async FIFO queue that can be reordered +_jobs = {} # an index to all unfinished jobs by client id (in queue or current_job) (insertion order is preserved in dict since python 3.7) +_current_job = None _status = 'setup' # setup | waiting | confirm_plot | plotting async def callback(fn, *args): @@ -43,8 +51,8 @@ async def callback(fn, *args): async def _notify_queue_positions(): cbs = [] - for i, client in enumerate(jobs): - job = jobs[client] + for i, client in enumerate(_jobs): + job = _jobs[client] if i == 0 and _status == 'plotting': i = -1 if 'position_notified' not in job or job['position_notified'] != i: job['position_notified'] = i @@ -52,21 +60,33 @@ async def _notify_queue_positions(): await asyncio.gather(*cbs) # run callbacks concurrently async def _notify_queue_size(): - await callback(queue_size_cb, len(jobs)) + await callback(queue_size_cb, len(_jobs)) def set_queue_size_cb(cb): global queue_size_cb queue_size_cb = cb -def queue_size(): - return len(jobs) +def num_jobs(): + return len(_jobs) + +def current_job(): + return _current_job + +def jobs(): + # return list(_jobs.values()) + queued = queue.list() + if (_current_job != None and not _current_job['cancel']): + queued.insert(0, _current_job) + return queued + def status(): return { 'status': _status, - 'job': current_job['client'] if current_job != None else None, - 'job_str': job_str(current_job) if current_job != None else None, - 'queue_size': queue_size(), + 'status_desc': STATUS_DESC[_status], + 'job': _current_job['client'] if _current_job != None else None, + 'job_str': job_str(_current_job) if _current_job != None else None, + 'queue_size': num_jobs(), } def timestamp(date = None): @@ -90,6 +110,7 @@ def save_svg(job, status): if key == status: os.makedirs( os.path.dirname(file), exist_ok=True) with open(file, 'w', encoding='utf-8') as f: f.write(job['svg']) + job['save_path'] = file else: try: os.remove(file) @@ -120,7 +141,7 @@ def update_svg(job): # todo: don't wait on callbacks async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = None, error_cb = None): # the client might be in queue (or currently plotting) - if job['client'] in jobs: + if job['client'] in _jobs: await callback( error_cb, 'Cannot add job, you already have a job queued!', job ) return False @@ -140,10 +161,8 @@ async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = Non if 'format' not in job: job['format'] = 'A3_LANDSCAPE' # add to jobs index - jobs[ job['client'] ] = job - await _notify_queue_size() # notify new queue size - await _notify_queue_positions() - print(f'New job [{job["client"]}] {job["hash"][0:5]}') + _jobs[ job['client'] ] = job + print(f'New job \\[{job["client"]}] {job["hash"][0:5]}') sim = await simulate_async(job) # run simulation job['time_estimate'] = sim['time_estimate'] job['layers'] = sim['layers'] @@ -151,34 +170,43 @@ async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = Non update_svg(job) await queue.put(job) await save_svg_async(job, 'waiting') + + await _notify_queue_size() # notify new queue size + await _notify_queue_positions() return True async def cancel(client, force = False): if not force: - if current_job != None and current_job['client'] == client: - await callback( current_job['error_cb'], 'Cannot cancel, already plotting!', current_job ) + if _current_job != None and _current_job['client'] == client: + await callback( _current_job['error_cb'], 'Cannot cancel, already plotting!', _current_job ) return False - if client not in jobs: return False - job = jobs[client] + if client not in _jobs: return False + job = _jobs[client] job['cancel'] = True # set cancel flag - del jobs[client] # remove from index + del _jobs[client] # remove from index + + # TODO: if current job, its not in the queue anymore await callback( job['cancel_cb'], job ) # notify canceled job await _notify_queue_size() # notify new queue size await _notify_queue_positions() # notify queue positions (might have changed for some) - print(f'❌ {COL.RED}Canceled job [{job["client"]}]{COL.OFF}') + print(f'❌ [red]Canceled job \\[{job["client"]}]') await save_svg_async(job, 'canceled') return True +async def cancel_current_job(force = True): + print('call cancel job') + return await cancel(_current_job['client'], force = force) + async def finish_current_job(): - await callback( current_job['done_cb'], current_job ) # notify job done - del jobs[ current_job['client'] ] # remove from jobs index + await callback( _current_job['done_cb'], _current_job ) # notify job done + del _jobs[ _current_job['client'] ] # remove from jobs index await _notify_queue_positions() # notify queue positions. current job is 0 await _notify_queue_size() # notify queue size - print(f'✅ {COL.GREEN}Finished job [{current_job["client"]}]{COL.OFF}') + print(f'✅ [green]Finished job \\[{_current_job["client"]}]') _status = 'waiting' - await save_svg_async(current_job, 'finished') + await save_svg_async(_current_job, 'finished') return True def job_str(job): @@ -215,7 +243,7 @@ def print_axidraw(*args): out = ' '.join(args) lines = out.split('\n') for line in lines: - print(f"{COL.GREY}[AxiDraw] " + line + COL.OFF) + print(f"[gray50]\\[AxiDraw] " + line) # Raise pen and disable XY stepper motors def align(): @@ -344,69 +372,68 @@ async def resume_plot_async(*args, **kwargs): async def resume_home_async(*args, **kwargs): return await asyncio.to_thread(resume_home, *args, **kwargs) +async def prompt_setup(message = 'Press \'Done\' when ready'): + while True: + res = await prompt_ui('setup', message) + res = res['id'] + if res == 'align': # Align + print('Aligning...') + await align_async() # -> prompt again + elif res == 'cycle': # Cycle + print('Cycling...') + await cycle_async() # -> prompt again + elif res == 'neg' : # Finish + return True + async def prompt_start_plot(message): - message += f' {KEY_START_PLOT[1]}, {KEY_ALIGN[1]}, {KEY_CYCLE[1]}, {KEY_CANCEL[1]} ?' while True: - res = await prompt.wait_for( [KEY_START_PLOT[0],KEY_ALIGN[0],KEY_CYCLE[0],KEY_CANCEL[0]], message, echo=True ) - if res == KEY_START_PLOT[0]: # Start Plot + res = await prompt_ui('start_plot', message) + res = res['id'] + if res == 'pos': # Start Plot return True - elif res == KEY_ALIGN[0]: # Align + elif res == 'align': # Align print('Aligning...') await align_async() # -> prompt again - elif res == KEY_CYCLE[0]: # Cycle + elif res == 'cycle': # Cycle print('Cycling...') await cycle_async() # -> prompt again - elif res == KEY_CANCEL[0]: # Cancel + elif res == 'neg': # Cancel return False async def prompt_repeat_plot(message): - message += f' {KEY_REPEAT[1]}, {KEY_ALIGN[1]}, {KEY_CYCLE[1]}, {KEY_DONE[1]} ?' while True: - res = await prompt.wait_for( [KEY_REPEAT[0],KEY_ALIGN[0],KEY_CYCLE[0],KEY_DONE[0]], message, echo=True ) - if res == KEY_REPEAT[0]: # Start Plot + res = await prompt_ui('repeat_plot', message) + res = res['id'] + if res == 'pos': # Start Plot return True - elif res == KEY_ALIGN[0]: # Align + elif res == 'align': # Align print('Aligning...') await align_async() # -> prompt again - elif res == KEY_CYCLE[0]: # Cycle + elif res == 'cycle': # Cycle print('Cycling...') await cycle_async() # -> prompt again - elif res == KEY_DONE[0]: # Finish + elif res == 'neg': # Done return False async def prompt_resume_plot(message, job): - message += f' {KEY_RESUME[1]}, {KEY_HOME[1]}, {KEY_ALIGN[1]}, {KEY_CYCLE[1]}, {KEY_RESTART_PLOT[1]}, {KEY_DONE[1]} ?' while True: - res = await prompt.wait_for( [KEY_RESUME[0],KEY_HOME[0],KEY_ALIGN[0],KEY_CYCLE[0],KEY_RESTART_PLOT[0],KEY_DONE[0]], message, echo=True ) - if res == KEY_RESUME[0]: # Resume Plot - return 'resume' - elif res == KEY_RESTART_PLOT[0]: # Restart plot - return 'restart' - elif res == KEY_HOME[0]: # Home + res = await prompt_ui('resume_plot', message) + res = res['id'] + + if res == 'pos': # Resume Plot + return True + elif res == 'home': # Home print('Returning home...') await resume_home_async(job) # -> prompt again - elif res == KEY_ALIGN[0]: # Align + elif res == 'align': # Align print('Aligning...') await align_async() # -> prompt again - elif res == KEY_CYCLE[0]: # Cycle + elif res == 'cycle': # Cycle print('Cycling...') await cycle_async() # -> prompt again - elif res == KEY_DONE[0]: # Finish + elif res == 'neg': # Done return False -async def prompt_setup(message = 'Setup Plotter:'): - message += f' {KEY_ALIGN[1]}, {KEY_CYCLE[1]}, {KEY_DONE[1]} ?' - while True: - res = await prompt.wait_for( [KEY_ALIGN[0],KEY_CYCLE[0],KEY_DONE[0]], message, echo=True ) - if res == KEY_ALIGN[0]: # Align - print('Aligning...') - await align_async() # -> prompt again - elif res == KEY_CYCLE[0]: # Cycle - print('Cycling...') - await cycle_async() # -> prompt again - elif res == KEY_DONE[0] : # Finish - return True - async def resume_queue(): import xml.etree.ElementTree as ElementTree list = sorted(os.listdir(FOLDER_WAITING)) @@ -453,33 +480,45 @@ def attr(attr, ns = 'https://sketch.process.studio/turtle-graphics'): await enqueue(job) -async def start(_prompt, print_status): - global current_job +def set_status(status): + global _status + _status = status + print_status() + +async def start(app): + global _current_job global _status + global print - global prompt - prompt = _prompt # make this global - print = prompt.print # replace global print function + print = app.print + + global prompt_ui + prompt_ui = app.prompt_ui + + global print_status + print_status = app.update_header + + if TESTING: print('[yellow]TESTING MODE enabled') + # if RESUME_QUEUE: await resume_queue() - if TESTING: print(f'{COL.YELLOW}TESTING MODE enabled{COL.OFF}') - if RESUME_QUEUE: await resume_queue() await align_async() await prompt_setup() - _status = 'waiting' while True: # get the next job from the queue, waits until a job becomes available if queue.empty(): - print_status() - current_job = await queue.get() - if not current_job['cancel']: # skip if job is canceled - _status = 'confirm_plot' - print_status() - ready = await prompt_start_plot(f'Ready to plot job {job_str(current_job)}?') + set_status('waiting') + prompt_ui('waiting') + _current_job = await queue.get() + + if not _current_job['cancel']: # skip if job is canceled + set_status('confirm_plot') + ready = await prompt_start_plot(f'Ready to plot job \\[{_current_job["client"]}] ?') if not ready: - await cancel(current_job['client'], force = True) - _status = 'waiting' + await cancel_current_job() + set_status('waiting') + _current_job = None continue # skip over rest of the loop # plot (and retry on error or repeat) @@ -487,42 +526,42 @@ async def start(_prompt, print_status): resume = False # flag indicating resume (vs. plotting from start) while True: if (resume): - print(f'🖨️ {COL.YELLOW}Resuming job [{current_job["client"]}] ...{COL.OFF}') + print(f'🖨️ [yellow]Resuming job \\[{_current_job["client"]}] ...') _status = 'plotting' - error = await resume_plot_async(current_job) + error = await resume_plot_async(_current_job) else: loop += 1 - print(f'🖨️ {COL.YELLOW}Plotting job [{current_job["client"]}] ...{COL.OFF}') + print(f'🖨️ [yellow]Plotting job \\[{_current_job["client"]}] ...') _status = 'plotting' await _notify_queue_positions() # notify plotting - error = await plot_async(current_job) + error = await plot_async(_current_job) resume = False # No error if error == 0: if REPEAT_JOBS: - print(f'{COL.BLUE}Done ({loop}x) job [{current_job["client"]}]{COL.OFF}') - _status = 'confirm_plot' - repeat = await prompt_repeat_plot(f'{COL.BLUE}Repeat{COL.OFF} job [{current_job["client"]}] ?') + print(f'[blue]Done ({loop}x) job \\[{_current_job["client"]}]') + set_status('confirm_plot') + repeat = await prompt_repeat_plot(f'Repeat ({loop+1}) job \\[{_current_job["client"]}] ?') if repeat: continue await finish_current_job() break # Paused programmatically (1), Stopped by pause button press (102) or Stopped by keyboard interrupt (103) elif error in PLOTTER_PAUSED: - print(f'{COL.YELLOW}Plotter: {get_error_msg(error)}{COL.OFF}') - _status = 'confirm_plot' - ready = await prompt_resume_plot(f'{COL.YELLOW}Resume{COL.OFF} job [{current_job["client"]}] ?', current_job) + print(f'[yellow]Plotter: {get_error_msg(error)}') + set_status('plotting') + ready = await prompt_resume_plot(f'[yellow]Resume[/yellow] job \\[{_current_job["client"]}] ?', _current_job) if not ready: - await finish_current_job() + await cancel_current_job() break - if ready == 'resume': resume = True + if ready: resume = True # Errors else: - print(f'{COL.RED}Plotter: {get_error_msg(error)}{COL.OFF}') - _status = 'confirm_plot' - ready = await prompt_start_plot(f'{COL.RED}Retry{COL.OFF} job [{current_job["client"]}] ?') + print(f'[red]Plotter: {get_error_msg(error)}') + set_status('confirm_plot') + ready = await prompt_start_plot(f'[red]Retry job \\[{_current_job["client"]}] ?') if not ready: - await cancel(current_job['client'], force = True) + await cancel(_current_job['client'], force = True) break _status = 'waiting' - current_job = None + _current_job = None diff --git a/spooler_old.py b/spooler_old.py new file mode 100644 index 0000000..4ff49e1 --- /dev/null +++ b/spooler_old.py @@ -0,0 +1,528 @@ +import asyncio +from pyaxidraw import axidraw +from tty_colors import COL +from datetime import datetime, timezone +import math +import os +from capture_output import capture_output +import re +import hashlib + +FOLDER_WAITING ='svgs/0_waiting' +FOLDER_CANCELED ='svgs/1_canceled' +FOLDER_FINISHED ='svgs/2_finished' +PEN_POS_UP = 60 # Default: 60 +PEN_POS_DOWN = 40 # Default: 40 +MIN_SPEED = 10 # percent + +KEY_DONE = [ 'd', '(D)one' ] +KEY_REPEAT = [ 'r', '(R)epeat' ] +KEY_START_PLOT = [ 'p', '(P)lot' ] +KEY_RESTART_PLOT = [ 'p', '(P)lot from start' ] +KEY_ALIGN = [ 'a', '(A)lign' ] +KEY_CYCLE = [ 'c', '(C)ycle' ] +KEY_CANCEL = [ chr(27), '(Esc) Cancel Job' ] +KEY_RESUME = [ 'r', '(R)esume' ] +KEY_HOME = [ 'h', '(H)ome' ] + +TESTING = False # Don't actually connect to AxiDraw, just simulate plotting +REPEAT_JOBS = True # Ask to repeat a plot after a sucessful print +RESUME_QUEUE = True # Resume plotting queue after quitting/restarting +ALIGN_AFTER = True # Align plotter after success or error +ALIGN_AFTER_PAUSE = False # Align plotter after pause (programmatic, stop button, keyboard interrupt) + +queue_size_cb = None +queue = asyncio.Queue() # an async FIFO queue +jobs = {} # an index to all unfinished jobs by client id (in queue or current_job) (insertion order is preserved in dict since python 3.7) +current_job = None +_status = 'setup' # setup | waiting | confirm_plot | plotting + +async def callback(fn, *args): + if callable(fn): + await fn(*args) + +async def _notify_queue_positions(): + cbs = [] + for i, client in enumerate(jobs): + job = jobs[client] + if i == 0 and _status == 'plotting': i = -1 + if 'position_notified' not in job or job['position_notified'] != i: + job['position_notified'] = i + cbs.append( callback(job['queue_position_cb'], i, job) ) + await asyncio.gather(*cbs) # run callbacks concurrently + +async def _notify_queue_size(): + await callback(queue_size_cb, len(jobs)) + +def set_queue_size_cb(cb): + global queue_size_cb + queue_size_cb = cb + +def queue_size(): + return len(jobs) + +def status(): + return { + 'status': _status, + 'job': current_job['client'] if current_job != None else None, + 'job_str': job_str(current_job) if current_job != None else None, + 'queue_size': queue_size(), + } + +def timestamp(date = None): + if date == None: + # make timezone aware timestamp: https://stackoverflow.com/a/39079819 + date = datetime.now(timezone.utc) + date = date.replace(tzinfo=date.astimezone().tzinfo) + return date.strftime("%Y%m%d_%H%M%S.%f_UTC%z") + +# status: 'waiting' | 'canceled' | 'finished' +def save_svg(job, status): + if status not in ['waiting', 'canceled', 'finished']: + return False + filename = f'{job["received"]}_{job["client"][0:10]}_{job["hash"][0:5]}.svg' + files = { + 'waiting': os.path.join(FOLDER_WAITING, filename), + 'canceled': os.path.join(FOLDER_CANCELED, filename), + 'finished': os.path.join(FOLDER_FINISHED, filename), + } + for key, file in files.items(): + if key == status: + os.makedirs( os.path.dirname(file), exist_ok=True) + with open(file, 'w', encoding='utf-8') as f: f.write(job['svg']) + else: + try: + os.remove(file) + except: + pass + return True + +def save_svg_async(*args, **kwargs): + return asyncio.to_thread(save_svg, *args, **kwargs) + + +# Updated pre version 4 SVGs, so they are compatible with resume queue +def update_svg(job): + match = re.search('tg:version="(\\d+)"', job['svg']) + if match != None and int(match.group(1)) >= 4: return + + MARKER = 'xmlns:tg="https://sketch.process.studio/turtle-graphics"' + idx = job['svg'].find(MARKER) + if idx == -1: return + idx += len(MARKER) + insert = f'\n tg:version="4" tg:layer_count="1" tg:oob_count="{job['stats']['oob_count']}" tg:short_count="{job['stats']['short_count']}" tg:format="{job['format']}" tg:width_mm="{job['size'][0]}" tg:height_mm="{job['size'][1]}" tg:speed="{job['speed']}" tg:author="{job['client']}" tg:timestamp="{job['timestamp']}"' + + job['svg'] = job['svg'][:idx] + insert + job['svg'][idx:] + job['hash'] = hashlib.sha1(job['svg'].encode('utf-8')).hexdigest() + +# job {'type': 'plot, 'client', 'id', 'svg', stats, timestamp, hash, speed, format, size, received?} +# adds to job: { 'cancel', time_estimate', 'layers', received } +# todo: don't wait on callbacks +async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = None, error_cb = None): + # the client might be in queue (or currently plotting) + if job['client'] in jobs: + await callback( error_cb, 'Cannot add job, you already have a job queued!', job ) + return False + + job['cancel'] = False + # save callbacks + job['queue_position_cb'] = queue_position_cb + job['done_cb'] = done_cb + job['cancel_cb'] = cancel_cb + job['error_cb'] = error_cb + if 'received' not in job or job['received'] == None: + job['received'] = timestamp() + + # speed + if 'speed' in job: job['speed'] = max( min(job['speed'], 100), MIN_SPEED ) # limit speed (MIN_SPEED, 100) + else: job['speed'] = 100 + # format + if 'format' not in job: job['format'] = 'A3_LANDSCAPE' + + # add to jobs index + jobs[ job['client'] ] = job + await _notify_queue_size() # notify new queue size + await _notify_queue_positions() + print(f'New job [{job["client"]}] {job["hash"][0:5]}') + sim = await simulate_async(job) # run simulation + job['time_estimate'] = sim['time_estimate'] + job['layers'] = sim['layers'] + + update_svg(job) + await queue.put(job) + await save_svg_async(job, 'waiting') + return True + +async def cancel(client, force = False): + if not force: + if current_job != None and current_job['client'] == client: + await callback( current_job['error_cb'], 'Cannot cancel, already plotting!', current_job ) + return False + + if client not in jobs: return False + job = jobs[client] + job['cancel'] = True # set cancel flag + del jobs[client] # remove from index + + await callback( job['cancel_cb'], job ) # notify canceled job + await _notify_queue_size() # notify new queue size + await _notify_queue_positions() # notify queue positions (might have changed for some) + print(f'❌ {COL.RED}Canceled job [{job["client"]}]{COL.OFF}') + await save_svg_async(job, 'canceled') + return True + +async def finish_current_job(): + await callback( current_job['done_cb'], current_job ) # notify job done + del jobs[ current_job['client'] ] # remove from jobs index + await _notify_queue_positions() # notify queue positions. current job is 0 + await _notify_queue_size() # notify queue size + print(f'✅ {COL.GREEN}Finished job [{current_job["client"]}]{COL.OFF}') + _status = 'waiting' + await save_svg_async(current_job, 'finished') + return True + +def job_str(job): + info = '[' + str(job["client"])[0:10] + '] ' + job['hash'][0:5] + speed_and_format = f'{job["speed"]}%, {job["format"]}, {math.floor(job["time_estimate"]/60)}:{round(job["time_estimate"]%60):02} min' + if 'stats' in job: + stats = job['stats'] + layers = f'{job["layers"]} layers, ' if 'layers' in job and job['layers'] > 1 else '' + if 'count' in stats and 'travel' in stats and 'travel_ink' in stats: + info += f' ({stats["count"]} lines, {layers}{int(stats["travel_ink"])}/{int(stats["travel"])} mm, {speed_and_format})' + else: + info += f' ({speed_and_format})' + return info + + +# Return codes +PLOTTER_ERRORS = { + 0: 'No error; operation nominal', + 1: 'Paused programmatically', + 101: 'Failed to connect', + 102: 'Stopped by pause button press', + 103: 'Stopped by keyboard interrupt', + 104: 'Lost USB connectivity' +} +PLOTTER_PAUSED = [ 1, 102, 103 ]; + +def get_error_msg(code): + if code in PLOTTER_ERRORS: + return PLOTTER_ERRORS[code] + else: + return f'Unkown error (Code {code})' + +def print_axidraw(*args): + out = ' '.join(args) + lines = out.split('\n') + for line in lines: + print(f"{COL.GREY}[AxiDraw] " + line + COL.OFF) + +# Raise pen and disable XY stepper motors +def align(): + with capture_output(print_axidraw, print_axidraw): + ad = axidraw.AxiDraw() + ad.plot_setup() + ad.options.mode = 'align' # A setup mode: Raise pen, disable XY stepper motors + ad.options.pen_pos_up = PEN_POS_UP + ad.options.pen_pos_down = PEN_POS_DOWN + if TESTING: ad.options.preview = True + ad.plot_run() + return ad.errors.code + +# Cycle the pen down and back up +def cycle(): + with capture_output(print_axidraw, print_axidraw): + ad = axidraw.AxiDraw() + ad.plot_setup() + ad.options.mode = 'cycle' # A setup mode: Lower and then raise the pen + ad.options.pen_pos_up = PEN_POS_UP + ad.options.pen_pos_down = PEN_POS_DOWN + if TESTING: ad.options.preview = True + ad.plot_run() + return ad.errors.code + +def plot(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, options_cb = None, return_ad = False): + if 'svg' not in job: return 0 + speed = job['speed'] / 100 + with capture_output(print_axidraw, print_axidraw): + ad = axidraw.AxiDraw() + ad.plot_setup(job['svg']) + ad.options.model = 2 # A3 + ad.options.reordering = 4 # No reordering + ad.options.auto_rotate = True # (This is the default) Drawings that are taller than wide will be rotated 90 deg to the left + ad.options.speed_pendown = int(110 * speed) + ad.options.speed_penup = int(110 * speed) + ad.options.accel = int(100 * speed) + ad.options.pen_rate_lower = int(100 * speed) + ad.options.pen_rate_raise = int(100 * speed) + ad.options.pen_pos_up = PEN_POS_UP + ad.options.pen_pos_down = PEN_POS_DOWN + if callable(options_cb): options_cb(ad.options) + if TESTING: ad.options.preview = True + job['output_svg'] = ad.plot_run(output=True) + if (ad.errors.code in PLOTTER_PAUSED and align_after_pause) or \ + (ad.errors.code not in PLOTTER_PAUSED and align_after): + align() + if return_ad: return ad + else: return ad.errors.code + +def resume_home(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, options_cb = None, return_ad = False): + if 'output_svg' not in job: return 0 + orig_svg = job['svg'] # save original svg + job['svg'] = job['output_svg'] # set last output svg as input + + def _options_cb(options): + if callable(options_cb): options_cb(options) + options.mode = 'res_home' + + res = plot(job, align_after, align_after_pause, _options_cb, return_ad) + job['svg'] = orig_svg # restore original svg + return res + +def resume_plot(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, options_cb = None, return_ad = False): + if 'output_svg' not in job: return 0 + orig_svg = job['svg'] # save original svg + job['svg'] = job['output_svg'] # set last output svg as input + + def _options_cb(options): + if callable(options_cb): options_cb(options) + options.mode = 'res_plot' + + res = plot(job, align_after, align_after_pause, _options_cb, return_ad) + job['svg'] = orig_svg # restore original svg + return res + +def simulate(job): + if 'svg' not in job: return 0 + speed = job['speed'] / 100 + + stats = { + 'error_code': None, + 'time_estimate': 0, + 'distance_total': 0, + 'distance_pendown': 0, + 'pen_lifts': 0, + 'layers': 0 + } + + def update_stats(ad): + stats['error_code'] = ad.errors.code + stats['time_estimate'] += ad.time_estimate + stats['distance_total'] += ad.distance_total + stats['distance_pendown'] += ad.distance_pendown + stats['pen_lifts'] += ad.pen_lifts + stats['layers'] += 1 + + def _options_cb(options): + options.preview = True + + ad = plot(job, align_after=False, align_after_pause=False, options_cb=_options_cb, return_ad=True) + update_stats(ad) + + while ad.errors.code == 1: # Paused programmatically + ad = resume_plot(job, align_after=False, align_after_pause=False, options_cb=_options_cb, return_ad=True) + update_stats(ad) + + return stats + + +async def plot_async(*args, **kwargs): + return await asyncio.to_thread(plot, *args, **kwargs) + +async def simulate_async(*args, **kwargs): + return await asyncio.to_thread(simulate, *args, **kwargs) + +async def align_async(): + return await asyncio.to_thread(align) + +async def cycle_async(): + return await asyncio.to_thread(cycle) + +async def resume_plot_async(*args, **kwargs): + return await asyncio.to_thread(resume_plot, *args, **kwargs) + +async def resume_home_async(*args, **kwargs): + return await asyncio.to_thread(resume_home, *args, **kwargs) + +async def prompt_start_plot(message): + message += f' {KEY_START_PLOT[1]}, {KEY_ALIGN[1]}, {KEY_CYCLE[1]}, {KEY_CANCEL[1]} ?' + while True: + res = await prompt.wait_for( [KEY_START_PLOT[0],KEY_ALIGN[0],KEY_CYCLE[0],KEY_CANCEL[0]], message, echo=True ) + if res == KEY_START_PLOT[0]: # Start Plot + return True + elif res == KEY_ALIGN[0]: # Align + print('Aligning...') + await align_async() # -> prompt again + elif res == KEY_CYCLE[0]: # Cycle + print('Cycling...') + await cycle_async() # -> prompt again + elif res == KEY_CANCEL[0]: # Cancel + return False + +async def prompt_repeat_plot(message): + message += f' {KEY_REPEAT[1]}, {KEY_ALIGN[1]}, {KEY_CYCLE[1]}, {KEY_DONE[1]} ?' + while True: + res = await prompt.wait_for( [KEY_REPEAT[0],KEY_ALIGN[0],KEY_CYCLE[0],KEY_DONE[0]], message, echo=True ) + if res == KEY_REPEAT[0]: # Start Plot + return True + elif res == KEY_ALIGN[0]: # Align + print('Aligning...') + await align_async() # -> prompt again + elif res == KEY_CYCLE[0]: # Cycle + print('Cycling...') + await cycle_async() # -> prompt again + elif res == KEY_DONE[0]: # Finish + return False + +async def prompt_resume_plot(message, job): + message += f' {KEY_RESUME[1]}, {KEY_HOME[1]}, {KEY_ALIGN[1]}, {KEY_CYCLE[1]}, {KEY_RESTART_PLOT[1]}, {KEY_DONE[1]} ?' + while True: + res = await prompt.wait_for( [KEY_RESUME[0],KEY_HOME[0],KEY_ALIGN[0],KEY_CYCLE[0],KEY_RESTART_PLOT[0],KEY_DONE[0]], message, echo=True ) + if res == KEY_RESUME[0]: # Resume Plot + return 'resume' + elif res == KEY_RESTART_PLOT[0]: # Restart plot + return 'restart' + elif res == KEY_HOME[0]: # Home + print('Returning home...') + await resume_home_async(job) # -> prompt again + elif res == KEY_ALIGN[0]: # Align + print('Aligning...') + await align_async() # -> prompt again + elif res == KEY_CYCLE[0]: # Cycle + print('Cycling...') + await cycle_async() # -> prompt again + elif res == KEY_DONE[0]: # Finish + return False + +async def prompt_setup(message = 'Setup Plotter:'): + message += f' {KEY_ALIGN[1]}, {KEY_CYCLE[1]}, {KEY_DONE[1]} ?' + while True: + res = await prompt.wait_for( [KEY_ALIGN[0],KEY_CYCLE[0],KEY_DONE[0]], message, echo=True ) + if res == KEY_ALIGN[0]: # Align + print('Aligning...') + await align_async() # -> prompt again + elif res == KEY_CYCLE[0]: # Cycle + print('Cycling...') + await cycle_async() # -> prompt again + elif res == KEY_DONE[0] : # Finish + return True + +async def resume_queue(): + import xml.etree.ElementTree as ElementTree + list = sorted(os.listdir(FOLDER_WAITING)) + list = [ os.path.join(FOLDER_WAITING, x) for x in list if x.endswith('.svg') ] + resumable_jobs = [] + for filename in list: + # print('Loading ', filename) + try: + with open(filename, 'r') as file: + svg = file.read() + root = ElementTree.fromstring(svg) + def attr(attr, ns = 'https://sketch.process.studio/turtle-graphics'): + return root.get(attr if ns == None else "{" + ns + "}" + attr) + match = re.search('\\d{8}_\\d{6}.\\d{6}_UTC[+-]\\d{4}', os.path.basename(filename)) + received_ts = None if match == None else match.group(0) + job = { + 'loaded_from_file': True, + 'client': attr('author'), + 'id': "XYZ", + 'svg': svg, + 'stats': { + 'count': int(attr('count')), + 'layer_count': int(attr('layer_count')), + 'oob_count': int(attr('oob_count')), + 'short_count': int(attr('short_count')), + 'travel': int(attr('travel')), + 'travel_ink': int(attr('travel_ink')), + 'travel_blank': int(attr('travel_blank')) + }, + 'timestamp': attr('timestamp'), + 'speed': int(attr('speed')), + 'format': attr('format'), + 'size': [int(attr('width_mm')), int(attr('height_mm'))], + 'hash': hashlib.sha1(svg.encode('utf-8')).hexdigest(), + 'received': received_ts + } + resumable_jobs.append(job) + except: + print('Error resuming ', filename) + + if len(resumable_jobs) > 0: print(f"Resuming {len(resumable_jobs)} jobs...") + else: print("No jobs to resume") + for job in resumable_jobs: + await enqueue(job) + + +async def start(_prompt, print_status): + global current_job + global _status + global print + global prompt + prompt = _prompt # make this global + print = prompt.print # replace global print function + + if TESTING: print(f'{COL.YELLOW}TESTING MODE enabled{COL.OFF}') + if RESUME_QUEUE: await resume_queue() + + await align_async() + await prompt_setup() + _status = 'waiting' + + while True: + # get the next job from the queue, waits until a job becomes available + if queue.empty(): + print_status() + current_job = await queue.get() + if not current_job['cancel']: # skip if job is canceled + _status = 'confirm_plot' + print_status() + ready = await prompt_start_plot(f'Ready to plot job {job_str(current_job)}?') + if not ready: + await cancel(current_job['client'], force = True) + _status = 'waiting' + continue # skip over rest of the loop + + # plot (and retry on error or repeat) + loop = 0 # number or tries/repetitions + resume = False # flag indicating resume (vs. plotting from start) + while True: + if (resume): + print(f'🖨️ {COL.YELLOW}Resuming job [{current_job["client"]}] ...{COL.OFF}') + _status = 'plotting' + error = await resume_plot_async(current_job) + else: + loop += 1 + print(f'🖨️ {COL.YELLOW}Plotting job [{current_job["client"]}] ...{COL.OFF}') + _status = 'plotting' + await _notify_queue_positions() # notify plotting + error = await plot_async(current_job) + resume = False + # No error + if error == 0: + if REPEAT_JOBS: + print(f'{COL.BLUE}Done ({loop}x) job [{current_job["client"]}]{COL.OFF}') + _status = 'confirm_plot' + repeat = await prompt_repeat_plot(f'{COL.BLUE}Repeat{COL.OFF} job [{current_job["client"]}] ?') + if repeat: continue + await finish_current_job() + break + # Paused programmatically (1), Stopped by pause button press (102) or Stopped by keyboard interrupt (103) + elif error in PLOTTER_PAUSED: + print(f'{COL.YELLOW}Plotter: {get_error_msg(error)}{COL.OFF}') + _status = 'confirm_plot' + ready = await prompt_resume_plot(f'{COL.YELLOW}Resume{COL.OFF} job [{current_job["client"]}] ?', current_job) + if not ready: + await finish_current_job() + break + if ready == 'resume': resume = True + # Errors + else: + print(f'{COL.RED}Plotter: {get_error_msg(error)}{COL.OFF}') + _status = 'confirm_plot' + ready = await prompt_start_plot(f'{COL.RED}Retry{COL.OFF} job [{current_job["client"]}] ?') + if not ready: + await cancel(current_job['client'], force = True) + break + + _status = 'waiting' + current_job = None From 175038dded15d2057808381166098e3aeb2a4684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 3 Nov 2024 15:01:11 +0100 Subject: [PATCH 07/51] Add move capability to queue --- async_queue.py | 85 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 74 insertions(+), 11 deletions(-) diff --git a/async_queue.py b/async_queue.py index 9999198..d0b735e 100644 --- a/async_queue.py +++ b/async_queue.py @@ -33,17 +33,21 @@ def swap(self, idx1, idx2): self.order[idx1], self.order[idx2] = self.order[idx2], self.order[idx1] self._rebuild() - # Not necessary (use swap): - - # def swap_to_front(self, idx): - # if idx < -len(self.order) or idx > len(self.order)-1: - # raise IndexError('index out of bounds') - # self.swap(idx, 0) - # - # def swap_to_back(self, idx): - # if idx < -len(self.order) or idx > len(self.order)-1: - # raise IndexError('index out of bounds') - # self.swap(idx, -1) + # move an item to new position in queue + def move(self, idx, new_idx): + if idx < -len(self.order) or idx > len(self.order)-1: + raise IndexError('index out of bounds') + if new_idx < -len(self.order) or new_idx > len(self.order)-1: + raise IndexError('target index out of bounds') + + # normalize negative indices + if idx < 0: idx = len(self.order) + idx + if new_idx < 0: new_idx = len(self.order) + new_idx + + if (idx == new_idx): return + item = self.order.pop(idx) + self.order.insert(new_idx, item) + self._rebuild() # remove an item from the queue; supports negative indices def pop(self, idx = -1): @@ -225,5 +229,64 @@ async def test_swap(self): self.assertEqual(q.list(), ['zero', 'three', 'two', 'one']) self.assertEqual(self.get_all(q), ['zero', 'three', 'two', 'one']) + async def test_move(self): + q = Queue() + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + with self.assertRaises(IndexError): q.move(0, 4) + with self.assertRaises(IndexError): q.move(0, -5) + with self.assertRaises(IndexError): q.move(4, 0) + with self.assertRaises(IndexError): q.move(-5, 0) + q.move(1, 1) + self.assertEqual(q.list(), ['zero', 'one', 'two', 'three']) + self.assertEqual(self.get_all(q), ['zero', 'one', 'two', 'three']) + + # move top to bottom + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + q.move(0, -1) + self.assertEqual(q.list(), ['one', 'two', 'three', 'zero']) + self.assertEqual(self.get_all(q), ['one', 'two', 'three', 'zero']) + + # move bottom to top + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + q.move(-1, 0) + self.assertEqual(q.list(), ['three', 'zero', 'one', 'two']) + self.assertEqual(self.get_all(q), ['three', 'zero', 'one', 'two']) + + # swap items next to each other + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + q.move(1, 2) + self.assertEqual(q.list(), ['zero', 'two', 'one', 'three']) + self.assertEqual(self.get_all(q), ['zero', 'two', 'one', 'three']) + + # move to bottom + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + q.move(1, -1) + self.assertEqual(q.list(), ['zero', 'two', 'three', 'one']) + self.assertEqual(self.get_all(q), ['zero', 'two', 'three', 'one']) + + # move to top + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + q.move(2, 0) + self.assertEqual(q.list(), ['two', 'zero', 'one', 'three']) + self.assertEqual(self.get_all(q), ['two', 'zero', 'one', 'three']) + unittest.main() \ No newline at end of file From 917c01151ee46d9fb5c16a08577e76a814c6793b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 3 Nov 2024 16:33:14 +0100 Subject: [PATCH 08/51] Add index and __iter__ to Queue --- async_queue.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/async_queue.py b/async_queue.py index d0b735e..baa9526 100644 --- a/async_queue.py +++ b/async_queue.py @@ -49,6 +49,12 @@ def move(self, idx, new_idx): self.order.insert(new_idx, item) self._rebuild() + def index(self, *args): + return self.order.index(*args) + + def __iter__(self): + return self.order.copy().__iter__() + # remove an item from the queue; supports negative indices def pop(self, idx = -1): if idx < -len(self.order) or idx > len(self.order)-1: @@ -288,5 +294,26 @@ async def test_move(self): self.assertEqual(q.list(), ['two', 'zero', 'one', 'three']) self.assertEqual(self.get_all(q), ['two', 'zero', 'one', 'three']) + async def test_index(self): + q = Queue() + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + self.assertEqual(q.index('zero'), 0) + self.assertEqual(q.index('one'), 1) + self.assertEqual(q.index('two'), 2) + self.assertEqual(q.index('three'), 3) + with self.assertRaises(ValueError): q.index('none') + + async def test_iter(self): + q = Queue() + q.put_nowait('zero') + q.put_nowait('one') + q.put_nowait('two') + q.put_nowait('three') + items = [x for x in q] + self.assertEqual(items, ['zero', 'one', 'two', 'three']) + unittest.main() \ No newline at end of file From 330ad5c5631eceda6c05097158f9683a574ce436 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 3 Nov 2024 19:17:35 +0100 Subject: [PATCH 09/51] Allow cancelling in queue --- main.py | 24 ++++++++++--- spooler.py | 103 ++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 107 insertions(+), 20 deletions(-) diff --git a/main.py b/main.py index bc0b207..b15d278 100644 --- a/main.py +++ b/main.py @@ -25,6 +25,7 @@ import textual from textual import on +from textual.events import Key from textual.app import App as TextualApp from textual.widgets import Button, DataTable, RichLog, Footer, Header, Static, ProgressBar, Rule from textual.containers import Horizontal, Vertical @@ -144,14 +145,13 @@ async def run_server(app): await spooler.start(app) # run forever - class App(TextualApp): prompt_future = None def compose(self): global header, queue, log, footer header = Header(icon = '🖨️', show_clock = True, time_format = '%H:%M') - queue = DataTable() + queue = DataTable(id = 'queue') log = RichLog(markup=True) footer = Footer(id="footer", show_command_palette=True) @@ -340,6 +340,18 @@ def on_button(self, event): 'button': event.button }) + @on(Key) + async def on_queue_hotkey(self, event): + if (event.key == 'backspace'): + if queue.row_count == 0: return # nothing in list + client = queue.ordered_rows[queue.cursor_row].key.value + # if this is the current job, and we haven't started, cancel the prompt to start + if spooler.current_client() == client and spooler.status()['status'] == 'confirm_plot': + self.cancel_prompt_ui() + # handle all other cases (even plots that are running) + else: + await spooler.cancel(client) + def job_to_row(self, job, idx): return (idx, job['client'], job['hash'][:5], job['stats']['count'], job['stats']['layer_count'], int(job['stats']['travel'])/1000, int(job['stats']['travel_ink'])/1000, job['format'], job['speed'], f'{math.floor(job["time_estimate"]/60)}:{round(job["time_estimate"]%60):02}') @@ -347,13 +359,17 @@ def update_current_job(self): job = spooler.current_job() job_current.clear() if job != None: - job_current.add_row( *self.job_to_row(job, 1) ) + job_current.add_row( *self.job_to_row(job, 1), key=job['client'] ) def update_job_queue(self): queue.clear() for idx, job in enumerate(spooler.jobs()): - queue.add_row( *self.job_to_row(job, idx+1) ) + queue.add_row( *self.job_to_row(job, idx+1), key=job['client'] ) + def cancel_prompt_ui(self): + if self.prompt_future != None and not self.prompt_future.done(): + self.prompt_future.cancel() + # This not a coroutine (no async). It returns a future, which can be awaited from coroutines def prompt_ui(self, variant, message = '', ): print('PROMPT', variant) diff --git a/spooler.py b/spooler.py index 8576681..9d7215f 100644 --- a/spooler.py +++ b/spooler.py @@ -38,21 +38,35 @@ ALIGN_AFTER = True # Align plotter after success or error ALIGN_AFTER_PAUSE = False # Align plotter after pause (programmatic, stop button, keyboard interrupt) + + queue_size_cb = None # queue = asyncio.Queue() # an async FIFO queue queue = async_queue.Queue() # an async FIFO queue that can be reordered -_jobs = {} # an index to all unfinished jobs by client id (in queue or current_job) (insertion order is preserved in dict since python 3.7) +_jobs = {} # an index to all unfinished jobs by client id (in queue or _current_job) _current_job = None _status = 'setup' # setup | waiting | confirm_plot | plotting -async def callback(fn, *args): + +# Helper function calls async function fn with args +# Returns a coroutine (because of async def) +async def callback(fn, *args, **kwargs): if callable(fn): - await fn(*args) + await fn(*args, **kwargs) + +# async def _notify_queue_positions(): +# cbs = [] +# for i, client in enumerate(_jobs): +# job = _jobs[client] +# if i == 0 and _status == 'plotting': i = -1 +# if 'position_notified' not in job or job['position_notified'] != i: +# job['position_notified'] = i +# cbs.append( callback(job['queue_position_cb'], i, job) ) +# await asyncio.gather(*cbs) # run callbacks concurrently async def _notify_queue_positions(): cbs = [] - for i, client in enumerate(_jobs): - job = _jobs[client] + for i, job in enumerate(jobs()): if i == 0 and _status == 'plotting': i = -1 if 'position_notified' not in job or job['position_notified'] != i: job['position_notified'] = i @@ -60,7 +74,7 @@ async def _notify_queue_positions(): await asyncio.gather(*cbs) # run callbacks concurrently async def _notify_queue_size(): - await callback(queue_size_cb, len(_jobs)) + await callback(queue_size_cb, num_jobs()) def set_queue_size_cb(cb): global queue_size_cb @@ -72,13 +86,15 @@ def num_jobs(): def current_job(): return _current_job +def current_client(): + if _current_job == None: return None + return _current_job['client'] + def jobs(): - # return list(_jobs.values()) - queued = queue.list() + lst = queue.list() if (_current_job != None and not _current_job['cancel']): - queued.insert(0, _current_job) - return queued - + lst.insert(0, _current_job) + return lst def status(): return { @@ -181,12 +197,17 @@ async def cancel(client, force = False): await callback( _current_job['error_cb'], 'Cannot cancel, already plotting!', _current_job ) return False + # remove from job index if client not in _jobs: return False job = _jobs[client] + if job == _current_job and status == 'plotting': return # can't cancel if plotting job['cancel'] = True # set cancel flag - del _jobs[client] # remove from index + del _jobs[client] - # TODO: if current job, its not in the queue anymore + # remove from queue + # if job is the current job, it has already been taken from the top of the queue + if job != _current_job: + queue.pop( queue.index(job) ) await callback( job['cancel_cb'], job ) # notify canceled job await _notify_queue_size() # notify new queue size @@ -196,7 +217,6 @@ async def cancel(client, force = False): return True async def cancel_current_job(force = True): - print('call cancel job') return await cancel(_current_job['client'], force = force) async def finish_current_job(): @@ -209,6 +229,54 @@ async def finish_current_job(): await save_svg_async(_current_job, 'finished') return True + +def job_pos(job): + if (job == _current_job): + if status == 'plotting': return -1 + return 0 + return queue.index(job) + 1 + +# positions +# 0 .. current job +# 1 .. first in queue (idx 0) +# last .. num_jobs()-1 +async def move(client, new_pos): + job = _jobs[client] + current_pos = job_pos(job) + + # cannot move if job is already plotting + if current_pos == -1: return + + # normalize new_pos + if new_pos < 0: new_pos = num_jobs() + new_pos + + # clamp to lower bound + if new_pos < 0: new_pos = 0 + + # can't take place of the plotting job + if new_pos == 0 and status == 'plotting': new_pos = 1 + + # clamp to upper bound + if new_pos > num_jobs()-1: new_pos = num_jobs()-1 + + # nothing to do + if new_pos == current_pos: return + + # move job from queue to current job + if new_pos == 0: + queue.pop(current_pos-1) # remove from current position + queue.insert(0, _current_job) # move current job (pos 0) to first waiting position + _current_job = job # set to current job + # move current job to queue + elif current_pos == 0: + _current_job = queue.pop(0) # new current job is next in line + queue.insert(new_pos-1, _job) + # move within queue + else: + queue.move(current_pos-1, new_pos-1) + + await _notify_queue_positions() # notify queue positions (might have changed for some) + def job_str(job): info = '[' + str(job["client"])[0:10] + '] ' + job['hash'][0:5] speed_and_format = f'{job["speed"]}%, {job["format"]}, {math.floor(job["time_estimate"]/60)}:{round(job["time_estimate"]%60):02} min' @@ -387,7 +455,11 @@ async def prompt_setup(message = 'Press \'Done\' when ready'): async def prompt_start_plot(message): while True: - res = await prompt_ui('start_plot', message) + try: + res = await prompt_ui('start_plot', message) + except asyncio.CancelledError: + return False # the prompt was cancelled -> Cancel plotting + res = res['id'] if res == 'pos': # Start Plot return True @@ -501,7 +573,6 @@ async def start(app): if TESTING: print('[yellow]TESTING MODE enabled') # if RESUME_QUEUE: await resume_queue() - await align_async() await prompt_setup() From be09446c969c903a020b4df204173bbd53daaa90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 3 Nov 2024 21:58:46 +0100 Subject: [PATCH 10/51] Move jobs --- main.py | 38 ++++++++++++++++++++++++++++---------- spooler.py | 49 +++++++++++++++++++++++++++++++++++-------------- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/main.py b/main.py index b15d278..c011adb 100644 --- a/main.py +++ b/main.py @@ -339,18 +339,34 @@ def on_button(self, event): 'id': id, # use button id, hotkey description (lowercase), or button label (lowercase) 'button': event.button }) + print('PROMPT result', id) @on(Key) async def on_queue_hotkey(self, event): - if (event.key == 'backspace'): + if event.key in ['backspace', 'i', 'k', '1', '0']: if queue.row_count == 0: return # nothing in list client = queue.ordered_rows[queue.cursor_row].key.value - # if this is the current job, and we haven't started, cancel the prompt to start - if spooler.current_client() == client and spooler.status()['status'] == 'confirm_plot': - self.cancel_prompt_ui() - # handle all other cases (even plots that are running) - else: - await spooler.cancel(client) + + if (event.key == 'backspace'): + # if this is the current job, and we haven't started, cancel the prompt to start + if spooler.current_client() == client and spooler.status()['status'] == 'confirm_plot': + self.cancel_prompt_ui() + # handle all other cases (even plots that are running) + else: + await spooler.cancel(client) + elif (event.key == 'i'): + await spooler.move(client, max(queue.cursor_row - 1, 0)) + queue.move_cursor(row=queue.get_row_index(client)) + elif (event.key == 'k'): + new_row = queue.cursor_row + 1 + await spooler.move(client, new_row) + queue.move_cursor(row=queue.get_row_index(client)) + elif (event.key == '1'): + await spooler.move(client, 0) + queue.move_cursor(row=queue.get_row_index(client)) + elif (event.key == '0'): + await spooler.move(client, -1) + queue.move_cursor(row=queue.get_row_index(client)) def job_to_row(self, job, idx): return (idx, job['client'], job['hash'][:5], job['stats']['count'], job['stats']['layer_count'], int(job['stats']['travel'])/1000, int(job['stats']['travel_ink'])/1000, job['format'], job['speed'], f'{math.floor(job["time_estimate"]/60)}:{round(job["time_estimate"]%60):02}') @@ -371,7 +387,7 @@ def cancel_prompt_ui(self): self.prompt_future.cancel() # This not a coroutine (no async). It returns a future, which can be awaited from coroutines - def prompt_ui(self, variant, message = '', ): + def prompt_ui(self, variant, message = ''): print('PROMPT', variant) if len(message) > 0: message = ' – ' + message @@ -453,8 +469,10 @@ def prompt_ui(self, variant, message = '', ): raise ValueError('Invalid variant') # return a future that eventually resolves to the result - loop = asyncio.get_running_loop() - self.prompt_future = loop.create_future() + # reuse the future if it isn't done. allows for updating the prompt + if self.prompt_future == None or self.prompt_future.done(): + loop = asyncio.get_running_loop() + self.prompt_future = loop.create_future() return self.prompt_future diff --git a/spooler.py b/spooler.py index 9d7215f..cbfe758 100644 --- a/spooler.py +++ b/spooler.py @@ -192,6 +192,7 @@ async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = Non return True async def cancel(client, force = False): + global _current_job if not force: if _current_job != None and _current_job['client'] == client: await callback( _current_job['error_cb'], 'Cannot cancel, already plotting!', _current_job ) @@ -200,7 +201,7 @@ async def cancel(client, force = False): # remove from job index if client not in _jobs: return False job = _jobs[client] - if job == _current_job and status == 'plotting': return # can't cancel if plotting + if job == _current_job and _status == 'plotting': return # can't cancel if plotting job['cancel'] = True # set cancel flag del _jobs[client] @@ -209,6 +210,8 @@ async def cancel(client, force = False): if job != _current_job: queue.pop( queue.index(job) ) + _current_job = None + await callback( job['cancel_cb'], job ) # notify canceled job await _notify_queue_size() # notify new queue size await _notify_queue_positions() # notify queue positions (might have changed for some) @@ -220,19 +223,22 @@ async def cancel_current_job(force = True): return await cancel(_current_job['client'], force = force) async def finish_current_job(): + global _current_job await callback( _current_job['done_cb'], _current_job ) # notify job done del _jobs[ _current_job['client'] ] # remove from jobs index + finished_job = _current_job + _current_job = None await _notify_queue_positions() # notify queue positions. current job is 0 await _notify_queue_size() # notify queue size - print(f'✅ [green]Finished job \\[{_current_job["client"]}]') + print(f'✅ [green]Finished job \\[{finished_job["client"]}]') _status = 'waiting' - await save_svg_async(_current_job, 'finished') + await save_svg_async(finished_job, 'finished') return True def job_pos(job): if (job == _current_job): - if status == 'plotting': return -1 + if _status == 'plotting': return -1 return 0 return queue.index(job) + 1 @@ -241,11 +247,15 @@ def job_pos(job): # 1 .. first in queue (idx 0) # last .. num_jobs()-1 async def move(client, new_pos): + global _current_job + print('move', client, new_pos) job = _jobs[client] current_pos = job_pos(job) - + print('current pos', current_pos) # cannot move if job is already plotting - if current_pos == -1: return + if current_pos == -1: + print('already plotting, can\'t move') + return # normalize new_pos if new_pos < 0: new_pos = num_jobs() + new_pos @@ -254,27 +264,38 @@ async def move(client, new_pos): if new_pos < 0: new_pos = 0 # can't take place of the plotting job - if new_pos == 0 and status == 'plotting': new_pos = 1 + if new_pos == 0 and _status == 'plotting': new_pos = 1 + + print(f'move from {current_pos} to {new_pos}') # clamp to upper bound if new_pos > num_jobs()-1: new_pos = num_jobs()-1 # nothing to do - if new_pos == current_pos: return + if new_pos == current_pos: + print('nothing to do') + return # move job from queue to current job if new_pos == 0: + print('move to top') queue.pop(current_pos-1) # remove from current position queue.insert(0, _current_job) # move current job (pos 0) to first waiting position _current_job = job # set to current job + prompt_ui('start_plot', f'Ready to plot job \\[{_current_job["client"]}] ?') # move current job to queue elif current_pos == 0: + print('move from top') + old_current_job = _current_job _current_job = queue.pop(0) # new current job is next in line - queue.insert(new_pos-1, _job) + queue.insert(new_pos-1, old_current_job) + prompt_ui('start_plot', f'Ready to plot job \\[{_current_job["client"]}] ?') # move within queue else: + print('move within queue') queue.move(current_pos-1, new_pos-1) + await _notify_queue_size() await _notify_queue_positions() # notify queue positions (might have changed for some) def job_str(job): @@ -598,12 +619,12 @@ async def start(app): while True: if (resume): print(f'🖨️ [yellow]Resuming job \\[{_current_job["client"]}] ...') - _status = 'plotting' + set_status('plotting') error = await resume_plot_async(_current_job) else: loop += 1 print(f'🖨️ [yellow]Plotting job \\[{_current_job["client"]}] ...') - _status = 'plotting' + set_status('plotting') await _notify_queue_positions() # notify plotting error = await plot_async(_current_job) resume = False @@ -611,7 +632,7 @@ async def start(app): if error == 0: if REPEAT_JOBS: print(f'[blue]Done ({loop}x) job \\[{_current_job["client"]}]') - set_status('confirm_plot') + set_status('plotting') repeat = await prompt_repeat_plot(f'Repeat ({loop+1}) job \\[{_current_job["client"]}] ?') if repeat: continue await finish_current_job() @@ -620,7 +641,7 @@ async def start(app): elif error in PLOTTER_PAUSED: print(f'[yellow]Plotter: {get_error_msg(error)}') set_status('plotting') - ready = await prompt_resume_plot(f'[yellow]Resume[/yellow] job \\[{_current_job["client"]}] ?', _current_job) + ready = await prompt_resume_plot(f'[yellow]Continue[/yellow] job \\[{_current_job["client"]}] ?', _current_job) if not ready: await cancel_current_job() break @@ -634,5 +655,5 @@ async def start(app): await cancel(_current_job['client'], force = True) break - _status = 'waiting' + set_status('waiting') _current_job = None From 202b443881cf3809a85e36335decc82f94c13e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 3 Nov 2024 22:07:35 +0100 Subject: [PATCH 11/51] Fix canceling queued job removing current job --- spooler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spooler.py b/spooler.py index cbfe758..d87b0f5 100644 --- a/spooler.py +++ b/spooler.py @@ -207,11 +207,11 @@ async def cancel(client, force = False): # remove from queue # if job is the current job, it has already been taken from the top of the queue - if job != _current_job: + if job == _current_job: + _current_job = None + else: queue.pop( queue.index(job) ) - _current_job = None - await callback( job['cancel_cb'], job ) # notify canceled job await _notify_queue_size() # notify new queue size await _notify_queue_positions() # notify queue positions (might have changed for some) From bf94eda1d369989740440eb252b6f70ed9d19208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 3 Nov 2024 22:57:18 +0100 Subject: [PATCH 12/51] Preserve queue item selection when updating --- main.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index c011adb..c2b9172 100644 --- a/main.py +++ b/main.py @@ -22,12 +22,12 @@ QUEUE_HEADERS = ['#', 'Client', 'Hash', 'Lines', 'Layers', 'Travel', 'Ink', 'Format', 'Speed', 'Duration'] - import textual from textual import on from textual.events import Key from textual.app import App as TextualApp from textual.widgets import Button, DataTable, RichLog, Footer, Header, Static, ProgressBar, Rule +from textual.widgets.data_table import RowDoesNotExist from textual.containers import Horizontal, Vertical from hotkey_button import HotkeyButton @@ -378,9 +378,20 @@ def update_current_job(self): job_current.add_row( *self.job_to_row(job, 1), key=job['client'] ) def update_job_queue(self): + # remember selected client + if queue.row_count > 0: + client = queue.ordered_rows[queue.cursor_row].key.value + queue.clear() for idx, job in enumerate(spooler.jobs()): queue.add_row( *self.job_to_row(job, idx+1), key=job['client'] ) + + # recall client + if client: + try: + queue.move_cursor(row=queue.get_row_index(client)) + except RowDoesNotExist: + pass def cancel_prompt_ui(self): if self.prompt_future != None and not self.prompt_future.done(): From 183aa561acf40d7c5b4be7b1761758b07818e9bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 3 Nov 2024 23:23:40 +0100 Subject: [PATCH 13/51] Remove debug prints --- main.py | 11 ++++++----- spooler.py | 34 +++++++++++++++++----------------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/main.py b/main.py index c2b9172..41b6bca 100644 --- a/main.py +++ b/main.py @@ -78,7 +78,7 @@ async def handle_connection(ws): remote_address = ws.remote_address # store remote address (might not be available on disconnect) if SHOW_CONNECTION_EVENTS: print(f'({num_clients}) Connected: {remote_address[0]}:{remote_address[1]}') - print_status() + print_status() # await send_current_queue_size(ws) try: # The iterator exits normally when the connection is closed with close code 1000 (OK) or 1001 (going away). It raises a ConnectionClosedError when the connection is closed with any other code. @@ -91,7 +91,7 @@ async def handle_connection(ws): clients.remove(ws) if SHOW_CONNECTION_EVENTS: print(f'({num_clients}) Disconnected: {remote_address[0]}:{remote_address[1]} ({ws.close_code}{(" " + ws.close_reason).rstrip()})') - print_status() + print_status() async def send_msg(msg, ws): if type(msg) is dict: msg = json.dumps(msg) @@ -131,7 +131,7 @@ async def on_error(msg, job): elif msg['type'] == 'plot': qsize = spooler.num_jobs() result = await spooler.enqueue(msg, on_queue_position, on_done, on_cancel, on_error) - if result and qsize > 0: print_status() # Don't print status if queue is empty -> Status will be printed by spooler + if result: print_status() elif msg['type'] == 'cancel': result = await spooler.cancel(msg['client']) if result: print_status() @@ -339,7 +339,7 @@ def on_button(self, event): 'id': id, # use button id, hotkey description (lowercase), or button label (lowercase) 'button': event.button }) - print('PROMPT result', id) + # print('PROMPT result', id) @on(Key) async def on_queue_hotkey(self, event): @@ -379,6 +379,7 @@ def update_current_job(self): def update_job_queue(self): # remember selected client + client = None if queue.row_count > 0: client = queue.ordered_rows[queue.cursor_row].key.value @@ -399,7 +400,7 @@ def cancel_prompt_ui(self): # This not a coroutine (no async). It returns a future, which can be awaited from coroutines def prompt_ui(self, variant, message = ''): - print('PROMPT', variant) + # print('PROMPT', variant) if len(message) > 0: message = ' – ' + message job_status.update(spooler.status()['status_desc'] + message) diff --git a/spooler.py b/spooler.py index d87b0f5..086e07a 100644 --- a/spooler.py +++ b/spooler.py @@ -15,15 +15,15 @@ PEN_POS_DOWN = 40 # Default: 40 MIN_SPEED = 10 # percent -KEY_DONE = ( 'd', '(D)one' ) -KEY_REPEAT = ( 'r', '(R)epeat' ) -KEY_START_PLOT = ( 'p', '(P)lot' ) -KEY_RESTART_PLOT = ( 'p', '(P)lot from start' ) -KEY_ALIGN = ( 'a', '(A)lign' ) -KEY_CYCLE = ( 'c', '(C)ycle' ) -KEY_CANCEL = ( chr(27), '(Esc) Cancel Job' ) -KEY_RESUME = ( 'r', '(R)esume' ) -KEY_HOME = ( 'h', '(H)ome' ) +# KEY_DONE = ( 'd', '(D)one' ) +# KEY_REPEAT = ( 'r', '(R)epeat' ) +# KEY_START_PLOT = ( 'p', '(P)lot' ) +# KEY_RESTART_PLOT = ( 'p', '(P)lot from start' ) +# KEY_ALIGN = ( 'a', '(A)lign' ) +# KEY_CYCLE = ( 'c', '(C)ycle' ) +# KEY_CANCEL = ( chr(27), '(Esc) Cancel Job' ) +# KEY_RESUME = ( 'r', '(R)esume' ) +# KEY_HOME = ( 'h', '(H)ome' ) STATUS_DESC = { 'setup': 'Setting up', @@ -248,13 +248,13 @@ def job_pos(job): # last .. num_jobs()-1 async def move(client, new_pos): global _current_job - print('move', client, new_pos) + # print('move', client, new_pos) job = _jobs[client] current_pos = job_pos(job) - print('current pos', current_pos) + # print('current pos', current_pos) # cannot move if job is already plotting if current_pos == -1: - print('already plotting, can\'t move') + # print('already plotting, can\'t move') return # normalize new_pos @@ -266,33 +266,33 @@ async def move(client, new_pos): # can't take place of the plotting job if new_pos == 0 and _status == 'plotting': new_pos = 1 - print(f'move from {current_pos} to {new_pos}') + # print(f'move from {current_pos} to {new_pos}') # clamp to upper bound if new_pos > num_jobs()-1: new_pos = num_jobs()-1 # nothing to do if new_pos == current_pos: - print('nothing to do') + # print('nothing to do') return # move job from queue to current job if new_pos == 0: - print('move to top') + # print('move to top') queue.pop(current_pos-1) # remove from current position queue.insert(0, _current_job) # move current job (pos 0) to first waiting position _current_job = job # set to current job prompt_ui('start_plot', f'Ready to plot job \\[{_current_job["client"]}] ?') # move current job to queue elif current_pos == 0: - print('move from top') + # print('move from top') old_current_job = _current_job _current_job = queue.pop(0) # new current job is next in line queue.insert(new_pos-1, old_current_job) prompt_ui('start_plot', f'Ready to plot job \\[{_current_job["client"]}] ?') # move within queue else: - print('move within queue') + # print('move within queue') queue.move(current_pos-1, new_pos-1) await _notify_queue_size() From 223b1af24460de8112a651784855b476e06f9cf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 3 Nov 2024 23:49:00 +0100 Subject: [PATCH 14/51] Hotkeys for preview and cancel --- main.py | 9 ++++++--- spooler.py | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 41b6bca..4a9b1c0 100644 --- a/main.py +++ b/main.py @@ -184,7 +184,7 @@ def compose(self): yield (b_plus := HotkeyButton(label='+10', id='plus')) yield (b_minus := HotkeyButton(label='-10', id='minus')) with Vertical() as commands_4: - yield (b_preview := HotkeyButton(label='Preview', id='preview')) + yield (b_preview := HotkeyButton('v', 'Preview', label='Preview', id='preview')) with Vertical() as commands_5: yield (b_neg := HotkeyButton(label='Cancel', id='neg')) yield queue @@ -213,6 +213,7 @@ def on_mount(self): job_progress.styles.margin = 1 job_progress.styles.width = '100%' job_progress.query_one('#bar').styles.width = '1fr' + job_progress.styles.display = 'none' commands.styles.margin = (3, 0, 0, 0) @@ -343,7 +344,7 @@ def on_button(self, event): @on(Key) async def on_queue_hotkey(self, event): - if event.key in ['backspace', 'i', 'k', '1', '0']: + if event.key in ['backspace', 'i', 'k', '1', '0', 'space']: if queue.row_count == 0: return # nothing in list client = queue.ordered_rows[queue.cursor_row].key.value @@ -367,6 +368,8 @@ async def on_queue_hotkey(self, event): elif (event.key == '0'): await spooler.move(client, -1) queue.move_cursor(row=queue.get_row_index(client)) + elif (event.key == 'space'): + self.preview_job( spooler.job_by_client(client) ) def job_to_row(self, job, idx): return (idx, job['client'], job['hash'][:5], job['stats']['count'], job['stats']['layer_count'], int(job['stats']['travel'])/1000, int(job['stats']['travel_ink'])/1000, job['format'], job['speed'], f'{math.floor(job["time_estimate"]/60)}:{round(job["time_estimate"]%60):02}') @@ -437,7 +440,7 @@ def prompt_ui(self, variant, message = ''): b_pos.variant = 'success' b_pos.disabled = False - b_neg.update_hotkey('c', 'Cancel') + b_neg.update_hotkey('escape', 'Cancel') b_neg.variant = 'error' b_neg.disabled = False diff --git a/spooler.py b/spooler.py index 086e07a..62af22d 100644 --- a/spooler.py +++ b/spooler.py @@ -90,6 +90,10 @@ def current_client(): if _current_job == None: return None return _current_job['client'] +def job_by_client(client): + if client not in _jobs: return None + return _jobs[client] + def jobs(): lst = queue.list() if (_current_job != None and not _current_job['cancel']): From b89b617e1f7790e0ab70f53178969beb4b45e16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Mon, 4 Nov 2024 01:36:59 +0100 Subject: [PATCH 15/51] clean exit when waiting for job start --- main.py | 14 +++++++------- spooler.py | 12 +++++++++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/main.py b/main.py index 4a9b1c0..464e302 100644 --- a/main.py +++ b/main.py @@ -252,13 +252,12 @@ def on_mount(self): server_task = asyncio.create_task(run_server(self)) def on_server_task_exit(task): - print('[red]SERVER TASK EXIT') - if not task.cancelled(): + tprint('server task exit') + if not task.cancelled(): # not a intentional exit ex = task.exception() if ex != None: - print(ex) - raise ex - self.quit() + tprint(ex) + self.exit() server_task.add_done_callback(on_server_task_exit) @@ -399,7 +398,7 @@ def update_job_queue(self): def cancel_prompt_ui(self): if self.prompt_future != None and not self.prompt_future.done(): - self.prompt_future.cancel() + self.prompt_future.cancel('cancel_prompt_ui') # This not a coroutine (no async). It returns a future, which can be awaited from coroutines def prompt_ui(self, variant, message = ''): @@ -481,7 +480,7 @@ def prompt_ui(self, variant, message = ''): b_minus.disabled = True b_preview.disabled = False case _: - raise ValueError('Invalid variant') + raise ValueError('Invalid prompt variant') # return a future that eventually resolves to the result # reuse the future if it isn't done. allows for updating the prompt @@ -506,5 +505,6 @@ def prompt_ui(self, variant, message = ''): app = App() print = app.print + app.tprint = tprint app.run() \ No newline at end of file diff --git a/spooler.py b/spooler.py index 62af22d..1315738 100644 --- a/spooler.py +++ b/spooler.py @@ -482,8 +482,11 @@ async def prompt_start_plot(message): while True: try: res = await prompt_ui('start_plot', message) - except asyncio.CancelledError: - return False # the prompt was cancelled -> Cancel plotting + except asyncio.CancelledError as e: + if len(e.args) > 0 and e.args[0] == 'cancel_prompt_ui': + return False # the prompt was intentionally cancelled -> Cancel plotting + # re-raise the exception in all other cases to not f-up asyncio + raise e res = res['id'] if res == 'pos': # Start Plot @@ -589,6 +592,9 @@ async def start(app): global print print = app.print + global tprint + tprint = app.tprint + global prompt_ui prompt_ui = app.prompt_ui @@ -596,7 +602,7 @@ async def start(app): print_status = app.update_header if TESTING: print('[yellow]TESTING MODE enabled') - # if RESUME_QUEUE: await resume_queue() + if RESUME_QUEUE: await resume_queue() await align_async() await prompt_setup() From 95228ba5626709c5fa9947216ad5118267ff9658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Mon, 4 Nov 2024 10:15:59 +0100 Subject: [PATCH 16/51] Latest updates --- main.py | 4 ++-- spooler.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 464e302..4c4c4ac 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ USE_ZEROCONF = 0 ZEROCONF_HOSTNAME = 'plotter' -USE_PORKBUN = 0 +USE_PORKBUN = 1 PORKBUN_ROOT_DOMAIN = 'process.tools' PORKBUN_SUBDOMAIN = 'plotter-local' PORKBUN_TTL = 600 @@ -17,7 +17,7 @@ PING_INTERVAL = 10 PING_TIMEOUT = 5 -SHOW_CONNECTION_EVENTS = 1 # Print when clients connect/disconnect +SHOW_CONNECTION_EVENTS = 0 # Print when clients connect/disconnect MAX_MESSAGE_SIZE_MB = 5 # in MB (Default in websockets lib is 2) QUEUE_HEADERS = ['#', 'Client', 'Hash', 'Lines', 'Layers', 'Travel', 'Ink', 'Format', 'Speed', 'Duration'] diff --git a/spooler.py b/spooler.py index 1315738..3a530c4 100644 --- a/spooler.py +++ b/spooler.py @@ -32,7 +32,7 @@ 'plotting': 'Plotting' } -TESTING = True # Don't actually connect to AxiDraw, just simulate plotting +TESTING = False # Don't actually connect to AxiDraw, just simulate plotting REPEAT_JOBS = True # Ask to repeat a plot after a sucessful print RESUME_QUEUE = True # Resume plotting queue after quitting/restarting ALIGN_AFTER = True # Align plotter after success or error From 0b6940e0410badc64e15996c10479089b634e11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Mon, 4 Nov 2024 22:00:12 +0100 Subject: [PATCH 17/51] Fix canceling paused jobs --- spooler.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/spooler.py b/spooler.py index 3a530c4..40c612b 100644 --- a/spooler.py +++ b/spooler.py @@ -28,11 +28,12 @@ STATUS_DESC = { 'setup': 'Setting up', 'waiting': 'Waiting for jobs', + 'paused': 'Plot paused', 'confirm_plot': 'Confirm job', 'plotting': 'Plotting' } -TESTING = False # Don't actually connect to AxiDraw, just simulate plotting +TESTING = True # Don't actually connect to AxiDraw, just simulate plotting REPEAT_JOBS = True # Ask to repeat a plot after a sucessful print RESUME_QUEUE = True # Resume plotting queue after quitting/restarting ALIGN_AFTER = True # Align plotter after success or error @@ -616,7 +617,7 @@ async def start(app): if not _current_job['cancel']: # skip if job is canceled set_status('confirm_plot') - ready = await prompt_start_plot(f'Ready to plot job \\[{_current_job["client"]}] ?') + ready = await prompt_start_plot(f'[green]Ready to plot[/green] job \\[{_current_job["client"]}] ?') if not ready: await cancel_current_job() set_status('waiting') @@ -642,16 +643,16 @@ async def start(app): if error == 0: if REPEAT_JOBS: print(f'[blue]Done ({loop}x) job \\[{_current_job["client"]}]') - set_status('plotting') - repeat = await prompt_repeat_plot(f'Repeat ({loop+1}) job \\[{_current_job["client"]}] ?') + set_status('confirm_plot') + repeat = await prompt_repeat_plot(f'[yellow]Repeat[/yellow] ({loop+1}) job \\[{_current_job["client"]}] ?') if repeat: continue await finish_current_job() break # Paused programmatically (1), Stopped by pause button press (102) or Stopped by keyboard interrupt (103) elif error in PLOTTER_PAUSED: print(f'[yellow]Plotter: {get_error_msg(error)}') - set_status('plotting') - ready = await prompt_resume_plot(f'[yellow]Continue[/yellow] job \\[{_current_job["client"]}] ?', _current_job) + set_status('paused') + ready = await prompt_resume_plot(f'[blue]Continue[/blue] job \\[{_current_job["client"]}] ?', _current_job) if not ready: await cancel_current_job() break From 4d02726ed21b2d7d619e5b6a828071eca5bdefca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Mon, 4 Nov 2024 23:39:31 +0100 Subject: [PATCH 18/51] Remove setup status; Can setup while waiting --- main.py | 10 +++++----- spooler.py | 37 ++++++++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/main.py b/main.py index 4c4c4ac..2b44d94 100644 --- a/main.py +++ b/main.py @@ -256,6 +256,7 @@ def on_server_task_exit(task): if not task.cancelled(): # not a intentional exit ex = task.exception() if ex != None: + tprint('server task exception') tprint(ex) self.exit() @@ -335,11 +336,11 @@ def on_button(self, event): id = str(event.button.hotkey_description).lower() if id == None and event.button.label: id = str(event.button.label).lower() + self.prompt_future.set_result({ 'id': id, # use button id, hotkey description (lowercase), or button label (lowercase) 'button': event.button }) - # print('PROMPT result', id) @on(Key) async def on_queue_hotkey(self, event): @@ -398,7 +399,7 @@ def update_job_queue(self): def cancel_prompt_ui(self): if self.prompt_future != None and not self.prompt_future.done(): - self.prompt_future.cancel('cancel_prompt_ui') + self.prompt_future.set_result(False) # This not a coroutine (no async). It returns a future, which can be awaited from coroutines def prompt_ui(self, variant, message = ''): @@ -423,13 +424,12 @@ def prompt_ui(self, variant, message = ''): b_plus.disabled = True b_minus.disabled = True b_preview.disabled = True - case 'waiting': b_pos.disabled = True b_neg.disabled = True - b_align.disabled = True - b_cycle.disabled = True + b_align.disabled = False + b_cycle.disabled = False b_home.disabled = True b_plus.disabled = True b_minus.disabled = True diff --git a/spooler.py b/spooler.py index 40c612b..620598b 100644 --- a/spooler.py +++ b/spooler.py @@ -46,7 +46,7 @@ queue = async_queue.Queue() # an async FIFO queue that can be reordered _jobs = {} # an index to all unfinished jobs by client id (in queue or _current_job) _current_job = None -_status = 'setup' # setup | waiting | confirm_plot | plotting +_status = 'waiting' # waiting | confirm_plot | plotting # Helper function calls async function fn with args @@ -479,15 +479,28 @@ async def prompt_setup(message = 'Press \'Done\' when ready'): elif res == 'neg' : # Finish return True +async def prompt_waiting(message = 'Setup as needed'): + while True: + res = await prompt_ui('waiting', message) + if not res: + print('prompt cancelled') + return False # the prompt was intentionally cancelled + + res = res['id'] + if res == 'align': # Align + print('Aligning...') + await align_async() # -> prompt again + elif res == 'cycle': # Cycle + print('Cycling...') + await cycle_async() # -> prompt again + +def cancel_prompt_waiting(): + cancel_prompt_ui() + async def prompt_start_plot(message): while True: - try: - res = await prompt_ui('start_plot', message) - except asyncio.CancelledError as e: - if len(e.args) > 0 and e.args[0] == 'cancel_prompt_ui': - return False # the prompt was intentionally cancelled -> Cancel plotting - # re-raise the exception in all other cases to not f-up asyncio - raise e + res = await prompt_ui('start_plot', message) + if not res: return False # the prompt was intentionally cancelled -> Cancel plotting res = res['id'] if res == 'pos': # Start Plot @@ -596,8 +609,9 @@ async def start(app): global tprint tprint = app.tprint - global prompt_ui + global prompt_ui, cancel_prompt_ui prompt_ui = app.prompt_ui + cancel_prompt_ui = app.cancel_prompt_ui global print_status print_status = app.update_header @@ -606,14 +620,15 @@ async def start(app): if RESUME_QUEUE: await resume_queue() await align_async() - await prompt_setup() + # await prompt_setup() while True: # get the next job from the queue, waits until a job becomes available if queue.empty(): set_status('waiting') - prompt_ui('waiting') + asyncio.create_task( prompt_waiting() ) # this allows align/cycle _current_job = await queue.get() + cancel_prompt_waiting() if not _current_job['cancel']: # skip if job is canceled set_status('confirm_plot') From fa68c4536927289aa162ac78ee2b2b41f4ac2e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Mon, 4 Nov 2024 23:42:24 +0100 Subject: [PATCH 19/51] All buttons start disabled --- main.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 2b44d94..da15e1f 100644 --- a/main.py +++ b/main.py @@ -157,7 +157,7 @@ def compose(self): global job_current, job_status,job_progress job_current = DataTable() - job_status = Static("Status: Waiting") + job_status = Static(spooler.status()['status_desc']) job_progress = ProgressBar() global col_left, col_right, job, commands, commands_1, commands_2, commands_3, commands_4, commands_5 @@ -245,6 +245,15 @@ def on_mount(self): self.update_header() + b_pos.disabled = True + b_neg.disabled = True + b_align.disabled = True + b_cycle.disabled = True + b_home.disabled = True + b_plus.disabled = True + b_minus.disabled = True + b_preview.disabled = True + setup_ssl() # log.write(log.styles.height) From d00c4b3335a9b29608602ab318496eb9865cce92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sat, 9 Nov 2024 11:22:53 +0100 Subject: [PATCH 20/51] Add dev mode switch --- start | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/start b/start index 3c8311e..07d363d 100755 --- a/start +++ b/start @@ -11,17 +11,25 @@ quit() { trap quit EXIT usage() { - echo "Usage: $(basename $0) [-f <0|1>] [-n <0|1>]" + echo "Usage: $(basename $0) [-d] [-f <0|1>] [-n <0|1>]" + echo " -h ... Show this help" + echo " -d ... Dev Mode (Defaults -f to false)" echo " -f ... Start frpc (Default 1)" - echo " -n ... Start ngrok (Default 0)" + echo " -n ... Start ngrok (Default 0)" exit 1 } +dev=false start_frpc=true start_ngrok=false -while getopts ":f:n:h" opts; do +while getopts ":f:n:h:d" opts; do case "${opts}" in + d) + dev=true + start_frpc=false + start_ngrok=false + ;; f) f=${OPTARG} [[ $f -eq 0 || $f -eq 1 ]] || usage @@ -57,4 +65,8 @@ if $start_ngrok; then fi # run plotter-server -python main.py +if $dev; then + textual run --dev main.py +else + python main.py +fi From e0496215403ffe43b3dc5cae2f4d2aa76bca18de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sat, 9 Nov 2024 15:06:39 +0100 Subject: [PATCH 21/51] Reflect queue position and time estimate in svg naming --- main.py | 9 ++-- spooler.py | 125 ++++++++++++++++++++++++++++++++++------------------- 2 files changed, 85 insertions(+), 49 deletions(-) diff --git a/main.py b/main.py index da15e1f..4b12263 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ USE_ZEROCONF = 0 ZEROCONF_HOSTNAME = 'plotter' -USE_PORKBUN = 1 +USE_PORKBUN = 0 PORKBUN_ROOT_DOMAIN = 'process.tools' PORKBUN_SUBDOMAIN = 'plotter-local' PORKBUN_TTL = 600 @@ -265,8 +265,9 @@ def on_server_task_exit(task): if not task.cancelled(): # not a intentional exit ex = task.exception() if ex != None: - tprint('server task exception') - tprint(ex) + import traceback + tprint('Server task exception:') + tprint(''.join(traceback.format_exception(ex))) self.exit() server_task.add_done_callback(on_server_task_exit) @@ -399,7 +400,7 @@ def update_job_queue(self): for idx, job in enumerate(spooler.jobs()): queue.add_row( *self.job_to_row(job, idx+1), key=job['client'] ) - # recall client + # recall client (if possible) if client: try: queue.move_cursor(row=queue.get_row_index(client)) diff --git a/spooler.py b/spooler.py index 620598b..08b8ab4 100644 --- a/spooler.py +++ b/spooler.py @@ -8,9 +8,12 @@ import hashlib import async_queue -FOLDER_WAITING ='svgs/0_waiting' -FOLDER_CANCELED ='svgs/1_canceled' -FOLDER_FINISHED ='svgs/2_finished' +STATUS_FOLDERS = { + 'waiting' : 'svgs/0_waiting', + 'plotting' : 'svgs/0_waiting', + 'canceled' : 'svgs/1_canceled', + 'finished' : 'svgs/2_finished' +} PEN_POS_UP = 60 # Default: 60 PEN_POS_DOWN = 40 # Default: 40 MIN_SPEED = 10 # percent @@ -117,30 +120,35 @@ def timestamp(date = None): date = date.replace(tzinfo=date.astimezone().tzinfo) return date.strftime("%Y%m%d_%H%M%S.%f_UTC%z") -# status: 'waiting' | 'canceled' | 'finished' -def save_svg(job, status): - if status not in ['waiting', 'canceled', 'finished']: - return False - filename = f'{job["received"]}_{job["client"][0:10]}_{job["hash"][0:5]}.svg' - files = { - 'waiting': os.path.join(FOLDER_WAITING, filename), - 'canceled': os.path.join(FOLDER_CANCELED, filename), - 'finished': os.path.join(FOLDER_FINISHED, filename), - } - for key, file in files.items(): - if key == status: - os.makedirs( os.path.dirname(file), exist_ok=True) - with open(file, 'w', encoding='utf-8') as f: f.write(job['svg']) - job['save_path'] = file - else: - try: - os.remove(file) - except: - pass - return True + +def save_svg(job, overwrite_existing = False): + if job['status'] not in ['waiting', 'plotting', 'canceled', 'finished']: return False + + min = int(job["time_estimate"] / 60) + sec = math.ceil(job["time_estimate"] % 60) + position = f'{(job["position"] + 1):03}_' if 'position' in job and job['status'] in ['waiting', 'plotting'] else '' + + filename = f'{position}{job["received"]}_{job["client"][0:10]}_{job["hash"][0:5]}_{min}m{sec}s.svg' + filename = os.path.join(STATUS_FOLDERS[job['status']], filename) -def save_svg_async(*args, **kwargs): - return asyncio.to_thread(save_svg, *args, **kwargs) + # remove previous save + if 'save_path' in job and job['save_path'] != filename: + try: + # print('removing', job['save_path']) + os.remove(job['save_path']) + except: + pass + + # save file + if not os.path.isfile(filename) or overwrite_existing: + # print('writing', filename) + os.makedirs( os.path.dirname(filename), exist_ok=True) + with open(filename, 'w', encoding='utf-8') as f: f.write(job['svg']) + job['save_path'] = filename + return True + +# def save_svg_async(*args, **kwargs): +# return asyncio.to_thread(save_svg, *args, **kwargs) # Updated pre version 4 SVGs, so they are compatible with resume queue @@ -166,6 +174,7 @@ async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = Non await callback( error_cb, 'Cannot add job, you already have a job queued!', job ) return False + job['status'] = 'waiting' job['cancel'] = False # save callbacks job['queue_position_cb'] = queue_position_cb @@ -190,7 +199,8 @@ async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = Non update_svg(job) await queue.put(job) - await save_svg_async(job, 'waiting') + job['position'] = job_pos(job) + save_svg(job) await _notify_queue_size() # notify new queue size await _notify_queue_positions() @@ -207,7 +217,9 @@ async def cancel(client, force = False): if client not in _jobs: return False job = _jobs[client] if job == _current_job and _status == 'plotting': return # can't cancel if plotting + job['status'] = 'canceled' job['cancel'] = True # set cancel flag + job['position'] = None del _jobs[client] # remove from queue @@ -221,7 +233,8 @@ async def cancel(client, force = False): await _notify_queue_size() # notify new queue size await _notify_queue_positions() # notify queue positions (might have changed for some) print(f'❌ [red]Canceled job \\[{job["client"]}]') - await save_svg_async(job, 'canceled') + save_svg(job) + update_positions_and_save() return True async def cancel_current_job(force = True): @@ -229,6 +242,8 @@ async def cancel_current_job(force = True): async def finish_current_job(): global _current_job + _current_job['status'] = 'finished' + _current_job['position'] = None await callback( _current_job['done_cb'], _current_job ) # notify job done del _jobs[ _current_job['client'] ] # remove from jobs index finished_job = _current_job @@ -237,15 +252,28 @@ async def finish_current_job(): await _notify_queue_size() # notify queue size print(f'✅ [green]Finished job \\[{finished_job["client"]}]') _status = 'waiting' - await save_svg_async(finished_job, 'finished') + save_svg(finished_job) + update_positions_and_save() return True - +# current job: 0 (if plotting or waiting, check job['status']) def job_pos(job): - if (job == _current_job): - if _status == 'plotting': return -1 - return 0 - return queue.index(job) + 1 + if (job == _current_job): return 0 + try: + return queue.index(job) + 1 + except ValueError: + return None + +def update_position(job): + if job == None: return + job['position'] = job_pos(job) + +def update_positions_and_save(): + # print('updating positions') + for job in _jobs.values(): + update_position(job) + # print(job['client'], job['position']) + save_svg(job, overwrite_existing=False) # positions # 0 .. current job @@ -256,10 +284,10 @@ async def move(client, new_pos): # print('move', client, new_pos) job = _jobs[client] current_pos = job_pos(job) - # print('current pos', current_pos) + # print('move: current pos', current_pos) # cannot move if job is already plotting - if current_pos == -1: - # print('already plotting, can\'t move') + if job['status'] == 'plotting': + # print('move: already plotting, can\'t move') return # normalize new_pos @@ -278,7 +306,7 @@ async def move(client, new_pos): # nothing to do if new_pos == current_pos: - # print('nothing to do') + # print('move: nothing to do') return # move job from queue to current job @@ -300,6 +328,9 @@ async def move(client, new_pos): # print('move within queue') queue.move(current_pos-1, new_pos-1) + # update 'position' attribute of all jobs and save svgs + update_positions_and_save() + await _notify_queue_size() await _notify_queue_positions() # notify queue positions (might have changed for some) @@ -365,6 +396,7 @@ def cycle(): def plot(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, options_cb = None, return_ad = False): if 'svg' not in job: return 0 + job['status'] = 'plotting' speed = job['speed'] / 100 with capture_output(print_axidraw, print_axidraw): ad = axidraw.AxiDraw() @@ -440,6 +472,7 @@ def _options_cb(options): ad = plot(job, align_after=False, align_after_pause=False, options_cb=_options_cb, return_ad=True) update_stats(ad) + job['status'] = 'waiting' # reset status (has been set to 'plotting' by plot()) while ad.errors.code == 1: # Paused programmatically ad = resume_plot(job, align_after=False, align_after_pause=False, options_cb=_options_cb, return_ad=True) @@ -483,7 +516,7 @@ async def prompt_waiting(message = 'Setup as needed'): while True: res = await prompt_ui('waiting', message) if not res: - print('prompt cancelled') + # print('prompt cancelled') return False # the prompt was intentionally cancelled res = res['id'] @@ -548,10 +581,10 @@ async def prompt_resume_plot(message, job): elif res == 'neg': # Done return False -async def resume_queue(): +async def resume_queue_from_disk(): import xml.etree.ElementTree as ElementTree - list = sorted(os.listdir(FOLDER_WAITING)) - list = [ os.path.join(FOLDER_WAITING, x) for x in list if x.endswith('.svg') ] + list = sorted(os.listdir(STATUS_FOLDERS['waiting'])) + list = [ os.path.join(STATUS_FOLDERS['waiting'], x) for x in list if x.endswith('.svg') ] resumable_jobs = [] for filename in list: # print('Loading ', filename) @@ -582,7 +615,8 @@ def attr(attr, ns = 'https://sketch.process.studio/turtle-graphics'): 'format': attr('format'), 'size': [int(attr('width_mm')), int(attr('height_mm'))], 'hash': hashlib.sha1(svg.encode('utf-8')).hexdigest(), - 'received': received_ts + 'received': received_ts, + 'save_path': filename, } resumable_jobs.append(job) except: @@ -617,7 +651,7 @@ async def start(app): print_status = app.update_header if TESTING: print('[yellow]TESTING MODE enabled') - if RESUME_QUEUE: await resume_queue() + if RESUME_QUEUE: await resume_queue_from_disk() await align_async() # await prompt_setup() @@ -629,6 +663,7 @@ async def start(app): asyncio.create_task( prompt_waiting() ) # this allows align/cycle _current_job = await queue.get() cancel_prompt_waiting() + update_positions_and_save() if not _current_job['cancel']: # skip if job is canceled set_status('confirm_plot') @@ -658,7 +693,7 @@ async def start(app): if error == 0: if REPEAT_JOBS: print(f'[blue]Done ({loop}x) job \\[{_current_job["client"]}]') - set_status('confirm_plot') + set_status('plotting') repeat = await prompt_repeat_plot(f'[yellow]Repeat[/yellow] ({loop+1}) job \\[{_current_job["client"]}] ?') if repeat: continue await finish_current_job() From 6536f0f097739c5eb74ffae971249f362bf0ed57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sat, 9 Nov 2024 15:23:00 +0100 Subject: [PATCH 22/51] Update SVG file naming --- spooler.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/spooler.py b/spooler.py index 08b8ab4..7c41432 100644 --- a/spooler.py +++ b/spooler.py @@ -113,13 +113,20 @@ def status(): 'queue_size': num_jobs(), } -def timestamp(date = None): +def timestamp_str_full(date = None): if date == None: # make timezone aware timestamp: https://stackoverflow.com/a/39079819 date = datetime.now(timezone.utc) date = date.replace(tzinfo=date.astimezone().tzinfo) return date.strftime("%Y%m%d_%H%M%S.%f_UTC%z") +def timestamp_str(date = None): + if date == None: + # make timezone aware timestamp: https://stackoverflow.com/a/39079819 + date = datetime.now(None) # None ... use current timezone + date = date.replace(tzinfo=date.astimezone().tzinfo) + return date.strftime("%Y%m%d_%H%M%S.%f")[:-3] # trim last three digits of microsecond + def save_svg(job, overwrite_existing = False): if job['status'] not in ['waiting', 'plotting', 'canceled', 'finished']: return False @@ -128,7 +135,7 @@ def save_svg(job, overwrite_existing = False): sec = math.ceil(job["time_estimate"] % 60) position = f'{(job["position"] + 1):03}_' if 'position' in job and job['status'] in ['waiting', 'plotting'] else '' - filename = f'{position}{job["received"]}_{job["client"][0:10]}_{job["hash"][0:5]}_{min}m{sec}s.svg' + filename = f'{position}{job["received"]}_[{job["client"][0:10]}]_{job["hash"][0:5]}_{min}m{sec}s.svg' filename = os.path.join(STATUS_FOLDERS[job['status']], filename) # remove previous save @@ -182,7 +189,7 @@ async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = Non job['cancel_cb'] = cancel_cb job['error_cb'] = error_cb if 'received' not in job or job['received'] == None: - job['received'] = timestamp() + job['received'] = timestamp_str() # speed if 'speed' in job: job['speed'] = max( min(job['speed'], 100), MIN_SPEED ) # limit speed (MIN_SPEED, 100) @@ -594,7 +601,8 @@ async def resume_queue_from_disk(): root = ElementTree.fromstring(svg) def attr(attr, ns = 'https://sketch.process.studio/turtle-graphics'): return root.get(attr if ns == None else "{" + ns + "}" + attr) - match = re.search('\\d{8}_\\d{6}.\\d{6}_UTC[+-]\\d{4}', os.path.basename(filename)) + # match = re.search('\\d{8}_\\d{6}.\\d{6}_UTC[+-]\\d{4}', os.path.basename(filename)) + match = re.search('\\d{8}_\\d{6}', os.path.basename(filename)) received_ts = None if match == None else match.group(0) job = { 'loaded_from_file': True, From 6706f8693df160d531c0c58eb7d0712114f4dd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sat, 9 Nov 2024 15:41:23 +0100 Subject: [PATCH 23/51] Add travel to SVG filename --- spooler.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spooler.py b/spooler.py index 7c41432..dd2872b 100644 --- a/spooler.py +++ b/spooler.py @@ -135,7 +135,9 @@ def save_svg(job, overwrite_existing = False): sec = math.ceil(job["time_estimate"] % 60) position = f'{(job["position"] + 1):03}_' if 'position' in job and job['status'] in ['waiting', 'plotting'] else '' - filename = f'{position}{job["received"]}_[{job["client"][0:10]}]_{job["hash"][0:5]}_{min}m{sec}s.svg' + ink = f'{(job["stats"]["travel_ink"] / 1000):.1f}' + travel = f'{(job["stats"]["travel"] / 1000):.1f}' + filename = f'{position}{job["received"]}_[{job["client"][0:10]}]_{job["hash"][0:5]}_{travel}m_{min}m{sec}s.svg' filename = os.path.join(STATUS_FOLDERS[job['status']], filename) # remove previous save From ac52fb3f38079f8682a44307ce2319f30ffc439f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sat, 9 Nov 2024 22:57:46 +0100 Subject: [PATCH 24/51] Manage cursor visibility on queue widget --- main.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 4b12263..db9d29c 100644 --- a/main.py +++ b/main.py @@ -144,6 +144,9 @@ async def run_server(app): # await asyncio.Future() # run forever await spooler.start(app) # run forever +class MyDataTable(DataTable): + def on_click(self, event): + self.app.on_queue_click(event) class App(TextualApp): prompt_future = None @@ -151,7 +154,9 @@ class App(TextualApp): def compose(self): global header, queue, log, footer header = Header(icon = '🖨️', show_clock = True, time_format = '%H:%M') - queue = DataTable(id = 'queue') + + queue = MyDataTable(id = 'queue') + log = RichLog(markup=True) footer = Footer(id="footer", show_command_palette=True) @@ -242,6 +247,8 @@ def on_mount(self): queue.styles.height = '1fr' queue.add_columns(*QUEUE_HEADERS) queue.cursor_type = 'row' + queue.zebra_stripes = True + queue.show_cursor = False self.update_header() @@ -354,6 +361,11 @@ def on_button(self, event): @on(Key) async def on_queue_hotkey(self, event): + if event.key in ['up', 'down'] and queue.row_count > 0: + queue.show_cursor = True + if queue.show_cursor == False: + return + if event.key in ['backspace', 'i', 'k', '1', '0', 'space']: if queue.row_count == 0: return # nothing in list client = queue.ordered_rows[queue.cursor_row].key.value @@ -381,6 +393,10 @@ async def on_queue_hotkey(self, event): elif (event.key == 'space'): self.preview_job( spooler.job_by_client(client) ) + def on_queue_click(self, event): + if queue.row_count > 0: + queue.show_cursor = True + def job_to_row(self, job, idx): return (idx, job['client'], job['hash'][:5], job['stats']['count'], job['stats']['layer_count'], int(job['stats']['travel'])/1000, int(job['stats']['travel_ink'])/1000, job['format'], job['speed'], f'{math.floor(job["time_estimate"]/60)}:{round(job["time_estimate"]%60):02}') @@ -391,10 +407,12 @@ def update_current_job(self): job_current.add_row( *self.job_to_row(job, 1), key=job['client'] ) def update_job_queue(self): + if queue.row_count == 0: queue.show_cursor = False # remember selected client client = None - if queue.row_count > 0: + if queue.row_count > 0 and queue.show_cursor: client = queue.ordered_rows[queue.cursor_row].key.value + # print('selected client:', client) queue.clear() for idx, job in enumerate(spooler.jobs()): @@ -403,9 +421,11 @@ def update_job_queue(self): # recall client (if possible) if client: try: + # print('select row:', queue.get_row_index(client)) queue.move_cursor(row=queue.get_row_index(client)) except RowDoesNotExist: - pass + # print('row does not exist') + queue.show_cursor = False def cancel_prompt_ui(self): if self.prompt_future != None and not self.prompt_future.done(): From e98d01f86c4221d177daee694c9e239f5e673873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 10 Nov 2024 02:32:26 +0100 Subject: [PATCH 25/51] Fix start script args --- start | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/start b/start index 07d363d..ccbec44 100755 --- a/start +++ b/start @@ -13,8 +13,8 @@ trap quit EXIT usage() { echo "Usage: $(basename $0) [-d] [-f <0|1>] [-n <0|1>]" echo " -h ... Show this help" - echo " -d ... Dev Mode (Defaults -f to false)" - echo " -f ... Start frpc (Default 1)" + echo " -d ... Dev Mode" + echo " -f ... Start frpc (Default 1 except when using -d)" echo " -n ... Start ngrok (Default 0)" exit 1 } @@ -23,10 +23,15 @@ dev=false start_frpc=true start_ngrok=false -while getopts ":f:n:h:d" opts; do +# A letter followed by a colon means the switch takes an argument +while getopts "hdf:n:" opts; do case "${opts}" in + h) + usage + exit 0 + ;; d) - dev=true + dev=true start_frpc=false start_ngrok=false ;; @@ -40,14 +45,14 @@ while getopts ":f:n:h:d" opts; do [[ $n -eq 0 || $n -eq 1 ]] || usage [[ $n -eq 1 || -z $n ]] && start_ngrok=true || start_ngrok=false ;; - h) - usage - ;; *) usage + exit 1 ;; esac done + +# remove switches from $@ shift $((OPTIND-1)) if $start_frpc; then From 3097db1aac22bc86436939ab7318c19d1fa829b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 10 Nov 2024 02:38:31 +0100 Subject: [PATCH 26/51] Add console switch to start script --- start | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/start b/start index ccbec44..db2febe 100755 --- a/start +++ b/start @@ -14,6 +14,7 @@ usage() { echo "Usage: $(basename $0) [-d] [-f <0|1>] [-n <0|1>]" echo " -h ... Show this help" echo " -d ... Dev Mode" + echo " -c ... Console (Run in second terminal to see output from Dev Mode" echo " -f ... Start frpc (Default 1 except when using -d)" echo " -n ... Start ngrok (Default 0)" exit 1 @@ -24,10 +25,14 @@ start_frpc=true start_ngrok=false # A letter followed by a colon means the switch takes an argument -while getopts "hdf:n:" opts; do +while getopts "hcdf:n:" opts; do case "${opts}" in h) usage + exit 1 + ;; + c) + textual console -x event -x debug exit 0 ;; d) From a7cc3396fb718c9cd5d30a81de5da6a3a45d92d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 10 Nov 2024 03:13:36 +0100 Subject: [PATCH 27/51] Add timer for total estimated printing time --- header_timer.py | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 6 +++++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 header_timer.py diff --git a/header_timer.py b/header_timer.py new file mode 100644 index 0000000..a501cec --- /dev/null +++ b/header_timer.py @@ -0,0 +1,50 @@ +"""Provides a Textual application header widget.""" +from datetime import datetime + +from rich.text import Text +from textual.widgets import Header +from textual.widgets._header import HeaderIcon, HeaderTitle, HeaderClockSpace +from textual.reactive import Reactive + +class HeaderClock(HeaderClockSpace): + """Display a clock on the right of the header.""" + + DEFAULT_CSS = """ + HeaderClock { + background: $foreground-darken-1 5%; + color: $text; + text-opacity: 85%; + content-align: center middle; + } + """ + + time_seconds: Reactive[str] = Reactive(0) + + def render(self): + """Render the header clock. + + Returns: + The rendered clock. + """ + if self.time_seconds == 0: return Text('--:--') + hours = int(self.time_seconds / 3600) + minutes = int((self.time_seconds - 3600 * hours) / 60) + seconds = int((self.time_seconds - 3600 * hours) % 60) + if hours == 0: + return Text(f'{minutes:02}:{seconds:02}') + else: + return Text(f'{hours:02}:{minutes:02}:{seconds:02}') + +class HeaderTimer(Header): + + time_seconds: Reactive[str] = Reactive(0) + """Time of the Clock in seconds.""" + + def compose(self): + yield HeaderIcon().data_bind(Header.icon) + yield HeaderTitle() + yield ( + HeaderClock().data_bind(HeaderTimer.time_seconds) + if self._show_clock + else HeaderClockSpace() + ) diff --git a/main.py b/main.py index db9d29c..37505cb 100644 --- a/main.py +++ b/main.py @@ -30,6 +30,7 @@ from textual.widgets.data_table import RowDoesNotExist from textual.containers import Horizontal, Vertical from hotkey_button import HotkeyButton +from header_timer import HeaderTimer import asyncio import websockets @@ -38,6 +39,7 @@ import math import subprocess import porkbun +import functools app = None @@ -153,7 +155,7 @@ class App(TextualApp): def compose(self): global header, queue, log, footer - header = Header(icon = '🖨️', show_clock = True, time_format = '%H:%M') + header = HeaderTimer(icon = '🖨️', show_clock = True, time_format = '%H:%M') queue = MyDataTable(id = 'queue') @@ -296,6 +298,8 @@ def update_header(self): status = spooler.status() self.title = status['status_desc'] self.sub_title = f'{num_clients} Clients – {spooler.num_jobs()} Jobs' + total_secs = functools.reduce(lambda acc, x: acc + x['time_estimate'], spooler.jobs(), 0) + header.time_seconds = total_secs def bind(self, *args, **kwargs): super().bind(*args, **kwargs) From be829aca8812e24b157e52aa78c249b1316ae8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 10 Nov 2024 10:13:43 +0100 Subject: [PATCH 28/51] Add info about resume counts --- spooler.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/spooler.py b/spooler.py index dd2872b..321a3f8 100644 --- a/spooler.py +++ b/spooler.py @@ -686,6 +686,8 @@ async def start(app): # plot (and retry on error or repeat) loop = 0 # number or tries/repetitions + layer = 0 # number of programmatic pauses (error 1) + interrupt = 0 # number of stops by button press (error 102) or keyboard interrupt (103) resume = False # flag indicating resume (vs. plotting from start) while True: if (resume): @@ -704,7 +706,7 @@ async def start(app): if REPEAT_JOBS: print(f'[blue]Done ({loop}x) job \\[{_current_job["client"]}]') set_status('plotting') - repeat = await prompt_repeat_plot(f'[yellow]Repeat[/yellow] ({loop+1}) job \\[{_current_job["client"]}] ?') + repeat = await prompt_repeat_plot(f'[yellow]Repeat ({loop+1}) job[/yellow] \\[{_current_job["client"]}] ?') if repeat: continue await finish_current_job() break @@ -712,7 +714,14 @@ async def start(app): elif error in PLOTTER_PAUSED: print(f'[yellow]Plotter: {get_error_msg(error)}') set_status('paused') - ready = await prompt_resume_plot(f'[blue]Continue[/blue] job \\[{_current_job["client"]}] ?', _current_job) + if error in [1]: + layer += 1 + prompt = f"[blue]Resume ({layer+1}/{_current_job['layers']}) with next layer[/blue]" + elif error in [102, 103]: + interrupt += 1 + prompt = f"[blue]Resume ({interrupt+1}) interrupted[/blue] job" + if _current_job['layers'] > 1: prompt += f" on layer ({layer+1}/{_current_job['layers']})" + ready = await prompt_resume_plot(f'{prompt} \\[{_current_job["client"]}] ?', _current_job) if not ready: await cancel_current_job() break From 738be5071c92cd32867901683f77e55a6e3ca225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 10 Nov 2024 22:10:56 +0100 Subject: [PATCH 29/51] Add hotkey to enqueue test job --- main.py | 31 ++++++++++++-------- spooler.py | 73 ++++++++++++++++++++++++++-------------------- test_job.py | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 43 deletions(-) create mode 100644 test_job.py diff --git a/main.py b/main.py index 37505cb..fdc4d7c 100644 --- a/main.py +++ b/main.py @@ -263,6 +263,8 @@ def on_mount(self): b_minus.disabled = True b_preview.disabled = True + self.bind('t', 'enqueue_test_job()', description = 'Test job') + setup_ssl() # log.write(log.styles.height) @@ -284,6 +286,11 @@ def on_server_task_exit(task): # global spooler_task # spooler_task = asyncio.create_task(spooler.start(self)) + async def action_enqueue_test_job(self): + from test_job import test_job + job = test_job() + await spooler.enqueue(test_job()) + def on_resize(self, event): pass @@ -310,18 +317,18 @@ def unbind(self, key): self._bindings.key_to_bindings.pop(key, None) self.refresh_bindings() - # bindings: [ (key, desc), ... ] - # This not a coroutine (no async). It returns a future, which can be awaited from coroutines - def prompt(self, bindings, message): - # setup bindings - self.print(message) - self.print(bindings) - self.update_bindings([ ('y', 'prompt_response("y")', 'Yes'), ('n', 'prompt_response("n")', 'No') ]) - - # return a future that eventually resolves to the result - loop = asyncio.get_running_loop() - self.prompt_future = loop.create_future() - return self.prompt_future + # # bindings: [ (key, desc), ... ] + # # This not a coroutine (no async). It returns a future, which can be awaited from coroutines + # def prompt(self, bindings, message): + # # setup bindings + # self.print(message) + # self.print(bindings) + # self.update_bindings([ ('y', 'prompt_response("y")', 'Yes'), ('n', 'prompt_response("n")', 'No') ]) + # + # # return a future that eventually resolves to the result + # loop = asyncio.get_running_loop() + # self.prompt_future = loop.create_future() + # return self.prompt_future def preview_job(self, job): if job != None and 'save_path' in job: diff --git a/spooler.py b/spooler.py index 321a3f8..6b29a0d 100644 --- a/spooler.py +++ b/spooler.py @@ -7,6 +7,7 @@ import re import hashlib import async_queue +import xml.etree.ElementTree as ElementTree STATUS_FOLDERS = { 'waiting' : 'svgs/0_waiting', @@ -197,7 +198,7 @@ async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = Non if 'speed' in job: job['speed'] = max( min(job['speed'], 100), MIN_SPEED ) # limit speed (MIN_SPEED, 100) else: job['speed'] = 100 # format - if 'format' not in job: job['format'] = 'A3_LANDSCAPE' + if 'format' not in job: job['format'] = 'A4_LANDSCAPE' # add to jobs index _jobs[ job['client'] ] = job @@ -590,6 +591,44 @@ async def prompt_resume_plot(message, job): elif res == 'neg': # Done return False +def svg_to_job(svg, filename = None): + NS = 'https://sketch.process.studio/turtle-graphics' + root = ElementTree.fromstring(svg) + + def attr(attr, ns = NS): + return root.get(attr if ns == None else "{" + ns + "}" + attr) + + received_ts = None + if filename != None: + match = re.search('\\d{8}_\\d{6}', os.path.basename(filename)) + if match != None: received_ts = match.group(0) + + job = { + 'loaded_from_file': True, + 'client': attr('author'), + 'id': "XYZ", + 'svg': svg, + 'stats': { + 'count': int(attr('count')), + 'layer_count': int(attr('layer_count')), + 'oob_count': int(attr('oob_count')), + 'short_count': int(attr('short_count')), + 'travel': int(attr('travel')), + 'travel_ink': int(attr('travel_ink')), + 'travel_blank': int(attr('travel_blank')) + }, + 'timestamp': attr('timestamp'), + 'speed': int(attr('speed')), + 'format': attr('format'), + 'size': [int(attr('width_mm')), int(attr('height_mm'))], + 'hash': hashlib.sha1(svg.encode('utf-8')).hexdigest(), + 'received': received_ts, + 'save_path': filename, + } + + return job + + async def resume_queue_from_disk(): import xml.etree.ElementTree as ElementTree list = sorted(os.listdir(STATUS_FOLDERS['waiting'])) @@ -598,36 +637,8 @@ async def resume_queue_from_disk(): for filename in list: # print('Loading ', filename) try: - with open(filename, 'r') as file: - svg = file.read() - root = ElementTree.fromstring(svg) - def attr(attr, ns = 'https://sketch.process.studio/turtle-graphics'): - return root.get(attr if ns == None else "{" + ns + "}" + attr) - # match = re.search('\\d{8}_\\d{6}.\\d{6}_UTC[+-]\\d{4}', os.path.basename(filename)) - match = re.search('\\d{8}_\\d{6}', os.path.basename(filename)) - received_ts = None if match == None else match.group(0) - job = { - 'loaded_from_file': True, - 'client': attr('author'), - 'id': "XYZ", - 'svg': svg, - 'stats': { - 'count': int(attr('count')), - 'layer_count': int(attr('layer_count')), - 'oob_count': int(attr('oob_count')), - 'short_count': int(attr('short_count')), - 'travel': int(attr('travel')), - 'travel_ink': int(attr('travel_ink')), - 'travel_blank': int(attr('travel_blank')) - }, - 'timestamp': attr('timestamp'), - 'speed': int(attr('speed')), - 'format': attr('format'), - 'size': [int(attr('width_mm')), int(attr('height_mm'))], - 'hash': hashlib.sha1(svg.encode('utf-8')).hexdigest(), - 'received': received_ts, - 'save_path': filename, - } + with open(filename, 'r') as file: svg = file.read() + job = svg_to_job(svg, filename) resumable_jobs.append(job) except: print('Error resuming ', filename) diff --git a/test_job.py b/test_job.py new file mode 100644 index 0000000..1f27904 --- /dev/null +++ b/test_job.py @@ -0,0 +1,83 @@ +test_square = ''' + + + + + + +''' + +test_wide = ''' + + + + + + +''' + +test_high = ''' + + + + + + +''' + +test_layers = ''' + + + + + + + + + + + + +''' + +from spooler import svg_to_job +import uuid + +def test_job(name = 'layers'): + svg = globals()['test_' + name] + client = 'Test-' + uuid.uuid4().hex[:3] + svg = svg.replace('tg:author=""', f'tg:author="{client}"') + job = svg_to_job(svg) + return job From 805c24b6092a913f8d4d328f2a8c165d07edb538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 10 Nov 2024 22:20:23 +0100 Subject: [PATCH 30/51] Fix move job hotkeys --- spooler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spooler.py b/spooler.py index 6b29a0d..05e7db4 100644 --- a/spooler.py +++ b/spooler.py @@ -482,12 +482,13 @@ def _options_cb(options): ad = plot(job, align_after=False, align_after_pause=False, options_cb=_options_cb, return_ad=True) update_stats(ad) - job['status'] = 'waiting' # reset status (has been set to 'plotting' by plot()) while ad.errors.code == 1: # Paused programmatically ad = resume_plot(job, align_after=False, align_after_pause=False, options_cb=_options_cb, return_ad=True) update_stats(ad) + job['status'] = 'waiting' # reset status (has been set to 'plotting' by plot()) + return stats From 9c591ad01d1705067db9e482ecabdc843496c30e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 10 Nov 2024 22:27:04 +0100 Subject: [PATCH 31/51] Fix hotkeys --- main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index fdc4d7c..6497556 100644 --- a/main.py +++ b/main.py @@ -491,7 +491,7 @@ def prompt_ui(self, variant, message = ''): b_minus.disabled = False b_preview.disabled = False case 'repeat_plot': - b_pos.update_hotkey('p', 'Plot again') + b_pos.update_hotkey('r', 'Repeat plot') b_pos.variant = 'primary' b_pos.disabled = False @@ -506,11 +506,11 @@ def prompt_ui(self, variant, message = ''): b_minus.disabled = False b_preview.disabled = False case 'resume_plot': - b_pos.update_hotkey('p', 'Resume') + b_pos.update_hotkey('p', 'Resume plot') b_pos.variant = 'primary' b_pos.disabled = False - b_neg.update_hotkey('c', 'Cancel') + b_neg.update_hotkey('escape', 'Cancel') b_neg.variant = 'error' b_neg.disabled = False From acd1da768cafd0f5f6fd58ea2821fe626bb6a6c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Mon, 11 Nov 2024 00:14:58 +0100 Subject: [PATCH 32/51] Add hotkey to open svg folder --- main.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 6497556..45f05c4 100644 --- a/main.py +++ b/main.py @@ -263,7 +263,8 @@ def on_mount(self): b_minus.disabled = True b_preview.disabled = True - self.bind('t', 'enqueue_test_job()', description = 'Test job') + self.bind('t', 'enqueue_test_job', description = 'Test job') + self.bind('o', 'open_svg_folder', description = 'Open SVG folder') setup_ssl() # log.write(log.styles.height) @@ -290,6 +291,10 @@ async def action_enqueue_test_job(self): from test_job import test_job job = test_job() await spooler.enqueue(test_job()) + + def action_open_svg_folder(self): + sub_coro = asyncio.create_subprocess_exec('open', spooler.STATUS_FOLDERS['waiting'], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + asyncio.create_task(sub_coro) def on_resize(self, event): pass From 05c9cfebfce4b3cdefdba4e48087f64be9ba1c08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sat, 16 Nov 2024 16:57:55 +0100 Subject: [PATCH 33/51] Fix typo in usage info --- start | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/start b/start index db2febe..cfdafac 100755 --- a/start +++ b/start @@ -14,7 +14,7 @@ usage() { echo "Usage: $(basename $0) [-d] [-f <0|1>] [-n <0|1>]" echo " -h ... Show this help" echo " -d ... Dev Mode" - echo " -c ... Console (Run in second terminal to see output from Dev Mode" + echo " -c ... Console (Run in second terminal to see output from Dev Mode)" echo " -f ... Start frpc (Default 1 except when using -d)" echo " -n ... Start ngrok (Default 0)" exit 1 @@ -37,7 +37,7 @@ while getopts "hcdf:n:" opts; do ;; d) dev=true - start_frpc=false + start_frpc=true start_ngrok=false ;; f) From 6d4b2328b3bc418764b5aef0779636a6cd80fa65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sat, 16 Nov 2024 17:16:02 +0100 Subject: [PATCH 34/51] Deactivate buttons during plotting --- main.py | 18 +++++++++++++++++- spooler.py | 4 ++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 45f05c4..e4a9138 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ USE_ZEROCONF = 0 ZEROCONF_HOSTNAME = 'plotter' -USE_PORKBUN = 0 +USE_PORKBUN = 1 PORKBUN_ROOT_DOMAIN = 'process.tools' PORKBUN_SUBDOMAIN = 'plotter-local' PORKBUN_TTL = 600 @@ -495,6 +495,19 @@ def prompt_ui(self, variant, message = ''): b_plus.disabled = False b_minus.disabled = False b_preview.disabled = False + case 'plotting': + b_pos.disabled = True + + b_neg.update_hotkey('escape', 'Pause') + b_neg.variant = 'warning' + b_neg.disabled = True + + b_align.disabled = True + b_cycle.disabled = True + b_home.disabled = True + b_plus.disabled = True + b_minus.disabled = True + b_preview.disabled = False case 'repeat_plot': b_pos.update_hotkey('r', 'Repeat plot') b_pos.variant = 'primary' @@ -533,6 +546,9 @@ def prompt_ui(self, variant, message = ''): if self.prompt_future == None or self.prompt_future.done(): loop = asyncio.get_running_loop() self.prompt_future = loop.create_future() + + if variant == 'plotting': self.prompt_future.set_result(True) + return self.prompt_future diff --git a/spooler.py b/spooler.py index 05e7db4..4e876de 100644 --- a/spooler.py +++ b/spooler.py @@ -558,6 +558,9 @@ async def prompt_start_plot(message): elif res == 'neg': # Cancel return False +async def prompt_plotting(message = ''): + return await prompt_ui('plotting', message); + async def prompt_repeat_plot(message): while True: res = await prompt_ui('repeat_plot', message) @@ -702,6 +705,7 @@ async def start(app): interrupt = 0 # number of stops by button press (error 102) or keyboard interrupt (103) resume = False # flag indicating resume (vs. plotting from start) while True: + await prompt_plotting() # this returns immediately if (resume): print(f'🖨️ [yellow]Resuming job \\[{_current_job["client"]}] ...') set_status('plotting') From 60956d9cd5ea471cd1737c1c1285b7185664d978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sat, 16 Nov 2024 17:52:25 +0100 Subject: [PATCH 35/51] Allow repeating job after canceling on a layer --- main.py | 6 +++--- spooler.py | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index e4a9138..19df3fe 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ USE_ZEROCONF = 0 ZEROCONF_HOSTNAME = 'plotter' -USE_PORKBUN = 1 +USE_PORKBUN = 0 PORKBUN_ROOT_DOMAIN = 'process.tools' PORKBUN_SUBDOMAIN = 'plotter-local' PORKBUN_TTL = 600 @@ -528,8 +528,8 @@ def prompt_ui(self, variant, message = ''): b_pos.variant = 'primary' b_pos.disabled = False - b_neg.update_hotkey('escape', 'Cancel') - b_neg.variant = 'error' + b_neg.update_hotkey('d', 'Done') + b_neg.variant = 'warning' b_neg.disabled = False b_align.disabled = False diff --git a/spooler.py b/spooler.py index 4e876de..b090db5 100644 --- a/spooler.py +++ b/spooler.py @@ -706,7 +706,9 @@ async def start(app): resume = False # flag indicating resume (vs. plotting from start) while True: await prompt_plotting() # this returns immediately - if (resume): + if (resume == 'skip_to_repeat'): + error = 0 + elif resume: print(f'🖨️ [yellow]Resuming job \\[{_current_job["client"]}] ...') set_status('plotting') error = await resume_plot_async(_current_job) @@ -722,6 +724,7 @@ async def start(app): if REPEAT_JOBS: print(f'[blue]Done ({loop}x) job \\[{_current_job["client"]}]') set_status('plotting') + layer = 0 repeat = await prompt_repeat_plot(f'[yellow]Repeat ({loop+1}) job[/yellow] \\[{_current_job["client"]}] ?') if repeat: continue await finish_current_job() @@ -738,10 +741,8 @@ async def start(app): prompt = f"[blue]Resume ({interrupt+1}) interrupted[/blue] job" if _current_job['layers'] > 1: prompt += f" on layer ({layer+1}/{_current_job['layers']})" ready = await prompt_resume_plot(f'{prompt} \\[{_current_job["client"]}] ?', _current_job) - if not ready: - await cancel_current_job() - break if ready: resume = True + else: resume = 'skip_to_repeat' # Skip to asking to repeat job # Errors else: print(f'[red]Plotter: {get_error_msg(error)}') From a256eb4411d15ce545f29d86618e416013c959fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sat, 16 Nov 2024 17:59:54 +0100 Subject: [PATCH 36/51] Wording of prompts --- main.py | 4 ++-- spooler.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 19df3fe..61e0086 100644 --- a/main.py +++ b/main.py @@ -509,7 +509,7 @@ def prompt_ui(self, variant, message = ''): b_minus.disabled = True b_preview.disabled = False case 'repeat_plot': - b_pos.update_hotkey('r', 'Repeat plot') + b_pos.update_hotkey('r', 'Repeat') b_pos.variant = 'primary' b_pos.disabled = False @@ -524,7 +524,7 @@ def prompt_ui(self, variant, message = ''): b_minus.disabled = False b_preview.disabled = False case 'resume_plot': - b_pos.update_hotkey('p', 'Resume plot') + b_pos.update_hotkey('p', 'Continue') b_pos.variant = 'primary' b_pos.disabled = False diff --git a/spooler.py b/spooler.py index b090db5..5cb6930 100644 --- a/spooler.py +++ b/spooler.py @@ -735,11 +735,11 @@ async def start(app): set_status('paused') if error in [1]: layer += 1 - prompt = f"[blue]Resume ({layer+1}/{_current_job['layers']}) with next layer[/blue]" + prompt = f"[blue]Continue layer ({layer+1}/{_current_job['layers']})[/blue]" elif error in [102, 103]: interrupt += 1 - prompt = f"[blue]Resume ({interrupt+1}) interrupted[/blue] job" - if _current_job['layers'] > 1: prompt += f" on layer ({layer+1}/{_current_job['layers']})" + prompt = f"[blue]Continue ({interrupt+1}) interrupted job[/blue]" + if _current_job['layers'] > 1: prompt += f" layer ({layer+1}/{_current_job['layers']})" ready = await prompt_resume_plot(f'{prompt} \\[{_current_job["client"]}] ?', _current_job) if ready: resume = True else: resume = 'skip_to_repeat' # Skip to asking to repeat job From 872e127ff5556512c24b8016082ea0cb06230b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sat, 16 Nov 2024 18:24:55 +0100 Subject: [PATCH 37/51] Add timeout for simulating jobs --- spooler.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/spooler.py b/spooler.py index 5cb6930..7fa068f 100644 --- a/spooler.py +++ b/spooler.py @@ -13,11 +13,13 @@ 'waiting' : 'svgs/0_waiting', 'plotting' : 'svgs/0_waiting', 'canceled' : 'svgs/1_canceled', - 'finished' : 'svgs/2_finished' + 'finished' : 'svgs/2_finished', + 'error' : 'svgs/3_error' } PEN_POS_UP = 60 # Default: 60 PEN_POS_DOWN = 40 # Default: 40 MIN_SPEED = 10 # percent +SIMULATION_TIMEOUT = 8 # seconds # KEY_DONE = ( 'd', '(D)one' ) # KEY_REPEAT = ( 'r', '(R)epeat' ) @@ -130,14 +132,14 @@ def timestamp_str(date = None): def save_svg(job, overwrite_existing = False): - if job['status'] not in ['waiting', 'plotting', 'canceled', 'finished']: return False + if job['status'] not in STATUS_FOLDERS.keys(): return False - min = int(job["time_estimate"] / 60) - sec = math.ceil(job["time_estimate"] % 60) + min = int(job["time_estimate"] / 60) if "time_estimate" in job else 0 + sec = math.ceil(job["time_estimate"] % 60) if "time_estimate" in job else 0 position = f'{(job["position"] + 1):03}_' if 'position' in job and job['status'] in ['waiting', 'plotting'] else '' - ink = f'{(job["stats"]["travel_ink"] / 1000):.1f}' - travel = f'{(job["stats"]["travel"] / 1000):.1f}' + ink = f'{(job["stats"]["travel_ink"] / 1000):.1f}' if "stats" in job else 0 + travel = f'{(job["stats"]["travel"] / 1000):.1f}' if "stats" in job else 0 filename = f'{position}{job["received"]}_[{job["client"][0:10]}]_{job["hash"][0:5]}_{travel}m_{min}m{sec}s.svg' filename = os.path.join(STATUS_FOLDERS[job['status']], filename) @@ -203,7 +205,16 @@ async def enqueue(job, queue_position_cb = None, done_cb = None, cancel_cb = Non # add to jobs index _jobs[ job['client'] ] = job print(f'New job \\[{job["client"]}] {job["hash"][0:5]}') - sim = await simulate_async(job) # run simulation + try: + async with asyncio.timeout(SIMULATION_TIMEOUT): + sim = await simulate_async(job) # run simulation + except TimeoutError: + print(f'⚠️ [red]Timeout on simulating job \\[{job["client"]}] {job["hash"][0:5]}') + job['status'] = 'error' + save_svg(job) + await callback( error_cb, 'Cannot add job, it took to long to simulate!', job ) + return False + job['time_estimate'] = sim['time_estimate'] job['layers'] = sim['layers'] From c503ab34271988f29b401d4a5335ea01a0674f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sat, 16 Nov 2024 18:34:49 +0100 Subject: [PATCH 38/51] Move spooler.py constants to top --- main.py | 2 +- spooler.py | 40 ++++++++++++++++++++-------------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/main.py b/main.py index 61e0086..8e745d7 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ USE_ZEROCONF = 0 ZEROCONF_HOSTNAME = 'plotter' -USE_PORKBUN = 0 +USE_PORKBUN = 1 PORKBUN_ROOT_DOMAIN = 'process.tools' PORKBUN_SUBDOMAIN = 'plotter-local' PORKBUN_TTL = 600 diff --git a/spooler.py b/spooler.py index 7fa068f..3646a79 100644 --- a/spooler.py +++ b/spooler.py @@ -1,13 +1,13 @@ -import asyncio -from pyaxidraw import axidraw -from datetime import datetime, timezone -import math -import os -from capture_output import capture_output -import re -import hashlib -import async_queue -import xml.etree.ElementTree as ElementTree +TESTING = False # Don't actually connect to AxiDraw, just simulate plotting +REPEAT_JOBS = True # Ask to repeat a plot after a sucessful print +RESUME_QUEUE = True # Resume plotting queue after quitting/restarting +ALIGN_AFTER = True # Align plotter after success or error +ALIGN_AFTER_PAUSE = False # Align plotter after pause (programmatic, stop button, keyboard interrupt) + +PEN_POS_UP = 60 # Default: 60 +PEN_POS_DOWN = 40 # Default: 40 +MIN_SPEED = 10 # percent +SIMULATION_TIMEOUT = 8 # seconds STATUS_FOLDERS = { 'waiting' : 'svgs/0_waiting', @@ -16,10 +16,6 @@ 'finished' : 'svgs/2_finished', 'error' : 'svgs/3_error' } -PEN_POS_UP = 60 # Default: 60 -PEN_POS_DOWN = 40 # Default: 40 -MIN_SPEED = 10 # percent -SIMULATION_TIMEOUT = 8 # seconds # KEY_DONE = ( 'd', '(D)one' ) # KEY_REPEAT = ( 'r', '(R)epeat' ) @@ -39,12 +35,16 @@ 'plotting': 'Plotting' } -TESTING = True # Don't actually connect to AxiDraw, just simulate plotting -REPEAT_JOBS = True # Ask to repeat a plot after a sucessful print -RESUME_QUEUE = True # Resume plotting queue after quitting/restarting -ALIGN_AFTER = True # Align plotter after success or error -ALIGN_AFTER_PAUSE = False # Align plotter after pause (programmatic, stop button, keyboard interrupt) - +import asyncio +from pyaxidraw import axidraw +from datetime import datetime, timezone +import math +import os +from capture_output import capture_output +import re +import hashlib +import async_queue +import xml.etree.ElementTree as ElementTree queue_size_cb = None From 07f3486b8ad936e7a863c8dcc71bf1b4d46fc624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 17 Nov 2024 20:02:55 +0100 Subject: [PATCH 39/51] Tentatively implement pausing print jobs using SIGINT --- main.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 8e745d7..d37f63e 100644 --- a/main.py +++ b/main.py @@ -40,6 +40,7 @@ import subprocess import porkbun import functools +import signal app = None @@ -354,15 +355,19 @@ def adjust_job_speed(self, job, delta): @on(Button.Pressed, '#commands Button') def on_button(self, event): id = event.button.id - if (id == 'preview'): + if id == 'preview': self.preview_job( spooler.current_job() ) return - if (id == 'plus'): + if id == 'plus': self.adjust_job_speed( spooler.current_job(), 10 ) return - if (id == 'minus'): + if id == 'minus': self.adjust_job_speed( spooler.current_job(), -10 ) return + if id == 'neg' and spooler.status()['status'] == 'plotting': + print('[yellow]Interrupting...') + signal.raise_signal(signal.SIGINT) + return if self.prompt_future != None and not self.prompt_future.done(): if id == None and event.button.hotkey_description: @@ -500,7 +505,7 @@ def prompt_ui(self, variant, message = ''): b_neg.update_hotkey('escape', 'Pause') b_neg.variant = 'warning' - b_neg.disabled = True + b_neg.disabled = False b_align.disabled = True b_cycle.disabled = True @@ -569,4 +574,8 @@ def prompt_ui(self, variant, message = ''): print = app.print app.tprint = tprint + def int_handler(*args): print("[yellow]SIGINT caught") + # signal.signal(signal.SIGINT, signal.SIG_IGN) + signal.signal(signal.SIGINT, int_handler) + app.run() \ No newline at end of file From f7188cfde1462c1b84c0a60bfeb75ea00ebeff8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 17 Nov 2024 21:00:29 +0100 Subject: [PATCH 40/51] Allow finished jobs to be moved --- main.py | 4 ++-- spooler.py | 21 ++++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index d37f63e..d2bbafe 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,7 @@ SHOW_CONNECTION_EVENTS = 0 # Print when clients connect/disconnect MAX_MESSAGE_SIZE_MB = 5 # in MB (Default in websockets lib is 2) -QUEUE_HEADERS = ['#', 'Client', 'Hash', 'Lines', 'Layers', 'Travel', 'Ink', 'Format', 'Speed', 'Duration'] +QUEUE_HEADERS = ['#', 'Client', 'Hash', 'Lines', 'Layers', 'Travel', 'Ink', 'Format', 'Speed', 'Duration', 'Status'] import textual from textual import on @@ -419,7 +419,7 @@ def on_queue_click(self, event): queue.show_cursor = True def job_to_row(self, job, idx): - return (idx, job['client'], job['hash'][:5], job['stats']['count'], job['stats']['layer_count'], int(job['stats']['travel'])/1000, int(job['stats']['travel_ink'])/1000, job['format'], job['speed'], f'{math.floor(job["time_estimate"]/60)}:{round(job["time_estimate"]%60):02}') + return (idx, job['client'], job['hash'][:5], job['stats']['count'], job['stats']['layer_count'], int(job['stats']['travel'])/1000, int(job['stats']['travel_ink'])/1000, job['format'], job['speed'], f'{math.floor(job["time_estimate"]/60)}:{round(job["time_estimate"]%60):02}', job['status']) def update_current_job(self): job = spooler.current_job() diff --git a/spooler.py b/spooler.py index 3646a79..65a9bb7 100644 --- a/spooler.py +++ b/spooler.py @@ -300,6 +300,8 @@ def update_positions_and_save(): # 0 .. current job # 1 .. first in queue (idx 0) # last .. num_jobs()-1 + +# plot['status']: 'waiting'|'plotting'|'paused'|'ok'|'error'|'finished'|'canceled' async def move(client, new_pos): global _current_job # print('move', client, new_pos) @@ -307,7 +309,7 @@ async def move(client, new_pos): current_pos = job_pos(job) # print('move: current pos', current_pos) # cannot move if job is already plotting - if job['status'] == 'plotting': + if job['status'] in ['plotting', 'paused']: # print('move: already plotting, can\'t move') return @@ -317,8 +319,8 @@ async def move(client, new_pos): # clamp to lower bound if new_pos < 0: new_pos = 0 - # can't take place of the plotting job - if new_pos == 0 and _status == 'plotting': new_pos = 1 + # can't take place of the plotting (or paused job) + if new_pos == 0 and _status in ['plotting', 'paused']: new_pos = 1 # print(f'move from {current_pos} to {new_pos}') @@ -378,6 +380,8 @@ def job_str(job): 104: 'Lost USB connectivity' } PLOTTER_PAUSED = [ 1, 102, 103 ]; +PLOTTER_OK = [ 0 ]; +PLOTTER_ERROR = [ 101, 104 ]; def get_error_msg(code): if code in PLOTTER_ERRORS: @@ -438,6 +442,11 @@ def plot(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, if (ad.errors.code in PLOTTER_PAUSED and align_after_pause) or \ (ad.errors.code not in PLOTTER_PAUSED and align_after): align() + + if ad.errors.code in PLOTTER_PAUSED: job['status'] = 'paused' + elif ad.errors.code in PLOTTER_OK: job['status'] = 'ok' + elif ad.errors.code in PLOTTER_ERROR: job['status'] = 'error' + if return_ad: return ad else: return ad.errors.code @@ -734,7 +743,7 @@ async def start(app): if error == 0: if REPEAT_JOBS: print(f'[blue]Done ({loop}x) job \\[{_current_job["client"]}]') - set_status('plotting') + set_status('confirm_plot') layer = 0 repeat = await prompt_repeat_plot(f'[yellow]Repeat ({loop+1}) job[/yellow] \\[{_current_job["client"]}] ?') if repeat: continue @@ -753,7 +762,9 @@ async def start(app): if _current_job['layers'] > 1: prompt += f" layer ({layer+1}/{_current_job['layers']})" ready = await prompt_resume_plot(f'{prompt} \\[{_current_job["client"]}] ?', _current_job) if ready: resume = True - else: resume = 'skip_to_repeat' # Skip to asking to repeat job + else: + resume = 'skip_to_repeat' # Skip to asking to repeat job + _current_job['status'] = 'ok' # set status to 'successfully printed' # Errors else: print(f'[red]Plotter: {get_error_msg(error)}') From 1df192e31bd320072fd6a1f8a41616beffcd22b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 17 Nov 2024 21:14:03 +0100 Subject: [PATCH 41/51] Disable pausing (Signal only works in main thread) --- main.py | 2 +- spooler.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index d2bbafe..d6089ff 100644 --- a/main.py +++ b/main.py @@ -505,7 +505,7 @@ def prompt_ui(self, variant, message = ''): b_neg.update_hotkey('escape', 'Pause') b_neg.variant = 'warning' - b_neg.disabled = False + b_neg.disabled = True b_align.disabled = True b_cycle.disabled = True diff --git a/spooler.py b/spooler.py index 65a9bb7..059e4de 100644 --- a/spooler.py +++ b/spooler.py @@ -425,6 +425,7 @@ def plot(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, speed = job['speed'] / 100 with capture_output(print_axidraw, print_axidraw): ad = axidraw.AxiDraw() + # ad.keyboard_pause = True # -> causes error: ValueError: signal only works in main thread of the main interpreter ad.plot_setup(job['svg']) ad.options.model = 2 # A3 ad.options.reordering = 4 # No reordering From 7b74a32ce50f0fa57eb12477b4684dca0103ac10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Sun, 17 Nov 2024 22:34:52 +0100 Subject: [PATCH 42/51] Implemeted interrupt (without signals) --- main.py | 15 +++++---------- spooler.py | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/main.py b/main.py index d6089ff..7eae689 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,12 @@ -USE_ZEROCONF = 0 -ZEROCONF_HOSTNAME = 'plotter' - USE_PORKBUN = 1 PORKBUN_ROOT_DOMAIN = 'process.tools' PORKBUN_SUBDOMAIN = 'plotter-local' PORKBUN_TTL = 600 PORKBUN_SSL_OUTFILE = 'cert/process.tools.pem' +USE_ZEROCONF = 0 +ZEROCONF_HOSTNAME = 'plotter' + BIND_IP = '0.0.0.0' PORT = 0 # Use 0 for default ports (80 for http, 443 for ssl/tls) USE_SSL = 1 @@ -40,7 +40,6 @@ import subprocess import porkbun import functools -import signal app = None @@ -366,7 +365,7 @@ def on_button(self, event): return if id == 'neg' and spooler.status()['status'] == 'plotting': print('[yellow]Interrupting...') - signal.raise_signal(signal.SIGINT) + spooler.request_plot_pause() return if self.prompt_future != None and not self.prompt_future.done(): @@ -505,7 +504,7 @@ def prompt_ui(self, variant, message = ''): b_neg.update_hotkey('escape', 'Pause') b_neg.variant = 'warning' - b_neg.disabled = True + b_neg.disabled = False b_align.disabled = True b_cycle.disabled = True @@ -574,8 +573,4 @@ def prompt_ui(self, variant, message = ''): print = app.print app.tprint = tprint - def int_handler(*args): print("[yellow]SIGINT caught") - # signal.signal(signal.SIGINT, signal.SIG_IGN) - signal.signal(signal.SIGINT, int_handler) - app.run() \ No newline at end of file diff --git a/spooler.py b/spooler.py index 059e4de..3fc3e80 100644 --- a/spooler.py +++ b/spooler.py @@ -419,13 +419,18 @@ def cycle(): ad.plot_run() return ad.errors.code +def request_plot_pause(): + global _current_ad + if _current_ad != None: + _current_ad.transmit_pause_request() + _current_ad = None + def plot(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, options_cb = None, return_ad = False): if 'svg' not in job: return 0 job['status'] = 'plotting' speed = job['speed'] / 100 with capture_output(print_axidraw, print_axidraw): ad = axidraw.AxiDraw() - # ad.keyboard_pause = True # -> causes error: ValueError: signal only works in main thread of the main interpreter ad.plot_setup(job['svg']) ad.options.model = 2 # A3 ad.options.reordering = 4 # No reordering @@ -439,7 +444,10 @@ def plot(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, ad.options.pen_pos_down = PEN_POS_DOWN if callable(options_cb): options_cb(ad.options) if TESTING: ad.options.preview = True + global _current_ad + _current_ad = ad # for request_plot_pause() job['output_svg'] = ad.plot_run(output=True) + _current_ad = None if (ad.errors.code in PLOTTER_PAUSED and align_after_pause) or \ (ad.errors.code not in PLOTTER_PAUSED and align_after): align() @@ -726,17 +734,16 @@ async def start(app): interrupt = 0 # number of stops by button press (error 102) or keyboard interrupt (103) resume = False # flag indicating resume (vs. plotting from start) while True: - await prompt_plotting() # this returns immediately + set_status('plotting') + await prompt_plotting(f'\\[{_current_job["client"]}]') # this returns immediately if (resume == 'skip_to_repeat'): error = 0 elif resume: print(f'🖨️ [yellow]Resuming job \\[{_current_job["client"]}] ...') - set_status('plotting') error = await resume_plot_async(_current_job) else: loop += 1 print(f'🖨️ [yellow]Plotting job \\[{_current_job["client"]}] ...') - set_status('plotting') await _notify_queue_positions() # notify plotting error = await plot_async(_current_job) resume = False @@ -746,6 +753,7 @@ async def start(app): print(f'[blue]Done ({loop}x) job \\[{_current_job["client"]}]') set_status('confirm_plot') layer = 0 + interrupt = 0 repeat = await prompt_repeat_plot(f'[yellow]Repeat ({loop+1}) job[/yellow] \\[{_current_job["client"]}] ?') if repeat: continue await finish_current_job() From 64c99544d8a62705600c46f1378d932f3733d29e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Mon, 18 Nov 2024 13:04:04 +0100 Subject: [PATCH 43/51] Add ui requirements --- main.py | 1 + requirements.txt | 2 ++ 2 files changed, 3 insertions(+) diff --git a/main.py b/main.py index 7eae689..b60e287 100644 --- a/main.py +++ b/main.py @@ -201,6 +201,7 @@ def compose(self): def on_mount(self): self.title = "Plotter" + # self.theme = "textual-dark" header.tall = True col_left.styles.width = '3fr' col_right.styles.width = '2fr' diff --git a/requirements.txt b/requirements.txt index 2a1fbff..7eff3a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ websockets==10.3 zeroconf==0.39.1 +textual==0.86.1 +textual-dev==1.6.1 # pyaxidraw module # From a459071bd4de942c10c19944f7d159e2f7d2094f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Mon, 18 Nov 2024 16:13:15 +0100 Subject: [PATCH 44/51] Allow multiple pause requests --- spooler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/spooler.py b/spooler.py index 3fc3e80..b7ef43c 100644 --- a/spooler.py +++ b/spooler.py @@ -423,7 +423,6 @@ def request_plot_pause(): global _current_ad if _current_ad != None: _current_ad.transmit_pause_request() - _current_ad = None def plot(job, align_after = ALIGN_AFTER, align_after_pause = ALIGN_AFTER_PAUSE, options_cb = None, return_ad = False): if 'svg' not in job: return 0 From 4309efcbb0f1fe449485cbc92c5494d8b2a316d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Thu, 24 Jul 2025 15:49:26 +0200 Subject: [PATCH 45/51] =?UTF-8?q?Fixed=20crash=20when=20status=20folder=20?= =?UTF-8?q?=E2=80=98waiting=E2=80=99=20doesn=E2=80=99t=20exist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- spooler.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/spooler.py b/spooler.py index b7ef43c..66ab4ef 100644 --- a/spooler.py +++ b/spooler.py @@ -663,8 +663,12 @@ def attr(attr, ns = NS): async def resume_queue_from_disk(): import xml.etree.ElementTree as ElementTree - list = sorted(os.listdir(STATUS_FOLDERS['waiting'])) - list = [ os.path.join(STATUS_FOLDERS['waiting'], x) for x in list if x.endswith('.svg') ] + try: + list = sorted(os.listdir(STATUS_FOLDERS['waiting'])) + list = [ os.path.join(STATUS_FOLDERS['waiting'], x) for x in list if x.endswith('.svg') ] + except FileNotFoundError: + list = [] + resumable_jobs = [] for filename in list: # print('Loading ', filename) From 778fb154aeb104b5c32d84d6e7c3aaad607cc710 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Fri, 25 Jul 2025 15:07:02 +0200 Subject: [PATCH 46/51] Automatically activate virtual env (venv/bin/activate) --- start | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/start b/start index cfdafac..4fc8a70 100755 --- a/start +++ b/start @@ -74,6 +74,12 @@ if $start_ngrok; then ngrok_pid=$! fi +# Try to activate venv +if [ -f ./venv/bin/activate ]; then + echo "Activating virtual env..." + source ./venv/bin/activate +fi + # run plotter-server if $dev; then textual run --dev main.py From 7023f73815807f63b3d8a5bf0dd20bd30d813ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Fri, 25 Jul 2025 15:14:43 +0200 Subject: [PATCH 47/51] Warn about missing Porkbun config file --- porkbun.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/porkbun.py b/porkbun.py index fa979b2..7f839dd 100644 --- a/porkbun.py +++ b/porkbun.py @@ -15,6 +15,9 @@ def get_lanip(): return ipaddrlist[-1] def get_config(): + if not os.path.exists(CONFIG_FILE): + print(f"Error: Porkbun (DNS Service) config file is missing: {CONFIG_FILE}") + exit() with open(CONFIG_FILE) as f: api_config = json.load(f) return api_config From ce030fcc6b57c0ba8471407b2eed529bfa94b264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Fri, 25 Jul 2025 15:27:10 +0200 Subject: [PATCH 48/51] Add warning for missing frp.aut --- frpc.sh | 12 +++++++++--- start | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/frpc.sh b/frpc.sh index 25542f7..896e600 100755 --- a/frpc.sh +++ b/frpc.sh @@ -12,14 +12,20 @@ frpc=$(which "frpc") if [[ -z $frpc ]]; then frpc=$(which "../frp-mac/latest/frpcx") if [[ -z $frpc ]]; then - >&2 echo "frpc not found; try installing with 'brew install frpc'" + >&2 echo "Error: frpc not found; Try installing with 'brew install frpc'" exit 1 fi fi ->&2 echo "Using frpc: $frpc" ->&2 echo "Plotter URL (via frp): wss://plotter.process.tools" +if [[ ! -f ./frp.authx ]]; then + >&2 echo "Error: Missing frp auth file: frp.auth" + exit 1 +fi + source frp.auth # read auth token from file export FRP_AUTH_TOKEN +>&2 echo "Using frpc: $frpc" +>&2 echo "Plotter URL (via frp): wss://plotter.process.tools" + $frpc -c frpc.toml diff --git a/start b/start index 4fc8a70..871caeb 100755 --- a/start +++ b/start @@ -75,7 +75,7 @@ if $start_ngrok; then fi # Try to activate venv -if [ -f ./venv/bin/activate ]; then +if [[ -f ./venv/bin/activate ]]; then echo "Activating virtual env..." source ./venv/bin/activate fi From 3f4c3bf21dfa47424b7d6f747a90d071f63c8a40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Fri, 25 Jul 2025 16:22:09 +0200 Subject: [PATCH 49/51] =?UTF-8?q?Don=E2=80=99t=20swallow=20server=20task?= =?UTF-8?q?=20exceptions=20Reprint=20after=20app=20exit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index b60e287..ed54385 100644 --- a/main.py +++ b/main.py @@ -274,14 +274,16 @@ def on_mount(self): server_task = asyncio.create_task(run_server(self)) def on_server_task_exit(task): - tprint('server task exit') + print('[red]Server task exit') if not task.cancelled(): # not a intentional exit ex = task.exception() if ex != None: import traceback - tprint('Server task exception:') - tprint(''.join(traceback.format_exception(ex))) - self.exit() + print('Server task exited with exception:') + print(''.join(traceback.format_exception(ex))) + global server_task_exception + server_task_exception = ex + self.exit() # This line can be removed, exception will then be show inside app log area server_task.add_done_callback(on_server_task_exit) @@ -561,6 +563,8 @@ def prompt_ui(self, variant, message = ''): if __name__ == "__main__": global print global tprint + global server_task_exception + tprint = print if USE_PORKBUN: @@ -574,4 +578,10 @@ def prompt_ui(self, variant, message = ''): print = app.print app.tprint = tprint - app.run() \ No newline at end of file + app.run() + + print = tprint # restore print function + if server_task_exception != None: + print() + print("Server task exited with exception:") + raise server_task_exception \ No newline at end of file From 8146e1b83578d5db438355121c704999dc7207c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Thu, 4 Sep 2025 18:05:54 +0200 Subject: [PATCH 50/51] Fixed error in frpc.sh --- frpc.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frpc.sh b/frpc.sh index 896e600..e9a0627 100755 --- a/frpc.sh +++ b/frpc.sh @@ -10,14 +10,14 @@ trap quit EXIT # find frpc frpc=$(which "frpc") if [[ -z $frpc ]]; then - frpc=$(which "../frp-mac/latest/frpcx") + frpc=$(which "../frp-mac/latest/frpc") if [[ -z $frpc ]]; then >&2 echo "Error: frpc not found; Try installing with 'brew install frpc'" exit 1 fi fi -if [[ ! -f ./frp.authx ]]; then +if [[ ! -f ./frp.auth ]]; then >&2 echo "Error: Missing frp auth file: frp.auth" exit 1 fi From d1186594ab5dea7df79d5dd30945c472ecc66926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Gr=C3=B6dl?= Date: Thu, 29 Jan 2026 16:24:52 +0100 Subject: [PATCH 51/51] Fix `name 'server_task_exception' is not defined` --- main.py | 1 + 1 file changed, 1 insertion(+) diff --git a/main.py b/main.py index ed54385..f0a9e76 100644 --- a/main.py +++ b/main.py @@ -566,6 +566,7 @@ def prompt_ui(self, variant, message = ''): global server_task_exception tprint = print + server_task_exception = None if USE_PORKBUN: porkbun.ddns_update(PORKBUN_ROOT_DOMAIN, PORKBUN_SUBDOMAIN, PORKBUN_TTL)