Zwift Sensor Component - Feedback and Testers Needed

You can create a template sensor in the menu “settings → devices & services → Helpers → Create helper → Template → sensor”
then you can use the look for the information convert it to float and divide by 1000. And add mts add the end.
Replace the XXXXXXX with your <playerid>


it will get you something similar to this (with your height) then you can use this sensor in your boards

Good Luck!!

OK rad, that’ll get me started. Thank you very much!!

I do mine a little different but the results are the same - this is different example for last ride distaste

{{ (states.sensor.zwift_online_24xxx.attributes.latest_activity.distanceInMeters *0.001 ) | round(2) }}

image

I’m pretty active right now and adding new stuff to this integration, check out GitHub - Maddimax/zwift_hass: Zwift Sensor Integration for HomeAssistant · GitHub if you like. It allows you to correctly set units etc. for the sensors, comes with a full config flow etc.

@Maddimax Will have a look to move to yours, you do need to update the Github readme as clicking the add via HACS redirects to the OG Snicker repo.

Thank for this. I’ve installed your fork. Do you think you’ll maintain this as I see your pull requests are going on the original aren’t getting merged. I will probably just stick with your version if you plan on maintaining it. Thanks!

I’ll get this merged in the next week, it’s just a substantial set of changes and I’ve been a bit preoccupied with some life events lately (not cycling much and wanted to personally test it)

v4.1.0 is live.

huge update. thanks to @Maddimax here and thanks for the patience from everyone, glad I can stop leaving that PR email unread in my inbox after so many months.

Complete modernization of the Zwift integration: YAML-only sensor: platform config → UI config flow with per-player devices, expanded entities, and zero-touch migration for existing users.

Architecture

  • Replaced YAML-only setup with a full config-entry/config-flow integration — UI flow fetches your Zwift followees for player selection instead of manually entering IDs.
  • Replaced the single shared polling loop with a DataUpdateCoordinator per tracked player — independent intervals (15s default for yourself, 60s for others), auto-speeds to 2s only for players actually online.
  • Each tracked player is now a proper HA device (profile link, full-name naming).

New entities

  • 17 new diagnostic sensors: total distance, distance climbed, total time, drops, streaks, racing score/category, FTP, weight, height, DOB, age, account creation date, power zone (+name), sport.
  • New light (power zone color), number (per-player poll interval), switch (per-player poll on/off), and image (profile picture) entities.
  • zwift_ride_on is now available as a device trigger in the automation UI, not just the raw event bus.

Migration

  • Existing YAML users migrate automatically on upgrade — no manual config edits needed, for both the original sensor: - platform: zwift format and the newer zwift: format.
  • A Repairs notice appears after import so users know the now-inert YAML block can be removed.
  • Entity IDs and history are preserved across upgrade (matched by unique ID), including for custom name: configs.

Bug fixes found and fixed during review/testing

  • Config flow showed “cannot connect” instead of “already configured” when re-adding an existing account.
  • Gradient sensor’s unit had regressed from % to an invalid °, breaking long-term statistics on upgrade.
  • A legacy update_interval YAML key took down the entire integration (not just the import), silently, on restart.
  • Sensor units followed each tracked player’s own metric/imperial preference instead of the account owner’s — verified against raw API data and the companion app’s own behavior that this was wrong.
  • Total Distance Climbed was missing a metric suggested-unit, leaving it unconverted for metric users.
  • Orphaned “Update” button entities weren’t cleaned up for players no longer tracked.
  • name: config was silently ignored when generating entity unique IDs, orphaning upgrading users’ entity history and creating duplicate devices.

Thanks for this. I installed and tested it yesterday. Nice work!

The only issue I ran into was the new light entity. It wreaked havoc with all my automations and routines that switch off all the lights. They all failed when they encountered this new entity, which isn’t really a light.

To be fair, it also exposed a few weaknesses in my automation logic, so that’s on me. But it definitely caught me out a few times!

Apart from that; I’m just modifying my automations now to use the attributes of this new power zone light. Right now, I have my own look-up table for the power/colour zones applied to my hue lights. For us triathletes, we’ll need the same thing for the running profile too, but probably based on HR zones and not power zones.

I didn’t find any docs, but it looks like sensor.zwift.activity.colour takes the colour attribute from light.zwift_<profile_name>_power_zone_color. Then we qpply this RGB sensor value to our RGB lights - is that the idea?

yeah, that was the idea, @Maddimax may be able to better explain how its being used. It may be worthwhile to make this light entity optional.

Got it. Card’s looking good.

Then “Last Zwift Session” get’s replaced with “Zwift Live Session” using conditional cards (state “Zwift Online”)

The colour bar dynamically track the state of sensor.zwift.activity.colour and sensor.zwift_<profile_neme>_power_zone_name.

Really great work guys!

Care to share that YAML config for that card?

Of course. As soon as I get back to my desktop. If I don’t get it to you in within max a couple of days, please remind me!

As promised @nebulight. Yaml below

Sensors/entities used:

image.zwift_<user_name>_profile_picture
sensor.zwift_online_<uniqueID_number>
sensor.zwift_<user_name>_power_zone_name
sensor.zwift_activity_color
sensor.zwift_distance_<uniqueID_number>
sensor.zwift_altitude_<uniqueID_number>
sensor.zwift_gradient_<uniqueID_number>
sensor.zwift_power_<uniqueID_number>
sensor.zwift_cadence_<uniqueID_number>
sensor.zwift_speed_<uniqueID_number>
sensor.zwift_heart_rate_<uniqueID_number>
sensor.zwift_level_<uniqueID_number>
sensor.zwift_cycle_progress_<uniqueID_number>
sensor.zwift_run_level_<uniqueID_number>
sensor.zwift_run_progress_<uniqueID_number>
sensor.distance_earth_circumference
sensor.elevation_mountain_everest

Cards used:

custom:layout-card
custom:button-card
conditional
markdown
entities
custom:bar-card

Main Last/Live Sessions (conditional)

cards:
  - type: custom:button-card
    entity: image.zwift_<user_name>_profile_picture
    show_entity_picture: true
    show_icon: false
    show_name: true
    show_label: true
    name: Zwift <user_name>
    label: >-
      [[[ return states['sensor.zwift_online_<uniqueID_number>']?.state === 'True'
      ? 'Live on Zwift' : 'Last Zwift session'; ]]]
    entity_picture: >-
      [[[ return entity?.attributes?.entity_picture ||
      entity?.attributes?.url || ''; ]]]
    tap_action:
      action: more-info
    styles:
      card:
        - padding: 12px
        - border-radius: 8px
        - display: grid
        - grid-template-columns: 72px 1fr
        - align-items: center
        - column-gap: 14px
      grid:
        - grid-template-areas: "'i n' 'i l'"
        - grid-template-columns: 72px 1fr
        - grid-template-rows: 1fr 1fr
        - align-items: center
      img_cell:
        - width: 72px
        - height: 72px
        - border-radius: 50%
        - overflow: hidden
      entity_picture:
        - width: 72px
        - height: 72px
        - object-fit: cover
      name:
        - justify-self: start
        - font-size: 18px
        - font-weight: "600"
      label:
        - justify-self: start
        - font-size: 13px
        - opacity: "0.75"

  - card:
      content: >-
        {% set last_act =
        state_attr("sensor.zwift_online_<uniqueID_number>",
        'latest_activity') %}

        {% set t = last_act['movingTimeInMs'] %}
        {% set d = last_act['distanceInMeters'] %}
        {% set pace_in_seconds = 0 %}

        {% if d and t %}
          {% set pace_in_seconds = t / d %}
        {% endif %}

        Last session: {{
        states.sensor.zwift_online_<uniqueID_number>.attributes.latest_activity.name
        }}

        ![Activity Image]( {{
        states.sensor.zwift_online_<uniqueID_number>.attributes.latest_activity.primaryImageUrl
        }} )

        The {% if last_act['sport'] == "CYCLING" -%}
        ride
        {%- else -%}
        run
        {%- endif %} started on {{
        as_timestamp(
        states.sensor.zwift_online_<uniqueID_number>.attributes.latest_activity.startDate
        ) | timestamp_custom(' %d %B %Y at %H:%M ') | replace(" 0", "")
        }}

        The {% if last_act['sport'] == "CYCLING" -%}
        ride
        {%- else -%}
        run
        {%- endif %} took {{
        states.sensor.zwift_online_<uniqueID_number>.attributes.latest_activity.duration
        }} minutes

        The {% if last_act['sport'] == "CYCLING" -%}
        ride
        {%- else -%}
        run
        {%- endif %} ended on {{
        as_timestamp(
        states.sensor.zwift_online_<uniqueID_number>.attributes.latest_activity.endDate
        ) | timestamp_custom(' %d %B %Y at %H:%M ') | replace(" 0", "")
        }}

        {% if last_act['sport'] == "CYCLING" -%}
        Ride
        {%- else -%}
        Run
        {%- endif %} stats:

        Distance: {{
        states.sensor.zwift_online_<uniqueID_number>.attributes.latest_activity.distanceInMeters
        | round / 1000
        }} km

        {% if last_act['sport'] == "CYCLING" -%}
        Climbing: {{
        states.sensor.zwift_online_<uniqueID_number>.attributes.latest_activity.totalElevation
        }} m
        {%- else -%}
        Pace: {{ pace_in_seconds | round // 60 }}:{{
        '%02d' | format(pace_in_seconds | round % 60)
        }} /km
        {%- endif %}

        Power: {{
        states.sensor.zwift_online_<uniqueID_number>.attributes.latest_activity.avgWatts
        }} W

        Calories: {{
        states.sensor.zwift_online_<uniqueID_number>.attributes.latest_activity.calories
        }} kcal
      title: Last Zwift Session
      type: markdown
    conditions:
      - entity: sensor.zwift_online_<uniqueID_number>
        state: "False"
    type: conditional

  - type: conditional
    conditions:
      - entity: sensor.zwift_online_<uniqueID_number>
        state: "True"
    card:
      type: custom:button-card
      entity: sensor.zwift_<user_name>_power_zone_name
      show_icon: false
      show_state: false
      show_label: false
      name: >-
        [[[ const zone = entity?.state || 'unknown';
        return zone.replace(/_/g, ' ').replace(/\b\w/g,
        c => c.toUpperCase()); ]]]
      tap_action:
        action: more-info
      styles:
        card:
          - height: 54px
          - border-radius: 8px
          - padding: 8px 14px
          - background: >-
              [[[ const raw =
              states['sensor.zwift_activity_color']?.state ||
              '[128, 128, 128]';
              const nums = (raw.match(/\d+/g) ||
              ['128', '128', '128']).slice(0, 3).map(Number);
              return `rgb(${nums[0]}, ${nums[1]}, ${nums[2]})`; ]]]
          - color: >-
              [[[ const raw =
              states['sensor.zwift_activity_color']?.state ||
              '[128, 128, 128]';
              const nums = (raw.match(/\d+/g) ||
              ['128', '128', '128']).slice(0, 3).map(Number);
              const lum =
              0.299 * nums[0] + 0.587 * nums[1] + 0.114 * nums[2];
              return lum > 150 ? '#111111' : '#ffffff'; ]]]
          - display: flex
          - align-items: center
          - justify-content: center
        grid:
          - grid-template-areas: "'n'"
          - grid-template-rows: 1fr
          - align-items: center
          - justify-items: center
        name:
          - justify-self: center
          - align-self: center
          - text-align: center
          - font-size: 22px
          - font-weight: "700"
          - line-height: "1"

  - card:
      entities:
        - entity: sensor.zwift_<user_name>_power_zone_name
          name: Power Zone
        - entity: sensor.zwift_activity_color
          name: Zone RGB
        - entity: sensor.zwift_distance_<uniqueID_number>
          name: Distance
        - entity: sensor.zwift_altitude_<uniqueID_number>
          name: Altitude
        - entity: sensor.zwift_gradient_<uniqueID_number>
          name: Gradient
        - entity: sensor.zwift_power_<uniqueID_number>
          name: Power
        - entity: sensor.zwift_cadence_<uniqueID_number>
          name: Cadence
        - entity: sensor.zwift_speed_<uniqueID_number>
          name: Speed
        - entity: sensor.zwift_heart_rate_<uniqueID_number>
          name: Heart Rate
      show_header_toggle: false
      title: Zwift Live Session
      type: entities
    conditions:
      - entity: sensor.zwift_online_<uniqueID_number>
        state: "True"
    type: conditional

layout: vertical
type: custom:layout-card

Bard cards (warning:possible not available on HACS anymore?)

cards:
  - entities:
      - entity: sensor.zwift_level_<uniqueID_number>
        name: Cycling Level

      - color: darkorange
        entity: sensor.zwift_cycle_progress_<uniqueID_number>
        entity_row: true
        height: 40px
        name: Cycle Progress
        positions:
          indicator: "off"
          name: inside
          value: "off"
        style: |-
          bar-card-currentbar,
          bar-card-contentbar,
          bar-card-backgroundbar {
            margin-top: 10px;
          }
        type: custom:bar-card

      - entity: sensor.zwift_run_level_<uniqueID_number>
        name: Run Level

      - color: deeppink
        entity: sensor.zwift_run_progress_<uniqueID_number>
        entity_row: true
        height: 40px
        name: Run Progress
        positions:
          indicator: "off"
          name: inside
          value: "off"
        style: |-
          bar-card-currentbar,
          bar-card-contentbar,
          bar-card-backgroundbar {
            margin-top: 10px;
          }
        type: custom:bar-card

    show_header_toggle: false
    title: <user_name>'s Zwift Profile
    type: entities

layout: vertical
type: custom:layout-card

and finally the global stats cards

type: custom:layout-card
layout_type: vertical
cards:
  - type: markdown
    title: Statistics (all-time)
    content: >-
      FTP: {{
      states.sensor.zwift_online_<uniqueID_number>.attributes.ftp
      }} Watts

      Max Streak: {{
      states.sensor.zwift_online_<uniqueID_number>.attributes.streaksMaxLength
      }} weeks

      Total Hours: {{
      (
      states.sensor.zwift_online_<uniqueID_number>.attributes.totalTimeInMinutes
      / 60
      ) | round(2)
      }} hours

      Power Generated: {{
      (
      states.sensor.zwift_online_<uniqueID_number>.attributes.totalWattHours
      / 1000
      ) | round(2)
      }} kWh

      Total Distance: {{
      (
      states.sensor.zwift_online_<uniqueID_number>.attributes.totalDistance
      / 1000
      ) | round(2)
      }} km

      Distance Perspective: {{
      states.sensor.distance_earth_circumference.state
      }} times around the Earth

      Total Elevation: {{
      (
      states.sensor.zwift_online_<uniqueID_number>.attributes.totalDistanceClimbed
      / 1000
      ) | round(2)
      }} km

      Elevation Perspective: {{
      states.sensor.elevation_mountain_everest.state
      }} times up Everest

associated template sensors (two of) for above:

- sensor:
    # Various sensors for the Zwift dashboard
    - name: "Distance Earth Circumference"
      default_entity_id: sensor.distance_earth_circumference
      unit_of_measurement: "times around Earth"
      state: >-
        {% set total_distance_travelled_km =
        states.sensor.zwift_online_<uniqueID_number>.attributes.totalDistance
        | float(0) / 1000 %}

        {% set earth_circumference_km = 40075 %}

        {{
        (total_distance_travelled_km / earth_circumference_km)
        | round(2)
        }}

    - name: "Elevation Mount Everest"
      default_entity_id: sensor.elevation_mountain_everest
      unit_of_measurement: "times up Everest"
      state: >-
        {% set total_distance_climbed_meters =
        states.sensor.zwift_online_<uniqueID_number>.attributes.totalDistanceClimbed
        | float(0) %}

        {% set everest_height_meters = 8849 %}

        {{
        (total_distance_climbed_meters / everest_height_meters)
        | round(2)
        }}

Love this integration.
Is there a way of receiving the duration of the current activity?
I’d like to set up a sensor so i can input my (expected activity duration - current activity duration) so my wife can see when i will be finished.

Thank you SO much for this… I think it was you who previously posted this ages ago and I copied it. Then when it updated a few days ago my dashboard broke…

So I can’t thank you enough! :grin:

This may be a really stupid question/statement, but I can’t get mine to work. Even if I follow @edgehassio’s awesome yaml’s.

My sensors are in this format: sensor.zwift_<username>_online not sensor.zwift_online_<uniqueID_number>

That was easy enough to change those sensors, but when we start getting to the states.sensor.zwift_online_<uniqueID_number>.attributes.latest_activity.name ones, it gets even more tricky as nothing populates :face_with_spiral_eyes:

Any assistant would be greatly appreciated