MainUI Widget with graphical timeline of item historical states

I wanted a MainUI widget that displays the historical state of an item.

I wrote a JS rule that processes each item I want to display as such into a new item holding a JSON string that can later be ingested by a MainUI repeater widget. The rule maps item states to labels and colours. This looks like this in the end:

This is the JS rule to process, it needs to be adapted to each item possible values, in this version I process a Contact item.

You need to create a String item with the same name as the item which value you want to display and the _HIST suffix. This item should not be persisted. I added all the items I wanted to process in a dedicated gTimelineWidget group.

This is my first attempt to the subject, it is not very flexible and has lots of room for improvements. You’ll need to modify the rule and the widget to display something else than 24h (processGroupMember function and labels at the end of the widget.)

If the widget constantly change states, it might tax a bit too much the CPU to compute the string item as it is fully reprocessed for each change.

rules.JSRule({
    name: "Historic state query for gTimelineWidget members",
    description: "Queries historic state of all members of a group for the last 24 hours on every change",
    triggers: [
        triggers.GroupStateChangeTrigger("gTimelineWidget"),
        triggers.GenericCronTrigger("0 */5 * * * ?") // Run every 5 minutes
    ],
    execute: (data) => {
        // If triggered by cron, process all members
        if (data.cronExpression) {
            processGroupMembers("gTimelineWidget");
        } else {
            // Otherwise process only the changed item
            var changedItemName = data.itemName;
            processGroupMember(changedItemName);
        }
    }
});

// Process all group members' historic states on file reload
processGroupMembers("gTimelineWidget");

function processGroupMembers(groupName) {
    var group = items.getItem(groupName);
    group.members.forEach(member => {
        processGroupMember(member.name);
    });
}

function processGroupMember(itemName) {
    var member = items.getItem(itemName);
    var now = new Date();
    var start =  time.Instant.ofEpochMilli(new Date(now.getTime() - (24 * 3600 * 1000)).getTime());
    var queryStart = new Date(now.getTime() - (25 * 3600 * 1000));

    var histItemName = itemName + "_HIST";

    var historicStates = member.persistence.getAllStatesSince(queryStart);
    
    var result = [];
    var previousTime = start;

    // console.log("Processing item: " + itemName);
    // console.log("Historic states count: " + historicStates.length);
    // console.log("Start time: " + start);
    // historicStates.forEach(persisted => {
    //     console.log("State: " + persisted.state + ", Timestamp: " + persisted.instant);
    // })

    // Initialize first data point either at first provided state or at start time
    var previousState = null;
    var previousTime = start;
    if (historicStates[0]?.instant.isBefore(previousTime)) {
        previousTime = historicStates[0]?.instant;
    }

    historicStates.push({
        state: member.state,
        instant: time.Instant.ofEpochMilli(now.getTime())
    })

    // The list of states may not begin before start, in that case the first state is null
    // Process each historic state
    historicStates.forEach((persisted, idx) => {
        // If the state is different from the previous state or if it is the last, process the state
        if (persisted.state != previousState || idx == historicStates.length - 1) {
            // If the state is after the start time, create a new entry
            if (persisted.instant.isAfter(start)) {
                // Clamp to start of period for duration calculation
                var duration = (previousTime.isBefore(start) ? start : previousTime).until(persisted.instant, time.ChronoUnit.SECONDS);
                
                result.push({
                    dur: Math.round(duration*1000 / (24*3600))/10,
                    col: mapValueToColor(previousState),
                    txt: mapValueToText(previousState),
                    start: previousTime,
                    end: persisted.instant,
                })
            }

            previousState = persisted.state;
            previousTime = persisted.instant;
        }
    })

    var payload = {
        states: result,
        lastTxt: result[result.length - 1]?.txt,
        lastCol: result[result.length - 1]?.col,
        end: now
    }

    var json = JSON.stringify(payload);
    console.log("Result: " + json);

    // Send the result to the corresponding item
    items.getItem(histItemName).sendCommand(json);
}

function mapValueToColor(value) {
    // Implement your value-to-color mapping logic here
    if (value == "CLOSED") {
        return "rgb(56, 204, 101)";
    } else if (value == "OPEN") {
        return "red";
    } else {
        return "gray";
    }
}

function mapValueToText(value) {
    if (value == "CLOSED") {
        return "Vide";
    } else if (value == "OPEN") {
        return "Occupé";
    } else {
        return "N/A";
    }
}

The code of the custom widget is the following. It is a div so I don’t think it can be added directly to a layout, I embedded it in a custom card widget.

uid: vas_timeline_div
tags: []
props:
  parameters:
    - context: item
      description: Item holding history over the last period
      label: History item
      name: itmHist
      required: true
      type: TEXT
  parameterGroups: []
timestamp: Apr 9, 2025, 4:45:42 PM
component: oh-context
config:
  variables:
    key: =Math.random().toString(36).substring(2, 10);
    data: =JSON.parse(items[props.itmHist].state)
slots:
  default:
    - component: div
      config:
        stylesheet: |
          .timeline {
            display: flex;
            border-radius: 4px;
            overflow: hidden;
            background-color: transparent;
            margin: 0px;
            padding: 0px;
            width: 100%;
            flex-grow: 1;
          } .period-div {
            display: block;
            position: relative;
            font-size: 10px;
            padding: 0px;
            min-width: 0px;
          } .timeline-scale-container {
            width: 100%;
            display: flex;
            -webkit-box-pack: justify;
            justify-content: space-between;
          } .timeline-scale-label {
            flex: 1 1 0%;
            color: #808893;
            font-size: 10px
          }
        config: {}
      slots:
        default:
          - component: oh-repeater
            config:
              containerClasses: timeline
              for: repeat
              fragment: false
              in: =JSON.parse(items[props.itmHist].state).states
            slots:
              default:
                - component: f7-button
                  config:
                    class: period-div
                    popover-open: =".popover-patch-"+loop.repeat_idx+"-"+vars.key
                    style:
                      background-color: =loop.repeat.col + ' !important'
                      border-radius: 0
                      height: 8px
                      width: =loop.repeat.dur +'%'
                    text: =".popover-patch-"+loop.repeat_idx
                - component: f7-popover
                  config:
                    backdrop: false
                    class: =['popover', 'popover-patch-'+loop.repeat_idx+"-"+vars.key]
                    close-on-escape: true
                    style:
                      --f7-popover-bg-color: black
                      --f7-card-bg-color: black
                    text: =loop.repeat.txt
                    vertical-position: bottom
                  slots:
                    default:
                      - component: f7-card
                        config:
                          style:
                            color: white
                        slots:
                          default:
                            - component: Label
                              config:
                                text: =dayjs(loop.repeat.start).format('H:mm') + ' - ' +
                                  dayjs(loop.repeat.end).format('H:mm')
                            - component: div
                              slots:
                                default:
                                  - component: div
                                    config:
                                      style:
                                        border-radius: 50%
                                        display: inline-block
                                        width: 10px
                                        height: 10px
                                        background-color: =loop.repeat.col
                                  - component: Content
                                    config:
                                      text: =loop.repeat.txt
          - component: div
            config:
              class: timeline-scale-container
            slots:
              default:
                - component: span
                  config:
                    class: timeline-scale-label
                    content: "=items[props.itmHist].state.startsWith('{') ?
                      dayjs(JSON.parse(items[props.itmHist].state).end).subtrac\
                      t(24, 'hour').format('H:mm') : ''"
                - component: span
                  config:
                    class: timeline-scale-label
                    content: "=items[props.itmHist].state.startsWith('{') ?
                      dayjs(JSON.parse(items[props.itmHist].state).end).subtrac\
                      t(12, 'hour').format('H:mm') : ''"
                    style:
                      text-align: center
                - component: span
                  config:
                    class: timeline-scale-label
                    content: Maintenant
                    style:
                      text-align: end

4 Likes

These are good candidates for the marketplace. The rule would be a rule template, obviously, and the widget a custom UI widget. See How to write a rule template for details on creating a rule template.

The advantage is users will be able to just install them same as an add-on and instantiate and configure these rather than copy/paste/edit.

It looks like your widget is already ready to go but the rule would need to be converted.

Note, there might be some improvements to creating rule templates comming in OH 5.

I agree, but for the moment I’ve never used, nor written marketplace widgets. The first one is always the hardest. I would of course not have any issue with anyone doing it instead of me :slight_smile:

Furthermore, I rather have in mind to create a built-in widget because it could also improve things performance-wise and would make sense as a complement to oh-trend.

Your code is done. All you’d have to do is follow the template presented when you create the posting to the marketplace. Nothing special needs to be done for UI widgets code wise. And most of the template is optional.

Just fill in the needed info. You’d paste the widget code in at the bottom in the indicated space.

The challenge with someone else doing it is then they are on the hook to keep it up and support it.

Existence of a marketplace option does not prevent the developers from adding features to OH and in a lot of cases it encourages them to do so. I’ve had several rule templates deprecated because what they do was added as a feature to OH (e.g. time is Item triggers, debounce mostly deprecated, hysteresis is mostly deprecated).

1 Like

I needed this feature as well and so took the time to implement a new oh-state-series and submitted a PR to the openhab-ui. Since I just submitted it, I don’t know when it will be accepted or included - hopefully in time for 5.0.

If you are up to give it a try and provide feedback, I compiled a version for use in openHAB 4.3. To install, just bundle:stop the existing MainUI and install this in the addons directory.

org.openhab.ui-4.3.6-SNAPSHOT.jar

Either a category axis can be used for the y-axis (pictured), or you can superimpose this state timeline on oh-value-axis with another graph.

Here is the YAML code for the chart shown:

config:
  label: Test Graph - State
slots:
  dataZoom:
    - component: oh-chart-datazoom
      config:
        type: inside
  grid:
    - component: oh-chart-grid
      config:
        show: false
  series:
    - component: oh-state-series
      config:
        gridIndex: 0
        item: Recirculating_Pump_Power
        name: Pump Power
        stateColor:
          OFF: rgba(255, 0, 0, .4)
          ON: rgba(0, 255, 0, .4)
        xAxisIndex: 0
        yAxisIndex: 0
        yValue: 1
    - component: oh-state-series
      config:
        gridIndex: 0
        item: HouseScene
        name: House Scene
        xAxisIndex: 0
        yAxisIndex: 0
        yValue: 0
    - component: oh-state-series
      config:
        gridIndex: 0
        item: Air_Temp
        name: Air Temp (Raw)
        xAxisIndex: 0
        yAxisIndex: 0
        yValue: 2
    - component: oh-state-series
      config:
        gridIndex: 0
        item: Air_Temp
        mapState: "=(point) => (point < 65) ? 'COLD' : (point > 77) ? 'HOT' : 'WARM'"
        name: Air Temp (State)
        stateColor:
          COLD: "#0000FF"
          HOT: "#FF0000"
          WARM: "#00FF00"
        xAxisIndex: 0
        yAxisIndex: 0
        yValue: 3
    - component: oh-state-series
      config:
        gridIndex: 0
        item: Air_Temp
        mapState: "=(point) => (point < 65) ? 'COLD' : (point > 77) ? 'HOT' : 'WARM'"
        name: Air Temp (Raw 2)
        xAxisIndex: 0
        yAxisIndex: 0
        yValue: 4
    - component: oh-state-series
      config:
        gridIndex: 0
        item: rachio_active_number
        name: Rachio Zone
        xAxisIndex: 0
        yAxisIndex: 0
        yValue: 5
  tooltip:
    - component: oh-chart-tooltip
      config:
        orient: horizontal
        show: true
  xAxis:
    - component: oh-time-axis
      config:
        gridIndex: 0
  yAxis:
    - component: oh-category-axis
      config:
        categoryType: values
        data:
          - |-
            Rachio Active
            Zone
          - |-
            Air Temp
            State (Full)
          - |-
            Air
            Temp
            State
          - |-
            Air
            Temperature
          - |-
            Recirulating
            Pump
            Power
          - |-
            House
            Scene
        gridIndex: 0
        show: true
        splitArea:
          areaStyle:
            color:
              - rgba(80,80,80,0.5)
              - rgba(0,0,0,0.3)
          show: true

The configurable parameters follow the oh-time-series - with a few additional items:

yValue (optional) - where the timeline center should be on the y axis. Note, if using categories, it will simply be the index of the category. Defaults to 0
yHeight (optional) - the unit (in relation to the y-axis coordiate system) of the height of the timeline. Defaults to .6.
mapState (optional) - a function to classify item states to a set of “string” states
stateColor (optional) - a map of specified colors to use in the graph for each state.

Would appreciate any feedback.

5 Likes