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 +0 -0
- os_tester/debug_plot.py +77 -0
- os_tester/stages.py +127 -0
- os_tester/vm.py +496 -0
- os_tester-1.1.0.dev8.dist-info/METADATA +293 -0
- os_tester-1.1.0.dev8.dist-info/RECORD +9 -0
- os_tester-1.1.0.dev8.dist-info/WHEEL +5 -0
- os_tester-1.1.0.dev8.dist-info/licenses/LICENSE +674 -0
- os_tester-1.1.0.dev8.dist-info/top_level.txt +1 -0
os_tester/__init__.py
ADDED
|
File without changes
|
os_tester/debug_plot.py
ADDED
|
@@ -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"])
|