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.
677 lines (603 loc) • 40.1 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' },
mqttUrl: { value: '' },
mqttBaseTopic: { value: 'knx-ultimate' },
mqttDiscovery: { value: true },
mqttDiscoveryPrefix: { value: 'homeassistant' },
mqttCustomEntities: { value: [] },
mqttExposedGAs: { value: [] },
mqttExposeConfigured: { value: false }
},
credentials: {
mqttUsername: { type: 'text' },
mqttPassword: { type: 'password' }
},
inputs: 1,
outputs: 2,
outputLabels: function (index) {
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);
}
$('#node-input-nodeMode').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 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);
row.on('click', function (ev) {
if (ev.target === cb[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(); });
$('#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 = [];
cbs.each(function () { if ($(this).is(':checked')) exposed.push($(this).val()); });
node.mqttExposedGAs = exposed;
node.mqttExposeConfigured = true;
}
}
} 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-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>
</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.
### 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.
### 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>