Buildbotics CNC Controller Firmware Change Log
==============================================
+## v0.3.5
+ - Fixed dwell (G4)
+ - Always show limit switch indicators regardless of motor enable
+ - Fixed feed rate display
+ - Added current GCode unit display
+ - Fixed homed axis zeroing
+ - Fixed probe pin input
+ - Added reload button to video tab
+ - Don't open error dialog on repeat messages
+ - Handle large GCode files in browser
+ - Added max lookahead limit to planner
+ - Fixed GCode stopping/pausing where ramp down needs more than is in the queue
+ - Added breakout box diagram to indicators
+ - Initialize axes offsets to zero on startup
+ - Fixed conflict between ``x`` state variable and ``x`` axis variable
+ - Don't show ipv6 addresses on LCD. They don't fit.
+
## v0.3.4
- Added alternate units for motor parameters
- Automatic config file upgrading
{
"name": "bbctrl",
- "version": "0.3.4",
+ "version": "0.3.5",
"homepage": "http://buildbotics.com/",
"repository": "https://github.com/buildbotics/bbctrl-firmware",
"license": "GPL-3.0+",
bool command_exec() {
if (!cmd.count) {
cmd.last_empty = rtc_get_time();
+ exec_set_velocity(0);
state_idle();
return false;
}
float jerk;
int tool;
- int line;
float feed_override;
float spindle_override;
void exec_set_acceleration(float a) {ex.accel = a;}
float exec_get_acceleration() {return ex.accel;}
void exec_set_jerk(float j) {ex.jerk = j;}
-void exec_set_line(int32_t line) {ex.line = line;}
-int32_t exec_get_line() {return ex.line;}
void exec_set_cb(exec_cb_t cb) {ex.cb = cb;}
// Variable callbacks
-int32_t get_line() {return ex.line;}
uint8_t get_tool() {return ex.tool;}
float get_velocity() {return ex.velocity / VELOCITY_MULTIPLIER;}
float get_acceleration() {return ex.accel / ACCEL_MULTIPLIER;}
float get_speed_override() {return ex.spindle_override;}
float get_axis_position(int axis) {return ex.position[axis];}
-void set_line(int32_t line) {ex.line = line;}
void set_tool(uint8_t tool) {ex.tool = tool;}
void set_feed_override(float value) {ex.feed_override = value;}
void set_speed_override(float value) {ex.spindle_override = value;}
void exec_set_acceleration(float a);
float exec_get_acceleration();
void exec_set_jerk(float j);
-void exec_set_line(int32_t line);
-int32_t exec_get_line();
void exec_set_cb(exec_cb_t cb);
bool writing;
bool done;
- float Vi;
- float Vt;
-
jog_axis_t axes[AXES];
} jog_runtime_t;
static void _set_state(state_t state) {
if (s.state == state) return; // No change
if (s.state == STATE_ESTOPPED) return; // Can't leave EStop state
- if (state == STATE_READY) exec_set_line(0);
s.state = state;
report_request();
}
case STAT_NOP: st.busy = false; break; // No command executed
case STAT_AGAIN: continue; // No command executed, try again
- case STAT_OK: // Move executed
+ case STAT_OK: // Move executed
if (!st.move_queued) ALARM(STAT_EXPECTED_MOVE); // No move was queued
st.move_queued = false;
st.move_ready = true;
// Machine state
VAR(id, id, u32, 0, 1, 1, "Last executed command ID")
-VAR(line, ln, s32, 0, 1, 1, "Last line executed")
VAR(speed, s, f32, 0, 1, 1, "Current spindle speed")
VAR(tool, t, u8, 0, 1, 1, "Current tool")
VAR(feed_override, fo, f32, 0, 1, 1, "Feed rate override")
th Repeat
th Message
- tr(v-for="msg in messages.reverse()", :class="msg.level || 'info'")
+ tr(v-for="msg in messages", :class="msg.level || 'info'")
td {{msg.level || 'info'}}
td {{msg.source || ''}}
td {{msg.where || ''}}
td.absolute {{state.#{axis}p || 0 | fixed 3}}
td.offset {{get_offset('#{axis}') | fixed 3}}
th.actions
- button.pure-button(:disabled="state.x != 'READY'",
+ button.pure-button(:disabled="state.xx != 'READY'",
title="Set {{'#{axis}' | upper}} axis position.",
@click="show_set_position('#{axis}')")
.fa.fa-cog
- button.pure-button(:disabled="state.x != 'READY'",
+ button.pure-button(:disabled="state.xx != 'READY'",
title="Zero {{'#{axis}' | upper}} axis offset.",
@click="zero_axis('#{axis}')") ∅
- button.pure-button(:disabled="state.x != 'READY'",
+ button.pure-button(:disabled="state.xx != 'READY'",
title="Home {{'#{axis}' | upper}} axis.",
@click="home('#{axis}')")
.fa.fa-home
th Message
td.reason(:class="{attention: highlight_reason()}") {{get_reason()}}
td
+ tr
+ th Units
+ td {{state.imperial ? 'IMPERIAL' : 'METRIC'}}
+ td
tr
th Feed
- td {{state.f || 0 | fixed 0}}
+ td {{state.feed || 0 | fixed 0}}
td mm/min
tr
th Speed
- td {{state.s || 0 | fixed 0}}
+ td {{state.speed || 0 | fixed 0}}
td RPM
- tr
- th Direction
- td {{state.ss || 'Off'}}
- td
table.info
tr
td m/min
tr
th Line
- td {{0 <= state.ln ? state.ln : '-'}}
+ td {{0 <= state.line ? state.line : '-'}}
td
tr
th Tool
- td {{state.t || 0}}
+ td {{state.tool || 0}}
td
tr
th Mist
- td {{state.mist || 'Off'}}
+ td {{state.mist ? 'On' : 'Off'}}
td
tr
th Coolant
- td {{state.coolant || 'Off'}}
+ td {{state.coolant ? 'On' : 'Off'}}
td
.override(title="Feed rate override.")
.toolbar
button.pure-button(title="Home the machine.", @click="home()",
- :disabled="state.x != 'READY'")
+ :disabled="state.xx != 'READY'")
.fa.fa-home
button.pure-button(
- title="{{state.x == 'RUNNING' ? 'Pause' : 'Start'}} program.",
+ title="{{state.xx == 'RUNNING' ? 'Pause' : 'Start'}} program.",
@click="start_pause", :disabled="!file")
- .fa(:class="state.x == 'RUNNING' ? 'fa-pause' : 'fa-play'")
+ .fa(:class="state.xx == 'RUNNING' ? 'fa-pause' : 'fa-play'")
button.pure-button(title="Stop program.", @click="stop",
- :disabled="state.x == 'READY'")
+ :disabled="state.xx == 'READY'")
.fa.fa-stop
button.pure-button(title="Pause program at next optional stop (M1).",
.fa.fa-stop-circle-o
button.pure-button(title="Execute one program step.", @click="step",
- :disabled="(state.x != 'READY' && state.x != 'HOLDING') || !file")
+ :disabled="(state.xx != 'READY' && state.xx != 'HOLDING') || !file")
.fa.fa-step-forward
.tabs
section#content1.tab-content
.toolbar
button.pure-button(title="Upload a new GCode program.", @click="open",
- :disabled="state.x == 'RUNNING' || state.x == 'STOPPING'")
+ :disabled="state.xx == 'RUNNING' || state.xx == 'STOPPING'")
.fa.fa-folder-open
input.gcode-file-input(type="file", @change="upload",
select(title="Select previously uploaded GCode programs.",
v-model="file", @change="load",
- :disabled="state.x == 'RUNNING' || state.x == 'STOPPING'")
+ :disabled="state.xx == 'RUNNING' || state.xx == 'STOPPING'")
option(v-for="file in files", :value="file") {{file}}
- .gcode(:class="{placeholder: !gcode}")
+ .gcode(:class="{placeholder: !gcode}", @scroll="gcode_scroll")
span(v-if="!gcode.length") GCode displays here.
ul
li(v-for="item in gcode", id="gcode-line-{{$index}}",
track-by="$index")
- span {{$index + 1}}
+ span {{$index + 1 + gcode_offset}}
| {{item}}
section#content2.tab-content
section#content6.tab-content
.video
+ img.reload(src="/images/reload.png", @click="load_video",
+ title="Reload video")
img.mjpeg(:src="video_url")
.indicators
table.inputs
tr
- th(colspan=6) Switch Inputs
+ th.header(colspan=7) Inputs
tr
th
th Pin
th Name
+ th.separator
th
th Pin
th Name
- each motor in '01234'
- tr(v-if="is_motor_enabled(#{motor})")
+ each motor in '0123'
+ tr
td
.fa(:class="get_class('#{motor}lw')")
th {{get_min_pin(#{motor})}}
- th #{motor} Min
-
+ th Motor #{motor} Min
+ th.separator
td
.fa(:class="get_class('#{motor}xw')")
th {{get_max_pin(#{motor})}}
- th #{motor} Max
+ th Motor #{motor} Max
tr
td
.fa(:class="get_class('ew')")
th 23
th EStop
-
+ th.separator
td
.fa(:class="get_class('pw')")
th 22
table.outputs
tr
- th(colspan=6) Outputs
+ th.header(colspan=7) Outputs
tr
th
th Pin
th Name
+ th.separator
th
th Pin
th Name
.fa(:class="get_class('eos')")
th 15
th Tool Enable
-
+ th.separator
td
.fa(:class="get_class('1os')")
th 2
.fa(:class="get_class('dos')")
th 16
th Tool Direction
-
+ th.separator
td
.fa(:class="get_class('2os')")
th 1
td {{state['pd'] | percent 0}}
th 17
th Tool PWM
-
+ th.separator
td
.fa(:class="get_class('fos')")
th 21
table.measurements
tr
- th(colspan=4) Measurements
+ th.header(colspan=5) Measurements
tr
td {{state['vin'] | fixed 1}} V
th Input
-
+ th.separator
td {{state['vout'] | fixed 1}} V
th Output
tr
td {{state['motor'] | fixed 2}} A
th Motor
-
+ th.separator
td {{state['temp'] | fixed 0}} ℃
th Temp
tr
td {{state['load1'] | fixed 2}} A
th Load 1
-
+ th.separator
td {{state['load2'] | fixed 2}} A
th Load 2
table.rs485
tr
- th(colspan=4) RS485 Spindle
+ th.header(colspan=5) RS485 Spindle
tr
- th(colspan=4) {{state['he'] ? "" : "Not "}}Connected
+ th(colspan=5) {{state['he'] ? "" : "Not "}}Connected
tr
td {{state['hz']}} Hz
th Frequency
-
+ th.separator
td {{state['hc']}} A
th Current
tr
td {{state['hr']}} RPM
th Speed
-
+ th.separator
td {{state['ht']}} ℃
th Temp
table.legend
tr
- th(colspan=10) Legend
+ th.header(colspan=10) Legend
tr
td
.fa.fa-circle.logic-hi
th Logic Hi
- tr
td
.fa.fa-circle.logic-lo
th Logic Lo
- tr
td
.fa.fa-circle-o.logic-tri
th Tristated / Disabled
+ h2 DB25 breakout box
+ img(width=700, src="/images/DB25_breakout_box.png")
+
h2 DB25-M2 breakout
- center: img(width=400, src="/images/DB25-M2_breakout.png")
+ img(width=400, src="/images/DB25-M2_breakout.png")
methods: {
estop: function () {
- if (this.state.x == 'ESTOPPED') api.put('clear');
+ if (this.state.xx == 'ESTOPPED') api.put('clear');
else api.put('estop');
},
msg.level = msg.level || 'info';
// Add to message log and count and collapse repeats
- if (messages.length && _msg_equal(msg, messages[messages.length - 1]))
- messages[messages.length - 1].repeat++;
-
+ var repeat = messages.length && _msg_equal(msg, messages[0]);
+ if (repeat) messages[0].repeat++;
else {
msg.repeat = 1;
- messages.push(msg);
+ messages.unshift(msg);
}
// Write message to browser console for debugging
else console.log(text);
// Event on errors
- if (msg.level == 'error' || msg.level == 'critical')
+ if (!repeat && (msg.level == 'error' || msg.level == 'critical'))
this.$dispatch('error', msg);
}
},
var api = require('./api');
+var maxLines = 1000;
+var pageSize = Math.round(maxLines / 10);
+
function _is_array(x) {
return Object.prototype.toString.call(x) === '[object Array]';
last_file: '',
files: [],
axes: 'xyzabc',
+ all_gcode: [],
gcode: [],
+ gcode_offset: 0,
history: [],
speed_override: 1,
feed_override: 1,
watch: {
- 'state.ln': function () {this.update_gcode_line();}
+ 'state.line': function () {this.update_gcode_line();}
},
methods: {
- get_state: function () {return this.state.x || ''},
+ get_state: function () {return this.state.xx || ''},
get_reason: function () {
- if (this.state.x == 'ESTOPPED') return this.state.er;
- if (this.state.x == 'HOLDING') return this.state.pr;
+ if (this.state.xx == 'ESTOPPED') return this.state.er;
+ if (this.state.xx == 'HOLDING') return this.state.pr;
return '';
},
},
+ gcode_move_up: function (count) {
+ var lines = 0;
+
+ for (var i = 0; i < count; i++) {
+ if (!this.gcode_offset) break;
+
+ this.gcode.unshift(this.all_gcode[this.gcode_offset - 1])
+ this.gcode.pop();
+ this.gcode_offset--;
+ lines++;
+ }
+
+ return lines;
+ },
+
+
+ gcode_move_down: function (count) {
+ var lines = 0;
+
+ for (var i = 0; i < count; i++) {
+ if (this.all_gcode.length <= this.gcode_offset + this.gcode.length)
+ break;
+
+ this.gcode.push(this.all_gcode[this.gcode_offset + this.gcode.length])
+ this.gcode.shift();
+ this.gcode_offset++;
+ lines++
+ }
+
+ return lines;
+ },
+
+
+ gcode_scroll: function (e) {
+ if (this.gcode.length == this.all_gcode.length) return;
+
+ var t = e.target;
+ var percentScroll = t.scrollTop / (t.scrollHeight - t.clientHeight);
+
+ var lines = 0;
+ if (percentScroll < 0.2) lines = this.gcode_move_up(pageSize);
+ else if (0.8 < percentScroll) lines = -this.gcode_move_down(pageSize);
+ else return;
+
+ if (lines) t.scrollTop += t.scrollHeight * lines / maxLines;
+ },
+
+
update_gcode_line: function () {
if (typeof this.last_line != 'undefined') {
$('#gcode-line-' + this.last_line).removeClass('highlight');
this.last_line = undefined;
}
- if (0 <= this.state.ln) {
- var line = this.state.ln - 1;
- var e = $('#gcode-line-' + line);
- if (e.length)
- e.addClass('highlight')[0]
- .scrollIntoView({behavior: 'smooth'});
+ if (0 <= this.state.line) {
+ var line = this.state.line - 1;
+
+ // Make sure the current GCode is loaded
+ if (line < this.gcode_offset ||
+ this.gcode_offset + this.gcode.length <= line) {
+ this.gcode_offset = line - pageSize;
+ if (this.gcode_offset < 0) this.gcode_offset = 0;
- this.last_line = line;
+ this.gcode = this.all_gcode.slice(this.gcode_offset, maxLines);
+ }
+
+ Vue.nextTick(function () {
+ var e = $('#gcode-line-' + line);
+ if (e.length)
+ e.addClass('highlight')[0]
+ .scrollIntoView({behavior: 'smooth'});
+
+ this.last_line = line;
+ }.bind(this));
}
},
if (!file || this.files.indexOf(file) == -1) {
this.file = '';
+ this.all_gcode = [];
this.gcode = [];
return;
}
api.get('file/' + file)
.done(function (data) {
- this.gcode = data.trimRight().split(/\r?\n/);
+ this.all_gcode = data.trimRight().split(/\r?\n/);
+ this.gcode = this.all_gcode.slice(0, maxLines);
+ this.gcode_offset = 0;
this.last_file = file;
Vue.nextTick(this.update_gcode_line);
start_pause: function () {
- if (this.state.x == 'RUNNING') this.pause();
+ if (this.state.xx == 'RUNNING') this.pause();
- else if (this.state.x == 'STOPPING' || this.state.x == 'HOLDING')
+ else if (this.state.xx == 'STOPPING' || this.state.xx == 'HOLDING')
this.unpause();
else this.start();
},
- ready: function () {
- this.update();
- },
+ ready: function () {this.update()},
methods: {
self.queue = deque()
self.in_buf = ''
self.command = None
+ self.stopping = False
self.lcd_page = ctrl.lcd.add_new_page()
self.install_page = True
log.warning('firmware rebooted')
self.connect()
+ if self.stopping and 'xx' in update and update['xx'] == 'HOLDING':
+ self._stop_sending_gcode()
+ # Resume once current queue of GCode commands has flushed
+ self._i2c_command(Cmd.FLUSH)
+ self._queue_command(Cmd.RESUME)
+ self.stopping = False
+
self.ctrl.state.update(update)
# Must be after AVR vars have loaded
def _update_state(self, update):
- if 'x' in update and update['x'] == 'ESTOPPED':
+ if 'xx' in update and update['xx'] == 'ESTOPPED':
self._stop_sending_gcode()
self._update_lcd(update)
def _update_lcd(self, update):
- if 'x' in update:
- self.lcd_page.text('%-9s' % self.ctrl.state.get('x'), 0, 0)
+ if 'xx' in update:
+ self.lcd_page.text('%-9s' % self.ctrl.state.get('xx'), 0, 0)
# Show enabled axes
row = 0
def connect(self):
try:
# Reset AVR communication
- self.stop();
+ self._stop_sending_gcode()
+ # Resume once current queue of GCode commands has flushed
+ self._queue_command(Cmd.RESUME)
self._queue_command('h') # Load AVR commands and variables
except Exception as e:
if self._is_busy(): raise Exception('Busy, cannot home')
if position is not None:
- self.ctrl.planner.mdi('G28.3 %c%f' % (axis, position))
+ self.mdi('G28.3 %c%f' % (axis, position))
else:
if axis is None: axes = 'zxyabc' # TODO This should be configurable
else: axes = '%c' % axis
for axis in axes:
- if not self.ctrl.state.axis_can_home(axis): continue
+ if not self.ctrl.state.axis_can_home(axis):
+ log.info('Cannot home ' + axis)
+ continue
log.info('Homing %s axis' % axis)
- gcode = axis_homing_procedure % {'axis': axis}
- self.ctrl.planner.mdi(gcode)
- self._set_write(True)
+ self.mdi(axis_homing_procedure % {'axis': axis})
def estop(self): self._i2c_command(Cmd.ESTOP)
def step(self, path):
self._i2c_command(Cmd.STEP)
if not self._is_busy() and path and \
- self.ctrl.state.get('x', '') == 'READY':
+ self.ctrl.state.get('xx', '') == 'READY':
self._start_sending_gcode(path)
def stop(self):
- self._i2c_command(Cmd.FLUSH)
- self._stop_sending_gcode()
- # Resume processing once current queue of GCode commands has flushed
- self._queue_command(Cmd.RESUME)
+ self.pause()
+ self.stopping = True
def pause(self): self._i2c_command(Cmd.PAUSE, byte = 0)
def unpause(self):
- if self.ctrl.state.get('x', '') != 'HOLDING' or not self._is_busy():
+ if self.ctrl.state.get('xx', '') != 'HOLDING' or not self._is_busy():
return
self._i2c_command(Cmd.FLUSH)
if self._is_busy(): raise Exception('Busy, cannot set position')
if self.ctrl.state.is_axis_homed('%c' % axis):
- self.ctrl.planner.mdi('G92 %c%f' % (axis, position))
+ self.mdi('G92 %c%f' % (axis, position))
else: self._queue_command('$%cp=%f' % (axis, position))
return data
-def line_number(line): return '#ln=%d' % line
+def set(name, value): return '#%s=%s' % (name, value)
-def line(id, target, exitVel, maxAccel, maxJerk, times):
- cmd = '#id=%u\n%c' % (id, LINE)
+def line(target, exitVel, maxAccel, maxJerk, times):
+ cmd = LINE
cmd += encode_float(exitVel)
cmd += encode_float(maxAccel)
"outputs": {},
"tool": {},
"gcode": {},
+ "planner": {},
"admin": {},
}
self.text('Host: %s' % hostname[0:14], 0, 0)
for i in range(min(3, len(ips))):
- self.text('IP: %s' % ips[i], 0, i + 1)
+ if len(ips[i]) <= 16:
+ self.text('IP: %s' % ips[i], 0, i + 1)
def activate(self): self.update()
self.callback()
self.processor = inevent.InEvent(ctrl.ioloop, self,
- types = "js kbd".split())
+ types = 'js kbd'.split())
def up(self): self.ctrl.lcd.page_up()
import json
import re
import logging
+from collections import deque
import camotics.gplan as gplan # pylint: disable=no-name-in-module,import-error
import bbctrl.Cmd as Cmd
self.ctrl = ctrl
self.lastID = -1
self.mode = 'idle'
+ self.setq = deque()
ctrl.state.add_listener(self.update)
def update(self, update):
- if 'id' in update: self.planner.set_active(update['id'])
+ if 'id' in update:
+ id = update['id']
+ self.planner.set_active(id)
- if self.ctrl.state.get('x', '') == 'HOLDING' and \
+ # Syncronize planner variables with execution id
+ self.release_set_cmds(id)
+
+ # Automatically unpause on seek hold
+ if self.ctrl.state.get('xx', '') == 'HOLDING' and \
self.ctrl.state.get('pr', '') == 'Switch found' and \
self.planner.is_synchronizing():
self.ctrl.avr.unpause()
+ def release_set_cmds(self, id):
+ self.lastID = id
+
+ # Apply all set commands <= to ID and those that follow consecutively
+ while len(self.setq) and self.setq[0][0] - 1 <= self.lastID:
+ id, name, value = self.setq.popleft()
+ self.ctrl.state.set(name, value)
+ if id == self.lastID + 1: self.lastID = id
+
+
+ def queue_set_cmd(self, id, name, value):
+ log.info('Planner set(#%d, %s, %s)', id, name, value)
+ self.setq.append((id, name, value))
+ self.release_set_cmds(self.lastID)
+
+
def restart(self):
state = self.ctrl.state
id = state.get('id')
self.planner = gplan.Planner()
self.planner.set_resolver(self.get_var)
self.planner.set_logger(self.log, 1, 'LinePlanner:3')
+ self.setq.clear()
- def encode(self, block):
+ def _encode(self, block):
type = block['type']
if type == 'line':
- return Cmd.line(block['id'], block['target'], block['exit-vel'],
+ return Cmd.line(block['target'], block['exit-vel'],
block['max-accel'], block['max-jerk'],
block['times'])
if type == 'set':
name, value = block['name'], block['value']
- if name == 'line': return Cmd.line_number(value)
+ if name == 'line': self.queue_set_cmd(block['id'], name, value)
if name == 'tool': return Cmd.tool(value)
if name == 'speed': return Cmd.speed(value)
if name[0:1] == '_' and name[1:2] in 'xyzabc' and \
return Cmd.set_position(name[1], value)
if len(name) and name[0] == '_':
- self.ctrl.state.set(name[1:], value)
+ self.queue_set_cmd(block['id'], name[1:], value)
return
raise Exception('Unknown planner type "%s"' % type)
+ def encode(self, block):
+ cmd = self._encode(block)
+ if cmd is not None: return Cmd.set('id', block['id']) + '\n' + cmd
+
+
def has_move(self): return self.planner.has_more()
def next(self):
while self.planner.has_more():
cmd = self.planner.next()
- self.lastID = cmd['id']
cmd = self.encode(cmd)
if cmd is not None: return cmd
log = logging.getLogger('State')
-machine_state_vars = 'xp yp zp ap bp cp t fo so'.split()
+machine_state_vars = '''xp yp zp ap bp cp t fo so'''.split()
class State(object):
# Set not homed
self.set('%dhomed' % i, False)
+ # Zero offsets
+ for axis in 'xyzabc': self.vars['offset_' + axis] = 0
+
+ # No line
+ self.vars['line'] = -1
+
def _notify(self):
if not self.changes: return
return event
except Exception as e:
- log.warning('Reading event: %s' % e)
+ log.info('Reading event: %s' % e)
def __enter__(self): return self
The keys are listed in inevent.Constants.py or /usr/include/linux/input.h
Note that the key names refer to a US keyboard.
"""
- def __init__(self, ioloop, cb, types = ["kbd", "mouse", "js"]):
+ def __init__(self, ioloop, cb, types = 'kbd mouse js'.split()):
self.ioloop = ioloop
self.cb = cb
self.streams = []
color green
.indicators
+ padding 1em
+ text-align center
+
table
display inline-block
vertical-align top
margin 0.5em
empty-cells show
+ border 3px solid #bbb
td, th
padding 4px
th:nth-child(3), th:nth-child(6)
text-align left
+ th.header
+ height 2.5em
+ border-bottom 3px solid #ccc
+
+ th.separator
+ background #ccc
+ border 1px solid #ccc
+ padding 1px
+
.logic-lo
color black
.video
text-align center
line-height 0
+ min-height 200px
+
+ .reload
+ float left
+ margin-right -48px
+ &:hover
+ opacity 0.5
.tabs
clear both