Joel Vardy

My Home Assistant Setup

I'd always had aspirations for a smart home, ever since renting a flat I had a few Philips Hue bulbs. But it was when moving to our home with my wife in Edinburgh that I put some effort into it. Initially it was only Philips Hue smart lighting. We love the ambiance and control, and we've even got those neat plates that cover the old physical switches (here's an example), making the Hue dimmer switches the primary control. I changed every light in the house within a few months.

Other than some automations for outside lights, and being able to make the lights pretty colours it wasn't really "smart" and this is what we had for 5 years

It was only a few months ago I really started to tinker with Home Assistant, I'd toyed with Homey Pro having believed that Home Assistant would require too much tinkering, but the high initial outlay of Homey caused me to try a Docker container with Home Assistant - and what a rabbit hole it's been.

I found inspiration and guidance with my automation config by reading others examples, so I've tried to include examples of mine. So, grab a cup of tea, and I'll dive into some of my favourite automations!

Contents

Kids TV control

This is a real hit with our kids! I've recessed an NFC reader into the wall below our TV. They have a collection of laminated cards each with an NFC tag within - when they tap one a short video plays on the TV (generally something from Cocomelon or another nursery rhyme). Home Assistant keeps a count of how many times each card has been played. There are also controls in our Home Assistant dashboard for my wife and me to disable the feature, override what's playing, or toggle whether they need to wait for one video to finish before starting another. It's a fun, interactive way for them to choose their content.

Technically

This was achieved using an ESP32 with a PN532 NFC module. I have these within a drywall backbox which was no longer being used, I already had a CAT6 cable to this outlet, so I got a POE splitter so I could power the ESP32 easily.

ESPHome config on the ESP32
wifi:
  fast_connect: true
  networks:
    - ssid: {MY_SSID}
      password: {MY_PASSWORD}
      hidden: true

esp32:
  board: esp32dev

esphome:
  name: tagreader
  name_add_mac_suffix: true

logger:

api:

i2c:
  scan: False
  frequency: 400kHz

pn532_i2c:
  update_interval: 0.5s
  on_tag:
    then:
      - homeassistant.tag_scanned: !lambda 'return x;'
      - text_sensor.template.publish:
          id: last_tag
          state: !lambda 'return x;'

text_sensor:
  - platform: version
    hide_timestamp: true
    name: Tag Reader ESPHome Version
    entity_category: diagnostic
  - platform: wifi_info
    ip_address:
      name: Tag Reader IP Address
      icon: mdi:wifi
      entity_category: diagnostic
    ssid:
      name: Tag Reader Connected SSID
      icon: mdi:wifi-strength-2
      entity_category: diagnostic
  - platform: template
    name: Tag Reader Last NFC Tag
    id: last_tag
    icon: mdi:nfc

The Home Assistant automation essentially checks the NFC tag is registered to a file, that the file is able to be played, and then triggers a script to actually play the file on the TV.

Config for the automation
mode: single
alias: Play movie from tag
triggers:
  - event_type: tag_scanned
    trigger: event
    alias: NFC tag is scanned
conditions:
  - alias: Ignore if triggered recently
    condition: template
    value_template: >-
      {{ state_attr('automation.living_room_nfc_tag_scanned', 'last_triggered')
      is not none and (now().timestamp() -
      state_attr('automation.living_room_nfc_tag_scanned',
      'last_triggered').timestamp()) > 15 }}
actions:
  - variables:
      NFC_MAPPING:
        04-FC-A3-35-BF-2A-81:
          filename: baby-shark.mp4
        04-F8-A3-35-BF-2A-81:
          filename: the-wheels-on-the-bus-uk.mp4
        04-D6-D1-35-BF-2A-81:
          filename: baa-baa-black-sheep.mp4
        04-D7-AE-35-BF-2A-81:
          filename: heads-shoulders-knees-and-toes.mp4
        04-C4-C4-35-BF-2A-81:
          filename: sleeping-bunnies.mp4
  - alias: Check for valid NFC tag
    if:
      - condition: template
        value_template: "{{ trigger.event.data.tag_id in NFC_MAPPING }}"
    else:
      - stop: Not valid NFC tag
        error: false
  - alias: Check whether the feature is enabled
    if:
      - condition: template
        value_template: "{{ states('input_boolean.living_room_nfc_reader_toggle') != 'on' }}"
    then:
      - stop: Feature is disabled
        error: false
  - alias: Check whether the children are being made to wait
    if:
      - condition: template
        value_template: >-
          {{ states('input_boolean.living_room_nfc_reader_wait') == 'on' and
          states('media_player.living_room_tv') == 'playing' and
          state_attr('media_player.living_room_tv', 'media_content_id') is not
          none and ('http://192.168.1.50:8081' in
          state_attr('media_player.living_room_tv', 'media_content_id')) }}
    then:
      - stop: Children are being made to wait
        error: false
  - alias: Check whether the same card is already playing
    if:
      - condition: template
        value_template: >-
          {{ states('media_player.living_room_tv') == 'playing' and
          state_attr('media_player.living_room_tv', 'media_title') ==
          NFC_MAPPING[trigger.event.data.tag_id].name }}
    then:
      - stop: Same card is already playing
        error: false
  - alias: Play file
    action: script.play_file
    data:
      filename: "{{ NFC_MAPPING[trigger.event.data.tag_id].filename }}"

The reason there is a specific script to play the file is because there are a few steps, and I do this from the automation but also from buttons on a dashboard which allow me to override what's playing.

Config for the play_file script
mode: single
alias: Play file
fields:
  filename:
    name: Filename
    required: true
    description: The filename to play
    example: baby-shark.mp4
    selector:
      select:
        options:
          - baby-shark.mp4
          - the-wheels-on-the-bus-uk.mp4
          - baa-baa-black-sheep.mp4
          - heads-shoulders-knees-and-toes.mp4
          - sleeping-bunnies.mp4
sequence:
  - variables:
      ENDPOINT: "http://192.168.1.50:8081"
      MAPPING:
        baby-shark.mp4:
          name: Baby Shark
          counter: play_baby_shark
        the-wheels-on-the-bus-uk.mp4:
          name: Wheels on the Bus (UK)
          counter: play_wheels_on_the_bus_uk
        baa-baa-black-sheep.mp4:
          name: Baa Baa Black Sheep
          counter: play_baa_baa_black_sheep
        heads-shoulders-knees-and-toes.mp4:
          name: Heads Shoulders Knees and Toes
          counter: play_heads_shoulders_knees_and_toes
        sleeping-bunnies.mp4:
          name: Sleeping Bunnies
          counter: play_sleeping_bunnies
  - action: script.turn_on
    target:
      entity_id: script.prepare_tv_to_play
  - action: media_player.play_media
    target:
      device_id: 252228ef0ecb6f8dcc4e04ef4b04c33c
    data:
      media_content_type: video
      media_content_id: "{{ ENDPOINT }}/{{ filename }}"
      extra:
        metadata:
          title: "{{ MAPPING[filename].name }}"
    alias: Start playing video
  - alias: Count watch
    action: counter.increment
    target:
      entity_id: counter.{{ MAPPING[filename].counter }}

Heating

We've got a Drayton Wiser system controlling our main heating zones (upstairs, downstairs, and hot water). Home Assistant integrates with this, but we've also added a few Sonoff TRVs in specific rooms for even more granular control. This will likely change when we come into the cooler months, for now the heating doesn't really get used (but having heating we could turn on remotely has been a desire for a while)

Laundry

I've got an Athom smart plug v2 with power monitoring on both the washing machine and the dryer. Home Assistant knows when the washer has finished - it then sends reminds us to move the load to the dryer until it detects the dryer is running. Nice to also get cost breakdowns.

Technically

I'm using a brilliant blueprint, Appliance Notifications & Actions which does all the heavy lifting

The secret sauce here is that I have created a custom boolean dryer_is_running and a template binary sensor washing_machine_has_been_emptied which has the template: {{ states('input_boolean.dryer_is_running') == 'on' }} effectively just converting the boolean to a binary value so the blueprint can use it. In the washer automation it will only send reminders to empty the washer if washing_machine_has_been_emptied is false.

Config for the washer automation
alias: Washer is running
use_blueprint:
  path: Blackshome/appliance-notifications.yaml
  input:
    power_sensor: sensor.athom_washer_plug_power
    start_title: Washer
    end_message_title: Washer
    end_notify_device:
      - {MY_PHONE}
      - {WIFES_PHONE}
    end_message: Yippee the washer has finished!
    include_power_tracking: enable_power_tracking_and_cost
    power_consumption_sensor: sensor.athom_washer_plug_energy
    end_message_cost: Approx Cost £
    include_duration_tracking: enable_duration_tracking
    include_end_notify: enable_end_notify_options
    cost_per_kwh: sensor.octopus_energy_electricity_xxxxxxxxxx_yyyyyyyyyyyyy_current_rate
    start_power_consumption: input_number.washer_start_number_helper
    end_power_consumption: input_number.washer_end_number_helper
    end_reminder_notification: enable_reminder_notification
    end_reminder_notification_entity: binary_sensor.washing_machine_has_been_emptied
    end_reminder_notification_message: Remember to put the washing in the dryer
    end_reminder_notification_max_count: 5

The dryer config sets the input_boolean.dryer_is_running using custom actions in the blueprint.

Config for the dryer automation
alias: Dryer is running
use_blueprint:
  path: Blackshome/appliance-notifications.yaml
  input:
    power_sensor: sensor.athom_dryer_plug_power
    start_title: Dryer
    end_message_title: Dryer
    end_notify_device:
      - {MY_PHONE}
      - {WIFES_PHONE}
    end_message: The dryer has finished
    include_power_tracking: enable_power_tracking_and_cost
    power_consumption_sensor: sensor.athom_dryer_plug_energy
    end_message_cost: Approx Cost £
    include_duration_tracking: enable_duration_tracking
    include_end_notify: enable_end_notify_options
    cost_per_kwh: sensor.octopus_energy_electricity_xxxxxxxxxx_yyyyyyyyyyyyy_current_rate
    start_power_consumption: input_number.dryer_start_number_helper
    end_power_consumption: input_number.dryer_end_number_helper
    include_custom_actions:
      - enable_start_custom_actions
      - enable_end_custom_actions
    start_custom_actions:
      - action: input_boolean.turn_on
        target:
          entity_id: input_boolean.dryer_is_running
    end_custom_actions:
      - action: input_boolean.turn_off
        target:
          entity_id: input_boolean.dryer_is_running

Garage

One of the home improvements the past few months was to get a new garage door, naturally I wanted to be able to control this with Home Assistant, but I also added some sensors and controls to the garage too

Technically

I went for a Somfy IO drive motor with a SeceuroGlide Garage Door. This required me to get a Somfy Connectivity Kit which would allow me to control the door remotely and integrate it with Home Assistant. But if I'm being honest it's not ideal, when you control the door with the Somfy remote it doesn't report that status back to Home Assistant so I ended up using door contact sensors on the door and a custom cover configured in Home Assistant. I've also found the connection to be quite unreliable. If I was going to do this again I would get a drive motor with regular "dry contacts" and control it with ESPHome (probably this garage door opener from Athom Tech)

For the light control we had LED battons already installed and I couldn't justify changing them just for automations so I added a SONOFF ZBMINIR2 in line with the light switch, so the lights can be controlled through Home Assistant, but there is also a mechanical switch in there just like before.

Config for garage open automation

Interesting point here is that I have a check so if both me and my wife are away from home and the garage is opened we will get a notification for a possible break in.

alias: Garage open
mode: single
triggers:
  - trigger: state
    entity_id:
      - cover.garage_door_cover
    to: open
actions:
  - type: turn_on
    device_id: xxxxxxxxxx
    domain: switch
  - alias: Check whether it might be a break in
    if:
      - alias: Both Joel and Rachel are away
        condition: and
        conditions:
          - condition: state
            entity_id: person.joel_vardy
            state: not_home
          - condition: state
            entity_id: person.rachel_vardy
            state: not_home
    then:
      - alias: Notify Joel
        action: notify.mobile_app_joels_iphone_16_pro
        data:
          message: Possible garage break in!
      - alias: Notify Rachel
        action: notify.mobile_app_rachels_iphone
        data:
          message: Possible garage break in!
  - type: turn_off
    alias: Turn off dehumidifier
    device_id: xxxxxxxxxx
    domain: switch

When the garage is left open for more than 30 minutes I get a notification on my phone

Garage left open notification

I'm using the contact sensor blueprint from Malte which makes the notification very simple. My automation config is below:

alias: Notify garage is left open
use_blueprint:
  path: Raukze/contact-sensor-left-open-notification.yaml
  input:
    trigger_entity: binary_sensor.garage_door_contact
    friendly_name: Garage door
    duration_issue_state:
      hours: 0
      minutes: 30
      seconds: 0
      days: 0
    duration_from_issue_state:
      hours: 0
      minutes: 0
      seconds: 10
      days: 0
    notify_services_string: notify.mobile_app_joels_iphone_16_pro
    notification_click_url: /dashboard-house/outside
    repeat_notification: false
    notification_interruption_level: time-sensitive

Office

I have some custom shelves in my office, these are simple IKEA LACK shelves that I've modified to add integrated USB power for Lego light kits and light strips under the first shelf. I've also added some mini spots on the ceiling to light up the top shelf. For the USB power and the light strip I'm controlling these using an ESP32 running ESPHome, the ceiling spots are switched through a Sonoff relay.

Using a Sonoff presence detector, all the office lights automatically turn on when someone enters between 8 am and 6 pm on weekdays. Walk in, lights on. Walk out, lights off.

My IKEA sit-stand desk doesn't have a memory function but I've integrated it into Home Assistant via a Bluetooth proxy, so now I have a quick button for my preferred heights for sit and stand

Lighting

Lighting plays a huge role in setting the mood and providing convenience throughout our home:

  • After sunset, if someone is in the kitchen or dining area, the lights automatically illuminate the space At around 25% brightness. This one is so simple (it's like automation 101) but really quite pleasant
  • Our outside lights operate on a sunset-to-sunrise schedule. But when we arrive home the outdoor lights are boosted to 100% brightness and it turns on the utility room light for 5 minutes
  • If my wife and I have left the house and Home Assistant detects that lights or the TV are still on, I get a notification. I can then turn everything off remotely.
  • Technically

    Config for the reminder to turn things off
    alias: Did someone turn off the lights?
    mode: single
    triggers:
      - trigger: state
        entity_id:
          - person.joel_vardy
          - person.rachel_vardy
        to: not_home
    conditions:
      - condition: state
        entity_id: person.joel_vardy
        state: not_home
      - condition: state
        entity_id: person.rachel_vardy
        state: not_home
      - condition: or
        alias: If anything has been left on
        conditions:
          - condition: state
            entity_id: light.house
            state: "on"
          - condition: state
            state: "on"
            entity_id: media_player.sony_xr_65a80j
            alias: If TV is on
    actions:
      - action: script.turn_on
        target:
          entity_id: script.turn_house_off_notification
        alias: Send the turn off house notification

    The Kids' Bedtime Routine

    Getting the little ones to bed can be a process, we've always had white noise play in their room, and the past 6 months or so we've had a Spotify playlist we'd play at bedtime.

    One of the problems with this is that depending who started playing the Spotify playlist one of us couldn't use Spotify for a few hours until we faded the music down to off, this was a pain as one of us would often go to the gym after bedtime.

    Technically

    So solve the Spotify issue I tried a lot of things, but eventually settled on running Music Assistant alongside Home Assistant and I downloaded a new set of music for offline playback, we don't currently use Music Assistant for anything other than this bedtime playlist, but it's solved a big pain point

    Config for the bedtime routine script

    To fade the lights so gradually I'm using the light fader 2.0 blueprint by Ashley Bischoff

    alias: Start bedtime
    description: Dims the lights and starts the 'Go to sleep' playlist
    icon: mdi:power-sleep
    sequence:
      - parallel:
          - sequence:
              - action: media_player.volume_set
                data:
                  volume_level: 0.45
                target:
                  entity_id: media_player.nursery
              - action: media_player.shuffle_set
                data:
                  shuffle: false
                target:
                  entity_id: media_player.nursery
              - action: music_assistant.play_media
                target:
                  entity_id: media_player.nursery
                data:
                  media_type: playlist
                  media_id: Go to sleep
                  enqueue: replace
              - action: media_player.repeat_set
                data:
                  repeat: all
                target:
                  entity_id: media_player.nursery
            alias: Start playlist
          - action: script.turn_on
            alias: Fade ceiling light
            target:
              entity_id: script.light_fader
            data:
              variables:
                light: light.nursery_ceiling
                lampBrightnessScale: zeroToOneHundred
                transitionTime:
                  hours: 0
                  minutes: 0
                  seconds: 20
                endBrightnessPercent: 0
                minimumStepDelayInMilliseconds: 100
          - action: script.turn_on
            alias: Fade cot left light
            target:
              entity_id: script.light_fader
            data:
              variables:
                light: light.nursery_cot_left
                lampBrightnessScale: zeroToOneHundred
                transitionTime:
                  hours: 0
                  minutes: 1
                  seconds: 0
                endBrightnessPercent: 10
                minimumStepDelayInMilliseconds: 100
          - action: script.turn_on
            alias: Fade cot right light
            target:
              entity_id: script.light_fader
            data:
              variables:
                light: light.nursery_cot_right
                lampBrightnessScale: zeroToOneHundred
                transitionTime:
                  hours: 0
                  minutes: 1
                  seconds: 0
                endBrightnessPercent: 10
                minimumStepDelayInMilliseconds: 100

    In Home Display

    I have a Samsung Galaxy Tab A9+ on a Makes by Mike mount where my downstairs heating thermostat used to be. This has a digital thermostat (although we have a physical Wiser one in the living room now too)

    Wife approval factor on this has been quite high, I've setup live bus times, bin day reminders, music control, and shared calendars on here along with video feed from the front door, weather, and a button you can press when leaving the house which turns everything off

    Technically

    I'm using Fully Kiosk pointed at a custom dashboard which has Kiosk Mode which hides the Home Assistant header and sidebar

    Config for the dashboard

    For the dashboard design itself I resorted to placing cards on the custom:button-card as described in this article and video from yoyotech.

    kiosk_mode:
      kiosk: true
    views:
      - title: Home
        type: panel
        theme: Catppuccin Latte
        cards:
          - type: custom:button-card
            show_state: false
            tap_action:
              action: none
            custom_fields:
              calendar:
                card:
                  entities:
                    - entity: calendar.xxxxx
                    - entity: calendar.yyyyy
                    - entity: calendar.birthdays
                      label: 🎂
                    - entity: calendar.holidays_in_united_kingdom
                      label: 🏖️
                  days_to_show: 7
                  show_empty_days: true
                  today_indicator: glow
                  show_month: false
                  type: custom:calendar-card-pro
                  height: 437px
              bus_times:
                card:
                  type: custom:bus-times-card
                  stop_id: xxxxxxxxxx
              weather:
                card:
                  type: custom:clock-weather-card
                  entity: weather.met_office_xx_yyyyyy_xx_yyyyyy
                  temperature_sensor: sensor.outside_temperature_humidity_sensor_temperature
                  humidity_sensor: sensor.outside_temperature_humidity_sensor_humidity
                  time_pattern: HH:mm
                  time_format: 24
                  date_pattern: DDD
                  show_humidity: true
              thermostat:
                card:
                  type: thermostat
                  show_current_as_primary: false
                  entity: climate.wiser_downstairs_heating
                  name: Downstairs
                  features:
                    - style: icons
                      type: climate-preset-modes
                      preset_modes:
                        - Cancel Overrides
                        - Boost 30m
                        - Boost 1h
                        - Boost 2h
              camera:
                card:
                  type: custom:webrtc-camera
                  url: rtsp://xxxxxxxxxx
                  ui: true
                  muted: true
                  background: true
                  style: '.screenshot, .pictureinpicture { display: none }'
              media_player:
                card:
                  type: media-control
                  entity: media_player.dining_room
              bin_day:
                card:
                  type: markdown
                  content: |-
                    {% set today = (now() + timedelta(days=1)).date() %} {% set
                    non_recyclable_dates = [
                      "2025-01-08", "2025-01-22",
                      "2025-02-05", "2025-02-19",
                      "2025-03-05", "2025-03-19",
                      "2025-04-02", "2025-04-16", "2025-04-30",
                      "2025-05-14", "2025-05-28",
                      "2025-06-11", "2025-06-25",
                      "2025-07-09", "2025-07-23",
                      "2025-08-06", "2025-08-20",
                      "2025-09-03", "2025-09-17",
                      "2025-10-01", "2025-10-15", "2025-10-29",
                      "2025-11-12", "2025-11-26",
                      "2025-12-10", "2025-12-24"
                    ] %} {% set recyclable_dates = [
                      "2025-01-01", "2025-01-15", "2025-01-29",
                      "2025-02-12", "2025-02-26",
                      "2025-03-12", "2025-03-26",
                      "2025-04-09", "2025-04-23",
                      "2025-05-07", "2025-05-21",
                      "2025-06-04", "2025-06-18",
                      "2025-07-02", "2025-07-16", "2025-07-30",
                      "2025-08-13", "2025-08-27",
                      "2025-09-10", "2025-09-24",
                      "2025-10-08", "2025-10-22",
                      "2025-11-05", "2025-11-19",
                      "2025-12-03", "2025-12-17", "2025-12-31"
                    ] %} {% if today|string in non_recyclable_dates %}
                      BIN DAY! Non-recyclable waste collection
                    {% elif today|string in recyclable_dates %}
                      BIN DAY! Recyclable waste collection
                    {% else %}
                      No waste collection today
                    {% endif %}
              house_off:
                card:
                  type: custom:button-card
                  icon: mdi:home-off
                  name: Turn everything off
                  size: 20%
                  tap_action:
                    action: perform-action
                    perform_action: script.turn_everything_off
            styles:
              card:
                - padding: 22px
                - align-self: start
                - width: 1280px
                - height: 799px
                - opacity: 1
                - background-color: transparent
                - border: thick
                - '--mdc-ripple-color': transparent
                - '--mdc-ripple-press-opacity': 0
                - cursor: default
              custom_fields:
                calendar:
                  - position: absolute
                  - top: 20px
                  - left: 20px
                  - width: 400px
                  - height: 470px
                  - text-align: left
                bus_times:
                  - position: absolute
                  - top: 510px
                  - left: 20px
                  - width: 400px
                  - height: 269px
                  - overflow-y: auto
                  - background: var(--card-background-color)
                weather:
                  - position: absolute
                  - top: 20px
                  - left: 440px
                  - width: 400px
                  - height: 250px
                thermostat:
                  - position: absolute
                  - top: 290px
                  - left: 440px
                  - width: 400px
                  - height: 489px
                camera:
                  - position: absolute
                  - top: 20px
                  - left: 860px
                  - width: 400px
                  - height: 300px
                media_player:
                  - position: absolute
                  - top: 340px
                  - left: 860px
                  - width: 400px
                  - height: 208px
                bin_day:
                  - position: absolute
                  - top: 568px
                  - left: 860px
                  - width: 400px
                  - height: 60px
                  - justify-self: center
                house_off:
                  - position: absolute
                  - top: 648px
                  - left: 860px
                  - width: 400px
                  - height: 131px

    There was a card in the config above for custom:bus-times-card this was my first attempt at a custom Lovelace card which shows bus times for my local bus stop into Edinburgh.

    Config for custom:bus-times-card

    The code below is saved to homeassistant/config/www/bus-times-card.js and then enabled as a custom resource in the Home Assistant UI

    class BusTimesCard extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.interval = null;
        this.data = null;
        this.stopId = null;
        this.apiUrl = null;
      }
    
      setConfig(config) {
        this.config = config;
        this.stopId = config.stop_id || '6200239328';
        this.apiUrl = `https://lothianapi.co.uk/departureBoards/website?stops=${this.stopId}`;
        this.render();
        this.fetchData();
        if (this.interval) clearInterval(this.interval);
        this.interval = setInterval(() => this.fetchData(), 60000); // 1 minute
      }
    
      async fetchData() {
        try {
          const response = await fetch(this.apiUrl);
          if (!response.ok) throw new Error('Network response was not ok');
          this.data = await response.json();
          this.render();
        } catch (e) {
          this.data = null;
          this.renderError(e.message);
        }
      }
    
      render() {
        if (!this.shadowRoot) return;
        const style = `
          <style>
            :host {
              display: block;
              font-family: var(--primary-font-family);
              color: var(--primary-text-color);
            }
            ha-card {
              padding: 16px;
              background: var(--ha-card-background, white);
              box-shadow: var(--ha-card-box-shadow, 0 2px 4px rgba(0,0,0,0.16));
              border-radius: var(--ha-card-border-radius, 12px);
            }
            table {
              width: 100%;
              border-collapse: collapse;
            }
            th, td {
              padding: 8px;
              text-align: left;
            }
            th {
              color: var(--primary-text-color);
            }
            .delta {
              color: var(--secondary-text-color, #888);
              font-size: 90%;
            }
          </style>
        `;
        let content = '';
        const services = (this.data && this.data.services) ? this.data.services : [];
        let departures = services.flatMap(service => (service.departures || []).map(dep => ({
          ...dep,
          service_name: service.service_name
        })));
        departures = departures.sort((a, b) => new Date(a.departure_time_iso) - new Date(b.departure_time_iso));
        if (!departures.length) {
          content = '<div>No upcoming buses.</div>';
        } else {
          content = `
            <table>
              <thead>
                <tr>
                  <th>Service</th>
                  <th>Destination</th>
                  <th>Due</th>
                </tr>
              </thead>
              <tbody>
                ${departures.map(bus => {
                  const depTime = new Date(bus.departure_time_iso);
                  const now = new Date();
                  const diffMs = depTime - now;
                  const diffMin = Math.round(diffMs / 60000);
                  const timeStr = depTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
                  const deltaStr = diffMin > 0 ? `in ${diffMin} min` : diffMin === 0 ? 'now' : `${-diffMin} min ago`;
                  // Add asterisk if not real_time
                  const needsAsterisk = !bus.real_time;
                  return `
                    <tr>
                      <td>${bus.service_name}${needsAsterisk ? ' *' : ''}</td>
                      <td>${bus.destination}</td>
                      <td>${timeStr} <span class="delta">(${deltaStr})</span></td>
                    </tr>
                  `;
                }).join('')}
              </tbody>
            </table>
          `;
        }
        this.shadowRoot.innerHTML = style + `<ha-card>${content}</ha-card>`;
      }
    
      renderError(message) {
        if (!this.shadowRoot) return;
        this.shadowRoot.innerHTML = `<div style="color:red;">Error: ${message}</div>`;
      }
    
      getCardSize() {
        return 3;
      }
    
      disconnectedCallback() {
        if (this.interval) clearInterval(this.interval);
      }
    }
    
    customElements.define('bus-times-card', BusTimesCard);
    
    window.customCards = window.customCards || [];
    window.customCards.push({
      type: 'bus-times-card',
      name: 'Bus Times Card',
      description: 'Shows a table of upcoming bus times for a given stop.',
    });

    For the physical mounting of the tablet I was able to get power from the old heating controls, from there I stepped it down from 240v to 5v for the USB input. Then hooked up the thin USB C cable which came with the mount.

    The mounting

    The screen turns off after 2 minutes, but is woken up when the tablet detects presence or when motion is detected in the kitchen or dining room via an automation

    I'm currently using the default Galaxy Tab A9+ power management to keep the battery at around 80% charge to avoid it swelling. If this becomes a problem I'll probably install a relay on the power here and only turn the power on when the charge drops to 30% and then back off when it's at 90%

    Energy Monitoring

    I've tried to understand by energy usage so I have monitoring on a few things:

    You might realise I have some duplication, I can see energy usage from Octopus and solar generation from my inverter so why do I need an energy monitor on top of that? Well the Octopus API doesn't expose export data, and I want to see how much of my solar usage is being used in the home vs exported back to the grid. And I wanted better resolution than the hourly data from my inverter.

    Dashboards

    I've got a lot of dashboards that I use to check in on things, but the Living Room dashboard is the main one we use as a family. It allows us to remotely control our TV, which is useful since we've found the iOS TV control app to randomly stop working from time to time.

    This dashboard also has some additional controls the kids NFC reader, you can turn the feature off altogether when we don't want them to turn off something we're watching, there is also a toggle which if enabled ensures the last video has finished playing before starting a new one. In addition to this we have buttons for every card so we can override what's playing (bypassing the checks previously mentioned). This is handy when one of our kids isn't taking turns nicely so we can play the video the other child wants. We also have a play count for videos played :D

    Things that aren't smart (yet)

    My house alarm system isn't really smart - I can access it remotely using an app, but it's something I'd consider changing so it was integrated into Home Assistant.

    Other Projects

    I had created a custom weather integration for the UK Met Office using the "DataHub" API since the old API had been deprecated breaking the core Met Office integration. However before I got this published on HACS the core Met Office integration was updated to support the new API so I won't be pursuing this.

    Bloopers

    If you've read this far you might be interested to hear about a few of the times thing didn't quite go to plan