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, and KNX routing between interfaces. Easy to use and highly configurable.
559 lines (498 loc) • 24.6 kB
HTML
<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>