node-red-contrib-knx-ultimate
Version:
Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control, ETS group address importer, KNX AI for diagnosticsand KNX routing between interfaces. Easy to use and highly configurable.
608 lines (556 loc) • 24.1 kB
HTML
<script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/11f26b4500.js"></script>
<script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/htmlUtils.js"></script>
<script type="text/javascript">
(function () {
let $tabs = null;
let $requiresBridgeElems = null;
let $knxSections = null;
let $deviceName = null;
let $refreshButton = null;
let $loadingIndicator = null;
let $dptSelect = null;
let $enablePinsSelect = null;
let $outputInfo = null;
let cachedDevices = [];
let defaultDevicePlaceholder = '';
let showingNoDevicesPlaceholder = false;
let currentNode = null;
const EMPTY_SERVER_VALUES = new Set(['', 'none', '_add_', '__none__', '__null__', 'null', 'undefined']);
const ALLOWED_DPT_PREFIXES = ['3.007', '5.001', '232.600'];
const ensureVerticalTabsStyle = () => {
if ($('#knxUltimateHueTapDialVerticalTabs').length) return;
const style = `
<style id="knxUltimateHueTapDialVerticalTabs">
.hue-vertical-tabs.ui-tabs.ui-widget.ui-widget-content.ui-corner-all {
display: flex;
border: none;
padding: 0;
}
.hue-vertical-tabs > ul.ui-tabs-nav {
flex: 0 0 160px;
border-right: 1px solid #ccc;
border-left: none;
border-top: none;
border-bottom: none;
padding: 0.5em 0.3em;
}
.hue-vertical-tabs > ul.ui-tabs-nav li {
float: none;
width: 100%;
margin: 0 0 2px 0;
}
.hue-vertical-tabs > ul.ui-tabs-nav li a {
display: block;
width: 100%;
white-space: nowrap;
position: relative;
border-bottom: none !important;
}
.hue-vertical-tabs > ul.ui-tabs-nav li.ui-tabs-active {
border-bottom: none !important;
}
.hue-vertical-tabs > ul.ui-tabs-nav li.ui-tabs-active a::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 50%;
height: 3px;
background: currentColor;
}
.hue-vertical-tabs .ui-tabs-panel {
flex: 1;
padding: 0.8em 1em;
box-sizing: border-box;
border: none;
background: transparent;
}
.hue-vertical-tabs .form-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
}
.hue-vertical-tabs .hue-form-tip {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
margin-left: 0 !important;
max-width: none;
color: #1b7d33;
margin-bottom: 6px;
padding: 6px 10px;
box-sizing: border-box;
}
.hue-vertical-tabs .hue-form-tip .fa {
color: forestgreen;
flex: 0 0 auto;
}
.hue-vertical-tabs .hue-form-tip span {
flex: 1 1 auto;
min-width: 0;
white-space: normal;
}
</style>`;
$('head').append(style);
};
const detachHandlers = () => {
$('#node-input-server').off('.knxUltimateHueTapDial');
$('#node-input-serverHue').off('.knxUltimateHueTapDial');
if ($deviceName) {
$deviceName.off('.knxUltimateHueTapDial');
if ($deviceName.data('ui-autocomplete')) {
try { $deviceName.autocomplete('destroy'); } catch (error) { /* empty */ }
}
}
if ($refreshButton) {
$refreshButton.off('.knxUltimateHueTapDial');
}
const $gaInput = $('#node-input-GArepeat');
if ($gaInput.length) {
$gaInput.off('.knxUltimateHueTapDial');
if ($gaInput.data('ui-autocomplete')) {
try { $gaInput.autocomplete('destroy'); } catch (error) { /* empty */ }
}
}
if ($enablePinsSelect) {
$enablePinsSelect.off('.knxUltimateHueTapDial');
}
if ($tabs && $tabs.data('ui-tabs')) {
try { $tabs.tabs('destroy'); } catch (error) { /* empty */ }
}
};
const ensureConfigSelection = (selector) => {
const $select = $(selector);
if (!$select.length) return;
if ($select.val() !== '_ADD_') return;
try { $select.prop('selectedIndex', 0); } catch (error) { /* empty */ }
};
const resolveServerId = (value) => {
if (value === undefined || value === null) return null;
if (value === false) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed === '') return null;
if (EMPTY_SERVER_VALUES.has(trimmed.toLowerCase())) return null;
return trimmed;
}
const asString = String(value).trim();
if (asString === '' || EMPTY_SERVER_VALUES.has(asString.toLowerCase())) return null;
return value;
};
const getKnxServer = (allowFallback = true) => {
const resolved = resolveServerId($('#node-input-server').val());
if (resolved) return RED.nodes.node(resolved);
if (!allowFallback) return null;
const fallback = resolveServerId(currentNode ? currentNode.server : null);
return fallback ? RED.nodes.node(fallback) : null;
};
const getHueServer = (allowFallback = true) => {
const resolved = resolveServerId($('#node-input-serverHue').val());
if (resolved) return RED.nodes.node(resolved);
if (!allowFallback) return null;
const fallback = resolveServerId(currentNode ? currentNode.serverHue : null);
return fallback ? RED.nodes.node(fallback) : null;
};
const hasKnxSelection = () => {
const resolved = resolveServerId($('#node-input-server').val());
if (resolved) return true;
if ($('#node-input-server').length) return false;
return resolveServerId(currentNode ? currentNode.server : null) !== null;
};
const hasHueSelection = () => {
const resolved = resolveServerId($('#node-input-serverHue').val());
if (resolved) return true;
if ($('#node-input-serverHue').length) return false;
return resolveServerId(currentNode ? currentNode.serverHue : null) !== null;
};
const normalizePinsValue = (value) => {
if (value === undefined || value === null || value === '') return 'yes';
if (value === true || value === 'true') return 'yes';
if (value === false || value === 'false') return 'no';
return value === 'no' ? 'no' : 'yes';
};
const applyNoDevicesPlaceholder = (hasDevices) => {
if (!$deviceName) return;
const noDevicesText = RED._('node-red-contrib-knx-ultimate/knxUltimateHueTapDial:knxUltimateHueTapDial.no_devices');
if (hasDevices) {
if (showingNoDevicesPlaceholder) {
$deviceName.attr('placeholder', defaultDevicePlaceholder);
showingNoDevicesPlaceholder = false;
}
return;
}
if (!showingNoDevicesPlaceholder) {
$deviceName.attr('placeholder', noDevicesText);
showingNoDevicesPlaceholder = true;
}
};
const filterDevices = (devices, term) => {
const cleaned = (term || '').replace(/exactmatch/gi, '').trim().toLowerCase();
return devices
.filter((value) => (value.name || '').toLowerCase().includes(cleaned))
.map((value) => ({ hueDevice: value.id, value: value.name }));
};
const fetchDevices = (hueServer, term, response, { forceRefresh = false } = {}) => {
if (!hueServer) {
applyNoDevicesPlaceholder(false);
response([]);
return;
}
if (!forceRefresh && cachedDevices.length > 0) {
applyNoDevicesPlaceholder(cachedDevices.length > 0);
response(filterDevices(cachedDevices, term));
return;
}
if ($loadingIndicator) $loadingIndicator.show();
const refreshQuery = forceRefresh ? '&forceRefresh=1' : '';
$.getJSON(`KNXUltimateGetResourcesHUE?rtype=relative_rotary&serverId=${encodeURIComponent(hueServer.id)}${refreshQuery}&_=${Date.now()}`, (data) => {
const listCandidates = Array.isArray(data) ? data : (Array.isArray(data?.devices) ? data.devices : []);
cachedDevices = listCandidates.map((value) => ({
id: value.id || value.rid,
name: value.name || value.metadata?.name || '',
}));
if (currentNode) currentNode._cachedTapDialDevices = cachedDevices;
applyNoDevicesPlaceholder(cachedDevices.length > 0);
response(filterDevices(cachedDevices, term));
}).always(() => {
if ($loadingIndicator) $loadingIndicator.hide();
}).fail(() => {
cachedDevices = [];
if (currentNode) currentNode._cachedTapDialDevices = cachedDevices;
applyNoDevicesPlaceholder(false);
response([]);
});
};
const loadDPTOptions = (serverCandidate, nodeRef) => {
if (!$dptSelect) return;
$dptSelect.empty();
const server = (() => {
const resolved = resolveServerId(serverCandidate);
if (resolved) return RED.nodes.node(resolved);
return getKnxServer(false);
})();
if (!server) return;
$.getJSON(`knxUltimateDpts?serverId=${server.id}`, (data) => {
data.forEach((dpt) => {
if (ALLOWED_DPT_PREFIXES.some((prefix) => dpt.value.startsWith(prefix))) {
$dptSelect.append($('<option></option>').attr('value', dpt.value).text(dpt.text));
}
});
const target = nodeRef?.dptrepeat && nodeRef.dptrepeat !== ''
? nodeRef.dptrepeat
: ($dptSelect.children().first().attr('value') || '3.007');
$dptSelect.val(target);
});
};
const attachGroupAddressAutocomplete = () => {
const $input = $('#node-input-GArepeat');
const $nameWidget = $('#node-input-namerepeat');
if (!$input.length) return;
if ($input.data('ui-autocomplete')) {
try { $input.autocomplete('destroy'); } catch (error) { /* empty */ }
}
$input.autocomplete({
minLength: 0,
source(request, response) {
const server = getKnxServer(false);
if (!server) { response([]); return; }
$.getJSON(`knxUltimatecsv?nodeID=${server.id}`, (data) => {
const matches = [];
data.forEach((value) => {
if (!value.dpt) return;
if (!ALLOWED_DPT_PREFIXES.some((prefix) => value.dpt.startsWith(prefix))) return;
const sSearch = `${value.ga} (${value.devicename}) DPT${value.dpt}`;
if (htmlUtilsfullCSVSearch(sSearch, request.term)) {
matches.push({
label: `${value.ga} # ${value.devicename} # ${value.dpt}`,
value: value.ga,
});
}
});
response(matches);
});
},
select(event, ui) {
let sDevName = ui.item.label.split('#')[1]?.trim() || '';
try {
sDevName = sDevName.substr(sDevName.indexOf(')') + 1).trim();
} catch (error) { /* empty */ }
if ($nameWidget) $nameWidget.val(sDevName);
const dptLabel = ui.item.label.split('#')[2]?.trim();
const optVal = dptLabel ? $dptSelect.find(`option:contains('${dptLabel}')`).attr('value') : undefined;
if (optVal !== undefined && optVal !== null) {
$dptSelect.val(optVal).trigger('change');
} else {
$dptSelect.trigger('change');
}
},
});
$input.on('focus.knxUltimateHueTapDial', function () {
$(this).autocomplete('search', `${$(this).val()}exactmatch`);
});
const server = getKnxServer(false);
if (server && server.id) {
try { KNX_enableSecureFormatting($input, server.id); } catch (error) { /* empty */ }
}
};
const updateTabsVisibility = () => {
if (!$tabs) return;
const hueSelected = hasHueSelection();
const knxSelected = hasKnxSelection();
if ($requiresBridgeElems) {
if (hueSelected) {
$requiresBridgeElems.show();
} else {
$requiresBridgeElems.hide();
}
}
if (hueSelected && knxSelected) {
$tabs.show();
$tabs.tabs('refresh');
} else {
$tabs.hide();
}
if ($outputInfo) {
if (knxSelected) {
$outputInfo.hide();
} else {
$outputInfo.show();
}
}
if ($enablePinsSelect && $enablePinsSelect.length) {
const desiredPins = knxSelected ? 'no' : 'yes';
if ($enablePinsSelect.val() !== desiredPins) {
$enablePinsSelect.val(desiredPins).trigger('change');
}
}
};
const updateKnxVisibility = () => {
const knxSelected = hasKnxSelection();
if ($knxSections) {
if (knxSelected) {
$knxSections.show();
} else {
$knxSections.hide();
}
}
updateTabsVisibility();
};
const updatePinsState = () => {
if (!currentNode || !$enablePinsSelect) return;
const val = normalizePinsValue($enablePinsSelect.val());
currentNode.enableNodePINS = val;
currentNode.outputs = val === 'yes' ? 1 : 0;
};
RED.nodes.registerType('knxUltimateHueTapDial', {
category: 'KNX Ultimate HUE',
color: '#C0C7E9',
defaults: {
server: { type: 'knxUltimate-config', required: false },
serverHue: { type: 'hue-config', required: true },
name: { value: '' },
namerepeat: { value: '' },
GArepeat: { value: '' },
dptrepeat: { value: '3.007' },
hueDevice: { value: '' },
enableNodePINS: { value: 'yes' },
outputs: { value: 1 },
},
inputs: 0,
outputs: 1,
icon: 'node-hue-icon.svg',
label() {
return this.name || RED._('node-red-contrib-knx-ultimate/knxUltimateHueTapDial:knxUltimateHueTapDial.paletteLabel');
},
paletteLabel: 'Hue Tap Dial',
oneditprepare() {
try { RED.sidebar.show('help'); } catch (error) { /* empty */ }
const node = this;
currentNode = node;
ensureConfigSelection('#node-input-serverHue');
ensureVerticalTabsStyle();
$tabs = $('#hue-tapdial-tabs');
$requiresBridgeElems = $('.hue-requires-bridge');
$knxSections = $('.hue-knx-section');
$deviceName = $('#node-input-name');
$refreshButton = $('.hue-refresh-devices');
$loadingIndicator = $('.hue-devices-loading');
$dptSelect = $('#node-input-dptrepeat');
$enablePinsSelect = $('#node-input-enableNodePINS');
$outputInfo = $('.hue-output-info');
cachedDevices = Array.isArray(node._cachedTapDialDevices) ? node._cachedTapDialDevices : [];
node._cachedTapDialDevices = cachedDevices;
defaultDevicePlaceholder = $deviceName.attr('placeholder') || '';
showingNoDevicesPlaceholder = false;
applyNoDevicesPlaceholder(cachedDevices.length > 0);
$tabs.addClass('hue-vertical-tabs');
$tabs.tabs();
$tabs.find('li').removeClass('ui-corner-top').addClass('ui-corner-left');
const initialServerDomValue = $('#node-input-server').val();
const initialServerId = initialServerDomValue === undefined ? node.server : initialServerDomValue;
loadDPTOptions(initialServerId, node);
attachGroupAddressAutocomplete();
if ($deviceName) {
$deviceName.autocomplete({
minLength: 0,
source(request, response) {
const hueServer = getHueServer(false);
if (!hueServer) { response([]); return; }
fetchDevices(hueServer, request.term, response);
},
select(event, ui) {
$('#node-input-hueDevice').val(ui.item.hueDevice);
updateTabsVisibility();
},
});
$deviceName.on('focus.knxUltimateHueTapDial', function () {
$(this).autocomplete('search', `${$(this).val()}exactmatch`);
});
}
if ($refreshButton) {
$refreshButton.on('click.knxUltimateHueTapDial', () => {
cachedDevices = [];
node._cachedTapDialDevices = cachedDevices;
const hueServer = getHueServer(false);
if (!hueServer) return;
fetchDevices(hueServer, '', () => {
if ($deviceName) {
$deviceName.autocomplete('search', `${$deviceName.val()}exactmatch`);
}
}, { forceRefresh: true });
});
}
if ($enablePinsSelect) {
$enablePinsSelect.val(normalizePinsValue(node.enableNodePINS));
$enablePinsSelect.on('change.knxUltimateHueTapDial', updatePinsState);
updatePinsState();
}
$('#node-input-server').on('change.knxUltimateHueTapDial', function () {
const serverId = $(this).val();
loadDPTOptions(serverId, node);
attachGroupAddressAutocomplete();
updateKnxVisibility();
});
$('#node-input-serverHue').on('change.knxUltimateHueTapDial', () => {
cachedDevices = [];
node._cachedTapDialDevices = cachedDevices;
if ($deviceName) {
$deviceName.val('');
$('#node-input-hueDevice').val('');
applyNoDevicesPlaceholder(false);
}
updateTabsVisibility();
});
updateKnxVisibility();
},
oneditsave() {
try { RED.sidebar.show('info'); } catch (error) { /* empty */ }
detachHandlers();
const pinsSelection = $enablePinsSelect ? normalizePinsValue($enablePinsSelect.val()) : 'yes';
this.enableNodePINS = pinsSelection;
this.outputs = pinsSelection === 'yes' ? 1 : 0;
this._cachedTapDialDevices = cachedDevices;
currentNode = null;
},
oneditcancel() {
try { RED.sidebar.show('info'); } catch (error) { /* empty */ }
detachHandlers();
cachedDevices = [];
this._cachedTapDialDevices = [];
currentNode = null;
},
});
}());
</script>
<script type="text/html" data-template-name="knxUltimateHueTapDial">
<div class="form-row">
<label for="node-input-server">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAKnRFWHRDcmVhdGlvbiBUaW1lAEZyIDYgQXVnIDIwMTAgMjE6NTI6MTkgKzAxMDD84aS8AAAAB3RJTUUH3gYYCicNV+4WIQAAAAlwSFlzAAALEgAACxIB0t1+/AAAAARnQU1BAACxjwv8YQUAAACUSURBVHjaY2CgFZg5c+Z/ZEyWAZ8+f/6/ZsWs/xoamqMGkGrA6Wla/1+fVARjEBuGsSoGmY4eZSCNL59d/g8DIDbIAHR14OgFGQByKjIGKX5+6/T///8gGMQGiV1+/B0Fg70GIkD+RMYgxf/O5/7//2MSmAZhkBi6OrgB6Bg5DGB4ajr3f2xqsYYLSDE2THJUDg0AAAqyDVd4tp4YAAAAAElFTkSuQmCC" />
<span data-i18n="common.knx_gw"></span>
</label>
<input type="text" id="node-input-server">
</div>
<div class="form-row">
<label for="node-input-serverHue">
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAEKADAAQAAAABAAAAEAAAAAA0VXHyAAABFUlEQVQ4EZWSsWoCQRCG1yiENEFEi6QSkjqWWoqFoBYJ+Br6JHkMn8Iibd4ihQpaJIhWNkry/ZtdGZY78Qa+m39nZ+dm9s4550awglNBluS/gVtAX6KgDclf68w2OThgfR9iT/jnoEv4TtByDThWTCDKW4SSZTf/zj9/eZbN+izTDuKGimu0vPF8B/YN8aC8LmcOj/AAn9CFTEs70Js/oGqy79C69bqJ5XbQI2kGO5N8QL9D08S8zBtBF5ZaVsznpCMoqJnVdjTpb1Db0fwIWmQV6BLXzFOYgA6/gDVfQN9bBWp2J2hdWDPoBV5FrKnAJutHikk/CHHR8i7x4iG7qQ720IYvu3GFbpHjx3pFrOFYkA354z/5bkK826phyAAAAABJRU5ErkJggg=="/>
<span data-i18n="common.hue_bridge"></span>
</label>
<input type="text" id="node-input-serverHue">
</div>
<div class="form-row hue-requires-bridge">
<label for="node-input-name">
<i class="fa fa-rotate-right"></i> <span data-i18n="knxUltimateHueTapDial.hue_device"></span>
</label>
<input type="text" id="node-input-name" placeholder="Hue Tap dial" style="flex:1 1 240px; min-width:240px; max-width:240px;">
<button type="button" class="red-ui-button hue-refresh-devices" style="margin-left:6px; color:#1b7d33; border-color:#1b7d33;">
<i class="fa fa-sync"></i>
</button>
<span class="hue-devices-loading" style="margin-left:6px; display:none; color:#1b7d33;">
<i class="fa fa-circle-notch fa-spin"></i>
</span>
</div>
<div id="hue-tapdial-tabs">
<ul>
<li><a href="#hue-tapdial-tab-mapping"><i class="fa fa-map"></i> <span data-i18n="knxUltimateHueTapDial.tabs.mapping"></span></a></li>
<li><a href="#hue-tapdial-tab-behaviour"><i class="fa fa-gear"></i> <span data-i18n="knxUltimateHueTapDial.tabs.behaviour"></span></a></li>
</ul>
<div id="hue-tapdial-tab-mapping">
<div class="form-tips hue-form-tip hue-knx-section">
<i class="fa fa-circle-info"></i>
<span data-i18n="knxUltimateHueTapDial.mapping_info"></span>
</div>
<div class="form-row hue-knx-section">
<label for="node-input-GArepeat" style="width:70px;"><span data-i18n="common.ga"></span></label>
<input type="text" id="node-input-GArepeat" placeholder="1/1/1" style="width:80px; text-align:left;">
<label for="node-input-dptrepeat" style="width:40px; text-align:right;"><span data-i18n="common.dpt"></span></label>
<select id="node-input-dptrepeat" style="width:130px;"></select>
<label for="node-input-namerepeat" style="width:50px; text-align:right;"><span data-i18n="common.name"></span></label>
<input type="text" id="node-input-namerepeat" style="flex:1 1 140px; min-width:120px; text-align:left;" placeholder="Rotation">
</div>
</div>
<div id="hue-tapdial-tab-behaviour">
<div class="form-tips hue-form-tip">
<i class="fa fa-circle-info"></i>
<span data-i18n="knxUltimateHueTapDial.behaviour_info"></span>
</div>
<div class="form-row">
<label for="node-input-enableNodePINS" style="width:220px;">
<i class="fa fa-code"></i> <span data-i18n="knxUltimateHueTapDial.node_pins"></span>
</label>
<select id="node-input-enableNodePINS" style="width:200px;">
<option value="yes" data-i18n="knxUltimateHueTapDial.node_pins_show"></option>
<option value="no" data-i18n="knxUltimateHueTapDial.node_pins_hide"></option>
</select>
</div>
</div>
</div>
<div class="form-tips hue-form-tip hue-output-info" style="display:none;">
<i class="fa fa-circle-info"></i>
<span data-i18n="knxUltimateHueTapDial.output_info"></span>
</div>
<input type="hidden" id="node-input-hueDevice">
</script>
<script type="text/markdown" data-help-name="knxUltimateHueTapDial">
The **Hue Tap Dial** node maps the rotary service of the Hue Tap Dial to KNX and forwards the raw Hue events to your flow. Use the refresh icon beside the device field after pairing a new dial on the bridge.
### Tabs
- **Mapping** - select the KNX GA and DPT used for the rotation events. Supported datapoints: DPT 3.007 (relative dim), DPT 5.001 (absolute level 0-100 %) and DPT 232.600 (vendor colour control).
- **Behaviour** - show or hide the Node-RED output pin. When no KNX gateway is configured the output is kept enabled so Hue events still reach the flow.
### General settings
|Property|Description|
|--|--|
| KNX GW | KNX gateway used for GA autocomplete. |
| Hue Bridge | Hue Bridge hosting the Tap Dial. |
| Hue Tap Dial | Rotary device to control (autocomplete; refresh button reloads the list). |
### Mapping tab
|Property|Description|
|--|--|
| Rotate GA | KNX GA receiving rotation events (supports DPT 3.007, 5.001, 232.600). |
| Name | Friendly label for the GA. |
### Outputs
|#|Port|Payload|
|--|--|--|
|1|Standard output|`msg.payload` (object) Raw Hue event emitted by the Tap Dial.|
> ℹ️ KNX-specific widgets appear only after selecting a KNX gateway; the Mapping tab stays hidden until both the bridge and the gateway are configured.
</script>