[Labs] Custom widgets

8.0 release will have Custom Widgets feature available in HomeHabit Labs.

Warnings

:warning: Please make configuration backup before enabling this feature.

:warning: This is an extremely experimental feature. It might significantly change in the future, have breaking changes in-between releases, or be discontinued.
It might also have a performance impact on dashboard loading time, depending on capabilities of the device.

What are Custom Widgets?

Custom widgets allow rendering a fully custom widget within existing dashboard. Custom widgets can show and act on any data available from the connected platforms.
These widgets are written in JavaScript as Web Components, using Remote Admin (see below).

What is Remote Admin?

Remote Admin is the new web interface that allows to configure the app using any browser. It is a replacement for Remote Config editor.
Note: Remote Admin URL is different, please check Settings for the correct URL.

How to enable these features?

  • Go to Settings > HomeHabit Labs
  • Enable both Custom widgets and Remote admin features
  • Restart the application

Availability

  • 8.0 build 4936 or newer
  • Android 5.0+ (currently will not work on Amazon devices)

How to create custom widgets?

Open Remote Admin, and navigate to Custom Widgets section.
There you can create new custom widgets (see examples below).
After custom widget is created, it needs to be added to a dashboard. When editing dashboard in the app, there will be a new widget type: custom, which will have all created custom widgets available.
Currently, it is not possible to add configurable properties to a custom widget. This is planned as a future enhancement.

Custom widget structure

Widget is defined using a class that extends from HTMLElement.
render() method is required and should return HTML widget content. render() method will be called each time widget needs to be updated.

export default class extends HTMLElement {
  render() {
    return ``;
  }
}

Actionable widgets

Add onAction() method to Web Component. It will be called on each tap (currently, only whole widget tap is supported).

onAction() {
  const currentState = this.state['hass.light#switch.state'];
  
  // toggle
  this.state['hass.light#switch.state'] = !currentState;
}

Load external CSS

Add static styles() array property to Web Component.
For example, here how to load MDC icons:

static get styles() {
  return [
    'https://cdn.materialdesignicons.com/5.4.55/css/materialdesignicons.min.css',
  ];
}

Platform data

Platform items data can be retrieved within custom widget using this.state, e.g.:

const lightState = this.state['hass.light#switch.state'];

Changing state of an item is as simple as assigning to value to this.state property, e.g.:

this.state['hass.light#switch.state'] = false;

Screenshots

Remote Admin

Custom widget editor

Custom widget examples

Note: item bindings are just examples and will need to be updated to point to actual items within your setup

Clock

image

export default class extends HTMLElement {
  render() {
    const now = this.state['time.now#timestamp'];
    const horizontal = this.size.width > this.size.height;
    const scale = horizontal ? this.size.height : Math.min(this.size.width, this.size.height) / 2;

    return `
      <style>
        :host {
          --scale: ${scale};

          align-items: center;
          display: flex;
          flex-direction: column;
          font-size: calc(1rem * var(--scale));
          justify-content: center;
          text-align: center;
        }

        .time {
          font-size: ${horizontal ? '4.5em' : '6.5em'};
          letter-spacing: 0.009375em;
        }

        .date {
          font-size: ${horizontal ? '0.9375em' : '1.25em'};
          letter-spacing: 0.06em;
        }
      </style>

      <time-formatted hour="numeric" minute="numeric" class="time" datetime="${now}"></time-formatted>
      <time-formatted weekday="long" month="long" day="numeric" class="date" part="color-text-secondary" datetime="${now}"></time-formatted>
    `;
  }
}
Flip Clock
export default class extends HTMLElement {
  render() {
    const scale = Math.min(this.size.width, this.size.height);

    const currentHour = this.state['time.now#hour'];
    const currentMinute = this.state['time.now#minute'];
    const previousHour = (12 + (currentHour - 1)) % 12;
    const previousMinute = (60 + (currentMinute - 1)) % 60;

    const currentHourPadded = ('0' + currentHour).slice(-2);
    const currentMinutePadded = ('0' + currentMinute).slice(-2);
    const previousHourPadded = ('0' + previousHour).slice(-2);
    const previousMinutePadded = ('0' + previousMinute).slice(-2);

    const hourChanged = this.hour != undefined && currentHour != this.hour;
    const minuteChanged = this.minute != undefined && currentMinute != this.minute;

    this.hour = currentHour;
    this.minute = currentMinute;

    return `
      <style>
        :host {
          --animation-duration-bottom: 0.6s;
          --animation-duration-top: 0.3s;
          --background-bottom: #303030;
          --background-top: #212121;
          --border-radius: 0.15em;
          --scale: ${scale};
          --text-bottom: #fff;
          --text-top: #ccc;

          align-items: center;
          display: flex;
          flex-direction: column;
          font-size: calc(3.2rem * var(--scale));
          font-weight: 500;
          justify-content: center;
          text-align: center;
        }

        :host *,
        :host *:before,
        :host *:after {
          box-sizing: border-box;
        }

        .flip-clock {
          margin: 20px auto;
          perspective: 400px;
          text-align: center;
        }

        .flip-clock__piece {
          display: inline-block;
          margin: 0 5px;
        }

        .card {
          display: block;
          line-height: 0.95;
          padding-bottom: 0.72em;
          position: relative;
        }

        .card__top,
        .card__bottom,
        .card__back::before,
        .card__back::after {
          backface-visiblity: hidden;
          background: var(--background-top);
          border-radius: var(--border-radius) var(--border-radius) 0 0;
          color: var(--text-top);
          display: block;
          height: 0.72em;
          padding: 0.25em 0.25em;
          transform: translateZ(0);
          transform-style: preserve-3d;
          width: 1.8em;
        }

        .card__bottom {
          background: var(--background-bottom);
          border-radius: 0 0 var(--border-radius) var(--border-radius); 
          border-top: solid 1px #000;
          color: var(--text-bottom);
          left: 0;
          overflow: hidden;
          position: absolute;
          top: 50%;
        }

        .card__bottom::after {
          display: block;
          margin-top: -0.72em;
        }

        .card__back::before,
        .card__bottom::after {
          content: attr(data-value);
        }

        .card__back {
          height: 100%;
          left: 0%;
          position: absolute;
          top: 0;
        }

        .card__back::before {
          overflow: hidden;
          position: relative;
          z-index: -1;
        }

        .flip .card__back::before {
          animation: flipTop cubic-bezier(0.37, 0.01, 0.94, 0.35);
          animation-duration: var(--animation-duration-top);
          animation-fill-mode: both;
          transform-origin: center bottom;
        }

        .flip .card__back .card__bottom {
          animation: flipBottom cubic-bezier(0.15, 0.45, 0.28, 1);
          animation-duration: var(--animation-duration-bottom);
          animation-fill-mode: both;
          transform-origin: center top;
        }

        @keyframes flipTop {
          0% {
            transform: rotateX(0deg);
            z-index: 2;
          }
          0%, 99% {
            opacity: 0.99;
          }
          100% {
            opacity: 0;
            transform: rotateX(-90deg);
          }
        }

        @keyframes flipBottom {
          0%, 50% {
            opacity: 0;
            transform: rotateX(90deg);
            z-index: -1;
          }
          51% {
            opacity: 0.99;
          }
          100% {
            opacity: 0.99;
            transform: rotateX(0deg);
            z-index: 5;
          }
        }
      </style>

      <div class="flip-clock">
        <span class="flip-clock__piece ${hourChanged ? 'flip' : ''}">
          <span class="flip-clock__card card">
            <span class="card__top">${currentHourPadded}</span>
            <span class="card__bottom" data-value="${previousHourPadded}"></span>
            <span class="card__back" data-value="${previousHourPadded}">
              <span class="card__bottom" data-value="${currentHourPadded}"></span>
            </span>
          </span>
        </span>
        <span class="flip-clock__piece ${minuteChanged ? 'flip' : ''}">
          <span class="flip-clock__card card">
            <span class="card__top">${currentMinutePadded}</span>
            <span class="card__bottom" data-value="${previousMinutePadded}"></span>
            <span class="card__back" data-value="${previousMinutePadded}">
              <span class="card__bottom" data-value="${currentMinutePadded}"></span>
            </span>
          </span>
        </span>
      </div>
    `;
  }
}
Switch

image image

export default class extends HTMLElement {
  render() {
    const state = this.state['hass.light#switch.state'];
    const scale = Math.min(this.size.width, this.size.height);

    return `
      <style>
        :host {
          --scale: ${scale};

          align-items: center;
          background: ${state ? 'var(--color-active)' : 'transparent'};
          display: flex;
          flex-direction: column;
          font-size: calc(1rem * var(--scale));
          justify-content: space-evenly;
          padding: 12px;
          text-align: center;
        }

        .label {
          text-transform: uppercase;
        }

        .icon {
          font-size: 3.2em;
        }
      </style>

      <span class="icon mdi mdi-lightbulb-outline"></span>
      <span class="label">Living Room</span>
    `;
  }

  onAction() {
    const currentState = this.state['hass.light#switch.state'];
    
    // toggle
    this.state['hass.light#switch.state'] = !currentState;
  }

  static get styles() {
    return [
      'https://cdn.materialdesignicons.com/5.4.55/css/materialdesignicons.min.css',
    ];
  }
}
1 Like

This is some seriously exciting stuff. Can’t wait to start experimenting with this!

1 Like

I’m interested in what is the end goal here?
Just my personal use case and opinion, but while customisation is great and will let enthusiastic folks tweak their dashboards endlessly, I use HomeHabit to avoid exactly that. I could do way more with the built in Home Assistant UI, but I’ve been there and done that writing my own custom dashboard UI in the past. I used Home Habit to be able to quickly pick from existing quality widgets, that have a consistent look, ‘just work’ and I dont need to fiddle with or tweak extensively.
I guess what Im thinking is:

  • Id hate to think the norm ever becomes: select your widgets, tweak it in code/html/css etc.
  • Could ‘good’ widgets be fed back - either with a user widget library people can access, or @igor rolling them into the core code…
1 Like

I also use homehabit because I absolutely don’t know how to program in javascript. I chose homehabit for this very reason. It would be interesting in the future to have the possibility to modify the widgets available from the graphical interface and not through code programming. But I don’t know if it’s feasible

@thisisdavidbell @mamos76
Custom widget are in no way intended to replace standard widgets, it is just an addition for people who actually want to do things in code to achieve that custom result. Standard widgets will be continued to be supported and improved as before.
I also see a case where some users might use it to feel a gap, if there is no standard widget exists at the moment for that need.

This quote sums up my thinking about designing anything quite nicely

Simple things should be simple, complex things should be possible

ok, at the moment I am quite comfortable with homehabit. A big step forward would be the ability to implement text input and datetime input. This in my opinion would take the application to a higher level still

1 Like

Good quote.
Thanks for the reply.

Upcoming

1 Like

Hi,

i used the switch example in this thread to create a custom widget.
To get the state string of an openHAB item i added a regular switch and checked the configuration json.
The custom switch widget change the state in the frontend but does not trigger the openHAB item to change.
Did i miss anything?

Br,
Trinitus01

@Trinitus01 Can confirm that there is an issue with changing the switch state. Will update on the bug fix.

1 Like

Hi,

seems the fix is not part of version 17.0, isnt it?

No, fix is not ready yet. Will be part of the later patch release.

1 Like

@Trinitus01 Just an update - the fix is not ready yet, so it might not be part 18.0 release either. It turned out to be a complex issue, needs more time.

Ok, thank you for the feedback. I checked prev. versions to 8.0 but it seems it also does not work in older version.

Versions before 12.0 should not have the same issue, so there might be something else with configuration there. There were changes in bindingId format after that, so it might be different for that specific device.

I check on version 21. Same IntemID use in Rules and Custom Widget - and in rules working fina, in custom widget - at start the status of swich is “undefined” i.e.:

const state = this.state[‘1k8pyce1wm0161fcstqdg6mdgc.device-115#switch.state’];

Same string “1k8pyce1wm0161fcstqdg6mdgc.device-115#switch.state” used in rules - work good.

Can you post the full custom widget code?

export default class extends HTMLElement {
  render() {
    const state = this.state['1k8pyce1wm0161fcstqdg6mdgc.device-115#switch.state'];
    const temp = this.state['1k8pyce1wm0161fcstqdg6mdgc.device-1#temperature-sensor.state'];
    const scale = Math.min(this.size.width, this.size.height);

    return `
      <style>
        :host {
          --scale: ${scale};
          align-items: center;
          background: ${state ? 'var(--color-active)' : 'transparent'};
          display: flex;
          flex-direction: column;
          font-size: calc(1rem * var(--scale));
          justify-content: space-evenly;
          padding: 12px;
          text-align: center;
        }

        .label {
          text-transform: uppercase;
        }

        .icon {
          font-size: 3.2em;
        }
      </style>

      <span class="icon mdi mdi-lightbulb-outline"></span>
      <span class="label">Living Room - ${state} - ${temp}</span>
    `;
  }

  onAction() {
    const currentState = this.state['1k8pyce1wm0161fcstqdg6mdgc.device-115#switch.state'];

    // toggle
    this.state['1k8pyce1wm0161fcstqdg6mdgc.device-115#switch.state'] = !currentState;
  }

  static get styles() {
    return [
      'https://cdn.materialdesignicons.com/5.4.55/css/materialdesignicons.min.css',
    ];
  }
}

When you use this widget, what is shown in a widget in place of “Living Room - [state] - [temp]”?
Also, you mentioned that status of the switch is “undefined”, do you mean state is shown as “undefined” or something different?