Blog

All Blog Posts  |  Next Post  |  Previous Post

TMS Web Core and More with Andrew:
Working with Home Assistant - Part 8: People

Bookmarks: 

Tuesday, March 28, 2023

Photo of Andrew Simard
Next up in our Home Assistant adventure, we've arrived at the last panel in the Catheedral home page - generally referred to as People & Energy. While these two topics are not directly related to one another, we've run out of space! So they've been combined into one panel. In this post, we'll have a look at the People aspects, and next time we'll have a look at the Energy situation. There are, as usual, a few new mechanisms to explore as we work on integrating more information from the Home Assistant WebSocket API into our TMS WEB Core project. Here's what we're working on this time - the last panel.

TMS Software Delphi  Components
Catheedral Home Page.


What is a Person?

Within Home Assistant, there is a "People" page within Settings where persons of interest can be managed. Generally, there are two groups of people defined within Home Assistant. First, a "Person" refers to an individual that is associated with one or more "device tracker" elements, where the "state" of the person is their last known location. Second, a "User" refers to a login account that may be used by others to access Home Assistant itself.

For our purposes today, we're only concerned with "Person" data and, specifically, anything we can glean about their location or anything else that we might be able to find out about them. A person can also be a user, but the reverse is not required. Here's what a person looks like in the Home Assistant Settings - my progeny... To create a user from a person, just click on the "Allow person to login" option. For some reason, it reverts to this state even though a user account was created previously. 

TMS Software Delphi  Components
Person Settings in Home Assistant.

Here we can also set a photo for the Person, which will carry over into Catheedral as we'll see in a little bit. The list of devices (in this example, an iPhone and an iPad) is used to set the "state" value for the person to be the location of the device.  

Presence Detection.

The "device trackers" available in Home Assistant may come from the various integrations available for specific devices. One way to get such a device tracker is to install the Home Assistant Companion app on a person's iOS or Android device. Another way is to install an integration for a supported router, where it can track devices connecting to a local network, linking those references to people and then marking them as "home" or "away" for example.  Additional information is available on the Home Assistant website about both Device Trackers and Presence Detection, which is really what we're up to here.  

From the Home Assistant perspective, the rationale for having this data is to enable certain kinds of automations, like turning on the lights when you come home at night, or perhaps turning down the heat when everyone has left for the day. Sometimes, though, we'd like to know a little bit more regarding the devices that are tracking us. Sometimes we'd rather not install yet another potentially battery-draining app on our devices when there is already plenty of information passed around that we can use.  

There is a distinct difference in how Apple and Google handle this kind of data, however, and at least in the case of iOS, there's a bit of work needed to get at it. There is in fact a separate platform hosted by Apple - their "Find My" network. This allows family and friends to share locations with one another, as well as track other devices, like Apple Air Tags, AirPods, or MacBooks. Not that long ago, they also opened up support for third-party devices as well.

To be clear, there are some lower-level distinctions between Apple's services, with "Find My" being a crowd-sourced service like Tile, "Find My iPhone" being a direct tracking service involving just an iPhone and Apple's servers. And "Find My AirPods" being something a little different again. "Find My Friends" is also another branch. Regardless, we can lump them all under the "Find My" umbrella for our purposes here and not lose any of our meaning.

In order to tap into this "Find My" network there are alternatives. Home Assistant includes an iCloud integration out-of-the-box, but it is (or has been?) problematic. What I've been using for a few months is a similar integration called iCloud3. It does take a bit of effort to set up, with the usual editing of configuration.yaml required. There's a solid guide to getting it set up available on its website. When configured and running normally, this Integration will need to be reauthorized once per month (an Apple security issue that is likely unavoidable), and Apple may even kindly send you a daily e-mail about how a website (your own!) is accessing your iCloud information. Not the best, but not the worst either.

Ultimately, whatever you've chosen for tracking devices, the end result will be a Home Assistant "Person" entity with the person's current location and potentially several "device tracker" entities with varying degrees of additional information. So let's leave it at that for now and go about getting at that data.

Configuring Catheedral.

As we've done a few times now, we'll need a few more sensors added to the Catheedral configuration. Given how our home page is configured, we'll need a sensor for each "person" spot on the home page (there are two), and then a sensor for each "device" spot that we'd like to display in the corners of the panel (four), so a total of six new sensors added. We can update our configuration code to ask for these, which can be found in the MiletusFormCreate method.

      {"id": 22, "feature":"Device 1"             , "example":"eg: device_tracker.someone_device" },
      {"id": 23, "feature":"Device 2"             , "example":"eg: device_tracker.someone_device" },
      {"id": 24, "feature":"Device 3"             , "example":"eg: device_tracker.someone_device" },
      {"id": 25, "feature":"Device 4"             , "example":"eg: device_tracker.someone_device" },
      {"id": 26, "feature":"Person 1"             , "example":"eg: person.someone" },
      {"id": 27, "feature":"Person 2"             , "example":"eg: person.someone" },

Then, within the running Catheedral app, we can select the People or Devices that we're interested in using. The data entry mechanism here makes it easy to find what we're after, as just typing 'person' will show all the People that are defined in Home Assistant, for example. The same mechanism works for the device trackers.


TMS Software Delphi  Components
Configuring Catheedral Sensors.


For the configured sensors, we're looking for specific states or attributes. If a device tracker doesn't have a particular attribute (or doesn't have a particular attribute at a particular time - sometimes these attributes may not always be available), then we'll have to do a little more error checking to compensate. Here, we're looking at the Person sensors as they come from Home Assistant, using the usual StateChanged method. This is for Person1, but the same is done for Person2. Another opportunity perhaps to generalize this for more people.

  else if (Entity = Person1Sensor) then
  begin
    asm
      if (State.attributes["friendly_name"] !== undefined) {
        this.Person1Name = window.CapWords(State.attributes["friendly_name"] || 'N/A');
      }
      if (State.attributes["entity_picture"] !== undefined) {
        this.Person1Photo = State.attributes["entity_picture"];
      }
      if (State.state !== undefined) {
        this.Person1Location = window.CapWords((State.state || 'N/A').replace('_',' '));
      }
    end;
    if Uppercase(Person1Location) = 'STATIONARY' then Person1Location := 'Somewhere';
    if Uppercase(Person1Location) = 'NOT_HOME' then Person1Location := 'Elsewhere';
  end

The location value returned, in Home Assistant parlance, is a "zone" name. We can set these explicitly, including latitude, longitude, radius, name, and even an icon, within Home Assistant. This allows us, for example, to label the places we might find ourselves most often, like home, work, or a friend's house. We can also have a little bit of fun with some of the terms coming from Home Assistant. For example, if the person is not moving, but is not in a named zone, Home Assistant will use the term "Stationary". And if no location data is available (because said progeny has not charged her phone... again?!) Home Assistant will report a location value of "NotSet". We can override these with other values if we choose. For "NotSet", we'll see in a little bit how we can ignore records entirely.

For the device sensors, what we're primarily after is the name of the device, what state it is in (charging or not), and what the battery level is. The iCloud3 interface in this case (or more accurately, the underlying data from the FindMy API calls that iCloud3 uses) doesn't always report values consistently here, so we've got a bit of work to sort out what is good data and what isn't. And, like the statistics data we covered in the Climate and Weather panels, this data isn't always immediately available after a Home Assistant server restart. So we have to be prepared for literally anything - from no data to all data - at any particular time. Here's the code for one device - we do the same for all four.

  else if (Entity = Battery1Sensor) then
  begin
    asm
      if (State.attributes["name"] !== undefined) {
        this.Battery1Name = State.attributes["name"];
      }
      if ((State.attributes["battery"] !== undefined) && (State.attributes["battery"] !== "") && (State.attributes["battery"] !== 0)) {
        this.Battery1 = State.attributes["battery"]+'%';
      }
      else {
        this.Battery1 = '';
      }
      if ((State.attributes["battery_status"] !== undefined) && (State.attributes["battery_status"] !== 'Unknown') && (State.attributes["battery_status"] !== "")) {
        if (State.attributes["battery_status"] == 'NotCharging') {
          this.Battery1Status = 'Idle';
        }
        else {
          this.Battery1Status = State.attributes["battery_status"];
        }
      }
      else {
        this.Battery1Status = 'N/A';
      }
    end;
    if Battery1 = '100%'
    then dataBattery1.ElementLabelClassName := 'Text TextSM Orange'
    else dataBattery1.ElementLabelClassName := 'Text TextSM Yellow';
  end

Depending on the values we receive, we can set a charging indicator color for the percentage value displayed, and also update the terminology to something a little shorter for our cramped UI. To display this data, we'll do what we've done previously, just using Form variables to store the data until we need to display it. Here's a sample of what that looks like. These are elements on the page already, so we're just populating them with new data for the most part.


      // Label the Batteries
      if   labelBattery1.Caption <> Battery1Name
      then labelBattery1.Caption := Battery1Name;

      // Battery Status Values
      if   dataBattery1Status.Caption <> Battery1Status
      then dataBattery1Status.Caption := Battery1Status;

      // Battery Values
      if   dataBattery1.Caption <> Battery1
      then dataBattery1.Caption := Battery1;

      // Person Locations
      if   dataPerson1Location.Caption <> Person1Location
      then dataPerson1Location.Caption := Person1Location;

      // Person Photos
      if Person1Photo <> '' then
      begin
        display := '<img style="border-radius:50%; width:50px; height:50px;" src='+editConfigURL.Text+Person1Photo+'>';
        if divPerson1.HTML.Text <> display
        then divPerson1.HTML.Text := display;
      end;

Nothing too fancy here. One new thing is that we've got an image to display. This was provided by Home Assistant with an image reference. We can simply append that to the end of the URL that we use to access the Home Assistant server. In order to display the image as a circle rather than a rectangle, it is just a matter of using the CSS property "border-radius" set to 50%.  

This all works pretty well, assuming that we've done all the hard work in Home Assistant to set up the device tracker sensors and that we've got our People state flowing properly. As we've mentioned a few times, this is a big reason to use Home Assistant as a data source for our TMS WEB Core project - we can get away with not having to do any of that kind of stuff which, to be honest, can be quite messy. Instead, we can focus on just our own Catheedral UI and not be even remotely concerned about what kind of devices our users might have or how many hoops they might have to jump through on the way to acquiring location data.

More Data.

Our home page is now updated with all the Person information that we're interested in seeing, at a glance - where the people are and the state of their devices. We could alter this UI to support more people by replacing the data in the corners with names and locations if necessary. Maybe parents in the middle and progeny around the outside, or perhaps the reverse! For example, we could have six Person sensors, and prioritize the use of those over the device sensors if needed. Incorporating pet trackers might work much the same way. We'll leave it for now, but the idea here is that we could alter or improve this another time if there was a need for this kind of thing.

What we might like to do, though, is see a little bit more information about the people. What information do we have? Well, there are two areas that we might explore. First, there is a history component to the Person state data, so we could show not only where the person is now, but where they have been over the past while. The default Home Assistant data retention period is 10 days. So by default, we can see where a person has been over that same period of time.

Second, perhaps a little more personal, we can search the Home Assistant entities for anything else that might be labelled with the person's name, and see what we find. Any sensors so labelled are likely to be of some interest, as this isn't normally done for things like lights or switches, but rather for entities or other elements that are specific to an individual. We'll see some examples of this in a little bit.

Location History.

Getting the location history for a person will require something new. In fact, getting the history of any sensor isn't something we've had to do yet. Up until now, we've been able to glean what we need from an existing sensor, or by using the Home Assistant "statistics" functionality to get summaries of historical data (aka statistics!) that we can report, such as the minimum and maximum temperatures for the Climate and Weather pages. This time, we'll need to get something that doesn't come along in summarized form. 

And we've got to dig a little deeper to get what we're after. So deep, in fact, that we've got to use an undocumented (but widely discussed!) WebSocket API. We'll need to do this a few more times before we're done with our Catheedral project, but for now, this is a basic function call - not likely to change much between Home Assistant releases. If it does, it will be on us to make any updates necessary. Kind of a blessing and a curse when using open-source software - we can see it sitting there - and we can even use it - but we don't have any guarantee that it will remain usable in the future.

The WebSocket API of interest, in this case, is the 'history' API. We have two ways we can get history - one is to use the "history/stream" variant, where we just get back all the history for a given set of entities. The other is the "history/history_during_period" variant, where we can limit the data to a particular time period. As this isn't likely to be a huge amount of data, being limited to 10 days, we might as well just use the /history/stream call. The details can be found in the Home Assistant source code. While it is written in Python of all things, it isn't hard to figure out what we're after. We'll do this again when it comes to figuring out how rooms (aka Areas) work in Home Assistant, but that's for another post.


We can get the data the same way that we make other calls using the WebSocket API. Just that when this data comes in, we'll want to store it somewhere so we can use it to populate a table. We already know the entities we are interested in - the Person entities that we've already configured. We can then pass those to the Home Assistant server and see what we get back.

  HAWebSocket.Send('{"id":'+IntToStr(HAGetPeople)+',"type": "history/stream", "start_time":"'+FormatDateTime('yyyy-MM-dd HH:nn:ss',Now-10)+'", "entity_ids":["'+Person1Sensor+'","'+Person2Sensor+'"]}');

The parameters needed for this WebSocketAPI call are the "start_time" and "entity_ids". For the first, we'll just go back 10 days, which is all the data available by default anyway. For the second, we'll pass in whatever sensor data we have available for people. This returns a couple of results from the WebSocket API interface. One is a "result" type, without any data, as is typically done for events, and one is an "event" type with the history included for each of the entities we've specified. Here's what it looks like in the browser's developer console.

TMS Software Delphi  Components
People History.

Most of what we need is available right here - the location name (aka zone) and timestamp. We also get the latitude and longitude coordinates, and even the GPS accuracy, if we want to present this data on a map (yup, sure do!). The only thing really missing is the icon associated with a zone. But we have the zone name. We can craft a zone-icon lookup function by looking through all the zone icons when we get all that state information from Home Assistant.  Here, we'll filter the "zone" entities that have a latitude, longitude, radius, and passive=false. We can then use these to look up the icon that we want to use, or even draw these zones on a map. 

        // Lookup Zones
        this.HAZones = hadata.result.filter(
          function(o) {
           return ((o.entity_id.indexOf("zone.") == 0) && (o.attributes.latitude !== undefined) && (o.attributes.longitude !== undefined) && (o.attributes.radius !== undefined)  && (o.attributes.passive == false));
          });

To display the location history, we can use a Tabulator table, just as we used in the Configuration UI. We can pass it the subset of data corresponding to the person we're looking at and then set about adding assorted column functions to make things look a little bit nicer.  

To start with the layout for this new Catheedral page, let's add the photo of the person we're looking at. On the main home page, a circular image is achieved by applying a CSS "border-radius" property set to 50%. This gives a hard circle edge which is fine on a small icon - might not even be noticeable. But if we make a larger icon, this looks a little less nice. 

Another way to do a circular image is to apply a mask image using a radial gradient where the gradient transitions from opaque in the middle to transparent on the edges. To do this, we'll need a container <div> element to hold the mask, and then an <img> element that the mask is applied to. This is less apparent with a complex background overall, so let's have a look at it with a plain background.

TMS Software Delphi  Components
Person Photo with CSS border-radius: 50%.


TMS Software Delphi  Components
Person Photo with CSS -webkit-mask-image: radial-gradient(white 50%, rgba(255, 255, 255, 0) 73%).

The result is a similar effect but with more of a softened edge. Just another technique to have in our toolbox. The same CSS mask-image property could be used if you had something that you wanted to fade out gradually, vertically, or horizontally. Multiple steps can also be added in either a linear or radial gradient which allows for quite a range of possibilities.

For the Tabulator table, there's quite a lot going on. Nothing overly complicated, but a lot all the same. The table itself can be defined when the app first starts, and then populated with data when the page is displayed. We've covered Tabulator extensively in other posts (have a look, starting here). But more examples can't hurt - much. Let's walk through this one in a little more detail. 

To start with, we'll define a form variable for the table, "tabLocations" as a JSValue so that we can refer to it more easily later. We'll need a <div> on our page (just a TWebHTMLDiv component). Its Name and ElementID properties have been set to "divLocations". Then, in MiletusFormCreate, we can define the table.

  asm
    this.tabLocations = new Tabulator('#divLocations',{
      layout: "fitColumns",
      selectable: 1,
      headerVisible: false,
      initialSort: [
        {column: "lu", dir: "desc"}
      ],
      columnDefaults: {
        resizable: false,
        visible: true
      },
      columns: [


Here, we're telling it to lay out the columns to fit the available width, enabling row selection, hiding the column headers, setting the timestamp to be the default sort (descending), and ensuring that the columns cannot be resized. Note that we'll be getting our column names (field names) from the underlying data that we saw earlier - "lu" is used for the timestamps, for example. 

Next, we're ready for our columns. Each column will have a "title" that is not displayed but might be helpful for understanding what the intent is, and we could display them during development (or if we just wanted them visible) by removing the "headerVisible" entry above. Often, when working with Tabulator, nothing more than a field name is required to display data in the table. In this case, though, it seems we have a bit of work to do for every column we want to look at.

        { title: "Day", field: "lu", width: 50, hozAlign: "center",
          formatter: function(cell, formatterParams, onRendered) {
            return luxon.DateTime.fromMillis(cell.getValue()*1000).toFormat(formatterParams.outputFormat);
          },
          formatterParams: {
            outputFormat: "ccc"
          }
        },

Here we're converting a Unix timestamp (seconds since 1970-Jan-01) into the weekday ("ccc" in Luxon is the same as "ddd" in Delphi's FormatDateTime). Using Luxon here isn't all that different from how we've used it before, but "fromMillis" is new - used to convert JavaScript datetime values. Also, we're using an entirely custom "formatter" here, where we just write JavaScript directly to produce the cell contents. Tabulator also has a mechanism to pass parameters using this mechanism, which is what we're doing to make it a little more obvious. There are other ways to do this if the dates were already in an ISO or similar format - using Tabulator's built-in datetime formatter.  

        { title: "Time", field: "lu", width: 60, hozAlign: "center",
          formatter: function(cell, formatterParams, onRendered) {
            return luxon.DateTime.fromMillis(cell.getValue()*1000).toFormat(formatterParams.outputFormat);
          },
          formatterParams: {
            outputFormat: "HH:mm"
          }
        },

Same approach with this column, even using the same underlying field, but we're getting a different output - the time. These could have been combined, but the styling and display of the table work better when these are in separate columns.

        { title: "Icon", field: "s", width: 40, hozAlign: "center",
          formatter: function(cell, formatterParams, onRendered) {
            var Zones = pas.Unit1.Form1.HAZones;
            var ZoneIcon = '<i class="fa-solid fa-person-walking"></i>';
            var Location = cell.getValue();

            if (Location.toUpperCase() == 'STATIONARY') {
              Location = 'Somewhere';
              var ZoneIcon = '<i class="fa-solid fa-store"></i>';
            }
            else if (Location.toUpperCase() == 'NOT_HOME') {
              Location = 'Elsewhere';
              var ZoneIcon = '<i class="fa-solid fa-car-side"></i>';
            }
            else if (Location.toUpperCase() == 'HOME') {
              Location = 'Home';
              var ZoneIcon = '<i class="fa-solid fa-house-chimney"></i>';
            }

            for (var i = 0; i < Zones.length; i ++) {
              if (Zones[i].attributes.friendly_name == cell.getValue()) {
                ZoneIcon = '<span class="mdi '+Zones[i].attributes.icon.replace(':','-')+'"></span>';
              }
            }
            return ZoneIcon;
          },
          formatterParams: {}
        },

This one is a little more involved. To get an icon, we'll be looking up the current location in the HAZones array we created earlier. The value we're using corresponds to the "friendly_Name" attribute, so we end up having to iterate through the array to find it.  I'm sure there's a JavaScript one-liner to do this (another filter, most likely) but this works well enough.  We're also checking for other values that don't correspond to a zone necessarily, and injecting our own icons if we find one. As we've seen previously with the Material Design Icons, we have to do a little bit of manipulation (adding a <span> element and replacing ":" with "-") to get those icons ready for display. Note also that we're mixing Font Awesome and Material Design Icons here without any particular problems - they work pretty well together.

        { title: "Location", field: "s", width: 170,
          formatter: function(cell, formatterParams, onRendered) {
            var Location = cell.getValue();
            if (Location.toUpperCase() == 'STATIONARY') {
              Location = 'Somewhere';
            }
            else if (Location.toUpperCase() == 'NOT_HOME') {
              Location = 'Elsewhere';
            }
            else if (Location.toUpperCase() == 'HOME') {
              Location = 'Home';
            }
            return Location;
          },
          formatterParams: {}
        },

All we're doing here is replacing some of the location names. Sometimes the locations come across with varying capitalization (like stationary and Stationary), which is kind of annoying, but easily dealt with. We've mentioned these other location names previously - they are supplied when a device tracker doesn't really know where it is, or when it hasn't been reporting data in some time.

        { title: "Duration", field: "lu", width: 110,
          formatter: function(cell, formatterParams, onRendered) {

            var Table = pas.Unit1.Form1.tabLocations;
            var StartTime = luxon.DateTime.fromMillis(cell.getValue()*1000);
            var EndTime = luxon.DateTime.fromMillis(cell.getValue()*1000);
            var Position  = Table.getRowPosition(cell.getRow());

            if (Position == 1) {
              EndTime = new luxon.DateTime.now();
            } else {
              EndTime = luxon.DateTime.fromMillis(Table.getRowFromPosition(Position - 1).getCell("lu").getValue()*1000);
            }

            var coded = EndTime.diff(StartTime).shiftTo('days','hours','minutes','seconds').toObject();
            var label ='';

            if (coded['days'] > 0) {
              if (coded['minutes'] !== 0) {
                label = coded['days']+'d '+coded['hours']+'h '+coded['minutes']+'m';
              } else {
                if (coded['hours'] !== 0) {
                  label = coded['days']+'d '+coded['hours']+'h';
                } else {
                  label = coded['days']+'d';
                }
              }
            } else if (coded['hours'] > 0) {
              if (coded['minutes'] !== 0) {
                label = coded['hours']+'h '+coded['minutes']+'m';
              } else {
               label = coded['hours']+'h';
              }
            } else {
              label = coded['minutes']+'m';
            }

            return '<div style="height: 100%; width:100%;">'+
                     '<div style="position: absolute; border-radius: 4px; height: 30px; left:0px; background: rgba(128,128,128,0.5); width: '+parseInt(105*(Math.max(15,Math.min(720,EndTime.diff(StartTime).shiftTo('minutes').toObject()['minutes'])) / 720))+'px"></div>'+
                     '<div style="position:absolute; color: white; text-align: center; left:0px; width:105px;">'+label+'</div>'+
                   '</div>';

          },
          formatterParams: {}
        },

Quite a bit more going on for this last column. What we're after is some kind of display to indicate how much time was spent at a location. We know the time when the location was first reported, so we use that as the StartTime.  Then, we go and find the previous row (recall that they are sorted in descending order) to find the EndTime or use the current time if it is the first row in the table. 

We then use Luxon to generate the results broken down into days, hours, minutes, and seconds. The last value it "shifts to" is a fractional number, so we include seconds here to ensure that minutes is a whole number. Then, based on the elements that are generated, an output string is constructed to include only the minimum required. Messy.  There are other ways to do this. Luxon supports "human" formatting, but it has idiosyncrasies all its own. 

The same approach is used to get the number of minutes between StartTime and EndTime as a numeric value.  Roughly equivalent, ultimately, to Delphi's MinutesBetween method. Which we could use here, but we'd have to convert the timestamps into Delphi's TDateTime format first, so it doesn't really save us anything. We then use the number of minutes as a portion of, at most, 12 hours (or 720 minutes), or a minimum of 15 minutes, to calculate the width of a <div> element - essentially creating a bar graph where the width is capped at 12 hours. The string is dropped on top and then centered. A lot of work, relatively, but we end up with something like the following output.

TMS Software Delphi  Components
Location Table Added.

Note that we're using largely the same styling as the Configuration table, but the transparency has been ramped up quite a bit.  After a bit of squishing, we've also reduced the table to a reasonably small width without shrinking the font size. Leaving us with a fair amount of space to work with.

Other Data.

Most of our work in Catheedral is focused on home-related data (or small office-related data) coming from the Home Assistant server - often related to Home Assistant "integration" sensors like lights or thermostats. But there are other bits that can make their way into the Home Assistant database, just as we've seen with location data. 

From a TMS WEB Core perspective, this makes it easy for us to get at a lot of different kinds of data just using one WebSocket connection, without having to write code to talk to the many various evolving and problematic APIs that all these sensors use. 

The page we've been working on, though, is typically going to be specific to an individual, hence the giant floating head. What other data can we add to this page, also specific to the same individual? Well, we can just search the sensor data that we're getting from Home Assistant, and report back with anything at all we find that might be interesting.

There are integrations available for Home Assistant that can be used to incorporate health information. Ultimately, it would be nice to integrate Apple Fitness data - specifically, those three rings - but that's a bit of a bridge too far at the moment. It is possible with a third-party app, but lots of hoops to jump through. 

Another source of similar data comes from Withings - their "smart" bathroom scales, blood pressure cuffs, sleep monitoring products, and so on. I've got two of those (scale, BP). Adding the Withings integration to Home Assistant was a bit more work than other integrations, but ultimately it does what it advertises - after stepping on the scale or taking a BP measurement, the data will find its way into the Home Assistant database. 

Unfortunately, the Withings integration creates entities for nearly every conceivable bit of data they might collect from any of their products. Which is great, but also a bit more than we want to deal with. We could add sensors to Catheedral to specify which of the (several dozen!) sensors created in Home Assistant we'd like to show, as we've done a few times already. But that seems like a chore. And as we're not putting these on the home page, also a little unnecessary. Instead, let's look for all the data in Home Assistant that might be interesting to us when we display this page.

  • Has the name of the person included as part of the entity id.
  • Has an icon attribute.
  • Has a unit_of_measurement attribute.
  • Has a friendly_name attribute.
  • Has a state that is numeric (float or integer).

We can apply our own filter in Home Assistant by just assigning (or removing, if it is unwanted) an icon to the items of interest. This doesn't impact Home Assistant all that much, as you can always assign an icon in the UI separately anyway, and this makes for an easy way to find what we're interested in. After adding icons in Home Assistant for the entities of interest, we can then search the data to get our values. Once we have them, we can sort them and then display them. 

In this case, the sorting is done by "units" and then "name". Why? Well, there's no place to stick a sort value in Home Assistant, and at least the items with the same units will be together. As it turns out, this works pretty well for the data in my system. But this could be adjusted in the future, perhaps to just use the name and add a "1." or "2." to the name in Home Assistant. Not ideal though.

  PersonalInfo := '<div class="d-flex flex-column h-100 justify-content-center align-items-start">';
  // Search for content
  asm
    var search = '';
    var interesting = [];
    var current_lat = 0.0;
    var current_lon = 0.0;

    // Searching for the name of a person
    if (this.CurrentPerson == 1) { search = this.Person1Sensor.split('.')[1]; }
    if (this.CurrentPerson == 2) { search = this.Person2Sensor.split('.')[1]; }

    // Searching all the Home Assistant objects
    for (var i = 0; i < this.HAStates.length; i++) {
      if (this.HAStates[i].entity_id == 'person.'+search) {
        current_lat = this.HAStates[i].attributes.latitude;
        current_lon = this.HAStates[i].attributes.longitude;
      }
      // Matches search
      if (this.HAStates[i].entity_id.indexOf(search) > -1) {
        var item = this.HAStates[i];
        // If it has all of our attributes, add it to our 'interesting' array
        if ((item.attributes.icon !== undefined) && (item.attributes.unit_of_measurement !== undefined) && (item.attributes.friendly_name !== undefined) && !isNaN(item.state)) {
          interesting.push({name:item.attributes.friendly_name, value:item.state, icon: item.attributes.icon, units: item.attributes.unit_of_measurement});
        }
      }
    }

    // Sort our interesting array by units and then by name
    interesting.sort((a,b) => ((a.units+a.name) > (b.units+b.name)) - ((a.units+a.name) < (b.units+b.name)));

    // Add our interesting array to the display
    for (var i = 0; i < interesting.length; i++) {
      PersonalInfo += '<div class="Text TextBG Blue p-0 m-0 d-flex justify-content-center align-items-center mdi '+interesting[i].icon.replace(':','-')+'">';
      PersonalInfo += '<div class="Text TextBG White p-0 m-0 ms-2">'+parseInt(interesting[i].value)+'</div></div>';
      PersonalInfo += '<div style="margin:-8px 0px 4px 0px;" class="Text TextXS Gray p-0">'+interesting[i].name+' - '+interesting[i].units+'</div>';
    }
  end;
  PersonalInfo := PersonalInfo+'</div>';
  divPersonInfo.HTML.Text := PersonalInfo;

The creation of the content to display involves generating a series of <div> elements heavily populated with Bootstrap classes, as has become our habit. Might not be all that much to look at in code, but it does the trick when it comes time to display the results.


TMS Software Delphi  Components
Personal Information.

Yes, I need to get out more! One of the troubles with this kind of location data is that it doesn't pick up anything if you routinely leave the house without your phone. The data coming from an Apple Watch apparently doesn't take precedence (or isn't updated frequently enough?) to augment other sources of device tracker data, it would seem. Might have to look into that. 

In any event, as for the personal information, well, this is not particularly meaningful to anyone but me and those closest to me, which is the point, after all (particularly without a height value for reference!). But a solid example of getting arbitrary data out of Home Assistant that would be far more problematic to get into TMS WEB Core otherwise. Yet, somehow, we still have more than half the page to fill. What to do?

Maps.

Well, we have a lot of empty space, and we already know the latitude and longitude coordinates (they're just sitting there staring back at us, blinking occasionally, in the location data). So let's add a map that will update whenever we select an item in the table. We've covered Leaflet before, in this post. So we can use that - no API key is required.  And we've got a handful of locations (including an icon, name, latitude, longitude, and radius) that we can add to the map, in addition to the currently selected location. To start, we'll need to add Leaflet to our Project.html file, as usual, or select it using Delphi's Manage JavaScript Packages functionality.


We'll need to add a TWebHTMLDiv component to our page, set to dimensions that will fill the page. Not much else is required to get started. We can immediately draw a map based on a set of coordinates, select a default zoom level, and also add markers for the zones we already know about. To make this a little more fun, we can add a Leaflet plugin to allow for a bit more creativity when it comes to drawing markers. There are several but let's use Leaflet Extra Markers. Another pair of entries for Project.html.



When we access this page for the first time (clicking on one of the People icons on the home page), we can create the map and all of the known zones. In this particular arrangement, adding a new zone in Home Assistant would not show up here until this app is restarted. But as this data changes infrequently, there's not much need to address that. If we wanted to, we'd have to remove the markers and add them in again each time. For now, though, here's how we initialize Leaflet and add in the markers we have available at the time.

    // Draw the map for the first time?
    if (pas.Unit1.Form1.LocationMap == undefined) {
      pas.Unit1.Form1.LocationMap = L.map('divLocationMap').setView([current_lat-0.1, current_lon], 12);
      L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
        maxZoom: 19,
        attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
      }).addTo(pas.Unit1.Form1.LocationMap);

      // Add locations we know about
      for (var i = 0; i < this.HAZones.length; i++) {
        var coords = [this.HAZones[i].attributes.latitude, this.HAZones[i].attributes.longitude];
        var radius = this.HAZones[i].attributes.radius;
        var ZoneMarker = L.ExtraMarkers.icon({
          icon: this.HAZones[i].attributes.icon.replace(':','-'),
          extraClasses: 'mdi md-20',
          markerColor: 'green-dark',
          shape: 'square'
        });
        L.marker(coords, {icon: ZoneMarker}).addTo(pas.Unit1.Form1.LocationMap);
        L.circle(coords, radius, {color: "darkgren", fillOpacity: 0.75, fillColor: "darkgreen"}).addTo(pas.Unit1.Form1.LocationMap);
      }
    }

We cheated a little and got the current_lat and current_lon values when searching for our interesting data earlier.  The Leaflet Extra Markers plugin allows us to define icons more easily than the default Leaflet arrangement - which uses images. Here we can even pick different marker shapes. The icons within the markers are added in a manner similar to what we did with the table. They're a little small though, so we've added a bit of CSS to bump up their size a little bit. Some of these use MDI icons (anything coming from Home Assistant) and others use Font Awesome (everything else) so the CSS is laid out accordingly.

.extra-marker i.fa-solid {
    font-size: 18px;
    margin-top: 10px;
}
.extra-marker i.md-20 {
    font-size: 20px;
    margin-top: 2px;
}

And while we're fiddling with CSS, there are a few things needed to move around the controls within Leaflet. The last post about Weather had some remarks about displaying other web content in our own projects and how it is sometimes inconvenient when the controls in the other web content are placed where our own controls are. Leaflet lets us go the extra distance by making it easy to move things around. 

We'll need to relocate the zoom buttons and also the attribution so that it isn't obscured by our own UI elements.  For the zoom buttons, we're moving them from the default top-left corner to the top-right corner, but to the left of our location table. Similarly, the attribution text that normally appears in the bottom-right corner is shifted so that it is to the left of our table and moved up a little bit. A border and some rounded corners were added as well.


.leaflet-control-container {
  position: absolute;
  top: -6px;
  left: 714px;
  z-index: 500 !important;
}
.leaflet-control-attribution {
  position: absolute;
  left: -134px;
  border-radius: 6px;
  top: 377px;
  padding: 2px 8px;
  border: 2px solid rgba(128,128,128,.5);
}

And finally, we'll need to draw a marker whenever the row selection is changed.

    this.tabLocations.on("rowSelected", function(row){
      var coords = [row.getCell('a.latitude').getValue(),row.getCell('a.longitude').getValue()];
      var radius = row.getCell('a.gps_accuracy').getValue();
      pas.Unit1.Form1.LocationMap.flyTo(coords);
      if (pas.Unit1.Form1.PersonMarker == undefined) {
        var PersonMarker = L.ExtraMarkers.icon({
          icon: 'fa-map-pin',
          extraClasses: 'fa-solid fa-3x',
          markerColor: 'blue',
          shape: 'circle'
        });
        pas.Unit1.Form1.PersonMarker = L.marker(coords, {icon:PersonMarker, zIndexOffset:100}).addTo(pas.Unit1.Form1.LocationMap);
        pas.Unit1.Form1.PersonCircle = L.circle(coords,radius, {color: "royalblue", fillOpacity: 0.75, fillColor: "royalblue"}).addTo(pas.Unit1.Form1.LocationMap);
      }
      else {
        pas.Unit1.Form1.PersonMarker.setLatLng(coords);
        pas.Unit1.Form1.PersonCircle.setLatLng(coords);
        pas.Unit1.Form1.PersonCircle.setRadius(radius);
      }
    });


Here, we're using the Leaflet Extra Markers plugin again, but with a Font Awesome icon for our map location marker. Each time we draw a marker, we're also drawing a circle around it that corresponds to either the radius (in the case of zone markers) or the GPS accuracy, in the case of the current person's location. Both of these are expressed in meters, though I don't know if that's always the case. Let's hope so! After a bit of fiddling with styles, icon sizes, and a lot of testing, we end up with the following.


TMS Software Delphi  Components
Location History.

Pretty fancy! The zoom works as well, so when visiting a location that is within a zone, the order was set to make it easy to see where the location is within that zone.  

TMS Software Delphi  Components
Location Zoomed In.

When zooming out, the zone circles almost disappear, but the markers are still visible, making it possible to see all of them at once.

TMS Software Delphi  Components
Location Zoomed Out.

Note also that our main Catheedral UI buttons and navigation controls almost disappear with the map displayed, but they're still there, fully operational as usual. Another reminder of why we bothered with all that drop-shadow business.

Those UI elements, the table, the floating head, and the other data, if available, are all overlaid on the map but not in the map, if that makes sense. The floating head and the data have the CSS property "pointer-events" set to "none", meaning that if you click and drag those elements, nothing happens to them - they are invisible in that sense - the underlying map gets dragged around instead. And, as one would expect, if the pointer is over the table, scrolling the mouse wheel will scroll the contents of the table. If the pointer is over the map, scrolling the mouse wheel will zoom the map in and out.  

Next Time.

Pretty happy with how that turned out. Did we miss anything? This is considerably easier to use than the equivalent functionality in Home Assistant. Which is of course one of the original motivations for this project. A lot of information is just far enough out of reach to make it almost unusable. 

Next time out, we'll see more of this when we look into the Energy data that is trapped in Home Assistant. We'll be able to draw a few more useful charts to get a better understanding of the underlying data than what we can normally see in Home Assistant. And maybe a few more surprises. But at the very least we'll have reached the last post covering the Catheedral home page and can then work on a few of the remaining other pages.

More topics to come after that though, so if there's anything you'd like to see covered in Catheedral or anywhere else in Home Assistant that I've overlooked, by all means, please post a comment below.

Catheedral Repository on GitHub


Follow Andrew on 𝕏 at @WebCoreAndMore or join our
𝕏
Web Core and More Community.





Andrew Simard


Bookmarks: 

This blog post has received 4 comments.


1. Tuesday, March 28, 2023 at 3:13:15 PM

Very nice Andrew!

Randall Ken


2. Tuesday, March 28, 2023 at 3:24:41 PM

Thanks!

Andrew Simard


3. Tuesday, March 28, 2023 at 3:38:11 PM

Very useful and informative Andrew. This project "Rocks".

Kamran


4. Tuesday, March 28, 2023 at 4:00:04 PM

Thanks! I think we are past the half-way point now!

Andrew Simard




Add a new comment

You will receive a confirmation mail with a link to validate your comment, please use a valid email address.
All fields are required.



All Blog Posts  |  Next Post  |  Previous Post