[
    {
        "id": "cmt1",
        "type": "comment",
        "z": "a1b2c3d4e5f60001",
        "name": "1) CONFIG",
        "info": "Set switchHost, community and ifIndex. WLED defaults are already filled in.",
        "x": 130,
        "y": 80,
        "wires": []
    },
    {
        "id": "inj_init",
        "type": "inject",
        "z": "a1b2c3d4e5f60001",
        "name": "Initialize on deploy",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": true,
        "onceDelay": "0.2",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 170,
        "y": 140,
        "wires": [
            [
                "fn_config"
            ]
        ]
    },
    {
        "id": "fn_config",
        "type": "function",
        "z": "a1b2c3d4e5f60001",
        "name": "CONFIG - EDIT ME",
        "func": "const cfg = {\n    switchHost: \"SwitchIP\",\n    community: \"SNMPcommunityName\",\n    ifIndex: null,\n    use64bit: true,\n    swapDirections: false,\n\n    wledHost: \"WLEDip\",\n    wledUdpPort: 21324,\n    ledCount: 90,\n    fallbackPreset: 22,\n\n    realtimeTimeoutSeconds: 2,\n    staleMs: 3000,\n\n    scaleMaxMbps: 1000,\n    ignoreBelowMbps: 0.02,\n\n    rxLedPerSecond: 15.0,\n    txLedPerSecond: 13.5,\n    rxMaxSpawnPerSecond: 3.0,\n    txMaxSpawnPerSecond: 2.4,\n    rxTrailPixels: 8.0,\n    txTrailPixels: 7.0,\n    maxParticlesPerDirection: 24,\n\n    rxColor: [0, 205, 255],\n    rxHighlight: [210, 255, 255],\n    txColor: [0, 255, 100],\n    txHighlight: [70, 255, 220]\n};\n\nflow.set(\"netled_config\", cfg);\nflow.set(\"netled_enabled\", true);\nflow.set(\"netled_prev\", null);\nflow.set(\"netled_traffic\", { valid:false, rxMbps:0, txMbps:0, lastUpdate:0 });\nflow.set(\"netled_particles\", { rx:[], tx:[], lastFrame:Date.now() });\n\nnode.status({fill:\"green\", shape:\"dot\", text:`${cfg.ledCount} LEDs | fallback P${cfg.fallbackPreset}`});\nmsg.payload = \"configuration loaded\";\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 420,
        "y": 140,
        "wires": [
            [
                "fn_arm"
            ]
        ]
    },
    {
        "id": "fn_arm",
        "type": "function",
        "z": "a1b2c3d4e5f60001",
        "name": "Arm fallback preset",
        "func": "const cfg = flow.get(\"netled_config\");\nif (!cfg) return null;\nmsg.method = \"POST\";\nmsg.url = `http://${cfg.wledHost}/json/state`;\nmsg.headers = {\"Content-Type\":\"application/json\"};\nmsg.payload = JSON.stringify({ps: cfg.fallbackPreset});\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 670,
        "y": 140,
        "wires": [
            [
                "http_wled"
            ]
        ]
    },
    {
        "id": "http_wled",
        "type": "http request",
        "z": "a1b2c3d4e5f60001",
        "name": "WLED JSON API",
        "method": "use",
        "ret": "txt",
        "paytoqs": "ignore",
        "url": "",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "",
        "senderr": false,
        "headers": [],
        "x": 910,
        "y": 140,
        "wires": [
            [
                "dbg_http"
            ]
        ]
    },
    {
        "id": "dbg_http",
        "type": "debug",
        "z": "a1b2c3d4e5f60001",
        "name": "WLED API response",
        "active": false,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 1150,
        "y": 140,
        "wires": []
    },
    {
        "id": "cmt2",
        "type": "comment",
        "z": "a1b2c3d4e5f60001",
        "name": "2) SNMP COUNTERS",
        "info": "Polls IF-MIB counters once per second.",
        "x": 150,
        "y": 250,
        "wires": []
    },
    {
        "id": "inj_poll",
        "type": "inject",
        "z": "a1b2c3d4e5f60001",
        "name": "Poll traffic every 1s",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "1",
        "crontab": "",
        "once": true,
        "onceDelay": "1.0",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 180,
        "y": 310,
        "wires": [
            [
                "fn_prep"
            ]
        ]
    },
    {
        "id": "fn_prep",
        "type": "function",
        "z": "a1b2c3d4e5f60001",
        "name": "Prepare traffic OIDs",
        "func": "const cfg = flow.get(\"netled_config\");\nif (!cfg) return null;\nconst i = cfg.ifIndex;\nmsg.host = cfg.switchHost;\nmsg.community = cfg.community;\n\nif (cfg.use64bit) {\n    msg.oid = `1.3.6.1.2.1.31.1.1.1.6.${i},1.3.6.1.2.1.31.1.1.1.10.${i}`;\n    msg.counterBits = 64;\n} else {\n    msg.oid = `1.3.6.1.2.1.2.2.1.10.${i},1.3.6.1.2.1.2.2.1.16.${i}`;\n    msg.counterBits = 32;\n}\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 430,
        "y": 310,
        "wires": [
            [
                "snmp_get"
            ]
        ]
    },
    {
        "id": "fn_rates",
        "type": "function",
        "z": "a1b2c3d4e5f60001",
        "name": "Calculate RX / TX Mbps",
        "func": "const cfg = flow.get(\"netled_config\");\nif (!cfg || !Array.isArray(msg.payload) || msg.payload.length < 2) {\n    node.status({fill:\"red\", shape:\"ring\", text:\"invalid SNMP reply\"});\n    return null;\n}\n\nfunction toBigInt(v) {\n    if (typeof v === \"bigint\") return v;\n    if (typeof v === \"number\" && Number.isFinite(v)) return BigInt(Math.trunc(v));\n    if (typeof v === \"string\") {\n        const m = v.match(/\\d+/g);\n        return (m && m.length) ? BigInt(m[m.length-1]) : null;\n    }\n    if (Buffer.isBuffer(v)) {\n        let n = 0n;\n        for (const b of v.values()) n = (n << 8n) + BigInt(b);\n        return n;\n    }\n    if (v && v.type === \"Buffer\" && Array.isArray(v.data)) {\n        let n = 0n;\n        for (const b of v.data) n = (n << 8n) + BigInt(b);\n        return n;\n    }\n    if (Array.isArray(v)) {\n        let n = 0n;\n        for (const b of v) n = (n << 8n) + BigInt(b);\n        return n;\n    }\n    if (v && typeof v === \"object\" && v.high !== undefined && v.low !== undefined) {\n        return (BigInt(v.high >>> 0) << 32n) + BigInt(v.low >>> 0);\n    }\n    return null;\n}\n\nif (msg.payload[0]._error || msg.payload[1]._error) {\n    node.status({fill:\"red\", shape:\"ring\", text:\"SNMP OID unavailable\"});\n    return null;\n}\n\nconst inCounter = toBigInt(msg.payload[0].value);\nconst outCounter = toBigInt(msg.payload[1].value);\nif (inCounter === null || outCounter === null) {\n    node.status({fill:\"red\", shape:\"ring\", text:\"counter parse error\"});\n    return null;\n}\n\nconst now = Date.now();\nconst prevRaw = flow.get(\"netled_prev\");\nconst prev = prevRaw ? {\n    inCounter: BigInt(prevRaw.inCounter),\n    outCounter: BigInt(prevRaw.outCounter),\n    timestamp: prevRaw.timestamp\n} : null;\n\n// Store counters as decimal strings so this also works with file-backed\n// Node-RED context stores that do not serialize BigInt values.\nflow.set(\"netled_prev\", {\n    inCounter: inCounter.toString(),\n    outCounter: outCounter.toString(),\n    timestamp: now\n});\n\nif (!prev) {\n    node.status({fill:\"yellow\", shape:\"ring\", text:\"priming counters\"});\n    return null;\n}\n\nconst dt = (now - prev.timestamp) / 1000;\nif (!(dt > 0) || dt > 10) return null;\n\nfunction delta(cur, old, bits) {\n    if (cur >= old) return cur - old;\n    if (bits === 32) return ((1n << 32n) - old) + cur;\n    return null;\n}\n\nconst dIn = delta(inCounter, prev.inCounter, msg.counterBits);\nconst dOut = delta(outCounter, prev.outCounter, msg.counterBits);\nif (dIn === null || dOut === null) {\n    node.status({fill:\"yellow\", shape:\"ring\", text:\"counter reset; reprime\"});\n    return null;\n}\n\nlet inMbps = (Number(dIn) * 8 / dt) / 1e6;\nlet outMbps = (Number(dOut) * 8 / dt) / 1e6;\nif (!Number.isFinite(inMbps) || !Number.isFinite(outMbps)) return null;\n\nlet rx = cfg.swapDirections ? outMbps : inMbps;\nlet tx = cfg.swapDirections ? inMbps : outMbps;\n\nlet t = flow.get(\"netled_traffic\") || {valid:false, rxMbps:0, txMbps:0, lastUpdate:0};\nconst alpha = 0.45;\n\nif (!t.valid) {\n    t.rxMbps = rx;\n    t.txMbps = tx;\n} else {\n    t.rxMbps = alpha*rx + (1-alpha)*t.rxMbps;\n    t.txMbps = alpha*tx + (1-alpha)*t.txMbps;\n}\n\nt.valid = true;\nt.lastUpdate = now;\nflow.set(\"netled_traffic\", t);\n\nnode.status({fill:\"green\", shape:\"dot\", text:`RX ${t.rxMbps.toFixed(1)} | TX ${t.txMbps.toFixed(1)} Mb/s`});\nmsg.payload = {rxMbps:+t.rxMbps.toFixed(3), txMbps:+t.txMbps.toFixed(3), sampleSeconds:+dt.toFixed(3)};\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 940,
        "y": 310,
        "wires": [
            [
                "dbg_rates"
            ]
        ]
    },
    {
        "id": "dbg_rates",
        "type": "debug",
        "z": "a1b2c3d4e5f60001",
        "name": "Live RX/TX Mbps",
        "active": false,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 1190,
        "y": 310,
        "wires": []
    },
    {
        "id": "cmt3",
        "type": "comment",
        "z": "a1b2c3d4e5f60001",
        "name": "3) 24 FPS DRGB RENDERER",
        "info": "Stops transmitting if SNMP is stale >3 seconds, allowing WLED fallback.",
        "x": 170,
        "y": 420,
        "wires": []
    },
    {
        "id": "inj_frame",
        "type": "inject",
        "z": "a1b2c3d4e5f60001",
        "name": "Render ~24 fps",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "0.042",
        "crontab": "",
        "once": true,
        "onceDelay": "1.5",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 170,
        "y": 480,
        "wires": [
            [
                "fn_render"
            ]
        ]
    },
    {
        "id": "fn_render",
        "type": "function",
        "z": "a1b2c3d4e5f60001",
        "name": "Render real traffic",
        "func": "const cfg = flow.get(\"netled_config\");\nconst enabled = flow.get(\"netled_enabled\");\nconst traffic = flow.get(\"netled_traffic\");\n\nif (!cfg || !enabled) {\n    node.status({ fill: \"grey\", shape: \"ring\", text: \"realtime disabled\" });\n    return null;\n}\n\nconst now = Date.now();\n\nif (!traffic || !traffic.valid ||\n    (now - traffic.lastUpdate) > cfg.staleMs) {\n    // Send nothing when SNMP data is stale.\n    // WLED's realtime timeout will expire and preset 22 will resume.\n    node.status({ fill: \"red\", shape: \"ring\", text: \"SNMP stale -> fallback\" });\n    return null;\n}\n\nlet state = flow.get(\"netled_particles\");\n\nif (!state) {\n    state = {\n        rx: [],\n        tx: [],\n        lastFrame: now\n    };\n}\n\nlet dt = (now - state.lastFrame) / 1000;\nstate.lastFrame = now;\n\nif (!(dt > 0)) {\n    dt = 1 / 24;\n}\n\ndt = Math.min(dt, 0.10);\n\nconst N = cfg.ledCount;\n\nconst pixels = Array.from(\n    { length: N },\n    () => [0, 0, 0]\n);\n\nfunction clamp(v, lo, hi) {\n    return Math.max(lo, Math.min(hi, v));\n}\n\n\n// ------------------------------------------------------------\n// TRAFFIC SCALING\n// ------------------------------------------------------------\n\n// Logarithmic activity scale:\n//\n// 0 = effectively idle\n// 1 = at or above scaleMaxMbps\n//\n// This keeps low-rate traffic visible while preventing\n// high-rate traffic from immediately saturating the animation.\n\nfunction activity(mbps) {\n\n    if (mbps < cfg.ignoreBelowMbps) {\n        return 0;\n    }\n\n    const top = Math.max(\n        cfg.scaleMaxMbps,\n        cfg.ignoreBelowMbps + 0.001\n    );\n\n    return clamp(\n        Math.log10(1 + mbps) /\n        Math.log10(1 + top),\n        0,\n        1\n    );\n}\n\n\n// ------------------------------------------------------------\n// PARTICLE FREQUENCY\n// ------------------------------------------------------------\n\n// Particle frequency remains the PRIMARY traffic indicator.\n\nfunction spawnRate(mbps, maxRate) {\n\n    const a = activity(mbps);\n\n    if (a <= 0) {\n        return 0;\n    }\n\n    // Even very small amounts of traffic can occasionally\n    // produce a visible packet.\n    //\n    // As throughput rises, packet frequency increases strongly.\n\n    return 0.12 + a * (maxRate - 0.12);\n}\n\n\n// ------------------------------------------------------------\n// PARTICLE CREATION\n// ------------------------------------------------------------\n\nfunction maybeSpawn(\n    arr,\n    direction,\n    mbps,\n    maxRate,\n    baseSpeed,\n    baseTrail\n) {\n\n    if (arr.length >= cfg.maxParticlesPerDirection) {\n        return;\n    }\n\n    const a = activity(mbps);\n\n    const rate = spawnRate(\n        mbps,\n        maxRate\n    );\n\n    const expected = rate * dt;\n\n    let count = Math.floor(expected);\n\n    if (\n        Math.random() <\n        (expected - count)\n    ) {\n        count++;\n    }\n\n    while (\n        count-- > 0 &&\n        arr.length < cfg.maxParticlesPerDirection\n    ) {\n\n        // ----------------------------------------------------\n        // TRAIL LENGTH\n        // ----------------------------------------------------\n        //\n        // Trail length is a SECONDARY traffic indicator.\n        //\n        // Low activity:\n        //     ~75% of configured base trail\n        //\n        // Maximum activity:\n        //     ~145% of configured base trail\n\n        const trailScale =\n            0.75 +\n            0.70 * a;\n\n\n        // ----------------------------------------------------\n        // BRIGHTNESS\n        // ----------------------------------------------------\n        //\n        // Brightness is also secondary.\n        //\n        // Low activity:\n        //     ~55%\n        //\n        // Maximum activity:\n        //     100%\n        //\n        // This is intentionally restrained so heavy traffic\n        // does not turn the strip into a solid bar.\n\n        const strength =\n            0.55 +\n            0.45 * a;\n\n\n        arr.push({\n\n            pos:\n                direction > 0\n                    ? 0\n                    : (N - 1),\n\n            dir: direction,\n\n\n            // Small natural speed variation.\n            //\n            // Speed itself is NOT tied to throughput.\n\n            speed:\n                baseSpeed *\n                (\n                    0.90 +\n                    Math.random() * 0.20\n                ),\n\n\n            // Traffic-dependent trail plus a little\n            // natural packet-to-packet variation.\n\n            trail:\n                baseTrail *\n                trailScale *\n                (\n                    0.90 +\n                    Math.random() * 0.20\n                ),\n\n\n            // Traffic-dependent brightness.\n\n            strength:\n                strength *\n                (\n                    0.92 +\n                    Math.random() * 0.08\n                ),\n\n\n            // Slight per-particle highlight variation.\n\n            accent:\n                Math.random()\n        });\n    }\n}\n\n\n// ------------------------------------------------------------\n// CREATE RX PARTICLES\n// ------------------------------------------------------------\n\nmaybeSpawn(\n    state.rx,\n    +1,\n    traffic.rxMbps,\n    cfg.rxMaxSpawnPerSecond,\n    cfg.rxLedPerSecond,\n    cfg.rxTrailPixels\n);\n\n\n// ------------------------------------------------------------\n// CREATE TX PARTICLES\n// ------------------------------------------------------------\n\nmaybeSpawn(\n    state.tx,\n    -1,\n    traffic.txMbps,\n    cfg.txMaxSpawnPerSecond,\n    cfg.txLedPerSecond,\n    cfg.txTrailPixels\n);\n\n\n// ------------------------------------------------------------\n// PIXEL COMPOSITING\n// ------------------------------------------------------------\n\nfunction addPixel(\n    idx,\n    rgb,\n    scale\n) {\n\n    if (\n        idx < 0 ||\n        idx >= N ||\n        scale <= 0\n    ) {\n        return;\n    }\n\n    const p = pixels[idx];\n\n    p[0] = Math.min(\n        255,\n        p[0] + rgb[0] * scale\n    );\n\n    p[1] = Math.min(\n        255,\n        p[1] + rgb[1] * scale\n    );\n\n    p[2] = Math.min(\n        255,\n        p[2] + rgb[2] * scale\n    );\n}\n\n\nfunction mixColor(\n    base,\n    hi,\n    amount\n) {\n\n    amount = clamp(\n        amount,\n        0,\n        1\n    );\n\n    return [\n\n        base[0] +\n        (hi[0] - base[0]) *\n        amount,\n\n        base[1] +\n        (hi[1] - base[1]) *\n        amount,\n\n        base[2] +\n        (hi[2] - base[2]) *\n        amount\n    ];\n}\n\n\n// ------------------------------------------------------------\n// PARTICLE RENDERING\n// ------------------------------------------------------------\n\nfunction renderParticle(\n    p,\n    base,\n    highlight\n) {\n\n    const steps =\n        Math.ceil(p.trail);\n\n    for (\n        let d = 0;\n        d <= steps;\n        d++\n    ) {\n\n        // Trail extends behind the moving head.\n\n        const x =\n            p.pos -\n            p.dir * d;\n\n\n        // Exponential fade along the trail.\n\n        const decay =\n            Math.exp(\n                -d /\n                Math.max(\n                    1.0,\n                    p.trail / 2.6\n                )\n            );\n\n\n        // Additional bright leading head.\n\n        const head =\n            Math.exp(\n                -d / 1.15\n            );\n\n\n        const rgb =\n            mixColor(\n                base,\n                highlight,\n                head *\n                (\n                    0.45 +\n                    0.35 *\n                    p.accent\n                )\n            );\n\n\n        const level =\n            decay *\n            p.strength;\n\n\n        // Fractional LED interpolation.\n        //\n        // This makes motion look considerably smoother,\n        // especially through a 24-fps camera.\n\n        const i0 =\n            Math.floor(x);\n\n        const f =\n            x - i0;\n\n\n        addPixel(\n            i0,\n            rgb,\n            level *\n            (1 - f)\n        );\n\n        addPixel(\n            i0 + 1,\n            rgb,\n            level *\n            f\n        );\n    }\n}\n\n\n// ------------------------------------------------------------\n// MOVE + RENDER RX\n// ------------------------------------------------------------\n\nfor (const p of state.rx) {\n\n    p.pos +=\n        p.dir *\n        p.speed *\n        dt;\n\n    renderParticle(\n        p,\n        cfg.rxColor,\n        cfg.rxHighlight\n    );\n}\n\n\n// ------------------------------------------------------------\n// MOVE + RENDER TX\n// ------------------------------------------------------------\n\nfor (const p of state.tx) {\n\n    p.pos +=\n        p.dir *\n        p.speed *\n        dt;\n\n    renderParticle(\n        p,\n        cfg.txColor,\n        cfg.txHighlight\n    );\n}\n\n\n// ------------------------------------------------------------\n// REMOVE FINISHED PARTICLES\n// ------------------------------------------------------------\n\n// Keep the particle alive until its full trail\n// has moved off the strip.\n\nstate.rx =\n    state.rx.filter(\n        p =>\n            p.pos <\n            (\n                N +\n                p.trail +\n                1\n            )\n    );\n\nstate.tx =\n    state.tx.filter(\n        p =>\n            p.pos >\n            (\n                -p.trail -\n                1\n            )\n    );\n\n\nflow.set(\n    \"netled_particles\",\n    state\n);\n\n\n// ------------------------------------------------------------\n// BUILD WLED DRGB REALTIME FRAME\n// ------------------------------------------------------------\n//\n// Byte 0:\n//     2 = DRGB realtime protocol\n//\n// Byte 1:\n//     WLED realtime timeout in seconds\n//\n// Remaining bytes:\n//     R,G,B for every LED\n\nconst packet =\n    Buffer.alloc(\n        2 +\n        N * 3\n    );\n\npacket[0] = 2;\n\npacket[1] =\n    clamp(\n        cfg.realtimeTimeoutSeconds,\n        1,\n        255\n    );\n\n\nfor (\n    let i = 0;\n    i < N;\n    i++\n) {\n\n    packet[\n        2 +\n        i * 3\n    ] =\n        Math.round(\n            clamp(\n                pixels[i][0],\n                0,\n                255\n            )\n        );\n\n\n    packet[\n        3 +\n        i * 3\n    ] =\n        Math.round(\n            clamp(\n                pixels[i][1],\n                0,\n                255\n            )\n        );\n\n\n    packet[\n        4 +\n        i * 3\n    ] =\n        Math.round(\n            clamp(\n                pixels[i][2],\n                0,\n                255\n            )\n        );\n}\n\n\n// ------------------------------------------------------------\n// UDP DESTINATION\n// ------------------------------------------------------------\n\nmsg.payload = packet;\n\nmsg.ip =\n    cfg.wledHost;\n\nmsg.port =\n    cfg.wledUdpPort;\n\n\n// ------------------------------------------------------------\n// NODE STATUS\n// ------------------------------------------------------------\n//\n// Also show the calculated activity percentage.\n// This is useful when tuning scaleMaxMbps.\n\nconst lastStatus =\n    context.get(\"lastStatus\") ||\n    0;\n\nif (\n    now -\n    lastStatus >\n    500\n) {\n\n    context.set(\n        \"lastStatus\",\n        now\n    );\n\n    const rxA =\n        activity(\n            traffic.rxMbps\n        );\n\n    const txA =\n        activity(\n            traffic.txMbps\n        );\n\n    node.status({\n        fill: \"blue\",\n        shape: \"dot\",\n\n        text:\n            `RX ${traffic.rxMbps.toFixed(1)} Mb/s ` +\n            `(${Math.round(rxA * 100)}%) | ` +\n            `TX ${traffic.txMbps.toFixed(1)} Mb/s ` +\n            `(${Math.round(txA * 100)}%)`\n    });\n}\n\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 420,
        "y": 480,
        "wires": [
            [
                "udp_wled"
            ]
        ]
    },
    {
        "id": "udp_wled",
        "type": "udp out",
        "z": "a1b2c3d4e5f60001",
        "name": "WLED DRGB realtime",
        "addr": "",
        "iface": "",
        "port": "",
        "ipv": "udp4",
        "outport": "",
        "base64": false,
        "multicast": "false",
        "x": 680,
        "y": 480,
        "wires": []
    },
    {
        "id": "cmt4",
        "type": "comment",
        "z": "a1b2c3d4e5f60001",
        "name": "PORT DISCOVERY",
        "info": "After entering switchHost/community, click Discover switch ports and read ifIndex values in Debug.",
        "x": 160,
        "y": 590,
        "wires": []
    },
    {
        "id": "inj_disc",
        "type": "inject",
        "z": "a1b2c3d4e5f60001",
        "name": "Discover switch ports",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": "0.1",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 170,
        "y": 650,
        "wires": [
            [
                "fn_discprep"
            ]
        ]
    },
    {
        "id": "fn_discprep",
        "type": "function",
        "z": "a1b2c3d4e5f60001",
        "name": "Prepare ifDescr walk",
        "func": "const cfg = flow.get(\"netled_config\");\nif (!cfg) return null;\nmsg.host = cfg.switchHost;\nmsg.community = cfg.community;\nmsg.oid = \"1.3.6.1.2.1.2.2.1.2\";\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 430,
        "y": 650,
        "wires": [
            [
                "snmp_sub"
            ]
        ]
    },
    {
        "id": "fn_discfmt",
        "type": "function",
        "z": "a1b2c3d4e5f60001",
        "name": "Format port list",
        "func": "if (!Array.isArray(msg.payload)) return null;\nfunction txt(v) {\n    if (Buffer.isBuffer(v)) return v.toString();\n    if (v && v.type === \"Buffer\" && Array.isArray(v.data)) return Buffer.from(v.data).toString();\n    return String(v);\n}\nconst base = \"1.3.6.1.2.1.2.2.1.2.\";\nmsg.payload = msg.payload.map(vb => {\n    const oid = String(vb.oid || \"\");\n    return {\n        ifIndex: Number(oid.startsWith(base) ? oid.slice(base.length) : oid.split(\".\").pop()),\n        ifDescr: txt(vb.value)\n    };\n}).sort((a,b)=>a.ifIndex-b.ifIndex);\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 920,
        "y": 650,
        "wires": [
            [
                "dbg_ports"
            ]
        ]
    },
    {
        "id": "dbg_ports",
        "type": "debug",
        "z": "a1b2c3d4e5f60001",
        "name": "PORTS: ifIndex + ifDescr",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 1180,
        "y": 650,
        "wires": []
    },
    {
        "id": "cmt5",
        "type": "comment",
        "z": "a1b2c3d4e5f60001",
        "name": "MANUAL CONTROLS",
        "info": "Disable stops realtime and immediately reapplies preset 22. Enable resumes when SNMP is valid.",
        "x": 170,
        "y": 760,
        "wires": []
    },
    {
        "id": "inj_dis",
        "type": "inject",
        "z": "a1b2c3d4e5f60001",
        "name": "Disable realtime / fallback",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": "0.1",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 190,
        "y": 820,
        "wires": [
            [
                "fn_disable"
            ]
        ]
    },
    {
        "id": "fn_disable",
        "type": "function",
        "z": "a1b2c3d4e5f60001",
        "name": "Disable + apply fallback",
        "func": "const cfg = flow.get(\"netled_config\");\nif (!cfg) return null;\nflow.set(\"netled_enabled\",false);\nflow.set(\"netled_particles\",{rx:[],tx:[],lastFrame:Date.now()});\nmsg.method = \"POST\";\nmsg.url = `http://${cfg.wledHost}/json/state`;\nmsg.headers = {\"Content-Type\":\"application/json\"};\nmsg.payload = JSON.stringify({ps:cfg.fallbackPreset});\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 460,
        "y": 820,
        "wires": [
            [
                "http_wled"
            ]
        ]
    },
    {
        "id": "inj_en",
        "type": "inject",
        "z": "a1b2c3d4e5f60001",
        "name": "Enable realtime",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": "0.1",
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 170,
        "y": 870,
        "wires": [
            [
                "fn_enable"
            ]
        ]
    },
    {
        "id": "fn_enable",
        "type": "function",
        "z": "a1b2c3d4e5f60001",
        "name": "Enable realtime",
        "func": "flow.set(\"netled_enabled\",true);\nmsg.payload=\"enabled\";\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 420,
        "y": 870,
        "wires": [
            []
        ]
    },
    {
        "id": "catch_snmp",
        "type": "catch",
        "z": "a1b2c3d4e5f60001",
        "name": "SNMP errors",
        "scope": [
            "snmp_get",
            "snmp_sub"
        ],
        "uncaught": false,
        "x": 170,
        "y": 930,
        "wires": [
            [
                "dbg_errors"
            ]
        ]
    },
    {
        "id": "dbg_errors",
        "type": "debug",
        "z": "a1b2c3d4e5f60001",
        "name": "SNMP errors",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "targetType": "full",
        "statusVal": "",
        "statusType": "auto",
        "x": 410,
        "y": 930,
        "wires": []
    },
    {
        "id": "snmp_get",
        "type": "snmp",
        "z": "a1b2c3d4e5f60001",
        "host": "",
        "version": "2c",
        "timeout": "1",
        "community": "",
        "auth": "noAuthNoPriv",
        "authprot": "MD5",
        "privprot": "DES",
        "oids": "",
        "name": "SwOS traffic counters",
        "x": 680,
        "y": 310,
        "wires": [
            [
                "fn_rates"
            ]
        ]
    },
    {
        "id": "snmp_sub",
        "type": "snmp subtree",
        "z": "a1b2c3d4e5f60001",
        "host": "",
        "version": "2c",
        "timeout": "2",
        "community": "",
        "auth": "noAuthNoPriv",
        "authprot": "MD5",
        "privprot": "DES",
        "oids": "",
        "name": "SwOS ifDescr table",
        "x": 680,
        "y": 650,
        "wires": [
            [
                "fn_discfmt"
            ]
        ]
    },
    {
        "id": "32694d331f432124",
        "type": "global-config",
        "env": [],
        "modules": {
            "node-red-node-snmp": "2.1.0"
        }
    }
]