Created
June 6, 2025 19:34
-
-
Save brandon-fryslie/9829f35c9a570e3b84035375436c0aa3 to your computer and use it in GitHub Desktop.
A highly functional presence monitoring card
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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