- Added ``Bug Report`` button to ``Admin`` -> ``General``.
- Only render 3D view as needed to save CPU.
- Prevent lockup due to browser causing out of memory condition.
+ - Show error message when too large GCode upload fails.
+ - Much faster 3D view loading.
## v0.4.1
- Fix toolpath view axes bug.
include scripts/avr109-flash.py
include scripts/buildbotics.gc
include scripts/xinitrc
+include scripts/rc.local
recursive-include src/py/camotics *
global-exclude .gitignore
-#!/usr/bin/env python3
-
-import os
-import sys
-import resource
-import subprocess
-
-# Limit memory usage
-limit = 1.5e9
-resource.setrlimit(resource.RLIMIT_DATA, (limit, limit))
+#!/bin/bash
# Clear browser errors
-prefs = '/home/pi/.config/chromium/Default/Preferences'
-subprocess.run(
- ('sed', '-i', 's/"exited_cleanly":false/"exited_cleanly":true/', prefs))
-subprocess.run(
- ('sed', '-i', 's/"exit_type":"Crashed"/"exit_type":"Normal"/', prefs))
+PREFS='/home/pi/.config/chromium/Default/Preferences'
+sed -i 's/"exited_cleanly":false/"exited_cleanly":true/' $PREFS
+sed -i 's/"exit_type":"Crashed"/"exit_type":"Normal"/' $PREFS
# Start browser
-cmd = '/usr/lib/chromium-browser/chromium-browser'
-args = (
- '--no-first-run',
- '--disable-infobars',
- '--noerrdialogs',
- '--single-process',
- 'http://localhost/'
-)
-os.execvp(cmd, args)
+/usr/lib/chromium-browser/chromium-browser --no-first-run \
+ --disable-infobars --noerrdialogs --disable-3d-apis --single-process \
+ http://localhost/ &
+
+# Enter cgroup
+echo $! >> /sys/fs/cgroup/memory/chrome/tasks
+
+wait
# ) >> /boot/config.txt
# mount -o remount,ro /boot
#fi
-#grep "plymouth quit" /etc/rc.local
-#if [ $? -ne 0 ]; then
-# sed -i 's/cd \/home\/pi/cd \/home\/pi; plymouth quit/' /etc/rc.local
-#fi
+#chmod ug+s /usr/lib/xorg/Xorg
# Fix camera
grep dwc_otg.fiq_fsm_mask /boot/cmdline.txt >/dev/null
REBOOT=true
fi
+# Enable memory cgroups
+grep cgroup_memory /boot/cmdline.txt >/dev/null
+if [ $? -ne 0 ]; then
+ mount -o remount,rw /boot &&
+ sed -i 's/\(.*\)/\1 cgroup_memory=1/' /boot/cmdline.txt
+ mount -o remount,ro /boot
+ REBOOT=true
+fi
+
# Remove Hawkeye
if [ -e /etc/init.d/hawkeye ]; then
apt-get remove --purge -y hawkeye
echo 'LABEL="automount_end"'
) > /etc/udev/rules.d/11-automount.rules
- grep "/etc/init.d/udev restart" /etc/rc.local >/dev/null
- if [ $? -ne 0 ]; then
- echo "/etc/init.d/udev restart" >> /etc/rc.local
- fi
-
sed -i 's/^\(MountFlags=slave\)/#\1/' /lib/systemd/system/systemd-udevd.service
REBOOT=true
fi
cp scripts/buildbotics.gc /var/lib/bbctrl/upload/
fi
+# Install rc.local
+cp scripts/rc.local /etc/
+
+# Install bbctrl
if $UPDATE_PY; then
rm -rf /usr/local/lib/python*/dist-packages/bbctrl-*
./setup.py install --force
--- /dev/null
+#!/bin/bash
+
+# Mount /boot read only
+mount -o remount,ro /boot
+
+# Set SPI GPIO mode
+gpio mode 27 alt3
+
+# Create browser memory limited cgroup
+if [ -d sys/fs/cgroup/memory ]; then
+ CGROUP=/sys/fs/cgroup/memory/chrome
+ mkdir $CGROUP
+ chown -R pi:pi $CGROUP
+ echo 700000000 > $CGROUP/memory.limit_in_bytes
+fi
+
+# Reload udev
+/etc/init.d/udev restart
+
+# Stop boot splash so it does not interfere with X if GPU enabled
+grep ^dtoverlay=vc4-kms-v3d /boot/config.txt >/dev/null
+if [ $? -eq 0 ]; then plymouth quit; fi
+
+# Start X in /home/pi
+cd /home/pi
+sudo -u pi startx
}).error(function (xhr, status, error) {
var text = xhr.responseText;
try {text = $.parseJSON(xhr.responseText)} catch(e) {}
+ if (!text) text = error;
+
d.reject(text, xhr, status, error);
- console.debug('API Error: ' + url + ': ' + xhr.responseText);
+ console.debug('API Error: ' + url + ': ' + text);
});
return d.promise();
if (this.last_file != file) return;
if (typeof toolpath.progress == 'undefined') {
+ toolpath.filename = file;
this.toolpath = toolpath;
var state = this.$root.state;
this.last_file = undefined; // Force reload
this.$broadcast('gcode-reload', file.name);
this.update();
+
+ }.bind(this)).fail(function (error) {
+ alert('Upload failed: ' + error)
}.bind(this));
},
this.clear();
this.file = file;
- api.get('file/' + file)
- .done(function (data) {
- if (this.file != file) return;
+ var xhr = new XMLHttpRequest();
+ xhr.open('GET', '/api/file/' + file + '?' + Math.random(), true);
+ xhr.responseType = 'text';
- var lines = data.trimRight().split(/\r?\n/);
+ xhr.onload = function (e) {
+ if (this.file != file) return;
+ var lines = xhr.response.trimRight().split(/\r?\n/);
- for (var i = 0; i < lines.length; i++) {
- lines[i] = '<li class="line-' + (i + 1) + '">' +
- '<span class="gcode-line">' + (i + 1) + '</span>' +
- lines[i] + '</li>';
- }
+ for (var i = 0; i < lines.length; i++) {
+ lines[i] = '<li class="ln' + (i + 1) + '">' +
+ '<b>' + (i + 1) + '</b>' + lines[i] + '</li>';
+ }
+
+ this.clusterize.update(lines);
+ this.empty = false;
- this.clusterize.update(lines);
- this.empty = false;
+ Vue.nextTick(this.update_line);
+ }.bind(this)
- Vue.nextTick(this.update_line);
- }.bind(this));
+ xhr.send();
},
var e = $(this.$el).find('.highlight');
if (e.length) e.removeClass('highlight');
- e = $(this.$el).find('.line-' + this.line);
+ e = $(this.$el).find('.ln' + this.line);
if (e.length) e.addClass('highlight');
},
}
}
}
+33554400
panOffset.set(0, 0, 0);
}
- scale = 1;
-
// update condition is:
// min(camera displacement, camera rotation in radians)^2 > EPS
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
- if (zoomChanged ||
+ if (zoomChanged || scale != 1 ||
lastPosition.distanceToSquared(scope.object.position) > EPS ||
8 * (1 - lastQuaternion.dot(scope.object.quaternion)) > EPS) {
lastPosition.copy(scope.object.position);
lastQuaternion.copy(scope.object.quaternion);
zoomChanged = false;
+ scale = 1;
return true;
}
var orbit = require('./orbit');
var cookie = require('./cookie')('bbctrl-');
+var api = require('./api');
function get(obj, name, defaultValue) {
computed: {
- hasPath: function () {return typeof this.toolpath.path != 'undefined'},
target: function () {return $(this.$el).find('.path-viewer-content')[0]}
},
ready: function () {
this.graphics();
- if (typeof this.toolpath.path != 'undefined') Vue.nextTick(this.update);
+ Vue.nextTick(this.update);
},
methods: {
update: function () {
- if (!this.enabled) return;
+ if (!this.toolpath.filename && !this.loading) {
+ this.loading = true;
+ this.scene = new THREE.Scene();
+ this.dirty = true;
+ }
+
+ if (!this.enabled || !this.toolpath.filename) return;
+
+ function get(url) {
+ var d = $.Deferred();
+ var xhr = new XMLHttpRequest();
+
+ xhr.open('GET', url + '?' + Math.random(), true);
+ xhr.responseType = 'arraybuffer';
+
+ xhr.onload = function (e) {
+ if (xhr.response) d.resolve(new Float32Array(xhr.response));
+ else d.reject();
+ };
+
+ xhr.send();
- // Reset message
- this.loading = !this.hasPath;
+ return d.promise();
+ }
+
+ var d1 = get('/api/path/' + this.toolpath.filename + '/positions');
+ var d2 = get('/api/path/' + this.toolpath.filename + '/speeds');
- // Update scene
- this.scene = new THREE.Scene();
- if (this.hasPath) {
+ $.when(d1, d2).done(function (positions, speeds) {
+ this.positions = positions
+ this.speeds = speeds;
+ this.loading = false;
+
+ // Update scene
+ this.scene = new THREE.Scene();
this.draw(this.scene);
this.snap(this.snapView);
- }
- this.update_view();
+ this.update_view();
+ }.bind(this))
},
keyLight.lookAt(scope.controls.target);
fillLight.lookAt(scope.controls.target);
backLight.lookAt(scope.controls.target);
+ scope.dirty = true;
}
}(this))
},
- get_color: function (rapid, speed) {
- if (rapid) return [1, 0, 0];
+ get_color: function (speed) {
+ if (isNaN(speed)) return [255, 0, 0]; // Rapid
var intensity = speed / this.toolpath.maxSpeed;
if (typeof speed == 'undefined' || !this.showIntensity) intensity = 1;
- return [0, intensity, 0.5 * (1 - intensity)];
+ return [0, 255 * intensity, 127 * (1 - intensity)];
},
draw_path: function (scene) {
- var s = undefined;
- var x = 0;
- var y = 0;
- var z = 0;
- var color = undefined;
-
- var positions = [];
- var colors = [];
-
- for (var i = 0; i < this.toolpath.path.length; i++) {
- var step = this.toolpath.path[i];
-
- s = get(step, 's', s);
- var newColor = this.get_color(step.rapid, s);
-
- // Handle color change
- if (i && newColor != color) {
- positions.push(x, y, z);
- colors.push.apply(colors, newColor);
- }
- color = newColor;
-
- // Draw to move target
- x = get(step, 'x', x);
- y = get(step, 'y', y);
- z = get(step, 'z', z);
-
- positions.push(x, y, z);
- colors.push.apply(colors, color);
- }
-
var geometry = new THREE.BufferGeometry();
var material =
new THREE.LineBasicMaterial({
linewidth: 1.5
});
- geometry.addAttribute('position',
- new THREE.Float32BufferAttribute(positions, 3));
- geometry.addAttribute('color',
- new THREE.Float32BufferAttribute(colors, 3));
+ var positions = new THREE.Float32BufferAttribute(this.positions, 3);
+ geometry.addAttribute('position', positions);
+
+ var colors = [];
+ for (var i = 0; i < this.speeds.length; i++) {
+ var color = this.get_color(this.speeds[i]);
+ Array.prototype.push.apply(colors, color);
+ }
+
+ colors = new THREE.Uint8BufferAttribute(colors, 3, true);
+ geometry.addAttribute('color', colors);
geometry.computeBoundingSphere();
geometry.computeBoundingBox();
if (!(this instanceof Sock)) return new Sock(url, retry);
if (typeof retry == 'undefined') retry = 2000;
- if (typeof timeout == 'undefined') timeout = 8000;
+ if (typeof timeout == 'undefined') timeout = 16000;
this.url = url;
this.retry = retry;
# Override exception logging
def log_exception(self, typ, value, tb):
- if isinstance(value, HTTPError) and value.status_code == 408:
+ if isinstance(value, HTTPError) and value.status_code in (404, 408):
return
log.error(str(value))
trace = ''.join(traceback.format_exception(typ, value, tb))
- log.error(trace)
log.debug(trace)
def release(self, id):
if id and not id_less(self.releaseID, id):
- log.warning('id out of order %d <= %d' % (id, self.releaseID))
+ log.debug('id out of order %d <= %d' % (id, self.releaseID))
self.releaseID = id
self._release()
if path:
path = path[1:]
+ self.set_header('Content-Type', 'text/plain')
+
with open('upload/' + path, 'r') as f:
- self.write_json(f.read())
+ self.write(f.read())
self.ctrl.mach.select(path)
return
def plan_hash(path, config):
path = 'upload/' + path
h = hashlib.sha256()
- h.update('v3'.encode('utf8'))
+ h.update('v4'.encode('utf8'))
h.update(hash_dump(config))
with open(path, 'rb') as f:
def invalidate(self, filename):
with self.lock:
if filename in self.plans:
- self.plans[filename][0].cancel()
+ self.plans[filename][2].set() # Cancel
del self.plans[filename]
def invalidate_all(self):
with self.lock:
for filename, plan in self.plans.items():
- plan[0].cancel()
+ plan[2].set() # Cancel
self.plans = {}
with self.lock:
if filename in self.plans: plan = self.plans[filename]
else:
- plan = [self._plan(filename), 0]
+ cancel = threading.Event()
+ plan = [self._plan(filename, cancel), 0, cancel]
self.plans[filename] = plan
return plan[0]
@gen.coroutine
- def _plan(self, filename):
+ def _plan(self, filename, cancel):
# Wait until state is fully initialized
yield self.started
del config['default-units']
# Start planner thread
- plan = yield self.pool.submit(self._exec_plan, filename, state, config)
+ plan = yield self.pool.submit(
+ self._exec_plan, filename, state, config, cancel)
return plan
def _progress(self, filename, progress):
with self.lock:
- if not filename in self.plans: return False
- self.plans[filename][1] = progress
- return True
+ if filename in self.plans:
+ self.plans[filename][1] = progress
- def _exec_plan(self, filename, state, config):
+ def _exec_plan(self, filename, state, config, cancel):
try:
os.nice(5)
hid = plan_hash(filename, config)
- plan_path = 'plans/' + filename + '.' + hid + '.gz'
+ base = 'plans/' + filename + '.' + hid
+ files = [
+ base + '.json', base + '.positions.gz', base + '.speeds.gz']
+
+ found = True
+ for path in files:
+ if not os.path.exists(path): found = False
- if not os.path.exists(plan_path):
+ if not found:
self._clean_plans(filename) # Clean up old plans
path = os.path.abspath('upload/' + filename)
cwd = tmpdir) as proc:
for line in proc.stdout:
- if not self._progress(filename, float(line)):
+ self._progress(filename, float(line))
+ if cancel.is_set():
proc.terminate()
- return # Cancelled
+ return
out, errs = proc.communicate()
- if not self._progress(filename, 1): return # Cancelled
+ self._progress(filename, 1)
+ if cancel.is_set(): return
if proc.returncode:
- log.error('Plan failed: ' + errs)
+ log.error('Plan failed: ' + errs.decode('utf8'))
return # Failed
- os.rename(tmpdir + '/plan.json.gz', plan_path)
+ os.rename(tmpdir + '/meta.json', files[0])
+ os.rename(tmpdir + '/positions.gz', files[1])
+ os.rename(tmpdir + '/speeds.gz', files[2])
+
+ with open(files[0], 'r') as f: meta = json.load(f)
+ with open(files[1], 'rb') as f: positions = f.read()
+ with open(files[2], 'rb') as f: speeds = f.read()
- with open(plan_path, 'rb') as f:
- return f.read()
+ return meta, positions, speeds
- except Exception as e: log.error(e)
+ except Exception as e: log.exception(e)
class PathHandler(bbctrl.APIHandler):
@gen.coroutine
- def get(self, filename):
+ def get(self, filename, dataType, *args):
if not os.path.exists('upload/' + filename):
raise HTTPError(404, 'File not found')
return
try:
- if data is not None:
- self.set_header('Content-Encoding', 'gzip')
+ if data is None: return
+ meta, positions, speeds = data
- # Respond with chunks to avoid long delays
- SIZE = 102400
- chunks = [data[i:i + SIZE] for i in range(0, len(data), SIZE)]
- for chunk in chunks:
- self.write(chunk)
- yield self.flush()
+ if dataType == '/positions': data = positions
+ elif dataType == '/speeds': data = speeds
+ else:
+ self.write_json(meta)
+ return
+
+ filename = filename + '-' + dataType[1:] + '.gz'
+ self.set_header('Content-Disposition', 'filename="%s"' % filename)
+ self.set_header('Content-Type', 'application/octet-stream')
+ self.set_header('Content-Encoding', 'gzip')
+ self.set_header('Content-Length', str(len(data)))
+
+ # Respond with chunks to avoid long delays
+ SIZE = 102400
+ chunks = [data[i:i + SIZE] for i in range(0, len(data), SIZE)]
+ for chunk in chunks:
+ self.write(chunk)
+ yield self.flush()
except tornado.iostream.StreamClosedError as e: pass
(r'/api/firmware/update', FirmwareUpdateHandler),
(r'/api/upgrade', UpgradeHandler),
(r'/api/file(/[^/]+)?', bbctrl.FileHandler),
- (r'/api/path/([^/]+)', PathHandler),
+ (r'/api/path/([^/]+)((/positions)|(/speeds))?', PathHandler),
(r'/api/home(/[xyzabcXYZABC]((/set)|(/clear))?)?', HomeHandler),
(r'/api/start', StartHandler),
(r'/api/estop', EStopHandler),
import os
import re
import gzip
+import struct
+import math
import camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error
r'(?P<msg>.*)$')
-
-# Formats floats with no more than two decimal places
-def _dump_json(o):
- if isinstance(o, str): yield json.dumps(o)
- elif o is None: yield 'null'
- elif o is True: yield 'true'
- elif o is False: yield 'false'
- elif isinstance(o, int): yield str(o)
-
- elif isinstance(o, float):
- if o != o: yield '"NaN"'
- elif o == float('inf'): yield '"Infinity"'
- elif o == float('-inf'): yield '"-Infinity"'
- else: yield format(o, '.2f')
-
- elif isinstance(o, (list, tuple)):
- yield '['
- first = True
-
- for item in o:
- if first: first = False
- else: yield ','
- yield from _dump_json(item)
-
- yield ']'
-
- elif isinstance(o, dict):
- yield '{'
- first = True
-
- for key, value in o.items():
- if first: first = False
- else: yield ','
- yield from _dump_json(key)
- yield ':'
- yield from _dump_json(value)
-
- yield '}'
-
-
-def dump_json(o): return ''.join(_dump_json(o))
-
-
def compute_unit(a, b):
unit = dict()
length = 0
- for axis in 'xyzabc':
+ for axis in 'xyz':
if axis in a and axis in b:
unit[axis] = b[axis] - a[axis]
length += unit[axis] * unit[axis]
length = math.sqrt(length)
- for axis in 'xyzabc':
+ for axis in 'xyz':
if axis in unit: unit[axis] /= length
return unit
def compute_move(start, unit, dist):
move = dict()
- for axis in 'xyzabc':
+ for axis in 'xyz':
if axis in unit and axis in start:
move[axis] = start[axis] + unit[axis] * dist
# Initialized axis states and bounds
self.bounds = dict(min = {}, max = {})
- for axis in 'xyzabc':
+ for axis in 'xyz':
self.bounds['min'][axis] = math.inf
self.bounds['max'][axis] = -math.inf
def get_bounds(self):
# Remove infinity from bounds
- for axis in 'xyzabc':
+ for axis in 'xyz':
if self.bounds['min'][axis] == math.inf:
del self.bounds['min'][axis]
if self.bounds['max'][axis] == -math.inf:
line = 0
maxLine = 0
maxLineTime = time.time()
- position = {axis: 0 for axis in 'xyzabc'}
+ position = {axis: 0 for axis in 'xyz'}
rapid = False
# Execute plan
move = {}
startPos = dict()
- for axis in 'xyzabc':
+ for axis in 'xyz':
if axis in target:
startPos[axis] = position[axis]
position[axis] = target[axis]
def run(self):
- with gzip.open('plan.json.gz', 'wb') as f:
- def write(s): f.write(s.encode('utf8'))
-
- write('{"path":[')
-
- first = True
- for move in self._run():
- if first: first = False
- else: write(',')
- write(dump_json(move))
-
- write('],')
- write('"time":%.2f,' % self.time)
- write('"lines":%s,' % self.lines)
- write('"maxSpeed":%s,' % self.maxSpeed)
- write('"bounds":%s,' % dump_json(self.get_bounds()))
- write('"messages":%s}' % dump_json(self.messages))
+ lastS = 0
+ speed = 0
+ first = True
+ x, y, z = 0, 0, 0
+
+ with gzip.open('positions.gz', 'wb') as f1:
+ with gzip.open('speeds.gz', 'wb') as f2:
+ for move in self._run():
+ x = move.get('x', x)
+ y = move.get('y', y)
+ z = move.get('z', z)
+ rapid = move.get('rapid', False)
+ speed = move.get('s', speed)
+ s = struct.pack('<f', math.nan if rapid else speed)
+
+ if not first and s != lastS:
+ f1.write(p)
+ f2.write(s)
+
+ lastS = s
+ first = False
+ p = struct.pack('<fff', x, y, z)
+
+ f1.write(p)
+ f2.write(s)
+
+ with open('meta.json', 'w') as f:
+ meta = dict(
+ time = self.time,
+ lines = self.lines,
+ maxSpeed = self.maxSpeed,
+ bounds = self.get_bounds(),
+ messages = self.messages)
+
+ json.dump(meta, f)
parser = argparse.ArgumentParser(description = 'Buildbotics GCode Planner')
list-style none
.gcode ul
+ li
+ line-height 15px
+
li:nth-child(even)
background-color #fafafa
li.highlight
background-color #eaeaea
- span.gcode-line
+ li > b
+ font-weight normal
display inline-block
padding 0 0.25em
color #e5aa3d