ioBroker Blockly
Blockly ist der grafische Skripteditor des ioBroker-JavaScript-Adapters. Automationen werden aus Blöcken zusammengesetzt, ohne dass der JavaScript-Code vollständig von Hand geschrieben werden muss.
Offizielle Dokumentation
Diese Seite gibt nur einen kurzen Einstieg. Die vollständige Beschreibung aller Blöcke und weitere Beispiele befinden sich in der offiziellen ioBroker-Dokumentation.
Home Assistant per MQTT mit ioBroker und VIS 2 verbinden
Dieses eigene UGSo-Beispiel ergänzt den Blockly-Einstieg: Der MQTT-Client für Home Assistant überträgt ausgewählte Entitäten an einen externen MQTT-Broker. Der hier gezeigte ioBroker-JavaScript-Helfer 0.1.34 bereitet diese Werte für VIS 2 auf und bietet eine wählbare flache Struktur mit kurzen Namen. Für die neue Option den vollständigen Helfer ersetzen und eigene Einstellungen übernehmen.
JavaScript, kein Blockly-Import
Das vollständige Skript wird im ioBroker-JavaScript-Adapter als JavaScript eingefügt. Es gehört weder in Home Assistant noch in den Blockly-XML-Import. Die vorhandenen Blockly-Beispiele weiter unten bleiben davon unabhängig.
Neu in 0.1.36: Einstellungen nach Updates erhalten
Erfolgreicher Neustarttest am 07.11.2026
Der Nutzer hat nach erneutem Speichern der Einstellungen beim Umstieg auf 0.1.36 die MQTT-Client-App neu gestartet und bestätigt: Der Messwerte-Haken bleibt gesetzt, die MQTT-Struktur bleibt „Flach mit kurzen Namen“. Damit ist der praktische Neustarttest erfolgreich. Ein weiterer Versionswechsel wurde dabei nicht geprüft; der praktische Update-Test auf die nächste Version steht noch aus.
Gespeicherte App-Einstellungen bleiben jetzt auch erhalten, wenn der Home-Assistant-Supervisor beim Update seine Optionsdatei neu erzeugt. Das betrifft insbesondere den Messwerte-Haken und die MQTT-Struktur für neue Entitäten, aber auch die übrigen in der App gespeicherten Einstellungen und Zugangsdaten. Die App sichert sie unabhängig in der privaten Datei /data/mqtt-settings.json. Die MQTT-Pfadzuordnungen bleiben weiterhin in /data/mqtt-routes.json; vorhandene Datenpunkte und VIS-2-Verknüpfungen werden nicht geändert.
Einmaliger Umstieg von 0.1.35 oder älter: Vor dem Update die Einstellungen in der App exportieren. Falls sie beim ersten Wechsel bereits zurückgesetzt wurden, die Sicherung nach dem Update wieder importieren oder die beiden Optionen einmal erneut wählen und Speichern und verbinden drücken. Schon verlorene Einstellungen können nicht zuverlässig rekonstruiert werden; zusätzliche Messwerte werden nicht ungefragt aktiviert.
Die Einstellungen bevorzugt in der App ändern. Tatsächlich geänderte Supervisor-Konfigurationswerte werden beim Neustart übernommen; unveränderte Supervisor-Standardwerte setzen App-Auswahlen nicht zurück. Soll eine App-Auswahl ausdrücklich auf ihren Standard zurück, dies in der App wählen und speichern. Die vorhandene Einstellungssicherung bleibt verwendbar und enthält weiterhin keine Passwörter oder privaten TLS-Schlüssel. Der JavaScript-Helfer bleibt 0.1.34; hierfür muss er nicht erneut ersetzt werden.
Hintergrund: Der Supervisor verwaltet /data/options.json; für eigene dauerhafte Daten ist das private /data-Verzeichnis vorgesehen. Offizielle HA-Dokumentation.
Neu in 0.1.34: flache MQTT-Pfade nur für neue Entitäten
Unter Übertragung und TLS → MQTT-Struktur für neue Entitäten kann Flach mit kurzen Namen gewählt werden. Standard bleibt die bisherige Struktur. Danach Speichern und verbinden. Die Einstellung gilt für erstmals ausgewählte manuelle Entitäten und neue Label-Mitglieder, einschließlich optionaler Gerätemesswerte.
Bereits bekannte Entitäten behalten ihre bisherigen Pfade. Die Zuordnung wird dauerhaft in /data/mqtt-routes.json gespeichert und ist in der exportierten Einstellungssicherung enthalten. Auch nach Entfernen und erneutem Auswählen eines Labels, Umbenennen, Neustart oder Update wird derselbe Pfad verwendet. Beim ersten Upgrade werden vorhandene Auswahlen und erfasste Topics als Bestand übernommen; die bestehende Label-Auswahl wird beim ersten erfolgreichen Abgleich gesichert. Diese Datei nicht löschen. Eine Rückstellung auf die bisherige Struktur migriert keine bereits zugeordneten Entitäten.
Beispiel für neue flache MQTT-Topics:
ha_external/geraet/schalter
ha_external/geraet/schalter_set
ha_external/geraet/schalter_attribute/device_class
ha_external/geraet/energie
ha_external/geraet/leistung
ha_external/geraet/licht_brightness
ha_external/geraet/licht_brightness_setAlte Topics wie ha_external/geraet/switch/state und …/switch/set bleiben parallel gültig. Vergebene kurze Namen werden bei Kollisionen eindeutig ergänzt. Vorhandene Datenpunkte, Werte, Aliase und VIS-2-Verknüpfungen werden weder umbenannt noch gelöscht.
Vor Aktivierung den vollständigen JavaScript-Helfer 0.1.34 installieren und eigene Einstellungen übernehmen. Er liest beide MQTT-Strukturen anhand des übertragenen Auswahlkatalogs mit exakten Status- und Rückkanalpfaden. Erst Vorschau prüfen, dann mode: 'apply'. Ältere Helfer erkennen die neuen flachen MQTT-Pfade nicht. virtualLayout: 'flat' ist davon unabhängig: Diese Helferoption bestimmt nur die Darstellung unter 0_userdata.0.ugso_mqtt. Die bisherigen manuellen Rollen und Verknüpfungen bleiben geschützt.
Wozu ist der Helfer gedacht?
MQTT verwendet für Rückmeldung und Bedienung getrennte Topics. Der Helfer verbindet beide über einen virtuellen Datenpunkt pro Wert unter 0_userdata.0.ugso_mqtt. In VIS 2 genügt dann dieser eine Datenpunkt für Anzeige und Bedienung.
HA -> MQTT state -> 0_userdata.0.ugso_mqtt -> VIS 2
VIS 2 -> 0_userdata.0.ugso_mqtt -> MQTT set -> HA- HA-Rückmeldungen aktualisieren den virtuellen Datenpunkt mit
ack: true. - Eine Bedienung in VIS 2 schreibt mit
ack: false; nur ein freigegebener Rückkanal wird verwendet. Ein Broker-Auftrag gilt erst nach einer neuen tatsächlichen HA-Rückmeldung als bestätigt. - Unterstützte Light-Werte werden passend normalisiert, etwa Helligkeit auf 0–100 %. Messwerte und Datum bleiben lesend.
- Geeignete HA-Metadaten liefern Datentyp, Einheit und eindeutige Rollen. Vorhandene Werte, manuelle Rollen und fremde Objekte bleiben geschützt.
- Das Skript verarbeitet betroffene MQTT-Änderungen gezielt. Es führt nicht bei jeder beliebigen Messwertänderung einen vollständigen Neuaufbau aus.
Voraussetzungen und Einrichtung
- MQTT-Client in HA und ioBroker-MQTT-Adapter einrichten. Die App überträgt an den externen Broker; für den hier beschriebenen Rückkanal wird der ioBroker-MQTT-Adapter im Modus Server/Broker verwendet.
- Entitäten auswählen oder das konfigurierte HA-Label setzen. Zusätzliche Gerätemesswerte benötigen ausdrücklich den Haken „Zugehörige Mess- und Batteriewerte desselben Geräts automatisch einbeziehen“ und anschließend „Speichern und verbinden“. Nur vorhandene, unterstützte Sensoren desselben HA-Geräts werden ergänzt.
- Im ioBroker-JavaScript-Adapter ein neues JavaScript-Skript anlegen und den vollständigen Code unten einfügen. Der Helfer ist für Adapter 10.4.10 geprüft.
- In den Adaptereinstellungen setObject erlauben und Do not subscribe to all states on start deaktivieren. Das Skript benötigt synchrone Objekt-/Zustandscaches. Für das reine Ansehen des Codes sind keine Freigaben erforderlich.
- Pfade im Einstellungsblock prüfen und zunächst
mode: 'preview'lassen. Die Vorschau legt nur fehlende eigene Basisordner/Sicherungsdatenpunkte an und protokolliert geplante Zuordnungen; sie erzeugt keine bedienbaren virtuellen Werte und sendet keine Schaltbefehle. - Nach Prüfung
mode: 'apply'einstellen und das Skript neu starten. Nun werden die virtuellen Datenpunkte angelegt und synchronisiert. - In VIS 2 den passenden virtuellen Datenpunkt auswählen, nicht den rohen MQTT-
set-Datenpunkt.
Einstellungsblock und Sicherung
| Einstellung | Bedeutung |
|---|---|
mqttRoot | Eingehender MQTT-Baum, standardmäßig mqtt.0.ha_external. |
mode | preview: prüfen; apply: anwenden; restore: eigene gesicherte Änderungen geschützt zurücknehmen. |
outputMode | virtual für VIS-2-Datenpunkte; roles für die alternative Rollen-/Alias-Zuordnung. |
virtualLayout | nested: bisherige IDs (Standard); flat: kurze Datenpunkte direkt im Geräteordner. |
virtualRoot | Zielbaum der virtuellen Werte. |
virtualBackupId, backupId | Eigene Sicherungsjournale; vorhandene Sicherungen werden erhalten. |
aliasRoot | Ziel für lesende Aliase im Rollenmodus. |
excludeIds | Exakte Quell-IDs, die nicht bearbeitet werden sollen. |
Bei bereits vorhandenem Skript die eigenen Einstellungen übernehmen. restore ist keine Wiederherstellung der gesamten ioBroker-Installation: Später manuell veränderte Objekte bleiben geschützt, virtuelle Werte/Referenzen werden erhalten und ihre Steuerung wird deaktiviert. Für die Details gilt die vollständige Helfer-Anleitung, einschließlich Rollentabelle, Sicherung und Grenzen.
Wählbare flache Struktur mit kurzen Namen
Im vollständigen Helfer 0.1.34 kann im Einstellungsblock virtualLayout: 'flat' gewählt werden. Standard bleibt virtualLayout: 'nested'; fehlt die neue Einstellung in übernommenen Optionen, gilt ebenfalls die bisherige Struktur. Prüfen Sie zuerst die Vorschau und wechseln Sie danach zu mode: 'apply'.
0_userdata.0.ugso_mqtt.pc_tv_wohnzimmer
schalter
energie
leistung
spannung
stromstaerkeDie technischen IDs verwenden diese kurzen deutschen Namen in allen Sprachen. Energie, Leistung, Spannung und Stromstärke werden aus der Geräteklasse erkannt; sonst wird ein geeigneter Teil der Entitäts-ID verwendet. Die Anzeigenamen lauten entsprechend etwa Energie oder Stromstärke. Für Lampen sind licht, helligkeit, farbe, effekt und farbtemperatur vorgesehen.
Mehrere passende Werte erhalten leistung, leistung_2, leistung_3 usw. Einmal vergebene Namen werden im vorhandenen Journal gespeichert und bleiben bei späteren Auswahländerungen erhalten. Bereits belegte Ordner, fremde Datenpunkte oder Sicherungs-IDs werden nicht überschrieben; stattdessen wird ein freier Zusatz vergeben.
Vorhandene Helfer-Datenpunkte werden weder gelöscht noch verschoben: Alte und neue IDs bleiben synchronisiert und ihre freigegebenen Rückkanäle erhalten. Bestehende VIS-2-Verknüpfungen müssen deshalb nicht geändert werden. Für neue Widgets können die kurzen IDs gewählt werden; alte Ordner bleiben zunächst sichtbar. Zurückschalten erfolgt mit virtualLayout: 'nested' im neuen Helfer. Die alte Datei 0.1.27 kann Journale mit flachen Einträgen nicht lesen.
MQTT-Objekte, Topic-Pfade, Werte und manuelle Rollen bleiben unverändert. Kann beim Zurückschalten ein benötigter Ordner wegen eines bestehenden Datenpunkts nicht angelegt werden, wird dieser neue Zielpfad ausgelassen und protokolliert; vorhandene Werte werden nicht ersetzt.
Vollständiges Skript mit Kopierbutton und Download
JavaScript-Helfer 0.1.34 herunterladen (.js)
Der Kopierbutton rechts oben im Codefeld übernimmt das gesamte Skript. Codefeld und Download verwenden dieselbe unveränderte Datei; sie enthält keine persönlichen Zugangsdaten. Die Pfade und den Modus anschließend für die eigene Installation prüfen.
/* MQTT-Client 0.1.34: ioBroker JavaScript adapter 10.4.10 compatible.
* Preview first. Settings and documentation: mqtt_client/iobroker/README*.md.
* No MQTT state values, types, units, permissions or commands are changed.
*/
'use strict';
const UGSO_MQTT_ROLE_SETTINGS = {
mqttRoot: 'mqtt.0.ha_external',
mode: 'preview', // preview | apply | restore
outputMode: 'virtual', // virtual (VIS 2 state/set) | roles (previous role/alias mapper)
virtualLayout: 'nested', // nested (existing IDs) | flat (short names per device)
virtualRoot: '0_userdata.0.ugso_mqtt',
virtualBackupId: '0_userdata.0.ugso_mqtt_helper.backup',
backupId: '0_userdata.0.ugso_mqtt_roles.backup',
aliasRoot: 'alias.0.ugso_mqtt_roles', // Read-only aliases when MQTT objects are writable.
excludeIds: [], // Exact state IDs; also protects manually assigned generic "state" roles.
};
const UgsoMqttRoles = (() => {
const numeric = {
temperature: ['value.temperature', ['°C', '°F', 'K']],
humidity: ['value.humidity', ['%']],
absolute_humidity: ['value.humidity.absolute', ['g/m³', 'mg/m³']],
battery: ['value.battery', ['%']],
voltage: ['value.voltage', ['V']],
current: ['value.current', ['A']],
power: ['value.power', ['W', 'kW']],
energy: ['value.energy', ['Wh', 'kWh']],
energy_storage: ['value.energy', ['Wh', 'kWh']],
reactive_power: ['value.power.reactive', ['var', 'kvar', 'kVar']],
reactive_energy: ['value.energy.reactive', ['varh', 'kvarh', 'kVarh']],
frequency: ['value.frequency', ['Hz']],
pressure: ['value.pressure', ['hPa', 'mbar']],
atmospheric_pressure: ['value.pressure', ['hPa', 'mbar']],
illuminance: ['value.brightness', ['lx', 'lux']],
distance: ['value.distance', ['m', 'km', 'cm', 'mm', 'mi', 'nmi', 'yd', 'ft', 'in']],
duration: ['value.interval', ['s', 'ms', 'h', 'd']],
wind_speed: ['value.speed', ['m/s', 'km/h', 'mph', 'kn']],
volume_flow_rate: ['value.flow', ['m³/h']],
signal_strength: ['value.rssi', ['dBm']],
aqi: ['value.airquality', ['']],
carbon_dioxide: ['value.co2', ['ppm']],
carbon_monoxide: ['value.co', ['ppm']],
nitrogen_monoxide: ['value.no', ['ppm', 'µg/m³', 'μg/m³']],
nitrogen_dioxide: ['value.no2', ['ppm', 'µg/m³', 'μg/m³']],
ozone: ['value.o3', ['ppm', 'µg/m³', 'μg/m³']],
sulphur_dioxide: ['value.so2', ['ppm', 'µg/m³', 'μg/m³']],
pm1: ['value.pm1', ['µg/m³', 'μg/m³']],
pm25: ['value.pm25', ['µg/m³', 'μg/m³']],
pm10: ['value.pm10', ['µg/m³', 'μg/m³']],
radon: ['value.rn', ['Bq/m³']],
volatile_organic_compounds: ['value.tvoc', ['µg/m³', 'μg/m³']],
volatile_organic_compounds_parts: ['value.tvoc', ['ppb']],
};
const binary = {
battery: 'indicator.lowbat', connectivity: 'indicator.reachable',
door: 'sensor.door', garage_door: 'sensor.door', window: 'sensor.window',
opening: 'sensor.contact', lock: 'sensor.lock', motion: 'sensor.motion',
moisture: 'sensor.alarm.flood', smoke: 'sensor.alarm.fire',
carbon_monoxide: 'sensor.alarm.co', light: 'sensor.light', sound: 'sensor.noise',
problem: 'indicator.error', running: 'indicator.working',
};
const own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
const clone = value => JSON.parse(JSON.stringify(value));
const stable = value => JSON.stringify(value, (key, item) => item && typeof item === 'object' && !Array.isArray(item) ? Object.fromEntries(Object.keys(item).sort().map(name => [name, item[name]])) : item);
const generic = role => role === undefined || role === '' || role === 'state';
const escapeRegex = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function validDate(value) {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const time = Date.parse(value + 'T00:00:00Z');
return Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === value;
}
function validTimestamp(value) {
return typeof value === 'string' && validDate(value.slice(0, 10)) &&
/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(value) &&
Number.isFinite(Date.parse(value));
}
function propose(domain, deviceClass, unit, object, state) {
if (typeof deviceClass !== 'string' || !object || object.type !== 'state' || !object.common || !state || state.val === null) return null;
const common = object.common;
if (common.read !== true) return null;
if (domain === 'sensor') {
if (deviceClass === 'date' || deviceClass === 'timestamp' || deviceClass === 'uptime') {
return common.type === 'string' && (deviceClass === 'date' ? validDate(state.val) : validTimestamp(state.val)) ? 'date' : null;
}
if (common.type !== 'number' || typeof state.val !== 'number' || !Number.isFinite(state.val)) return null;
const rule = own(numeric, deviceClass) ? numeric[deviceClass] : null;
if (!rule || !rule[1].includes(unit || '')) return null;
// An explicitly configured different unit must not be relabelled.
if (common.unit !== undefined && common.unit !== '' && common.unit !== unit) return null;
return rule[0];
}
if (domain === 'binary_sensor' && common.type === 'boolean' && typeof state.val === 'boolean') {
return own(binary, deviceClass) ? binary[deviceClass] : null;
}
return null;
}
function validateSettings(settings) {
if (!/^mqtt\.\d+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(settings.mqttRoot)) throw new Error('Invalid mqttRoot');
if (!['preview', 'apply', 'restore'].includes(settings.mode)) throw new Error('Invalid mode');
if (!/^0_userdata\.0\.[A-Za-z0-9_.-]+$/.test(settings.backupId)) throw new Error('Invalid backupId');
if (!/^alias\.0\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(settings.aliasRoot)) throw new Error('Invalid aliasRoot');
if (!Array.isArray(settings.excludeIds) || settings.excludeIds.some(id => typeof id !== 'string')) throw new Error('Invalid excludeIds');
}
function createMapper(api, settings) {
validateSettings(settings);
const inScope = id => typeof id === 'string' && id.startsWith(settings.mqttRoot + '.');
const excluded = id => settings.excludeIds.includes(id);
const metadataPattern = new RegExp('^' + escapeRegex(settings.mqttRoot) + '\\.(.+)(?:\\.attribute|_attribute)\\.device_class$');
const previewed = new Map();
function plan(metadataId, routes) {
const match = metadataId.match(metadataPattern);
if (!match) return null;
const flat = metadataId.endsWith('_attribute.device_class');
const base = metadataId.slice(0, -(flat ? '_attribute.device_class' : '.attribute.device_class').length);
const id = base + (flat ? '' : '.state');
if (!inScope(id) || excluded(id)) return null;
const object = api.getObject(id);
if (!object || !generic(object.common && object.common.role)) return null;
// MQTT device/value names do not reliably contain the HA domain.
// Disambiguate the allowlisted measurement/binary classes by their actual type.
const domain = object.common.type === 'boolean' ? 'binary_sensor' : 'sensor';
const deviceClass = api.getState(metadataId)?.val;
if (flat) {
const route = (routes || catalogRoutes(readCatalog(api, settings))).get(object.native?.topic);
if (!route?.flat || route.feature || route.selected.attributes?.device_class !== deviceClass) return null;
}
const unit = api.getState(base + (flat ? '_attribute.' : '.attribute.') + 'unit_of_measurement')?.val;
const role = propose(domain, deviceClass, unit, object, api.getState(id));
if (!role) return null;
const item = {id, role, type: object.common.type, hadRole: own(object.common, 'role'), previousRole: object.common.role ?? null, metadataId, kind: 'role'};
// Value/sensor roles are read-only. Preserve MQTT write flags by using an alias.
if (role !== 'date' && object.common.write !== false) {
item.kind = 'alias';
item.aliasId = settings.aliasRoot + id.slice(settings.mqttRoot.length);
if (api.getObject(item.aliasId)) return null;
const common = {name: object.common.name || id, type: item.type, role, read: true, write: false, alias: {id}};
for (const key of ['unit', 'min', 'max', 'states']) if (own(object.common, key)) common[key] = clone(object.common[key]);
if (unit && !common.unit) common.unit = unit;
item.aliasObject = {type: 'state', common, native: {ugsoMqttRoles: {source: id, version: 1}}};
}
return item;
}
async function loadJournal() {
const value = api.getState(settings.backupId)?.val;
if (value === undefined || value === null || value === '') {
if (api.getObject(settings.backupId)) throw new Error('Existing backup object is empty; use a separate backupId');
return {format: 'ugso-mqtt-roles', version: 1, mqttRoot: settings.mqttRoot, aliasRoot: settings.aliasRoot, entries: {}};
}
const data = JSON.parse(value);
if (data.format !== 'ugso-mqtt-roles' || data.version !== 1 || data.mqttRoot !== settings.mqttRoot || data.aliasRoot !== settings.aliasRoot || !data.entries || typeof data.entries !== 'object' || Array.isArray(data.entries)) throw new Error('Backup format/root mismatch');
for (const [id, entry] of Object.entries(data.entries)) {
if (!inScope(id) || !entry || typeof entry.role !== 'string' || typeof entry.hadRole !== 'boolean' || !['number', 'boolean', 'string'].includes(entry.type) || !(entry.previousRole === null || typeof entry.previousRole === 'string') || !generic(entry.hadRole ? entry.previousRole : undefined)) throw new Error('Invalid backup entry');
if (!['role', 'alias'].includes(entry.kind)) throw new Error('Invalid backup kind');
if (entry.kind === 'alias' && (entry.aliasId !== settings.aliasRoot + id.slice(settings.mqttRoot.length) || entry.aliasObject?.common?.alias?.id !== id)) throw new Error('Invalid alias backup');
}
return data;
}
async function run() {
const journal = await loadJournal();
let count = 0;
if (settings.mode === 'restore') {
for (const [id, entry] of Object.entries(journal.entries)) {
if (entry.restored || excluded(id)) continue;
if (entry.kind === 'alias') {
const alias = api.getObject(entry.aliasId);
if (!alias || alias.type !== 'state' || stable(alias.common) !== stable(entry.aliasObject.common) || stable(alias.native) !== stable(entry.aliasObject.native)) continue;
await api.deleteAlias(entry.aliasId);
entry.restored = new Date().toISOString();
await api.writeJournal(settings.backupId, JSON.stringify(journal));
api.log('Removed own unchanged alias: ' + entry.aliasId);
count++;
continue;
}
const current = api.getObject(id);
if (!current || current.type !== 'state' || current.common?.type !== entry.type || current.common.role !== entry.role) continue;
// Only restore the role. Keep all current object properties and values.
const object = clone(current);
if (entry.hadRole) object.common.role = entry.previousRole;
else delete object.common.role;
await api.restoreObject(id, object);
entry.restored = new Date().toISOString();
await api.writeJournal(settings.backupId, JSON.stringify(journal));
api.log('Restored: ' + id);
count++;
}
return count;
}
const routes = catalogRoutes(readCatalog(api, settings));
for (const metadataId of api.listMetadata()) {
const item = plan(metadataId, routes);
if (!item || own(journal.entries, item.id)) continue;
if (settings.mode !== 'preview' || previewed.get(item.id) !== item.role) {
api.log((settings.mode === 'preview' ? 'Preview: ' : 'Apply: ') + item.id + ' -> ' + item.role + (item.kind === 'alias' ? ' (read-only alias: ' + item.aliasId + ')' : ''));
previewed.set(item.id, item.role);
}
if (settings.mode === 'apply') {
journal.entries[item.id] = {...item, savedAt: new Date().toISOString()};
// Persist and confirm the original role BEFORE changing the MQTT object.
await api.writeJournal(settings.backupId, JSON.stringify(journal));
const fresh = plan(metadataId);
if (!fresh || fresh.role !== item.role || fresh.type !== item.type || fresh.previousRole !== item.previousRole || fresh.hadRole !== item.hadRole || fresh.kind !== item.kind) continue;
if (item.kind === 'alias') await api.createAlias(item.aliasId, item.aliasObject);
else await api.writeRole(item.id, item.role);
}
count++;
}
return count;
}
return {run};
}
function readCatalog(api, settings) {
const raw = api.getState(settings.mqttRoot + '.selected_entities')?.val;
if (typeof raw !== 'string' || !raw.startsWith('v1:')) return {};
try { const data = JSON.parse(raw.slice(3)); return data && typeof data === 'object' && !Array.isArray(data) ? data : {}; } catch { return {}; }
}
function catalogRoutes(catalog) {
const routes = new Map();
const add = (topic, route) => {
if (typeof topic !== 'string' || !topic || /[+#\u0000]/.test(topic)) return;
routes.set(topic, routes.has(topic) ? null : route); // Never guess ambiguous paths.
};
for (const [entity, selected] of Object.entries(catalog)) {
if (!/^[a-z0-9_]+\.[a-z0-9_]+$/.test(entity) || !selected || typeof selected !== 'object' || typeof selected.state_topic !== 'string') continue;
const flat = selected.topic_layout === 'flat';
if (!flat && !selected.state_topic.endsWith('/state')) continue;
const root = selected.state_topic;
add(root, {entity, selected, flat, feature: '', commandTopic: selected.command_topic || (flat ? null : root.slice(0,-6) + '/set')});
if (entity.startsWith('light.') && Array.isArray(selected.features)) for (const feature of selected.features) {
if (!['brightness', 'rgb_color', 'effect', 'color_temp_kelvin'].includes(feature)) continue;
add(selected.feature_state_topics?.[feature] || (!flat ? root.slice(0,-6) + '/' + feature + '/state' : null),
{entity, selected, flat, feature, commandTopic: selected.feature_command_topics?.[feature] || (!flat ? root.slice(0,-6) + '/' + feature + '/set' : null)});
}
}
return routes;
}
function createVirtualMapper(api, settings) {
validateSettings(settings);
const layout = settings.virtualLayout ?? 'nested';
if (!['nested', 'flat'].includes(layout)) throw new Error('Invalid virtualLayout');
if (!/^0_userdata\.0\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(settings.virtualRoot) ||
!/^0_userdata\.0\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/.test(settings.virtualBackupId) ||
settings.virtualRoot === settings.virtualBackupId) throw new Error('Invalid virtual scope');
const prefix = settings.mqttRoot + '.', output = settings.virtualRoot + '.';
const pending = new Map(), started = api.now(), previewed = new Set();
let ready = false;
const get = id => api.getState(id)?.val;
const list = value => {
if (typeof value === 'string') { try { value = JSON.parse(value); } catch { return []; } }
return Array.isArray(value) && value.every(v => typeof v === 'string') ? value : [];
};
const num = value => typeof value === 'number' && Number.isFinite(value);
const bool = value => value === true || value === 'on' || value === 'ON' ? true : value === false || value === 'off' || value === 'OFF' ? false : undefined;
const active = () => readCatalog(api, settings);
const online = () => get(settings.mqttRoot + '.availability') === 'online';
const targetFor = id => settings.virtualRoot + id.slice(settings.mqttRoot.length);
const excluded = (sourceId, targetId) => settings.excludeIds.includes(sourceId) || Boolean(targetId && settings.excludeIds.includes(targetId));
const owned = (object, record) => object?.type === 'state' && object.common?.type === record.common.type && stable(object.native) === stable(record.native);
const groupFor = sourceId => sourceId.slice(prefix.length).split('.')[0];
const flatRecord = (id, record) => {
const group = groupFor(record.sourceId);
return record.layout === 'flat' && /^[A-Za-z0-9_-]+$/.test(group) &&
new RegExp('^' + escapeRegex(output + group + '.') + '[a-z0-9_]{1,96}$').test(id);
};
function shortName(spec) {
const features = {brightness: 'helligkeit', rgb_color: 'farbe', effect: 'effekt', color_temp_kelvin: 'farbtemperatur'};
if (own(features, spec.feature)) return features[spec.feature];
const domain = spec.entity.split('.')[0];
if (domain === 'light') return 'licht';
if (['switch', 'input_boolean', 'fan', 'humidifier', 'automation', 'script', 'remote', 'siren'].includes(domain)) return 'schalter';
if (domain === 'binary_sensor' && spec.deviceClass === 'battery') return 'batterie_schwach';
const classes = {energy: 'energie', energy_storage: 'energiespeicher', power: 'leistung', voltage: 'spannung', current: 'stromstaerke', temperature: 'temperatur', humidity: 'luftfeuchte', battery: 'batterie', date: 'datum', timestamp: 'zeitstempel', uptime: 'zeitstempel'};
if (typeof spec.deviceClass === 'string' && own(classes, spec.deviceClass)) return classes[spec.deviceClass];
const group = groupFor(spec.sourceId), name = spec.entity.split('.')[1];
return (name.startsWith(group + '_') ? name.slice(group.length + 1) : name).slice(0, 64) || 'wert';
}
function flatDisplayName(id) {
const leaf = id.slice(id.lastIndexOf('.') + 1), match = leaf.match(/^(.*?)(_[0-9]+)?$/);
const names = {energie: 'Energie', energiespeicher: 'Energiespeicher', leistung: 'Leistung', spannung: 'Spannung', stromstaerke: 'Stromstärke', temperatur: 'Temperatur', luftfeuchte: 'Luftfeuchte', batterie: 'Batterie', batterie_schwach: 'Batterie schwach', datum: 'Datum', zeitstempel: 'Zeitstempel', schalter: 'Schalter', licht: 'Licht', helligkeit: 'Helligkeit', farbe: 'Farbe', effekt: 'Effekt', farbtemperatur: 'Farbtemperatur'};
return (own(names, match[1]) ? names[match[1]] : match[1].replaceAll('_', ' ')) + (match[2] ? ' ' + match[2].slice(1) : '');
}
function flatTarget(spec, data, recordsBySource, reserved) {
const existing = (recordsBySource.get(spec.sourceId) || []).find(id => data.entries[id].layout === 'flat');
if (existing) return existing; // Pin names even if selection order or HA metadata changes.
if (spec.sourceLayout === 'flat') {
const mirrored = (recordsBySource.get(spec.sourceId) || []).find(id => id === targetFor(spec.sourceId) && new RegExp('^' + escapeRegex(shortName(spec)) + '(?:_[0-9]+)?$').test(id.slice(id.lastIndexOf('.') + 1)));
if (mirrored) return mirrored;
}
const group = groupFor(spec.sourceId);
if (!/^[A-Za-z0-9_-]+$/.test(group)) return null;
const parent = output + group, object = api.getObject(parent);
if (object && !['folder', 'channel', 'device'].includes(object.type)) return null;
const name = shortName(spec);
for (let index = 1; index <= 10000; index++) {
const id = parent + '.' + name + (index === 1 ? '' : '_' + index);
if (![settings.virtualBackupId, settings.backupId].includes(id) && !reserved.has(id) && !api.getObject(id)) {
reserved.add(id);
return id;
}
}
throw new Error('No free flat name for ' + spec.sourceId);
}
function describe(id, catalog = active(), index = catalogRoutes(catalog)) {
if (!id.startsWith(prefix) || excluded(id)) return null;
const source = api.getState(id);
if (!source || source.ack !== true || source.q && source.q !== 0) return null;
const route = index.get(api.getObject(id)?.native?.topic);
if (!route) return null;
const {entity, feature, selected, flat} = route;
const root = flat ? feature ? id.slice(0, -feature.length - 1) : id : feature ? id.slice(0, -feature.length - 7) : id.slice(0,-6);
const metadata = root + (flat ? '_attribute.' : '.attribute.');
if (get(metadata + 'ha_entity_id') !== entity || api.getObject(root + (flat ? '' : '.state'))?.native?.topic !== selected.state_topic) return null;
const attribute = key => selected.attributes && own(selected.attributes, key) ? selected.attributes[key] : get(metadata + key);
const domain = entity.split('.')[0], dc = attribute('device_class');
const flag = attribute('command_enabled');
const enabled = selected.command === true && (flag === true || flag === 'true');
const setId = flat ? id + '_set' : id.slice(0,-6) + '.set';
let writable = enabled && api.getObject(setId)?.type === 'state' && api.getObject(setId)?.native?.topic === route.commandTopic;
let value = source.val, type = typeof value, role = 'state', encode;
const extra = {}, unit = feature === 'brightness' ? '%' : feature === 'color_temp_kelvin' ? 'K' : attribute('unit_of_measurement');
if (feature === 'rgb_color') {
let rgb = value;
if (typeof rgb === 'string') { try { rgb = JSON.parse(rgb); } catch { return null; } }
if (!Array.isArray(rgb) || rgb.length !== 3 || rgb.some(v => !Number.isInteger(v) || v < 0 || v > 255)) return null;
value = '#' + rgb.map(v => v.toString(16).padStart(2, '0')).join('').toUpperCase();
type = 'string'; role = writable ? 'level.color.rgb' : 'text';
encode = input => {
if (typeof input !== 'string' || !/^#[0-9a-f]{6}$/i.test(input)) throw new Error('Expected #RRGGBB');
return JSON.stringify([1, 3, 5].map(i => parseInt(input.slice(i, i + 2), 16)));
};
} else if (feature === 'brightness' || feature === 'color_temp_kelvin') {
const lo = feature === 'brightness' ? 0 : attribute('min_color_temp_kelvin');
const hi = feature === 'brightness' ? 100 : attribute('max_color_temp_kelvin');
if (!num(value) || !num(lo) || !num(hi) || value < lo || value > hi || lo > hi) return null;
if (feature === 'color_temp_kelvin' && !Number.isInteger(value)) return null;
type = 'number'; role = writable ? feature === 'brightness' ? 'level.dimmer' : 'level.color.temperature' : feature === 'brightness' ? 'value.dimmer' : 'value';
extra.min = lo; extra.max = hi;
encode = input => {
if (!num(input) || input < lo || input > hi || feature === 'color_temp_kelvin' && !Number.isInteger(input)) throw new Error('Light value outside limits');
return String(input);
};
} else if (feature === 'effect' || ['select', 'input_select'].includes(domain)) {
const options = list(attribute(feature === 'effect' ? 'effect_list' : 'options'));
if (typeof value !== 'string' || !options.includes(value)) return null;
type = 'string'; role = feature === 'effect' && writable ? 'level.effect' : 'state';
extra.states = Object.fromEntries(options.map(v => [v, v]));
encode = input => { if (!options.includes(input)) throw new Error('Option not supported'); return input; };
} else if (domain === 'binary_sensor' || ['switch', 'light', 'input_boolean', 'fan', 'humidifier', 'automation', 'script', 'remote', 'siren'].includes(domain)) {
value = bool(value); if (value === undefined) return null;
type = 'boolean';
if (domain === 'binary_sensor') writable = false;
role = writable ? domain === 'light' ? 'switch.light' : 'switch' : own(binary, dc) ? binary[dc] : 'sensor.switch';
encode = input => { if (typeof input !== 'boolean') throw new Error('Boolean expected'); return input ? 'ON' : 'OFF'; };
} else if (['number', 'input_number'].includes(domain)) {
if (!num(value)) return null;
type = 'number'; role = writable ? 'level' : 'value';
const lo = attribute('min'), hi = attribute('max'), step = attribute('step');
if (num(lo)) extra.min = lo; if (num(hi)) extra.max = hi; if (num(step) && step > 0) extra.step = step;
encode = input => { if (!num(input) || num(lo) && input < lo || num(hi) && input > hi) throw new Error('Number outside limits'); return String(input); };
} else if (['text', 'input_text'].includes(domain)) {
if (typeof value !== 'string') return null;
type = 'string'; role = 'text';
const lo = num(attribute('min')) ? attribute('min') : 0, hi = num(attribute('max')) ? Math.min(255, attribute('max')) : 255;
encode = input => { if (typeof input !== 'string' || input.length < lo || input.length > hi) throw new Error('Text outside limits'); return input; };
} else {
writable = false;
if (domain === 'sensor' && ['date', 'timestamp', 'uptime'].includes(dc)) {
if (!(dc === 'date' ? validDate(value) : validTimestamp(value))) return null;
type = 'string'; role = 'date';
} else if (type === 'number' && num(value)) {
role = propose('sensor', dc, unit, {type: 'state', common: {type, read: true, write: false}}, {val: value}) || 'value';
} else if (type === 'string' && !['unknown', 'unavailable'].includes(value)) role = 'text';
else return null;
}
const common = {name: String(attribute('friendly_name') || entity) + (feature ? ' ' + feature : ''), type, role, read: true, write: Boolean(writable), ...extra};
if (typeof unit === 'string' && type === 'number') common.unit = unit;
return {id: targetFor(id), sourceId: id, sourceLayout: flat ? 'flat' : 'nested', entity, feature, deviceClass: dc, setId: writable ? setId : null, value, sourceTs: source.ts || 0, common,
native: {ugsoMqttVis2: {version: 1, sourceId: id}}, encode};
}
async function journal() {
const raw = api.getState(settings.virtualBackupId)?.val;
if (raw === undefined || raw === null || raw === '') {
if (api.getObject(settings.virtualBackupId)) throw new Error('Existing virtual backup is empty; use a separate virtualBackupId');
return {format: 'ugso-mqtt-vis2', version: 1, mqttRoot: settings.mqttRoot, virtualRoot: settings.virtualRoot, entries: {}};
}
const data = JSON.parse(raw);
if (data.format !== 'ugso-mqtt-vis2' || data.version !== 1 || data.mqttRoot !== settings.mqttRoot || data.virtualRoot !== settings.virtualRoot || !data.entries || Array.isArray(data.entries)) throw new Error('Virtual backup mismatch');
for (const [id, record] of Object.entries(data.entries)) {
if (!id.startsWith(output) || typeof record.sourceId !== 'string' || !record.sourceId.startsWith(prefix) || (record.sourceLayout !== 'flat' && (!record.sourceId.endsWith('.state') || record.sourceId.includes('.attribute.'))) ||
(record.layout !== undefined && !['nested', 'flat'].includes(record.layout)) ||
!(id === targetFor(record.sourceId) || flatRecord(id, record)) || !record.common || !['boolean', 'number', 'string'].includes(record.common.type) || record.native?.ugsoMqttVis2?.sourceId !== record.sourceId) throw new Error('Invalid virtual backup entry');
}
return data;
}
async function run(sourceIds) {
const data = await journal();
const changed = sourceIds ? new Set(sourceIds) : null;
if (settings.mode === 'restore') {
for (const [id, record] of Object.entries(data.entries)) {
if (!owned(api.getObject(id), record) || excluded(record.sourceId, id)) continue;
record.disabled = true;
await api.writeJournal(settings.virtualBackupId, JSON.stringify(data));
await api.disableVirtual(id); // Keep values, roles and dashboard references.
}
return;
}
const valid = new Set();
const catalog = active();
const index = catalogRoutes(catalog);
const reserved = new Set(Object.keys(data.entries));
const recordsBySource = new Map();
for (const [id, record] of Object.entries(data.entries)) {
if (!recordsBySource.has(record.sourceId)) recordsBySource.set(record.sourceId, []);
recordsBySource.get(record.sourceId).push(id);
}
for (const sourceId of [...new Set(changed || api.listSources())].sort()) {
const spec = describe(sourceId, catalog, index);
if (!spec) continue;
const desired = layout === 'flat' ? flatTarget(spec, data, recordsBySource, reserved) : spec.id;
// Keep existing IDs synchronized and controllable when changing layout.
const targets = new Set([...(desired ? [desired] : []), ...(recordsBySource.get(sourceId) || [])]);
for (const id of targets) {
if (excluded(sourceId, id)) continue;
const parts = id.split('.');
if (parts.slice(3, -1).some((_, index) => {
const parent = api.getObject(parts.slice(0, index + 4).join('.'));
return parent && !['folder', 'channel', 'device'].includes(parent.type);
})) { api.log('Skipped VIS 2 target: parent is not a folder: ' + id); continue; }
valid.add(id);
let record = data.entries[id], object = api.getObject(id), created = false;
if (record?.disabled || record && object && !owned(object, record) || !record && object) continue;
if (record && record.common.type !== spec.common.type) { valid.delete(id); continue; }
if (settings.mode === 'preview') {
if (!previewed.has(id)) { api.log('Preview VIS 2: ' + id + ' <- ' + sourceId + (spec.setId ? ' -> ' + spec.setId : ' (read-only)')); previewed.add(id); }
continue;
}
if (!online()) continue;
if (!record || !object) {
const common = record?.layout === 'flat' || id === desired && layout === 'flat' ? {...spec.common, name: flatDisplayName(id)} : spec.common;
record = {sourceId, common, native: spec.native, savedAt: new Date(api.now()).toISOString(),
...(spec.sourceLayout === 'flat' ? {sourceLayout: 'flat'} : {}),
...(record?.layout ? {layout: record.layout} : id === desired && layout === 'flat' ? {layout: 'flat'} : {})};
data.entries[id] = record;
await api.writeJournal(settings.virtualBackupId, JSON.stringify(data));
if (api.getObject(id)) continue;
await api.createVirtual(id, {type: 'state', common, native: spec.native});
created = true;
api.log('Created VIS 2: ' + id);
}
if (!online()) continue;
const wait = pending.get(id);
if (wait && spec.sourceTs <= wait.sourceTs) {
if (!wait.timedOut && api.now() - wait.time >= 30000) {
wait.timedOut = true;
await api.setVirtual(id, {val: wait.requested, ack: false, q: 0x80});
api.log('No new HA confirmation after 30s: ' + id);
}
continue;
}
pending.delete(id);
const current = created ? undefined : api.getState(id);
if (!current || current.val !== spec.value || current.ack !== true || current.q) await api.setVirtual(id, {val: spec.value, ack: true, q: 0});
}
}
if (settings.mode === 'apply') {
for (const [id, record] of Object.entries(data.entries)) {
if (changed && !changed.has(record.sourceId)) continue;
if (record.disabled || excluded(record.sourceId, id) || !owned(api.getObject(id), record) || valid.has(id) && online()) continue;
const current = api.getState(id);
if (current && current.q !== 0x82) await api.setVirtual(id, {val: current.val, ack: current.ack, q: 0x82});
}
ready = true;
}
}
async function command(event) {
if (settings.mode !== 'apply' || !ready || event?.state?.ack !== false || event.state.q && event.state.q !== 0 || !num(event.state.ts) || event.state.ts <= started || api.now() - event.state.ts > 10000 || typeof event.id !== 'string' || !event.id.startsWith(output)) return;
const data = await journal(), record = data.entries[event.id];
if (!record || record.disabled || excluded(record.sourceId, event.id) || !owned(api.getObject(event.id), record) || api.getObject(event.id).common.write !== true) return;
const spec = describe(record.sourceId);
if (!online() || !spec?.setId || !spec.encode || spec.common.type !== record.common.type) throw new Error('Source offline, unselected or return channel disabled');
const payload = spec.encode(event.state.val);
const bytes = typeof payload === 'string' ? Array.from(payload).reduce((sum, char) => sum + (char.codePointAt(0) < 0x80 ? 1 : char.codePointAt(0) < 0x800 ? 2 : char.codePointAt(0) < 0x10000 ? 3 : 4), 0) : 257;
if (bytes > 256) throw new Error('Command payload too large');
pending.set(event.id, {sourceTs: spec.sourceTs, time: api.now(), requested: event.state.val, timedOut: false});
try { await api.sendCommand(spec.setId, payload); }
catch (error) {
pending.get(event.id).timedOut = true;
await api.setVirtual(event.id, {val: event.state.val, ack: false, q: 0x80});
throw error;
}
}
const pendingSources = () => [...pending.entries()].filter(([, wait]) => !wait.timedOut).map(([id]) => api.getObject(id)?.native?.ugsoMqttVis2?.sourceId).filter(Boolean);
return {run, command, describe, pendingSources, hasPending: () => pendingSources().length > 0};
}
async function initialize(api, settings) {
validateSettings(settings);
createVirtualMapper(api, settings); // Validate all paths before any writes.
const journals = [
[settings.virtualBackupId, {format: 'ugso-mqtt-vis2', version: 1, mqttRoot: settings.mqttRoot, virtualRoot: settings.virtualRoot, entries: {}}],
[settings.backupId, {format: 'ugso-mqtt-roles', version: 1, mqttRoot: settings.mqttRoot, aliasRoot: settings.aliasRoot, entries: {}}],
];
if (settings.backupId === settings.virtualBackupId || journals.some(([id]) => settings.virtualRoot === id || settings.virtualRoot.startsWith(id + '.'))) throw new Error('Conflicting helper paths');
for (const [id] of journals) {
const object = api.getObject(id), state = api.getState(id);
if (object && object.type !== 'state' || !object && state) throw new Error('Existing backup path conflict: ' + id);
}
await api.ensureFolders(settings.virtualRoot);
await api.ensureFolders(settings.aliasRoot);
for (const [id, value] of journals) {
await api.ensureFolders(id.slice(0, id.lastIndexOf('.')));
if (!api.getObject(id)) await api.writeJournal(id, JSON.stringify(value));
}
api.log('Helper infrastructure ready; existing backups and values preserved.');
}
async function publishCommand(api, settings, id, payload) {
if (typeof id !== 'string' || !id.startsWith(settings.mqttRoot + '.') || !(id.endsWith('.set') || id.endsWith('_set'))) throw new Error('Invalid command ID');
const flat = !id.endsWith('.set');
const target = api.getObject(id), source = api.getObject(id.slice(0, -4) + (flat ? '' : '.state'));
const stateTopic = source?.native?.topic, topic = target?.native?.topic;
const route = flat ? catalogRoutes(readCatalog(api, settings)).get(stateTopic) : null;
if (target?.type !== 'state' || source?.type !== 'state' || typeof stateTopic !== 'string' ||
(flat ? !route?.flat || route.selected.command !== true || route.commandTopic !== topic || topic !== stateTopic + '_set' : !stateTopic.endsWith('/state') || topic !== stateTopic.slice(0,-6) + '/set')) throw new Error('Command topic does not match source');
const instance = settings.mqttRoot.split('.').slice(0, 2).join('.');
const result = await api.sendMqtt(instance, 'sendMessage2Client', {topic, message: payload, retain: false}, {timeout: 5000});
if (result?.result !== true) throw new Error(result?.error || 'MQTT adapter did not accept the command');
api.log('Command handed to MQTT adapter (retain=false): ' + id + '; waiting for HA feedback.');
}
return {createMapper, createVirtualMapper, initialize, publishCommand, readCatalog, catalogRoutes, propose, validDate, validTimestamp, numeric, binary};
})();
if (typeof module !== 'undefined' && module.exports) module.exports = UgsoMqttRoles;
if (typeof on === 'function' && typeof getObject === 'function') {
const settings = UGSO_MQTT_ROLE_SETTINGS;
// In adapter 10.4.10 both getObject and getState warn for missing IDs.
// existsObject/existsState perform silent checks of the adapter caches.
const optionalObject = id => existsObject(id) ? getObject(id) : undefined;
const optionalState = id => existsState(id) ? getState(id) : undefined;
let previousCatalog, cachedRoutes = new Map();
const currentRoutes = () => {
const value = optionalState(settings.mqttRoot + '.selected_entities')?.val;
if (value !== previousCatalog) {
previousCatalog = value;
cachedRoutes = UgsoMqttRoles.catalogRoutes(UgsoMqttRoles.readCatalog(api, settings));
}
return cachedRoutes;
};
const api = {
getObject: optionalObject,
getState: optionalState,
listMetadata: () => {
const ids = [];
$('state[id=' + settings.mqttRoot + '.*device_class]').each(id => { if (id.endsWith('.attribute.device_class') || id.endsWith('_attribute.device_class')) ids.push(id); });
return ids;
},
listSources: () => {
const ids = [];
const routes = currentRoutes();
$('state[id=' + settings.mqttRoot + '.*]').each(id => { if (routes.get(optionalObject(id)?.native?.topic)) ids.push(id); });
return ids;
},
now: () => Date.now(),
ensureFolders: async id => {
const parts = id.split('.');
for (let size = 3; size <= parts.length; size++) {
const folderId = parts.slice(0, size).join('.'), existing = optionalObject(folderId);
if (existing && !['folder', 'channel', 'device'].includes(existing.type)) throw new Error('Existing parent is not a folder: ' + folderId);
if (!existing) await new Promise((resolve, reject) => setObject(folderId, {type: 'folder', common: {name: parts[size - 1]}, native: {ugsoMqttVis2Folder: true}}, error => error ? reject(new Error(String(error))) : resolve()));
}
},
createVirtual: async (id, object) => {
await api.ensureFolders(id.slice(0, id.lastIndexOf('.')));
if (optionalObject(id)) throw new Error('Virtual target appeared during creation');
await new Promise((resolve, reject) => setObject(id, object, error => error ? reject(new Error(String(error))) : resolve()));
},
disableVirtual: id => new Promise((resolve, reject) => extendObject(id, {common: {write: false}}, error => error ? reject(new Error(String(error))) : resolve())),
setVirtual: (id, state) => setStateAsync(id, state),
sendMqtt: (instance, command, message, options) => sendToAsync(instance, command, message, options),
sendCommand: (id, payload) => UgsoMqttRoles.publishCommand(api, settings, id, payload),
writeRole: (id, role) => new Promise((resolve, reject) => extendObject(id, {common: {role}}, error => error ? reject(new Error(String(error))) : resolve())),
restoreObject: (id, object) => new Promise((resolve, reject) => setObject(id, object, error => error ? reject(new Error(String(error))) : resolve())),
createAlias: (id, object) => new Promise((resolve, reject) => setObject(id, object, error => error ? reject(new Error(String(error))) : resolve())),
deleteAlias: id => new Promise((resolve, reject) => deleteObject(id, false, error => error ? reject(new Error(String(error))) : resolve())),
writeJournal: async (id, value) => {
if (!optionalObject(id)) await createStateAsync(id, '', false, {name: 'UGSo MQTT role backup', type: 'string', role: 'json', read: true, write: false});
await setStateAsync(id, {val: value, ack: true});
if ((await getStateAsync(id))?.val !== value) throw new Error('Backup could not be verified');
},
log: message => log('[UGSo MQTT roles] ' + message, 'info'),
};
try {
if (!['virtual', 'roles'].includes(settings.outputMode)) throw new Error('Invalid outputMode');
const virtual = settings.outputMode === 'virtual';
api.log('Helper 0.1.34; output=' + settings.outputMode + '; layout=' + settings.virtualLayout + '; mode=' + settings.mode + (settings.mode === 'preview' ? ' (creates missing helper folders/backups only; source changes are previewed)' : ''));
const mapper = virtual ? UgsoMqttRoles.createVirtualMapper(api, settings) : UgsoMqttRoles.createMapper(api, settings);
let serial = Promise.resolve(), timer, pendingTimer, stopped = false, queued = 0, initialized = false;
const enqueue = task => {
if (stopped || queued >= 100) return;
queued++;
serial = serial.then(() => stopped ? undefined : task()).catch(error => log('[UGSo MQTT helper] ' + error.message, 'error')).finally(() => queued--);
};
const checkPending = () => {
if (!stopped && virtual && mapper.hasPending() && !pendingTimer) pendingTimer = setTimeout(() => {
pendingTimer = undefined; enqueue(async () => { await mapper.run(mapper.pendingSources()); checkPending(); });
}, 5000);
};
if (settings.mode !== 'restore') {
const escaped = settings.mqttRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
let fullScan = false;
const dirtySources = new Set();
on({id: new RegExp('^' + escaped + '\\..+$'), change: 'any', q: '*'}, event => {
if (!initialized) return;
if (!event?.id || !event.state) return;
const same = event.oldState && JSON.stringify(event.state.val) === JSON.stringify(event.oldState.val) && event.state.ack === event.oldState.ack && event.state.q === event.oldState.q;
if (same && !(virtual && mapper.pendingSources().includes(event.id))) return;
if (event.id === settings.mqttRoot + '.availability' || event.id === settings.mqttRoot + '.selected_entities') fullScan = true;
else if (currentRoutes().get(optionalObject(event.id)?.native?.topic)) dirtySources.add(event.id);
else if (event.id.includes('.attribute.') || event.id.includes('_attribute.')) {
const flat = event.id.includes('_attribute.');
const [base, attribute] = event.id.split(flat ? '_attribute.' : '.attribute.');
if (!['ha_entity_id', 'command_enabled', 'device_class', 'unit_of_measurement', 'friendly_name', 'min', 'max', 'step', 'options', 'effect_list', 'min_color_temp_kelvin', 'max_color_temp_kelvin'].includes(attribute)) return;
dirtySources.add(base + (flat ? '' : '.state'));
for (const feature of ['brightness', 'rgb_color', 'effect', 'color_temp_kelvin']) dirtySources.add(base + (flat ? '_' + feature : '.' + feature + '.state'));
} else return;
if (!timer) timer = setTimeout(() => {
timer = undefined;
const sources = fullScan || !virtual ? undefined : [...dirtySources];
fullScan = false; dirtySources.clear();
enqueue(() => mapper.run(sources));
}, 1000);
});
if (virtual) {
const target = settings.virtualRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
on({id: new RegExp('^' + target + '\\..+$'), ack: false, change: 'any', q: '*'}, event => {
if (!initialized) return;
enqueue(async () => { await mapper.command(event); checkPending(); });
});
}
}
onStop(() => { stopped = true; if (timer) clearTimeout(timer); if (pendingTimer) clearTimeout(pendingTimer); }, 1000);
enqueue(async () => { await UgsoMqttRoles.initialize(api, settings); initialized = true; await mapper.run(); });
} catch (error) { log('[UGSo MQTT roles] ' + error.message, 'error'); }
}Wenn Werte oder Bestätigungen fehlen
- Nur „Preview VIS 2“ im Log: Erst nach Prüfung auf
mode: 'apply'wechseln. - Fehler „setObject is not allowed“: Die oben beschriebene Adapterfreigabe fehlt.
- Energie, Spannung, Strom oder Leistung fehlen: Messwerte-Haken aktivieren, speichern und in der HA-App „Zugehörige Messwerte prüfen“ öffnen. Ähnliche Namen ersetzen keine HA-Gerätezuordnung.
- Schalten möglich, Bestätigung fehlt: Zuerst den rohen MQTT-Status prüfen. Bleibt er alt, fehlt die HA-Rückmeldung bereits vor dem Helfer. Der Helfer bestätigt nicht künstlich und wiederholt Befehle nicht automatisch.
- Roher MQTT-
set-Wert ändert sich nicht: Der Helfer veröffentlicht Befehle über die Broker-Schnittstelle ohne Retain; der gespeicherte rohe Wert muss dabei nicht wechseln.
MQTT-Client-Doku und App-Update · Helfer-Anleitung und vollständige Rollenzuordnung
Offizielle Quellen
- ioBroker.javascript – offizielles Repository
- Deutsche Dokumentation des JavaScript-Adapters
- Vollständige Blockly-Dokumentation
Beispiel 1: Datenpunkt abhängig von einem anderen Datenpunkt schalten
Das offizielle Einstiegsbeispiel zeigt eine typische Bewegungserkennung:
- Ein Bewegungs-Datenpunkt dient als Auslöser.
- Bei einer Zustandsänderung wird ein Licht-Datenpunkt gesteuert.
- Der aktuelle Wert des Bewegungsmelders wird an den Licht-Datenpunkt weitergegeben.
- Bewegung schaltet das Licht ein.
- Keine Bewegung schaltet das Licht wieder aus.
Benötigte Blockly-Blöcke
1. Trigger
Aus dem Bereich Trigger wird der Block Falls Objekt verwendet.
Dort wird der Datenpunkt des Bewegungsmelders ausgewählt.
2. Datenpunkt steuern
Aus dem Bereich System wird der Block Steuere eingefügt.
Als Ziel wird der Datenpunkt des Lichts ausgewählt.
3. Wert übernehmen
In den Steuere-Block wird Wert von Objekt-ID eingesetzt.
Dort wird erneut der Bewegungs-Datenpunkt ausgewählt. Dadurch übernimmt das Licht den aktuellen Wahr/Falsch-Zustand des Bewegungsmelders.
Vereinfachter Ablauf
Falls sich der Bewegungsmelder ändert
setze den Zustand des Lichts
auf den aktuellen Wert des BewegungsmeldersBlockly importieren
Den vollständigen importierbaren XML-Code stellt die offizielle Dokumentation direkt unter Beispiel 1 bereit:
Beispiel 1 mit Blockly-XML öffnen
Dort kann der XML-Code kopiert und im Blockly-Editor über die Importfunktion eingefügt werden.
Weitere offizielle Beispiele
Die offizielle Dokumentation enthält zusätzlich:
- Beispiel 2: Licht bei Bewegung einschalten und nach zehn Minuten ohne weitere Bewegung wieder ausschalten.
- Beispiel 3: E-Mail senden, wenn die Außentemperatur einen Grenzwert überschreitet.
Alle offiziellen Blockly-Beispiele ansehen
Eigene Blockly-Beiträge im Blog
Für eigene Beispiele empfiehlt sich folgender Aufbau:
- Aufgabe beschreiben
- benötigte Objekt-IDs nennen
- Screenshot des Blockly-Aufbaus einfügen
- exportierten Blockly-XML-Code bereitstellen
- Anpassungen und Voraussetzungen erklären
Screenshots werden im Dokumentationsprojekt hier abgelegt:
docs/public/images/blog/Einbindung im Beitrag:
WARNING
Objekt-IDs aus Beispielen müssen immer an das eigene ioBroker-System angepasst werden.

