Aller au contenu

ioBroker Blockly ​

Blockly est l'éditeur de scripts graphique de l'adaptateur JavaScript d'ioBroker. Les automatisations sont construites à l'aide de blocs, ce qui évite d'avoir à rédiger manuellement l'intégralité du code JavaScript.

Documentation officielle

Cette page ne propose qu'une brève introduction. La description complète de tous les blocs ainsi que d'autres exemples sont disponibles dans la documentation officielle d'ioBroker.

Relier Home Assistant à ioBroker et VIS 2 par MQTT ​

Cet exemple original UGSo complète l’introduction à Blockly : le MQTT-Client pour Home Assistant transmet les entités sélectionnées à un broker MQTT externe. Le script JavaScript ioBroker 0.1.34 présenté ici les prépare pour VIS 2 et propose une structure plate facultative avec noms courts. Remplacez le script complet en conservant vos réglages pour utiliser cette option.

JavaScript, pas un import Blockly

Collez le script complet dans l’adaptateur JavaScript ioBroker, en tant que JavaScript. Il ne va ni dans Home Assistant ni dans l’importateur XML Blockly. Les exemples Blockly existants ci-dessous restent indépendants.

Nouveauté 0.1.36 : conserver les réglages après les mises à jour ​

Test de redémarrage réussi le 7 novembre 2026

Après avoir de nouveau enregistré les réglages lors du passage à 0.1.36, l’utilisateur a redémarré l’application MQTT-Client et confirmé : La case des mesures associées reste cochée et la structure MQTT reste « Plate avec noms courts ». Le test pratique de redémarrage est donc réussi. Aucun nouveau changement de version n’a été testé ; le test pratique de mise à jour vers la prochaine version reste à effectuer.

Les réglages enregistrés dans l’application sont désormais conservés lorsque le Supervisor Home Assistant régénère son fichier d’options pendant une mise à jour. Cela concerne notamment la case des mesures associées, la structure MQTT des nouvelles entités, les autres réglages enregistrés et les identifiants de connexion. L’application les conserve séparément dans son fichier privé /data/mqtt-settings.json. Les affectations de chemins MQTT restent dans /data/mqtt-routes.json ; les points et références VIS-2 existants ne changent pas.

Premier passage depuis la version 0.1.35 ou antérieure : Exportez les réglages dans l’application avant la mise à jour. S’ils ont déjà été réinitialisés lors de ce premier passage, importez ensuite la sauvegarde ou sélectionnez de nouveau les deux options une fois, puis cliquez sur Enregistrer et connecter. Les réglages déjà perdus ne peuvent pas être reconstitués de façon fiable ; les mesures supplémentaires ne sont jamais activées sans votre choix.

Modifiez de préférence ces réglages dans l’application. Les modifications réelles de la configuration Supervisor sont reprises au redémarrage ; ses valeurs par défaut inchangées ne réinitialisent pas les choix de l’application. Pour revenir explicitement à un choix par défaut, sélectionnez-le et enregistrez-le dans l’application. Les sauvegardes de réglages existantes restent utilisables et excluent toujours les mots de passe et clés TLS privées. Le script JavaScript reste en version 0.1.34 ; ce correctif ne nécessite pas de le remplacer à nouveau.

Contexte : le Supervisor gère /data/options.json ; le répertoire privé /data fournit le stockage persistant de l’application. Documentation officielle HA.

Nouveauté 0.1.34 : chemins MQTT plats pour les nouvelles entités uniquement ​

Dans Transmission et TLS → Structure MQTT des nouvelles entités, choisissez Plate avec noms courts, puis Enregistrer et connecter. La structure existante reste le choix par défaut. Ce réglage concerne les entités manuelles et les membres d’un label rencontrés pour la première fois ensuite, y compris les mesures facultatives du même appareil.

Les entités connues conservent leurs chemins. Les affectations sont enregistrées durablement dans /data/mqtt-routes.json et incluses dans la sauvegarde exportée des réglages. Retirer puis resélectionner un label, renommer, redémarrer ou mettre à jour réutilise le chemin attribué. Lors de la première mise à jour, les sélections existantes et les topics enregistrés sont conservés ; la sélection existante du label est fixée au premier rapprochement réussi. Ne supprimez pas ce fichier. Revenir au réglage précédent ne migre pas les entités déjà affectées.

Exemples de nouveaux topics MQTT plats :

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

Les anciens topics comme ha_external/geraet/switch/state et …/switch/set restent valides en parallèle. Les noms courts en conflit reçoivent un suffixe unique. Les points, valeurs, alias et références VIS-2 existants ne sont ni renommés ni supprimés.

Avant d’activer les chemins MQTT plats, remplacez le script JavaScript complet par la version 0.1.34 en conservant vos réglages. Il lit les deux structures grâce au catalogue transmis, qui indique les chemins exacts des états et commandes. Vérifiez la prévisualisation, puis choisissez mode: 'apply'. Les anciens scripts ne reconnaissent pas les nouveaux chemins MQTT plats. L’option indépendante du script virtualLayout: 'flat' détermine uniquement la présentation sous 0_userdata.0.ugso_mqtt. Les rôles manuels et références existants restent protégés.

À quoi sert le script ? ​

MQTT utilise des topics séparés pour les retours d’état et les commandes. Le script les réunit via un point virtuel par valeur sous 0_userdata.0.ugso_mqtt. Dans VIS 2, ce seul point suffit pour l’affichage et la commande.

text
HA -> MQTT state -> 0_userdata.0.ugso_mqtt -> VIS 2
VIS 2 -> 0_userdata.0.ugso_mqtt -> MQTT set -> HA
  • Les retours HA actualisent le point virtuel avec ack: true.
  • La commande dans VIS 2 écrit avec ack: false ; seul un canal de retour autorisé est utilisé. La remise au broker n’est confirmée qu’après un nouveau retour HA réel.
  • Les valeurs de lampe compatibles sont normalisées, par exemple la luminosité sur 0–100 %. Les mesures et dates restent en lecture seule.
  • Les métadonnées HA adaptées fournissent les types, unités et rôles non ambigus. Les valeurs existantes, rôles manuels et objets tiers restent protégés.
  • Le script traite uniquement les changements MQTT concernés. Il ne reconstruit pas l’ensemble à chaque modification de mesure sans rapport.

Prérequis et installation ​

  1. Configurez le MQTT-Client HA et l’adaptateur MQTT ioBroker. L’application transmet au broker externe ; le canal de retour décrit ici utilise l’adaptateur MQTT ioBroker en mode Server/Broker.
  2. Sélectionnez les entités ou attribuez l’étiquette HA configurée. Les mesures supplémentaires nécessitent explicitement « Inclure automatiquement les mesures et valeurs de batterie du même appareil », puis « Enregistrer et connecter ». Seuls les capteurs existants et compatibles du même appareil HA sont ajoutés.
  3. Créez un script JavaScript dans l’adaptateur JavaScript ioBroker et collez le code complet ci-dessous. Le script a été testé avec l’adaptateur 10.4.10.
  4. Autorisez setObject dans les réglages de l’adaptateur et désactivez Do not subscribe to all states on start. Le script nécessite les caches synchrones d’objets/états. Aucune autorisation n’est nécessaire pour simplement consulter le code.
  5. Vérifiez les chemins du bloc de réglages et conservez d’abord mode: 'preview'. L’aperçu crée uniquement les dossiers de base/points de sauvegarde manquants du script et journalise les affectations prévues ; il ne crée pas de valeurs virtuelles commandables et n’envoie aucune commande.
  6. Après vérification, choisissez mode: 'apply' et redémarrez le script. Les points virtuels sont alors créés et synchronisés.
  7. Dans VIS 2, sélectionnez le point virtuel adapté, pas le point MQTT set brut.

Réglages et sauvegarde ​

RéglageSignification
mqttRootArborescence MQTT entrante ; valeur par défaut mqtt.0.ha_external.
modepreview : vérifier ; apply : appliquer ; restore : annuler prudemment les propres modifications sauvegardées du script.
outputModevirtual pour les points VIS-2 ; roles pour l’affectation alternative de rôles/alias.
virtualLayoutnested : anciens identifiants (par défaut) ; flat : points courts directement dans le dossier de l’appareil.
virtualRootArborescence cible des valeurs virtuelles.
virtualBackupId, backupIdJournaux de sauvegarde du script ; les sauvegardes existantes sont conservées.
aliasRootCible des alias en lecture seule en mode rôles.
excludeIdsIdentifiants sources exacts à préserver.

Lors du remplacement d’un script existant, conservez vos réglages. restore ne restaure pas toute l’installation ioBroker : les objets modifiés manuellement depuis restent protégés ; les valeurs/références virtuelles sont conservées et leurs commandes désactivées. Consultez le guide complet du script pour les détails, dont le tableau des rôles, les sauvegardes et les limites.

Structure plate facultative avec noms courts ​

Dans le script complet 0.1.34, choisissez virtualLayout: 'flat' dans le bloc de réglages. La valeur par défaut reste virtualLayout: 'nested' ; une configuration existante sans ce réglage conserve également l’ancienne structure. Vérifiez d’abord l’aperçu, puis passez à mode: 'apply'.

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

Les identifiants techniques utilisent ces noms allemands courts dans toutes les langues : énergie, puissance, tension et courant sont reconnus à partir de la classe d’appareil ; sinon, une partie adaptée de l’identifiant d’entité est utilisée. Les noms affichés sont aussi raccourcis, par exemple Energie ou Stromstärke. Les lampes utilisent licht, helligkeit, farbe, effekt et farbtemperatur.

Plusieurs valeurs correspondantes reçoivent leistung, leistung_2, leistung_3, etc. Les noms attribués sont enregistrés dans le journal existant et restent stables lors des changements de sélection ultérieurs. Les dossiers occupés, points tiers ou identifiants de sauvegarde ne sont pas écrasés ; un suffixe libre est attribué.

Les points existants du script ne sont ni supprimés ni déplacés : anciens et nouveaux identifiants restent synchronisés et conservent leurs canaux de commande autorisés. Les références VIS-2 existantes peuvent donc rester. Les nouveaux widgets peuvent utiliser les identifiants courts ; les anciens dossiers restent visibles au départ. Revenez en arrière avec virtualLayout: 'nested' dans le nouveau script. L’ancien fichier 0.1.27 ne peut pas lire les journaux contenant des entrées plates.

Les objets MQTT, chemins des topics, valeurs et rôles manuels restent inchangés. Si le retour à l’ancienne structure nécessite un dossier occupé par un point existant, ce nouveau chemin cible est ignoré et journalisé ; aucune valeur existante n’est remplacée.

Script complet avec bouton de copie et téléchargement ​

Télécharger le script JavaScript 0.1.34 (.js)

Le bouton de copie en haut à droite du bloc reprend le script entier. Le bloc et le téléchargement utilisent le même fichier inchangé, sans identifiants personnels. Vérifiez ensuite les chemins et le mode pour votre installation.

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

Valeurs ou confirmations absentes ​

  • Seulement « Preview VIS 2 » dans le journal : après vérification, passez à mode: 'apply'.
  • « setObject is not allowed » : l’autorisation de l’adaptateur décrite ci-dessus manque.
  • Énergie, tension, courant ou puissance absents : activez la case des mesures, enregistrez, puis ouvrez « Vérifier les mesures associées » dans l’application HA. Des noms similaires ne remplacent pas l’association à un appareil HA.
  • La commande fonctionne, mais sans confirmation : vérifiez d’abord l’état MQTT brut. S’il reste ancien, le retour HA manque déjà en amont du script. Celui-ci ne fabrique jamais de confirmation et ne répète pas automatiquement les commandes.
  • La valeur MQTT set brute ne change pas : les commandes passent par l’API du broker sans retain ; la valeur brute enregistrée ne doit pas nécessairement changer.

Documentation MQTT-Client et mise à jour de l’application · Guide du script et tableau complet des rôles

Sources officielles ​

Exemple 1 : Commuter un point de données en fonction d'un autre point de données ​

L'exemple d'introduction officiel illustre un scénario typique de détection de mouvement :

  • Un point de données lié au mouvement sert de déclencheur.
  • Lors d'un changement d'état, un point de données lié à l'éclairage est piloté.
  • La valeur actuelle du détecteur de mouvement est transmise au point de données de l'éclairage.
  • La détection d'un mouvement allume la lumière.
  • L'absence de mouvement éteint la lumière.

Exemple Blockly officiel n° 1 pour ioBroker

Source de l'image et droits : L'image provient du dépôt officiel ioBroker.javascript. Les droits et les conditions de licence sont régis par le projet qui y est publié et par sa licence associée.

Blocs Blockly nécessaires ​

1. Déclencheur (Trigger) ​

Dans la catégorie Déclencheur (Trigger), on utilise le bloc Si objet (Falls Objekt).

On y sélectionne le point de données correspondant au détecteur de mouvement.

2. Piloter un point de données ​

Dans la catégorie Système, on insère le bloc Piloter (Steuere).

Le point de données correspondant à l'éclairage est sélectionné comme cible.

3. Récupérer la valeur ​

Le bloc « commander » (ou « piloter ») intègre la valeur de l'ID d'objet.

On y sélectionne à nouveau le point de données correspondant au mouvement. Ainsi, l'éclairage adopte l'état actuel (Vrai/Faux) du détecteur de mouvement.

Processus simplifié ​

text
Falls sich der Bewegungsmelder ändert
    setze den Zustand des Lichts
    auf den aktuellen Wert des Bewegungsmelders

Importer le bloc Blockly ​

Le code XML complet, prêt à être importé, est fourni dans la documentation officielle, juste en dessous de l'Exemple 1 :

Ouvrir l'Exemple 1 avec le code XML Blockly

Il suffit de copier ce code XML et de l'insérer dans l'éditeur Blockly via la fonction d'importation.

Autres exemples officiels ​

La documentation officielle propose également :

  • Exemple 2 : Allumer la lumière en cas de détection de mouvement et l'éteindre après dix minutes sans nouveau mouvement.
  • Exemple 3 : Envoyer un e-mail lorsque la température extérieure dépasse un certain seuil.

Voir tous les exemples officiels Blockly

Partager vos propres exemples Blockly sur le blog ​

Pour vos propres exemples, la structure suivante est recommandée :

  1. Décrire la tâche
  2. Indiquer les ID d'objets nécessaires
  3. Insérer une capture d'écran de la configuration Blockly
  4. Fournir le code XML Blockly exporté
  5. Expliquer les adaptations et les prérequis

Les captures d'écran sont stockées dans le projet de documentation ici :

text
docs/public/images/blog/

Intégration dans l'article :

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

WARNING

Les ID d'objets présents dans les exemples doivent toujours être adaptés à votre propre système ioBroker.