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.
745 lines (668 loc) • 45.5 kB
HTML
<script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/htmlUtils.js"></script>
<script type="text/javascript">
RED.nodes.registerType('knxUltimateIoTBridge', {
category: 'KNX Ultimate',
color: '#C7E9C0',
defaults: {
server: { type: 'knxUltimate-config', required: false },
name: { value: '' },
outputtopic: { value: '' },
emitOnChangeOnly: { value: true },
readOnDeploy: { value: true },
acceptFlowInput: { value: true },
mappings: { value: [] },
nodeMode: { value: 'iot' },
haBusMode: { value: 'standalone' },
inputs: { value: 1 },
outputs: { value: 2 },
mqttUrl: { value: '' },
mqttBaseTopic: { value: 'knx-ultimate' },
mqttDiscovery: { value: true },
mqttDiscoveryPrefix: { value: 'homeassistant' },
mqttCustomEntities: { value: [] },
mqttExposedGAs: { value: [] },
mqttReadOnlyGAs: { value: [] },
mqttExposeConfigured: { value: false }
},
credentials: {
mqttUsername: { type: 'text' },
mqttPassword: { type: 'password' }
},
inputs: 1,
outputs: 2,
inputLabels: function () {
// Flow-msg mode: the input pin carries KNX bus telegrams from a universal node.
if (this.nodeMode === 'homeassistant' && this.haBusMode === 'flow') {
return this._('knxUltimateIoTBridge.labels.inputKnxBus');
}
return undefined;
},
outputLabels: function (index) {
if (this.nodeMode === 'homeassistant') {
// Stand-alone HA has no outputs; flow mode emits KNX writes on the single pin.
if (this.haBusMode === 'flow' && index === 0) return this._('knxUltimateIoTBridge.labels.outputKnxWrite');
return undefined;
}
if (index === 0) return this._('knxUltimateIoTBridge.labels.outputKnxToIoT');
if (index === 1) return this._('knxUltimateIoTBridge.labels.outputIoTToKnx');
},
icon: 'node-knx-icon.svg',
paletteLabel: function () {
return this._('knxUltimateIoTBridge.paletteLabel');
},
label: function () {
return this.name || this._('knxUltimateIoTBridge.title');
},
oneditprepare: function () {
try {
RED.sidebar.show('help');
} catch (error) { }
const node = this;
let oNodeServer = RED.nodes.node($('#node-input-server').val());
$('#node-input-server').change(function () {
try {
oNodeServer = RED.nodes.node($(this).val());
} catch (error) { }
});
const directions = [
{ value: 'bidirectional', label: node._('knxUltimateIoTBridge.direction.bidirectional') },
{ value: 'knx-to-iot', label: node._('knxUltimateIoTBridge.direction.knx-to-iot') },
{ value: 'iot-to-knx', label: node._('knxUltimateIoTBridge.direction.iot-to-knx') }
];
const channelTypes = [
{ value: 'mqtt', label: node._('knxUltimateIoTBridge.type.mqtt') },
{ value: 'rest', label: node._('knxUltimateIoTBridge.type.rest') },
{ value: 'modbus', label: node._('knxUltimateIoTBridge.type.modbus') }
];
function createLabeledField(row, key, element, options = {}) {
const wrapper = $('<div/>', {
class: 'bridge-field-wrapper',
style: 'display:flex; flex-direction:column; margin-right:10px;'
}).appendTo(row);
if (options.width) wrapper.css('width', options.width);
if (options.flex) wrapper.css('flex', options.flex);
const labelSpan = $('<span/>', {
class: 'bridge-field-label',
text: node._('knxUltimateIoTBridge.fields.' + key),
style: 'font-size:11px; color:#666; margin-bottom:2px;'
}).appendTo(wrapper);
element.css('width', '100%');
element.appendTo(wrapper);
element.data('bridgeWrapper', wrapper);
element.data('bridgeLabel', labelSpan);
return element;
}
const buildSelect = (options, selected) => {
const select = $('<select/>', { class: 'form-control input-small' });
options.forEach(opt => {
const option = $('<option/>', { value: opt.value }).text(opt.label);
if (opt.value === selected) option.attr('selected', 'selected');
select.append(option);
});
return select;
};
const container = $('#node-input-mapping-container');
container.css('min-height', '320px').css('min-width', '640px').editableList({
sortable: true,
removable: true,
addItem: function (row, index, data) {
const mapping = $.extend(true, {
id: '',
enabled: true,
label: '',
ga: '',
dpt: '',
direction: 'bidirectional',
iotType: 'mqtt',
target: '',
method: 'POST',
modbusFunction: 'writeHoldingRegister',
scale: 1,
offset: 0,
template: '',
property: '',
timeout: 0,
retry: 0
}, data.mapping);
if (!mapping.id || mapping.id === '') {
try {
mapping.id = RED.util.generateId();
} catch (error) {
mapping.id = Math.random().toString(16).slice(2);
}
}
const block = $('<div/>').addClass('knxultimate-bridge-block').appendTo(row);
const topRow = $('<div/>').addClass('form-row').css({ display: 'flex', alignItems: 'flex-end', flexWrap: 'wrap', gap: '12px' }).appendTo(block);
const midRow = $('<div/>').addClass('form-row').css({ display: 'flex', alignItems: 'flex-end', flexWrap: 'wrap', gap: '12px', marginTop: '8px' }).appendTo(block);
const bottomRow = $('<div/>').addClass('form-row').css({ display: 'flex', alignItems: 'flex-end', flexWrap: 'wrap', gap: '12px', marginTop: '8px' }).appendTo(block);
const checkboxWrapper = $('<label/>', { class: 'bridge-enabled-wrapper', style: 'display:flex; align-items:center; gap:6px; padding:4px 8px 4px 6px; border:1px solid #ced4da; border-radius:4px; background:#fff; cursor:pointer;' }).appendTo(topRow);
const enabled = $('<input/>', { type: 'checkbox', class: 'bridge-enabled', style: 'margin:0;' }).appendTo(checkboxWrapper);
$('<span/>', { text: node._('knxUltimateIoTBridge.mapping.enabled'), style: 'font-size:12px; font-weight:500;' }).appendTo(checkboxWrapper);
enabled.prop('checked', mapping.enabled !== false);
const labelInput = createLabeledField(topRow, 'label', $('<input/>', {
type: 'text',
class: 'form-control bridge-label'
}).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.label')).val(mapping.label), { width: '190px' });
const gaInput = createLabeledField(topRow, 'ga', $('<input/>', {
type: 'text',
class: 'form-control bridge-ga'
}).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.ga')).val(mapping.ga), { width: '140px' });
try {
if (oNodeServer && oNodeServer.id) KNX_enableSecureFormatting(gaInput, oNodeServer.id);
} catch (error) { }
gaInput.autocomplete({
minLength: 0,
source: function (request, response) {
$.getJSON('knxUltimatecsv?nodeID=' + oNodeServer?.id, (data) => {
response($.map(data, (value) => {
const search = (value.ga + ' (' + value.devicename + ') DPT' + value.dpt);
if (htmlUtilsfullCSVSearch(search, request.term + ' 1.')) {
return {
label: value.ga + ' # ' + value.devicename + ' # ' + value.dpt,
value: value.ga,
dpt: value.dpt
};
}
return null;
}));
});
},
select: function (event, ui) {
if (!dptInput.val()) dptInput.val(ui.item.dpt);
if (!labelInput.val()) labelInput.val(ui.item.label.split('#')[1]?.trim() || '');
}
});
gaInput.on('focus.knxUltimateIoTBridge click.knxUltimateIoTBridge', function () {
try { $(this).autocomplete('search', ''); } catch (error) { }
});
const dptInput = createLabeledField(topRow, 'dpt', $('<input/>', {
type: 'text',
class: 'form-control bridge-dpt'
}).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.dpt')).val(mapping.dpt), { width: '100px' });
const directionSelect = createLabeledField(topRow, 'direction', buildSelect(directions, mapping.direction).addClass('bridge-direction'), { width: '150px' });
const typeSelect = createLabeledField(topRow, 'channel', buildSelect(channelTypes, mapping.iotType).addClass('bridge-type'), { width: '140px' });
const targetInput = createLabeledField(midRow, 'target', $('<input/>', {
type: 'text',
class: 'form-control bridge-target'
}).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.target')).val(mapping.target), { flex: '1 1 280px' });
const methodInput = createLabeledField(midRow, 'method', $('<input/>', {
type: 'text',
class: 'form-control bridge-method'
}).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.method')).val(mapping.method), { width: '120px' });
const modbusInput = createLabeledField(midRow, 'modbusFunction', $('<input/>', {
type: 'text',
class: 'form-control bridge-modbus'
}).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.modbusFunction')).val(mapping.modbusFunction), { width: '180px' });
const scaleInput = createLabeledField(midRow, 'scale', $('<input/>', {
type: 'number',
class: 'form-control bridge-scale'
}).attr('step', 'any').val(mapping.scale), { width: '100px' });
const offsetInput = createLabeledField(midRow, 'offset', $('<input/>', {
type: 'number',
class: 'form-control bridge-offset'
}).attr('step', 'any').val(mapping.offset), { width: '100px' });
const timeoutInput = createLabeledField(midRow, 'timeout', $('<input/>', {
type: 'number',
class: 'form-control bridge-timeout'
}).attr('placeholder', node._('knxUltimateIoTBridge.mapping.timeout')).val(mapping.timeout), { width: '140px' });
const retryInput = createLabeledField(midRow, 'retry', $('<input/>', {
type: 'number',
class: 'form-control bridge-retry'
}).attr('placeholder', node._('knxUltimateIoTBridge.mapping.retry')).val(mapping.retry), { width: '110px' });
const templateInput = createLabeledField(bottomRow, 'template', $('<input/>', {
type: 'text',
class: 'form-control bridge-template'
}).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.template')).val(mapping.template), { flex: '1 1 340px' });
const propertyInput = createLabeledField(bottomRow, 'property', $('<input/>', {
type: 'text',
class: 'form-control bridge-property'
}).attr('placeholder', node._('knxUltimateIoTBridge.placeholders.property')).val(mapping.property), { flex: '1 1 260px' });
const updateChannelPresentation = (channel) => {
const methodWrapper = methodInput.data('bridgeWrapper');
const modbusWrapper = modbusInput.data('bridgeWrapper');
const targetLabel = targetInput.data('bridgeLabel');
const methodLabel = methodInput.data('bridgeLabel');
const modbusLabel = modbusInput.data('bridgeLabel');
const translateVariant = (group, variant, fallbackFieldKey) => {
let text = node._(`knxUltimateIoTBridge.fieldVariants.${group}.${variant}`);
if (!text || text.indexOf('??') !== -1) {
text = node._(`knxUltimateIoTBridge.fieldVariants.${group}.default`);
if (!text || text.indexOf('??') !== -1) {
text = node._(`knxUltimateIoTBridge.fields.${fallbackFieldKey}`);
}
}
return text;
};
const translatePlaceholder = (baseKey, variant) => {
let placeholder = node._(`knxUltimateIoTBridge.placeholders.${baseKey}_${variant}`);
if (!placeholder || placeholder.indexOf('??') !== -1) {
placeholder = node._(`knxUltimateIoTBridge.placeholders.${baseKey}`);
}
return placeholder;
};
if (channel === 'rest') {
methodWrapper.show();
} else {
methodWrapper.hide();
}
if (channel === 'modbus') {
modbusWrapper.show();
} else {
modbusWrapper.hide();
}
targetLabel.text(translateVariant('target', channel, 'target'));
targetInput.attr('placeholder', translatePlaceholder('target', channel));
methodLabel.text(translateVariant('method', channel, 'method'));
modbusLabel.text(translateVariant('modbusFunction', channel, 'modbusFunction'));
};
typeSelect.on('change', function () {
updateChannelPresentation($(this).val());
});
updateChannelPresentation(typeSelect.val());
row.data('mapping-id', mapping.id || '');
}
});
(node.mappings || []).forEach((m) => {
container.editableList('addItem', { mapping: m });
});
// Operation mode toggle: show IoT mapping fields or Home Assistant (MQTT) fields.
try {
if (node.mqttDiscovery === undefined) $('#node-input-mqttDiscovery').prop('checked', true);
function refreshMode() {
const ha = $('#node-input-nodeMode').val() === 'homeassistant';
$('.ha-mode-row').toggle(ha);
$('.iot-mode-row').toggle(!ha);
// The flow-mode hint is only relevant while the "flow msg" bus mode is picked.
$('.ha-flow-hint').toggle(ha && $('#node-input-haBusMode').val() === 'flow');
}
$('#node-input-nodeMode').on('change', refreshMode);
$('#node-input-haBusMode').on('change', refreshMode);
refreshMode();
} catch (error) { }
// Home Assistant: selectable list of group addresses to expose as simple entities.
try {
const savedSet = new Set(Array.isArray(node.mqttExposedGAs) ? node.mqttExposedGAs : []);
const readOnlySet = new Set(Array.isArray(node.mqttReadOnlyGAs) ? node.mqttReadOnlyGAs : []);
const exposeConfigured = node.mqttExposeConfigured === true;
function updateGaCount() {
const total = $('#mqtt-ga-list input.mqtt-ga-cb').length;
const sel = $('#mqtt-ga-list input.mqtt-ga-cb:checked').length;
$('#mqtt-ga-count').text(sel + ' / ' + total + ' ' + node._('knxUltimateIoTBridge.ha.exposed_count'));
}
function buildGaList() {
const list = $('#mqtt-ga-list').empty();
const sid = oNodeServer ? oNodeServer.id : ($('#node-input-server').val() || '');
if (!sid) {
list.append($('<div/>').css('color', '#999').text(node._('knxUltimateIoTBridge.ha.no_gateway')));
updateGaCount();
return;
}
$.getJSON('knxUltimatecsv?nodeID=' + sid, function (data) {
list.empty();
if (!Array.isArray(data) || data.length === 0) {
list.append($('<div/>').css('color', '#999').text(node._('knxUltimateIoTBridge.ha.no_ga')));
updateGaCount();
return;
}
data.forEach(function (item) {
if (!item || !item.ga) return;
// Use a <div> (not <label>) so Node-RED's ".form-row label" width rule
// doesn't squash the columns; keep everything on a single line.
const row = $('<div/>').addClass('mqtt-ga-row').css({
display: 'flex', gap: '8px', alignItems: 'center', padding: '3px 2px',
width: '100%', whiteSpace: 'nowrap', cursor: 'pointer', borderBottom: '1px solid #f3f3f3'
}).appendTo(list);
const cb = $('<input/>', { type: 'checkbox', class: 'mqtt-ga-cb', value: item.ga, style: 'width:auto; margin:0; flex:0 0 auto;' }).appendTo(row);
cb.prop('checked', exposeConfigured ? savedSet.has(item.ga) : true);
$('<span/>').css({ flex: '0 0 auto', fontFamily: 'monospace', minWidth: '64px' }).text(item.ga).appendTo(row);
$('<span/>').css({ flex: '1 1 auto', minWidth: '0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#444' }).attr('title', item.devicename || '').text(item.devicename || '').appendTo(row);
$('<span/>').css({ flex: '0 0 auto', color: '#888', fontSize: '11px' }).text('DPT' + (item.dpt || '')).appendTo(row);
// Read-only toggle: expose the GA to HA but never accept commands back to KNX.
const roLabel = $('<label/>').css({ flex: '0 0 auto', display: 'flex', alignItems: 'center', gap: '3px', margin: '0', fontSize: '11px', color: '#888', cursor: 'pointer' }).appendTo(row);
const ro = $('<input/>', { type: 'checkbox', class: 'mqtt-ga-ro', value: item.ga, style: 'width:auto; margin:0;' }).appendTo(roLabel);
ro.prop('checked', readOnlySet.has(item.ga));
$('<span/>').text(node._('knxUltimateIoTBridge.ha.read_only')).appendTo(roLabel);
roLabel.on('click', function (ev) { ev.stopPropagation(); });
row.on('click', function (ev) {
if (ev.target === cb[0] || ev.target === ro[0]) return;
cb.prop('checked', !cb.prop('checked'));
updateGaCount();
});
});
$('#mqtt-ga-list input.mqtt-ga-cb').on('change', updateGaCount);
updateGaCount();
}).fail(function () {
list.empty().append($('<div/>').css('color', '#c0392b').text(node._('knxUltimateIoTBridge.ha.csv_error')));
updateGaCount();
});
}
$('#mqtt-ga-all').on('click', function (e) { e.preventDefault(); $('#mqtt-ga-list .mqtt-ga-row:visible').find('input.mqtt-ga-cb').prop('checked', true); updateGaCount(); });
$('#mqtt-ga-none').on('click', function (e) { e.preventDefault(); $('#mqtt-ga-list .mqtt-ga-row:visible').find('input.mqtt-ga-cb').prop('checked', false); updateGaCount(); });
// Flag / unflag read-only for the currently visible GAs (respects the filter).
$('#mqtt-ga-ro-all').on('click', function (e) { e.preventDefault(); $('#mqtt-ga-list .mqtt-ga-row:visible').find('input.mqtt-ga-ro').prop('checked', true); });
$('#mqtt-ga-ro-none').on('click', function (e) { e.preventDefault(); $('#mqtt-ga-list .mqtt-ga-row:visible').find('input.mqtt-ga-ro').prop('checked', false); });
$('#mqtt-ga-filter').on('input', function () {
const term = ($(this).val() || '').toLowerCase();
$('#mqtt-ga-list .mqtt-ga-row').each(function () {
$(this).toggle($(this).text().toLowerCase().indexOf(term) !== -1);
});
});
buildGaList();
$('#node-input-server').on('change', buildGaList);
} catch (error) { }
// Home Assistant composite entities (cover / climate): editable list.
try {
const T = (k) => node._('knxUltimateIoTBridge.ha.' + k);
const coverFields = ['gaUpDown', 'gaStop', 'gaPosSet', 'gaPosState'];
const climateFields = ['gaCurrentTemp', 'gaSetpointSet', 'gaSetpointState', 'gaOnOff'];
// Group-address autocomplete, identical to the mapping GA field (and the
// knxUltimate node): suggests GAs from the gateway's ETS CSV.
function attachGaAutocomplete(input) {
try {
if (oNodeServer && oNodeServer.id && typeof KNX_enableSecureFormatting === 'function') {
KNX_enableSecureFormatting(input, oNodeServer.id);
}
} catch (error) { }
input.autocomplete({
minLength: 0,
source: function (request, response) {
const sid = oNodeServer ? oNodeServer.id : ($('#node-input-server').val() || '');
if (!sid) { response([]); return; }
$.getJSON('knxUltimatecsv?nodeID=' + sid, (data) => {
response($.map(data || [], (value) => {
const search = (value.ga + ' (' + value.devicename + ') DPT' + value.dpt);
if (htmlUtilsfullCSVSearch(search, request.term + ' 1.')) {
return {
label: value.ga + ' # ' + value.devicename + ' # ' + value.dpt,
value: value.ga
};
}
return null;
}));
});
}
});
input.on('focus.knxUltimateIoTBridge click.knxUltimateIoTBridge', function () {
try { $(this).autocomplete('search', ''); } catch (error) { }
});
}
function gaRow(parent, key, value) {
const row = $('<div/>').addClass('form-row').css({ margin: '2px 0' }).appendTo(parent);
$('<label/>').css({ width: '160px' }).text(T('ce_' + key)).appendTo(row);
const input = $('<input/>', { type: 'text', class: 'ce-' + key })
.attr('placeholder', '1/2/3').css({ width: '130px' }).val(value || '').appendTo(row);
attachGaAutocomplete(input);
return input;
}
function numRow(parent, key, value) {
const row = $('<div/>').addClass('form-row').css({ margin: '2px 0' }).appendTo(parent);
$('<label/>').css({ width: '160px' }).text(T('ce_' + key)).appendTo(row);
return $('<input/>', { type: 'number', class: 'ce-' + key, step: 'any' })
.css({ width: '90px' }).val(value).appendTo(row);
}
$('#node-input-mqttentities-container').css('min-width', '560px').editableList({
sortable: true,
removable: true,
addItem: function (rowEl, index, data) {
const e = data.entity || {};
const block = $('<div/>').css({ padding: '4px 2px' }).appendTo(rowEl);
const head = $('<div/>').addClass('form-row').css({ display: 'flex', gap: '10px', alignItems: 'center', margin: '2px 0' }).appendTo(block);
const typeSel = $('<select/>', { class: 'ce-type' }).css({ width: '120px' }).appendTo(head);
typeSel.append($('<option/>', { value: 'cover', text: T('ce_type_cover') }));
typeSel.append($('<option/>', { value: 'climate', text: T('ce_type_climate') }));
typeSel.val(e.type === 'climate' ? 'climate' : 'cover');
$('<input/>', { type: 'text', class: 'ce-name' })
.attr('placeholder', T('ce_name')).css({ flex: '1 1 180px' }).val(e.name || '').appendTo(head);
const coverBox = $('<div/>', { class: 'ce-cover-box' }).appendTo(block);
coverFields.forEach((f) => gaRow(coverBox, f, e[f]));
const invRow = $('<div/>').addClass('form-row').css({ margin: '2px 0' }).appendTo(coverBox);
const invLabel = $('<label/>', { style: 'width:auto; display:flex; align-items:center; gap:6px; cursor:pointer;' }).appendTo(invRow);
const inv = $('<input/>', { type: 'checkbox', class: 'ce-invertPosition', style: 'margin:0; width:auto;' }).appendTo(invLabel);
$('<span/>').text(T('ce_invertPosition')).appendTo(invLabel);
inv.prop('checked', e.invertPosition !== false);
const climateBox = $('<div/>', { class: 'ce-climate-box' }).appendTo(block);
climateFields.forEach((f) => gaRow(climateBox, f, e[f]));
numRow(climateBox, 'minTemp', e.minTemp === undefined ? 5 : e.minTemp);
numRow(climateBox, 'maxTemp', e.maxTemp === undefined ? 35 : e.maxTemp);
numRow(climateBox, 'tempStep', e.tempStep === undefined ? 0.5 : e.tempStep);
function refreshType() {
const t = typeSel.val();
coverBox.toggle(t === 'cover');
climateBox.toggle(t === 'climate');
}
typeSel.on('change', refreshType);
refreshType();
}
});
(node.mqttCustomEntities || []).forEach((en) => {
$('#node-input-mqttentities-container').editableList('addItem', { entity: en });
});
} catch (error) { }
},
oneditsave: function () {
const node = this;
const items = $('#node-input-mapping-container').editableList('items');
node.mappings = [];
items.each(function () {
const row = $(this);
node.mappings.push({
id: row.data('mapping-id') || '',
enabled: row.find('.bridge-enabled').is(':checked'),
label: row.find('.bridge-label').val(),
ga: row.find('.bridge-ga').val(),
dpt: row.find('.bridge-dpt').val(),
direction: row.find('.bridge-direction').val(),
iotType: row.find('.bridge-type').val(),
target: row.find('.bridge-target').val(),
method: row.find('.bridge-method').val(),
modbusFunction: row.find('.bridge-modbus').val(),
scale: row.find('.bridge-scale').val(),
offset: row.find('.bridge-offset').val(),
timeout: row.find('.bridge-timeout').val(),
retry: row.find('.bridge-retry').val(),
template: row.find('.bridge-template').val(),
property: row.find('.bridge-property').val()
});
});
// Serialize Home Assistant composite entities (cover / climate).
try {
const entities = [];
$('#node-input-mqttentities-container').editableList('items').each(function () {
const row = $(this);
const type = row.find('.ce-type').val();
const entity = { type: type, name: (row.find('.ce-name').val() || '').trim() };
if (type === 'cover') {
['gaUpDown', 'gaStop', 'gaPosSet', 'gaPosState'].forEach((f) => {
entity[f] = (row.find('.ce-' + f).val() || '').trim();
});
entity.invertPosition = row.find('.ce-invertPosition').is(':checked');
} else {
['gaCurrentTemp', 'gaSetpointSet', 'gaSetpointState', 'gaOnOff'].forEach((f) => {
entity[f] = (row.find('.ce-' + f).val() || '').trim();
});
entity.minTemp = parseFloat(row.find('.ce-minTemp').val());
entity.maxTemp = parseFloat(row.find('.ce-maxTemp').val());
entity.tempStep = parseFloat(row.find('.ce-tempStep').val());
}
entities.push(entity);
});
node.mqttCustomEntities = entities;
} catch (error) { }
// Serialize the selected group addresses to expose (only when the list is populated,
// so a failed/unopened list never wipes a previous selection).
try {
if ($('#node-input-nodeMode').val() === 'homeassistant') {
const cbs = $('#mqtt-ga-list input.mqtt-ga-cb');
if (cbs.length > 0) {
const exposed = [];
const readOnly = [];
cbs.each(function () { if ($(this).is(':checked')) exposed.push($(this).val()); });
$('#mqtt-ga-list input.mqtt-ga-ro').each(function () { if ($(this).is(':checked')) readOnly.push($(this).val()); });
node.mqttExposedGAs = exposed;
node.mqttReadOnlyGAs = readOnly;
node.mqttExposeConfigured = true;
}
}
} catch (error) { }
// Adjust the pins to the selected mode:
// - IoT: 1 input, 2 outputs (stream + ack)
// - HA flow-msg: 1 input, 1 output (KNX bus in / KNX write out)
// - HA stand-alone: 0 inputs, 0 outputs (talks to the gateway only)
try {
if ($('#node-input-nodeMode').val() === 'homeassistant') {
const flow = $('#node-input-haBusMode').val() === 'flow';
node.inputs = flow ? 1 : 0;
node.outputs = flow ? 1 : 0;
} else {
node.inputs = 1;
node.outputs = 2;
}
} catch (error) { }
try {
RED.sidebar.show('info');
} catch (error) { }
},
oneditresize: function (size) {
const rows = $('#dialog-form>div:not(.node-input-mapping-container-row)');
let height = size.height;
for (let i = 0; i < rows.length; i++) {
height -= $(rows[i]).outerHeight(true);
}
const editorRow = $('#dialog-form>div.node-input-mapping-container-row');
height -= (parseInt(editorRow.css('marginTop')) + parseInt(editorRow.css('marginBottom')));
height += 16;
$('#node-input-mapping-container').editableList('height', height);
}
});
</script>
<script type="text/html" data-template-name="knxUltimateIoTBridge">
<div class="form-row">
<label for="node-input-server" data-i18n="knxUltimateIoTBridge.node-input-server"></label>
<input type="text" id="node-input-server" />
</div>
<div class="form-row">
<label for="node-input-name" data-i18n="knxUltimateIoTBridge.node-input-name"></label>
<input type="text" id="node-input-name" />
</div>
<div class="form-row">
<label for="node-input-nodeMode" data-i18n="knxUltimateIoTBridge.node-input-nodeMode"></label>
<select id="node-input-nodeMode" style="width:auto;">
<option value="iot" data-i18n="knxUltimateIoTBridge.mode.iot"></option>
<option value="homeassistant" data-i18n="knxUltimateIoTBridge.mode.homeassistant"></option>
</select>
</div>
<!-- IoT (classic) mode fields -->
<div class="form-row iot-mode-row">
<label for="node-input-outputtopic" data-i18n="knxUltimateIoTBridge.node-input-outputtopic"></label>
<input type="text" id="node-input-outputtopic" />
</div>
<div class="form-row iot-mode-row" style="display:flex; align-items:flex-start; gap:8px;">
<input type="checkbox" id="node-input-emitOnChangeOnly" style="width:auto; margin-top:4px;" />
<label for="node-input-emitOnChangeOnly" style="flex:1; margin:0;" data-i18n="knxUltimateIoTBridge.node-input-emitOnChangeOnly"></label>
</div>
<div class="form-row iot-mode-row" style="display:flex; align-items:flex-start; gap:8px;">
<input type="checkbox" id="node-input-readOnDeploy" style="width:auto; margin-top:4px;" />
<label for="node-input-readOnDeploy" style="flex:1; margin:0;" data-i18n="knxUltimateIoTBridge.node-input-readOnDeploy"></label>
</div>
<div class="form-row iot-mode-row" style="display:flex; align-items:flex-start; gap:8px;">
<input type="checkbox" id="node-input-acceptFlowInput" style="width:auto; margin-top:4px;" />
<label for="node-input-acceptFlowInput" style="flex:1; margin:0;" data-i18n="knxUltimateIoTBridge.node-input-acceptFlowInput"></label>
</div>
<div class="form-row node-input-mapping-container-row iot-mode-row">
<label style="width:auto;" data-i18n="knxUltimateIoTBridge.section_mappings"></label>
<ol id="node-input-mapping-container"></ol>
</div>
<!-- Home Assistant (MQTT) mode fields -->
<div class="form-row ha-mode-row">
<label for="node-input-haBusMode" data-i18n="knxUltimateIoTBridge.ha.bus_mode"></label>
<select id="node-input-haBusMode" style="width:auto;">
<option value="standalone" data-i18n="knxUltimateIoTBridge.ha.bus_standalone"></option>
<option value="flow" data-i18n="knxUltimateIoTBridge.ha.bus_flow"></option>
</select>
</div>
<div class="form-row ha-mode-row ha-flow-hint" style="margin-top:-4px;">
<label style="width:auto;"> </label>
<span style="font-size:11px; color:#888;" data-i18n="knxUltimateIoTBridge.ha.bus_flow_hint"></span>
</div>
<div class="form-row ha-mode-row">
<label for="node-input-mqttUrl" data-i18n="knxUltimateIoTBridge.ha.broker_url"></label>
<input type="text" id="node-input-mqttUrl" placeholder="mqtt://localhost:1883" />
</div>
<div class="form-row ha-mode-row">
<label for="node-input-mqttUsername" data-i18n="knxUltimateIoTBridge.ha.username"></label>
<input type="text" id="node-input-mqttUsername" data-i18n="[placeholder]knxUltimateIoTBridge.ha.optional" />
</div>
<div class="form-row ha-mode-row">
<label for="node-input-mqttPassword" data-i18n="knxUltimateIoTBridge.ha.password"></label>
<input type="password" id="node-input-mqttPassword" data-i18n="[placeholder]knxUltimateIoTBridge.ha.optional" />
</div>
<div class="form-row ha-mode-row">
<label for="node-input-mqttBaseTopic" data-i18n="knxUltimateIoTBridge.ha.base_topic"></label>
<input type="text" id="node-input-mqttBaseTopic" placeholder="knx-ultimate" />
</div>
<div class="form-row ha-mode-row" style="display:flex; align-items:flex-start; gap:8px;">
<input type="checkbox" id="node-input-mqttDiscovery" style="width:auto; margin-top:4px;" />
<label for="node-input-mqttDiscovery" style="flex:1; margin:0;" data-i18n="knxUltimateIoTBridge.ha.discovery"></label>
</div>
<div class="form-row ha-mode-row">
<label for="node-input-mqttDiscoveryPrefix" data-i18n="knxUltimateIoTBridge.ha.discovery_prefix"></label>
<input type="text" id="node-input-mqttDiscoveryPrefix" placeholder="homeassistant" />
</div>
<div class="form-row ha-mode-row" id="rowMqttGaSelect">
<label style="width:auto; font-weight:600;" data-i18n="knxUltimateIoTBridge.ha.exposed_gas"></label>
<div style="margin:4px 0; display:flex; gap:6px; align-items:center;">
<input type="text" id="mqtt-ga-filter" data-i18n="[placeholder]knxUltimateIoTBridge.ha.filter_placeholder" style="flex:1 1 auto;">
<button type="button" id="mqtt-ga-all" class="ui-button ui-corner-all ui-widget" style="width:auto;" data-i18n="knxUltimateIoTBridge.ha.select_all"></button>
<button type="button" id="mqtt-ga-none" class="ui-button ui-corner-all ui-widget" style="width:auto;" data-i18n="knxUltimateIoTBridge.ha.select_none"></button>
<span style="border-left:1px solid #ccc; align-self:stretch;"></span>
<button type="button" id="mqtt-ga-ro-all" class="ui-button ui-corner-all ui-widget" style="width:auto;" data-i18n="[title]knxUltimateIoTBridge.ha.read_only_bulk;knxUltimateIoTBridge.ha.read_only_all"></button>
<button type="button" id="mqtt-ga-ro-none" class="ui-button ui-corner-all ui-widget" style="width:auto;" data-i18n="[title]knxUltimateIoTBridge.ha.read_only_bulk;knxUltimateIoTBridge.ha.read_only_none"></button>
</div>
<div id="mqtt-ga-count" style="font-size:11px; color:#666; margin:2px 0;"></div>
<div id="mqtt-ga-list" style="max-height:220px; overflow:auto; border:1px solid #ccc; padding:6px; border-radius:4px; background:#fff;"></div>
</div>
<div class="form-row ha-mode-row node-input-mqttentities-container-row">
<label style="width:auto; font-weight:600;" data-i18n="knxUltimateIoTBridge.ha.custom_entities"></label>
<ol id="node-input-mqttentities-container"></ol>
</div>
<br/><br/><br/><br/>
</script>
<script type="text/markdown" data-help-name="knxUltimateIoTBridge">
# MQTT Home Assistant - IoT
Configure bidirectional maps between KNX group addresses and IoT backends such as MQTT, REST APIs or Modbus registers. Each mapping can scale values, format payloads and define single direction behaviour.
### Inputs
- **Flow input**: when enabled, a message whose `topic` (or `msg.bridge`) matches a configured mapping is converted to the KNX payload and written to the bus.
- **KNX telegrams**: received automatically from the configured gateway and routed through the mapping list.
### Outputs
- **Output 1 (KNX → IoT)**: emits a message with the mapped payload plus metadata in `msg.bridge` and `msg.knx`.
- **Output 2 (IoT → KNX ack)**: reports when a flow message has been written to KNX, including the resolved GA and scaling information.
### Mapping options
- **Direction**: choose between KNX→IoT, IoT→KNX or bidirectional.
- **Channel type**: pick MQTT, REST or Modbus. The `target` field adapts: topic name, URL or register address.
- **Template**: optional string; placeholders `{{value}}`, `{{ga}}`, `{{target}}`, `{{label}}`, `{{type}}`, `{{isoTimestamp}}` are replaced at runtime.
- **Scale & Offset**: numeric transformation applied on KNX→IoT. For IoT→KNX the inverse is used.
- **Timeout/Retries**: retained for flow logic; the node does not execute external requests but exposes the desired values to downstream nodes.
### Tips
- Place HTTP or MQTT nodes after the bridge outputs to perform the actual transport.
- Use `msg.bridge.id` to route acknowledgements or correlate responses.
- Enable "Read KNX values on deploy" to bootstrap dashboards after deploys.
## Mode
The **Mode** selector switches the node between two behaviours:
- **IoT bridge** (default): the classic behaviour described above (mapping list, MQTT/REST/Modbus output messages).
- **MQTT / Home Assistant (native)**: the node connects directly to an MQTT broker and bridges KNX ↔ MQTT both ways, publishing Home Assistant MQTT Discovery so entities appear automatically.
## MQTT / Home Assistant mode
Native MQTT bridge with Home Assistant discovery. Every group address imported in the KNX gateway (ETS list) can be exposed automatically as a Home Assistant entity (switch, sensor, binary_sensor, number, text), chosen from its datapoint type (DPT). KNX bus values are published to MQTT and writable datapoints accept commands from Home Assistant.
Requires an MQTT broker reachable by both Node-RED and Home Assistant, with the MQTT integration enabled in HA. Entities appear under a device named after this node.
### KNX bus connection
Choose how the node exchanges telegrams with KNX:
- **Stand-alone** (default): the node talks to the KNX gateway directly and shows no input/output pins.
- **Flow messages**: the node exposes an input pin and an output pin. Wire the output of a KNXUltimate node in **Universal** mode to the input (KNX bus → MQTT) and the output pin to the input of another KNXUltimate node in **Universal** mode (MQTT → KNX bus).
### Group addresses to expose
Tick the group addresses you want to publish to Home Assistant. By default every imported address is selected. Addresses used by a cover/thermostat (below) are handled there and don't need to be ticked here. Use the filter box and the Select all / Select none buttons to curate large lists.
Each row also has a **Read only** toggle: a read-only address is still published to Home Assistant (its state stays visible) but never accepts commands back to the KNX bus (switches become binary_sensors, numbers become sensors). The Set read only / Clear read only buttons apply it to all currently shown addresses.
### Covers & Thermostats
Covers and thermostats group several group addresses into one Home Assistant entity, so they cannot be created automatically from the DPT - add them in the list:
- **Cover**: Up/Down GA (DPT 1.008), optional Stop GA (1.007), optional Set/Status position GA (5.001). *Invert position* maps the KNX convention (0% = open) to Home Assistant (100% = open).
- **Thermostat**: Current temperature GA (9.001), Setpoint set/status GA (9.001), optional On/Off GA (1.001 → off/heat), plus min/max temperature and step.
Datapoint types come from the imported ETS list when available, otherwise from the KNX defaults shown above. For reliable status feedback, those group addresses should be present in the ETS import.
</script>