Turn your Desktronic standing desk into a smart desk!

Getting a DIY DeskUp Pro Controller Working on a Desktronic HomeOne (ESP32-S3-mini)

I recently built my own DeskUp Pro controller to bring my Desktronic HomeOne standing desk into Home Assistant — slider control, presets, and "Alexa, raise my desk." The official DeskUp Pro DIY guide on GitHub is excellent and gets you 90% of the way there, so this post only covers the changes I had to make for my specific combination:

  • Desk: Desktronic HomeOne (RJ12 port)
  • Controller: DIY DeskUp Pro, built from the repo
  • Board: ESP32-S3-mini

Everything else — flashing with ESPHome Builder, the base base.yaml package, the dashboard cards — follow the official docs. This is just the delta. If you've got the same desk, these tweaks should save you a frustrating afternoon.


1. Don't trust the cable colours — measure the RJ12 first

This was my single biggest time-sink. The wiring instructions assume a particular RJ12 colour order, but cheap RJ12 cables vary wildly, and the connector can be read mirror-imaged depending on which way the clip faces. I wired mine by the colour table, plugged it in, and the board's power LED flickered and died — a classic sign that 5V and GND aren't landing where they should.

Do this before connecting anything to the ESP: power the desk, plug the RJ12 into the desk's handset port, leave the ESP end loose, and put a multimeter across the bare conductors to find the ~5V pair. That tells you which colours carry power and ground — don't assume from pin numbers.

On my cable it turned out to be:

Desk wire (measured) Signal ESP32-S3 pin
Red 5V 5V / VCC
Yellow GND GND
Green Data GPIO13
Black Data GPIO12

Note this was the reverse of what the colours implied — red was 5V, not a data line. Feeding 5V into a GPIO is exactly what caused my flicker-and-die. Measure, don't guess.

A nice bonus of the ESP32-S3: GPIO12 and GPIO13 (the repo's default UART pins) are just normal GPIOs on the S3, so there's nothing special to remap. (Heads up if you ever use an older ESP32-WROOM32 instead: on that chip GPIO12 is a boot strapping pin and the desk's idle-high data line will stop it booting — one more reason the S3 is the easier board here.)

Swap RX/TX in config to match your soldering

The two data wires are interchangeable until proven otherwise, so depending on how you solder them you may need to flip which one is RX and which is TX. The repo defaults to tx_pin: GPIO13 / rx_pin: GPIO12; I had them soldered the other way, so in my top-level config (the file that includes base.yaml) I set:

yaml

substitutions:
  tx_pin: "GPIO12"   # black wire
  rx_pin: "GPIO13"   # green wire

Top-level substitutions override the package defaults. If commands don't work on the first try, just swap these two values and reflash — once power and ground are correct, that's a safe and quick test.


2. Make Wi-Fi rock solid: disable power save + set a static IP

Out of the box my device kept showing OFFLINE in the ESPHome Builder dashboard, http://<name>.local/ wouldn't resolve, and pinging it showed wild latency (6–170 ms) and ~10% packet loss on my local network — despite a strong −51 dBm signal.

The culprit was Wi-Fi modem power-save on the ESP32, which sleeps the radio between beacons and drops multicast packets (which is what mDNS/.local relies on, and what the dashboard uses for its ONLINE badge). The fix is two lines in the wifi: block:

yaml

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  power_save_mode: none
  manual_ip:
    static_ip: 192.168.1.183     # pick one outside your DHCP range
    gateway: 192.168.1.1
    subnet: 255.255.255.0

After this, latency dropped to low single-digit milliseconds, .local resolved reliably, and the dashboard badge went green. The static IP is belt-and-braces, but it means you can always reach the device even if mDNS is having a bad day. Make sure the IP is your real gateway and sits outside the router's DHCP pool (or reserve it).


3. Adoption gotcha: add an API key + OTA password after "Take Control"

I did first-time setup with ESPHome Web and then hit "Take Control" in Home Assistant. That flow leaves the device with no API encryption key, so it's worth adding one (and an OTA password while you're there). Generate them with:

bash

openssl rand -base64 32     # API encryption key
openssl rand -hex 16        # OTA password

Put them in secrets.yaml:

yaml

api_encryption_key: "your-generated-base64-key="
ota_password: "your-generated-hex-password"

and reference them:

yaml

api:
  encryption:
    key: !secret api_encryption_key

ota:
  - platform: esphome
    password: !secret ota_password

Flash over OTA first, then when Home Assistant flags the device for re-authentication, paste the same key in Settings → Devices & Services → ESPHome. Copy-paste it exactly, including the trailing =.


4. Use the "Default" desk control variant — NOT "Fully Jarvis"

The DeskUp config has a Desk Control Code Variant selector. Because the HomeOne is a Jiecang-family controller, I assumed "Fully Jarvis" was the right pick. It wasn't — and it actively broke things.

"Fully Jarvis" mode is only for desks that report height in motor steps and need a calibration step to convert to centimetres. The Desktronic HomeOne already reports its height in real centimetres (the value in the log matched the desk's physical display exactly). With "Fully Jarvis" selected, the controller tried to apply a motor-step conversion that had never been calibrated, producing nan in the log:

Fully Jarvis Calculation countsPerCM: nan, min motor steps: 0.0, max motor steps: 0.0 ...

Set the variant to "Default." Height then reads correctly, and you can ignore the "Calibrate Fully Jarvis Desk" button entirely — it does nothing useful on this desk.


5. The big one: make "Go to Height" actually work

With everything above sorted, the up/down nudges and presets worked — but the height slider did nothing useful. Dragging it sent the desk's absolute "move to height" command (F1 F1 1B 02 …) and the desk would twitch ~1 cm in the right direction and stop.

That twitch is the key clue: the HomeOne does understand the absolute-move command, but its controller has a safety watchdog that halts motion the instant it stops receiving frames. The stock code sends the command exactly once, so the desk barely moves.

The fix is closed-loop control: repeat the move command until the measured height reaches the target, then send a stop. Since height now reads reliably (see section 4), this works beautifully and lands within a few millimetres.

First, add a counter to the globals: section of base.yaml:

yaml

  - id: goto_iterations
    type: int
    restore_value: no
    initial_value: "0"

Then replace the existing script_set_Desk_Height with this looped version:

yaml

  - id: script_set_Desk_Height
    parameters:
      newHeight_cm: float        # target in cm
    mode: restart                # moving the slider again cancels the current move
    then:
      - lambda: 'id(goto_iterations) = 0;'
      - logger.log:
          format: "Move to %.1f cm (from %.1f)"
          args: ["newHeight_cm", "id(number_deskHeight).state"]
      # wake the desk
      - uart.write: [0xF1, 0xF1, 0x2B, 0x00, 0x2B, 0x7E]
      - delay: 150ms
      - while:
          condition:
            lambda: |-
              float cur = id(number_deskHeight).state;
              if (std::isnan(cur)) return false;
              return fabs(cur - newHeight_cm) > 0.3 && id(goto_iterations) < 400;
          then:
            - lambda: |-
                id(goto_iterations)++;
                int height_mm = (int)(newHeight_cm * 10);
                uint8_t hi  = (uint8_t)(height_mm / 256);
                uint8_t lo  = (uint8_t)(height_mm % 256);
                uint8_t cks = (uint8_t)((0x1B + 0x02 + hi + lo) % 256);
                const uint8_t cmd[8] = {0xF1, 0xF1, 0x1B, 0x02, hi, lo, cks, 0x7E};
                id(uart_bus).write_array(cmd, 8);
            - delay: 150ms
      # arrived — tell the desk to stop
      - uart.write: [0xF1, 0xF1, 0x2B, 0x00, 0x2B, 0x7E]
      - logger.log: "Move complete"

A few notes:

  • Both the slider and the cover control already call script_set_Desk_Height, so replacing this one script fixes both — no rewiring needed.
  • I removed the old up/down "offset compensation" on purpose. Closed-loop stops at the actual measured height, so it self-corrects and you don't need fudge factors.
  • mode: restart means grabbing the slider mid-move cancels the previous target cleanly.
  • Tuning: if it overshoots, raise the 0.3 tolerance to 0.5 and/or the delay to 200ms. If it's slow or stutters, drop the delay to 100ms.

6. Make the M1–M4 presets jump to the right height

The memory buttons had the same single-frame problem — they sent the desk's native "recall preset" command once, so the desk twitched 1 cm and stopped. Rather than fight the watchdog again, I repointed the preset buttons at the now-working closed-loop script, and made the target heights editable from Home Assistant (so you never have to reflash to change a preset).

Add four target inputs to the number: section of base.yaml (pre-fill with your own heights):

yaml

  - platform: template
    name: "M1 Target"
    id: number_m1_target
    unit_of_measurement: "cm"
    min_value: ${desk_min_height}
    max_value: ${desk_max_height}
    step: 0.1
    initial_value: "72.8"     # my sitting height
    mode: box
    optimistic: true
    restore_value: true
    entity_category: config
    icon: "mdi:numeric-1-circle-outline"

  - platform: template
    name: "M2 Target"
    id: number_m2_target
    unit_of_measurement: "cm"
    min_value: ${desk_min_height}
    max_value: ${desk_max_height}
    step: 0.1
    initial_value: "102.2"    # my standing height
    mode: box
    optimistic: true
    restore_value: true
    entity_category: config
    icon: "mdi:numeric-2-circle-outline"

(Add number_m3_target / number_m4_target the same way if you use them.)

Repoint the recall buttons — replace the existing "Desk M1"/"Desk M2" entries in the button: section so they read their target box:

yaml

  - platform: template
    name: "Desk M1"
    id: "button_desk_m1"
    icon: mdi:numeric-1-circle-outline
    on_press:
      then:
        - logger.log: M1 Pressed
        - script.execute:
            id: script_set_Desk_Height
            newHeight_cm: !lambda 'return id(number_m1_target).state;'

  - platform: template
    name: "Desk M2"
    id: "button_desk_m2"
    icon: mdi:numeric-2-circle-outline
    on_press:
      then:
        - logger.log: M2 Pressed
        - script.execute:
            id: script_set_Desk_Height
            newHeight_cm: !lambda 'return id(number_m2_target).state;'

(Optional) Make the "Set Desk Mx" buttons capture the current height into the target box, so you can save a preset straight from HA — stand the desk where you want and press "Set Desk M1":

yaml

  - platform: template
    name: "Set Desk M1"
    id: "button_desk_set_m1"
    icon: mdi:numeric-1-circle-outline
    entity_category: config
    on_press:
      then:
        - logger.log: Set M1 Pressed
        - lambda: |-
            float h = id(number_deskHeight).state;
            if (!std::isnan(h)) {
              auto call = id(number_m1_target).make_call();
              call.set_value(h);
              call.perform();
            }

Now M1/M2 glide to your exact stored heights using the same reliable mechanism as the slider. One caveat: this drives the desk from the heights stored in HA, independent of whatever's saved on the desk's own controller — treat the HA targets as your source of truth.


7. Voice control: "Alexa, raise my desk"

The M1/M2 buttons appear in Home Assistant as button entities, so two small HA scripts are all you need. M2 (102.2 cm) raises, M1 (72.8 cm) lowers:

yaml

raise_desk:
  alias: Raise Desk
  sequence:
    - action: button.press
      target:
        entity_id: button.office_deskup_pro_desk_m2

lower_desk:
  alias: Lower Desk
  sequence:
    - action: button.press
      target:
        entity_id: button.office_deskup_pro_desk_m1

Check your entity IDs in Developer Tools → States (filter for desk_m) — if your config uses name_add_mac_suffix: true, the IDs will include a MAC suffix.

To wire up Alexa: expose the two scripts via Home Assistant Cloud (Nabu Casa) → Alexa, run device discovery, then create an Alexa Routine for each with a custom phrase ("raise my desk" → run Raise Desk; "lower my desk" → run Lower Desk). Routines let you use natural phrasing instead of "Alexa, turn on Raise Desk."


8. Bonus: fix the !lambda warnings in Studio Code Server

If you edit base.yaml in HA's Studio Code Server, you'll see harmless "Unresolved tag !lambda" warnings — the YAML extension doesn't know ESPHome's custom tags. Silence them via Command Palette → "Preferences: Open User Settings (JSON)":

json

"yaml.customTags": [
  "!lambda scalar",
  "!secret scalar",
  "!include scalar",
  "!include_dir_list scalar",
  "!include_dir_merge_list sequence",
  "!include_dir_named scalar",
  "!include_dir_merge_named mapping",
  "!extend scalar",
  "!remove scalar"
]

Editor-only — it doesn't change what ESPHome builds.


The result

After these changes my Desktronic HomeOne is fully integrated: a slider and cover that glide to any height, M1/M2 presets that jump to my exact sit/stand heights, reliable Wi-Fi, and Alexa voice control. The two changes that mattered most were using the "Default" variant (the HomeOne reports real centimetres) and the closed-loop "go to height" script (the controller needs the move command repeated, not sent once).

Huge thanks to the SmartHomeGuys DeskUp Pro project for the foundation — none of this would exist without their reverse-engineering and open-source code. If you've got a different desk, the Home Assistant community desk thread is the place to compare notes.

Standard disclaimer: this is reverse-engineered, community-driven work. Connecting custom electronics to your desk is at your own risk — measure twice, and double-check polarity before you plug anything in.