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.
note: emoji icons do not copy from the card code
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