UNPKG

node-red-contrib-alexa-remote2-applestrudel

Version:
1,463 lines 62.4 kB
<script type="text/x-red" data-template-name="alexa-remote-routine">
	<div class="form-row">
		<label for="node-input-name"><i class="icon-tag" style="width: 14px; text-align: center"></i> Name</label>
		<input type="text" id="node-input-name" placeholder="Optional">
	</div>
	<div class="form-row">
		<label for="node-input-account"><i class="fa fa-amazon" style="width: 14px; text-align: center"></i> Account</label>
		<input id="node-input-account">
	</div>
	<div id="node-input-routineNode_div" />
</script>

<script type="text/x-red" data-help-name="alexa-remote-routine">
	<style>
		table, th, td {
			border-collapse: collapse;
			border: 1px solid rgb(204, 204, 204);
			padding: 4px 8px;
		}
	</style>

	<p>Emulates Alexa Routine behaviour.</p>
	<hr>
	<h3><strong>Info</strong></h3>
	<ul>
		<li>
			<p>Echo devices can be referenced by id or name (not case sensitive)</p>
		</li>
		<li>
			<p>Announcement and SSML speak options will speak to all devices if you don't specify any device (does not work with
				<em>Speak At Volume</em>)</p>
		</li>
		<li>
			<p><strong>Speak At Volume</strong> or <strong>Volume</strong> with the <em>Add</em> mode can only change the volume
				if the echo has recently been active playing music!</p>
		</li>
		<li>
			<p><strong>Text Command</strong> allows for sending anything you would otherwise say to Alexa (more details <a href="https://github.com/thorsten-gehrig/alexa-remote-control">here</a>)</p>
		</li>
		<li>
			<p>With the <strong>Custom</strong> option, you can feed in a routine node as js object for completely dynamic
				routines. The objects can look like this:</p>
			<ul>
				<li><code>{ type: 'speak', payload: { type: 'regular', text: 'Hello!', devices: ['My Echo']}</code></li>
				<li>
					<code>{ type: 'speakAtVolume', payload: { type: 'regular', text: 'Hello!', volume: 50 devices: ['My Echo']}</code>
					<ul>
						<li>type: <code>regular</code>, <code>ssml</code>, <code>announcement</code></li>
						<li>devices: string or array, can be falsy to send to all devices (only for type speak with announcement or
							ssml type)</li>
					</ul>
				</li>
				<li><code>{ type: 'stop', payload: { devices: ['My Echo']}</code></li>
				<li><code>{ type: 'stop', payload: { devices: ['My Echo']}</code></li>
				<li><code>{ type: 'prompt', payload: { type: 'goodMorning', devices: ['My Echo']}</code>
					<ul>
						<li>prompt: <code>goodMorning</code>, <code>weather</code>, <code>traffic</code>, <code>flashBriefing</code>,
							<code>singSong</code>, <code>joke</code>, <code>tellStory</code>, <code>calendarToday</code>,
							<code>calendarTomorrow</code>, <code>calendarNext</code>, <code>funFact</code>, <code>cleanUp</code>,
							<code>imHome</code></li>
					</ul>
				</li>
				<li><code>{ type: 'phrase', payload: { category: 'confirmations', devices: ['My Echo']}</code>
					<ul>
						<li>category: <code>birthday</code>, <code>compliments</code>, <code>confirmations</code>,
							<code>goodbye</code>, <code>goodmorning</code>, <code>goodnight</code>, <code>iamhome</code></li>
					</ul>
				</li>
				<li><code>{ type: 'volume', payload: { value: 50, devices: ['My Echo']}</code>
					<ul>
						<li>value 0..100</li>
					</ul>
				</li>
				<li>
					<code>{ type: 'music', payload: { provider: 'AMAZON_MUSIC', search: '', device: 'My Echo', duration: 300}</code>
					<ul>
						<li>provider: <code>AMAZON_MUSIC</code>, <code>TUNEIN</code>, <code>CLOUDPLAYER</code>, <code>SPOTIFY</code>
						</li>
						<li>duration is optional</li>
					</ul>
				</li>
				<li><code>{ type: 'wait', payload: { time: 3 }</code>
					<ul>
						<li>time in seconds</li>
					</ul>
				</li>
				<li><code>{ type: 'smarthome', payload: { entity: 'Lamp', action: 'setColor', value: '#FF00FF' }</code>
					<em>(seconds)</em>
					<ul>
						<li>entity can be an id or name (case insensitive)</li>
						<li>action: <code>turnOn</code>, <code>turnOff</code>, <code>setColor</code>,
							<code>setColorTemperature</code>, <code>setBrightness</code>, <code>setPercentage</code>,
							<code>lockAction</code>, <code>setTargetTemperature</code></li>
					</ul>
				</li>
				<li><code>{ type: 'routine', payload: { routine: 'hello' }</code>
					<ul>
						<li>routine can be an id or utterance (case insensitive)</li>
					</ul>
				</li>
				<li><code>{ type: 'pushNotification', payload: { text: 'Hello from Node-RED!', title: 'Node-RED' }</code></li>
				<li><code>{ type: 'node', payload: { type: 'serial', children: [ { type: 'speak', payload: {...}}] }</code>
					<ul>
						<li>type: <code>serial</code>, <code>parallel</code></li>
					</ul>
				</li>
			</ul>
		</li>
	</ul>
	<hr>
	<h3><strong>References</strong></h3>
	<ul>
		<li><a href="https://npmjs.com/package/node-red-contrib-alexa-remote2">npm</a> - the nodes npm repository</li>
		<li><a href="https://github.com/586837r/node-red-contrib-alexa-remote2">GitHub</a> - the nodes GitHub repository</li>
	</ul>
</script>

<style>
	#dialog-form .red-ui-editableList-item-sortable.red-ui-editableList-item-removable:last-child {
		border-bottom: none !important;
	}
	#dialog-form .red-ui-editableList-border.red-ui-editableList-container {
		overflow-y: auto !important;
	}

	.alexaRemote-arRow {
		flex: 1 !important;
		display: flex !important;
		margin-bottom: 12px !important;
	}
	.alexaRemote-arRow > *:first-child {
		min-width: 150px !important;
		flex: 1 !important;
	}
	.alexaRemote-arRow_label {
		flex: 1 !important;
		display: flex !important;
		align-items: center !important;
		justify-content: flex-end !important;
		white-space: nowrap !important;
		overflow: hidden !important;
		text-overflow: ellipsis !important;	
	}

	.alexaRemote-arSelect, .alexaRemote-arBaseOrSelect > select {
		font-family: FontAwesome, "Helvetica Neue", Helvetica, Arial, sans-serif !important;
	}

	.alexaRemote-arBaseOrSelect {
		display: flex !important;
	}

	.alexaRemote-arBaseOrSelect > select, .alexaRemote-arSelect {
		flex: 1 !important;
		width: 100% !important;
	}
	.alexaRemote-arTypedInputOrInputList .hidden {
		display: none !important;
	}

	.alexaRemote-arTypedInput .red-ui-typedInput-container {
		width: 100% !important;
	}
</style>

<script type="text/javascript">
	RED.nodes.registerType('alexa-remote-routine', {
		category: 'alexa',
		color: '#6fbad8',
		defaults: {
			name: { value: '' },
			account: { value: '', type: 'alexa-remote-account', required: true },
			routineNode: { value: { type: 'speak', payload: undefined } },
		},
		inputs: 1,
		outputs: 1,
		icon: 'alexa-remote-icon.png',
		paletteLabel: 'Alexa Routine',
		label: function () {
			function keyToLabel(str) {
				str = String(str);
				const match = str.match(/(?:[A-Z]?[a-z]+)|[A-Z]|[0-9]+/g);
				if (match) str = match.map(s => s.slice(0, 1).toUpperCase() + s.slice(1)).join(' ');
				return str;
			}

			if(this.name) return this.name;
			const type = this.routineNode && this.routineNode.type;
			if(type === 'routine') return `Routine Execute`;
			if(type) return `Routine ${keyToLabel(type)}`;
			return `Routine`;
		},
		labelStyle: function () {
			return this.name ? 'node_label_italic' : '';
		},
		oneditprepare: function () {
			console.log('loaded', this.routineNode);

			class EventEmitter {
				constructor() {
					this.off();
				}

				once(type, callback) {
					let set = this.onceListeners.get(type);
					if (!set) {
						set = new Set();
						this.onceListeners.set(type, set);
					}
					set.add(callback);
				}

				on(type, callback) {
					let set = this.listeners.get(type);
					if (!set) {
						set = new Set();
						this.listeners.set(type, set);
					}
					set.add(callback);
				}

				// listens until element has left the document
				listen(type, element, callback) {
					if (typeof type !== 'string' || !(element instanceof $ || element instanceof Node) || typeof callback !== 'function') throw new Error('dumbass');

					let map = this.elementListeners.get(type);
					if (!map) {
						map = new Map();
						this.elementListeners.set(type, map);
					}
					map.set(callback, element instanceof $ ? element.get(0) : element);
				}

				off(type, callback) {
					if (arguments.length === 0) {
						this.listeners = new Map(); // { type => Set { callback } }
						this.onceListeners = new Map(); // { type => Set { callback } }
						this.elementListeners = new Map(); // { type => Map { callback => element } }
					}

					if (arguments.length === 1) {
						this.listeners.delete(type);
						this.onceListeners.delete(type);
						return;
					}

					let set, map;

					if (set = this.listeners.get(type)) {
						set.delete(callback);
						if (set.length === 0) this.listeners.delete(type);
					}

					if (set = this.onceListeners.get(type)) {
						set.delete(callback);
						if (set.length === 0) this.onceListeners.delete(type);
					}

					if (map = this.elementListeners.get(type)) {
						map.delete(callback);
						if (map.length === 0) this.elementListeners.delete(type);
					}
				}

				emit(type) {
					const args = [...arguments].slice(1);
					let set, map;

					if (set = this.listeners.get(type)) {
						for (const cb of set) cb(...args);
					}

					if (set = this.onceListeners.get(type)) {
						this.onceListeners.delete(type);
						for (const cb of set) cb(...args);
					}

					if (map = this.elementListeners.get(type)) {
						for (const [cb, e] of map) document.contains(e) ? cb(...args) : map.delete(cb);
					}
				}
			}

			function keyToLabel(str) {
				str = String(str);
				const match = str.match(/(?:[A-Z]?[a-z]+)|[A-Z]|[0-9]+/g);
				if (match) str = match.map(s => s.slice(0, 1).toUpperCase() + s.slice(1)).join(' ');
				return str;
			}
			function template(source, templ, depth = Infinity, fillArray = false) {
				function isObject(x) {
					return typeof x == 'object' && x !== null && !Array.isArray(x)
				}

				if (templ === undefined) {
					return source;
				}

				// are they different types?
				if (typeof templ !== typeof source || Array.isArray(templ) !== Array.isArray(source) || isObject(templ) !== isObject(source)) {
					return templ;
				}

				if (depth !== 0) {
					if (Array.isArray(templ)) {
						if (templ.length === 0) {
							return source;
						}

						const result = [];
						for (let i = 0; i < source.length; i++) {
							result[i] = template(source[i], templ[0], depth - 1);
						}
						return result;
					}

					if (isObject(templ)) {
						const result = {};
						for (const k of Object.keys(templ)) {
							result[k] = template(source[k], templ[k], depth - 1);
						}
						return result;
					}
				}

				return source;
			}
			function inDocument(element) {
				return document.contains(element instanceof $ ? element[0] : element);
			}
			function zip(a, b) {
				const arr = [];
				for (let i = 0; i < a.length; i++) {
					arr[i] = [a[i], b[i]];
				}
				return arr;
			}
			function expand(a) {
				return zip(a, a);
			}
			function clean(obj) {
				return Object.entries(obj).filter(([k, v]) => v || (typeof v === 'number')).reduce((o, [k, v]) => (o[k] = v, o), {});
			}

			$.widget('alexaRemote.arInput', {
				_create: function () {
					this.element.attr('type', 'text').addClass('alexaRemote-arInput');
				},
				value: function (value) {
					if (arguments.length === 0) return this.element.val();
					this.element.val(value);
				}
			});
			$.widget('alexaRemote.arSelect', {
				_create: function () {
					this.element.addClass('alexaRemote-arSelect');
					this.selectOptions([]);
				},
				selectOptions: function (entries) {
					if (arguments.length === 0) {
						return this.element.children().toArray().map(o => [$(o).val(), $(o).html()]);
					}

					this.element.empty();
					for (const entry of entries) {
						const [v, t] = Array.isArray(entry) ? entry : [entry, keyToLabel(entry)];
						$('<option>').val(v).html(t).appendTo(this.element);
					}
				},
				value: function (value) {
					if (arguments.length === 0) {
						return this.element.val();
					}

					this.element.val(value);
				}
			});
			$.widget('alexaRemote.arTypedInput', {
				options: {
					types: [],
					placeholder: ''
				},
				_create: function () {
					this.input = $('<input>').appendTo(this.element)
						.typedInput({ types: this.options.types.concat(['msg', 'flow', 'global', 'jsonata', 'env']) })
						.typedInput('width', '100%');

					if (this.options.placeholder) {
						this.element.find('.red-ui-typedInput-input > input')
							.attr('placeholder', this.options.placeholder);
					}
					this.element.addClass('alexaRemote-arTypedInput');
				},
				value: function (value) {
					if (arguments.length === 0) {
						return this.input.typedInput('value');
					}

					this.input.typedInput('value', value);
				},
				type: function (type) {
					if (arguments.length === 0) {
						return this.input.typedInput('type');
					}

					this.input.typedInput('type', type);
				},
				types: function (types) {
					this.input.typedInput('types', types.concat(['msg', 'flow', 'global', 'jsonata', 'env']));
				},
				refresh: function () {
					// fix display bug
					const type = this.type();
					this.type(type === 'msg' ? 'flow' : 'msg');
					this.type(type);
				},
				data: function (data) {
					if (arguments.length === 0) {
						return {
							type: this.type(),
							value: this.value()
						}
					}

					this.type(data.type);
					this.value(data.value);
				}
			});
			$.widget('alexaRemote.arBaseOrSelect', {
				options: {
					button: false,
				},
				_setup: function (element) {
					this.element.addClass('alexaRemote-arBaseOrSelect');
					this.notSelect = element;
					this.select = $('<select>').css({ width: 50 });

					this._on(this.select, {
						'change': function (event) {
							event.stopPropagation();
							this._inputValue(this.select.val());
							this._change();
						}
					});

					this._on(this.notSelect, {
						'change': function (event) {
							event.stopPropagation();
							this._change();
						}
					});

					if (this.options.button) {
						this.button = $('<a>').addClass('editor-button');

						this.element.css({ display: 'flex' }).append(
							this.notSelect.css({ flex: '1' }),
							this.select.css({ flex: '1' }),
							$('<div>').css({ width: 8 }),
							this.button.append($('<i>').attr('class', 'fa fa-list'))
						);

						this.button.click(() => {
							const active = this.selectActive();
							this.selectActive(!active);
						});
					}
					else {
						this.element.append(this.notSelect, this.select);
					}

					this.selectActive(false);
					this.selectOptions([]);
				},
				_inputValue: function (value) { throw 'abstract widget you dingus'; },
				_selectValue: function (value) {
					if (arguments.length === 0) return this.select.val();
					const found = Array.from(this.select.get(0).options).find(o => o.value === value);
					this.select.val(found ? value : '');
				},
				selectActive: function (active) {
					if (arguments.length === 0) {
						return this.select.is(':visible');
					}

					if (active) {
						this.notSelect.hide();
						this.select.show();
					}
					else {
						this.select.hide();
						this.notSelect.show();
					}
				},
				selectOptions: function (optionsArrayOrObj) {
					if (arguments.length === 0) {
						throw new Error('NYI');
					}
					
					this.select.empty();
					this.select.append('<option hidden disabled selected value>???</option>');
					
					// allows [['myval', 'My Label'], 'myValAndLabel_special'] => [.., ['myValAndLabel_special', 'My Val And Label Special']]
					if(Array.isArray(optionsArrayOrObj)) {
						for(const option of optionsArrayOrObj) {
							const [value, label] = Array.isArray(option) ? option : [option, keyToLabel(option)];
							$('<option>').val(value).html(label).appendTo(this.select);
						}
					} else {
						for(const [groupName, options] of Object.entries(optionsArrayOrObj)) {
							const group = $('<optgroup>').attr('label', groupName).appendTo(this.select);
							for(const option of options) {
								const [value, label] = Array.isArray(option) ? option : [option, keyToLabel(option)];
								$('<option>').val(value).html(label).appendTo(group);
							}
						}
					}

					this._selectValue(this._inputValue());
				},
				selectIndex: function (index) {
					if (arguments.length === 0) return this.select.get(0).selectedIndex - 1;
					const option = this.select.get(0).options[index + 1];
					this.selectActive(true);
					this.value(option && option.value || '');
				},
				selectSomething: function () {
					if (this.selectIndex() < 0) this.selectIndex(0);
				},
				choose: function (some = true) {
					const value = this.value();
					if (some && !value) return this.selectSomething();
					const select = this.select.get(0);
					const found = value && Array.from(select.options).find(o => o.value === value);
					this.selectActive(!!found);
				},
				value: function (value) {
					if (arguments.length === 0) return this._inputValue();
					this._selectValue(value);
					this._inputValue(value);
					this._change();
				},
				_change: function () {
					this.element.trigger('change', this.value());
				}
			})
			$.widget('alexaRemote.arInputOrSelect', $.alexaRemote.arBaseOrSelect, {
				options: {
					placeholder: ''
				},
				_create: function () {
					this.element.addClass('alexaRemote-arInputOrSelect');
					this.input = $('<input>').css({ width: '100%' }).attr('type', 'text');
					if (this.options.placeholder) this.input.attr('placeholder', this.options.placeholder);
					this._setup(this.input);
				},
				_inputValue: function (value) {
					if (arguments.length === 0) return this.input.val();
					this.input.val(value);
				}
			});
			$.widget('alexaRemote.arTypedInputOrSelect', $.alexaRemote.arBaseOrSelect, {
				options: {
					optionType: 'str',
					placeholder: '',
				},
				_create: function () {
					this.element.addClass('alexaRemote-arTypedInputOrSelect');
					this.input = $('<div>').arTypedInput({ placeholder: this.options.placeholder, types: [this.options.optionType] });
					if (typeof this.options.optionType === 'object') this.options.optionType = this.options.optionType.value;
					this._setup(this.input);
				},
				_change: function () {
					this.element.trigger('change', this.data());
				},
				_inputValue: function (value) {
					if (arguments.length === 0) return this.input.arTypedInput('value');
					this.input.arTypedInput('value', value);
				},
				selectActive: function (active) {
					if (arguments.length === 0) {
						return this._super(...arguments);
					}

					this._super(...arguments);
					if (active) this.type(this.options.optionType);
					this.refresh();
				},
				selectActiveMaybe: function (active) {
					if (active && this.type() !== this.options.optionType) return;
					return this.selectActive(...arguments);
				},
				choose: function () {
					const type = this.input.arTypedInput('type');
					type === this.options.optionType ? this._super(...arguments) : this.selectActive(false);
				},
				type: function (type) {
					if (arguments.length === 0) {
						return this.input.arTypedInput('type');
					}

					this.input.arTypedInput('type', type);
					if (this.type() !== this.options.optionType) this.selectActive(false);
				},
				types(types) {
					this.input.arTypedInput('types', types.concat(['msg', 'flow', 'global', 'jsonata', 'env']));
				},
				refresh: function () {
					this.input.arTypedInput('refresh');
				},
				data: function (property) {
					if (arguments.length === 0) {
						return {
							type: this.type(),
							value: this.value()
						}
					}

					this.type(property.type);
					this.value(property.value);
				}
			});
			$.widget('alexaRemote.arInputList', {
				options: {
					add: function (item, index) { return undefined; },
					header: undefined
				},
				_create: function () {
					this.list = $('<ol>').appendTo(this.element).editableList({
						removable: true,
						sortable: true,
						scrollOnAdd: true,
						header: this.options.header && $('<div>').css({ display: 'flex', paddingTop: 4, paddingLeft: 22 + 5, paddingRight: 28 + 5 }).append(this.options.header),
						addItem: (row, index, data) => {
							row.css({ display: 'flex' });
							const callback = this.options.add.bind(row)(data, index);
							row.data('callback', callback);
						}
					});

					this.element.css({ flex: '1', display: 'flex' });
					this.element.find('.red-ui-editableList').css({ flex: '1', display: 'flex', flexDirection: 'column' });
					this.element.find('.red-ui-editableList-border').css({ flex: '1', display: 'flex', flexDirection: 'column' });
					this.element.find('.red-ui-editableList-container').css({ flex: '1' });
					this.element.find('.red-ui-editableList-addButton').css({ alignSelf: 'flex-start' })
				},
				add: function (item) {
					this.list.editableList('addItem', item)
				},
				value: function (items) {
					if (arguments.length === 0) {
						return this.list.editableList('items').toArray().map(row => {
							const callback = row.data('callback');
							return callback && callback();
						});
					}

					this.list.empty();
					for (const item of items) {
						this.list.editableList('addItem', item);
					}
				}
			});
			$.widget('alexaRemote.arInputGroups', {
				options: { clean: true },
				_create: function () {
					this.element.addClass('alexaRemote-arInputGroups');
					this.element.hide();
				},
				groups: function (groups) {
					if (arguments.length === 0) return this.groupMap;
					if (this.groupMap) this.groupValue = this.groupObject = undefined;
					this.groupMap = groups;
					this.update();
				},
				group: function (group) {
					if (arguments.length === 0) return this.groupKey;
					if (this.groupKey === group) return;
					this.groupKey = group;
					if (this.options.clean) this.groupValue = undefined;
					this.update();
				},
				value: function (value) {
					if (arguments.length === 0) return this.groupCallbacks && this.groupCallbacks.getter() || null;
					this.groupValue = value;
					this.update();
				},
				update: function () {
					this.element.empty();
					if (!this.groupMap) return;
					this.groupObject = this.groupMap.hasOwnProperty(this.groupKey) ? this.groupMap[this.groupKey] : undefined;
					if (typeof this.groupObject === 'function') this.groupObject = { create: this.groupObject };
					this.groupCallbacks = this.groupObject && this.groupObject.create && this.groupObject.create.bind(this.element)(this.groupValue);
					if (typeof this.groupCallbacks === 'function') this.groupCallbacks = { getter: this.groupCallbacks };
					if (this.groupObject) this.element.show(); else this.element.hide();
					this.element.trigger('change', this.groupKey);
				}
			});
			$.widget('alexaRemote.arTypedInputOrInputList', {
				_create: function() {
					this.input = $('<div>').arTypedInput({ types: ['str', 'json'] }).css({ flex: '1' });
					this.list = $('<div>').arInputList(this.options);
					this.button = $('<a>').addClass('editor-button');
					this.element.addClass('alexaRemote-arTypedInputOrInputList').css({ display: 'flex'}).append(
						this.input, 
						this.list,
						$('<div>').css({ width: 8 }),
						this.button.css({ alignSelf: 'center' }).append($('<i>').attr('class', 'fa fa-list')),
					);
					
					this.button.click(() => {
						this.listActive(!this.listActive());
					});

					this._listVisible(false);
				},
				_change: function(data) {
					this.element.trigger('change');
				},
				_listVisible: function(visible) {
					if(arguments.length === 0) return !this.list.hasClass('hidden');

					if(visible) {
						this.input.addClass('hidden');
						this.list.removeClass('hidden');
					}
					else {
						this.list.addClass('hidden');
						this.input.removeClass('hidden');
						this.refresh();
					}

					this.button.css({ marginBottom: visible ? 24 : 0 });
				},
				listActive: function(active) {
					if(arguments.length === 0) return this._listVisible();
					
					if(active) {
						const data = this.input.arTypedInput('data');

						let array; 
						if(data.type === 'str') array = [data.value];
						if(data.type === 'json') { try { array = JSON.parse(data.value); } catch(err) {}; }
						
						this.list.arInputList('value', Array.isArray(array) ? array : [undefined]);
					}
					else {
						const array = this.list.arInputList('value');
						const data = array.length <= 1 
							? { type: 'str', value: array[0] || '' }
							: { type: 'json', value: JSON.stringify(array) };

						this.input.arTypedInput('data', data);	
					}
					this._listVisible(active);
					this._change();
				},
				data: function(data) {
					if(arguments.length === 0) {
						return this.listActive() ? this.list.arInputList('value') : this.input.arTypedInput('data');
					}

					if(data === undefined) {
						data = [undefined];
					}

					if(Array.isArray(data)) {
						this.list.arInputList('value', data);
						this._listVisible(true);
					}
					else {
						this.input.arTypedInput('data', data);
						this._listVisible(false);
					}
				},
				refresh: function() {
					this.input.arTypedInput('refresh');
				}
			});
			$.widget('alexaRemote.arTips', {
				_create: function() {
					this.element.addClass('form-tips').css({marginBottom: 12, maxWidth: 'unset'});
				},
				show: function(arg = true) {
					if(typeof arg === 'string') this.element.html(arg);
					arg ? this.element.show() : this.element.hide();
				},
				hide: function(arg = true) {
					this.show(!arg);
				}
			});

			function arFormRow(element, label = '', icon = '') {
				return $('<div>').addClass('form-row arFormRow').append(
					$('<label>').text(' ' + label).prepend(
						$('<i>').attr('class', icon).css({ width: 14, textAlign: 'center' })
					),
					$(document.createTextNode(' ')),
					$('<div>').css({ width: '70%', display: 'inline-block' }).append(
						element.css({ width: '100%' })
					)
				);
			}
			function arRow(element, label, flex = '3') {
				return $('<div>').addClass('alexaRemote-arRow').append(
					$('<label>').addClass('alexaRemote-arRow_label').html(label),
					$('<div>').css({ width: 10, minWidth: 10 }),
					element.css({ flex: flex }),
				);
			}
			function arGroup(element) {
				return $('<div>').css({display: 'flex'}).append(
					...arguments
				);
			}
			function arInput(data = '') {
				return $('<input>').arInput()
					.arInput('value', data)
					.css({ flex: '1' });
			}
			function arSelect(data = '', options = []) {
				return $('<select>').arSelect()
					.arSelect('selectOptions', options)
					.arSelect('value', data)
					.css({ flex: '1', width: 100 });
			}
			function arTypedInput(data = { type: 'str', value: '' }, types = ['str'], { placeholder = '' } = {}) {
				return $('<div>')
					.arTypedInput({ types: types, placeholder: placeholder })
					.arTypedInput('data', data)
					.css({ flex: '1' });
			}
			function arInputList(data = [], add = () => { }, { header } = {}) {
				return $('<div>')
					.arInputList({ add: add, header: header })
					.arInputList('value', data);
			}
			function arInputOrSelect(data = '', options = [], { button = false, placeholder = '', choose = true, some = true } = {}) {
				const div = $('<div>').css({ flex: '1' })
					.arInputOrSelect({ button: button, placeholder: placeholder })
					.arInputOrSelect('selectOptions', options)
					.arInputOrSelect('value', data);

				if (choose) div.arInputOrSelect('choose', some);
				return div;
			}
			function arTypedInputOrSelect(data = { type: 'str', value: '' }, options = [], { button = true, placeholder = '', optionType = 'str', choose = true, some = true } = {}) {
				const div = $('<div>').css({ flex: '1' })
					.arTypedInputOrSelect({ button: button, placeholder: placeholder, optionType: optionType })
					.arTypedInputOrSelect('selectOptions', options)
					.arTypedInputOrSelect('data', data);

				if (choose) div.arTypedInputOrSelect('choose', some);
				return div;
			}
			function arInputGroups(group, value, groups = {}, clean = true) {
				return $('<div>')
					.arInputGroups({ clean: clean })
					.arInputGroups('group', group)
					.arInputGroups('value', value)
					.arInputGroups('groups', groups);
			}
			function arTypedInputOrInputList(data, add, { header } = {}) {
				return $('<div>').arTypedInputOrInputList({add: add, header: header})
					.arTypedInputOrInputList('data', data);
			}
			function arTips(text = true) {
				return $('<div>').arTips().arTips('show', text);
			}

			function arRoutineNode(data = { node: 'speak' }, loader){
				return $('<div>')
					.arRoutineNode({loader: loader})
					.arRoutineNode('data', data);
			}

			$.widget('alexaRemote.arRoutineNode', {
				options: {
					loader: null
				},
				_create: function () {

					const rowDiv = $('<div>').css({display: 'flex', flexDirection: 'row', marginBottom: 12}).appendTo(this.element);
					const loader = this.options.loader;
					let channel = {}; // for bottom <-> right communication
					this.loader = loader;
					this.channel = channel;

					const options = [
						['speak',							'&#xf04b;  Speak'], // play
						['speakAtVolume', 		'&#xf0fe;  Speak At Volume'],
		                ['textCommand',					'&#xf0e7;  Text Command'], // bolt
						['wait',							'&#xf017;  Wait'], // clock-o
						['stop',							'&#xf04d;  Stop'], // stop
						['prompt',						'&#xf27a;  Prompt'], // commenting
						['phrase',						'&#xf075;  Phrase'], // comment
						['sound',             '&#xf001;  Sound'], // music
						['volume',						'&#xf028;  Volume'], // volume-up
						['music',							'&#xf001;  Music'], // music
						['smarthome',					'&#xf015;  Smarthome'], // home
						['skill',							'&#xf12e;  Launch Skill'], // puzzle-piece
						['routine',						'&#xf0e7;  Execute Routine'], // bolt
						['pushNotification',	'&#xf10b;  Push Notification'], // mobile
						['node',							'&#xf126;  Node'], // code-fork
						['custom',						'&#xf061;  Custom'], // arrow-right
					];

					const common = { input: {}, right: { group: {}, }, bottom: { group: {}, row: {} } };
					common.input.device = function(data, all) {
						data = template(data, { type: 'str', value: '' });
						const input = arTypedInputOrSelect(data);
						const updateInput = () => {
							input.arTypedInputOrSelect('selectOptions', all ? [['ALEXA_ALL_DSN', '&#xf0ac;  All Devices']].concat(loader.devices) : loader.devices);
							if(loader.success) {
								input.arTypedInputOrSelect('choose', true);
							}
							else {
								input.arTypedInputOrSelect('selectActive', false);
							}
						}
						loader.listen('change', input, updateInput);
						updateInput();
						return input;
					}
					common.input.deviceList = function(data, all) {
						return arTypedInputOrInputList(data, function(data) {
							data = template(data, '');
							const input = arInputOrSelect(data).appendTo(this);
							const updateInput = () => {
								input.arInputOrSelect('selectOptions', all ? [['ALEXA_ALL_DSN', '&#xf0ac;  All Devices']].concat(loader.devices) : loader.devices);
								input.arInputOrSelect('selectActive', loader.success);
								if (!input.arInputOrSelect('value') && loader.success) input.arInputOrSelect('selectSomething');
							}
							loader.listen('change', input, updateInput);
							updateInput();
							return () => input.arInputOrSelect('value');
						});
					}
					common.right.group.device = function(all) {
						return function(data) {
							data = template(data, { device: undefined });
							const input = common.input.device(data.device, all).appendTo(this);											
							return () => ({ device: input.arTypedInputOrSelect('data') });
						}
					}
					common.bottom.row.deviceList = function(data, all) {
						const input = common.input.deviceList(data, all);
						const row = arRow(input, 'Devices');
						const updateRow = () => {
							// to center it to the list (without add button);	
							const active = input.arTypedInputOrInputList('listActive')
							row.children('label').css({ paddingBottom: active ? 24 : 0 });
						} 
						input.on('change', updateRow);
						updateRow();
						return [input, row];
					}
					common.bottom.group.lockAction = function () {
						return function (data) {
							data = template(data, { value: { type: 'str', value: 'locked' } });
							const value = arTypedInputOrSelect(data.value, ['locked', 'unlocked', 'jammed']);
							const valueRow = arRow(value, 'Lock State').appendTo(this);
							return () => ({ value: value.arTypedInputOrSelect('data') });
						}
					}
					common.bottom.group.device = function(all) {
						return function(data) {
							data = template(data, { device: undefined });
							const input = common.input.device(data.device, all);
							arRow(input, 'Device').appendTo(this);												
							return () => ({ device: input.arTypedInputOrSelect('data') });
						}
					}
					common.bottom.group.deviceList = function(all) {
						return function(data) {
							data = template(data, { devices: undefined });
							const [devices, devicesRow] = common.bottom.row.deviceList(data.devices, all);
							devicesRow.appendTo(this);
							return () => ({	devices: devices.arTypedInputOrInputList('data') });
						}
					}
					common.bottom.group.number = function(label) {
						return function(data) {
							data = template(data, { value: { type: 'num', value: '50' } });
							const value = arTypedInput(data.value, ['num']); 
							arRow(value, label).appendTo(this);
							return () => ({	value: value.arTypedInput('data') });
						}
					}
					common.bottom.group.color = function (labelHtml, loaderMapProperty, loaderListProperty) {
						return function (data) {
							data = template(data, { value: { type: 'str', value: '' } });
							const value = arTypedInputOrSelect(data.value);
							const row = arRow(value, labelHtml).appendTo(this);
							const label = row.children('label');
							const separator = $('<div>').css({ width: 8 }).hide();
							const preview = $('<div>').css({ width: 34, height: 34, border: '1px solid #ccc', borderRadius: 4, boxSizing: 'border-box' }).hide();
							const wrap = $('<div>').css({ flex: '1', display: 'flex' }).insertAfter(label).append(label, separator, preview);

							const updatePreview = () => {
								const type = value.arTypedInputOrSelect('type');
								if (type !== 'str') return;

								const name = value.arTypedInputOrSelect('value');
								const color = loader[loaderMapProperty].get(name);

								if (!color) {
									preview.add(separator).hide();
									return;
								}

								preview.add(separator).show();
								preview.css({ background: color });
							}
							const updateValue = () => {
								value.arTypedInputOrSelect('selectOptions', loader.smarthome[loaderListProperty]).arTypedInputOrSelect('choose');
							}
							value.on('change', updatePreview);
							loader.listen('change', value, updateValue);
							updateValue();
							updatePreview();

							return () => ({ value: value.arTypedInputOrSelect('data') });
						}
					}

					const groups = { right: {}, bottom: {} };
					groups.right.speak = function(data) {
						data = template(data, { type: 'regular' });
						const type = arSelect(data.type, ['regular', ['ssml', 'SSML'], 'announcement']).appendTo(this);
						channel.type = type;
						return () => ({ type: type.arSelect('value') });
					}
					groups.right.speakAtVolume = groups.right.speak;
                    groups.right.textCommand = function(data) {
						data = template(data, { text: { type: 'str', value: 'Hello from Alexa Remote!'}});
						const input = arTypedInput(data.text, ['str']).appendTo(this);
						return () => ({ text: input.arTypedInput('data') });
					}
					groups.right.wait = function(data) {
						data = template(data, { time: { type: 'num', value: '1' }});
						const type = arTypedInput(data.time, ['num']).appendTo(this);
						$('<label>').text('seconds').css({display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '0px 15px'}).appendTo(this);
						return () => ({ time: type.arTypedInput('data') });
					}
					groups.right.volume = function(data) {
						data = template(data, { value: { type: 'str', value: '50' } });
						const input = arTypedInput(data.value, ['num']).appendTo(this);
						return () => ({ value: input.arTypedInput('data') });
					}
					groups.right.music = common.right.group.device(false);
					groups.right.prompt = function(data) {
						data = template(data, { type: { type: 'str', value: 'goodMorning' }});
						const select = arTypedInputOrSelect(data.type, [
							['goodMorning',      'Good Morning'],
							['goodNight',        'Good Night'],
							['weather',          'Weather'],
							['traffic',          'Traffic'],
							['flashBriefing',    'News Flash Briefing'],
							['singSong',         'Sing a Song'],
							['joke',             'Tell a Joke'],
							['tellStory',        'Tell me a Story'],
							['calendarToday',    `Today's Calendar`],
							['calendarTomorrow', `Tomorrow's Calendar`],
							['calendarNext',     `Next Event`],
							['funFact',          `Fun Fact`],
							['cleanUp',          `Clean Up`],
							['imHome',           `I'm Home`],
						]).appendTo(this);
						return () => ({ type: select.arTypedInputOrSelect('data') });
					}
					groups.right.phrase = function(data) {
						data = template(data, { category: { type: 'str', value: 'confirmations' }});
						const select = arTypedInputOrSelect(data.category, [
							['birthday',      `Birthday`],
							['compliments',   `Compliments`],
							['confirmations', `Confirmations`],
							['goodbye',       `Goodbye`],
							['goodmorning',   `Good Morning`],
							['goodnight',     `Good Night`],
							['iamhome',       `I'm Home`],
						]).appendTo(this);
						return () => ({ category: select.arTypedInputOrSelect('data') });
					}
					groups.right.sound = function(data) {
						data = template(data, { sound: { type: 'str', value: 'amzn_sfx_doorbell_01' }});
						const select = arTypedInputOrSelect(data.sound, {
							'Animals': [ // paw
								['amzn_sfx_cat_meow_1x_01',       'Cat meow'],
								['amzn_sfx_dog_med_bark_1x_02',   'Dog bark'],
								['amzn_sfx_lion_roar_02',         'Lion roar'],
								['amzn_sfx_rooster_crow_01',      'Rooster crow'],
								['amzn_sfx_wolf_howl_02',         'Wolf howl'],
							],
							'Bells and Buzzers': [
								['bell_02',                       'Bells howl'],
								['buzzers_pistols_01',            'Buzzer'],
								['amzn_sfx_church_bell_1x_02',    'Church bell'],
								['amzn_sfx_doorbell_01',          'Doorbell 1'],
								['amzn_sfx_doorbell_chime_01',    'Doorbell 2'],
								['amzn_sfx_doorbell_chime_02',    'Doorbell 3'],
							],
							'Crowds': [
								['amzn_sfx_crowd_applause_01',    'Crowd applause'],
								['amzn_sfx_large_crowd_cheer_01', 'Crowd cheers'],
							],
							'Festive Season': [
								['christmas_05',                  'Christmas bells'],
								['horror_10',                     'Halloween creepy door'],
							],
							'Miscellaneous': [
								['air_horn_03',                   'Air horn'],
								['boing_01',                      'Boing 1'],
								['boing_03',                      'Boing 2'],
								['camera_01',                     'Camera'],
								['squeaky_12',                    'Squeaky door'],
								['clock_01',                      'Ticking clock'],
								['amzn_sfx_trumpet_bugle_04',     'Trumpet'],
							],
							'Sci-fi': [
								['futuristic_10',                 'Aircraft'],
								['amzn_sfx_scifi_engines_on_02',  'Engines on'],
								['amzn_sfx_scifi_alarm_04',       'Red alert'],
								['amzn_sfx_scifi_sheilds_up_01',  'Shields up'],
								['amzn_sfx_scifi_alarm_01',       'Sirens'],
								['zap_01',                        'Zap'],
							],
						}).appendTo(this);
						return () => ({ sound: select.arTypedInputOrSelect('data') });
					}
					groups.right.smarthome = function(data) {
						data = template(data, {entity: '' });
						const entity = arInputOrSelect(data.entity).appendTo(this);
						channel.entity = entity;

						const updateEntity = () => {
							const entities = loader.smarthome.entitiesWithActions;
							entity.arInputOrSelect('selectOptions', entities);
							entity.arInputOrSelect('selectActive', loader.success);
							if (!entity.arInputOrSelect('value') && loader.success) entity.arInputOrSelect('selectSomething');
						}
						loader.listen('change', entity, updateEntity);
						updateEntity();

						return () => ({ entity: entity.arInputOrSelect('value') });
					}
					groups.right.routine = function(data) {
						data = template(data, { routine: { type: 'str', value: ''} });
						const routine = arTypedInputOrSelect(data.routine).appendTo(this);

						const updateRoutine = () => {
							routine.arTypedInputOrSelect('selectOptions', loader.routines);
							routine.arTypedInputOrSelect('selectActiveMaybe', loader.success);
							if(!routine.arTypedInputOrSelect('value') && loader.success) routine.arTypedInputOrSelect('selectSomething');
						}
						loader.listen('change', routine, updateRoutine);
						updateRoutine();

						return () => ({ routine: routine.arTypedInputOrSelect('data') });
					}
					groups.right.skill = function(data) {
						data = template(data, { skill: { type: 'str', value: ''} });
						const skill = arTypedInputOrSelect(data.skill).appendTo(this);

						const updateSkill = () => {
							skill.arTypedInputOrSelect('selectOptions', loader.skills);
							skill.arTypedInputOrSelect('selectActiveMaybe', loader.success);
							if(!skill.arTypedInputOrSelect('value') && loader.success) skill.arTypedInputOrSelect('selectSomething');
						}
						loader.listen('change', skill, updateSkill);
						updateSkill();

						return () => ({ skill: skill.arTypedInputOrSelect('data') });
					}
					groups.right.pushNotification = function(data) {
						data = template(data, { text: { type: 'str', value: 'Hello from Alexa Remote!'}});
						const input = arTypedInput(data.text, ['str']).appendTo(this);
						return () => ({ text: input.arTypedInput('data') });
					}
					groups.right.node = function(data) {
						data = template(data, { type: 'serial' });
						const input = arSelect(data.type, ['serial', 'parallel']).appendTo(this);
						return () => ({ type: input.arSelect('value') });
					}
					groups.right.custom = function(data) {
						data = template(data, { type: 'msg', value: 'payload' });
						const input = arTypedInput(data, ['json']).appendTo(this);
						return () => input.arTypedInput('data');
					}

					groups.bottom.speak = function(data) {
						data = template(data, { text: { type: '', value: '' }, devices: undefined });
						const ssml = {
							value: "ssml",
							label: "SSML",
							expand: function () {
								RED.editor.editMarkdown({
									value: this.value().replace(/\t/g, "\n"),
									complete: (value) => {
										this.value(value.replace(/\n/g, "\t"));
									}
								})
							}
						}
						const text = arTypedInput(data.text, ['str']);
						const [devices, devicesRow] = common.bottom.row.deviceList(data.devices);
						const updateType = () => {
							const value = channel.type.arSelect('value');
							const type = value === 'ssml' ? ssml : 'str'
							text.arTypedInput('types', [type]);
						}
						channel.type.on('change', updateType);
						updateType();
						
						arRow(text, 'Text').appendTo(this);
						devicesRow.appendTo(this);
						return () => ({ text: text.arTypedInput('data'), devices: devices.arTypedInputOrInputList('data') });
					};
					groups.bottom.speakAtVolume = function(data) {
						data = template(data, { text: { type: 'str', value: '' }, volume: {type: 'num', value: '50'}, mode: undefined, devices: undefined });
						const ssml = {
							value: "ssml",
							label: "SSML",
							expand: function () {
								RED.editor.editMarkdown({
									value: this.value().replace(/\t/g, "\n"),
									complete: (value) => {
										this.value(value.replace(/\n/g, "\t"));
									}
								})
							}
						}
						const text = arTypedInput(data.text, ['str']);
						const [devices, devicesRow] = common.bottom.row.deviceList(data.devices);
						const volume = arTypedInput(data.volume, ['num']);
						const separator = $('<div>').css({ width: 10 });
						const mode = arSelect(data.mode || 'set', ['set', 'add']).attr('style', 'flex: 1 !important; max-width: 150px !important;');
						channel.type.on('change', () => {
							const value = channel.type.arSelect('value');
							const type = value === 'ssml' ? ssml : 'str'
							text.arTypedInput('types', [type]);
						});
						//arTips('<b>Warning:</b> Only works if the echo has recently been playing music!').appendTo(this);
						arRow(text, 'Text').appendTo(this);
						arRow(arGroup(volume, separator, mode), 'Volume').appendTo(this);
						devicesRow.appendTo(this).children('label');
						return () => ({ 
							text: text.arTypedInput('data'), 
							volume: volume.arTypedInput('data'),
							mode: mode.arSelect('value'),
							devices: devices.arTypedInputOrInputList('data'),
						});
					}
					groups.bottom.stop = common.bottom.group.deviceList(true);
					groups.bottom.prompt = common.bottom.group.deviceList(false);
					groups.bottom.phrase = common.bottom.group.deviceList(false);
					groups.bottom.sound = common.bottom.group.deviceList(false);
                    groups.bottom.textCommand = common.bottom.group.deviceList(false);
					groups.bottom.volume = function(data) {
						data = template(data, { mode: { type: 'str', value: 'set' }, devices: undefined });
						const mode = arTypedInputOrSelect(data.mode || 'set', ['set', 'add']);
						const [devices, devicesRow] = common.bottom.row.deviceList(data.devices);					
						arRow(mode, 'Mode').appendTo(this);
						devicesRow.appendTo(this);
						return () => ({ mode: mode.arTypedInputOrSelect('data'), devices: devices.arTypedInputOrInputList('data') });
					};
					groups.bottom.music = function(data) {
						data = template(data, {provider: { type: 'str', value: ''}, search: {type: 'str', value: ''}, duration: { type: 'num', value: ''}});
						const provider = arTypedInputOrSelect(data.provider);
						const search = arTypedInput(data.search);
						const duration = arTypedInput(data.duration, ['num'], { placeholder: 'Optional' });
						this.append(
							arRow(provider, 'Provider'), 
							arRow(search, 'Search'),
							arRow(duration, 'Duration'),
						);

						const updateProvider = () => {
							provider.arTypedInputOrSelect('selectOptions', loader.musicProviders);
							provider.arTypedInputOrSelect('selectActiveMaybe', loader.success);
							if (!provider.arTypedInputOrSelect('value') && loader.success) provider.arTypedInputOrSelect('selectSomething');
						}
						loader.listen('change', provider, updateProvider);
						updateProvider();

						return () => ({ provider: provider.arTypedInputOrSelect('data'), search: search.arTypedInput('data'), duration: duration.arTypedInput('data')});
					}
					groups.bottom.smarthome = function(data) {
						data = template(data, { entity: '', action: '', value: undefined, scale: undefined });
						const action = arInputOrSelect(data.action);
						const parameters = arInputGroups(data.action, data, {
							setColor:             common.bottom.group.color('Color', 'colorNameToHex', 'colorNames'),
							setColorTemperature:  common.bottom.group.color('Color Temp.', 'colorTemperatureNameToHex', 'colorTemperatureNames'),
							setBrightness:        common.bottom.group.number('Brightness'),
							setPercentage:        common.bottom.group.number('Percentage'),
							setLockState:         common.bottom.group.lockAction(),
							lockAction:           common.bottom.group.lockAction(),
							setTargetTemperature: function (data) {
								data = template(data, { value: { type: 'num', value: '20' }, scale: { type: 'str', value: 'celsius' } });
								const scale = arTypedInputOrSelect(data.scale, ['celsius', 'fahrenheit']);
								const value = arTypedInput(data.value, ['num']);
								arRow(scale, 'Scale').appendTo(this);
								arRow(value, 'Value').appendTo(this);
								return () => ({ scale: scale.arTypedInputOrSelect('data'), value: value.arTypedInput('data') });
							},
						});
					
						const updateAction = (user) => {
							const entityId = channel.entity.arInputOrSelect('value');
							const [label, properties, actions] = loader.smarthome.entityById[entityId] || [];
							if(actions) action.arInputOrSelect('selectOptions', actions);
							action.arInputOrSelect('selectActive', loader.success);
							if(!action.arInputOrSelect('value') && loader.success || user) action.arInputOrSelect('selectSomething'); 
						};
						action.on('change', () => parameters.arInputGroups('group', action.arInputOrSelect('value')));
						loader.listen('change', action, updateAction);
						channel.entity.on('change', () => updateAction(true));
						updateAction();

						this.append(arRow(action, 'Action'), parameters);

						return () => Object.assign({ action: action.arInputOrSelect('value') }, parameters.arInputGroups('value'));
					}
					groups.bottom.routine = common.bottom.group.device(false);
					groups.bottom.skill = common.bottom.group.device(false);
					groups.bottom.pushNotification = function(data) {
						data = template(data, { title: { type: 'str', value: 'Node-RED' } });
						const title = arTypedInput(data.title, ['str']);					
						arRow(title, 'Title').appendTo(this);
						return () => ({ title: title.arTypedInput('data') });
					};
					groups.bottom.node = function (data) {
						data = template(data, { children: [{}] }, 1);
						const list = arInputList(data.children, function (data) {
							const node = arRoutineNode(data, loader).css({ flex: '1' }).appendTo(this);
							return () => node.arRoutineNode('data');
						}).appendTo(this);
						return () => ({ children: list.arInputList('value') });
					}
					
					this.element.addClass('alexaRemote-arRoutineNode').css({marginTop: 12});
					this.select = $('<select>').css({minWidth: 150, flex: '1'}).appendTo(rowDiv).arSelect().arSelect('selectOptions', options).on('change', () => {
						const type = this.select.arSelect('value');
						for(const k in this.channel) delete this.channel[k];
						this.inputsRight.arInputGroups('group', type);
						this.inputsBottom.arInputGroups('group', type);
						if(Object.keys(groups.right).includes(type)) this.separator.show(); else this.separator.hide();
					});
					this.separator = $('<div>').css({width: 10}).appendTo(rowDiv);
					this.inputsRight = $('<div>').css({flex: '3', display: 'flex'}).appendTo(rowDiv).arInputGroups().arInputGroups('groups', groups.right);
					this.inputsBottom = $('<div>').css({marginTop: 12}).appendTo(this.element).arInputGroups().arInputGroups('groups', groups.bottom);

					this.type(this.select.arSelect('value'));
				},
				type: function(type) {
					if(arguments.length === 0) {
						return this.select.arSelect('value');
					}

					this.select.arSelect('value', type);
					this.inputsRight.arInputGroups('group', type);
					this.inputsBottom.arInputGroups('group', type);
				},
				value: function(value) {
					if(arguments.length === 0) {
						const right = this.inputsRight.arInputGroups('value');
						const bottom = this.inputsBottom.arInputGroups('value');
						return Object.assign({}, right, bottom);;
					}

					this.inputsRight.arInputGroups('value', value);
					this.inputsBottom.arInputGroups('value', value);
				},
				data: function(data) {
					if(arguments.length === 0) {
						return {
							type: this.type(),
							payload: this.value()
						}
					}

					data = template(data, {type: 'speak', payload: undefined});
					this.type(data.type);
					this.value(data.payload);
				}
			})

			const loader = new EventEmitter();
			loader.colorNameToHex = new Map([["blanched_almond", "#ffeacc"], ["pale_goldenrod", "#ede9aa"], ["deep_pink", "#ff1491"], ["cyan", "#00ffff"], ["light_goldenrod", "#f9f9d1"], ["pale_green", "#99f999"], ["medium_blue", "#0000cc"], ["dark_turquoise", "#00ced1"], ["hot_pink", "#ff68b6"], ["dark_olive_green", "#546b2d"], ["dodger_blue", "#1e8eff"], ["red", "#ff0000"], ["goldenrod", "#d8a421"], ["blue", "#0000ff"], ["fuchsia", "#ff00ff"], ["medium_turquoise", "#47d1cc"], ["light_steel_blue", "#afc4dd"], ["navajo_white", "#ffddad"], ["antique_white", "#f9ead6"], ["cornsilk", "#fff7db"], ["dark_slate_blue", "#483d8c"], ["light_pink", "#ffb5c1"], ["gainsboro", "#dbdbdb"], ["slate_blue", "#6a59cc"], ["light_slate_gray", "#778799"], ["wheat", "#f4ddb2"], ["plum", "#dda0dd"], ["dark_magenta", "#8c008c"], ["peach_puff", "#ffd8ba"], ["sea_green", "#2d8c56"], ["blue_violet", "#8a2be2"], ["burlywood", "#ddb687"], ["dark_cyan", "#008c8c"], ["dark_green", "#006300"], ["rebecca_purple", "#663399"], ["web_purple", "#7f007f"], ["pale_turquoise", "#afeded"], ["olive_drab", "#6a8e23"], ["dark_red", "#8c0000"], ["alice_blue", "#eff7ff"], ["medium_aquamarine", "#66ccaa"], ["orchid", "#d870d6"], ["old_lace", "#fcf4e5"], ["seashell", "#fff4ed"], ["brown", "#a52828"], ["dark_gray", "#a8a8a8"], ["dark_orange", "#ff8c00"], ["sandy_brown", "#f4a360"], ["dim_gray", "#686868"], ["turquoise", "#3fe0d0"], ["purple", "#a021ef"], ["tan", "#d1b58c"], ["pink", "#ffbfcc"], ["dark_goldenrod", "#b7860a"], ["misty_rose", "#ffe2e0"], ["aqua", "#00ffff"], ["yellow", "#ffff00"], ["light_gray", "#d3d3d3"], ["pale_violet_red", "#db7094"], ["medium_spring_green", "#00f99a"], ["light_sea_green", "#21b2ab"], ["forest_green", "#218c21"], ["moccasin", "#ffe1b5"], ["web_gray", "#7f7f7f"], ["deep_sky_blue", "#00bfff"], ["white_smoke", "#f4f4f4"], ["gold", "#ffd500"], ["lime", "#c7ff1f"], ["olive", "#7f7f00"], ["web_green", "#007f00"], ["light_coral", "#ef7f7f"], ["royal_blue", "#3f67e0"], ["floral_white", "#fff9ef"], ["navy_blue", "#00007f"], ["bisque", "#ffe2c4"], ["coral", "#ff7e4f"], ["yellow_green", "#99cc33"], ["salmon", "#ffa07a"], ["papaya_whip", "#ffefd6"], ["light_yellow", "#ffffe0"], ["medium_sea_green", "#3db270"], ["steel_blue", "#4482b5"], ["light_green", "#8eed8e"], ["firebrick", "#b22121"], ["midnight_blue", "#191970"], ["linen", "#f9efe5"], ["violet", "#ed82ed"], ["cadet_blue", "#5e9ea0"], ["light_salmon", "#ffa07a"], ["spring_green", "#00ff80"], ["mint_cream", "#f4fff9"], ["dark_khaki", "#bcb76b"], ["maroon", "#af3061"], ["web_maroon", "#7f0000"], ["dark_sea_green", "#8ebc8e"], ["crimson", "#db143c"], ["tomato", "#ff6347"], ["lawn_green", "#7efc00"], ["white", "#ffffff"], ["lavender", "#9f80ff"], ["green_yellow", "#afff2d"], ["chocolate", "#d1691e"], ["lavender_blush", "#ffeff4"], ["dark_orchid", "#9933cc"], ["sky_blue", "#87ceea"], ["magenta", "#ff00ff"], ["medium_violet_red", "#c61485"], ["gray", "#bfbfbf"], ["orange_red", "#ff4400"], ["silver", "#bfbfbf"], ["green", "#00ff00"], ["light_cyan", "#e0ffff"], ["chartreuse", "#80ff00"], ["dark_salmon", "#e8967a"], ["sienna", "#a0512d"], ["saddle_brown", "#8c4411"], ["thistle", "#d8bfd8"], ["lemon_chiffon", "#fff9cc"], ["light_blue", "#add8e5"], ["indigo", "#4a0082"], ["indian_red", "#cc5b5b"], ["medium_orchid", "#ba54d3"], ["dark_violet", "#9400d3"], ["ghost_white", "#f7f7ff"], ["lime_green", "#33cc33"], ["medium_purple", "#9470db"], ["teal", "#007f7f"], ["beige", "#f4f4db"], ["peru", "#cc833f"], ["dark_blue", "#00008c"], ["light_sky_blue", "#87cdf9"], ["ivory", "#ffffef"], ["honeydew", "#efffef"], ["dark_slate_gray", "#2d4f4f"], ["orange", "#ffa600"], ["cornflower", "#6393ed"], ["slate_gray", "#707f8e"], ["medium_slate_blue", "#7a68ed"], ["azure", "#efffff"], ["powder_blue", "#afe0e5"], ["snow", "#fff9f9"], ["aquamarine", "#7fffd2"], ["khaki", "#efe58c"], ["black", "#000000"], ["rosy_brown", "#bc8e8e"],]);
			loader.colorTemperatureNameToHex = new Map([["sunset", "#ff9227"], ["warm", "#ff9227"], ["evening", "#ff9227"], ["warm_white", "#ff9227"], ["candlelight", "#ff9227"], ["relax", "#ff9227"], ["soft_white", "#ffa757"], ["incandescent", "#ffa757"], ["soft", "#ffa757"], ["reading_white", "#ffa757"], ["reading", "#ffa757"], ["white", "#ffcea6"], ["daytime", "#ffedde"], ["daylight_white", "#ffedde"], ["daytime_white", "#ffedde"], ["daylight", "#ffedde"], ["cool_white", "#f3f2ff"], ["cool", "#f3f2ff"], ["bright_white", "#f3f2ff"]]);
			loader.update = function(success, account='', devices, routines, musicProviders, smarthome, messages, skills) {
				this.success 				= success;
				this.account 				= account;
				this.devices 				= !success ? [] : devices;
				this.routines 				= !success ? [] : routines;
				this.musicProviders			= !success ? [] : musicProviders;
				this.smarthome				= !success ? { colorNames: [], colorTemperatureNames: [], entityById: {}} : smarthome;
				this.smarthome.entities		= Object.entries(this.smarthome.entityById).map(([id, [label, properties, actions, type]]) => [id, label, properties, actions, type]);
				this.smarthome.entitiesWithActions 			= this.smarthome.entities.filter(([id, label, properties, actions]) => actions.length !== 0);
				//this.smarthome.entitiesWithProperties 	= this.smarthome.entities.filter(([id, label, properties, actions]) => properties.length !== 0);
				//this.smarthome.entitiesThatAreAppliances 	= this.smarthome.entities.filter(([id, label, properties, actions, type]) => type === 'APPLIANCE');
				//this.smarthome.entitiesThatAreGroups 		= this.smarthome.entities.filter(([id, label, properties, actions, type]) => type === 'GROUP');
				this.messages       = !success ? {} : messages;
				this.skills         = !success ? [] : skills;
				this.emit('change', this.success);
			}
			loader.load = function() {
				const account = $('#node-input-account').val();
				if(loader.account === account) return /*console.log('updateLoader', {result: 'same account', account: account, loader: loader})*/;
				loader.update(false, account);
				if(!account || account === '_ADD_') return /*console.log('updateLoader', {result: 'invalid account', account: account, loader: loader})*/;

				const getDevices = $.get('alexa-remote-devices.json', { account: account }, null, 'json');
				const getRoutines = $.get('alexa-remote-routines.json', { account: account }, null, 'json');
				const getMusicProviders = $.get('alexa-remote-musicProviders.json', { account: account }, null, 'json');
				const getSmarthome = $.get('alexa-remote-smarthome.json', { account: account }, null, 'json');
				const getMessages = $.get('alexa-remote-error-messages.json', { account: account }, null, 'json');
				const getSkills = $.get('alexa-remote-skills.json', { account: account }, null, 'json');

				$.when(getDevices, getRoutines, getMusicProviders, getSmarthome, getMessages, getSkills)
					.done(([devices], [routines], [musicProviders], [smarthome], [messages], [skills]) => { 
						loader.update(true, account, devices, routines, musicProviders, smarthome, messages, skills); 
						/*console.log('updateLoader', {result: 'success', account: account, loader: loader});*/ 
					})
					.fail(res => RED.notify(res.responseText || 'Unknown error, reopen this node...', 'error'));
			}
			loader.update(false);
			loader.on('change', (success) => console.log('loaderChange', {state: success ? 'SUCCESS' : 'FAILURE', loader:loader}));

			$('#dialog-form').css({ display: 'flex', flexDirection: 'column' });
			const inputs = $('#node-input-routineNode_div')
				.arRoutineNode({ loader: loader })
				.arRoutineNode('data', this.routineNode);

			const info = arTips().insertBefore(inputs);

			function updateInfo() {
				if(!loader.success) {
					return info.arTips('show', '<b>Note:</b> Select an initialised account first to be able to select from a list of devices!');
				}

				let message = '';

				if (loader.messages.devices) message += `Loading echo devices failed: "${loader.messages.devices}". `;
				if (loader.messages.smarthome) message += `Loading smarthome appliances failed: "${loader.messages.smarthome}". `;
				if (loader.messages.colors) message += `Loading smarthome colors failed: "${loader.messages.colors}". `;
				if (loader.messages.musicProviders) message += `Loading music providers failed: "${loader.messages.musicProviders}". `;
				if (loader.messages.routines) message += `Loading routines failed: "${loader.messages.routines}". `;

				info.arTips('show', message ? `<b>Warning:</b> ` + message : false);
			}
			loader.on('change', updateInfo);
			updateInfo();

			$('#node-input-account').on('change', () => loader.load());
			loader.load();
		},
		oneditsave: function () {
			this.routineNode = $('#node-input-routineNode_div').arRoutineNode('data');
			console.log('saved', this.routineNode);
		},
	});
</script>