Express duration in seconds into human readable format

Is there a way to configure the display state of an Item containing a duration in seconds into a human-readable duration?

The tem is defined as a Number:Duration and its value is expressed in seconds.

Ideally I’d like to turn the state value “154806840 s” into e.g. “255 weeks, 6 days, 17 hours, 54 minutes”

I tried experimenting with the stateDescription metadata, e.g. with the following Java Date/Time printf format string:

%1$tY years, %1$tj days, %1$tH hours, %1$tM minutes %1$tS seconds

But this returns e.g.: “1974 days, 331 days, 17 hours, 54 minutes, 00 seconds” which clearly means the Duration entry is parsed as a UNIX timestamp starting at January 1st, 1970…

Some people seem to use dayjs but I can’t find a way to use it in the state description metadata.

Can this be done in Main UI? Or should I create my own custom Duration widget to this end?

It’s Number:Time not Number:Duration

You could use script transformation, if you’re on openhab 4M2 or snapshot

Here’s a version using ruby (you’d need to install jrubyscripting addon)

Number:Time TestDuration "Duration [RB(human_duration.rb):%s]"

human_duration.rb:

module HumanDuration
  def to_weeks_part
    to_days_part / 7
  end

  def to_human
    result = []
    %w[weeks days hours minutes seconds].each do |period|
      part = send("to_#{period}_part")
      part = part % 7 if period == "days"
      next unless part.positive?

      period.chop! if part == 1
      result << "#{part} #{period}"
    end
    result.join(", ")
  end

  ::Duration.prepend(self)
end

Duration.of_seconds(input.to_f).to_human

A Number:Time does support DateTime formatting in the state description but it does so through a hack which causes limitations. Essentially it adds the time represented by the Item’s state to a DateTime so what you are formatting is 1970-01-01T00:00:00 + Item State. So clearly this will fall down completely if your Item’s state is larger than 31 days.

You can’t use it in the State Description but you can use it in the widgets directly. So where ever in the label the state of the Item is printed you can use an expression that uses dayjs to format it. Obviously that isn’t going to work in sitemaps or HABPanel though.

In OH 4 the answer is “yes”! Finally transformations can be defined in MainUI, and you can use any installed rules language in transformations now.

In OH 3 you’ll either need to use a JS transformation or SCRIPT transformation (which can be applied in a State Description) or a rule and an extra String Item. For upgrade purposes, you are now better off using the JS transformation which hopefully will be automatically upgraded when upgrading to OH 4 (i.e. will be less work) unless you want to write your transformation in some other rules language.

A JS transformation that would do this would look something like (assumes the units on the Item is s):

NOTE: I’m going to write this Nashorn style which will be compatible with either Nashorn or GraalVM JS.

(function(data) {
  var Duration = Java.type('java.time.Duration');
  var ChronoUnit = Java.type('java.time.ChronoUnit');
  var totalSecs = parseInt(data); // removes the units and converts to a number we can do math with
  var totalDur = Duration.ofSeconds(totalSecs);
  var years = dur.trunkatedto(ChronoUnit.YEARS);
  var remTime = totalDur.minus(years);
  return  years + ' years, ' + remTime.daysPart() 
          + ' days, ' + remTime.hoursPart() + ' hours, ' 
          + remTime.minutesPart() + ' minutes, ' 
          + remTime.secondsPart() + ' seconds';
)(input)

Or if you use SCRIPT and want to use GraalVM JS which is only available when using the SCRIPT transform in 3.4.2:

(function(data) {
  var totalSecs = parseInt(data); // removes the units and converts to a number we can do math with
  var totalDur = time.Duration.ofSeconds(totalSecs);
  var years = dur.get(time.ChronoUnit.YEARS); // I'm not 100% positive this works without an import
  var remTime = totalDur.minus(years);
  var days = remTime.toDays();
  remTime = totalDur.minus(days);
  var hours = remTime.toHours();
  remTime = totalDur.minus(hours);
  var minutes = remTime.toMinutes();
  remTime = totalDur.minus(minutes);
  var seconds = remTime.toSeconds();
  return  years + ' years, ' 
          + days + ' days, ' 
          + hours + ' hours, '  
          + minutes + ' minutes, '  
          + seconds + ' seconds';
)(input)

(Ugh, this is one case where the joda-js library is less capable than the Java equivalent. )

There are lots of different ways to do this but I prefer to use Duration as the code reads better without “magic” numbers.

Great!

So far I created a file /etc/openhab/transform/human_readable_duration.js with the following code:

(function(data) {
  var totalSecs = parseInt(data); // removes the units and converts to a number we can do math with
  var totalDur = time.Duration.ofSeconds(totalSecs);
  var s = [];

  var years = dur.get(time.ChronoUnit.YEARS); // I'm not 100% positive this works without an import
  if (years > 0) { s.push( years + (years == 1 ? ' year' : ' years') ) }
  var remainingTime = totalDur.minus(years);
  var days = remainingTime.toDays();
  if (days > 0) { s.push( days + (days == 1 ? ' day' : ' days') ) }
  remainingTime = totalDur.minus(days);
  var hours = remainingTime.toHours();
  if (hours > 0) { s.push( hours + (hours == 1 ? ' hour' : ' hours') ) }
  remainingTime = totalDur.minus(hours);
  var minutes = remainingTime.toMinutes();
  if (minutes > 0) { s.push( minutes + (minutes == 1 ? ' minute' : ' minutes') ) }
  remainingTime = totalDur.minus(minutes);
  var seconds = remainingTime.toSeconds();
  if (seconds > 0) { s.push( seconds + (seconds == 1 ? ' second' : ' seconds') ) }

  return s.join(', ');
)(input)

However the openHAB docs don’t tell you how to invoke this script in the stateDescription metadata.

Yes they do, it’s just in an unexpected place. The State Description Pattern is exactly the same thing you’d put into the label for an Item or Sitemap element. So Items | openHAB applies for in general how to use it as the pattern and Transformations | openHAB tells you how to invoke the SCRIPT transformation specifically.

Note that the latter changes in OH 4. I know @JimT is working on an upgrade script that will handle migration from the JS Transformation to SCRIPT but I don’t know if it will handle the migration from SCRIPT to the new format for invoking SCRIPT.

The openHAB documentation seems to be inconsistent on the matter.

Does not work: SCRIPT(human_readable_duration.js):%s

Does not work: SCRIPT(JS:human_readable_duration.js):%s
2023-04-28 16:06:03.976 [WARN ] [e.internal.SseItemStatesEventBuilder] - Failed transforming the state ‘161445600 s’ on item ‘SolarInverter_OperatingTime’ with pattern ‘SCRIPT(JS:human_readable_duration.js):%s’: Configuration does not have correct type ‘script’ but ‘js’.

Does not work: JS.SCRIPT(human_readable_duration.js):%s
2023-04-28 16:17:14.417 [WARN ] [rest.core.item.EnrichedItemDTOMapper] - Failed transforming the state ‘161446320 s’ on item ‘SolarInverter_OperatingTime’ with pattern ‘JS.SCRIPT(human_readable_duration.js):%s’: Couldn’t transform value because transformation service of type ‘JS.SCRIPT’ is not available.

Does not work: js.script(human_readable_duration.js):%s
2023-04-28 16:21:51.240 [WARN ] [e.internal.SseItemStatesEventBuilder] - Failed transforming the state ‘161446680 s’ on item ‘SolarInverter_OperatingTime’ with pattern ‘js.script(human_readable_duration.js):%s’: Couldn’t transform value because transformation service of type ‘js.script’ is not available.

The following renders main UI unresponsive: JS(human_readable_duration.js):%s (requires shutting down openHAB and manually editing $OPENHAB_USERDATA/jsondb/org.openhab.core.items.Metadata.json to remove the offending metadata).
Reason is a problem with a missing import (time.ChronoUnit.YEARS not defined):
2023-04-28 12:31:04.972 [WARN ] [e.internal.SseItemStatesEventBuilder] - Failed transforming the state ‘161432640 s’ on item ‘SolarInverter_OperatingTime’ with pattern ‘JS(human_readable_duration.js):%s’: An error occurred while executing script. ReferenceError: “time” is not defined in at line number 3

I tried importing java.time.ChronoUnit but I fail to understand which flavour of JavaScript is used/required in this case. The following definitely does NOT work:

  var ChronoUnit = Java.type('java.time.ChronoUnit');

So what should I do to be compliant with upcoming openHAB 4.0 changes? Is there a way to debug this type of script snippets before deploying them and letting main UI crash?

The following does work, but it does not use Java.time (and does not use years):

File: /etc/openhab/transform/human_readable_duration.js:

(function(data) {
    var totalSeconds = parseInt(data); // removes the units and converts to a number we can do math with
    var s = [];

    var seconds = totalSeconds % 60;
    var remainingMinutes = (totalSeconds - seconds) / 60;
    
    var minutes = remainingMinutes % 60;
    var remainingHours = (remainingMinutes - minutes) / 60;
    
    var hours = remainingHours % 24;
    var days = (remainingHours - hours) / 24;
    
    if (days > 0) { s.push( days + (days == 1 ? ' day' : ' days') ); }
    if (hours > 0) { s.push( hours + (hours == 1 ? ' hour' : ' hours') ); }
    if (minutes > 0) { s.push( minutes + (minutes == 1 ? ' minute' : ' minutes') ); }
    if (seconds > 0) { s.push( seconds + (seconds == 1 ? ' second' : ' seconds') ); }

    return s.join(', ');
})(input)

The pattern to enter in the stateDescription metadata is:

JS(human_readable_duration.js):%s

This results in e.g.:

1792 days, 11 hours, 30 minutes

You missed this in the docs:

The script needs to be placed in the $OPENHAB_CONF/transform folder with an extension .script regardless of the actual script type

Your file is misnamed. It needs to end in .script, not .js.

I had a typo. It should be

var ChronoUnit = Java.type('java.time.temporal.ChronoUnit');

I copied and pasted but appear to have messed that up.

What you’ve done should work with OH 4 once the upgrade script runs.

It shouldn’t crash MainUI. It might be worth filing a bug. The transformation should not crash MainUI if it fails.

Be aware that you are using Nashorn JS for this. When you upgrade to OH 4 it will use what ever Java Script you install. Luckily Nashorn is compatible with GraalVM.

Thanks again for your help and your patience!

Ok, I renamed my script’s extension to .script and then I wrote the following pattern in the stateDescription metadata:

SCRIPT(human_readable_duration.script):%s

This produces the following error message which I can’t fix based on the documentation:

2023-04-28 17:47:22.319 [WARN ] [e.internal.SseItemStatesEventBuilder] - Failed transforming the state ‘154872000 s’ on item ‘SolarInverter_FeedInTime’ with pattern ‘SCRIPT(human_readable_duration.script):%s’: Script Type must be prepended to transformation UID.

What is the script type and where should it be prepended? In other words, what is the correct pattern in this case for the stateDescription metadata?

Also from the docs:

Given the filename stringlength.script , the transformation pattern is SCRIPT(<script-type>:stringlength.script):%s .

The tab for the JS example says the <script-type> is JS so

SCRIPT(JS:human_readable_duration.script):%s

I already tried this but then I get the following error message:

2023-04-28 18:49:21.299 [WARN ] [e.internal.SseItemStatesEventBuilder] - Failed transforming the state ‘154875600 s’ on item ‘SolarInverter_FeedInTime’ with pattern ‘SCRIPT(JS:human_readable_duration.script):%s’: Script type ‘JS’ is not supported by any available script engine.

I have JavaScript scripting enabled.

I’m on OH 4 so I can’t test that out myself any longer. But that is how I’ve used it in the past. I’m not sure how much more help I can be on that front beyond saying what you have working now should continue to work just fine in OH 4 once the upgrade script processes it.

1 Like

I want to stay on openHAB 3 until I can get hold of a Raspberry Pi 4 with 4GB of memory, or another (preferably low power) device which can run openHAB 4.

Try:
JS(human_readable_duration.js):%s

1 Like

Both do the job. I am now using the second option and it works like a charm!

Thanks!

You should use the JS() syntax - that way you won’t have to change it when you upgrade to OH4.

1 Like

I am still struggling with the temporal units in this transform.

Apparently I can’t retrieve the units by means of data.split(' ').

Ideally I would be able to convert the duration into ChronoUnit.SECONDS before I run the math to avoid scaling issues (values in hours or minutes or milliseconds versus seconds…)

I found a way to split the value and unit part of a QuantityType string by using data.match(/\S+/g) to split the UoM string in a value part and a unit part, irrespective of the type of white space used. Leading and trailing white space are also disregarded.

Here’s a debug-interesting version to explore:

(function(data) {
	var valueArray = data.match(/\S+/g);
	var value = valueArray[0];
	var unit = valueArray[1];

	var toSeconds = 1;
	// Incomplete but okay for now:
	if (unit == 'min') {
		toSeconds = 60;
	} else if (unit == 'h') {
		toSeconds = 3600;
	}

	var totalSeconds = parseInt(value) * toSeconds; // converts to a number we can do math with
	var s = [];

	var seconds = totalSeconds % 60;
	var remainingMinutes = (totalSeconds - seconds) / 60;
	
	var minutes = remainingMinutes % 60;
	var remainingHours = (remainingMinutes - minutes) / 60;
	
	var hours = remainingHours % 24;
	var days = (remainingHours - hours) / 24;
	
	if (days > 0) { s.push( days + (days == 1 ? ' day' : ' days') ); }
	if (hours > 0) { s.push( hours + (hours == 1 ? ' hour' : ' hours') ); }
	if (minutes > 0) { s.push( minutes + (minutes == 1 ? ' minute' : ' minutes') ); }
	if (seconds > 0) { s.push( seconds + (seconds == 1 ? ' second' : ' seconds') ); }

	// Production code:
	// return s.join(', ');

	// Debug code:
	return 'Data ('+typeof data + '): ['+data+'], Unit: ['+unit+'], value: ['+value+'], '+s.join(', ');
})(input)

I found this solution the best:

val d = Duration.between (st_time, now)
val readable_d = d.toString.substring (2).replaceAll ("(\\d[HMS])(?!$)", "$1 ").replaceAll ("\\.\\d+", "").toLowerCase

st_time would be a DateTimeType like a start time.

It gives a string like

9m 57s