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.
602 lines (549 loc) • 23.6 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 ensureVerticalTabsStyle = () => {
if ($('#knxUltimateHueMotionVerticalTabs').length) return;
const style = `
<style id="knxUltimateHueMotionVerticalTabs">
.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 144px;
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('.knxUltimateHueMotion');
$('#node-input-serverHue').off('.knxUltimateHueMotion');
if ($deviceName) {
$deviceName.off('.knxUltimateHueMotion');
if ($deviceName.data('ui-autocomplete')) {
try { $deviceName.autocomplete('destroy'); } catch (error) { /* empty */ }
}
}
if ($refreshButton) {
$refreshButton.off('.knxUltimateHueMotion');
}
const $gaInput = $('#node-input-GAmotion');
if ($gaInput.length) {
$gaInput.off('.knxUltimateHueMotion');
if ($gaInput.data('ui-autocomplete')) {
try { $gaInput.autocomplete('destroy'); } catch (error) { /* empty */ }
}
}
if ($enablePinsSelect) {
$enablePinsSelect.off('.knxUltimateHueMotion');
}
};
const ensureConfigSelection = (selector) => {
if ($(selector).val() !== '_ADD_') return;
try { $(selector).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;
};
const applyNoDevicesPlaceholder = (hasDevices) => {
if (!$deviceName) return;
if (hasDevices) {
if (showingNoDevicesPlaceholder) {
showingNoDevicesPlaceholder = false;
$deviceName.attr('placeholder', defaultDevicePlaceholder);
}
return;
}
const message = RED._('node-red-contrib-knx-ultimate/knxUltimateHueMotion:knxUltimateHueMotion.no_devices');
showingNoDevicesPlaceholder = true;
$deviceName.attr('placeholder', message);
if (($deviceName.val() || '').trim() === '') {
$deviceName.val('');
}
};
const filterDevices = (devices, term) => {
const cleaned = (term || '').replace(/exactmatch/gi, '').trim();
return $.map(devices, (value) => {
const sSearch = value.name;
if (cleaned === '' || htmlUtilsfullCSVSearch(sSearch, cleaned)) {
return {
hueDevice: value.id,
value: value.name,
deviceObject: value.deviceObject || value,
};
}
return null;
});
};
const fetchDevices = (hueServer, term, response, { forceRefresh = false } = {}) => {
if (!hueServer) {
applyNoDevicesPlaceholder(true);
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=motion&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 || '',
deviceObject: value.deviceObject || value,
}));
if (currentNode) currentNode._cachedMotionDevices = cachedDevices;
applyNoDevicesPlaceholder(cachedDevices.length > 0);
response(filterDevices(cachedDevices, term));
}).always(() => {
if ($loadingIndicator) $loadingIndicator.hide();
}).fail(() => {
cachedDevices = [];
if (currentNode) currentNode._cachedMotionDevices = 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 (dpt.value.startsWith('1.')) {
$dptSelect.append($('<option></option>').attr('value', dpt.value).text(dpt.text));
}
});
const referenceNode = nodeRef || currentNode || {};
const targetDpt = referenceNode.dptmotion || '1.001';
if ($dptSelect.children().length) $dptSelect.val(targetDpt);
});
};
const attachGroupAddressAutocomplete = () => {
const $input = $('#node-input-GAmotion');
const $nameWidget = $('#node-input-namemotion');
if (!$input.length) return;
$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 || !value.dpt.startsWith('1.')) 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.knxUltimateHueMotion', function () {
$(this).autocomplete('search', `${$(this).val()}exactmatch`);
});
const server = getKnxServer(false);
if (server && server.id) KNX_enableSecureFormatting($input, server.id);
};
const updateKnxVisibility = () => {
const knxSelected = hasKnxSelection();
if (knxSelected) {
$knxSections.show();
} else {
$knxSections.hide();
}
updateTabsVisibility();
};
const updateTabsVisibility = () => {
if (!$tabs) return;
const hueSelected = hasHueSelection();
const knxSelected = hasKnxSelection();
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 updatePinsState = () => {
if (!$enablePinsSelect || !currentNode) return;
const val = normalizePinsValue($enablePinsSelect.val());
currentNode.enableNodePINS = val;
currentNode.outputs = val === 'yes' ? 1 : 0;
};
RED.nodes.registerType('knxUltimateHueMotion', {
category: 'KNX Ultimate HUE',
color: '#C0C7E9',
defaults: {
server: { type: 'knxUltimate-config', required: false },
serverHue: { type: 'hue-config', required: true },
name: { value: '' },
namemotion: { value: '' },
GAmotion: { value: '' },
dptmotion: { value: '1.001' },
enableNodePINS: { value: 'yes' },
hueDevice: { value: '' },
outputs: { value: 1 },
},
inputs: 0,
outputs: 1,
icon: 'node-hue-icon.svg',
label() {
return this.name || RED._('node-red-contrib-knx-ultimate/knxUltimateHueMotion:knxUltimateHueMotion.paletteLabel');
},
paletteLabel: 'Hue Motion',
oneditprepare() {
try { RED.sidebar.show('help'); } catch (error) { /* empty */ }
const node = this;
currentNode = node;
ensureConfigSelection('#node-input-serverHue');
ensureVerticalTabsStyle();
$tabs = $('#hue-motion-tabs');
$requiresBridgeElems = $('.hue-requires-bridge');
$knxSections = $('.hue-knx-section');
$deviceName = $('#node-input-name');
$refreshButton = $('.hue-refresh-devices');
$loadingIndicator = $('.hue-devices-loading');
$dptSelect = $('#node-input-dptmotion');
$enablePinsSelect = $('#node-input-enableNodePINS');
$outputInfo = $('.hue-output-info');
cachedDevices = Array.isArray(node._cachedMotionDevices) ? node._cachedMotionDevices : [];
node._cachedMotionDevices = cachedDevices;
defaultDevicePlaceholder = $deviceName.attr('placeholder') || '';
showingNoDevicesPlaceholder = false;
$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);
},
});
$deviceName.on('focus.knxUltimateHueMotion', function () {
$(this).autocomplete('search', `${$(this).val()}exactmatch`);
});
}
if ($refreshButton) {
$refreshButton.on('click.knxUltimateHueMotion', () => {
cachedDevices = [];
node._cachedMotionDevices = 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.knxUltimateHueMotion', updatePinsState);
updatePinsState();
}
$('#node-input-server').on('change.knxUltimateHueMotion', function () {
const serverId = $(this).val();
loadDPTOptions(serverId, node);
attachGroupAddressAutocomplete();
updateKnxVisibility();
});
$('#node-input-serverHue').on('change.knxUltimateHueMotion', function () {
cachedDevices = [];
node._cachedMotionDevices = cachedDevices;
if ($loadingIndicator) $loadingIndicator.hide();
showingNoDevicesPlaceholder = false;
if ($deviceName) $deviceName.attr('placeholder', defaultDevicePlaceholder);
if (!hasHueSelection()) {
applyNoDevicesPlaceholder(true);
}
updateTabsVisibility();
});
updateKnxVisibility();
},
oneditsave() {
try { RED.sidebar.show('info'); } catch (error) { /* empty */ }
detachHandlers();
cachedDevices = [];
const pinsSelection = $enablePinsSelect ? normalizePinsValue($enablePinsSelect.val()) : 'yes';
this.enableNodePINS = pinsSelection;
this.outputs = pinsSelection === 'yes' ? 1 : 0;
this._cachedMotionDevices = [];
currentNode = null;
},
oneditcancel() {
try { RED.sidebar.show('info'); } catch (error) { /* empty */ }
detachHandlers();
cachedDevices = [];
this._cachedMotionDevices = [];
currentNode = null;
},
});
}());
</script>
<script type="text/html" data-template-name="knxUltimateHueMotion">
<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-person-running"></i> <span data-i18n="knxUltimateHueMotion.hue_sensor"></span>
</label>
<input type="text" id="node-input-name" placeholder="Hue motion sensor" 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-motion-tabs">
<ul>
<li><a href="#hue-motion-tab-mapping"><i class="fa fa-map"></i> <span data-i18n="knxUltimateHueMotion.tabs.mapping"></span></a></li>
<li><a href="#hue-motion-tab-behaviour"><i class="fa fa-gear"></i> <span data-i18n="knxUltimateHueMotion.tabs.behaviour"></span></a></li>
</ul>
<div id="hue-motion-tab-mapping">
<div class="form-tips hue-form-tip hue-knx-section">
<i class="fa fa-circle-info"></i>
<span data-i18n="knxUltimateHueMotion.mapping_info"></span>
</div>
<div class="form-row hue-knx-section">
<label for="node-input-GAmotion" style="width:70px;"><span data-i18n="common.ga"></span></label>
<input type="text" id="node-input-GAmotion" placeholder="1/1/1" style="width:80px; text-align:left;">
<label for="node-input-dptmotion" style="width:40px; text-align:right;"><span data-i18n="common.dpt"></span></label>
<select id="node-input-dptmotion" style="width:130px;"></select>
<label for="node-input-namemotion" style="width:50px; text-align:right;"><span data-i18n="common.name"></span></label>
<input type="text" id="node-input-namemotion" style="flex:1 1 140px; min-width:120px; text-align:left;" placeholder="Motion state">
</div>
</div>
<div id="hue-motion-tab-behaviour">
<div class="form-tips hue-form-tip">
<i class="fa fa-circle-info"></i>
<span data-i18n="knxUltimateHueMotion.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="knxUltimateHueMotion.node_pins"></span>
</label>
<select id="node-input-enableNodePINS" style="width:200px;">
<option value="yes" data-i18n="knxUltimateHueMotion.node_pins_show"></option>
<option value="no" data-i18n="knxUltimateHueMotion.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="knxUltimateHueMotion.output_info"></span>
</div>
<input type="hidden" id="node-input-hueDevice">
</script>
<script type="text/markdown" data-help-name="knxUltimateHueMotion">
<p>This node listens to a Hue motion sensor and mirrors the events to KNX and/or your Node-RED flow.</p>
Start typing the KNX device name or Group Address in the GA field; suggestions appear while you type. Hit the refresh button next to "Hue sensor” to reload the device list from the bridge if you add new sensors.
**General**
|Property|Description|
|--|--|
| KNX GW | KNX gateway that receives the motion updates (required before KNX mapping fields appear). |
| Hue Bridge | Hue Bridge to query. |
| Hue motion sensor | Hue motion sensor (supports autocomplete and refresh). |
**Mapping**
|Property|Description|
|--|--|
| Motion | KNX GA that receives `true` when motion is detected and `false` when the area is clear. Recommended DPT: <b>1.001</b>. |
**Behaviour**
|Property|Description|
|--|--|
| Node output pin | Show or hide the Node-RED output. When no KNX gateway is selected the output pin stays enabled so Hue motion events still reach your flow. |
> ℹ️ KNX widgets remain hidden until you select a KNX gateway, making it easy to use the node purely as a Hue → Node-RED listener.
### Output
1. Standard output — `msg.payload` (boolean)
: `true` on motion, `false` when motion ends.
</script>