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("
") webserver.content_send(string.format(" %s%s", @@ -515,6 +527,7 @@ class Partition_manager : Driver webserver.content_send(string.format("

Partition size: %i KB

", slot.size / 1024)) var used = slot.get_image_size() + if used > slot.size slot.used = -1 end # we may have a leftover of a previous firmware but the slot shrank - in this case the slot is inknown if used >= 0 webserver.content_send(string.format("

Used: %i KB

", used / 1024)) webserver.content_send(string.format("

Free: %i KB

", (slot.size - used) / 1024)) @@ -522,7 +535,7 @@ class Partition_manager : Driver webserver.content_send("

Used: unknwon

") webserver.content_send("

Free: unknwon

") end - if !active && used >= 0 + if !active && used > 0 webserver.content_send("

") webserver.content_send("") @@ -547,6 +560,8 @@ class Partition_manager : Driver # Show a single OTA Partition #- ---------------------------------------------------------------------- -# def page_show_spiffs(slot, free_mem) + import webserver + import string webserver.content_send(string.format("
 %s", slot.start / 0x1000, slot.label)) @@ -586,9 +601,11 @@ class Partition_manager : Driver end #- ---------------------------------------------------------------------- -# - #- Display the Re-partition section + #- Display the Re-partition section - both OTA different sizes #- ---------------------------------------------------------------------- -# - def page_show_repartition(p) + def page_show_repartition_asym(p) + import webserver + import string if p.get_active() != 0 webserver.content_send("

Re-partition can be done only if 'app0' is active.

") else @@ -596,23 +613,40 @@ class Partition_manager : Driver var app0 = p.get_ota_slot(0) 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 - webserver.content_send("

Resize app Partitions

") - webserver.content_send(string.format("

Min: %i KB

", app0_used_kb)) - webserver.content_send(string.format("

Max: %i KB

", app_size_max)) - webserver.content_send("

New: (multiple of 64 KB)

") + var app1 = p.get_ota_slot(1) + var app1_size_kb = ((app1.size / 1024 + 63) / 64) * 64 # rounded to upper 64kb + # var app1_used_kb = (((app1.get_image_size()) / 1024 / 64) + 1) * 64 # we don't actually need it + + var flash_size_kb = tasmota.memory()['flash'] + + webserver.content_send("

Resize app Partitions.
It is highly recommended to set
both partition with the same size.
SPIFFS is adjusted accordinlgy.

") + webserver.content_send("") - webserver.content_send(string.format("", app0_used_kb, app_size_max, app0_size_kb)) + + webserver.content_send("

app0:

") + webserver.content_send(string.format("

Min: %i KB

", app0_used_kb)) + webserver.content_send(string.format("

Max: %i KB

", self.app_size_max)) + webserver.content_send("

New: (multiple of 64 KB)

") + webserver.content_send(string.format("", app0_used_kb, self.app_size_max, app0_size_kb)) + + webserver.content_send("

app1:

") + webserver.content_send(string.format("

Min: %i KB

", self.app_size_min)) + webserver.content_send(string.format("

Max: %i KB

", self.app_size_max)) + webserver.content_send("

New: (multiple of 64 KB)

") + webserver.content_send(string.format("", self.app_size_min, self.app_size_max, app1_size_kb)) + 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("
 Re-partition

") - self.page_show_repartition(p) + self.page_show_repartition_asym(p) 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 zcmd5@&vV<@eSZKRK@fr{h^Do)%(W3&TGEbU%U-YRdL`|TtT)Zn-u3J9jL#rx&ZA?|Tm* z2uhBVUaFAA)m(^cj$}gC! zzpxowV>xmC+M)Xy{ROUHXX$=ce}U`QF5O?yU*P&Rt^+n_4zP#668ALkJl1$iALKQ5 z#OCb*PCzAIxAwZiYq2bW?1*QSI)yChG>Bdh-z;KJ_=d ze&>nbac5Y^-)%kdH-b*J-oVq1AN!q_-*t0rtF!g?jg762Uk&iMy4mpE3DqfEj%w|- zYZ&5As%9&wZgl-0xmorg1ocL}x!syhc9_^H{c%=r)&u!JfgnB2*hd(`m~~Irts-Z3 zkY(1xv&?zOba$O`t8b!D)AVOr3V&(*W%Os}1g=v63TFqFVI67AUCXfi8Z)i_FCJto z26u{cEZegT=ZN}kR=_i-Z!rBC^-aY4xa>37OvPZc6*10~ed&0gh4l*A!34`yX3?Lr z-1oWeK4-eqpG-SI80NR-d*|ctW%Xwnrnmp)*K%JB`%0nBirK*w-g9Z+ys$3Toio1z zFb;C~=W-V8w|&qe?$fNhw@FQ)yBh%Rc-pQ1n3|31rr!XCNkVwkbq)5&4>Zs#)97W5 z_~dj#uvoRL2q&T-lWn&;yVbxw2h=^OHtIERxB95Qi7g`0Z)LoTC0VKA>f{l7@KW(TDuCeA(jNMKI-B{#xktl-RQRK+uPmP zZ4z8S9wZI0$PYDx7Y%0iblm@x*%fTR2o4K0#vfqY^mia|tq5mye>4fp5@~GIflPmc zPL*sYSD$38LhorDGi?8g z2^R2lu)xdX$qIJT z!TQVw`>;Zv@e2cEs(6%UZ&fnZJWsPLAdF%^HRWNh@|wY}qV1tQe?<2NyMgw6pT^m_ zGP5n0{rXsIr)mFs{!5(qE6vJtK9Of)uQorR>m=A$XZ%YWSB*rKn;T>@?vW5PdJ$sF zTW6NrCy_ws6lWxs$9#b10rU|AWdZ*XrV&Snf}Dfosx`s8ikC5*f#148tG!VN&s9mk z4F!ITT|fNn`**y%y)C~@Lsq@gLbv37%Uh5n7cLoIljsKZK#)YlHlc`ZN)lT+t=bo5 zJ0B9Gdd`U)@b(B1q8(!rYB38kLh{A%F1eE_#&*SE^Jf~Q1c1Dz6K7}K;(3LVq^c7VfNipnOyg!Zp z8P(5Y{WjSTQ^S?gL_ z_8C#1vtBUVqjO+NT`Pjgz4eqff5^=8`mDBicwSpNENV9o-_&j&vOEah;s+Oiw;^rf zKCZ_gK-=w@&`h)K>Q>O|?1R#Y7ckcELfz9GoWbs>VlWwV&#N#j*EQ>jYyv>buM2}YC;Wn zr(+hDg2D6&eJKD7S{0JBTYcio|4DwzFm?+=NNB%2&QzG@90J1kAVob5!kB5+Ap{$6 zJnZM_r?zf7^E5`s7z1N8!^x8kL*uoSyf?rT9Al5Cauv;TEzAoI?>@vD{ZuA@#CUPt zU~>?J`Tki5aFc{Qgg5A&iRal1Fn%F4^&)IEISw|=YX&%%Lo%hb!l6l==0M#-o)Th*+!PV&cTXl%MI~iiH^5i*C;pv<+M-8I zz+>eHXe`uzC_8qmy_JO;h&IdQP*O&M&mU7ymV4UH)d$Zd!gPGB*)-4Nl8R<$Pfm|+{jW`)xd zyG?QgbR9`5u`@DXq-o=&l#VkD&N)$h%7UlJAtq>t!*P>poO+K8vw)rxmtg!n%|q>z z=7~;t+ED}@3mF&!Um-)B&jxJ=3cd0B#0kl_x-4FRl}+*<6vkNo9(WEsp94-Tw547R z`#I?YjMFU_JWuz;^PtX@3uwSfh9QLaT^L24%Vv69&(G`f%2V8e2-4f(I1c@)*%*(0 zBb^7nXL`C0+Q1m11?XWIi;{l{-yt69@Cp8v1PI4ALaaC!XM}V4KXG^sEOt!{k}O7q zCR_}YtXO+MZpQJy<%QmG!l^0bs$KfgHXi|M#xY?R0)Jot|qBiiVwA~%ut6-$L=|1A~(wsC=4af zXe+TX4MZ}FSB`&aLVuB?VfA4@Jrr~eLmNIeoC(AXpmX$$pjB`sXcYIUi9F=cOwY{l z!~YZpz61T^2m_zxvd}$vPtU_}hY#<{gOl4OjSm7DsrE)I=mG=6D;Hpiw78_*&@~^k z)Q2X_3F;fmkdH@>nUurv`y>p28FKNII|#Dk2ZXGck(9&+jRs$y8iFAsijJBS4mq50 z2s-$4!rz0ZDXxaPV%Py;E(Q$ZKr|7Kf*u^j9|jVF>0m+u0eqva#Nl)xSOh-EZ6`wn z2(}2R+yPRZK8#cB#_hwvfG~joQ{Ll$0iik!5R8`yg(zoS`NARELYHUEjS{;YIwHfn z#O{dvWU!IL1h+tTa);^I4s` zoT%E%Kd>XH!*2Cp)`ngbbdx=HEDZkPqK$Dw+B2Aac~u-Y3eH3~Cmi;Yz#gJBq$l9~ z|Ecu)=3%w;bi@2gMsPTIaELC%wIX!I0r5Wo0{LtaifjZs5t%U|Vne>8m|vPAKNLqJ zponMkJ_1gN&Itw;zX$uCB2NfYRaz~CsG9qph>ME&uP6C6I^sBp40ivccVK3y0H??9 zet7r8SJ{RbyA4L_?g%JC!kubwgIJU&Ef|RD_CP0<^OIcVcuJ}q>Z5=O^$8dtf<+bw z1x<+bv4*sRh9@}1?u_AB?n!O$bnOUJjSNesn*-1+wvffPunlNOVZLE$5;M>L&46jL84r73YAi>o2FJxVd3w7kzBeD~vzKbIs!;Sz7V zwb!h{EnHfP&a6y(;yntxio?f^uK0c8xFpZWAZCVzPzeRGC1Mfi1{2vmI&O$?C^3Km z_5#A5)MpAJkO6Qy7GX;&8Nts&BaNP@$iAyWwGg(kGs%!`j6_D1zZ>5_?UgB6t}clq zX^>F2G026o_#w#yX1`Ce;VSXHZb3|2{fjclXXz=IsaT4oXJ9{?&ikaR4a<2Bd+>;{ zB4QIB;_|QoZFl__4=DbTFCymNWA@B|nZPRSLlfpxda`iDZ1{V;0C_{PbUD_+Sg{7r zTPmi*Jc{k`f~8L)NNDG=X5kQS>hl!zx{h~2_2TazAd^UiP+tbVEY6|S2Rk5zD8Vf_ zKzbmf7+pWDbCU6M6;5%i6MK?(pgKXXuLq`>j2y`k{sHb7e zBZf2yH3&Hy#k?}At>B(djXnouP$z-AA3V=16`|!+t5h7dp~Jiik~v zOZh!g8H}3)IAGu`Y4vk(RZ1ln$P18MMxxdpr11>lL6LX}+Zoli1?Ps`D927D#yYA! z`5VS&iL+U5fDj;rnq1oz+%82vNsN&c-WzOd$KQIq(eBir)EoXIzXm&T%kOqEN@zO~ z4362>~)?YiHn!QEV4rj?tu z)#c6Ax0;*X_PY@Sgi|hG+gx3~77lCUzP;-H30rP^V)B*J(|Rq~x%2jorFQS#5+(}! zw_1%>=gy_=cW#wd|G3&|B1?IPCi5yg^{%&7ZF-xX`qpD_zqQx#YW|b@mVZ@Do|m-J z-Dy1)1S8ab7uLYCTz&c4aQkXM@4o6tpZVX40D9tS&c+t(_|aG}dI-18y&ynnewuyw zy-MZNPd4s<@W}_?xqJWaCzUvG5yvN@MLGtV+6|q$(`axFm#2b zAk=j{^P9ls6C4~lJ+EF{!CDc2g*d3yyX{7G|BkoWXl*@yx8#v4(rT`h=#a#SC2!Xc zc3QQS66|@xo;c|0NmL-80Qgu>+M8!$aEqXJVrSyS++j?nE^-462^O8=h8$`V3`A{a z&JH+qLkfDtH0BY1q(siVlQznTXWEG0FCz9q@*P*35=7(g+IV+@-ZkXA&^uzBgSa=T z5`S1S|w;aE5h zpaMDGg)WT0KOMGtejT~yfc8*`?I9OFSMQ%mwDXBJGv%JoVm}PA`{aIS)c9O%Pj9F` zS{pft===rMk6e1R|8t4;!ui7U%|_4J!FkZ{h=5B_>isy23emB8brbaYXPecn$B#N> zqTRW4`}Xa3|6Fw6*+F?jXZ+#vHTf=V_9(rrqWsHjD9t<+i!}U|(&uE|-SH@<|Mr@< zw6s)Ga&Do!&>ay5p(%)~Ouc82Mv$KSWfw=gB*f2Vj5R3JddH(l7V6#~ekf!Y40TH1 zCljzz`AP}4x%$1XUrW9iKKr=ik4<`BEluyya(BGF=HuqmR#Pk)KBYIupMEOd4P^vG zzC6-z6c9ook4lI((t9P({XT0oVKwa5gOyUp5B54u4`On=-q|hB3DN$v-e`DRJJseR z-wSqpA#ne^pfE)D(Hn!CPti)NJ8=P=Ec=f{TdF0D+uaA>EChiRw4CbS~vn_qu^gusnn-GxUV$Iv@%pP4=XldF9C`Od~#UsK}L%hP$15ck~a>wmkX;=GRt=06sRZv>|H54F9!@^-0*VYB4)b7`t z(k&P&(dbdNmwfb=dg~MaseDx4-D?E(b_1u~_HNyT9=bYe1iPuFK#ucgmE zx_j?#<*ty($|;})Nxf~ud-J;c-N4^{g=T{umWoJf9~r)Z^-^xE9Q_4l~oW>5{;Ej!{Oj*}9rY})SQCd|viu5GmXT+>0&=HrjsmO(a9&q*PQ)T; z5CTL0_qn!$hMEg&pPr||9t7{>>@-~b#J7ZL#+swi5`g`jm_kDLRnd}vCdG=VNIYWl zHpF;_g8fW}Wkp#g5@sak(}=Jc2;ow3tq9m%nhu%EqokX8V)Ie<$9v2jhULH>aKKVm# zyU&XmxgORDO82R#8HER2o;`#E&fY8InPo_$YhfafHD-F3(RVSG2RwxV26I4$`QMXQ zsC{XeWm9sE5bp0G=T7inm@L2?5cd{ax-d8gXPpX*W~YiU2j(gnHV+!UGBD4UkLK7C z;8_L@&-cw-88E*RSh?~%DnbGCD}yT-^GzC)gQ|9*s@-L%>vG=)+*7$SV11>xC}1rr zNG*HSes(@pKD?M(JcP5)>|5DqGO$p_v+T3;X*^4Z&vNovwrrixh(2zw=76g*_FD$7 z;Kkj8FE|JOkOOR*rz^%!s5pnFAU zZ-;b7!2-}(7&y5}V?aCaOroYi1ygQCb5KA)R=EBi16Rk5l;1vznPxlH?nbrq2+?#h zZ&5nfc_hS_G>t_LWZYah8|6WUe&nTvS|b0GuxXOUjGK>U`LyzZ$Rv6+Nr_cD`-o2p zKTJBV%1s_F1{Vz@<9U^sDKGPLP)$&?^^U(q)ri;L>vV$^Y)~5IqA)v3D7=PwANK}| z>|V7S$PL4cYqo+g%OyS@xDg#IwwAY0U-S?}-D}jmO&`C(Q{QiTOTtK5sD}n$k*Rzc zITHvhNXH%3W!23VzDGDA{f8sPj1qukSmWScXWy{U&4TQDF!vJKD!Z4Lt znB->%2=|_z$}6$&HOY@Ad?9mN8^#vol;Vh(`?`CDRd)Oi0uA_s6GZ_D1fFkrg#KuU z;aN?FV0<&!c7H#DXEeqW;1M?JNl?WDifRC?bF}X;`q3ILA|?n|n!*7dH(O6Bbh3+7 zNe#z6wZ?la@IOUqD#S-nGVaMt`Rj!J-yL@#ZiX@Y)u^^ySk=p$omHxTr7MNWV8H*b zvG5bT(=mTFVXYacqpv{FkU^rs$Y5NUkSOFTuBG7dA5Y;k0M~OUGE5%7G&V-ZrZLu{ zv4$KgvXR0laqi(OBJT_tF^Tyz>K)zn*!X*+^V=AEY|K?G?jcG%x(8_#iQKo?zY`i8 zYQ4!sJ3L?Zg!L_)QP0g|KQf9vB|d4GhXEqmR`UE)iMi!hNVFGrFqVQ!L}iQv3+nPJF*O4H=+t4}CAP$FUfLO`l_J)a1}H$CDhn)4qR9ND@jU7cksG zT?0%Q)E&SHHTe8AYMrPU4&QU2A0|91RrIvO#TRN+N{UaiOpKudx#Sp`nUl2!zd|gK z{7Bl8G+|^h0o~)U(iO)&Tom<{W&FF$#AZ=KAU>f(t=vPrfr1GB@MWwR<9w_bgKFpS z%@hjm5h`Od?~@(q&~wzC!VX7GU15>KU%+)41rv+Ran|uA)=eski1+=6C>B7dPItc~ z8xJ3vAP_2jtbY>E;sq336fTK_3Z< zRL)EV+w}PYYUXZI7&s1cC;$!*6eomN5(^9Cvmg3yY#Ajei!TWZdVbMhh3D|nZc_SC zl!jRDO)81_cX5(onVMY7x=Gc9lz1II5rcgrH!Q!GJu#S?_-ZzBC<=y9tqBD@!mkyn fG?{2E{HjrWKt@?!6f{uq!W{(@*~PV*n1lT