From efda81fbf5e82e8ae493328bbb22c8284957cc5f Mon Sep 17 00:00:00 2001
From: Stephan Hadinger
Date: Wed, 12 Jan 2022 19:20:55 +0100
Subject: [PATCH] Berry Partition Manager v2
---
tasmota/berry/modules/partition.be | 338 ++++++++++++++++++----------
tasmota/berry/modules/partition.bec | Bin 15637 -> 0 bytes
2 files changed, 216 insertions(+), 122 deletions(-)
delete mode 100644 tasmota/berry/modules/partition.bec
diff --git a/tasmota/berry/modules/partition.be b/tasmota/berry/modules/partition.be
index db9773424..50bd00bd8 100644
--- a/tasmota/berry/modules/partition.be
+++ b/tasmota/berry/modules/partition.be
@@ -1,77 +1,27 @@
-#-------------------------------------------------------------
- - Parser for ESP32 partition
- -
- -------------------------------------------------------------#
-partition = module('partition')
+#######################################################################
+# Partition manager for ESP32 - ESP32C3 - ESP32S2
+#
+# use : `import partition`
+#
+# Provides low-level objects and a Web UI
+#######################################################################
-import flash
-import string
-import webserver
+var partition = module('partition')
-#- remove trailing NULL chars from a buffer before converting to string -#
-#- Berry strings can contain NULL, but this messes up C-Berry interface -#
-def remove_trailing_zeroes(b)
- while size(b) > 0
- if b.get(size(b)-1,1) == 0
- b.resize(size(b)-1)
- else
- break
- end
- end
- return b
-end
-
-
-#-------------------------------------------------------------
- - Simple CRC32 imple
- -
- - adapted from Python https://rosettacode.org/wiki/CRC-32#Python
- -------------------------------------------------------------#
-def crc32_create_table()
- var a = []
- for i:0..255
- var k = i
- for j:0..7
- if k & 1
- k = (k >> 1) & 0x7FFFFFFF
- k ^= 0xedb88320
- else
- k = (k >> 1) & 0x7FFFFFFF
- end
- end
- a.push(k)
- end
- return a
-end
-
-var crc32_table = crc32_create_table()
-
-def crc32_update(buf, crc)
- crc ^= 0xffffffff
- for k:0..size(buf)-1
- crc = (crc >> 8 & 0x00FFFFFF) ^ crc32_table[(crc & 0xff) ^ buf[k]]
- end
- return crc ^ 0xffffffff
-end
-
-#- crc32 for ota_seq as 32 bits unsigned, with init vector -1 -#
-def crc32_ota_seq(seq)
- return crc32_update(bytes().add(seq, 4), 0xFFFFFFFF)
-end
-
-#-------------------------------------------------------------
- - Class for a partition table entry
- -
- typedef struct {
- uint16_t magic;
- uint8_t type;
- uint8_t subtype;
- uint32_t offset;
- uint32_t size;
- uint8_t label[16];
- uint32_t flags;
- } esp_partition_info_t_simplified;
- -------------------------------------------------------------#
+#######################################################################
+# Class for a partition table entry
+#
+# typedef struct {
+# uint16_t magic;
+# uint8_t type;
+# uint8_t subtype;
+# uint32_t offset;
+# uint32_t size;
+# uint8_t label[16];
+# uint32_t flags;
+# } esp_partition_info_t_simplified;
+#
+#######################################################################
class Partition_info
var type
var subtype
@@ -80,8 +30,19 @@ class Partition_info
var label
var flags
+ #- remove trailing NULL chars from a buffer before converting to string -#
+ #- Berry strings can contain NULL, but this messes up C-Berry interface -#
+ static def remove_trailing_zeroes(b)
+ while size(b) > 0
+ if b[-1] == 0 b.resize(size(b)-1)
+ else break end
+ end
+ return b
+ end
+
+ #
def init(raw)
- if raw == nil || !issubclass(bytes, raw)
+ if raw == nil || !issubclass(bytes, raw) # no payload, empty partition information
self.type = 0
self.subtype = 0
self.start = 0
@@ -91,7 +52,7 @@ class Partition_info
return
end
- #- parse -#
+ #- we have a payload, parse it -#
var magic = raw.get(0,2)
if magic == 0x50AA #- partition entry -#
@@ -99,11 +60,12 @@ class Partition_info
self.subtype = raw.get(3,1)
self.start = raw.get(4,4)
self.size = raw.get(8,4)
- self.label = remove_trailing_zeroes(raw[12..27]).asstring()
+ self.label = self.remove_trailing_zeroes(raw[12..27]).asstring()
self.flags = raw.get(28,4)
elif magic == 0xEBEB #- MD5 -#
else
+ import string
raise "internal_error", string.format("invalid magic number %02X", magic)
end
@@ -127,6 +89,7 @@ class Partition_info
# get the actual image size give of the partition
# returns -1 if the partition is not an app ota partition
def get_image_size()
+ import flash
if self.is_ota() == nil return -1 end
try
var addr = self.start
@@ -153,7 +116,7 @@ class Partition_info
return total_size
except .. as e, m
- print(string.format("BRY: Exception> '%s' - %s", e, m))
+ print("BRY: Exception> '" + e + "' - " + m)
return -1
end
end
@@ -206,7 +169,7 @@ class Partition_info
end
end
-
+partition.Partition_info = Partition_info
#-------------------------------------------------------------
- OTA Data
@@ -244,6 +207,45 @@ class Partition_otadata
var seq0 #- ota_seq of first block -#
var seq1 #- ota_seq of second block -#
+ #-------------------------------------------------------------
+ - Simple CRC32 imple
+ -
+ - adapted from Python https://rosettacode.org/wiki/CRC-32#Python
+ -------------------------------------------------------------#
+ static def crc32_create_table()
+ var a = []
+ for i:0..255
+ var k = i
+ for j:0..7
+ if k & 1
+ k = (k >> 1) & 0x7FFFFFFF
+ k ^= 0xedb88320
+ else
+ k = (k >> 1) & 0x7FFFFFFF
+ end
+ end
+ a.push(k)
+ end
+ return a
+ end
+ static crc32_table = Partition_otadata.crc32_create_table()
+
+ static def crc32_update(buf, crc)
+ crc ^= 0xffffffff
+ for k:0..size(buf)-1
+ crc = (crc >> 8 & 0x00FFFFFF) ^ Partition_otadata.crc32_table[(crc & 0xff) ^ buf[k]]
+ end
+ return crc ^ 0xffffffff
+ end
+
+ #- crc32 for ota_seq as 32 bits unsigned, with init vector -1 -#
+ static def crc32_ota_seq(seq)
+ return Partition_otadata.crc32_update(bytes().add(seq, 4), 0xFFFFFFFF)
+ end
+
+ #---------------------------------------------------------------------#
+ # Rest of the class
+ #---------------------------------------------------------------------#
def init(maxota, offset)
self.maxota = maxota
if self.maxota == nil self.maxota = 1 end
@@ -258,6 +260,7 @@ class Partition_otadata
self.maxota = n
end
+ # change the active OTA partition
def set_active(n)
var seq_max = 0 #- current highest seq number -#
var block_act = 0 #- block number containing the highest seq number -#
@@ -286,17 +289,17 @@ class Partition_otadata
end
self._validate()
end
-
end
- #- load otadata -#
+ #- load otadata from SPI Flash -#
def load()
+ import flash
var otadata0 = flash.read(0xE000, 32)
var otadata1 = flash.read(0xF000, 32)
self.seq0 = otadata0.get(0, 4) #- ota_seq for block 1 -#
self.seq1 = otadata1.get(0, 4) #- ota_seq for block 2 -#
- var valid0 = otadata0.get(28, 4) == crc32_ota_seq(self.seq0) #- is CRC32 valid? -#
- var valid1 = otadata1.get(28, 4) == crc32_ota_seq(self.seq1) #- is CRC32 valid? -#
+ var valid0 = otadata0.get(28, 4) == self.crc32_ota_seq(self.seq0) #- is CRC32 valid? -#
+ var valid1 = otadata1.get(28, 4) == self.crc32_ota_seq(self.seq1) #- is CRC32 valid? -#
if !valid0 self.seq0 = nil end
if !valid1 self.seq1 = nil end
@@ -314,7 +317,9 @@ class Partition_otadata
end
end
+ # Save partition information to SPI Flash
def save()
+ import flash
#- check the block number to save, 0 or 1. Choose the highest ota_seq -#
var block_to_save = -1 #- invalid -#
var seq_to_save = -1 #- invalid value -#
@@ -337,19 +342,21 @@ class Partition_otadata
var bytes_to_save = bytes()
bytes_to_save.add(seq_to_save, 4)
bytes_to_save += bytes("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")
- bytes_to_save.add(crc32_ota_seq(seq_to_save), 4)
+ bytes_to_save.add(self.crc32_ota_seq(seq_to_save), 4)
#- erase flash area and write -#
flash.erase(offset_to_save, 0x1000)
flash.write(offset_to_save, bytes_to_save)
end
+ # Produce a human-readable representation of the object with relevant information
def tostring()
+ import string
return string.format("",
- self.active_otadata, self.seq0, self.seq1, self.maxota)
+ self.active_otadata, self.seq0, self.seq1, self.maxota)
end
-
end
+partition.Partition_otadata = Partition_otadata
#-------------------------------------------------------------
- Class for a partition table entry
@@ -367,8 +374,9 @@ class Partition
self.load_otadata()
end
+ # Load partition information from SPI Flash
def load()
- #- load partition table from flash -#
+ import flash
self.raw = flash.read(0x8000,0x1000)
end
@@ -466,6 +474,7 @@ class Partition
#- write back to flash -#
def save()
+ import flash
var b = self.tobytes()
#- erase flash area and write -#
flash.erase(0x8000, 0x1000)
@@ -476,6 +485,7 @@ class Partition
#- invalidate SPIFFS partition to force format at next boot -#
#- we simply erase the first byte of the first 2 blocks in the SPIFFS partition -#
def invalidate_spiffs()
+ import flash
#- we expect the SPIFFS partition to be the last one -#
var spiffs = self.slots[-1]
if !spiffs.is_spiffs() raise 'value_error', 'No SPIFFS partition found' end
@@ -485,21 +495,21 @@ class Partition
flash.write(spiffs.start + 0x1000, b) #- block #1 -#
end
end
+partition.Partition = Partition
-
-#-------------------------------------------------------------
- - Parser manager for ESP32
- -
- -------------------------------------------------------------#
-
-class Partition_manager : Driver
-
- def init()
- end
+#################################################################################
+# Partition_manager_UI
+#
+# WebUI for the partition manager
+#################################################################################
+class Partition_manager_UI
+ static app_size_max = 1984 # Max OTA size (4096 - 64) / 2 rounded to lowest 64KB
+ static app_size_min = 896 # Min OTA size - let's set it at a safe 896KB for minimal Tasmota32 with TLS
# create a method for adding a button to the main menu
# the button 'Partition Manager' redirects to '/part_mgr?'
def web_add_button()
+ import webserver
webserver.content_send(
"")
end
@@ -508,6 +518,8 @@ class Partition_manager : Driver
# Show a single OTA Partition
#- ---------------------------------------------------------------------- -#
def page_show_partition(slot, active, ota_num)
+ import webserver
+ import string
#- define `bdis` style for gray disabled buttons -#
webserver.content_send("
")
end
end
-
- #- this method displays the web page -#
+ #######################################################################
+ # Display the complete page
+ #######################################################################
def page_part_mgr()
+ import webserver
+ import string
if !webserver.check_privileged_access() return nil end
var p = partition.Partition()
@@ -626,24 +660,28 @@ class Partition_manager : Driver
webserver.content_send("")
webserver.content_send("")
-
webserver.content_button(webserver.BUTTON_MANAGEMENT) #- button back to management page -#
webserver.content_stop() #- end of web page -#
end
- #- ---------------------------------------------------------------------- -#
- #- this is the controller, called using POST and changing parameters
- #- ---------------------------------------------------------------------- -#
+ #######################################################################
+ # Web Controller, called by POST to `/part_mgr`
+ #######################################################################
def page_part_ctl()
+ import webserver
+ import string
if !webserver.check_privileged_access() return nil end
#- check that the partition is valid -#
var p = partition.Partition()
try
+ #---------------------------------------------------------------------#
+ # Switch OTA partition from one to another
+ #---------------------------------------------------------------------#
if webserver.has_arg("ota")
#- OTA switch partition -#
var ota_target = int(webserver.arg("ota"))
@@ -661,6 +699,10 @@ class Partition_manager : Driver
#- and force restart -#
webserver.redirect("/?rst=")
+
+ #---------------------------------------------------------------------#
+ # Resize the SPIFFS partition, generally to extend it to full free size
+ #---------------------------------------------------------------------#
elif webserver.has_arg("spiffs_size")
#- SPIFFS size change -#
var spiffs_size_kb = int(webserver.arg("spiffs_size"))
@@ -683,6 +725,9 @@ class Partition_manager : Driver
#- and force restart -#
webserver.redirect("/?rst=")
+ #---------------------------------------------------------------------#
+ # Repartition symmetrical OTA with a new SPIFFS size
+ #---------------------------------------------------------------------#
elif webserver.has_arg("repartition")
if p.get_active() != 0 raise "value_error", "Can't repartition unless active partition is app0" end
#- complete repartition -#
@@ -700,11 +745,10 @@ class Partition_manager : Driver
var app0_size_kb = ((app0.size / 1024 + 63) / 64) * 64 # rounded to upper 64kb
var app0_used_kb = (((app0.get_image_size()) / 1024 / 64) + 1) * 64
var flash_size_kb = tasmota.memory()['flash']
- var app_size_max = 1984 # Max OTA size (4096 - 64) / 2 rounded to lowest 64KB
var part_size_kb = int(webserver.arg("repartition"))
- if part_size_kb < app0_used_kb || part_size_kb > app_size_max
- raise "value_error", string.printf("Invalid partition size %i KB, should be between %i and %i", part_size_kb, app0_used_kb, app_size_max)
+ if part_size_kb < app0_used_kb || part_size_kb > self.app_size_max
+ raise "value_error", string.printf("Invalid partition size %i KB, should be between %i and %i", part_size_kb, app0_used_kb, self.app_size_max)
end
if part_size_kb == app0_size_kb raise "value_error", "No change to partition size, abort" end
@@ -722,6 +766,54 @@ class Partition_manager : Driver
p.invalidate_spiffs() # erase SPIFFS or data is corrupt
#- and force restart -#
webserver.redirect("/?rst=")
+
+ #---------------------------------------------------------------------#
+ # Repartition OTA with a new SPIFFS size
+ #---------------------------------------------------------------------#
+ elif webserver.has_arg("app0") && webserver.has_arg("app1")
+ if p.get_active() != 0 raise "value_error", "Can't repartition unless active partition is app0" end
+ #- complete repartition -#
+ var app0 = p.get_ota_slot(0)
+ var app1 = p.get_ota_slot(1)
+ var spiffs = p.slots[-1]
+
+ if !spiffs.is_spiffs() raise 'internal_error', 'No SPIFFS partition found' end
+ if app0 == nil || app1 == nil
+ raise "internal_error", "Unable to find partitions app0 and app1"
+ end
+ if p.ota_max() != 1
+ raise "internal_error", "There are more than 2 OTA partition, abort"
+ end
+ var app0_size_kb = ((app0.size / 1024 + 63) / 64) * 64 # rounded to upper 64kb
+ var app0_used_kb = (((app0.get_image_size()) / 1024 / 64) + 1) * 64
+ var app1_size_kb = ((app1.size / 1024 + 63) / 64) * 64 # rounded to upper 64kb
+ var flash_size_kb = tasmota.memory()['flash']
+
+ var part0_size_kb = int(webserver.arg("app0"))
+ if part0_size_kb < app0_used_kb || part0_size_kb > self.app_size_max
+ raise "value_error", string.printf("Invalid partition size app%i %i KB, should be between %i and %i", 0, part0_size_kb, app0_used_kb, self.app_size_max)
+ end
+ var part1_size_kb = int(webserver.arg("app1"))
+ if part1_size_kb < self.app_size_min || part1_size_kb > self.app_size_max
+ raise "value_error", string.printf("Invalid partition size app%i %i KB, should be between %i and %i", 1, part1_size_kb, self.app_size_min, self.app_size_max)
+ end
+ if part0_size_kb == app0_size_kb && part1_size_kb == app1_size_kb raise "value_error", "No change to partition sizes, abort" end
+
+ #- all good, proceed -#
+ # resize app0
+ app0.size = part0_size_kb * 1024
+ # change app1
+ app1.start = app0.start + app0.size
+ app1.size = part1_size_kb * 1024
+ # change spiffs
+ spiffs.start = app1.start + app1.size
+ spiffs.size = flash_size_kb * 1024 - spiffs.start
+
+ p.save()
+ p.invalidate_spiffs() # erase SPIFFS or data is corrupt
+ #- and force restart -#
+ webserver.redirect("/?rst=")
+ #---------------------------------------------------------------------#
else
raise "value_error", "Unknown command"
end
@@ -744,20 +836,22 @@ class Partition_manager : Driver
#- ---------------------------------------------------------------------- -#
#- this is called at Tasmota start-up, as soon as Wifi/Eth is up and web server running -#
def web_add_handler()
+ import webserver
#- we need to register a closure, not just a function, that captures the current instance -#
webserver.on("/part_mgr", / -> self.page_part_mgr(), webserver.HTTP_GET)
webserver.on("/part_mgr", / -> self.page_part_ctl(), webserver.HTTP_POST)
end
-
end
+partition.Partition_manager_UI = Partition_manager_UI
+
#- create and register driver in Tasmota -#
-var partition_manager = Partition_manager()
-tasmota.add_driver(partition_manager)
-## can be removed if put in 'autoexec.bat'
-partition_manager.web_add_handler()
-
-partition.Partition = Partition
+if tasmota
+ var partition_manager_ui = partition.Partition_manager_UI()
+ tasmota.add_driver(partition_manager_ui)
+ ## can be removed if put in 'autoexec.bat'
+ partition_manager_ui.web_add_handler()
+end
return partition
diff --git a/tasmota/berry/modules/partition.bec b/tasmota/berry/modules/partition.bec
deleted file mode 100644
index 8a2f56cd3418bfac81145d1e5d44bcac92c4f397..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 15637
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
fG?{2E{HjrWKt@?!6f{uq!W{(@*~PV*n1lT