Smart Servos Smarter - ESPHome style

While I will admit that smart servos are a niche for those that cannot code their own PWM + rotary encoder + PID. It is even harder for those that cannot code their own motion control algorithms (RPM, ac/de-celeration, hold stiffness, etc); which is me.

With the help of @phillip1, I think there is some really useful code to share that brings Lynxmotion LSS smart servos into the ESPHome world. And while not entirely exhaustive, it is a solid foundation to add any missing feature from the communication protocol.

Why a Smart Servo?

Well let’s cover the benefits;

  • far simpler to deploy with existing libraries
  • can be daisy chained
  • highly configurable out of the box

Now I understand that they cost 10X the price of analogue servo. That’s what sales are for!

Lynxmotion Servo Control

The LSS Communication Protocol is actually quite ESPHome friendly by using simple text parsing and no CRC/checksum’s. We can not only control the direction, speed and holding power; but have live feedback of current values! This is benefitial to ensure that HA is in sync w/ the servo’s current state.

In addition to “knowning state”, you can control behavior beyond just movement. You can control whether the server “holds” or resists outside forces. Or if it should go “limp” and allow free movement. All of a sudden, your servo isn’t controlling something, it is measuring!

With a single servo up and running, you’ll want to add a second. This is where the below code becomes more useful. As long as you follow the naming convention, all state will be captured without additional coding.

YAML Naming Convention

Prefix: servo# where # is an identifier digit from 1 to 9. ID 0 is reserved as a default for all servos and should be avoided
Name: _NAME where NAME is the description matching a configuration parameter including;

  • led
  • gyre
  • state
  • degree
  • degree_per_second
  • angular_acceleration
  • angular_deceleration
  • hold_mode
  • limp_mode

The Code

The .h

This solution leverages a Custom UART Text Sensor with one small tweak. Returning on the ‘*’ character which is indicative of a response from a servo.

    if (readch > 0) {
      switch (readch) {
        case '\n': // Ignore new-lines
          break;
        case '\r': // Return on CR
          rpos = pos;
          pos = 0;  // Reset position index ready for next time
          return rpos;
        case '*': // Return on LSS servo response
          rpos = pos;
          pos = 0;  // Reset position index ready for next time
          return rpos;
        default:
          if (pos < len-1) {
            buffer[pos++] = readch;
            buffer[pos] = 0;
          }
      }
    }

YAML

I have included a two (2) servo example. By following the naming convention you can easily add 3-9 servos with no added C++ lambda’s necessary to capture state info.

esphome:
  includes:
    - uart_read_line_sensor_lss.h

logger:
  logs:
    text_sensor: INFO

switch:
  - platform: template
    id: servo_query_state
    name: servo_query_state
    optimistic: true
    assumed_state : OFF

  - platform: template
    id: servo1_gyre
    name: servo1_gyre
    optimistic: true
    assumed_state : ON
    turn_on_action:
      - uart.write: 
          id: uart_bus
          data: "#1CG1\r"
    turn_off_action:
      - uart.write: 
          id: uart_bus
          data: "#1CG-1\r"
          
  - platform: template
    id: servo2_gyre
    name: servo2_gyre
    optimistic: true
    assumed_state : ON
    turn_on_action:
      - uart.write: 
          id: uart_bus
          data: "#2CG1\r"
    turn_off_action:
      - uart.write: 
          id: uart_bus
          data: "#2CG-1\r"

  - platform: template
    id: servo1_hold_mode
    name: servo1_hold_mode
    optimistic: true
    assumed_state : ON
    turn_on_action:
      - uart.write: 
          id: uart_bus
          data: "#1H\r"
    turn_off_action:
      - uart.write: 
          id: uart_bus
          data: "#1L\r"
      
  - platform: template
    id: servo2_hold_mode
    name: servo2_hold_mode
    optimistic: true
    assumed_state : ON
    turn_on_action:
      - uart.write: 
          id: uart_bus
          data: "#2H\r"
    turn_off_action:
      - uart.write: 
          id: uart_bus
          data: "#2L\r"
    
  - platform: template
    id: servo1_limp_mode
    name: servo1_limp_mode
    optimistic: true
    assumed_state : true
    turn_on_action:
      - uart.write: 
          id: uart_bus
          data: "#1L\r"
    turn_off_action:
      - uart.write: 
          id: uart_bus
          data: "#1H\r"
      
  - platform: template
    id: servo2_limp_mode
    name: servo2_limp_mode
    optimistic: true
    assumed_state : true
    turn_on_action:
      - uart.write: 
          id: uart_bus
          data: "#2L\r"
    turn_off_action:
      - uart.write: 
          id: uart_bus
          data: "#2H\r"
    
  - platform: template
    id: servo1_led
    name: servo1_led
    optimistic: true
    turn_on_action:
      - uart.write: 
          id: uart_bus
          data: "#1CLED5\r"
      - delay: 500ms
      - uart.write: 
          id: uart_bus
          data: "#1QLED\r"
    turn_off_action:
      - uart.write: 
          id: uart_bus
          data: "#1CLED0\r"
      - delay: 500ms
      - uart.write: 
          id: uart_bus
          data: "#1QLED\r"
      
  - platform: template
    id: servo2_led
    name: servo2_led
    optimistic: true
    turn_on_action:
      - uart.write: 
          id: uart_bus
          data: "#2CLED6\r"
      - delay: 500ms
      - uart.write: 
          id: uart_bus
          data: "#2QLED\r"
    turn_off_action:
      - uart.write: 
          id: uart_bus
          data: "#2CLED0\r"
      - delay: 500ms
      - uart.write: 
          id: uart_bus
          data: "#2QLED\r"

button:
  - platform: template
    name: servo1_soft_reset
    on_press:
      - uart.write: 
          id: uart_bus
          data: "#1RESET\r"
  
  - platform: template
    name: servo2_soft_reset
    on_press:
      - uart.write: 
          id: uart_bus
          data: "#2RESET\r"      

uart:
  - id: uart_bus
    tx_pin: GPIO18
    rx_pin: GPIO5
    baud_rate: 115200

number:
  - platform: template
    id: servo1_degree_per_second
    name: servo1_degree_per_second
    step: 1
    min_value: 1
    max_value: 600
    optimistic: true
    restore_value: true
    initial_value: 360
    mode: box
    
  - platform: template
    id: servo2_degree_per_second
    name: servo2_degree_per_second
    step: 1
    min_value: 1
    max_value: 600
    optimistic: true
    restore_value: true
    initial_value: 360
    mode: box
    
  - platform: template
    id: servo1_degree
    name: servo1_degree
    step: 0.1
    min_value: -90
    max_value: 90
    mode: box
    set_action: 
      - uart.write: 
          id: uart_bus
          data: !lambda
            std::string D = "#1D" + to_string(int(x*10)) + "SD" + to_string(int(id(servo1_degree_per_second).state*10)) +"\r";
            return std::vector<uint8_t>(D.begin(), D.end());
      - delay: 1s
      - uart.write: 
          id: uart_bus
          data: "#1QD\r"
      
  - platform: template
    id: servo2_degree
    name: servo2_degree 
    step: 0.1
    min_value: -33
    max_value: 110
    mode: box
    set_action: 
      - uart.write: 
          id: uart_bus
          data: !lambda
            std::string D = "#2D" + to_string(int(x*10)) + "SD" + to_string(int(id(servo2_degree_per_second).state*10)) +"\r";
            return std::vector<uint8_t>(D.begin(), D.end());
      - delay: 1s
      - uart.write: 
          id: uart_bus
          data: "#2QD\r"
        
  - platform: template
    id: servo1_angular_acceleration
    name: servo1_angular_acceleration
    step: 1
    min_value: 1
    max_value: 360
    # optimistic: true
    restore_value: true
    initial_value: 100
    mode: box
    set_action: 
      - uart.write: 
          id: uart_bus
          data: !lambda
            std::string CAA = "#1CAA" + to_string(int(x*10)) +"\r";
            return std::vector<uint8_t>(CAA.begin(), CAA.end());
      - delay: 1s
      - uart.write: 
          id: uart_bus
          data: "#1QAA\r"
      
  - platform: template
    id: servo2_angular_acceleration
    name: servo2_angular_acceleration
    step: 1
    min_value: 1
    max_value: 360
    # optimistic: true
    restore_value: true
    initial_value: 100
    mode: box
    set_action: 
      - uart.write: 
          id: uart_bus
          data: !lambda 
            std::string CAA = "#2CAA" + to_string(int(x*10)) +"\r";
            return std::vector<uint8_t>(CAA.begin(), CAA.end());
      - delay: 1s
      - uart.write: 
          id: uart_bus
          data: "#2QAA\r"

  - platform: template
    id: servo1_angular_deceleration
    name: servo1_angular_deceleration
    step: 1
    min_value: 1
    max_value: 360
    # optimistic: true
    restore_value: true
    initial_value: 100
    mode: box
    set_action: 
      - uart.write: 
          id: uart_bus
          data: !lambda
            std::string CAD = "#1CAD" + to_string(int(x*10)) +"\r";
            return std::vector<uint8_t>(CAD.begin(), CAD.end());
      - delay: 1s
      - uart.write: 
          id: uart_bus
          data: "#1QAD\r"
      
  - platform: template
    id: servo2_angular_deceleration
    name: servo2_angular_deceleration
    step: 1
    min_value: 1
    max_value: 360
    # optimistic: true
    restore_value: true
    initial_value: 100
    mode: box
    set_action: 
      - uart.write: 
          id: uart_bus
          data: !lambda
            std::string CAD = "#2CAD" + to_string(int(x*10)) +"\r";
            return std::vector<uint8_t>(CAD.begin(), CAD.end());
      - delay: 1s
      - uart.write: 
          id: uart_bus
          data: "#2QAD\r"

interval:
  - interval: 1s
    then:
      - uart.write: 
          id: uart_bus
          data: "#1Q\r"
      - uart.write: 
           id: uart_bus
           data: "#2Q\r"
      - if:
          condition:
            switch.is_on: servo1_limp_mode
          then:
            - uart.write: 
                id: uart_bus
                data: "#1QD\r"
      - if:
          condition:
            switch.is_on: servo2_limp_mode
          then:
            - uart.write: 
                id: uart_bus
                data: "#2QD\r"

text_sensor:
  - platform: template
    name: servo1_state
    id: servo1_state
    # internal: true
    
  - platform: template
    name: servo2_state
    id: servo2_state
    # internal: true
  
  - platform: custom
    lambda: |-
      auto my_custom_sensor = new UartReadLineSensor(id(uart_bus));
      App.register_component(my_custom_sensor);
      return {my_custom_sensor};
    text_sensors:
      id: "servo_readline"
      internal: true
      on_value:
        lambda: |-
          auto publishNumber = [](std::string idx, std::string sensor, float resp) {
            auto sens = App.get_numbers();
            for(int i = 0; i < sens.size(); i++) {
              auto name = sens[i]->get_name();
              auto servo = "servo" + to_string(idx);
              if(name.size() > 7 && name.substr(0, 6) == servo) {
                if(name.substr(7) == sensor) {
                  sens[i]->publish_state(resp);
                }
              }
            }
          };
          
          auto publishSwitch = [](std::string idx, std::string sensor, int state) {
            auto sens = App.get_switches();
            for(int i = 0; i < sens.size(); i++) {
              auto name = sens[i]->get_name();
              auto servo = "servo" + to_string(idx);
              if(name.size() > 7 && name.substr(0, 6) == servo) {
                if(name.substr(7) == sensor) {
                  sens[i]->publish_state(state);
                }
              }
            }
          };
          
          auto publishTextSensor = [](std::string idx, std::string sensor, std::string stringstate) {
            auto sens = App.get_text_sensors();
            for(int i = 0; i < sens.size(); i++) {
              auto name = sens[i]->get_name();
              auto servo = "servo" + to_string(idx);
              if(name.size() > 7 && name.substr(0, 6) == servo) {
                if(name.substr(7) == sensor) {
                  sens[i]->publish_state(stringstate);
                }
              }
            }
          };
          
          auto ss = [](int pos, int len) {
            std::string line = id(servo_readline).state;
            return line.substr(pos, len);
          };
          
          std::string sid = ss(0,1);
          
          // Degree
          if (ss(1,3) == "QD-") {
              float QDR = parse_number<float>(ss(3,5)).value();
              publishNumber(sid,"degree",(QDR/10));
          } else if (ss(1,2) == "QD") {
              float QDR = parse_number<float>(ss(3,4)).value();
              publishNumber(sid,"degree",(QDR/10));
          }
          // Angular Acceleration 
          else if (ss(1,3) == "QAA") {
              float QAAR = parse_number<float>(ss(4,4)).value();
              publishNumber(sid,"angular_acceleration",(QAAR/10));
          } 
          // Angular Deceleration
          else if (ss(1,2) == "QAD") {
              float QADR = parse_number<float>(ss(4,4)).value();
              publishNumber(sid,"angular_deceleration",(QADR/10));
          } 
          // LED
          else if (ss(1,4) == "QLED") {
              publishSwitch(sid,"led",(int)(ss(5,1) != "0"));
          }
          // Unknown Mode
          else if (ss(1,2) == "Q0") {
              publishTextSensor(sid,"state","Unknown");
          } 
          // Limp Mode
          else if (ss(1,2) == "Q1") {
              publishSwitch(sid,"hold_mode",0);
              publishSwitch(sid,"limp_mode",1);
              publishTextSensor(sid,"state","Limp");
          } 
          // Hold Mode
          else if (ss(1,2) == "Q6") {
              publishSwitch(sid,"hold_mode",1);
              publishSwitch(sid,"limp_mode",0);
              publishTextSensor(sid,"state","Hold");
          }
          // Stuck Mode
          else if (ss(1,2) == "Q8") {
              publishTextSensor(sid,"state","Stuck");
          }
          // Blocked Mode
          else if (ss(1,2) == "Q9") {
              publishTextSensor(sid,"state","Blocked");
          } 
          // Safe Mode
          else if (ss(1,3) == "Q10") {
              publishTextSensor(sid,"state","SafeMode");
          } 
          // Gyre Counter-Clockwise
          else if (ss(1,3) == "QG-") {
              publishSwitch(sid,"gyre",0);
          } 
          // Gyre Clockwise
          else if (ss(1,3) == "QG1") {
              publishSwitch(sid,"gyre",1);
          } 
1 Like