Reverse-engineering the IKEA IDÅSEN desk, in Go
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:
| service | characteristic | role |
|---|---|---|
99fa0001 | 99fa0002 | control: where move commands go |
99fa0001 | 99fa0003 | read/notify: errors |
99fa0010 | 99fa0011 | read/write/notify: DPG config commands |
99fa0020 | 99fa0021 | read/notify: height and speed |
99fa0030 | 99fa0031 | write: 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