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.
- ESP32 dev board, 38-pin ESP-WROOM-32
— a DevKitC-style board rather than a literal NodeMCU-32S, but it is the same
ESP-WROOM-32 module on the same pinout and the config’s
board: nodemcu-32skey builds for it unchanged. Ships as a 2-pack. - 2-channel 5V relay module, low-level trigger — 4 boards, you need one. The trigger type is not a preference here; see the wiring section.
- Logic level converter, 3.3V ↔ 5V — a 6-pack of TXS0108E boards, eight channels each, and you need one channel. Only if your desk’s handset bus runs above 3.3 V; see the caution in the wiring section.
- Silicone hookup wire, 24 AWG — 10 colours × 30 ft, flexible enough to run alongside a cable that moves every time the desk does.
- Dupont jumper assortment — for the bench, not for the finished box.
- Perfboard, headers and screw terminals.
- Project enclosure with cable glands — the glands are the point, they take the strain off the tap cable.
- Right-angle micro-USB cable, 1 ft — a 3-pack. The plug turns the cable along the board instead of adding an inch past the end of it, which is what decides where the gland goes.
- Dual-port USB wall charger — 3-pack, 12 W. An ESP32 with the radio up draws well under a quarter of that.
- VHB double-sided tape — how the box gets under the desktop.
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.
| Signal | ESP32 pin | Goes to |
|---|---|---|
| UART RX | GPIO17 | The height line out of the desk’s control box |
| UART TX | GPIO16 | Declared by the uart: block, nothing is ever sent on it |
| Relay IN1 — Up | GPIO18 | Relay contacts across the handset’s up button |
| Relay IN2 — Down | GPIO19 | Relay contacts across the handset’s down button |
| Status LED | GPIO2 | The board’s own blue LED, no wiring needed |
| Relay board power | 5V and GND | The devkit’s 5V/VIN pin, live whenever USB is plugged in |
| Ground | GND | Must 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
-
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 underuart: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. -
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.
-
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.
-
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.
-
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.
-
Drop both files into your ESPHome folder.
-
Fill in
secrets.yaml—wifi_ssid,wifi_password,ota_password. There is asecrets.yaml.examplein the repo with every key these configs use. -
Change the two substitutions at the top:
substitutions:device_name: "Office Desk"device_id: "office-desk" -
Fix the unit while you are in there —
unit_of_measurement: cmon the height sensor should bein. -
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 fromdevice_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 fordelay_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
# 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 This config includes a custom component. Save it in the same folder as the
YAML above, or the compile fails on a missing header.