os-tester 1.1.0.dev8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
os_tester/__init__.py ADDED
File without changes
@@ -0,0 +1,77 @@
1
+ from typing import Any, List
2
+
3
+ import cv2
4
+ import matplotlib.pyplot as plt
5
+
6
+
7
+ class debugPlot:
8
+ """
9
+ A wrapper class around a matplotlib plot to visualize the current image output detection.
10
+ """
11
+
12
+ mseValues: List[float]
13
+ ssimValues: List[float]
14
+ sameImageValues: List[float]
15
+ fig: Any
16
+ axd: Any
17
+
18
+ def __init__(self):
19
+ self.mseValues: List[float] = list()
20
+ self.ssimValues: List[float] = list()
21
+ self.sameImageValues: List[float] = list()
22
+
23
+ fig, axd = plt.subplot_mosaic(
24
+ [["refImg", "curImg", "difImg"], ["plot", "plot", "plot"]],
25
+ layout="constrained",
26
+ )
27
+ self.fig = fig
28
+ self.axd = axd
29
+
30
+ def update_plot(
31
+ self,
32
+ refImg: cv2.typing.MatLike,
33
+ curImg: cv2.typing.MatLike,
34
+ difImage: cv2.typing.MatLike,
35
+ mse: float,
36
+ ssim: float,
37
+ same: float,
38
+ ) -> None:
39
+ """
40
+ Takes the measured and reference image dif and updates the plot accordingly.
41
+
42
+ Args:
43
+ refImg (cv2.typing.MatLike): The reference image we are waiting for.
44
+ curImg (cv2.typing.MatLike): The current VM output image.
45
+ difImage (cv2.typing.MatLike): |refImg - curImg| aka the diff of those images.
46
+ mse (float): The mean square error between refImg and curImg.
47
+ ssim (float): The structural similarity index error between refImg and curImg.
48
+ same (float): 1 if refImg and curImg are equal enough and 0 else.
49
+ """
50
+ self.mseValues.append(mse)
51
+ self.ssimValues.append(ssim)
52
+ self.sameImageValues.append(same)
53
+
54
+ # Plot the images. Convert images from BGR to RBG.
55
+ self.axd["refImg"].clear()
56
+ self.axd["refImg"].imshow(cv2.cvtColor(refImg, cv2.COLOR_BGR2RGB))
57
+ self.axd["refImg"].set_title("Ref Image")
58
+
59
+ self.axd["curImg"].clear()
60
+ self.axd["curImg"].imshow(cv2.cvtColor(curImg, cv2.COLOR_BGR2RGB))
61
+ self.axd["curImg"].set_title("Cur Image")
62
+
63
+ self.axd["difImg"].clear()
64
+ self.axd["difImg"].imshow(cv2.cvtColor(difImage, cv2.COLOR_BGR2RGB))
65
+ self.axd["difImg"].set_title("Dif Image")
66
+
67
+ # Plot MSE over time
68
+ self.axd["plot"].clear()
69
+ self.axd["plot"].plot(self.mseValues, "bx-", label="MSE over Time")
70
+ self.axd["plot"].plot(self.ssimValues, "rx-", label="SSIM over Time")
71
+ self.axd["plot"].plot(self.sameImageValues, "gx-", label="Same Image")
72
+ self.axd["plot"].set_title("MSE over Time")
73
+ self.axd["plot"].set_xlabel("Iterations")
74
+ self.axd["plot"].set_ylabel("MSE")
75
+ self.axd["plot"].legend()
76
+
77
+ plt.pause(0.001) # Allows the plot to update
os_tester/stages.py ADDED
@@ -0,0 +1,127 @@
1
+ import sys
2
+ from os import path
3
+ from typing import Any, Dict, List
4
+
5
+ import cv2
6
+ import yaml # type: ignore
7
+
8
+
9
+ class checkFile:
10
+ """
11
+ A single reference file with thresholds.
12
+ """
13
+
14
+ filePath: str
15
+ fileData: cv2.typing.MatLike
16
+
17
+ mseLeq: float
18
+ ssimGeq: float
19
+ nextStage: str
20
+ actions: List[Dict[str, Any]]
21
+
22
+ def __init__(self, fileDict: Dict[str, Any], basePath: str):
23
+ self.filePath = path.join(basePath, fileDict["path"])
24
+ # Check if the reference images exist and if so load them as OpenCV object
25
+ self.fileData = self.__load(self.filePath)
26
+ self.mseLeq = fileDict["mse_leq"]
27
+ self.ssimGeq = fileDict["ssim_geq"]
28
+
29
+ def __load(self, filePath: str) -> cv2.typing.MatLike:
30
+ """
31
+ Check if the reference image exist and if so load/return it as OpenCV object.
32
+ """
33
+
34
+ if not path.exists(filePath):
35
+ print(f"Stage ref image file '{filePath}' not found!")
36
+ sys.exit(2)
37
+
38
+ if not path.isfile(filePath):
39
+ print(f"Stage ref image file '{filePath}' is no file!")
40
+ sys.exit(3)
41
+
42
+ data: cv2.typing.MatLike | None = cv2.imread(filePath)
43
+ if data is None:
44
+ print(f"Failed to load CV2 data from '{filePath}'!")
45
+ sys.exit(4)
46
+ return data
47
+
48
+
49
+ class subPath:
50
+ """
51
+ A single path with optionally multiple ref images, thresholds and actions to perform once the threshold for one file (image) is reached.
52
+ """
53
+
54
+ checkList: List[checkFile]
55
+
56
+ nextStage: str
57
+ actions: List[Dict[str, Any]]
58
+
59
+ def __init__(self, pathDict: Dict[str, Any], basePath: str):
60
+ # Removed in 1.1.0
61
+ if "check" in pathDict:
62
+ raise Exception("The keyword 'check' has been replaced with the 'checks' keyword.")
63
+
64
+ self.checkList = list()
65
+ if "checks" in pathDict:
66
+ checkDict: Dict[str, Any]
67
+ for checkDict in pathDict["checks"]:
68
+ self.checkList.append(checkFile(checkDict, basePath))
69
+
70
+ self.actions = pathDict["actions"] if "actions" in pathDict else list()
71
+ self.nextStage = pathDict["nextStage"]
72
+
73
+
74
+ class stage:
75
+ """
76
+ A single stage with timeout
77
+ """
78
+
79
+ name: str
80
+ timeoutS: float
81
+ pathsList: List[subPath]
82
+
83
+ def __init__(self, stageDict: Dict[str, Any], basePath: str):
84
+ self.name = stageDict["stage"]
85
+ self.timeoutS = stageDict["timeout_s"]
86
+
87
+ self.pathsList = list()
88
+ pathDict: Dict[str, Any]
89
+ for pathDict in stageDict["paths"]:
90
+ self.pathsList.append(subPath(pathDict["path"], basePath))
91
+
92
+
93
+ class stages:
94
+ """
95
+ A list of stages that are used to automate the VM process.
96
+ """
97
+
98
+ basePath: str
99
+ stagesList: List[stage]
100
+
101
+ def __load_stages(self, yamlFileName: str) -> None:
102
+ """
103
+ Loads the stage definition from 'self.basePath' and stores the result inside 'self.stagesList'.
104
+ """
105
+ ymlFilePath: str = path.join(self.basePath, yamlFileName + ".yml")
106
+ print(f"Loading stages from: {ymlFilePath}")
107
+
108
+ if not path.exists(ymlFilePath):
109
+ print(f"Stage config at '{ymlFilePath}' not found!")
110
+ sys.exit(2)
111
+
112
+ if not path.isfile(ymlFilePath):
113
+ print(f"Stage config at '{ymlFilePath}' is no file!")
114
+ sys.exit(3)
115
+
116
+ stagesDict: Dict[str, Any]
117
+ with open(ymlFilePath, "r", encoding="utf-8") as file:
118
+ stagesDict = yaml.safe_load(file)
119
+
120
+ self.stagesList = list()
121
+ stageDict: Dict[str, Any]
122
+ for stageDict in stagesDict["stages"]:
123
+ self.stagesList.append(stage(stageDict, self.basePath))
124
+
125
+ def __init__(self, basePath: str, yamlFileName: str):
126
+ self.basePath = basePath
127
+ self.__load_stages(yamlFileName)
os_tester/vm.py ADDED
@@ -0,0 +1,496 @@
1
+ import json
2
+ import sys
3
+ from contextlib import suppress
4
+ from os import path, remove
5
+ from time import sleep, time
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ import cv2
9
+ import libvirt
10
+ import libvirt_qemu
11
+ import numpy as np
12
+ from skimage import metrics as skimage_metrics
13
+
14
+ from os_tester.debug_plot import debugPlot
15
+ from os_tester.stages import stage, stages, subPath
16
+
17
+
18
+ class vm:
19
+ """
20
+ A wrapper around a qemu libvirt VM that handles the live time and stage execution.
21
+ """
22
+
23
+ conn: libvirt.virConnect
24
+ uuid: str
25
+ debugPlt: bool
26
+
27
+ vmDom: Optional[libvirt.virDomain]
28
+ debugPlotObj: debugPlot
29
+
30
+ def __init__(
31
+ self,
32
+ conn: libvirt.virConnect,
33
+ uuid: str,
34
+ debugPlt: bool = False,
35
+ ):
36
+ self.conn = conn
37
+ self.uuid = uuid
38
+ self.debugPlt = debugPlt
39
+ if self.debugPlt:
40
+ self.debugPlotObj = debugPlot()
41
+
42
+ self.vmDom = None
43
+
44
+ def __perform_stage_actions(self, actions: List[Dict[str, Any]]) -> None:
45
+ """
46
+ Performs all stage actions (mouse_move, keyboard_key, reboot, ...) on the current VM.
47
+
48
+ Args:
49
+ actions (List[Dict[str, Any]]): A list of actions that should be performed
50
+ """
51
+ action: Dict[str, Any]
52
+ for action in actions:
53
+ if "mouse_move" in action:
54
+ self.__send_mouse_move_action(action["mouse_move"])
55
+ elif "mouse_click" in action:
56
+ self.__send_mouse_click_action(action["mouse_click"])
57
+ elif "keyboard_key" in action:
58
+ self.__send_keyboard_key_action(action["keyboard_key"])
59
+ elif "keyboard_text" in action:
60
+ self.__send_keyboard_text_action(action["keyboard_text"])
61
+ elif "sleep" in action:
62
+ sleep(action["duration_s"])
63
+ elif "reboot" in action:
64
+ assert self.vmDom
65
+ self.vmDom.reboot()
66
+ elif "shutdown" in action:
67
+ assert self.vmDom
68
+ self.vmDom.shutdown()
69
+ else:
70
+ raise Exception(f"Invalid stage action: {action}")
71
+
72
+ def __img_mse(
73
+ self,
74
+ curImg: cv2.typing.MatLike,
75
+ refImg: cv2.typing.MatLike,
76
+ ) -> Tuple[float, cv2.typing.MatLike]:
77
+ """
78
+ Calculates the mean square error between two given images.
79
+ Both images have to have the same size.
80
+
81
+ Args:
82
+ curImg (cv2.typing.MatLike): The current image taken from the VM.
83
+ refImg (cv2.typing.MatLike): The reference image we are awaiting.
84
+
85
+ Returns:
86
+ Tuple[float, cv2.typing.MatLike]: A tuple of the mean square error and the image diff.
87
+ """
88
+ # Compute the difference
89
+ imgDif: cv2.typing.MatLike = cv2.subtract(curImg, refImg)
90
+ err = np.sum(imgDif**2)
91
+
92
+ # Compute Mean Squared Error
93
+ h, w = curImg.shape[:2]
94
+ mse = err / (float(h * w))
95
+
96
+ mse = min(
97
+ mse,
98
+ 10,
99
+ ) # Values over 10 do not make sense for our case and it makes it easier to plot it
100
+ return mse, imgDif
101
+
102
+ def __comp_images(
103
+ self,
104
+ curImg: cv2.typing.MatLike,
105
+ refImg: cv2.typing.MatLike,
106
+ ) -> Tuple[float, float, cv2.typing.MatLike]:
107
+ """
108
+ Compares the provided images and calculates the mean square error and structural similarity index.
109
+ Based on: https://www.tutorialspoint.com/how-to-compare-two-images-in-opencv-python
110
+
111
+ Args:
112
+ curImg (cv2.typing.MatLike): The current image taken from the VM.
113
+ refImg (cv2.typing.MatLike): The reference image we are awaiting.
114
+
115
+ Returns:
116
+ Tuple[float, float, cv2.typing.MatLike]: A tuple consisting of the mean square error, structural similarity index and a image diff of both images.
117
+ """
118
+ # Get the dimensions of the original image
119
+ hRef, wRef = refImg.shape[:2]
120
+
121
+ # Resize the reference image to match the original image's dimensions
122
+ curImgResized = cv2.resize(curImg, (wRef, hRef))
123
+
124
+ mse: float
125
+ difImg: cv2.typing.MatLike
126
+ mse, difImg = self.__img_mse(curImgResized, refImg)
127
+
128
+ # Compute SSIM
129
+ ssimIndex: float = skimage_metrics.structural_similarity(curImgResized, refImg, channel_axis=-1)
130
+
131
+ return (mse, ssimIndex, difImg)
132
+
133
+ def __wait_for_stage_done(self, stageObj: stage) -> subPath:
134
+ """
135
+ Returns once the given stages reference image is reached.
136
+
137
+ Args:
138
+ stageObj (stage): The stage we want to await for.
139
+ """
140
+ timeoutInS = stageObj.timeoutS
141
+ start = time()
142
+
143
+ while True:
144
+ # Take a new screenshot
145
+ curImgPath: str = f"/tmp/{self.uuid}_check.png"
146
+ self.take_screenshot(curImgPath)
147
+ curImgOpt: cv2.typing.MatLike | None = cv2.imread(curImgPath)
148
+ if curImgOpt is None:
149
+ print("Failed to convert current image to CV2 object")
150
+ sys.exit(6)
151
+ curImg: cv2.typing.MatLike = curImgOpt
152
+
153
+ mse: float
154
+ ssimIndex: float
155
+ difImg: cv2.typing.MatLike
156
+
157
+ pathIndex: int = 1
158
+
159
+ # Compare the screenshot with all reference images
160
+ for subPathObj in stageObj.pathsList:
161
+ # If there are no checks. We consider is asd a successful check
162
+ if not subPathObj.checkList:
163
+ return subPathObj
164
+
165
+ print(f"Checking path {pathIndex}...")
166
+ for check in subPathObj.checkList:
167
+ # Compare images by calculating similarity
168
+ mse, ssimIndex, difImg = self.__comp_images(curImg, check.fileData)
169
+ same: float = 1 if mse <= check.mseLeq and ssimIndex >= check.ssimGeq else 0
170
+
171
+ if self.debugPlt:
172
+ self.debugPlotObj.update_plot(check.fileData, curImg, difImg, mse, ssimIndex, same)
173
+
174
+ # Break if we found a matching image
175
+ if same >= 1:
176
+ print(f"\t✅ [{path.basename(check.filePath)}]: MSE expected leq {check.mseLeq}, SSIM expected geq {check.ssimGeq} - MSE actual: {mse}, SSIM actual: {ssimIndex}, Images same: {same}")
177
+ return subPathObj
178
+ print(f"\t❌ [{path.basename(check.filePath)}]: MSE expected leq {check.mseLeq}, SSIM expected geq {check.ssimGeq} - MSE actual: {mse}, SSIM actual: {ssimIndex}, Images same: {same}")
179
+
180
+ pathIndex += 1
181
+
182
+ # if timeout is exited
183
+ if start + timeoutInS < time():
184
+ print(f"⌛ Timeout for stage '{stageObj.name}' reached after {timeoutInS} seconds.")
185
+ sys.exit(5)
186
+
187
+ sleep(0.25)
188
+
189
+ def __run_stage(self, stageObj: stage) -> str:
190
+ """
191
+ 1. Awaits until we reach the current stage reference image.
192
+ 2. Executes all actions defined by this stage.
193
+
194
+ Args:
195
+ stageObj (stage): The stage to execute/await for the image.
196
+ Returns:
197
+ str: with the name of the next requested Stage
198
+ """
199
+ start: float = time()
200
+ print(f"Running stage '{stageObj.name}'.")
201
+
202
+ subPathObj: subPath = self.__wait_for_stage_done(stageObj)
203
+ self.__perform_stage_actions(subPathObj.actions)
204
+
205
+ duration: float = time() - start
206
+ print(f"Stage '{stageObj.name}' finished after {duration}s. Next Stage is: '{subPathObj.nextStage}'")
207
+
208
+ return subPathObj.nextStage
209
+
210
+ def run_stages(self, stagesObj: stages) -> None:
211
+ """
212
+ Executes all stages defined for the current PC and awaits every stage to finish before returning.
213
+ If no name with requested StageName found exit with error
214
+ """
215
+ nextStage = stagesObj.stagesList[0]
216
+ while True:
217
+ nextStageName = self.__run_stage(nextStage)
218
+ # if nextStageName is None exit program
219
+ if nextStageName == "None":
220
+ break
221
+ found: bool = False
222
+ for stageObj in stagesObj.stagesList:
223
+ # If the expected next Stage name is found break and continue with that stage
224
+ if stageObj.name == nextStageName:
225
+ nextStage = stageObj
226
+ found = True
227
+ break
228
+
229
+ # Exit if no matching stage was found
230
+ if not found:
231
+ print(f"No Stage named '{nextStageName}' was found ")
232
+ sys.exit(10)
233
+
234
+ def try_load(self) -> bool:
235
+ """
236
+ Tries to lookup and load the qemu/libvirt VM via 'self.uuid' and returns the result.
237
+
238
+ Returns:
239
+ bool: True: The VM exists and was loaded successfully.
240
+ """
241
+ with suppress(libvirt.libvirtError):
242
+ self.vmDom = self.conn.lookupByUUIDString(self.uuid)
243
+ return self.vmDom is not None
244
+ return False
245
+
246
+ def destroy(self) -> None:
247
+ """
248
+ Tell qemu/libvirt to destroy the VM defined by 'self.uuid'.
249
+
250
+ Raises:
251
+ Exception: In case the VM has not been loaded before via e.g. try_load(...).
252
+ """
253
+ if not self.vmDom:
254
+ raise Exception("Can not destroy vm. Use try_load or create first!")
255
+
256
+ self.vmDom.destroy()
257
+
258
+ def create(self, vmXml: str) -> None:
259
+ """
260
+ Creates a new libvirt/qemu VM based on the provided libvirt XML string.
261
+ Ref: https://libvirt.org/formatdomain.html
262
+
263
+ Args:
264
+ vmXml (str): The libvirt XML string defining the VM. Ref: https://libvirt.org/formatdomain.html
265
+
266
+ Raises:
267
+ Exception: In case the VM with 'self.uuid' already exists.
268
+ """
269
+ with suppress(libvirt.libvirtError):
270
+ if self.conn.lookupByUUIDString(self.uuid):
271
+ raise Exception(
272
+ f"Can not create vm with UUID '{self.uuid}'. VM already exists. Destroy first!",
273
+ )
274
+
275
+ self.vmDom = self.conn.createXML(vmXml, 0)
276
+
277
+ def take_screenshot(self, targetPath: str) -> None:
278
+ """
279
+ Takes a screenshoot of the current VM output and stores it as a file.
280
+
281
+ Args:
282
+ targetPath (str): Where to store the screenshoot at.
283
+ """
284
+ stream: libvirt.virStream = self.conn.newStream()
285
+
286
+ assert self.vmDom
287
+ _ = self.vmDom.screenshot(stream, 0)
288
+
289
+ with open(targetPath, "wb") as f:
290
+ streamBytes = stream.recv(262120)
291
+ while streamBytes != b"":
292
+ f.write(streamBytes)
293
+ streamBytes = stream.recv(262120)
294
+ stream.finish()
295
+
296
+ def __get_screen_size(self) -> Tuple[int, int]:
297
+ """
298
+ Helper function returning the VM screen size by taking a screenshoot and using this image than as width and height.
299
+
300
+ Returns:
301
+ Tuple[int, int]: width and height
302
+ """
303
+ filePath: str = f"/tmp/{self.uuid}_screen_size.png"
304
+ self.take_screenshot(filePath)
305
+
306
+ img: cv2.typing.MatLike | None = cv2.imread(filePath)
307
+
308
+ if not img:
309
+ return (0, 0)
310
+
311
+ # Delete screen shoot again since we do not need it any more
312
+ remove(filePath)
313
+
314
+ h, w = img.shape[:2]
315
+ return (w, h)
316
+
317
+ def __send_action(self, cmdDict: Dict[str, Any]) -> Optional[Any]:
318
+ """
319
+ Sends a qemu monitor command to the VM.
320
+ Ref: https://en.wikibooks.org/wiki/QEMU/Monitor
321
+
322
+ Args:
323
+ cmdDict (Dict[str, Any]): A dict defining the qemu monitor command.
324
+
325
+ Returns:
326
+ Optional[Any]: The qemu execution result.
327
+ """
328
+ cmd: str = json.dumps(cmdDict)
329
+ try:
330
+ response: Any = libvirt_qemu.qemuMonitorCommand(self.vmDom, cmd, 0)
331
+ print(f"Action response: {response}")
332
+ return response
333
+ except libvirt.libvirtError as e:
334
+ print(f"Failed to send action event: {e}")
335
+ return None
336
+
337
+ def __send_keyboard_text_action(self, keyboardText: Dict[str, Any]) -> None:
338
+ """
339
+ Sends a row of key press events via the qemu monitor.
340
+
341
+ Args:
342
+ keyboardText (Dict[str, Any]): The dict defining the text to send and how.
343
+ """
344
+ for c in keyboardText["value"]:
345
+ cmdDictDown: Dict[str, Any] = {
346
+ "execute": "input-send-event",
347
+ "arguments": {
348
+ "events": [
349
+ {
350
+ "type": "key",
351
+ "data": {
352
+ "down": True,
353
+ "key": {"type": "qcode", "data": c},
354
+ },
355
+ },
356
+ ],
357
+ },
358
+ }
359
+ self.__send_action(cmdDictDown)
360
+ sleep(keyboardText["duration_s"])
361
+
362
+ cmdDictUp: Dict[str, Any] = {
363
+ "execute": "input-send-event",
364
+ "arguments": {
365
+ "events": [
366
+ {
367
+ "type": "key",
368
+ "data": {
369
+ "down": False,
370
+ "key": {"type": "qcode", "data": c},
371
+ },
372
+ },
373
+ ],
374
+ },
375
+ }
376
+ self.__send_action(cmdDictUp)
377
+ sleep(keyboardText["duration_s"])
378
+
379
+ def __send_keyboard_key_action(self, keyboardKey: Dict[str, Any]) -> None:
380
+ """
381
+ Performs a keyboard key press action via the qemu monitor.
382
+
383
+ Args:
384
+ keyboardKey (Dict[str, Any]): The dict defining the keyboard key to send and how.
385
+ """
386
+ cmdDictDown: Dict[str, Any] = {
387
+ "execute": "input-send-event",
388
+ "arguments": {
389
+ "events": [
390
+ {
391
+ "type": "key",
392
+ "data": {
393
+ "down": True,
394
+ "key": {"type": "qcode", "data": keyboardKey["value"]},
395
+ },
396
+ },
397
+ ],
398
+ },
399
+ }
400
+ self.__send_action(cmdDictDown)
401
+ sleep(keyboardKey["duration_s"])
402
+
403
+ cmdDictUp: Dict[str, Any] = {
404
+ "execute": "input-send-event",
405
+ "arguments": {
406
+ "events": [
407
+ {
408
+ "type": "key",
409
+ "data": {
410
+ "down": False,
411
+ "key": {"type": "qcode", "data": keyboardKey["value"]},
412
+ },
413
+ },
414
+ ],
415
+ },
416
+ }
417
+ self.__send_action(cmdDictUp)
418
+ sleep(keyboardKey["duration_s"])
419
+
420
+ def __send_mouse_move_action(self, mouseMove: Dict[str, Any]) -> None:
421
+ """
422
+ Performs a mouse move action via the qemu monitor.
423
+
424
+ Args:
425
+ mouseMove (Dict[str, Any]): The dict defining the mouse move action.
426
+ """
427
+ w: int
428
+ h: int
429
+ w, h = self.__get_screen_size()
430
+
431
+ cmdDict: Dict[str, Any] = {
432
+ "execute": "input-send-event",
433
+ "arguments": {
434
+ "events": [
435
+ {
436
+ "type": "abs",
437
+ "data": {
438
+ "axis": "x",
439
+ "value": 0,
440
+ },
441
+ },
442
+ {
443
+ "type": "abs",
444
+ "data": {"axis": "y", "value": 0},
445
+ },
446
+ {
447
+ "type": "rel",
448
+ "data": {
449
+ "axis": "x",
450
+ "value": int(w * mouseMove["x_rel"]),
451
+ },
452
+ },
453
+ {
454
+ "type": "rel",
455
+ "data": {"axis": "y", "value": int(h * mouseMove["y_rel"])},
456
+ },
457
+ ],
458
+ },
459
+ }
460
+ self.__send_action(cmdDict)
461
+ sleep(mouseMove["duration_s"])
462
+
463
+ def __send_mouse_click_action(self, mouseClick: Dict[str, Any]) -> None:
464
+ """
465
+ Performs a mouse click action via the qemu monitor.
466
+
467
+ Args:
468
+ mouseMove (Dict[str, Any]): The dict defining the mouse click action.
469
+ """
470
+ cmdDictDown: Dict[str, Any] = {
471
+ "execute": "input-send-event",
472
+ "arguments": {
473
+ "events": [
474
+ {
475
+ "type": "btn",
476
+ "data": {"down": True, "button": mouseClick["value"]},
477
+ },
478
+ ],
479
+ },
480
+ }
481
+ self.__send_action(cmdDictDown)
482
+ sleep(mouseClick["duration_s"])
483
+
484
+ cmdDictUp: Dict[str, Any] = {
485
+ "execute": "input-send-event",
486
+ "arguments": {
487
+ "events": [
488
+ {
489
+ "type": "btn",
490
+ "data": {"down": False, "button": mouseClick["value"]},
491
+ },
492
+ ],
493
+ },
494
+ }
495
+ self.__send_action(cmdDictUp)
496
+ sleep(mouseClick["duration_s"])