Blog
All Blog Posts | Next Post | Previous Post
TMS Web Core and More with Andrew:
Working with Home Assistant - Part 8: People
Tuesday, March 28, 2023
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.
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.
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.
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.
Person Photo with CSS border-radius: 50%.
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.
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.
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.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.min.css"> <script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.min.js"></script>
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.
<script src="https://cdn.jsdelivr.net/npm/leaflet-extra-markers@1.2.1/dist/js/leaflet.extra-markers.min.js"></script> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-extra-markers@1.2.1/dist/css/leaflet.extra-markers.min.css">
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: '© <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.
.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.
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.
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.
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
This blog post has received 4 comments.
Andrew Simard
Kamran
Andrew Simard
All Blog Posts | Next Post | Previous Post
Randall Ken