Tasmota/lib/libesp32/berry_tasmota/src/embedded/extension_manager.be

793 lines
34 KiB
Plaintext

# 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/"
var ext_repo
#####################################################################################################
# init - constructor
#
# Register as driver
#
def init()
self.ext_repo = ""
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'
#
# @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'
#####################################################################################################
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'}
#
# Example:
# > extension_manager.list_installed_ext()
# {'Leds_Panel': '/.extensions/Leds_Panel.tapp', 'Partition_Wizard': '/.extensions/Partition_Wizard.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
#
# Example:
# > extension_manager.list_extensions_in_fs()
# {'Leds Panel': '/.extensions/Leds_Panel.tapp', 'Partition Wizard': '/.extensions/Partition_Wizard.tapp_'}
#
# @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
#####################################################################################################
#####################################################################################################
# check_or_create_dir()
#
# Check that directory exists or create it
static def check_or_create_dir(ext_folder)
import path
if path.exists(ext_folder)
if path.isdir(ext_folder)
return # all good, already exists
else
path.remove(ext_folder) # it's a file, remove it
end
end
# `/.extensions/` does not exist
path.mkdir(ext_folder)
# final check
if !path.exists(ext_folder) || !path.isdir(ext_folder)
raise "io_error", f"cannot create folder '{ext_folder}'"
end
# all good, created successfully
end
#####################################################################################################
# run_stop_ext(tapp_fname, run_stop)
#
# @param tapp_fname : string - name of tapp file to install from repository (ex: "Leds_Panel")
# @param run_stop : bool - `true` to run , `false` to stop
# @return bool - `true` if success
static def run_stop_ext(tapp_fname, run_stop)
# sanitize
tapp_fname = _class.tapp_name(tapp_fname)
# get the path for actual file
var tapp_path = _class.list_installed_ext().find(tapp_fname)
if tapp_path != nil
if run_stop
return tasmota.load(tapp_path)
else
return tasmota.unload_extension(tapp_path)
end
else
return false
end
end
#####################################################################################################
# enable_disable_ext(tapp_fname, run_stop)
#
# @param tapp_fname : string - name of tapp file to enable or disable (ex: "Leds_Panel")
# @param enable : bool - `true` to enable , `false` to disable
# @return bool - `true` if success
static def enable_disable_ext(tapp_fname, enable)
import string
# sanitize
tapp_fname = _class.tapp_name(tapp_fname)
# get the path for actual file
var tapp_path = _class.list_installed_ext().find(tapp_fname)
if tapp_path != nil
var new_name
if enable && string.endswith(tapp_path, ".tapp_")
new_name = tapp_path[0..-2] # remove trailing '_'
elif !enable && string.endswith(tapp_path, ".tapp")
new_name = tapp_path + '_' # add trailing '_'
end
if new_name
import path
var success = path.rename(tapp_path, new_name)
if (success) # update any running extension with its new name
if (tasmota._ext != nil) && tasmota._ext.contains(tapp_path)
tasmota._ext[new_name] = tasmota._ext[tapp_path]
tasmota._ext.remove(tapp_path)
end
end
return success
end
end
return false
end
#####################################################################################################
# delete_ext(tapp_fname)
#
# @param tapp_fname : string - name of tapp file to delete from file system (ex: "Leds_Panel")
# @return bool - `true` if success
static def delete_ext(tapp_fname)
# sanitize
tapp_fname = _class.tapp_name(tapp_fname)
# get the path for actual file
var tapp_path = _class.list_installed_ext().find(tapp_fname)
if tapp_path != nil
import path
_class.run_stop_ext(tapp_fname, false) # stop the extension if it's running
var success = path.remove(tapp_path)
return success
else
return false
end
end
#####################################################################################################
# install_from_store(tapp_fname)
#
# @param tapp_fname : string - name of tapp file to install from repository
# @return bool : 'true' if success
def install_from_store(tapp_fname)
import string
import path
# sanitize
tapp_fname = self.tapp_name(tapp_fname) + ".tapp"
# 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
# check if directory exists
self.check_or_create_dir(self.EXT_FOLDER) # raises an exception if failed
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 false
end
var ret = cl.write_file(local_file)
cl.close()
# test if file exists and tell its size
if ret > 0 && path.exists(local_file)
log(f"EXT: successfully installed '{local_file}' {ret} bytes", 3)
return true
else
raise "io_error", f"could not download into '{local_file}' ret={ret}"
end
except .. as e, m
log(format("EXT: exception '%s' - '%s'", e, m), 2)
return false
end
end
#####################################################################################################
# Web handlers
#####################################################################################################
# Displays a "Extension Manager" 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;"
"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;"
"}"
"}"
"};"
"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;}"
".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{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{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 tapp_name = self.tapp_name(ext_path)
var tapp_name_html = webserver.html_escape(tapp_name)
var details = tasmota.read_extension_manifest(ext_path)
var installed_version = int(details.find('version', 0))
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: {tapp_name_html}'><b>{webserver.html_escape(details['name'])}</b>{running_indicator}</span><br>")
webserver.content_send(f"<small>{webserver.html_escape(details['description'])}</small>")
if (installed_version > 0)
webserver.content_send(f"<small>{self.version_string(installed_version)}</small>")
end
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'}{tapp_name_html}'>{running ? 'Running' :: 'Stopped'}</button>")
webserver.content_send(f"<button type='submit' class='btn-small' {autorun ? '' :: dark_blue} name='{autorun ? 'a' :: 'A'}{tapp_name_html}'>Auto-run: {autorun ? 'ON' :: 'OFF'}</button>")
webserver.content_send(f"<button type='submit' class='btn-small bred' name='d{tapp_name_html}' onclick='return confirm(\"Confirm deletion of {tapp_name_html}.tapp\")'>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>")
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)
if installed
var installed_path = installed_ext[installed_tapp_name]
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_tapp_name_web}' onclick='return confirm(\"Confirm upgrade of {installed_tapp_name_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_tapp_name_web}' onclick='return confirm(\"Confirm deletion of {installed_tapp_name_web}\")'>Uninstall</button>")
else
webserver.content_send( f"<button type='submit' class='btn-action' name='i{installed_tapp_name_web}' onclick='return confirm(\"Confirm installation of {app_name_web}\")'>Install</button>"
"<button type='submit' class='btn-action bgrn' name='I{installed_tapp_name_web}' onclick='return confirm(\"Confirm installation of {app_name_web}\")'>Install+Run</button>")
end
webserver.content_send( "</form>"
"</div>"
"</div>")
item_idx += 1
end
json_pos = lf_pos + 1
end
webserver.content_send("<p></p>"
"<hr style='margin:2px 0 0 0;'>"
"<p></p>")
webserver.content_send(f"<form action='/ext' method='post'>"
"<input type='text' id='x' name='x' placeholder='0 = User, 1 = Global' value='{self.ext_repo}'>"
"</form>")
webserver.content_send("<p></p></fieldset><p></p>")
webserver.content_close()
end
#####################################################################################################
# Load manifest from ota.tasmota.com
#####################################################################################################
def load_manifest()
try
import string
var arch = tasmota.arch() # architecture, ex: "esp32" - not used currently but might be useful
var version = f"0x{tasmota.version():08X}"
if !self.ext_repo
self.ext_repo = self.EXT_REPO # Default
var ota_url = tasmota.cmd("OtaUrl", true)['OtaUrl']
if size(ota_url) > 0
var url_parts = string.split(ota_url, "/")
if url_parts.size() > 2
self.ext_repo = f"{url_parts[0]}//{url_parts[2]}/extensions/" # http://otaserver/extensions/
end
end
end
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 extensions manifest
var cl = webclient()
cl.begin(url)
var r = cl.GET()
if r != 200
if self.EXT_REPO != self.ext_repo
if cl.get_size() < 0 # Happens on https://github.com/extensions/extensions.jsonl where 404 page is a lot of data
cl.deinit()
cl = webclient()
else
cl.close() # Fails to close if cl.get_size() < 0
end
self.ext_repo = self.EXT_REPO
url = f"{self.ext_repo}{self.EXT_REPO_MANIFEST}?a={arch}&v={version}"
log(f"EXT: fetching extensions manifest '{url}'", 3)
cl.begin(url)
r = cl.GET()
end
if r != 200
log(f"EXT: error fetching manifest {r}", 2)
raise "webclient_error", f"Error fetching manifest code={r}"
end
end
var s = cl.get_string()
cl.close()
return s
except .. as e, m
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 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") || (action == "s") # button "Run" or "Stop"
self.run_stop_ext(action_path, action == "r")
elif (action == "a") || (action == "A") # button "Autorun", "A" enable, "a" disable
self.enable_disable_ext(action_path, action == "A")
elif (action == 'd') # button "Delete"
self.delete_ext(action_path)
# Now try the store commands
elif (action == 'u') # Upgrade ext
# first stop the app if it's running
self.run_stop_ext(action_path, false) # stop the extension
var success = self.install_from_store(self.tapp_name(action_path))
elif (action == 'i') || (action == 'I') # Install ext ('I' for run as well)
var success = self.install_from_store(self.tapp_name(action_path))
if success
if (action == 'I') # run
self.run_stop_ext(action_path, true)
else # disable
self.enable_disable_ext(action_path, false)
end
end
# And finally try the provided repository website
elif (action == 'x') # User input repository website
var url = webserver.arg(0)
self.ext_repo = "" # Use OtaUrl or default
if size(url) > 0 # Input validation
if url == "0" # Reset to use OtaUrl or default
elif url == "1" # Default repository
self.ext_repo = self.EXT_REPO
else # Process user input
var url_parts = string.split(url, "/")
if url_parts.size() > 2 # http: / / server / extensions
var is_httpx = url_parts[0] == "http:" || url_parts[0] == "https:"
var is_delim = url[-1] == '/'
var is_extensions = url_parts[(is_delim)?-2:-1] == "extensions"
if is_httpx && url_parts[1] == "" && is_extensions
self.ext_repo = url
if !is_delim
self.ext_repo += '/'
end
end
end
end
end
else
log(f"EXT: wrong action '{btn_name}'", 3)
end
webserver.redirect(f"/ext")
except .. as e, m
log(f"EXT: 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_MANAGEMENT) #- 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