Lovelace plugin: Camera History Browser

For anyone who wants to browse through footage captured by a security camera on a Lovelace dashboard, this Camera History plugin might be helpful. It will group the images by day and display the last image taken per day below each other. To browse through the images from a single day, simply drag the slider. Clicking on an image will show it in a new browser window.

Screenshot

Prerequisites

  • Store images from your camera into a folder under /config/www/ to expose it to the front end.
  • The filename should contain the date and time as two different integer values, for example:
    • Kitchen_20210508_184321.jpg
    • Cam_20210508-184321.jpg
    • Garage 20210508 184321 porch.jpg
  • Use a folder sensor and point it at the directory.

Installation

  • Save the below script as /config/www/plugins/camera-history/camera-history.js
class CameraHistory extends HTMLElement {

  set hass(hass) {
    // Capture data
    const entityId = this.config.entity;
    const state = hass.states[entityId];
    var files = state ? state.attributes.file_list : ["none"];
    
    // Initialize    
    if (!this.content) {
      // Add card
      const card = document.createElement("ha-card");
      card.header = (this.config.title || "");
      this.content = document.createElement("div");
      this.content.style.padding = "0 16px 16px";
      card.appendChild(this.content);
      this.appendChild(card);
      // Expose hass methods
      this.localize = hass.localize;
    }

    // Set up file names for change detection
    files = files.map(file => file.replace("/config/www/", "/local/"));
    files.sort();
    files.reverse();
    files = files.filter(filename => !filename.includes("latest"));

    // Change detection
    if (!this.previousFiles || JSON.stringify(this.previousFiles) != JSON.stringify(files)) {
      this.files = files;
      this.previousFiles = files;
      this.draw();
    }

  }

  draw() {
    
    var dates = this.files.reduce(function(output, current)
    {
      var date = current.match(/[0-9]{8}/);
      if (!date)
      {
        date = "00000000";
      }
      if (!output[date[0]])
      {
        output[date[0]] = [];
      }
      output[date[0]].push(current);
      return output;
    }, []);

    dates.reverse();

    var output = `
      <style>
        .imagecontainer {
          margin: 0;
          padding: 0;
        }
        .slidecontainer {
          margin: 0;
          padding: 0;
          width: 100%; /* Width of the outside container */
        }
        /* The slider itself */
        input[data-slider] {
          -webkit-appearance: none;  /* Override default CSS styles */
          appearance: none;
          width: 100%; /* Full-width */
          height: 40px; /* Specified height */
          background: #d3d3d3; /* Grey background */
          outline: none; /* Remove outline */
          opacity: 0.7; /* Set transparency (for mouse-over effects on hover) */
          -webkit-transition: .2s; /* 0.2 seconds transition on hover */
          transition: opacity .2s;
          margin: 0;
        }
        /* Mouse-over effects */
        input[data-slider]:hover {
          opacity: 0.8; /* Fully shown on mouse-over */
        }
        /* The slider handle (use -webkit- (Chrome, Opera, Safari, Edge) and -moz- (Firefox) to override default look) */ 
        input[data-slider]::-webkit-slider-thumb {
          -webkit-appearance: none; /* Override default look */
          appearance: none;
          width: 40px; /* Set a specific slider handle width */
          height: 40px; /* Slider handle height */
          background: #4CAF50; /* Green background */
          cursor: pointer; /* Cursor on hover */
        }
        input[data-slider]::-moz-range-thumb {
          width: 25px; /* Set a specific slider handle width */
          height: 25px; /* Slider handle height */
          background: #4CAF50; /* Green background */
          cursor: pointer; /* Cursor on hover */
        }
        h5[data-timestamp] {
          float: left;
          margin-top: -35px;
          margin-left: 10px;
        }
        h5[data-frame] {
          float: right;
          margin-top: -35px;
          margin-right: 10px;
        }

      </style>
      <table>
      ${Object.keys(dates).map(key => 
      { 
        var filename = dates[key][0];
        var timestamp = filename.match(/(?<year>[0-9]{4})(?<month>[0-9]{2})(?<day>[0-9]{2})(.*)(?<hour>[0-9]{2})(?<minute>[0-9]{2})(?<second>[0-9]{2})/);
        var date = new Date(`${timestamp.groups.year}-${timestamp.groups.month}-${timestamp.groups.day} ${timestamp.groups.hour}:${timestamp.groups.minute}:${timestamp.groups.second}`);
        return `
            <tr>

              <td>
                <div class="imagecontainer">
                  <a href="${filename}" data-image="${key}" target="_blank">
                      <img src="${filename}" data-image="${key}" width="100%">
                  </a>
                </div>
                <div class="slidecontainer">
                  <input type="range" min="0" max="${dates[key].length - 1}" value="0" data-slider data-image="${key}">
                  <h5 class="date" data-timestamp="${key}">${this.formatTimestamp(date, timestamp)}</h5>
                  <h5 class="index" data-frame="${key}">1 / ${dates[key].length}</h5>
                </div>
              </td>

              <!--
              <td width="80">
                <center>
                  <ha-icon icon="mdi:clock"></ha-icon><br/>
                  <strong>${this.formatDay(date)}</strong><br/>
                  <span>${timestamp.groups.year}-${timestamp.groups.month}-${timestamp.groups.day}</span><br/>
                </center>
              </td>
              -->

            </tr>`
      }).join("")}
      </table>`;

    this.content.innerHTML = output;

    Array.from(this.content.querySelectorAll("input[data-slider]")).map(slider =>
    {
      slider.addEventListener("input", e => {
        // Process selection
        var currentImage = e.target.value;
        var filename = dates[e.target.dataset.image][currentImage];
        var timestamp = filename.match(/(?<year>[0-9]{4})(?<month>[0-9]{2})(?<day>[0-9]{2})_(?<hour>[0-9]{2})(?<minute>[0-9]{2})(?<second>[0-9]{2})/);
        var date = new Date(`${timestamp.groups.year}-${timestamp.groups.month}-${timestamp.groups.day} ${timestamp.groups.hour}:${timestamp.groups.minute}:${timestamp.groups.second}`);
        // Update image
        this.content.querySelector(`img[data-image="${e.target.dataset.image}"]`).src = filename;
        // Update link
        this.content.querySelector(`a[data-image="${e.target.dataset.image}"]`).href = filename;
        // Update timestamp
        this.content.querySelector(`h5[data-timestamp="${e.target.dataset.image}"]`).innerHTML = this.formatTimestamp(date, timestamp);
        this.content.querySelector(`h5[data-frame="${e.target.dataset.image}"]`).innerText = `${parseInt(currentImage) + 1} / ${dates[e.target.dataset.image].length}`;
      })
    });

  }

  setConfig(config) {
    if (!config.entity) {
      throw new Error("You need to define an entity");
    }
    this.config = config;
  }

  // The height of your card. Home Assistant uses this to automatically
  // distribute all cards over the available columns.
  getCardSize() {
    return 5;
  }

  formatDay(timestamp) {
    if (timestamp === null || timestamp === undefined) {
      return "";
    }
    // Calculate time passed
    var today = new Date((new Date()).getFullYear(), (new Date()).getMonth(), (new Date()).getDate());
    var date = new Date(timestamp.getFullYear(), timestamp.getMonth(), timestamp.getDate());
    var daysBetween = Math.round((today.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
    // Output date string
    if (daysBetween == 0) {
      return this.localize("ui.components.calendar.today");
      /*
      } else if (daysBetween == 1) {
        return "Yesterday";
      } else if (daysBetween < 7) {
        return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][date.getDay()];
      } else if (daysBetween < 14) {
        return `Last week ${["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getDay()]}`;
      */
    } else {
      return this.localize("ui.duration.day", "count", daysBetween);
    }
  }

  formatTimestamp(date, timestamp) {
    return `${timestamp.groups.year}-${timestamp.groups.month}-${timestamp.groups.day} (${this.formatDay(date)}) &middot; ${timestamp.groups.hour}:${timestamp.groups.minute}:${timestamp.groups.second}`;
  }

}

customElements.define("camera-history", CameraHistory);
  • Go to Configuration | Lovelace Dashboards | Resources
    • Click Add Resource
    • Set URL to /local/plugins/camera-history/camera-history.js
    • Set Resource type to JavaScript Module
    • Click Update
  • Edit your dashboard and add a new Manual Card
  • Add a card configuration similar to:
type: 'custom:camera-history'
entity: sensor.kitchenFolder
title: Kitchen

Possible enhancements

  • Defining in the card configuration how many days to display.
  • Move the date from the right of the image, into the slider.
  • Add a dedicated popup when clicking on an image.
  • Better wording on the dates (so “yesterday”, “last Wednesday”, “Tuesday last week”) instead of the simple “1 day ago”.
2 Likes

I love this idea! To pick select the important images of all the stored data and store it in a compact designed card is the second best after a full timelineviewer of your recordings. I would love to have this as a custom component if possible. Unfortunately I dont have the time to go through the installationprocess with this one. So tell me when there is a installable component :smiley:

Couldn’t get this to work, adding the card to dashboard give error: “Cannot read properties of null (reading ‘groups’)”

And when you said to ‘Set URL to /local/plugins/camera-history/camera-history.js’, I assume that’s a typo, shouldn’t it be: /local/camera-history/camera-history.js ?

Any help would be appreciated, this card seems really useful, thanks!

I updated this line in my instructions to include /plugins:

  • Save the below script as /config/www/plugins/camera-history/camera-history.js

Also, I updated the code with the latest version that I am currently running myself. It may or may not resolve the issue you are having; please let me know if it does not. The root cause of it may be that your camera files do not contain a date in format yyyymmdd-hhmmss like Cam_20210508-184321.jpg, so you could double check that.

Maybe a next version should use the date the files are created rather than a random time stamp in the file name, but I yet have to figure out how to access that meta data.

Thanks for the update.
I can’t seem to figure out how to name a folder sensor, how did you define ‘sensor.kitchenFolder’ in the yaml?

This is what I have (below), but it complains if I try putting “name: xyz” or “id: xyz”.

sensor:

  • platform: folder
    folder: /media
    name: xyz => this line gives an error!

When you set up your folder sensor, the sensors name will be sensor.leaffoldername.

For example:

sensor:
  - platform: folder
    folder: /config/www/camera/kitchen

Will result in a sensor named sensor.kitchen that you can inspect using Developer Tools. The file_list attribute lists all the images in the directory.

Ah yes, thanks for clearing that up.
Got it to work now, cheers for that!

I’m surprised no ones done anything like this, it’s very useful (at least I haven’t found anything similar). The hassio Media Browser is not very useful for camera snapshots especially for long filenames it just gets truncated unless you mouse over each image.

1 Like

Looks like frigate card has timeline now
Haven’t test it yet, but it seems like the solution