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
sonic74
September 27, 2026, 3:22pm
3
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.