M5Stack Dial - ESP32-S3 Smart Rotary Knob

Hi there!

I have made my own integration using ESPHome. If anyone is interested, here you can find the full code and instructions.

Hope you like it!

7 Likes

@Brano69 @EnsconcE I was able to fix the error by changing the C++ lambda and was able to compile under ESPHome 2025.5.2. Maybe there are some breaking changes in the newer ESPHome releases.

I have raised an issue on GitHub Compile error and solution · Issue #2 · Skons/M5Dial · GitHub

1 Like

Pretty basic and I need to upload the transparent web icons although these are easy to find online and are in the esphome directory directly. YAML needs some tweaking. Hope to have some updates. Not a developer so a lot of it is pieces (specifically the LVGL) from others I managed to get working together.


2 Likes

Please help!!!
The installation of “Mr. Avocado” proceeded without any problems. The values of the state of the vacuum cleaner, lights, air conditioner loaded and loaded without problems and displayed on the display
However, when I want to set a value (give a command) from the display nothing happens
If you can help!!!



I solved it :slight_smile: I forgot to enable Allow the device to perform Home Assistant actions"

2 Likes

I am glad you got It working!

This one has popped up in more threads probably then any other issue regarding whyy something in HA can’t be controlled from an ESPHome device. I had the same issue with the first device that either controlled or read sensor data from HA and not something directly attached to the ESP32 pins. I know it’s documented but it’s just one of those things deep in the settings were most don’t go. I think I spent a good 2 hours the first time, assuming I was doing something wrong, before doing a search and immediate face palm after checking one box resolved everything. Wasn’t even aware it was an option until then. The good thing is you will never forget it in the future.

Hello to all

I’m using this code for a several time that is working like a charme, until some new update for the ESPHome Builder


m5-dial-for-esphome

Now I have this errors when I try to perform the update availabl via ESPHome Builder: `*****************************************************************

44 | #include “Network.h”
| ^~~~~~~~~~~
compilation terminated.
*** [.pioenvs/m5dial/lib64d/WiFi/WiFi.cpp.o] Error 1
*** [.pioenvs/m5dial/lib64d/WiFi/WiFiAP.cpp.o] Error 1
In file included from /data/cache/platformio/packages/framework-arduinoespressif32/libraries/WiFi/src/WiFiSTA.h:30,
from /data/cache/platformio/packages/framework-arduinoespressif32/libraries/WiFi/src/WiFi.h:34,
from /data/cache/platformio/packages/framework-arduinoespressif32/libraries/WiFi/src/AP.cpp:7:
/data/cache/platformio/packages/framework-arduinoespressif32/libraries/WiFi/src/WiFiGeneric.h:44:10: fatal error: Network.h: No such file or directory


  • Looking for Network.h dependency? Check our library registry!
  • CLI > platformio lib search “header:Network.h”
  • Web > PlatformIO Registry

44 | #include “Network.h”
| ^~~~~~~~~~~
compilation terminated.
*** [.pioenvs/m5dial/lib64d/WiFi/AP.cpp.o] Error 1`

Can some one please help me in oder to solve this issue? Thank you

Too late haha.

Spent a while wrestling with IP addresses and ended up using the serial port and usb connection to flash instead of ESPhome/web. The ESPhome integration doesn’t require a static IP
 right?

I / AI have referenced many different configs and have eventually ended up with this Music Assistant/Home Assistant media controller (although I think you can change it to use a non music assistant action and playlist easily enough). If anyone is interested I can put it on github properly.

You use the rotary to select a option/button, and the M5 button (the one on the front of the rotary) to click ‘enter’. The touch screen is not used.

Brightness and screen are exposed to home assistant for two-way sync, e.g in automations like dimming the screen at night. Accent colour can use a HA sensor that contains a hex code, or a hardcoded one.



My Code
substitutions:
  wifi_ssid: "REDACTED_WIFI_SSID"
  wifi_password: "REDACTED_WIFI_PASSWORD"
  api_encryption_key: "REDACTED_API_ENCRYPTION_KEY"
  ota_password: "REDACTED_OTA_PASSWORD"

  node_name: dial
  friendly_node_name: Dial

  media_player_id: media_player.speakers
  svc_play_pause: media_player.media_play_pause
  svc_next: media_player.media_next_track
  svc_prev: media_player.media_previous_track
  svc_volume_set: media_player.volume_set
  svc_shuffle_set: media_player.shuffle_set
  svc_repeat_set: media_player.repeat_set
  svc_ma_play_playlist: music_assistant.play_media
  accent_color: sensor.accent_colour # hex codes also work

  volume_step: "0.05"

  playlist_slots: "4"

  playlist_0_name: "Playlist 1"
  playlist_1_name: "Playlist 2"
  playlist_2_name: "Playlist 3"
  playlist_3_name: "Playlist 4"
  playlist_4_name: "-"
  playlist_5_name: "-"

  playlist_0_id: "library://playlist/REDACTED_1" # music assistant library urls
  playlist_1_id: "library://playlist/REDACTED_2"
  playlist_2_id: "library://playlist/REDACTED_3"
  playlist_3_id: "library://playlist/REDACTED_4"
  playlist_4_id: ""
  playlist_5_id: ""

  screen_rotation_degrees: "90"

esphome:
  name: ${node_name}
  friendly_name: ${friendly_node_name}
  platformio_options:
    board_build.flash_mode: dio
  on_boot:
    then:
      - lambda: |-
          pinMode(46, OUTPUT);
          digitalWrite(46, HIGH);
      - pcf8563.read_time: rtctime
      - light.turn_on:
          id: display_backlight
          brightness: 80%
      - lambda: |-
          id(allow_ui_sync) = true;
      - script.execute: sync_page

esp32:
  board: m5stack-stamps3
  variant: esp32s3
  framework:
    type: arduino
  flash_size: 8MB
psram:
  mode: octal
  speed: 80MHz
logger:
  level: WARN
  baud_rate: 115200
  hardware_uart: USB_SERIAL_JTAG

api:
  encryption:
    key: ${api_encryption_key}

ota:
  - platform: esphome
    password: ${ota_password}

wifi:
  ssid: ${wifi_ssid}
  password: ${wifi_password}
  power_save_mode: none
  on_connect:
    then:
      - if:
          condition:
            lambda: "return id(allow_ui_sync);"
          then:
            - script.execute: sync_page

http_request:
  useragent: esphome-m5dial
  timeout: 10s

external_components:
  - source: github://dgaust/esphome@gc9a01
    components: [gc9a01]
    refresh: never

globals:
  - id: allow_ui_sync
    type: bool
    restore_value: no
    initial_value: "false"
  - id: ui_mode
    type: int
    restore_value: no
    initial_value: "0"
  - id: menu_index
    type: int
    restore_value: no
    initial_value: "0"
  - id: playlist_index
    type: int
    restore_value: no
    initial_value: "0"
  - id: shuffle_is_on
    type: bool
    restore_value: no
    initial_value: "false"
  - id: repeat_mode
    type: int
    restore_value: no
    initial_value: "0"
  - id: vol_target
    type: float
    restore_value: no
    initial_value: "0.5"
  - id: marquee_tick
    type: int
    restore_value: no
    initial_value: "0"
  - id: media_position_est
    type: float
    restore_value: no
    initial_value: "0.0"
  - id: media_position_est_last_ms
    type: uint32_t
    restore_value: no
    initial_value: "0"

i2c:
  - id: internal_i2c
    sda: GPIO11
    scl: GPIO12
    scan: false

spi:
  id: spi_bus
  mosi_pin: GPIO5
  clk_pin: GPIO6

font:
  - id: font_small
    file: "gfonts://Roboto"
    size: 14
    glyphs: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 :-_./%|><*,&'\""
  - id: font_medium
    file: "gfonts://Roboto"
    size: 18
    glyphs: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:-_ '\"()%|><*,/&"
  - id: font_large
    file: "gfonts://Roboto"
    size: 22
    glyphs: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:-_ '\"()%|><*,/&"
  - id: font_xlarge
    file: "gfonts://Roboto"
    size: 38
    glyphs: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:-_ '\"()%|><*,/&"

image:
  - file: mdi:arrow-left
    id: ic_back
    type: BINARY
    transparency: chroma_key
    resize: 28x28
  - file: mdi:skip-backward
    id: ic_prev
    type: BINARY
    transparency: chroma_key
    resize: 28x28
  - file: mdi:skip-forward
    id: ic_next
    type: BINARY
    transparency: chroma_key
    resize: 28x28
  - file: mdi:volume-high
    id: ic_volume
    type: BINARY
    transparency: chroma_key
    resize: 28x28
  - file: mdi:shuffle
    id: ic_shuffle_on
    type: BINARY
    transparency: chroma_key
    resize: 28x28
  - file: mdi:shuffle-disabled
    id: ic_shuffle_off
    type: BINARY
    transparency: chroma_key
    resize: 28x28
  - file: mdi:format-list-bulleted
    id: ic_playlist
    type: BINARY
    transparency: chroma_key
    resize: 28x28
  - file: mdi:play-pause
    id: ic_play_pause
    type: BINARY
    transparency: chroma_key
    resize: 28x28
  - file: mdi:repeat-off
    id: ic_repeat_off
    type: BINARY
    transparency: chroma_key
    resize: 26x26
  - file: mdi:repeat
    id: ic_repeat
    type: BINARY
    transparency: chroma_key
    resize: 26x26
  - file: mdi:repeat-once
    id: ic_repeat_once
    type: BINARY
    transparency: chroma_key
    resize: 26x26

output:
  - platform: ledc
    pin: GPIO9
    id: backlight_output

light:
  - platform: monochromatic
    id: display_backlight
    output: backlight_output
    default_transition_length: 0s
    internal: true

number:
  - platform: template
    name: "Backlight Brightness"
    id: backlight_brightness
    optimistic: true
    min_value: 10
    max_value: 100
    step: 1
    initial_value: 80
    restore_value: true
    set_action:
      - light.turn_on:
          id: display_backlight
          brightness: !lambda "return x / 100.0f;"

select:
  - platform: template
    name: "Current Page"
    id: page_select
    optimistic: true
    options:
      - "Now Playing"
      - "Volume"
      - "Playlists"
    initial_option: "Now Playing"
    set_action:
      - lambda: |-
          if (x == "Now Playing") {
            id(ui_mode) = 0;
          } else if (x == "Volume") {
            id(ui_mode) = 1;
          } else {
            id(ui_mode) = 2;
          }
      - script.execute: sync_page

interval:
  - interval: 100ms
    then:
      - if:
          condition:
            lambda: "return id(allow_ui_sync) && id(ui_mode) == 0;"
          then:
            - lambda: "id(marquee_tick)++;"
            - component.update: round_display
  - interval: 500ms
    then:
      - lambda: |-
          auto state = id(media_state).state;
          float p = id(media_position).state;
          float d = id(media_duration).state;
          uint32_t now = millis();
          if (state == "playing") {
            if (!isnan(p) && p > 0.01f) {
              id(media_position_est) = p;
              id(media_position_est_last_ms) = now;
            } else {
              if (id(media_position_est_last_ms) == 0) {
                id(media_position_est_last_ms) = now;
              } else {
                float dt = (now - id(media_position_est_last_ms)) / 1000.0f;
                if (dt > 0.0f && dt < 5.0f)
                  id(media_position_est) += dt;
                id(media_position_est_last_ms) = now;
              }
            }
            if (!isnan(d) && d > 0.0f && id(media_position_est) > d)
              id(media_position_est) = d;
          } else if (state == "paused") {
            if (!isnan(p) && p >= 0.0f)
              id(media_position_est) = p;
            id(media_position_est_last_ms) = now;
          } else {
            if (!isnan(p) && p >= 0.0f)
              id(media_position_est) = p;
            id(media_position_est_last_ms) = 0;
          }

time:
  - platform: pcf8563
    id: rtctime
    i2c_id: internal_i2c
    address: 0x51
    update_interval: never
  - platform: homeassistant
    id: esptime
    on_time_sync:
      then:
        - pcf8563.write_time: rtctime

display:
  - platform: gc9a01
    id: round_display
    cs_pin: GPIO7
    reset_pin: GPIO8
    dc_pin: GPIO4
    rotation: ${screen_rotation_degrees}
    update_interval: never
    pages:
      - id: PlayingPage
        lambda: |-
          const int cx = 120;
          auto BG = Color(0x00, 0x00, 0x00);
          auto FG = Color(0xFF, 0xFF, 0xFF);
          auto DIM = Color(0xFF, 0xFF, 0xFF);
          auto BTN = Color(0xFF, 0xFF, 0xFF);
          auto SEL = Color(0xFF, 0xFF, 0xFF);
          auto DARK = Color(0x00, 0x00, 0x00);
          auto ACC = Color(0x7D, 0xD3, 0xFC);
          int acc_r = 0, acc_g = 0, acc_b = 0;
          const char *acc_src = id(accent_color_text).state.c_str();
          if (sscanf(acc_src, "#%02x%02x%02x", &acc_r, &acc_g, &acc_b) == 3 ||
              sscanf(acc_src, "%02x%02x%02x", &acc_r, &acc_g, &acc_b) == 3 ||
              sscanf("${accent_color}", "#%02x%02x%02x", &acc_r, &acc_g, &acc_b) == 3 ||
              sscanf("${accent_color}", "%02x%02x%02x", &acc_r, &acc_g, &acc_b) == 3) {
            ACC = Color(acc_r, acc_g, acc_b);
          }
          bool paused = id(media_state).state == "paused";
          it.fill(BG);
          char title_buf[64];
          char artist_buf[64];
          auto build_scroll = [&](const std::string &src, int window_chars, char *out, size_t out_sz) {
            std::string s = src;
            if (s.empty())
              s = "-";
            if ((int) s.size() <= window_chars) {
              snprintf(out, out_sz, "%s", s.c_str());
              return;
            }
            std::string gap = "   ";
            std::string padded = s + gap;
            int span = (int) padded.size();
            int off = id(marquee_tick) % span;
            std::string loop = padded + padded;
            std::string view = loop.substr(off, window_chars);
            snprintf(out, out_sz, "%s", view.c_str());
          };
          // Large title font needs a tighter char window so long titles still marquee.
          build_scroll(id(media_title).state, 11, title_buf, sizeof(title_buf));
          std::string artist_raw = id(media_artist).state;
          if (artist_raw.empty())
            artist_raw = "No media";
          build_scroll(artist_raw, 16, artist_buf, sizeof(artist_buf));
          it.printf(cx, 24, id(font_medium), FG, TextAlign::TOP_CENTER, "%s", artist_buf);
          it.printf(cx, 56, id(font_xlarge), FG, TextAlign::TOP_CENTER, "%s", title_buf);
          char pos_buf[8];
          char dur_buf[8];
          float p = id(media_position).state;
          float d = id(media_duration).state;
          if (isnan(p) || p <= 0.0f) {
            p = id(media_position_est);
            if (id(media_state).state == "playing" && id(media_position_est_last_ms) > 0) {
              float dt = (millis() - id(media_position_est_last_ms)) / 1000.0f;
              if (dt > 0.0f && dt < 120.0f)
                p += dt;
            }
          }
          if (isnan(p) || p < 0) {
            snprintf(pos_buf, sizeof(pos_buf), "--:--");
          } else {
            int ps = (int) p;
            snprintf(pos_buf, sizeof(pos_buf), "%02d:%02d", (ps / 60) % 100, ps % 60);
          }
          if (isnan(d) || d < 0) {
            snprintf(dur_buf, sizeof(dur_buf), "--:--");
          } else {
            int ds = (int) d;
            snprintf(dur_buf, sizeof(dur_buf), "%02d:%02d", (ds / 60) % 100, ds % 60);
          }
          it.printf(cx, 126, id(font_medium), FG, TextAlign::CENTER, "%s / %s", pos_buf, dur_buf);
          float v = id(media_volume).state;
          if (isnan(v))
            v = 0.0f;
          if (v < 0.0f)
            v = 0.0f;
          if (v > 1.0f)
            v = 1.0f;
          int pct = (int) (v * 100.0f + 0.5f);
          it.printf(cx, 158, id(font_medium), FG, TextAlign::CENTER, "%d%%", pct);

          // Left->right visual order: playlist, volume, previous, play/pause, next, shuffle, repeat.
          const int arc_cx = 120;
          const int arc_cy = 121;
          const int arc_r = 84;
          const int count = 7;
          const float start_deg = 170.0f;
          const float end_deg = 10.0f;
          for (int i = 0; i < 7; i++) {
            float t = (count <= 1) ? 0.0f : (float) i / (count - 1);
            float a = (start_deg + t * (end_deg - start_deg)) * 3.14159265f / 180.0f;
            int bx = (int) roundf(arc_cx + cosf(a) * arc_r);
            int by = (int) roundf(arc_cy + sinf(a) * arc_r);
            bool sel = id(menu_index) == i;
            int r = sel ? 22 : 18;
            Color face = sel ? SEL : BTN;
            if (i == 5 && id(shuffle_is_on))
              face = ACC;
            if (i == 6 && id(repeat_mode) > 0)
              face = ACC;
            if (i == 3 && paused)
              face = ACC;
            it.filled_circle(bx, by, r, face);
            if (i == 0) {
              it.image(bx, by, id(ic_playlist), ImageAlign::CENTER, DARK);
            } else if (i == 1) {
              it.image(bx, by, id(ic_volume), ImageAlign::CENTER, DARK);
            } else if (i == 2) {
              it.image(bx, by, id(ic_prev), ImageAlign::CENTER, DARK);
            } else if (i == 3) {
              it.image(bx, by, id(ic_play_pause), ImageAlign::CENTER, DARK);
            } else if (i == 4) {
              it.image(bx, by, id(ic_next), ImageAlign::CENTER, DARK);
            } else if (i == 5) {
              it.image(bx, by, id(shuffle_is_on) ? id(ic_shuffle_on) : id(ic_shuffle_off), ImageAlign::CENTER, DARK);
            } else if (i == 6) {
              if (id(repeat_mode) == 2) {
                it.image(bx, by, id(ic_repeat_once), ImageAlign::CENTER, DARK);
              } else if (id(repeat_mode) == 1) {
                it.image(bx, by, id(ic_repeat), ImageAlign::CENTER, DARK);
              } else {
                it.image(bx, by, id(ic_repeat_off), ImageAlign::CENTER, DARK);
              }
            }
          }
      - id: VolumePage
        lambda: |-
          auto BG = Color(0x00, 0x00, 0x00);
          auto FG = Color(0xFF, 0xFF, 0xFF);
          auto TRACK = Color(0xFF, 0xFF, 0xFF);
          auto ACTIVE = Color(0x7D, 0xD3, 0xFC);
          int acc_r = 0, acc_g = 0, acc_b = 0;
          const char *acc_src = id(accent_color_text).state.c_str();
          if (sscanf(acc_src, "#%02x%02x%02x", &acc_r, &acc_g, &acc_b) == 3 ||
              sscanf(acc_src, "%02x%02x%02x", &acc_r, &acc_g, &acc_b) == 3 ||
              sscanf("${accent_color}", "#%02x%02x%02x", &acc_r, &acc_g, &acc_b) == 3 ||
              sscanf("${accent_color}", "%02x%02x%02x", &acc_r, &acc_g, &acc_b) == 3) {
            ACTIVE = Color(acc_r, acc_g, acc_b);
          }
          it.fill(BG);
          float v = id(media_volume).state;
          if (isnan(v))
            v = 0.0f;
          if (v < 0.0f)
            v = 0.0f;
          if (v > 1.0f)
            v = 1.0f;
          int pct = (int) (v * 100.0f + 0.5f);
          const int steps = 110;
          int lit = (int) roundf(v * steps);
          // Circular slider: dark full track + bright active portion from bottom clockwise.
          for (int i = 0; i < steps; i++) {
            float a = (90.0f + (i * 360.0f / steps)) * 3.14159265f / 180.0f;
            int x1 = (int) roundf(120.0f + cosf(a) * 98.0f);
            int y1 = (int) roundf(120.0f + sinf(a) * 98.0f);
            int x2 = (int) roundf(120.0f + cosf(a) * 114.0f);
            int y2 = (int) roundf(120.0f + sinf(a) * 114.0f);
            Color c = (i < lit) ? ACTIVE : TRACK;
            it.line(x1, y1, x2, y2, c);
          }
          if (lit > 0) {
            float ka = (90.0f + ((lit - 1) * 360.0f / steps)) * 3.14159265f / 180.0f;
            int kx = (int) roundf(120.0f + cosf(ka) * 114.0f);
            int ky = (int) roundf(120.0f + sinf(ka) * 114.0f);
            it.filled_circle(kx, ky, 6, ACTIVE);
          }
          it.printf(120, 122, id(font_xlarge), FG, TextAlign::CENTER, "%d%%", pct);
          it.filled_circle(120, 188, 22, FG);
          it.image(120, 188, id(ic_back), ImageAlign::CENTER, Color(0x00, 0x00, 0x00));
      - id: PlaylistPage
        lambda: |-
          auto BG = Color(0x00, 0x00, 0x00);
          auto FG = Color(0xFF, 0xFF, 0xFF);
          auto DIM = Color(0xFF, 0xFF, 0xFF);
          it.fill(BG);
          int slots = ${playlist_slots};
          if (slots > 6)
            slots = 6;
          if (slots < 1)
            slots = 1;
          const int visible = 4;
          int cursor = id(playlist_index);
          if (cursor < 0)
            cursor = 0;
          if (cursor > slots)
            cursor = slots;
          int anchor = cursor;
          if (anchor >= slots)
            anchor = slots - 1;
          int start = 0;
          if (slots > visible) {
            start = anchor - visible / 2;
            if (start < 0)
              start = 0;
            if (start > slots - visible)
              start = slots - visible;
          }
          for (int row = 0; row < visible; row++) {
            int idx = start + row;
            if (idx >= slots)
              break;
            bool sel = idx == cursor;
            const char *name = "-";
            switch (idx) {
              case 0:
                name = "${playlist_0_name}";
                break;
              case 1:
                name = "${playlist_1_name}";
                break;
              case 2:
                name = "${playlist_2_name}";
                break;
              case 3:
                name = "${playlist_3_name}";
                break;
              case 4:
                name = "${playlist_4_name}";
                break;
              case 5:
                name = "${playlist_5_name}";
                break;
            }
            it.printf(52, 32 + row * 34, sel ? id(font_large) : id(font_medium),
                      sel ? FG : DIM, TextAlign::TOP_LEFT, "%s", name);
          }
          bool back_sel = cursor == slots;
          int rr = back_sel ? 24 : 20;
          it.filled_circle(120, 206, rr, FG);
          it.image(120, 206, id(ic_back), ImageAlign::CENTER, Color(0x00, 0x00, 0x00));

sensor:
  - platform: homeassistant
    id: media_volume
    entity_id: ${media_player_id}
    attribute: volume_level
    on_value:
      then:
        - component.update: round_display
  - platform: homeassistant
    id: media_position
    entity_id: ${media_player_id}
    attribute: media_position
    on_value:
      then:
        - lambda: |-
            if (!isnan(x) && x >= 0.0f) {
              id(media_position_est) = x;
              id(media_position_est_last_ms) = millis();
            }
        - component.update: round_display
  - platform: homeassistant
    id: media_duration
    entity_id: ${media_player_id}
    attribute: media_duration
    on_value:
      then:
        - component.update: round_display

  - platform: rotary_encoder
    id: encoder
    resolution: 1
    pin_a:
      number: GPIO40
      mode:
        input: true
        pullup: true
    pin_b:
      number: GPIO41
      mode:
        input: true
        pullup: true
    on_clockwise:
      then:
        - logger.log: "Encoder CW"
        - script.execute: handle_rotate_cw
    on_anticlockwise:
      then:
        - logger.log: "Encoder CCW"
        - script.execute: handle_rotate_ccw

text_sensor:
  - platform: homeassistant
    id: accent_color_text
    entity_id: ${accent_color}
    internal: true
    on_value:
      then:
        - component.update: round_display
  - platform: homeassistant
    id: media_title
    entity_id: ${media_player_id}
    attribute: media_title
    on_value:
      then:
        - lambda: |-
            float p = id(media_position).state;
            id(media_position_est) = (!isnan(p) && p >= 0.0f) ? p : 0.0f;
            id(media_position_est_last_ms) = millis();
        - component.update: round_display
  - platform: homeassistant
    id: media_artist
    entity_id: ${media_player_id}
    attribute: media_artist
    on_value:
      then:
        - component.update: round_display
  - platform: homeassistant
    id: media_state
    entity_id: ${media_player_id}
    on_value:
      then:
        - component.update: round_display
  - platform: homeassistant
    id: media_shuffle_text
    entity_id: ${media_player_id}
    attribute: shuffle
    on_value:
      then:
        - lambda: |-
            auto s = id(media_shuffle_text).state;
            bool on = (s == "on" || s == "True" || s == "true" || s == "1");
            id(shuffle_is_on) = on;
        - component.update: round_display
  - platform: homeassistant
    id: media_repeat_text
    entity_id: ${media_player_id}
    attribute: repeat
    on_value:
      then:
        - lambda: |-
            auto s = id(media_repeat_text).state;
            if (s == "one" || s == "track" || s == "single") {
              id(repeat_mode) = 2;
            } else if (s == "all" || s == "context" || s == "on") {
              id(repeat_mode) = 1;
            } else {
              id(repeat_mode) = 0;
            }
        - component.update: round_display

binary_sensor:
  - platform: gpio
    id: front_button
    internal: true
    pin: GPIO42
    filters:
      - delayed_on: 12ms
      - delayed_off: 12ms
    on_press:
      then:
        - logger.log: "Dial button (GPIO42) pressed"
        - script.execute: handle_button

script:
  - id: apply_volume_target
    then:
      - homeassistant.service:
          service: ${svc_volume_set}
          data:
            entity_id: ${media_player_id}
            volume_level: !lambda "return id(vol_target);"
      - script.execute: sync_page

  - id: sync_page
    mode: restart
    then:
      - if:
          condition:
            lambda: "return id(allow_ui_sync);"
          then:
            - if:
                condition:
                  lambda: "return id(ui_mode) == 0;"
                then:
                  - lambda: |-
                      id(page_select).publish_state("Now Playing");
                  - display.page.show: PlayingPage
                else:
                  - if:
                      condition:
                        lambda: "return id(ui_mode) == 1;"
                      then:
                        - lambda: |-
                            id(page_select).publish_state("Volume");
                        - display.page.show: VolumePage
                      else:
                        - lambda: |-
                            id(page_select).publish_state("Playlists");
                        - display.page.show: PlaylistPage
            - component.update: round_display

  - id: handle_rotate_cw
    then:
      - lambda: |-
          const int slots = ${playlist_slots};
          if (id(ui_mode) == 0) {
            id(menu_index) = (id(menu_index) + 6) % 7;
          } else if (id(ui_mode) == 1) {
            float v = id(media_volume).state;
            if (isnan(v))
              v = 0.0f;
            float step = static_cast<float>(atof("${volume_step}"));
            float nv = std::min(1.0f, v + step);
            id(vol_target) = nv;
          } else if (id(ui_mode) == 2) {
            int s = slots;
            if (s < 1)
              s = 1;
            id(playlist_index) = (id(playlist_index) + 1) % (s + 1);
          }
      - if:
          condition:
            lambda: "return id(ui_mode) == 1;"
          then:
            - homeassistant.service:
                service: ${svc_volume_set}
                data:
                  entity_id: ${media_player_id}
                  volume_level: !lambda "return id(vol_target);"
      - script.execute: sync_page

  - id: handle_rotate_ccw
    then:
      - lambda: |-
          const int slots = ${playlist_slots};
          if (id(ui_mode) == 0) {
            id(menu_index) = (id(menu_index) + 1) % 7;
          } else if (id(ui_mode) == 1) {
            float v = id(media_volume).state;
            if (isnan(v))
              v = 0.0f;
            float step = static_cast<float>(atof("${volume_step}"));
            float nv = std::max(0.0f, v - step);
            id(vol_target) = nv;
          } else if (id(ui_mode) == 2) {
            int s = slots;
            if (s < 1)
              s = 1;
            id(playlist_index) = (id(playlist_index) + s) % (s + 1);
          }
      - if:
          condition:
            lambda: "return id(ui_mode) == 1;"
          then:
            - homeassistant.service:
                service: ${svc_volume_set}
                data:
                  entity_id: ${media_player_id}
                  volume_level: !lambda "return id(vol_target);"
      - script.execute: sync_page

  - id: handle_button
    then:
      - if:
          condition:
            lambda: "return id(ui_mode) == 1;"
          then:
            - lambda: "id(ui_mode) = 0;"
            - script.execute: sync_page
          else:
            - if:
                condition:
                  lambda: "return id(ui_mode) == 2;"
                then:
                  - if:
                      condition:
                        lambda: "return id(playlist_index) >= ${playlist_slots};"
                      then:
                        - lambda: "id(ui_mode) = 0;"
                        - script.execute: sync_page
                      else:
                        - script.execute: play_selected_playlist
                        - lambda: "id(ui_mode) = 0;"
                        - script.execute: sync_page
                else:
                  - script.execute: main_menu_action

  - id: main_menu_action
    then:
      - if:
          condition:
            lambda: "return id(menu_index) == 0;"
          then:
            - lambda: "id(ui_mode) = 2;"
            - script.execute: sync_page
      - if:
          condition:
            lambda: "return id(menu_index) == 1;"
          then:
            - lambda: "id(ui_mode) = 1;"
            - script.execute: sync_page
      - if:
          condition:
            lambda: "return id(menu_index) == 2;"
          then:
            - homeassistant.service:
                service: ${svc_prev}
                data:
                  entity_id: ${media_player_id}
      - if:
          condition:
            lambda: "return id(menu_index) == 3;"
          then:
            - homeassistant.service:
                service: ${svc_play_pause}
                data:
                  entity_id: ${media_player_id}
      - if:
          condition:
            lambda: "return id(menu_index) == 4;"
          then:
            - homeassistant.service:
                service: ${svc_next}
                data:
                  entity_id: ${media_player_id}
      - if:
          condition:
            lambda: "return id(menu_index) == 5;"
          then:
            - homeassistant.service:
                service: ${svc_shuffle_set}
                data:
                  entity_id: ${media_player_id}
                  shuffle: !lambda "return !id(shuffle_is_on);"
      - if:
          condition:
            lambda: "return id(menu_index) == 6;"
          then:
            - if:
                condition:
                  lambda: "return id(repeat_mode) == 0;"
                then:
                  - homeassistant.service:
                      service: ${svc_repeat_set}
                      data:
                        entity_id: ${media_player_id}
                        repeat: all
            - if:
                condition:
                  lambda: "return id(repeat_mode) == 1;"
                then:
                  - homeassistant.service:
                      service: ${svc_repeat_set}
                      data:
                        entity_id: ${media_player_id}
                        repeat: one
            - if:
                condition:
                  lambda: "return id(repeat_mode) == 2;"
                then:
                  - homeassistant.service:
                      service: ${svc_repeat_set}
                      data:
                        entity_id: ${media_player_id}
                        repeat: "off"

  - id: play_selected_playlist
    then:
      - if:
          condition:
            lambda: "return id(playlist_index) == 0 && strlen(\"${playlist_0_id}\") > 3;"
          then:
            - homeassistant.service:
                service: ${svc_ma_play_playlist}
                data:
                  entity_id: ${media_player_id}
                  media_type: playlist
                  media_id: ${playlist_0_id}
      - if:
          condition:
            lambda: "return id(playlist_index) == 1 && strlen(\"${playlist_1_id}\") > 3;"
          then:
            - homeassistant.service:
                service: ${svc_ma_play_playlist}
                data:
                  entity_id: ${media_player_id}
                  media_type: playlist
                  media_id: ${playlist_1_id}
      - if:
          condition:
            lambda: "return id(playlist_index) == 2 && strlen(\"${playlist_2_id}\") > 3;"
          then:
            - homeassistant.service:
                service: ${svc_ma_play_playlist}
                data:
                  entity_id: ${media_player_id}
                  media_type: playlist
                  media_id: ${playlist_2_id}
      - if:
          condition:
            lambda: "return id(playlist_index) == 3 && strlen(\"${playlist_3_id}\") > 3;"
          then:
            - homeassistant.service:
                service: ${svc_ma_play_playlist}
                data:
                  entity_id: ${media_player_id}
                  media_type: playlist
                  media_id: ${playlist_3_id}
      - if:
          condition:
            lambda: "return id(playlist_index) == 4 && strlen(\"${playlist_4_id}\") > 3;"
          then:
            - homeassistant.service:
                service: ${svc_ma_play_playlist}
                data:
                  entity_id: ${media_player_id}
                  media_type: playlist
                  media_id: ${playlist_4_id}
      - if:
          condition:
            lambda: "return id(playlist_index) == 5 && strlen(\"${playlist_5_id}\") > 3;"
          then:
            - homeassistant.service:
                service: ${svc_ma_play_playlist}
                data:
                  entity_id: ${media_player_id}
                  media_type: playlist
                  media_id: ${playlist_5_id}

2 Likes

What parts did you use for the Schneider frame? It looks good!

The Schneider ones, I cut a hole in a 'fake socket/shutter' plate (in France this is the p/n: S520666) , attached the Dial on it and that's it.