[{"id":"tab_z2m_auto","type":"tab","label":"Zigbee2MQTT → APEX (auto)","disabled":false,"info":"ZIGBEE2MQTT → APEX : CRÉATION AUTOMATIQUE DES OBJETS BACnet\n\nDÉCOUVERTE (zigbee2mqtt/bridge/devices)\n- Pour chaque appareil appairé : objets créés automatiquement\n  • binary → BV, numeric → AV, enum → AV (index 1..n, correspondance dans la description)\n  • attribut publié → objet état  {prefix}{nom}_{attribut}\n  • attribut commandable → objet  {prefix}{nom}_{attribut}_Cmd\n  • BV {prefix}{nom}_Online (disponibilité)\n- Catégories : principal + diagnostic (config exclue) — exceptions/exclusions dans la config\n- Instances : première libre dans la plage 3000-3999 (par type)\n- Point déjà présent sur l'APEX avec le même nom → retrouvé, pas recréé\n- File de création : un point à la fois\n\nVALEURS : zigbee2mqtt/<nom> → present-value (sur changement, refresh 10 min)\nDISPONIBILITÉ : zigbee2mqtt/<nom>/availability → _Online + out-of-service\nCOMMANDES : lecture des _Cmd toutes les 2 s → zigbee2mqtt/<nom>/set sur changement\n\nPRÉREQUIS\n- Renseigner user/password dans ⚙️ Configuration\n- Activer availability dans Z2M (Paramètres → Disponibilité)\n- Pas de \"/\" dans les noms conviviaux Z2M\n- Un renommage dans Z2M crée de nouveaux objets (les anciens restent à supprimer)"},{"id":"cm_readme","type":"comment","z":"tab_z2m_auto","name":"📖 Lisez-moi (double-clic)","info":"ZIGBEE2MQTT → APEX : CRÉATION AUTOMATIQUE DES OBJETS BACnet\n\nDÉCOUVERTE (zigbee2mqtt/bridge/devices)\n- Pour chaque appareil appairé : objets créés automatiquement\n  • binary → BV, numeric → AV, enum → AV (index 1..n, correspondance dans la description)\n  • attribut publié → objet état  {prefix}{nom}_{attribut}\n  • attribut commandable → objet  {prefix}{nom}_{attribut}_Cmd\n  • BV {prefix}{nom}_Online (disponibilité)\n- Catégories : principal + diagnostic (config exclue) — exceptions/exclusions dans la config\n- Instances : première libre dans la plage 3000-3999 (par type)\n- Point déjà présent sur l'APEX avec le même nom → retrouvé, pas recréé\n- File de création : un point à la fois\n\nVALEURS : zigbee2mqtt/<nom> → present-value (sur changement, refresh 10 min)\nDISPONIBILITÉ : zigbee2mqtt/<nom>/availability → _Online + out-of-service\nCOMMANDES : lecture des _Cmd toutes les 2 s → zigbee2mqtt/<nom>/set sur changement\n\nPRÉREQUIS\n- Renseigner user/password dans ⚙️ Configuration\n- Activer availability dans Z2M (Paramètres → Disponibilité)\n- Pas de \"/\" dans les noms conviviaux Z2M\n- Un renommage dans Z2M crée de nouveaux objets (les anciens restent à supprimer)","x":150,"y":40,"wires":[]},{"id":"inj_start","type":"inject","z":"tab_z2m_auto","name":"▶ Démarrage","props":[],"repeat":"","crontab":"","once":true,"onceDelay":"0.1","topic":"","x":140,"y":100,"wires":[["fn_cfg"]]},{"id":"fn_cfg","type":"function","z":"tab_z2m_auto","name":"⚙️ Configuration (à adapter)","func":"// ============================================================\n// ⚙️ CONFIGURATION — À ADAPTER\n// ============================================================\nconst cfg = {\n    apex: \"https://IP_APEX\",\n    user: \"API_USER\",\n    password: \"API_PASSWORD\",\n    baseTopic: \"zigbee2mqtt\",   // si modifié : changer aussi les nœuds MQTT In\n\n    // Nommage BACnet : {prefix}{nom_z2m}_{attribut}[_Cmd]\n    prefix: \"ZB_\",\n    maxName: 60,\n\n    // Plage d'instances réservée au Zigbee (par type AV / BV)\n    instMin: 3000,\n    instMax: 3999,\n\n    // Catégories Z2M créées : \"principal\" (sans catégorie), \"diagnostic\", \"config\"\n    categories: [\"principal\", \"diagnostic\"],\n\n    // Attributs à AJOUTER malgré le filtre de catégorie\n    // clé = modèle Z2M (ex. \"ZBMINIR2\") ou nom convivial de l'appareil\n    exceptions: {\n        // \"ZBMINIR2\": [\"power_on_behavior\"]\n    },\n\n    // Attributs à EXCLURE (même clé)\n    exclusions: {\n        // \"ZBMINIR2\": [\"linkquality\"]\n    },\n\n    // BV {prefix}{nom}_Online + out-of-service des points si hors ligne\n    // (nécessite availability activée dans Z2M)\n    online: true,\n\n    // Réécriture forcée d'une valeur inchangée toutes les X minutes\n    rafraichissementMin: 10\n};\n\ncfg.auth = \"Basic \" + Buffer.from(cfg.user + \":\" + cfg.password).toString(\"base64\");\nflow.set(\"cfg\", cfg);\nflow.set(\"knownPoints\", {});\nflow.set(\"lists\", {});\nflow.set(\"lastWritten\", {});\nflow.set(\"lastCmd\", {});\n\nnode.status({ fill: \"green\", shape: \"dot\", text: \"Config chargée — plage \" + cfg.instMin + \"-\" + cfg.instMax });\n\n// Si la liste des appareils est déjà arrivée, relancer la découverte\nconst raw = flow.get(\"rawDevices\");\nreturn raw ? { payload: raw } : null;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":100,"wires":[["fn_catalog"]]},{"id":"cm_disc","type":"comment","z":"tab_z2m_auto","name":"═══ DÉCOUVERTE & CRÉATION DES OBJETS ═══","info":"","x":230,"y":170,"wires":[]},{"id":"mq_devices","type":"mqtt in","z":"tab_z2m_auto","name":"bridge/devices","topic":"zigbee2mqtt/bridge/devices","qos":"0","datatype":"json","broker":"broker_apex_z2m","nl":false,"rap":true,"rh":0,"inputs":0,"x":140,"y":230,"wires":[["fn_catalog"]]},{"id":"fn_catalog","type":"function","z":"tab_z2m_auto","name":"🔎 Catalogue des points","func":"// ============================================================\n// DÉCOUVERTE : zigbee2mqtt/bridge/devices → catalogue de points\n// ============================================================\nif (Array.isArray(msg.payload)) flow.set(\"rawDevices\", msg.payload);\nconst cfg = flow.get(\"cfg\");\nif (!cfg) { node.status({ fill: \"red\", shape: \"ring\", text: \"Config non chargée\" }); return null; }\n\nconst devices = flow.get(\"rawDevices\") || [];\nconst known = flow.get(\"knownPoints\") || {};\n\nconst clean = s => String(s).replace(/\\s+/g, \"_\").replace(/[^a-zA-Z0-9_\\-]/g, \"_\");\n\nfunction mkName(dev, attr, suffix) {\n    const a = clean(attr);\n    const fixed = cfg.prefix.length + 1 + a.length + suffix.length;\n    let d = clean(dev);\n    const room = cfg.maxName - fixed;\n    if (d.length > room) d = d.substring(0, Math.max(3, room));\n    return (cfg.prefix + d + \"_\" + a + suffix).substring(0, cfg.maxName);\n}\n\nfunction describe(base, role) {\n    let s = base.model + \" | \" + base.ieee + \" | \" + base.path;\n    if (base.unit) s += \" (\" + base.unit + \")\";\n    if (base.kind === \"enum\") s += \" | \" + base.enumValues.map((v, i) => (i + 1) + \"=\" + v).join(\", \");\n    s += \" | \" + role;\n    return s.substring(0, 250);\n}\n\nconst catalog = {};\nconst all = [];\nlet devCount = 0;\n\nfor (const d of devices) {\n    if (!d || d.type === \"Coordinator\" || d.disabled || !d.interview_completed || !d.definition) continue;\n    devCount++;\n    const fname = d.friendly_name;\n    const model = d.definition.model || \"?\";\n    const exc = [].concat(cfg.exceptions[model] || [], cfg.exceptions[fname] || []);\n    const excl = [].concat(cfg.exclusions[model] || [], cfg.exclusions[fname] || []);\n    const pts = [];\n\n    (function walk(list, prefix, parentCat) {\n        for (const e of (list || [])) {\n            const cat = (e.category !== undefined && e.category !== null) ? e.category : parentCat;\n            if (e.features && e.features.length) {\n                const pre = (e.type === \"composite\" && e.property) ? prefix + e.property + \".\" : prefix;\n                walk(e.features, pre, cat);\n                continue;\n            }\n            if (!e.property || [\"binary\", \"numeric\", \"enum\"].indexOf(e.type) < 0) continue;\n            if (excl.indexOf(e.property) >= 0) continue;\n            const allowed = cfg.categories.indexOf(cat || \"principal\") >= 0 || exc.indexOf(e.property) >= 0;\n            if (!allowed) continue;\n\n            const isBin = e.type === \"binary\";\n            const base = {\n                device: fname, ieee: d.ieee_address, model: model,\n                path: prefix + e.property, kind: e.type, unit: e.unit || \"\",\n                valueOn: e.value_on, valueOff: e.value_off, enumValues: e.values || [],\n                objType: isBin ? \"BinaryValue\" : \"AnalogValue\",\n                bacnetPath: isBin ? \"binary-values\" : \"analog-values\"\n            };\n            const access = e.access || 0;\n            if (access & 1) pts.push(Object.assign({}, base, {\n                role: \"etat\", name: mkName(fname, base.path.replace(/\\./g, \"_\"), \"\"),\n                description: describe(base, \"état\") }));\n            if (access & 2) pts.push(Object.assign({}, base, {\n                role: \"cmd\", name: mkName(fname, base.path.replace(/\\./g, \"_\"), \"_Cmd\"),\n                description: describe(base, \"commande\") }));\n        }\n    })(d.definition.exposes, \"\", null);\n\n    if (cfg.online) {\n        const b = { device: fname, ieee: d.ieee_address, model: model, path: \"__online\", kind: \"binary\",\n                    unit: \"\", enumValues: [], objType: \"BinaryValue\", bacnetPath: \"binary-values\" };\n        pts.push(Object.assign(b, { role: \"online\", name: mkName(fname, \"Online\", \"\"),\n                                    description: (model + \" | \" + d.ieee_address + \" | disponibilité\").substring(0, 250) }));\n    }\n    catalog[fname] = pts;\n    all.push(...pts);\n}\n\nflow.set(\"catalog\", catalog);\nconst missing = all.filter(p => !known[p.name]);\n\nnode.status({\n    fill: missing.length ? \"yellow\" : \"green\", shape: \"dot\",\n    text: devCount + \" appareil(s), \" + all.length + \" point(s), \" + missing.length + \" à traiter — \" + new Date().toLocaleTimeString()\n});\n\nreturn missing.length ? { topic: \"__enqueue\", payload: missing } : null;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":380,"y":230,"wires":[["fn_queue"]]},{"id":"inj_tick","type":"inject","z":"tab_z2m_auto","name":"⏱ Watchdog 10 s","props":[],"repeat":"10","crontab":"","once":false,"onceDelay":"","topic":"","x":380,"y":290,"wires":[["fn_queue"]]},{"id":"fn_queue","type":"function","z":"tab_z2m_auto","name":"📥 File (1 à la fois)","func":"// ============================================================\n// FILE DE CRÉATION — un point à la fois (évite les doublons d'instance)\n// ============================================================\nconst q = context.get(\"q\") || [];\nlet busy = context.get(\"busy\") || 0;\n\nif (msg.topic === \"__enqueue\") {\n    const names = new Set(q.map(i => i.name));\n    for (const p of msg.payload) if (!names.has(p.name)) q.push(p);\n} else if (msg.topic === \"__next\") {\n    busy = 0;\n}\n\n// Watchdog : si un traitement reste bloqué plus de 30 s, on passe au suivant\nif (busy && Date.now() - busy > 30000) {\n    node.warn(\"Watchdog : traitement bloqué, point suivant\");\n    busy = 0;\n}\n\nlet out = null;\nif (!busy && q.length) {\n    const it = q.shift();\n    busy = Date.now();\n    out = { _item: it, _step: \"start\" };\n}\ncontext.set(\"q\", q);\ncontext.set(\"busy\", busy);\n\nnode.status({\n    fill: (q.length || busy) ? \"yellow\" : \"green\", shape: \"dot\",\n    text: \"File : \" + q.length + (busy ? \" (1 en cours)\" : \" — vide\")\n});\nreturn out;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":620,"y":230,"wires":[["fn_process"]]},{"id":"fn_process","type":"function","z":"tab_z2m_auto","name":"🧠 Scan → retrouver / créer","func":"\n// --- Helpers communs ---\nfunction getPath(obj, path) {\n    return path.split(\".\").reduce((o, k) => (o === null || o === undefined) ? undefined : o[k], obj);\n}\nfunction toBac(p, v) {\n    if (v === undefined || v === null) return undefined;\n    if (p.kind === \"binary\") return (v === p.valueOn || v === true);\n    if (p.kind === \"enum\") {\n        const i = (p.enumValues || []).indexOf(v);\n        return i < 0 ? undefined : i + 1;          // index 1..n (façon MSV)\n    }\n    const n = Number(v);\n    return isNaN(n) ? undefined : Math.round(n * 100) / 100;\n}\n\n// ============================================================\n// TRAITEMENT D'UN POINT : scan → retrouver par nom OU créer → description/valeur\n// Sorties : 1 = GET liste | 2 = POST | 3 = point suivant\n// ============================================================\nconst cfg = flow.get(\"cfg\");\nconst known = flow.get(\"knownPoints\") || {};\nconst lists = flow.get(\"lists\") || {};\nconst it = msg._item;\nconst base = cfg.apex + \"/api/rest/v2/services/bacnet/local/objects/\";\nconst done = () => [null, null, { topic: \"__next\" }];\nlet step = msg._step;\n\nfunction register(inst) {\n    known[it.name] = Object.assign({}, it, { instance: inst });\n    flow.set(\"knownPoints\", known);\n}\n\n// --- 1. Départ : déjà connu ? liste en cache ? ---\nif (step === \"start\") {\n    if (known[it.name]) return done();\n    const l = lists[it.bacnetPath];\n    if (!l || Date.now() - l.t > 60000) {\n        msg._step = \"list\";\n        msg.method = \"GET\";\n        msg.url = base + it.bacnetPath;\n        msg.headers = { \"Authorization\": cfg.auth };\n        msg.payload = \"\";\n        node.status({ fill: \"yellow\", shape: \"ring\", text: \"Scan \" + it.bacnetPath });\n        return [msg, null, null];\n    }\n    step = \"decide\";\n}\n\n// --- 2. Résultat du scan ---\nif (step === \"list\") {\n    if (msg.statusCode !== 200 || typeof msg.payload !== \"object\" || msg.payload === null) {\n        node.error(\"Scan \" + it.bacnetPath + \" : HTTP \" + msg.statusCode, msg);\n        return done();\n    }\n    const items = {};\n    for (const k of Object.keys(msg.payload)) {\n        const n = parseInt(k, 10);\n        if (!isNaN(n)) items[n] = (msg.payload[k] || {})[\"object-name\"];\n    }\n    lists[it.bacnetPath] = { t: Date.now(), items: items };\n    flow.set(\"lists\", lists);\n    step = \"decide\";\n}\n\n// --- 3. Retrouver par nom, sinon créer dans la plage ---\nif (step === \"decide\") {\n    const items = lists[it.bacnetPath].items;\n    const found = Object.keys(items).find(k => items[k] === it.name);\n    if (found !== undefined) {\n        register(parseInt(found, 10));\n        node.status({ fill: \"blue\", shape: \"dot\", text: \"♻ \" + it.name + \" = \" + found });\n        return done();\n    }\n    let free = null;\n    for (let i = cfg.instMin; i <= cfg.instMax; i++) if (items[i] === undefined) { free = i; break; }\n    if (free === null) {\n        node.error(\"Plage \" + cfg.instMin + \"-\" + cfg.instMax + \" pleine pour \" + it.bacnetPath);\n        return done();\n    }\n    msg._step = \"created\";\n    msg._inst = free;\n    msg.method = \"POST\";\n    msg.url = base + \"add\";\n    msg.headers = { \"Content-Type\": \"application/json\", \"Authorization\": cfg.auth };\n    msg.payload = { \"object-type\": it.objType, \"instance-number\": free, \"name\": it.name };\n    node.status({ fill: \"yellow\", shape: \"dot\", text: \"+ \" + it.name + \" → \" + free });\n    return [null, msg, null];\n}\n\n// --- 4. Après création : description + valeur initiale ---\nif (step === \"created\") {\n    if (msg.statusCode !== 200) {\n        node.error(\"Création \" + it.name + \" (\" + msg._inst + \") : HTTP \" + msg.statusCode + \" — \" + msg.payload, msg);\n        return done();\n    }\n    lists[it.bacnetPath].items[msg._inst] = it.name;\n    flow.set(\"lists\", lists);\n    register(msg._inst);\n\n    const body = { \"description\": it.description };\n    const last = (flow.get(\"lastPayload\") || {})[it.device];\n    const avail = (flow.get(\"avail\") || {})[it.device];\n    let pv;\n    if (it.role === \"online\") pv = (avail === undefined) ? undefined : avail;\n    else if (last) pv = toBac(it, getPath(last, it.path));\n    if (pv !== undefined) body[\"present-value\"] = pv;\n\n    msg._step = \"desc\";\n    msg.method = \"POST\";\n    msg.url = base + it.bacnetPath + \"/\" + msg._inst;\n    msg.headers = { \"Content-Type\": \"application/json\", \"Authorization\": cfg.auth };\n    msg.payload = body;\n    return [null, msg, null];\n}\n\n// --- 5. Fin ---\nif (step === \"desc\") {\n    if (msg.statusCode !== 200) node.warn(\"Description/valeur \" + it.name + \" : HTTP \" + msg.statusCode);\n    node.status({ fill: \"green\", shape: \"dot\", text: \"✓ \" + it.name + \" (\" + msg._inst + \") — \" + new Date().toLocaleTimeString() });\n    return done();\n}\nreturn done();","outputs":3,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":870,"y":230,"wires":[["http_list"],["http_create"],["fn_queue"]]},{"id":"http_list","type":"http request","z":"tab_z2m_auto","name":"GET liste objets","method":"use","ret":"obj","paytoqs":"ignore","url":"","tls":"tls_apex_z2m","persist":true,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":1120,"y":190,"wires":[["fn_process"]]},{"id":"http_create","type":"http request","z":"tab_z2m_auto","name":"POST création / description","method":"use","ret":"txt","paytoqs":"ignore","url":"","tls":"tls_apex_z2m","persist":true,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":1140,"y":270,"wires":[["fn_process"]]},{"id":"cm_val","type":"comment","z":"tab_z2m_auto","name":"═══ VALEURS & DISPONIBILITÉ → APEX ═══","info":"","x":220,"y":370,"wires":[]},{"id":"mq_values","type":"mqtt in","z":"tab_z2m_auto","name":"zigbee2mqtt/+","topic":"zigbee2mqtt/+","qos":"0","datatype":"json","broker":"broker_apex_z2m","nl":false,"rap":true,"rh":0,"inputs":0,"x":140,"y":430,"wires":[["fn_runtime"]]},{"id":"fn_runtime","type":"function","z":"tab_z2m_auto","name":"Z2M → present-value","func":"\n// --- Helpers communs ---\nfunction getPath(obj, path) {\n    return path.split(\".\").reduce((o, k) => (o === null || o === undefined) ? undefined : o[k], obj);\n}\nfunction toBac(p, v) {\n    if (v === undefined || v === null) return undefined;\n    if (p.kind === \"binary\") return (v === p.valueOn || v === true);\n    if (p.kind === \"enum\") {\n        const i = (p.enumValues || []).indexOf(v);\n        return i < 0 ? undefined : i + 1;          // index 1..n (façon MSV)\n    }\n    const n = Number(v);\n    return isNaN(n) ? undefined : Math.round(n * 100) / 100;\n}\n\n// ============================================================\n// VALEURS : zigbee2mqtt/<appareil> → present-value des objets \"état\"\n// ============================================================\nconst cfg = flow.get(\"cfg\");\nif (!cfg || typeof msg.payload !== \"object\" || msg.payload === null) return null;\nconst device = msg.topic.split(\"/\")[1];\nif (device === \"bridge\") return null;\n\n// Mémoriser le dernier état (sert aussi à initialiser les objets créés plus tard)\nconst lastPayload = flow.get(\"lastPayload\") || {};\nlastPayload[device] = Object.assign(lastPayload[device] || {}, msg.payload);\nflow.set(\"lastPayload\", lastPayload);\n\nconst pts = ((flow.get(\"catalog\") || {})[device] || []).filter(p => p.role === \"etat\");\nconst known = flow.get(\"knownPoints\") || {};\nconst lastW = flow.get(\"lastWritten\") || {};\nconst now = Date.now();\nconst out = [];\n\nfor (const p of pts) {\n    const k = known[p.name];\n    if (!k) continue;                       // pas encore créé\n    const v = toBac(p, getPath(msg.payload, p.path));\n    if (v === undefined) continue;\n    const prev = lastW[p.name];\n    if (prev && prev.v === v && now - prev.t < cfg.rafraichissementMin * 60000) continue;\n    lastW[p.name] = { v: v, t: now };\n    out.push({\n        method: \"POST\",\n        url: cfg.apex + \"/api/rest/v2/services/bacnet/local/objects/\" + p.bacnetPath + \"/\" + k.instance,\n        headers: { \"Content-Type\": \"application/json\", \"Authorization\": cfg.auth },\n        payload: { \"present-value\": v },\n        _name: p.name,\n        _info: p.name + \" = \" + v\n    });\n}\nflow.set(\"lastWritten\", lastW);\nif (out.length) node.status({ fill: \"green\", shape: \"dot\", text: device + \" : \" + out.length + \" écriture(s) — \" + new Date().toLocaleTimeString() });\nreturn [out];","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":380,"y":430,"wires":[["rate_w"]]},{"id":"mq_avail","type":"mqtt in","z":"tab_z2m_auto","name":"zigbee2mqtt/+/availability","topic":"zigbee2mqtt/+/availability","qos":"0","datatype":"auto-detect","broker":"broker_apex_z2m","nl":false,"rap":true,"rh":0,"inputs":0,"x":170,"y":490,"wires":[["fn_avail"]]},{"id":"fn_avail","type":"function","z":"tab_z2m_auto","name":"Disponibilité → _Online / OOS","func":"// ============================================================\n// DISPONIBILITÉ : zigbee2mqtt/<appareil>/availability\n// → BV _Online + out-of-service des objets \"état\" de l'appareil\n// ============================================================\nconst cfg = flow.get(\"cfg\");\nif (!cfg || !cfg.online) return null;\nconst device = msg.topic.split(\"/\")[1];\n\nlet s = msg.payload;\nif (typeof s === \"object\" && s !== null) s = s.state;\nconst online = String(s).toLowerCase() === \"online\";\n\nconst avail = flow.get(\"avail\") || {};\nconst changed = avail[device] !== online;\navail[device] = online;\nflow.set(\"avail\", avail);\nif (!changed) return null;\n\nconst known = flow.get(\"knownPoints\") || {};\nconst pts = (flow.get(\"catalog\") || {})[device] || [];\nconst base = cfg.apex + \"/api/rest/v2/services/bacnet/local/objects/\";\nconst hdr = { \"Content-Type\": \"application/json\", \"Authorization\": cfg.auth };\nconst out = [];\n\nfor (const p of pts) {\n    const k = known[p.name];\n    if (!k) continue;\n    if (p.role === \"online\") {\n        out.push({ method: \"POST\", url: base + p.bacnetPath + \"/\" + k.instance, headers: hdr,\n                   payload: { \"present-value\": online }, _name: p.name, _info: p.name + \" = \" + online });\n    } else if (p.role === \"etat\") {\n        out.push({ method: \"POST\", url: base + p.bacnetPath + \"/\" + k.instance, headers: hdr,\n                   payload: { \"out-of-service\": !online }, _name: null, _info: p.name + \" OOS = \" + !online });\n    }\n}\nnode.status({ fill: online ? \"green\" : \"red\", shape: \"dot\", text: device + \" \" + (online ? \"en ligne\" : \"HORS LIGNE\") });\nreturn [out];","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":420,"y":490,"wires":[["rate_w"]]},{"id":"rate_w","type":"delay","z":"tab_z2m_auto","name":"10 req/s max","pauseType":"rate","timeout":"5","timeoutUnits":"seconds","rate":"10","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":650,"y":460,"wires":[["http_write"]]},{"id":"http_write","type":"http request","z":"tab_z2m_auto","name":"POST écriture","method":"use","ret":"txt","paytoqs":"ignore","url":"","tls":"tls_apex_z2m","persist":true,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":840,"y":460,"wires":[["fn_check"]]},{"id":"fn_check","type":"function","z":"tab_z2m_auto","name":"Contrôle écriture","func":"// 200 + body vide = succès (normal sur l'APEX)\nif (msg.statusCode >= 200 && msg.statusCode < 300) {\n    node.status({ fill: \"green\", shape: \"dot\", text: \"✓ \" + msg._info });\n    return null;\n}\nif (msg._name) {\n    const lastW = flow.get(\"lastWritten\") || {};\n    delete lastW[msg._name];               // on réessaiera au prochain message\n    flow.set(\"lastWritten\", lastW);\n}\nnode.status({ fill: \"red\", shape: \"ring\", text: \"✗ HTTP \" + msg.statusCode + \" \" + msg._info });\nnode.warn(\"Échec \" + msg._info + \" — HTTP \" + msg.statusCode + \" — \" + msg.payload);\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1030,"y":460,"wires":[["dbg_err"]]},{"id":"dbg_err","type":"debug","z":"tab_z2m_auto","name":"Erreurs écriture","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1220,"y":460,"wires":[]},{"id":"cm_cmd","type":"comment","z":"tab_z2m_auto","name":"═══ COMMANDES : objets _Cmd (GFX) → Zigbee ═══","info":"","x":240,"y":570,"wires":[]},{"id":"inj_poll","type":"inject","z":"tab_z2m_auto","name":"Toutes les 2 s","props":[],"repeat":"2","crontab":"","once":true,"onceDelay":"5","topic":"","x":150,"y":630,"wires":[["fn_poll"]]},{"id":"fn_poll","type":"function","z":"tab_z2m_auto","name":"Lire les _Cmd","func":"// ============================================================\n// Lecture des objets _Cmd (écrits par GFX)\n// ============================================================\nconst cfg = flow.get(\"cfg\");\nif (!cfg) return null;\nconst known = flow.get(\"knownPoints\") || {};\nconst out = [];\nfor (const name of Object.keys(known)) {\n    const p = known[name];\n    if (p.role !== \"cmd\") continue;\n    out.push({\n        method: \"GET\",\n        url: cfg.apex + \"/api/rest/v2/services/bacnet/local/objects/\" + p.bacnetPath + \"/\" + p.instance,\n        headers: { \"Authorization\": cfg.auth },\n        _p: p\n    });\n}\nreturn [out];","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":340,"y":630,"wires":[["rate_r"]]},{"id":"rate_r","type":"delay","z":"tab_z2m_auto","name":"20 req/s max","pauseType":"rate","timeout":"5","timeoutUnits":"seconds","rate":"20","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":520,"y":630,"wires":[["http_cmd"]]},{"id":"http_cmd","type":"http request","z":"tab_z2m_auto","name":"GET _Cmd","method":"use","ret":"obj","paytoqs":"ignore","url":"","tls":"tls_apex_z2m","persist":true,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":690,"y":630,"wires":[["fn_cmd"]]},{"id":"fn_cmd","type":"function","z":"tab_z2m_auto","name":"Changement → /set","func":"// ============================================================\n// Changement d'un objet _Cmd → zigbee2mqtt/<appareil>/set\n// Première lecture = mémorisation uniquement (pas d'envoi)\n// ============================================================\nconst cfg = flow.get(\"cfg\");\nconst p = msg._p;\nif (msg.statusCode !== 200 || typeof msg.payload !== \"object\" || msg.payload === null) {\n    node.status({ fill: \"red\", shape: \"ring\", text: \"✗ lecture \" + p.name + \" HTTP \" + msg.statusCode });\n    return null;\n}\nconst raw = msg.payload[\"present-value\"];\nlet v;\nif (p.kind === \"binary\") {\n    const s = String(raw).toLowerCase();\n    const on = raw === true || raw === 1 || s === \"active\" || s === \"true\" || s === \"1\";\n    v = on ? (p.valueOn !== undefined ? p.valueOn : true) : (p.valueOff !== undefined ? p.valueOff : false);\n} else if (p.kind === \"enum\") {\n    v = (p.enumValues || [])[Math.round(Number(raw)) - 1];\n    if (v === undefined) return null;\n} else {\n    v = Number(raw);\n    if (isNaN(v)) return null;\n}\n\nconst lastCmd = flow.get(\"lastCmd\") || {};\nconst first = !(p.name in lastCmd);\nconst changed = lastCmd[p.name] !== v;\nlastCmd[p.name] = v;\nflow.set(\"lastCmd\", lastCmd);\nif (!changed || first) {\n    if (first) node.status({ fill: \"blue\", shape: \"dot\", text: \"Init \" + p.name + \" = \" + v });\n    return null;\n}\n\n// Construire le payload (gère les chemins imbriqués ex. color.x)\nconst payload = {};\nconst keys = p.path.split(\".\");\nlet o = payload;\nkeys.forEach((k, i) => { if (i === keys.length - 1) o[k] = v; else o = o[k] = {}; });\n\nnode.status({ fill: \"green\", shape: \"dot\", text: \"→ \" + p.device + \" \" + p.path + \" = \" + v + \" — \" + new Date().toLocaleTimeString() });\nreturn { topic: cfg.baseTopic + \"/\" + p.device + \"/set\", payload: JSON.stringify(payload) };","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":870,"y":630,"wires":[["mq_out","dbg_cmd"]]},{"id":"mq_out","type":"mqtt out","z":"tab_z2m_auto","name":"Z2M /set","topic":"","qos":"0","retain":"false","respTopic":"","contentType":"","userProps":"","correl":"","expiry":"","broker":"broker_apex_z2m","x":1070,"y":610,"wires":[]},{"id":"dbg_cmd","type":"debug","z":"tab_z2m_auto","name":"Commandes envoyées","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1100,"y":660,"wires":[]},{"id":"cm_tools","type":"comment","z":"tab_z2m_auto","name":"═══ OUTILS ═══","info":"","x":150,"y":740,"wires":[]},{"id":"inj_stats","type":"inject","z":"tab_z2m_auto","name":"📊 Stats","props":[],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","x":130,"y":800,"wires":[["fn_stats"]]},{"id":"fn_stats","type":"function","z":"tab_z2m_auto","name":"📊 Stats","func":"const catalog = flow.get(\"catalog\") || {};\nconst known = flow.get(\"knownPoints\") || {};\nconst avail = flow.get(\"avail\") || {};\nconst devices = Object.keys(catalog).map(d => ({\n    appareil: d,\n    enLigne: avail[d],\n    points: catalog[d].map(p => ({\n        nom: p.name, role: p.role, type: p.objType,\n        instance: known[p.name] ? known[p.name].instance : \"(non créé)\"\n    }))\n}));\nconst n = Object.keys(known).length;\nmsg.payload = { appareils: devices.length, pointsCrees: n, detail: devices };\nnode.status({ fill: \"blue\", shape: \"dot\", text: devices.length + \" appareil(s), \" + n + \" point(s) créés\" });\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":300,"y":800,"wires":[["dbg_stats"]]},{"id":"dbg_stats","type":"debug","z":"tab_z2m_auto","name":"Stats","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":470,"y":800,"wires":[]},{"id":"inj_clear","type":"inject","z":"tab_z2m_auto","name":"🗑 Vider cache + rescan","props":[],"repeat":"","crontab":"","once":false,"onceDelay":"","topic":"","x":170,"y":860,"wires":[["fn_clear"]]},{"id":"fn_clear","type":"function","z":"tab_z2m_auto","name":"🗑 Vider cache","func":"flow.set(\"knownPoints\", {});\nflow.set(\"lists\", {});\nflow.set(\"lastWritten\", {});\nflow.set(\"lastCmd\", {});\nnode.warn(\"Cache vidé — rescan : les points existants seront retrouvés par leur nom.\");\nnode.status({ fill: \"red\", shape: \"ring\", text: \"Cache vidé — \" + new Date().toLocaleTimeString() });\nreturn { payload: flow.get(\"rawDevices\") || [] };","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":380,"y":860,"wires":[["fn_catalog"]]},{"id":"catch_z2m","type":"catch","z":"tab_z2m_auto","name":"Erreurs du flux","scope":null,"uncaught":false,"x":140,"y":930,"wires":[["dbg_catch"]]},{"id":"dbg_catch","type":"debug","z":"tab_z2m_auto","name":"Exceptions","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":330,"y":930,"wires":[]},{"id":"broker_apex_z2m","type":"mqtt-broker","name":"Mosquitto APEX (Z2M)","broker":"IP_APEX","port":"50083","clientid":"nodered-z2m-auto","autoConnect":true,"usetls":false,"protocolVersion":"4","keepalive":"60","cleansession":true,"autoUnsubscribe":true,"birthTopic":"","birthQos":"0","birthPayload":"","birthMsg":{},"closeTopic":"","closeQos":"0","closePayload":"","closeMsg":{},"willTopic":"","willQos":"0","willPayload":"","willMsg":{},"userProps":"","sessionExpiry":""},{"id":"tls_apex_z2m","type":"tls-config","name":"APEX (certificat auto-signé)","cert":"","key":"","ca":"","certname":"","keyname":"","caname":"","servername":"","verifyservercert":false,"alpnprotocol":""}]
