UNPKG

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.

559 lines (498 loc) 24.6 kB
<script type="text/javascript" src="resources/node-red-contrib-knx-ultimate/htmlUtils.js"></script> <script type="text/javascript"> const KNX_ULTIMATE_DATETIME_KEYWORDS = { datetime: [/date\s*\/\s*time/i, /date\s*time/i, /datetime/i, /data\s*\/\s*ora/i, /data\s*ora/i], date: [/\bdate\b/i, /\bdata\b/i, /\bdatum\b/i, /\bfecha\b/i], time: [/\btime\b/i, /\bora\b/i, /\bheure\b/i, /\bzeit\b/i, /\bhora\b/i, /\borologio\b/i, /\bclock\b/i] } const KNX_ULTIMATE_DATETIME_STOPWORDS = new Set([ 'date', 'datetime', 'time', 'data', 'ora', 'orologio', 'clock', 'bus', 'knx', 'set', 'sync', 'sincro', 'synchronization' ]) const knxDateTimeNormalizeTokens = (value) => { const str = (value || '').toString().toLowerCase() const cleaned = str .replace(/dpt\s*\d+(\.\d+)?/g, ' ') .replace(/[^a-z0-9]+/g, ' ') .trim() if (!cleaned) return [] return cleaned .split(/\s+/) .map((t) => t.trim()) .filter((t) => t.length > 1 && !KNX_ULTIMATE_DATETIME_STOPWORDS.has(t)) } const knxDateTimeTokenSimilarity = (aTokens, bTokens) => { if (!Array.isArray(aTokens) || !Array.isArray(bTokens) || aTokens.length === 0 || bTokens.length === 0) return 0 const a = new Set(aTokens) const b = new Set(bTokens) let inter = 0 a.forEach((t) => { if (b.has(t)) inter += 1 }) const union = a.size + b.size - inter return union > 0 ? inter / union : 0 } const knxDateTimeParseGA = (ga) => { const parts = (ga || '').toString().trim().split('/') if (parts.length !== 3) return null const nums = parts.map((p) => Number(p)) if (nums.some((n) => !Number.isInteger(n) || n < 0)) return null return nums } const knxDateTimeCompareGA = (a, b) => { const aa = knxDateTimeParseGA(a) const bb = knxDateTimeParseGA(b) if (!aa && !bb) return 0 if (!aa) return 1 if (!bb) return -1 for (let i = 0; i < 3; i++) { if (aa[i] !== bb[i]) return aa[i] - bb[i] } return 0 } const knxGetKnxUltimateConfigs = () => { const configs = [] try { if (RED && RED.nodes) { if (typeof RED.nodes.eachConfig === 'function') { RED.nodes.eachConfig((cfg) => { if (cfg && cfg.type === 'knxUltimate-config') configs.push(cfg) }) } else if (typeof RED.nodes.eachNode === 'function') { RED.nodes.eachNode((n) => { if (n && n.type === 'knxUltimate-config') configs.push(n) }) } if (configs.length === 0 && typeof RED.nodes.filterNodes === 'function') { try { const filtered = RED.nodes.filterNodes({ type: 'knxUltimate-config' }) if (Array.isArray(filtered)) filtered.forEach((n) => configs.push(n)) } catch (error) { /* ignore */ } } } } catch (error) { /* ignore */ } return configs } const knxFetchGroupAddresses = (serverId) => { return new Promise((resolve) => { if (!serverId) return resolve([]) $.getJSON(`knxUltimatecsv?nodeID=${serverId}&_=${Date.now()}`, (data) => { resolve(Array.isArray(data) ? data : []) }).fail(() => resolve([])) }) } const knxScoreEntry = (entry, kind, baseTokens) => { if (!entry) return -1 const dpt = (entry.dpt || '').toString() const name = (entry.devicename || '').toString() const tokens = knxDateTimeNormalizeTokens(name) let score = 0 if (kind === 'datetime') { if (dpt === '19.001') score += 60 else if (dpt.startsWith('19.')) score += 45 KNX_ULTIMATE_DATETIME_KEYWORDS.datetime.forEach((re) => { if (re.test(name)) score += 20 }) KNX_ULTIMATE_DATETIME_KEYWORDS.time.forEach((re) => { if (re.test(name)) score += 6 }) KNX_ULTIMATE_DATETIME_KEYWORDS.date.forEach((re) => { if (re.test(name)) score += 6 }) } else if (kind === 'date') { if (dpt === '11.001') score += 60 else if (dpt.startsWith('11.')) score += 45 KNX_ULTIMATE_DATETIME_KEYWORDS.date.forEach((re) => { if (re.test(name)) score += 18 }) } else if (kind === 'time') { if (dpt === '10.001') score += 60 else if (dpt.startsWith('10.')) score += 45 KNX_ULTIMATE_DATETIME_KEYWORDS.time.forEach((re) => { if (re.test(name)) score += 18 }) } if (Array.isArray(baseTokens) && baseTokens.length > 0) { score += Math.round(knxDateTimeTokenSimilarity(tokens, baseTokens) * 30) } return score } const knxPickBest = (entries, kind, baseTokens) => { if (!Array.isArray(entries) || entries.length === 0) return null let best = null let bestScore = -1 entries.forEach((e) => { const s = knxScoreEntry(e, kind, baseTokens) if (s > bestScore) { bestScore = s best = e } else if (s === bestScore && best) { const cmp = knxDateTimeCompareGA(e.ga, best.ga) if (cmp < 0) best = e } }) return best } const knxSuggestFromCsv = (csvRows) => { const rows = Array.isArray(csvRows) ? csvRows : [] const datetimeRows = rows.filter((r) => (r && typeof r.dpt === 'string' && r.dpt.startsWith('19.'))) const dateRows = rows.filter((r) => (r && typeof r.dpt === 'string' && r.dpt.startsWith('11.'))) const timeRows = rows.filter((r) => (r && typeof r.dpt === 'string' && r.dpt.startsWith('10.'))) const bestDateTime = knxPickBest(datetimeRows, 'datetime', []) const baseTokens = bestDateTime ? knxDateTimeNormalizeTokens(bestDateTime.devicename || '') : [] const bestDate = knxPickBest(dateRows, 'date', baseTokens) const bestTime = knxPickBest(timeRows, 'time', baseTokens) return { dateTime: bestDateTime, date: bestDate, time: bestTime } } const knxAutoConfigureDateTimeNode = async (node, { updateDom = false, preferExistingServer = true } = {}) => { try { if (!node) return const hasAnyGA = !!((node.gaDateTime || '').trim() || (node.gaDate || '').trim() || (node.gaTime || '').trim()) if (hasAnyGA) return // If server already selected and it has ETS rows, reuse it. const currentServerId = preferExistingServer ? (node.server || '') : '' if (currentServerId && currentServerId !== '_ADD_') { const rows = await knxFetchGroupAddresses(currentServerId) if (rows.length > 0) { const suggestions = knxSuggestFromCsv(rows) return knxApplySuggestions(node, currentServerId, suggestions, { updateDom }) } } // Otherwise, select the first knxUltimate-config that has an ETS CSV imported (non-empty parsed GA list). const configs = knxGetKnxUltimateConfigs() if (configs.length === 0) return // Fast path: config node already carries an ETS file/path in its `csv` property. for (let i = 0; i < configs.length; i++) { const cfg = configs[i] const id = cfg && cfg.id ? cfg.id : null const csvHint = cfg && typeof cfg.csv === 'string' ? cfg.csv.trim() : '' if (!id || !csvHint) continue const rows = await knxFetchGroupAddresses(id) if (rows.length === 0) continue const suggestions = knxSuggestFromCsv(rows) return knxApplySuggestions(node, id, suggestions, { updateDom }) } const maxCandidates = Math.min(10, configs.length) const checks = configs.slice(0, maxCandidates).map((cfg) => { const id = cfg && cfg.id ? cfg.id : null return knxFetchGroupAddresses(id).then((rows) => ({ id, rows })) }) const results = await Promise.all(checks) let selected = null for (let i = 0; i < results.length; i++) { if (results[i] && results[i].id && Array.isArray(results[i].rows) && results[i].rows.length > 0) { selected = results[i] break } } if (!selected) return const suggestions = knxSuggestFromCsv(selected.rows) return knxApplySuggestions(node, selected.id, suggestions, { updateDom }) } catch (error) { try { console.warn('knxUltimateDateTime auto-config failed', error) } catch (e) { /* ignore */ } } } const knxAutoConfigureDateTimeNodeForServer = async (node, serverId, { updateDom = false, overwrite = false } = {}) => { try { if (!node || !serverId) return const rows = await knxFetchGroupAddresses(serverId) if (!Array.isArray(rows) || rows.length === 0) return const suggestions = knxSuggestFromCsv(rows) return knxApplySuggestions(node, serverId, suggestions, { updateDom, overwrite }) } catch (error) { try { console.warn('knxUltimateDateTime auto-config for server failed', error) } catch (e) { /* ignore */ } } } const knxApplySuggestions = (node, serverId, suggestions, { updateDom = false, overwrite = false } = {}) => { if (!node || !serverId) return if (!suggestions) return // Avoid repeating the same automation multiple times. if (node._knxDateTimeAutoConfigured === true && overwrite !== true) return node.server = serverId if (suggestions.dateTime && suggestions.dateTime.ga && (overwrite || !(node.gaDateTime || '').trim())) { node.gaDateTime = suggestions.dateTime.ga node.nameDateTime = suggestions.dateTime.devicename || '' } if (suggestions.date && suggestions.date.ga && (overwrite || !(node.gaDate || '').trim())) { node.gaDate = suggestions.date.ga node.nameDate = suggestions.date.devicename || '' } if (suggestions.time && suggestions.time.ga && (overwrite || !(node.gaTime || '').trim())) { node.gaTime = suggestions.time.ga node.nameTime = suggestions.time.devicename || '' } node._knxDateTimeAutoConfigured = true node._knxDateTimeAutoConfiguredServer = serverId if (updateDom) { try { $('#node-input-server').val(serverId).trigger('change') if (node.gaDateTime) $('#node-input-gaDateTime').val(node.gaDateTime) if (node.nameDateTime) $('#node-input-nameDateTime').val(node.nameDateTime) if (node.gaDate) $('#node-input-gaDate').val(node.gaDate) if (node.nameDate) $('#node-input-nameDate').val(node.nameDate) if (node.gaTime) $('#node-input-gaTime').val(node.gaTime) if (node.nameTime) $('#node-input-nameTime').val(node.nameTime) } catch (error) { /* ignore */ } } else { try { if (RED && RED.nodes && typeof RED.nodes.dirty === 'function') RED.nodes.dirty(true) } catch (error) { /* ignore */ } try { if (RED && RED.view && typeof RED.view.redraw === 'function') RED.view.redraw() } catch (error) { /* ignore */ } } } RED.nodes.registerType('knxUltimateDateTime', { category: 'KNX Ultimate', color: '#C7E9C0', defaults: { server: { type: 'knxUltimate-config', required: true }, name: { value: '' }, outputtopic: { value: '' }, gaDateTime: { value: '' }, nameDateTime: { value: '' }, dptDateTime: { value: '19.001' }, gaDate: { value: '' }, nameDate: { value: '' }, dptDate: { value: '11.001' }, gaTime: { value: '' }, nameTime: { value: '' }, dptTime: { value: '10.001' }, sendOnDeploy: { value: true }, sendOnDeployDelay: { value: 30, validate: RED.validators.number() }, periodicSend: { value: true }, periodicSendInterval: { value: 60, validate: RED.validators.number() }, periodicSendUnit: { value: 'm' } }, inputs: 0, outputs: 0, icon: 'node-knx-icon.svg', label: function () { return this.name || 'KNX DateTime' }, paletteLabel: function () { try { return RED._('node-red-contrib-knx-ultimate/knxUltimateDateTime:knxUltimateDateTime.paletteLabel') || 'DateTime' } catch (error) { return 'DateTime' } }, onadd: function () { // Auto-select a KNX gateway (first one with ETS CSV imported) and prefill coherent group addresses. // This runs when the node is dragged from the palette. Best-effort, non-blocking. const node = this setTimeout(() => { knxAutoConfigureDateTimeNode(node, { updateDom: false, preferExistingServer: true }) }, 50) }, button: { enabled: function () { return !this.changed }, visible: function () { return true }, onclick: function () { const node = this $.ajax({ type: 'POST', url: 'knxUltimateDateTime/sendNow', data: { id: node.id }, success: function (response) { const queued = response && response.queued === true const message = queued ? (RED._('node-red-contrib-knx-ultimate/knxUltimateDateTime:knxUltimateDateTime.notifyQueued') || 'Queued (gateway not connected yet)') : (RED._('node-red-contrib-knx-ultimate/knxUltimateDateTime:knxUltimateDateTime.notifySent') || 'Sent to KNX') RED.notify(message, 'success') }, error: function (xhr) { let message = 'Error' try { if (xhr && xhr.responseJSON && xhr.responseJSON.error) message = xhr.responseJSON.error } catch (error) { /* ignore */ } RED.notify(message, 'error') } }) } }, oneditprepare: function () { const node = this const $knxServerInput = $('#node-input-server') const KNX_EMPTY_VALUES = new Set(['', '_ADD_', '__NONE__', 'none']) const KNX_GA_CACHE = node._knxGaCache || (node._knxGaCache = new Map()) try { RED.sidebar.show('help') } catch (error) { /* ignore */ } const resolveKnxServerValue = () => { const domValue = $knxServerInput.val() if (domValue !== undefined && domValue !== null && domValue !== '') return domValue if (node.server !== undefined && node.server !== null && node.server !== '') return node.server return '' } const hasKnxServerSelected = () => { const val = resolveKnxServerValue() return !(val === undefined || val === null || KNX_EMPTY_VALUES.has(String(val))) } const fetchGroupAddresses = (serverId) => { if (!serverId) return Promise.resolve([]) if (KNX_GA_CACHE.has(serverId)) return Promise.resolve(KNX_GA_CACHE.get(serverId)) return new Promise((resolve) => { $.getJSON(`knxUltimatecsv?nodeID=${serverId}&_=${Date.now()}`, (data) => { const list = Array.isArray(data) ? data : [] KNX_GA_CACHE.set(serverId, list) resolve(list) }).fail(() => resolve([])) }) } const setupGA = (gaSelector, nameSelector, dptSelector, allowedPrefixes, defaultDpt) => { const $gaInput = $(gaSelector) const $nameInput = $(nameSelector) const $dptInput = $(dptSelector) if (!$gaInput.length) return if ($dptInput.length && (!$dptInput.val() || $dptInput.val() === '')) $dptInput.val(defaultDpt) const sourceFn = (request, response) => { if (!hasKnxServerSelected()) { response([]) return } const serverId = resolveKnxServerValue() fetchGroupAddresses(serverId).then((data) => { const items = [] data.forEach((entry) => { const dpt = entry.dpt || '' const allowed = allowedPrefixes.some((prefix) => prefix === '' || dpt.startsWith(prefix)) if (!allowed) return const devName = entry.devicename || '' const searchStr = `${entry.ga} (${devName}) DPT${dpt}` if (!htmlUtilsfullCSVSearch(searchStr, request.term || '')) return items.push({ label: `${entry.ga} # ${devName} # ${dpt}`, value: entry.ga, ga: entry.ga }) }) response(items) }) } if ($gaInput.data('knx-ga-initialised')) { $gaInput.autocomplete('option', 'source', sourceFn) } else { $gaInput .autocomplete({ minLength: 0, source: sourceFn, select: (event, ui) => { let deviceName = '' try { deviceName = ui.item.label.split('#')[1].trim() } catch (error) { deviceName = '' } if ($nameInput.length) { $nameInput.val(deviceName || '') } try { const parts = ui.item.label.split('#') const dptFromLabel = parts.length >= 3 ? parts[2].trim() : '' $dptInput.val(dptFromLabel || defaultDpt) } catch (error) { $dptInput.val(defaultDpt) } } }) .on('focus.knxUltimateDateTime click.knxUltimateDateTime', function () { const currentValue = $(this).val() || '' try { $(this).autocomplete('search', `${currentValue} exactmatch`) } catch (error) { /* ignore */ } }) $gaInput.data('knx-ga-initialised', true) } try { if (hasKnxServerSelected()) { const srv = RED.nodes.node(resolveKnxServerValue()) if (srv && srv.id) KNX_enableSecureFormatting($gaInput, srv.id) } } catch (error) { /* ignore */ } } const refresh = () => { setupGA('#node-input-gaDateTime', '#node-input-nameDateTime', '#node-input-dptDateTime', ['19.'], '19.001') setupGA('#node-input-gaDate', '#node-input-nameDate', '#node-input-dptDate', ['11.'], '11.001') setupGA('#node-input-gaTime', '#node-input-nameTime', '#node-input-dptTime', ['10.'], '10.001') } $knxServerInput.on('change', () => { KNX_GA_CACHE.clear() refresh() try { const sid = resolveKnxServerValue() if (!sid || sid === '_ADD_') return const hasAnyGAInUi = !!( ($('#node-input-gaDateTime').val() || '').toString().trim() || ($('#node-input-gaDate').val() || '').toString().trim() || ($('#node-input-gaTime').val() || '').toString().trim() ) // If the current values were auto-filled, allow overwrite when server changes. const shouldOverwrite = node._knxDateTimeAutoConfigured === true && node._knxDateTimeAutoConfiguredServer && node._knxDateTimeAutoConfiguredServer !== sid // Otherwise fill only empty fields (do not override manual config). knxAutoConfigureDateTimeNodeForServer(node, sid, { updateDom: true, overwrite: shouldOverwrite || !hasAnyGAInUi }) } catch (error) { /* ignore */ } }) refresh() // Auto-select server + fill GAs only for a brand new node (all GAs empty). setTimeout(() => { knxAutoConfigureDateTimeNode(node, { updateDom: true, preferExistingServer: true }) }, 50) const syncUi = () => { const sendOnDeploy = $('#node-input-sendOnDeploy').is(':checked') $('.knx-datetime-deploy-options').toggle(sendOnDeploy) const periodicSend = $('#node-input-periodicSend').is(':checked') $('.knx-datetime-periodic-options').toggle(periodicSend) } $('#node-input-sendOnDeploy').on('change', syncUi) $('#node-input-periodicSend').on('change', syncUi) syncUi() } }) </script> <script type="text/html" data-template-name="knxUltimateDateTime"> <div class="form-row" style="display:flex; align-items:center;"> <label for="node-input-server" style="width:180px"><i class="fa fa-circle-o"></i> <span data-i18n="knxUltimateDateTime.node-input-server"></span></label> <input type="text" id="node-input-server" style="flex:1"> </div> <div class="form-row" style="display:flex; align-items:center;"> <label for="node-input-name" style="width:180px"><i class="fa fa-tag"></i> <span data-i18n="knxUltimateDateTime.node-input-name"></span></label> <input type="text" id="node-input-name" style="flex:1" placeholder="KNX DateTime"> </div> <div class="form-row" style="display:flex; align-items:center;"> <label for="node-input-outputtopic" style="width:180px"><i class="fa fa-comment"></i> <span data-i18n="knxUltimateDateTime.node-input-outputtopic"></span></label> <input type="text" id="node-input-outputtopic" style="flex:1" placeholder="events/knx/datetime" data-i18n="[placeholder]knxUltimateDateTime.placeholders.outputtopic"> </div> <hr> <div class="form-row" style="margin:4px 0 2px;"> <span style="font-weight:bold;" data-i18n="knxUltimateDateTime.section_addresses"></span> </div> <div class="form-row" style="display:flex; align-items:center; gap:8px;"> <label for="node-input-gaDateTime" style="width:180px"><i class="fa fa-calendar"></i> <span data-i18n="knxUltimateDateTime.gaDateTime"></span></label> <input type="text" id="node-input-gaDateTime" style="width:160px" placeholder="1/7/1" data-i18n="[placeholder]knxUltimateDateTime.placeholders.ga"> <input type="text" id="node-input-nameDateTime" style="flex:1" placeholder="DateTime object" data-i18n="[placeholder]knxUltimateDateTime.placeholders.nameDateTime"> <label for="node-input-dptDateTime" style="width:60px; text-align:right">DPT</label> <input type="text" id="node-input-dptDateTime" style="width:160px" readonly> </div> <div class="form-row" style="display:flex; align-items:center; gap:8px;"> <label for="node-input-gaDate" style="width:180px"><i class="fa fa-calendar-o"></i> <span data-i18n="knxUltimateDateTime.gaDate"></span></label> <input type="text" id="node-input-gaDate" style="width:160px" placeholder="1/7/2" data-i18n="[placeholder]knxUltimateDateTime.placeholders.ga"> <input type="text" id="node-input-nameDate" style="flex:1" placeholder="Date object" data-i18n="[placeholder]knxUltimateDateTime.placeholders.nameDate"> <label for="node-input-dptDate" style="width:60px; text-align:right">DPT</label> <input type="text" id="node-input-dptDate" style="width:160px" readonly> </div> <div class="form-row" style="display:flex; align-items:center; gap:8px;"> <label for="node-input-gaTime" style="width:180px"><i class="fa fa-clock-o"></i> <span data-i18n="knxUltimateDateTime.gaTime"></span></label> <input type="text" id="node-input-gaTime" style="width:160px" placeholder="1/7/3" data-i18n="[placeholder]knxUltimateDateTime.placeholders.ga"> <input type="text" id="node-input-nameTime" style="flex:1" placeholder="Time object" data-i18n="[placeholder]knxUltimateDateTime.placeholders.nameTime"> <label for="node-input-dptTime" style="width:60px; text-align:right">DPT</label> <input type="text" id="node-input-dptTime" style="width:160px" readonly> </div> <hr> <div class="form-row" style="margin:4px 0 2px;"> <span style="font-weight:bold;" data-i18n="knxUltimateDateTime.section_send"></span> </div> <div class="form-row" style="display:flex; align-items:center;"> <label for="node-input-sendOnDeploy" style="width:180px"><i class="fa fa-play"></i> <span data-i18n="knxUltimateDateTime.node-input-sendOnDeploy"></span></label> <input type="checkbox" id="node-input-sendOnDeploy" style="width:auto"> </div> <div class="form-row knx-datetime-deploy-options" style="display:flex; align-items:center;"> <label for="node-input-sendOnDeployDelay" style="width:180px"><i class="fa fa-clock-o"></i> <span data-i18n="knxUltimateDateTime.node-input-sendOnDeployDelay"></span></label> <input type="number" id="node-input-sendOnDeployDelay" style="width:120px"> </div> <div class="form-row" style="display:flex; align-items:center;"> <label for="node-input-periodicSend" style="width:180px"><i class="fa fa-repeat"></i> <span data-i18n="knxUltimateDateTime.node-input-periodicSend"></span></label> <input type="checkbox" id="node-input-periodicSend" style="width:auto"> </div> <div class="form-row knx-datetime-periodic-options" style="display:flex; align-items:center; gap:8px;"> <label for="node-input-periodicSendInterval" style="width:180px"><i class="fa fa-hourglass"></i> <span data-i18n="knxUltimateDateTime.node-input-periodicSendInterval"></span></label> <input type="number" id="node-input-periodicSendInterval" style="width:120px"> <select id="node-input-periodicSendUnit" style="width:160px"> <option value="s" data-i18n="knxUltimateDateTime.unit_seconds"></option> <option value="m" data-i18n="knxUltimateDateTime.unit_minutes"></option> </select> </div> </script>