FlexiSpot EN2 (HS51A-6 controller) with esphome (no rj ports)

I've really wanted to interface with my standing desk but almost all examples use the controllers with rj45 or similar ports and listen on UART.

The cheaper FlexiSpot (LokTek Motion) desks use controllers that are hardwired. I used pc817 optocouplers for button presses and am using an i2c interrupt handler to try and read the segment display.

This did require fine soldering, so I wouldn't recommend for users that don't have some experience in soldering. Specifically the 2 pins on the LED driver for i2c CLK and DATA. I used single core 30 AWG to tap into the LED driver.

BOM:

  • esp32-c3 super mini
  • 220 - 330 ohm resistors for pin 1 on pc817 to limit current x4
  • pc817 optocouplers x4 (up, down, stand, sit)
  • i2c logic level converter HW-024
  • 1n5817 Schottky diode to protect esp32 3.3v pin from back feeding from the HW-024. Cathode (stripe) towards the HW-024 low voltage VCC pin.

Note: the +5vdc pin for the momentary switch goes to pin 4 on the pc817.

I have it reading the height from the 7-segment display.

I had to write some custom code to interface with the AIP650E0 IC, it uses I2C but its own version. The HW-024 was too slow, as this custom I2C runs at a higher frequency than standard; it created a lot of noise. The fix that’s working for me so far is some 1N5817 diodes with pull-up resistors to the 3.3V line; cathode towards the desk controller IC. I may try and use a 6N137 instead. When I clean the wiring up, I will add a new reply with the final BoM and schematic.

desk_sniffer.h

#pragma once
#include "esphome.h"
#include "esphome/components/switch/switch.h"

namespace esphome {
namespace desk_sniffer {

class DeskSniffer : public sensor::Sensor, public Component {
 public:
  void set_pins(InternalGPIOPin *scl, InternalGPIOPin *sda) {
    this->scl_pin_ = scl;
    this->sda_pin_ = sda;
  }

  void set_debug_mode(bool debug) {
    this->debug_mode_ = debug;
  }

  void set_min_height(float min) { min_height_ = min; }
  void set_max_height(float max) { max_height_ = max; }

  void setup() override;
  void loop() override;

  volatile uint8_t current_byte = 0;
  volatile int bit_count = 0;

  volatile uint8_t buffer[64];
  volatile uint8_t buffer_index = 0;
  volatile uint32_t last_clock_time = 0;
  volatile bool packet_ready = false;

  static void IRAM_ATTR isr_sda_change(DeskSniffer *arg);

 protected:
  InternalGPIOPin *scl_pin_;
  InternalGPIOPin *sda_pin_;
  bool debug_mode_{false};

  uint8_t last_sys_cfg_{0xFF};
  uint8_t last_key_val_{0xFF}; 
  uint8_t last_digit_data_[4] = {0xFF, 0xFF, 0xFF, 0xFF};
  
  int current_digits_[4] = {-1, -1, -1, -1};
  float last_published_height_ = 0.0;
  
  uint32_t last_digit_update_time_ = 0;
  bool pending_publish_ = false;
  
  uint8_t last_unknown_cmd_ = 0xFF;
  uint8_t last_unknown_data_ = 0xFF;

  float min_height_ = 20.0;
  float max_height_ = 50.0;

  int decode_7seg(uint8_t data);
  void calculate_and_publish_height();
};

class DeskSnifferDebugSwitch : public switch_::Switch {
 public:
  DeskSnifferDebugSwitch(DeskSniffer *parent) : parent_(parent) {}
 protected:
  void write_state(bool state) override;
  DeskSniffer *parent_;
};

} // desk_sniffer
} // esphome

desk_sniffer.cpp

#include "desk_sniffer.h"
#include "driver/gpio.h"

namespace esphome {
namespace desk_sniffer {

void IRAM_ATTR DeskSniffer::isr_sda_change(DeskSniffer *arg) {
  gpio_num_t scl_pin = (gpio_num_t)arg->scl_pin_->get_pin();
  gpio_num_t sda_pin = (gpio_num_t)arg->sda_pin_->get_pin();

  if (!gpio_get_level(scl_pin)) return;

  delayMicroseconds(2);
  if (gpio_get_level(sda_pin)) return;

  arg->buffer_index = 0;
  arg->packet_ready = false;

  uint8_t current_byte = 0;
  int bit_count = 0;
  bool last_sda = false;

  // block wifi/os interrupts to prevent missed bits
  portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED;
  portENTER_CRITICAL_ISR(&mux);

  while (true) {
    uint32_t timeout = 50000;

    // wait for clk to go LOW
    while (gpio_get_level(scl_pin)) {
      bool current_sda = gpio_get_level(sda_pin);

      if (!last_sda && current_sda) { // stop cond
        if (arg->buffer_index > 0) arg->packet_ready = true;
        portEXIT_CRITICAL_ISR(&mux); // release os lock
        return;
      }

      if (last_sda && !current_sda) { // repeated start
        arg->buffer_index = 0;
        bit_count = 0;
        current_byte = 0;
      }

      last_sda = current_sda;

      if (--timeout == 0) {
        if (arg->buffer_index > 0) arg->packet_ready = true;
        portEXIT_CRITICAL_ISR(&mux);
        return;
      }
    }

    // wait for clk to go HIGH
    timeout = 50000;
    while (!gpio_get_level(scl_pin)) {
      if (--timeout == 0) {
        if (arg->buffer_index > 0) arg->packet_ready = true;
        portEXIT_CRITICAL_ISR(&mux);
        return;
      }
    }

    // read the sda pin
    bool bit_val = gpio_get_level(sda_pin);
    last_sda = bit_val;
    bit_count++;

    // shift and save
    if (bit_count <= 8) {
      current_byte = (current_byte << 1) | bit_val;
    }

    if (bit_count == 9) {
      if (arg->buffer_index < 64) {
        arg->buffer[arg->buffer_index++] = current_byte;
      }
      bit_count = 0;
      current_byte = 0;
    }
  }
}

void DeskSniffer::setup() {
  this->scl_pin_->setup();
  this->sda_pin_->setup();
  this->sda_pin_->attach_interrupt(DeskSniffer::isr_sda_change, this, gpio::INTERRUPT_FALLING_EDGE);
}

// 7-segment hex codes
int DeskSniffer::decode_7seg(uint8_t data) {
  uint8_t digit_only = data & 0x7F;
  switch (digit_only) {
    case 0x3F: return 0;
    case 0x06: return 1;
    case 0x5B: return 2;
    case 0x4F: return 3;
    case 0x66: return 4;
    case 0x6D: return 5;
    case 0x7D: return 6;
    case 0x07: return 7;
    case 0x7F: return 8;
    case 0x6F: return 9;
    default: return -1; // blank / unreadable
  }
}

void DeskSniffer::calculate_and_publish_height() {
  if (current_digits_[2] == -1) {
    return;
  }

  float height = 0.0f;
  if (current_digits_[0] != -1) height += current_digits_[0] * 100.0f;
  if (current_digits_[1] != -1) height += current_digits_[1] * 10.0f;
  if (current_digits_[2] != -1) height += current_digits_[2] * 1.0f;

  if (last_digit_data_[1] & 0x80) {
      height = height / 10.0f;
  }

  height = (float)((int)(height * 10.0f + (height >= 0 ? 0.5f : -0.5f))) / 10.0f;

  if (height >= min_height_ && height <= max_height_) {
      if (fabsf(height - last_published_height_) >= 0.1f) {
        last_published_height_ = height;
        this->publish_state(height);
        ESP_LOGI("desk_sniffer", "Published New Height: %.1f", height);
      }
  } else {
      ESP_LOGD("desk_sniffer", "Dropped out-of-bounds reading: %.1f", height);
  }
}

void DeskSniffer::loop() {
  // only publish after 50ms of silence from the display to prevent tearing (fixes 33.0 bug)
  if (this->pending_publish_ && (millis() - this->last_digit_update_time_ > 50)) {
     this->calculate_and_publish_height();
     this->pending_publish_ = false;
  }

  if (this->packet_ready) {
    if (this->buffer_index >= 2) {

      bool screen_updated = false;

      for (int i = 0; i < this->buffer_index - 1; i += 2) {
        uint8_t cmd = this->buffer[i];
        uint8_t data = this->buffer[i+1];

        // sys config (0x48)
        if (cmd == 0x48 && data != last_sys_cfg_) {
            last_sys_cfg_ = data;
            if (this->debug_mode_) ESP_LOGI("desk_sniffer", "[SYSTEM] Raw: 0x%02X", data);
        }
        // key scan (0x49)
        else if ((cmd & 0xF9) == 0x49 && data != last_key_val_) {
            last_key_val_ = data;
            if (data != 0x00 && data != 0xFF && this->debug_mode_) {
              ESP_LOGI("desk_sniffer", "[KEYBOARD] Button Pressed! Code: 0x%02X", data);
            }
        }
        // display ram writes (0x68, 0x6A, 0x6C, 0x6E)
        else if (cmd >= 0x68 && cmd <= 0x6E && (cmd % 2 == 0)) {
          int digit_idx = (cmd - 0x68) / 2;

          if (data != last_digit_data_[digit_idx]) {
            last_digit_data_[digit_idx] = data;
            current_digits_[digit_idx] = decode_7seg(data);
            screen_updated = true;
          }
        }
      }

      // if the screen changed, reset the 50ms timer instead of publishing immediately
      if (screen_updated) {
         this->last_digit_update_time_ = millis();
         this->pending_publish_ = true;
      }
    }

    this->buffer_index = 0;
    this->packet_ready = false;
  }
}

void DeskSnifferDebugSwitch::write_state(bool state) {
  this->parent_->set_debug_mode(state);
  this->publish_state(state);
}

}
}

example esphome config

external_components:
  - source:
      type: local
      path: custom_components

sensor:
  - platform: desk_sniffer
    id: desk_height_sniffer
    scl_pin: 3
    sda_pin: 2
    name: "Desk Height"
    debug_switch:
      name: "I2C Debug Logging"
      id: i2c_debug_toggle
    accuracy_decimals: 1
    unit_of_measurement: in
    on_value:
        - binary_sensor.template.publish:
            id: desk_is_moving
            state: ON
        - script.execute: reset_moving_timer

switch:
  - platform: gpio
    pin: 4
    id: raw_up_pin
    name: "Desk Up"
    restore_mode: ALWAYS_OFF
    internal: true

  - platform: gpio
    pin: 5
    id: raw_down_pin
    name: "Desk Down"
    restore_mode: ALWAYS_OFF
    internal: true


  - platform: gpio
    pin: 6
    id: raw_stand_pin
    name: "Desk Stand Preset"
    restore_mode: ALWAYS_OFF
    internal: true

 
  - platform: gpio
    pin: 7
    id: raw_sit_pin
    name: "Desk Sit Preset"
    restore_mode: ALWAYS_OFF
    internal: true

button:
  - platform: template
    name: "Desk Move Down"
    icon: "mdi:down"
    on_press:
      - if:
          condition:
            - binary_sensor.is_on: desk_is_moving
          then:
            # interrupt it and delay
            - switch.turn_on: raw_up_pin
            - delay: 50ms
            - switch.turn_off: raw_up_pin
            - delay: 700ms
      - switch.turn_on: raw_down_pin
      - delay: 300ms
      - switch.turn_off: raw_down_pin
        
  - platform: template
    name: "Desk Move Up"
    icon: "mdi:up"
    on_press:
      - if:
          condition:
            - binary_sensor.is_on: desk_is_moving
          then:
            # interrupt it and delay
            - switch.turn_on: raw_up_pin
            - delay: 50ms
            - switch.turn_off: raw_up_pin
            - delay: 700ms
      - switch.turn_on: raw_up_pin
      - delay: 300ms
      - switch.turn_off: raw_up_pin
  
  - platform: template
    name: "Stand Preset (Move)"
    icon: "mdi:chair-rolling"
    on_press:
      - if:
          condition:
            - binary_sensor.is_on: desk_is_moving
          then:
            # interrupt it and delay for 1s
            - switch.turn_on: raw_up_pin
            - switch.turn_off: raw_up_pin
            - delay: 700ms
      - switch.turn_on: raw_stand_pin
      - delay: 300ms
      - switch.turn_off: raw_stand_pin
            
  - platform: template
    name: "Stand Preset (Save Current Height)"
    icon: "mdi:content-save"
    on_press:
      - if:
          condition:
            - binary_sensor.is_on: desk_is_moving
          then:
            # interrupt it and delay for 1s
            - switch.turn_on: raw_stand_pin
            - switch.turn_off: raw_stand_pin
            - delay: 700ms
      - switch.turn_on: raw_stand_pin
      - delay: 3500ms
      - switch.turn_off: raw_stand_pin
  
  - platform: template
    name: "Sit Preset (Move)"
    icon: "mdi:chair-rolling"
    on_press:
      - if:
          condition:
            - binary_sensor.is_on: desk_is_moving
          then:
            # interrupt it and delay for 1s
            - switch.turn_on: raw_sit_pin
            - switch.turn_off: raw_sit_pin
            - delay: 700ms
      - switch.turn_on: raw_sit_pin
      - delay: 300ms
      - switch.turn_off: raw_sit_pin
            
  - platform: template
    name: "Sit Preset (Save Current Height)"
    icon: "mdi:content-save"
    on_press:
      - if:
          condition:
            - binary_sensor.is_on: desk_is_moving
          then:
            # interrupt it and delay for 1s
            - switch.turn_on: raw_sit_pin
            - switch.turn_off: raw_sit_pin
            - delay: 700ms
      - switch.turn_on: raw_sit_pin
      - delay: 3500ms # holds the button down for 3.5 seconds to save the memory (3 seconds is required, it will flash "5-{btn ID}" on success)
      - switch.turn_off: raw_sit_pin


        
number:
  - platform: template
    name: "Desk Min Height Limit"
    id: desk_min
    optimistic: true
    min_value: 20.0
    max_value: 35.0
    step: 0.1
    initial_value: 28.0
    on_value:
      - lambda: 'id(desk_height_sniffer).set_min_height(x);'

  - platform: template
    name: "Desk Max Height Limit"
    id: desk_max
    optimistic: true
    min_value: 35.0
    max_value: 55.0
    step: 0.1
    initial_value: 46.0
    on_value:
      - lambda: 'id(desk_height_sniffer).set_max_height(x);'

binary_sensor:
  - platform: template
    name: "Desk is Moving"
    id: desk_is_moving

script:
  - id: reset_moving_timer
    mode: restart
    then:
      - delay: 1s
      - binary_sensor.template.publish:
          id: desk_is_moving
          state: OFF

I got a FlexiSpot E1 Pro with a HCB238A-1 controller that looks very similar to yours. Thanks for your investigations! Do you have some tips about opening the case?

the single screw is under the sticker on bottom of the control module.