EPG (Electronic Program Guide) ... who wants it in HA?

So, working on the
Authentic Roku Remote and YouTube TV - Share your Projects! / Dashboards & Frontend - Home Assistant Community (home-assistant.io)

I decided it would be cool to be able to show the current and next show on the channel buttons.

After digging into this subject for several weeks, I ended up with a full blown EPG Dashboard.

I’ll do my best do describe the steps needed to get it up and running.

First, there needs to be an understanding of the XMLTV standard for consuming and using this information. There is a LOT of information on this. Just do a search.

Second is figuring out the best way to consume this info for your situation.

I decided on WebGrab++. They have software for Windows and Linux including OS-X, RPi, and Synology NAS.

I followed this guide to get it running in Docker on my Synology NAS.

I am not going to spend much time on this as the above guide is very good and WebGrab++ has a website with documentation and a forum to get help.

A couple of things:
First, at the bottom of the guide when setting up the cron, the command has changed. The guide says

0 */12 * * * /bin/bash /defaults/update.sh

But, it is actually

0 */12 * * * /bin/bash /app/update.sh

Second, you will need to decide which services you want/need to use to get your XMLTV data. I am using tvguide.com and tvpassport.com configs. They can both be found in the Internation directory and both have _info.txt files that explain how to get the list of channels for your location.

Third, once you are ready and know the lines you need to use to get your channels, shorten your channel name between the channel tags. I use those for the name of the images that I use. It defaults to the xmltv_id name. Example:

  <channel update="i" site="tvguide.com" site_id="9100004081##9200012085" xmltv_id="CMT (East) (CMTV) [40]">CMT</channel>

And, finally, I made the data directory where guide.xml will be created a share available to HA so I can use a script to copy that to the www folder so I can get it with a rest platform.

Okay. So, you now have your XMLTV data.
I created copy_epg.sh in my config folder. It copies my guide.xml to my www folder in HA:

cp /share/epg/guide.xml /config/www/guide.xml

I set my cron up before to run at midnight. So, I created an automation to copy the guide.xml at 12:15am.

alias: Copy EPG
description: ""
trigger:
  - platform: time
    at: "00:15:00"
condition: []
action:
  - service: shell_command.copy_epg
    data: {}
  - service: homeassistant.update_entity
    target:
      entity_id: sensor.epg_raw
    data: {}
mode: single

This also updates my rest sensor coming next.

Now, for the rest sensor in sensor.yaml. Change the resource to where your guide.xml can be found.

##
## EPG Raw
##
- platform: rest
  scan_interval: 99000
  name: EPG Raw
  unique_id: sensor.epg_raw
  resource: https://homeassistant.jeffcrum.com/local/guide.xml
  method: GET
  verify_ssl: false
  value_template: "{{ now() }}"
  json_attributes_path: "$.tv"
  json_attributes:
      - channel
      - programme

This creates sensor.epg_raw with channel and programme attributes. The scan_interval is long because in the automation above, I update it after the copy.

Make sure to add the following to configuration.yaml so you don’t get messages about these sensors having too much info and causing performance issues with recorder.

recorder:
  exclude:
    entity_globs:
      - sensor.epg_*

In template.yaml, build your sensor.epg_info

###
### EPG Info
###
- sensor:
  - name: EPG Info
    unique_id: sensor.epg_info
    state: "{{ now() }}"
    attributes:
        programs: >
            {%- set ns = namespace(channel = [], programs = [], hold_channel='') %}
            {%- for program in state_attr('sensor.epg_raw', 'programme') %}
              {%- if loop.first -%}
                {%- set ns.hold_channel = program['@channel'] -%}
              {%- endif -%}
              {%- if loop.last or program['@channel'] != ns.hold_channel %}
                {%- set ns.channel = ns.channel + [{'channel_name': state_attr('sensor.epg_raw', 'channel') | selectattr('@id', 'eq', ns.hold_channel) | map(attribute='display-name') | map(attribute='#text') | list | first, "programs": ns.programs}] -%}
                {%- set ns.hold_channel = program['@channel'] -%}
                {%- set ns.programs = [] -%}
              {%- endif -%}
              {%- set t = program['@start'] -%}
              {%- set start = as_timestamp(t[0:4]~'-'~t[4:6]~'-'~t[6:8]~' '~t[8:10]~':'~t[10:12]~':'~t[12:14]~'.000000'~t[15:18]~':00') -%}
              {%- set t = program['@stop'] -%}
              {%- set stop = as_timestamp(t[0:4]~'-'~t[4:6]~'-'~t[6:8]~' '~t[8:10]~':'~t[10:12]~':'~t[12:14]~'.000000'~t[15:18]~':00') -%}
              {%- set ns.programs = ns.programs + [{'title': program.title['#text'],
                                                    'sub_title': program['sub-title']['#text'] if program['sub-title'] is defined else '',
                                                    'desc': program['desc']['#text'] if program['desc'] is defined else '',
                                                    'start': start | int,
                                                    'stop': stop | int,
                                                    'length': ((stop - start) / 300) | round }] %}
            {%- endfor -%}
            {{ ns.channel }}

This sensor has an attribute of programs containing the channel name along with a list of programs for that channel including title, subtitle (if exists), desc (if exists), start timestamp, stop timestamp, and length (number of five minute increments).

Now, for the EPG. Yes, finally :slight_smile:
I started with an HTML Table in a markdown card

views:
  - title: Markdown
    type: panel
    path: markdown
    badges: []
    cards:
      - type: markdown
        content: >
            {%- set ns = namespace(debug = false, number_of_hours = 12, rows_between_head = 5, epg_times = [], cols_used = 1, th="", lines=0) -%}
              <table border=1>
            {%- set ns.th %}
                <tr>
                  <th colspan=1>Channel</th>
            {%- endset -%}
            {%- for count in range(0, ns.number_of_hours * 2) -%}
                {%- set ns.epg_times = ns.epg_times + [(as_timestamp(now().replace(second=0,microsecond=0)) - (as_timestamp(now().replace(second=0,microsecond=0)) % 1800) + ((1800 * count))) | int] -%}
              {%- set ns.th -%}
              {{ ns.th }}
                  <th colspan=6>{{ ns.epg_times | last | timestamp_custom('%-I:%M%p') }}</th>
              {%- endset -%}
            {%- endfor %}
            {%- set ns.th -%}
            {{ ns.th }}
                </tr>
            {%- endset -%}
            {{ ns.th if ns.lines == 0 }}
            {%- for channel in state_attr('sensor.epg_info', 'programs') | sort(attribute='channel_name') -%}
              {%- set ns.cols_used = 0 %}
                <tr>
                  <td colspan=1>
                    <img src=/local/images/channels/{{ channel.channel_name | lower() | replace(' ', '')  }}.webp>
                  </td>
              {%- for program in channel.programs | selectattr('stop', 'gt', ns.epg_times | first) | rejectattr('start', 'gt', ns.epg_times | last) -%}
                {%- set col_span = program.length | int -%}
                {%- if loop.first -%}
                  {%- if program.start < ns.epg_times | first -%}
                    {%- set col_span = (program.length | int - (ns.epg_times | first - program.start) / 300) | int -%}
                  {%- endif %}
                  {%- if program.start > ns.epg_times | first -%}
                    {%- set col_span = ((program.start - ns.epg_times | first) / 300) | int %}
                    <td colspan={{ col_span }}>&nbsp;</td>
                    {%- set ns.cols_used = col_span -%}
                    {%- set col_span = program.length | int -%}
                  {%- endif %}
                {%- endif %}
                  <td colspan={{ col_span }}
                {%- if program.desc != '' -%}
                {{ ' ' }}title='{{ program.desc | replace("'", "&apos;") }}'
                {%- endif -%}
                    >
                {%- if ns.debug %}
                    {{ program.start | timestamp_custom('%m/%d/%y %-I:%M%p') }}<br>
                    {{ program.stop | timestamp_custom('%m/%d/%y %-I:%M%p') }}<br>
                    Length: {{ program.length }}<br>
                {%- endif -%}
                {%- if program.start == ns.epg_times[(ns.cols_used / 6) | int] -%}
                  {%- set odd_start = '' -%}
                {%- else -%}
                  {%- set odd_start = '('~program.start | timestamp_custom('%-I:%M%p')~') ' -%}
                {%- endif %}
                    {{ odd_start }}{{ program.title }}
                {%- if program.sub_title != '' -%}
                    <br>{{ program.sub_title }}
                {%- endif -%}
                  </td>
                {%- set ns.cols_used = ns.cols_used + col_span -%}
              {%- endfor %}
                </tr>
              {%- set ns.lines = ns.lines + 1 -%}
              {%- if ns.lines == ns.rows_between_head -%}
                {%- set ns.lines = 0 -%}
              {%- endif -%}
            {%- endfor -%}
              </table>
title: Test

There are three options set in the namespace (first set statement).

  • debug - when true it shows formatted start and end times along with program length
  • number_of_hours - sets the width of the EPG
  • rows_between_head - the number of channel rows before printing the heading again

The EPG is built in 30 minute increments starting with the previous even hour or half hour that has just passed. Each half hour heading is a colspan of six (five minute increments). The heading line is repeated after each five lines of channels.

There is a lot in here. I think I have it pretty well built at this point.

But, a bit bland. So, I put it into a custom html template card, added a background change every other channel, and left and right scrolling leaving the channel on the screen.

type: custom:html-template-card
ignore_line_breaks: true
content: >
  {%- set ns = namespace(debug = false, number_of_hours = 12, rows_between_head = 5, epg_times = [], cols_used = 1, th="", lines=0) -%}
    <style>
      .tscroll {
        overflow-x: scroll;
        margin-bottom: 10px;
        border: solid black 1px;
      }

      .tscroll table td:first-child {
        position: sticky;
        left: 0;
        background-color: #ddd;
      }

      .tscroll table th:first-child {
        position: sticky;
        left: 0;
        background-color: #ddd;
      }

      .tscroll table th {
        background-color: #ddd;
      }

      .tscroll td, .tscroll th {
        border-bottom: solid black 1px;
        border-right: solid black 1px;
      }
      
      tr:nth-child(even) {
        background-color: #A8DAFC;
      }
    </style>

    <div class="tscroll">
      <table>
  {%- set ns.th %}
        <tr>
          <th colspan=1>Channel</th>
  {%- endset -%}
  {%- for count in range(0, ns.number_of_hours * 2) -%}
    {%- if now().minute < 30 -%}
      {%- set ns.epg_times = ns.epg_times + [(as_timestamp(now().replace(minute=0, second=0) + timedelta(minutes=30 * count))) | int] -%}
    {%- else -%}
      {%- set ns.epg_times = ns.epg_times + [(as_timestamp(now().replace(minute=30, second=0) + timedelta(minutes=30 * count))) | int] -%}
    {%- endif %}
    {%- set ns.th -%}
      {{ ns.th }}
          <th colspan=6>{{ ns.epg_times | last | timestamp_custom('%-I:%M%p') }}</th>
    {%- endset -%}
  {%- endfor %}
  {%- set ns.th -%}
    {{ ns.th }}
        </tr>
  {%- endset -%}
  {%- for channel in states.sensor.epg_info.attributes.programs | sort(attribute='channel_name') -%}
  {{ ns.th if ns.lines == 0 }}
    {%- set ns.cols_used = 0 %}
        <tr>
          <td colspan=1>
            <img src=/local/images/channels/{{ channel.channel_name | lower() | replace(' ', '')  }}.webp>
          </td>
    {%- for program in channel.programs | selectattr('stop', 'gt', ns.epg_times | first) | rejectattr('start', 'gt', ns.epg_times | last) -%}
      {%- set col_span = program.length | int -%}
      {%- if loop.first -%}
        {%- if program.start < ns.epg_times | first -%}
          {%- set col_span = (program.length | int - (ns.epg_times | first - program.start) / 300) | int -%}
        {%- endif %}
        {%- if program.start > ns.epg_times | first -%}
          {%- set col_span = ((program.start - ns.epg_times | first) / 300) | int %}
          <td colspan={{ col_span }}>&nbsp;</td>
          {%- set ns.cols_used = col_span -%}
          {%- set col_span = program.length | int -%}
        {%- endif %}
      {%- endif %}
          <td colspan={{ col_span }} title='{{ program.start | timestamp_custom('%-I:%M%p') }}-{{ program.stop | timestamp_custom('%-I:%M%p') }}&#10;{{ ('' if program.desc == '' else program.desc | replace("'", "&apos;")) }}'>
      {%- if ns.debug %}
            {{ program.start | timestamp_custom('%m/%d/%y %-I:%M%p') }}<br>
            {{ program.stop | timestamp_custom('%m/%d/%y %-I:%M%p') }}<br>
            Length: {{ program.length }}<br>
      {%- endif -%}
      {%- if program.start == ns.epg_times[(ns.cols_used / 6) | int] -%}
        {%- set odd_start = '' -%}
      {%- else -%}
        {%- set odd_start = '('~program.start | timestamp_custom('%-I:%M%p')~') ' -%}
      {%- endif %}
            {{ odd_start }}{{ program.title }}
      {%- if program.sub_title != '' -%}
            <br>{{ program.sub_title }}
      {%- endif %}
          </td>
      {%- set ns.cols_used = ns.cols_used + col_span -%}
    {%- endfor %}
        </tr>
    {%- set ns.lines = ns.lines + 1 -%}
    {%- if ns.lines == ns.rows_between_head -%}
      {%- set ns.lines = 0 -%}
    {%- endif -%}
  {%- endfor -%}
      </table>
    </div>

Okay. Lets search your EPG. I built an input_text helper named input_text.epg_search_box.

Another sensor in template.yaml

###
### EPG Search
###
- sensor:
  - name: EPG Search
    unique_id: sensor.epg_search
    state: >
        {%- if states.input_text.epg_search_box.state == '' -%}
            false
        {%- else -%}
            true
        {%- endif -%}
    attributes:
        programs: >
            {%- set ns = namespace(programs = []) %}
            {%- if states.input_text.epg_search_box.state != '' -%}
                {%- for program in state_attr('sensor.epg_raw', 'programme') %}
                  {%- if ' '~states.input_text.epg_search_box.state~' ' | lower in program | lower | string | replace("'", " ") | replace(":", " ") | replace(";", " ") -%}
                    {%- set t = program['@start'] -%}
                    {%- set start = as_timestamp(t[0:4]~'-'~t[4:6]~'-'~t[6:8]~' '~t[8:10]~':'~t[10:12]~':'~t[12:14]~'.000000'~t[15:18]~':00') -%}
                    {%- set t = program['@stop'] -%}
                    {%- set stop = as_timestamp(t[0:4]~'-'~t[4:6]~'-'~t[6:8]~' '~t[8:10]~':'~t[10:12]~':'~t[12:14]~'.000000'~t[15:18]~':00') -%}
                    {%- set ns.programs = ns.programs + [{'channel_name': state_attr('sensor.epg_raw', 'channel') | selectattr('@id', 'eq', program['@channel']) | map(attribute='display-name') | map(attribute='#text') | list | first,
                                                          'title': program.title['#text'],
                                                          'sub_title': program['sub-title']['#text'] if program['sub-title'] is defined else '',
                                                          'desc': program['desc']['#text'] if program['desc'] is defined else '',
                                                          'start': start | int,
                                                          'stop': stop | int,
                                                          'length': ((stop - start) / 300) | round }] %}
                  {%- endif -%}
                {%- endfor -%}
            {%- endif -%}
            {{ ns.programs | sort(attribute='start,stop') }}

And, the following dashboard

  - title: EPG Search
    path: epg-search
    icon: ''
    badges: []
    cards:
      - type: entities
        entities:
          - entity: input_text.epg_search_box
      - type: markdown
        content: >
          {%- set ns = namespace(prevtime='') -%}

          {%- for program in states.sensor.epg_search.attributes.programs |
          selectattr('stop', 'gt',
          (as_timestamp(now().replace(second=0,microsecond=0)) -
          (as_timestamp(now().replace(second=0,microsecond=0))) % 1800) + ((1800
          * 0) | float)) %}

          {%- if ns.prevtime != program.start~program.stop -%}
            {%- set ns.prevtime = program.start~program.stop -%}
          # {{ program.start | timestamp_custom('%a %m/%d %-I:%M%p') }}-{{
          program.stop | timestamp_custom('%-I:%M%p') }}
            {%- endif -%}
          <br><img src=/local/images/channels/{{ program.channel_name | lower()
          | replace(' ', '')  }}.webp>

          ## {{ program.title }}

          {{ '' if program.sub_title == '' else program.sub_title }}

          {{ '' if program.desc == '' else program.desc }}

          {% endfor -%}

I think I got everything. But, certainly could have missed something. Let me know if there are any questions or if you see a better way to do some of this.

Of course, you could add links to your players to the shows. But, I did not do that due to the remote we are using.

4 Likes

This is so awesome!!! Thank you for contributing this - was searching way to long for a way!!!

Tank you, for this :heart:

Thanks @Flipso. Have you done it yet? If so, how is it working.

I am sure I missed something and would love input from others.

Havnt had time to allocate to, so will try to dig into this on the weekend - and i am a newbie with docker - so this will need some basic reading :wink: but will keep you updated!

No problem. Just curious.

Again, I read for several weeks as I built it and was wondering. Not pushing!