1#!/usr/bin/env python3

2#

3# Copyright 2019 Canonical Ltd.

4# Written by:

5# Maciej Kisielewski <maciej.kisielewski@canonical.com>

6#

7# This is free software: you can redistribute it and/or modify

8# it under the terms of the GNU General Public License version 3,

9# as published by the Free Software Foundation.

10#

11# This file is distributed in the hope that it will be useful,

12# but WITHOUT ANY WARRANTY; without even the implied warranty of

13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the

14# GNU General Public License for more details.

15#

16# You should have received a copy of the GNU General Public License

17# along with this file. If not, see <http://www.gnu.org/licenses/>.

18"""

19 This program tests whether the system properly reacts to hotkey presses.

20

21 To inject the keypresses /dev/input/ devices are used.

22

23 For particular scenarios see "check_*" methods of the HotKeyTesting class.

24

25 TODO: Create one virtual input device using uinput.

26 Heuristic for picking the device to write to is not optimal, and on some

27 systems the fake keypresses are not registered. Having "own" input

28 device could improve reliability/portability.

29"""

30

31 import contextlib

32 import datetime

33 import enum

34 import os

35 import re

36 import struct

37 import subprocess

38 import sys

39 import time

40

41

42 class KeyCodes(enum.Enum):

43 KEY_RESERVED = 0

44 KEY_ESC = 1

45 KEY_1 = 2

46 KEY_2 = 3

47 KEY_3 = 4

48 KEY_4 = 5

49 KEY_5 = 6

50 KEY_6 = 7

51 KEY_7 = 8

52 KEY_8 = 9

53 KEY_9 = 10

54 KEY_0 = 11

55 KEY_MINUS = 12

56 KEY_EQUAL = 13

57 KEY_BACKSPACE = 14

58 KEY_TAB = 15

59 KEY_Q = 16

60 KEY_W = 17

61 KEY_E = 18

62 KEY_R = 19

63 KEY_T = 20

64 KEY_Y = 21

65 KEY_U = 22

66 KEY_I = 23

67 KEY_O = 24

68 KEY_P = 25

69 KEY_LEFTBRACE = 26

70 KEY_RIGHTBRACE = 27

71 KEY_ENTER = 28

72 KEY_LEFTCTRL = 29

73 KEY_A = 30

74 KEY_S = 31

75 KEY_D = 32

76 KEY_F = 33

77 KEY_G = 34

78 KEY_H = 35

79 KEY_J = 36

80 KEY_K = 37

81 KEY_L = 38

82 KEY_SEMICOLON = 39

83 KEY_APOSTROPHE = 40

84 KEY_GRAVE = 41

85 KEY_LEFTSHIFT = 42

86 KEY_BACKSLASH = 43

87 KEY_Z = 44

88 KEY_X = 45

89 KEY_C = 46

90 KEY_V = 47

91 KEY_B = 48

92 KEY_N = 49

93 KEY_M = 50

94 KEY_COMMA = 51

95 KEY_DOT = 52

96 KEY_SLASH = 53

97 KEY_RIGHTSHIFT = 54

98 KEY_KPASTERISK = 55

99 KEY_LEFTALT = 56

100 KEY_SPACE = 57

101 KEY_CAPSLOCK = 58

102 KEY_F1 = 59

103 KEY_F2 = 60

104 KEY_F3 = 61

105 KEY_F4 = 62

106 KEY_F5 = 63

107 KEY_F6 = 64

108 KEY_F7 = 65

109 KEY_F8 = 66

110 KEY_F9 = 67

111 KEY_F10 = 68

112 KEY_NUMLOCK = 69

113 KEY_SCROLLLOCK = 70

114 KEY_KP7 = 71

115 KEY_KP8 = 72

116 KEY_KP9 = 73

117 KEY_KPMINUS = 74

118 KEY_KP4 = 75

119 KEY_KP5 = 76

120 KEY_KP6 = 77

121 KEY_KPPLUS = 78

122 KEY_KP1 = 79

123 KEY_KP2 = 80

124 KEY_KP3 = 81

125 KEY_KP0 = 82

126 KEY_KPDOT = 83

127 KEY_ZENKAKUHANKAKU = 85

128 KEY_102ND = 86

129 KEY_F11 = 87

130 KEY_F12 = 88

131 KEY_RO = 89

132 KEY_KATAKANA = 90

133 KEY_HIRAGANA = 91

134 KEY_HENKAN = 92

135 KEY_KATAKANAHIRAGANA = 93

136 KEY_MUHENKAN = 94

137 KEY_KPJPCOMMA = 95

138 KEY_KPENTER = 96

139 KEY_RIGHTCTRL = 97

140 KEY_KPSLASH = 98

141 KEY_SYSRQ = 99

142 KEY_RIGHTALT = 100

143 KEY_LINEFEED = 101

144 KEY_HOME = 102

145 KEY_UP = 103

146 KEY_PAGEUP = 104

147 KEY_LEFT = 105

148 KEY_RIGHT = 106

149 KEY_END = 107

150 KEY_DOWN = 108

151 KEY_PAGEDOWN = 109

152 KEY_INSERT = 110

153 KEY_DELETE = 111

154 KEY_MACRO = 112

155 KEY_MUTE = 113

156 KEY_VOLUMEDOWN = 114

157 KEY_VOLUMEUP = 115

158 KEY_POWER = 116 # SC System Power Down

159 KEY_KPEQUAL = 117

160 KEY_KPPLUSMINUS = 118

161 KEY_PAUSE = 119

162 KEY_SCALE = 120 # AL Compiz Scale (Expose)

163

164 KEY_KPCOMMA = 121

165 KEY_HANGEUL = 122

166 KEY_HANGUEL = KEY_HANGEUL

167 KEY_HANJA = 123

168 KEY_YEN = 124

169 KEY_LEFTMETA = 125

170 KEY_RIGHTMETA = 126

171 KEY_COMPOSE = 127

172

173 KEY_STOP = 128 # AC Stop

174 KEY_AGAIN = 129

175 KEY_PROPS = 130 # AC Properties

176 KEY_UNDO = 131 # AC Undo

177 KEY_FRONT = 132

178 KEY_COPY = 133 # AC Copy

179 KEY_OPEN = 134 # AC Open

180 KEY_PASTE = 135 # AC Paste

181 KEY_FIND = 136 # AC Search

182 KEY_CUT = 137 # AC Cut

183 KEY_HELP = 138 # AL Integrated Help Center

184 KEY_MENU = 139 # Menu (show menu)

185 KEY_CALC = 140 # AL Calculator

186 KEY_SETUP = 141

187 KEY_SLEEP = 142 # SC System Sleep

188 KEY_WAKEUP = 143 # System Wake Up

189 KEY_FILE = 144 # AL Local Machine Browser

190 KEY_SENDFILE = 145

191 KEY_DELETEFILE = 146

192 KEY_XFER = 147

193 KEY_PROG1 = 148

194 KEY_PROG2 = 149

195 KEY_WWW = 150 # AL Internet Browser

196 KEY_MSDOS = 151

197 KEY_COFFEE = 152 # AL Terminal Lock/Screensaver

198 KEY_SCREENLOCK = KEY_COFFEE

199 KEY_ROTATE_DISPLAY = 153 # Display orientation for e.g. tablets

200 KEY_DIRECTION = KEY_ROTATE_DISPLAY

201 KEY_CYCLEWINDOWS = 154

202 KEY_MAIL = 155

203 KEY_BOOKMARKS = 156 # AC Bookmarks

204 KEY_COMPUTER = 157

205 KEY_BACK = 158 # AC Back

206 KEY_FORWARD = 159 # AC Forward

207 KEY_CLOSECD = 160

208 KEY_EJECTCD = 161

209 KEY_EJECTCLOSECD = 162

210 KEY_NEXTSONG = 163

211 KEY_PLAYPAUSE = 164

212 KEY_PREVIOUSSONG = 165

213 KEY_STOPCD = 166

214 KEY_RECORD = 167

215 KEY_REWIND = 168

216 KEY_PHONE = 169 # Media Select Telephone

217 KEY_ISO = 170

218 KEY_CONFIG = 171 # AL Consumer Control Configuration

219 KEY_HOMEPAGE = 172 # AC Home

220 KEY_REFRESH = 173 # AC Refresh

221 KEY_EXIT = 174 # AC Exit

222 KEY_MOVE = 175

223 KEY_EDIT = 176

224 KEY_SCROLLUP = 177

225 KEY_SCROLLDOWN = 178

226 KEY_KPLEFTPAREN = 179

227 KEY_KPRIGHTPAREN = 180

228 KEY_NEW = 181 # AC New

229 KEY_REDO = 182 # AC Redo/Repeat

230

231 KEY_F13 = 183

232 KEY_F14 = 184

233 KEY_F15 = 185

234 KEY_F16 = 186

235 KEY_F17 = 187

236 KEY_F18 = 188

237 KEY_F19 = 189

238 KEY_F20 = 190

239 KEY_F21 = 191

240 KEY_F22 = 192

241 KEY_F23 = 193

242 KEY_F24 = 194

243

244 KEY_PLAYCD = 200

245 KEY_PAUSECD = 201

246 KEY_PROG3 = 202

247 KEY_PROG4 = 203

248 KEY_DASHBOARD = 204 # AL Dashboard

249 KEY_SUSPEND = 205

250 KEY_CLOSE = 206 # AC Close

251 KEY_PLAY = 207

252 KEY_FASTFORWARD = 208

253 KEY_BASSBOOST = 209

254 KEY_PRINT = 210 # AC Print

255 KEY_HP = 211

256 KEY_CAMERA = 212

257 KEY_SOUND = 213

258 KEY_QUESTION = 214

259 KEY_EMAIL = 215

260 KEY_CHAT = 216

261 KEY_SEARCH = 217

262 KEY_CONNECT = 218

263 KEY_FINANCE = 219 # AL Checkbook/Finance

264 KEY_SPORT = 220

265 KEY_SHOP = 221

266 KEY_ALTERASE = 222

267 KEY_CANCEL = 223 # AC Cancel

268 KEY_BRIGHTNESSDOWN = 224

269 KEY_BRIGHTNESSUP = 225

270 KEY_MEDIA = 226

271 # Cycle between available video outputs (Monitor/LCD/TV-out/etc)

272 KEY_SWITCHVIDEOMODE = 227

273 KEY_KBDILLUMTOGGLE = 228

274 KEY_KBDILLUMDOWN = 229

275 KEY_KBDILLUMUP = 230

276 KEY_SEND = 231 # AC Send

277 KEY_REPLY = 232 # AC Reply

278 KEY_FORWARDMAIL = 233 # AC Forward Msg

279 KEY_SAVE = 234 # AC Save

280 KEY_DOCUMENTS = 235

281 KEY_BATTERY = 236

282 KEY_BLUETOOTH = 237

283 KEY_WLAN = 238

284 KEY_UWB = 239

285 KEY_UNKNOWN = 240

286 KEY_VIDEO_NEXT = 241 # drive next video source

287 KEY_VIDEO_PREV = 242 # drive previous video source

288 KEY_BRIGHTNESS_CYCLE = 243 # brightness up, after max is min

289 # Set Auto Brightness = manual brightness control is off, rely on ambient

290 KEY_BRIGHTNESS_AUTO = 244

291 KEY_BRIGHTNESS_ZERO = KEY_BRIGHTNESS_AUTO

292 KEY_DISPLAY_OFF = 245 # display device to off state

293 KEY_WWAN = 246 # Wireless WAN (LTE, UMTS, GSM, etc.)

294 KEY_WIMAX = KEY_WWAN

295 KEY_RFKILL = 247 # Key that controls all radios

296 KEY_MICMUTE = 248 # Mute / unmute the microphone

297

298 def from_char(c):

299 obvious_keys = {

300 "/": KeyCodes.KEY_SLASH,

301 " ": KeyCodes.KEY_SPACE,

302 "-": KeyCodes.KEY_MINUS,

303 ".": KeyCodes.KEY_DOT,

304 }

305 if c in obvious_keys.keys():

306 return obvious_keys[c]

307 keycode_name = "KEY_{}".format(c.upper())

308 try:

309 return KeyCodes[keycode_name]

310 except KeyError:

311 raise SystemExit(

312 "One does not simply convert {} to a keycode".format(c)

313 )

314

315

316 class VolumeChange:

317 def __init__(self):

318 self.before = 0

319 self.after = 0

320 self.mute_before = None

321 self.mute_after = None

322

323

324 class FauxKeyboard:

325 def __init__(self):

326 base = "/dev/input/by-path"

327 all_devs = [

328 os.path.join(base, dev) for dev in sorted(os.listdir(base))

329 ]

330 kbd_devs = [dev for dev in all_devs if dev.endswith("kbd")]

331 event_devs = [dev for dev in all_devs if dev.endswith("event-mouse")]

332 self.kb_dev_file = None

333 self.event_dev_file = None

334 if not kbd_devs:

335 raise SystemExit(

336 "Could not connect to existing keyboard connection. "

337 "Is keyboard plugged in?"

338 )

339 if not event_devs:

340 raise SystemExit(

341 "Could not connect to existing mouse connection. "

342 "Is mouse plugged in?"

343 )

344 self.kb_dev_file = open(kbd_devs[0], "wb")

345 self.event_dev_file = open(event_devs[0], "wb")

346 self.event_struct = struct.Struct("llHHi")

347

348 def __del__(self):

349 if self.kb_dev_file:

350 self.kb_dev_file.close()

351 if self.event_dev_file:

352 self.event_dev_file.close()

353

354 def type_text(self, text):

355 for c in text:

356 if c == ">":

357 self.press_key(KeyCodes.KEY_DOT, {"shift"})

358 continue

359 modifiers = {"shift"} if c.isupper() else set()

360 self.press_key(KeyCodes.from_char(c), modifiers)

361

362 def press_key(self, key_code, modifiers=set(), repetitions=1, delay=0.05):

363 # simple key press actions contains four events:

364 # EV_MSC, MSC_SCAN, {KEY_CODE}

365 # EV_KEY, {KEY_CODE}, 1

366 # EV_SYN, 0, 0

367 # EV_KEY, {KEY_CODE}, 0

368 # XXX: ATM there's no distinction between left and right modifiers

369 assert repetitions >= 0

370 # sending "special" codes (like media control ones) to a general kbd

371 # device doesn't work, so we have to send them to the event-mouse one

372 SPECIAL_CODES = [

373 KeyCodes.KEY_PLAYPAUSE,

374 ]

375 use_special = key_code in SPECIAL_CODES

376 while repetitions:

377 if not modifiers.issubset({"alt", "ctrl", "shift", "meta"}):

378 raise SystemExit("Unknown modifier")

379 if isinstance(key_code, KeyCodes):

380 key_code = key_code.value

381 data = bytes()

382 data += self.event_struct.pack(0, 0, 4, 4, key_code)

383 for mod in modifiers:

384 mod_code = KeyCodes["KEY_LEFT{}".format(mod.upper())].value

385 data += self.event_struct.pack(0, 0, 1, mod_code, 1)

386 data += self.event_struct.pack(0, 0, 1, key_code, 1)

387 data += self.event_struct.pack(0, 0, 0, 0, 0)

388 data += self.event_struct.pack(0, 10, 1, key_code, 0)

389 for mod in modifiers:

390 mod_code = KeyCodes["KEY_LEFT{}".format(mod.upper())].value

391 data += self.event_struct.pack(0, 10, 1, mod_code, 0)

392 data += self.event_struct.pack(0, 10, 0, 0, 0)

393 if use_special:

394 self.event_dev_file.write(data)

395 self.event_dev_file.flush()

396 else:

397 self.kb_dev_file.write(data)

398 self.kb_dev_file.flush()

399 time.sleep(delay)

400 repetitions -= 1

401

402

403 class HotKeyTesting:

404

405 def __init__(self):

406 self.kb = FauxKeyboard()

407

408 def check_volume_up(self):

409 """

410 Check if the volume up key has an effect on ALSA

411 """

412 # if the volume is already on max, then raising it won't make any

413 # difference, so first, let's lower it before establishing the baseline

414 self.kb.press_key(KeyCodes.KEY_VOLUMEDOWN, repetitions=4, delay=0.2)

415 self.kb.press_key(KeyCodes.KEY_VOLUMEUP)

416 # let's grab output of alsa mixer to establish what is the baseline

417 # before we start raising the volume

418 vc = VolumeChange()

419 with self._monitored_volume_change(vc):

420 self.kb.press_key(KeyCodes.KEY_VOLUMEUP, repetitions=3, delay=0.2)

421 time.sleep(1)

422 return vc.before < vc.after

423

424 def check_volume_down(self):

425 """

426 Check if the volume down key has an effect on ALSA

427 """

428 # if the volume is already on min, then lowering it won't make any

429 # difference, so first, let's raise it before establishing the baseline

430 self.kb.press_key(KeyCodes.KEY_VOLUMEUP, repetitions=4, delay=0.2)

431 self.kb.press_key(KeyCodes.KEY_VOLUMEDOWN)

432 # let's grab output of alsa mixer to establish what is the baseline

433 # before we start raising the volume

434 vc = VolumeChange()

435 with self._monitored_volume_change(vc):

436 self.kb.press_key(

437 KeyCodes.KEY_VOLUMEDOWN, repetitions=3, delay=0.2

438 )

439 return vc.before > vc.after

440

441 def check_mute(self):

442 """

443 Check if the mute key has an effect on ALSA

444 """

445 # first, let's raise volume (if it was already muted, then it will

446 # unmute it)

447 self.kb.press_key(KeyCodes.KEY_VOLUMEUP)

448 vc = VolumeChange()

449 with self._monitored_volume_change(vc):

450 self.kb.press_key(KeyCodes.KEY_MUTE)

451 time.sleep(1)

452 return vc.mute_after

453

454 @contextlib.contextmanager

455 def _monitored_volume_change(self, vc):

456 before = (

457 subprocess.check_output("amixer")

458 .decode(sys.stdout.encoding)

459 .splitlines()

460 )

461 yield

462 after = (

463 subprocess.check_output("amixer")

464 .decode(sys.stdout.encoding)

465 .splitlines()

466 )

467 temp = before.copy()

468 for line in temp:

469 if line in after:

470 before.remove(line)

471 after.remove(line)

472 if not before:

473 # all lines removed from before, so there's no state change

474 # of the stuff reported bevore hitting volume up, so let's fail

475 # the test

476 print("No change in amixer registered! ", end="")

477 return

478 # we expect that the lines that changed are status information about

479 # the output devices. Percentage volume is in square brackets so let's

480 # search for those and see if they got higher

481 regex = re.compile(r"\[(\d*)%\].*\[(on|off)\]")

482 if len(before) != len(after):

483 # more of an assertion - the lines diff should match

484 return

485 for b, a in zip(before, after):

486 match_b = regex.search(b)

487 match_a = regex.search(a)

488 if match_b and match_a:

489 vc.before = int(match_b.groups()[0])

490 vc.after = int(match_a.groups()[0])

491 vc.mute_before = match_b.groups()[1] == "off"

492 vc.mute_after = match_a.groups()[1] == "off"

493 return

494

495 def check_terminal_hotkey(self):

496 # spawn a terminal window using ctrl+alt+t

497 # touch a unique temporary file, and check if it got created

498 timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")

499 filename = os.path.join("/tmp/hotkey-testing-{}".format(timestamp))

500 self.kb.press_key(KeyCodes.KEY_T, {"ctrl", "alt"})

501 # wait for the terminal window to appear

502 assert not os.path.exists(filename)

503 time.sleep(2)

504 self.kb.type_text("touch {}".format(filename))

505 self.kb.press_key(KeyCodes.KEY_ENTER)

506 for attempt_no in range(10):

507 # let's wait some time to let X/terminal process the command

508 time.sleep(0.5)

509 if os.path.exists(filename):

510 self.kb.press_key(KeyCodes.KEY_D, {"ctrl"})

511 os.unlink(filename)

512 return True

513 else:

514 self.kb.press_key(KeyCodes.KEY_D, {"ctrl"})

515 return False

516

517 def check_command_hotkey(self):

518 timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")

519 filename = os.path.join("/tmp/hotkey-testing-cmd-{}".format(timestamp))

520 self.kb.press_key(KeyCodes.KEY_F2, {"alt"})

521 assert not os.path.exists(filename)

522 time.sleep(2)

523 self.kb.type_text("touch {}".format(filename))

524 self.kb.press_key(KeyCodes.KEY_ENTER)

525 for attempt_no in range(10):

526 # let's wait some time to let X/terminal process the command

527 time.sleep(0.5)

528 if os.path.exists(filename):

529 os.unlink(filename)

530 return True

531 else:

532 return False

533

534 def check_media_play(self):

535 cmd = "timeout 30 rhythmbox --debug 2> /tmp/media-key-test"

536 self.kb.press_key(KeyCodes.KEY_T, {"ctrl", "alt"})

537 time.sleep(2)

538 self.kb.type_text(cmd)

539 self.kb.press_key(KeyCodes.KEY_ENTER)

540 time.sleep(3)

541 self.kb.press_key(KeyCodes.KEY_PLAYPAUSE)

542 self.kb.press_key(KeyCodes.KEY_F4, {"alt"})

543 time.sleep(1)

544 self.kb.press_key(KeyCodes.KEY_D, {"ctrl"})

545 with open("/tmp/media-key-test", "rt") as f:

546 output = f.read()

547 return "got media key 'Play'" in output

548

549

550 def main():

551 if not (os.geteuid() == 0):

552 raise SystemExit("Must be run as root.")

553 hkt = HotKeyTesting()

554 failed = False

555 for member in dir(hkt):

556 attr = getattr(hkt, member)

557 if not member.startswith("check_") or not callable(attr):

558 continue

559 print("{}... ".format(member), end="", flush=True)

560 if not attr():

561 print("FAIL")

562 failed = True

563 else:

564 print("PASS")

565 if failed:

566 raise SystemExit("Some test failed!")

567

568

569 if __name__ == "__main__":

570 main()