Reverse-engineering the IKEA IDÅSEN desk, in Go

gobluetoothreverse-engineeringidasen

My IDÅSEN desk came with a LINAK DPG1C panel and a phone app I didn’t want to rely on. There are Go projects that already control this desk, but I wanted to figure out the protocol myself and write it up. This is a log of that process, compressed.

The result is a small Go CLI, desk, with height, up/down/stop, move 1.05, sit/stand, and save. It uses tinygo.org/x/bluetooth on Linux, which talks to the desk through BlueZ.

Pairing and the sleep problem

Pair the desk once with bluetoothctl while holding its connect button. I had some issues with connecting to the desk, but I think i just made some mistakes with the app. I thought it needed physical action to wake it for input, but that doesn’t seem to be the case.

The GATT layout

A quick dump of the services showed the desk’s own name (Desk 8742) plus four LINAK services. The interesting part:

servicecharacteristicrole
99fa000199fa0002control: where move commands go
99fa000199fa0003read/notify: errors
99fa001099fa0011read/write/notify: DPG config commands
99fa002099fa0021read/notify: height and speed
99fa003099fa0031write: also a command sink

The character flags (from BlueZ’s GattCharacteristic1.Flags) told me which ones were writable vs notifiable, which narrowed the search.

Height announcements

99fa0021 is the service that communicates with 4-byte packets. The desk only announces changes, so I had to move it with the physical buttons to capture data. Once I had a stream the format fell out:

[height_lo][height_hi][speed_lo][speed_hi]   little-endian u16 pairs
  • height is a 16-bit value, monotonic with the desk’s movement
  • speed is signed: 0 when stopped, positive up, negative down
  • the magnitude of the speed is the same both directions, which is a nice sanity check

A calibration run: drive to the top and bottom. Top was 6500, bottom was 0. The IDÅSEN travels 62 to 127 cm, which maps nicely in the range the calibration found. meters = (6200 + raw)/10000.

Sending movement

The first few attempts wrote single bytes (0x01, 0x47) to 99fa0002 and nothing happened. The commands turned out to be two bytes:

write 99fa0002: 47 00   # move up
write 99fa0002: 46 00   # move down
write 99fa0002: ff 00   # stop

Each command alone moves the desk for about a second before it stops itself, so continuous movement means re-sending the same command roughly every 400 ms. The app does that while watching the announced height, and sends ff 00 the moment it crosses the target, so a full move comes out as one smooth motion. 99fa0011 is for config commands and they need a 7f header (7f <command> 00 for a read, 7f <command> 80 <data> to write), which is how the memory positions are programmed.

I did not discover 47/46/ff purely from the desk. The reverse-engineering community had already mapped these controllers, and I used that as a source of hypotheses, then confirmed the two-byte framing live. The height and speed decode, the unit calibration, and the sleep behavior I got from the desk itself.

The app

The app is small: a config file with the MAC and two named positions, a decode function I wrote tests for using the real captured packets, and a thin BLE layer. move subscribes to 99fa0021 and stops the moment it crosses the target position.

Config at ~/.config/desk/config.yaml:

mac_address: "E4:D2:80:3C:49:36"
positions:
  sit: 0.72
  stand: 1.05