From 7a06aa2cfe036c7b81d57fbe4d49a187b340a00b Mon Sep 17 00:00:00 2001 From: s-hadinger <49731213+s-hadinger@users.noreply.github.com> Date: Tue, 14 Oct 2025 19:26:45 +0200 Subject: [PATCH] Berry add 'cb.free_cb' for extension manager (#24014) --- CHANGELOG.md | 1 + lib/libesp32/berry_mapping/src/be_cb_module.c | 45 + tasmota/berry/extensions/LVGL_Panel.tapp | Bin 0 -> 38712 bytes .../berry/extensions/LVGL_Panel/autoexec.be | 6 + .../berry/extensions/LVGL_Panel/lvgl_panel.be | 1096 +++++++++++++++++ .../berry/extensions/LVGL_Panel/manifest.json | 8 + 6 files changed, 1156 insertions(+) create mode 100644 tasmota/berry/extensions/LVGL_Panel.tapp create mode 100644 tasmota/berry/extensions/LVGL_Panel/autoexec.be create mode 100644 tasmota/berry/extensions/LVGL_Panel/lvgl_panel.be create mode 100644 tasmota/berry/extensions/LVGL_Panel/manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0d9b219..550940eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ## [15.1.0.1] ### Added - TLS enabled ECDSA by default for ESP8266 (#24009) +- Berry add `cb.free_cb` for extension manager ### Breaking Changed diff --git a/lib/libesp32/berry_mapping/src/be_cb_module.c b/lib/libesp32/berry_mapping/src/be_cb_module.c index f04c162f7..2f485b2e9 100644 --- a/lib/libesp32/berry_mapping/src/be_cb_module.c +++ b/lib/libesp32/berry_mapping/src/be_cb_module.c @@ -343,6 +343,50 @@ static int call_berry_cb(int num, int v0, int v1, int v2, int v3, int v4) { return ret; } +/*********************************************************************************************\ + * Free a cb by function pointer +\*********************************************************************************************/ +static int be_cb_free_cb(bvm *vm) { + int32_t top = be_top(vm); +#if BE_MAPPING_ENABLE_INPUT_VALIDATION + // SECURITY: Input validation + if (top < 1) { + be_raise(vm, "value_error", "gen_cb requires at least 1 argument"); + } + + if (!be_iscomptr(vm, 1)) { + be_raise(vm, "value_error", "arg must be a comptr"); + } +#endif // BE_MAPPING_ENABLE_INPUT_VALIDATION + + void *cb = be_tocomptr(vm, 1); + + // Find slot number + int32_t slot; + for (slot = 0; slot < BE_MAX_CB; slot++) { + if (cb == (void*) berry_callback_array[slot]) { + break; + } + } + + if (slot >= BE_MAX_CB) { + be_raise(vm, "internal_error", "could not find cb"); + } + + // Fix GC object if needed + bvalue *found = &be_cb_hooks[slot].f; + if (be_isgcobj(found)) { + be_gc_fix_set(vm, found->v.gc, bfalse); // mark the function as gc so it can be freed + } + + // Record pointers + be_cb_hooks[slot].vm = NULL; + be_cb_hooks[slot].f.type = 0; + be_cb_hooks[slot].f.v.p = NULL; + + be_return_nil(vm); +} + /*********************************************************************************************\ * `be_cb_deinit`: SECURITY PATCHED * Clean any callback for this VM, they shouldn't call the registered function anymore @@ -406,6 +450,7 @@ void be_cb_deinit(bvm *vm) { /* @const_object_info_begin module cb (scope: global) { gen_cb, func(be_cb_gen_cb) + free_cb, func(be_cb_free_cb) get_cb_list, func(be_cb_get_cb_list) add_handler, func(be_cb_add_handler) diff --git a/tasmota/berry/extensions/LVGL_Panel.tapp b/tasmota/berry/extensions/LVGL_Panel.tapp new file mode 100644 index 0000000000000000000000000000000000000000..5b2fe94ebf5c25f5155754f335d1ba4803c16b9c GIT binary patch literal 38712 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 literal 0 HcmV?d00001 diff --git a/tasmota/berry/extensions/LVGL_Panel/autoexec.be b/tasmota/berry/extensions/LVGL_Panel/autoexec.be new file mode 100644 index 000000000..5c2dd8260 --- /dev/null +++ b/tasmota/berry/extensions/LVGL_Panel/autoexec.be @@ -0,0 +1,6 @@ +# rm LVGL_Panel.tapp; zip -j -0 LVGL_Panel.tapp LVGL_Panel/autoexec.be LVGL_Panel/lvgl_panel.be LVGL_Panel/manifest.json +do # embed in `do` so we don't add anything to global namespace + import introspect + var leds_panel = introspect.module('lvgl_panel', true) # load module but don't cache + tasmota.add_extension(leds_panel) +end diff --git a/tasmota/berry/extensions/LVGL_Panel/lvgl_panel.be b/tasmota/berry/extensions/LVGL_Panel/lvgl_panel.be new file mode 100644 index 000000000..1c37f9296 --- /dev/null +++ b/tasmota/berry/extensions/LVGL_Panel/lvgl_panel.be @@ -0,0 +1,1096 @@ +# +# lvgl_panel.be - implements a real-time mirroring of LVGL display on the main page +# +# Copyright (C) 2025 Stephan Hadinger & Theo Arends +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +# make sure we use `webserver_async` if it's already solidified +if !global.contains("webserver_async") || type(global.webserver_async) != 'class' + class webserver_async + ############################################################# + # class webserver_async_cnx + # + # This instance represents an active connection between + # the server and a client (TCP connection) + ############################################################# + static class webserver_async_cnx + var server # link to server object + var cnx # holds the tcpclientasync instance + var close_after_send # if true, close after we finished sending the out_buffer + var fastloop_cb # cb for fastloop + var buf_in # incoming buffer + var buf_in_offset + var buf_out + var phase # parsing phase: 0/ status line, 1/ headers, 2/ payload + # request + var req_verb # verb for request (we support only GET) + var req_uri # URI for request + var req_version # HTTP version for request + var header_host # 'Host' header - useful for redirections + # response + var resp_headers # (string) aggregate headers + var chunked # if true enable chunked encoding (default true) + # conversion + static var CODE_TO_STRING = { + # 100: "Continue", + 200: "OK", + # 204: "No Content", + 301: "Moved Permanently", + # 400: "Bad Request", + # 401: "Unauthorized", + # 403: "Payment Required", # not sure it's useful in Tasmota context + 404: "Not Found", + 500: "Internal Server Error", + # 501: "Not Implemented" + } + + ############################################################# + # init + # + # Called when a new connection is received from a client + # Arg: + # - server : main instance of `webserver_async` server + # - cnx : instance of `tcpclientasync` + # + # By default: + # version is HTTP/1.1 + # response is chunked-encoded + def init(server, cnx) + self.server = server + self.cnx = cnx + self.buf_in = '' + self.buf_in_offset = 0 + self.buf_out = bytes() + self.phase = 0 # 0 = status line + # util + self.close_after_send = false + # response + self.resp_headers = '' + self.chunked = true + # register cb + self.fastloop_cb = def () self.loop() end # the closure needs to be kept, to allow removal of fast_loop later + tasmota.add_fast_loop(self.fastloop_cb) + end + + ############################################################# + # set_chunked: sets whether the response is chunked encoded + # true by default + # + def set_chunked(chunked) + self.chunked = bool(chunked) + end + + ############################################################# + # connected: returns `true` if the connection is still open + # + def connected() + return self.cnx ? self.cnx.connected() : false + end + + ############################################################# + # buf_out_empty: returns `true` if out buffer is empty, + # i.e. all content was sent to client + # + def buf_out_empty() + return size(self.buf_out) == 0 + end + + ############################################################# + # _write: (internal method) write bytes + # + # Arg: + # v must be bytes() + # + def _write(v) + var sz_v = size(v) + if (sz_v == 0) return end # do nothing if empty content + + var buf_out = self.buf_out # keep a copy of reference in local variable (avoids multiple dereferencing) + var buf_out_sz = size(buf_out) + buf_out.resize(buf_out_sz + sz_v) + buf_out.setbytes(buf_out_sz, v) + + self._send() # try sending `self.buf_out` now + end + + ############################################################# + # close: close the connection to client + # + # Can be called multiple times + # Does nothing if connection is already closed + # + def close() + # log(f"WEB: closing cnx", 3) + if (self.cnx != nil) self.cnx.close() end + self.cnx = nil + end + + ############################################################# + # loop: called by fastloop every 5 ms + # + def loop() + if self.cnx == nil # if connection is closed, this instance is marked for deletion + tasmota.remove_fast_loop(self.fastloop_cb) # remove from fast_loop + self.fastloop_cb = nil # fastloop_cb can be garbage collected + return + end + + self._send() # try sending any pending output data + + var cnx = self.cnx # keep copy + if (cnx == nil) return end # it's possible that it was closed after _send() + + # any new incoming data received? + if cnx.available() > 0 + var buf_in_new = cnx.read() # read bytes() object + if (!self.buf_in) # use the same instance if none present + self.buf_in = buf_in_new + else # or append to current incoming buffer + self.buf_in += buf_in_new + end + end + + # parse incoming data if any + if (self.buf_in) + self.parse() + end + end + + ############################################################# + # _send: (internal method) try sending pendin data out + # + # the content is in `self.buf_out` + # + def _send() + # any data waiting to go out? + var cnx = self.cnx + if (cnx == nil) return end # abort if connection is closed + + var buf_out = self.buf_out # keep reference in local variable + if size(buf_out) > 0 + if cnx.listening() # is the client ready to receive? + var sent = cnx.write(buf_out) # send the buffer, `sent` contains the number of bytes actually sent + if sent > 0 # did we succeed in sending anything? + # we did sent something + if sent >= size(buf_out) # the entire buffer was sent, clear it + # all sent + self.buf_out.clear() + else # buffer was sent partially, remove what was sent from `out_buf` + # remove the first bytes already sent + self.buf_out.setbytes(0, buf_out, sent) # copy to index 0 (start of buffer), content from the same buffer starting at offset 'sent' + self.buf_out.resize(size(buf_out) - sent) # shrink buffer + end + end + end + else + # empty buffer, do the cleaning + # self.buf_out.clear() # TODO not needed? + # self.buf_in_offset = 0 # TODO really useful? + + if self.close_after_send # close connection if we have sent everything + self.close() + end + end + end + + ############################################################# + # parse incoming + # + # pre: self.buf_in is not empty + # post: self.buf_in has made progress (smaller or '') + def parse() + # log(f"WEB: incoming {bytes().fromstring(self.buf_in).tohex()}", 3) + if self.phase == 0 + self.parse_http_req_line() + elif self.phase == 1 + self.parse_http_headers() + elif self.phase == 2 + self.parse_http_payload() + end + end + + ############################################################# + # parse incoming request + # + # pre: self.buf_in is not empty + # post: self.buf_in has made progress (smaller or '') + def parse_http_req_line() + var m = global._re_http_srv.match2(self.buf_in, self.buf_in_offset) + # Ex: "GET / HTTP/1.1\r\n" + if m + var offset = m[0] + self.req_verb = m[1] # GET/POST... + self.req_uri = m[2] # / + self.req_version = m[3] # "1.0" or "1.1" + self.phase = 1 # proceed to parsing headers + self.buf_in = self.buf_in[offset .. ] # remove what we parsed + if tasmota.loglevel(4) + log(f"WEB: HTTP verb: {self.req_verb} URI: '{self.req_uri}' Version:{self.req_version}", 4) + end + self.parse_http_headers() + elif size(self.buf_in) > 100 # if no match and we still have 100 bytes, then it fails + log("WEB: error invalid request", 4) + self.close() + self.buf_in = '' + end + end + + ############################################################# + # parse incoming headers + def parse_http_headers() + while true + # print("parse_http_headers", "self.buf_in_offset=", self.buf_in_offset) + var m = global._re_http_srv_header.match2(self.buf_in, self.buf_in_offset) + # print("m=", m) + # Ex: [32, 'Content-Type', 'application/json'] + if m + self.event_http_header(m[1], m[2]) + self.buf_in_offset += m[0] + else # no more headers + var m2 = global._re_http_srv_body.match2(self.buf_in, self.buf_in_offset) + if m2 + # end of headers + # we keep \r\n which is used by pattern + self.buf_in = self.buf_in[self.buf_in_offset + m2[0] .. ] # truncate + self.buf_in_offset = 0 + + # self.event_http_headers_end() # no more headers + self.phase = 2 + self.parse_http_payload() # continue to parsing payload + end + if size(self.buf_in) > 1024 # we don't accept a single header larger than 1KB + log("WEB: error header is bigger than 1KB", 4) + self.close() + self.buf_in = '' + end + return + end + end + + end + + ############################################################# + # event_http_header: method called for each header received + # + # Default implementation only stores "Host" header + # and ignores all other headers + # + # Args: + # header_key: string + # header_value: string + # + def event_http_header(header_key, header_value) + # log(f"WEB: header key '{header_key}' = '{header_value}'") + + if (header_key == "Host") + self.header_host = header_value + end + end + + ############################################################# + # event_http_headers_end: called afte all headers are received + # + # By default does nothing + # + # def event_http_headers_end() + # end + + ############################################################# + # parse incoming payload (if any) + # + # Calls the server's dispatcher with 'verb' and 'uri' + # + # Payload is in `self.buf_in` + # + def parse_http_payload() + # log(f"WEB: parsing payload '{bytes().fromstring(self.buf_in).tohex()}'") + # dispatch request before parsing payload + self.server.dispatch(self, self.req_uri, self.req_verb) + end + + + ############################################################# + # Responses + ############################################################# + # send_header: add header to the response + # + # Args: + # name: key of header + # value: value of header + # first: if 'true' prepend, or append if 'false' + def send_header(name, value, first) + if first + self.resp_headers = f"{name}: {value}\r\n{self.resp_headers}" + else + self.resp_headers = f"{self.resp_headers}{name}: {value}\r\n" + end + end + + ############################################################# + # send: send response to client + # + # Args + # code: (int) http code (ex: 200) + # content_type: (string, opt) MIME type, "text/html" if not specified + # content: (bytes or string, opt) first content to send to client (you can send more later) + # + def send(code, content_type, content) + var response = f"HTTP/1.1 {code} {self.CODE_TO_STRING.find(code, 'UNKNOWN')}\r\n" + self.send_header("Content-Type", content_type ? content_type : "text/html", true) + + self.send_header("Accept-Ranges", "none") + # chunked encoding? + if self.chunked + self.send_header("Transfer-Encoding", "chunked") + end + # cors? + if self.server.cors + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "*") + self.send_header("Access-Control-Allow-Headers", "*") + end + # others + self.send_header("Connection", "close") + + response += self.resp_headers + response += "\r\n" + self.resp_headers = nil + + # send status-line and headers + self.write_raw(response) + + # send first part of content + if (content) self.write(content) end + end + + ############################################################# + # write: writes a bytes or string piece of content + # + # If chunked encoding is enabled, it is sent as a separate chunk + # + # If content is empty, it can be sent as an empty chunk + # which is an indicator of end-of-content + # + def write(v) + if type(v) == 'string' # if string, convert to bytes + v = bytes().fromstring(v) + end + + # use chunk encoding + if self.chunked + var p1 = self.server.p1 + p1.clear() + p1.append(f"{size(v):X}\r\n") + p1.append(v) + p1.append("\r\n") + + # log(f"WEB: sending chunk '{p1.tohex()}'") + self._write(p1) + else + self._write(v) + end + end + + ############################################################# + # write_raw: low-level write of string or bytes (without chunk encoding) + # + # If content is empty, nothing is sent + # + def write_raw(v) + if (size(v) == 0) return end + + if type(v) == 'string' # if string, convert to bytes + v = bytes().fromstring(v) + end + + self._write(v) + end + + ############################################################# + # content_stop: signal that the response is finished + # + def content_stop() + self.write('') # send 'end-of-content' for chunked encoding + self.close_after_send = true # close connection when everything was sent to client + end + end + + ####################################################################### + # class webserver_async_dispatcher + # + # Pre-register callbacks triggered when a certain URL is accessed + # + # You can register either a pure function or a method and an instance + # + # Pure function: + # webserver_async_dispatcher(uri_prefix, nil, func, verb) + # will call: + # func(cnx, uri, verb) + # + # Instance and method: + # webserver_async_dispatcher(uri_prefix, instance, method, verb) + # will call: + # insatnce.method(cnx, uri, verb) + # + # Args in: + # uri_prefix: prefix string for matchin URI, must start with '/' + # cb_obj: 'nil' for pure function or instance from which we call a method + # cb_mth: pure function or method to call + # verb: verb to match, only supported: 'GET' or 'nil' for any + # + # Args of callback: + # cnx: instance of 'webserver_async_cnx' for the current connection + # uri: full uri of request + # verb: verb received (currently only GET is supported) + ####################################################################### + static class webserver_async_dispatcher + var uri_prefix # prefix string, must start with '/' + var verb # verb to match, or nil for ANY + var cb_obj # callback object (sent as first argument if not 'nil') + var cb_mth # callback function + + def init(uri_prefix, cb_obj, cb_mth, verb) + self.uri_prefix = uri_prefix + self.cb_obj = cb_obj + self.cb_mth = cb_mth + self.verb = verb + end + + # return true if matched + def dispatch(cnx, uri, verb) + import string + if string.find(uri, self.uri_prefix) == 0 + var match = false + if (self.verb == nil) || (self.verb == verb) + # method is valid + var cb_obj = self.cb_obj + if (cb_obj != nil) + self.cb_mth(self.cb_obj, cnx, uri, verb) + else + self.cb_mth(cnx, uri, verb) + end + return true + end + end + return false + end + end + + ############################################################# + # class webserver_async + # + # This is the main class to call + ############################################################# + var local_port # listening port, 80 is already used by Tasmota + var server # instance of `tcpserver` + var fastloop_cb # closure used by fastloop + # var timeout # default timeout for tcp connection + var connections # list of active connections + # var auth # web authentication string (Basic Auth) or `nil`, in format `user:password` as bade64 + # var cmd # GET url command + var dispatchers + # copied in each connection + var chunked # if true enable chunked encoding (default true) + var cors # if true send CORS headers (default false) + # + var p1 # temporary object bytes() to avoid reallocation + + # static var TIMEOUT = 1000 # default timeout: 1000ms + # static var HTTP_REQ = "^(\\w+) (\\S+) HTTP\\/(\\d\\.\\d)\r\n" + # static var HTTP_HEADER_REGEX = "([A-Za-z0-9-]+): (.*?)\r\n" # extract a header with its 2 parts + # static var HTTP_BODY_REGEX = "\r\n" # end of headers + + ############################################################# + # init + def init(port, timeout) + # if (timeout == nil) timeout = self.TIMEOUT end + # if (timeout == nil) timeout = 1000 end + self.connections = [] + self.dispatchers = [] + self.server = tcpserver(port) # throws an exception if port is not available + self.chunked = true + self.cors = false + self.p1 = bytes(100) # reserve 100 bytes by default + # TODO what about max_clients ? + self.compile_re() + # register cb + tasmota.add_driver(self) + self.fastloop_cb = def () self.loop() end + tasmota.add_fast_loop(self.fastloop_cb) + end + + ############################################################# + # compile once for all the regex + def compile_re() + import re + if !global.contains("_re_http_srv") + # global._re_http_srv = re.compile(self.HTTP_REQ) + # global._re_http_srv_header = re.compile(self.HTTP_HEADER_REGEX) + # global._re_http_srv_body = re.compile(self.HTTP_BODY_REGEX) + global._re_http_srv = re.compile("^(\\w+) (\\S+) HTTP\\/(\\d\\.\\d)\r\n") + global._re_http_srv_header = re.compile("([A-Za-z0-9-]+): (.*?)\r\n") + global._re_http_srv_body = re.compile("\r\n") + end + end + + ############################################################# + # enable or disable chunked mode (enabled by default) + def set_chunked(chunked) + self.chunked = bool(chunked) + end + + ############################################################# + # enable or disable CORS mode (enabled by default) + def set_cors(cors) + self.cors = bool(cors) + end + + ############################################################# + # Helper function to encode integer as hex (uppercase) + static def bytes_format_hex(b, i, default) + b.clear() + if (i == nil) b .. default return end + # sanity check + if (i < 0) i = -i end + if (i < 0) return end # special case for MININT + if (i == 0) b.resize(1) b[0] = 0x30 return end # return bytes("30") + + b.resize(8) + var len = 0 + while i > 0 + var digit = i & 0x0F + if (digit < 10) + b[len] = 0x30 + digit + else + b[len] = 0x37 + digit # 0x37 = 0x41 ('A') - 10 + end + len += 1 + i = (i >> 4) + end + # reverse order + b.resize(len) + b.reverse() + end + + ############################################################# + # Helper function to encode integer as int + static def bytes_append_int(b, i, default) + var sz = size(b) + if (i == 0) # just append '0' + b.resize(sz + 1) + b[sz] = 0x30 + elif (i != nil) # we have a non-zero value + var negative = false + # sanity check + if (i < 0) i = -i negative = true end + if (i < 0) return b end # special case for MININT + + if negative + b.resize(sz + 1) + b[sz] = 0x2D + sz += 1 + end + + var start = sz + while i > 0 + var digit = i % 10 + b.resize(sz + 1) + b[sz] = 0x30 + digit + sz += 1 + i = (i / 10) + end + # reverse order starting where the integer is + b.reverse(start) + + else # i is `nil`, append default + b.append(default) + end + return b + end + + ############################################################# + # closing web server + def close() + tasmota.remove_driver(self) + tasmota.remove_fast_loop(self.fastloop_cb) + self.fastloop_cb = nil + self.server.close() + + # close all active connections + for cnx: self.connections + cnx.close() + end + self.connections = nil # and free memory + end + + ############################################################# + # clean connections + # + # Remove any connections that is closed or in error + def clean_connections() + var idx = 0 + while idx < size(self.connections) + var cnx = self.connections[idx] + # remove if not connected + if !cnx.connected() + # log("WEB: does not appear to be connected") + cnx.close() + self.connections.remove(idx) + else + idx += 1 + end + end + end + + ############################################################# + # called by fastloop + def loop() + self.clean_connections() + # check if any incoming connection + while self.server.hasclient() + # retrieve new client + var cnx = self.webserver_async_cnx(self, self.server.acceptasync()) + cnx.set_chunked(self.chunked) + self.connections.push(cnx) + end + end + + ############################################################# + # add to dispatcher + def on(prefix, obj, mth, verb) + var dispatcher = self.webserver_async_dispatcher(prefix, obj, mth, verb) + self.dispatchers.push(dispatcher) + end + + ############################################################# + # add to dispatcher + def dispatch(cnx, uri, verb) + var idx = 0 + while idx < size(self.dispatchers) + if (self.dispatchers[idx].dispatch(cnx, uri, verb)) + return + end + idx += 1 + end + # fallback unsupported request + cnx.send(500, "text/plain") + cnx.write("Unsupported") + cnx.content_stop() + end + + end + + # assign the class to a global + global.webserver_async = webserver_async +end + +class lvgl_panel + var port + var web + var p1 # bytes() object reused when generating payload + var feeders + var _cb # callback to paint + + # static var SAMPLING = 100 + static var PORT = 8881 # default port 8881 + + static var HTML_HEAD1 = + "" + static var HTML_URL_F = + "" + static var HTML_HEAD2 = + '' + '' + '' + '' + static var HTML_CONTENT = + '' + '' + '' + '' + '
' + '' + '
' + static var HTML_END = + '' + '' + + def init(port) + if (port == nil) port = self.PORT end + self.port = port + + self.p1 = bytes(100) + self.feeders = [] + + # start network part + if tasmota.is_network_up() + self.init_network() + else + tasmota.when_network_up(/ -> self.init_network()) + end + end + + def init_network() + self.web = global.webserver_async(self.port) + + self.web.set_chunked(true) + self.web.set_cors(true) + self.web.on("/lvgl_feed", self, self.send_info_feed) # feed with lvgl pixels + self.web.on("/lvgl_touch", self, self.touch_received, "POST") # virtual touch screen + self.web.on("/lvgl", self, self.send_info_page) + + import cb + self._cb = cb.gen_cb(def (x1,y1,x2,y2,pixels) self.paint_cb(x1,y1,x2,y2,pixels) end) + lv.set_paint_cb(self._cb) + + tasmota.add_driver(self) + end + + ################################################################################# + # unload + # + # Uninstall the extension and deallocate all resources + ################################################################################# + def unload() + import introspect + import cb + self.close() # stop server + if (self._cb != nil) + lv.set_paint_cb(introspect.toptr(0)) # remove the paint_cb + cb.free_cb(self._cb) # free the cb + self._cb = nil + end + 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 + + def close() + tasmota.remove_driver(self) + self.web.close() + end + + def update() + end + + def touch_received(cnx, uri, verb) + # log(f">>>TS: touch_received {uri=} {verb=} {cnx.buf_in=}") + cnx.close() + # Example of events: + # {"x":376,"y":258} + import json + import display + var touch = json.load(cnx.buf_in) # POST payload + if (touch == nil) + log(f"LVG: received invalid touch event '{cnx.buf_in}'") + return + end + + if (tasmota.loglevel(4)) + log(f"LVG: received touch event '{touch}'") + end + + display.touch_update(1, touch.find('x', 0), touch.find('y', 0), 0) + end + + def paint_cb(x1,y1,x2,y2,pixels) + if (size(self.feeders) == 0) return end # nothing to do if no feeders + + import introspect + var pixels_count = (x2-x1+1) * (y2-y1+1) + var pixels_bytes = bytes(introspect.toptr(pixels), pixels_count * 2) + #log(f">>>>>: {x1=} {x2=} {y1=} {y2=} {pixels_count=} {size(pixels_bytes)=}") + + var bytes_per_line = (x2 - x1 + 1) * 2 + var lines_remaining = (y2 - y1 + 1) + var lines_per_msg = 2000 / bytes_per_line + var bytes_per_msg = lines_per_msg * bytes_per_line + var y = y1 + var offset_bytes = 0 + + #log(f">>>>>: {x1=} {x2=} {y1=} {y2=} {bytes_per_line=} {lines_per_msg=} {bytes_per_msg=}") + + while lines_remaining > 0 + # compute the workload + # var payload = pixels_bytes[offset_bytes .. offset_bytes + bytes_per_msg - 1].tob64() # string in base64 + + var idx = 0 + var lines_to_send = (lines_per_msg > lines_remaining) ? lines_remaining : lines_per_msg + var bytes_to_send = lines_to_send * bytes_per_line + while idx < size(self.feeders) + self.feeders[idx].send_feed(x1, y, x2, y + lines_to_send - 1, pixels_bytes, offset_bytes, bytes_to_send) + idx += 1 + end + + # move to next message + offset_bytes += bytes_to_send + y += lines_to_send + lines_remaining -= lines_to_send + end + + # log(f">>>: paint {x1=} {y1=} {x2=} {y2=} {pixels_count=}", 2) + end + + def add_feed(feed) + if self.feeders.find(feed) == nil # make sure it's not already in the list + self.feeders.push(feed) + end + # enforce paint_cb the first time and if it changed over time + lv.set_paint_cb(self._cb) + end + + def remove_feed(feed) + var idx = self.feeders.find(feed) + if idx != nil + self.feeders.remove(idx) + end + end + + def send_info_page(cnx, uri, verb) + import string + var height = lv.get_ver_res() + var width = lv.get_hor_res() + + var host = cnx.header_host + var host_split = string.split(host, ':') # need to make it stronger + var ip = host_split[0] + var port = 80 + if size(host_split) > 1 + port = int(host_split[1]) + end + + cnx.send(200, "text/html") + cnx.write(self.HTML_HEAD1) + cnx.write(format(self.HTML_URL_F, ip, port)) + cnx.write(self.HTML_HEAD2) + cnx.write(format(self.HTML_CONTENT, width, height)) + cnx.write(self.HTML_END) + + cnx.content_stop() + end + + static class feeder + var app # overarching app (debug_panel) + var cnx # connection object + var w, h + + def init(app, cnx) + self.app = app + self.cnx = cnx + self.w = lv.get_hor_res() + self.h = lv.get_ver_res() + #tasmota.add_driver(self) + end + + def close() + self.app.remove_feed(self) + end + + # payload is max 16KB + def send_feed(x1, y1, x2, y2, pixels, idx, len) + var cnx = self.cnx + if !cnx.connected() + self.close() + return nil + end + + var server = self.cnx.server + # if cnx.buf_out_empty() + # if out buffer is not empty, do not send any new information + var app = self.app + var p1 = app.p1 + + p1.clear() + p1.append("id:") + server.bytes_append_int(p1, tasmota.millis()) + p1.append("\r\nevent:lvgl\r\ndata:") + + p1.append('{"x1":') + server.bytes_append_int(p1, x1) + p1.append(',"x2":') + server.bytes_append_int(p1, x2) + p1.append(',"y1":') + server.bytes_append_int(p1, y1) + p1.append(',"y2":') + server.bytes_append_int(p1, y2) + p1.append(',"b64":"') + p1.appendb64(pixels, idx, len) + p1.append('"}\r\n\r\n') + cnx.write(p1) + # end + end + + end + + def send_info_feed(cnx, uri, verb) + cnx.set_chunked(false) # no chunking since we use EventSource + cnx.send(200, "text/event-stream") + # + var feed = feeder(self, cnx) + self.add_feed(feed) + lv.scr_act().invalidate() # force a screen redraw for any new connection + end + + def web_add_main_button() + self.send_iframe_code() + end + + def send_iframe_code() + import webserver + self.update() + var ip = tasmota.wifi().find('ip') + if (ip == nil) + ip = tasmota.eth().find('ip') + end + if (ip != nil) + var height = lv.get_ver_res() + 10 + var width = lv.get_hor_res() + 20 + if (width < 340) width = 340 end + webserver.content_send( + f'' + '' + '' + '' + '' + '' + '
' + '
' + '' + '' + '' + '' + '
' + '
' + '' + ) + webserver.content_send( + '' + ) + end + end + +end + +return lvgl_panel() diff --git a/tasmota/berry/extensions/LVGL_Panel/manifest.json b/tasmota/berry/extensions/LVGL_Panel/manifest.json new file mode 100644 index 000000000..751e702e6 --- /dev/null +++ b/tasmota/berry/extensions/LVGL_Panel/manifest.json @@ -0,0 +1,8 @@ +{ + "name": "LVGL Panel", + "version": "0x190A0100", + "description": "Realtime display of the LVGL display in browser", + "author": "Stephan Hadinger", + "min_tasmota": "0x0F010002", + "features": "display" +} \ No newline at end of file