Skip to the content
PCBKitNew board
2 layers94 × 60 mmDesk, the printed circuit board in 3D

Reverse-engineered LOGICDATA COMPACT-e-3-VAL (ErgoSwiss hydraulic desk) handset bus: height readback + ESP32/ESPHome control through a $3 optocoupler tap. First public decode of the COMPACT-e-3's 9-bit serial protocol.

Size
94 × 60 mm
Copper layers
2
Thickness
1.6 mm
Parts
17
Nets
38
Pads
70
Tracks, vias
103, 0
Pours
2
Routed
Yes
KiCad
KiCad 10.0 (file 20260206)
Created
11 Aug 2026
Updated
20 Sep 2026

Overview

Desk is an open-source printed circuit board by scottdube, from the scottdube/deskhack repository on GitHub, published under the MIT licence. It is a 2-layer board of 94 × 60 mm with 17 parts on 38 nets, routed with 103 tracks and 0 vias.

Its parts include 7 resistors, 4 connectors and 2 ICs. It belongs with the home automation and IoT boards and the ESP32 boards of this library.

README

README.md

deskhack

Home Assistant control and height readback for an ErgoSwiss hydraulic standing desk running a LOGICDATA COMPACT-e-3-VAL-ES-US control box ("Compact-3-eco", software V290) — without buying the vendor Memory handset, and without opening either sealed box.

The desk shipped with a dumb up/down panel: no presets, no display. This project taps the 7-pin DIN handset cable inline, drives the button lines from an ESP32 through optocouplers, and reads the desk's height from a previously undocumented serial broadcast the control box sends on one of the handset pins. The original panel keeps working in parallel.

As far as I can tell this is the first public decode of the COMPACT-e-3 handset bus. The well-known reverse-engineering projects (RoboDesk, logicdata-standing-desk) target the COMPACT-e-2L, whose framing differs in an important way (see below).

The protocol

Everything below was measured on real hardware with a $12 FX2LP logic analyzer and sigrok-cli; nothing is copied from other models' documentation.

Physical

7-pin DIN (DIN 45329 family) between handset and control box. Wire colours in the cheap extension cable used for the tap:

wirefunction
brownGND
red+5V supply to handset
whiteHS line — assert high to drive UP
yellowHS line — assert high to drive DOWN
greenHS line, unused by the basic panel (memory functions)
blueHS line, unused by the basic panel (memory functions)
blackserial data out of the box, idles high

Button lines are active-high and high-impedance: the box's inputs are sensitive enough that a multimeter in ohms mode will command motion (ask me how I know). A dumb panel is nothing but switches connecting HS lines to the +5V rail.

Serial framing

The data line is 1000 baud, 9 data bits, no parity, 1 stop, LSB-first — a classic 9-bit multi-drop bus. Community docs for the COMPACT-e-2L describe "1000 baud with even parity"; on this box that is a misread of the 9th data bit. The framing here was solved by constraint propagation over the raw bit runs (every 0 must sit inside a frame; see dataline.py and messages.py), not assumed.

Packets are two 9-bit words, sent only when the displayed height changes (one packet per inch of travel) plus a fixed handshake burst at the start of any button press. Packet: 0x07F header, then the payload word.

Height

text
height_inches = (245 - (payload & 0xFF)) / 2

Verified against a tape measure at four points across the full travel (69–96 cm on this desk, 27.0–38.0 in): every reading within 0.5 cm. The box counts pump-motor Hall pulses internally, so this is the same position the controller itself acts on.

The 8N1 trick

You do not need 9-bit UART support to read this bus — a plain 8N1 UART at 1000 baud recovers every payload byte intact. But how the packet arrives depends on how the UART handles framing errors, and real ESP32 silicon differs from the obvious simulation:

  • A UART that discards error bytes and resyncs bit-by-bit (what a naive software decoder does) sees the header mis-frame into a constant 0xFE sync marker followed by the height byte.
  • The ESP32 keeps framing-error bytes (verified on an ESP32-WROOM-32, ESP-IDF 5.5). Packets arrive as 00 7F <payload>: the header comes through verbatim as 0x7F, each framing error also emits a 0x00 artifact, and the payload byte is unmangled.

Either way the sync-then-payload structure survives. On the ESP32: skip 0x00, treat 0x7F as "next byte is payload".

Filtering the handshake burst is easy thanks to an accident of the encoding: real heights are always odd bytes (245 - 2*inches), and the handshake constants are even or outside the travel range — except 0x0BF, which aliases a true 27.0 in reading. Its tell is timing: as a handshake word it arrives within ~400 ms of the others, as a real height it arrives alone, so reject 0xBF seen within 600 ms of other handshake traffic. The working lambda is in desk.yaml.

Hardware

Eight components, all through-hole:

  • 2 × PC817 optocoupler (or CPC1017N photoMOS) — GPIO drives the LED through 330 Ω; the output side connects a button line to the +5V rail, exactly as a finger on the panel does
  • 2 × 100 kΩ bleed, opto emitter → GND, so leakage can't float the high-impedance inputs
  • 3 × 22 kΩ — divider (22k : 44k) dropping the 5 V data line to 3.3 V for the ESP32's RX
  • ESP32 dev board on its own USB supply, grounds tied at the DIN cable

The tap itself is a male-female 7-pin DIN extension cable cut in half and landed on lever nuts, so the whole thing unplugs back to stock in seconds.

The tap: a 7-pin DIN extension cut in half, each conductor landed in a
WAGO 221 lever nut with a pigtail out to the logic analyzer

Nothing is soldered and nothing on the desk is modified — the box and the original panel each keep their own connector, and the lever nuts sit in between. Pull the two halves and the desk is stock again.

The injection rig: a cheap male-female 7-pin DIN extension cut in half, both
halves landed in WAGO 221 lever nuts. Male end to the control box, female end
to the original handset.

This rig is the whole trick, and it generalises. It is a $10 extension cable, not the desk's harness. Cutting it gives you seven conductors broken out into lever nuts, which is simultaneously a probe point for the logic analyzer and an injection point for the ESP32 — read the bus and drive it without ever putting a knife near the cable that came with the machine. Get it wrong, unplug two connectors, and the desk has never been touched. Any bus that runs through a detachable cable can be attacked this way.

The finished board wired to the rig on the bench, before install. The
wire-colour legend along each terminal block matches the DIN conductor
colours.

The board keeps the same idea: J1 and J4 are wired straight through, so the control box and the original handset each land on their own terminal block and the ESP32 sits across the pair. The panel goes on working exactly as before.

The cable shield is left floating — on purpose

The handset cable is seven conductors plus a shield, and the shield is not grounded here. That is deliberate, and it is worth stating plainly because the instinct on seeing an ungrounded shield is that somebody forgot.

It is not grounded in the desk's own wiring. The two halves of the tap have their drains joined to each other, so the shield stays electrically continuous end to end exactly as it was before the cable was cut — and it terminates nowhere, exactly as it did before.

Why not "improve" it: the whole design goal is that the control box cannot tell the difference. Everything else here is built that way — the original panel keeps working, the box keeps its own connector, nothing is soldered to the desk. Bonding the shield to logic ground would be a change to a mains-fed system whose internal grounding we cannot see, in exchange for a benefit nothing has demonstrated a need for. If the box grounds the shield at its end, adding a second bond creates a loop.

No idea why LOGICDATA left it floating. Matching what the manufacturer did is the conservative choice when you do not know the reason.

kicad/ contains a KiCad 10 project (ERC-clean, footprints assigned) with the interface circuit and a 30-pin ESP32 DevKit V1 socket, ready for board layout. The schematic is generated — edit kicad/gen_sch.py and rerun rather than editing the sheet, or retire the generator once you start hand-routing.

Firmware

desk.yaml is a complete ESPHome configuration:

  • height sensor via the 8N1 trick (hardware UART, zero protocol code)
  • target-height number entity + sit/stand/stop buttons for Home Assistant
  • bang-bang seek loop (feedback is 1-inch granular; there is nothing finer to chase)
  • duty-cycle watchdog — the box is rated 2 min on / 18 min off and this is enforced in firmware, don't remove it; Drive Budget and Budget Recovery sensors expose how much motion is left before lockout. The budget persists across reboots (and credits real elapsed time while powered off), so an OTA can't hand you a fresh one. A diagnostic Reset Duty Budget button exists for bench work — deliberate operator action only, never automate it
  • stall detection (no height change while driving → stop)
  • a template cover face (0% = bottom of travel, 100% = top) so voice assistants can drive it with built-in position intents — no custom sentences required, and it provides a voice "stop"

Voice control: use the cover, not the buttons

The cover face is not a convenience — on Home Assistant it is the only part of this firmware a voice assistant can reach without extra configuration, and that is worth stating plainly because the sit/stand buttons look like the obvious target.

HA's local sentence matcher only recognises entities in a fixed set of domains. Measured against home-assistant-intents (en, 851 sentence templates): every template with a {name} slot carries a requires_context.domain allow-list, and the union of every domain any template can target is

text
binary_sensor climate cover fan input_boolean lawn_mower light lock
media_player person scene script sensor switch todo vacuum valve weather

button is not in that set. So button.<device>_sit / _stand cannot be matched by any phrasing — and entity aliases do not help, because an alias only supplies candidate text for a {name} slot and there is no {name} template that accepts a button.

What this costs depends on your pipeline. With a purely local assistant, the buttons are simply unreachable by voice. With an LLM conversation agent and "prefer handling commands locally" enabled, they still work — but they miss the local matcher every time and fall through to the LLM, so every "sit"/"stand" pays a full cloud round trip while cover commands answer instantly. That asymmetry is easy to misread as a problem with the desk, the ESP32 or the WiFi; it is none of those.

Two ways to get fast sit/stand:

  • Use the cover. "raise / lower the standing desk" works with no extra config. Note the cover's ends are the ends of travel, so "lower" goes to the bottom of travel, which may be below your preferred sitting height.
  • Add custom sentences mapping your own phrases to an intent_script that presses the buttons. This keeps one definition of "sit" and "stand" — in this firmware, next to the duty-cycle watchdog — and preserves a non-travel-end sitting height.

Copy wifi.yaml.example to wifi.yaml first.

Analysis tools

Everything runs on a capture exported to CSV (sigrok-cli -i cap.sr -O csv > cap.csv):

toolwhat it does
probe.pyper-channel characterization: idle level, edges, implied baud from narrowest pulse
timeline.pybutton lines as a deglitched state timeline
dataline.pybit-time histogram and exhaustive UART framing search
messages.pypacket segmentation + solved 9N1 decode — the useful one
viewer.pyrenders a capture as a self-contained interactive HTML waveform viewer
make_test_capture.pysynthetic capture for self-testing the toolchain

Safety notes

  • The control box is mains-fed and sealed. Nothing here requires opening it, and you shouldn't.
  • Disconnect mains before plugging/unplugging anything on the handset cable — the box enters a re-commissioning state (down-only until re-homed) if the handset connection glitches while powered.
  • Respect the 2/18 duty cycle. A "nudge every N minutes" automation will overheat the box; preset seeks lasting seconds are fine.
  • If the box loses its reference: drive to the bottom, hold both panel buttons 5 s, drive to the top, hold both 5 s (the §4.2 commissioning procedure — it works with a plain up/down panel).

Applies to

Verified on: ErgoSwiss "Steuerung Compact-3-eco 110V V290" (P/N 124.30290), LOGICDATA type COMPACT-e-3-VAL-ES-US, revision 3/1.9.14, US 120 V. The VAL firmware is hydraulic-specific ("only for hydraulic lift system — modified parameters"). Other COMPACT-e-3 variants likely match; measure before you trust — probe.py will tell you in one capture.

License

MIT. If this saved you a €63 handset or an afternoon with a logic analyzer, pay it forward.

Parts

17
QtyValueFootprintReferences
322kResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_HorizontalR5, R6, R7
2100kResistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_HorizontalR3, R4
2330Resistor_THT:R_Axial_DIN0207_L6.3mm_D2.5mm_P7.62mm_HorizontalR1, R2
2DIN harnessTerminalBlock_Phoenix:TerminalBlock_Phoenix_MKDS-1,5-7_1x07_P5.00mm_HorizontalJ1, J4
2ESP32 DevKit V1Connector_PinSocket_2.54mm:PinSocket_1x15_P2.54mm_VerticalJ2, J3
2PC817Package_DIP:DIP-4_W7.62mmU1, U2
1TestPoint_SquareTestPoint:TestPoint_Pad_2.0x2.0mmTP1

The 14 most common of 17 parts.

Questions

How big is the Desk PCB?

It measures 94 × 60 mm and has 2 copper layers, 1.6 mm thick.

What parts does the Desk use?

17 parts. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.

Where can I download the Desk KiCad files?

From the scottdube/deskhack repository on GitHub, at the commit this page was made from: the board is kicad/desk.kicad_pcb and its schematic kicad/desk.kicad_sch. PCBKit opens the board in the browser, with nothing to install.

Can I use the Desk design in my own project?

Its repository publishes it under the MIT licence: you may reuse it on that licence's terms (keep the author's notice, and for a copyleft licence share your changes alike).

Similar boards

  • 2 layers50 × 35 mmServo power, the printed circuit board in 3D

    Servo power

    earthtojake/text-to-cad

    Design physical products with AI

    17 parts26kMIT
  • 2 layers139.7 × 62.2 mmBreakout 60pin EEC IV, the printed circuit board in 3D

    Breakout 60pin EEC IV

    rusefi/rusefi

    rusefi - GPL internal combustion engine control unit

    17 parts1.2k
  • 2 layers29.2 × 55.9 mmDeniseadapter, the printed circuit board in 3D

    Deniseadapter

    hoglet67/RGBtoHDMI

    Bare-metal Raspberry Pi project that provides pixel-perfect sampling of Retro Computer RGB/YUV video and conversion to HDMI

    17 parts1kGPL-3.0
  • 2 layers24.5 × 36.5 mmCluster, the printed circuit board in 3D

    Cluster

    JesusFreke/lalboard

    A 3D-printed keyboard inspired by the DataHand

    17 parts993
  • 2 layers56.3 × 55.7 mmStarNecklace, the printed circuit board in 3D

    StarNecklace

    hackclub/OnBoard

    💡 Join 1,000 teenagers and make your first PCB with a $100 grant!

    17 parts915
  • 2 layers25 × 25 mmSmallEncoder, the printed circuit board in 3D

    SmallEncoder

    DDeGonge/OS-ARM

    This arm is not industrial and it will not be fast, but that is not my goal. But it will hopefully be a very nice looking arm fully capable of 6dof motion, cheap enough to keep it accessable but capable enough to…

    17 parts498MIT
  • 2 layers38.9 × 16.1 mmAmoeba spc, the printed circuit board in 3D

    Amoeba spc

    mtl/keyboard-pcbs

    Keyboard PCBs

    17 parts485
  • 2 layers36 × 51 mmABSIS ALE, the printed circuit board in 3D

    ABSIS ALE

    jrsteensen/OpenHornet

    OpenHornet 1:1 F/A-18C Simulator Repository

    17 parts431