openHAB and Homepage

As part of my recent efforts to shore up my homelab environment and make everything easier for the family to use, I’ve been experimenting with Homepage as a dashboard. The thing I like about Homepage is it integrates health and status checks on the dashboard which can save me from needing to rely too much on other monitoring services I use to send me alerts.

Here is a quick tutorial for how I integrated openHAB into Homepage. Installing Homepage and fully configuring that is beyond the scope of this tutorial. See their docs for details. This tutorial will include what you need to configure just for openHAB.

openHAB

Homepage does not have a native widget for openHAB but it does have the customapi widget which can query another service’s REST API and parse it to provide information on the dashboard. Unfortunately it’s pretty basic in what it can do with the retrieved JSON so we need to create a rule in openHAB to update a String Item with simplified JSON for Homepage to consume.

The stats I’ve chosen to show on Homepage are:

  • uptime in DDd HHh MMm format.
  • Number of not ONLINE Things/Number of Things
  • Number of enabled Rules/Number of Rules
  • Number of Items

First, if you haven’t already, create an API token. You’ll need this for the rule and for Homepage.

Next create an Item. I named mine Homepage_Stats.

The following rule will gather the above statistics and update Homepage_Stats with a JSON String containing them. The rule follows. I’ll add it to the marketplace upon request.

configuration: {}
triggers:
  - id: "2"
    configuration:
      cronExpression: 0 0/2 * * * ? *
    type: timer.GenericCronTrigger
conditions: []
actions:
  - inputs: {}
    id: "1"
    configuration:
      type: application/javascript
      script: |-
        // Version 0.1
        const loggerBase = 'org.openhab.automation.rules_tools.PublishStats.'+ruleUID;
        console.loggerName = loggerBase;
        // osgi.getService('org.apache.karaf.log.core.LogService').setLevel(console.loggerName, 'DEBUG');

        console.debug('Homepage rule triggered by ' 
                      + event.eventName + ' ' 
                      + event.eventSource + ' ' 
                      + event.module + ' ' 
                      + event.triggerType + ' '
                      + event.eventType + '\n'
                      + event.raw.toString());

        // Properties
        const STATS_ITEM = 'Homepage_Stats';
        const API_TOKEN = 'oh.status.YOURTOKEN';

        // Things stats
        const allThings = things.getThings();
        const numThings = allThings.length;
        const numOnlineThings = allThings.filter(t => t.status == "ONLINE").length;
        const numOfflineThings = numThings - numOnlineThings;
        console.trace('Total Things: ' + numThings + ' Online Things: ' + numOnlineThings + ' Offline Things: ' + numOfflineThings);

        // Since we have to pull from the REST API anyway, we can get this value from there
        // const uptime = Quantity(Java.type('java.lang.management.ManagementFactory').getRuntimeMXBean().uptime + ' ms');
        // console.trace('Uptime in seconds: ' + uptime.toUnit('s'));
        let startLevel = 'ERR';
        let uptimeStr = 'ERR;'
        // Get the system info
        try {
          const headers = new Map();
          headers.set('accept', 'application/json');
          headers.set('X-OPENHAB-TOKEN', API_TOKEN);
          const sysinfo = JSON.parse(actions.HTTP.sendHttpGetRequest('https://openhab.koshak.us/rest/systeminfo', headers, 3000));
          console.trace('Raw statistics:\n' + JSON.stringify(sysinfo));
          
          /** Example
          {
            "systemInfo":{
              "configFolder":"/openhab/conf",
              "userdataFolder":"/openhab/userdata",
              "logFolder":"/openhab/userdata/logs",
              "javaVersion":"21.0.10",
              "javaVendor":"Debian",
              "osName":"Linux",
              "osVersion":"6.8.0-110-generic",
              "osArchitecture":"amd64",
              "availableProcessors":4,
              "freeMemory":853541448,
              "totalMemory":1384120320,
              "uptime":319198,
              "startLevel":100}}
          */
          
          // Start Level
          startLevel = sysinfo.systemInfo.startLevel;
          console.trace('Start Level: ' + startLevel);
          
          // Uptime
          const uptime = time.Duration.ofSeconds(sysinfo.systemInfo.uptime);
          const days = uptime.toDays();
          const hours = uptime.minusDays(days).toHours()
          const mins = uptime.minusDays(days).minusHours(hours).toMinutes();
          const seconds = uptime.minusDays(days).minusHours(hours).minusMinutes(mins).seconds();
          uptimeStr = ((days > 0) ? days + 'd ' : "") + hours + "h " + mins + "m"; // + seconds.toString().padStart(2, '0') + ' s';
          console.trace(" Uptime: " + uptimeStr);
        }
        catch(e) {
          console.error('Failed to query REST API for start level and uptime: ' + e);
        }

        // Rules
        const { ruleRegistry } = require('@runtime/RuleSupport');
        const allRules = utils.javaSetToJsArray(ruleRegistry.getAll());
        const numRules = allRules.length;
        const enabledRules = allRules.filter(r => rules.isEnabled(r.getUID().toString())).length;
        const numDisabledRules = numRules - enabledRules;
        console.trace('Total Rules: ' + numRules + ' Num Enabled Rules: ' + enabledRules + ' Num Disabled Rules: ' + numDisabledRules);

        // Items
        const numItems = items.getItems().length;
        console.trace('Num Items: ' + numItems);

        // Build and publish the JSON
        const newState = { 'uptime' : uptimeStr,
                           'startLevel' : startLevel,
                           'things' : numThings,
                           'onlineThings': numOnlineThings,
                           'thingsSummary': numOnlineThings+'/'+numThings,
                           'rules': numRules,
                           'enabledRules': enabledRules,
                           'rulesSummary': enabledRules+'/'+numRules,
                           'items' : numItems
                         };
        console.debug(JSON.stringify(newState));

        items[STATS_ITEM].postUpdate(JSON.stringify(newState));
    type: script.ScriptAction

I tried to make this work using generic triggers but I could not get the generic trigger to react on StartlevelEvents and it appears that enabling/disabling a rule doesn’t generate a RuleUpdatedEvent. So I opted for a simply cron trigger instead.

Homepage

Docker Stats

Homepage is able to pull the statistics of the docker container from openHAB. If you are not running openHAB in Docker, you can skip this section.

The first thing you’ll want to do is expose the Docker socket on the host to the Homepage container. If they are running on the same host, that’s as easy as mounting /var/run/docker.sock into the Homepage container. If not, I use a Docker socket proxy for this. My Ansible task to spin this up right now is:

- name: Pull and run the docker proxy
  community.docker.docker_container:
    detach: true
    env:
      CONTAINERS: "1"
      SERVICES: "1"
      TASKS: "1"
      POST: "0"
    hostname: "{{ ansible_fqdn }}"
    image: ghcr.io/tecnativa/docker-socket-proxy:latest
    log_driver: "{{ docker_log_driver }}"
    name: docker_proxy
    published_ports:
      - "{{ docker_proxy_port }}:2375"
    privileged: true
    recreate: true
    restart_policy: unless-stopped
    state: started
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"

The “ro” on the mount and “POST: 0” makes this read only. I run this on all my hosts running Docker so Homepage can query the stats for all my services.

On the Homepage side, you’ll need to edit docker.yaml and add an entry:

openhab_host:
  host: <ip or hostname of docker socket proxy>
  port: 2375

Adding to the Dashboard

You’ll edit services.yaml and add an entry for openHAB. We will set up a serviceMonitor which will attempt to make an HTTP request to your openHAB server and display how long that takes in the upper right corner of the widget. We will also add a container which adds another status to the upper right showing the online status of the container and clicking on that will show statistics of that container (e.g. RAM usage). Finally, we’ll add a widget to show those stats we set up above from the Homepage_Stats Item.

The full yaml:

    - openHAB:
        icon: openhab.png
        href: https://<IP or hostname and port of openHAB>
        siteMonitor: https://<IP or hostname and port of openHAB>
        server: openhab_host # name of the server in docker.yaml, omit if not using docker
        container: openhab # name of the openHAB container, omit if not using docker
        widget:
          type: customapi
          url: https://<IP or hostname and port of openHAB>/rest/items/Homepage_Stats/state
          headers:
            X-OPENHAB-TOKEN: oh.status.YOURTOKEN
          mappings:
            - field: uptime
              label: Uptime
            - field: thingsSummary
              label: Things
            - field: rulesSummary
              label: Rules
            - field: items
              label: Item

That’s it. It will look like this on Homepage.

If you click on the Docker container status (“HEALTHY”) it will be:

This is probably the last openHAB facing work I’m going to be doing while shoring up my homelab so there probably won’t be any more tutorials like these unless requested.

What do you mean by “generic trigger”? What’s wrong with the “regular” startlevel trigger?

The “run status” of a rule is in a wrapper outside the rule itself, so it’s not a property of the rule. As such, it won’t trigger a rule updated event. But, if no event is created when a rule is enabled or disabled, perhaps that’s a shortcoming that should be looked into?

An example to trigger on Things being added/removed or changing their online status:

  - id: "2"
    label: Thing added, removed, or status changed
    description: Triggers when a Thing is added or removed
    configuration:
      topic: openhab/things/**
      types: ThingAddedEvent,ThingRemovedEvent,ThingStatusInfoChangedEvent
      source: ""
      payload: .*
    type: core.GenericEventTrigger

But I couldn’t get GenericEventTrigger to work with StartlevelEvents.

I didn’t want to add five of them to trigger on all changes in startlevel if I didn’t have to.

I certainly wouldn’t be against it, but it’s utility would be pretty limited to weird stuff like I’m doing. I can’t think of too many reasons why anything in OH would want or need to know when a rule gets enabled or disabled beyond my little statics gathering use case. And even I wouldn’t be able to justify the work to add it just for that.

Homepage only polls every five minutes anyway so an every 2 minute cron trigger is reasonable. I mainly just wanted to see if I could do it with all GenericEventTriggers.

After working so much with rules, I wasn’t aware of this trigger type. It seems like it’s “really well hidden” - I can’t find anything about it in the documentation, and I can’t find it even under “show all” when adding a trigger in the UI.

It is available with my YAML hints though, so I was able to add it in YAML, and then it does actually show up in the “design view” as “Basic Event Trigger”:

Since I know nothing about it, I don’t know why it doesn’t work, but I seem to recall that there is “special handling” of the system level trigger to make it react in time. So, that might be why it doesn’t trigger, this trigger doesn’t have this “special treatment” and probably isn’t “active” until after the event occurred. The system start level trigger goes “back in time” and triggers for levels that have already been reached when first evaluated, if I remember correctly.

That said, this was a strange animal. Topic must match the class name of the event type it seems, but without a complete list, how do people know what’s available? Watching the event log?

In addition, the three remaining fields all act differently: source must be an exact match, topic uses “glob” syntax while payload uses regex. That should be enough to confuse anyone :smiley:

https://www.openhab.org/docs/configuration/jsr223.html: All the way down the page, second from the bottom.

It’s definitely not a trigger that 99% of OH users would ever use. But on occasion it’s handy to just trigger off of what ever I want from the raw event stream, particularly when the alternative is setting up and/or needing to maintain lots of separate triggers.

I need to file an issue though. While there isn’t a way to create this type of trigger from the UI, if you add one manually on the code tab it will show you a form if you try to edit it. But the optional fields are not optional.

On that screenshot you see there, Source, Event Type, and Event Payload are all optional and not required. The desription even says “will match all events if emptry” but becuase it’s required it won’t let you leave it empty.

And while obscure, the event names match what you see in events.log and the topics and events match what you see in the developer sidebar event stream. So they don’t take all that much research to figure out.

That’s one way. Watching the event stream in the developer sidebar also works. You can find all the classes that extend the Event interface here. You can dig deeper into the code by searching GitHub to find where the event you care about is created to get at the topic it’s published to. But usually just knowing the first half of the topic is enough if you know the event class you care about. Everything Item related is published to openhab/items, everything Things related are published to openhab/things and so on. The next level of topic is always the ID of idividual entiti4es (e.g. openhab/items/Homepage_Stats). It’s usually easy enough to guess and get right.

:person_shrugging: I just work here. :wink:

For the most part, helper library developers and maybe sometimes rule template authors like me are the only ones who really should be using this trigger. We should all be knowledgable enough to know where to look and deal with the lack of consistency and clear docs.

Though, I’ve always thought that we should document the topics somewhere to make the developer sidebar easier to work with. But I haven’t even finished the file locations PR to the docs so its one more thing on the backlog.

The link doesn’t actually work, but I managed to find it by searching for jsr223. It wasn’t enough for the “documentation search” to pick it up when I searched though, and I have no idea why it’s put under jsr223. It’s a trigger like any other, and I’m not sure I see the relation between the trigger and jsr223 - except that the helper library authors probably are the ones that usually use it.

I don’t know why users “shouldn’t” use it, such generic tools are better than having to make a custom trigger for all kind of situations. But, it would have to be documented/explained, not hidden in the UI and of course not bugged in the UI. I’m not so sure that the event type is optional, I haven’t checked the details, but from the code I’ve seen, this is used as an “event filter” handled by core, and leaving it blank would probably mean that “nothing matches” rather than “everything matches”.

I know how to find them, I’ve looked them up in Eclipse already (just right-click the parent interface and “show implementations”). But, for a normal users, using IDEs or Javadocs to figure out the internal relationships and which class names qualify is hardly viable. I guess that’s part of the reason why it’s “hidden”, nobody wants to actually document all this.

Regarding the different “match criteria” for the fields, I just find it strange. I’d say that exact match, regex and glob might be desirable for all fields, depending on. I would certainly imagine than something more than a full equals match on source would be useful.

But it’s not like any other. You can’t use it from Rules DSL. You can’t use it directly in the UI. I know that JS Scripting doesn’t discuss it (though it is in the code). I don’t know about the other languages.

This trigger is part of the “raw” OH API documented in JSR223, but it’s not a normal trigger exposed to regular end users. It never has been. Maybe it could be in the future. I don’t have too strong of an opinion on the matter except for the fact that it will trip up a lot of end users who try to use it.

With the addition of the event source recently, I can see some more useful reasons why one might want want to use it now so maybe it’s time has come. (e.g. trigger on commands but only from a certain source).

The generic trigger was never intended to be used by “normal” end users. There’s no way to use it following normal means. It’s not available to choose in the UI. It’s not supported by Rules DSL. JS Scripting at least doesn’t document it’s existence (though it is in the code).

It was originally created way back when in the first place at the request of the helper library developers so they could define triggers that didn’t yet exist in core (e.g. trigger on status changes to all Things).

IIRC many of the Jython helper library triggers just use GenericEventTriggers instead of the triggers provided by core. But since then almost every “normal” thing GenericEventTriggers were useful for has been implemented by a standard trigger now. And the few that are not like triggering on the creation of an Item or deletion of a Thing are really niche and likely means you really know what you are doing already.

Well, a glob makes sense for the Topic so you can match stuff like openhab/items/*/updated (I’m guessing at the topic) to trigger on all updates to all Items. I suppose a REGEX could make sense here but a glob is always going to be easier.

A list makes the most sense for Source to me. I can certainly see wanting to match against more than one source without creating a separate trigger for each one. Maybe regex or glob could be useful, but some of the source strings do not follow quite so nice of a hierarchy as the structure of the topics have so a glob might be awkward.

A list makes sense here. There are not that many event types and the topic already narrows down the potential events you might receive. You’d never receive a RuleUpdatedEvent on the openhab/items/** topic, for example. A glob doesn’t really make sense to me since these are all discrete names without a real hierarchy. A regex can make sense, but to me doing anything with a regex is more complicated than just listing the at most four events your interested in. And if you care about more than four events for the one trigger.

What one does want to avoid is just triggering on everything (e.g. openhab/** as the topic, nothing for the rest of the fields). Even is one has it narrowed down by using source and/or event types that’s going to be a lot of grinding as there are a lot of events in OH at any given moment. I’ve not tested that but I wouldn’t be surprised if it pegged a CPU and started to fall behind as its queue fills up.

The thing is that a regex can do anything a glob can, more or less as easily. But, I too feel that it’s “easier” to use a glob if that suffices, because there are fewer “traps” one might fall into. But, the regex for openhab/items/*/updated is openhab/items/.*/updated or openhab/items/.+/updated (depending on your desire), so it’s not exactly a lot more complicated. ? is a glob is simply . in regex. The problem with regex is that some things must be escaped, like ., if you want it to be literal. But, a glob is very limited, and it takes very little complexity before you just can’t do it. And then regex is way more appealing, because it probably can.

Regex can do many things easily. If you want to match a list of different sources, you do foo|bar|fu|buhr. So, it really covers a lot, it can also easily do things that “starts with”, “ends with”, “contains” etc. I’ve seen cases where people have been using regex for years without realizing, they have just learned to use a few of the operators like | and think that it’s a “normal text match” otherwise. You get away with that until you happen to use one of the reserved characters that need escaping.

Yes, I see the concern here. I’m sure some would manage to make completely off-the-charts triggers if they knew about this, and it might not be so easy to explain that it’s really their fault. Still, it’s not that hidden anymore, my YAML lookup lists it like any other trigger unless I do something to explicitly hide it (like has been done in the UI).

The decision to not include it in DSL is deliberate I assume, and the same with the UI. It doesn’t “make” the trigger any different than others in itself, it’s just that somebody has decided to hide it. I don’t know about DSL, perhaps it would be challenging to define a syntax for it, but since somebody has already created a (bugged) UI dialog for it, there must have been some desire to have it visible.

In the scheme of things, there are so few topics and the naming and hierarchy is so regular I struggle to think of anything but the most contrived of cases where a glob won’t suffice.

It wouldn’t hurt me if regex were added, but I certainly would never use it. I might reference the famous quote often attributed to Jamie Zawinski here.

Regex can do many things, true. Regex can do many things easily for those who know and use regex regularly. For the rest of us it is archane and even the simplest of patterns need to be tested at regex101.com to even be remotely trusted.

But again, if it’s there I’m under no obligation to use it.

Or no one got around to it. Rules DSL has been ignored for many many years before the recent flurry of activity on it.

I think that dialog is automatically generated from some metadata from somewhere. I seriously doubt it was created by hand.

I’m not saying that regex is “simple”, it can be certainly look very “scary” (this is the rule UID validation I’m just working on adding to MainUI: [^\s\/\\\x00-\x1F\x7F](?:[^\/\\\x00-\x08\x0A-\x1F\x7F]*[^\s\/\\\x00-\x1F\x7F])?). But, most of what makes regex “scary” is the escaping rules. It’s they that make it so extremely hard to read at times. The point is that if you just make sure to escape a few characters that are “reserved characters”, you can use it as a regular “text match”/search. All the advanced functionality comes from using those same “reserved characters”, and if you always escape those when needed, you can opt to stay away from all the complicated stuff.

I actually find regex’es relatively easy to write most of the time (there are exceptions), but often almost impossible to read.

My point wasn’t first and foremost that “you should prefer regex to glob”, it’s rather that having 4 fields in the same trigger that use completely different rules (to do more or less the same thing: filtering), is confusing. And if you had to pick one to use overall, it would have to be regex, because glob is too limited, it can’t do e.g a simple “or” operation.

You’re right, it’s automatically populated by org.openhab.ui/web/src/pages/settings/rules/rule-module-popup.vue and org.openhab.ui/web/src/components/config/config-sheet.vue. But, that means that the reason it requires values isn’t in the UI, it’s because the parameter definition is wrong (required should be false) here:

edit: Regarding regex, if you always escape these characters (so that they are interpreted literally), there should be no surprises and it will simply match the text you enter.

  • . (Dot): Use \. to match a literal period.
  • * (Asterisk): Use \* to match a literal star.
  • ? (Question Mark): Use \? to match a literal question mark.
  • + (Plus): Use \+ to match a literal plus sign.
  • [ or ] (Brackets): Use \[ or \] to match literal brackets.
  • ( or ) (Parentheses): Use \( or \) to match literal parentheses.
  • { or } (Curly Braces): Use \{ or \} to match literal braces.
  • ^ (Caret): Use \^ to match a literal caret.
  • $ (Dollar Sign): Use \$ to match a literal dollar sign.
  • | (Pipe): Use \| to match a literal pipe character.
  • \ (Backslash): Use \\ to match a literal backslash.

You can then “spice it up” using a few of the “functional characters” like ., *, + and | only, and get pretty far with just those tools.

Issue created.