Meater Graph Card

Intro

This custom Home Assistant card brings the MEATER cooking experience right into your dashboard with a sleek, dark-themed interface inspired by the original app. It displays live internal, target, and ambient temperatures, a visual progress gauge with estimated time remaining, and quick cook status details at a glance. Built for multi-probe setups, it’s designed to look great in modern dashboards while staying practical for real cooks.

Prerequisites:

custom:button-card

note: emoji icons do not copy from the card code

Meater Probe 1.yml
Copy to clipboard
type: custom:button-card
entity: sensor.food_probe_1_internal_temperature
show_name: false
show_state: false
show_icon: false

triggers_update:
  - sensor.food_probe_1_ambient_temperature
  - sensor.food_probe_1_cook_state
  - sensor.food_probe_1_cooking
  - sensor.food_probe_1_internal_temperature
  - sensor.food_probe_1_peak_temperature
  - sensor.food_probe_1_target_temperature
  - sensor.food_probe_1_time_elapsed
  - sensor.food_probe_1_time_remaining

styles:
  card:
    - padding: 12px 12px 10px 12px
    - border-radius: 22px
    - background: "linear-gradient(180deg,#17181c 0%, #111216 100%)"
    - box-shadow: 0 10px 24px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.03)
    - color: "#f2f2f2"
    - height: 478px
    - overflow: hidden
  grid:
    - grid-template-areas: "\"main\""
    - grid-template-columns: 1fr
    - grid-template-rows: 1fr
  custom_fields:
    main:
      - align-self: stretch
      - justify-self: stretch

custom_fields:
  main: |
    [[[
      const s = (id) => states[id]?.state;
      const isMissing = (v) => v === undefined || v === null;
      const isBad = (v) => {
        if (isMissing(v)) return true;
        const x = String(v).toLowerCase();
        return ['unknown','unavailable','none','null','nan'].includes(x);
      };
      const n = (id) => {
        const raw = s(id);
        if (isBad(raw)) return NaN;
        const v = parseFloat(raw);
        return Number.isFinite(v) ? v : NaN;
      };

      const ambient = n('sensor.food_probe_1_ambient_temperature');
      const internal = n('sensor.food_probe_1_internal_temperature');
      const target = n('sensor.food_probe_1_target_temperature');
      const peak = n('sensor.food_probe_1_peak_temperature');

      const cookStateRaw = s('sensor.food_probe_1_cook_state');
      const cookingRaw = s('sensor.food_probe_1_cooking');
      const elapsedRaw = s('sensor.food_probe_1_time_elapsed');
      const remainingRaw = s('sensor.food_probe_1_time_remaining');

      const connected =
        !isBad(cookStateRaw) ||
        !isBad(cookingRaw) ||
        Number.isFinite(internal) ||
        Number.isFinite(target) ||
        Number.isFinite(ambient);

      const cookState = isBad(cookStateRaw) ? (connected ? 'started' : 'Disconnected') : String(cookStateRaw);
      const cooking = isBad(cookingRaw) ? (connected ? 'No Cook' : 'Probe Offline') : String(cookingRaw);

      const fmtDeg = (v) => Number.isFinite(v) ? `${Math.round(v)}°` : '--';
      const fmtDegF = (v) => Number.isFinite(v) ? `${Math.round(v)}°F` : '--';

      function formatMinutesValue(val) {
        const mins = parseFloat(val);
        if (!Number.isFinite(mins)) return null;
        if (mins <= 0) return '0m';
        if (mins < 60) return `${Math.round(mins)}m`;
        const h = Math.floor(mins / 60);
        const m = Math.round(mins % 60);
        return m ? `${h}h ${m}m` : `${h}h`;
      }

      function formatDurationFromSeconds(sec) {
        if (!Number.isFinite(sec)) return null;
        if (sec <= 0) return '0m';
        const totalMin = Math.floor(sec / 60);
        if (totalMin < 60) return `${totalMin}m`;
        const h = Math.floor(totalMin / 60);
        const m = totalMin % 60;
        return m ? `${h}h ${m}m` : `${h}h`;
      }

      function formatElapsed(raw) {
        if (isBad(raw)) return '';
        const str = String(raw).trim();

        if (/^-?\d+(\.\d+)?$/.test(str)) return formatMinutesValue(str) || str;

        const ts = Date.parse(str);
        if (!Number.isNaN(ts)) {
          const diffSec = Math.floor((Date.now() - ts) / 1000);
          return formatDurationFromSeconds(diffSec) || str;
        }

        const m = str.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
        if (m) {
          let h = 0, mm = 0, ss = 0;
          if (m[3] !== undefined) {
            h = parseInt(m[1], 10); mm = parseInt(m[2], 10); ss = parseInt(m[3], 10);
          } else {
            mm = parseInt(m[1], 10); ss = parseInt(m[2], 10);
          }
          return formatDurationFromSeconds(h*3600 + mm*60 + ss) || str;
        }

        return str;
      }

      function formatRemaining(raw) {
        if (isBad(raw)) return 'Unknown';
        const str = String(raw).trim();

        if (/^-?\d+(\.\d+)?$/.test(str)) return formatMinutesValue(str) || str;

        let m = str.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
        if (m) {
          let h = 0, mm = 0, ss = 0;
          if (m[3] !== undefined) {
            h = parseInt(m[1], 10); mm = parseInt(m[2], 10); ss = parseInt(m[3], 10);
          } else {
            mm = parseInt(m[1], 10); ss = parseInt(m[2], 10);
          }
          return formatDurationFromSeconds(h * 3600 + mm * 60 + ss) || str;
        }

        const ts = Date.parse(str);
        if (!Number.isNaN(ts)) {
          const diffSec = Math.floor((ts - Date.now()) / 1000);
          if (diffSec <= 0) return '0m';
          return formatDurationFromSeconds(diffSec) || str;
        }

        return str;
      }

      const elapsedText = formatElapsed(elapsedRaw);
      const remainingText = connected ? formatRemaining(remainingRaw) : 'Unknown';

      let pct = 0;
      if (Number.isFinite(target) && target > 0 && Number.isFinite(internal)) {
        pct = Math.max(0, Math.min(100, (internal / target) * 100));
      }

      let ambPct = 0;
      if (Number.isFinite(target) && target > 0 && Number.isFinite(ambient)) {
        ambPct = Math.max(0, Math.min(100, (ambient / target) * 100));
      }

      const cx = 170, cy = 182, r = 132;
      const pathD = `M ${cx-r} ${cy} A ${r} ${r} 0 0 1 ${cx+r} ${cy}`;
      const circumference = Math.PI * r;
      const progressLen = Math.max(0, Math.min(circumference, (pct / 100) * circumference));

      function pointOnArc(percent, radiusAdjust=0) {
        const angle = Math.PI - (Math.PI * percent / 100);
        const rr = r + radiusAdjust;
        return { x: cx + rr * Math.cos(angle), y: cy - rr * Math.sin(angle) };
      }

      function triangleAt(pctVal, color, size=9) {
        const angle = Math.PI - (Math.PI * pctVal / 100);
        const ux = Math.cos(angle), uy = -Math.sin(angle);
        const tx = Math.sin(angle), ty = Math.cos(angle);
        const p = pointOnArc(pctVal, 5);
        const tip = { x: p.x + ux * size, y: p.y + uy * size };
        const b1 = { x: p.x - ux * 1.5 + tx * (size*0.95), y: p.y - uy * 1.5 + ty * (size*0.95) };
        const b2 = { x: p.x - ux * 1.5 - tx * (size*0.95), y: p.y - uy * 1.5 - ty * (size*0.95) };
        return `<polygon points="${tip.x},${tip.y} ${b1.x},${b1.y} ${b2.x},${b2.y}" fill="${color}" opacity="0.98"/>`;
      }

      let peakPct = 0;
      if (Number.isFinite(target) && target > 0 && Number.isFinite(peak)) {
        peakPct = Math.max(0, Math.min(100, (peak / target) * 100));
      }
      const peakLen = Math.max(0, Math.min(circumference, (peakPct / 100) * circumference));

      let statusParts = [];
      if (connected) {
        if (!isBad(cookStateRaw)) statusParts.push(String(cookStateRaw));
        if (Number.isFinite(peak)) statusParts.push(`Peak ${fmtDegF(peak)}`);
        if (elapsedText) statusParts.push(`Elapsed ${elapsedText}`);
      } else {
        statusParts = ['Waiting for probe data'];
      }
      const statusLine = statusParts.join(' • ');

      const badgeBg = connected ? 'rgba(255,255,255,0.06)' : 'rgba(255,102,102,0.12)';
      const badgeBorder = connected ? 'rgba(255,255,255,0.08)' : 'rgba(255,102,102,0.25)';
      const badgeColor = connected ? '#d7dbe3' : '#ff9d9d';

      return `
      <div style="display:flex; flex-direction:column; height:100%; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;">

        <div style="display:flex; gap:10px; margin-bottom:8px;">
          ${[
            {label:'Internal', value:fmtDeg(internal), bg:'linear-gradient(180deg,#d930e1,#b723ce)'},
            {label:'Target',   value:fmtDeg(target),   bg:'linear-gradient(180deg,#44b8fc,#2f9ce9)'},
            {label:'Ambient',  value:fmtDeg(ambient),  bg:'linear-gradient(180deg,#32e05a,#20cf4d)'}
          ].map(b => `
            <div style="flex:1;height:56px;border-radius:18px;background:${b.bg};color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 4px 12px rgba(0,0,0,.22);">
              <div style="font-size:16px; font-weight:700; line-height:1.05; letter-spacing:0.2px;">${b.value}</div>
              <div style="font-size:9px; opacity:.96; margin-top:3px;">${b.label}</div>
            </div>
          `).join('')}
        </div>

        <div style="position:relative; display:flex; justify-content:center; margin-top:0;">
          <div style="
            position:absolute; right:4px; top:2px; z-index:2;
            padding:4px 10px; border-radius:999px;
            background:${badgeBg}; border:1px solid ${badgeBorder};
            color:${badgeColor}; font-size:11px; font-weight:700;
            letter-spacing:0.3px; line-height:1;
            box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
          ">Probe 1</div>

          <div style="position:relative; width:320px; height:236px;">
            <svg width="320" height="236" viewBox="0 0 340 270">
              <defs>
                <linearGradient id="tempArcGradient" x1="0%" y1="0%" x2="100%" y2="0%">
                  <stop offset="0%" stop-color="#5b58ff"/>
                  <stop offset="25%" stop-color="#7a57f6"/>
                  <stop offset="52%" stop-color="#b03ee7"/>
                  <stop offset="78%" stop-color="#ff4e6c"/>
                  <stop offset="100%" stop-color="#ea2727"/>
                </linearGradient>
                <filter id="tempArcGlow" x="-50%" y="-50%" width="200%" height="200%">
                  <feDropShadow dx="0" dy="2" stdDeviation="3.3" flood-color="#000000" flood-opacity="0.35"/>
                  <feDropShadow dx="0" dy="0" stdDeviation="2.8" flood-color="#ff5874" flood-opacity="0.22"/>
                </filter>
                <filter id="markerShadow" x="-50%" y="-50%" width="200%" height="200%">
                  <feDropShadow dx="0" dy="1" stdDeviation="1.2" flood-color="#000" flood-opacity="0.45"/>
                </filter>
              </defs>

              <path d="${pathD}" fill="none" stroke="#0c0d11" stroke-width="38" stroke-linecap="round" opacity="0.95"/>
              <path d="${pathD}" fill="none" stroke="#2a2d34" stroke-width="30" stroke-linecap="round"/>
              <path d="${pathD}" fill="none" stroke="#3b3f49" stroke-width="24" stroke-linecap="round" opacity="0.85"/>
              <path d="${pathD}" fill="none" stroke="rgba(255,255,255,0.10)" stroke-width="16" stroke-linecap="round" stroke-dasharray="${peakLen} ${circumference}"/>
              <path d="${pathD}" fill="none" stroke="url(#tempArcGradient)" stroke-width="22" stroke-linecap="round" stroke-dasharray="${progressLen} ${circumference}" filter="url(#tempArcGlow)"/>
              <path d="${pathD}" fill="none" stroke="rgba(255,255,255,0.20)" stroke-width="8" stroke-linecap="round" stroke-dasharray="${Math.max(0, progressLen-3)} ${circumference}"/>

              <g filter="url(#markerShadow)">
                ${triangleAt(0, '#c933e5', 9)}
                ${triangleAt(Math.max(0, Math.min(100, ambPct)), '#22d84f', 9)}
                ${triangleAt(100, '#33aefd', 9)}
              </g>
            </svg>

            <div style="position:absolute; inset:0; top:118px; text-align:center;">
              <div style="font-size:31px; font-weight:700; color:#ffffff; letter-spacing:.2px; line-height:1.05; text-shadow: 0 1px 6px rgba(0,0,0,0.35); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; padding:0 12px;">
                ${remainingText}
              </div>
              <div style="font-size:11px; color:#9ea3ad; margin-top:5px;">remaining</div>
            </div>
          </div>
        </div>

        <div style="margin-top:-2px; text-align:center;">
          <div style="font-size:24px; font-weight:650; color:#f0f0f1; line-height:1.08; letter-spacing:0.1px;">
            ${cooking}
          </div>
          <div style="font-size:11px; color:#aab0bb; margin-top:6px; line-height:1.3; padding:0 8px;">
            ${statusLine}
          </div>
        </div>

        <div style="margin-top:auto; padding-top:8px; padding-bottom:6px; display:flex; justify-content:space-around; color:#ff6c6c;">
          <div style="text-align:center; min-width:68px;">
            <div style="font-size:20px; line-height:1;">⏱️</div>
            <div style="font-size:11px; font-weight:600; margin-top:4px; color:#ff7a7a;">Cook</div>
          </div>
          <div style="text-align:center; min-width:68px;">
            <div style="font-size:20px; line-height:1;">🌡️</div>
            <div style="font-size:11px; font-weight:600; margin-top:4px; color:#ff7a7a;">Monitor</div>
          </div>
          <div style="text-align:center; min-width:68px;">
            <div style="font-size:20px; line-height:1;">🍖</div>
            <div style="font-size:11px; font-weight:600; margin-top:4px; color:#ff7a7a;">Meat</div>
          </div>
        </div>
      </div>
      `;
    ]]]

tap_action:
  action: more-info

hold_action:
  action: more-info