diff --git a/.gitignore b/.gitignore index 64adb71c..581c4d75 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,6 @@ compile_commands.json k5_eeprom.raw /build -/serialtool/__pycache__ +__pycache__/ -.DS_Store \ No newline at end of file +.DS_Store diff --git a/archive/quansheng.k1.stock.firmware.v7.03.01.bin b/archive/quansheng.k1.stock.firmware.v7.03.01.bin new file mode 100644 index 00000000..7beb1851 Binary files /dev/null and b/archive/quansheng.k1.stock.firmware.v7.03.01.bin differ diff --git a/tools/serialtool/_fwcodec.py b/tools/serialtool/_fwcodec.py new file mode 100644 index 00000000..437b763b --- /dev/null +++ b/tools/serialtool/_fwcodec.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +# Copyright (c) 2026 +# +# Licensed under the MIT License (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at the root of this repository. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from binascii import crc_hqx +from itertools import cycle + + +# Quansheng stock updater pack format: +# - raw firmware split at 0x2000 +# - 16-byte ASCII version field inserted there +# - XOR obfuscation with the table below +# - CRC16/XMODEM appended at the end +OBFUSCATION = bytes( + [ + 0x47, 0x22, 0xC0, 0x52, 0x5D, 0x57, 0x48, 0x94, 0xB1, 0x60, 0x60, 0xDB, 0x6F, 0xE3, 0x4C, 0x7C, + 0xD8, 0x4A, 0xD6, 0x8B, 0x30, 0xEC, 0x25, 0xE0, 0x4C, 0xD9, 0x00, 0x7F, 0xBF, 0xE3, 0x54, 0x05, + 0xE9, 0x3A, 0x97, 0x6B, 0xB0, 0x6E, 0x0C, 0xFB, 0xB1, 0x1A, 0xE2, 0xC9, 0xC1, 0x56, 0x47, 0xE9, + 0xBA, 0xF1, 0x42, 0xB6, 0x67, 0x5F, 0x0F, 0x96, 0xF7, 0xC9, 0x3C, 0x84, 0x1B, 0x26, 0xE1, 0x4E, + 0x3B, 0x6F, 0x66, 0xE6, 0xA0, 0x6A, 0xB0, 0xBF, 0xC6, 0xA5, 0x70, 0x3A, 0xBA, 0x18, 0x9E, 0x27, + 0x1A, 0x53, 0x5B, 0x71, 0xB1, 0x94, 0x1E, 0x18, 0xF2, 0xD6, 0x81, 0x02, 0x22, 0xFD, 0x5A, 0x28, + 0x91, 0xDB, 0xBA, 0x5D, 0x64, 0xC6, 0xFE, 0x86, 0x83, 0x9C, 0x50, 0x1C, 0x73, 0x03, 0x11, 0xD6, + 0xAF, 0x30, 0xF4, 0x2C, 0x77, 0xB2, 0x7D, 0xBB, 0x3F, 0x29, 0x28, 0x57, 0x22, 0xD6, 0x92, 0x8B, + ] +) + +VERSION_OFFSET = 0x2000 +VERSION_SIZE = 16 +CRC_SIZE = 2 + + +def _vector_table_looks_valid(image: bytes) -> bool: + if len(image) < 8: + return False + + stack_pointer = int.from_bytes(image[0:4], "little") + reset_vector = int.from_bytes(image[4:8], "little") + + return (stack_pointer & 0xFFF00000) == 0x20000000 and (reset_vector & 0xFFF00000) == 0x08000000 + + +def is_raw_image(image: bytes) -> bool: + return _vector_table_looks_valid(image) + + +def has_valid_vendor_crc(image: bytes) -> bool: + if len(image) < CRC_SIZE: + return False + + expected_crc = crc_hqx(image[:-CRC_SIZE], 0) + actual_crc = int.from_bytes(image[-CRC_SIZE:], "little") + + return expected_crc == actual_crc + + +def is_packed_image(image: bytes) -> bool: + if len(image) <= VERSION_OFFSET + VERSION_SIZE + CRC_SIZE: + return False + + if is_raw_image(image): + return False + + return has_valid_vendor_crc(image) + + +def decode_packed_image(image: bytes) -> tuple[bytes, str]: + if not is_packed_image(image): + raise ValueError("not a packed Quansheng firmware image") + + decoded = bytes(a ^ b for a, b in zip(image[:-CRC_SIZE], cycle(OBFUSCATION))) + version_bytes = decoded[VERSION_OFFSET:VERSION_OFFSET + VERSION_SIZE] + version = version_bytes.split(b"\x00", 1)[0].decode("ascii", errors="replace") + + raw = decoded[:VERSION_OFFSET] + decoded[VERSION_OFFSET + VERSION_SIZE:] + if not is_raw_image(raw): + raise ValueError("decoded firmware does not look like a raw MCU image") + + return raw, version diff --git a/tools/serialtool/cli.py b/tools/serialtool/cli.py index 861cdbf8..5aa16d07 100644 --- a/tools/serialtool/cli.py +++ b/tools/serialtool/cli.py @@ -17,14 +17,11 @@ import argparse -import serial import signal from time import sleep import os -import _prog as pp -import _dump as dd -import _restore as rr +import _fwcodec as fc def load_image(file: str) -> bytes: @@ -42,7 +39,9 @@ def load_image(file: str) -> bytes: return a -def main_dump(args, ser: serial.Serial): +def main_dump(args, ser): + + import _dump as dd dump_file: str = args.file @@ -73,7 +72,10 @@ def main_dump(args, ser: serial.Serial): sleep(0) -def main_restore(args, ser: serial.Serial): +def main_restore(args, ser): + + import _dump as dd + import _restore as rr dump_file: str = args.file @@ -105,7 +107,9 @@ def main_restore(args, ser: serial.Serial): sleep(0) -def main_flash(args, ser: serial.Serial): +def main_flash(args, ser): + + import _prog as pp bl_ver: str = args.bl_ver fw_file: str = args.file @@ -139,6 +143,56 @@ def main_flash(args, ser: serial.Serial): sleep(0) +def get_raw_output_path(file: str) -> str: + + root, ext = os.path.splitext(file) + if ext: + file_name = "{}.raw{}".format(os.path.basename(root), ext) + candidate = "{}.raw{}".format(root, ext) + else: + file_name = os.path.basename(file) + ".raw.bin" + candidate = file + ".raw.bin" + + out_dir = os.path.dirname(candidate) or "." + if os.access(out_dir, os.W_OK): + return candidate + + return os.path.join(os.getcwd(), file_name) + + +def main_decode(args): + + fw_file: str = args.file + out_file: str = args.output or get_raw_output_path(fw_file) + + try: + fw_image = load_image(fw_file) + if 0 == len(fw_image): + print("Invalid firmware image: {}: empty file".format(fw_file)) + return + except Exception as e: + print("Cannot load firmware image '{}': {}".format(fw_file, e)) + return + + if fc.is_raw_image(fw_image): + print("Firmware image already looks raw") + raw_image = fw_image + version = None + else: + try: + raw_image, version = fc.decode_packed_image(fw_image) + except Exception as e: + print("Cannot decode firmware image '{}': {}".format(fw_file, e)) + return + + with open(out_file, "wb") as fd: + fd.write(raw_image) + + print("Raw firmware image written: {}, size = {}".format(out_file, len(raw_image))) + if version: + print("Packed version field: {}".format(version)) + + def main(): # Usage: @@ -146,6 +200,7 @@ def main(): # serialtool.py .. flash [--bl-ver ] # serialtool.py .. dump {--config | --calib [| --all]} file # serialtool.py .. restore {--config | --calib [| --all]} file + # serialtool.py decode [raw.bin] ap = argparse.ArgumentParser(description="UV-K5 V2 serial tool") # TODO: have to add option to each of subcommands ?? @@ -198,14 +253,31 @@ def main(): ) ap_restore.add_argument("file", help="input dump file") + ap_decode = sp.add_parser( + "decode", help="decode a packed Quansheng stock firmware into a raw image" + ) + ap_decode.add_argument("file", help="input firmware image file") + ap_decode.add_argument( + "output", + nargs="?", + help="output raw firmware image file (default: .raw.bin)", + ) + args = ap.parse_args() - port: str = args.port sub_name: str = args.subcommand print(ap.description) # print("Press Ctrl-C to quit") + if "decode" == sub_name: + main_decode(args) + return + + port: str = args.port + try: + import serial + ser = serial.Serial(port, baudrate=38400, timeout=0.0001, write_timeout=None) except Exception as e: print("Cannot open port '{}': {}".format(port, e))