Created
June 4, 2026 05:47
-
-
Save Ap0dexMe0/c7070042a8560ef2939205df1b72b720 to your computer and use it in GitHub Desktop.
Loewe TLI Dumper
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Loewe TLI Firmware Dumper / Extractor | |
| ====================================== | |
| Reverse-engineered format analysis and extraction tool for Loewe .tli firmware | |
| update files, commonly found at https://cdn.loewe.tv/software_new/ | |
| TLI File Format: | |
| The .tli format consists of two parts: | |
| 1. TLV Header (variable length, typically <1KB): | |
| - Custom BER-TLV encoded partition table describing flash layout | |
| - Partition entries with names, flash addresses, sizes, and CRC32 checksums | |
| - Uses tag-length-value encoding where: | |
| * 0x81 = 1-byte length follows | |
| * 0x82 = 2-byte length follows | |
| * 0x83 = 3-byte length follows (value = N bytes) | |
| * 0x05, 0x01, 0x0b, 0x21 = structural tags | |
| * 0x83 0x0c 0x21 = partition block descriptor (12 bytes): | |
| - 4 bytes: start address in flash | |
| - 4 bytes: size or end address | |
| - 4 bytes: CRC32 checksum | |
| 2. Tar Archive (rest of file): | |
| - Standard POSIX tar archive (ustar format) | |
| - Each tar entry is a firmware partition (Main, Boot, STB, FRC, etc.) | |
| - Partition names match the TLV header partition table | |
| Partition types found: | |
| - Main: Contains flash.img (NAND flash image, may have inner structure) | |
| - Boot: Contains uboot.bim (U-Boot bootloader) | |
| - STB: Set-top box firmware (raw binary) | |
| - FRC: uImage/ARM format (Linux kernel for FRC board) | |
| - FRCDB: uImage/ARM format (Linux kernel for FRC DB board) | |
| - HDMI: uImage/ARM format (HDMI firmware) | |
| - MAP: MAPX format (memory map / partition layout) | |
| - Subw300: Subwoofer DSP firmware | |
| - ImgCaH: xz-compressed image cache (H panel) | |
| - ImgCaI: xz-compressed image cache (I panel) | |
| Usage: | |
| python3 tli_dumper.py <file.tli> [output_dir] [options] | |
| Options: | |
| --info Show partition table and metadata only (no extraction) | |
| --extract Extract all partitions (default) | |
| --extract NAME Extract only the named partition(s), comma-separated | |
| --raw Save raw partition data without further processing | |
| --unpack Try to unpack inner formats (uImage, gzip, xz, etc.) | |
| --verbose Show detailed progress information | |
| Examples: | |
| python3 tli_dumper.py firmware.tli | |
| python3 tli_dumper.py firmware.tli output/ --info | |
| python3 tli_dumper.py firmware.tli output/ --extract Main,Boot | |
| python3 tli_dumper.py firmware.tli output/ --unpack | |
| """ | |
| import os | |
| import sys | |
| import struct | |
| import tarfile | |
| import io | |
| import hashlib | |
| import zlib | |
| import gzip | |
| import argparse | |
| from pathlib import Path | |
| from datetime import datetime | |
| # ─── Constants ─────────────────────────────────────────────────────────────── | |
| KNOWN_PARTITION_TYPES = { | |
| "Main": "NAND flash image (contains flash.img)", | |
| "Boot": "U-Boot bootloader (uboot.bim)", | |
| "STB": "Set-top box firmware (raw binary)", | |
| "FRC": "uImage/ARM (Linux kernel - FRC board)", | |
| "FRCDB": "uImage/ARM (Linux kernel - FRC DB)", | |
| "HDMI": "uImage/ARM (HDMI firmware)", | |
| "MAP": "MAPX memory map / partition layout", | |
| "Subw300": "Subwoofer DSP firmware", | |
| "ImgCaH": "xz-compressed image cache (H panel)", | |
| "ImgCaI": "xz-compressed image cache (I panel)", | |
| } | |
| SIGNATURES = { | |
| b'\x27\x05\x19\x56': 'uImage', | |
| b'\x1f\x8b': 'gzip', | |
| b'HSQS': 'SquashFS', | |
| b'\xfd7zXZ\x00': 'xz', | |
| b'BZh': 'bzip2', | |
| b'\x89PNG': 'PNG', | |
| b'\x67\x45\x23\x01': 'uImage-ARM', | |
| b'ELF': 'ELF', | |
| } | |
| # ─── TLV Parser ────────────────────────────────────────────────────────────── | |
| class TLVEntry: | |
| """Represents a parsed TLV entry from the TLI header.""" | |
| def __init__(self, tag, length, value, offset): | |
| self.tag = tag | |
| self.length = length | |
| self.value = value | |
| self.offset = offset | |
| def __repr__(self): | |
| return f"TLVEntry(tag=0x{self.tag:02x}, len={self.length}, offset=0x{self.offset:04x})" | |
| class PartitionInfo: | |
| """Represents a firmware partition from the TLV header.""" | |
| def __init__(self, name, flash_blocks=None, tar_size=0, tar_offset=0, description=""): | |
| self.name = name | |
| self.flash_blocks = flash_blocks or [] # List of (start, size, checksum) tuples | |
| self.tar_size = tar_size | |
| self.tar_offset = tar_offset | |
| self.description = description or KNOWN_PARTITION_TYPES.get(name, "Unknown") | |
| def __repr__(self): | |
| return f"Partition({self.name}, size={self.tar_size}, blocks={len(self.flash_blocks)})" | |
| def parse_tli_header(data): | |
| """Parse the TLV header of a .tli file. | |
| Returns a list of PartitionInfo objects. | |
| """ | |
| partitions = [] | |
| pos = 0 | |
| # Skip the initial file-level metadata (first few bytes) | |
| # The structure starts with some global info, then partition entries | |
| # Find all partition names by looking for length-prefixed ASCII strings | |
| # within the TLV header region | |
| seen_names = set() | |
| while pos < len(data): | |
| # Look for a length byte followed by an ASCII string | |
| if pos >= len(data): | |
| break | |
| name_len = data[pos] | |
| if 2 <= name_len <= 16 and pos + 1 + name_len <= len(data): | |
| name_bytes = data[pos + 1:pos + 1 + name_len] | |
| if all(32 <= b < 127 for b in name_bytes): | |
| name = name_bytes.decode('ascii') | |
| if name not in seen_names and name.isalnum(): | |
| seen_names.add(name) | |
| # Parse flash block descriptors that follow | |
| # Look for 0x83 0x0c 0x21 patterns (block descriptor) | |
| blocks = [] | |
| search_start = pos + 1 + name_len | |
| search_end = min(search_start + 200, len(data)) | |
| sp = search_start | |
| while sp + 12 <= search_end: | |
| # Look for block descriptor: 83 0c 21 <4:start> <4:size> <4:crc> | |
| if data[sp] == 0x83 and data[sp + 1] == 0x0c and data[sp + 2] == 0x21: | |
| start_addr = struct.unpack('>I', data[sp+3:sp+7])[0] | |
| size_or_end = struct.unpack('>I', data[sp+7:sp+11])[0] | |
| checksum = struct.unpack('>I', data[sp+11:sp+15])[0] | |
| blocks.append({ | |
| 'flash_start': start_addr, | |
| 'flash_size_or_end': size_or_end, | |
| 'crc32': checksum, | |
| 'raw_offset': sp, | |
| }) | |
| sp += 15 | |
| else: | |
| sp += 1 | |
| # Also look at the preceding entry structure for flash addresses | |
| # Pattern before name: ... 81 LL TT SS AAAA BBBB ... | |
| ctx_start = max(0, pos - 30) | |
| ctx = data[ctx_start:pos] | |
| # Try to find 0x21 sub-tag entries before the name | |
| for i in range(len(ctx)): | |
| if ctx[i] == 0x21 and i + 9 <= len(ctx): | |
| addr1 = struct.unpack('>I', ctx[i+1:i+5])[0] | |
| addr2 = struct.unpack('>I', ctx[i+5:i+9])[0] | |
| # Only add if looks like valid flash address | |
| if addr1 > 0 or addr2 > 0: | |
| # Check if not already in blocks | |
| already = any(b['flash_start'] == addr1 for b in blocks) | |
| if not already: | |
| blocks.insert(0, { | |
| 'flash_start': addr1, | |
| 'flash_size_or_end': addr2, | |
| 'crc32': 0, | |
| 'raw_offset': ctx_start + i, | |
| }) | |
| pinfo = PartitionInfo(name=name, flash_blocks=blocks) | |
| pinfo.description = KNOWN_PARTITION_TYPES.get(name, "Unknown") | |
| partitions.append(pinfo) | |
| pos += 1 | |
| return partitions | |
| def detect_signature(data): | |
| """Detect the file format signature of the given data.""" | |
| for sig, name in SIGNATURES.items(): | |
| if data[:len(sig)] == sig: | |
| return name | |
| return None | |
| def parse_uimage_header(data): | |
| """Parse a uImage header and return metadata dict.""" | |
| if len(data) < 64 or data[:4] != b'\x27\x05\x19\x56': | |
| return None | |
| magic = struct.unpack('>I', data[0:4])[0] | |
| hdr_crc = struct.unpack('>I', data[4:8])[0] | |
| timestamp = struct.unpack('>I', data[8:12])[0] | |
| data_size = struct.unpack('>I', data[12:16])[0] | |
| load_addr = struct.unpack('>I', data[16:20])[0] | |
| entry_addr = struct.unpack('>I', data[20:24])[0] | |
| data_crc = struct.unpack('>I', data[24:28])[0] | |
| os_type = data[28] | |
| arch = data[29] | |
| img_type = data[30] | |
| comp = data[31] | |
| name = data[32:64].split(b'\x00')[0].decode('ascii', errors='replace') | |
| comp_names = {0: 'none', 1: 'gzip', 2: 'bzip2', 3: 'lzma', 4: 'lzo', 5: 'lz4'} | |
| type_names = {1: 'Standalone', 2: 'Kernel', 3: 'RAMDisk', 4: 'Multi', 5: 'Firmware', | |
| 6: 'Script', 7: 'Filesystem', 8: 'Flat Device Tree'} | |
| arch_names = {2: 'Alpha', 5: 'ARM', 6: 'x86', 7: 'IA-64', 8: 'MIPS', 9: 'MIPS64', | |
| 10: 'PowerPC', 12: 'SH', 15: 'x86_64', 16: 'ARC'} | |
| return { | |
| 'magic': f'0x{magic:08x}', | |
| 'timestamp': datetime.utcfromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S'), | |
| 'data_size': data_size, | |
| 'load_addr': f'0x{load_addr:08x}', | |
| 'entry_addr': f'0x{entry_addr:08x}', | |
| 'data_crc': f'0x{data_crc:08x}', | |
| 'os_type': os_type, | |
| 'arch': arch_names.get(arch, f'Unknown({arch})'), | |
| 'image_type': type_names.get(img_type, f'Unknown({img_type})'), | |
| 'compression': comp_names.get(comp, f'Unknown({comp})'), | |
| 'name': name, | |
| 'header_size': 64, | |
| } | |
| def format_size(size): | |
| """Format a byte size as human-readable string.""" | |
| for unit in ['B', 'KB', 'MB', 'GB']: | |
| if size < 1024: | |
| return f"{size:.1f} {unit}" | |
| size /= 1024 | |
| return f"{size:.1f} TB" | |
| # ─── Main Dumper ───────────────────────────────────────────────────────────── | |
| class TLIDumper: | |
| """Main TLI firmware dumper class.""" | |
| def __init__(self, filepath): | |
| self.filepath = filepath | |
| self.data = None | |
| self.tar_start = 0 | |
| self.partitions = [] | |
| self.tar_members = [] | |
| def load(self): | |
| """Load the TLI file into memory (reads header + tar index only for large files).""" | |
| with open(self.filepath, 'rb') as f: | |
| # Read header | |
| self.data = f.read() | |
| # Find tar archive start (look for first ustar magic) | |
| self.tar_start = self._find_tar_start() | |
| # Parse TLV header | |
| header_data = self.data[:self.tar_start] | |
| self.partitions = parse_tli_header(header_data) | |
| # Parse tar index | |
| self._parse_tar_index() | |
| def _find_tar_start(self): | |
| """Find the start offset of the embedded tar archive.""" | |
| for i in range(0, min(len(self.data), 0x1000)): | |
| if i + 263 < len(self.data): | |
| if self.data[i + 257:i + 262] == b'ustar': | |
| return i | |
| return 0 | |
| def _parse_tar_index(self): | |
| """Parse the tar archive and populate partition info.""" | |
| try: | |
| tf = tarfile.open(fileobj=io.BytesIO(self.data[self.tar_start:])) | |
| for m in tf.getmembers(): | |
| if m.isfile(): | |
| # Match tar member to partition | |
| for p in self.partitions: | |
| if p.name == m.name: | |
| p.tar_size = m.size | |
| p.tar_offset = self.tar_start + m.offset + 512 | |
| break | |
| self.tar_members.append({ | |
| 'name': m.name, | |
| 'size': m.size, | |
| 'offset': m.offset, | |
| 'mode': m.mode, | |
| 'uid': m.uid, | |
| 'gid': m.gid, | |
| 'mtime': m.mtime, | |
| }) | |
| tf.close() | |
| except Exception as e: | |
| print(f"Warning: tar parsing issue: {e}") | |
| def show_info(self): | |
| """Display firmware information and partition table.""" | |
| print("=" * 80) | |
| print(f" Loewe TLI Firmware - {os.path.basename(self.filepath)}") | |
| print("=" * 80) | |
| print(f"\n File size: {format_size(len(self.data))} ({len(self.data)} bytes)") | |
| print(f" TLV header: 0x0000 - 0x{self.tar_start:04x} ({self.tar_start} bytes)") | |
| print(f" Tar archive: 0x{self.tar_start:04x} - EOF") | |
| print(f" Partitions: {len(self.partitions)}") | |
| print("\n" + "-" * 80) | |
| print(f" {'#':>3} {'Name':<12} {'Size':>12} {'Format':<16} {'Description'}") | |
| print("-" * 80) | |
| for i, p in enumerate(self.partitions): | |
| # Detect format | |
| fmt = "unknown" | |
| if p.tar_offset > 0 and p.tar_offset < len(self.data): | |
| sig = detect_signature(self.data[p.tar_offset:p.tar_offset + 6]) | |
| if sig: | |
| fmt = sig | |
| else: | |
| # Check for inner tar | |
| first_64 = self.data[p.tar_offset:p.tar_offset + 64] | |
| null_pos = first_64.find(b'\x00') | |
| if null_pos and 0 < null_pos < 32: | |
| maybe_name = first_64[:null_pos].decode('ascii', errors='replace') | |
| if '.' in maybe_name and maybe_name.isprintable(): | |
| fmt = f"tar ({maybe_name})" | |
| desc = p.description if len(p.description) < 30 else p.description[:27] + "..." | |
| size_str = format_size(p.tar_size) if p.tar_size else "?" | |
| print(f" {i+1:3} {p.name:<12} {size_str:>12} {fmt:<16} {desc}") | |
| # Show flash layout | |
| if any(p.flash_blocks for p in self.partitions): | |
| print("\n" + "-" * 80) | |
| print(" Flash Memory Layout (from TLV header):") | |
| print("-" * 80) | |
| for p in self.partitions: | |
| for j, blk in enumerate(p.flash_blocks): | |
| start = blk['flash_start'] | |
| size_end = blk['flash_size_or_end'] | |
| crc = blk['crc32'] | |
| crc_str = f"0x{crc:08x}" if crc else "N/A" | |
| print(f" {p.name:<12} block {j}: start=0x{start:08x} end/size=0x{size_end:08x} crc={crc_str}") | |
| # Show uImage details for applicable partitions | |
| for p in self.partitions: | |
| if p.tar_offset > 0 and p.tar_offset + 64 <= len(self.data): | |
| uimg = parse_uimage_header(self.data[p.tar_offset:p.tar_offset + 64]) | |
| if uimg: | |
| print(f"\n [{p.name}] uImage Details:") | |
| print(f" Name: {uimg['name']}") | |
| print(f" Created: {uimg['timestamp']}") | |
| print(f" Data size: {format_size(uimg['data_size'])}") | |
| print(f" Load addr: {uimg['load_addr']}") | |
| print(f" Entry addr: {uimg['entry_addr']}") | |
| print(f" Architecture: {uimg['arch']}") | |
| print(f" Type: {uimg['image_type']}") | |
| print(f" Compression: {uimg['compression']}") | |
| def extract(self, output_dir, partition_names=None, raw=False, unpack=False, verbose=False): | |
| """Extract partitions to the output directory. | |
| Args: | |
| output_dir: Directory to save extracted files | |
| partition_names: List of partition names to extract (None = all) | |
| raw: Save raw data without processing | |
| unpack: Try to unpack inner formats | |
| verbose: Show detailed progress | |
| """ | |
| os.makedirs(output_dir, exist_ok=True) | |
| tf = tarfile.open(fileobj=io.BytesIO(self.data[self.tar_start:])) | |
| for p in self.partitions: | |
| if partition_names and p.name not in partition_names: | |
| continue | |
| if p.tar_size == 0: | |
| if verbose: | |
| print(f" Skipping {p.name} (no data)") | |
| continue | |
| if verbose: | |
| print(f" Extracting {p.name} ({format_size(p.tar_size)})...") | |
| # Extract from tar | |
| try: | |
| member = tf.getmember(p.name) | |
| f = tf.extractfile(member) | |
| if not f: | |
| print(f" Warning: Could not extract {p.name}") | |
| continue | |
| part_data = f.read() | |
| except Exception as e: | |
| print(f" Error extracting {p.name}: {e}") | |
| continue | |
| # Save raw partition data | |
| part_dir = os.path.join(output_dir, p.name) | |
| os.makedirs(part_dir, exist_ok=True) | |
| raw_path = os.path.join(part_dir, f"{p.name}.bin") | |
| with open(raw_path, 'wb') as out: | |
| out.write(part_data) | |
| if verbose: | |
| sig = detect_signature(part_data[:6]) or "raw" | |
| print(f" Saved raw: {raw_path} [{sig}]") | |
| if raw: | |
| continue | |
| # Try to unpack inner formats | |
| if unpack: | |
| self._unpack_partition(part_dir, p.name, part_data, verbose) | |
| tf.close() | |
| print(f"\n Extraction complete! Files saved to: {output_dir}") | |
| def _unpack_partition(self, part_dir, name, data, verbose=False): | |
| """Try to unpack the inner format of a partition.""" | |
| sig = detect_signature(data[:6]) | |
| if sig == 'uImage' or sig == 'uImage-ARM': | |
| # Parse uImage header and extract payload | |
| uimg = parse_uimage_header(data[:64]) | |
| if uimg: | |
| if verbose: | |
| print(f" uImage: {uimg['name']}, {uimg['compression']}, {format_size(uimg['data_size'])}") | |
| # Save uImage info | |
| info_path = os.path.join(part_dir, f"{name}_uimage_info.txt") | |
| with open(info_path, 'w') as f: | |
| for k, v in uimg.items(): | |
| f.write(f"{k}: {v}\n") | |
| # Extract payload (skip 64-byte header) | |
| payload = data[64:] | |
| payload_path = os.path.join(part_dir, f"{name}_payload.bin") | |
| with open(payload_path, 'wb') as f: | |
| f.write(payload) | |
| # If gzip compressed, decompress | |
| if uimg['compression'] == 'gzip' and payload[:2] == b'\x1f\x8b': | |
| try: | |
| decompressed = gzip.decompress(payload) | |
| dec_path = os.path.join(part_dir, f"{name}_kernel.bin") | |
| with open(dec_path, 'wb') as f: | |
| f.write(decompressed) | |
| if verbose: | |
| print(f" Decompressed kernel: {format_size(len(decompressed))}") | |
| except Exception as e: | |
| if verbose: | |
| print(f" gzip decompress failed: {e}") | |
| # Try to find embedded filesystem | |
| for offset in range(0, min(len(payload), 0x10000), 256): | |
| if offset + 4 < len(payload): | |
| if payload[offset:offset+4] == b'HSQS': | |
| if verbose: | |
| print(f" Found SquashFS at offset 0x{offset:04x}") | |
| sqsh_path = os.path.join(part_dir, f"{name}_squashfs.bin") | |
| with open(sqsh_path, 'wb') as f: | |
| f.write(payload[offset:]) | |
| break | |
| elif sig == 'gzip': | |
| try: | |
| decompressed = gzip.decompress(data) | |
| dec_path = os.path.join(part_dir, f"{name}_decompressed.bin") | |
| with open(dec_path, 'wb') as f: | |
| f.write(decompressed) | |
| if verbose: | |
| print(f" Decompressed: {format_size(len(decompressed))}") | |
| except Exception as e: | |
| if verbose: | |
| print(f" gzip decompress failed: {e}") | |
| elif sig == 'xz': | |
| try: | |
| import lzma | |
| decompressed = lzma.decompress(data) | |
| dec_path = os.path.join(part_dir, f"{name}_decompressed.bin") | |
| with open(dec_path, 'wb') as f: | |
| f.write(decompressed) | |
| if verbose: | |
| print(f" Decompressed: {format_size(len(decompressed))}") | |
| # Check what the decompressed data is | |
| inner_sig = detect_signature(decompressed[:6]) | |
| if inner_sig and verbose: | |
| print(f" Inner format: {inner_sig}") | |
| # If it's a cpio archive | |
| if decompressed[:6] == b'070701' or decompressed[:6] == b'070702': | |
| cpio_path = os.path.join(part_dir, f"{name}.cpio") | |
| with open(cpio_path, 'wb') as f: | |
| f.write(decompressed) | |
| if verbose: | |
| print(f" Saved as cpio archive") | |
| except Exception as e: | |
| if verbose: | |
| print(f" xz decompress failed: {e}") | |
| elif sig == 'SquashFS': | |
| sqsh_path = os.path.join(part_dir, f"{name}.squashfs") | |
| with open(sqsh_path, 'wb') as f: | |
| f.write(data) | |
| if verbose: | |
| print(f" Saved as SquashFS image") | |
| else: | |
| # Check if it's a tar archive with an inner file | |
| null_pos = data[:64].find(b'\x00') | |
| if null_pos and 0 < null_pos < 64: | |
| maybe_name = data[:null_pos].decode('ascii', errors='replace') | |
| if '.' in maybe_name and maybe_name.isprintable(): | |
| # It contains an inner file - look for ustar | |
| for off in range(0, min(len(data), 0x400), 512): | |
| if off + 263 < len(data) and data[off+257:off+262] == b'ustar': | |
| # This IS a tar - extract the inner file | |
| try: | |
| inner_tar = tarfile.open(fileobj=io.BytesIO(data)) | |
| for m in inner_tar.getmembers(): | |
| if m.isfile(): | |
| inf = inner_tar.extractfile(m) | |
| if inf: | |
| inner_data = inf.read() | |
| inner_path = os.path.join(part_dir, m.name.replace('/', '_')) | |
| os.makedirs(os.path.dirname(inner_path) or part_dir, exist_ok=True) | |
| with open(inner_path, 'wb') as f: | |
| f.write(inner_data) | |
| if verbose: | |
| inner_sig = detect_signature(inner_data[:6]) or "raw" | |
| print(f" Inner file: {m.name} ({format_size(len(inner_data))}) [{inner_sig}]") | |
| # Recursively unpack | |
| self._unpack_partition(part_dir, f"{name}_{m.name}", inner_data, verbose) | |
| inner_tar.close() | |
| except: | |
| pass | |
| break | |
| def compute_hashes(self, output_file=None): | |
| """Compute MD5/SHA1/SHA256/CRC32 hashes of each partition.""" | |
| print("\n Partition Hashes:") | |
| print(" " + "-" * 76) | |
| print(f" {'Name':<12} {'Size':>10} {'MD5':<34} {'CRC32':<10}") | |
| print(" " + "-" * 76) | |
| tf = tarfile.open(fileobj=io.BytesIO(self.data[self.tar_start:])) | |
| results = [] | |
| for p in self.partitions: | |
| if p.tar_size == 0: | |
| continue | |
| try: | |
| member = tf.getmember(p.name) | |
| f = tf.extractfile(member) | |
| if not f: | |
| continue | |
| part_data = f.read() | |
| md5 = hashlib.md5(part_data).hexdigest() | |
| crc = zlib.crc32(part_data) & 0xffffffff | |
| print(f" {p.name:<12} {p.tar_size:>10} {md5:<34} {crc:08x}") | |
| results.append({ | |
| 'name': p.name, | |
| 'size': p.tar_size, | |
| 'md5': md5, | |
| 'crc32': f"{crc:08x}", | |
| }) | |
| except: | |
| pass | |
| tf.close() | |
| return results | |
| # ─── CLI ───────────────────────────────────────────────────────────────────── | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description='Loewe TLI Firmware Dumper / Extractor', | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| %(prog)s firmware.tli # Show info | |
| %(prog)s firmware.tli output/ --extract # Extract all partitions | |
| %(prog)s firmware.tli output/ --extract Main # Extract only Main | |
| %(prog)s firmware.tli output/ --unpack # Extract and unpack inner formats | |
| %(prog)s firmware.tli --hashes # Show partition hashes | |
| """ | |
| ) | |
| parser.add_argument('input', help='Path to .tli firmware file') | |
| parser.add_argument('output', nargs='?', default=None, help='Output directory') | |
| parser.add_argument('--info', action='store_true', help='Show partition table and metadata only') | |
| parser.add_argument('--extract', nargs='?', const='all', default=None, | |
| help='Extract partitions (comma-separated names, or "all")') | |
| parser.add_argument('--raw', action='store_true', help='Save raw data without processing') | |
| parser.add_argument('--unpack', action='store_true', help='Try to unpack inner formats (uImage, gzip, xz)') | |
| parser.add_argument('--hashes', action='store_true', help='Compute and display partition hashes') | |
| parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output') | |
| args = parser.parse_args() | |
| # Load file | |
| if not os.path.isfile(args.input): | |
| print(f"Error: File not found: {args.input}") | |
| sys.exit(1) | |
| print(f"\n Loading {args.input}...") | |
| dumper = TLIDumper(args.input) | |
| dumper.load() | |
| # Default action: show info | |
| if not args.extract and not args.hashes: | |
| dumper.show_info() | |
| if args.hashes: | |
| dumper.compute_hashes() | |
| if args.extract: | |
| if not args.output: | |
| args.output = os.path.splitext(args.input)[0] + "_extracted" | |
| partition_names = None | |
| if args.extract != 'all': | |
| partition_names = [n.strip() for n in args.extract.split(',')] | |
| print(f"\n Extracting to: {args.output}") | |
| dumper.extract( | |
| args.output, | |
| partition_names=partition_names, | |
| raw=args.raw, | |
| unpack=args.unpack, | |
| verbose=args.verbose, | |
| ) | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment