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/device.py ADDED
@@ -0,0 +1,640 @@
1
+ import socket
2
+ import subprocess
3
+ import logging
4
+ import threading
5
+ import re
6
+ import os
7
+ import time
8
+
9
+ from .server import startUpdate
10
+
11
+ try:
12
+ import paramiko
13
+ import psutil
14
+ except ImportError:
15
+ pass
16
+
17
+
18
+ class DeviceManager:
19
+ def __init__(
20
+ self, logger=None, remote=False, address=None, authentication=None
21
+ ) -> None:
22
+ """Initializes the DeviceManager for codexctl
23
+
24
+ Args:
25
+ remote (bool, optional): Whether the device is remote. Defaults to False.
26
+ address (bool, optional): Known IP of remote device, if applicable. Defaults to None.
27
+ logger (logger, optional): Logger object for logging. Defaults to None.
28
+ Authentication (str, optional): Authentication method. Defaults to None.
29
+ """
30
+ self.logger = logger
31
+ self.address = address
32
+ self.authentication = authentication
33
+ self.client = None
34
+
35
+ if self.logger is None:
36
+ self.logger = logging
37
+
38
+ if remote:
39
+ self.client = self.connect_to_device(
40
+ authentication=authentication, remote_address=address
41
+ )
42
+
43
+ self.client.authentication = authentication
44
+ self.client.address = address
45
+
46
+ ftp = self.client.open_sftp()
47
+ with ftp.file("/sys/devices/soc0/machine") as file:
48
+ machine_contents = file.read().decode("utf-8").strip("\n")
49
+ else:
50
+ with open("/sys/devices/soc0/machine") as file:
51
+ machine_contents = file.read().decode("utf-8").strip("\n")
52
+
53
+ if "reMarkable Ferrari" in machine_contents:
54
+ self.hardware = "ferrari"
55
+ elif "reMarkable 1" in machine_contents:
56
+ self.hardware = "reMarkable1"
57
+ else:
58
+ self.hardware = "reMarkable2"
59
+
60
+ def get_host_address(self) -> list[str] | list | None: # Interaction required
61
+ """Gets the IP address of the host machine
62
+
63
+ Returns:
64
+ str | None: IP address of the host machine, or None if not found
65
+ """
66
+
67
+ possible_ips = []
68
+ try:
69
+ for interface, snics in psutil.net_if_addrs().items():
70
+ self.logger.debug(f"New interface found: {interface}")
71
+ for snic in snics:
72
+ if snic.family == socket.AF_INET:
73
+ if snic.address.startswith("10.11.99"):
74
+ return snic.address
75
+ self.logger.debug(f"Adding new address: {snic.address}")
76
+ possible_ips.append(snic.address)
77
+
78
+ except Exception as error:
79
+ self.logger.error(f"Error automatically getting interfaces: {error}")
80
+
81
+ if possible_ips:
82
+ host_interfaces = "\n".join(possible_ips)
83
+ else:
84
+ host_interfaces = "Could not find any available interfaces."
85
+
86
+ print(f"\n{host_interfaces}")
87
+ while True:
88
+ host_address = input(
89
+ "\nPlease enter your host IP for the network the device is connected to: "
90
+ )
91
+
92
+ if possible_ips and host_address not in host_interfaces.split("\n"):
93
+ print("Error: Invalid IP given")
94
+ continue
95
+
96
+ if "n" in input("Are you sure? (Y/n): ").lower():
97
+ continue
98
+
99
+ break
100
+
101
+ return host_address
102
+
103
+ def get_remarkable_address(self) -> str:
104
+ """Gets the IP address of the remarkable device
105
+
106
+ Returns:
107
+ str: IP address of the remarkable device
108
+ """
109
+
110
+ if self.check_is_address_reachable("10.11.99.1"):
111
+ return "10.11.99.1"
112
+
113
+ while True:
114
+ remote_ip = input("Please enter the IP of the remarkable device: ")
115
+
116
+ if self.check_is_address_reachable(remote_ip):
117
+ return remote_ip
118
+
119
+ print(f"Error: Device {remote_ip} is not reachable. Please try again.")
120
+
121
+ def check_is_address_reachable(self, remote_ip="10.11.99.1") -> bool:
122
+ """Checks if the given IP address is reachable over SSH
123
+
124
+ Args:
125
+ remote_ip (str, optional): IP to check. Defaults to '10.11.99.1'.
126
+
127
+ Returns:
128
+ bool: True if reachable, False otherwise
129
+ """
130
+ self.logger.debug(f"Checking if {remote_ip} is reachable")
131
+ try:
132
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
133
+ sock.settimeout(1)
134
+
135
+ sock.connect((remote_ip, 22))
136
+ sock.shutdown(2)
137
+
138
+ return True
139
+
140
+ except Exception:
141
+ self.logger.debug(f"Device {remote_ip} is not reachable")
142
+ return False
143
+
144
+ def connect_to_device(
145
+ self, remote_address=None, authentication=None
146
+ ) -> paramiko.client.SSHClient:
147
+ """Connects to the device using the given IP address
148
+
149
+ Args:
150
+ remote_address (str, optional): IP address of the device.
151
+ authentication (str, optional): Authentication credentials. Defaults to None.
152
+
153
+ Returns:
154
+ paramiko.client.SSHClient: SSH client object for the device.
155
+ """
156
+
157
+ if remote_address is None:
158
+ remote_address = self.get_remarkable_address()
159
+ self.address = remote_address # For future reference
160
+ else:
161
+ if self.check_is_address_reachable(remote_address) is False:
162
+ raise SystemError(f"Error: Device {remote_address} is not reachable!")
163
+
164
+ client = paramiko.client.SSHClient()
165
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
166
+
167
+ if authentication:
168
+ self.logger.debug(f"Using authentication: {authentication}")
169
+ try:
170
+ if os.path.isfile(authentication):
171
+ self.logger.debug(
172
+ f"Attempting to connect to {remote_address} with key file {authentication}"
173
+ )
174
+ client.connect(
175
+ remote_address, username="root", key_filename=authentication
176
+ )
177
+ else:
178
+ self.logger.debug(
179
+ f"Attempting to connect to {remote_address} with password {authentication}"
180
+ )
181
+ client.connect(
182
+ remote_address, username="root", password=authentication
183
+ )
184
+
185
+ except paramiko.ssh_exception.AuthenticationException:
186
+ print("Incorrect password or ssh path given in arguments!")
187
+
188
+ elif (
189
+ "n" in input("Would you like to use a password to connect? (Y/n): ").lower()
190
+ ):
191
+ while True:
192
+ key_path = input("Enter path to SSH key: ")
193
+
194
+ if not os.path.isfile(key_path):
195
+ print("Invalid path given")
196
+
197
+ continue
198
+ try:
199
+ self.logger.debug(
200
+ f"Attempting to connect to {remote_address} with key file {key_path}"
201
+ )
202
+ client.connect(
203
+ remote_address, username="root", key_filename=key_path
204
+ )
205
+ except Exception:
206
+ print("Error while connecting to device: {error}")
207
+
208
+ continue
209
+ break
210
+ else:
211
+ while True:
212
+ password = input("Enter RM SSH password: ")
213
+
214
+ try:
215
+ self.logger.debug(
216
+ f"Attempting to connect to {remote_address} with password {password}"
217
+ )
218
+ client.connect(remote_address, username="root", password=password)
219
+ except paramiko.ssh_exception.AuthenticationException:
220
+ print("Incorrect password given")
221
+
222
+ continue
223
+ break
224
+
225
+ print("Success: Connected to device")
226
+
227
+ return client
228
+
229
+ def get_device_status(self) -> tuple[str | None, str, str]:
230
+ """Gets the status of the device
231
+
232
+ Returns:
233
+ tuple: Beta status, previous version, and current version (in that order)
234
+ """
235
+ old_update_engine = True
236
+
237
+ if self.client:
238
+ self.logger.debug("Connecting to FTP")
239
+ ftp = self.client.open_sftp()
240
+ self.logger.debug("Connected")
241
+
242
+ try:
243
+ with ftp.file("/usr/share/remarkable/update.conf") as file:
244
+ xochitl_version = re.search(
245
+ "(?<=REMARKABLE_RELEASE_VERSION=).*",
246
+ file.read().decode("utf-8").strip("\n"),
247
+ ).group()
248
+ except Exception:
249
+ with ftp.file("/etc/os-release") as file:
250
+ xochitl_version = (
251
+ re.search("(?<=IMG_VERSION=).*", file.read().decode("utf-8"))
252
+ .group()
253
+ .strip('"')
254
+ )
255
+ old_update_engine = False
256
+
257
+ with ftp.file("/etc/version") as file:
258
+ version_id = file.read().decode("utf-8").strip("\n")
259
+
260
+ with ftp.file("/home/root/.config/remarkable/xochitl.conf") as file:
261
+ beta_contents = file.read().decode("utf-8")
262
+
263
+ else:
264
+ if os.path.exists("/usr/share/remarkable/update.conf"):
265
+ with open("/usr/share/remarkable/update.conf") as file:
266
+ xochitl_version = re.search(
267
+ "(?<=REMARKABLE_RELEASE_VERSION=).*",
268
+ file.read().decode("utf-8").strip("\n"),
269
+ ).group()
270
+ else:
271
+ with open("/etc/os-release") as file:
272
+ xochitl_version = (
273
+ re.search("(?<=IMG_VERSION=).*", file.read().decode("utf-8"))
274
+ .group()
275
+ .strip('"')
276
+ )
277
+
278
+ old_update_engine = False
279
+ if os.path.exists("/etc/version"):
280
+ with open("/etc/version") as file:
281
+ version_id = file.read().rstrip()
282
+ else:
283
+ version_id = ""
284
+
285
+ if os.path.exists("/home/root/.config/remarkable/xochitl.conf"):
286
+ with open("/home/root/.config/remarkable/xochitl.conf") as file:
287
+ beta_contents = file.read().rstrip()
288
+ else:
289
+ beta_contents = ""
290
+
291
+ beta_possible = re.search("(?<=GROUP=).*", beta_contents)
292
+ beta = "Release"
293
+
294
+ if beta_possible is not None:
295
+ beta = re.search("(?<=GROUP=).*", beta_contents).group()
296
+
297
+ return beta, old_update_engine, xochitl_version, version_id
298
+
299
+ def set_server_config(self, contents: str, server_host_name: str) -> str:
300
+ """Converts the contents given to point to the given server IP and port
301
+
302
+ Args:
303
+ contents (str): Contents of the update.conf file
304
+ server_host_name (str): Hostname of the server
305
+
306
+ Returns:
307
+ str: Converted contents
308
+ """
309
+ data_attributes = contents.split("\n")
310
+ line = 0
311
+
312
+ self.logger.debug(f"Contents are:\n{contents}")
313
+
314
+ for i in range(0, len(data_attributes)):
315
+ if data_attributes[i].startswith("[General]"):
316
+ self.logger.debug("Found [General] line")
317
+ line = i + 1
318
+ if not data_attributes[i].startswith("SERVER="):
319
+ continue
320
+
321
+ data_attributes[i] = f"#{data_attributes[i]}"
322
+ self.logger.debug(f"Using {data_attributes[i]}")
323
+
324
+ data_attributes.insert(line, f"SERVER={server_host_name}")
325
+ converted = "\n".join(data_attributes)
326
+
327
+ self.logger.debug(f"Converted contents are:\n{converted}")
328
+
329
+ return converted
330
+
331
+ def edit_update_conf(self, server_ip: str, server_port: str) -> bool:
332
+ """Edits the update.conf file to point to the given server IP and port
333
+
334
+ Args:
335
+ server_ip (str): IP of update server
336
+ server_port (str): Port of update service
337
+
338
+ Returns:
339
+ bool: True if successful, False otherwise
340
+ """
341
+ server_host_name = f"http://{server_ip}:{server_port}"
342
+ self.logger.debug(f"Hostname is: {server_host_name}")
343
+ try:
344
+ if not self.client:
345
+ self.logger.debug("Detected running on local device")
346
+ with open(
347
+ "/usr/share/remarkable/update.conf", encoding="utf-8"
348
+ ) as file:
349
+ modified_conf_version = self.set_server_config(
350
+ file.read(), server_host_name
351
+ )
352
+
353
+ with open("/usr/share/remarkable/update.conf", "w") as file:
354
+ file.write(modified_conf_version)
355
+
356
+ return True
357
+
358
+ self.logger.debug("Connecting to FTP")
359
+ ftp = self.client.open_sftp() # or ssh
360
+ self.logger.debug("Connected")
361
+
362
+ with ftp.file("/usr/share/remarkable/update.conf") as update_conf_file:
363
+ modified_conf_version = self.set_server_config(
364
+ update_conf_file.read().decode("utf-8"), server_host_name
365
+ )
366
+
367
+ with ftp.file("/usr/share/remarkable/update.conf", "w") as update_conf_file:
368
+ update_conf_file.write(modified_conf_version)
369
+
370
+ return True
371
+ except Exception as error:
372
+ self.logger.error(f"Error while editing update.conf: {error}")
373
+ return False
374
+
375
+ def restore_previous_version(self) -> None:
376
+ """Restores the previous version of the device"""
377
+
378
+ RESTORE_CODE = """/sbin/fw_setenv "upgrade_available" "1"
379
+ /sbin/fw_setenv "bootcount" "0"
380
+
381
+ OLDPART=$(/sbin/fw_printenv -n active_partition)
382
+ if [ $OLDPART == "2" ]; then
383
+ NEWPART="3"
384
+ else
385
+ NEWPART="2"
386
+ fi
387
+ echo "new: ${NEWPART}"
388
+ echo "fallback: ${OLDPART}"
389
+
390
+ /sbin/fw_setenv "fallback_partition" "${OLDPART}"
391
+ /sbin/fw_setenv "active_partition" "${NEWPART}\""""
392
+
393
+ if self.client:
394
+ self.logger.debug("Connecting to FTP")
395
+ ftp = self.client.open_sftp()
396
+ self.logger.debug("Connected")
397
+
398
+ with ftp.file("/usr/bin/restore.sh", "w") as file:
399
+ file.write(RESTORE_CODE)
400
+
401
+ self.logger.debug("Setting permissions and running restore.sh")
402
+
403
+ self.client.exec_command("chmod +x /usr/bin/restore.sh")
404
+ self.client.exec_command("bash /usr/bin/restore.sh")
405
+ else:
406
+ with open("/usr/bin/restore.sh", "w") as file:
407
+ file.write(RESTORE_CODE)
408
+
409
+ self.logger.debug("Setting permissions and running restore.sh")
410
+
411
+ os.system("chmod +x /usr/bin/restore.sh")
412
+ os.system("/usr/bin/restore.sh")
413
+
414
+ self.logger.debug("Restore script ran")
415
+
416
+ def install_sw_update(self, version_file: str) -> None:
417
+ """
418
+ Installs new version from version file path, utilising swupdate
419
+
420
+ Args:
421
+ version_file (str): Path to img file
422
+
423
+ Raises:
424
+ SystemExit: If there was an error installing the update
425
+
426
+ """
427
+ command = f'/usr/bin/swupdate -v -i VERSION_FILE -k /usr/share/swupdate/swupdate-payload-key-pub.pem -H "{self.hardware}:1.0" -e "stable,copy1"'
428
+
429
+ if self.client:
430
+ ftp_client = self.client.open_sftp()
431
+
432
+ print(f"Uploading {version_file} image")
433
+
434
+ out_location = f'/tmp/{os.path.basename(version_file)}.swu'
435
+ ftp_client.put(
436
+ version_file, out_location, callback=self.output_put_progress
437
+ )
438
+
439
+ print("\nDone! Running swupdate (PLEASE BE PATIENT, ~5 MINUTES)")
440
+
441
+ command = command.replace("VERSION_FILE", out_location)
442
+
443
+ for num in (1, 2):
444
+ command = command.replace(
445
+ "stable,copy1", f"stable,copy{num}"
446
+ ) # terrible hack but it works
447
+ self.logger.debug(command)
448
+ _stdin, stdout, _stderr = self.client.exec_command(command)
449
+
450
+ self.logger.debug(f"Stdout of swupdate checking: {stdout.readlines()}")
451
+
452
+ exit_status = stdout.channel.recv_exit_status()
453
+
454
+ if exit_status != 0:
455
+ if "over our current root" in "".join(_stderr.readlines()):
456
+ continue
457
+ else:
458
+ print("".join(_stderr.readlines()))
459
+ raise SystemError("Update failed!")
460
+
461
+ print("Done! Now rebooting the device and disabling update service")
462
+
463
+ #### Now disable automatic updates
464
+
465
+ self.client.exec_command("sleep 1 && reboot") # Should be enough
466
+ self.client.close()
467
+
468
+ time.sleep(
469
+ 2
470
+ ) # Somehow the code runs faster than the time it takes for the device to reboot
471
+
472
+ print("Trying to connect to device")
473
+
474
+ while not self.check_is_address_reachable(self.address):
475
+ time.sleep(1)
476
+
477
+ self.client = self.connect_to_device(
478
+ remote_address=self.address, authentication=self.authentication
479
+ )
480
+ self.client.exec_command("systemctl stop swupdate memfaultd")
481
+
482
+ print(
483
+ "Update complete and update service disabled, restart device to enable it"
484
+ )
485
+
486
+ else:
487
+ print("Running swupdate")
488
+ command = command.replace("VERSION_FILE", version_file)
489
+
490
+ for num in (1, 2):
491
+ command = command.replace(
492
+ "stable,copy1", f"stable,copy{num}"
493
+ ) # terrible hack but it works
494
+ self.logger.debug(command)
495
+
496
+ with subprocess.Popen(
497
+ command,
498
+ text=True,
499
+ shell=True, # Being lazy...
500
+ stdout=subprocess.PIPE,
501
+ stderr=subprocess.PIPE,
502
+ env={"PATH": "/bin:/usr/bin:/sbin"},
503
+ ) as process:
504
+ if process.wait() != 0:
505
+ if "installing over our current root" in "".join(
506
+ process.stderr.readlines()
507
+ ):
508
+ continue
509
+ else:
510
+ print("".join(process.stderr.readlines()))
511
+ raise SystemError("Update failed")
512
+
513
+ self.logger.debug(
514
+ f'Stdout of update checking service is {"".join(process.stdout.readlines())}'
515
+ )
516
+
517
+ print("Update complete and device rebooting")
518
+ os.system("reboot")
519
+
520
+ def install_ohma_update(self, version_available: dict) -> None:
521
+ """Installs version from update folder on the device
522
+
523
+ Args:
524
+ version_available (dict): Version available for installation from `get_available_version`
525
+
526
+ Raises:
527
+ SystemExit: If there was an error installing the update
528
+ """
529
+
530
+ server_host = self.get_host_address()
531
+
532
+ self.logger.debug("Editing config file")
533
+
534
+ if (
535
+ self.edit_update_conf(server_ip=server_host, server_port=8085) is False
536
+ ): # We want a port that probably isn't being used
537
+ self.logger.error("Error while editing update.conf")
538
+
539
+ return
540
+
541
+ thread = threading.Thread(
542
+ target=startUpdate, args=(version_available, server_host), daemon=True
543
+ )
544
+ thread.start()
545
+
546
+ self.logger.debug("Thread started")
547
+
548
+ if self.client:
549
+ print("Checking if device can connect to this machine")
550
+
551
+ _stdin, stdout, _stderr = self.client.exec_command(
552
+ f"sleep 2 && echo | nc {server_host} 8085"
553
+ )
554
+ check = stdout.channel.recv_exit_status()
555
+ self.logger.debug(f"Stdout of nc checking: {stdout.readlines()}")
556
+
557
+ if check != 0:
558
+ raise SystemError(
559
+ "Device cannot connect to this machine! Is the firewall blocking connections?"
560
+ )
561
+
562
+ print("Starting update service on device")
563
+
564
+ self.client.exec_command("systemctl start update-engine")
565
+
566
+ _stdin, stdout, _stderr = self.client.exec_command(
567
+ "/usr/bin/update_engine_client -update"
568
+ )
569
+ exit_status = stdout.channel.recv_exit_status()
570
+
571
+ if exit_status != 0:
572
+ print("".join(_stderr.readlines()))
573
+ raise SystemError("There was an error updating :(")
574
+
575
+ self.logger.debug(
576
+ f'Stdout of update checking service is {"".join(_stderr.readlines())}'
577
+ )
578
+
579
+ #### Now disable automatic updates
580
+
581
+ print("Done! Now rebooting the device and disabling update service")
582
+
583
+ self.client.exec_command("sleep 1 && reboot") # Should be enough
584
+ self.client.close()
585
+
586
+ time.sleep(
587
+ 2
588
+ ) # Somehow the code runs faster than the time it takes for the device to reboot
589
+
590
+ print("Trying to connect to device")
591
+
592
+ while not self.check_is_address_reachable(address):
593
+ time.sleep(1)
594
+
595
+ self.client = self.connect_to_device(
596
+ remote_address=address, authentication=authentication
597
+ )
598
+ self.client.exec_command("systemctl stop update-engine")
599
+
600
+ print(
601
+ "Update complete and update service disabled. Restart device to enable it"
602
+ )
603
+
604
+ else:
605
+ print("Enabling update service")
606
+
607
+ subprocess.run(
608
+ ["/bin/systemctl", "start", "update-engine"],
609
+ text=True,
610
+ check=True,
611
+ env={"PATH": "/bin:/usr/bin:/sbin"},
612
+ )
613
+
614
+ with subprocess.Popen(
615
+ ["/usr/bin/update_engine_client", "-update"],
616
+ text=True,
617
+ stdout=subprocess.PIPE,
618
+ stderr=subprocess.PIPE,
619
+ env={"PATH": "/bin:/usr/bin:/sbin"},
620
+ ) as process:
621
+ if process.wait() != 0:
622
+ print("".join(process.stderr.readlines()))
623
+
624
+ raise SystemError("There was an error updating :(")
625
+
626
+ self.logger.debug(
627
+ f'Stdout of update checking service is {"".join(process.stderr.readlines())}'
628
+ )
629
+
630
+ print("Update complete and device rebooting")
631
+ os.system("reboot")
632
+
633
+ @staticmethod
634
+ def output_put_progress(transferred: int, toBeTransferred: int) -> None:
635
+ """Used for displaying progress for paramiko ftp.put function"""
636
+
637
+ print(
638
+ f"Transferring progress{int((transferred/toBeTransferred)*100)}%",
639
+ end="\r",
640
+ )