Change node

Using JSONata for more complex tasks:

Many automations can be coded using a simple JSONata expression at some convenient point in the flow, often directly within one of the WebSocket nodes. Where the data involved is a more complex structure, then JSONata is a powerful tool for manipulating JSON objects and arrays. Almost all computation can be achieved using JSONata in a Change node in place of using a Function node.

Read a person state history for the past week

Since Home Assistant stores state history for 10 days by default, it is possible to read historic state records. The Get History node can do this for any given entity, and using relative time it is easy to obtain an array of past state-change events.

There are no opportunities to use JSONata within the Get History node itself, however JSONata can be used both to setup the node parameters and to manipulate the returned array. In this example, JSONata is used extensively to:

  • set the input parameters for the time period required
  • read the current entity state for 'now' and the given entity ID
  • add an 'event' for 'now' and an 'event' for the start history-period time
  • calculate the time interval between successive state changes to create 'event-periods'
  • filter out any event periods less than, say, 50 minutes or for state 'unknown'
  • compact any now sequential equal-state events into one longer period

This returns a filtered array of entity states, with the time that state period started and the time it ended, and the duration in minutes. The requirement for extensive data processing here is due to the way 'person' sensors report, giving rise to short periods of 'unknown' or 'away' because Wi-Fi signal or a smart phone has gone 'off-line'.

screenshot

[{"id":"1997b594da7d37b1","type":"group","z":"776c027950fc8c3f","name":"Process history data using JSONata in a change node","style":{"label":true,"color":"#000000"},"nodes":["bd6511844a39ca08","3d87bbb92fa35243","9a3ca494edc54a30","3a4534cdc46ae67d","891ee0da3deec2c1","27f0ecf4ea3dc24f","d5dc0b522463577a"],"x":34,"y":2499,"w":1312,"h":122},{"id":"bd6511844a39ca08","type":"api-current-state","z":"776c027950fc8c3f","g":"1997b594da7d37b1","name":"Get current state","server":"","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"person.george","state_type":"str","blockInputOverrides":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"payload ~> |$|{\"entityId\": $entity().entity_id}|","valueType":"jsonata"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":350,"y":2540,"wires":[["9a3ca494edc54a30"]]},{"id":"3d87bbb92fa35243","type":"inject","z":"776c027950fc8c3f","g":"1997b594da7d37b1","name":"Set parameters","props":[{"p":"payload"},{"p":"startAt","v":"$fromMillis($millis()-(7*24*60*60000))","vt":"jsonata"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"{\"relativeTime\": \"1 week\"\t}","payloadType":"jsonata","x":160,"y":2540,"wires":[["bd6511844a39ca08"]]},{"id":"9a3ca494edc54a30","type":"api-get-history","z":"776c027950fc8c3f","g":"1997b594da7d37b1","name":"Read history","server":"","version":1,"startDate":"","endDate":"","entityId":"","entityIdType":"equals","useRelativeTime":true,"relativeTime":"","flatten":true,"outputType":"array","outputLocationType":"msg","outputLocation":"payload","x":530,"y":2540,"wires":[["3a4534cdc46ae67d"]]},{"id":"3a4534cdc46ae67d","type":"change","z":"776c027950fc8c3f","g":"1997b594da7d37b1","name":"Get filtered state periods","rules":[{"t":"set","p":"payload","pt":"msg","to":"(\t/* FILTER parameters */\t    $fMins:=50;\t\t/* get current state from entity 'data', and set the 'last_changed' to now */\t    $first:= data~>|$|{\"last_changed\": $now()}|;\t\t/* add this to far end of history payload array, then sort by reverse time order */\t    $x:=$append(payload, $first)^(>last_changed,>last_updated);\t\t/* copy the oldest state value, and add in as the first record at start of history */\t/* we now have a 'now' and 'start of history' record, even if payload was empty    */    \t    $x:=$append($x,{\"state\": $x[0].state, \"last_changed\": startAt});\t\t/* create array of state changes, with how long they have been in that state */\t/* remove any zero periods and unknown states FILTER OUT AS REQUIRED         */\t    $events:=$x#$i.(\t        $prior:= $i>0 ? $x[$i-1] : {\"first\": $now()};\t        {\"index\": $i,\t         \"state\": state,\t         \"from\": last_changed,\t         \"upto\": $prior.last_changed,\t         \"dmins\": ($toMillis($prior.last_changed)-$toMillis(last_changed))/60000~>$round(0)\t        }\t    )[dmins>$fMins and state!=\"unknown\"];\t\t/* merge consecutive records with the same state into one longer period */\t/* get each event position as 'start - middle - end' or 'only'          */\t\t    $temp:=$events#$v.(\t        $back:= $v<1 ? false : state = $events[$v-1].state;\t        $next:= state = $events[$v+1].state;\t        $position:=( $back ? ($next ? \"middle\" : \"end\") : ($next ? \"start\" : \"only\") );\t        $~>|$|{\"index\": $v, \"position\": $position}|\t    );\t\t/* get start and end indexes, and zip into a sequence array of [start, end]  */\t/* map this array of sequences to an array of objects, one for each sequence */\t/* where the object is the combination of a run of the same state value      */\t\t    $chain:=$zip($temp[position=\"start\"].index, $temp[position=\"end\"].index);\t\t    $array:=$map($chain, function($item) {(\t        $recA:=$events[$item[0]];\t        $recB:=$events[$item[1]];\t        {\"state\": $recA.state,\t        \"from\": $recB.from,\t        \"upto\":  $recA.upto,\t        \"dmins\": ($toMillis($recA.upto)-$toMillis($recB.from))/60000~>$round(0),\t        \"position\": \"merged\"}\t        )\t    });\t\t/* combine the 'only' single events with the now-merged sequences, and sort by time */\t    $append($temp[position=\"only\"], $array)^(>from);\t\t)","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":750,"y":2540,"wires":[["891ee0da3deec2c1","27f0ecf4ea3dc24f"]]},{"id":"891ee0da3deec2c1","type":"debug","z":"776c027950fc8c3f","g":"1997b594da7d37b1","name":"State History Events","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1200,"y":2540,"wires":[]},{"id":"27f0ecf4ea3dc24f","type":"change","z":"776c027950fc8c3f","g":"1997b594da7d37b1","name":"When 'not home' during the day","rules":[{"t":"set","p":"payload","pt":"msg","to":"payload[state=\"not_home\" and ($substringBefore(from,\"T\") = $substringBefore(upto,\"T\") ) ]","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":930,"y":2580,"wires":[["d5dc0b522463577a"]]},{"id":"d5dc0b522463577a","type":"debug","z":"776c027950fc8c3f","g":"1997b594da7d37b1","name":"Out during the day","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1190,"y":2580,"wires":[]}]
(
/* FILTER parameters */
    $fMins:=50;

/* get current state from entity 'data', and set the 'last_changed' to now */
    $first:= data~>|$|{"last_changed": $now()}|;

/* add this to far end of history payload array, then sort by reverse time order */
    $x:=$append(payload, $first)^(>last_changed,>last_updated);

/* copy the oldest state value, and add in as the first record at start of history */
/* we now have a 'now' and 'start of history' record, even if payload was empty    */
    $x:=$append($x,{"state": $x[0].state, "last_changed": startAt});

/* create array of state changes, with how long they have been in that state */
/* remove any zero periods and unknown states FILTER OUT AS REQUIRED         */
    $events:=$x#$i.(
        $prior:= $i>0 ? $x[$i-1] : {"first": $now()};
        {"index": $i,
         "state": state,
         "from": last_changed,
         "upto": $prior.last_changed,
         "dmins": ($toMillis($prior.last_changed)-$toMillis(last_changed))/60000~>$round(0)
        }
    )[dmins>$fMins and state!="unknown"];

/* merge consecutive records with the same state into one longer period */
/* get each event position as 'start - middle - end' or 'only'          */

    $temp:=$events#$v.(
        $back:= $v<1 ? false : state = $events[$v-1].state;
        $next:= state = $events[$v+1].state;
        $position:=( $back ? ($next ? "middle" : "end") : ($next ? "start" : "only") );
        $~>|$|{"index": $v, "position": $position}|
    );

/* get start and end indexes, and zip into a sequence array of [start, end]  */
/* map this array of sequences to an array of objects, one for each sequence */
/* where the object is the combination of a run of the same state value      */

    $chain:=$zip($temp[position="start"].index, $temp[position="end"].index);

    $array:=$map($chain, function($item) {(
        $recA:=$events[$item[0]];
        $recB:=$events[$item[1]];
        {"state": $recA.state,
        "from": $recB.from,
        "upto":  $recA.upto,
        "dmins": ($toMillis($recA.upto)-$toMillis($recB.from))/60000~>$round(0),
        "position": "merged"}
        )
    });

/* combine the 'only' single events with the now-merged sequences, and sort by time */
    $append($temp[position="only"], $array)^(>from);

)

Notes:

The Inject node uses JSONata to initially create msg.payload as a data object, setting the relative time parameter for the Get History node, and also setting the timestamp for the effective start of this time period.

The Current State node uses JSONata in the output to retain msg.payload and also merge in the entityId using $entity().entity_id. This now sets msg.payload with the required input parameters for the Get History node (which therefore requires no UI settings). Full entity details are also captured in msg.data as usual.

::: caution This example code has been tested but person sensors can go 'off line' for long periods and the exact nature of the output data should be checked by experimentation. :::

Report only those periods when 'not home' during the day

Once state history has been manipulated like this into an event-array, it is easy to ask questions, such as "how many times has this person been away this week?"

The question "when was this person away during the day?" for example, can be answered by filtering the state event array, to look for 'not home' and for the event period (both start and end) to be entirely on the same date.

payload[state="not_home" and ($substringBefore(from,"T") = $substringBefore(upto,"T") ) ]

Note on history records:

In asking for the history for the past 24 hours, the Get History node will return an array of all state change events that occurred within that time period. If the current state has been unchanged for the entire period requested, then an empty array will be returned.

To address this, the JSONata code here first adds a record for 'now' with the current state. The code then finds the oldest state (which may well be the current 'now' state just added) and adds that as a record for 'the start of the time period requested' at that state. This ensures that the returned history event array is topped and tailed. The minimum state event array will therefore be one entry from start of history request to now, at the current state.

Also see: