diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 998ba52..3d94399 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -115,6 +115,21 @@ jobs: name: Build for remarkable runs-on: ubuntu-latest steps: + - name: Free up disk space + shell: bash + run: | + sudo rm -vrf \ + /usr/lib/jvm \ + /usr/share/dotnet \ + /usr/share/swift \ + /usr/local/.ghcup \ + /usr/local/julia* \ + /usr/local/lib/android \ + /usr/local/share/chromium \ + /opt/microsoft /opt/google \ + /opt/az \ + /usr/local/share/powershell \ + | wc -l - name: Checkout the codexctl repository uses: actions/checkout@v4 - name: Nuitka ccache @@ -210,4 +225,4 @@ jobs: tag: ${{ env.TAG }} commit: ${{ github.sha }} generateReleaseNotes: true - makeLatest: true \ No newline at end of file + makeLatest: true diff --git a/Makefile b/Makefile index 0184d66..6b80488 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,11 @@ FW_DATA := wVbHkgKisg- IMG_SHA := fc7d145e18f14a1a3f435f2fd5ca5924fe8dfe59bf45605dc540deed59551ae4 LS_DATA := ". .. lost+found bin boot dev etc home lib media mnt postinst proc run sbin sys tmp uboot-postinst uboot-version usr var" CAT_DATA := 20221026104022 +FW_VERSION_SWU := 3.20.0.92 +FW_FILE_SWU := remarkable-production-memfault-image-$(FW_VERSION_SWU)-rm2-public +IMG_SHA_SWU := 7de74325d82d249ccd644e6a6be2ada954a225cfe434d3bf16c4fa6e1c145eb9 +LS_DATA_SWU := ". .. lost+found bin boot dev etc home lib media mnt postinst postinst-waveform proc run sbin srv sys tmp uboot-version usr var" +CAT_DATA_SWU := 20250613122401 SHELL := /bin/bash ifeq ($(OS),Windows_NT) @@ -49,7 +54,13 @@ $(VENV_BIN_ACTIVATE): requirements.remote.txt requirements.txt . $(VENV_BIN_ACTIVATE); \ python -m codexctl download --hardware rm2 --out .venv ${FW_VERSION} -test: $(VENV_BIN_ACTIVATE) .venv/${FW_VERSION}_reMarkable2-${FW_DATA}.signed +.venv/$(FW_FILE_SWU): $(VENV_BIN_ACTIVATE) $(OBJ) + @echo "[info] Downloading remarkable .swu update file" + @set -e; \ + . $(VENV_BIN_ACTIVATE); \ + python -m codexctl download --hardware rm2 --out .venv $(FW_VERSION_SWU) + +test: $(VENV_BIN_ACTIVATE) .venv/${FW_VERSION}_reMarkable2-${FW_DATA}.signed .venv/$(FW_FILE_SWU) @echo "[info] Running test" @set -e; \ . $(VENV_BIN_ACTIVATE); \ @@ -74,9 +85,21 @@ test: $(VENV_BIN_ACTIVATE) .venv/${FW_VERSION}_reMarkable2-${FW_DATA}.signed if ! diff --color <(python -m codexctl cat ".venv/${FW_VERSION}_reMarkable2-${FW_DATA}.signed" /etc/version | tr -d "\n\r") <(echo -n ${CAT_DATA}) | cat -te; then \ echo "codexctl cat failed test"; \ exit 1; \ + fi; \ + echo "[info] Running .swu tests"; \ + python -m codexctl extract --out ".venv/$(FW_FILE_SWU).img" ".venv/$(FW_FILE_SWU)"; \ + echo "$(IMG_SHA_SWU) .venv/$(FW_FILE_SWU).img" | $(SHA256SUM) -c; \ + rm -f ".venv/$(FW_FILE_SWU).img"; \ + if ! diff --color <(python -m codexctl ls ".venv/$(FW_FILE_SWU)" / | tr -d "\n\r") <(echo -n $(LS_DATA_SWU)) | cat -te; then \ + echo "codexctl ls .swu failed test"; \ + exit 1; \ + fi; \ + if ! diff --color <(python -m codexctl cat ".venv/$(FW_FILE_SWU)" /etc/version | tr -d "\n\r") <(echo -n $(CAT_DATA_SWU)) | cat -te; then \ + echo "codexctl cat .swu failed test"; \ + exit 1; \ fi -test-executable: .venv/${FW_VERSION}_reMarkable2-${FW_DATA}.signed +test-executable: .venv/${FW_VERSION}_reMarkable2-${FW_DATA}.signed .venv/$(FW_FILE_SWU) @set -e; \ . $(VENV_BIN_ACTIVATE); \ dist/${CODEXCTL_BIN} extract --out ".venv/${FW_VERSION}_reMarkable2-${FW_DATA}.img" ".venv/${FW_VERSION}_reMarkable2-${FW_DATA}.signed"; \ @@ -90,6 +113,18 @@ test-executable: .venv/${FW_VERSION}_reMarkable2-${FW_DATA}.signed if ! diff --color <(dist/${CODEXCTL_BIN} cat ".venv/${FW_VERSION}_reMarkable2-${FW_DATA}.signed" /etc/version | tr -d "\n\r") <(echo -n ${CAT_DATA}) | cat -te; then \ echo "codexctl cat failed test"; \ exit 1; \ + fi; \ + echo "[info] Running .swu tests"; \ + dist/${CODEXCTL_BIN} extract --out ".venv/$(FW_FILE_SWU).img" ".venv/$(FW_FILE_SWU)"; \ + echo "$(IMG_SHA_SWU) .venv/$(FW_FILE_SWU).img" | $(SHA256SUM) -c; \ + rm -f ".venv/$(FW_FILE_SWU).img"; \ + if ! diff --color <(dist/${CODEXCTL_BIN} ls ".venv/$(FW_FILE_SWU)" / | tr -d "\n\r") <(echo -n $(LS_DATA_SWU)) | cat -te; then \ + echo "codexctl ls .swu failed test"; \ + exit 1; \ + fi; \ + if ! diff --color <(dist/${CODEXCTL_BIN} cat ".venv/$(FW_FILE_SWU)" /etc/version | tr -d "\n\r") <(echo -n $(CAT_DATA_SWU)) | cat -te; then \ + echo "codexctl cat .swu failed test"; \ + exit 1; \ fi clean: diff --git a/README.md b/README.md index 3ef821a..c2584ba 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ A utility program that helps to manage the remarkable device version utilizing [ If your reMarkable device is at or above 3.11.2.5 and you want to downgrade to a version below 3.11.2.5, codexctl cannot do this currently. Please refer to https://github.com/Jayy001/codexctl/issues/95#issuecomment-2305529048 for manual instructions. +## Paper Pro bootloader updates + +When downgrading a Paper Pro device across the 3.20/3.22 firmware boundary, codexctl automatically handles bootloader updates. It will download the current version's firmware if needed and extract the necessary bootloader files (`update-bootloader.sh` and `imx-boot`) to ensure a safe downgrade. + ## Installation You can find pre-compiled binaries on the [releases](https://github.com/Jayy001/codexctl/releases/) page. This includes a build for the reMarkable itself, as well as well as builds for linux, macOS, and Windows. Alternatively, you can install directly from pypi with `pip install codexctl`. Codexctl currently only has support for a **command line interfaces** but a graphical interface is soon to come. @@ -53,6 +57,11 @@ codexctl install latest codexctl download 3.15.4.2 --hardware rmpp -o out codexctl install ./out/remarkable-ct-prototype-image-3.15.4.2-ferrari-public.swu ``` +- Downloading rmppm version 3.23.0.64 to a folder named `out` and then installing it +``` +codexctl download 3.23.0.64 --hardware rmppm -o out +codexctl install ./out/remarkable-production-image-3.23.0.64-chiappa-public.swu +``` - Backing up all documents to the cwd ``` codexctl backup @@ -65,7 +74,7 @@ codexctl backup --incremental ``` codexctl backup -l root -r FM --no-recursion --no-overwrite ``` -- Getting the version of the device and then switching to previous version (restore only for rm1/rm2) +- Getting the version of the device and then switching to previous version ``` codexctl status codexctl restore @@ -75,3 +84,7 @@ codexctl restore codexctl download 3.8.0.1944 --hardware rm2 codexctl cat 3.8.0.1944_reMarkable2-7eGpAv7sYB.signed /etc/version ``` +- Extract the filesystem image of an upgrade file as a file named `extracted` +``` +codexctl extract remarkable-production-image-3.22.0.64-ferrari-public.swu -o extracted +``` diff --git a/codexctl/__init__.py b/codexctl/__init__.py index 9d445c8..871c834 100644 --- a/codexctl/__init__.py +++ b/codexctl/__init__.py @@ -70,12 +70,17 @@ def call_func(self, function: str, args: dict) -> None: remarkable_pp_versions = "\n".join( self.updater.remarkablepp_versions.keys() ) + remarkable_ppm_versions = "\n".join( + self.updater.remarkableppm_versions.keys() + ) remarkable_2_versions = "\n".join(self.updater.remarkable2_versions.keys()) remarkable_1_versions = "\n".join(self.updater.remarkable1_versions.keys()) version_blocks = [] if remarkable_version is None or remarkable_version == HardwareType.RMPP: version_blocks.append(f"ReMarkable Paper Pro:\n{remarkable_pp_versions}") + if remarkable_version is None or remarkable_version == HardwareType.RMPPM: + version_blocks.append(f"ReMarkable Paper Pro Move:\n{remarkable_ppm_versions}") if remarkable_version is None or remarkable_version == HardwareType.RM2: version_blocks.append(f"ReMarkable 2:\n{remarkable_2_versions}") if remarkable_version is None or remarkable_version == HardwareType.RM1: @@ -95,24 +100,35 @@ def call_func(self, function: str, args: dict) -> None: ### Mounting functionalities elif function in ("extract", "mount"): - try: - from .analysis import get_update_image - except ImportError: - raise ImportError( - "remarkable_update_image is required for analysis. Please install it!" - ) - if function == "extract": if not args["out"]: args["out"] = os.getcwd() + "/extracted" logger.debug(f"Extracting {args['file']} to {args['out']}") - image, volume = get_update_image(args["file"]) + + try: + from remarkable_update_image import UpdateImage + except ImportError: + raise ImportError( + "remarkable_update_image is required for extraction. Please install it!" + ) from None + + image = UpdateImage(args["file"]) image.seek(0) with open(args["out"], "wb") as f: f.write(image.read()) + + logger.info(f"Extracted image to {args['out']}") else: + try: + from .analysis import get_update_image + from remarkable_update_fuse import UpdateFS + except ImportError: + raise ImportError( + "remarkable_update_fuse is required for mounting. Please install it!" + ) + if args["out"] is None: args["out"] = "/opt/remarkable/" @@ -122,8 +138,6 @@ def call_func(self, function: str, args: dict) -> None: if not os.path.exists(args["filesystem"]): raise SystemExit("Firmware file does not exist!") - from remarkable_update_fuse import UpdateFS - server = UpdateFS() server.parse( args=[args["filesystem"], args["out"]], values=server, errex=1 @@ -193,6 +207,13 @@ def call_func(self, function: str, args: dict) -> None: ) remote = True + try: + from remarkable_update_image import UpdateImage + except ImportError: + raise ImportError( + "remarkable_update_image is required for install. Please install it!" + ) from None + from .device import DeviceManager from .server import get_available_version @@ -209,17 +230,15 @@ def call_func(self, function: str, args: dict) -> None: version = self.updater.get_toltec_version(remarkable.hardware) if function == "status": - beta, prev, current, version_id = remarkable.get_device_status() + beta, prev, current, version_id, backup = remarkable.get_device_status() print( - f"\nCurrent version: {current}\nOld update engine: {prev}\nBeta active: {beta}\nVersion id: {version_id}" + f"\nCurrent version: {current}\nBackup version: {backup}\nOld update engine: {prev}\nBeta active: {beta}\nVersion id: {version_id}" ) elif function == "restore": - if remarkable.hardware == HardwareType.RMPP: - raise SystemError("Restore not available for rmpro.") remarkable.restore_previous_version() print( - f"Device restored to previous version [{remarkable.get_device_status()[1]}]" + f"Device restored to previous version [{remarkable.get_device_status()[4]}]" ) remarkable.reboot_device() print("Device rebooted") @@ -236,18 +255,56 @@ def call_func(self, function: str, args: dict) -> None: def version_lookup(version: str | None) -> re.Match[str] | None: return re.search(r"\b\d+\.\d+\.\d+\.\d+\b", cast(str, version)) - version_number = version_lookup(version) + version_number = None + swu_hardware = None + + if update_file: + try: + from remarkable_update_image import UpdateImage + from remarkable_update_image.cpio import UpdateImage as CPIOUpdateImage + + image = UpdateImage(update_file) + if isinstance(image, CPIOUpdateImage): + if image.version is None: + raise SystemError(f"Could not determine version from SWU file: {update_file}") + + version_number = image.version + hw_map = { + "reMarkable1": HardwareType.RM1, + "reMarkable2": HardwareType.RM2, + "ferrari": HardwareType.RMPP, + "chiappa": HardwareType.RMPPM, + } + if image.hardware_type not in hw_map: + raise SystemError(f"Unsupported hardware type in SWU file: {update_file}") + + swu_hardware = hw_map[image.hardware_type] + logger.info(f"Extracted from SWU: version={version_number}, hardware={swu_hardware.name}") + + if swu_hardware != remarkable.hardware: + raise SystemError( + f"Hardware mismatch!\n" + f"SWU file is for: {swu_hardware.name}\n" + f"Connected device is: {remarkable.hardware.name}\n" + f"Cannot install firmware for different hardware." + ) + except ValueError as e: + logger.warning(f"Could not extract metadata from update file: {e}") if not version_number: - version_number = version_lookup( - input( - "Failed to get the version number from the filename, please enter it: " + version_match = version_lookup(version) + if not version_match: + version_match = version_lookup( + input( + "Failed to get the version number from the filename, please enter it: " + ) ) - ) - if not version_number: - raise SystemError("Invalid version!") + if not version_match: + raise SystemError("Invalid version!") - version_number = version_number.group() + version_number = version_match.group() + else: + version_number = str(version_number) update_file_requires_new_engine = UpdateManager.uses_new_update_engine( version_number @@ -278,6 +335,68 @@ def version_lookup(version: str | None) -> re.Match[str] | None: ############################################################# + bootloader_files_for_install = None + + if (device_version_uses_new_engine and + remarkable.hardware == HardwareType.RMPP): + + current_version = remarkable.get_device_status()[2] + + if UpdateManager.is_bootloader_boundary_downgrade(current_version, version_number): + print("\n" + "="*60) + print("WARNING: Bootloader Update Required") + print("="*60) + print(f"Current version: {current_version}") + print(f"Target version: {version_number}") + print() + print("Downgrading from 3.22+ to <3.22 requires updating the") + print("bootloader on both partitions. This process will:") + print(" 1. Download the current version's bootloader files") + print(" 2. Download the target OS version") + print(" 3. Install the target OS version") + print(" 4. Update bootloader on both partitions") + print(" 5. Reboot") + print() + + response = input("Do you want to continue? (y/N): ") + if response.lower() != 'y': + raise SystemExit("Installation cancelled by user") + + expected_swu_name = f"remarkable-production-memfault-image-{current_version}-{remarkable.hardware.new_download_hw}-public" + expected_swu_path = f"./{expected_swu_name}" + + if os.path.isfile(expected_swu_path): + print(f"\nUsing existing {expected_swu_name} for bootloader extraction...") + current_swu_path = expected_swu_path + else: + print("\nDownloading current version's SWU for bootloader extraction...") + current_swu_path = self.updater.download_version( + remarkable.hardware, + current_version, + "./" + ) + + if not current_swu_path: + raise SystemError( + f"Failed to download current version {current_version} for bootloader extraction. " + f"This is required for safe downgrade across bootloader boundary." + ) + + print("Extracting bootloader files...") + from remarkable_update_image import UpdateImage + swu_image = UpdateImage(current_swu_path) + bootloader_files_for_install = { + 'update-bootloader.sh': swu_image.archive[b'update-bootloader.sh'].read(), + 'imx-boot': swu_image.archive[b'imx-boot'].read(), + } + + if not all(bootloader_files_for_install.values()): + raise SystemError("Failed to extract bootloader files from current version") + + print(f"✓ Extracted update-bootloader.sh ({len(bootloader_files_for_install['update-bootloader.sh'])} bytes)") + print(f"✓ Extracted imx-boot ({len(bootloader_files_for_install['imx-boot'])} bytes)") + print() + if not update_file_requires_new_engine: if update_file: # Check if file exists if os.path.dirname( @@ -318,7 +437,7 @@ def version_lookup(version: str | None) -> re.Match[str] | None: ) if device_version_uses_new_engine: - remarkable.install_sw_update(update_file) + remarkable.install_sw_update(update_file, bootloader_files=bootloader_files_for_install) else: remarkable.install_ohma_update(update_file) diff --git a/codexctl/analysis.py b/codexctl/analysis.py index 8e8fe85..f84c4d3 100644 --- a/codexctl/analysis.py +++ b/codexctl/analysis.py @@ -1,9 +1,10 @@ import ext4 -import warnings +import warnings import errno from remarkable_update_image import UpdateImage from remarkable_update_image import UpdateImageSignatureException +from .device import HardwareType def get_update_image(file: str): diff --git a/codexctl/device.py b/codexctl/device.py index 02e4975..4cd75b0 100644 --- a/codexctl/device.py +++ b/codexctl/device.py @@ -1,10 +1,11 @@ import enum +import logging +import os +import re import socket import subprocess -import logging +import tempfile import threading -import re -import os import time from .server import startUpdate @@ -20,17 +21,25 @@ class HardwareType(enum.Enum): RM1 = enum.auto() RM2 = enum.auto() RMPP = enum.auto() + RMPPM = enum.auto() @classmethod def parse(cls, device_type: str) -> "HardwareType": - if device_type.lower() in ("pp", "pro", "rmpp", "ferrari", "remarkable ferrari"): - return cls.RMPP - elif device_type.lower() in ("2", "rm2", "remarkable 2", "remarkable 2.0"): - return cls.RM2 - elif device_type.lower() in ("1", "rm1", "remarkable 1", "remarkable 1.0", "remarkable prototype 1"): - return cls.RM1 + match device_type.lower(): + case "ppm" | "rmppm" | "chiappa" | "remarkable chiappa": + return cls.RMPPM + + case "pp" | "pro" | "rmpp" | "ferrari" | "remarkable ferrari": + return cls.RMPP - raise ValueError(f"Unknown hardware version: {device_type} (rm1, rm2, rmpp)") + case "2" | "rm2" | "remarkable 2" | "remarkable 2.0": + return cls.RM2 + + case "1" | "rm1" | "remarkable 1" | "remarkable 1.0" | "remarkable prototype 1": + return cls.RM1 + + case _: + raise ValueError(f"Unknown hardware version: {device_type} (rm1, rm2, rmpp, rmppm)") @property def old_download_hw(self): @@ -40,7 +49,9 @@ def old_download_hw(self): case HardwareType.RM2: return "reMarkable2" case HardwareType.RMPP: - raise ValueError("ReMarkable Paper Pro does not support the old update engine") + raise ValueError("reMarkable Paper Pro does not support the old update engine") + case HardwareType.RMPPM: + raise ValueError("reMarkable Paper Pro Move does not support the old update engine") @property def new_download_hw(self): @@ -51,6 +62,8 @@ def new_download_hw(self): return "rm2" case HardwareType.RMPP: return "rmpp" + case HardwareType.RMPPM: + return "rmppm" @property def swupdate_hw(self): @@ -61,6 +74,8 @@ def swupdate_hw(self): return "reMarkable2" case HardwareType.RMPP: return "ferrari" + case HardwareType.RMPPM: + return "chiappa" @property def toltec_type(self): @@ -70,7 +85,9 @@ def toltec_type(self): case HardwareType.RM2: return "rm2" case HardwareType.RMPP: - raise ValueError("ReMarkable Paper Pro does not support toltec") + raise ValueError("reMarkable Paper Pro does not support toltec") + case HardwareType.RMPPM: + raise ValueError("reMarkable Paper Pro Move does not support toltec") class DeviceManager: def __init__( @@ -274,11 +291,140 @@ def connect_to_device( return client - def get_device_status(self) -> tuple[str | None, str, str]: + def _read_version_from_path(self, ftp, base_path: str = "") -> tuple[str, bool]: + """Reads version from a given path (current partition or mounted backup) + + Args: + ftp: SFTP client connection + base_path: Base path prefix (empty for current partition, /tmp/mount_pX for backup) + + Returns: + tuple: (version_string, old_update_engine_boolean) + """ + update_conf_path = f"{base_path}/usr/share/remarkable/update.conf" if base_path else "/usr/share/remarkable/update.conf" + os_release_path = f"{base_path}/etc/os-release" if base_path else "/etc/os-release" + + def file_exists(path: str) -> bool: + try: + ftp.stat(path) + return True + except FileNotFoundError: + return False + + if file_exists(update_conf_path): + with ftp.file(update_conf_path) as file: + contents = file.read().decode("utf-8").strip("\n") + match = re.search("(?<=REMARKABLE_RELEASE_VERSION=).*", contents) + if match: + return match.group(), True + raise SystemError(f"REMARKABLE_RELEASE_VERSION not found in {update_conf_path}") + + if file_exists(os_release_path): + with ftp.file(os_release_path) as file: + contents = file.read().decode("utf-8") + match = re.search("(?<=IMG_VERSION=).*", contents) + if match: + return match.group().strip('"'), False + raise SystemError(f"IMG_VERSION not found in {os_release_path}") + + raise SystemError(f"Cannot read version from {base_path or 'current partition'}: no version file found") + + def _get_backup_partition_version(self) -> str: + """Gets the version installed on the backup (inactive) partition + + Returns: + str: Version string + + Raises: + SystemError: If backup partition version cannot be determined + """ + if not self.client: + raise SystemError("Cannot get backup partition version: no SSH client connection") + + ftp = self.client.open_sftp() + + if self.hardware in (HardwareType.RMPP, HardwareType.RMPPM): + _stdin, stdout, _stderr = self.client.exec_command("swupdate -g") + active_device = stdout.read().decode("utf-8").strip() + active_part = int(active_device.split('p')[-1]) + inactive_part = 3 if active_part == 2 else 2 + device_base = re.sub(r'p\d+$', '', active_device) + else: + _stdin, stdout, _stderr = self.client.exec_command("rootdev") + active_device = stdout.read().decode("utf-8").strip() + active_part = int(active_device.split('p')[-1]) + inactive_part = 3 if active_part == 2 else 2 + device_base = re.sub(r'p\d+$', '', active_device) + + mount_point = f"/tmp/mount_p{inactive_part}" + + self.client.exec_command(f"mkdir -p {mount_point}") + _stdin, stdout, _stderr = self.client.exec_command( + f"mount -o ro {device_base}p{inactive_part} {mount_point}" + ) + exit_status = stdout.channel.recv_exit_status() + + if exit_status != 0: + error_msg = _stderr.read().decode('utf-8') + raise SystemError(f"Failed to mount backup partition: {error_msg}") + + try: + version, _ = self._read_version_from_path(ftp, mount_point) + return version + finally: + self.client.exec_command(f"umount {mount_point}") + self.client.exec_command(f"rm -rf {mount_point}") + + def _get_paper_pro_partition_info(self, current_version: str) -> tuple[int, int, int]: + """Gets partition information for Paper Pro devices + + Args: + current_version: Current OS version string for version-aware detection + + Returns: + tuple: (current_partition, inactive_partition, next_boot_partition) + """ + if not self.client: + raise SystemError("SSH client required for partition detection") + + _stdin, stdout, _stderr = self.client.exec_command("swupdate -g") + active_device = stdout.read().decode("utf-8").strip() + current_part = int(active_device.split('p')[-1]) + inactive_part = 3 if current_part == 2 else 2 + + parts = current_version.split('.') + if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit(): + is_new_version = [int(parts[0]), int(parts[1])] >= [3, 22] + else: + raise SystemError(f"Cannot detect partition scheme: unexpected version format '{current_version}'") + + next_boot_part = current_part + + if is_new_version: + try: + ftp = self.client.open_sftp() + with ftp.file("/sys/bus/mmc/devices/mmc0:0001/boot_part") as file: + boot_part_value = file.read().decode("utf-8").strip() + next_boot_part = 2 if boot_part_value == "1" else 3 + except (IOError, OSError): + is_new_version = False + + if not is_new_version: + try: + ftp = self.client.open_sftp() + with ftp.file("/sys/devices/platform/lpgpr/root_part") as file: + root_part_value = file.read().decode("utf-8").strip() + next_boot_part = 2 if root_part_value == "a" else 3 + except (IOError, OSError) as e: + self.logger.debug(f"Failed to read next boot partition: {e}") + + return current_part, inactive_part, next_boot_part + + def get_device_status(self) -> tuple[str | None, str, str, str, str]: """Gets the status of the device Returns: - tuple: Beta status, previous version, and current version (in that order) + tuple: Beta status, old_update_engine, current version, version_id, backup version (in that order) """ old_update_engine = True @@ -287,20 +433,7 @@ def get_device_status(self) -> tuple[str | None, str, str]: ftp = self.client.open_sftp() self.logger.debug("Connected") - try: - with ftp.file("/usr/share/remarkable/update.conf") as file: - xochitl_version = re.search( - "(?<=REMARKABLE_RELEASE_VERSION=).*", - file.read().decode("utf-8").strip("\n"), - ).group() - except Exception: - with ftp.file("/etc/os-release") as file: - xochitl_version = ( - re.search("(?<=IMG_VERSION=).*", file.read().decode("utf-8")) - .group() - .strip('"') - ) - old_update_engine = False + xochitl_version, old_update_engine = self._read_version_from_path(ftp) with ftp.file("/etc/version") as file: version_id = file.read().decode("utf-8").strip("\n") @@ -310,15 +443,15 @@ def get_device_status(self) -> tuple[str | None, str, str]: else: if os.path.exists("/usr/share/remarkable/update.conf"): - with open("/usr/share/remarkable/update.conf") as file: + with open("/usr/share/remarkable/update.conf", encoding="utf-8") as file: xochitl_version = re.search( "(?<=REMARKABLE_RELEASE_VERSION=).*", - file.read().decode("utf-8").strip("\n"), + file.read().strip("\n"), ).group() else: - with open("/etc/os-release") as file: + with open("/etc/os-release", encoding="utf-8") as file: xochitl_version = ( - re.search("(?<=IMG_VERSION=).*", file.read().decode("utf-8")) + re.search("(?<=IMG_VERSION=).*", file.read()) .group() .strip('"') ) @@ -342,7 +475,9 @@ def get_device_status(self) -> tuple[str | None, str, str]: if beta_possible is not None: beta = re.search("(?<=GROUP=).*", beta_contents).group() - return beta, old_update_engine, xochitl_version, version_id + backup_version = self._get_backup_partition_version() + + return beta, old_update_engine, xochitl_version, version_id, backup_version def set_server_config(self, contents: str, server_host_name: str) -> str: """Converts the contents given to point to the given server IP and port @@ -438,18 +573,49 @@ def restore_previous_version(self) -> None: /sbin/fw_setenv "fallback_partition" "${OLDPART}" /sbin/fw_setenv "active_partition" "${NEWPART}\"""" - if self.hardware == HardwareType.RMPP: - RESTORE_CODE = """#!/bin/bash -OLDPART=$(< /sys/devices/platform/lpgpr/root_part) -if [[ $OLDPART == "a" ]]; then - NEWPART="b" -else - NEWPART="a" -fi -echo "new: ${NEWPART}" -echo "fallback: ${OLDPART}" -echo $NEWPART > /sys/devices/platform/lpgpr/root_part -""" + if self.hardware in (HardwareType.RMPP, HardwareType.RMPPM): + _, _, current_version, _, backup_version = self.get_device_status() + current_part, inactive_part, _ = self._get_paper_pro_partition_info(current_version) + + new_part_label = "a" if inactive_part == 2 else "b" + + parts = current_version.split('.') + if len(parts) < 2 or not parts[0].isdigit() or not parts[1].isdigit(): + raise SystemError(f"Cannot restore: unexpected current version format '{current_version}'") + + current_is_new = [int(parts[0]), int(parts[1])] >= [3, 22] + + parts = backup_version.split('.') + if len(parts) < 2 or not parts[0].isdigit() or not parts[1].isdigit(): + raise SystemError(f"Cannot restore: unexpected backup version format '{backup_version}'") + + target_is_new = [int(parts[0]), int(parts[1])] >= [3, 22] + + code = [ + "#!/bin/bash", + f"echo 'Switching from partition {current_part} to partition {inactive_part}'", + f"echo 'Current version: {current_version}'", + f"echo 'Target version: {backup_version}'", + ] + + if not current_is_new: + code.extend([ + f"echo '{new_part_label}' > /sys/devices/platform/lpgpr/root_part", + "echo 'Set next boot via sysfs (legacy method)'", + ]) + + if target_is_new or current_is_new: + code.extend([ + f"mmc bootpart enable {inactive_part - 1} 0 /dev/mmcblk0boot{inactive_part - 2}", + "echo 'Set next boot via mmc bootpart (new method)'", + ]) + + code.extend([ + f"echo '0' > /sys/devices/platform/lpgpr/root{new_part_label}_errcnt 2>/dev/null || true", + "echo 'Partition switch complete'", + ]) + + RESTORE_CODE = "\n".join(code) if self.client: self.logger.debug("Connecting to FTP") @@ -501,12 +667,13 @@ def reboot_device(self) -> None: self.logger.debug("Device rebooted") - def install_sw_update(self, version_file: str) -> None: + def install_sw_update(self, version_file: str, bootloader_files: dict[str, bytes] | None = None) -> None: """ Installs new version from version file path, utilising swupdate Args: version_file (str): Path to img file + bootloader_files (dict[str, bytes] | None): Bootloader files for Paper Pro downgrade Raises: SystemExit: If there was an error installing the update @@ -546,6 +713,14 @@ def install_sw_update(self, version_file: str) -> None: print("".join(_stderr.readlines())) raise SystemError("Update failed!") + if bootloader_files: + print("\nApplying bootloader update...") + self._update_paper_pro_bootloader( + bootloader_files['update-bootloader.sh'], + bootloader_files['imx-boot'] + ) + print("✓ Bootloader update completed") + print("Done! Now rebooting the device and disabling update service") #### Now disable automatic updates @@ -565,6 +740,7 @@ def install_sw_update(self, version_file: str) -> None: self.client = self.connect_to_device( remote_address=self.address, authentication=self.authentication ) + self.client.exec_command("systemctl stop swupdate memfaultd") print( @@ -605,6 +781,84 @@ def install_sw_update(self, version_file: str) -> None: print("Update complete and device rebooting") os.system("reboot") + def _update_paper_pro_bootloader(self, bootloader_script: bytes, imx_boot: bytes) -> None: + """ + Update bootloader on Paper Pro device for 3.22+ -> <3.22 downgrades. + + This method uploads the bootloader script and image to the device, + then runs the update script twice (preinst and postinst) to update + both boot partitions. + + Args: + bootloader_script: Contents of update-bootloader.sh + imx_boot: Contents of imx-boot image file + + Raises: + SystemError: If bootloader update fails + """ + self.logger.info("Starting bootloader update for Paper Pro") + + if not self.client: + raise SystemError("No SSH connection to device") + + ftp_client = None + try: + ftp_client = self.client.open_sftp() + except Exception: + raise SystemError("Failed to open SFTP connection for bootloader update") + + script_path = "/tmp/update-bootloader.sh" + boot_image_path = "/tmp/imx-boot" + + with tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.sh') as tmp_script: + tmp_script.write(bootloader_script) + tmp_script_path = tmp_script.name + + with tempfile.NamedTemporaryFile(mode='wb', delete=False, suffix='.img') as tmp_boot: + tmp_boot.write(imx_boot) + tmp_boot_path = tmp_boot.name + + try: + self.logger.debug("Uploading bootloader script to device") + ftp_client.put(tmp_script_path, script_path) + + self.logger.debug("Uploading imx-boot image to device") + ftp_client.put(tmp_boot_path, boot_image_path) + + self.logger.debug("Making bootloader script executable") + _stdin, stdout, _stderr = self.client.exec_command(f"chmod +x {script_path}") + stdout.channel.recv_exit_status() + + self.logger.info("Running bootloader update script (preinst)") + _stdin, stdout, stderr = self.client.exec_command( + f"{script_path} preinst {boot_image_path}" + ) + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0: + error_msg = "".join(stderr.readlines()) + raise SystemError(f"Bootloader preinst failed: {error_msg}") + + self.logger.info("Running bootloader update script (postinst)") + _stdin, stdout, stderr = self.client.exec_command( + f"{script_path} postinst {boot_image_path}" + ) + exit_status = stdout.channel.recv_exit_status() + if exit_status != 0: + error_msg = "".join(stderr.readlines()) + raise SystemError(f"Bootloader postinst failed: {error_msg}") + + self.logger.info("Bootloader update completed successfully") + + finally: + self.logger.debug("Cleaning up temporary bootloader files on device") + self.client.exec_command(f"rm -f {script_path} {boot_image_path}") + + self.logger.debug("Cleaning up local temporary files") + os.unlink(tmp_script_path) + os.unlink(tmp_boot_path) + if ftp_client: + ftp_client.close() + def install_ohma_update(self, version_available: dict) -> None: """Installs version from update folder on the device @@ -726,6 +980,3 @@ def output_put_progress(transferred: int, toBeTransferred: int) -> None: f"Transferring progress{int((transferred / toBeTransferred) * 100)}%", end="\r", ) - - - diff --git a/codexctl/updates.py b/codexctl/updates.py index 9d20377..c23d253 100644 --- a/codexctl/updates.py +++ b/codexctl/updates.py @@ -29,16 +29,17 @@ def __init__(self, logger=None) -> None: ( self.remarkablepp_versions, + self.remarkableppm_versions, self.remarkable2_versions, self.remarkable1_versions, - self.external_provider_url, + self.external_provider_urls, ) = self.get_remarkable_versions() - def get_remarkable_versions(self) -> tuple[dict, dict, dict, str, str]: + def get_remarkable_versions(self) -> tuple[dict, dict, dict, dict, list]: """Gets the avaliable versions for the device, by checking the local version-ids.json file and then updating it if necessary Returns: - tuple: A tuple containing the version ids for the remarkablepp, remarkable2, remarkable1, toltec version and external provider (in that order) + tuple: A tuple containing the version ids for the remarkablepp, remarkableppm, remarkable2, remarkable1, and external provider urls (in that order) """ if os.path.exists("data/version-ids.json"): @@ -82,11 +83,22 @@ def get_remarkable_versions(self) -> tuple[dict, dict, dict, str, str]: self.logger.debug(f"Version ids contents are {contents}") + provider_urls = contents.get("external-provider-urls", contents.get("external-provider-url")) + if provider_urls is None: + raise SystemError( + f"version-ids.json at {file_location} is missing external provider URLs. " + "Please delete the file and try again, or open an issue on the repo." + ) + + if isinstance(provider_urls, str): + provider_urls = [provider_urls] + return ( - contents["remarkablepp"], - contents["remarkable2"], - contents["remarkable1"], - contents["external-provider-url"], + contents.get("remarkablepp", {}), + contents.get("remarkableppm", {}), + contents.get("remarkable2", {}), + contents.get("remarkable1", {}), + provider_urls, ) def update_version_ids(self, location: str) -> None: @@ -131,6 +143,8 @@ def get_latest_version(self, hardware_type: HardwareType) -> str: versions = self.remarkable2_versions case HardwareType.RMPP: versions = self.remarkablepp_versions + case HardwareType.RMPPM: + versions = self.remarkableppm_versions return self.__max_version(versions.keys()) @@ -200,6 +214,9 @@ def download_version( case HardwareType.RMPP: version_lookup = self.remarkablepp_versions + case HardwareType.RMPPM: + version_lookup = self.remarkableppm_versions + case HardwareType.RM2: version_lookup = self.remarkable2_versions BASE_URL_V3 += "2" @@ -221,16 +238,30 @@ def download_version( if version <= (3, 11, 2, 5): file_name = f"{update_version}_{hardware_type.old_download_hw}-{version_id}.signed" file_url = f"{BASE_URL}/{update_version}/{file_name}" + self.logger.debug(f"File URL is {file_url}, File name is {file_name}") + return self.__download_version_file( + file_url, file_name, download_folder, version_checksum + ) else: - file_url = self.external_provider_url.replace("REPLACE_ID", version_id) file_name = f"remarkable-production-memfault-image-{update_version}-{hardware_type.new_download_hw}-public" - self.logger.debug(f"File URL is {file_url}, File name is {file_name}") + for provider_url in self.external_provider_urls: + file_url = provider_url.replace("REPLACE_ID", version_id) + self.logger.debug(f"Trying to download from {file_url}") - return self.__download_version_file( - file_url, file_name, download_folder, version_checksum - ) + result = self.__download_version_file( + file_url, file_name, download_folder, version_checksum + ) + + if result is not None: + self.logger.debug(f"Successfully downloaded from {provider_url}") + return result + + self.logger.debug(f"Failed to download from {provider_url}, trying next source...") + + self.logger.error(f"Failed to download {file_name} from all sources") + return None def __generate_xml_data(self) -> str: """Generates and returns XML data for the update request""" @@ -298,7 +329,7 @@ def __download_version_file( """ response = requests.get(uri, stream=True) if response.status_code != 200: - self.logger.error(f"Unable to download update file: {response.status_code}") + self.logger.debug(f"Unable to download update file: {response.status_code}") return None file_length = response.headers.get("content-length") @@ -366,3 +397,40 @@ def uses_new_update_engine(version: str) -> bool: bool: If it uses the new update engine or not """ return int(version.split(".")[0]) >= 3 and int(version.split(".")[1]) >= 11 + + @staticmethod + def is_bootloader_boundary_downgrade(current_version: str, target_version: str) -> bool: + """ + Checks if downgrade crosses the 3.22 bootloader boundary (3.22+ -> <3.22). + + Paper Pro devices require bootloader updates when downgrading from + version 3.22 or higher to any version below 3.22. + + Args: + current_version (str): Currently installed version + target_version (str): Target version to install + + Returns: + bool: True if crossing boundary downward (3.22+ -> <3.22) + + Raises: + ValueError: If either version is empty or has invalid format + """ + if not current_version or not current_version.strip(): + raise ValueError("current_version cannot be empty") + if not target_version or not target_version.strip(): + raise ValueError("target_version cannot be empty") + + try: + current_parts = [int(x) for x in current_version.split('.')] + target_parts = [int(x) for x in target_version.split('.')] + except ValueError as e: + raise ValueError(f"Invalid version format: {e}") from e + + if len(current_parts) < 2 or len(target_parts) < 2: + raise ValueError("Version must have at least 2 components (e.g., '3.22')") + + current_is_322_or_higher = current_parts >= [3, 22] + target_is_below_322 = target_parts < [3, 22] + + return current_is_322_or_higher and target_is_below_322 diff --git a/data/version-ids.json b/data/version-ids.json index fbed47d..887cb01 100644 --- a/data/version-ids.json +++ b/data/version-ids.json @@ -1,5 +1,13 @@ { "remarkable1": { + "3.23.0.64": [ + "remarkable-production-image-3.23.0.64-rm1-public.swu", + "88c35f0e52b08e9246677babe7163d7917f40bf133ea5b9dfa70a7fc09f548af" + ], + "3.22.0.64": [ + "remarkable-production-image-3.22.0.64-rm1-public.swu", + "9f23e1309b801a9ba68897113dad2fda032f50d261202d5b1b4ce561204afe20" + ], "3.20.0.92": [ "remarkable-production-image-3.20.0.92-rm1-public.swu", "a7558d9ef4b2bcc41d9a9219822a1e7e34bff46851658dcfd9666856401e9728" @@ -182,9 +190,17 @@ ] }, "remarkable2": { - "3.17.0.72": [ - "remarkable-production-memfault-image-3.17.0.72-rm2-public.swu", - "508f63bcae0b43e812d7afb4fbea42d11947e57abd63bc2538adb2884cbfba78" + "3.23.0.64": [ + "remarkable-production-image-3.23.0.64-rm2-public.swu", + "6de624bc03f892b73f56e83d0fd3d49c3c69a6d63a5831509fee98416d776807" + ], + "3.22.4.2": [ + "remarkable-production-image-3.22.4.2-rm2-public.swu", + "e714f7a48362fefa2b4b801d5f1b0fadfd8a272fdac9680b059c10dde6903319" + ], + "3.22.0.64": [ + "remarkable-production-image-3.22.0.64-rm2-public.swu", + "3662e7f0e827d631c99a8447038fff7710b6d2c84049ca4d06162677f1b1aa8a" ], "3.20.0.92": [ "remarkable-production-image-3.20.0.92-rm2-public.swu", @@ -376,6 +392,18 @@ ] }, "remarkablepp": { + "3.23.0.64": [ + "remarkable-production-image-3.23.0.64-ferrari-public.swu", + "c19301055b8cf4351d530a2f59a91fd6ebc8af8c36f0b8999c6557480c8554da" + ], + "3.22.4.2": [ + "remarkable-production-image-3.22.4.2-ferrari-public.swu", + "6baa6f0e9f6a4ad949ef043c3e604021b8f6c897d624b435752c5128cfb410d6" + ], + "3.22.0.64": [ + "remarkable-production-image-3.22.0.64-ferrari-public.swu", + "74b8a32e5aa6b9f1ae8d807e6a3595fed37d0d4e524e58b3a240b48de83d7e9c" + ], "3.20.0.92": [ "remarkable-production-image-3.20.0.92-ferrari-public.swu", "a63c1573aa26ed3e1f7e5dded3eb4adc09710515b2d2b1964ff397c94243ba89" @@ -421,6 +449,19 @@ "8c92f589900e7e355697206c71e2256d909313fcd96aa2c5fd9910ff04b062f1" ] }, - "last-updated": 1751286637, - "external-provider-url": "https://storage.googleapis.com/remarkable-versions/REPLACE_ID" + "remarkableppm": { + "3.23.0.64": [ + "remarkable-production-image-3.23.0.64-chiappa-public.swu", + "0a10ba1dbd873a442f144cb346c9497b820604a98203478345343a01be0d417f" + ], + "3.22.0.65": [ + "remarkable-production-image-3.22.0.65-chiappa-public.swu", + "b494b36e6c1749bbe205ef26f2cea31728f6fe82aa5abf5057e37a09ec326ffa" + ] + }, + "last-updated": 1763843954, + "external-provider-urls": [ + "https://storage.googleapis.com/remarkable-versions/REPLACE_ID", + "https://remarkable-software.s3.us-east-2.amazonaws.com/REPLACE_ID" + ] } diff --git a/pyproject.toml b/pyproject.toml index a45818a..88c3475 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,8 @@ paramiko = "3.4.1" psutil = "6.0.0" requests = "2.32.4" loguru = "0.7.3" -remarkable-update-image = { version = "1.1.6", markers = "sys_platform != 'linux'" } -remarkable-update-fuse = { version = "1.2.4", markers = "sys_platform == 'linux'" } +remarkable-update-image = { version = "1.3", markers = "sys_platform != 'linux'" } +remarkable-update-fuse = { version = "1.3", markers = "sys_platform == 'linux'" } [build-system] requires = ["poetry-core"] diff --git a/requirements.txt b/requirements.txt index b3a2f42..78e5d89 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ requests==2.32.4 loguru==0.7.3 -remarkable-update-image==1.2; sys_platform != 'linux' -remarkable-update-fuse==1.2.6; sys_platform == 'linux' +remarkable-update-image==1.3; sys_platform != 'linux' +remarkable-update-fuse==1.3; sys_platform == 'linux' diff --git a/tests/test.py b/tests/test.py index cdbbe05..3a5d0ad 100644 --- a/tests/test.py +++ b/tests/test.py @@ -198,10 +198,6 @@ def test_cat(path, expected): test_cat("/etc/version", b"20221026104022\n") -assert_value("latest rm1 version", updater.get_latest_version(HardwareType.RM1), "3.20.0.92") -assert_value("latest rm2 version", updater.get_latest_version(HardwareType.RM2), "3.20.0.92") -# Don't think this test is needed. - assert_gt( "toltec rm1 version", updater.get_toltec_version(HardwareType.RM1), @@ -214,6 +210,43 @@ def test_cat(path, expected): ) with assert_raises("toltec rmpp version", SystemExit): updater.get_toltec_version(HardwareType.RMPP) +with assert_raises("toltec rmppm version", SystemExit): + updater.get_toltec_version(HardwareType.RMPPM) + +assert_value( + "boundary cross 3.23->3.20", + UpdateManager.is_bootloader_boundary_downgrade("3.23.0.64", "3.20.0.92"), + True +) +assert_value( + "boundary cross 3.22->3.20", + UpdateManager.is_bootloader_boundary_downgrade("3.22.0.64", "3.20.0.92"), + True +) +assert_value( + "no boundary 3.23->3.22", + UpdateManager.is_bootloader_boundary_downgrade("3.23.0.64", "3.22.0.64"), + False +) +assert_value( + "no boundary 3.20->3.19", + UpdateManager.is_bootloader_boundary_downgrade("3.20.0.92", "3.19.0.82"), + False +) +assert_value( + "upgrade 3.20->3.22", + UpdateManager.is_bootloader_boundary_downgrade("3.20.0.92", "3.22.0.64"), + False +) +assert_value( + "same version 3.22->3.22", + UpdateManager.is_bootloader_boundary_downgrade("3.22.0.64", "3.22.0.64"), + False +) +with assert_raises("empty string current", ValueError): + UpdateManager.is_bootloader_boundary_downgrade("", "3.20.0.92") +with assert_raises("non-numeric version", ValueError): + UpdateManager.is_bootloader_boundary_downgrade("abc.def", "3.20.0.92") if FAILED: sys.exit(1)