Added custom controls via configuration file.

Closes #13
master
Gina Häußge 2013-01-27 18:28:11 +01:00
parent 8c038a6829
commit ee7a1f9615
5 changed files with 207 additions and 145 deletions

119
README.md
View File

@ -12,7 +12,6 @@ allows
* while printing, gaining information regarding the current progress of the print job (height, percentage etc) * while printing, gaining information regarding the current progress of the print job (height, percentage etc)
* reading the communication log and send arbitrary codes to be executed by the printer * reading the communication log and send arbitrary codes to be executed by the printer
* moving the X, Y and Z axis (jog controls, although very ugly ones right now) * moving the X, Y and Z axis (jog controls, although very ugly ones right now)
* changing the speed modifiers for inner & outer wall, fill and support
* optional: visual monitoring of the printer via webcam stream integrated into the UI (using e.g. MJPG-Streamer) * optional: visual monitoring of the printer via webcam stream integrated into the UI (using e.g. MJPG-Streamer)
* optional: creation of timelapse recordings of the printjob via webcam stream (using e.g. MJPG-Streamer) -- currently two timelaspe methods are implemented, triggering a shot on z-layer change or every "n" seconds * optional: creation of timelapse recordings of the printjob via webcam stream (using e.g. MJPG-Streamer) -- currently two timelaspe methods are implemented, triggering a shot on z-layer change or every "n" seconds
@ -66,50 +65,81 @@ on Linux, at `%APPDATA%/OctoPrint` on Windows and at `~/Library/Application Supp
The following example config should explain the available options: The following example config should explain the available options:
[serial] # Use the following settings to configure the serial connection to the printer
# Use the following option to define the default serial port, defaults to unset (= AUTO) serial:
port = /dev/ttyACM0 # Use the following option to define the default serial port, defaults to unset (= AUTO)
port: /dev/ttyACM0
# Use the following option to define the default baudrate, defaults to unset (= AUTO) # Use the following option to define the default baudrate, defaults to unset (= AUTO)
baudrate = 115200 baudrate: 115200
[server] # Use the following settings to configure the web server
# Use this option to define the host to which to bind the server, defaults to "0.0.0.0" (= all interfaces) server:
host = 0.0.0.0 # Use this option to define the host to which to bind the server, defaults to "0.0.0.0" (= all interfaces)
host: 0.0.0.0
# Use this option to define the port to which to bind the server, defaults to 5000 # Use this option to define the port to which to bind the server, defaults to 5000
port = 5000 port: 5000
[webcam] # Use the following settings to configure webcam support
# Use this option to enable display of a webcam stream in the UI, e.g. via MJPG-Streamer. webcam:
# Webcam support will be disabled if not set # Use this option to enable display of a webcam stream in the UI, e.g. via MJPG-Streamer.
stream = http://<stream host>:<stream port>/?action=stream # Webcam support will be disabled if not set
stream: http://<stream host>:<stream port>/?action=stream
# Use this option to enable timelapse support via snapshot, e.g. via MJPG-Streamer. # Use this option to enable timelapse support via snapshot, e.g. via MJPG-Streamer.
# Timelapse support will be disabled if not set # Timelapse support will be disabled if not set
snapshot = http://<stream host>:<stream port>/?action=snapshot snapshot: http://<stream host>:<stream port>/?action=snapshot
# Path to ffmpeg binary to use for creating timelapse recordings. # Path to ffmpeg binary to use for creating timelapse recordings.
# Timelapse support will be disabled if not set # Timelapse support will be disabled if not set
ffmpeg = /path/to/ffmpeg ffmpeg: /path/to/ffmpeg
[feature] # Use the following settings to enable or disable OctoPrint features
# Whether to enable gcode analysis for displaying needed filament and estimated print time. Disabling this (set feature:
# to False) will speed up the loading of gcode files before printing significantly, but the mentioned statistical # Whether to enable gcode analysis for displaying needed filament and estimated print time. Disabling this (set
# data will not be available # to false) will speed up the loading of gcode files before printing significantly, but the mentioned statistical
analyzeGcode = True # data will not be available
analyzeGcode: true
[folder] # Use the following settings to set custom paths for folders used by OctoPrint
# Absolute path where to store gcode uploads. Defaults to the uploads folder in the OctoPrint settings folder folder:
uploads = /path/to/upload/folder # Absolute path where to store gcode uploads. Defaults to the uploads folder in the OctoPrint settings folder
uploads: /path/to/upload/folder
# Absolute path where to store finished timelapse recordings. Defaults to the timelapse folder in the OctoPrint # Absolute path where to store finished timelapse recordings. Defaults to the timelapse folder in the OctoPrint
# settings dir # settings dir
timelapse = /path/to/timelapse/folder timelapse: /path/to/timelapse/folder
# Absolute path where to store temporary timelapse files. Defaults to the timelapse/tmp folder in the OctoPrint
# settings dir
timelapse_tmp: /path/timelapse/tmp/folder
# Use the following settings to add custom controls to the "Controls" tab within OctoPrint
#
# Controls consist at least of a name, a type and type-specific further attributes. Currently recognized types are
# - section: Creates a visual section in the UI, you can use this to separate functional blocks
# - command: Creates a button that sends a defined GCODE command to the printer when clicked
# - parametrized_command: Creates a button that sends a parametrized GCODE command to the printer, parameters
# needed for the command are added to the UI as input fields and are named
#
# The following example defines a control for enabling the cooling fan with a variable speed defined by the user
# (default 255) and a control for disabling the fan, all within a section named "Fan".
controls:
- name: Fan
type: section
children:
- name: Enable Fan
type: parametrized_command
command: M106 S%(speed)s
input:
- name: Speed (0-255)
parameter: speed
default: 255
- name: Disable Fan
type: command
command: M107
# Absolute path where to store temporary timelapse files. Defaults to the timelapse/tmp folder in the OctoPrint
# settings dir
timelapse_tmp = /path/timelapse/tmp/folder
Setup on a Raspberry Pi running Raspbian Setup on a Raspberry Pi running Raspbian
---------------------------------------- ----------------------------------------
@ -158,10 +188,10 @@ This should hopefully run through without any compilation errors. You should the
If you now point your browser to `http://<your Raspi's IP>:8080/?action=stream`, you should see a moving picture at 5fps. If you now point your browser to `http://<your Raspi's IP>:8080/?action=stream`, you should see a moving picture at 5fps.
Open `~/.octoprint/config.ini` and add the following lines to it: Open `~/.octoprint/config.ini` and add the following lines to it:
[webcam] webcam:
stream = http://<your Raspi's IP>:8080/?action=stream stream: http://<your Raspi's IP>:8080/?action=stream
snapshot = http://127.0.0.1:8080/?action=snapshot snapshot: http://127.0.0.1:8080/?action=snapshot
ffmpeg = /usr/bin/avconv ffmpeg: /usr/bin/avconv
Restart the OctoPrint server and reload its frontend. You should now see a Webcam tab with content. Restart the OctoPrint server and reload its frontend. You should now see a Webcam tab with content.
@ -179,6 +209,7 @@ It also uses the following libraries and frameworks for backend and frontend:
* Flask: http://flask.pocoo.org/ * Flask: http://flask.pocoo.org/
* Tornado: http://www.tornadoweb.org/ * Tornado: http://www.tornadoweb.org/
* Tornadio2: https://github.com/MrJoes/tornadio2 * Tornadio2: https://github.com/MrJoes/tornadio2
* PyYAML: http://pyyaml.org/
* Socket.io: http://socket.io/ * Socket.io: http://socket.io/
* jQuery: http://jquery.com/ * jQuery: http://jquery.com/
* Bootstrap: http://twitter.github.com/bootstrap/ * Bootstrap: http://twitter.github.com/bootstrap/
@ -201,14 +232,14 @@ What do I have to do after the rename from Printer WebUI to OctoPrint?
---------------------------------------------------------------------- ----------------------------------------------------------------------
If you did checkout OctoPrint from its previous location at https://github.com/foosel/PrinterWebUI.git, you'll have to If you did checkout OctoPrint from its previous location at https://github.com/foosel/PrinterWebUI.git, you'll have to
update your so-called remote references in git in order to make 'git pull' use the new repository location as origin. update your so-called remote references in git in order to make `git pull` use the new repository location as origin.
To do so you'll only need to execute the following command in your OctoPrint/PrinterWebUI folder: To do so you'll only need to execute the following command in your OctoPrint/PrinterWebUI folder:
git remote set-url origin https://github.com/foosel/OctoPrint.git git remote set-url origin https://github.com/foosel/OctoPrint.git
After that you might also want to rename your base directory (which probably still is called 'PrinterWebUI') to 'OctoPrint' After that you might also want to rename your base directory (which probably still is called `PrinterWebUI`) to `OctoPrint`
and delete the folder 'printer_webui' in your base folder (which stays there thanks to Python's compiled bytecode files and delete the folder `printer_webui` in your base folder (which stays there thanks to Python's compiled bytecode files
even after a rename of the Python package to 'octoprint'). even after a rename of the Python package to `octoprint`).
After that you are set, the configuration files are migrated automatically :) After that you are set, the configuration files are migrated automatically.

View File

@ -120,6 +120,19 @@ def disconnect():
@app.route(BASEURL + "control/command", methods=["POST"]) @app.route(BASEURL + "control/command", methods=["POST"])
def printerCommand(): def printerCommand():
command = request.form["command"] command = request.form["command"]
# if parameters for the command are given, retrieve them from the request and format the command string with them
parameters = {}
for requestParameter in request.values.keys():
if not requestParameter.startswith("parameter_"):
continue
parameterName = requestParameter[len("parameter_"):]
parameterValue = request.values[requestParameter]
parameters[parameterName] = parameterValue
if len(parameters) > 0:
command = command % parameters
printer.command(command) printer.command(command)
return jsonify(SUCCESS) return jsonify(SUCCESS)
@ -182,6 +195,10 @@ def jog():
return jsonify(SUCCESS) return jsonify(SUCCESS)
@app.route(BASEURL + "control/speed", methods=["GET"])
def getSpeedValues():
return jsonify(feedrate = printer.feedrateState())
@app.route(BASEURL + "control/speed", methods=["POST"]) @app.route(BASEURL + "control/speed", methods=["POST"])
def speed(): def speed():
if not printer.isOperational(): if not printer.isOperational():
@ -192,12 +209,11 @@ def speed():
value = int(request.values[key]) value = int(request.values[key])
printer.setFeedrateModifier(key, value) printer.setFeedrateModifier(key, value)
return jsonify(feedrate = printer.feedrateState()) return getSpeedValues()
@app.route(BASEURL + "control/custom", methods=["GET"]) @app.route(BASEURL + "control/custom", methods=["GET"])
def getCustomControls(): def getCustomControls():
customControls = settings().getObject("controls") customControls = settings().getObject("controls")
print("custom controls: %r" % customControls)
return jsonify(controls = customControls) return jsonify(controls = customControls)
#~~ GCODE file handling #~~ GCODE file handling

View File

@ -45,46 +45,7 @@ old_default_settings = {
default_settings = old_default_settings.copy() default_settings = old_default_settings.copy()
default_settings.update({ default_settings.update({
"controls": [ "controls": []
{
"name": "Motors",
"type": "section",
"children": [
{
"name": "Enable Motors",
"type": "command",
"command": "M17"
},
{
"name": "Disable Motors",
"type": "command",
"command": "M18"
}
]
},
{
"name": "Fan",
"type": "section",
"children": [
{
"name": "Enable Fan",
"type": "parameterized_command",
"command": "M106 S%(speed)",
"input": [{
"name": "Speed (0-255)",
"parameter": "speed",
"type": "integer",
"range": [0, 255]
}]
},
{
"name": "Disable Fan",
"type": "command",
"command": "M107"
}
]
}
]
}) })
class Settings(object): class Settings(object):
@ -128,7 +89,7 @@ class Settings(object):
self._config[section][option] = config.get(section, option) self._config[section][option] = config.get(section, option)
self._dirty = True self._dirty = True
self.save(force=True) self.save(force=True)
#os.rename(oldFilename, oldFilename + ".bck") os.rename(oldFilename, oldFilename + ".bck")
else: else:
self._config = {} self._config = {}
@ -195,7 +156,7 @@ class Settings(object):
def set(self, section, key, value): def set(self, section, key, value):
if section not in default_settings.keys(): if section not in default_settings.keys():
return None return
if self._config.has_key(section): if self._config.has_key(section):
sectionConfig = self._config[section] sectionConfig = self._config[section]
@ -204,6 +165,14 @@ class Settings(object):
sectionConfig[key] = value sectionConfig[key] = value
self._config[section] = sectionConfig self._config[section] = sectionConfig
self._dirty = True
def setObject(self, key, value):
if key not in default_settings.keys():
return
self._config[key] = value
self._dirty = True
def _resolveSettingsDir(applicationName): def _resolveSettingsDir(applicationName):
# taken from http://stackoverflow.com/questions/1084697/how-do-i-store-desktop-application-data-in-a-cross-platform-way-for-python # taken from http://stackoverflow.com/questions/1084697/how-do-i-store-desktop-application-data-in-a-cross-platform-way-for-python

View File

@ -387,16 +387,66 @@ function ControlsViewModel() {
} }
self._fromResponse = function(response) { self._fromResponse = function(response) {
self.controls(response.controls); self.controls(self._enhanceControls(response.controls));
}
self._enhanceControls = function(controls) {
for (var i = 0; i < controls.length; i++) {
controls[i] = self._enhanceControl(controls[i]);
}
return controls;
}
self._enhanceControl = function(control) {
if (control.type == "parametrized_command") {
for (var i = 0; i < control.input.length; i++) {
control.input[i].value = control.input[i].default;
}
} else if (control.type == "section") {
control.children = self._enhanceControls(control.children);
}
return control;
}
self.sendJogCommand = function(axis, distance) {
$.ajax({
url: AJAX_BASEURL + "control/jog",
type: "POST",
dataType: "json",
data: axis + "=" + distance
})
}
self.sendHomeCommand = function(axis) {
$.ajax({
url: AJAX_BASEURL + "control/jog",
type: "POST",
dataType: "json",
data: "home" + axis
})
} }
self.sendCustomCommand = function(command) { self.sendCustomCommand = function(command) {
if (command) { if (!command)
return;
if (command.type == "command") {
$.ajax({ $.ajax({
url: AJAX_BASEURL + "control/command", url: AJAX_BASEURL + "control/command",
type: "POST", type: "POST",
dataType: "json", dataType: "json",
data: "command=" + command data: "command=" + command.command
})
} else if (command.type="parametrized_command") {
var data = {"command": command.command};
for (var i = 0; i < command.input.length; i++) {
data["parameter_" + command.input[i].parameter] = command.input[i].value;
}
$.ajax({
url: AJAX_BASEURL + "control/command",
type: "POST",
dataType: "json",
data: data
}) })
} }
} }
@ -406,8 +456,9 @@ function ControlsViewModel() {
case "section": case "section":
return "customControls_sectionTemplate"; return "customControls_sectionTemplate";
case "command": case "command":
case "parameterized_command":
return "customControls_commandTemplate"; return "customControls_commandTemplate";
case "parametrized_command":
return "customControls_parametrizedCommandTemplate";
default: default:
return "customControls_emptyTemplate"; return "customControls_emptyTemplate";
} }
@ -449,19 +500,28 @@ function SpeedViewModel() {
self.isLoading(data.flags.loading); self.isLoading(data.flags.loading);
} }
/* self.requestData = function() {
if (response.feedrate) { $.ajax({
self.outerWall(response.feedrate.outerWall); url: AJAX_BASEURL + "control/speed",
self.innerWall(response.feedrate.innerWall); type: "GET",
self.fill(response.feedrate.fill); dataType: "json",
self.support(response.feedrate.support); success: self._fromResponse
} else { });
self.outerWall(undefined); }
self.innerWall(undefined);
self.fill(undefined); self._fromResponse = function(response) {
self.support(undefined); if (response.feedrate) {
self.outerWall(response.feedrate.outerWall);
self.innerWall(response.feedrate.innerWall);
self.fill(response.feedrate.fill);
self.support(response.feedrate.support);
} else {
self.outerWall(undefined);
self.innerWall(undefined);
self.fill(undefined);
self.support(undefined);
}
} }
*/
} }
function TerminalViewModel() { function TerminalViewModel() {
@ -667,6 +727,7 @@ function WebcamViewModel() {
dataType: "json", dataType: "json",
success: self.fromResponse success: self.fromResponse
}); });
$("#webcam_image").attr("src", CONFIG_WEBCAM_STREAM + "?" + new Date().getTime());
} }
self.fromResponse = function(response) { self.fromResponse = function(response) {
@ -847,33 +908,6 @@ $(function() {
temperatureViewModel.updatePlot(); temperatureViewModel.updatePlot();
}); });
//~~ Jog controls
function jogCommand(axis, distance) {
$.ajax({
url: AJAX_BASEURL + "control/jog",
type: "POST",
dataType: "json",
data: axis + "=" + distance
})
}
function homeCommand(axis) {
$.ajax({
url: AJAX_BASEURL + "control/jog",
type: "POST",
dataType: "json",
data: "home" + axis
})
}
$("#jog_x_inc").click(function() {jogCommand("x", "10")});
$("#jog_x_dec").click(function() {jogCommand("x", "-10")});
$("#jog_y_inc").click(function() {jogCommand("y", "10")});
$("#jog_y_dec").click(function() {jogCommand("y", "-10")});
$("#jog_z_inc").click(function() {jogCommand("z", "10")});
$("#jog_z_dec").click(function() {jogCommand("z", "-10")});
$("#jog_xy_home").click(function() {homeCommand("XY")});
$("#jog_z_home").click(function() {homeCommand("Z")});
//~~ Speed controls //~~ Speed controls
function speedCommand(structure) { function speedCommand(structure) {
@ -932,7 +966,7 @@ $(function() {
ko.applyBindings(printerStateViewModel, document.getElementById("state")); ko.applyBindings(printerStateViewModel, document.getElementById("state"));
ko.applyBindings(gcodeFilesViewModel, document.getElementById("files")); ko.applyBindings(gcodeFilesViewModel, document.getElementById("files"));
ko.applyBindings(temperatureViewModel, document.getElementById("temp")); ko.applyBindings(temperatureViewModel, document.getElementById("temp"));
ko.applyBindings(controlsViewModel, document.getElementById("jog")); ko.applyBindings(controlsViewModel, document.getElementById("controls"));
ko.applyBindings(terminalViewModel, document.getElementById("term")); ko.applyBindings(terminalViewModel, document.getElementById("term"));
ko.applyBindings(speedViewModel, document.getElementById("speed")); ko.applyBindings(speedViewModel, document.getElementById("speed"));

View File

@ -10,8 +10,9 @@
<link href="{{ url_for('static', filename='css/ui.css') }}" rel="stylesheet" media="screen"> <link href="{{ url_for('static', filename='css/ui.css') }}" rel="stylesheet" media="screen">
<script lang="javascript"> <script lang="javascript">
var AJAX_BASEURL = '/ajax/'; var AJAX_BASEURL = "/ajax/";
var CONFIG_FILESPERPAGE = 5; var CONFIG_FILESPERPAGE = 5;
var CONFIG_WEBCAM_STREAM = "{{ webcamStream }}";
var WEB_SOCKET_SWF_LOCATION = "{{ url_for('static', filename='js/WebSocketMain.swf') }}"; var WEB_SOCKET_SWF_LOCATION = "{{ url_for('static', filename='js/WebSocketMain.swf') }}";
var WEB_SOCKET_DEBUG = true; var WEB_SOCKET_DEBUG = true;
@ -124,7 +125,7 @@
<div class="tabbable span8"> <div class="tabbable span8">
<ul class="nav nav-tabs" id="tabs"> <ul class="nav nav-tabs" id="tabs">
<li class="active"><a href="#temp" data-toggle="tab">Temperature</a></li> <li class="active"><a href="#temp" data-toggle="tab">Temperature</a></li>
<li><a href="#jog" data-toggle="tab">Controls</a></li> <li><a href="#controls" data-toggle="tab">Controls</a></li>
<!--<li><a href="#speed" data-toggle="tab">Speed</a></li>--> <!--<li><a href="#speed" data-toggle="tab">Speed</a></li>-->
<li><a href="#term" data-toggle="tab">Terminal</a></li> <li><a href="#term" data-toggle="tab">Terminal</a></li>
{% if webcamStream %}<li><a href="#webcam" data-toggle="tab">Webcam</a></li>{% endif %} {% if webcamStream %}<li><a href="#webcam" data-toggle="tab">Webcam</a></li>{% endif %}
@ -166,24 +167,24 @@
</div> </div>
</div> </div>
</div> </div>
<div class="tab-pane" id="jog"> <div class="tab-pane" id="controls">
<div style="width: 350px; height: 70px"> <div style="width: 350px; height: 70px">
<div style="width: 70px; float: left;">&nbsp;</div> <div style="width: 70px; float: left;">&nbsp;</div>
<div style="width: 70px; float: left;"><button class="btn btn-block" id="jog_y_inc" data-bind="enable: isOperational() && !isPrinting()">Y+</button></div> <div style="width: 70px; float: left;"><button class="btn btn-block" data-bind="enable: isOperational() && !isPrinting(), click: function() { $root.sendJogCommand('y', 10) }">Y+</button></div>
<div style="width: 70px; float: left;">&nbsp;</div> <div style="width: 70px; float: left;">&nbsp;</div>
<div style="width: 70px; float: left; margin-left: 20px"><button class="btn btn-block" id="jog_z_inc" data-bind="enable: isOperational() && !isPrinting()">Z+</button></div> <div style="width: 70px; float: left; margin-left: 20px"><button class="btn btn-block" data-bind="enable: isOperational() && !isPrinting(), click: function() { $root.sendJogCommand('z', 10) }">Z+</button></div>
</div> </div>
<div style="width: 350px; height: 70px"> <div style="width: 350px; height: 70px">
<div style="width: 70px; float: left;"><button class="btn btn-block" id="jog_x_dec" data-bind="enable: isOperational() && !isPrinting()">X-</button></div> <div style="width: 70px; float: left;"><button class="btn btn-block" data-bind="enable: isOperational() && !isPrinting(), click: function() { $root.sendJogCommand('x', -10) }">X-</button></div>
<div style="width: 70px; float: left;"><button class="btn btn-block" id="jog_xy_home" data-bind="enable: isOperational() && !isPrinting()">Home</button></div> <div style="width: 70px; float: left;"><button class="btn btn-block" data-bind="enable: isOperational() && !isPrinting(), click: function() { $root.sendHomeCommand('XY') }">Home</button></div>
<div style="width: 70px; float: left;"><button class="btn btn-block" id="jog_x_inc" data-bind="enable: isOperational() && !isPrinting()">X+</button></div> <div style="width: 70px; float: left;"><button class="btn btn-block" data-bind="enable: isOperational() && !isPrinting(), click: function() { $root.sendJogCommand('x', 10) }">X+</button></div>
<div style="width: 70px; float: left; margin-left: 20px"><button class="btn btn-block" id="jog_z_home" data-bind="enable: isOperational() && !isPrinting()">Home</button></div> <div style="width: 70px; float: left; margin-left: 20px"><button class="btn btn-block" id="jog_z_home" data-bind="enable: isOperational() && !isPrinting(), click: function() { $root.sendHomeCommand('Z') }">Home</button></div>
</div> </div>
<div style="width: 350px; height: 70px"> <div style="width: 350px; height: 70px">
<div style="width: 70px; float: left;">&nbsp;</div> <div style="width: 70px; float: left;">&nbsp;</div>
<div style="width: 70px; float: left;"><button class="btn btn-block" id="jog_y_dec" data-bind="enable: isOperational() && !isPrinting()">Y-</button></div> <div style="width: 70px; float: left;"><button class="btn btn-block" data-bind="enable: isOperational() && !isPrinting(), click: function() { $root.sendJogCommand('y', -10) }">Y-</button></div>
<div style="width: 70px; float: left;">&nbsp;</div> <div style="width: 70px; float: left;">&nbsp;</div>
<div style="width: 70px; float: left; margin-left: 20px"><button class="btn btn-block" id="jog_z_dec" data-bind="enable: isOperational() && !isPrinting()">Z-</button></div> <div style="width: 70px; float: left; margin-left: 20px"><button class="btn btn-block" data-bind="enable: isOperational() && !isPrinting(), click: function() { $root.sendJogCommand('z', -10) }">Z-</button></div>
</div> </div>
<div data-bind="template: { name: $root.displayMode, foreach: controls }"></div> <div data-bind="template: { name: $root.displayMode, foreach: controls }"></div>
@ -195,9 +196,20 @@
<div data-bind="template: { name: $root.displayMode, foreach: children }"></div> <div data-bind="template: { name: $root.displayMode, foreach: children }"></div>
</script> </script>
<script type="text/html" id="customControls_commandTemplate"> <script type="text/html" id="customControls_commandTemplate">
<button class="btn" data-bind="text: name, enable: $root.isOperational(), click: function() { $root.sendCustomCommand($data.command) }"></button> <form class="form-inline">
<button class="btn" data-bind="text: name, enable: $root.isOperational(), click: function() { $root.sendCustomCommand($data) }"></button>
</form>
</script> </script>
<script type="text/html" id="customControls_emptyTemplate"></script> <script type="text/html" id="customControls_parametrizedCommandTemplate">
<form class="form-inline">
<!-- ko foreach: input -->
<label data-bind="text: name"></label>
<input type="text" class="input-small" data-bind="attr: {placeholder: name}, value: value">
<!-- /ko -->
<button class="btn" data-bind="text: name, enable: $root.isOperational(), click: function() { $root.sendCustomCommand($data) }"></button>
</form>
</script>
<script type="text/html" id="customControls_emptyTemplate"><div></div></script>
<!-- End of templates for custom controls --> <!-- End of templates for custom controls -->
</div> </div>
<div class="tab-pane" id="speed"> <div class="tab-pane" id="speed">