MainUI Widgets and a 3D Smart Home

There have been previous examples of integrating openHAB with a 3D rendering, but they have required significant coding and have relied on some of the more specialized OH UI interfaces such as HABPanel. While working on a completely unrelated project recently, it occurred to me that the library I was using, model-viewer, is a perfect candidate for bringing 3D capability into OH just using the custom widget system. So, in this tutorial I’m going to go over:

  1. The basics of getting a 3D model shown in a OH widget
  2. How to integrate other oh widget components with the 3D model for smart home control
  3. How to used advanced widget + javascript techniques to make the 3D model itself reactive to oh states and event.

A quick note about javascript and widgets

The custom widgets have their own built-in widget expressions. These expressions are based on javascript, but they do not actually access the browser javascript environment and only act in limited (and isolated) contexts. There are other topics in this forum that have broached the idea of actually accessing the full browser javascript environment so I will not go into all the pros and cons of this here. Until this point, however, there has not been, in my opinion, a compelling reason to take advantage of this capabilty. This topic, I believe, is a good reason to begin to showcase some of the ways to actually integrate oh widgets and full javascript scripts and modules (in fact, this will be one of the very first steps).

As always with javascript, do not run any code that you do not trust or know the origin of.

Adding a model-viewer widget

We’re going to need only two things for the first step of displaying a 3D model in a widget: 1) a model, of course, and 2) the model-viewer library.

I had some old svg floor plans of my house lying around, so I grabbed the main floor svg, quick threw it into Blender, extruded some walls, added a touch of color and a few example features and I had a demo model ready.

The model-viewer library requires glTF files (.gltf or .glb) and blender has an excellent gltf i/o plug-in. The single binary file format (.glb) might be a little easier for starters, but, as you will see later on, there is a reason for preferring the the separate file format (.gltf). The file (or files for .gltf) needs to be someplace that OH has access to for it to be loaded, so I just dropped mine in the OH_CONF/html folder.

The model-viewer library can be loaded remotely from a couple different hosting services. For our purposes it’s better to just have a self-hosted copy of the minified version, so I pointed a browser tab to this google hosted libraries site and saved the script (also to my OH_CONF/html folder) as model-viewer.min.js.

The widget

The basic widget is quite simple. For the root component of the widget I’ll use a basic div and set it to full width and height for demo purposes. The width and height, of course, can be adjusted to your own purposes; the div will come in handy in later steps. Inside the div we need to load the model-viewer javascript library. If this were html, the script tag would look like this (because the script is in the html folder and therefore accessed via OH’s \static url location):

<script src="/static/model-viewer.min.js" type="module"></script>

With the widget system we just use the tag as our component name and the attributes become config properties.
So, to load this external javascript library into our widget as a module we can just add:

- component: script
  config:
    src: /static/model-viewer.min.js
    type: module

The last piece that we need is the model viewer itself. The library creates the custom tag model-viewer, and it only needs one attribute to display the model: src pointing to the model file.
Beyond the minimum, however, if we want to be able to interact with our model we should also add the camera-controls attribute. The id attribute will be useful in the next steps, though not requried if you prefer other methods of accessing the element in javascript. Lastly we want a nice big model view so we’ll use style to fill the whole parent div:

- component: model-viewer
  config:
    id: house-view
    src: /static/Behrend_main_nt.glb
    camera-controls: true
    style:
      width: 100%
      height: 100%

All together the widget code is short and sweet:

uid: model_viewer_demo
tags: []
props:
  parameters: []
  parameterGroups: []
component: div
config:
  style:
    width: 100%
    height: 100%
slots:
  default:
    - component: script
      config:
        src: /static/model-viewer.min.js
        type: module
    - component: model-viewer
      config:
        id: house-view
        src: /static/Behrend_main_nt.glb
        camera-controls: true
        style:
          width: 100%
          height: 100%

That’s it, and the result is:
first-model

At this point, additional configuration of the model is easy. Any of the attributes listed in the model-viewer docs can be incorporated in the same manner by including them in the config object of the model-viewer component. For example, it doesn’t make much sense to be able to swing the model up and see the underside of the floor. To prevent this we can add limits to the camera movement; in this case, we want to limit the maximum pitch so we add:

max-camera-orbit: auto 77deg auto

to the model-viewer configuration.
pitch-limit

The model-viewer is based on a streamlined version of the three.js library so it can handle many advanced rendering features (e.g., transparency and roughness in the window). The flat colors aren’t as nice as I’d like, so we’ll take advantage of gltf’s ability to include textures. A little more work in blender to add laminate floors, wood panels to the door, and textured knockdown to the walls and our widget model looks much better:

Here is where the difference between the single file glb format and the multi-file gltf format is important. The single file glb format will be fetching the texture images in a manner that violates OH’s content-security-policy so they will be blocked by the browser. It is possible to use a reverse proxy to modify the default OH CSP to allow OH to load the textures from the binary file, but this is not necessary because if you use the multi-file format and have the texture folder in your configuration directory with the other pieces of the gltf then OH can load the texture files without any issues.

Integrating Other Components

So far, this doesn’t do anything useful (even if it is fun). So, let’s start adding to our model. The power of the model-viewer library is that it makes it very easy (and widget compatible) to integrate html elements into the model. These elements will be drawn on top of the model, but, the library will handle moving and positioning these elements in sync with the model itself.

The system that model-viewer uses is its hotspot/annotation system. Let’s start with a simple example: I want to label some of the spaces in my model. To do this all I need to do is two steps:

  1. add a child component to the model-viewer that is my label
  2. make sure that child component is registered as a hotspot of the model-viewer

The child component is easy. I’ll start with a simple div again (the OH Label element will not work because it will not accept the additional required attributes). The component looks like this:

- component: div
  config:
    class:
      - Hotspot
      - indoor-label
    data-position: 1.6907273451362617m 0m -0.8410376624211873m
    data-normal: 1m 0m -1m
    content: Living room

To register this component as a hotspot of the model-viewer it must have an attribute named slot with a value that starts with hotspot-. This is one place where we need a little trickery for a couple different technical reasons. This piece of trickery is our next encounter with adding browser level javascript to this widget. In this case we’ll create our own custom little script instead of loading a source library.

- component: script
  config:
    content: >
      [...document.getElementsByClassName('Hotspot')].forEach((hs, idx) => {
        hs.setAttribute('slot',`hotspot-${idx + 1}`)
      })

This script collects an array of all the elements with the Hotspot class (which we added to our div) and adds an appropriate slot attribute to the element. Now our element is integrated into the model, but the library doesn’t know where in the model to put it. This is where the data-position and data-normal attributes come in. With a lot of extra work, it would be possible to get these values from either blender or your OH window, but the easiest way to do it is to take advantage of the excellent model tester/editor that model-viewer makes available at Model Editor . If you drag and drop your model file(s) into this editor, you can then use it’s Add Hotspot function and click on the location in the model where you want to add your component. The editor will add the new hotspot to its html snippet window and you can copy the position and normal values right from that window. The last few pieces of the above component are just a class to let me style all such labels so they are not just black text on dark flooring and the actual label itself as the content. Here’s the current full widget text and the result:

Widget code
uid: model_viewer_demo
tags: []
props:
  parameters: []
  parameterGroups: []
component: div
config:
  style:
    width: 100%
    height: 100%
  stylesheet: |
    .indoor-label {
      border-radius: 5px;
      background: #90909090;
      padding: 2px;
      font-weight: bold;
    }
slots:
  default:
    - component: script
      config:
        src: /static/model-viewer.min.js
        type: module
    - component: script
      config:
        content: >
          [...document.getElementsByClassName('Hotspot')].forEach((hs, idx) => {
            hs.setAttribute('slot',`hotspot-${idx + 1}`)
          })
    - component: model-viewer
      config:
        id: house-view
        src: /static/Behrend_main.gltf
        camera-controls: true
        max-camera-orbit: auto 77deg auto
        style:
          width: 100%
          height: 100%
      slots:
        default:
          - component: div
            config:
              class:
                - Hotspot
                - indoor-label
              data-position: 1.6907273451362617m 0m -0.8410376624211873m
              data-normal: 1m 0m -1m
              content: Living room

first-label

The beauty of this system is that it is not restricted to any single element. Hotspot children of the model-viewer can be any arbitrary element or complex element tree. This means that just as easily as we added div we can add OH specialized components as well. In my living room, on the wall next to the standing lamp is a switch that controls that lamp. Adding an oh-toggle to represent that switch follows the exact same steps as the div, and the component is simply this:

- component: oh-toggle
  config:
    class:
      - Hotspot
    data-position: 1.2739383812891471m 0.3081846817712068m -0.17051529192625736m
    data-normal: 0m 0m -1m
    style:
      transform: rotate(-90deg) scale(75%)
    item: Outlet_LivingRoomLamp_OnOff

And now there’s a toggle switch on the wall of the model where my real switch is:
switch

You can’t see the effect on my lamp (we’ll come back to this problem later), but trust me it’s turning on and off.

Let’s add another example of an OH component and action. I’ve got a sensor out on my patio and I’d like to see the temperature and be able to open a window of all the sensor properties.
I’ll define two more hotspots: a patio label just like the living room label, and an oh-link with an expression for its text and the group action. I don’t want this component on the patio wall, however, so after I get the component’s position from the model-viewer editor, I’m going to manually adjust them a little so the link is floating in the patio area.

Widget code
uid: model_viewer_demo
tags: []
props:
  parameters: []
  parameterGroups: []
component: div
config:
  style:
    width: 100%
    height: 100%
  stylesheet: |
    .indoor-label {
      border-radius: 5px;
      background: #90909090;
      padding: 2px;
      font-weight: bold;
    }
slots:
  default:
    - component: script
      config:
        src: /static/model-viewer.min.js
        type: module
    - component: script
      config:
        content: >
          [...document.getElementsByClassName('Hotspot')].forEach((hs, idx) => {
            hs.setAttribute('slot',`hotspot-${idx + 1}`)
          })
    - component: model-viewer
      config:
        id: house-view
        src: /static/Behrend_main.gltf
        camera-controls: true
        max-camera-orbit: auto 77deg auto
        style:
          width: 100%
          height: 100%
      slots:
        default:
          - component: div
            config:
              class:
                - Hotspot
                - indoor-label
              data-position: 1.6907273451362617m 0m -0.8410376624211873m
              data-normal: 1m 0m -1m
              content: Living room
          - component: oh-toggle
            config:
              class:
                - Hotspot
                - scaled-widget
              data-position: 1.2739383812891471m 0.3081846817712068m -0.17051529192625736m
              data-normal: 0m 0m -1m
              style:
                transform: rotate(-90deg) scale(75%)
              item: Outlet_LivingRoomLamp_OnOff
          - component: div
            config:
              class:
                - Hotspot
              data-position: -0.0618133434224899m 0m 1.9828180426074304m
              data-normal: 0m 0m 1m
              content: Patio
          - component: oh-link
            config:
              class:
                - Hotspot
              data-position: 0.3870192117519836m 0.3260225588598695m 2m
              data-normal: 0m 0m 1m
              action: group
              actionGroupPopupItem: Sensor_Patio
              text: =@'Sensor_Patio_Temperature'

patio

At this point you should be able to see that you can now add as many additional hotspots as you want turning this widget into a 3D model equivalent of the floorplan. If you don’t think this tutorial is already too long, then move on to the next section where we’ll start to add more complex custom features.

Using Javascript for Advanced Features

The model-viewer comes with a significant library of javascript methods for interacting with the model, so I’m going to use some of these to introduce how to integrate more js into the widget system. For most of the javascript I’m going to demonstrate here you can either continue to add inline scripts as we did above with adding the child elements to hotspot slots, or just develop your own external script and load it as the src of another script component. However, there are, as you will see, a couple of places where it is necessary to keep the javascript as an inline script to allow for integrating OH information.

Event listeners and calling javascript functions

Let’s start with the room labels. I want my model to automatically show me the ideal view of a room when I click on that label. If you’re familiar with javascript you know that is going to need a click event listener for the label div. You could include a loop in your custom script that adds the event listener to each element that needs it, but you can also define onEvent type events for an element directly through the appropriately named config property. For this task I’ll add onclick listeners that call a custom function. Here’s the custom function:

function camera2hotspot(e) {
  mViewer = document.getElementById('house-view')
  if (e.target.dataset.target) {
    mViewer.cameraTarget = e.target.dataset.target
  }
  if (e.target.dataset.orbit) {
    mViewer.cameraOrbit = e.target.dataset.orbit
  }
  const hsFov = e.target.getAttribute('field-of-view')
  if (hsFov) {
    mViewer.fieldOfView = hsFov
  }
}

This function will check the label that was clicked on (e.target) for 3 pieces of data: the target, the orbit, and the field of view. For each of these data that exist, it will assign the value (either via model-viewer method, or directly changing the value) and those changes will automatically change the camera position to the desired view. Model-viewer itself will handle the interpolated camera track to the new location, so you don’t have to.

This means that we have to add not only the listener to the label div but also these new data. Again I recommend collecting these data from the model-viewer editor. This time, using the Save current as initial button will write the orbit and field of view data to the snippet window. You will have to refresh the target point to get those values from the edit boxes below the save button. Once you have those values the addition to the component is easy:

- component: div
  config:
    class:
      - Hotspot
      - indoor-label
    data-position: 1.6907273451362617m 0m -0.8410376624211873m
    data-normal: 1m 0m -1m
    data-orbit: 115.9deg 38.3deg 8.405m
    data-target: 1.62m 0m -0.68m
    field-of-view: 13.48deg
    onclick: camera2hotspot(event)
    content: Living room

move-to

Integrating OH information in javascript

As mentioned above, the widget expressions occur in a context that is isolated. This means that the javascript that you run via the browser does not have access to things like the items object (and therefore item states). The place where these two different streams cross, however, is that we can use widget expressions to set the content of a script component.

For example, model-viewer gives us a method for adjusting the strength of light emission for a glowing material, such as a light bulb. If I want to make the light bulb in the standing lamp glow or not depending on the status of the OH items I need a two step process. First I’ll add another basic function which sets the emission value of a input material based on whether the input is 'ON' or not.

function setMaterialEmission(matName, newEmVal) {
  mViewer = document.getElementById('house-view')
  if (mViewer.loaded) {
    mViewer.model.getMaterialByName(matName).setEmissiveStrength((newEmVal == 'ON') ? 100 : 1)
  }
}

This function can go into whatever main javascript entity you are using: external js file or inline script.

The trick to integrate the OH information in this is to remember that the content property of a script component is also parsed by the expression parser. So, I create a new separate inline script component, and in this component I do two things: use a widget expression with a string template for the script content, and set the key property.

- component: script
  config:
    key: =Math.random() + items.Outlet_LivingRoomLamp_OnOff.state
    content: =`setMaterialEmission('bulb',
      '${items.Outlet_LivingRoomLamp_OnOff.state}')`

When this script component gets rendered into the html doc, the content will either be setMaterialEmission('bulb','ON') or setMaterialEmission('bulb','OFF'). As soon as it’s rendered that script will be run and the function is called with the parameters of the current item state. The problem is, that script will only run once when it is rendered; we also need it to run whenever the item state changes. This is where the key property comes in. I’ve discussed the key property in other posts, so just a quick reminder: this property causes the Vue renderer to re-render a component anytime it changes. So, now, any time the lamp item state changes, a “new” script with the correct function call is rendered and runs. Now my light bulb turns on an off with my item (and the lamp itself, of course).
bulb

Doors

A similar example of this same technique can be used to show the status of things that move such as doors and windows. I’ve actually created two front doors in the model, one open and one closed. The one that is closed is shown because its texture alpha value is set to 1, and the door that is open has an initial alpha value of 0 so it’s invisible. Just like changing the emission value of a material using our two step javascript we can simultaneously change the alpha values of these two versions of the door based on the OPEN/CLOSED status of the door sensor.

Step one - javascript function that changes the alpha value:

function setMaterialAlpha(matName, newAlpha) {
  mViewer = document.getElementById('house-view')
  mViewer.model.getMaterialByName(matName).pbrMetallicRoughness.setBaseColorFactor([1,1,1,newAlpha])
}

Step two - separate script component (with a key value) that sets it’s content based on an item state:

- component: script
  config:
    key: =Math.random() + items.Door_FrontDoor_Status.state
    content: =(items.Door_FrontDoor_Status.state == 'OPEN')?(`
      setMaterialAlpha('door.front.closed',0);
      setMaterialAlpha('door.front.open',1); `):(`
      setMaterialAlpha('door.front.open',0);
      setMaterialAlpha('door.front.closed',1); `)

Now the model door opens and closes in sync with my door sensor.
door

Wrap up

This turned into a long one! Thanks for reading this far. There’s a lot of model-viewer functionality that I haven’t covered in tutorial, such as animations, variant mesh materials, and rending plug-ins. You can even swap out the more light weight renderer that model-viewer uses by default bring in a full three.js library if you want even more realistic effects such as lighting. Working with most of these other features, however, would follow more or less the same principles I’ve outlined here.

9 Likes

This is amazing, @JustinG :heavy_heart_exclamation:
I will definitely have a look at that (if I only had more time…).

Did you see new interactive svg background feature that I added to the latest milestone?

I just caught up with some of the progress on that the other day. As I said, I’ve been focusing on an unrelated project recently. I’m a big fan of svg’s though so it is definitely on my list to check it out.

2 Likes