Sports Ticker Integration for Home Assistant – ESPN-Based Live Scores, Schedules, and Custom Dashboard Cards

I wanted to share my Sports Ticker integration for Home Assistant, built for anyone who wants live sports data displayed in a clean, customizable way on their dashboards.

LiquidFXX/sports-ticker: Sports ticker

This integration is designed to pull in ESPN-based sports data and make it easy to use inside Home Assistant for things like:

  • Live scores
  • Game status and start times
  • Team matchups
  • League-specific scoreboard data
  • Custom Lovelace cards and ticker-style displays

The goal was to create something that feels more like a real sports dashboard experience instead of just a plain sensor readout. It works especially well for building custom views for leagues like MLB, NFL, NBA, college sports, NASCAR, and more.

A few highlights:

  • Easy-to-use scoreboard sensors
  • Great for ticker cards, favorite team views, and game-day dashboards
  • Designed for highly visual Lovelace setups
  • Flexible enough for custom button-card and Mushroom-based designs
  • Ideal for wall tablets, sports pages, and family dashboards

I originally built this because I wanted a better way to follow my favorite teams directly in Home Assistant, and it turned into a full project that others may find useful too.

I’d love feedback from the community, especially around:

  • Additional leagues or sports to support
  • Sensor ideas and attribute improvements
  • Better card examples and dashboard layouts
  • Feature requests for favorite teams, standings, stats, or alerts

If you’re into sports and Home Assistant customization, I’d love for you to check it out and share your thoughts.

mlbticker

1 Like

This looks brilliant, but how hard would it be to add soccer scores?
This would finish of my dashboard nicely if I can have Manchester City information :slight_smile:

Sorry for the long delay, working on adding the soccer option now. Check for the update soon.

This could be one of my favorite cards ever. You did a great job!

type: custom:button-card
entity: sensor.espn_mlb_scoreboard_raw
show_name: false
show_icon: false
show_state: false
triggers_update:
  - sensor.espn_mlb_scoreboard_raw
variables:
  src: sensor.espn_mlb_scoreboard_raw
  fav: ATL
  mlb_logo: https://a.espncdn.com/i/teamlogos/leagues/500/mlb.png
styles:
  card:
    - padding: 0
    - border-radius: 34px
    - overflow: hidden
    - background: "#efe1bd"
    - box-shadow: 0 18px 40px rgba(0,0,0,.48)
  grid:
    - grid-template-areas: "\"main\""
    - grid-template-columns: 1fr
  custom_fields:
    main:
      - width: 100%
      - padding: 0
custom_fields:
  main: |
    [[[
      const st = states[variables.src];
      const events = st?.attributes?.events || [];
      const fav = String(variables.fav || 'ATL').toUpperCase();

      const getComp = e => e?.competitions?.[0] || {};
      const ev = events.find(e =>
        (getComp(e).competitors || []).some(t => t.team?.abbreviation === fav)
      ) || events[0];

      if (!ev) return `<div class="retro-shell"><div class="retro-card"><div class="empty">No MLB games found</div></div></div>`;

      const comp = getComp(ev);
      const teams = comp.competitors || [];
      const away = teams.find(t => t.homeAway === 'away');
      const home = teams.find(t => t.homeAway === 'home');

      if (!away || !home) return `<div class="retro-shell"><div class="retro-card"><div class="empty">Game data unavailable</div></div></div>`;

      const type = comp.status?.type || ev.status?.type || {};
      const state = type.state || 'pre';

      const badge =
        state === 'in' ? 'LIVE' :
        state === 'post' ? 'FINAL' :
        'UP NEXT';

      const logo = t =>
        t.team?.logo ||
        t.team?.logos?.[0]?.href ||
        t.team?.logos?.[0]?.url ||
        '';

      const record = t => t.records?.[0]?.summary || '';

      const getStat = (t, keys) => {
        const stats = t.statistics || [];
        for (const k of keys) {
          const found = stats.find(s =>
            String(s.name || '').toLowerCase() === k ||
            String(s.abbreviation || '').toLowerCase() === k
          );
          if (found?.displayValue !== undefined) return found.displayValue;
        }
        return '0';
      };

      const hits = t => getStat(t, ['hits', 'h']);
      const errors = t => getStat(t, ['errors', 'e']);

      const line = t => {
        const arr = Array.from({ length: 9 }, () => '0');
        (t.linescores || []).forEach(ls => {
          const i = Number(ls.period) - 1;
          if (i >= 0 && i < 9) arr[i] = ls.displayValue ?? ls.value ?? '0';
        });
        return arr;
      };

      const teamName = t => {
        const name = t.team?.displayName || t.team?.shortDisplayName || '';
        return name.toUpperCase();
      };

      const collectStrings = (obj, out = []) => {
        if (!obj) return out;

        if (typeof obj === 'string') {
          out.push(obj);
          return out;
        }

        if (Array.isArray(obj)) {
          obj.forEach(x => collectStrings(x, out));
          return out;
        }

        if (typeof obj === 'object') {
          Object.entries(obj).forEach(([key, value]) => {
            const k = String(key).toLowerCase();

            if (
              k.includes('winning') ||
              k.includes('win') ||
              k.includes('losing') ||
              k.includes('loss') ||
              k.includes('wp') ||
              k.includes('lp') ||
              k.includes('pitcher')
            ) {
              if (typeof value === 'string') out.push(`${key}: ${value}`);
              else collectStrings(value, out);
            } else if (
              k === 'description' ||
              k === 'displayvalue' ||
              k === 'displayname' ||
              k === 'headline' ||
              k === 'text' ||
              k === 'summary' ||
              k === 'name' ||
              k === 'shortname'
            ) {
              if (typeof value === 'string') out.push(value);
            } else if (typeof value === 'object') {
              collectStrings(value, out);
            }
          });
        }

        return out;
      };

      const cleanPitcherName = txt => {
        if (!txt) return '';

        return String(txt)
          .replace(/^winning pitcher\s*[:\-]\s*/i, '')
          .replace(/^losing pitcher\s*[:\-]\s*/i, '')
          .replace(/^winner\s*[:\-]\s*/i, '')
          .replace(/^loser\s*[:\-]\s*/i, '')
          .replace(/^wp\s*[:\-]\s*/i, '')
          .replace(/^lp\s*[:\-]\s*/i, '')
          .replace(/\s*\([^)]*\)\s*$/g, '')
          .trim();
      };

      const findPitcherDecision = (kind) => {
        const target = kind === 'win' ? 'winning' : 'losing';
        const short = kind === 'win' ? 'wp' : 'lp';

        const directCandidates = [
          comp?.situation?.[`${target}Pitcher`],
          comp?.situation?.[`${kind}Pitcher`],
          ev?.situation?.[`${target}Pitcher`],
          ev?.situation?.[`${kind}Pitcher`],
          comp?.[`${target}Pitcher`],
          comp?.[`${kind}Pitcher`],
          ev?.[`${target}Pitcher`],
          ev?.[`${kind}Pitcher`],
          st?.attributes?.[`${target}_pitcher`],
          st?.attributes?.[`${kind}_pitcher`],
          st?.attributes?.[`${short}`]
        ];

        for (const item of directCandidates) {
          const name =
            item?.athlete?.shortName ||
            item?.athlete?.displayName ||
            item?.shortName ||
            item?.displayName ||
            item?.name ||
            item;

          if (typeof name === 'string' && name.trim()) return cleanPitcherName(name);
        }

        const searchArea = [
          comp?.details,
          comp?.notes,
          comp?.headlines,
          comp?.leaders,
          comp?.situation,
          ev?.details,
          ev?.notes,
          ev?.headlines,
          ev?.leaders,
          ev?.competitions,
          st?.attributes
        ];

        const strings = collectStrings(searchArea, []);

        const patterns = kind === 'win'
          ? [
              /(?:winning pitcher|winner|wp)\s*[:\-]\s*([^,;|]+)/i,
              /\bW\s*[:\-]\s*([^,;|]+)/i
            ]
          : [
              /(?:losing pitcher|loser|lp)\s*[:\-]\s*([^,;|]+)/i,
              /\bL\s*[:\-]\s*([^,;|]+)/i
            ];

        for (const txt of strings) {
          for (const pattern of patterns) {
            const match = String(txt).match(pattern);
            if (match?.[1]) return cleanPitcherName(match[1]);
          }
        }

        const allObjects = [
          ...(Array.isArray(comp?.details) ? comp.details : []),
          ...(Array.isArray(comp?.notes) ? comp.notes : []),
          ...(Array.isArray(ev?.details) ? ev.details : []),
          ...(Array.isArray(ev?.notes) ? ev.notes : [])
        ];

        for (const obj of allObjects) {
          const typeText = String(obj?.type || obj?.name || obj?.label || '').toLowerCase();
          if (
            typeText.includes(target) ||
            typeText.includes(`${target}pitcher`) ||
            typeText === short
          ) {
            const val =
              obj?.athlete?.shortName ||
              obj?.athlete?.displayName ||
              obj?.player?.shortName ||
              obj?.player?.displayName ||
              obj?.displayValue ||
              obj?.displayName ||
              obj?.description ||
              obj?.headline ||
              obj?.text ||
              obj?.name;

            if (val) return cleanPitcherName(val);
          }
        }

        return '';
      };

      const fallbackWinner =
        away.winner ? away.team?.shortDisplayName :
        home.winner ? home.team?.shortDisplayName :
        'β€”';

      const fallbackLoser =
        away.winner ? home.team?.shortDisplayName :
        home.winner ? away.team?.shortDisplayName :
        'β€”';

      const winningPitcher = findPitcherDecision('win');
      const losingPitcher = findPitcherDecision('loss');

      const winDisplay = winningPitcher || fallbackWinner;
      const lossDisplay = losingPitcher || fallbackLoser;

      const decisionReady = state === 'post' || away.winner || home.winner || winningPitcher || losingPitcher;

      return `
        <div class="retro-shell">
          <div class="retro-card">

            <div class="header-section">
              <div class="top-title">
                <span>β˜…</span>
                <b>MAJOR LEAGUE BASEBALL</b>
                <span>β˜…</span>
              </div>

              <div class="stripe-row">
                <i></i>
                <div class="mlb-mark">
                  <img src="${variables.mlb_logo}">
                </div>
                <i></i>
              </div>
            </div>

            <div class="status-row">
              <span>β˜…</span>
              <div class="status-pill">${badge}</div>
              <span>β˜…</span>
            </div>

            <div class="matchup">
              <div class="team-card away">
                <div class="logo-circle navy">
                  ${logo(away) ? `<img src="${logo(away)}">` : ''}
                </div>
                <div class="abbr">${away.team?.abbreviation || 'AWY'}</div>
                <div class="team-name">${teamName(away)}</div>
                <div class="record">${record(away) || ''}</div>
              </div>

              <div class="score-area">
                <div class="score-grid">
                  <div class="score navy-text">${away.score ?? '0'}</div>
                  <div class="score-divider"></div>
                  <div class="score red-text">${home.score ?? '0'}</div>
                </div>
                <div class="vs-box"><span>VS</span></div>
              </div>

              <div class="team-card home">
                <div class="logo-circle red-bg">
                  ${logo(home) ? `<img src="${logo(home)}">` : ''}
                </div>
                <div class="abbr red-text">${home.team?.abbreviation || 'HME'}</div>
                <div class="team-name red-text">${teamName(home)}</div>
                <div class="record">${record(home) || ''}</div>
              </div>
            </div>

            <div class="linescore">
              <div class="ls-row head">
                <b></b>
                ${[1,2,3,4,5,6,7,8,9].map(i => `<span>${i}</span>`).join('')}
                <strong>R</strong>
                <strong>H</strong>
                <strong>E</strong>
              </div>

              <div class="ls-row">
                <b>${away.team?.abbreviation || 'AWY'}</b>
                ${line(away).map(x => `<span>${x}</span>`).join('')}
                <strong>${away.score ?? '0'}</strong>
                <strong>${hits(away)}</strong>
                <strong>${errors(away)}</strong>
              </div>

              <div class="ls-row home-row">
                <b>${home.team?.abbreviation || 'HME'}</b>
                ${line(home).map(x => `<span>${x}</span>`).join('')}
                <strong>${home.score ?? '0'}</strong>
                <strong>${hits(home)}</strong>
                <strong>${errors(home)}</strong>
              </div>
            </div>

            <div class="award-row">
              <div class="award">
                <small>β˜… WINNING PITCHER β˜…</small>
                <b>${winDisplay}</b>
                <span>${decisionReady ? (winningPitcher ? 'Pitching Decision' : 'Winning Team') : 'Pending'}</span>
              </div>

              <div class="ball-wrap">
                <div class="burst"></div>
                <div class="baseball">⚾</div>
              </div>

              <div class="award">
                <small>β˜… LOSING PITCHER β˜…</small>
                <b>${lossDisplay}</b>
                <span>${decisionReady ? (losingPitcher ? 'Pitching Decision' : 'Losing Team') : 'Pending'}</span>
              </div>
            </div>

            <div class="bottom-band">
              <span>β˜…β˜…β˜… GREAT MOMENTS.</span>
              <b>EST.<br>1869</b>
              <span>TIMELESS TRADITION. β˜…β˜…β˜…</span>
            </div>

          </div>
        </div>
      `;
    ]]]
card_mod:
  style: |
    * {
      box-sizing: border-box;
    }

    .retro-shell {
      padding: 14px;
      background:
        radial-gradient(circle at 20% 10%, rgba(255,255,255,.38), transparent 18%),
        radial-gradient(circle at 80% 82%, rgba(255,255,255,.22), transparent 22%),
        linear-gradient(180deg, #f9edcd 0%, #ead19c 100%);
      color: #0e2a44;
      font-family: Georgia, "Times New Roman", serif;
      overflow: hidden;
    }

    .retro-card {
      position: relative;
      overflow: hidden;
      padding: 30px 18px 20px;
      border-radius: 30px;
      border: 7px solid #0e2a44;
      background:
        radial-gradient(rgba(14,42,68,.11) 1px, transparent 1px),
        radial-gradient(rgba(194,56,51,.10) 1px, transparent 1px),
        linear-gradient(180deg, #faedcc, #efd8a6);
      background-size: 10px 10px, 14px 14px, auto;
      box-shadow:
        inset 0 0 0 2px #c23833,
        inset 0 0 0 12px rgba(255,255,255,.22),
        0 14px 26px rgba(0,0,0,.20);
    }

    .retro-card::before {
      content: "";
      position: absolute;
      inset: 14px;
      border: 1px solid rgba(194,56,51,.55);
      border-radius: 20px;
      pointer-events: none;
      z-index: 1;
    }

    .header-section,
    .status-row,
    .matchup,
    .linescore,
    .award-row,
    .bottom-band {
      position: relative;
      z-index: 2;
    }

    .header-section {
      display: grid;
      grid-template-rows: auto auto;
      gap: 8px;
      margin-bottom: 10px;
    }

    .top-title {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 8px;
      margin: 0 40px 4px 40px;
      color: #0e2a44;
      font-size: 10px;
      font-weight: 900;
      letter-spacing: .12em;
      white-space: nowrap;
      overflow: visible;
      text-align: center;
    }

    .top-title b {
      min-width: 0;
      overflow: visible;
      text-overflow: clip;
      white-space: nowrap;
    }

    .top-title span {
      color: #c23833;
      flex: 0 0 auto;
      font-size: 14px;
    }

    .stripe-row {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
      align-items: center;
      gap: 8px;
      margin: 0 32px 4px;
    }

    .stripe-row i {
      height: 20px;
      background:
        linear-gradient(
          #c23833 0 5px,
          transparent 5px 8px,
          #0e2a44 8px 13px,
          transparent 13px 16px,
          #c23833 16px 20px
        );
    }

    .mlb-mark {
      width: 72px;
      height: 46px;
      border-radius: 7px;
      background: transparent;
      display: flex;
      align-items: center;
      justify-content: center;
      overflow: visible;
      flex: 0 0 auto;
    }

    .mlb-mark img {
      width: 72px;
      height: 46px;
      object-fit: contain;
      display: block;
      filter: drop-shadow(0 1px 1px rgba(0,0,0,.2));
    }

    .status-row {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 18px;
      color: #c23833;
      font-size: 22px;
      font-weight: 900;
      margin-top: 2px;
      margin-bottom: 12px;
    }

    .status-pill {
      min-width: 176px;
      padding: 8px 30px;
      border-radius: 999px;
      background: #0e2a44;
      color: #f8ebc9;
      text-align: center;
      font-size: 18px;
      font-weight: 900;
      letter-spacing: .22em;
      white-space: nowrap;
    }

    .matchup {
      display: grid;
      grid-template-columns: minmax(0, 1fr) minmax(120px, 160px) minmax(0, 1fr);
      gap: 4px;
      align-items: center;
      text-align: center;
      margin-bottom: 18px;
    }

    .team-card {
      min-width: 0;
      overflow: visible;
      display: grid;
      justify-items: center;
    }

    .logo-circle {
      width: 86px;
      height: 86px;
      margin: 0 auto 12px;
      border-radius: 50%;
      border: 2px solid #b8322e;
      outline: 2px solid #0e2a44;
      outline-offset: 4px;
      background: #0e2a44;
      padding: 14px;
      display: flex;
      align-items: center;
      justify-content: center;
    }

    .logo-circle.navy {
      background: #0e2a44;
      color: #0e2a44;
    }

    .logo-circle.red-bg {
      background: #c23833;
      color: #c23833;
    }

    .logo-circle img {
      width: 100%;
      height: 100%;
      object-fit: contain;
      filter: drop-shadow(0 1px 1px rgba(0,0,0,.18));
    }

    .abbr {
      font-size: 40px;
      line-height: .9;
      font-weight: 900;
      letter-spacing: .03em;
      white-space: nowrap;
      font-family: Georgia, "Times New Roman", serif;
    }

    .red-text {
      color: #c23833;
    }

    .team-name {
      margin-top: 6px;
      color: #c23833;
      max-width: 100%;
      font-size: 11px;
      font-weight: 900;
      letter-spacing: .05em;
      text-transform: uppercase;
      white-space: normal;
      overflow: hidden;
      text-align: center;
      font-family: Georgia, "Times New Roman", serif;
    }

    .record {
      margin-top: 9px;
      color: #0e2a44;
      font-family: Georgia, "Times New Roman", serif;
      font-size: 14px;
      font-weight: 500;
      white-space: nowrap;
      position: relative;
      display: inline-flex;
      align-items: center;
      gap: 7px;
    }

    .record::before,
    .record::after {
      content: "";
      width: 28px;
      height: 1px;
      background: rgba(14,42,68,.75);
      display: block;
    }

    .score-area {
      position: relative;
      min-width: 0;
      display: grid;
      place-items: center;
    }

    .score-grid {
      width: 100%;
      display: grid;
      grid-template-columns: 1fr 1px 1fr;
      align-items: center;
      gap: 10px;
    }

    .score {
      font-family: Impact, "Arial Narrow", "Roboto Condensed", sans-serif;
      font-size: 50px;
      line-height: .78;
      font-weight: 10;
      letter-spacing: .01em;
      transform: scaleY(2.2);
      transform-origin: center;
    }

    .navy-text {
      color: #0e2a44;
    }

    .score-divider {
      width: 1px;
      height: 120px;
      background: rgba(14,42,68,.56);
    }

    .vs-box {
      position: absolute;
      width: 38px;
      height: 38px;
      transform: rotate(45deg);
      border: 2px solid rgba(14,42,68,.72);
      background: #f8ebc9;
      color: #0e2a44;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 14px;
      font-style: italic;
      font-weight: 900;
    }

    .vs-box span {
      display: block;
      transform: rotate(-45deg);
    }

    .linescore {
      width: 100%;
      border: 3px solid #0e2a44;
      border-radius: 14px;
      overflow: hidden;
      background: rgba(255,255,255,.14);
      margin-bottom: 22px;
    }

    .ls-row {
      display: grid;
      grid-template-columns: 70px repeat(9, minmax(14px, 1fr)) 30px 30px 30px;
      border-top: 1px solid rgba(14,42,68,.42);
    }

    .ls-row.head {
      border-top: 0;
    }

    .ls-row > * {
      min-width: 0;
      padding: 8px 2px;
      text-align: center;
      border-right: 1px solid rgba(14,42,68,.34);
      font-size: 14px;
      line-height: 1;
      font-weight: 900;
      white-space: nowrap;
      overflow: hidden;
    }

    .ls-row b {
      background: #0e2a44;
      color: #f8ebc9;
      font-size: 17px;
    }

    .ls-row.home-row b {
      background: #c23833;
    }

    .award-row {
      display: grid;
      grid-template-columns: minmax(0, 1fr) 72px minmax(0, 1fr);
      align-items: center;
      gap: 16px;
      text-align: center;
      margin-bottom: 22px;
    }

    .award {
      min-width: 0;
      border: 1px solid rgba(14,42,68,.45);
      padding: 13px 10px;
      min-height: 84px;
    }

    .award small {
      display: block;
      color: #c23833;
      font-size: 12px;
      letter-spacing: .09em;
      font-weight: 900;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .award b {
      display: block;
      margin-top: 7px;
      font-size: 20px;
      font-weight: 900;
      color: #0e2a44;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }

    .award span {
      display: block;
      margin-top: 5px;
      font-family: Arial, sans-serif;
      color: rgba(14,42,68,.65);
      font-size: 11px;
      font-weight: 800;
      white-space: nowrap;
    }

    .ball-wrap {
      position: relative;
      width: 72px;
      height: 72px;
      display: grid;
      place-items: center;
    }

    .burst {
      position: absolute;
      inset: 0;
      background:
        conic-gradient(
          from 0deg,
          transparent 0 8deg,
          rgba(14,42,68,.28) 8deg 10deg,
          transparent 10deg 22deg
        );
      border-radius: 50%;
      opacity: .75;
    }

    .baseball {
      position: relative;
      z-index: 1;
      font-size: 42px;
      filter: drop-shadow(0 2px 2px rgba(0,0,0,.18));
    }

    .bottom-band {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
      align-items: center;
      gap: 12px;
      background: #0e2a44;
      color: #f8ebc9;
      border-top: 5px solid #c23833;
      border-bottom: 5px solid #c23833;
      padding: 11px 14px;
      font-size: 7px;
      font-weight: 900;
      letter-spacing: .10em;
      overflow: hidden;
    }

    .bottom-band span {
      min-width: 0;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      text-align: center;
    }

    .bottom-band b {
      background: #f8ebc9;
      color: #c23833;
      border: 1px solid rgba(14,42,68,.45);
      padding: 7px 14px;
      font-size: 13px;
      line-height: 1.05;
      white-space: nowrap;
      text-align: center;
    }

Thank you, the possibilities are endless

Hey everyone,

I just published Sports Ticker v0.0.16, which brings a much better setup experience and some important improvements for keeping dashboards stable.

Sports Ticker is a Home Assistant integration that pulls ESPN scoreboard data and exposes it as sensors for Lovelace dashboards, ticker cards, scoreboard cards, gamecast-style layouts, and team-focused views.

What’s new in v0.0.16

The biggest change is the new guided setup flow.

Instead of picking everything from one basic dropdown, the integration now walks you through:

  1. Choose Sports & Leagues

  2. Choose Favorite Teams

This makes it easier to configure only the leagues you want and then select a favorite team for each selected league.

Favorite Team Support

You can now pick a favorite team per league during setup or from the integration options.

Example setup:


MLB β†’ Atlanta Braves
NFL β†’ Atlanta Falcons
MLS β†’ Atlanta United FC
Premier League β†’ Manchester United

Each raw scoreboard sensor now exposes favorite-team attributes:


favorite_team: ATL
favorite_team_name: Atlanta Braves
has_favorite_team: true

This makes it easier to build dashboards that highlight your team, sort favorite games first, or create team-focused cards.

Expanded Soccer Support

This update also adds more soccer options.

Supported soccer leagues now include:


MLS
Premier League
LaLiga
Bundesliga
Serie A
Ligue 1
Champions League

These create sensors such as:


sensor.espn_mls_scoreboard_raw
sensor.espn_epl_scoreboard_raw
sensor.espn_laliga_scoreboard_raw
sensor.espn_bundesliga_scoreboard_raw
sensor.espn_seriea_scoreboard_raw
sensor.espn_ligue1_scoreboard_raw
sensor.espn_ucl_scoreboard_raw

Cached Data / Better Dashboard Stability

Another big improvement is last-good-data caching.

If ESPN is temporarily unavailable, times out, or returns bad data, the sensor will keep showing the last valid scoreboard data instead of going unavailable or blank.

The sensor will expose cache/freshness attributes like:


stale: true
source: cache
last_successful_update: "2026-05-05T23:00:00+00:00"
last_attempted_update: "2026-05-05T23:05:00+00:00"
last_error: "Unexpected status 503"

When the data is fresh, it will show:


stale: false
source: espn

This should help keep ticker cards and dashboard layouts from breaking when an endpoint has a temporary issue.

Updating

Update through HACS, then restart Home Assistant.

After updating, open:


Settings β†’ Devices & services β†’ Sports Ticker β†’ Configure

From there, you can select your leagues and set favorite teams.

Current version


v0.0.16

GitHub repo:


https://github.com/LiquidFXX/sports-ticker

I’m continuing to work on more polished Lovelace examples and better favorite-team-focused cards. Feedback, issues, and card examples are welcome.