diff --git a/tools/gen_esp32part.exe b/tools/gen_esp32part.exe
index 5bd12c6360d..2e7b1001bb0 100644
Binary files a/tools/gen_esp32part.exe and b/tools/gen_esp32part.exe differ
diff --git a/tools/gen_esp32part.py b/tools/gen_esp32part.py
index ffa740a36e0..959b3188a0a 100755
--- a/tools/gen_esp32part.py
+++ b/tools/gen_esp32part.py
@@ -7,11 +7,8 @@
 # See https://docs.espressif.com/projects/esp-idf/en/latest/api-guides/partition-tables.html
 # for explanation of partition table structure and uses.
 #
-# SPDX-FileCopyrightText: 2016-2021 Espressif Systems (Shanghai) CO LTD
+# SPDX-FileCopyrightText: 2016-2025 Espressif Systems (Shanghai) CO LTD
 # SPDX-License-Identifier: Apache-2.0
-
-from __future__ import division, print_function, unicode_literals
-
 import argparse
 import binascii
 import errno
@@ -22,26 +19,34 @@
 import sys
 
 MAX_PARTITION_LENGTH = 0xC00  # 3K for partition data (96 entries) leaves 1K in a 4K sector for signature
-MD5_PARTITION_BEGIN = b"\xEB\xEB" + b"\xFF" * 14  # The first 2 bytes are like magic numbers for MD5 sum
+MD5_PARTITION_BEGIN = b"\xeb\xeb" + b"\xff" * 14  # The first 2 bytes are like magic numbers for MD5 sum
 PARTITION_TABLE_SIZE = 0x1000  # Size of partition table
 
 MIN_PARTITION_SUBTYPE_APP_OTA = 0x10
 NUM_PARTITION_SUBTYPE_APP_OTA = 16
+MIN_PARTITION_SUBTYPE_APP_TEE = 0x30
+NUM_PARTITION_SUBTYPE_APP_TEE = 2
 
 SECURE_NONE = None
 SECURE_V1 = "v1"
 SECURE_V2 = "v2"
 
-__version__ = "1.2"
+__version__ = "1.5"
 
 APP_TYPE = 0x00
 DATA_TYPE = 0x01
+BOOTLOADER_TYPE = 0x02
+PARTITION_TABLE_TYPE = 0x03
 
 TYPES = {
+    "bootloader": BOOTLOADER_TYPE,
+    "partition_table": PARTITION_TABLE_TYPE,
     "app": APP_TYPE,
     "data": DATA_TYPE,
 }
 
+NVS_RW_MIN_PARTITION_SIZE = 0x3000
+
 
 def get_ptype_as_int(ptype):
     """Convert a string which might be numeric or the name of a partition type to an integer"""
@@ -56,6 +61,15 @@ def get_ptype_as_int(ptype):
 
 # Keep this map in sync with esp_partition_subtype_t enum in esp_partition.h
 SUBTYPES = {
+    BOOTLOADER_TYPE: {
+        "primary": 0x00,
+        "ota": 0x01,
+        "recovery": 0x02,
+    },
+    PARTITION_TABLE_TYPE: {
+        "primary": 0x00,
+        "ota": 0x01,
+    },
     APP_TYPE: {
         "factory": 0x00,
         "test": 0x20,
@@ -72,6 +86,7 @@ def get_ptype_as_int(ptype):
         "fat": 0x81,
         "spiffs": 0x82,
         "littlefs": 0x83,
+        "tee_ota": 0x90,
     },
 }
 
@@ -90,6 +105,8 @@ def get_subtype_as_int(ptype, subtype):
 ALIGNMENT = {
     APP_TYPE: 0x10000,
     DATA_TYPE: 0x1000,
+    BOOTLOADER_TYPE: 0x1000,
+    PARTITION_TABLE_TYPE: 0x1000,
 }
 
 
@@ -98,14 +115,18 @@ def get_alignment_offset_for_type(ptype):
 
 
 def get_alignment_size_for_type(ptype):
-    if ptype == APP_TYPE and secure == SECURE_V1:
-        # For secure boot v1 case, app partition must be 64K aligned
-        # signature block (68 bytes) lies at the very end of 64K block
-        return 0x10000
-    if ptype == APP_TYPE and secure == SECURE_V2:
-        # For secure boot v2 case, app partition must be 4K aligned
-        # signature block (4K) is kept after padding the unsigned image to 64K boundary
-        return 0x1000
+    if ptype == APP_TYPE:
+        if secure == SECURE_V1:
+            # For secure boot v1 case, app partition must be 64K aligned
+            # signature block (68 bytes) lies at the very end of 64K block
+            return 0x10000
+        elif secure == SECURE_V2:
+            # For secure boot v2 case, app partition must be 4K aligned
+            # signature block (4K) is kept after padding the unsigned image to 64K boundary
+            return 0x1000
+        else:
+            # For no secure boot enabled case, app partition must be 4K aligned (min. flash erase size)
+            return 0x1000
     # No specific size alignment requirement as such
     return 0x1
 
@@ -115,6 +136,10 @@ def get_partition_type(ptype):
         return APP_TYPE
     if ptype == "data":
         return DATA_TYPE
+    if ptype == "bootloader":
+        return BOOTLOADER_TYPE
+    if ptype == "partition_table":
+        return PARTITION_TABLE_TYPE
     raise InputError("Invalid partition type")
 
 
@@ -134,6 +159,8 @@ def add_extra_subtypes(csv):
 md5sum = True
 secure = SECURE_NONE
 offset_part_table = 0
+primary_bootloader_offset = None
+recovery_bootloader_offset = None
 
 
 def status(msg):
@@ -165,7 +192,7 @@ def from_file(cls, f):
         return cls.from_csv(data), False
 
     @classmethod
-    def from_csv(cls, csv_contents):  # noqa: C901
+    def from_csv(cls, csv_contents):
         res = PartitionTable()
         lines = csv_contents.splitlines()
 
@@ -194,6 +221,11 @@ def expand_vars(f):
         # fix up missing offsets & negative sizes
         last_end = offset_part_table + PARTITION_TABLE_SIZE  # first offset after partition table
         for e in res:
+            is_primary_bootloader = e.type == BOOTLOADER_TYPE and e.subtype == SUBTYPES[e.type]["primary"]
+            is_primary_partition_table = e.type == PARTITION_TABLE_TYPE and e.subtype == SUBTYPES[e.type]["primary"]
+            if is_primary_bootloader or is_primary_partition_table:
+                # They do not participate in the restoration of missing offsets
+                continue
             if e.offset is not None and e.offset < last_end:
                 if e == res[0]:
                     raise InputError(
@@ -203,8 +235,8 @@ def expand_vars(f):
                     )
                 else:
                     raise InputError(
-                        "CSV Error at line %d: Partitions overlap. Partition sets offset 0x%x. Previous partition ends 0x%x"  # noqa: E501
-                        % (e.line_no, e.offset, last_end)
+                        "CSV Error at line %d: Partitions overlap. Partition sets offset 0x%x. "
+                        "Previous partition ends 0x%x" % (e.line_no, e.offset, last_end)
                     )
             if e.offset is None:
                 pad_to = get_alignment_offset_for_type(e.type)
@@ -246,14 +278,14 @@ def find_by_name(self, name):
                 return p
         return None
 
-    def verify(self):  # noqa: C901
+    def verify(self):
         # verify each partition individually
         for p in self:
             p.verify()
 
         # check on duplicate name
         names = [p.name for p in self]
-        duplicates = set(n for n in names if names.count(n) > 1)  # noqa: C401
+        duplicates = {n for n in names if names.count(n) > 1}
 
         # print sorted duplicate partitions by name
         if len(duplicates) != 0:
@@ -267,9 +299,12 @@ def verify(self):  # noqa: C901
         last = None
         for p in sorted(self, key=lambda x: x.offset):
             if p.offset < offset_part_table + PARTITION_TABLE_SIZE:
-                raise InputError(
-                    "Partition offset 0x%x is below 0x%x" % (p.offset, offset_part_table + PARTITION_TABLE_SIZE)
-                )
+                is_primary_bootloader = p.type == BOOTLOADER_TYPE and p.subtype == SUBTYPES[p.type]["primary"]
+                is_primary_partition_table = p.type == PARTITION_TABLE_TYPE and p.subtype == SUBTYPES[p.type]["primary"]
+                if not (is_primary_bootloader or is_primary_partition_table):
+                    raise InputError(
+                        "Partition offset 0x%x is below 0x%x" % (p.offset, offset_part_table + PARTITION_TABLE_SIZE)
+                    )
             if last is not None and p.offset < last.offset + last.size:
                 raise InputError(
                     "Partition at 0x%x overlaps 0x%x-0x%x" % (p.offset, last.offset, last.offset + last.size - 1)
@@ -282,7 +317,8 @@ def verify(self):  # noqa: C901
             for p in otadata_duplicates:
                 critical("%s" % (p.to_csv()))
             raise InputError(
-                'Found multiple otadata partitions. Only one partition can be defined with type="data"(1) and subtype="ota"(0).'  # noqa: E501
+                "Found multiple otadata partitions. Only one partition can be defined with "
+                'type="data"(1) and subtype="ota"(0).'
             )
 
         if len(otadata_duplicates) == 1 and otadata_duplicates[0].size != 0x2000:
@@ -290,6 +326,23 @@ def verify(self):  # noqa: C901
             critical("%s" % (p.to_csv()))
             raise InputError("otadata partition must have size = 0x2000")
 
+        # Above checks but for TEE otadata
+        otadata_duplicates = [
+            p for p in self if p.type == TYPES["data"] and p.subtype == SUBTYPES[DATA_TYPE]["tee_ota"]
+        ]
+        if len(otadata_duplicates) > 1:
+            for p in otadata_duplicates:
+                critical("%s" % (p.to_csv()))
+            raise InputError(
+                "Found multiple TEE otadata partitions. Only one partition can be defined with "
+                'type="data"(1) and subtype="tee_ota"(0x90).'
+            )
+
+        if len(otadata_duplicates) == 1 and otadata_duplicates[0].size != 0x2000:
+            p = otadata_duplicates[0]
+            critical("%s" % (p.to_csv()))
+            raise InputError("TEE otadata partition must have size = 0x2000")
+
     def flash_size(self):
         """Return the size that partitions will occupy in flash
         (ie the offset the last partition ends at)
@@ -321,7 +374,7 @@ def from_binary(cls, b):
             data = b[o : o + 32]
             if len(data) != 32:
                 raise InputError("Partition table length must be a multiple of 32 bytes")
-            if data == b"\xFF" * 32:
+            if data == b"\xff" * 32:
                 return result  # got end marker
             if md5sum and data[:2] == MD5_PARTITION_BEGIN[:2]:  # check only the magic number part
                 if data[16:] == md5.digest():
@@ -342,7 +395,7 @@ def to_binary(self):
             result += MD5_PARTITION_BEGIN + hashlib.md5(result).digest()
         if len(result) >= MAX_PARTITION_LENGTH:
             raise InputError("Binary partition table length (%d) longer than max" % len(result))
-        result += b"\xFF" * (MAX_PARTITION_LENGTH - len(result))  # pad the sector, for signing
+        result += b"\xff" * (MAX_PARTITION_LENGTH - len(result))  # pad the sector, for signing
         return result
 
     def to_csv(self, simple_formatting=False):
@@ -352,16 +405,20 @@ def to_csv(self, simple_formatting=False):
 
 
 class PartitionDefinition(object):
-    MAGIC_BYTES = b"\xAA\x50"
+    MAGIC_BYTES = b"\xaa\x50"
 
     # dictionary maps flag name (as used in CSV flags list, property name)
     # to bit set in flags words in binary format
-    FLAGS = {"encrypted": 0}
+    FLAGS = {"encrypted": 0, "readonly": 1}
 
     # add subtypes for the 16 OTA slot values ("ota_XX, etc.")
     for ota_slot in range(NUM_PARTITION_SUBTYPE_APP_OTA):
         SUBTYPES[TYPES["app"]]["ota_%d" % ota_slot] = MIN_PARTITION_SUBTYPE_APP_OTA + ota_slot
 
+    # add subtypes for the 2 TEE OTA slot values ("tee_XX, etc.")
+    for tee_slot in range(NUM_PARTITION_SUBTYPE_APP_TEE):
+        SUBTYPES[TYPES["app"]]["tee_%d" % tee_slot] = MIN_PARTITION_SUBTYPE_APP_TEE + tee_slot
+
     def __init__(self):
         self.name = ""
         self.type = None
@@ -369,6 +426,7 @@ def __init__(self):
         self.offset = None
         self.size = None
         self.encrypted = False
+        self.readonly = False
 
     @classmethod
     def from_csv(cls, line, line_no):
@@ -381,8 +439,8 @@ def from_csv(cls, line, line_no):
         res.name = fields[0]
         res.type = res.parse_type(fields[1])
         res.subtype = res.parse_subtype(fields[2])
-        res.offset = res.parse_address(fields[3])
-        res.size = res.parse_address(fields[4])
+        res.offset = res.parse_address(fields[3], res.type, res.subtype)
+        res.size = res.parse_size(fields[4], res.type)
         if res.size is None:
             raise InputError("Size field can't be empty")
 
@@ -452,12 +510,36 @@ def parse_subtype(self, strval):
             return SUBTYPES[DATA_TYPE]["undefined"]
         return parse_int(strval, SUBTYPES.get(self.type, {}))
 
-    def parse_address(self, strval):
+    def parse_size(self, strval, ptype):
+        if ptype == BOOTLOADER_TYPE:
+            if primary_bootloader_offset is None:
+                raise InputError("Primary bootloader offset is not defined. Please use --primary-bootloader-offset")
+            return offset_part_table - primary_bootloader_offset
+        if ptype == PARTITION_TABLE_TYPE:
+            return PARTITION_TABLE_SIZE
         if strval == "":
             return None  # PartitionTable will fill in default
         return parse_int(strval)
 
-    def verify(self):  # noqa: C901
+    def parse_address(self, strval, ptype, psubtype):
+        if ptype == BOOTLOADER_TYPE:
+            if psubtype == SUBTYPES[ptype]["primary"]:
+                if primary_bootloader_offset is None:
+                    raise InputError("Primary bootloader offset is not defined. Please use --primary-bootloader-offset")
+                return primary_bootloader_offset
+            if psubtype == SUBTYPES[ptype]["recovery"]:
+                if recovery_bootloader_offset is None:
+                    raise InputError(
+                        "Recovery bootloader offset is not defined. Please use --recovery-bootloader-offset"
+                    )
+                return recovery_bootloader_offset
+        if ptype == PARTITION_TABLE_TYPE and psubtype == SUBTYPES[ptype]["primary"]:
+            return offset_part_table
+        if strval == "":
+            return None  # PartitionTable will fill in default
+        return parse_int(strval)
+
+    def verify(self):
         if self.type is None:
             raise ValidationError(self, "Type field is not set")
         if self.subtype is None:
@@ -469,7 +551,7 @@ def verify(self):  # noqa: C901
         offset_align = get_alignment_offset_for_type(self.type)
         if self.offset % offset_align:
             raise ValidationError(self, "Offset 0x%x is not aligned to 0x%x" % (self.offset, offset_align))
-        if self.type == APP_TYPE and secure is not SECURE_NONE:
+        if self.type == APP_TYPE:
             size_align = get_alignment_size_for_type(self.type)
             if self.size % size_align:
                 raise ValidationError(self, "Size 0x%x is not aligned to 0x%x" % (self.size, size_align))
@@ -489,6 +571,23 @@ def verify(self):  # noqa: C901
                 % (self.name, self.type, self.subtype)
             )
 
+        always_rw_data_subtypes = [SUBTYPES[DATA_TYPE]["ota"], SUBTYPES[DATA_TYPE]["coredump"]]
+        if self.type == TYPES["data"] and self.subtype in always_rw_data_subtypes and self.readonly is True:
+            raise ValidationError(
+                self,
+                "'%s' partition of type %s and subtype %s is always read-write and cannot be read-only"
+                % (self.name, self.type, self.subtype),
+            )
+
+        if self.type == TYPES["data"] and self.subtype == SUBTYPES[DATA_TYPE]["nvs"]:
+            if self.size < NVS_RW_MIN_PARTITION_SIZE and self.readonly is False:
+                raise ValidationError(
+                    self,
+                    """'%s' partition of type %s and subtype %s of this size (0x%x) must be flagged as 'readonly' \
+(the size of read/write NVS has to be at least 0x%x)"""
+                    % (self.name, self.type, self.subtype, self.size, NVS_RW_MIN_PARTITION_SIZE),
+                )
+
     STRUCT_FORMAT = b"<2sBBLL16sL"
 
     @classmethod
@@ -574,11 +673,13 @@ def parse_int(v, keywords={}):
             raise InputError("Value '%s' is not valid. Known keywords: %s" % (v, ", ".join(keywords)))
 
 
-def main():  # noqa: C901
+def main():
     global quiet
     global md5sum
     global offset_part_table
     global secure
+    global primary_bootloader_offset
+    global recovery_bootloader_offset
     parser = argparse.ArgumentParser(description="ESP32 partition table utility")
 
     parser.add_argument(
@@ -600,6 +701,8 @@ def main():  # noqa: C901
     )
     parser.add_argument("--quiet", "-q", help="Don't print non-critical status messages to stderr", action="store_true")
     parser.add_argument("--offset", "-o", help="Set offset partition table", default="0x8000")
+    parser.add_argument("--primary-bootloader-offset", help="Set primary bootloader offset", default=None)
+    parser.add_argument("--recovery-bootloader-offset", help="Set recovery bootloader offset", default=None)
     parser.add_argument(
         "--secure",
         help="Require app partitions to be suitable for secure boot",
@@ -622,6 +725,15 @@ def main():  # noqa: C901
     md5sum = not args.disable_md5sum
     secure = args.secure
     offset_part_table = int(args.offset, 0)
+    if args.primary_bootloader_offset is not None:
+        primary_bootloader_offset = int(args.primary_bootloader_offset, 0)
+        if primary_bootloader_offset >= offset_part_table:
+            raise InputError(
+                f"Unsupported configuration. Primary bootloader must be below partition table. "
+                f"Check --primary-bootloader-offset={primary_bootloader_offset:#x} and --offset={offset_part_table:#x}"
+            )
+    if args.recovery_bootloader_offset is not None:
+        recovery_bootloader_offset = int(args.recovery_bootloader_offset, 0)
     if args.extra_partition_subtypes:
         add_extra_subtypes(args.extra_partition_subtypes)
 
@@ -647,7 +759,7 @@ def main():  # noqa: C901
 
     if input_is_binary:
         output = table.to_csv()
-        with sys.stdout if args.output == "-" else open(args.output, "w") as f:
+        with sys.stdout if args.output == "-" else open(args.output, "w", encoding="utf-8") as f:
             f.write(output)
     else:
         output = table.to_binary()