Skip to content

Commit cfab5a7

Browse files
committed
Add config options and improve accuracy of final height
1 parent aeafdf1 commit cfab5a7

File tree

6 files changed

+371
-45
lines changed

6 files changed

+371
-45
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
ref/
22
venv/
3+
config.yaml

README.md

+82-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,83 @@
11
# idasen-controller
2-
Script to control the Ikea Idasen standing desk
2+
3+
The Idasen is a Linak standing desk sold by Ikea. It can be controlled by a physical switch on the desk or via bluetooth using an phone app. This is a script to control the Idasen via bluetooth from a computer.
4+
5+
## Set up
6+
7+
### Prerequisites
8+
9+
The desk should be connected and paired to the computer.
10+
11+
### Install
12+
13+
Install the Python requirements found in `requirements.text`.
14+
15+
### Configuration
16+
17+
Configuration can either be provided with a file, or via command line arguments. Use `--help` to see the command line arguments help. Edit `config.yaml` if you prefer your config to be in a file.
18+
19+
Config options:
20+
21+
- `mac_address` - The MAC address of the desk. This is required.
22+
- `stand_height` - The standing height from the floor of the desk in mm. Default `1040`.
23+
- `sit_height` - The standing height from the floor of the desk in mm. Default `683`.
24+
- `adapter_name` - The adapter name for the bluetooth adapter to use for the connection. Default `hci0`
25+
26+
Device MAC addresses can be found using `blueoothctl` and blueooth adapter names can be found with `hcitool dev` on linux.
27+
28+
## Usage
29+
30+
### Command Line
31+
32+
To print the current desk height:
33+
34+
```
35+
python3 main.py
36+
37+
```
38+
39+
Assuming the config file is populated to move the desk to standing position:
40+
41+
```
42+
python3 main.py --stand
43+
```
44+
45+
Assuming the config file is populated to move the desk to sitting position:
46+
47+
```
48+
python3 main.py --sit
49+
```
50+
51+
### Albert Launcher
52+
53+
I use the [albert](https://github.com/albertlauncher/albert) launcher along with two `.desktop` files to allow me to trigger this script from the launcher. An example of a desktop file for this is:
54+
55+
```
56+
[Desktop Entry]
57+
Name=Desk - Sit
58+
Exec=/home/user/idasen-controller/venv/bin/python /home/user/idasen-controller/main.py --sit
59+
Icon=/home/user/idasen-controller/sit-icon.png
60+
Type=Application
61+
Comment=Lower desk to sitting height.
62+
63+
```
64+
65+
## Desk Internals
66+
67+
### Connection and Commands
68+
69+
Connecting and pairing can be done by any bluetooth device and there is no authentication. Once connected the desk communicates using Bluetooth LE, using the GATT protocol. GATT is quite complex and I do not understand much of it but the useful bit is that the desk advertises some `characteristics` which are addresses that bytes can be written to and read from. There's various other things like `services` and `descriptors` but they were not relevant to getting this working.
70+
71+
Python has several packages available for communicating over GATT so the only tricky bit is working out what each of the characteristics do and what data they want. It seems like in general they're expecting quite simple data to be exchanged.
72+
73+
The desk is from Ikea but it is a rebranded Linak device, and Linak publish an app to control it. I was able to examine the app to find out missing information. This included mapping the characteristic UUIDs to functionality (the two important ones being the characteristic that accepts commands to control the desk, and the characteristic that broadcasts the current height of the desk), and also finding out the command codes and the format they needed to be in.
74+
75+
For example to move the desk up you encode `71` into bytes as an unsigned little endian short and write that to the characteristic identified by the UUID `99fa0002-338a-1024-8a49-009c0215f78a`. The other command codes are similar short numbers. For some reason there is another characteristic (reference input) that accepts up/down/stop commands but it requires signed little endian shorts. I don't understand why it is like this.
76+
77+
### Behaviour
78+
79+
Sending move commands to the desk seems to make the motors run for about one second in the desired direction. If another move command is sent within that second then the motion continues with no slowing or stopping. If no move command is recieved in that second then the motor slows down towards the end and then stops. If you send a move command late, then there will some stuttering as the desk may have already started to slow the motors. You can stop the motion part way through by sending a stop command though it sometimes does not respond immediately. As the desk moves it sends notifications of the current height to a characteristic. This can be monitored to work out when to stop moving, but it also seems to be a little bit slow and the final notified value is often not the same as the actual final value if a measuremment is made at rest.
80+
81+
The height values the desk provides are in 10ths of a millimetre, and correspond to the height above the desks lowest setting i.e. if you lower the desk as far as it will go then the desk will report its height as being zero. The minimum raw height value is zero and the maximum height is 6500. This corresponds to a range of 620mm to 1270mm off the floor.
82+
83+
The desk appears to be pretty good at not doing anything stupid if you send it stupid commands. It won't try to go below the minimum height or above the maximum height and it doesn't do much if you send lots of commands in quick succession. The usual hit detection works, and it will stop moving if it hits an object and will not respond to further commands until a stop command is sent.

config.yaml

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
mac_address: AA:AA:AA:AA:AA:AA
2+
stand_height: 1040
3+
sit_height: 683
4+
adapter_name: hci0

main.py

+168-44
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,103 @@
1+
#!python3
12
import gatt
23
import struct
34
import argparse
5+
import os
6+
import yaml
7+
8+
UUID_HEIGHT = '99fa0021-338a-1024-8a49-009c0215f78a'
9+
UUID_COMMAND = '99fa0002-338a-1024-8a49-009c0215f78a'
10+
UUID_REFERENCE_INPUT = '99fa0031-338a-1024-8a49-009c0215f78a'
11+
12+
COMMAND_UP = struct.pack("<H", 71)
13+
COMMAND_DOWN = struct.pack("<H", 70)
14+
COMMAND_STOP = struct.pack("<H", 255)
15+
16+
COMMAND_REFERENCE_INPUT_STOP = struct.pack("<H", 32769)
17+
COMMAND_REFERENCE_INPUT_UP = struct.pack("<H", 32768)
18+
COMMAND_REFERENCE_INPUT_DOWN = struct.pack("<H", 32767)
19+
20+
# Height of the desk at it's lowest (in mm)
21+
# I assume this is the same for all Idasen desks
22+
BASE_HEIGHT = 620
23+
MAX_HEIGHT = 1270 # 6500
24+
25+
# Default config
26+
config = {
27+
"mac_address": None,
28+
"stand_height": BASE_HEIGHT + 420,
29+
"sit_height": BASE_HEIGHT + 63,
30+
"adapter_name": 'hci0',
31+
"sit": False,
32+
"stand": False
33+
}
34+
35+
# Overwrite from config.yaml
36+
config_file = {}
37+
config_file_path = os.path.join(os.path.dirname(
38+
os.path.realpath(__file__)), 'config.yaml')
39+
if (config_file_path):
40+
with open(config_file_path, 'r') as stream:
41+
try:
42+
config_file = yaml.safe_load(stream)
43+
except yaml.YAMLError as exc:
44+
print("Reading config.yaml failed")
45+
exit(1)
46+
config.update(config_file)
47+
48+
# Overwrite from command line args
49+
parser = argparse.ArgumentParser(description='')
50+
parser.add_argument('--mac-address', dest='mac_address',
51+
type=str, help="Mac address of the Idasen desk")
52+
parser.add_argument('--stand-height', dest='stand_height', type=int,
53+
help="The height the desk should be at when standing")
54+
parser.add_argument('--sit-height', dest='sit_height', type=int,
55+
help="The height the desk should be at when sitting")
56+
parser.add_argument('--adapter', dest='adapter_name', type=str,
57+
help="The bluetooth adapter device name")
58+
parser.add_argument('--sit', dest='sit', action='store_true',
59+
help="Move the desk to sitting height")
60+
parser.add_argument('--stand', dest='stand', action='store_true',
61+
help="Move the desk to standing height")
462

63+
args = {k: v for k, v in vars(parser.parse_args()).items() if v is not None}
64+
config.update(args)
565

6-
STAND_HEIGHT = 4402
7-
SIT_HEIGHT = 630
66+
if not config['mac_address']:
67+
parser.error("Mac address must be provided")
868

9-
parser = argparse.ArgumentParser(description='')
10-
parser.add_argument('--stand', dest='stand', action='store_true')
11-
parser.set_defaults(stand=False)
12-
args = parser.parse_args()
69+
if config['sit'] and config['stand']:
70+
parser.error("Only one of --sit and --stand can be used")
71+
72+
if config['sit_height'] >= config['stand_height']:
73+
parser.error("Sit height must be less than stand height")
74+
75+
if config['sit_height'] < BASE_HEIGHT:
76+
parser.error("Sit height must be greater than {}".format(BASE_HEIGHT))
77+
78+
if config['stand_height'] > MAX_HEIGHT:
79+
parser.error("Stand height must be less than {}".format(MAX_HEIGHT))
80+
81+
82+
def mmToRaw(mm):
83+
return (mm - BASE_HEIGHT) * 10
84+
85+
86+
def rawToMM(raw):
87+
return (raw / 10) + BASE_HEIGHT
88+
89+
90+
config['stand_height_raw'] = mmToRaw(config['stand_height'])
91+
config['sit_height_raw'] = mmToRaw(config['sit_height'])
1392

14-
# Pick your adapter
15-
manager = gatt.DeviceManager(adapter_name='hci0')
1693

17-
class AnyDevice(gatt.Device):
18-
def __init__(self, mac_address, manager):
94+
class Desk(gatt.Device):
95+
def __init__(self, mac_address, manager, config):
96+
self.config = config
1997
self.direction = None
20-
self.target = None
2198
self.height = None
99+
self.target = None
100+
self.count = 0
22101
super().__init__(mac_address, manager)
23102

24103
def connect_succeeded(self):
@@ -36,58 +115,103 @@ def disconnect_succeeded(self):
36115
def services_resolved(self):
37116
super().services_resolved()
38117

118+
if self.config['sit']:
119+
self.target = self.config['sit_height_raw']
120+
if self.config['stand']:
121+
self.target = self.config['stand_height_raw']
122+
39123
for service in self.services:
40124
for characteristic in service.characteristics:
41-
if characteristic.uuid == '99fa0021-338a-1024-8a49-009c0215f78a':
42-
# raw = characteristic.read_value()
43-
# print("Inital height: {}".format(int.from_bytes(bytes([int(raw[0])]) + bytes([int(raw[1])]), 'little')))
44-
characteristic.enable_notifications()
45-
characteristic.read_value()
46-
if characteristic.uuid == '99fa0002-338a-1024-8a49-009c0215f78a':
47-
self.command = characteristic
125+
if characteristic.uuid == UUID_HEIGHT:
126+
self.height_characteristic = characteristic
127+
self.height_characteristic.enable_notifications()
128+
# Reading the value triggers self.characteristic_value_updated
129+
self.height_characteristic.read_value()
130+
if characteristic.uuid == UUID_COMMAND:
131+
self.command_characteristic = characteristic
132+
if characteristic.uuid == UUID_REFERENCE_INPUT:
133+
self.reference_input_characteristic = characteristic
48134

49135
def characteristic_value_updated(self, characteristic, value):
50-
height, speed = struct.unpack("<HH", value)
51-
print("Height change: {}".format(height))
52-
self.height = height
53-
#if self.has_reached_target():
54-
#self.command.write_value(struct.pack("<H", 255))
55-
if not self.direction:
56-
self.move_to_target()
136+
if characteristic.uuid == UUID_HEIGHT:
137+
height, speed = struct.unpack("<HH", value)
138+
self.count += 1
139+
self.height = height
140+
141+
# No target specified so print current height
142+
if not self.target:
143+
print("Current height: {}mm".format(rawToMM(height)))
144+
self.stop()
145+
return
146+
147+
# Initialise by setting the movement direction and asking to send
148+
# move commands
149+
if not self.direction:
150+
print("Initial height: {}mm".format(rawToMM(height)))
151+
self.direction = "UP" if self.target > self.height else "DOWN"
152+
self.move_to_target()
153+
154+
# If already moving then stop if we have reached the target
155+
if self.has_reached_target():
156+
print("Stopping at height: {}mm (target: {}mm)".format(
157+
rawToMM(height), rawToMM(self.target)))
158+
self.stop()
159+
# Or resend the movement command if we have not yet reached the
160+
# target.
161+
# Each movement command seems to run the desk motors for about 1
162+
# second if uninterrupted and the height value is updated about 16
163+
# times.
164+
# Resending the command on the 12th update seems a good balance
165+
# between helping to avoid overshoots and preventing stutterinhg
166+
# (the motor seems to slow if no new move command has been sent)
167+
elif self.count == 12:
168+
self.count = 0
169+
self.move_to_target()
57170

58171
def characteristic_write_value_succeeded(self, characteristic):
59-
self.move_to_target()
172+
if characteristic.uuid == UUID_COMMAND and self.target:
173+
pass
174+
if characteristic.uuid == UUID_REFERENCE_INPUT:
175+
pass
60176

61177
def characteristic_write_value_failed(self, characteristic, error):
62178
print("Error ", error)
179+
self.stop()
63180

64181
def has_reached_target(self):
65-
return (self.direction == "DOWN" and self.height <= self.target) or (self.direction == "UP" and self.height >= self.target)
66-
67-
def set_target(self, target):
68-
self.target = target
69-
self.direction = None
182+
# The notified height values seem a bit behind so try to stop before
183+
# reaching the target value to prevent overshooting
184+
return (abs(self.height - self.target) <= 20)
70185

71186
def move_to_target(self):
72-
if not self.direction:
73-
self.direction = "UP" if self.target > self.height else "DOWN"
74187
if self.has_reached_target():
75-
self.command.write_value(struct.pack("<H", 255))
76-
manager.stop()
188+
return
77189
elif self.direction == "DOWN" and self.height > self.target:
78-
self.command.write_value(struct.pack("<H", 70))
190+
self.move_down()
79191
elif self.direction == "UP" and self.height < self.target:
80-
self.command.write_value(struct.pack("<H", 71))
192+
self.move_up()
81193

82-
device = AnyDevice(mac_address='E8:5B:5B:24:22:E4', manager=manager)
83-
device.connect()
84-
if args.stand:
85-
device.set_target(STAND_HEIGHT)
86-
else:
87-
device.set_target(SIT_HEIGHT)
194+
def move_up(self):
195+
self.command_characteristic.write_value(COMMAND_UP)
196+
197+
def move_down(self):
198+
self.command_characteristic.write_value(COMMAND_DOWN)
199+
200+
def stop(self):
201+
# This emulates the behaviour of the app. Stop commands are sent to both
202+
# Reference Input and Command characteristics.
203+
self.command_characteristic.write_value(COMMAND_STOP)
204+
self.reference_input_characteristic.write_value(
205+
COMMAND_REFERENCE_INPUT_STOP)
206+
manager.stop()
88207

208+
209+
manager = gatt.DeviceManager(adapter_name=config['adapter_name'])
210+
device = Desk(mac_address=config['mac_address'],
211+
manager=manager, config=config)
212+
device.connect()
89213
try:
90214
manager.run()
91215
except KeyboardInterrupt:
216+
device.stop()
92217
manager.stop()
93-
print("\rExiting")

0 commit comments

Comments
 (0)