diff --git a/README.md b/README.md
index 11b73e4..53482f4 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# ltchiptool
-Universal, easy-to-use GUI flashing/dumping tool for BK7231, RTL8710B and RTL8720C. Also contains some CLI utilities for binary firmware manipulation.
+Universal, easy-to-use GUI flashing/dumping tool for BK7231, LN882H, RTL8710B and RTL8720C. Also contains some CLI utilities for binary firmware manipulation.
diff --git a/ltchiptool/commands/soc.py b/ltchiptool/commands/soc.py
index 5ef40d5..cf5eff4 100644
--- a/ltchiptool/commands/soc.py
+++ b/ltchiptool/commands/soc.py
@@ -9,6 +9,7 @@
"rtltool": "ltchiptool/soc/ambz/util/rtltool.py",
"ambztool": "ltchiptool/soc/ambz/util/ambztool.py",
"ambz2tool": "ltchiptool/soc/ambz2/util/ambz2tool.py",
+ "ln882htool": "ltchiptool/soc/ln882h/util/ln882htool.py",
}
diff --git a/ltchiptool/soc/amb/system.py b/ltchiptool/soc/amb/system.py
index db17167..a5ad5ae 100644
--- a/ltchiptool/soc/amb/system.py
+++ b/ltchiptool/soc/amb/system.py
@@ -6,7 +6,7 @@
from datastruct import DataStruct
from datastruct.fields import adapter, alignto, bitfield, field
-FF_16 = b"\xFF" * 16
+FF_16 = b"\xff" * 16
class FlashSpeed(IntEnum):
diff --git a/ltchiptool/soc/ambz/binary.py b/ltchiptool/soc/ambz/binary.py
index 7a5d214..aa8e0d5 100644
--- a/ltchiptool/soc/ambz/binary.py
+++ b/ltchiptool/soc/ambz/binary.py
@@ -24,14 +24,14 @@ def check_xip_binary(
) -> Optional[Tuple[int, int, bytes]]:
if data[0:8] != header:
return None
- if data[16:32] != b"\xFF" * 16:
+ if data[16:32] != b"\xff" * 16:
return None
length, start = unpack(" Optional[Tuple[int, int, bytes]]:
- return check_xip_binary(data, header=b"\x99\x99\x96\x96\x3F\xCC\x66\xFC")
+ return check_xip_binary(data, header=b"\x99\x99\x96\x96\x3f\xcc\x66\xfc")
class AmebaZBinary(SocInterface, ABC):
diff --git a/ltchiptool/soc/ambz/flash.py b/ltchiptool/soc/ambz/flash.py
index b2c64e8..cff27f5 100644
--- a/ltchiptool/soc/ambz/flash.py
+++ b/ltchiptool/soc/ambz/flash.py
@@ -105,7 +105,7 @@ def flash_sw_reset(self) -> None:
port.baudrate = 115200
sleep(0.1)
# try software reset by writing the family ID, preceded by 55AA
- magic_word = b"\x55\xAA" + self.family.id.to_bytes(length=4, byteorder="big")
+ magic_word = b"\x55\xaa" + self.family.id.to_bytes(length=4, byteorder="big")
port.write(magic_word)
sleep(0.5)
port.baudrate = prev_baudrate
@@ -178,7 +178,7 @@ def flash_get_chip_info(self) -> List[Tuple[str, str]]:
syscfg2 = letoint(data[256 + 512 + 16 + 8 : 256 + 512 + 16 + 8 + 4])
system_data = data[256 + 512 + 16 + 16 : 256 + 512 + 16 + 16 + 128].ljust(
- 4096, b"\xFF"
+ 4096, b"\xff"
)
system = SystemData.unpack(system_data)
diff --git a/ltchiptool/soc/ambz/util/ambzcode.py b/ltchiptool/soc/ambz/util/ambzcode.py
index 72d2220..c0fdc4a 100644
--- a/ltchiptool/soc/ambz/util/ambzcode.py
+++ b/ltchiptool/soc/ambz/util/ambzcode.py
@@ -204,7 +204,7 @@ def read_efuse_otp(offset: int = 0) -> bytes:
return (
b"\x02\x48\x01\x4b"
b"\x98\x47\x03\xe0"
- b"\x21\x3C\x00\x00" # EFUSE_OTP_Read32B()
+ b"\x21\x3c\x00\x00" # EFUSE_OTP_Read32B()
) + inttole32(AMBZ_DATA_ADDRESS + offset)
@staticmethod
diff --git a/ltchiptool/soc/ambz/util/ambztool.py b/ltchiptool/soc/ambz/util/ambztool.py
index dc88e59..d3d7575 100644
--- a/ltchiptool/soc/ambz/util/ambztool.py
+++ b/ltchiptool/soc/ambz/util/ambztool.py
@@ -108,7 +108,7 @@ def read(self, io: IO[bytes], n: int) -> bytes:
# increment saved address
self.address += n
# add padding to force sending N+4 packet size
- data = data.ljust(n + 4, b"\xFF")
+ data = data.ljust(n + 4, b"\xff")
return data
diff --git a/ltchiptool/soc/ambz/util/rtltool.py b/ltchiptool/soc/ambz/util/rtltool.py
index 0b3077e..8214c1b 100644
--- a/ltchiptool/soc/ambz/util/rtltool.py
+++ b/ltchiptool/soc/ambz/util/rtltool.py
@@ -29,7 +29,7 @@
CMD_XMD = b"\x07" # Go xmodem mode (write RAM/Flash mode)
CMD_EFS = b"\x17" # Erase Flash Sectors
CMD_RBF = b"\x19" # Read Block Flash
-CMD_ABRT = b"\x1B" # End xmodem mode (write RAM/Flash mode)
+CMD_ABRT = b"\x1b" # End xmodem mode (write RAM/Flash mode)
CMD_GFS = b"\x21" # FLASH Get Status
CMD_SFS = b"\x26" # FLASH Set Status
@@ -283,7 +283,7 @@ def send_xmodem(self, stream, offset, size, retry=3):
if not data: # end of stream
print("send: at EOF")
return False
- data = data.ljust(packet_size, b"\xFF")
+ data = data.ljust(packet_size, b"\xff")
pkt = (
struct.pack(" List[FirmwareBinary]:
nmap_ota1 = self.board.toolchain.nm(input)
# build the partition table
- ptable = PartitionTable(user_data=b"\xFF" * 256)
+ ptable = PartitionTable(user_data=b"\xff" * 256)
for region, type in config.ptable.items():
offset, length, _ = self.board.region(region)
hash_key = config.keys.hash_keys[region]
@@ -205,11 +205,11 @@ def elf2bin(self, input: str, ota_idx: int) -> List[FirmwareBinary]:
with output.write() as f:
f.write(data)
with out_ptab.write() as f:
- ptab = data[ptab_offset:ptab_end].rstrip(b"\xFF")
+ ptab = data[ptab_offset:ptab_end].rstrip(b"\xff")
ptab = pad_data(ptab, 0x20, 0xFF)
f.write(ptab)
with out_boot.write() as f:
- boot = data[boot_offset:boot_end].rstrip(b"\xFF")
+ boot = data[boot_offset:boot_end].rstrip(b"\xff")
boot = pad_data(boot, 0x20, 0xFF)
f.write(boot)
with out_ota1.write() as f:
@@ -230,15 +230,15 @@ def detect_file_type(
return Detection.make("Realtek AmebaZ2 Flash Image", offset=0)
if (
- data[0x40:0x44] != b"\xFF\xFF\xFF\xFF"
+ data[0x40:0x44] != b"\xff\xff\xff\xff"
and data[0x48] == ImageType.BOOT.value
):
return Detection.make("Realtek AmebaZ2 Bootloader", offset=0x4000)
if (
- data[0xE0:0xE8].strip(b"\xFF")
+ data[0xE0:0xE8].strip(b"\xff")
and data[0xE8] == ImageType.FWHS_S.value
- and data[0x1A0:0x1A8].strip(b"\xFF")
+ and data[0x1A0:0x1A8].strip(b"\xff")
and data[0x1A8] == SectionType.SRAM.value
):
return Detection.make("Realtek AmebaZ2 Firmware", offset=None)
diff --git a/ltchiptool/soc/ambz2/util/models/images.py b/ltchiptool/soc/ambz2/util/models/images.py
index da6804a..7b55797 100644
--- a/ltchiptool/soc/ambz2/util/models/images.py
+++ b/ltchiptool/soc/ambz2/util/models/images.py
@@ -27,7 +27,7 @@
from .partitions import Bootloader, Firmware, PartitionTable
from .utils import FF_32
-FLASH_CALIBRATION = b"\x99\x99\x96\x96\x3F\xCC\x66\xFC\xC0\x33\xCC\x03\xE5\xDC\x31\x62"
+FLASH_CALIBRATION = b"\x99\x99\x96\x96\x3f\xcc\x66\xfc\xc0\x33\xcc\x03\xe5\xdc\x31\x62"
@dataclass
diff --git a/ltchiptool/soc/ambz2/util/models/utils.py b/ltchiptool/soc/ambz2/util/models/utils.py
index c577a14..08d97eb 100644
--- a/ltchiptool/soc/ambz2/util/models/utils.py
+++ b/ltchiptool/soc/ambz2/util/models/utils.py
@@ -4,9 +4,9 @@
from datastruct import Adapter, Context
-FF_48 = b"\xFF" * 48
-FF_32 = b"\xFF" * 32
-FF_16 = b"\xFF" * 16
+FF_48 = b"\xff" * 48
+FF_32 = b"\xff" * 32
+FF_16 = b"\xff" * 16
T = TypeVar("T")
diff --git a/ltchiptool/soc/bk72xx/binary.py b/ltchiptool/soc/bk72xx/binary.py
index 9bffc84..2e0e42f 100644
--- a/ltchiptool/soc/bk72xx/binary.py
+++ b/ltchiptool/soc/bk72xx/binary.py
@@ -29,7 +29,7 @@ def to_address(offs: int) -> int:
def check_app_code_crc(data: bytes) -> Union[bool, None]:
# b #0x40
# ldr pc, [pc, #0x14]
- if data[0:8] == b"\x2F\x07\xB5\x94\x35\xFF\x2A\x9B":
+ if data[0:8] == b"\x2f\x07\xb5\x94\x35\xff\x2a\x9b":
crc = CRC16.CMS.calc(data[0:32])
crc_found = betoint(data[32:34])
if crc == crc_found:
@@ -183,13 +183,13 @@ def elf2bin(self, input: str, ota_idx: int) -> List[FirmwareBinary]:
with out_ug.write() as ug:
hdr = BytesIO()
ota_bin = ota_data.getvalue()
- hdr.write(b"\x55\xAA\x55\xAA")
+ hdr.write(b"\x55\xaa\x55\xaa")
hdr.write(pad_data(version.encode(), 12, 0x00))
hdr.write(inttobe32(len(ota_bin)))
hdr.write(inttobe32(sum(ota_bin)))
ug.write(hdr.getvalue())
ug.write(inttobe32(sum(hdr.getvalue())))
- ug.write(b"\xAA\x55\xAA\x55")
+ ug.write(b"\xaa\x55\xaa\x55")
ug.write(ota_bin)
# close all files
@@ -218,7 +218,7 @@ def detect_file_type(
return Detection.make_unsupported("Beken Encrypted App")
# raw firmware binary
- if data[0:8] == b"\x0E\x00\x00\xEA\x14\xF0\x9F\xE5":
+ if data[0:8] == b"\x0e\x00\x00\xea\x14\xf0\x9f\xe5":
return Detection.make_unsupported("Raw ARM Binary")
# RBL file for OTA - 'download' partition
diff --git a/ltchiptool/soc/bk72xx/util/binary.py b/ltchiptool/soc/bk72xx/util/binary.py
index cb49eb7..26ef545 100644
--- a/ltchiptool/soc/bk72xx/util/binary.py
+++ b/ltchiptool/soc/bk72xx/util/binary.py
@@ -44,7 +44,7 @@ def __init__(self, coeffs: Union[bytes, str] = None) -> None:
def crc(self, data: ByteSource, type: DataType = None) -> DataGenerator:
for block in geniter(data, 32):
if len(block) < 32:
- block += b"\xFF" * (32 - len(block))
+ block += b"\xff" * (32 - len(block))
crc = CRC16.CMS.calc(block)
block += inttobe16(crc)
if type:
@@ -54,7 +54,7 @@ def crc(self, data: ByteSource, type: DataType = None) -> DataGenerator:
def uncrc(self, data: ByteSource, check: bool = True) -> ByteGenerator:
for block in geniter(data, 34):
- if check and block != b"\xFF" * 34:
+ if check and block != b"\xff" * 34:
crc = CRC16.CMS.calc(block[0:32])
crc_found = betoint(block[32:34])
if crc != crc_found:
diff --git a/ltchiptool/soc/interface.py b/ltchiptool/soc/interface.py
index 0858047..ccef695 100644
--- a/ltchiptool/soc/interface.py
+++ b/ltchiptool/soc/interface.py
@@ -28,6 +28,9 @@ def get(cls, family: Family) -> "SocInterface":
if family.is_child_of("realtek-ambz2"):
from .ambz2 import AmebaZ2Main
return AmebaZ2Main(family)
+ if family.is_child_of("lightning-ln882h"):
+ from .ln882h import LN882hMain
+ return LN882hMain(family)
# fmt: on
raise NotImplementedError(f"Unsupported family - {family.name}")
@@ -38,6 +41,7 @@ def get_family_names(cls) -> List[str]:
"beken-72xx",
"realtek-ambz",
"realtek-ambz2",
+ "lightning-ln882h",
]
#########################
diff --git a/ltchiptool/soc/ln882h/__init__.py b/ltchiptool/soc/ln882h/__init__.py
new file mode 100644
index 0000000..2f2c3ed
--- /dev/null
+++ b/ltchiptool/soc/ln882h/__init__.py
@@ -0,0 +1,7 @@
+# Copyright (c) Etienne Le Cousin 2025-01-02.
+
+from .main import LN882hMain
+
+__all__ = [
+ "LN882hMain",
+]
diff --git a/ltchiptool/soc/ln882h/binary.py b/ltchiptool/soc/ln882h/binary.py
new file mode 100644
index 0000000..637b789
--- /dev/null
+++ b/ltchiptool/soc/ln882h/binary.py
@@ -0,0 +1,118 @@
+# Copyright (c) Etienne Le Cousin 2025-01-02.
+
+from abc import ABC
+from logging import warning
+from os import stat
+from os.path import dirname, isfile
+from shutil import copyfile
+from typing import List
+
+from ltchiptool import SocInterface
+from ltchiptool.util.fileio import chext, chname
+from ltchiptool.util.fwbinary import FirmwareBinary
+
+from .util import OTATOOL, MakeImageTool
+from .util.models import PartDescInfo, part_type_str2num
+
+
+class LN882hBinary(SocInterface, ABC):
+ def elf2bin(self, input: str, ota_idx: int) -> List[FirmwareBinary]:
+ toolchain = self.board.toolchain
+ flash_layout = self.board["flash"]
+
+ # find bootloader image
+ input_boot = chname(input, "boot.bin")
+ if not isfile(input_boot):
+ raise FileNotFoundError("Bootloader image not found")
+
+ # build output names
+ output = FirmwareBinary(
+ location=input,
+ name="firmware",
+ offset=0,
+ title="Flash Image",
+ description="Complete image with boot for flashing at offset 0",
+ public=True,
+ )
+ out_boot = FirmwareBinary(
+ location=input,
+ name="boot",
+ offset=self.board.region("boot")[0],
+ title="Bootloader Image",
+ )
+ out_ptab = FirmwareBinary(
+ location=input,
+ name="part_tab",
+ offset=self.board.region("part_tab")[0],
+ title="Partition Table",
+ )
+ out_app = FirmwareBinary(
+ location=input,
+ name="app",
+ offset=self.board.region("app")[0],
+ title="Application Image",
+ description="Firmware partition image for direct flashing",
+ public=True,
+ )
+ out_ota = FirmwareBinary(
+ location=input,
+ name="ota",
+ offset=self.board.region("ota")[0],
+ title="OTA Image",
+ description="Compressed App image for OTA flashing",
+ public=True,
+ )
+ # print graph element
+ output.graph(1)
+
+ input_bin = chext(input, "bin")
+ # objcopy ELF -> raw BIN
+ toolchain.objcopy(input, input_bin)
+
+ # Make Image Tool
+ # fmt: off
+ mkimage = MakeImageTool()
+ mkimage.boot_filepath = input_boot
+ mkimage.app_filepath = input_bin
+ mkimage.flashimage_filepath = output.path
+ mkimage.ver_str = "1.0"
+ mkimage.swd_crp = 0
+ mkimage.readPartCfg = lambda : True
+ # fmt: off
+
+ # find all partitions
+ for name, layout in flash_layout.items():
+ (offset, _, length) = layout.partition("+")
+ part_info = PartDescInfo(
+ parttype = part_type_str2num(name.upper()),
+ startaddr = int(offset, 16),
+ partsize = int(length, 16)
+ )
+ mkimage._MakeImageTool__part_desc_info_list.append(part_info)
+
+ if not mkimage.doAllWork():
+ raise RuntimeError("MakeImageTool: Fail to generate image")
+
+ # write all parts to files
+ with out_boot.write() as f:
+ f.write(mkimage._MakeImageTool__partbuf_bootram)
+ with out_ptab.write() as f:
+ f.write(mkimage._MakeImageTool__partbuf_parttab)
+ with out_app.write() as f:
+ f.write(mkimage._MakeImageTool__partbuf_app)
+
+ # Make ota image
+ ota_tool = OTATOOL()
+ ota_tool.input_filepath = output.path
+ ota_tool.output_dir = dirname(input)
+ if not ota_tool.doAllWork():
+ raise RuntimeError("MakeImageTool: Fail to generate OTA image")
+
+ copyfile(ota_tool.output_filepath, out_ota.path)
+ _, ota_size, _ = self.board.region("ota")
+ if stat(out_ota.path).st_size > ota_size:
+ warning(
+ f"OTA size too large: {out_ota.filename} > {ota_size} (0x{ota_size:X})"
+ )
+
+ return output.group()
diff --git a/ltchiptool/soc/ln882h/flash.py b/ltchiptool/soc/ln882h/flash.py
new file mode 100644
index 0000000..95cb3be
--- /dev/null
+++ b/ltchiptool/soc/ln882h/flash.py
@@ -0,0 +1,200 @@
+# Copyright (c) Etienne Le Cousin 2025-01-02.
+
+from abc import ABC
+from typing import IO, Generator, List, Optional, Tuple, Union
+
+from ltchiptool import SocInterface
+from ltchiptool.util.flash import FlashConnection, FlashFeatures, FlashMemoryType
+from ltchiptool.util.streams import ProgressCallback
+from uf2tool import OTAScheme, UploadContext
+
+from .util.ln882htool import LN882hTool
+
+LN882H_GUIDE = [
+ "Connect UART1 of the LN882h to the USB-TTL adapter:",
+ [
+ ("PC", "LN882h"),
+ ("RX", "TX1 (GPIOA2 / P2)"),
+ ("TX", "RX1 (GPIOA3 / P3)"),
+ ("", ""),
+ ("GND", "GND"),
+ ],
+ "Using a good, stable 3.3V power supply is crucial. Most flashing issues\n"
+ "are caused by either voltage drops during intensive flash operations,\n"
+ "or bad/loose wires.",
+ "The UART adapter's 3.3V power regulator is usually not enough. Instead,\n"
+ "a regulated bench power supply, or a linear 1117-type regulator is recommended.",
+ "To enter download mode, the chip has to be rebooted while the flashing program\n"
+ "is trying to establish communication.\n"
+ "In order to do that, you need to bridge BOOT pin (GPIOA9) to GND with a wire.",
+]
+
+
+class LN882hFlash(SocInterface, ABC):
+ ln882h: Optional[LN882hTool] = None
+ info: List[Tuple[str, str]] = None
+
+ def flash_get_features(self) -> FlashFeatures:
+ return FlashFeatures()
+
+ def flash_get_guide(self) -> List[Union[str, list]]:
+ return LN882H_GUIDE
+
+ def flash_get_docs_url(self) -> Optional[str]:
+ return "https://docs.libretiny.eu/link/flashing-ln882h"
+
+ def flash_set_connection(self, connection: FlashConnection) -> None:
+ if self.conn:
+ self.flash_disconnect()
+ self.conn = connection
+ self.conn.fill_baudrate(115200)
+
+ def flash_build_protocol(self, force: bool = False) -> None:
+ if not force and self.ln882h:
+ return
+ self.flash_disconnect()
+ self.ln882h = LN882hTool(
+ port=self.conn.port,
+ baudrate=self.conn.link_baudrate,
+ )
+ self.flash_change_timeout(self.conn.timeout, self.conn.link_timeout)
+
+ def flash_change_timeout(self, timeout: float = 0.0, link_timeout: float = 0.0):
+ self.flash_build_protocol()
+ if timeout:
+ self.ln882h.read_timeout = timeout
+ self.conn.timeout = timeout
+ if link_timeout:
+ self.ln882h.link_timeout = link_timeout
+ self.conn.link_timeout = link_timeout
+
+ def flash_connect(self, callback: ProgressCallback = ProgressCallback()) -> None:
+ if self.ln882h and self.conn.linked:
+ return
+ self.flash_build_protocol()
+ assert self.ln882h
+ self.ln882h.link()
+
+ def cb(i, n, t, sent):
+ callback.on_update(sent - cb.total_sent)
+ cb.total_sent = sent
+
+ cb.total_sent = 0
+
+ callback.on_message(f"Loading Ram Code")
+ self.ln882h.ram_boot(cb)
+ self.conn.linked = True
+
+ def flash_disconnect(self) -> None:
+ if self.ln882h:
+ self.ln882h.close()
+ self.ln882h = None
+ if self.conn:
+ self.conn.linked = False
+
+ def flash_get_chip_info(self) -> List[Tuple[str, str]]:
+ if self.info:
+ return self.info
+ self.flash_connect()
+ assert self.ln882h
+
+ flash_info = self.ln882h.command("flash_info")[-1]
+ flash_info = dict(s.split(":") for s in flash_info.split(","))
+
+ self.info = [
+ ("Flash ID", flash_info["id"]),
+ ("Flash Size", flash_info["flash size"]),
+ ("Flash UUID", self.ln882h.command("flash_uid")[-1][10:]),
+ ("OTP MAC", self.ln882h.command("get_mac_in_flash_otp")[-2]),
+ ]
+ return self.info
+
+ def flash_get_chip_info_string(self) -> str:
+ self.flash_connect()
+ assert self.ln882h
+ return "LN882H"
+
+ def flash_get_size(self, memory: FlashMemoryType = FlashMemoryType.FLASH) -> int:
+ self.flash_connect()
+ assert self.ln882h
+ if memory == FlashMemoryType.EFUSE:
+ raise NotImplementedError("Memory type not readable via UART")
+ else:
+ # It appears that flash size is coded in the low byte of flash ID as 2^X
+ # Ex: LN882HKI id=0xEB6015 --> 0x15 = 21 --> flash_size = 2^21 = 2MB
+ flash_info = self.ln882h.command("flash_info")[-1]
+ flash_info = dict(s.split(":") for s in flash_info.split(","))
+ flash_size = 1 << (int(flash_info["id"], 16) & 0xFF)
+ return flash_size
+
+ def flash_read_raw(
+ self,
+ offset: int,
+ length: int,
+ verify: bool = True,
+ memory: FlashMemoryType = FlashMemoryType.FLASH,
+ callback: ProgressCallback = ProgressCallback(),
+ ) -> Generator[bytes, None, None]:
+ self.flash_connect()
+ assert self.ln882h
+ if memory == FlashMemoryType.EFUSE:
+ raise NotImplementedError("Memory type not readable via UART")
+ else:
+ gen = self.ln882h.flash_read(
+ offset=offset,
+ length=length,
+ verify=verify,
+ )
+ yield from callback.update_with(gen)
+
+ def flash_write_raw(
+ self,
+ offset: int,
+ length: int,
+ data: IO[bytes],
+ verify: bool = True,
+ callback: ProgressCallback = ProgressCallback(),
+ ) -> None:
+ self.flash_connect()
+ assert self.ln882h
+
+ def cb(i, n, t, sent):
+ callback.on_update(sent - cb.total_sent)
+ cb.total_sent = sent
+
+ cb.total_sent = 0
+
+ self.ln882h.flash_write(
+ offset=offset,
+ stream=data,
+ callback=cb,
+ )
+
+ def flash_write_uf2(
+ self,
+ ctx: UploadContext,
+ verify: bool = True,
+ callback: ProgressCallback = ProgressCallback(),
+ ) -> None:
+ # collect continuous blocks of data (before linking, as this takes time)
+ parts = ctx.collect_data(OTAScheme.FLASHER_SINGLE)
+ callback.on_total(sum(len(part.getvalue()) for part in parts.values()))
+
+ # connect to chip
+ self.flash_connect()
+
+ # write blocks to flash
+ for offset, data in parts.items():
+ length = len(data.getvalue())
+ data.seek(0)
+ callback.on_message(f"Writing (0x{offset:06X})")
+ gen = self.flash_write_raw(
+ offset=offset,
+ data=data,
+ length=length,
+ callback=callback,
+ )
+
+ callback.on_message("Booting firmware")
+ # reboot the chip
+ self.ln882h.disconnect()
diff --git a/ltchiptool/soc/ln882h/main.py b/ltchiptool/soc/ln882h/main.py
new file mode 100644
index 0000000..4b9353d
--- /dev/null
+++ b/ltchiptool/soc/ln882h/main.py
@@ -0,0 +1,34 @@
+# Copyright (c) Etienne Le Cousin 2025-01-02.
+
+from abc import ABC
+from logging import info
+from typing import Optional
+
+from ltchiptool import Family
+from ltchiptool.models import OTAType
+from ltchiptool.soc import SocInterfaceCommon
+
+from .binary import LN882hBinary
+from .flash import LN882hFlash
+
+
+class LN882hMain(
+ LN882hBinary,
+ LN882hFlash,
+ SocInterfaceCommon,
+ ABC,
+):
+ def __init__(self, family: Family) -> None:
+ super().__init__()
+ self.family = family
+
+ def hello(self):
+ info("Hello from LN882h")
+
+ @property
+ def ota_type(self) -> Optional[OTAType]:
+ return OTAType.SINGLE
+
+ @property
+ def ota_supports_format_1(self) -> bool:
+ return True
diff --git a/ltchiptool/soc/ln882h/util/__init__.py b/ltchiptool/soc/ln882h/util/__init__.py
new file mode 100644
index 0000000..6b539b4
--- /dev/null
+++ b/ltchiptool/soc/ln882h/util/__init__.py
@@ -0,0 +1,9 @@
+# Copyright (c) Etienne Le Cousin 2025-01-02.
+
+from .makeimage import MakeImageTool
+from .ota_image_generator import OTATOOL
+
+__all__ = [
+ "MakeImageTool",
+ "OTATOOL",
+]
diff --git a/ltchiptool/soc/ln882h/util/ln882htool.py b/ltchiptool/soc/ln882h/util/ln882htool.py
new file mode 100644
index 0000000..777c32f
--- /dev/null
+++ b/ltchiptool/soc/ln882h/util/ln882htool.py
@@ -0,0 +1,256 @@
+# Copyright (c) Etienne Le Cousin 2025-02-23.
+
+from logging import debug, info
+from os import path, stat
+from tempfile import NamedTemporaryFile
+from time import sleep, time
+from typing import IO, Callable, Generator, Optional
+
+import click
+from ymodem.Socket import ModemSocket
+
+from ltchiptool.util.cli import DevicePortParamType
+from ltchiptool.util.serialtool import SerialToolBase
+
+_T_YmodemCB = Optional[Callable[[int, str, int, int], None]]
+
+LN882H_YM_BAUDRATE = 2000000
+LN882H_ROM_BAUDRATE = 115200
+LN882H_FLASH_ADDRESS = 0x0000000
+LN882H_RAM_ADDRESS = 0x20000000
+LN882H_BOOTRAM_FILE = "ramcode.bin"
+
+
+class LN882hTool(SerialToolBase):
+ ramcode = False
+
+ def __init__(
+ self,
+ port: str,
+ baudrate: int,
+ link_timeout: float = 10.0,
+ read_timeout: float = 0.2,
+ retry_count: int = 10,
+ ):
+ super().__init__(port, baudrate, link_timeout, read_timeout, retry_count)
+ self.ym = ModemSocket(
+ read=lambda size, timeout=2: self.read(size) or None,
+ write=lambda data, timeout=2: self.write(data),
+ packet_size=128, # it seems that ramcode doesn't support 1k packets for filename...
+ )
+
+ #########################################
+ # Private #
+ #########################################
+
+ # Redefinition of readlines because romloader omit the last '\n' on some cmds...
+ def readlines(self) -> Generator[str, None, None]:
+ response = b""
+ end = time() + self.read_timeout
+ self.s.timeout = self.read_timeout
+ while time() < end:
+ read = self.s.read_all()
+ if not read:
+ continue
+ end = time() + self.read_timeout
+ while b"\n" in read:
+ line, _, read = read.partition(b"\n")
+ line = (response + line).decode().strip()
+ if not line:
+ continue
+ yield line
+ response = b""
+ response += read
+ if response: # add the last received "line" if any
+ yield response
+ raise TimeoutError("Timeout in readlines() - no more data received")
+
+ #########################################
+ # Basic commands - public low-level API #
+ #########################################
+
+ def command(self, cmd: str, waitresp: bool = True) -> str:
+ debug(f"cmd: {cmd}")
+ self.flush()
+ self.write(cmd.encode() + b"\r\n")
+ # remove ramcode echo
+ if self.ramcode:
+ self.read(len(cmd))
+ return waitresp and self.resp() or None
+
+ def resp(self) -> str:
+ r = []
+ try:
+ for l in self.readlines():
+ r.append(l)
+ except TimeoutError:
+ pass
+ if not r:
+ raise TimeoutError("No response")
+ debug(f"resp: {r}")
+ return r
+
+ def ping(self) -> None:
+ self.ramcode = False
+ resp = self.command("version")[-1]
+ if resp == "RAMCODE":
+ self.ramcode = True
+ elif len(resp) != 20 or resp[11] != "/":
+ raise RuntimeError(f"Incorrect ping response: {resp!r}")
+
+ def disconnect(self) -> None:
+ self.sw_reset()
+
+ def link(self) -> None:
+ end = time() + self.link_timeout
+ while time() < end:
+ try:
+ self.ping()
+ return
+ except (RuntimeError, TimeoutError):
+ pass
+ raise TimeoutError("Timeout while linking")
+
+ def sw_reset(self) -> None:
+ self.command("reboot", waitresp=False)
+
+ def change_baudrate(self, baudrate: int) -> None:
+ if self.s.baudrate == baudrate:
+ return
+ self.flush()
+ self.command(f"baudrate {baudrate}", waitresp=False)
+ self.flush()
+ self.set_baudrate(baudrate)
+
+ ###############################################
+ # Flash-related commands - for internal usage #
+ ###############################################
+
+ def ram_boot(
+ self,
+ callback: _T_YmodemCB = None,
+ ) -> None:
+ if self.ramcode:
+ return
+
+ info("Loading RAM Code...")
+ ramcode_file = path.join(path.dirname(__file__), LN882H_BOOTRAM_FILE)
+ ramcode_size = stat(ramcode_file).st_size
+
+ self.command(
+ f"download [rambin] [0x{LN882H_RAM_ADDRESS:X}] [{ramcode_size}]",
+ waitresp=False,
+ )
+
+ self.push_timeout(3)
+ debug(f"YMODEM: transmitting to 0x{LN882H_RAM_ADDRESS:X}")
+ if not self.ym.send([ramcode_file], callback=callback):
+ self.pop_timeout()
+ raise RuntimeError("YMODEM transmission failed")
+ info("RAM Code successfully loaded.")
+ self.pop_timeout()
+
+ # wait for boot start
+ sleep(2)
+ self.link()
+
+ if not self.ramcode:
+ raise RuntimeError("RAM boot failed")
+
+ #######################################
+ # Memory-related commands - public API #
+ #######################################
+
+ def flash_read(
+ self,
+ offset: int,
+ length: int,
+ verify: bool = True,
+ chunk_size: int = 256, # maximum supported chunk size
+ ) -> Generator[bytes, None, None]:
+ self.link()
+ if not self.ramcode:
+ self.ram_boot()
+
+ if chunk_size > 256:
+ raise RuntimeError(
+ f"Chunk size {chunk_size} exceeds the maximum allowed (256)"
+ )
+
+ for start in range(offset, offset + length, chunk_size):
+ count = min(start + chunk_size, offset + length) - start
+ debug(f"Dumping bytes: start=0x{start:X}, count=0x{count:X}")
+
+ resp = self.command(f"flash_read 0x{start:X} 0x{count:X}")[-1]
+ data = bytearray.fromhex(resp.decode())
+
+ valid, data = self.ym._verify_recv_checksum(True, data)
+ if verify and not valid:
+ raise RuntimeError(f"Invalid checksum")
+
+ yield data
+
+ def flash_write(
+ self,
+ offset: int,
+ stream: IO[bytes],
+ callback: _T_YmodemCB = None,
+ ) -> None:
+ self.link()
+ prev_baudrate = self.s.baudrate
+ if not self.ramcode:
+ self.ram_boot()
+
+ self.change_baudrate(LN882H_YM_BAUDRATE)
+ self.link()
+
+ self.command(f"startaddr 0x{offset:X}")
+
+ # Convert stream to temporary file before sending with YMODEM
+ tmp_file = NamedTemporaryFile()
+ with open(tmp_file.name, "wb") as f:
+ f.write(stream.getbuffer())
+
+ self.command(f"upgrade", waitresp=False)
+
+ self.push_timeout(3)
+ debug(f"YMODEM: transmitting to 0x{offset:X}")
+ if not self.ym.send([f.name], callback=callback):
+ self.change_baudrate(prev_baudrate)
+ self.pop_timeout()
+ raise RuntimeError("YMODEM transmission failed")
+
+ self.link()
+
+ self.change_baudrate(prev_baudrate)
+ self.pop_timeout()
+ info("Flash Successful.")
+
+
+@click.command(
+ help="LN882H flashing tool",
+)
+@click.option(
+ "-d",
+ "--device",
+ help="Target device port (default: auto detect)",
+ type=DevicePortParamType(),
+ default=(),
+)
+def cli(device: str):
+ ln882h = LN882HTool(port=device, baudrate=LN882H_ROM_BAUDRATE)
+ info("Linking...")
+ ln882h.link()
+
+ info("Loading Ram code...")
+ ln882h.ram_boot()
+
+ flash_info = ln882h.command("flash_info")[-1]
+ info(f"Received flash info: {flash_info}")
+
+ info("Disconnecting...")
+ ln882h.disconnect()
+
+
+if __name__ == "__main__":
+ cli()
diff --git a/ltchiptool/soc/ln882h/util/makeimage.py b/ltchiptool/soc/ln882h/util/makeimage.py
new file mode 100644
index 0000000..fb2bb42
--- /dev/null
+++ b/ltchiptool/soc/ln882h/util/makeimage.py
@@ -0,0 +1,483 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+#
+# Copyright 2021 Shanghai Lightning Semiconductor Technology Co., LTD
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# 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.
+# fmt: off
+# isort:skip_file
+
+import argparse
+import json
+
+from .models.boot_header import *
+from .models.image_header import *
+from .models.part_desc_info import *
+
+
+class MakeImageTool:
+ def __init__(self) -> None:
+ self.__boot_filepath = None
+ self.__app_filepath = None
+ self.__flashimage_filepath = None
+ self.__part_cfg_filepath = None
+ self.__ver_str = None
+ self.__ver_major = 0
+ self.__ver_minor = 0
+ self.__swd_crp = 0
+ self.__verbose = 0
+
+ self.__part_desc_info_list = []
+ self.__partbuf_bootram = None
+ self.__partbuf_parttab = None
+ self.__partbuf_nvds = None
+ self.__partbuf_app = None
+ self.__partbuf_kv = None
+ self.__partbuf_eeprom = None
+
+ def readPartCfg(self) -> bool:
+ try:
+ with open(self.part_cfg_filepath, "r", encoding="utf-8") as fObj:
+ root_node = json.load(fp=fObj)
+ vendor_node = root_node["vendor_define"]
+ user_node = root_node["user_define"]
+
+ for node in vendor_node:
+ parttype = part_type_str2num(node["partition_type"])
+ startaddr = int(node["start_addr"], 16)
+ partsize = node["size_KB"] * 1024
+
+ part_info = PartDescInfo(parttype=parttype, startaddr=startaddr, partsize=partsize)
+ self.__part_desc_info_list.append(part_info)
+
+ for node in user_node:
+ parttype = part_type_str2num(node["partition_type"])
+ startaddr = int(node["start_addr"], 16)
+ partsize = node["size_KB"] * 1024
+
+ part_info = PartDescInfo(parttype=parttype, startaddr=startaddr, partsize=partsize)
+ self.__part_desc_info_list.append(part_info)
+
+ except Exception as err:
+ print("Error: open partition cfg file failed: {}".format(str(err)))
+ return False
+
+ if len(self.__part_desc_info_list) >= 4:
+ print("----------" * 10)
+ for item in self.__part_desc_info_list:
+ print(item)
+ print("----------" * 10)
+
+ return True
+
+ return False
+
+ def genPartBufPartTab(self) -> bool:
+ parttab_part = self.getPartDescInfoFromList(PART_TYPE_PART_TAB)
+ if not parttab_part:
+ print("Error: partition table has not been found!!!")
+ return False
+
+ part_tab_buffer = bytearray(parttab_part.part_size)
+ part_tab_buffer = part_tab_buffer.replace(b'\x00', b'\xFF')
+
+ offset = 0
+ for part in self.__part_desc_info_list:
+ if isinstance(part, PartDescInfo):
+ if (part.part_type == PART_TYPE_BOOT) or (part.part_type == PART_TYPE_PART_TAB) or (part.part_type == PART_TYPE_INVALID):
+ continue
+ part_tab_buffer[offset:(offset+PARTITION_DESC_INFO_SIZE)] = part.toBytes()
+ offset += PARTITION_DESC_INFO_SIZE
+
+ part_tab_buffer[offset:(offset+PARTITION_DESC_INFO_SIZE)] = bytearray(PARTITION_DESC_INFO_SIZE)[:]
+
+ self.__partbuf_parttab = part_tab_buffer
+ return True
+
+ def getPartDescInfoFromList(self, part_type) -> PartDescInfo:
+ if not isinstance(part_type, int):
+ raise TypeError("Error: part_type MUST be an int value!!!")
+
+ for part_info in self.__part_desc_info_list:
+ if isinstance(part_info, PartDescInfo):
+ if part_info.part_type == part_type:
+ return part_info
+ return None
+
+ def checkFileSize(self) -> bool:
+ boot_fileinfo = os.stat(self.boot_filepath)
+ app_fileinfo = os.stat(self.app_filepath)
+
+ max_boot_filesize = self.getPartDescInfoFromList(PART_TYPE_BOOT).part_size
+ max_app_filesize = self.getPartDescInfoFromList(PART_TYPE_APP).part_size
+
+ if boot_fileinfo.st_size >= max_boot_filesize:
+ print("FAIL -- checking {}".format(self.boot_filepath))
+ return False
+ print("PASS -- checking {}".format(self.boot_filepath))
+
+ if app_fileinfo.st_size >= max_app_filesize:
+ print("FAIL -- checking {}".format(self.app_filepath))
+ return False
+ print("PASS -- checking {}".format(self.app_filepath))
+
+
+ return True
+
+ def genPartBufBootRam(self) -> bool:
+ boot_part = self.getPartDescInfoFromList(PART_TYPE_BOOT)
+ if not boot_part:
+ print("Error: BOOT partition has not been found!!!")
+ return False
+
+ bootram_buffer = bytearray(boot_part.part_size)
+ bootram_buffer = bootram_buffer.replace(b'\x00', b'\xFF')
+
+ fileInfo = os.stat(self.boot_filepath)
+ try:
+ with open(self.boot_filepath, "rb") as fObj:
+ bootram_content = fObj.read()
+ bootheader = BootHeader(bootram_content)
+ if self.swd_crp == 0:
+ bootheader.crp_flag = 0
+ else:
+ bootheader.crp_flag = BootHeader.CRP_VALID_FLAG
+
+ bootram_buffer[0:BootHeader.BOOT_HEADER_SIZE] = bootheader.toByteArray()[:]
+ bootram_buffer[BootHeader.BOOT_HEADER_SIZE:fileInfo.st_size] = bootram_content[BootHeader.BOOT_HEADER_SIZE:fileInfo.st_size]
+ self.__partbuf_bootram = bootram_buffer
+
+ except Exception as err:
+ print("Error: open boot file failed: {}".format(str(err)))
+ return False
+
+ return True
+
+ def genPartBufKV(self) -> bool:
+ kv_part = self.getPartDescInfoFromList(PART_TYPE_KV)
+ if kv_part:
+ kv_buffer = bytearray(kv_part.part_size)
+ kv_buffer = kv_buffer.replace(b'\x00', b'\xFF')
+ self.__partbuf_kv = kv_buffer
+ return True
+
+ def genPartBufEEPROM(self) -> bool:
+ eeprom_part = self.getPartDescInfoFromList(PART_TYPE_SIMU_EEPROM)
+ if eeprom_part:
+ eeprom_buffer = bytearray(eeprom_part.part_size)
+ eeprom_buffer = eeprom_buffer.replace(b'\x00', b'\xFF')
+ self.__partbuf_eeprom = eeprom_buffer
+ return True
+
+ def genPartBufAPP(self) -> bool:
+ app_part = self.getPartDescInfoFromList(PART_TYPE_APP)
+ if not app_part:
+ print("Error: APP part is not found in the partition table!!!")
+ return False
+
+ try:
+ with open(self.app_filepath, "rb") as fObj:
+ app_content = fObj.read()
+
+ image_header = ImageHeader(bytearray(256))
+ image_header.image_type = IMAGE_TYPE_ORIGINAL
+ image_header.setVerMajor(self.__ver_major)
+ image_header.setVerMinor(self.__ver_minor)
+ image_header.img_size_orig = len(app_content)
+ image_header.img_crc32_orig = zlib.crc32(app_content)
+
+ temp = bytearray(image_header.toBytes())
+ temp.extend(app_content)
+ self.__partbuf_app = temp
+ except Exception as err:
+ print("Error: open app file failed: {}".format(str(err)))
+ return False
+
+ if not self.__partbuf_app:
+ return False
+
+ return True
+
+ def writeOutputFile(self) -> bool:
+ if not self.__partbuf_bootram:
+ print("Error: ramcode has not been processed!!!")
+ return False
+
+ if not self.__partbuf_parttab:
+ print("Error: partition table has not been processed!!!")
+ return False
+
+ if not self.__partbuf_nvds:
+ nvds_part = self.getPartDescInfoFromList(PART_TYPE_NVDS)
+ if nvds_part:
+ nvds_buffer = bytearray(nvds_part.part_size)
+ nvds_buffer = nvds_buffer.replace(b'\x00', b'\xFF')
+ self.__partbuf_nvds = nvds_buffer
+
+ if not self.__partbuf_app:
+ print("Error: app has not been processed!!!")
+ return False
+
+ if not self.__partbuf_kv:
+ print("Error: KV has not been processed!!!")
+ return False
+
+ try:
+ with open(self.flashimage_filepath, "wb") as fObj:
+ # ram code
+ ramcode_part = self.getPartDescInfoFromList(PART_TYPE_BOOT)
+ fObj.seek(ramcode_part.start_addr, os.SEEK_SET)
+ fObj.write(self.__partbuf_bootram)
+
+ # partition table
+ parttab_part = self.getPartDescInfoFromList(PART_TYPE_PART_TAB)
+ fObj.seek(parttab_part.start_addr, os.SEEK_SET)
+ fObj.write(self.__partbuf_parttab)
+
+ # APP
+ app_part = self.getPartDescInfoFromList(PART_TYPE_APP)
+ fObj.seek(app_part.start_addr, os.SEEK_SET)
+ fObj.write(self.__partbuf_app)
+
+ # NVDS
+ nvds_part = self.getPartDescInfoFromList(PART_TYPE_NVDS)
+ if (not nvds_part) and nvds_part.start_addr < app_part.start_addr:
+ fObj.seek(nvds_part.start_addr, os.SEEK_SET)
+ fObj.write(self.__partbuf_nvds)
+
+ # KV
+ kv_part = self.getPartDescInfoFromList(PART_TYPE_KV)
+ if kv_part.start_addr < app_part.start_addr:
+ fObj.seek(kv_part.start_addr, os.SEEK_SET)
+ fObj.write(self.__partbuf_kv)
+
+ # SIMU_EEPROM
+ eeprom_part = self.getPartDescInfoFromList(PART_TYPE_SIMU_EEPROM)
+ if eeprom_part and (eeprom_part.start_addr < app_part.start_addr):
+ fObj.seek(eeprom_part.start_addr, os.SEEK_SET)
+ fObj.write(self.__partbuf_eeprom)
+ except Exception as err:
+ print("Error: open file failed: {}!!!".format(str(err)))
+ return False
+
+ return True
+
+ def doAllWork(self) -> bool:
+ if not self.readPartCfg():
+ return False
+
+ if not self.genPartBufPartTab():
+ return False
+
+ if not self.checkFileSize():
+ print("Error: file size check failed!!!")
+ return False
+
+ if not self.genPartBufBootRam():
+ print("Error: ram code wrong!!!")
+ return False
+
+ if not self.genPartBufKV():
+ print("Error: KV wrong!!!")
+ return False
+
+ if not self.genPartBufEEPROM():
+ print("Error: EEPROM wrong!!!")
+ return False
+
+ if not self.genPartBufAPP():
+ print("Error: process app content!!!")
+ return False
+
+ if not self.writeOutputFile():
+ print("Error: final store!!!")
+ return False
+
+ return True
+
+ @property
+ def boot_filepath(self):
+ return self.__boot_filepath
+
+ @boot_filepath.setter
+ def boot_filepath(self, boot):
+ if isinstance(boot, str):
+ if os.path.exists(boot):
+ self.__boot_filepath = boot
+ else:
+ raise ValueError("Error: not exist: {} !!!".format(boot))
+ else:
+ raise TypeError("Error: boot MUST be a str!!!")
+
+ @property
+ def app_filepath(self):
+ return self.__app_filepath
+
+ @app_filepath.setter
+ def app_filepath(self, app):
+ if isinstance(app, str):
+ if os.path.exists(app):
+ self.__app_filepath = app
+ else:
+ raise ValueError("Error: not exist: {} !!!".format(app))
+ else:
+ raise TypeError("Error: app MUST be a str!!!")
+
+ @property
+ def flashimage_filepath(self):
+ return self.__flashimage_filepath
+
+ @flashimage_filepath.setter
+ def flashimage_filepath(self, flashimage):
+ if isinstance(flashimage, str):
+ dest_dir = os.path.dirname(flashimage)
+ if os.path.exists(dest_dir):
+ self.__flashimage_filepath = flashimage
+ else:
+ raise ValueError("Error: directory for {} NOT exist!!!".format(flashimage))
+ else:
+ raise TypeError("Error: flashimage MUST be a str!!!")
+
+ @property
+ def part_cfg_filepath(self):
+ return self.__part_cfg_filepath
+
+ @part_cfg_filepath.setter
+ def part_cfg_filepath(self, part_cfg):
+ if isinstance(part_cfg, str):
+ if os.path.exists(part_cfg):
+ self.__part_cfg_filepath = part_cfg
+ else:
+ raise ValueError("Error: not exist: {}".format(part_cfg))
+ else:
+ raise TypeError("Error: part_cfg MUST be a str!!!")
+
+ @property
+ def ver_str(self):
+ return self.__ver_str
+
+ @ver_str.setter
+ def ver_str(self, ver):
+ """
+ `ver` is a str with format ".", such as "1.2" or "2.3".
+ """
+ if isinstance(ver, str):
+ temp_list = ver.split(".")
+ if (len(temp_list) == 2) and temp_list[0].isnumeric() and temp_list[1].isnumeric():
+ self.__ver_str = ver
+ self.__ver_major = int(temp_list[0])
+ self.__ver_minor = int(temp_list[1])
+ else:
+ raise ValueError("Error: ver MUST be like '1.2' (major.minor)")
+ else:
+ raise TypeError("Error: ver MUST be a str!!!")
+
+ @property
+ def verbose(self):
+ return self.__verbose
+
+ @verbose.setter
+ def verbose(self, verbose):
+ if isinstance(verbose, int):
+ self.__verbose = verbose % 3
+ else:
+ raise TypeError("Error: verbose MUST be [0, 1, 2]")
+
+ @property
+ def swd_crp(self) -> int:
+ return self.__swd_crp
+
+ @swd_crp.setter
+ def swd_crp(self, crp):
+ if isinstance(crp, int):
+ if crp == 0:
+ self.__swd_crp = 0
+ else:
+ self.__swd_crp = 1
+ else:
+ raise TypeError("Error: crp MUST be one of [0, 1]!!!")
+
+ def __str__(self):
+ output_str = ( "\n------ mkimage ------\n" \
+ "2nd boot: {_boot}\n" \
+ "app.bin : {_app}\n" \
+ "output : {_flash}\n" \
+ "part_cfg: {_part}\n" \
+ "ver str : {_ver}\n" \
+ .format(_boot=self.boot_filepath, _app=self.app_filepath,
+ _flash=self.flashimage_filepath, _part=self.part_cfg_filepath, _ver=self.ver_str))
+ return output_str
+
+
+if __name__ == "__main__":
+ """
+ The following arguments are required:
+ --boot /path/to/boot_ln88xx.bin, that is ramcode;
+ --app /path/to/app.bin, that is compiler output;
+ --output /path/to/flashimage.bin, that is our final image file which can be downloaded to flash;
+ --part /path/to/flash_partition_cfg.json, that is configuration for flash partition;
+ --ver APP version, like "1.2", but is only used for LN SDK boot, not for user app version;
+
+ The following arguments are optional:
+ --crp which change the SWD behavior, 0 -- SWD protect is disabled; 1 -- SWD protect is enabled;
+
+ Usage
+ =====
+ python3 makeimage.py -h
+ """
+ prog = os.path.basename(__file__)
+ desc = "makeimage tool for LN88XX"
+ parser = argparse.ArgumentParser(prog=prog, description=desc)
+ parser.add_argument("--boot", help="/path/to/boot_ln88xx.bin", type=str)
+ parser.add_argument("--app", help="/path/to/app.bin", type=str)
+ parser.add_argument("--output", help="/path/to/flashimage.bin, that is output filepath", type=str)
+ parser.add_argument("--part", help="/path/to/flash_partition_cfg.json", type=str)
+ parser.add_argument("--ver", help="APP version (only used for LN SDK boot), such as 1.2", type=str)
+ parser.add_argument("--crp", help="SWD protect bit [0 -- disable, 1 -- enable]", type=int, choices=[0, 1])
+
+ args = parser.parse_args()
+
+ if args.boot is None:
+ print("Error: /path/to/boot_ln88xx.bin has not been set!!!")
+ exit(-1)
+
+ if args.app is None:
+ print("Error: /path/to/app.bin has not been set!!!")
+ exit(-2)
+
+ if args.output is None:
+ print("Error: /path/to/flashimage.bin has not been set!!!")
+ exit(-3)
+
+ if args.part is None:
+ print("Error: /path/to/flash_partition_cfg.json has not been set!!!")
+ exit(-4)
+
+ if args.ver is None:
+ print("Error: LN SDK boot version has not been set!!!")
+ exit(-5)
+
+ mkimage = MakeImageTool()
+ mkimage.boot_filepath = args.boot
+ mkimage.app_filepath = args.app
+ mkimage.flashimage_filepath = args.output
+ mkimage.part_cfg_filepath = args.part
+ mkimage.ver_str = args.ver
+
+ if args.crp:
+ mkimage.swd_crp = args.crp
+
+ if not mkimage.doAllWork():
+ exit(-1)
+
+ exit(0)
diff --git a/ltchiptool/soc/ln882h/util/models/__init__.py b/ltchiptool/soc/ln882h/util/models/__init__.py
new file mode 100644
index 0000000..4eb12d6
--- /dev/null
+++ b/ltchiptool/soc/ln882h/util/models/__init__.py
@@ -0,0 +1,13 @@
+# Copyright (c) Etienne Le Cousin 2025-03-02.
+
+from .boot_header import BootHeader
+from .image_header import ImageHeader
+from .part_desc_info import *
+
+__all__ = [
+ "BootHeader",
+ "ImageHeader",
+ "PartDescInfo",
+ "part_type_str2num",
+ "part_type_num2str",
+]
diff --git a/ltchiptool/soc/ln882h/util/models/boot_header.py b/ltchiptool/soc/ln882h/util/models/boot_header.py
new file mode 100644
index 0000000..9d8e59b
--- /dev/null
+++ b/ltchiptool/soc/ln882h/util/models/boot_header.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+#
+# Copyright 2021 Shanghai Lightning Semiconductor Technology Co., LTD
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# 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.
+# fmt: off
+# isort:skip_file
+
+import zlib
+import struct
+
+
+class BootHeader:
+
+ BOOT_HEADER_SIZE = (4 + 2 + 2 + 4 * 4)
+
+ CRP_VALID_FLAG = 0x46505243
+
+ BOOT_START_ADDR = 0
+ BOOT_SIZE_LIMIT = (1024 * 24)
+
+ def __init__(self, other_buf) -> None:
+ self.__bootram_target_addr = 0
+ self.__bootram_bin_length = 0 # 2bytes
+ self.__bootram_crc_offset = 0 # 2bytes
+ self.__bootram_crc_value = 0
+ self.__bootram_vector_addr = 0
+ self.__crp_flag = 0
+ self.__boot_header_crc = 0
+
+ if not (isinstance(other_buf, bytearray) or isinstance(other_buf, bytes)):
+ raise TypeError("Error: other_buf MUST be a bytearray or bytes!!!")
+
+ if len(other_buf) < BootHeader.BOOT_HEADER_SIZE:
+ raise ValueError("Error: other_buf MUST have at least {} bytes!!!".format(BootHeader.BOOT_HEADER_SIZE))
+
+ self.__buffer = bytearray(BootHeader.BOOT_HEADER_SIZE)
+ self.__buffer[:] = other_buf[0:BootHeader.BOOT_HEADER_SIZE]
+
+ items = struct.unpack(" bytearray:
+ struct.pack_into("> (8*shift) )
+ return val
+
+
+def dump_bytes_in_hex(byte_arr=None, lineSize=16, bytesMax=256, title=""):
+ """
+ Print byte array in hex format.
+ lineSize: print how many items each line.
+ bytesMax: print how many items at most. (-1, print the whole byte array.)
+ title:
+ """
+
+ if title:
+ print("\n---------- {} ----------".format(title))
+
+ if bytesMax == -1:
+ bytesMax = len(byte_arr)
+ elif bytesMax > len(byte_arr):
+ bytesMax = len(byte_arr)
+ else:
+ pass
+
+ for cnt in range(0, bytesMax):
+ if cnt % lineSize == 0:
+ print("{_so:08X} |".format(_so=cnt), end=" ")
+ print("{_b:02X}".format(_b=byte_arr[cnt]), end=" ")
+ if cnt % lineSize == (lineSize-1):
+ print("")
+
+
+def check_python_version():
+ major = sys.version_info.major
+ minor = sys.version_info.minor
+ if (major == 3) and (minor >= 6):
+ return True
+ else:
+ print('WARNING: Python 2 or Python 3 versions older than 3.6 are not supported.', file=sys.stderr)
+ exit(-100)
+ return False
diff --git a/ltchiptool/soc/ln882h/util/models/part_desc_info.py b/ltchiptool/soc/ln882h/util/models/part_desc_info.py
new file mode 100644
index 0000000..ef4959e
--- /dev/null
+++ b/ltchiptool/soc/ln882h/util/models/part_desc_info.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+#
+# Copyright 2021 Shanghai Lightning Semiconductor Technology Co., LTD
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# 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.
+# fmt: off
+# isort:skip_file
+
+import zlib
+from .ln_tools import *
+from .boot_header import BootHeader
+
+# partition table start addr and size.
+PARTITION_TAB_OFFSET = BootHeader.BOOT_START_ADDR + BootHeader.BOOT_SIZE_LIMIT
+PARTITION_TAB_SIZE = 1024 * 4
+
+PARTITION_DESC_INFO_SIZE = 4 + 4 + 4 + 4
+
+PART_TYPE_APP = 0
+PART_TYPE_OTA = 1
+PART_TYPE_KV = 2
+PART_TYPE_NVDS = 3
+PART_TYPE_SIMU_EEPROM = 4
+PART_TYPE_USER = 5
+PART_TYPE_INVALID = 6
+PART_TYPE_BOOT = 7
+PART_TYPE_PART_TAB = 8
+
+__PART_TYPE_DICT = {
+ PART_TYPE_APP : "APP",
+ PART_TYPE_OTA : "OTA",
+ PART_TYPE_KV : "KV",
+ PART_TYPE_NVDS : "NVDS",
+ PART_TYPE_SIMU_EEPROM : "SIMU_EEPROM",
+ PART_TYPE_USER : "USER",
+ PART_TYPE_INVALID : "INVALID",
+ PART_TYPE_BOOT : "BOOT",
+ PART_TYPE_PART_TAB : "PART_TAB"
+}
+
+
+def part_type_num2str(type_num=PART_TYPE_INVALID):
+ return __PART_TYPE_DICT.get(type_num, __PART_TYPE_DICT.get(PART_TYPE_INVALID))
+
+
+def part_type_str2num(type_str):
+ for k, v in __PART_TYPE_DICT.items():
+ if v == type_str:
+ return k
+ return PART_TYPE_INVALID
+
+
+class PartDescInfo(object):
+ def __init__(self, parttype=0, startaddr=0, partsize=0, partcrc32=0):
+ self.part_type = parttype
+ self.start_addr = startaddr
+ self.part_size = partsize
+ self.__part_crc32 = partcrc32
+
+ self.buffer = bytearray(4 * 4)
+ for i in range(0, 4 * 4):
+ self.buffer[i] = 0
+
+ self.toBytes()
+
+ def toBytes(self) -> bytearray:
+ self.buffer[0] = get_num_at_byte(self.part_type, 0)
+ self.buffer[1] = get_num_at_byte(self.part_type, 1)
+ self.buffer[2] = get_num_at_byte(self.part_type, 2)
+ self.buffer[3] = get_num_at_byte(self.part_type, 3)
+
+ self.buffer[4] = get_num_at_byte(self.start_addr, 0)
+ self.buffer[5] = get_num_at_byte(self.start_addr, 1)
+ self.buffer[6] = get_num_at_byte(self.start_addr, 2)
+ self.buffer[7] = get_num_at_byte(self.start_addr, 3)
+
+ self.buffer[8] = get_num_at_byte(self.part_size, 0)
+ self.buffer[9] = get_num_at_byte(self.part_size, 1)
+ self.buffer[10] = get_num_at_byte(self.part_size, 2)
+ self.buffer[11] = get_num_at_byte(self.part_size, 3)
+
+ self.reCalCRC32()
+
+ return self.buffer
+
+ def reCalCRC32(self):
+ self.__part_crc32 = zlib.crc32(self.buffer[0:12])
+
+ self.buffer[12] = get_num_at_byte(self.part_crc32, 0)
+ self.buffer[13] = get_num_at_byte(self.part_crc32, 1)
+ self.buffer[14] = get_num_at_byte(self.part_crc32, 2)
+ self.buffer[15] = get_num_at_byte(self.part_crc32, 3)
+
+ @property
+ def part_type(self):
+ return self.__part_type
+
+ @part_type.setter
+ def part_type(self, t):
+ if isinstance(t, int):
+ self.__part_type = t
+ else:
+ raise TypeError("part_type MUST be assigned to an int value (0~5)")
+
+ @property
+ def start_addr(self):
+ return self.__start_addr
+
+ @start_addr.setter
+ def start_addr(self, addr):
+ if isinstance(addr, int):
+ self.__start_addr = addr
+ else:
+ raise TypeError("start_addr MUST be assigned to an int value")
+
+ @property
+ def part_size(self):
+ return self.__part_size
+
+ @part_size.setter
+ def part_size(self, s):
+ if isinstance(s, int):
+ self.__part_size = s
+ else:
+ raise TypeError("part_size MUST be assigned to an int value")
+
+ @property
+ def part_crc32(self):
+ return self.__part_crc32
+
+ # readonly
+ # @part_crc32.setter
+ # def part_crc32(self, crc32):
+ # if isinstance(crc32, int):
+ # self.__part_crc32 = crc32
+ # else:
+ # raise TypeError("part_crc32 MUST be assigned to an int value")
+
+ def __str__(self) -> str:
+ output = ("partition_type: {_p:>12}, start_addr: 0x{_sa:08X}, size_KB: 0x{_sz:08X}, crc32: 0x{_c:08X}"
+ .format(_p=part_type_num2str(self.part_type), _sa=self.start_addr, _sz=self.part_size, _c=self.part_crc32))
+ return output
diff --git a/ltchiptool/soc/ln882h/util/ota_image_generator.py b/ltchiptool/soc/ln882h/util/ota_image_generator.py
new file mode 100644
index 0000000..e54d663
--- /dev/null
+++ b/ltchiptool/soc/ln882h/util/ota_image_generator.py
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+# -*- coding:utf-8 -*-
+#
+# Copyright 2021 Shanghai Lightning Semiconductor Technology Co., LTD
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# 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.
+# fmt: off
+# isort:skip_file
+
+import lzma
+import sys
+import os
+import struct
+import zlib
+import shutil
+import argparse
+from .models.part_desc_info import *
+from .models.image_header import *
+
+
+class OTATOOL:
+ def __init__(self):
+ self.part_desc_info_list = []
+ self.image_header = None
+ self.app_content = None
+ self.output_filepath = None
+ self.__input_filepath = None
+
+ def readPartTab(self) -> bool:
+ try:
+ with open(self.input_filepath, "rb") as fInputObj:
+ fInputObj.seek(PARTITION_TAB_OFFSET, os.SEEK_SET)
+ ptable_buffer = fInputObj.read(PARTITION_TAB_SIZE)
+
+ offset = 0
+ while offset < PARTITION_TAB_SIZE:
+ part_desc_info_buffer = ptable_buffer[offset : (offset + PARTITION_DESC_INFO_SIZE)]
+ part_type, start_addr, part_size, part_crc32 = struct.unpack("= PART_TYPE_INVALID:
+ break
+ crc32_recalc = zlib.crc32(part_desc_info_buffer[0:(3*4)]) & 0xFFFFFFFF
+ if part_crc32 != crc32_recalc:
+ break
+ # print("type: {_pt:>12}, start_addr: 0x{_sa:08X}, part_size: 0x{_ps:08X}, part_crc32: 0x{_pc:08X}"
+ # .format(_pt=part_type, _sa=start_addr, _ps=part_size, _pc=part_crc32))
+
+ part_desc_info_obj = PartDescInfo(part_type, start_addr, part_size, part_crc32)
+ self.part_desc_info_list.append(part_desc_info_obj)
+ offset += PARTITION_DESC_INFO_SIZE
+ except Exception as err:
+ print("Error: open file failed: {}".format(str(err)))
+ return False
+ if len(self.part_desc_info_list) >= 3:
+ return True
+ else:
+ return False
+
+ def readAPP(self):
+ if len(self.part_desc_info_list) < 3:
+ print("Please make sure that partition table has at least 3 items!!!")
+ return False
+
+ app_desc_info = None
+ for desc_info in self.part_desc_info_list:
+ if isinstance(desc_info, PartDescInfo):
+ if desc_info.part_type == PART_TYPE_APP:
+ app_desc_info = desc_info
+ break
+
+ if app_desc_info is None:
+ print("Please make sure that APP partition is in the partition table!!!")
+ return False
+
+ try:
+ with open(self.input_filepath, "rb") as fInputObj:
+ fInputObj.seek(app_desc_info.start_addr, os.SEEK_SET)
+ app_image_header_buffer = fInputObj.read(256)
+
+ # image header
+ self.image_header = ImageHeader(app_image_header_buffer)
+
+ # app content
+ if self.image_header.image_type == IMAGE_TYPE_ORIGINAL:
+ fInputObj.seek(app_desc_info.start_addr + 256, os.SEEK_SET)
+ self.app_content = fInputObj.read(self.image_header.img_size_orig)
+ else:
+ print("Not supported image type, which is {_t}".format(_t=image_type_num2str(self.image_header.image_type)))
+ return False
+ except Exception as err:
+ print("Error: open file failed: {}".format(str(err)))
+ return False
+
+ return True
+
+ def processOTAImage(self):
+ if (self.image_header is None) or (self.app_content is None):
+ print("No valid app image header or app conent found!!!")
+ return False
+
+ app_content_size_before_lzma = len(self.app_content)
+ my_filter = [
+ {
+ "id": lzma.FILTER_LZMA1,
+ "dict_size": 4*1024, # 4KB, (32KB max)
+ "mode": lzma.MODE_NORMAL,
+ },
+ ]
+ lzc = lzma.LZMACompressor(format=lzma.FORMAT_ALONE, filters=my_filter)
+ out1 = lzc.compress(self.app_content)
+ content_after_lzma = bytearray(b"".join([out1, lzc.flush()]))
+
+ content_after_lzma[5] = get_num_at_byte(app_content_size_before_lzma, 0)
+ content_after_lzma[6] = get_num_at_byte(app_content_size_before_lzma, 1)
+ content_after_lzma[7] = get_num_at_byte(app_content_size_before_lzma, 2)
+ content_after_lzma[8] = get_num_at_byte(app_content_size_before_lzma, 3)
+
+ content_after_lzma[9] = 0
+ content_after_lzma[10] = 0
+ content_after_lzma[11] = 0
+ content_after_lzma[12] = 0
+
+ app_content_size_after_lzma = len(content_after_lzma)
+
+ self.app_content = content_after_lzma
+ crc32_after_lzma = zlib.crc32(content_after_lzma)
+
+ self.image_header.image_type = IMAGE_TYPE_ORIGINAL_XZ
+ ota_ver_major = self.image_header.getVerMajor()
+ ota_ver_minor = self.image_header.getVerMinor()
+ self.image_header.ver = ((ota_ver_major << 8) | ota_ver_minor) & 0xFFFF
+ self.image_header.img_size_orig_xz = app_content_size_after_lzma
+ self.image_header.img_crc32_orig_xz = crc32_after_lzma
+ self.image_header.reCalcCRC32()
+
+ return True
+
+ def writeOTAImage(self):
+ """
+ OTA image, XZ format.
+ """
+ ota_filename = "{_a}-ota-xz-v{_ma}.{_mi}.bin" \
+ .format(_a= os.path.basename(self.input_filepath).split(".")[0],
+ _ma=self.image_header.getVerMajor(), _mi=self.image_header.getVerMinor())
+ self.output_filepath = os.path.join(self.output_dir, ota_filename)
+
+ if os.path.exists(self.output_filepath):
+ shutil.rmtree(self.output_filepath, ignore_errors=True)
+
+ try:
+ with open(self.output_filepath, "wb") as fOutObj:
+ fOutObj.write(self.image_header.toBytes())
+ fOutObj.write(self.app_content)
+ except Exception as err:
+ print("Error: write file failed: {}".format(str(err)))
+ return False
+
+ if not os.path.exists(self.output_filepath):
+ print("Failed to build: {_ota}".format(_ota=self.output_filepath))
+ return False
+
+ return True
+
+ def doAllWork(self) -> bool:
+ if not self.readPartTab():
+ return False
+ if not self.readAPP():
+ return False
+ if not self.processOTAImage():
+ return False
+ if not self.writeOTAImage():
+ return False
+ return True
+
+ @property
+ def input_filepath(self):
+ return self.__input_filepath
+
+ @input_filepath.setter
+ def input_filepath(self, filepath):
+ """
+ Absolute filepath of flashimage.bin.
+ """
+ if isinstance(filepath, str):
+ if os.path.exists(realpath(filepath)):
+ self.__input_filepath = realpath(filepath)
+ else:
+ raise ValueError("not exist: {_f}".format(_f=filepath))
+ else:
+ raise TypeError("filepath MUST be a valid string")
+
+ @property
+ def output_dir(self):
+ return self.__output_dir
+
+ @output_dir.setter
+ def output_dir(self, filepath):
+ """
+ Indicates the directory where to save ota.bin, normally it's the same
+ directory as flashimage.bin.
+ The output filename is `flashimage-ota-v{X}.{Y}.bin`, where X/Y is the
+ major/minor version of flashimage.bin.
+ """
+ if isinstance(filepath, str):
+ if os.path.exists(filepath):
+ self.__output_dir = filepath
+ else:
+ raise ValueError("dir not exist: {_f}".format(_f=filepath))
+ else:
+ raise TypeError("dir MUST be a valid string")
+
+
+if __name__ == "__main__":
+ prog = os.path.basename(__file__)
+ usage = ("\nargv1: /path/to/flashimage.bin \n"
+ "Example: \n"
+ "python3 {_p} E:/ln_sdk/build/bin/flashimage.bin".format(_p=prog))
+
+ parser = argparse.ArgumentParser(prog=prog, usage=usage)
+ parser.add_argument("path_to_flashimage", help="absolute path of flashimage.bin")
+
+ print(sys.argv)
+ args = parser.parse_args()
+
+ flashimage_filepath = args.path_to_flashimage
+ ota_save_dir = os.path.dirname(flashimage_filepath)
+
+ ota_tool = OTATOOL()
+ ota_tool.input_filepath = flashimage_filepath
+ ota_tool.output_dir = ota_save_dir
+
+ if not ota_tool.doAllWork():
+ exit(-1)
+
+ print("Succeed to build: {}".format(ota_tool.output_filepath))
+
+ exit(0)
diff --git a/ltchiptool/soc/ln882h/util/ramcode.bin b/ltchiptool/soc/ln882h/util/ramcode.bin
new file mode 100644
index 0000000..d80ee44
Binary files /dev/null and b/ltchiptool/soc/ln882h/util/ramcode.bin differ
diff --git a/ltchiptool/util/detection.py b/ltchiptool/util/detection.py
index 0e11e65..0c67f72 100644
--- a/ltchiptool/util/detection.py
+++ b/ltchiptool/util/detection.py
@@ -13,16 +13,16 @@
FILE_TYPES = {
"UF2": [
- (0x000, b"UF2\x0A"),
- (0x004, b"\x57\x51\x5D\x9E"),
- (0x1FC, b"\x30\x6F\xB1\x0A"),
+ (0x000, b"UF2\x0a"),
+ (0x004, b"\x57\x51\x5d\x9e"),
+ (0x1FC, b"\x30\x6f\xb1\x0a"),
],
"ELF": [
- (0x00, b"\x7FELF"),
+ (0x00, b"\x7fELF"),
],
"Tuya UG": [
- (0x00, b"\x55\xAA\x55\xAA"),
- (0x1C, b"\xAA\x55\xAA\x55"),
+ (0x00, b"\x55\xaa\x55\xaa"),
+ (0x1C, b"\xaa\x55\xaa\x55"),
],
}
diff --git a/pyproject.toml b/pyproject.toml
index 029779a..67dc0df 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,6 +25,7 @@ importlib-metadata = "*"
prettytable = "^3.3.0"
bk7231tools = "^2.0.0"
xmodem = "^0.4.6"
+ymodem = "^1.5.1"
wxPython = {version = "^4.2.0", optional = true}
pywin32 = {version = "*", optional = true, markers = "sys_platform == 'win32'"}
py-datastruct = "^1.0.0"
diff --git a/uf2tool/models/block.py b/uf2tool/models/block.py
index 5f3196d..d5b4c43 100644
--- a/uf2tool/models/block.py
+++ b/uf2tool/models/block.py
@@ -37,7 +37,7 @@ def encode(self) -> bytes:
self.flags.has_tags = not not self.tags
self.length = self.data and len(self.data) or 0
# UF2 magic 1 and 2
- data = b"\x55\x46\x32\x0A\x57\x51\x5D\x9E"
+ data = b"\x55\x46\x32\x0a\x57\x51\x5d\x9e"
# encode integer variables
data += inttole32(self.flags.encode())
data += inttole32(self.address)
@@ -71,7 +71,7 @@ def encode(self) -> bytes:
raise ValueError("Padding too long")
data += self.padding
data += b"\x00" * (512 - 4 - len(data))
- data += b"\x30\x6F\xB1\x0A" # magic 3
+ data += b"\x30\x6f\xb1\x0a" # magic 3
return data
def decode(self, data: bytes):