HDMI CEC for Home Assistant with Node-RED

I set out on a Sunday morning thinking this was going to be a quick project and, not having decided on a blog topic for this week, it seemed like the ideal candidate. I was wrong – about it being a quick project, hopefully not about it being a reasonable subject for a blog post.

This post is brought to you by issue #12846 in Home Assistant (and the letter ‘C’). That is to say, one of my automations was broken by this issue, which has been sitting open on GitHub since the beginning of march with no progress. I don’t want this to sound like the usual “user of Open Source application complains about free stuff”, because I’m not actually complaining. I understand that software breaks and sometimes there aren’t the resources available to fix it. The solution to this is to get more developers paid to work on Free and Open Source Software (but that’s entirely a discussion for outside of this post).

Actually, this post is here to offer a solution (or at least a temporary one) to the issue, outside of Home Assistant, since I couldn’t fix it myself (I took a look at the code in question and I couldn’t work it out – it needs to be done by someone with more familiarity with the Home Assistant core).

My solution is to use Node-RED along with the HDMI CEC nodes to create an auto-discovered MQTT switch with which I can turn on and off my TV. So, let’s get into the flow…

The Flow

HDMI CEC Flow

The HDMI CEC Switch Flow

This flow runs on an instance of Node-RED running on my OSMC based Raspberry Pi sitting behind my TV (for those keeping up at home, this makes two NR instances on my network – so far). Currently, this is the only flow running on this instance, but I’m considering what else I can run now that I have Node-RED available there. I installed Node-RED on OSMC using the official install/upgrade script. I had fully expected installing Node-RED under OSMC to be a major pain, but it turned out to just amount to running that command.

After the install had finished, I created a user for Node-RED since I like it to run under it’s own user and updated the systemd unit file accordingly. I then installed the CEC nodes linked above from the palette manager. Here I ran into a minor bump in the road in that the CEC nodes couldn’t execute the cec-client program. As it turned out the location of that binary is in a weird location on OSMC, so I added the following in the systemd file to set this up:

Environment="PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/sbin:/usr/sbin:/usr/osmc/bin:/opt/vc/bin"

I also needed to add my new Node-RED user to the video group to allow access to the CEC device:

sudo gpasswd -a node-red video

Where I really got stuck was playing around with the example flow for the CEC nodes. It wasn’t that it didn’t work as advertised, it was that it broke the CEC command passthrough to Kodi running on the same machine, rendering my TV remote useless within Kodi. Many hours, much futile searching and playing with cec-client later, I still wasn’t any closer to a solution. I knew it must work, because somehow the pycec script I was using previously is able to send an receive CEC packets without interfering with Kodi.

The breakthrough was dropping both a CEC-In and a CEC-Out node into my flow and only grabbing a few CEC packet types in the filter of the input node. I say ‘breakthrough’ – this works most of the time, but it throws a few errors and warnings on start up. I found it to be most reliable when I immediately restarted Kodi after deploying it – this also helps Kodi to regain its CEC connection if necessary.

So How Does It Work?

Oh, yeah. I was going to talk about the flow, but I kinda got sidetracked there.

Well, it’s pretty simple there are two sequences in the flow. One which handles the switch state and MQTT discovery configuration (bottom) and one which handles the incoming commands over MQTT and sending the corresponding CEC commands.

Let’s start with the bottom one:

This sequence has two input paths, the bottom of these executes on start up (or at deploy time) and sends the Home Assistant MQTT Discovery configuration, using the same technique I used in my volcano sensors. The start up message also passes through a 3 second delay before passing to an exec node, which restarts Kodi. I added the following to my sudoers file (via visudo), to allow this:

# Allow node-red to restart kodi
node-red ALL=(ALL) NOPASSWD: /bin/systemctl restart mediacenter

The top input path receives incoming CEC messages of the type REPORT_POWER_STATUS. In my setup, this only receives power messages from the TV, but you may receive messages from other devices on the bus, in this case you can add a check on the source address of the packet in the following function node (clue: the TV is usually address 0).

The message passes through a function node, which converts the power status to the switch status expected by HASS and also sets the MQTT topic:

msg.topic = "homeassistant/switch/tv_living_room/state";
if(msg.payload.data.str == "ON")
{
    msg.payload = "ON";
}
else if(msg.payload.data.str == "STANDBY")
{
    msg.payload = "OFF";
}
return msg;

Both input paths are connected to a common MQTT output node to send their respective messages (config and state) out to Home Assistant.

The top sequence simply subscribes to the command topic from HASS and determines whether the command was on or off. The JSON payload for the CEC command is then set respectively in either branch – this JSON is taken directly from the example flow linked above. Then we pass this out to the CEC adapter – done. When the device acts upon the CEC command it should send its new power state back through and update the state of our switch. The state will also be updated if you turn on the TV by other means, e.g. the remote.

Pure JSON

This JSON was made in clean, green New Zealand from 100% natural ingredients (electrons):

[{"id":"8c6731b6.47fe6","type":"cec-out","z":"faa74966.a2471","cec_adapter":"7b6bdd74.05a08c","name":"Send CEC Command","x":620,"y":80,"wires":[]},{"id":"6851e3fd.f0320c","type":"cec-in","z":"faa74966.a2471","cec_adapter":"7b6bdd74.05a08c","name":"TV Power Status","flow_in":true,"flow_out":false,"select_all":"false","active_source":false,"image_view_on":false,"text_view_on":false,"inactive_source":false,"request_active_source":false,"routing_change":false,"routing_information":false,"set_stream_path":false,"standby":false,"record_off":false,"record_on":false,"record_status":false,"record_tv_screen":false,"clear_analogue_timer":false,"clear_digital_timer":false,"clear_external_timer":false,"set_analogue_timer":false,"set_digital_timer":false,"set_external_timer":false,"set_timer_program_title":false,"timer_cleared_status":false,"timer_status":false,"cec_version":false,"get_cec_version":false,"give_physical_address":false,"get_menu_language":false,"report_physical_address":false,"set_menu_language":false,"deck_control":false,"deck_status":false,"give_deck_status":false,"play":false,"give_tuner_device_status":false,"select_analogue_service":false,"select_digital_service":false,"tuner_device_status":false,"tuner_step_decrement":false,"tuner_step_increment":false,"device_vendor_id":false,"give_device_vendor_id":false,"vendor_command":false,"vendor_command_with_id":false,"vendor_remote_button_down":false,"vendor_remote_button_up":false,"set_osd_string":false,"give_osd_name":false,"set_osd_name":false,"menu_request":false,"menu_status":false,"user_control_pressed":false,"user_control_release":false,"give_device_power_status":false,"report_power_status":true,"feature_abort":false,"abort":false,"give_audio_status":false,"give_system_audio_mode_status":false,"report_audio_status":false,"set_system_audio_mode":false,"system_audio_mode_request":false,"system_audio_mode_status":false,"set_audio_rate":false,"start_arc":false,"report_arc_started":false,"report_arc_ended":false,"request_arc_start":false,"request_arc_end":false,"end_arc":false,"cdc":false,"none":false,"x":120,"y":140,"wires":[["cb58724f.7560e"]]},{"id":"cb58724f.7560e","type":"function","z":"faa74966.a2471","name":"Extract Status","func":"msg.topic = \"homeassistant/switch/tv_living_room/state\";\nif(msg.payload.data.str == \"ON\")\n{\n    msg.payload = \"ON\";\n}\nelse if(msg.payload.data.str == \"STANDBY\")\n{\n    msg.payload = \"OFF\";\n}\nreturn msg;","outputs":1,"noerr":0,"x":300,"y":140,"wires":[["fb6e90b5.114e2"]]},{"id":"fb6e90b5.114e2","type":"mqtt out","z":"faa74966.a2471","name":"Send Messages","topic":"","qos":"2","retain":"true","broker":"e320da15.60a5c8","x":520,"y":180,"wires":[]},{"id":"1e1472f2.aa2665","type":"function","z":"faa74966.a2471","name":"Format config messages","func":"var config = {\n    payload: {\n        name: \"Living Room TV\",\n        command_topic: \"homeassistant/switch/tv_living_room/cmd\",\n    },\n    topic: \"homeassistant/switch/tv_living_room/config\"\n};\nreturn config;","outputs":1,"noerr":0,"x":310,"y":200,"wires":[["fb6e90b5.114e2"]]},{"id":"3fba0578.68931a","type":"inject","z":"faa74966.a2471","name":"@Startup","topic":"","payload":"","payloadType":"date","repeat":"","crontab":"","once":true,"onceDelay":0.1,"x":100,"y":200,"wires":[["1e1472f2.aa2665","e1ab4a22.f0286"]]},{"id":"bde8cf94.665618","type":"mqtt in","z":"faa74966.a2471","name":"TV Command","topic":"homeassistant/switch/tv_living_room/cmd","qos":"2","broker":"e320da15.60a5c8","x":110,"y":80,"wires":[["60969010.3d95e"]]},{"id":"60969010.3d95e","type":"switch","z":"faa74966.a2471","name":"On or Off?","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"ON","vt":"str"},{"t":"eq","v":"OFF","vt":"str"}],"checkall":"false","repair":false,"outputs":2,"x":270,"y":80,"wires":[["437a5107.5312"],["533b352b.b8d2fc"]]},{"id":"437a5107.5312","type":"change","z":"faa74966.a2471","name":"Turn TV On","rules":[{"t":"set","p":"payload","pt":"msg","to":"{\"source\":null,\"target\":0,\"opcode\":\"IMAGE_VIEW_ON\"}","tot":"json"}],"action":"","property":"","from":"","to":"","reg":false,"x":430,"y":60,"wires":[["8c6731b6.47fe6"]]},{"id":"533b352b.b8d2fc","type":"change","z":"faa74966.a2471","name":"Turn TV Off","rules":[{"t":"set","p":"payload","pt":"msg","to":"{\"source\":null,\"target\":\"0.0.0.0\",\"opcode\":\"STANDBY\"}","tot":"json"}],"action":"","property":"","from":"","to":"","reg":false,"x":430,"y":100,"wires":[["8c6731b6.47fe6"]]},{"id":"e1ab4a22.f0286","type":"delay","z":"faa74966.a2471","name":"","pauseType":"delay","timeout":"3","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"x":260,"y":260,"wires":[["3fb711f5.af2e76"]]},{"id":"3fb711f5.af2e76","type":"exec","z":"faa74966.a2471","command":"/usr/bin/sudo /bin/systemctl restart mediacenter","addpay":false,"append":"","useSpawn":"false","timer":"","oldrc":false,"name":"Restart Kodi","x":430,"y":260,"wires":[[],[],[]]},{"id":"7b6bdd74.05a08c","type":"cec-config","z":"","OSDname":"Kodi","comport":"RPI","hdmiport":"1","player":false,"recorder":true,"tuner":false,"audio":false},{"id":"e320da15.60a5c8","type":"mqtt-broker","z":"","name":"Home Broker","broker":"mqtt.webworxshop.com","port":"8883","tls":"3f2e52ab.1b319e","clientid":"","usetls":true,"compatmode":true,"keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","closeTopic":"","closePayload":"","willTopic":"","willQos":"0","willPayload":""},{"id":"3f2e52ab.1b319e","type":"tls-config","z":"","name":"Home Broker","cert":"","key":"","ca":"","certname":"","keyname":"","caname":"","verifyservercert":true}]

Bonus: Home Assistant Automation Rules

Here are the Home Assistant automations that I’m using with this. Basically I’m turning off the TV five minutes after either Kodi or the Chromecast stops playing, unless it started again in the meantime:

- alias: 'Start shut off timer when TV becomes idle'
  trigger:
    - platform: state
      entity_id: media_player.tv
      to: idle
    - platform: state
      entity_id: media_player.living_room
      to: idle
    - platform: state
      entity_id: media_player.living_room
      to: 'off'
  action:
    service: timer.start
    entity_id: timer.tv_off

- alias: 'Cancel shut off timer if TV starts playing'
  trigger:
    - platform: state
      entity_id: media_player.tv
      to: playing
    - platform: state
      entity_id: media_player.living_room
      to: playing
  action:
    service: timer.cancel
    entity_id: timer.tv_off

- alias: 'When timer expires, turn off TV'
  trigger:
    - platform: event
      event_type: timer.finished
      event_data:
        entity_id: timer.tv_off
  action:
    service: switch.turn_off
    data:
      entity_id: switch.living_room_tv

This uses a timer, which is defined as:

tv_off:
  duration: "00:05:00"

Done. Now we can be lazy/forgetful about leaving TV on and also not waste power. Mission Accomplished.

Conclusion

Hopefully, someone will find time to fix the bug above. I’m probably going to stick with this regardless because I had some other issues running pyCEC on top of OSMC – mainly because they don’t build the libcec bindings for Python 3 by default. I had some custom patches to do this, but it would break (in one way or another on every update). Hopefully, this solution should be more robust. Also, the MQTT connection used in this solution runs over TLS (rather than the unencrypted TCP of the pyCEC network mode), so there is a little security win. Plus, as I already mentioned, now I have a Node-RED instance on a Pi in my living room.

One thought on “HDMI CEC for Home Assistant with Node-RED

Leave a Reply

Your email address will not be published. Required fields are marked *