Berry extension manager (#23940)

This commit is contained in:
s-hadinger 2025-09-22 22:32:03 +02:00 committed by GitHub
parent 9d39901967
commit c95063a56b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 5866 additions and 2102 deletions

View File

@ -34,6 +34,7 @@ be_extern_native_module(re);
be_extern_native_module(mqtt);
be_extern_native_module(persist);
be_extern_native_module(autoconf);
be_extern_native_module(extension_manager);
be_extern_native_module(tapp);
be_extern_native_module(light);
be_extern_native_module(gpio);
@ -153,6 +154,9 @@ BERRY_LOCAL const bntvmodule_t* const be_module_table[] = {
#ifdef USE_AUTOCONF
&be_native_module(autoconf),
#endif // USE_AUTOCONF
#ifdef USE_EXTENSION_MANAGER
&be_native_module(extension_manager),
#endif // USE_EXTENSION_MANAGER
&be_native_module(tapp),
&be_native_module(gpio),
#ifdef USE_DISPLAY

View File

@ -13,7 +13,7 @@ import sys
sys.path().push('src/embedded') # allow to import from src/embedded
# globals that need to exist to make compilation succeed
var globs = "path,ctypes_bytes_dyn,tasmota,ccronexpr,gpio,light,webclient,load,MD5,lv,light_state,udp,tcpserver,log,"
var globs = "path,ctypes_bytes_dyn,tasmota,ccronexpr,gpio,light,webclient,load,MD5,lv,light_state,udp,tcpserver,log,sortedmap,"
"lv_clock,lv_clock_icon,lv_signal_arcs,lv_signal_bars,lv_wifi_arcs_icon,lv_wifi_arcs,"
"lv_wifi_bars_icon,lv_wifi_bars,"
"_lvgl,"

View File

@ -0,0 +1,7 @@
/********************************************************************
* Tasmota lib
*
* To use: `import autoconf`
*
*******************************************************************/
#include "solidify/solidified_extension_manager.h"

View File

@ -90,6 +90,7 @@ class be_class_tasmota (scope: global, name: Tasmota) {
_crons, var // list of active crons
_ccmd, var // list of active Tasmota commands implemented in Berry
_drivers, var // list of active drivers
_ext, var // list of active extensions
_wnu, var // list of closures to call when network is connected
wire1, var // Tasmota I2C Wire1
wire2, var // Tasmota I2C Wire2
@ -189,6 +190,9 @@ class be_class_tasmota (scope: global, name: Tasmota) {
run_network_up, closure(class_Tasmota_run_network_up_closure)
add_driver, closure(class_Tasmota_add_driver_closure)
remove_driver, closure(class_Tasmota_remove_driver_closure)
add_extension, closure(class_Tasmota_add_extension_closure)
read_extension_manifest, closure(class_Tasmota_read_extension_manifest_closure)
unload_extension, closure(class_Tasmota_unload_extension_closure)
load, closure(class_Tasmota_load_closure)
compile, closure(class_Tasmota_compile_closure)
wire_scan, closure(class_Tasmota_wire_scan_closure)

View File

@ -0,0 +1,644 @@
# extensions manager module for Berry
#
#
var extension_manager = module("extension_manager")
#@ solidify:extension_manager
class Extension_manager
static var EXT_FOLDER = "/.extensions/"
static var EXT_REPO = "https://ota.tasmota.com/extensions/"
static var EXT_REPO_MANIFEST = "extensions.jsonl"
static var EXT_REPO_FOLDER = "tapp/"
#####################################################################################################
# init - constructor
#
# Register as driver
#
def init()
tasmota.add_driver(self)
end
#####################################################################################################
# General static helper functions
#####################################################################################################
#####################################################################################################
# version_string(v)
#
# Convert 32 bits version to "a.b.c.d" where version is 0xAABBCCDD
#
# @param v: int - version in format 0xAABBCCDD
# @return string - string in format "a.b.c.d"
#####################################################################################################
static def version_string(v)
return f"v{(v >> 24) & 0xFF}.{(v >> 16) & 0xFF}.{(v >> 8) & 0xFF}.{v & 0xFF}"
end
#####################################################################################################
# tapp_name(wd)
#
# Takes a working directory 'wd' and extract the name of the tapp file.
# Ex: '/.extensions/Leds_Panel.tapp#' becomes 'Leds_Panel.tapp'
#
# @param wv: string - the Working Dir of the tapp file like '/.extensions/Leds_Panel.tapp#'
# @return string - the raw name of the tapp file, like 'Leds_Panel.tapp'
#####################################################################################################
static def tapp_name(wd)
import string
var from = 0
var to = size(wd) - 1
# if ends with '#' or '_' remove last char
if (wd[to] == "#") || (wd[to] == "_")
to -= 1
end
# remove suffix .tapp
if string.find(wd, ".tapp", to + 1 - size(".tapp")) >= 0
to -= size(".tapp")
end
# remove anything before the last '/'
var idx
while (idx := string.find(wd, '/', from)) >= 0
from = idx + 1
end
# final result
return wd[from .. to]
end
#####################################################################################################
# General functions to get information about tapp files (installed or from store)
#####################################################################################################
#####################################################################################################
# manifest_decode()
#
# Decode the manifest entry from JSONL (single line) into a validated format
# or return 'nil' if something major went wrong (and logged)
#
# @param json_line: string - single JSONL entry, like '{"name":"Leds Panel","file":"Leds_Panel.tapp","version":"0x19090100","description":"[...]","author":"[...]"}
# @return map or nil: normalized map or 'nil' if something went wrong
# guaranteed fileds: 'name', 'description', 'file', 'version', 'author'
static def manifest_decode(json_line)
import json
# try to parse json
var entry = json.load(json_line)
if (entry == nil)
log(f"EXT: unable to parse manifest line '{json_line}'", 3)
return nil
end
# check for mandatory fields
if !entry.contains('name') || !entry.contains('file') || !entry.contains('version')
log(f"EXT: manifest is missing 'name/file/version' in map '{entry}'")
return nil
end
# build result with mandatory fields
var result = {
'name': entry['name'],
'file': entry['file'],
'version': int(entry['version'])
}
# add non-critical fields
result['description'] = entry.find('description', "[no description]")
result['author'] = entry.find('author', "")
return result
end
#####################################################################################################
# list_installed_ext()
#
# Return a map of installed tap files, by tapp file name
# tapp_name -> path {'Leds_Panel.tapp': '/.extensions/Leds_Panel.tapp'}
#
# @return map: tapp_namt -> full path (or wd)
static def list_installed_ext()
# Read extensions in file system
var installed_ext = {}
for ext_path: _class.list_extensions()
installed_ext[_class.tapp_name(ext_path)] = ext_path
end
# log(f"EXT: list_installed_ext={installed_ext}")
return installed_ext
end
#####################################################################################################
# list_extensions_in_fs()
#
# List all extensions in file-system, whether they are running or not
#
# @return sortedmap: with Name of App as key, and following map:
# name, description, version (int), autorun (bool)
static def list_extensions_in_fs()
import string
var sm = sortedmap()
for ext: _class.list_extensions()
var details = tasmota.read_extension_manifest(ext)
if (details != nil)
var name = details.find("name")
if (name)
sm[name] = ext
end
else
log(f"EXT: unable to read details from '{ext}'", 3)
end
end
return sm
end
#####################################################################################################
# list_extensions()
# Returns the list of enabled and disabled extensions
# i.e. scan files in ".extensions" folder and keep files ending with ".tapp" and ".tapp_"
#
# @return list: list of extensions by path or by instance
static def list_extensions()
import path
import string
var l = []
# read from fs
for d: path.listdir(_class.EXT_FOLDER)
if string.endswith(d, ".tapp") || string.endswith(d, ".tapp_")
l.push(_class.EXT_FOLDER + d)
end
end
# finish
return l
end
#####################################################################################################
# Methods to install / upgrade / delete extensions from the store
#####################################################################################################
#####################################################################################################
# install_from_store(tapp_fname)
#
# @param tapp_fname : string - name of tapp file to install from repository
def install_from_store(tapp_fname)
import string
# sanitize
tapp_fname = self.tapp_name(tapp_fname)
# add '.tapp' extension if it is not present
if !string.endswith(tapp_fname, ".tapp")
tapp_fname += '.tapp'
end
# full url
var ext_url = f"{self.EXT_REPO}{self.EXT_REPO_FOLDER}{tapp_fname}"
log(f"EXT: installing from '{ext_url}'", 3)
# load from web
try
var local_file = f"{self.EXT_FOLDER}{tapp_fname}"
var cl = webclient()
cl.begin(ext_url)
var r = cl.GET()
if r != 200
log(f"EXT: return_code={r}", 2)
return
end
cl.write_file(local_file)
cl.close()
except .. as e, m
tasmota.log(format("CFG: exception '%s' - '%s'", e, m), 2)
return nil
end
end
#####################################################################################################
# Init web handlers
#####################################################################################################
# Displays a "Autoconf" button on the configuration page
def web_add_button()
import webserver
webserver.content_send(
"<p></p><form id=but_part_mgr style='display: block;' action='ext' method='get'><button>Extension Manager</button></form><p></p>")
end
#####################################################################################################
# This HTTP GET manager controls which web controls are displayed
#####################################################################################################
def page_extensions_mgr_dispatcher()
import webserver
if !webserver.check_privileged_access() return nil end
if (webserver.has_arg("store"))
return self.page_extensions_store()
else
return self.page_extensions_mgr()
end
end
#####################################################################################################
# This HTTP GET manager controls which web controls are displayed
#####################################################################################################
def page_extensions_mgr()
import webserver
import string
webserver.content_start('Extensions Manager')
webserver.content_send_style()
# webserver.content_send("<p><small>&nbsp;(This feature requires an internet connection)</small></p>")
webserver.content_send("<div style='padding:0px 5px;text-align:center;'><h3><hr>Extension Manager<hr></h3></div>")
webserver.content_send("<script>"
"function loadext() {"
"eb('store').disabled=true;"
# "eb('store').innerHTML = '[ <span style=\"color:var(--c_btnsv);\">Loading from Store...</span> ]';"
"x=new XMLHttpRequest();"
"x.timeout=4000;"
"x.onreadystatechange = () => {"
"if(x.readyState==4){"
"if(x.status==200){"
"eb('inet').style.display='none';"
"eb('store').outerHTML=x.responseText;"
# "}else{"
# "eb('store').innerHTML='<b>[ <span style=\"color:var(--c_btnrsthvr);\">Error loading manifest.</span> ]</b>';"
"}"
"}"
"};"
"x.open('GET','?store=');"
"x.send();"
"}"
"window.onload=function(){"
"loadext();"
"};"
# scripts for Store
"function toggleDesc(id) {"
"var desc = document.getElementById('desc-' + id);"
"var arrow = document.getElementById('arrow-' + id);"
"if (desc.style.display === 'none' || desc.style.display === '') {"
"desc.style.display = 'block';"
"arrow.innerHTML = '▼';"
"} else {"
"desc.style.display = 'none';"
"arrow.innerHTML = '▶';"
"}"
"}"
# Simple filtering functions (optional enhancement)
"function filterExtensions(query) {"
"var items = document.getElementsByClassName('ext-store-item');"
"query = query.toLowerCase();"
"for (var i = 0; i < items.length; i++) {"
"var name = items[i].getElementsByClassName('ext-name')[0].textContent.toLowerCase();"
"var desc = items[i].getElementsByClassName('ext-desc')[0].textContent.toLowerCase();"
"if (name.includes(query) || desc.includes(query)) {"
"items[i].style.display = 'block';"
"} else {"
"items[i].style.display = 'none';"
"}"
"}"
"}"
"</script>"
)
webserver.content_send("<fieldset style='padding:0 5px;'>"
"<style>"
# Fix for small text - the key is width: min-content on parent */
".ext-item{width:min-content;min-width:100%;}"
".ext-item small{display:block;word-wrap:break-word;overflow-wrap:break-word;white-space:normal;padding-right:5px;padding-top:2px;}"
# Control bar styles
".ext-controls{display:flex;gap:8px;align-items:center;margin-top:4px;padding:0px}"
# Small action buttons
".btn-small{padding:0 6px;line-height:1.8rem;font-size:0.9rem;min-width:auto;width:auto;flex-shrink:0;}"
# form
"form{padding-top:0px;padding-bottom:0px;}"
# Running indicator
".running-indicator{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:8px;background:var(--c_btnsvhvr);animation:pulse 1.5s infinite;}"
"@keyframes pulse{0%{opacity:1;}50%{opacity:0.5;}100%{opacity:1;}}"
# for store
# /* Extension Store specific styles */
".store-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;}"
".store-stats{font-size:0.9em;color:var(--c_in);}"
".ext-store-item{background:var(--c_bg);border-radius:0.3em;margin-bottom:5px;padding:4px;}"
".ext-header{display:flex;justify-content:space-between;align-items:center;cursor:pointer;user-select:none;padding:5px;}"
".ext-title{display:flex;align-items:center;gap:6px;flex:1;padding:0;}"
".ext-name{font-weight:bold;}"
".ext-version{font-size:0.8em;}"
".ext-arrow{color:var(--c_in);font-size:0.8em;}"
".ext-badges{padding:0;}"
# ".ext-badges{margin-left:auto;gap:8px;align-items:center;}"
".ext-details{width:min-content;min-width:100%;padding:0;display:none;}"
".ext-desc{color:var(--c_in);font-size:0.8em;line-height:1.4;display:block;word-wrap:break-word;overflow-wrap:break-word;white-space:normal;padding:0 5px;}"
".ext-actions{display:flex;gap:8px;padding:5px;}"
".btn-action{padding:0 12px;line-height:1.8em;font-size:0.9em;flex:1;}"
".installed-badge{border-color:var(--c_btnhvr);padding:0px 4px;border-radius:4px;font-size:0.7em;border-width:2px;border-style:solid;margin-right:3px;}"
# ".installed-badge{background:var(--c_btnsv);padding:2px 6px;border-radius:3px;font-size:0.7em;}"
".update-badge{background:var(--c_btnhvr);padding:2px 6px;border-radius:4px;font-size:0.7em;margin-right:3px;animation:pulse 2s infinite;}"
"@keyframes pulse{0%{opacity:1;}50%{opacity:0.7;}100%{opacity:1;}}"
# ".category-filter{display:flex;gap:5px;margin-bottom:15px;overflow-x:auto;padding:5px 0;}"
# ".cat-btn{padding:5px 12px;background:#3a3a3a;border:1px solid var(--c_frm);border-radius:15px;color:#aaa;font-size:0.9em;white-space:nowrap;cursor:pointer;transition:all 0.2s;}"
# ".cat-btn:hover{background:#4a4a4a;color:var(--c_btntxt);}"
# ".cat-btn.active{background:#1fa3ec;color:var(--c_btntxt);border-color:var(--c_btn);}"
# ".search-box{width:100%;box-sizing:border-box;padding:8px;border:0;color:var(--c_txt);margin-bottom:10px;border-radius:0.3em;}"
# ".search-box::placeholder{color:var(--c_frm);}"
"</style>"
"<legend><b title='Running extensions'>&nbsp;Installed extensions</b></legend>")
var installed_ext = self.list_extensions_in_fs(true)
if size(installed_ext) > 0
var ext_nb = 0
while ext_nb < size(installed_ext)
if (ext_nb > 0) webserver.content_send("<hr style='margin:2px 0 0 0;'>") end
var ext_path = installed_ext.get_by_index(ext_nb) # ex: '/.extensions/Partition_Wizard.tapp'
var ext_path_html = webserver.html_escape(ext_path)
var details = tasmota.read_extension_manifest(ext_path)
# log(f"EXT: {details=}")
var running = tasmota._ext ? tasmota._ext.contains(ext_path) : false
var running_indicator = running ? " <span class='running-indicator' title='Running'></span>" : ""
var autorun = details.find("autorun", false)
var back_green = "style='background:var(--c_btnsvhvr);'"
var dark_blue = "style='background:var(--c_btnoff);'"
webserver.content_send("<div class='ext-item'>")
webserver.content_send(f"<span title='path: {ext_path_html}'><b>{webserver.html_escape(details['name'])}</b>{running_indicator}</span><br>")
webserver.content_send(f"<small>{webserver.html_escape(details['description'])}</small>")
webserver.content_send("<div class='ext-controls' style='padding-top:0px;padding-bottom:0px;'>")
webserver.content_send("<form action='/ext' method='post' class='ext-controls'>")
webserver.content_send(f"<button type='submit' class='btn-small' {running ? back_green :: dark_blue} name='{running ? 's' :: 'r'}{ext_path_html}'>{running ? 'Running' :: 'Stopped'}</button>")
webserver.content_send(f"<button type='submit' class='btn-small' {autorun ? '' :: dark_blue} name='{autorun ? 'a' :: 'A'}{ext_path_html}'>Auto-run: {autorun ? 'ON' :: 'OFF'}</button>")
webserver.content_send(f"<button type='submit' class='btn-small bred' name='d{ext_path_html}' onclick='return confirm(\"Confirm deletion of {webserver.html_escape(ext_path)}\")'>Uninstall</button>")
# webserver.content_send(f"<button type='submit' class='btn-small' style='background-color:var(--c_btnoff);border-color:var(--c_btnrst);border-width:3px;border-style:solid;' name='d{ext_path_html}' onclick='return confirm(\"Confirm deletion of {webserver.html_escape(ext_path)}\")'>Uninstall</button>")
webserver.content_send("</form></div></div>")
ext_nb += 1
end
else
# no installed extensions
webserver.content_send("<div><small><i>No installed extension.</i></small></p>")
end
webserver.content_send("<p></p></fieldset><p></p>")
webserver.content_send("<div style='padding:0px 5px;text-align:center;'><h3><hr>Online Store"
"<hr style='margin-bottom:0;'>"
"<span id='inet' style='font-size:small;font-weight:normal;''>&nbsp;(This feature requires an internet connection)</span>"
"</h3></div>")
# "<p><small>&nbsp;(This feature requires an internet connection)</small></p>")
webserver.content_send("<b id='store'>[ <span style='color:var(--c_btnsv);'>Loading from Store...</span> ]</b>")
webserver.content_button(webserver.BUTTON_MANAGEMENT) #- button back to management page -#
webserver.content_stop()
end
#####################################################################################################
# Extension Store
#####################################################################################################
def page_extensions_store()
import webserver
import string
import json
webserver.content_open(200, "text/html")
# read manifest from ota.tasmota.com
var item_jsonl
try
item_jsonl = self.load_manifest()
except .. as e, m
webserver.content_send("<b id='store'>[ <span style='color:var(--c_btnrst);'>Error loading manifest.</span> ]</b>")
webserver.content_send(f"<p><small>{webserver.html_escape(m)}</small></p>")
webserver.content_close()
return
end
var item_json_count = string.count(item_jsonl, '"name":')
webserver.content_send("<fieldset id='store'>")
webserver.content_send(f"<div class='store-header'>"
"<span>Browse Extensions</span>"
"<span class='store-stats'>{item_json_count} available</span>"
"</div>")
webserver.content_send("<input type='text' placeholder='Search extensions...' onkeyup='filterExtensions(this.value)'>"
"<p></p>")
# Read extensions in file system
# as a map tapp_name -> wd_path, ex {'Leds_Panel.tapp': '/.extensions/Leds_Panel.tapp'}
var installed_ext = self.list_installed_ext()
# Now parse application manifests
var item_idx = 1
var json_pos = 0 # starting char to parse JSONL
while (json_pos < size(item_jsonl)) # item_idx negative means that we have nothing more to display
var lf_pos = string.find(item_jsonl, "\n", json_pos)
if (lf_pos < 0) lf_pos = size(item_jsonl) end # go to end of string
# extract the json from the line
var json_line = item_jsonl[json_pos .. lf_pos]
# ex: {"name":"Leds Panel","file":"Leds_Panel.tapp","version":"0x19090100","description":"Real-time display of WS2812 LEDs in browser with smooth animations and pattern editor.","author":"Stephan Hadinger"}
var entry = self.manifest_decode(json_line)
if (entry != nil)
# entry is guaranteed to have the following fields: 'name', 'description', 'file', 'version', 'author'
var app_version = entry['version']
var app_version_web = self.version_string(app_version)
var app_name_web = webserver.html_escape(entry['name'])
var app_file = entry['file']
var app_description_web = string.replace(webserver.html_escape(entry['description']), '\\n', '<br>')
var app_author = entry['author']
# now compute the status
var installed = false
var installed_version
var installed_tapp_name
installed_tapp_name = self.tapp_name(entry['file'])
var installed_tapp_name_web = webserver.html_escape(installed_tapp_name)
installed = installed_ext.contains(installed_tapp_name)
var installed_path_web
if installed
var installed_path = installed_ext[installed_tapp_name]
installed_path_web = webserver.html_escape(installed_path)
var details = tasmota.read_extension_manifest(installed_path)
installed_version = int(details.find('version', 0))
end
# We have 3 options:
# - 'installed == false', only button "Install"
# - 'installed' and 'installed_version < app_version', buttons "Upgrade" and "Delete"
# - else 'installed' and version more recent, 1 button "Delete"
var upgrade = installed && (installed_version < app_version)
webserver.content_send(f"<div class='ext-store-item'>"
"<div class='ext-header' onclick='toggleDesc(\"{item_idx}\")'>"
"<div class='ext-title'>"
"<span class='ext-name'>{app_name_web}</span>"
"<span class='ext-version'><small>{self.version_string(app_version)}</small></span>"
"</div>")
if upgrade
webserver.content_send( "<div class='ext-badges'>"
"<span class='update-badge'>Upgrade</span>"
"</div>")
elif installed
webserver.content_send( "<div class='ext-badges'>"
"<span class='installed-badge'>Installed</span>"
"</div>")
end
webserver.content_send( f"<span id='arrow-{item_idx}' class='ext-arrow'>▶</span>"
"</div>"
"<div id='desc-{item_idx}' class='ext-details'>"
"<div class='ext-desc'>"
"{app_description_web}")
if upgrade
webserver.content_send( f"<br>{self.version_string(installed_version)} → {app_version_web}")
end
webserver.content_send( "</div>"
"<form action='/ext' method='post' class='ext-actions'>"
"<div style='width:30%'></div>")
if installed
if upgrade
webserver.content_send( f"<button type='submit' class='btn-action' name='u{installed_path_web}' onclick='return confirm(\"Confirm upgrade of {installed_path_web}\")'>Upgrade</button>")
else
webserver.content_send( "<button type='submit' class='btn-action' style='visibility:hidden;'></button>")
end
webserver.content_send( f"<button type='submit' class='btn-action bred' name='d{installed_path_web}' onclick='return confirm(\"Confirm deletion of {installed_path_web}\")'>Uninstall</button>")
else
webserver.content_send( f"<button type='submit' class='btn-action' style='visibility:hidden;'></button>"
"<button type='submit' class='btn-action bgrn' name='i{installed_tapp_name_web}' onclick='return confirm(\"Confirm installation of {app_name_web}\")'>Install</button>")
end
webserver.content_send( "</form>"
"</div>"
"</div>")
item_idx += 1
end
json_pos = lf_pos + 1
end
webserver.content_send("<p></p></fieldset><p></p>")
webserver.content_close()
end
#####################################################################################################
# Load manifest from ota.tasmota.com
#####################################################################################################
def load_manifest()
try
var arch = tasmota.arch() # architecture, ex: "esp32" - not used currently but might be useful
var version = f"0x{tasmota.version():08X}"
var url = f"{self.EXT_REPO}{self.EXT_REPO_MANIFEST}?a={arch}&v={version}"
log(f"EXT: fetching extensions manifest '{url}'", 3)
# Add architeture and version information
# They are not used for now but may be interesting in the future to serve
# different content based on architecture (Ex: ESP32) and version (ex: 0x0E060001 for 14.6.0.1)
# load the template
var cl = webclient()
cl.begin(url)
var r = cl.GET()
if r != 200
tasmota.log(f"EXT: error fetching manifest {r}", 2)
raise "webclient_error", f"Error fetching manifest code={r}"
end
var s = cl.get_string()
cl.close()
return s
except .. as e, m
tasmota.log(format("EXT: exception '%s' - '%s'", e, m), 2)
raise e, m
end
end
#####################################################################################################
# Web controller
#
# Applies the changes and restart
#####################################################################################################
# This HTTP POST manager handles the submitted web form data
def page_extensions_ctl()
import webserver
import path
import string
if !webserver.check_privileged_access() return nil end
try
# log(f">>> {webserver.arg_name(0)=} {webserver.arg(0)=} {webserver.arg_size()=}")
# var redirect_to_store = false # add suffix to redirect to store
var btn_name = webserver.arg_name(0)
var action = btn_name[0] # first character
var action_path = btn_name[1..] # remove first character
if (action == "r") # button "Run"
if (action_path != "")
# log(f"EXT: run '{action_path}'")
tasmota.load(action_path)
end
elif (action == "s") # button "Stop"
# log(f"EXT: stop '{action_path}'")
tasmota.unload_extension(action_path)
elif (action == "a") || (action == "A") # button "Autorun", "A" enable, "a" disable
var new_name
if (action == "a") && string.endswith(action_path, ".tapp") # Autorun is enabled, disable it
new_name = action_path[0..-5] + "tapp_"
elif (action == "A") && string.endswith(action_path, ".tapp_")
new_name = action_path[0..-6] + "tapp"
end
if new_name
var success = path.rename(action_path, new_name)
# log(f"EXT: rename '{action_path}' to '{new_name} {success=}", 3)
if (success) # update any running extension with its new name
if tasmota._ext.contains(action_path)
tasmota._ext[new_name] = tasmota._ext[action_path]
tasmota._ext.remove(action_path)
end
end
else
log(f"EXT: wrong action '{btn_name}'", 3)
end
elif (action == 'd') # button "Delete"
if (action_path != "")
# first stop if it was running
tasmota.unload_extension(action_path)
# then delete file
var success = path.remove(action_path)
# log(f"EXT: delete '{action_path}' {success=}", 3)
end
# Now try the store commands
elif (action == 'u') # Upgrade ext
# log(f"EXT: upgrade '{action_path}'", 3)
# first stop the app if it's running
tasmota.unload_extension(action_path)
self.install_from_store(self.tapp_name(action_path))
# redirect_to_store = true
elif (action == 'i') # Install ext
# log(f"EXT: install '{action_path}'", 3)
self.install_from_store(self.tapp_name(action_path))
# redirect_to_store = true
end
# var redirect_suffix = redirect_to_store ? "store=" : ""
# webserver.redirect(f"/ext?{redirect_suffix}")
webserver.redirect(f"/ext")
except .. as e, m
log(f"CFG: Exception> '{e}' - {m}", 2)
#- display error page -#
webserver.content_start("Parameter error") #- title of the web page -#
webserver.content_send_style() #- send standard Tasmota styles -#
webserver.content_send(f"<p style='width:340px;'><b>Exception:</b><br>'{webserver.html_escape(e)}'<br>{webserver.html_escape(m)}</p>")
webserver.content_button(webserver.BUTTON_CONFIGURATION) #- button back to management page -#
webserver.content_stop() #- end of web page -#
end
end
# Add HTTP POST and GET handlers
def web_add_handler()
import webserver
webserver.on('/ext', / -> self.page_extensions_mgr_dispatcher(), webserver.HTTP_GET)
webserver.on('/ext', / -> self.page_extensions_ctl(), webserver.HTTP_POST)
end
end
extension_manager.Extension_manager = Extension_manager
extension_manager.init = def (m)
return m.Extension_manager() # return an instance of this class
end

View File

@ -599,8 +599,8 @@ class Tasmota
# Ex: f_name = '/app.zip#autoexec'
# if ends with ".tapp", add "#autoexec"
# there's a trick here, since actual prefix may be ".tapp" or "._tapp" (the later for no-autp-run)
if string.endswith(f_name, 'tapp')
# prefix may be ".tapp" or ".tapp_"
if string.endswith(f_name, '.tapp') || string.endswith(f_name, '.tapp_')
f_name += "#autoexec"
end
@ -845,6 +845,95 @@ class Tasmota
end
end
######################################################################
# add_extension
#
# Add an instance to the dispatchin of Berry events
#
# Args:
# - `d`: instance (or driver)
# The events will be dispatched to this instance whenever
# it has a method with the same name of the instance
# - `ext_path`: the path of the extension, usually a '.tapp' file
######################################################################
def add_extension(d, ext_path) # add ext
if (ext_path == nil)
ext_path = tasmota.wd
end
if (type(d) != 'instance') || (type(ext_path) != 'string')
raise "value_error", "instance and name required"
end
if (ext_path != nil)
import string
# initialize self._ext if it does not exist
if self._ext == nil
self._ext = sortedmap()
end
if string.endswith(ext_path, '#')
ext_path = ext_path[0..-2] # remove trailing '#''
end
if self._ext.contains(ext_path)
log(f"BRY: Extension '{ext_path}' already registered", 3)
else
self._ext[ext_path] = d
end
end
end
######################################################################
# read_extension_manifest
#
# Read and parse the 'manifest.json' file in the 'wd' (working dir)
#
# Args:
# - `wd`: (string) working dir indicating which .tapp file to read
# ex: 'Partition_Wizard.tapp#'
# Returns: map of values from JSON, or `nil` if an error occured
#
# Returned map is eitner `nil` if failed or a map with guaranteed content:
# - name (string)
# - description (string), default ""
# - version (int), default 0
# - min_tasmota(int), default 0
#
######################################################################
def read_extension_manifest(wd_or_instance)
var f
var wd = wd_or_instance
try
import json
import string
if (wd == nil) wd = tasmota.wd end # if 'wd' is nil, use the current `tasmota.wd`
var delimiter = ((size(wd) > 0) && (wd[-1] != '/') && (wd[-1] != '#')) ? '#' : '' # add '#' delimiter if filename
f = open(wd + delimiter + 'manifest.json')
var s = f.read()
f.close()
var j = json.load(s)
# check if valid, 'name' is mandatory
var name = j.find('name')
if name
# convert version numbers if present
j['name'] = str(j['name'])
j['description'] = str(j.find('description', ''))
j['version'] = int(j.find('version', 0))
j['min_tasmota'] = int(j.find('min_tasmota', 0))
j['autorun'] = string.endswith(wd, ".tapp")
return j
else
return nil
end
except .. as e, m
log(f"BRY: error {e} {m} when reading 'manifest.json' in '{wd}'")
if (f != nil)
f.close()
end
return nil
end
end
def remove_driver(d)
if self._drivers
var idx = self._drivers.find(d)
@ -852,6 +941,31 @@ class Tasmota
self._drivers.pop(idx)
end
end
# remove ext
if self._ext
self._ext.remove_by_value(d)
end
end
def unload_extension(name_or_instance)
if (self._ext == nil) return end
var d = name_or_instance # d = driver
if type(name_or_instance) == 'string'
d = self._ext.find(name_or_instance)
end
if type(d) == 'instance'
import introspect
if introspect.contains(d, "unload")
d.unload()
end
self.remove_driver(d)
end
# force gc of instance
name_or_instance = nil
d = nil
tasmota.gc()
end
# cmd high-level function

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -0,0 +1,9 @@
# rm Leds_Panel.tapp; zip -j -0 Leds_Panel.tapp Leds_Panel/autoexec.be Leds_Panel/leds_panel.be Leds_Panel/manifest.json
do # embed in `do` so we don't add anything to global namespace
import introspect
var leds_panel = introspect.module('leds_panel', true) # load module but don't cache
tasmota.add_extension(leds_panel)
end
# to remove:
# tasmota.unload_extension('Leds Panel')

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
{
"name": "Leds Panel",
"version": "0x19090100",
"description": "Realtime display of the WS2812 leds in browser",
"author": "Stephan Hadinger",
"min_tasmota": "0x0F000100",
"features": ""
}

Binary file not shown.

View File

@ -0,0 +1,9 @@
# rm Partition_Wizard.tapp; zip -j -0 Partition_Wizard.tapp Partition_Wizard/autoexec.be Partition_Wizard/partition_wizard.bec Partition_Wizard/manifest.json
do # embed in `do` so we don't add anything to global namespace
import introspect
var partition_wizard = introspect.module('partition_wizard', true) # load module but don't cache
tasmota.add_extension(partition_wizard)
end
# to remove:
# tasmota.unload_extension('Partition Wizard')

View File

@ -0,0 +1,8 @@
{
"name": "Partition Wizard",
"version": "0x19090100",
"description": "Wizard for resizing partitions and converting to safeboot layout",
"author": "Stephan Hadinger",
"min_tasmota": "0x0F000100",
"features": ""
}

View File

@ -7,8 +7,6 @@
# rm Partition_Wizard.tapp; zip Partition_Wizard.tapp -j -0 Partition_Wizard/autoexec.be Partition_Wizard/partition_wizard.be
#######################################################################
var partition_wizard = module('partition_wizard')
#################################################################################
# Partition_wizard_UI
#
@ -22,6 +20,11 @@ class Partition_wizard_UI
def init()
import persist
tasmota.add_driver(self)
if tasmota.is_network_up()
self.web_add_handler() # if init is called after the network is up, `web_add_handler` event is not fired
end
if persist.find("factory_migrate") == true
# remove marker to avoid bootloop if something goes wrong
tasmota.log("UPL: Resuming after step 1", 2)
@ -30,18 +33,25 @@ class Partition_wizard_UI
# continue the migration process 5 seconds after Wifi is connected
def continue_after_5s()
tasmota.remove_rule("parwiz_5s1") # first remove rule to avoid firing it again at Wifi reconnect
tasmota.remove_rule("parwiz_5s2") # first remove rule to avoid firing it again at Wifi reconnect
tasmota.set_timer(5000, /-> self.do_safeboot_partitioning()) # delay by 5 s
tasmota.set_timer(5000, /-> self.do_safeboot_partitioning(), "partition_wizard_timer") # delay by 5 s
end
tasmota.add_rule("Wifi#Connected=1", continue_after_5s, "parwiz_5s1")
tasmota.add_rule("Wifi#Connected==1", continue_after_5s, "parwiz_5s2")
tasmota.when_network_up(continue_after_5s)
end
end
#- ---------------------------------------------------------------------- -#
# unload
#- ---------------------------------------------------------------------- -#
def unload()
import webserver
webserver.remove_route("/part_wiz", webserver.HTTP_GET)
webserver.remove_route("/part_wiz", webserver.HTTP_POST)
tasmota.remove_driver(self)
tasmota.remove_timer("partition_wizard_timer")
end
# ----------------------------------------------------------------------
# Patch partition core since we can't chang the solidified code
# Patch partition core since we can't change the solidified code
# ----------------------------------------------------------------------
def patch_partition_core(p)
var otadata = p.otadata
@ -881,26 +891,5 @@ class Partition_wizard_UI
webserver.on("/part_wiz", / -> self.page_part_ctl(), webserver.HTTP_POST)
end
end
partition_wizard.Partition_wizard_UI = Partition_wizard_UI
#- create and register driver in Tasmota -#
if tasmota
import partition_core
var partition_wizard_ui = partition_wizard.Partition_wizard_UI()
tasmota.add_driver(partition_wizard_ui)
## can be removed if put in 'autoexec.bat'
partition_wizard_ui.web_add_handler()
end
return partition_wizard
#- Example
import partition
# read
p = partition.Partition()
print(p)
-#
return Partition_wizard_UI()

Binary file not shown.

View File

@ -0,0 +1,6 @@
# rm Wifi_Memory_Sticker.tapp; zip -j -0 Wifi_Memory_Sticker.tapp Wifi_Memory_Sticker/autoexec.be Wifi_Memory_Sticker/wifi_memory_sticker.be Wifi_Memory_Sticker/manifest.json
do # embed in `do` so we don't add anything to global namespace
import introspect
var wifi_memory_sticker = introspect.module('wifi_memory_sticker', true) # load module but don't cache
tasmota.add_extension(wifi_memory_sticker)
end

View File

@ -0,0 +1,8 @@
{
"name": "Wifi Memory Sticker",
"version": "0x19090100",
"description": "Display top left sticker with Wifi strength and memory heap",
"author": "Stephan Hadinger",
"min_tasmota": "0x0F000100",
"features": ""
}

View File

@ -0,0 +1,50 @@
#######################################################################
# Wifi Memory Sticker
#
# Sticker to show realtime wifi strengh and memory (top left of main page)
#################################################################################
# Wifi_Memory_Sticker
#################################################################################
class Wifi_Memory_Sticker
static var HTTP_HEAD_STYLE_WIFI =
"<style>"
".wifi{width:18px;height:18px;position:relative}"
".arc{padding:0;position:absolute;border:2px solid transparent;border-radius:50%;border-top-color:var(--c_frm)}"
".a0{width:2px;height:3px;top:10px;left:11px}"
".a1{width:6px;height:6px;top:7px;left:9px}"
".a2{width:12px;height:12px;top:4px;left:6px}"
".a3{width:18px;height:18px;top:1px;left:3px}"
".arc.active{border-top-color:var(--c_ttl)}"
"</style>"
def init()
tasmota.add_driver(self)
end
def unload()
tasmota.remove_driver(self)
end
#################################################################################
# called when displaying the left status line
def web_status_line_left()
import webserver
# display wifi
if tasmota.wifi('up')
webserver.content_send(self.HTTP_HEAD_STYLE_WIFI)
var rssi = tasmota.wifi('rssi')
webserver.content_send(format("<div class='wifi' title='RSSI %d%%, %d dBm' style='padding:0 2px 0 2px;'><div class='arc a3 %s'></div><div class='arc a2 %s'></div><div class='arc a1 %s'></div><div class='arc a0 active'></div></div>",
tasmota.wifi('quality'), rssi,
rssi >= -55 ? "active" : "",
rssi >= -70 ? "active" : "",
rssi >= -85 ? "active" : ""))
end
# display free heap
webserver.content_send(f"<span>&nbsp;{tasmota.memory('heap_free')}k</span>")
end
end
return Wifi_Memory_Sticker()

View File

@ -0,0 +1,6 @@
# Led panel web app
do # embed in `do` so we don't add anything to global namespace
import introspect
var leds_panel = introspect.module('leds_panel', true) # load module but don't cache
tasmota.add_driver(leds_panel, "leds_panel")
end

View File

@ -705,6 +705,7 @@ class leds_panel
var strip # strip object
var h, w, cell_size, cell_space
static var EXT_NAME = "leds_panel"
static var SAMPLING = 100
static var PORT = 8886 # default port 8886
static var HTML_WIDTH = 290
@ -826,9 +827,25 @@ class leds_panel
self.web.on("/leds_feed", self, self.send_info_feed) # feed with leds values
self.web.on("/leds", self, self.send_info_page) # display leds page
tasmota.add_driver(self)
tasmota.add_driver(self, self.EXT_NAME) # also register as `leds_panel` extension
end
#################################################################################
# unload
#
# Uninstall the extension and deallocate all resources
#################################################################################
def unload()
self.close() # stop server
tasmota.remove_driver(self) # remove driver, normally already done by tasmota.unload_ext
global.undef("webserver_async") # free `webserver_async` if it was loaded as part of this file
end
#################################################################################
# stop
#
# Stop server
#################################################################################
def close()
tasmota.remove_driver(self)
self.web.close()

View File

@ -1220,7 +1220,8 @@
//#define USE_IBEACON_ESP32 // Add support for Bluetooth LE passive scan of iBeacon devices using the internal ESP32 Bluetooth module
//#define USE_WEBCAM // Add support for webcam
#define USE_AUTOCONF // Enable Esp32 autoconf feature, requires USE_BERRY and USE_WEBCLIENT_HTTPS (12KB Flash)
#define USE_AUTOCONF // Enable Esp32(x) autoconf feature, requires USE_BERRY and USE_WEBCLIENT_HTTPS (12KB Flash)
// #define USE_EXTENSION_MANAGER // Enable Esp32(x) extensions manager, requires USE_BERRY and USE_WEBCLIENT_HTTPS (9KB Flash)
#define USE_BERRY // Enable Berry scripting language
#define USE_BERRY_PYTHON_COMPAT // Enable by default `import python_compat`
#define USE_BERRY_TIMEOUT 4000 // Timeout in ms, will raise an exception if running time exceeds this timeout

View File

@ -58,6 +58,10 @@ const char be_berry_init_code[] =
"do import autoconf end "
#endif // USE_AUTOCONF
#ifdef USE_EXTENSION_MANAGER
"do import extension_manager end "
#endif
#ifdef USE_LVGL
"import lv "
"import lv_tasmota "