Today I want to talk about our UI, what we're trying to solve and how we solve it.
The embedded demo has been hidden because the screen is too small. Open the demo in a new screen.
When building a platform for the Internet of Things, there are many use cases that warrant a user interface. Note that for the sake of this blog post, we're focusing on graphical user interfaces on devices with screens the size of a phone, or bigger.
Security. Users that want to have a quick glance at their house when they are away from home. They quickly want to check if their garage door is closed or maybe they got notified of unexpected motion in the backyard or water below the sink and need to check out. These users will want to see a logbook with recent security related incidents, the current camera streams and the current state of the doors, windows, water- and motion sensors.
Control all the things. Users want to have a single place to control all their devices. Their goal of the user interface is to quickly see each part of the house at a glance and control lights, switches, thermostats and the music.
Control single room. Users that are building interfaces for devices in each room. These devices need to expose all controls of a single room, with optional extra information about the whole house and options to control other parts of the house.
Monitor. Users want to build their own dashboards to visualize all the current and historical data inside Home Assistant and other databases, like InfluxDB. They want to keep track of their energy usage, track their internet speeds or current commute time.
Administer and Automate. Users that want to have dashboards that contain their controls to administrate Home Assistant. It contains their automations, scripts and various input controls to allow configuring the parameters of their automations and scripts.
The odds are that if you are using Home Assistant right now, your UI is a hybrid of a couple of the mentioned use cases. Todays blog post is going to talk about what technologies we have used to built a UI that allows users to build UIs that fit all these use case.
When we started to re-architect a part of our interface last year, we came up with a list of requirements based on the use cases, our experience with building our previous interface, and the problems we ran into.
- It needs to have zero runtime magic. Each user wants something different, and so if we make a decision during runtime, people will ask for configuration options to influence this decision. Our UI will need to base all decisions off the configuration, and only jump in with smartness if no configuration is given. We can still suggest configurations to the user, but once the user edits it, they own it and are the only ones updating it.
- It needs to be customizable. Users want to be able to change everything. It is important that we allow users to specify configuration options to change things like styling, names or icons.
- It needs to be mobile first. Home Assistant does not ship with separate mobile applications. Our UI is going to be used on mobile devices, laptops, and everything in between. It needs to be possible to install, use and configure Home Assistant with just a mobile phone.
- It needs to be emerging market ready. Home Assistant wants to make the smart home accessible to everyone. Because we are free and open source, we are accessible to a wide range of people from across the globe. Most of these people are on slow phones and with spotty internet. Our user interface still needs to be usable and be able to run on many different phones.
- It needs to be extensible. With many use cases to cover, it is important that the UI is extensible. Extensibility allows developers to experiment and allows us to support everything, yet not have to satisfy all edge cases/unique features in the Home Assistant core, where it will have to be maintained forever.
- It needs to be easy to develop for. It can be easy to get lost in tooling. Especially while trying to maintain support for all the different devices, but also offer easy theming and configuration. It should be easy for anyone to go from user to developer, without spending 2 days preparing the development environment.
Quite the list of requirements. Home Assistant is an open source project that, thanks to the generous support of our users, has 2 full time developers working on it together with an army of volunteer developers across the globe. With such a set up, we had to make decisions on how to achieve our goals. And there was only one platform that fit the bill perfectly, a platform that allows us to offer theming, configuration options for defaults but also allows us the user to import external components to render parts of the page, and that allows us to structure the code in an easy way to allow users to easily access it from any device: the modern web.
Note that I say modern. In recent years browsers, web frameworks and tooling have come a really long way in offering a way to easily tackle our challenges. We have seen the introduction of CSS properties, custom elements and all major web tooling supports code splitting out of the box.
And all this modern web goodness is widely available: desktop browsers have become evergreen, which means they self-update without user intervention. Android users can get browser updates via the Play Store and Apple tends to give their phones 5 years of updates, with each update containing an updated browser.
A lot of the things that we do are based off or inspired by others. Although I don’t have a definite list of all people that inspired all our choices or approaches, it was definitely inspired by tweets and talks by Alex Russel, Rich Harris, Justin Fagnani, Malte Ubl and the Polymer team mantra “Do less and be lazy". These people have helped shape our mindset on how we approach building a great UI.
In case you are wondering how an open source project got such an extensive list of cool stuff that we are doing, it’s actually because we are open source project. For the volunteers working on this, this is fun. We love following the latest frontend developments and implement them, without having to worry about someone complaining about how our time is better spend on other things.
Lit Element is our web framework of choice and powers our user interface. Lit Element is a web framework that allows declarative templating, is just 6kb gzip’d, is blazing fast and has great TypeScript support. Lit Element does not require any build tooling, so it has also become the framework of choice for our developers creating UI extensions.
Isolating our components using Shadow DOM. Although we want developers to run code on our page, we don’t want them to accidentally mess up our built-in components, or even worse, break the whole page. To protect our components, we use Shadow DOM. This allows us to encapsulate the DOM and styling of our components.
Allow theming using CSS properties. Users want to customize the built-in components and they want to customize third party components that they import. This has to work regardless of the web frameworks that the developers use to build their widgets. We have been able to solve this using CSS properties. CSS properties are CSS values that a user can set on the whole UI, per tab and sometimes per card to customize the look and feel.
Rely on Static DOM. When the page is first loaded, we will build up the page as per the users configuration. After that, we won’t touch it again. Instead, when a state changes, we will have the state be passed on to all the relevant components where lit-html will efficiently change the pieces that require updating.
We used to use browser user agent detection to decide on what version to ship, but have recently moved to frontend feature detection by leveraging
nomoduleand a specific check to make sure Safari 10.1 doesn’t load both.
Easy state management. To be able to share our state with any component, internal or external, we decided to roll our own state management. It’s a simple object that contains the current state of the house and it is passed down our component tree. Whenever the state changes, we make a copy and update the changed parts and then pass it down again to all the components. Components will then re-render and show the latest state. All state updates are managed by Home Assistant JS Websocket, and are all generated on the server. They are pushed to the frontend using web sockets, allowing all open UIs to stay in sync because they all receive the latest changes as soon as possible.
Make it easy to find bloat in bundles. If we can make it easier to find things that we ship unnecessarily, developers are more likely to find it and spend time on removing the bloat. For that reason, we include scripts in our repository that allows anyone to quickly run a bundle analysis using the webpack-bundle-analyzer and see what packages add to our bundle size. These analysis look like this.
Code split translations. We use lokalise.co to manage our translations. All translation strings in our code base are automatically uploaded to lokalise.co where volunteers are translating the available strings. We have build tooling to split our translations based on the different panels in our UI. This allows us to only load the translations on the fly that we need.
Decentralized routing. The top level of the application only knows how to route the first part of the url. It knows which code to load and which web component to instantiate. It will pass the rest of the url to the panel and further routing can be done from there. This will avoid the initial bundle to keep growing as more parts are splitter out. We have also created a simple to use router for web components with support for code splitting and loading screens, making it easy for our developers to do the right thing.
Avoid doing things when the source is parsed or classes are initialized. This one is in the category “Do less and be lazy”. It is important to get to the initial render as fast as possible. It’s therefore important that you avoid anything that isn’t necessary. Lit Element helps with this by offering life cycle hooks that are run after the update is complete. There is no “run before component will update” life cycle.
A great example of this mantra in Polymer is debounce. Their debounce will not generate a debounced function, but instead you only call it when you want to call the function. It might then call the function and will return a piece of state that you need to pass back in on the next invocation. That way you don’t need to generate a debounced function before it is needed.
We do a lot of cool things, but the work is never done. Here are some challenges we faced and future improvements that we might explore. One interesting observation I had while writing this list, is that some of the problems we have are not due to bad design, but are due to outgrowing the design. The designs were the right designs when the UI was smaller, but now that we have grown we need to adapt.
Dealing with duplicate custom element names. A custom element can only be defined once. This means that if a component imports a different version, this will go haywire. Home Assistant has it’s own specialized operating system Hass.io which ships with it’s own control panel on it’s own release cycle. We were unable to make any guarantees on version compatibility, and after one time too many of breaking our users UI, we sat down and solved it: we are running the Hass.io control panel in an iframe so that the custom elements are isolated from the parents page.
We also offer this functionality as an extension point for developers. Via the custom panel integration they can build their own panel with optional iframe isolation. The other use case we found for iframe isolation was for React. This web framework is popular but does not work in ShadowDOM. By isolating it in an iframe, we were able to solve this too (example code).
Moving away from trying to put everything in Webpack. When the project moved from HTML imports to JS imports, we switched to WebPack as our bundler of choice. We got a little too excited and did everything in WebPack, including generating HTML. Since we have two builds of our frontend, we actually have two WebPack builds. By putting everything in WebPack, we made it impossible to create files that could refer to the hashed filenames of both the modern and ES5 version of our builds. It’s our goal to minimize WebPack’s duties to transpiling TypeScript, bundling the files and minification. By reducing the responsibilities, it will be easier for us to try out other bundlers. I am looking forward to experiment with Rollup + Closure Compiler plugin.
Removing Polymer from main bundle. The first version of the UI was built with the Polymer web framework and the Paper elements, a set of material design components built using Polymer. We still have some core UI code that relies on Polymer, which means that it’s still part of the initial UI bundle. That means that currently both Polymer and Lit Element are part of our initial bundle, bloating this bundle unnecessarily.
Translating all the strings. We still ship text that is not translated yet. Home Assistant is used in homes by all demographics. It is important that users can use the UI in a language that they understand. On top of that, we have made great strides with RTL support recently and most is covered, but there are still places where this can be improved too.
- We ship an iconset that is build as part of the release and contains all icons referenced in the whole UI. As our UI grows, this iconset is growing. It now includes a lot of icons that are no longer required for initial load but are still loaded. An option to explore is to replace our current icon rendering components with one that supports lit-html templates that contain the SVG icons. We can then start loading the icons via our normal JS import. This will allow our JS bundler to only include the icons referenced by the code in the bundle. We should make sure that we can mark these files as being ok to be duplicated, or else the JS bundler will generate a lot of tiny bundles with just a single icon.
- We currently split our translation files out per language and per panel of the UI and then individually fingerprint each file. This manifests file is included in our initial build, and as our app has grown in panels and we have gained support for more languages, our manifest file is now a big chunk of our initial UI bundle. Our first step should be to generate a single hash per language, allowing us to only contain a single line per language.
Stop using local storage. We use local storage to store the auth tokens, the language preference and if the sidebar is shown. Since local storage is accessed synchronously, the browser will stop executing anything on the page while it is waiting for the local storage to be read from disk. Browser vendors are working on alternatives like kv-storage which are asynchronously and might become a viable alternative.
Swap out Paper elements for Material Web Components. The Paper elements are no longer maintained and Material Web Components will be the successor, however they are still under development. The MWC components use Lit Element under the hood and rely on the material.io VanillaJS implementation of Material design. This means that they will be a lot lighter than the current Paper elements and will actually be maintained.