codexctl 0.1.0__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.
- codexctl/__init__.py +462 -0
- codexctl/__main__.py +4 -0
- codexctl/analysis.py +30 -0
- codexctl/device.py +640 -0
- codexctl/server.py +172 -0
- codexctl/sync.py +218 -0
- codexctl/updates.py +368 -0
- codexctl-0.1.0.dist-info/LICENSE +674 -0
- codexctl-0.1.0.dist-info/METADATA +105 -0
- codexctl-0.1.0.dist-info/RECORD +11 -0
- codexctl-0.1.0.dist-info/WHEEL +4 -0
codexctl/__init__.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
### Importing required general modules
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os.path
|
|
5
|
+
import sys
|
|
6
|
+
import logging
|
|
7
|
+
import importlib.util
|
|
8
|
+
import tempfile
|
|
9
|
+
import shutil
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from os import listdir
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from loguru import logger
|
|
17
|
+
except ImportError:
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
if importlib.util.find_spec("requests") is None:
|
|
21
|
+
raise ImportError(
|
|
22
|
+
"Requests is required for accessing remote files. Please install it."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .updates import UpdateManager
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Manager:
|
|
29
|
+
"""
|
|
30
|
+
Main class for codexctl
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, device: str, logger: logging.Logger) -> None:
|
|
34
|
+
"""Initializes the Manager class for codexctl
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
device (str): Type of device that is running the script
|
|
38
|
+
logger (logger): Logger object
|
|
39
|
+
"""
|
|
40
|
+
self.device = device
|
|
41
|
+
self.logger = logger
|
|
42
|
+
self.updater = UpdateManager(logger)
|
|
43
|
+
|
|
44
|
+
def call_func(self, function: str, args: dict) -> None:
|
|
45
|
+
"""Runs a command based on the function name and arguments provided
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
function: The function to run
|
|
49
|
+
args: What arguments to pass into the function
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
if "remarkable" not in self.device:
|
|
53
|
+
remarkable_version = args.get("hardware")
|
|
54
|
+
else:
|
|
55
|
+
remarkable_version = self.device
|
|
56
|
+
|
|
57
|
+
version = args.get("version", None)
|
|
58
|
+
|
|
59
|
+
if version == "latest":
|
|
60
|
+
version = self.updater.get_latest_version(remarkable_version)
|
|
61
|
+
elif version == "toltec":
|
|
62
|
+
version = self.updater.get_toltec_version(remarkable_version)
|
|
63
|
+
|
|
64
|
+
### Download functionalities
|
|
65
|
+
if function == "list":
|
|
66
|
+
print(
|
|
67
|
+
f"""
|
|
68
|
+
ReMarkable Paper Pro:
|
|
69
|
+
{'\n'.join(list(self.updater.remarkablepp_versions.keys()))}
|
|
70
|
+
|
|
71
|
+
ReMarkable 2:
|
|
72
|
+
{'\n'.join(list(self.updater.remarkable2_versions.keys()))}
|
|
73
|
+
|
|
74
|
+
ReMarkable 1:
|
|
75
|
+
{'\n'.join(list(self.updater.remarkable1_versions.keys()))}
|
|
76
|
+
"""
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
elif function == "download":
|
|
80
|
+
logger.debug(f"Downloading version {version}")
|
|
81
|
+
filename = self.updater.download_version(
|
|
82
|
+
remarkable_version, version, args["out"]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if filename:
|
|
86
|
+
print(f"Sucessfully downloaded to {filename}")
|
|
87
|
+
|
|
88
|
+
### Mounting functionalities
|
|
89
|
+
elif function in ("extract", "mount"):
|
|
90
|
+
try:
|
|
91
|
+
from .analysis import get_update_image
|
|
92
|
+
except ImportError:
|
|
93
|
+
raise ImportError(
|
|
94
|
+
"remarkable_update_image is required for analysis. Please install it!"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if function == "extract":
|
|
98
|
+
if sys.platform != "linux":
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
if not args["out"]:
|
|
102
|
+
args["out"] = os.getcwd() + "/extracted"
|
|
103
|
+
|
|
104
|
+
logger.debug(f"Extracting {args['file']} to {args['out']}")
|
|
105
|
+
image, volume = get_update_image(args["file"])
|
|
106
|
+
image.seek(0)
|
|
107
|
+
|
|
108
|
+
with open(args["out"], "wb") as f:
|
|
109
|
+
f.write(image.read())
|
|
110
|
+
else:
|
|
111
|
+
if args["out"] is None:
|
|
112
|
+
args["out"] = "/opt/remarkable/"
|
|
113
|
+
|
|
114
|
+
if not os.path.exists(args["out"]):
|
|
115
|
+
os.mkdir(args["out"])
|
|
116
|
+
|
|
117
|
+
if not os.path.exists(args["filesystem"]):
|
|
118
|
+
raise SystemExit("Firmware file does not exist!")
|
|
119
|
+
|
|
120
|
+
from remarkable_update_fuse import UpdateFS
|
|
121
|
+
|
|
122
|
+
server = UpdateFS()
|
|
123
|
+
server.parse(
|
|
124
|
+
args=[args["filesystem"], args["out"]], values=server, errex=1
|
|
125
|
+
)
|
|
126
|
+
server.main()
|
|
127
|
+
|
|
128
|
+
### Analysis functionalities
|
|
129
|
+
elif function in ("cat", "ls"):
|
|
130
|
+
try:
|
|
131
|
+
from .analysis import get_update_image
|
|
132
|
+
except ImportError:
|
|
133
|
+
raise ImportError(
|
|
134
|
+
"remarkable_update_image is required for analysis. Please install it. (Linux only!)"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
image, volume = get_update_image(args.file)
|
|
139
|
+
inode = volume.inode_at(args.target_path)
|
|
140
|
+
|
|
141
|
+
except FileNotFoundError:
|
|
142
|
+
print(f"'{args.target_path}': No such file or directory")
|
|
143
|
+
raise FileNotFoundError
|
|
144
|
+
|
|
145
|
+
except OSError:
|
|
146
|
+
print(f"'{args.target_path}': {os.strerror(e.errno)}")
|
|
147
|
+
sys.exit(e.errno)
|
|
148
|
+
|
|
149
|
+
if function == "cat":
|
|
150
|
+
sys.stdout.buffer.write(inode.open().read())
|
|
151
|
+
|
|
152
|
+
elif function == "ls":
|
|
153
|
+
print(" ".join([x.name_str for x, _ in inode.opendir()]))
|
|
154
|
+
|
|
155
|
+
### WebInterface functionalities
|
|
156
|
+
elif function in ("backup", "upload"):
|
|
157
|
+
from .sync import RmWebInterfaceAPI
|
|
158
|
+
|
|
159
|
+
print(
|
|
160
|
+
"Please make sure the web-interface is enabled in the remarkable settings!\nStarting upload"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
rmWeb = RmWebInterfaceAPI(BASE="http://10.11.99.1/", logger=logger)
|
|
164
|
+
|
|
165
|
+
if function == "backup":
|
|
166
|
+
rmWeb.sync(
|
|
167
|
+
localFolder=args["local"],
|
|
168
|
+
remoteFolder=args["remote"],
|
|
169
|
+
recursive=not args["no_recursion"],
|
|
170
|
+
overwrite=not args["no_overwrite"],
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
rmWeb.upload(input_paths=args["paths"], remoteFolder=args["remote"])
|
|
174
|
+
|
|
175
|
+
### Update & Version functionalities
|
|
176
|
+
elif function in ("install", "status", "restore"):
|
|
177
|
+
remote = False
|
|
178
|
+
|
|
179
|
+
if "remarkable" not in self.device:
|
|
180
|
+
if importlib.util.find_spec("paramiko") is None:
|
|
181
|
+
raise ImportError(
|
|
182
|
+
"Paramiko is required for SSH access. Please install it."
|
|
183
|
+
)
|
|
184
|
+
if importlib.util.find_spec("psutil") is None:
|
|
185
|
+
raise ImportError(
|
|
186
|
+
"Psutil is required for SSH access. Please install it."
|
|
187
|
+
)
|
|
188
|
+
remote = True
|
|
189
|
+
|
|
190
|
+
from .device import DeviceManager
|
|
191
|
+
from .server import get_available_version
|
|
192
|
+
|
|
193
|
+
remarkable = DeviceManager(
|
|
194
|
+
remote=remote,
|
|
195
|
+
address=args["address"],
|
|
196
|
+
logger=self.logger,
|
|
197
|
+
authentication=args["password"],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if function == "status":
|
|
201
|
+
beta, prev, current, version_id = remarkable.get_device_status()
|
|
202
|
+
print(
|
|
203
|
+
f"\nCurrent version: {current}\nOld update engine: {prev}\nBeta active: {beta}\nVersion id: {version_id}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
elif function == "restore":
|
|
207
|
+
remarkable.restore_previous_version()
|
|
208
|
+
print(
|
|
209
|
+
f"Device restored to previous version [{remarkable.get_device_status()[1]}]"
|
|
210
|
+
)
|
|
211
|
+
remarkable.reboot_device()
|
|
212
|
+
print("Device rebooted")
|
|
213
|
+
|
|
214
|
+
else:
|
|
215
|
+
temp_path = None
|
|
216
|
+
made_update_folder = False
|
|
217
|
+
orig_cwd = os.getcwd()
|
|
218
|
+
|
|
219
|
+
# Do we have a specific update file to serve?
|
|
220
|
+
|
|
221
|
+
update_file = version if os.path.isfile(version) else None
|
|
222
|
+
|
|
223
|
+
version_lookup = lambda version: re.search(r'\b\d+\.\d+\.\d+\.\d+\b', version)
|
|
224
|
+
version_number = version_lookup(version)
|
|
225
|
+
|
|
226
|
+
if not version_number:
|
|
227
|
+
version_number = input("Failed to get the version number from the filename, please enter it: ")
|
|
228
|
+
if not version_lookup(version_number):
|
|
229
|
+
raise SystemError("Invalid version!")
|
|
230
|
+
|
|
231
|
+
version_number = version_number.group()
|
|
232
|
+
|
|
233
|
+
update_file_requires_new_engine = UpdateManager.uses_new_update_engine(
|
|
234
|
+
version_number
|
|
235
|
+
)
|
|
236
|
+
device_version_uses_new_engine = UpdateManager.uses_new_update_engine(
|
|
237
|
+
remarkable.get_device_status()[2]
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
#### PREVENT USERS FROM INSTALLING NON-COMPATIBLE IMAGES ####
|
|
241
|
+
|
|
242
|
+
# TODO: Downgrade from versions above 3.11 to versions below 3.11 (We alredy know how to do this with #71)
|
|
243
|
+
# TODO: Upgrade from versions below 3.11 to versions above 3.11 (Easy way: upgrade to 3.11.2.5 to get the new update engine, then upgrade again to the specific version)
|
|
244
|
+
|
|
245
|
+
if device_version_uses_new_engine != update_file_requires_new_engine:
|
|
246
|
+
raise SystemError(
|
|
247
|
+
"Incompatible update file with current reMarkable update engine. See #71"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
#############################################################
|
|
251
|
+
|
|
252
|
+
if not update_file_requires_new_engine:
|
|
253
|
+
if not os.path.exists("updates"):
|
|
254
|
+
os.mkdir("updates")
|
|
255
|
+
if update_file:
|
|
256
|
+
shutil.move(update_file, "updates")
|
|
257
|
+
update_file = get_available_version(version)
|
|
258
|
+
made_update_folder = True # Delete at end
|
|
259
|
+
|
|
260
|
+
# If version was a valid location file, update_file will be the location.
|
|
261
|
+
|
|
262
|
+
if not update_file:
|
|
263
|
+
temp_path = tempfile.mkdtemp()
|
|
264
|
+
os.chdir(temp_path)
|
|
265
|
+
|
|
266
|
+
print(f"Version {version} not found. Attempting to download")
|
|
267
|
+
|
|
268
|
+
location = "./"
|
|
269
|
+
if not update_file_requires_new_engine:
|
|
270
|
+
location += "updates"
|
|
271
|
+
|
|
272
|
+
result = self.updater.download_version(
|
|
273
|
+
remarkable.hardware, version, location
|
|
274
|
+
)
|
|
275
|
+
if result:
|
|
276
|
+
print(f"Downloaded version {version} to {result}")
|
|
277
|
+
|
|
278
|
+
if device_version_uses_new_engine:
|
|
279
|
+
update_file = result
|
|
280
|
+
else:
|
|
281
|
+
update_file = get_available_version(version)
|
|
282
|
+
|
|
283
|
+
else:
|
|
284
|
+
raise SystemExit(
|
|
285
|
+
f"Failed to download version {version}! Does this version or location exist?"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
if device_version_uses_new_engine:
|
|
289
|
+
remarkable.install_sw_update(update_file)
|
|
290
|
+
else:
|
|
291
|
+
remarkable.install_ohma_update(update_file)
|
|
292
|
+
|
|
293
|
+
if made_update_folder: # Move update file back out
|
|
294
|
+
shutil.move(os.listdir("updates")[0], "../")
|
|
295
|
+
shutil.rmtree("updates")
|
|
296
|
+
|
|
297
|
+
os.chdir(orig_cwd)
|
|
298
|
+
if temp_path:
|
|
299
|
+
logger.debug(f"Removing temporary folder {temp_path}")
|
|
300
|
+
shutil.rmtree(temp_path)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def main() -> None:
|
|
304
|
+
"""Main function for codexctl"""
|
|
305
|
+
|
|
306
|
+
### Setting up the argument parser
|
|
307
|
+
parser = argparse.ArgumentParser("Codexctl")
|
|
308
|
+
parser.add_argument(
|
|
309
|
+
"--verbose",
|
|
310
|
+
"-v",
|
|
311
|
+
required=False,
|
|
312
|
+
help="Enable verbose logging",
|
|
313
|
+
action="store_true",
|
|
314
|
+
)
|
|
315
|
+
parser.add_argument(
|
|
316
|
+
"--address",
|
|
317
|
+
"-a",
|
|
318
|
+
required=False,
|
|
319
|
+
help="Specify the address of the device",
|
|
320
|
+
default=None,
|
|
321
|
+
)
|
|
322
|
+
parser.add_argument(
|
|
323
|
+
"--password",
|
|
324
|
+
"-p",
|
|
325
|
+
required=False,
|
|
326
|
+
help="Specify password or path to SSH key for remote access",
|
|
327
|
+
)
|
|
328
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
329
|
+
subparsers.required = True # This fixes a bug with older versions of python
|
|
330
|
+
|
|
331
|
+
### Install subcommand
|
|
332
|
+
install = subparsers.add_parser(
|
|
333
|
+
"install",
|
|
334
|
+
help="Install the specified version (will download if not available on the device)",
|
|
335
|
+
)
|
|
336
|
+
install.add_argument("version", help="Version (or location to file) to install")
|
|
337
|
+
|
|
338
|
+
### Download subcommand
|
|
339
|
+
download = subparsers.add_parser(
|
|
340
|
+
"download", help="Download the specified version firmware file"
|
|
341
|
+
)
|
|
342
|
+
download.add_argument("version", help="Version to download")
|
|
343
|
+
download.add_argument("--out", "-o", help="Folder to download to", default=None)
|
|
344
|
+
download.add_argument(
|
|
345
|
+
"--hardware", "--h", help="Hardware to download for", required=True
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
### Backup subcommand
|
|
349
|
+
backup = subparsers.add_parser(
|
|
350
|
+
"backup", help="Download remote files to local directory"
|
|
351
|
+
)
|
|
352
|
+
backup.add_argument(
|
|
353
|
+
"-r",
|
|
354
|
+
"--remote",
|
|
355
|
+
help="Remote directory to backup. Defaults to download folder",
|
|
356
|
+
default="",
|
|
357
|
+
)
|
|
358
|
+
backup.add_argument(
|
|
359
|
+
"-l",
|
|
360
|
+
"--local",
|
|
361
|
+
help="Local directory to backup to. Defaults to download folder",
|
|
362
|
+
default="./",
|
|
363
|
+
)
|
|
364
|
+
backup.add_argument(
|
|
365
|
+
"-nr",
|
|
366
|
+
"--no-recursion",
|
|
367
|
+
help="Disables recursively backup remote directory",
|
|
368
|
+
action="store_true",
|
|
369
|
+
)
|
|
370
|
+
backup.add_argument(
|
|
371
|
+
"-no-ow", "--no-overwrite", help="Disables overwrite", action="store_true"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
### Cat subcommand
|
|
375
|
+
cat = subparsers.add_parser(
|
|
376
|
+
"cat", help="Cat the contents of a file inside an update image"
|
|
377
|
+
)
|
|
378
|
+
cat.add_argument("file", help="Path to update file to cat", default=None)
|
|
379
|
+
cat.add_argument("target_path", help="Path inside the image to list", default=None)
|
|
380
|
+
|
|
381
|
+
### Ls subcommand
|
|
382
|
+
ls = subparsers.add_parser("ls", help="List files inside an update image")
|
|
383
|
+
ls.add_argument("file", help="Path to update file to extract", default=None)
|
|
384
|
+
ls.add_argument("target_path", help="Path inside the image to list", default=None)
|
|
385
|
+
|
|
386
|
+
### Extract subcommand
|
|
387
|
+
extract = subparsers.add_parser(
|
|
388
|
+
"extract", help="Extract the specified version update file"
|
|
389
|
+
)
|
|
390
|
+
extract.add_argument("file", help="Path to update file to extract", default=None)
|
|
391
|
+
extract.add_argument("--out", help="Folder to extract to", default=None)
|
|
392
|
+
|
|
393
|
+
### Mount subcommand
|
|
394
|
+
mount = subparsers.add_parser(
|
|
395
|
+
"mount", help="Mount the specified version firmware filesystem"
|
|
396
|
+
)
|
|
397
|
+
mount.add_argument(
|
|
398
|
+
"filesystem",
|
|
399
|
+
help="Path to version firmware filesystem to extract",
|
|
400
|
+
default=None,
|
|
401
|
+
)
|
|
402
|
+
mount.add_argument("--out", help="Folder to mount to", default=None)
|
|
403
|
+
|
|
404
|
+
### Upload subcommand
|
|
405
|
+
upload = subparsers.add_parser(
|
|
406
|
+
"upload", help="Upload folder/files to device (pdf only)"
|
|
407
|
+
)
|
|
408
|
+
upload.add_argument(
|
|
409
|
+
"paths", help="Path to file(s)/folder to upload", default=None, nargs="+"
|
|
410
|
+
)
|
|
411
|
+
upload.add_argument(
|
|
412
|
+
"-r",
|
|
413
|
+
"--remote",
|
|
414
|
+
help="Remote directory to upload to. Defaults to root folder",
|
|
415
|
+
default="",
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
### Status subcommand
|
|
419
|
+
subparsers.add_parser(
|
|
420
|
+
"status", help="Get the current version of the device and other information"
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
### Restore subcommand
|
|
424
|
+
subparsers.add_parser(
|
|
425
|
+
"restore", help="Restores to previous version installed on device"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
### List subcommand
|
|
429
|
+
subparsers.add_parser("list", help="List all available versions")
|
|
430
|
+
|
|
431
|
+
### Setting logging level
|
|
432
|
+
args = parser.parse_args()
|
|
433
|
+
logging_level, paramiko_level = (
|
|
434
|
+
("DEBUG", logging.DEBUG) if args.verbose else ("ERROR", logging.ERROR)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
logger.remove()
|
|
439
|
+
logger.add(sys.stderr, level=logging_level)
|
|
440
|
+
logging.basicConfig(level=paramiko_level)
|
|
441
|
+
except AttributeError: # For non-loguru
|
|
442
|
+
logger.level = logging_level
|
|
443
|
+
|
|
444
|
+
### Detecting device information
|
|
445
|
+
device = None
|
|
446
|
+
|
|
447
|
+
if os.path.exists("/sys/devices/soc0/machine"):
|
|
448
|
+
with open("/sys/devices/soc0/machine") as machine_file:
|
|
449
|
+
contents = machine_file.read().strip()
|
|
450
|
+
|
|
451
|
+
if "reMarkable" in contents:
|
|
452
|
+
device = contents # reMarkable 1, reMarkable 2, reMarkable Ferrari
|
|
453
|
+
|
|
454
|
+
if device is None:
|
|
455
|
+
device = sys.platform
|
|
456
|
+
|
|
457
|
+
logger.debug(f"Running on platform: {device}")
|
|
458
|
+
logger.debug(f"Running with args: {args}")
|
|
459
|
+
|
|
460
|
+
### Call function
|
|
461
|
+
man = Manager(device, logger)
|
|
462
|
+
man.call_func(args.command, vars(args))
|
codexctl/__main__.py
ADDED
codexctl/analysis.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import ext4
|
|
2
|
+
from remarkable_update_image import UpdateImage
|
|
3
|
+
from remarkable_update_image import UpdateImageSignatureException
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_update_image(file: str):
|
|
7
|
+
"""Extracts files from an update image (<3.11 currently)"""
|
|
8
|
+
|
|
9
|
+
image = UpdateImage(file)
|
|
10
|
+
volume = ext4.Volume(image, offset=0)
|
|
11
|
+
try:
|
|
12
|
+
inode = volume.inode_at("/usr/share/update_engine/update-payload-key.pub.pem")
|
|
13
|
+
if inode is None:
|
|
14
|
+
raise FileNotFoundError()
|
|
15
|
+
|
|
16
|
+
inode.verify()
|
|
17
|
+
image.verify(inode.open().read())
|
|
18
|
+
|
|
19
|
+
except UpdateImageSignatureException:
|
|
20
|
+
warnings.warn("Signature doesn't match contents", RuntimeWarning)
|
|
21
|
+
|
|
22
|
+
except FileNotFoundError:
|
|
23
|
+
warnings.warn("Public key missing", RuntimeWarning)
|
|
24
|
+
|
|
25
|
+
except OSError as e:
|
|
26
|
+
if e.errno != errno.ENOTDIR:
|
|
27
|
+
raise
|
|
28
|
+
warnings.warn("Unable to open public key", RuntimeWarning)
|
|
29
|
+
|
|
30
|
+
return image, volume
|