How long ago did this happen? Time between softener regenerations. Approach ideas

I have a water softener which regenerates every 9 days or so depending on use. I can see when this has happened when the power monitor for the softener goes above 4w.

I am wondering since Home Assistant tends to be trigger and threshold based what approaches I could hijack or adapt to evaluate the time between regenerations. Static coded to number of days elapsed between the last four or five regenerations recorded. The goal really is to know when to fill the salt bin.

I have tried a couple of things so far, but none of them really achieve what I want dynamically. Specifically:

  1. An input number which increments - and then in grafana via influxdb calculate the timestamp differences between the number increments. Using a query and six transformations I can statically get the answer I am looking for, however it is a hard coded option due to the limitation of grafana and the need to convert to rows to do the timestamp calculations.
  2. I used a input date time to record the time of the last regen, but of course this is only good for the last regen offset against this one which isn’t what I want to achieve.

I am now thinking maybe a better way would be to template the data back from influxdb into the frontend and use time_delta in jija to do the delta calculations.

Ideas sought on how to natively do a calculation between five or six previous timestamps in home assistant.

If your goal is to know when to fill the salt bin then I solved the same problem with a golfball, a plastic stick and a magnetic door sensor.
You need some weight. I used a golfball because I had it and it doesn’t corrode in a salty enviroment. I drilled a hole in the ball and glued a plastic rod on it. The stick is guide through a small hole in the lid of the salt bin. The golf ball will sit on top of the salt. The door sensor is attached on top of the lid. I attached a magnet to the stick at a hight such that when salt is low the magnet triggers the door sendor.
Just keeping it simple and it works for years already

Thank you for that wisdom my good man, simple is ALWAYS good !

It helps allot to know how quickly the unit regenerates, to understand our water usage against what the water company say we use. The unit regens at 3mÂł. These things will not be identical even in a perfect world since not all our water is softened. But I guess I can just do that by the input number increments and manually keeping a note. Anyway, you gave me food for thought. thank you.

I use a HC-SR04 Ultrasonic Module Distance Sensor connected to a Wemos D1 Mini.

I think, I originally built it based on this description (Water tank monitoring with ESPHome) but found out that the beam of the sensor that’s used here (JSN SR04T) is too wide for the narrow salt container, so I switched to the HC-SR04.

Once the distance is above a predefined value for (I think) 30min) I receive a notification that I need to add more salt.

I would think a level monitor would be best (I built my own, but you can also get one already built like this.

However, if you want to do what you originally asked about, there are options for that as well. It really depends on how you want the data stored or displayed in HA. Here’s some options:

  1. A sensor with a state that is the last regen datetime. It would also have a “history” attribute with a list of the previous 6 regen datetimes. I use something like this to store the history of when I last changed my furnace filter. I don’t have the data shown on any dashboard but I can go look at the entity whenever I want to see it. This is what I have for attribute data:
history:
  - datetime: "2025-04-20T21:19:07-05:00"
    hours: 530.4
    calendar_days: 90
  - datetime: "2025-01-20T10:04:04-06:00"
    hours: 629.52
    calendar_days: 142
  - datetime: "2024-08-31T09:18:23-05:00"
    hours: 696.05
    calendar_days: 62
  - datetime: "2024-06-30T21:29:24.000Z"
    hours: 696.36
    calendar_days: 112
  1. A sensor that simply increments by one every time there is a regen. You could look at a history graph of the sensor to see how many regens happened over any period. Assuming you set up the sensor properly so that statistics are created for it, the retention duration would be indefinite.

  2. A sensor that reports the number of days since the last regen and resets to zero at every regen. It could also store a history attribute with a list of the number of days between each of the past 6 regens.

—

Whatever option you choose, the best method to make the sensor would be to use a trigger-based template sensor. The trigger would be power increasing above 4W, and then you can write whatever logic you want for the state and attributes.

If you want to go down this path and need help just ask.

Hi Rick,

I think your wisdom is more along the lines of the original ask and clearly you have some advanced templating knowledge. I would be grateful for an example of the trigger based template sensor with history that you use. My templating skills are very much developing as opposed to complete.

I had a database explosion some time back and this caused me to split mine between mariadb and influxdb. MariaDB purges all data > 9 days. InfluxDb only records entities I specify.

Given my DB configuration I guess that your solution would still work fine since the entity history is an attribute of the object which lives either in memory or in the core.restore_state file for reboots? [not to mention the other files in .storage where entities are located.]

Since the information is in an attribute for the current state of the sensor, it is never purged. The recorder database will never purge a “current” value.

I haven’t tested this, but the sensor config would be something like this:

template:
  - triggers:
      - trigger: numeric_state
        entity_id: sensor.water_softener_power
        above: 4
        for:
          seconds: 15
    sensor:
      - name: "Water Softener Last Regen"
        unique_id: 01978d92-ba43-709f-8e94-4951601822d2
        device_class: timestamp
        state: "{{ now() }}"
        attributes:
          history: >
            {% set history = this.attributes.get('history', []) %}
            {% set last = [{
              "datetime": now().replace(microsecond=0).isoformat()) | string, 
              "days_since_previous": now().timestamp() - ( (history[0] | default({"datetime": now()}, true)).datetime | as_timestamp ) ) / 60/60/24) | round(0) }] %}
            {{ (last + history)[:20] | sort(attribute='datetime', reverse=true) }}

Looks good, about 10 times more complex than I can mange at the moment in jinja. I can write that in PowerShell no problem, but jinja syntax I just haven’t had enough time with it. plus its all on one line which makes it hard to understand.

If I decode it I think it says this:

  • history: retrieves existing history from this sensor’s attributes.
  • last: adds a new entry with:
  • datetime: now (ISO format, rounded to seconds).
  • days_since_previous: calculated as days since the last recorded regeneration.
  • (last + history)[:20]: prepends new record and limits to the 20 most recent.
  • Sorted by most recent (reverse=true)
    ?

I found a couple of bracket equality issues which I have corrected as a best guess below?.

template:
  - triggers:
      - trigger: numeric_state
        entity_id: sensor.water_softener_power
        above: 4
        for:
          seconds: 2
    sensor:
      - name: "Water Softener Last Regen"
        unique_id: 01978d92-ba43-709f-8e94-4951601822d2
        device_class: timestamp
        state: "{{ now() }}"
        attributes:
          history: >
            {% set history = this.attributes.get('history', []) %}
            {% set last = [{
              "datetime": now().replace(microsecond=0).isoformat() | string, 
              "days_since_previous": now().timestamp() - (((history[0] | default({"datetime": now()}, true)).datetime | as_timestamp) / 60/60/24) | round(0) }] %}
            {{ (last + history)[:20] | sort(attribute='datetime', reverse=true) }}

This line extra closing after timestamp and needed another opening around the - side of the equation.

( (history[0] | default({"datetime": now()}, true)).datetime | as_timestamp ) ) / 60/60/24)

Extra closing in Isoformat line - removed.

"datetime": now().replace(microsecond=0).isoformat() | string, 

I’ll look to get this going next week I should think.

Sorry, I should have tested it out before dropping it here. You were right about bracket equality issues; this is now confirmed working (and I also changed some variable names just so it is easier for me to explain what is going on):

  - triggers:
      - trigger: numeric_state
        entity_id: sensor.water_softener_power
        above: 4
        for:
          seconds: 2
    sensor:
      - name: "Water Softener Last Regen"
        unique_id: 01978d92-ba43-709f-8e94-4951601822d2
        device_class: timestamp
        state: "{{ now() }}"
        attributes:
          history: >
            {% set prev_history = this.attributes.get('history', []) %}
            {% set new_entry = [{
              "datetime": now().replace(microsecond=0).isoformat() | string, 
              "days_since_previous": ((now().timestamp() - (history[0] | default({"datetime": now()}, true)).datetime | as_timestamp ) / 60/60/24) | round(0) }] %}
            {{ (new_entry + prev_history)[:20] | sort(attribute='datetime', reverse=true) }}

Most of what you had was correct. I’ll repeat what was correct and update with the new variables:

  • history is the name I chose for the name of the attribute that I’m storing all the info in
  • prev_history is a temporary jinja variable that I’m using just to make the code easier to read. It contains the data that was stored in the history attribute of this sensor the instant before executing this code. If the history attribute doesn’t exist (which will happen the very first time the template is triggered), this variable will be an empty list [].
  • new_entry is also a temporary jinja variable. It will be a list containing one dictionary, with a datetime key and a days_since_previous key.
    • datetime will have a value of now, truncated to whole seconds, in isoformat string
    • days_since_previous will have the days since last recorded regen. It calculates this by taking now() and subtracting the most recent regen datetime from the prev_history variable. If there isn’t a datetime key, it will default to now, and so the calculation will be now()-now() which is zero days.
  • (last + history)[:20]: prepends new record and limits to the 20 most recent.
  • Sorted by the datetime key, starting with most recent (reverse=true)

You might also consider replacing the two lines:

        device_class: timestamp
        state: "{{ now() }}"

with these three lines:

        unit_of_measurement: count
        state_class: total
        state: "{{ this.state | int(0) + 1 }}"

That would change the state of the sensor to be continuously-incrementing, and it would also create hourly statistics retained indefinitely. So 5 years from now, if you want to show a bar chart of how many regens per month (or per hour, day, week, or year), you could do that.

I am also using MariaDB (though I have 10 day retention) and InfluxDB, but that doesn’t have any effect on the above behavior.

Well it’s all time Rick, so it means allot that you came back with such a comprehensive response. Thank you for your valuable reply. I feel sure allot of other people after me will be helped since the logic you have provided could be reused in so many areas of home automation.

I have a ton of stuff to complete this weekend outside so I won’t get to any time with HA until the weekday evenings.

Thank you also for all the other replies each and everyone really useful and appreciated. I am looking forward to upgrading my empty floating jam jar stainless tube guide and nut ‘fishing line’ arrangement level indicator on my borehole water tank to a JSN SR04T based MQTT esp32 S3 ultrasonic version at some future point. After I do the HC-SR04 esp32 S3 water softener version (or the golf ball door sensor version) first which is higher priority. [ the task list grows again ].

I use around 1.5 times what the tank can hold each run, which is every 2 days in the heat (at the moment), so if the well pump stops for any reason, the watering cycle will not complete and if the tank float switch (empty cut off device) fails as it did last year I risk loosing the pressure pump which is a non-trivial cost and time replacement deal. Consequently being able to shut off hydrawise via an HA automation before the tank float switch ever needs to kick in is going to be a significant notch in the better nights sleep pole.

Thank you again, one and all.

Hi Rick,

I managed to get some unexpected moments with the system and added the following to my template.yaml (need to move to packages at some point, to do).

- trigger:
    - trigger: state
      entity_id: 
        - counter.water_softener_regenerations
      not_from:
        - "unknown"
        - "unavailable"
      to:     
  sensor:
    - name: "Water Softener Regnerations"
      unique_id: 335fe0ee-902a-4a67-933c-01540cc7a526
      unit_of_measurement: count
      state_class: total
      state: "{{ this.state | int(0) + 1 }}"
      attributes:
        history: >
          {% set prev_history = this.attributes.get('history', []) %}
          {% set new_entry = [{
            "datetime": now().replace(microsecond=0).isoformat() | string, 
            "days_since_previous": ((now().timestamp() - (history[0] | default({"datetime": now()}, true)).datetime | as_timestamp ) / 60/60/24) | round(0) }] %}
          {{ (new_entry + prev_history)[:20] | sort(attribute='datetime', reverse=true) }}

The difference is what I have implemented from what was discussed is that an automation watches for the water softener power consumption and increments a counter. This is because the softener valve rotates a few times and spikes power > 4 each time it does so. Clearly from my perspective these additional spikes all constitute one regeneration. So it is easer to have an automation trigger on the first spike and then delay for an hour to effectively ignore the additional spikes of valve rotation.

With this implementation I can manually increase the counter via the helpers interface which I have done “two times in short succession” to test the code out. Once the first “real regeneration” happens, this won’t be an option as it will mess with the data.

I am not clear whether what I am seeing in the state under dev tools states is truthfully what is expected.

I was expecting to see some timestamps and a history attribute in there as well. Or does that not appear in this case do we know? Or have I totally goofed on how this works?

In the sprit of clarity here is an image of the statistics for the sensor entity.

Also a screen grab of the counter entity. It already existed and had a state of 3 before this modification which is the reason why the state is = 5.

I am wondering if what I have done wrong is obvious at all? Or whether I just have missunderstood perhaps?

Sorry, when renaming some of the variables I forgot to change one. A tip for next time: make sure to go to your logs and see if there are issues whenever something isn’t working as expected. In this case, the log error should state

Error rendering attributes.history template for sensor.water_softener_last_regen: UndefinedError: 'history' is undefined

Seeing that error is how I realized what mistake I made.

"days_since_previous": ((now().timestamp() - (prev_history[0] | default({"datetime": now()}, true)).datetime | as_timestamp ) / 60/60/24) | round(0) }] %}
                                              ^^^^^
                     I forgot to rename "history" to "prev_history"

Once you change that line it should work as expected.

Also know that you can do lots of advanced stuff with trigger-based template sensors. You could add a condition to avoid the update if the last entry to the history is less than an hour old, for example:

- triggers:
    - trigger: numeric_state
      entity_id: sensor.water_softener_power
      above: 4
      for:
        seconds: 2
  conditions:
    - condition: template
      value_template: >
        {% set prev_history = state_attr('sensor.water_softener_regenerations', 'history') %}
        {{ (prev_history[0] | default({"datetime": 0}, true)).datetime | as_datetime + timedelta(hours=1) < now() }}
  sensor:
    - name: "Water Softener Regenerations"
      unique_id: 01979b54-7fa3-71cb-bbe3-0e4d843ecae1
      unit_of_measurement: count
      state_class: total
      state: "{{ this.state | int(0) + 1 }}"
      attributes:
        history: >
          {% set prev_history = this.attributes.get('history', []) %}
          {% set new_entry = [{
            "datetime": now().replace(microsecond=0).isoformat() | string, 
            "days_since_previous": ((now().timestamp() - (prev_history[0] | default({"datetime": now()}, true)).datetime | as_timestamp ) / 60/60/24) | round(0) }] %}
          {{ (new_entry + prev_history)[:20] | sort(attribute='datetime', reverse=true) }}

In the condition template, it is taking the first item from the sensor’s history (or using 0 if the history isn’t defined) and converting that to a datetime object, then adding one hour, and comparing it to now().

Ah I see, I did not know that errors based on things I had configured or messed up were placed into the log file. Like messed up jinja code in templates. So that’s a really great tip to check that every time something isn’t working. I do sense there isn’t much feedback from the system compared to Windows which is what I am used to. Windows is a Goliath in terms of the power to run it due to all the error handling and logging !

Anyway, I did find that error in my log, exactly as stated, thank you, another light in the dark. Where did you, how did you become familiar with this stuff may I ask?

Yes that’s perfect - I also started to move my config into packages which makes modifications so much less disruptive. This also means I can just assign a new guid to a sensor entity and trash the old one if I don’t like the data.

I incremented and decremented the counter (in quick succession for testing) and found all the entries were recorded. As far as I can see the logic in the condition is right and should be returning false when the incoming trigger timestamp is less that one hour after the last recorded one in the history attribute. But it is allowing the entry to proceed.

state_class: total
history:
  - datetime: "2025-06-23T14:36:54+01:00"
    days_since_previous: 0
  - datetime: "2025-06-23T14:35:31+01:00"
    days_since_previous: 0
unit_of_measurement: count
friendly_name: Water Softener Regnerations

I would suggest you paste the template from the conditions into developer tools → templates and that will show you whether it evaluates to false or true. You can then dig into it further, for example remove the < now() from the template to see what the datetime is that is being calculated. Or just enter {{ state_attr('sensor.water_softener_regenerations', 'history') }} to see if it outputs what you expect.

I say all this already knowing that the problem is probably that you have the incorrect entity_id specified, and that last line will probably return null. Take note of what your entity id is. I see in an earlier post your code showed:

And that will get slugified as the entity id. Note the misspelling of “Regnerations”. Therefore I suspect your entity_id is sensor.water_softener_regnerations which is one letter different from what I had in my code.

I spent a good amount of time reading the documentation (HA docs and jinja docs) and these forums, and trying various things out in my own HA installation to see how it works. I now spend a decent amount of time helping others because I always learn something in the process, and I enjoy learning about this.

It basically needs time which would prevent those foolish character entry errors, which your calmer clearer head has spotted. Thank you once more.

1 Like

I just wanted to feedback that the salt distance sensor and the Softener Regenerations template are both working perfectly. The machine regenerated last night and co-incidentally I added salt yesterday evening. So I was a bit surprised to see the distance had changed in the morning. Then I saw the state of the template had incremented and the history and day calculation all had worked as expected. Now I can custom-buttoncard that into the dashboard. The board for the tank level sensor “JSN SR04T” should come today enabling that project to complete as well.

Many thanks once more one and all.