Dynamic Sitemaps with JRuby

I use sitemaps because I find them to be the best way to interact with OpenHAB using my mobile devices. However, one thing that has always bothered me about Sitemaps is that they were really anti-DRY and were not dynamic.

I have just converted my sitemap to dynamic sitemap using the Jruby Openhab Rules System.

Ruby has a built in templating engine called ERB - which stands for embedded ruby, it is similar to Jinga2 in Python - but in my opinion more powerful because you have access to the full Ruby language within your templates.

I have recently been working on my whole home audio system - which was my breaking point with static sitemaps.

I had this:

          Frame label="Music" {
	    	Switch item=Nuvo_Z7_Power icon="player" 
        	Selection item=Nuvo_Z7_Source visibility=[Nuvo_Z7_Power==ON] icon="player"
        	//Volume can be a Setpoint also
			// Nuvo Controls
        	Slider item=Nuvo_Z7_Volume minValue=0 maxValue=100 step=1 visibility=[Nuvo_Z7_Power==ON] icon="soundvolume"
        	Switch item=Nuvo_Z7_Mute visibility=[Nuvo_Z7_Power==ON] icon="soundvolume_mute"
       	    Default item=Nuvo_Z7_Control icon="player"  visibility=[Nuvo_Z7_Source=="1", Nuvo_Z7_Source=="2", Nuvo_Z7_Source=="3", Nuvo_Z7_Source=="4"]
        	Text item=Nuvo_S5_Display_Line2 visibility=[Nuvo_Z7_Source=="1"] icon="zoom"
            Text item=Nuvo_S5_Display_Line3 visibility=[Nuvo_Z7_Source=="1"] icon="zoom"
            Text item=Nuvo_S5_Display_Line4 visibility=[Nuvo_Z7_Source=="1"] icon="zoom"
            Text item=Nuvo_S5_Play_Mode visibility=[Nuvo_Z7_Source=="1"] icon="player"
			// Spotify Controls  (Chromecast)
        	Default item=SpotifyTrackPlayer icon="player" visibility=[Nuvo_Z7_Source=="5"] 
	        Selection item=SpotifyPlaylists  label="Playlist" icon="music" visibility=[Nuvo_Z7_Source=="5"] 
	        Switch  item=SpotifyDeviceShuffle label="Shuffle mode:" visibility=[Nuvo_Z7_Source=="5"] 
        	Text item=ChromecastStatusArtist visibility=[Nuvo_Z7_Source=="5"] icon="zoom"
            Text item=ChromecastStatusTitle visibility=[Nuvo_Z7_Source=="5"] icon="zoom"
            Text item=ChromecastStatusAlbum visibility=[Nuvo_Z7_Source=="5"] icon="zoom"
            Text item=SpotifyDurationProgress visibility=[Nuvo_Z7_Source=="5"] icon="progress_clock"
	        Image item=ChromecastStatusImage visibility=[Nuvo_Z7_Source=="5"] icon="none"
			// SqueezeBox Controls 
        	Default item=Squeeze_Home_Audio_A_Player icon="player" visibility=[Nuvo_Z7_Source=="6"] 
			Switch item=Squeeze_Home_Audio_A_Shuffle label="Shuffle []" icon="shuffle" mappings=[0=Off, 1=Songs, 2=Albums] visibility=[Nuvo_Z7_Source=="6"] 
	        Selection item=Squeeze_Home_Audio_A_Favorites  label="Playlist" icon="music" visibility=[Nuvo_Z7_Source=="6"] 
        	Text item=Squeeze_Home_Audio_A_Track_Progress visibility=[Nuvo_Z7_Source=="6"] icon="zoom"
        	Text item=Squeeze_Home_Audio_A_Artist visibility=[Nuvo_Z7_Source=="6"] icon="zoom"
        	Text item=Squeeze_Home_Audio_A_Title visibility=[Nuvo_Z7_Source=="6"] icon="zoom"
        	Text item=Squeeze_Home_Audio_A_Album visibility=[Nuvo_Z7_Source=="6"] icon="zoom"
            Text item=Squeeze_Home_Audio_A_Duration_Progress visibility=[Nuvo_Z7_Source=="6"] icon="progress_clock"
	        Image item=Squeeze_Home_Audio_A_Cover visibility=[Nuvo_Z7_Source=="6"] icon="none"

I had to repeat that for every room that has audio in the house with one minor change, replacing ‘Z7’ everywhere with whatever zone it was. That is 33 times I had to change it for each zone. Also, the way I organize my sitemaps is by room and by control area (audio, lighting), so this was again duplicated twice for every zone.

Then if I wanted to make a change or made a mistake, I was updating this thing all over the sitemap.

Here is what it looks like now:

Frame label="Music" {
  <%= render('music', zone: 7) %>

And for the kitchen:

Frame label="Music" {
  <%= render('music', zone: 1) %>

So how does this work and what does it look like:

First I have a Jruby rules file:

require 'openhab'
require 'erb'

SITEMAP_DIR = __conf__/'sitemaps/'

# Render a template 
# @param source template file in sitemap dir without the .erb extension
# @param variables optional list of bindings for template, if supplied template will not have access to openhab variables
def render(source, **variables)
  template = ERB.new(File.read(File.join(SITEMAP_DIR, "#{source}.erb")), trim_mode: '-')
  variables ? template.result_with_hash(variables) : template.result 

# Format the sitemap
# by stripping the whitespace and properly indentint
def format(sitemap)
    level = 0
           .map {|line| line.strip}
           .map do |line|
              level-= line.count '}' 
              indent = '  ' * level
              level+= line.count '{' 
              indent + line 

# Render the sitemap
def sitemap
  sitemap = 'home.sitemap'
  File.write(File.join(SITEMAP_DIR, sitemap), format(render(sitemap)))
  logger.info "Rendered #{sitemap}"

rule 'Render Sitemap' do
  watch SITEMAP_DIR/'home.sitemap.erb'
  run { sitemap }

At a high level it has a rule with a trigger on startup that renders the sitemap and another trigger that fires whenever the template file changes.

The interesting methods are sitemap and render.

sitemap writes a new ‘home.sitemap’ from the template ‘home.sitemap.erb’ by calling the render method.
The render method is where the magic happens, the method takes the name of a template (without the .erb extension) and invokes the built in template engine and then returns the result. Here is the neat thing, during the templating process the template has full access to all openhab items, groups, etc.

One downside of the templating engine is that it doesn’t maintain indentation. So I created the format method which just stripes all whitespace then indents the rendered sitemap based on curly brackets.

If you remember, in my example above my sitemap template just had:

Frame label="Music" {
  <%= render('music', zone: 7) %>

So how is that working in the template? I mention that the template has access to all openhab items, groups, etc ? It also has access to all methods defined in the rules file, so it is just calling the render method defined above which looks for a template file named ‘music’ and passes in an optional variable of ‘zone is 7’. In Ruby this is called partial templates, where a template can include and render another template inside it self. This lets you decompose your sitemap/templates.

This is what music.erb looks like:

<%=%Q(Switch item=Nuvo_Z#{zone}_Power icon="player" 
Selection item=Nuvo_Z#{zone}_Source visibility=[Nuvo_Z#{zone}_Power==ON] icon="player"

// Nuvo Controls
Setpoint item=Nuvo_Z#{zone}_Volume minValue=0 maxValue=100 step=1 visibility=[Nuvo_Z#{zone}_Power==ON] icon="soundvolume"
Switch item=Nuvo_Z#{zone}_Mute visibility=[Nuvo_Z#{zone}_Power==ON] icon="soundvolume_mute"
Default item=Nuvo_Z#{zone}_Control icon="player"  visibility=[Nuvo_Z#{zone}_Source=="1", Nuvo_Z#{zone}_Source=="2", Nuvo_Z#{zone}_Source=="3", Nuvo_Z#{zone}_Source=="4"]
Text item=Nuvo_S5_Display_Line2 visibility=[Nuvo_Z#{zone}_Source=="1"] icon="zoom"
Text item=Nuvo_S5_Display_Line3 visibility=[Nuvo_Z#{zone}_Source=="1"] icon="zoom"
Text item=Nuvo_S5_Display_Line4 visibility=[Nuvo_Z#{zone}_Source=="1"] icon="zoom"
Text item=Nuvo_S5_Play_Mode visibility=[Nuvo_Z#{zone}_Source=="1"] icon="player"

// Spotify Controls  (Chromecast)
Default item=SpotifyTrackPlayer icon="player" visibility=[Nuvo_Z#{zone}_Source=="5"] 
Selection item=SpotifyPlaylists  label="Playlist" icon="music" visibility=[Nuvo_Z#{zone}_Source=="5"] 
Switch  item=SpotifyDeviceShuffle label="Shuffle mode:" visibility=[Nuvo_Z#{zone}_Source=="5"] 
Text item=ChromecastStatusArtist visibility=[Nuvo_Z#{zone}_Source=="5"] icon="zoom"
Text item=ChromecastStatusTitle visibility=[Nuvo_Z#{zone}_Source=="5"] icon="zoom"
Text item=ChromecastStatusAlbum visibility=[Nuvo_Z#{zone}_Source=="5"] icon="zoom"
Text item=SpotifyDurationProgress visibility=[Nuvo_Z#{zone}_Source=="5"] icon="progress_clock"
Image item=ChromecastStatusImage visibility=[Nuvo_Z#{zone}_Source=="5"] icon="none"
// SqueezeBox Controls 
Default item=Squeeze_Home_Audio_A_Player icon="player" visibility=[Nuvo_Z#{zone}_Source=="6"] 
Switch item=Squeeze_Home_Audio_A_Shuffle label="Shuffle []" icon="shuffle" mappings=[0=Off, 1=Songs, 2=Albums] visibility=[Nuvo_Z#{zone}_Source=="6"] 
Selection item=Squeeze_Home_Audio_A_Favorites  label="Playlist" icon="music" visibility=[Nuvo_Z#{zone}_Source=="6"] 
Text item=Squeeze_Home_Audio_A_Track_Progress visibility=[Nuvo_Z#{zone}_Source=="6"] icon="zoom"
Text item=Squeeze_Home_Audio_A_Artist visibility=[Nuvo_Z#{zone}_Source=="6"] icon="zoom"
Text item=Squeeze_Home_Audio_A_Title visibility=[Nuvo_Z#{zone}_Source=="6"] icon="zoom"
Text item=Squeeze_Home_Audio_A_Album visibility=[Nuvo_Z#{zone}_Source=="6"] icon="zoom"
Text item=Squeeze_Home_Audio_A_Duration_Progress visibility=[Nuvo_Z#{zone}_Source=="6"] icon="progress_clock"
Image item=Squeeze_Home_Audio_A_Cover visibility=[Nuvo_Z#{zone}_Source=="6"] icon="none")%>

Which is essentially what we had before except:
The <%= which have seen above is the ERB syntax for replace this the result of the code in this block
the %Q tells Ruby to interpolate the multi-line string that follows and the ‘#{zone]’ is where ruby inserts the zone number passed above as the variable.

As I mentioned above, I have my sitemap setup by room and by control area. Which means I have a control area called “Audio” that contains all my zones.

There are are two ways I could do that, I could do:

Frame label="Office" {
   <%= render('music', zone: 7) %>
Frame label="Kitchen" {
   <%= render('music', zone: 1) %>

However, since you can execute arbitrary ruby in the templates, I did this:

	    <% ['Kitchen', "Anna's Office", 'Family Room', 
			'Primary Bathroom', 'Primary Bedroom', 'Covered Deck', 'Office'
		   ].each.with_index(1) do |room, zone| %>
		<%= %(Frame label="#{room}" { ) %>
		<%= render('music', zone: zone) %>
		<%= "}" %>
	    <% end %>

When using ERB, the thing to remember is that <% just executes code and <%= replaces what is there with the result of that code. That is why the loop controls and end don’t have the ‘=’


That’s a very creative workaround to the static sitemap.
Just a caution that results may vary by viewer - different browsers, apps, that do not expect such changes.

Not sure that I am following?
The end result is no different than me having edited the ‘home.sitemap’ by hand.

Absolutely. But not all sitemap viewers deal as well with a changing sitemap, e.g. cache and refresh issues are possible.

Awesome!! I too am still using sitemaps, because I find them much easier to create/edit than the new MainUI.

My Sitemaps are extremely simple in comparison. I only have the bare minimum for controlling various key things in the house (alarm, garage door, lights (under a subitem), TVs).

One of the problems I am having is how to sort the items contained in a Group item=xxx. This dynamic generation could the the ticket to solve it by using a subframe instead. There has been some talks about using metadata to sort the groups but I haven’t looked into it much further. Not sure if it has been implemented.

I made a templated things/items file generation using Python/Jinja before I came across JRuby. I have been wanting to rewrite it into JRuby / ERB but haven’t had a chance yet. It is currently regenerated manually via the cli. This gives me an idea to regenerate it automatically.

I’d love to see a screenshot of your sitemap as rendered on the devices. I’m curious how you manage the “room by room” information without having a huge sitemap that gets very long to scroll around.

I have already started on items as well :slight_smile:

1 Like

Awesome! I’d love to see what you’ll come up. Here’s mine in Python - try not to laugh at my code - I was (and still am) a Python beginner GitHub - jimtng/ohgen: OpenHAB Things and Items Generator

I started learning Python and JRuby because of OpenHAB

Looking forward to you publishing your items generator to github. I still haven’t found the time to start creating one in ruby, so I might as well use yours if you are willing to share.

I updated the example with the newly released ‘watch’ directive for watching directories and files.