Dimming lights with rotary encoder on ESP8266 and ESPHome

Hello,

I’m trying to make an ESP dimmer with a rotary encoder for replacing my wall dimming controller. The ESP is loaded with esphomeyalm and controlled by the rotary_platform component.
The encoder can be pushed down like a switch to turn on or off the lights.

The sensor in Home Assistant:

image

Problem:

I can’t figure out how to connect the rotary encoder to dim the lights (at this moment I’m dimming my Yeelight Desk Lamp for testing). This is so far the non-working code I created:

- id: '6'
  alias: RotaryDim
  trigger:
    - platform: state
      entity_id: sensor.rotary_encoder
  condition:
    condition: state
    entity_id: light.milight_desklamp
    state: 'on'
  action:
   - service: light.turn_on
     entity_id: light.milight_desklamp
     data:
       brightness_pct: '{{states.sensor.rotary_encoder.state | int}}'

If anyone has suggestions, I’d be happy to hear them. Thank you!

Did you ever get this working? I was looking to do the same. Also if you mounted this in a standard wall switch plate, how did you power the ESP?

Hi!

I haven’t looked at it since weeks, but I will try to make some time for further investigation.
For powering the esp with mains voltages you can use AC/DC converters like the IRM-10-3.3 from Mean Well or, the much smaller one, HLK-PMO3 from Hi-link.

nothing on tv just doing some reading found your post

reason why not working is

you are trying to mix Jinja with Automation
we need to tell the automation to do some maths (Jinja)
need to add Template: so HA Know to go and do maths first before runit

need to change this

     data:
       brightness_pct: '{{states.sensor.rotary_encoder.state | int}}'

to

     data_template:
       brightness_pct: >
            {{states.sensor.rotary_encoder.state | int}}

once we add the _template HA know its needs to do some maths before run it.

also you need to make shours rotary_encode only send the right values so
the sensor in esphome need to look like this

sensor:
  - platform: rotary_encoder
    name: "Rotary Encoder"
    pin_a: D1
    pin_b: D2
    min_value: 0
    max_value: 100
    resolution: 4

see clear are mud

2 Likes

Hi, I’m trying to make a dimmer too. I just found out, that an automation with this data_template is not too optimal, because HA is handling every single pulse, the resolution just send multiple pulses at once. Any ideas, how to send only every 4th pulse as example? Or how to handle every 4th pulse in HA.

1 Like

OK, I found a solution:

sensor:
  - platform: rotary_encoder
    name: "Rotary Encoder"
    pin_a: PIN_A
    pin_b: PIN_B
    min_value: 0
    max_value: 100
    resolution: 2
    filters:
      - delta: 4.0

With delta, it will send data to HA if the difference is greater than delta’s value.

My automation:

- id: '1569433349039'
  alias: Bedroom Light Dimming
  trigger:
  - entity_id: sensor.rotary_encoder
    platform: state
  condition: []
  action:
  - data_template:
      brightness_pct: '{{states.sensor.rotary_encoder.state | int }}'
    service: light.turn_on
    entity_id: light.bedroom_light
3 Likes

Thanks guys! I really appreciate your help! Other on-going project kept me away from the dimmer, but I will resume it asap to integrate in my home setup.

Hi all

I have been messing about with this for a while. I have rotary encoders wired to wemos d1 minis where my dimmers would be on the walls and zigbee lights in the ceiling

Originally I created some custom MQTT code for the dimmers which was successful, but behaved a bit weirdly.

I now have what I believe is the best solution:

The rotarys just send status to home assistant through esphome

Then I use node red to handle the switch and the rotary movement.

The issue I was coming across was that every time the rotary is moved, the brightness is changed. If you rotate it fast, it generates a huge load/lag on the system while it catches up sending instructions to the lights.

So I installed ‘debounce’ in node red and set up a limit of 250ms with a counter. Basically, when you turn the rotary, node red counts how many times you’re rotating it in that 250ms and then turns that into a command for the lights.

This way, if you want a big adjustment, you rotate it fast and alot and if you want better control you rotate it slowly. There’s still a tiny lag (which is inevitable), but the whole system works brilliantly now.

I also went ahead and added a virtual switch to the esphome which is toggled by a long press on the dimmer. This is set to toggle the colour mode, so I can control brightness and colour temperature at the wall.

Happy to share any code, etc. if this is useful to anyone.

2 Likes

I’d like the code of your flow! I want to use òn_clockwise and òn_anticlockwiseto increase and decrease brightness. Seems as if Node-RED is the way to go.

Okey dokey.

So hopefully you can make sense of this.

I have two subflows. 1 is for rooms with colour temperature changing lights and adds a long press to the rotary encoder that switches to colour change mode instead of brightness. This is what my main flow looks like:

The brains for the rotary adjustment is in the dimmerControl subroutine. This has some hopefully self explanatory environmental variables:-

Then these are passed into the subflow:-

The magic happens at the top with the debounce node in combination with a counter. It’ll pass a number of how many times it’s been rotated in 250ms through the flow. The rest of the flow handles the environmental variables and then increments or decrements the brightness. It’ll stop at the max brightness set in the environmental variable and turn the light off if it hits minimum brightness.

Here’s the subflow:-

[{"id":"f4daa825.5dd3f8","type":"subflow","name":"dimmerControl (no colour)","info":"","category":"","in":[{"x":40,"y":80,"wires":[{"id":"2af0480a.6ebf38"}]}],"out":[],"env":[{"name":"light","type":"str","value":""},{"name":"brightstep","type":"num","value":"5"},{"name":"brightmin","type":"num","value":"5"},{"name":"brightmax","type":"str","value":"255"}],"color":"#DDAA99"},{"id":"2af0480a.6ebf38","type":"counter","z":"f4daa825.5dd3f8","name":"","init":"0","step":"1","lower":null,"upper":null,"mode":"increment","outputs":"1","x":180,"y":80,"wires":[["ce11729f.961e3"]]},{"id":"aa88fac1.a39c28","type":"debounce","z":"f4daa825.5dd3f8","time":"250","name":"debounce","x":520,"y":80,"wires":[["2b2bdf57.7b2f3","c536d7.0e53b928"]]},{"id":"2b2bdf57.7b2f3","type":"change","z":"f4daa825.5dd3f8","name":"reset count","rules":[{"t":"set","p":"reset","pt":"msg","to":"true","tot":"bool"}],"action":"","property":"","from":"","to":"","reg":false,"x":670,"y":80,"wires":[["2af0480a.6ebf38"]]},{"id":"ce11729f.961e3","type":"switch","z":"f4daa825.5dd3f8","name":"if non-zero","property":"count","propertyType":"msg","rules":[{"t":"neq","v":"0","vt":"num"}],"checkall":"true","repair":false,"outputs":1,"x":350,"y":80,"wires":[["aa88fac1.a39c28"]]},{"id":"b7c452a6.45573","type":"comment","z":"f4daa825.5dd3f8","name":"How many times have we moved in 250ms interval?","info":"","x":310,"y":40,"wires":[]},{"id":"992273c2.55bc","type":"api-current-state","z":"f4daa825.5dd3f8","name":"Current state - light","server":"e733376d.c37508","version":1,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","override_topic":false,"entity_id":"${light}","state_type":"str","state_location":"state","override_payload":"msg","entity_location":"lightdata","override_data":"msg","blockInputOverrides":false,"x":230,"y":280,"wires":[["5d3ed6e8.5482c8"]]},{"id":"5d3ed6e8.5482c8","type":"function","z":"f4daa825.5dd3f8","name":"Increment or Decrement?","func":"if (Number(msg.data.old_state.state) < Number(msg.data.new_state.state)) {\n    return [ msg, null ];\n} else {\n    return [ null, msg ];\n}","outputs":2,"noerr":0,"initialize":"","finalize":"","x":450,"y":360,"wires":[["92e9856d.0fee18"],["4fbf4574.3ea10c"]]},{"id":"2ec45dd6.827172","type":"change","z":"f4daa825.5dd3f8","name":"+ brightness","rules":[{"t":"set","p":"lightdata.attributes.brightness","pt":"msg","to":"$min([$number(lightdata.attributes.brightness + ($number(env.brightstep)*count)), $number(env.brightmax)])","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":550,"y":500,"wires":[["73047e8d.2f322"]]},{"id":"4fdc3278.fd253c","type":"change","z":"f4daa825.5dd3f8","name":"- brightness","rules":[{"t":"set","p":"lightdata.attributes.brightness","pt":"msg","to":"$max([$number(brightmin), $number(lightdata.attributes.brightness - ($number(env.brightstep)*count))])","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":570,"y":660,"wires":[["73047e8d.2f322"]]},{"id":"73047e8d.2f322","type":"api-call-service","z":"f4daa825.5dd3f8","name":"light_on","server":"e733376d.c37508","version":1,"debugenabled":false,"service_domain":"light","service":"turn_on","entityId":"${light}","data":"{\"brightness\": {{lightdata.attributes.brightness}}}","dataType":"json","mergecontext":"","output_location":"","output_location_type":"none","mustacheAltTags":false,"x":820,"y":500,"wires":[[]]},{"id":"4fbf4574.3ea10c","type":"switch","z":"f4daa825.5dd3f8","name":"brightness at bright min?","property":"lightdata.attributes.brightness","propertyType":"msg","rules":[{"t":"gt","v":"brightmin","vt":"env"},{"t":"lte","v":"brightmin","vt":"env"}],"checkall":"true","repair":false,"outputs":2,"x":370,"y":700,"wires":[["4fdc3278.fd253c"],["247dca32.696136"]]},{"id":"247dca32.696136","type":"api-call-service","z":"f4daa825.5dd3f8","name":"light_off","server":"e733376d.c37508","version":1,"debugenabled":false,"service_domain":"light","service":"turn_off","entityId":"${light}","data":"","dataType":"json","mergecontext":"","output_location":"","output_location_type":"none","mustacheAltTags":false,"x":800,"y":700,"wires":[[]]},{"id":"c536d7.0e53b928","type":"change","z":"f4daa825.5dd3f8","name":"copy env vars","rules":[{"t":"set","p":"env.brightmin","pt":"msg","to":"brightmin","tot":"env"},{"t":"set","p":"env.brightstep","pt":"msg","to":"brightstep","tot":"env"},{"t":"set","p":"env.brightmax","pt":"msg","to":"brightmax","tot":"env"}],"action":"","property":"","from":"","to":"","reg":false,"x":140,"y":200,"wires":[["992273c2.55bc"]]},{"id":"92e9856d.0fee18","type":"switch","z":"f4daa825.5dd3f8","name":"light off?","property":"lightdata.state","propertyType":"msg","rules":[{"t":"neq","v":"off","vt":"str"},{"t":"eq","v":"off","vt":"str"}],"checkall":"true","repair":false,"outputs":2,"x":360,"y":540,"wires":[["2ec45dd6.827172"],["581a8f95.14cfa"]]},{"id":"581a8f95.14cfa","type":"change","z":"f4daa825.5dd3f8","name":"brightness to min bright","rules":[{"t":"set","p":"lightdata.attributes.brightness","pt":"msg","to":"$number(env.brightmin)","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":570,"y":560,"wires":[["73047e8d.2f322"]]},{"id":"8cd6de54.b82f8","type":"comment","z":"f4daa825.5dd3f8","name":"Take the environmental vars into the msg","info":"","x":200,"y":160,"wires":[]},{"id":"6562bb6f.296884","type":"comment","z":"f4daa825.5dd3f8","name":"Get the current state of the light - msg. state msg.lightdata","info":"","x":250,"y":240,"wires":[]},{"id":"dbcc9e65.15529","type":"comment","z":"f4daa825.5dd3f8","name":"Rotary increased or decreased?","info":"","x":170,"y":320,"wires":[]},{"id":"e733376d.c37508","type":"server","name":"Home Assistant","legacy":false,"addon":true,"rejectUnauthorizedCerts":true,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true,"cacheJson":true}]

I hope that’s understandable!

1 Like

Thank you for the response!

Before you answered, I was playing around and came up with this:

The top one is for adjusting the brightness with rotation, and the bottom one turns the light on and off by pushing the knob.

I use this in conjunction with a template sensor that I feed into esphome, so that if I change the brightness in any other way than with the rotary encoder, the rotary encoder gets that value. I also set the min and max for the enoder to 0 and 100.

Apart from the color control, is there anything your flow does that mine doesn’t? I feel like I might be missing something.

So yours will slow the adjustment to a maximum of 1 step per 250ms.
Mine counts the steps made per 250ms and then fires off a brightness command accounting for the steps.
The result is that you can turn the rotary fast and get a larger brightness change, or turn it slow for a more delicate change.

Apart from that, it just deals with minimum and maximum conditions (turning off below minimum, turning on at minimum if rotated while off).

Actually, the flow above does that too. The debounce node passes on a maximum of 1 value per 250 ms, but that doesn’t mean that it only passes on 1 brightness step up or down. If I turn the rotary enconder fast so that it goes from 10 to 45 in 250 ms, then the debounce node will pass on the value 45 to the set-brightness-node, but omit all the steps between 10 and 45.

It also turns off when brightness is turned down to 0% and turns on if brightness is increased from 0%. I set the stepsize to 4 in the esphome rotary button config to avoid having to turn the know a 100 times for 100%.

If you want to simplify your flow, I think the above one would do the same as yours :slight_smile:

1 Like

Ah ok. Yeah I see. It’s because you’ve got the rotary value attached to the light.

I think at some point I abandoned that idea. I have 3 dimmers in the master bedroom and it was adding lag updating all the values.

This would be very useful to me. If you could share the code would be great.

to whoever bumps into this topic:
EspHome has a built in debounce filter

My code is this; I have a 20 step encoder. The first sensor is the rotary encoder itself, with debounce. The second sensor is receiving the new state from HA in case the state is changed from the frontend.

sensor:
  - platform: rotary_encoder
    name: "Aanrecht dimmer knop"
    id: my_rotary_encoder
    resolution: 1
    min_value: 0
    max_value: 20
    pin_a:
      number: GPIO26
      mode:
        input: true
        pullup: true
    pin_b:
      number: GPIO27
      mode:
        input: true
        pullup: true
    filters:
      debounce: 0.25s
  - platform: homeassistant
    name: Actual Light State
    id: actual_light_state
    entity_id: input_number.aanrecht_dimmer_knop_value
    on_value:
        then:
          - sensor.rotary_encoder.set_value:
              id: my_rotary_encoder
              value: !lambda 'return id(actual_light_state).state ;'

This automation translates the value from the rotary encoder (0-20) to a brightness value (0-255) following a square root function. I like this better than a linear profile. sqrt(encoder * 3250)

alias: 'Keuken aanrecht: rotary dim kitchen light'
description: ''
trigger:
  - platform: state
    entity_id: sensor.aanrecht_dimmer_knop
condition: []
action:
  - service: light.turn_on
    target:
      entity_id: light.keuken_aanrecht_groep
    data:
      brightness: '{{ (((trigger.to_state.state | float(0)) * 3250)**0.5) | round(0) }}'
mode: single

This function saves the actual brightness to an input_number following the inverse of the function above, to correlate brightness with the steps of the encoder. Trick here is the restart mode in combination with the 2 seconds delay; it functions as a debounce filter. (brightness^2)/3250

alias: 'Keuken aanrecht: Sync rotary kitchen light'
description: ''
trigger:
  - platform: state
    entity_id: light.keuken_aanrecht_groep
    attribute: brightness
condition: []
action:
  - delay:
      hours: 0
      minutes: 0
      seconds: 2
      milliseconds: 0
  - service: input_number.set_value
    target:
      entity_id: input_number.aanrecht_dimmer_knop_value
    data:
      value: >-
        {{ ((float(state_attr("light.keuken_aanrecht_groep", "brightness"),0)|
        int )**2/3250) | round(0) }}
mode: restart

This input_number is picked up by ESPHome and used as set_value for the rotary encoder.

6 Likes

I ran into an issue. Both the light (in HA) and the ESP were maintaining the absolute dimmer position. I had to keep them in sync which was a pain; updates were bouncing back and forth.

I have now changed the ESP to only issue an incremental value, generated within the debounce time frame. It creates a number in HA.

There is a global variable which stores the previous encoder position and it is subtracted from the current encoder position.

I hope this helps someone :slight_smile:

globals:
  - id: aanrechtoldvalue
    type: int
    restore_value: no
    initial_value: '0'
  

number:
  - platform: template
    name: Aanrecht Increment
    id: aanrechtincrement
    min_value: -20
    max_value: 20
    step: 1
    optimistic: true
    initial_value: 0
  
sensor:
  - platform: rotary_encoder
    name: "Aanrecht dimmer knop"
    id: aanrecht_rotary_encoder
    resolution: 1
    pin_a:
      number: GPIO26
      mode:
        input: true
        pullup: true
    pin_b:
      number: GPIO27
      mode:
        input: true
        pullup: true
    filters:
      debounce: 0.25s
    on_value:
      then:
        - number.set:
            id: aanrechtincrement
            value: !lambda 'return id(aanrecht_rotary_encoder).state - id(aanrechtoldvalue);'
        - globals.set:
            id: aanrechtoldvalue
            value: !lambda 'return id(aanrecht_rotary_encoder).state;'

The automation in HA picks up the new number value and issues a brightness_step command with that increment. I have 20 steps for a brightness resolution of 255, so multiplied by 13. There is also a pir sensor looking at the dimmer knob, so I have to disable that for a short while. The automation is queued so multiple number changes are being picked up correctly.

alias: 'Keuken aanrecht: rotary dim kitchen light (increment)'
description: ''
trigger:
  - platform: state
    entity_id: number.aanrecht_increment
condition: []
action:
  - service: light.turn_on
    target:
      entity_id: light.keuken_aanrecht_groep
    data:
      brightness_step: '{{ (((trigger.to_state.state | float(0)) * 13)) | round(0) }}'
  - service: input_boolean.turn_on
    target:
      entity_id: input_boolean.aanrecht_override_pir
  - delay:
      hours: 0
      minutes: 0
      seconds: 30
      milliseconds: 0
  - service: input_boolean.turn_off
    target:
      entity_id: input_boolean.aanrecht_override_pir
mode: queued
max: 10

and

alias: 'Keuken Aanrecht: Reset Override PIR'
description: ''
trigger:
  - platform: state
    entity_id: input_boolean.aanrecht_override_pir
    to: 'on'
condition: []
action:
  - delay:
      hours: 0
      minutes: 0
      seconds: 30
      milliseconds: 0
  - service: input_boolean.turn_off
    target:
      entity_id: input_boolean.aanrecht_override_pir
mode: restart

5 Likes

Thanks, I will try this. It seems better to do most the processing in ESP.

Hello

Thank you for The code, works pretty well. Smart solution,
Would it be possible to limit The dimmer value so it doesent turn of The light? Just stop on The minimum value.
Because i use The switch for on off toogle.