Skip to content

ioBroker Blockly ​

Blockly is the graphical script editor of the ioBroker JavaScript adapter. Automations are built from blocks without having to write all JavaScript code by hand.

Official documentation

This page only gives a short introduction. The complete description of all blocks and more examples are available in the official ioBroker documentation.

Connect Home Assistant to ioBroker and VIS 2 over MQTT ​

This original UGSo example supplements the Blockly introduction: the MQTT-Client for Home Assistant forwards selected entities to an external MQTT broker. The ioBroker JavaScript helper 0.1.34 shown here prepares them for VIS 2 and offers an optional flat layout with short names. Replace the complete helper and retain your settings to use the new option.

JavaScript, not a Blockly import

Paste the complete script into the ioBroker JavaScript adapter as JavaScript. It does not belong in Home Assistant or the Blockly XML importer. The existing Blockly examples below remain independent.

New in 0.1.36: retain settings across updates ​

Successful restart test on 7 November 2026

After saving the settings again when moving to 0.1.36, the user restarted the MQTT-Client app and confirmed: The related-measurements checkbox stays enabled and the MQTT layout stays “Flat with short names”. This confirms the practical restart test. It does not test another version change; a practical update test with the next release is still pending.

Saved app settings now survive the Home Assistant Supervisor regenerating its options file during an update. This includes the related-measurements checkbox, the MQTT layout for new entities, other saved app settings and credentials. The app stores them independently in its private /data/mqtt-settings.json file. MQTT path assignments remain in /data/mqtt-routes.json; existing points and VIS-2 references are unchanged.

One-time upgrade from 0.1.35 or earlier: Export settings in the app before updating. If they have already been reset during the first upgrade, import that backup afterwards or select both options again once and click Save and connect. Already lost settings cannot be reliably reconstructed; additional measurements are never enabled without your selection.

Prefer editing these settings in the app. Actual Supervisor configuration changes are merged on restart; unchanged Supervisor defaults do not reset app choices. To explicitly return an app choice to its default, select and save it in the app. Existing settings backups remain usable and still exclude passwords and private TLS keys. The JavaScript helper remains 0.1.34; no further helper replacement is required for this fix.

Background: Supervisor manages /data/options.json; the private /data directory provides persistent storage for the app. Official HA documentation.

New in 0.1.34: flat MQTT paths for new entities only ​

Under Transmission and TLS → MQTT structure for new entities, select Flat with short names and Save and connect. The existing structure remains the default. This preference applies to manually selected entities and label members first encountered afterwards, including optional device measurements.

Known entities keep their existing paths. Assignments persist in /data/mqtt-routes.json and are included in exported settings backups. Removing and reselecting a label, renaming, restarting or updating reuses the assigned path. On the first upgrade, existing selections and recorded topics are preserved; the existing label selection is pinned during its first successful reconciliation. Do not delete this file. Changing the preference back does not migrate previously assigned entities.

Example new flat MQTT topics:

text
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_set

Existing topics such as ha_external/geraet/switch/state and …/switch/set remain valid alongside them. Short-name collisions receive unique suffixes. Existing points, values, aliases and VIS-2 references are neither renamed nor removed.

Before enabling flat MQTT paths, replace the complete JavaScript helper with 0.1.34 and retain your settings. It reads both MQTT structures from the published selection catalog with exact state and command paths. Review preview first, then choose mode: 'apply'. Older helpers cannot discover new flat MQTT paths. The independent helper option virtualLayout: 'flat' only controls the layout below 0_userdata.0.ugso_mqtt. Existing manual roles and references remain protected.

What does the helper do? ​

MQTT uses separate topics for feedback and commands. The helper joins them through one virtual state per value under 0_userdata.0.ugso_mqtt. In VIS 2, this one point is sufficient for display and control.

text
HA -> MQTT state -> 0_userdata.0.ugso_mqtt -> VIS 2
VIS 2 -> 0_userdata.0.ugso_mqtt -> MQTT set -> HA
  • HA feedback updates the virtual point with ack: true.
  • VIS 2 control writes with ack: false; only an authorized return channel is used. A broker handoff is confirmed only after new actual HA feedback.
  • Supported light values are normalized appropriately, such as brightness to 0–100%. Measurements and dates remain read-only.
  • Suitable HA metadata supplies data types, units and unambiguous roles. Existing values, manual roles and unrelated objects remain protected.
  • The script processes affected MQTT changes selectively. It does not rebuild everything on every unrelated measurement update.

Requirements and setup ​

  1. Configure the HA MQTT-Client and ioBroker MQTT adapter. The app sends to the external broker; the return channel described here uses the ioBroker MQTT adapter in Server/Broker mode.
  2. Select entities or assign the configured HA label. Additional device measurements explicitly require “Automatically include related measurements and battery values from the same device” and then “Save and connect”. Only existing, supported sensors of the same HA device are added.
  3. Create a JavaScript script in the ioBroker JavaScript adapter and paste the complete code below. The helper has been tested with adapter 10.4.10.
  4. Enable setObject in the adapter settings and disable Do not subscribe to all states on start. The script requires synchronous object/state caches. No permissions are required merely to view the code.
  5. Check the paths in the settings block and initially keep mode: 'preview'. Preview creates only missing helper base folders/backup points and logs planned mappings; it creates no controllable virtual values and sends no commands.
  6. After review, set mode: 'apply' and restart the script. Virtual points are now created and synchronized.
  7. In VIS 2 select the appropriate virtual point, not the raw MQTT set point.

Settings and backup ​

SettingMeaning
mqttRootIncoming MQTT tree; default mqtt.0.ha_external.
modepreview: inspect; apply: apply; restore: safely undo the helper’s own backed-up changes.
outputModevirtual for VIS-2 points; roles for alternative role/alias mapping.
virtualLayoutnested: original IDs (default); flat: short points directly inside the device folder.
virtualRootTarget tree for virtual values.
virtualBackupId, backupIdHelper backup journals; existing backups are preserved.
aliasRootTarget for read-only aliases in role mode.
excludeIdsExact source IDs to leave untouched.

When replacing an existing script, retain your settings. restore does not restore the entire ioBroker installation: objects changed manually afterwards remain protected; virtual values/references are retained and their controls disabled. See the complete helper guide for details, including the role table, backups and limitations.

Optional flat layout with short names ​

In the complete helper 0.1.34, select virtualLayout: 'flat' in the settings block. The default remains virtualLayout: 'nested'; missing settings in existing configurations also mean the original layout. Review preview first and then switch to mode: 'apply'.

text
0_userdata.0.ugso_mqtt.pc_tv_wohnzimmer
  schalter
  energie
  leistung
  spannung
  stromstaerke

Technical IDs use these short German names in all languages: energy, power, voltage and current are detected from the device class; otherwise a suitable part of the entity ID is used. Display names are shortened accordingly, for example Energie or Stromstärke. Lights use licht, helligkeit, farbe, effekt and farbtemperatur.

Multiple matching values receive leistung, leistung_2, leistung_3, etc. Assigned names are stored in the existing journal and remain stable when selections change later. Occupied folders, unrelated points or backup IDs are not overwritten; a free suffix is used instead.

Existing helper points are neither deleted nor moved: old and new IDs remain synchronized and retain authorized command channels. Existing VIS-2 references therefore do not need changing. New widgets can use the short IDs; old folders remain visible initially. Switch back using virtualLayout: 'nested' in the new helper. The old 0.1.27 file cannot read journals containing flat entries.

MQTT objects, topic paths, values and manual roles remain unchanged. If switching back requires a folder occupied by an existing state, that new target path is skipped and logged; existing values are not replaced.

Complete script with copy button and download ​

Download JavaScript helper 0.1.34 (.js)

The copy button at the top right of the code block copies the entire script. The code block and download use the same unchanged file, without personal credentials. Review paths and mode for your installation afterwards.

js
/* 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'); }
}

Missing values or confirmations ​

  • Only “Preview VIS 2” in the log: After review, switch to mode: 'apply'.
  • “setObject is not allowed”: The adapter permission described above is missing.
  • Missing energy, voltage, current or power: Enable the measurements checkbox, save, and open “Check related measurements” in the HA app. Similar names do not replace an HA device association.
  • Commands work but confirmation is missing: Check the raw MQTT state first. If it remains old, HA feedback is already missing before the helper. The helper never fabricates confirmation or retries commands automatically.
  • The raw MQTT set value does not change: Commands are published through the broker API without retain; the stored raw value does not have to change.

MQTT-Client documentation and app update · Helper guide and complete role mapping

Official Sources ​

Example 1: Switch a Data Point Based on Another Data Point ​

The official starter example shows a typical motion detection workflow:

  • A motion data point is used as the trigger.
  • On a state change, a light data point is controlled.
  • The current value of the motion detector is passed to the light data point.
  • Motion turns the light on.
  • No motion turns the light off again.

Official ioBroker Blockly example 1

Image source and rights note: The image comes from the official ioBroker.javascript repository. Rights and license terms follow the project and license published there.

Required Blockly Blocks ​

1. Trigger ​

Use the If object block from the Trigger section.

Select the motion detector data point there.

2. Control Data Point ​

Insert the Control block from the System section.

Select the light data point as the target.

3. Use the Current Value ​

Insert Value of object ID into the control block.

Select the motion data point again. The light then uses the current true/false state of the motion detector.

Simplified Flow ​

text
When the motion detector changes
    set the state of the light
    to the current value of the motion detector

Import Blockly ​

The official documentation provides the full importable XML code directly under Example 1:

Open example 1 with Blockly XML

Copy the XML code there and paste it into the Blockly editor through the import function.

More Official Examples ​

The official documentation also contains:

  • Example 2: Turn on a light on motion and turn it off again after ten minutes without further motion.
  • Example 3: Send an email when the outside temperature exceeds a threshold.

View all official Blockly examples

Own Blockly Posts in the Blog ​

For custom examples, this structure is recommended:

  1. describe the task
  2. list required object IDs
  3. add a screenshot of the Blockly structure
  4. provide exported Blockly XML code
  5. explain adjustments and requirements

Screenshots are stored in the documentation project here:

text
docs/public/images/blog/

Embedding in a post:

md
![ioBroker Blockly](/images/blog/my-blockly.png)

WARNING

Object IDs from examples must always be adapted to your own ioBroker system.