Skip to content

Instantly share code, notes, and snippets.

@brandon-fryslie
Created June 6, 2025 19:34
Show Gist options
  • Save brandon-fryslie/9829f35c9a570e3b84035375436c0aa3 to your computer and use it in GitHub Desktop.
Save brandon-fryslie/9829f35c9a570e3b84035375436c0aa3 to your computer and use it in GitHub Desktop.
A highly functional presence monitoring card
# Note: This is packaged into a dashboard for ease of use but you could put the card into any existing dashboard
views:
- type: sections
max_columns: 8
title: Presence Card
path: presence
dense_section_placement: true
sections:
- type: grid
grid_options:
columns: full
cards:
- type: custom:button-card
show_name: false
show_state: false
show_label: true
triggers_update: all
extra_styles: |
.sensor-row.active {
text-shadow: 0 0 5px #ffffff80;
font-weight: bold;
}
.sensor-row.error {
text-shadow: 0 0 5px #800020;
text-decoration: line-through;
text-decoration-color: red;
}
.sensor-row {
text-shadow: 0 0 5px #fffff80, 0 0 10px #0073e6;
}
label: |
[[[
const FADE_MAX_SECONDS = 6 * 60 * 60; // 6 hours
const MAIN_FADE_MIN_OPACITY = 0.5;
const MAIN_FADE_MAX_OPACITY = 0.8;
const AGE_TEXT_COLOR_BASE = [200, 200, 200];
const AGE_FADE_MIN_OPACITY = 0.3;
const AGE_FADE_MAX_OPACITY = 0.8;
const MIN_RATIO = 0.001;
const DATETIME_NOW = Date.now();
const GRADIENT_STOPS = [
{ time: 1 * 60, color: [0, 255, 0] }, // Green
{ time: 5 * 60, color: [166, 255, 0] }, // Lime
{ time: 15 * 60, color: [255, 255, 0] }, // Yellow
{ time: 60 * 60, color: [255, 165, 0] }, // Orange
{ time: 360 * 60, color: [255, 51, 0] }, // Red
{ time: Infinity, color: [102, 102, 102] } // Gray
];
function interpolateColor(c1, c2, t) {
const r = Math.round(c1[0] + (c2[0] - c1[0]) * t);
const g = Math.round(c1[1] + (c2[1] - c1[1]) * t);
const b = Math.round(c1[2] + (c2[2] - c1[2]) * t);
return [r, g, b];
}
function getColor(isError, seconds) {
if (isError) {
return GRADIENT_STOPS[GRADIENT_STOPS.length - 2].color;
}
for (let i = 0; i < GRADIENT_STOPS.length - 1; i++) {
const curr = GRADIENT_STOPS[i];
const next = GRADIENT_STOPS[i + 1];
if (seconds <= next.time) {
const localRatio = (seconds - curr.time) / (next.time - curr.time);
return interpolateColor(curr.color, next.color, Math.max(0, Math.min(1, localRatio)));
}
}
return GRADIENT_STOPS[GRADIENT_STOPS.length - 1].color;
}
function getAgeText(isActive, isError, seconds) {
if (isError) {
return '(err)'
}
return isActive
? '(now)'
: (seconds < 60)
? '(<1m ago)'
: (seconds < 3600)
? `(~${Math.floor(seconds / 60)}m ago)`
: (seconds < 43200)
? `(~${Math.floor(seconds / 3600)}hr ago)`
: '(>12hr ago)';
}
function buildRow(rowData) {
const {
entityId,
finalRatio,
friendlyName,
isActive,
isError,
seconds,
time,
} = rowData;
const rowClass = `sensor-row ${isActive ? ' active' : ''} ${isError ? ' error' : ''}`;
const rgb = getColor(isError, seconds);
const ageOpacity = AGE_FADE_MAX_OPACITY - (AGE_FADE_MAX_OPACITY - AGE_FADE_MIN_OPACITY) * finalRatio;
const ageColor = `rgba(${AGE_TEXT_COLOR_BASE[0]}, ${AGE_TEXT_COLOR_BASE[1]}, ${AGE_TEXT_COLOR_BASE[2]}, ${ageOpacity.toFixed(2)})`;
const ageText = getAgeText(isActive, isError, seconds)
const baseColor = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
const mainOpacity = isActive ? 1.0 : MAIN_FADE_MAX_OPACITY - (MAIN_FADE_MAX_OPACITY - MAIN_FADE_MIN_OPACITY) * finalRatio;
const sensorNameColor = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${mainOpacity.toFixed(2)})`;
// This just removes some extraneous text from the labels so we can fit more useful data on screen
const name = friendlyName
.replace(/Sensor|PIR|Temp|Occupancy/gi, '')
.trim();
return `<div class="${rowClass}" style="text-align: left; cursor: pointer;" onclick="window.dispatchEvent(new CustomEvent('hass-more-info', { detail: { entityId: '${entityId}' } }))">` +
`<span class="timestamp" style="color:${baseColor};">${time}:</span> ` +
`<span class="sensor-name" style="color:${sensorNameColor};">${name}</span>` +
`<span style="color:${ageColor}; font-size:80%; font-weight:bold; margin-left:8px;">${ageText}</span>` +
`</div>`;
}
////////////////////////////////////////////////////////////////////
// main
////////////////////////////////////////////////////////////////////
const motion = Object.values(states)
.filter(e =>
(e.entity_id.match(/^binary_sensor/) && e.attributes.friendly_name?.match(/Occupancy|Presence/)) ||
(e.entity_id.match(/^binary_sensor/) && e.entity_id.match(/_motion/))
)
.sort((a, b) => new Date(b.last_changed) - new Date(a.last_changed));
if (motion.length === 0) {
return '<div style="color: gray; text-align: left;">Currently no motion detected.</div>';
}
// Encapsulate per-row calculations and build map of data needed to render each row
const rowDatas = motion.map((s) => {
const seconds = (DATETIME_NOW - new Date(s.last_changed).getTime()) / 1000;
const clamped = Math.min(Math.max(seconds, 0), FADE_MAX_SECONDS);
const ratio = clamped / FADE_MAX_SECONDS;
const logRatio = Math.log10(ratio * (1 - MIN_RATIO) + MIN_RATIO);
const finalRatio = (logRatio - Math.log10(MIN_RATIO)) / (0 - Math.log10(MIN_RATIO));
const time = new Date(s.last_changed).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const rowData = {
seconds,
// There is an error case where presence sensors get 'stuck' on. Since this is not very useful,
// we are going to assume anything 'on' for more than 1 hour continuously is in an error state and sort
// it to the bottom
isActive: s.state === 'on',
// stuck 'on', and hasn't been updated in over an hour. in practice this never happens unless they're stuck
isError: s.state === 'on' && seconds >= 3600,
entityId: s.entityId,
time,
finalRatio,
friendlyName: s.attributes.friendly_name,
};
return rowData;
});
// Partition list so active sensors are at the top, regardless of trigger time
const partitioned = [
...rowDatas.filter(item => item.isActive && !item.isError),
...rowDatas.filter(item => !item.isActive && !item.isError),
...rowDatas.filter(item => item.isError),
];
// Render each row and join into a single string
return `
${partitioned.map(buildRow).join("")}
`
]]]
styles:
card:
- padding: 16px
- font-size: 15px
- font-family: monospace
icon: mdi:account
theme: visionos
cards: []
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment