################################################################################ # # # This file is part of the Buildbotics firmware. # # # # Copyright (c) 2015 - 2018, Buildbotics LLC # # All rights reserved. # # # # This file ("the software") is free software: you can redistribute it # # and/or modify it under the terms of the GNU General Public License, # # version 2 as published by the Free Software Foundation. You should # # have received a copy of the GNU General Public License, version 2 # # along with the software. If not, see . # # # # The software is distributed in the hope that it will be useful, but # # WITHOUT ANY WARRANTY; without even the implied warranty of # # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # # Lesser General Public License for more details. # # # # You should have received a copy of the GNU Lesser General Public # # License along with the software. If not, see # # . # # # # For information regarding this software email: # # "Joseph Coffland" # # # ################################################################################ import os import time import json import hashlib import glob import tempfile import signal from concurrent.futures import Future from tornado import gen, process, iostream import bbctrl def hash_dump(o): s = json.dumps(o, separators = (',', ':'), sort_keys = True) return s.encode('utf8') def plan_hash(path, config): h = hashlib.sha256() h.update('v4'.encode('utf8')) h.update(hash_dump(config)) with open(path, 'rb') as f: while True: buf = f.read(1024 * 1024) if not buf: break h.update(buf) return h.hexdigest() def safe_remove(path): try: os.unlink(path) except: pass class Plan(object): def __init__(self, preplanner, ctrl, filename): self.preplanner = preplanner # Copy planner state self.state = ctrl.state.snapshot() self.config = ctrl.mach.planner.get_config(False, False) del self.config['default-units'] self.progress = 0 self.cancel = False self.pid = None root = ctrl.get_path() self.gcode = '%s/upload/%s' % (root, filename) self.base = '%s/plans/%s' % (root, filename) self.hid = plan_hash(self.gcode, self.config) fbase = '%s.%s.' % (self.base, self.hid) self.files = [ fbase + 'json', fbase + 'positions.gz', fbase + 'speeds.gz'] self.future = Future() ctrl.ioloop.add_callback(self._load) def terminate(self): if self.cancel: return self.cancel = True if self.pid is not None: try: os.kill(self.pid, signal.SIGKILL) except: pass def delete(self): files = glob.glob(self.base + '.*') for path in files: safe_remove(path) def clean(self, max = 2): plans = glob.glob(self.base + '.*.json') if len(plans) <= max: return # Delete oldest plans plans = [(os.path.getmtime(path), path) for path in plans] plans.sort() for mtime, path in plans[:len(plans) - max]: safe_remove(path) safe_remove(path[:-4] + 'positions.gz') safe_remove(path[:-4] + 'speeds.gz') def _exists(self): for path in self.files: if not os.path.exists(path): return False return True def _read(self): if self.cancel: return try: with open(self.files[0], 'r') as f: meta = json.load(f) with open(self.files[1], 'rb') as f: positions = f.read() with open(self.files[2], 'rb') as f: speeds = f.read() return meta, positions, speeds except: self.preplanner.log.exception('Internal error: Preplanner read') # Clean for path in self.files: if os.path.exists(path): os.remove(path) @gen.coroutine def _exec(self): self.clean() # Clean up old plans with tempfile.TemporaryDirectory() as tmpdir: cmd = ( '/usr/bin/env', 'python3', bbctrl.get_resource('plan.py'), os.path.abspath(self.gcode), json.dumps(self.state), json.dumps(self.config), '--max-time=%s' % self.preplanner.max_plan_time, '--max-loop=%s' % self.preplanner.max_loop_time ) self.preplanner.log.info('Running: %s', cmd) proc = process.Subprocess(cmd, stdout = process.Subprocess.STREAM, stderr = process.Subprocess.STREAM, cwd = tmpdir) errs = '' self.pid = proc.proc.pid try: try: while True: line = yield proc.stdout.read_until(b'\n') self.progress = float(line.strip()) if self.cancel: return except iostream.StreamClosedError: pass self.progress = 1 ret = yield proc.wait_for_exit(False) if ret: errs = yield proc.stderr.read_until_close() raise Exception('Plan failed: ' + errs.decode('utf8')) finally: proc.stderr.close() proc.stdout.close() if not self.cancel: os.rename(tmpdir + '/meta.json', self.files[0]) os.rename(tmpdir + '/positions.gz', self.files[1]) os.rename(tmpdir + '/speeds.gz', self.files[2]) os.sync() @gen.coroutine def _load(self): try: if self._exists(): data = self._read() if data is not None: self.future.set_result(data) return if not self._exists(): yield self._exec() self.future.set_result(self._read()) except: self.preplanner.log.exception("Failed to load file - doesn't appear to be GCode.") class Preplanner(object): def __init__(self, ctrl, max_plan_time = 60 * 60 * 24, max_loop_time = 300): self.ctrl = ctrl self.log = ctrl.log.get('Preplanner') self.max_plan_time = max_plan_time self.max_loop_time = max_loop_time path = self.ctrl.get_plan() if not os.path.exists(path): os.mkdir(path) self.started = Future() self.plans = {} def start(self): if not self.started.done(): self.log.info('Preplanner started') self.started.set_result(True) def invalidate(self, filename): if filename in self.plans: self.plans[filename].terminate() del self.plans[filename] def invalidate_all(self): for filename, plan in self.plans.items(): plan.terminate() self.plans = {} def delete_all_plans(self): files = glob.glob(self.ctrl.get_plan('*')) for path in files: safe_remove(path) self.invalidate_all() def delete_plans(self, filename): if filename in self.plans: self.plans[filename].delete() self.invalidate(filename) @gen.coroutine def get_plan(self, filename): if filename is None: raise Exception('Filename cannot be None') # Wait until state is fully initialized yield self.started if filename in self.plans: plan = self.plans[filename] else: plan = Plan(self, self.ctrl, filename) self.plans[filename] = plan data = yield plan.future return data def get_plan_progress(self, filename): return self.plans[filename].progress if filename in self.plans else 0