LED colour status - save my sanity!

I'm running an ESP32 to fire an ultrasonic distance sensor to check the fill level on my coffee machine. If it gets below a float-switch level, it just turns the mains power off...and always midway through a shot. The LED shows green/yellow/red for level and flashes red if there's an error. Worked perfectly with 2026.1.0. The problem I have is that with the latest versions (2026.6.3) the LED gets stuck on flashing red the moment there's a single error. Sometimes it seems it will change if the status changes - so if it's in the green level and there's an error reading the ultrasonic it will flash red and not go back to green if there's a good reading. If the level drops to yellow, that change seems to make it change the LED to yellow until the next error. Also, my logger.log attempts to diagnose this, don't seem to be working.
I don't know if there were many errors reading the ultrasonic before but I didn't notice it being a problem before; it just worked.
This might be a minor irritation...but right now it's the straw that broke the camels back!
Any pointers appreciated.
Many thanks,
Gareth

esphome:
  name: espresso
  friendly_name: ESPresso
  on_boot: 
    then:
      - light.turn_on: 
          id: led
          effect: Booting
      - component.update: fill_level

esp32:
  board: m5stack-atom
  framework:
    type: esp-idf

substitutions:
  #model Atom (grey)
  rgb_pin: "GPIO27"
  button_pin: "GPIO39"
  ir_pin: "GPIO12"
  grove1_pin: "GPIO26"
  grove2_pin: "GPIO32"
  #bottom pins: 33,23,19,22 25,21

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: ""

ota:
  - platform: esphome
    password: ""

wifi:
  ssid: !secret IoT_ssid
  password: !secret IoT_password
  min_auth_mode: WPA2

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Espresso Fallback Hotspot"
    password: ""

captive_portal:

web_server:
  port: 80
  version: 2
  auth:
    username: !secret web_server_username
    password: !secret web_server_password

light:
  - platform: esp32_rmt_led_strip
    rgb_order: GRB
    pin: ${rgb_pin}
    num_leds: 1
    chipset: ws2811
    name: "LED"
    id: led
    effects:
      - strobe:
          name: Booting
          colors:
            - state: true
              brightness: 100%
              red: 0%
              green: 100%
              blue: 0%
              duration: 1s
              transition_length: 500ms
            - state: false
              duration: 1s
              transition_length: 500ms
      - strobe:
          name: Overfill
          colors:
            - state: true
              brightness: 100%
              red: 100%
              green: 0%
              blue: 0%
              duration: 250ms
            - state: false
              duration: 250ms
      - strobe:
          name: Error
          colors:
            - state: true
              brightness: 100%
              red: 100%
              green: 0%
              blue: 0%
              duration: 1s
            - state: false
              duration: 1s
      - automation:
          name: Good
          sequence:
            - light.addressable_set:
                id: led
                color_brightness: 40%
                red: 0%
                green: 100%
                blue: 0%
      - automation:
          name: Warning
          sequence:
            - light.addressable_set:
                id: led
                red: 100%
                green: 100%
                blue: 0%
      - automation:
          name: Empty
          sequence:
            - light.addressable_set:
                id: led
                red: 100%
                green: 0%
                blue: 0%

binary_sensor:
  - platform: homeassistant
    id: coffee_machine_power
    entity_id: switch.coffee_machine
  - platform: gpio
    id: button
    name: "button"
    internal: true
    pin: 
      number: ${button_pin}
      inverted: true
      mode:
        input: true
    on_multi_click:
      - timing: # Double-click
          - ON for at most 1s
          - OFF for at most 1s
          - ON for at most 1s
          - OFF for at least 0.2s
        then:
          - switch.turn_on: button_double
      - timing: # Long-click
          - ON for 1s to 3s
          - OFF for at least 0.5s
        then:
          - switch.turn_on: button_long
      - timing:
          - ON for at most 1s
          - OFF for at least 0.5s
        then: # Single-click
          - switch.turn_on: button_single

switch:
  - platform: template
    name: Single Click
    id: button_single
    optimistic: True
    on_turn_on:
      - delay: 500ms
      - switch.turn_off: button_single
  - platform: template
    name: Double Click
    id: button_double
    optimistic: True
    on_turn_on:
      - delay: 500ms
      - switch.turn_off: button_double
  - platform: template
    name: Long Click
    id: button_long
    optimistic: True
    on_turn_on:
      - delay: 500ms
      - switch.turn_off: button_long

interval:
  - interval: 10s
    then:
      if:
        condition:
          binary_sensor.is_on: coffee_machine_power
        then:
          -  component.update: fill_level
  - interval: 5min
    then:
      if:
        condition:
          binary_sensor.is_off: coffee_machine_power
        then:
          -  component.update: fill_level

sensor:
  - platform: ultrasonic
    trigger_pin: ${grove1_pin}
    echo_pin: ${grove2_pin}
    name: "Coffee Machine Fill Level"
    id: fill_level
    update_interval: never
    #timeout: 8m     #8m minimum
    unit_of_measurement: "cm"
    filters: 
      - lambda: return 24.5-((x+0.005)*100);
    on_raw_value:
       then:
          if:
            condition:
              lambda: 'return (isnan(x));'
            then:
              - logger.log: "Ultrasonic failed"
              - light.turn_on:
                  id: led
                  effect: Error
    on_value_range:
      - below: 0
        then:
          #state Error
          - logger.log: "Ultrasonic below 0"
          - light.turn_on: 
              id: led
              effect: Error
      - above: 0
        below: 6
        then:
          #state Empty (Red) - switches off about 4cm
          - logger.log: "Ultrasonic 0-6"
          - light.turn_on:
              id: led
              effect: Empty
      - above: 5
        below: 8
        #state Warning (Yellow)
        then:
          - logger.log: "Ultrasonic 5-8"
          - light.turn_on:
              id: led
              effect: Warning
      - above: 8
        below: 22
        then:
          #state OK (Green)
          - logger.log: "Ultrasonic 8-22"
          - light.turn_on:
              id: led
              effect: Good
      - above: 22
        then:
          #state Overfull (Pulse Red)
          - logger.log: "Ultrasonic 22+"
          - light.turn_on:
              id: led
              effect: Overfill

Sounds similar to your problem. You might find people who know more there. You should hook it up directly to a PC to get the serial log, which likely tell you more about what is going wrong.

So what do you actually get on your logs when you get error reading and eventually valid one? Capture the problem and post here.
Also, why are your low levels (0-6, 5-8) overlapping?

@neel-m Good call but I think it's a different problem here. The level is correctly read most of the time but sometimes fails to trigger. I've had it run overnight without failing but mostly it fails quite quickly. Entirely possible this is a timing issue with my sensor but I'm not aware that it happened in slightly earlier versions - worked fine after it shifted to an interrupt driven result rather than a wait time.
This is the log when it fails.

[12:44:39.809][W][ultrasonic.sensor:063]: 'Coffee Machine Fill Level' - Measurement start timed out
[12:44:39.812][D][main:217]: Ultrasonic failed
[12:44:39.913][S][light]: 'LED' >> ON
[12:44:39.913][S][light]:   Brightness: 100%
[12:44:39.913][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[12:44:39.913][S][light]:   Effect: 'Error'
[12:44:39.913][S][sensor]: 'Coffee Machine Fill Level' >> nan cm

@Karosm Good catch and the answer would be user stupidity :smiley: I tried correcting that, thinking it might be just invalidating two of the levels but it doesn't make a difference. I'm not getting the logger.log firing at the on_value_range. It works in the on_raw_value section and if I add one to the interval section, that works too. So the suggestion is that it isn't hitting the range section reliably...but I'm not sure why.

But it presents output that matches perfectly your code. What happens after? Don't be shy with your logs.

Not for your actual problem, but it would run both automations in case 5-6cm and that is likely not desidered.

As @Karosm said your log shows correct behavior, where is the log for incorrect behavior?

Are you not getting logs, or not understanding what they are trying to say?

Sorry guys, misunderstanding there. That's the log of the ultrasonic failing to fire, returning NaN and triggering the error (LED) state. It does do what my code tells it to at that point - although the intention was for things like taking the lid of the coffee machine off where it will fire into open air and not get a return ping. This is also an error state but not the intended one...not a bad thing though. The issue is that it's failing to start a reading and I don't think it used to before 2026.6.x
If failing to start a measurement can be fixed that'd be great but an individual failure isn't a problem.

I could live with it going to error and then returning but when it then gets a good reading it completely skips the LED state change and the debug log for that section too. This is the current code:

interval:
  - interval: 10s
    then:
      if:
        condition:
          binary_sensor.is_on: coffee_machine_power
        then:
          - logger.log: "Debug - Fire!"   # log to show logging works and sensor fires
          - component.update: fill_level
  - interval: 5min
    then:
      if:
        condition:
          binary_sensor.is_off: coffee_machine_power
        then:
          -  component.update: fill_level

sensor:
  - platform: ultrasonic
    trigger_pin: ${grove1_pin}
    echo_pin: ${grove2_pin}
    name: "Coffee Machine Fill Level"
    id: fill_level
    update_interval: never
    #timeout: 8m     #8m minimum
    unit_of_measurement: "cm"
    filters: 
      - lambda: return 24.5-((x+0.005)*100);
    on_raw_value:
       then:
          if:
            condition:
              lambda: 'return (isnan(x));'
            then:
              - logger.log: "Ultrasonic failed"   # this log works
              - light.turn_on:
                  id: led
                  effect: Error
    on_value_range:
      - below: 0
        then:
          #state Error
          - logger.log: "Ultrasonic below 0"
          - light.turn_on: 
              id: led
              effect: Error
      - above: 0
        below: 6
        then:
          #state Empty (Red) - switches off about 4cm
          - logger.log: "Ultrasonic 0-6"
          - light.turn_on:
              id: led
              effect: Empty
      - above: 6
        below: 8
        #state Warning (Yellow)
        then:
          - logger.log: "Ultrasonic 6-8"
          - light.turn_on:
              id: led
              effect: Warning
      - above: 8
        below: 22
        then:
          #state OK (Green)
          - logger.log: "Ultrasonic 8-22"   # measurement ends up in this range so should trigger this log and change to green...but doesn't
          - light.turn_on:
              id: led
              effect: Good
      - above: 22
        then:
          #state Overfull (Pulse Red)
          - logger.log: "Ultrasonic 22+"
          - light.turn_on:
              id: led
              effect: Overfill

This is log output where it should (and does) go to a flashing red error state but then should (and doesn't) go to a green 'OK' state. The LED doesn't change, it's not logged and the debug log doesn't fire either.

[16:53:19.843][D][main:1019]: Debug - Fire!
[16:53:19.889][W][ultrasonic.sensor:063]: 'Coffee Machine Fill Level' - Measurement start timed out
[16:53:19.899][D][main:217]: Ultrasonic failed
[16:53:20.005][S][light]: 'LED' >> ON
[16:53:20.005][S][light]:   Brightness: 100%
[16:53:20.005][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[16:53:20.005][S][light]:   Effect: 'Error'
[16:53:20.005][S][sensor]: 'Coffee Machine Fill Level' >> nan cm
[16:53:29.848][D][main:1019]: Debug - Fire!
[16:53:29.899][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.144 m
[16:53:30.001][S][sensor]: 'Coffee Machine Fill Level' >> 9.61 cm
[16:53:39.844][D][main:1019]: Debug - Fire!
[16:53:39.906][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.144 m
[16:53:40.014][S][sensor]: 'Coffee Machine Fill Level' >> 9.61 cm
[16:53:49.847][D][main:1019]: Debug - Fire!
[16:53:49.896][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.144 m
[16:53:50.001][S][sensor]: 'Coffee Machine Fill Level' >> 9.61 cm
[16:53:59.916][D][main:1019]: Debug - Fire!
[16:53:59.960][W][ultrasonic.sensor:063]: 'Coffee Machine Fill Level' - Measurement start timed out
[16:53:59.961][D][main:217]: Ultrasonic failed
[16:53:59.997][S][light]: 'LED' >> ON
[16:53:59.997][S][light]:   Brightness: 100%
[16:53:59.997][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[16:53:59.997][S][light]:   Effect: 'Error'
[16:53:59.997][S][sensor]: 'Coffee Machine Fill Level' >> nan cm

Essentially what happens is after a boot, the first error causes it to go to error state and then it stays there forever...or at least until the level drops to the warning level when I've seen it go yellow until it next errors.

Now your logs show the situation.
So on_value_range triggers only when value enters in range from out-range state (edge detection). So if you had 9cm before nan, 9cm after doesn't trigger the automation.
I don't know if in previous versions nan was considered as value.
You can fix it easily using on_value automations with conditions like you did with on_raw_value.

Thanks, hadn't spotted that it only tripped on change of range....but that definitely fits what's happening. Also checked with https://web.archive.org that this isn't a new behaviour so it also implies that the ultrasonic wasn't error'ing previously or the same would have happened then.

Actually, the result does NOT match the code as the error effect should produce a strobing on/off red but it's actually just turning the led on white at full brightness.

[19:36:04.229][D][main:1023]: Fire!
[19:36:04.270][W][ultrasonic.sensor:063]: 'Coffee Machine Fill Level' - Measurement start timed out
[19:36:04.270][D][main:217]: Ultrasonic failed
[19:36:04.275][D][main:288]: Range no match
[19:36:04.374][S][light]: 'LED' >> ON
[19:36:04.374][S][light]:   Brightness: 100%
[19:36:04.374][S][light]:   Red: 100%, Green: 100%, Blue: 100%   # HERE
[19:36:04.374][S][light]:   Effect: 'Error'                      # HERE
[19:36:04.375][S][sensor]: 'Coffee Machine Fill Level' >> nan cm

Corrected my code (multiple identical ranges again sigh )

light:
  - platform: esp32_rmt_led_strip
    rgb_order: GRB
    pin: ${rgb_pin}
    num_leds: 1
    chipset: ws2811
    name: "LED"
    id: led
    effects:
      - strobe:
          name: Booting
          colors:
            - state: true
              brightness: 100%
              red: 0%
              green: 100%
              blue: 0%
              duration: 1s
              transition_length: 500ms
            - state: false
              duration: 1s
              transition_length: 500ms
      - strobe:
          name: Overfill
          colors:
            - state: true
              brightness: 100%
              red: 100%
              green: 0%
              blue: 0%
              duration: 250ms
            - state: false
              duration: 250ms
      - strobe:
          name: Error
          colors:
            - state: true
              brightness: 100%
              red: 100%
              green: 0%
              blue: 0%
              duration: 1s
            - state: false
              duration: 1s
      - automation:
          name: Good
          sequence:
            - light.addressable_set:
                id: led
                color_brightness: 40%
                red: 0%
                green: 100%
                blue: 0%
      - automation:
          name: Warning
          sequence:
            - light.addressable_set:
                id: led
                red: 100%
                green: 100%
                blue: 0%
      - automation:
          name: Empty
          sequence:
            - light.addressable_set:
                id: led
                red: 100%
                green: 0%
                blue: 0%

sensor:
  - platform: ultrasonic
    trigger_pin: ${grove1_pin}
    echo_pin: ${grove2_pin}
    name: "Coffee Machine Fill Level"
    id: fill_level
    update_interval: never
    #timeout: 8m     #8m minimum
    unit_of_measurement: "cm"
    filters: 
      - lambda: return 24.5-((x+0.005)*100);
    on_raw_value:
      then:
          if:
            condition:
              lambda: 'return (isnan(x));'
            then:
              - logger.log: "Ultrasonic failed"
              - light.turn_on:
                  id: led
                  effect: Error
    on_value:
      then:
          if:
            condition:
              sensor.in_range:
                id: fill_level
                below: 0
            then:
              - logger.log: "Ultrasonic below 0"
              - light.turn_on:
                  id: led
                  effect: Error
            else:
              if:
                condition:
                  sensor.in_range:
                    id: fill_level
                    above: 0
                    below: 6
                then:
                  - logger.log: "Ultrasonic 0-6"
                  - light.turn_on:
                      id: led
                      effect: Empty
                else:
                  if:
                    condition:
                      sensor.in_range:
                        id: fill_level
                        above: 6
                        below: 8
                    then:
                      - logger.log: "Ultrasonic 6-8"
                      - light.turn_on:
                          id: led
                          effect: Warning
                    else:
                      if:
                        condition:
                          sensor.in_range:
                            id: fill_level
                            above: 6
                            below: 8
                        then:
                          - logger.log: "Ultrasonic 8-22"
                          - light.turn_on:
                              id: led
                              effect: Good
                        else:
                          if:
                            condition:
                              sensor.in_range:
                                id: fill_level
                                above: 6
                                below: 8
                            then:
                              - logger.log: "Ultrasonic 22+"
                              - light.turn_on:
                                  id: led
                                  effect: Overfill
                            else:
                              - logger.log: "Range no match"
                              - light.turn_on:
                                  id: led
                                  effect: Error

Now I'm getting it hitting the right range, logging the right thing and tripping to the right effect...but it's logging the wrong LED parameters and what I'm getting in real life is the light going dim green (the 'Good' effect) and then going full-bright white (no effect set for this) and then going dim green again. I've cleared the build environment and rebuilt but no change. Do you think it's a side-effect of changing from 'Good' to 'Good'?

[19:47:04.201][D][main:1023]: Fire!
[19:47:04.250][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.133 m
[19:47:04.262][D][main:268]: Ultrasonic 8-22
[19:47:04.376][S][light]: 'LED' >> ON
[19:47:04.376][S][light]:   Brightness: 100%
[19:47:04.376][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[19:47:04.376][S][light]:   Effect: 'Good'
[19:47:04.376][S][sensor]: 'Coffee Machine Fill Level' >> 10.66 cm
[19:47:14.200][D][main:1023]: Fire!
[19:47:14.250][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.133 m
[19:47:14.256][D][main:268]: Ultrasonic 8-22
[19:47:14.361][S][light]: 'LED' >> ON
[19:47:14.361][S][light]:   Brightness: 100%
[19:47:14.361][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[19:47:14.361][S][light]:   Effect: 'Good'
[19:47:14.361][S][sensor]: 'Coffee Machine Fill Level' >> 10.66 cm
[19:47:24.199][D][main:1023]: Fire!
[19:47:24.259][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.134 m
[19:47:24.262][D][main:268]: Ultrasonic 8-22
[19:47:24.365][S][light]: 'LED' >> ON
[19:47:24.365][S][light]:   Brightness: 100%
[19:47:24.365][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[19:47:24.365][S][light]:   Effect: 'Good'
[19:47:24.366][S][sensor]: 'Coffee Machine Fill Level' >> 10.64 cm
[19:47:34.202][D][main:1023]: Fire!
[19:47:34.243][W][ultrasonic.sensor:063]: 'Coffee Machine Fill Level' - Measurement start timed out
[19:47:34.243][D][main:217]: Ultrasonic failed
[19:47:34.243][D][main:287]: Range no match
[19:47:34.348][S][light]: 'LED' >> ON
[19:47:34.348][S][light]:   Brightness: 100%
[19:47:34.348][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[19:47:34.348][S][light]:   Effect: 'Error'
[19:47:34.349][S][sensor]: 'Coffee Machine Fill Level' >> nan cm[19:47:04.201][D][main:1023]: Fire!
[19:47:04.250][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.133 m
[19:47:04.262][D][main:268]: Ultrasonic 8-22
[19:47:04.376][S][light]: 'LED' >> ON
[19:47:04.376][S][light]:   Brightness: 100%
[19:47:04.376][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[19:47:04.376][S][light]:   Effect: 'Good'
[19:47:04.376][S][sensor]: 'Coffee Machine Fill Level' >> 10.66 cm
[19:47:14.200][D][main:1023]: Fire!
[19:47:14.250][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.133 m
[19:47:14.256][D][main:268]: Ultrasonic 8-22
[19:47:14.361][S][light]: 'LED' >> ON
[19:47:14.361][S][light]:   Brightness: 100%
[19:47:14.361][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[19:47:14.361][S][light]:   Effect: 'Good'
[19:47:14.361][S][sensor]: 'Coffee Machine Fill Level' >> 10.66 cm
[19:47:24.199][D][main:1023]: Fire!
[19:47:24.259][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.134 m
[19:47:24.262][D][main:268]: Ultrasonic 8-22
[19:47:24.365][S][light]: 'LED' >> ON
[19:47:24.365][S][light]:   Brightness: 100%
[19:47:24.365][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[19:47:24.365][S][light]:   Effect: 'Good'
[19:47:24.366][S][sensor]: 'Coffee Machine Fill Level' >> 10.64 cm
[19:47:34.202][D][main:1023]: Fire!
[19:47:34.243][W][ultrasonic.sensor:063]: 'Coffee Machine Fill Level' - Measurement start timed out
[19:47:34.243][D][main:217]: Ultrasonic failed
[19:47:34.243][D][main:287]: Range no match
[19:47:34.348][S][light]: 'LED' >> ON
[19:47:34.348][S][light]:   Brightness: 100%
[19:47:34.348][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[19:47:34.348][S][light]:   Effect: 'Error'
[19:47:34.349][S][sensor]: 'Coffee Machine Fill Level' >> nan cm

I have no idea what are you trying here, you have 3 conditions with same range 6-8.
Anyway, if that code you posted gave that log, I'm not skilled to help. There's no blue 100 anywhere so no idea from where that could come from....

I personally would just modify the lambda filter to return -1 if isnan.

What I'm trying to do is paste the code I corrected, rather than somehow pasting the code before I corrected it facepalm Things work better if you've had more than 3 hours sleep... don't ask! :roll_eyes:
That part works once I'd corrected the ranges - it didn't before that.
I'll see if I can work out how to modify the lambda to return -1...it would at least give me one mechanism to select ranges from and therefore trigger when changing between the error and good levels.
No idea why it's going white but it's either going to be a side effect of changing the LED to what it already is or a weird whitespace YAML error that I just can't see. I'll try and test it some more when less frazzled.
Will post back if I crack it.
Thanks for the pointers in the right direction, appreciate it.
Gareth

I proposed that because you already have range for <0.

There's nothing on your yaml posted that calls blue 100%, make sure you posted actual code..

Just for a change, I'd posted the right code! :wink:
Had a bit more of a play over coffee. Two things: what's logged by the light component is garbage as it states 100% R,G & B for every change even when it correctly transitions from 'Error' to 'Good'.
The full-bright white flash happens exactly every 10 seconds which is the interval the code applies the ultrasonic scan and changes LED colour. It doesn't happen if it changes from one effect to another, only when reapplying the same effect.
What I've done for the second is to turn off the led and then apply the 'Good'. This results in the light just staying green; the off is so short as to not be visible even as a dimming.
So it's all good...but I'm going to change it when I get a minute because your suggestion is cleaner, more efficient and gets rid of the nasty mess of nested if's while achieving the same thing...at least hopefully!
Thanks Karosm

Weird thing that white light. I have never played with effects but automation effect expect sequence to cycle, which you don't have. Maybe it misbehaves for that?

Ok, this worked but it's inefficient and rather messy:

sensor:
  - platform: ultrasonic
    trigger_pin: ${grove1_pin}
    echo_pin: ${grove2_pin}
    name: "Coffee Machine Fill Level"
    id: fill_level
    update_interval: never
    #timeout: 8m     #8m minimum
    unit_of_measurement: "cm"
    filters: 
      - lambda: return 24.5-((x+0.005)*100);
    on_raw_value:
      then:
          if:
            condition:
              lambda: 'return (isnan(x));'
            then:
              - logger.log: "Ultrasonic failed"
              - light.turn_on:
                  id: led
                  effect: Error
    on_value:
      then:
          if:
            condition:
              sensor.in_range:
                id: fill_level
                below: 0
            then:
              - logger.log: "Ultrasonic below 0"
              - light.turn_off:
                  id: led
              - light.turn_on:
                  id: led
                  effect: Error
            else:
              if:
                condition:
                  sensor.in_range:
                    id: fill_level
                    above: 0
                    below: 6
                then:
                  - logger.log: "Ultrasonic 0-6"
                  - light.turn_off:
                      id: led
                  - light.turn_on:
                      id: led
                      effect: Empty
                else:
                  if:
                    condition:
                      sensor.in_range:
                        id: fill_level
                        above: 6
                        below: 8
                    then:
                      - logger.log: "Ultrasonic 6-8"
                      - light.turn_off:
                          id: led
                      - light.turn_on:
                          id: led
                          effect: Warning
                    else:
                      if:
                        condition:
                          sensor.in_range:
                            id: fill_level
                            above: 8
                            below: 22
                        then:
                          - logger.log: "Ultrasonic 8-22"
                          - light.turn_off:
                              id: led
                          - light.turn_on:
                              id: led
                              effect: Good
                        else:
                          if:
                            condition:
                              sensor.in_range:
                                id: fill_level
                                above: 22
                            then:
                              - logger.log: "Ultrasonic 22+"
                              - light.turn_off:
                                  id: led
                              - light.turn_on:
                                  id: led
                                  effect: Overfill
                            else:
                              - logger.log: "Range no match"
                              - light.turn_on:
                                  id: led
                                  effect: Error

Karosm's suggestion of bringing the isnan detection into the lambda filter is much neater as it gets rid of the nested if's and means that each state change is a value_range change so the LED gets set only if it changes. This avoids the white flash and means that it's idle more of the time. The Light component is still misreporting what it's doing but I suspect this is a bug - the actual LED does what it's supposed to do.
Logs of this working:

[17:09:15.976][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.054 m
[17:09:16.079][S][sensor]: 'Coffee Machine Fill Level' >> 18.56 cm
[17:09:25.935][D][main:1009]: Fire!
[17:09:25.974][W][ultrasonic.sensor:063]: 'Coffee Machine Fill Level' - Measurement start timed out
[17:09:25.982][D][main:238]: Ultrasonic below 0
[17:09:26.076][S][light]: 'LED' >> ON
[17:09:26.076][S][light]:   Brightness: 100%
[17:09:26.076][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[17:09:26.076][S][light]:   Effect: 'Error'
[17:09:26.077][S][sensor]: 'Coffee Machine Fill Level' >> -1.00 cm
[17:09:35.931][D][main:1009]: Fire!
[17:09:35.981][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.054 m
[17:09:35.990][D][main:289]: Ultrasonic 8-22
[17:09:36.088][S][light]: 'LED' >> ON
[17:09:36.088][S][light]:   Brightness: 100%
[17:09:36.088][S][light]:   Red: 100%, Green: 100%, Blue: 100%
[17:09:36.088][S][light]:   Effect: 'Good'
[17:09:36.088][S][sensor]: 'Coffee Machine Fill Level' >> 18.56 cm
[17:09:45.933][D][main:1009]: Fire!
[17:09:45.988][D][ultrasonic.sensor:087]: 'Coffee Machine Fill Level' - Got distance: 0.054 m
[17:09:46.090][S][sensor]: 'Coffee Machine Fill Level' >> 18.56 cm

There's still the bug in the Ultrasonic component (pretty sure it's only been faulty since I upgraded version but it could be coincidentally intermittently faulty) and a bug in the logging of the light component but this now functions where before it broke at the first ultrasonic failure after boot. so many thanks to Karosm for the help.

Full code for reference:

esphome:
  name: espresso
  friendly_name: ESPresso
  on_boot: 
    then:
      - light.turn_on: 
          id: led
          effect: Booting
      - component.update: fill_level

esp32:
  board: m5stack-atom
  framework:
    type: esp-idf

substitutions:
  #model Atom (grey)
  rgb_pin: "GPIO27"
  button_pin: "GPIO39"
  ir_pin: "GPIO12"
  grove1_pin: "GPIO26"
  grove2_pin: "GPIO32"
  #bottom pins: 33,23,19,22 25,21

# Enable logging
logger:

# Enable Home Assistant API
api:
  encryption:
    key: "<snip>"

ota:
  - platform: esphome
    password: "<snip>"

wifi:
  ssid: !secret IoT_ssid
  password: !secret IoT_password
  min_auth_mode: WPA2

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Espresso Fallback Hotspot"
    password: "<snip>"

captive_portal:

web_server:
  port: 80
  version: 2
  auth:
    username: !secret web_server_username
    password: !secret web_server_password

light:
  - platform: esp32_rmt_led_strip
    rgb_order: GRB
    pin: ${rgb_pin}
    num_leds: 1
    chipset: ws2811
    name: "LED"
    id: led
    effects:
      - strobe:
          name: Booting
          colors:
            - state: true
              brightness: 100%
              red: 0%
              green: 100%
              blue: 0%
              duration: 1s
              transition_length: 500ms
            - state: false
              duration: 1s
              transition_length: 500ms
      - strobe:
          name: Overfill
          colors:
            - state: true
              brightness: 100%
              red: 100%
              green: 0%
              blue: 0%
              duration: 250ms
            - state: false
              duration: 250ms
      - strobe:
          name: Error
          colors:
            - state: true
              brightness: 100%
              red: 100%
              green: 0%
              blue: 0%
              duration: 1s
            - state: false
              duration: 1s
      - automation:
          name: Good
          sequence:
            - light.addressable_set:
                id: led
                color_brightness: 40%
                red: 0%
                green: 100%
                blue: 0%
      - automation:
          name: Warning
          sequence:
            - light.addressable_set:
                id: led
                red: 100%
                green: 100%
                blue: 0%
      - automation:
          name: Empty
          sequence:
            - light.addressable_set:
                id: led
                red: 100%
                green: 0%
                blue: 0%

binary_sensor:
  - platform: homeassistant
    id: coffee_machine_power
    entity_id: switch.coffee_machine
  - platform: gpio
    id: button
    name: "button"
    internal: true
    pin: 
      number: ${button_pin}
      inverted: true
      mode:
        input: true
    on_multi_click:
      - timing: # Double-click
          - ON for at most 1s
          - OFF for at most 1s
          - ON for at most 1s
          - OFF for at least 0.2s
        then:
          - switch.turn_on: button_double
      - timing: # Long-click
          - ON for 1s to 3s
          - OFF for at least 0.5s
        then:
          - switch.turn_on: button_long
      - timing:
          - ON for at most 1s
          - OFF for at least 0.5s
        then: # Single-click
          - switch.turn_on: button_single

switch:
  - platform: template
    name: Single Click
    id: button_single
    optimistic: True
    on_turn_on:
      - delay: 500ms
      - switch.turn_off: button_single
  - platform: template
    name: Double Click
    id: button_double
    optimistic: True
    on_turn_on:
      - delay: 500ms
      - switch.turn_off: button_double
  - platform: template
    name: Long Click
    id: button_long
    optimistic: True
    on_turn_on:
      - delay: 500ms
      - switch.turn_off: button_long

interval:
  - interval: 10s
    then:
      if:
        condition:
          binary_sensor.is_on: coffee_machine_power
        then:
          - component.update: fill_level
  - interval: 5min
    then:
      if:
        condition:
          binary_sensor.is_off: coffee_machine_power
        then:
          -  component.update: fill_level

sensor:
  - platform: ultrasonic
    trigger_pin: ${grove1_pin}
    echo_pin: ${grove2_pin}
    name: "Coffee Machine Fill Level"
    id: fill_level
    update_interval: never
    #timeout: 8m     #8m minimum
    unit_of_measurement: "cm"
    filters: 
      - lambda: |-
          if (isnan(x)) {
            return -1;
          } else {
            return 24.5-((x+0.005)*100);
          }
    on_value_range:
      - below: 0
        then:
          #state Error
          - logger.log: "Ultrasonic below 0"
          - light.turn_on: 
              id: led
              effect: Error
      - above: 0
        below: 6
        then:
          #state Empty (Red) - switches off about 4cm
          - logger.log: "Ultrasonic 0-6"
          - light.turn_on:
              id: led
              effect: Empty
      - above: 6
        below: 8
        #state Warning (Yellow)
        then:
          - logger.log: "Ultrasonic 6-8"
          - light.turn_on:
              id: led
              effect: Warning
      - above: 8
        below: 22
        then:
          #state OK (Green)
          - logger.log: "Ultrasonic 8-22"
          - light.turn_on:
              id: led
              effect: Good
      - above: 22
        then:
          #state Overfull (Pulse Red)
          - logger.log: "Ultrasonic 22+"
          - light.turn_on:
              id: led
              effect: Overfill

I have to agree, logs should not print RGB x100% light.
For the sensor, difficult to say if it behaves abnormal way, occasional "failures" are pretty normal.
Try higher trigger, like 20us.

Good call. I was loathe to play with that setting because it had worked perfectly before without messing about with that...but so far so good. I'll have another check tomorrow morning after it's been sat at brewing temp for a while - just in case it's affected at all by the heat. I'll see if I can file a bug for the light component too.

Gareth

LED Parameters incorrectly logged: Incorrect parameters logged on change · Issue #17424 · esphome/esphome · GitHub
White Flash: Light - applying current effect again causes full-bright white flash · Issue #17425 · esphome/esphome · GitHub

We have ~37 'C outdoors, I feel about everything is affected by heat..

I feel for you. It was a far more reasonable 30.5°C here today. Peaked at 34 on the 26th June.
That's why I thought it might be affecting the sensor - it might not, it's not over the boiler, just the water tank...but it's bound to get some heat-soak. The 20us is either a magic fix...or it's just waiting until I've said it's fixed and then it'll bit me!
Incidentally, this sensor is an M5Stack sensor so the timings are different from a 'normal' HC-SR04. The reading comes back after 42ms rather than about 0.5ms
More details in case it's of interest/relevance: M5Stack Ultrasonic I/O sensor - does work with longer timeout · Issue #10 · Chill-Division/M5Stack-ESPHome · GitHub