node-red-contrib-knx-ultimate 6.3.14 → 6.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@
4
4
  "sections": {
5
5
  "groupAssistant": "AI 助手",
6
6
  "groupChatHome": "对话与家庭",
7
+ "detectedAdapters": "自动检测到的适配器",
7
8
  "groupKnxAnalysis": "KNX 总线流量分析",
8
9
  "quickSetup": "助手配置",
9
10
  "capture": "总线报文输入",
@@ -57,7 +58,6 @@
57
58
  "proactiveCooldownMinutes": "重复通知冷却时间(分钟)",
58
59
  "proactiveQuietStart": "静默时段开始",
59
60
  "proactiveQuietEnd": "静默时段结束",
60
- "homeMemoryMaxKb": "家庭记忆文件最大值(KB)",
61
61
  "aiEducation": "AI 教育(由用户管理)",
62
62
  "llmIncludeDocsSnippets": "Include documentation snippets (help/README/examples)",
63
63
  "llmDocsLanguage": "Docs language"
@@ -86,11 +86,16 @@
86
86
  "installOllamaModelFailed": "Failed to install Ollama model",
87
87
  "ollamaInstallSteps": "1) Open the model library and copy the model name (for example llama3.1). 2) Put the name in the Model field and click Install it.",
88
88
  "ollamaStartedAuto": "Ollama server started automatically.",
89
+ "detectedAdaptersLoading": "正在检测已安装的适配器…",
90
+ "detectedAdaptersNone": "未检测到自动适配器。",
91
+ "detectedAdaptersUnavailable": "自动适配器检测暂时不可用。",
92
+ "detectedAdapterDetected": "已检测",
93
+ "detectedAdapterControllers": "控制器",
94
+ "detectedAdapterCameras": "摄像机",
89
95
  "chatAdapterIntro": "选择预设即可插入输入和输出映射代码。列表从随包提供的聊天适配器文件加载;生成的代码仍可编辑。",
90
96
  "chatAdapterCodeHelp": "映射同步运行。返回 msg 以继续,或不返回值以丢弃消息。错误会被捕获并报告,不会停止 Node-RED。",
91
97
  "homeIntelligenceIntro": "节点会创建多语言 ETS 语义模型,并在可靠识别的卷帘、窗户或门长时间保持打开时主动通知聊天。它绝不会自行发送 KNX 命令。",
92
- "aiEducationHelp": "只有用户可以编辑此部分。AI 会把它作为权威指导读取,但学习到的记忆绝不能覆盖它。最多 16,000 个字符。",
93
- "homeMemoryLimitHelp": "Markdown 记忆每 15 分钟以原子方式重写,并始终限制在 64 到 1,024 KB。达到限制时会先删除较旧的观察,再删除习惯和语义对象。"
98
+ "aiEducationHelp": "只有用户可以编辑此部分。AI 会把它作为权威指导读取,但学习到的记忆绝不能覆盖它。最多 16,000 个字符。"
94
99
  },
95
100
  "placeholder": {
96
101
  "llmBaseUrl": "https://api.openai.com/v1/chat/completions (or your compatible endpoint)",
@@ -0,0 +1,302 @@
1
+ const KNX_AI_CAMERA_REGISTRY_KEY = Symbol.for('node-red.knx-ai.camera-adapters.v1')
2
+ const KNX_AI_CAMERA_IMAGE_MAX_BYTES = 6 * 1024 * 1024
3
+ const KNX_AI_CAMERA_MAX_ACTIONS = 8
4
+
5
+ const clampText = (value, maxChars = 240) => String(value === undefined || value === null ? '' : value)
6
+ .trim()
7
+ .slice(0, Math.max(0, Number(maxChars) || 0))
8
+
9
+ const normalizeSearchText = value => clampText(value, 300)
10
+ .normalize('NFKD')
11
+ .replace(/[\u0300-\u036f]/g, '')
12
+ .toLocaleLowerCase()
13
+ .replace(/[^a-z0-9]+/g, ' ')
14
+ .trim()
15
+
16
+ const uniqueTexts = values => Array.from(new Set((Array.isArray(values) ? values : [])
17
+ .map(value => clampText(value, 240))
18
+ .filter(Boolean)))
19
+
20
+ // Runtime contract for optional camera suites. A package registers one adapter
21
+ // manifest and one provider per configured controller. Providers expose:
22
+ // listCameras({ force }) -> camera[]
23
+ // takeSnapshot({ cameraId, cameraName, highQuality }) -> { data, mediaType, camera }
24
+ // subscribe(listener) -> unsubscribe(), with normalized camera event objects.
25
+ // The global Symbol lets separately installed Node-RED packages share the same
26
+ // in-process registry without either package importing the other one.
27
+ const getKnxAiCameraAdapterRegistry = () => {
28
+ const existing = globalThis[KNX_AI_CAMERA_REGISTRY_KEY]
29
+ if (existing && existing.version === 1 && existing.adapters instanceof Map && existing.providers instanceof Map) return existing
30
+ const registry = {
31
+ version: 1,
32
+ adapters: new Map(),
33
+ providers: new Map(),
34
+ listeners: new Set(),
35
+ registerAdapter (adapter) {
36
+ if (!adapter || !adapter.id) return
37
+ this.adapters.set(String(adapter.id), Object.freeze(Object.assign({}, adapter)))
38
+ this.listeners.forEach(listener => {
39
+ try { listener({ type: 'adapter_registered', adapter: this.adapters.get(String(adapter.id)) }) } catch (error) { /* ignore */ }
40
+ })
41
+ },
42
+ registerProvider (provider) {
43
+ if (!provider || !provider.id) return
44
+ this.providers.set(String(provider.id), provider)
45
+ this.listeners.forEach(listener => {
46
+ try { listener({ type: 'provider_registered', provider }) } catch (error) { /* ignore */ }
47
+ })
48
+ },
49
+ unregisterProvider (providerId) {
50
+ const id = String(providerId || '')
51
+ const provider = this.providers.get(id)
52
+ if (!provider) return
53
+ this.providers.delete(id)
54
+ this.listeners.forEach(listener => {
55
+ try { listener({ type: 'provider_unregistered', provider }) } catch (error) { /* ignore */ }
56
+ })
57
+ },
58
+ subscribe (listener) {
59
+ if (typeof listener !== 'function') return () => {}
60
+ this.listeners.add(listener)
61
+ return () => this.listeners.delete(listener)
62
+ }
63
+ }
64
+ globalThis[KNX_AI_CAMERA_REGISTRY_KEY] = registry
65
+ return registry
66
+ }
67
+
68
+ const normalizeKnxAiCameraRegistration = (value = {}) => {
69
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {}
70
+ const id = clampText(source.cameraId || source.id, 160)
71
+ const name = clampText(source.cameraName || source.name || id, 240)
72
+ const state = clampText(source.state, 80).toUpperCase()
73
+ const online = typeof source.online === 'boolean'
74
+ ? source.online
75
+ : state
76
+ ? ['CONNECTED', 'ONLINE'].includes(state)
77
+ : null
78
+ if (!id && !name) return null
79
+ return {
80
+ id,
81
+ name,
82
+ aliases: uniqueTexts([name, id].concat(Array.isArray(source.aliases) ? source.aliases : [])),
83
+ source: clampText(source.source || 'camera-adapter', 80),
84
+ adapterId: clampText(source.adapterId, 120),
85
+ adapterTitle: clampText(source.adapterTitle, 240),
86
+ providerId: clampText(source.providerId, 200),
87
+ controllerId: clampText(source.controllerId, 160),
88
+ controllerName: clampText(source.controllerName, 240),
89
+ nativeCameraId: clampText(source.nativeCameraId, 160),
90
+ state,
91
+ online,
92
+ objectTypes: uniqueTexts(source.objectTypes).map(normalizeCameraObjectType).filter(Boolean).slice(0, 24),
93
+ lines: (Array.isArray(source.lines) ? source.lines : []).slice(0, 80),
94
+ zones: (Array.isArray(source.zones) ? source.zones : []).slice(0, 80),
95
+ lastSeenAt: clampText(source.lastSeenAt || new Date().toISOString(), 64)
96
+ }
97
+ }
98
+
99
+ const resolveKnxAiCamera = ({ target, cameras } = {}) => {
100
+ const requested = normalizeSearchText(target)
101
+ const list = (Array.isArray(cameras) ? cameras : [])
102
+ .map(normalizeKnxAiCameraRegistration)
103
+ .filter(Boolean)
104
+ if (!requested) return { camera: null, ambiguous: false }
105
+
106
+ const exact = list.filter(camera => [camera.id, camera.name].concat(camera.aliases)
107
+ .some(alias => normalizeSearchText(alias) === requested))
108
+ if (exact.length === 1) return { camera: exact[0], ambiguous: false }
109
+ if (exact.length > 1) return { camera: null, ambiguous: true }
110
+
111
+ const partial = list.filter(camera => [camera.id, camera.name].concat(camera.aliases)
112
+ .some(alias => {
113
+ const normalizedAlias = normalizeSearchText(alias)
114
+ return normalizedAlias && (normalizedAlias.includes(requested) || requested.includes(normalizedAlias))
115
+ }))
116
+ if (partial.length === 1) return { camera: partial[0], ambiguous: false }
117
+ return { camera: null, ambiguous: partial.length > 1 }
118
+ }
119
+
120
+ const normalizeCameraEventType = value => {
121
+ const raw = normalizeSearchText(value).replace(/\s+/g, '')
122
+ if (['smartdetect', 'objectdetect', 'objectdetection', 'smartdetection'].includes(raw)) return 'smartDetect'
123
+ if (['smartdetectline', 'line', 'linecrossing', 'crossline'].includes(raw)) return 'smartDetectLine'
124
+ if (['smartdetectzone', 'zone', 'intrusionzone', 'intrusion'].includes(raw)) return 'smartDetectZone'
125
+ if (['smartdetectloiterzone', 'loiter', 'loiterzone'].includes(raw)) return 'smartDetectLoiterZone'
126
+ if (['motion', 'movement'].includes(raw)) return 'motion'
127
+ if (['ring', 'doorbell'].includes(raw)) return 'ring'
128
+ if (['smartaudiodetect', 'audio'].includes(raw)) return 'smartAudioDetect'
129
+ return clampText(value, 80)
130
+ }
131
+
132
+ const normalizeCameraObjectType = value => {
133
+ const raw = normalizeSearchText(value).replace(/\s+/g, '')
134
+ if (['person', 'people', 'persona', 'persone', 'human', 'someone', 'qualcuno'].includes(raw)) return 'person'
135
+ if (['animal', 'animale', 'animali', 'pet', 'pets', 'dog', 'cat', 'cane', 'gatto'].includes(raw)) return 'animal'
136
+ if (['vehicle', 'vehicles', 'veicolo', 'veicoli', 'car', 'auto', 'automobile'].includes(raw)) return 'vehicle'
137
+ if (['face', 'volto', 'faccia'].includes(raw)) return 'face'
138
+ if (['licenseplate', 'numberplate', 'targa'].includes(raw)) return 'licenseplate'
139
+ if (['package', 'parcel', 'pacco'].includes(raw)) return 'package'
140
+ return normalizeSearchText(value).replace(/\s+/g, '')
141
+ }
142
+
143
+ const normalizeCameraActionType = value => {
144
+ const raw = normalizeSearchText(value).replace(/\s+/g, '_')
145
+ if (['snapshot', 'get_snapshot', 'send_snapshot', 'camera_snapshot'].includes(raw)) return 'snapshot'
146
+ if (['analyze', 'analyse', 'analyze_snapshot', 'describe_snapshot', 'vision'].includes(raw)) return 'analyze'
147
+ if (['watch', 'subscribe', 'notify', 'create_watch'].includes(raw)) return 'watch'
148
+ if (['unwatch', 'unsubscribe', 'stop', 'remove_watch', 'delete_watch'].includes(raw)) return 'unwatch'
149
+ if (['list', 'list_watches', 'show_watches'].includes(raw)) return 'list_watches'
150
+ return ''
151
+ }
152
+
153
+ const normalizeKnxAiCameraAction = (value, cameras = []) => {
154
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
155
+ const type = normalizeCameraActionType(value.type || value.action || value.operation)
156
+ if (!type) return null
157
+ const target = clampText(value.cameraId || value.cameraName || value.camera || value.target, 240)
158
+ const resolved = resolveKnxAiCamera({ target, cameras })
159
+ const camera = resolved.camera
160
+ let eventType = normalizeCameraEventType(value.eventType || value.event)
161
+ const requestedScope = clampText(value.scopeId || value.scopeName || value.zoneId || value.lineId || value.zone || value.line, 240)
162
+ const availableScopes = camera
163
+ ? eventType === 'smartDetectLine'
164
+ ? camera.lines
165
+ : ['smartDetectZone', 'smartDetectLoiterZone'].includes(eventType)
166
+ ? camera.zones
167
+ : []
168
+ : []
169
+ const normalizedScopeTarget = normalizeSearchText(requestedScope)
170
+ const matchingScopes = normalizedScopeTarget
171
+ ? (Array.isArray(availableScopes) ? availableScopes : []).filter(scope => [scope && scope.id, scope && scope.name]
172
+ .some(candidate => normalizeSearchText(candidate) === normalizedScopeTarget))
173
+ : []
174
+ const resolvedScope = matchingScopes.length === 1 ? matchingScopes[0] : null
175
+ const objectTypes = uniqueTexts(Array.isArray(value.objectTypes)
176
+ ? value.objectTypes
177
+ : [value.objectType || value.smartDetectType]).map(normalizeCameraObjectType).filter(Boolean).slice(0, 12)
178
+ // A plain motion event has no classified object metadata. Convert requests
179
+ // such as "motion caused by a person/animal" to the generic smart-detection
180
+ // family so they can match Protect zone, line, or loiter detections.
181
+ if (type === 'watch' && eventType === 'motion' && objectTypes.length > 0) eventType = 'smartDetect'
182
+ return {
183
+ type,
184
+ cameraId: camera ? camera.id : clampText(value.cameraId, 160),
185
+ cameraName: camera ? camera.name : clampText(value.cameraName || value.camera || value.target, 240),
186
+ unresolvedTarget: camera ? '' : target,
187
+ unresolved: Boolean(target && !camera),
188
+ ambiguous: resolved.ambiguous,
189
+ eventType,
190
+ scopeId: resolvedScope ? clampText(resolvedScope.id, 160) : clampText(value.scopeId || value.zoneId || value.lineId, 160),
191
+ scopeName: resolvedScope ? clampText(resolvedScope.name || resolvedScope.id, 240) : clampText(value.scopeName || value.zone || value.line, 240),
192
+ unresolvedScope: Boolean(requestedScope && camera && !resolvedScope),
193
+ ambiguousScope: matchingScopes.length > 1,
194
+ objectTypes,
195
+ cooldownSeconds: Math.max(10, Math.min(86400, Number(value.cooldownSeconds) || 60)),
196
+ sendSnapshot: value.sendSnapshot !== false,
197
+ reason: clampText(value.reason, 500)
198
+ }
199
+ }
200
+
201
+ const normalizeKnxAiCameraActions = ({ actions, cameras } = {}) => (Array.isArray(actions) ? actions : [])
202
+ .slice(0, KNX_AI_CAMERA_MAX_ACTIONS)
203
+ .map(action => normalizeKnxAiCameraAction(action, cameras))
204
+ .filter(Boolean)
205
+
206
+ const normalizeKnxAiCameraEvent = value => {
207
+ const meta = value && typeof value === 'object' && !Array.isArray(value) && value.eventType
208
+ ? value
209
+ : null
210
+ if (!meta) return null
211
+ return {
212
+ cameraId: clampText(meta.cameraId, 160),
213
+ cameraName: clampText(meta.cameraName, 240),
214
+ eventType: normalizeCameraEventType(meta.eventType),
215
+ scopeId: clampText(meta.scopeId, 160),
216
+ scopeName: clampText(meta.scopeName, 240),
217
+ objectTypes: uniqueTexts(Array.isArray(meta.objectTypes) ? meta.objectTypes : []).map(normalizeSearchText).filter(Boolean),
218
+ eventId: clampText(meta.eventId, 160),
219
+ active: meta.active !== false,
220
+ at: clampText(meta.at || new Date().toISOString(), 64),
221
+ raw: meta.raw
222
+ }
223
+ }
224
+
225
+ const cameraWatchMatchesEvent = (watch, event) => {
226
+ if (!watch || !event) return false
227
+ const watchCameraId = clampText(watch.cameraId, 160)
228
+ const eventCameraId = clampText(event.cameraId, 160)
229
+ if (watchCameraId && eventCameraId && watchCameraId !== eventCameraId) return false
230
+ if ((!watchCameraId || !eventCameraId) && normalizeSearchText(watch.cameraName) !== normalizeSearchText(event.cameraName)) return false
231
+ const wantedObjects = (Array.isArray(watch.objectTypes) ? watch.objectTypes : []).map(normalizeCameraObjectType).filter(Boolean)
232
+ const watchEventType = normalizeCameraEventType(watch.eventType)
233
+ const eventType = normalizeCameraEventType(event.eventType)
234
+ const genericSmartDetection = watchEventType === 'smartDetect'
235
+ || (watchEventType === 'motion' && wantedObjects.length > 0)
236
+ if (genericSmartDetection) {
237
+ if (!['smartDetectZone', 'smartDetectLine', 'smartDetectLoiterZone'].includes(eventType)) return false
238
+ } else if (watchEventType && watchEventType !== eventType) return false
239
+ const watchScope = normalizeSearchText(watch.scopeId || watch.scopeName)
240
+ const eventScopeCandidates = [event.scopeId, event.scopeName].map(normalizeSearchText).filter(Boolean)
241
+ if (watchScope && !eventScopeCandidates.some(candidate => candidate === watchScope || candidate.includes(watchScope) || watchScope.includes(candidate))) return false
242
+ if (wantedObjects.length) {
243
+ const detectedObjects = (Array.isArray(event.objectTypes) ? event.objectTypes : []).map(normalizeCameraObjectType).filter(Boolean)
244
+ if (!detectedObjects.some(type => wantedObjects.includes(type))) return false
245
+ }
246
+ return true
247
+ }
248
+
249
+ const normalizeKnxAiCameraImage = ({ data, mediaType } = {}) => {
250
+ if (!Buffer.isBuffer(data)) throw new Error('The camera snapshot is not binary image data')
251
+ if (data.length === 0) throw new Error('The camera snapshot is empty')
252
+ if (data.length > KNX_AI_CAMERA_IMAGE_MAX_BYTES) throw new Error(`The camera snapshot exceeds ${KNX_AI_CAMERA_IMAGE_MAX_BYTES} bytes`)
253
+ const type = clampText(mediaType || 'image/jpeg', 80).toLocaleLowerCase().split(';')[0]
254
+ if (!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(type)) throw new Error(`Unsupported camera image type: ${type}`)
255
+ return { data, mediaType: type, bytes: data.length }
256
+ }
257
+
258
+ const buildKnxAiCameraNotificationText = ({ language, event } = {}) => {
259
+ const lang = clampText(language || 'en', 8).toLocaleLowerCase()
260
+ const camera = clampText(event && event.cameraName, 240) || clampText(event && event.cameraId, 160) || 'camera'
261
+ const eventType = normalizeCameraEventType(event && event.eventType)
262
+ const scope = clampText(event && (event.scopeName || event.scopeId), 240)
263
+ const objectTypes = uniqueTexts(event && event.objectTypes).join(', ')
264
+ const details = [scope, objectTypes].filter(Boolean).join(' — ')
265
+ const suffix = details ? ` (${details})` : ''
266
+ const labels = {
267
+ it: { smartDetectLine: 'un attraversamento di linea', smartDetectZone: 'un ingresso in zona', smartDetectLoiterZone: 'una permanenza in zona', motion: 'un movimento', ring: 'un suono del campanello', smartAudioDetect: 'un evento audio intelligente' },
268
+ de: { smartDetectLine: 'eine Linienüberquerung', smartDetectZone: 'einen Zoneneintritt', smartDetectLoiterZone: 'einen längeren Zonenaufenthalt', motion: 'eine Bewegung', ring: 'ein Klingeln', smartAudioDetect: 'ein intelligentes Audioereignis' },
269
+ fr: { smartDetectLine: 'un franchissement de ligne', smartDetectZone: 'une entrée dans une zone', smartDetectLoiterZone: 'une présence prolongée dans une zone', motion: 'un mouvement', ring: 'une sonnerie', smartAudioDetect: 'un événement audio intelligent' },
270
+ es: { smartDetectLine: 'un cruce de línea', smartDetectZone: 'una entrada en zona', smartDetectLoiterZone: 'una permanencia en zona', motion: 'un movimiento', ring: 'una llamada al timbre', smartAudioDetect: 'un evento de audio inteligente' },
271
+ zh: { smartDetectLine: '越线事件', smartDetectZone: '进入区域事件', smartDetectLoiterZone: '区域徘徊事件', motion: '移动事件', ring: '门铃事件', smartAudioDetect: '智能音频事件' },
272
+ en: { smartDetectLine: 'a line crossing', smartDetectZone: 'a zone entry', smartDetectLoiterZone: 'loitering in a zone', motion: 'motion', ring: 'a doorbell ring', smartAudioDetect: 'a smart audio event' }
273
+ }
274
+ const label = (labels[lang] || labels.en)[eventType] || (lang === 'zh' ? '摄像机事件' : lang === 'it' ? 'un evento' : lang === 'de' ? 'ein Ereignis' : lang === 'fr' ? 'un événement' : lang === 'es' ? 'un evento' : 'an event')
275
+ const copy = {
276
+ it: `La telecamera ${camera} ha rilevato ${label}${suffix}.`,
277
+ de: `Die Kamera ${camera} hat ${label} erkannt${suffix}.`,
278
+ fr: `La caméra ${camera} a détecté ${label}${suffix}.`,
279
+ es: `La cámara ${camera} ha detectado ${label}${suffix}.`,
280
+ zh: `摄像机 ${camera} 检测到${label}${suffix}。`,
281
+ en: `Camera ${camera} detected ${label}${suffix}.`
282
+ }
283
+ return copy[lang] || copy.en
284
+ }
285
+
286
+ module.exports = {
287
+ KNX_AI_CAMERA_IMAGE_MAX_BYTES,
288
+ KNX_AI_CAMERA_MAX_ACTIONS,
289
+ KNX_AI_CAMERA_REGISTRY_KEY,
290
+ buildKnxAiCameraNotificationText,
291
+ cameraWatchMatchesEvent,
292
+ getKnxAiCameraAdapterRegistry,
293
+ normalizeCameraEventType,
294
+ normalizeCameraObjectType,
295
+ normalizeKnxAiCameraAction,
296
+ normalizeKnxAiCameraActions,
297
+ normalizeKnxAiCameraEvent,
298
+ normalizeKnxAiCameraImage,
299
+ normalizeKnxAiCameraRegistration,
300
+ normalizeSearchText,
301
+ resolveKnxAiCamera
302
+ }