PTV Transit Departure Boards

I couldn’t find a good way to display the information provided natively by my local transit authority’s integration, and after looking at a few examples from other people, I couldn’t find any I really liked. So I’ve spent a little time today working on my own. It’s still a WIP so I’ll solicit specific advice and feedback in the replies, but wanted to dedicate the first post to sharing what I have so far.

These cards require two existing custom components:

The data from PTV is quite messy and needs to be parsed to remove a bit of junk, and formatted into a form more suitable for humans. There are three bits of code I’m working on to accomplish this:

  • A reusable Jinja template, to prevent duplicating several lines of code
  • Template sensors, to create a sensor for each stop
  • Button card code, to present it all nicely

Jinja Template

Saved in config/custom_templates/process_ptv.jinja

{%-macro process_ptv(attribute, stop_string) -%}
    {%-set stop_string = "sensor." + stop_string + "_" -%}

    {%- set ns = namespace (ids = [], times = [], next_service_time = "", next_service_id = "", following_service_time = "", following_service_id = "", skip = false) -%}

    {%- for index in range(0,5) -%}
    {%- if state_attr(stop_string + index | string, 'run_id' ) != -1 -%}
        {%- set ns.ids = ns.ids + [state_attr( stop_string + index | string(), 'run_id' )] -%}
    {%- else -%}
        {%- set ns.ids = ns.ids + [state_attr( stop_string + index | string(), 'run_ref' )] -%}
    {%- endif -%}
    {%- endfor -%}

    {%- for index in range(0,5) -%}
    {%- if state_attr(stop_string + index | string, 'estimated_departure_utc' ) != none -%}
        {%- set ns.times = ns.times + [strptime(state_attr(stop_string + index | string, 'estimated_departure_utc' ) + "+0000", "%Y-%m-%dT%H:%M:%SZ%z" )] -%}
    {%- else -%}
        {%- set ns.times = ns.times + [strptime(state_attr(stop_string + index | string, 'scheduled_departure_utc' ) + "+0000", "%Y-%m-%dT%H:%M:%SZ%z" )] -%}
    {%- endif -%}
    {%- endfor -%}

    {%- set ns.skip = false -%}
    {%- for index in range(0,5) -%}
    {%- if ns.skip == false -%}
        {%- if ns.times[index] > utcnow() -%}
        {%- set ns.skip = true -%}
        {%- endif -%}

        {%- set ns.next_service_time = ns.times[index] -%}
        {%- set ns.next_service_id = ns.ids[index] -%}
    {%- endif -%}
    {%- endfor -%}

    {%- set ns.skip = false -%}
    {%- for index in range(1,5) -%}
    {%- if ns.skip == false -%}
        {%- if ns.times[index] > ns.next_service_time and ns.ids[index] != ns.next_service_id -%}
        {%- set ns.skip = true -%}
        {%- endif -%}

        {%- set ns.following_service_time = ns.times[index] -%}
        {%- set ns.following_service_id = ns.ids[index] -%}
    {%- endif -%}
    {%- endfor -%}

    {%- if attribute == "next_service_time" -%}
        {{ ns.next_service_time }}
    {%- elif attribute == "next_service_id" -%}
        {{ ns.next_service_id }}
    {%- elif attribute == "following_service_time" -%}
        {{ ns.following_service_time }}
    {%- elif attribute == "following_service_id" -%}
        {{ ns.following_service_id }}
    {%- else -%}
        Unknown Attribute
    {%-endif -%}
{%-endmacro -%}

Template Sensor

This sensor must be duplicated for each stop you want to display.

For PTV users, replace stop_string with the sensor name for the stop added by the custom integration. Remove the sensor. from the beginning, and the _0/_1/etc from the end. The template will iterate over the five individual sensors the integration creates for each stop. The integration will often continue to display services service has departed, and duplicates of a single service. The template will eliminate both of these to determine the next two true services.

For non-PTV users, you could still use the button card by creating your own sensor that has datetime strings for the attributes shown here.

template:
    sensor:
      - name: "My Service Name"
        state: >
          {% from 'process_ptv.jinja' import process_ptv %}
          {{ process_ptv('next_service_time','stop_string')}}
        attributes:
          next_service_time: >
            {% from 'process_ptv.jinja' import process_ptv %}
            {{ process_ptv('next_service_time','stop_stringion')}}
          next_service_id: >
            {% from 'process_ptv.jinja' import process_ptv %}
            {{ process_ptv('next_service_id','stop_string')}}
          following_service_time: >
            {% from 'process_ptv.jinja' import process_ptv %}
            {{ process_ptv('following_service_time','stop_string')}}
          following_service_id: >
            {% from 'process_ptv.jinja' import process_ptv %}
            {{ process_ptv('following_service_id','stop_string')}}

Button Card

Simply replace the two sensor IDs to show the correct information for each stop. Optionally, the icon, colour and text size can be adjusted too.

type: custom:button-card
icon: mdi:train
name: Route 1
styles:
  card:
    - background-color: '#028430'
    - padding: 2% 3%
    - color: ivory
    - font-size: 12px
    - font-weight: bold
  grid:
    - grid-template-areas: '"i n next_title following_title" "i n next_value following_value"'
    - grid-template-columns: min-content 1fr min-content min-content
    - grid-template-rows: min-content min-content
  name:
    - font-weight: bold
    - font-size: 30px
    - color: white
    - align-self: middle
    - justify-self: start
    - padding: 0px
  icon:
    - color: white
    - width: 50px
    - padding: 0px 12px 0px 0px
  custom_fields:
    next_title:
      - text-align: left
      - justify-self: start
    next_value:
      - padding-bottom: 0px
      - padding-right: 10px
      - text-align: left
      - justify-self: null
      - padding-top: |
          [[[
            const service_time = new Date(states['sensor.test_transit_1'].attributes.next_service_time);
            const now = new Date();
            const difference = service_time - now;

            const millisecondsInMinute = 1000 * 60;
            const minutes = Math.ceil(difference / millisecondsInMinute);

            if (minutes <=60) {
              return '<0px';
            } else {
              return '5px';
            }
          ]]]
    following_title:
      - text-align: left
    following_value:
      - text-align: left
      - padding-top: |
          [[[
            const service_time = new Date(states['sensor.test_transit_1'].attributes.following_service_time);
            const now = new Date();
            const difference = service_time - now;

            const millisecondsInMinute = 1000 * 60;
            const minutes = Math.ceil(difference / millisecondsInMinute);

            if (minutes <=60) {
              return '<0px';
            } else {
              return '5px';
            }
          ]]]
custom_fields:
  next_title: 'Next:'
  next_value: |
    [[[
      const service_time = new Date(states['sensor.test_transit_1'].attributes.next_service_time);
      const now = new Date();
      const difference = service_time - now;

      const millisecondsInMinute = 1000 * 60;
      const minutes = Math.ceil(difference / millisecondsInMinute);
      

      if (minutes <=60) {
        return '<span style="font-size:40px;">' + minutes + '</span><span style="font-size:14px;"> min</span>';
      } else {
        return '<span style="font-size:35px; padding-top: 5px">' + helpers.formatTime24h(service_time) + '</span>';
      }
    ]]]
  following_title: 'Following:'
  following_value: |
    [[[
      const service_time = new Date(states['sensor.test_transit_1'].attributes.following_service_time);
      const now = new Date();
      const difference = service_time - now;

      const millisecondsInMinute = 1000 * 60;
      const minutes = Math.ceil(difference / millisecondsInMinute);

      if (minutes <=60) {
        return '<span style="font-size:40px;">' + minutes + '</span><span style="font-size:14px;"> min</span>';
      } else {
        return '<span style="font-size:35px; padding:115px 0px 0px 0px;">' + helpers.formatTime24h(service_time) + '</span>';
      }
    ]]]

There’s a lot of refinement left to do in the code, especially some redundant styling that I haven’t had a chance to go over. Before I start that, I want to figure out the overall formatting as I’m not completely happy with it yet.

Essentially, I want the service times to show minutes when it’s less than an hour away, or the arrival time if it’s more than an hour. It’s been tough getting this to look nice - a big font for the number of minutes looks best, but displaying the time in the same size doesn’t look good and takes up too much space. On the other hand, having the time a bit smaller looks a little incongruent.

There’s also the issue of the width of the next/following columns. It looks a little messy when they don’t line up across multiple services, but having them a fixed width takes up a lot of the space for the service name. Shrinking either the name or the minutes/times has been making things look less nice but I think this is where the most tweaking eeds to be done. Very keen for feedback from someone with an eye for UI design.