Presence Card - Highly functional presence monitoring (based on custom button card)

presence-ui-demo_fast
This is a screen recording of the card in action (playing at 4x speed). There were 3 people at home, mostly together but I also walked around a bit to trigger some other sensors. Sensors are mainly Phillips Hue purchased used on ebay (~$10 each). They seem to be the most reliable. The ones that say “Motion” are motion alerts from Scrypted cameras via MQTT.

Here is a project I’ve been working on (with my buddy ChatGPT / aider). I was looking for a better way of getting a more ‘glanceable’ view of my presence sensors than the list of entity cards I had. There wasn’t any way to automatically order entities on an entity card by ‘last_changed’ so I went exploring other options. I was inspired by another post on here, and after some work, I think I’ve come up with something that will hopefully be useful to others.

As this is based on custom-button-card, you will need to have that installed. This isn’t packaged in any way, as I wrote the code directly inline in the card. But what it lacks in packaging it makes up for in simplicity: just create a new dashboard and paste the yaml in. You can also copy the yaml for the card directly into an existing dashboard if you prefer.

You also don’t have to worry that I’m going to abandon the project - there’s not much to abandon! We depend on 3 things from custom:button-card: the ‘label’ property (and JS templating of this property, this is basically everything), the ‘triggers_update’ property (this is how we rerender when state changes), and the extra_style property (adds a bit of flair but most styles are inline). If ‘label’ and ‘triggers_update’ continue to function, this card will work.

**Note: you will almost certainly want to customize this a bit to fit your situation. If you don’t understand how to modify these things, I would recommend using ChatGPT.
**

This is where you control which entities are shown:

              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));          

&& means AND
|| means OR
This code means I am getting all entities where the entity_id starts with ‘binary_sensor’ and the friendly name contains ‘Occupancy’ or ‘Presence’, or where the entity_id ends with ‘_motion’. This is specific to how I have named my sensors, either rename your sensors or update the code as needed

This strips some extraneous text from entity names to better fit the card (e.g., ‘Side Door E Occupancy Sensor’ becomes ‘Side Door E’):

                const name = friendlyName
                  .replace(/Sensor|PIR|Temp|Occupancy/gi, '')
                  .trim();

Depending on your sensor names you may want to add more here. It’s purely a visual thing but prevents text from overflowing

A couple addl things:

  • If you want to change the colors they should be pretty easy to find. There are 6 colors and we use a log based function to determine a gradient between adjacent colors. All of this stuff is in constants towards the top of the JS code. I personally wouldn’t mess with any of the mathy stuff but you do you
  • Adding a header should be trivial if you want, just put whatever HTML you want onto line 172-173 (the final return statement)
  • I have a problem where sometimes sensors will get stuck ‘on’ (usually poor zigbee signal) so I automatically sort those to the bottom and label them as ‘err’ (those are the red ones). Any sensor that’s been ‘on’ for more than an hour straight is determined to be in an error state (these are mostly PIR sensors so ~5 minutes is more typical)
  • Any sensors that are currently “on” are sorted to the top, and have a light bright outline. The text says ‘now’. Once they go to ‘off’, they dim a bit and are sorted to the middle (below the ‘on’ sensors). the gray text is the amount of time since they were last triggered (last_changed)
  • Opacity decreases the longer they’ve been off
  • ‘last_changed’ is updated when you restart HA so unfortunately if you’ve reset within the last 12 hours, that will be the time shown for your sensors. This is an HA problem and apparently working as designed. it is what it is
  • Within each section (on, off, err), everything is sorted by ‘last_changed’ so the most recently triggered sensors are at the top (but ‘on’ sensors will always be first)
  • All of it updates in realtime and seems performant! This is a big one. It’s very snappy and takes almost no resources to render since it’s just s with text. It seems to always rerender whenever any of the sensors is updated, with no perceptible lag. This took a lot more time than you might expect, and I think this line is what made it happen: ‘triggers_update: all’

What could be improved?

  • Icons would be cool
  • I’ve experimented with adding Contact (door) sensors to this but they need to be treated a little differently to make sense (obviously you just want the ‘on’->‘off’ or ‘off->on’ transition, and don’t want to sort all closed doors to the top). I don’t know where that code went and it’s not in this version.
  • I’m working on getting the more-info popup to trigger for each entity. custom:button-card only supports opening the more-info popup for a single entity without a massive change to how all of this works, so it will need to be an onclick handler or custom javascript
  • some of these are Scrypted cameras, it would be cool if clicking one popped up a live view of the camera, or maybe even customizable so you could pop up a view of a camera with a view of the sensor area
  • I’m hoping to add a text input field that allows configuring the entities used without editing the code directly. might require custom JS which adds a lot of complexity
  • I might try using last_updated instead of last_changed, but this already works great

Comments, suggestions, issues, or just general “hey that’s cool!” messages are welcome!

If there are other ideas people have for similar visualizations (i.e., plain text) of sensor data, let me know!

Here’s the code:

4 Likes

Nice, I like the idea of this card.
I changed the part where you control which entities are shown to use device classes (occupancy/motion) instead.
To make it more manageable, I also added the option to exclude specific entities.
This makes it easier since you don’t have to rely on exact entity names to control what appears in the list.

              // --- CONFIGURATION Excluding entities---
              // Add the full entity_ids here of the sensors you want to exclude.
              const entitiesToExclude = [
                'binary_sensor.bewegingssensor_kledingkamer_groep',
                'binary_sensor.bewegings'
              ];
              // --- END CONFIGURATION ---
              
              const motion = Object.values(states)
                .filter(e =>
                  // Condition 1: Must be a binary_sensor.
                  e.entity_id.startsWith('binary_sensor.') &&
                  
                  // Condition 2: The device_class must be 'motion' or 'occupancy'.
                  (e.attributes.device_class === 'motion' || e.attributes.device_class === 'occupancy') &&
                  
                  // Condition 3: The entity_id must NOT be in the exclusion list.
                  !entitiesToExclude.includes(e.entity_id)
                )
                .sort((a, b) => new Date(b.last_changed) - new Date(a.last_changed));
1 Like

Nice! Thank you! I wonder if there is a way to get this into a format it’s easier to consume. Right now for a user to uptake your changes would require some manual editing…not ideal.

I want to keep it lightweight with very little ‘process’ to cut a new ‘release’. Installing it should ideally be as easy as pasting a repo URL or even just a link to the code on github or elsewhere (where it would be fetched 1 time and then cached). Then someone could install from a fork, a gist, or anything without much effort. Is there anything like that available in HA? Maybe we need a “fetch card” that can take a URL along with basic card config, which would fetch the config of the card from the URL and merge it with the defined config, using the merged output as the full config? I can write a component for HACS for something like that but am not going to write a separate HACS thing for each card.

I understand what you’re trying to achieve, but I think it can quickly become complex. Personally, I believe it’s always best to understand the code before copying it. Another option could be to build the code and use it via Auto-Entities. That way, you could implement it as follows

  type: custom:auto-entities
  card:
    type: vertical-stack
  card_param: cards
  filter:
    include:
      - options:
          type: custom:button-card
          entity: this.entity_id
          show_icon: false
          show_name: false
          show_state: false
          show_label: true
          tap_action:
            action: more-info
          label: |
            [[[
              const now = Date.now();
              const FADE_MAX = 21600, MIN_RATIO = 0.001;
              const MAIN_FADE_MIN_OPACITY = 0.5;
              const MAIN_FADE_MAX_OPACITY = 0.8;
              const AGE_COLOR = [200, 200, 200];
              const GRADIENT = [
                { t: 60, c: [0, 255, 0] },
                { t: 300, c: [166, 255, 0] },
                { t: 900, c: [255, 255, 0] },
                { t: 3600, c: [255, 165, 0] },
                { t: 21600, c: [255, 51, 0] },
                { t: Infinity, c: [102, 102, 102] }
              ];

              const sec = (now - new Date(entity.last_changed)) / 1000;
              const clamp = Math.min(Math.max(sec, 0), FADE_MAX);
              const ratio = Math.log10(clamp / FADE_MAX * (1 - MIN_RATIO) + MIN_RATIO);
              const finalRatio = (ratio - Math.log10(MIN_RATIO)) / -Math.log10(MIN_RATIO);

              const isOn = entity.state === 'on';
              const isErr = isOn && sec >= 3600;

              const getColor = (seconds, error) => {
                if (error) return GRADIENT[GRADIENT.length - 2].c;
                for (let i = 0; i < GRADIENT.length - 1; i++) {
                  const { t: t1, c: c1 } = GRADIENT[i], { t: t2, c: c2 } = GRADIENT[i + 1];
                  if (seconds <= t2) {
                    const t = Math.max(0, Math.min(1, (seconds - t1) / (t2 - t1)));
                    return c1.map((v, j) => Math.round(v + (c2[j] - v) * t));
                  }
                }
                return GRADIENT[GRADIENT.length - 1].c;
              };

              const getAgeText = (on, err, s) =>
                err ? '(err)' :
                on ? '(now)' :
                s < 60 ? '(<1m ago)' :
                s < 3600 ? `(~${Math.floor(s / 60)}m ago)` :
                s < 43200 ? `(~${Math.floor(s / 3600)}hr ago)` : '(>12hr ago)';

              const time = new Date(entity.last_changed).toLocaleTimeString('nl-NL', {
                hour: '2-digit', minute: '2-digit', second: '2-digit'
              });

              const rgb = getColor(sec, isErr);
              const baseColor = `rgb(${rgb.join(',')})`;

              const mainOpacity = isOn ? 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)})`;

              const name = entity.attributes.friendly_name
                .replace(/sensor|aanwezigheid[s]?|beweging[s]?|Bezetting/gi, '')
                .replace(/\s?\|\s?/g, ' ')
                .replace(/\s+/g, ' ')
                .trim();

              const age = getAgeText(isOn, isErr, sec);
              const ageOpacity = 0.8 - (0.8 - 0.3) * finalRatio;
              const ageColor = `rgba(${AGE_COLOR.join(',')}, ${ageOpacity})`;

              const timeStyled = `<span style="color:${baseColor}; font-size:100%; font-weight:600; display:inline-block; margin-right:8px;">${time}</span>`;
              const nameStyled = `<span style="color:${sensorNameColor}; font-size:100%; font-weight:400; display:inline-block; margin-right:8px;">${name}</span>`;
              const ageStyled  = `<span style="color:${ageColor}; font-size:80%; font-weight:bold; display:inline-block; width:12ch;">${age}</span>`;

              return `<pre style="margin:0; font-family:monospace; font-size:14px;">${timeStyled}${nameStyled}${ageStyled}</pre>`;
            ]]]
          styles:
            card:
              - font-family: monospace
              - font-size: 14px
              - padding: 2px
              - border-radius: 0
              - background: none
            label:
              - text-align: left
              - font-weight: normal
        domain: binary_sensor
        attributes:
          device_class: occupancy
      - options:
          type: custom:button-card
          entity: this.entity_id
          template: presence_detection
        domain: binary_sensor
        attributes:
          device_class: motion
    exclude:
      - options: {}
        entity_id: binary_sensor.bewegingssensor_kledingkamer_groep
  sort:
    method: last_changed
    reverse: true

The only problem is that you need a second custom card. The benefit is that you also make te sensors clickable, for more interaction.

I understand what you’re trying to achieve, but I think it can quickly become complex. Personally, I believe it’s always best to understand the code before copying it.

I apologize, it’s not quite clear what you’re referring to. I wrote the majority of this code, although I did use ChatGPT for things like creating a log-based easing function for color interpolation over 5 separate color ‘brackets’ (something I have no interest in doing myself).

That’s great work you did on simplifying the code! I originally had it in a single function but split it into separate functions for ease of understanding for others and maintenance/adding functionality. Auto-entities is not necessary and doesn’t actually do anything useful here, I didn’t realize I still had the code in there for that.

To open a more-info dialog, this solves the issue w/o any extra cards or fuss:

<div onclick="this.dispatchEvent(Object.assign(new Event('hass-more-info', { bubbles: true, composed: true }), { detail: { entityId: '${entityId}' } }))"
</div>

Just add that onclick handler to any element. The entity ID in that example would be rendered by the template, it could also be provided some other way. I looked into how it was done “officially” by other cards, then refactored it to remove any dependencies and so it could be inlined directly into the handler. Note: The button card will have mouse events disabled if all ‘tap/hold actions’ are set to ‘none’. I set one of the actions to prevent this from happening. It’s done with a CSS class so that could be fixed other ways as well.

I’ve updated the code above to reflect this.