238 lines
6.7 KiB
Python
238 lines
6.7 KiB
Python
from concurrent.futures import Future
|
|
from tornado import gen, process, iostream
|
|
import bbctrl
|
|
import glob
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import signal
|
|
import tempfile
|
|
|
|
|
|
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
|