Manage RF433 covers' exact position with node-red and Home Assistant

Hi,

I wanted to be able to manage my RFY covers’ position but they only support UP/DOWN/STOP commands, so I came up with some node-red automation to do it.
It’s based on the time needed by each cover to go all the way up or all the way down.

This doesn’t work if you’re using remotes to manage your covers. I didn’t yet test if it was possible to trap the remote message and use it.

Hass

What you need for each cover is:

  • The actual cover:
cover:
- platform: rfxtrx
  automatic_add: False
  signal_repetitions: 1
  devices:
    SOMEID:
      name: "RFY Cover 1"
  • An MQTT virtual cover that supports positions:
- name: "Cover Room 1"
  platform: mqtt
  command_topic: 'hass/room/cover/1/in'
  set_position_topic: 'hass/room/cover/1/position'
  position_topic: 'hass/room/cover/1/state'
  retain: false
  optimistic: false

This is for each cover that you want to manage.

Nodered

Prerequisites

You’ll need to install some nodes before this works

  • node-red-contrib-home-assistant-websocket

You’ll also need to install an mqtt server (eg: mosquitto)

The flow

Then import this flow into node-red:

[{"id":"5cafdd9.eae5724","type":"tab","label":"Flow 1","disabled":false,"info":""},{"id":"bd6441.09020bc","type":"mqtt out","z":"5cafdd9.eae5724","name":"hass/+/cover/+/state","topic":"","qos":"","retain":"true","broker":"b2ce6188.0fb15","x":720,"y":480,"wires":[]},{"id":"3b2db43e.8bfc1c","type":"mqtt in","z":"5cafdd9.eae5724","name":"","topic":"hass/+/cover/+/in","qos":"2","broker":"b2ce6188.0fb15","x":80,"y":540,"wires":[["625d1eb4.3948f"]]},{"id":"625d1eb4.3948f","type":"function","z":"5cafdd9.eae5724","name":"Manage Manual Position","func":"function up (entityId) {\n    return {\n        payload: {\n            service: \"open_cover\",\n            data: {\n                entity_id: entityId\n            }\n        }\n    };\n}\n\nfunction down (entityId) {\n    return {\n        payload: {\n            service: \"close_cover\",\n            data: {\n                entity_id: entityId\n            }\n        }\n    };\n}\n\nfunction stop (entityId) {\n    return {\n        payload: {\n            service: \"stop_cover\",\n            data: {\n                entity_id: entityId\n            }\n        }\n    };\n}\n\nvar idx = msg.topic.split(\"/\", 4).join(\"/\");\nvar topic = msg.topic.split(\"/\")[4];\n\nvar blinds = flow.get('blindsHASS');\nvar cover = blinds[idx];\n\nvar now = Date.now();\n\nif (msg.hasOwnProperty('forceStop') || msg.hasOwnProperty('positionStop')) {\n    if (msg.lastUpdate != cover.lastUpdate) {\n        return null;\n    }\n}\n\nif (msg.payload == \"OPEN\") {\n    var waitTime = ((100 - cover.value) / 100 * cover.upTime + 1) * 1000\n    var wait = {\n        topic: msg.topic,\n        lastUpdate: now,\n        delay: waitTime,\n        forceStop: true,\n        payload: \"STOP\"\n    }\n    node.send([null, wait, null, up(cover.idxHW)]);\n} else if (msg.payload == \"CLOSE\") {\n    var waitTime = ((cover.value) / 100 * cover.downTime + 1) * 1000\n    var wait = {\n        topic: msg.topic,\n        lastUpdate: now,\n        delay: waitTime,\n        forceStop: true,\n        payload: \"STOP\"\n    }\n    node.send([null, wait, null, down(cover.idxHW)]);\n} else if (msg.payload == \"STOP\" && cover.lastAction != \"STOP\" && !msg.hasOwnProperty('forceStop')) {\n    node.send([null, null, null, stop(cover.idxHW)]);\n}\n\nif (msg.hasOwnProperty('positionStop')) {\n    cover.lastAction = msg.payload;\n    cover.lastUpdate = now;\n    cover.value = msg.position;\n    return { topic: idx + '/state', payload: msg.position };\n}\n\nif (cover.lastAction == \"OPEN\") {\n    if ((now - cover.lastUpdate) / 1000 > cover.upTime) {\n        cover.lastUpdate = now;\n        cover.lastAction = msg.payload;\n        cover.value = 100;\n        return { topic: idx + '/state', payload: 100 };\n    }\n    cover.value = cover.value + (((now - cover.lastUpdate) / 1000) / cover.upTime * 100) | 0;\n    if (cover.value > 100) { cover.value = 100 }\n    node.send({ topic: idx + '/state', payload: cover.value });\n} else if (cover.lastAction == \"CLOSE\") {\n    if ((now - cover.lastUpdate) / 1000 > cover.downTime) {\n        cover.lastUpdate = now;\n        cover.lastAction = msg.payload;\n        cover.value = 0;\n        return { topic: idx + '/state', payload: 0 };\n    }\n    cover.value = cover.value - (((now - cover.lastUpdate) / 1000) / cover.upTime * 100) | 0;\n    if (cover.value < 0) { cover.value = 0; }\n    node.send({ topic: idx + '/state', payload: cover.value });\n}\ncover.lastAction = msg.payload;\ncover.lastUpdate = now;\n\n\n\nreturn [null, null, blinds];","outputs":"4","noerr":0,"x":350,"y":540,"wires":[["860529bf.6559f8","bd6441.09020bc"],["860529bf.6559f8","a9f49c9b.afbe9"],["860529bf.6559f8"],["c0c885fa.9f1d18"]],"outputLabels":["state","stop wait","blinds","hass request"]},{"id":"860529bf.6559f8","type":"debug","z":"5cafdd9.eae5724","name":"","active":false,"console":"false","complete":"true","x":670,"y":540,"wires":[]},{"id":"a9f49c9b.afbe9","type":"delay","z":"5cafdd9.eae5724","name":"If no stop is received","pauseType":"delayv","timeout":"5","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"x":360,"y":640,"wires":[["625d1eb4.3948f","7be9faa8.b4c6b4"]]},{"id":"7be9faa8.b4c6b4","type":"debug","z":"5cafdd9.eae5724","name":"","active":false,"console":"false","complete":"true","x":670,"y":640,"wires":[]},{"id":"69dbb978.2390e8","type":"debug","z":"5cafdd9.eae5724","name":"","active":false,"console":"false","complete":"true","x":850,"y":580,"wires":[]},{"id":"732de4c2.cc2e4c","type":"function","z":"5cafdd9.eae5724","name":"Manage Blinds","func":"function up (entityId) {\n    return {\n        payload: {\n            service: \"open_cover\",\n            data: {\n                entity_id: entityId\n            }\n        }\n    };\n}\n\nfunction down (entityId) {\n    return {\n        payload: {\n            service: \"close_cover\",\n            data: {\n                entity_id: entityId\n            }\n        }\n    };\n}\n\nfunction stop (entityId) {\n    return {\n        payload: {\n            service: \"stop_cover\",\n            data: {\n                entity_id: entityId\n            }\n        }\n    };\n}\n\n\nvar idx = msg.topic.split(\"/\", 4).join(\"/\");\nvar topic = msg.topic.split(\"/\")[4];\n\nvar blinds = flow.get('blindsHASS');\nvar cover = blinds[idx];\n\nvar now = Date.now();\n\nif (cover.lastAction == \"OPEN\") {\n    if ((now - cover.lastUpdate) / 1000 > cover.upTime) {\n        cover.lastUpdate = now;\n        cover.lastAction = msg.payload;\n        cover.value = 100;\n        node.send([{ topic: idx + '/state', payload: 100 }, null]);\n    }\n    cover.value = cover.value + (((now - cover.lastUpdate) / 1000) / cover.upTime * 100) | 0;\n    if (cover.value > 100) { cover.value = 100 }\n    node.send({ topic: idx + '/state', payload: cover.value });\n} else if (cover.lastAction == \"CLOSE\") {\n    if ((now - cover.lastUpdate) / 1000 > cover.downTime) {\n        cover.lastUpdate = now;\n        cover.lastAction = msg.payload;\n        cover.value = 0;\n        node.send([{ topic: idx + '/state', payload: 0 }, null]);\n    }\n    cover.value = cover.value - (((now - cover.lastUpdate) / 1000) / cover.upTime * 100) | 0;\n    if (cover.value < 0) { cover.value = 0; }\n    node.send({ topic: idx + '/state', payload: cover.value });\n}\ncover.lastUpdate = now;\n\nwantedVal = Number(msg.payload);\nif (wantedVal == cover.value) {\n    return null;\n}\nttw = 0;\nif (cover.value > wantedVal) { // Cover down\n    delta = cover.value - wantedVal;\n    ttw = cover.downTime / 100 * delta;\n    localMsg = down(cover.idxHW);\n    cover.lastAction = \"CLOSE\";\n} else if (cover.value < wantedVal) { // Cover Up\n    delta = wantedVal - cover.value;\n    ttw = cover.upTime / 100 * delta;\n    localMsg = up(cover.idxHW);\n    cover.lastAction = \"OPEN\";\n}\nif (wantedVal === 0 || wantedVal == 100) {\n    stopMsg = null;\n    ttw += 2;\n}\nstopMsg = {\n    topic: msg.topic,\n    payload: \"STOP\",\n    lastUpdate: now,\n    delay: ttw * 1000,\n    positionStop: true,\n    position: wantedVal\n};\nif (wantedVal === 0 || wantedVal == 100) {\n    stopMsg.forceStop = true;\n}\n\nnode.send([null, localMsg, stopMsg]);\nreturn null;","outputs":"3","noerr":0,"x":340,"y":340,"wires":[["829ed290.7e5d6","b6521a54.7e9e08"],["21dfa073.240ff","b2bdb169.2aa9"],["50fd4094.c5234","bd978c3d.efc75"]],"outputLabels":["state","hass request","stop"]},{"id":"50fd4094.c5234","type":"delay","z":"5cafdd9.eae5724","name":"Delay stop","pauseType":"delayv","timeout":"5","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"x":350,"y":440,"wires":[["625d1eb4.3948f"]]},{"id":"9a638973.cc1628","type":"mqtt in","z":"5cafdd9.eae5724","name":"","topic":"hass/+/cover/+/position","qos":"2","broker":"b2ce6188.0fb15","x":100,"y":340,"wires":[["732de4c2.cc2e4c"]]},{"id":"829ed290.7e5d6","type":"debug","z":"5cafdd9.eae5724","name":"","active":false,"console":"false","complete":"true","x":670,"y":260,"wires":[]},{"id":"21dfa073.240ff","type":"debug","z":"5cafdd9.eae5724","name":"","active":false,"console":"false","complete":"true","x":670,"y":360,"wires":[]},{"id":"bd978c3d.efc75","type":"debug","z":"5cafdd9.eae5724","name":"","active":false,"console":"false","complete":"true","x":670,"y":420,"wires":[]},{"id":"b6521a54.7e9e08","type":"mqtt out","z":"5cafdd9.eae5724","name":"hass/+/cover/+/state","topic":"","qos":"","retain":"","broker":"b2ce6188.0fb15","x":720,"y":220,"wires":[]},{"id":"da7146b2.8fee58","type":"function","z":"5cafdd9.eae5724","name":"Init JSON","func":"if (flow.get('blindsHASS') === undefined || msg.payload === 'reset') {\n    flow.set('blindsHASS', undefined);\n    flow.set('blindsHASS', {\n        'hass/room/cover/1': { // Volet salon 1\n            lastAction: \"STOP\",\n            lastUpdate: 0,\n            value: 0,\n            idxHW: \"cover.rfy_cover_1\",\n            entityID: \"cover.cover_room_1\",\n            upTime: 28.7,\n            downTime: 26.5,\n        }\n    });\n}\nreturn [{go: true}, flow.get('blindsHASS')];","outputs":"2","noerr":0,"x":180,"y":120,"wires":[["6d6ea286.d3addc"],["4bd99ca6.2fbfa4"]]},{"id":"f43f7d6c.193ec","type":"inject","z":"5cafdd9.eae5724","name":"Init","topic":"","payload":"","payloadType":"date","repeat":"60","crontab":"","once":true,"x":90,"y":60,"wires":[["da7146b2.8fee58"]]},{"id":"6d6ea286.d3addc","type":"function","z":"5cafdd9.eae5724","name":"Create Blinds List","func":"blinds = flow.get('blindsHASS');\nvar msgs = [];\nfor (var prop in blinds) {\n    if (!blinds.hasOwnProperty(prop)) {\n        //The current property is not a direct property of p\n        continue;\n    }\n    var toget = {\n        idx: prop,\n        entityID: blinds[prop].entityID,\n        payload: {\n            entity_id: blinds[prop].entityID\n        }\n    }\n    if (toget.entityID) {\n        msgs.push(toget);\n    }\n}\nnode.send([msgs]);","outputs":1,"noerr":0,"x":310,"y":60,"wires":[["8989d9e6.64aa08"]]},{"id":"9508e019.1ddda","type":"debug","z":"5cafdd9.eae5724","name":"","active":false,"console":"false","complete":"true","x":830,"y":60,"wires":[]},{"id":"29c33da4.204312","type":"function","z":"5cafdd9.eae5724","name":"Extract Value","func":"blinds = flow.get('blindsHASS');\nblinds[msg.idx].value = msg.data.attributes.current_position | 0;\n\nreturn blinds;","outputs":1,"noerr":0,"x":670,"y":60,"wires":[["9508e019.1ddda"]]},{"id":"30a76119.a91e2e","type":"comment","z":"5cafdd9.eae5724","name":"HASS","info":"","x":60,"y":20,"wires":[]},{"id":"4bd99ca6.2fbfa4","type":"debug","z":"5cafdd9.eae5724","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","x":330,"y":120,"wires":[]},{"id":"8989d9e6.64aa08","type":"api-current-state","z":"5cafdd9.eae5724","name":"Get State","server":"186c7670.558e3a","halt_if":"","halt_if_type":"","halt_if_compare":"is","override_topic":true,"override_payload":true,"override_data":true,"entity_id":"","state_type":"str","outputs":1,"x":500,"y":120,"wires":[["8f5ad89a.869ec8","29c33da4.204312"]]},{"id":"8f5ad89a.869ec8","type":"debug","z":"5cafdd9.eae5724","name":"","active":false,"console":"false","complete":"true","x":670,"y":120,"wires":[]},{"id":"b2bdb169.2aa9","type":"api-call-service","z":"5cafdd9.eae5724","name":"","server":"186c7670.558e3a","service_domain":"cover","service":"","data":"","mergecontext":"","x":690,"y":320,"wires":[["227f7aa0.f26ef6"]]},{"id":"c0c885fa.9f1d18","type":"api-call-service","z":"5cafdd9.eae5724","name":"","server":"186c7670.558e3a","service_domain":"cover","service":"","data":"","mergecontext":"","x":690,"y":580,"wires":[["69dbb978.2390e8"]]},{"id":"227f7aa0.f26ef6","type":"debug","z":"5cafdd9.eae5724","name":"","active":false,"console":"false","complete":"true","x":850,"y":320,"wires":[]},{"id":"9995a6f6.aabc48","type":"inject","z":"5cafdd9.eae5724","name":"Reset","topic":"","payload":"reset","payloadType":"str","repeat":"","crontab":"","once":false,"onceDelay":"","x":90,"y":180,"wires":[["da7146b2.8fee58"]]},{"id":"b2ce6188.0fb15","type":"mqtt-broker","z":"","name":"docker-mqtt","broker":"1.2.3.4","port":"1883","tls":"7d764641.872898","clientid":"","usetls":true,"compatmode":true,"keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","willTopic":"","willQos":"0","willPayload":""},{"id":"186c7670.558e3a","type":"server","z":"","name":"hass","legacy":false},{"id":"7d764641.872898","type":"tls-config","z":"","name":"","cert":"","key":"","ca":"","certname":"","keyname":"","caname":"","verifyservercert":false}]

You’ll need to edit the configuration node named hass (in one of the blue node) and the mqtt-server configuration (named docker-mqtt)

You’ll then want to modify the node called Init JSON to match your setup.

if (flow.get('blindsHASS') === undefined || msg.payload === 'reset') {
    flow.set('blindsHASS', undefined);
    flow.set('blindsHASS', { // You can have as many covers as needed here, just add it to the json map
        'hass/room/cover/1': { // The topic prefix used by your mqtt cover, see above
            lastAction: "STOP",
            lastUpdate: 0,
            value: 0,
            idxHW: "cover.rfy_cover_1",  // The device name of the actual RFY cover
            entityID: "cover.cover_room_1", // The device name of the virtual cover
            upTime: 28.7, // The time needed by the cover to go all the way up in seconds, be accurate
            downTime: 26.5, // The time needed by the cover to go all the way down in seconds, be accurate
        },
        'hass/room/cover/2': { // Exemple for a second cover
            [...]
        }
    });
}
return [{go: true}, flow.get('blindsHASS')];

Apply you flow and add your MQTT virtual cover device to your frontend, you’ll now be able to manage the cover position from within Home Assistant.
image

6 Likes

Hi

This is very interesting.

My shutters are controlled by a Tasmotised Sonoff Dual. (Sonoff Dual Window Cover with set position). Each cover is set up as a template cover like so:

- platform: template
  covers:
    back_bedroom_shutter:
      friendly_name: "Back Bedroom"
      position_template: "{{ (((states.input_number.back_bedroom_shutter_position.state | int) * 100)/16) | round(0) }}"
      set_cover_position:
        - service: input_number.set_value
          entity_id: input_number.back_bedroom_shutter_set_position
          data_template:
            value: '{{ position }}'
      icon_template: >-
        {% if (states.input_number.back_bedroom_shutter_position.state | int) > 0 %}
          mdi:window-open
        {% else %}
          mdi:window-closed
        {% endif %}
      open_cover:
        - service: mqtt.publish
          data:
            topic: 'cmnd/sonoff11/power1'
            payload: 'ON'
      close_cover:
        - service: mqtt.publish
          data:
            topic: 'cmnd/sonoff11/power2'
            payload: 'ON'
      stop_cover:
        - service: mqtt.publish
          data:
            topic: 'cmnd/sonoff11/power1'
            payload: 'OFF'
        - service: mqtt.publish
          data:
            topic: 'cmnd/sonoff11/power2'
            payload: 'OFF'

Can this cover definition be used instead of your rfxtrx definition?

Am I to understand that I can add all my shutters to this flow in the ‘init JSON’ node? I have 11 shutters with 9 automations each. Would be nice if I could replace it all with your flow.

Still trying to understand how your code works.

Any suggestions would be most welcome?

Hey,

Sure, just replace the rfxtrx cover entity ID with yours in the JSON in the attribute idxHW
But your cover already does something with the position, no? Is you system too complex to maintain?

For my flow to work, you’ll need to create 11 fake mqtt covers on top of your 11 template covers.

In the future, I guess the code in the flow could be optimized for your specific setup as the hardware cover is also controlled by MQTT. In this case you wouldn’t need the 11 template covers, only the 11 fake MQTT covers and nodered would control the SONOFF covers directly.

Let me know if I can be of any help.
Cheers

Thank you for your reply. You’ve given me food for thought.

I’ll give it a go and see what happens.

Thanks

Yes I can control the shutter position. But it requires 2 input sliders, 2 timers, a cover definition and 9 automations per cover. And if I change anything in one cover, I have to do the remaining 10 too.

Once I get all the definitions into your ‘init JSON’ it should be much simpler even if the code is a bit daunting.

May I ask what the purpose of the 2 inject nodes at the top left of the flow is?

Also, I agree that the actual cover definition is not required and it can all be done from the flow. In this case, can the idxHW attribute be omitted?

Thanks again

True, your system doesn’t sound very sustainable but it’s great that it works only thanks to Home Assistant :slight_smile:

The code is not the best I ever wrote I agree :sweat_smile: it’s a rework of an initial code I had for another home automation system… It works great now, but it’s a bit “raw”… I never took the time to make it more beautiful.

What I’d suggest in your case it try to make it work with the current flow and then change the code if you want to control directly your SONOFF from nodered. However, it will need some deep modifications.

The 2 inject nodes at the beginning are:

  • Init: it’s used to initialize the JSON and inject it into the flow environment. It also runs every 5min automatically if I remember correctly to keep all the values in sync with HA in case it gets out of sync (shouldn’t happen, it’s more a security)
  • Reset: should be used when you modify the JSON in init JSON so that it takes into account your new values.

Hope it helps.

Thanks for the info.

I’ll see how it goes.

Interesting approach, I’ve been using an implementation for non-smart blinds based on switches and timers as explained here Sonoff Dual Window Cover with set position , and I liked your node-red solution, mostly because with a single flow you control all your covers.
After quickly testing I see it works great setting a position or controlling manually, but the position only updates when receiving a new command (open/stop/close) or getting to the desired position. Is that the expected behaviour?

Did you experiment with detecting remote commands (from a RF remote) that update the current position?

In my implementation I catched RF reception events and updated an input_select variable in HA, do you think there is there an elegant way of doing this with node-red?

Regards

Did you expect it to update when for example you use a remote? I don’t quite get when the position should be updated?

I didn’t no, as I’m not using my remotes. I didn’t even know that you could catch easily the remote event. Do you have a config example for that? I so, I could have a look. I think it wouldn’t be too complicated to add this to node red.

Hi @RomRider

It would be very useful to update the position periodically (every 500ms or 1000ms) while the motor is moving (when received an open/close command or when receiving a new setpoint with “set_cover_position”

I was looking at the code but i’m afraid I still don’t understand very well how node-red functions work…

As for catching the remotes, in HA I catch the event from my RF gateway (in my case the event is pilight_received, but I guess any RF gateway will generate some event), then I filter by device id, command and so on. This can be done with the event node in node-red.
Then, when a command from a remote is detected one could expect to have the position synchronized according to the last command received (but in this case the open/close/stop commands should not be repeated)

As I said before, your node-red implementation is elegant and easier to maintain than the 10+ automations per cover that I have, thanks for sharing your work.

@RomRider just a couple quick questions about your code:
In the function “Manage Blinds” how do you manage the delay between the open/close command and the stop command? I see you use “ttw” variable (time to wait)? and then a “delay” variable. Where is this delay used later? I can’t find it.

As you can control several covers, I understand that the function is run once when a set cover position is received via mqtt.
EDIT: ok now I realize that you send the “delay” as the payload for the delay.

Thanks!

Buenas.

Pudiste crear el flow en node-red de las persianas con el sonoff dual?
Estaría muy interesado en esto, ya que no consigo pasar mis automatizaciones de las persianas a node-red.

Un Saludo.

My Sonoff Duals are now running ESPHome without MQTT. Much simpler.

I use this as a starting point:

HA discovers the covers automatically.

Please let me tell you this great Custom Component in HACS that I made work with RFXTRX integration in a breathe.

Custom Component: Cover Time Based - Share your Projects! / Custom Components - Home Assistant Community (home-assistant.io)

@RomRider I just came across your node-red flow and it works flawlessly with my RFXtrx433XL.

I can now get rid of my Connexoon RTS Hub and the fantastically awful Somfy API, which went down all the time. I need to buy you a beer!

You should publish this flow on Github and the node-red library so others can find it

Thanks