Skip to content

Desk Controller

An electric sit-stand desk knows exactly how high it is and tells nobody but its own handset. This gets in the middle of that: an ESP32 listens to the serial line feeding the handset’s display, and two relays sit across the handset’s up and down buttons. The desk gets a height sensor and two switches in Home Assistant, and from there it can stand itself up because you have been sitting for two hours, or drop to your seated preset when the morning alarm goes off.

This is a bolt-on to a desk you already own. Nothing inside the desk’s control box changes — the ESP32 eavesdrops on one wire and presses two buttons electrically. Unplug it and you have a stock desk again.

How it works

The control box streams the current height up to the handset so the handset can show a number. That is a plain 9600-baud serial line, and table_height_sensor.h is a custom ESPHome component that sits on it and does one thing: it drops any byte of 5 or below and the value 170 (0xAA), and turns whatever is left into a height with (byte + 256) / 10. One byte per reading, offset by 256 tenths. It polls once a second and publishes only when the value changes, so the sensor sits quiet while the desk is still.

Going the other way there is no protocol at all. GPIO18 and GPIO19 each drive a relay, and the relay contacts are wired across the handset’s own up and down buttons. The control box cannot tell the difference between a relay closing and a thumb.

The sensor says cm and the number is inches. (byte + 256) / 10 spans 26.2 to 51.1, which is a sit-stand desk’s travel in inches, not centimetres. Nothing breaks — move_desk compares the sensor against the setpoint in the same raw units either way — but Home Assistant will draw a graph with the wrong label on it forever. Change unit_of_measurement to in before you flash.

Parts

Some links below are Amazon affiliate links. As an Amazon Associate I earn from qualifying purchases — at no extra cost to you. It helps keep these guides free. Outside the US, they should send you to your own Amazon store; if one doesn't, searching the part number there will find it.

Not on the list: the desk. Any electric sit-stand frame whose control box sends the height to its handset over serial will do, and the tap point is the thing you work out for yourself.

The config also carries an unrelated passenger — see the plant sensor riding along at the bottom.

Wiring

Every pin here is the pin the config expects.

SignalESP32 pinGoes to
UART RXGPIO17The height line out of the desk’s control box
UART TXGPIO16Declared by the uart: block, nothing is ever sent on it
Relay IN1 — UpGPIO18Relay contacts across the handset’s up button
Relay IN2 — DownGPIO19Relay contacts across the handset’s down button
Status LEDGPIO2The board’s own blue LED, no wiring needed
Relay board power5V and GNDThe devkit’s 5V/VIN pin, live whenever USB is plugged in
GroundGNDMust be common with the desk’s ground, or the UART reads noise

Both relay switches are declared inverted: true, which is what a low-level-trigger board wants — the relay pulls in when the ESP32 drags the IN pin to ground, and a switch reading off holds that pin high. That is why the trigger type is not a preference. RESTORE_DEFAULT_OFF puts both switches off at boot, so both pins go high; on a high-level-trigger board high means energised, and the desk comes up with both buttons held down. interlock will not save you either — as far as ESPHome is concerned both switches are off.

Both lines do a real job on the right board. interlock means ESPHome will never have up and down energised together. RESTORE_DEFAULT_OFF means a reboot or a power cut never comes back with a relay latched — the desk should be still when nobody asked it to move.

Measure the height line before you connect it. ESP32 GPIOs are 3.3 V and are not 5 V tolerant, and plenty of desk controllers swing their handset bus to 5 V or higher. If yours does, the level converter goes between that line and GPIO17.

Assembly

  1. Find the height line. Open the handset and look for the wire carrying data from the control box. Put the ESP32 on it at 9600 baud, add a debug: block under uart: so the raw bytes go to the log, and raise the desk by hand — the right line is the one whose bytes climb as the desk does. Ground first, always.

  2. Tap the two buttons. Solder a pair of wires to each of the up and down button pads inside the handset and bring them out to the relay’s normally-open contacts. These are dry contacts, so the relay coil voltage has nothing to do with the desk side — you are just shorting a switch the desk already has.

  3. Build the board. ESP32 and relay module on perfboard, screw terminals for the three wires that leave the box. Solder the tap wires last; they are the ones you least want to redo with the lid on.

  4. Flash and test with the desk clear. Toggle the up and down switches from the ESPHome web view before you trust an automation with them. Check the height sensor tracks in both directions.

  5. Close it up and stick it on. VHB tape under the desktop, glands facing the handset, and enough slack in the tap cable that a full travel from sitting to standing does not pull on it.

Firmware

bruh-desk.yaml and table_height_sensor.h both live in the download section below, and they have to sit in the same folder — the includes: line pulls the header in relative to the config.

  1. Drop both files into your ESPHome folder.

  2. Fill in secrets.yamlwifi_ssid, wifi_password, ota_password. There is a secrets.yaml.example in the repo with every key these configs use.

  3. Change the two substitutions at the top:

    substitutions:
    device_name: "Office Desk"
    device_id: "office-desk"
  4. Fix the unit while you are in there — unit_of_measurement: cm on the height sensor should be in.

  5. Flash over USB the first time. Everything after that goes over the air.

In Home Assistant

The config expects one helper to exist: an input_number with the entity id input_number.bruh_desk_height. Create it under Settings → Devices & Services → Helpers, and set its min and max to your own desk’s travel, in whatever unit the sensor is reporting. That helper is the setpoint — the config imports it back as an internal sensor and compares it against the measured height.

That entity id is written out in full in the config and does not follow the substitutions, so renaming the device does not rename the helper. Either name the helper bruh_desk_height whatever you call the desk, or change the entity_id: line to match.

The device then exposes:

  • Height — the live reading off the serial line.
  • Up and Down — the two relays, as switches you can hold on and off yourself.
  • esphome.bruh_desk_move_desk — the service name is built from device_id, so renaming the device renames this too. It takes one variable, delay_time, in seconds: it looks at the setpoint, decides which way that is, pulses the matching relay for delay_time, and stops.
  • Status, WiFi signal and a restart switch.

move_desk is open-loop, and that is the thing to design around. It does not drive to the setpoint; it moves for the number of seconds you hand it, in the right direction. The reliable pattern is to call it repeatedly with a short delay_time — a second or less — and let the automation re-check the height between calls, so the last pulse is small. One long pulse will overshoot, and the service has no idea that it did.

The plant sensor riding along

bruh-desk.yaml also carries a xiaomi_hhccjcy01 block and an esp32_ble_tracker, reporting temperature, moisture, illuminance, conductivity and battery for a plant sensor named “Yard 1”. That has nothing to do with the desk. It is there because the ESP32 under the desk happened to be the nearest board with a Bluetooth radio to a plant outside, and an ESP32 will do both jobs at once without noticing.

If you are not using it, delete the xiaomi_hhccjcy01 sensor block and the esp32_ble_tracker: block at the bottom. If you are, put your own sensor’s MAC in mac_address: — the one in the file is Ben’s and will never advertise on your network. Scanning is set to active: false on purpose: passive scanning is all these sensors need, and it keeps the radio out of the way of WiFi.

Troubleshooting

The height sensor never publishes. Silence means nothing is arriving — wrong wire, or the wrong baud rate. A missing ground looks nothing like this: the filter only drops seven values out of 256, so noise reaches Home Assistant as a height that jumps around rather than as no height at all.

The height jumps to a wildly wrong number. Some other byte on that bus is getting through the filter. The decoder trusts every byte above 5 that is not 0xAA, which is a thin protocol; put a debug: block under uart: and read what else the control box sends.

Both relays click at boot. High-level-trigger relay board. Either swap it for a low-level-trigger one or remove inverted: true from both switches, and do it before the desk is loaded.

The desk moves the wrong way. The two relays are on the wrong buttons. Swap the pin: values between the two switch: blocks rather than resoldering, and leave everything else alone — interlock: names ids, not pins, so it is still correct. You end up with an id called gpio_18 sitting on pin 19, which is a misleading name and nothing worse.

It overshoots every preset. delay_time is too long. See the note above: short pulses, re-check between each.

Files & downloads

ESPHome configuration

Copy this into your ESPHome directory and adjust the substitutions at the top. Secrets are referenced by name — see secrets.yaml.example .

bruh-desk.yaml 126 lines
bruh-desk.yaml
# BRUH Desk Height Sensor
# ────────────────────────────────────────────────────────────
# Electric desk height controller with custom UART sensor.
# Reads desk height from controller and provides remote
# control via Home Assistant.
#
# Hardware:
# - Board: ESP32
# - Sensor: Custom UART desk height sensor
# - Control: GPIO relays for up/down movement
# ────────────────────────────────────────────────────────────
substitutions:
device_name: "BRUH Desk"
device_id: "bruh-desk"
esphome:
name: ${device_id}
platform: ESP32
board: nodemcu-32s
includes:
- table_height_sensor.h
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ota:
password: !secret ota_password
platform: esphome
api:
services:
- service: move_desk
variables:
delay_time: float
then:
- if:
condition:
lambda: 'return id(bruh_desk_set_height).state < id(bruh_desk_height).state;'
then:
- switch.turn_on: gpio_19
- delay: !lambda 'return delay_time;'
- switch.turn_off: gpio_19
- if:
condition:
lambda: 'return id(bruh_desk_set_height).state > id(bruh_desk_height).state;'
then:
- switch.turn_on: gpio_18
- delay: !lambda 'return delay_time;'
- switch.turn_off: gpio_18
status_led:
pin:
number: GPIO2
logger:
uart:
id: uart_bus
tx_pin: 16
rx_pin: 17
baud_rate: 9600
sensor:
- platform: custom
lambda: |-
auto height_sensor = new TableHeightSensor(id(uart_bus));
App.register_component(height_sensor);
return {height_sensor};
sensors:
name: "${device_name} height"
unit_of_measurement: cm
id: bruh_desk_height
accuracy_decimals: 1
- platform: homeassistant
id: bruh_desk_set_height
name: "${device_name} Set Height"
entity_id: input_number.bruh_desk_height
internal: true
- platform: xiaomi_hhccjcy01
mac_address: "DC:23:4D:E5:62:8C"
temperature:
name: "Yard 1 Temperature"
moisture:
name: "Yard 1 Moisture"
illuminance:
name: "Yard 1 Illuminance"
conductivity:
name: "Yard 1 Soil Conductivity"
battery_level:
name: "Yard 1 Battery Level"
- platform: wifi_signal
name: "${device_name} WiFi Signal"
update_interval: 300s
binary_sensor:
- platform: status
name: "${device_name} Status"
switch:
- platform: gpio
pin: 18
id: gpio_18
name: "${device_name} Up"
inverted: true
restore_mode: RESTORE_DEFAULT_OFF
interlock: [gpio_19]
- platform: gpio
pin: 19
id: gpio_19
name: "${device_name} Down"
inverted: true
restore_mode: RESTORE_DEFAULT_OFF
interlock: [gpio_18]
- platform: restart
name: "${device_name} Restart"
esp32_ble_tracker:
scan_parameters:
active: false

View on GitHub · Download raw

This config includes a custom component. Save it in the same folder as the YAML above, or the compile fails on a missing header.

Every file for this project on GitHub →