From 7a4d8a5fb128c546bea742f32721c7bad6e61957 Mon Sep 17 00:00:00 2001 From: s-hadinger <49731213+s-hadinger@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:45:45 +0100 Subject: [PATCH] LVGL experimental mirroring of display on Web UI (#23041) --- CHANGELOG.md | 1 + tasmota/berry/lvgl_panel/lvgl_panel.be | 1036 +++++++++++++++++ tasmota/berry/lvgl_panel/lvgl_panel.bec | Bin 0 -> 13786 bytes tasmota/my_user_config.h | 2 + .../xdrv_52_0_berry_struct.ino | 15 +- tasmota/tasmota_xdrv_driver/xdrv_54_lvgl.ino | 4 + 6 files changed, 1057 insertions(+), 1 deletion(-) create mode 100644 tasmota/berry/lvgl_panel/lvgl_panel.be create mode 100644 tasmota/berry/lvgl_panel/lvgl_panel.bec diff --git a/CHANGELOG.md b/CHANGELOG.md index deb32f4fd..a6165401d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ## [14.5.0.1] ### Added - Berry experimental driver for AXP2101 for M5Core2v1.1 +- LVGL experimental mirroring of display on Web UI ### Breaking Changed diff --git a/tasmota/berry/lvgl_panel/lvgl_panel.be b/tasmota/berry/lvgl_panel/lvgl_panel.be new file mode 100644 index 000000000..fe46c942c --- /dev/null +++ b/tasmota/berry/lvgl_panel/lvgl_panel.be @@ -0,0 +1,1036 @@ +# +# 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 + + # 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.web = global.webserver_async(port) + + self.p1 = bytes(100) + self.feeders = [] + + 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) # display lvgl page + + import cb + var paint_cb = cb.gen_cb(def (x1,y1,x2,y2,pixels) self.paint_cb(x1,y1,x2,y2,pixels) end) + lv.set_paint_cb(paint_cb) + + tasmota.add_driver(self) + 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 teh list + self.feeders.push(feed) + end + 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'' + '' + '' + '' + '' + '' + '
' + '
 LVGL screen mirroring ' + '' + '
' + '
' + ) + end + end + +end + +return lvgl_panel() diff --git a/tasmota/berry/lvgl_panel/lvgl_panel.bec b/tasmota/berry/lvgl_panel/lvgl_panel.bec new file mode 100644 index 0000000000000000000000000000000000000000..d820f8609ec0d25ad5232cae165a172a0e95c3d6 GIT binary patch literal 13786 zcmb_jOLH69bw2$-qX9O-CJAw94J@}|NCGwBOCwDbzC1IMY>iDztP#hiV@x0rXp)dY z06{lJz=A4Vc)<*+BOo_ZwkuPb{x;Y!_&m`k9a2V z)Y8dwbw)k6$l9^+o_1Y5=Wmd1oKFz#P)7YnJb#bp#qYAlw&JMgQ{Pk1)3??0nGe+S z*+upI`iJWI`=sx@4?5)mzfZqqdOUlW)5*6{unpR;H+Rc*+oVdf;bXC$JweCruIIL2 zx$RQf>ozKOj4JiA=S^PfF)_wC{*966`lZUjQRA0xb*Mt5>MFC3-Xrq`saBCxv!{}n z&xyiwh*R()Dn!$xUoTY}9ir=(nyWRhRrV_fZks5H*&nuQb+^=Zqh%t+P-#XCY`h~i zd7;M4GmUKQp4m%L(&si^v(93aMw`{sjO2OP77yF3L0ckhv(ZL$lEja#Qro?fl@?(_ zr32q@mAv*VUa6d>{cZZ|eDU~$IVWFyj^8KS+gruOe6d{|CjS$elWM<)ZcnrA4NCuyjnOgP zwkJl=sore1hz;q*ViuNe_5KITjzde){|{*Y zofyNC+QHg|`C=J|O}}i9L&^@DuiR3#U1L7jVSTUc`6b>TEQ!lpELC;~91B)5vf>nZ zJ_u{R+b%PU#R_fO?fR})+H1BC%RU6JbGd~`Gj+Nv=&c=WvfN;@H+9v{C^Xek$^IBp zccN-`no!5;9`(zzJrhH_hIe(Eh3474Mc71LSAWCraeJJuZ*x1w+EKN1Zh~eyDVo`i zsp&!jeMWLNtgi$98Kv>Z-M+nY_h#Ce`z6c&~!Rvp8tj`nV!RgY48rB91bpF<}c6wSKLo@gCa8 z(4JJ%5Ib&%VaFmY{ETez7!9oCkOujcR-jFN>uXDOht+5;+fgGL5y2#ttJP5chJ|fh zboE7%xi&_na--Sk9yX7>66z{*PW&AZoG3d>)T9##O$)RQuU?`5S2RTo5j4nxe^FQy z*}te}dYqCS1)8Vm=36z;HiJ^W7L=J*XZETgd({@Smi{9!L@X1$icy{>LbKskg~Y&8 z2w`E>6vD#qP^dNKR?BTvOEuW4K(hud5S=ld?hAVM^Y#m|)i9QdmWF=JbX3TwmdJo_ zY3K)JA2NO$G7h>;=z;TH(4f4fXtQYry1;sd^@uurCi?UfInFG`aZsJ@6<8lIjt1Qz zbGE2xncNuN=p^XIc3jQas^Wk)4_XJ`%x=eRhs=OJfx4;c)_;RXx{^5~D+kfcus?4J zebS|F@ppwzNd|t6E`e>58N`1oH5>d7_IFH$?BGvmD39nA$-tM)e36oAxSQEib$s@W zl4!?@KzX9!$Nnae0L_T%Ld4vg{fT$}z?4^4q%Apxz-xrEpbh3ka zNo%CT6*j6+Qs3?Pi>*3f8$|KA4O5O~e$nubTCHZ=hk#lvtUf$tiEX`DP7!2a#)CAfnI3N{n`Q0~ zZ3~yp068YuyzA2Du-+t;0SLm>B%;K|`0ZL_AA7vlZXO1eKK}iPY&C{Mfr;w%)lyIXQg2T zM1rwWGNG}5OpFRB{c6hG_6A-5_p8ojh zv!6YM-ZSsQNWr3qQt#)tyDb-xSy+k#|F8}i`ALPr@%-oI#=h%eB=~3-BLz_y!1s2$ z-0=3?_Wa{UrCDV$3=KsH65bbsc-}mJrroU1e^ReEkLRDYYx}T83f-|YS5y4N^$(g= z@9Vmsh(O1K*kj-T`uDx8#p2Tnmgq_8PDry?YL|~g69^WvD2+li4pc?a zgoIuYeF3{-69FW%(Laqc!!(9l47(7E(`@Qu9C(36BnrFO)(!Zh5eFcK5P5I{UE@Ky z)ZE*H)Q{>!j9Lh1Tx_}sI!fg|;Blc6Fy2G!9qiDMxsJfIKQjgc;)UZ7(W?wVC+SiX zFbv{*0qZnrG?>3&$qCRNL0q0RJs-j;2Phq&hIY2hBQchy!einO_8(SwQ&n|GMJU<# z2Z*1rtDC%oppv6YX7}SXv=3>)Pz5i;_)mf+4!vWWqOd`RO#y%@lH$P4z>Z*e1Ga=QXr(3?2(&a0@Hr zx`DbX!Y-`LfbGW0xIK<`i)Qg%w5zjFi&>pRFhn#h6MjI!cZK`H958d~s^lj0jl7ia z1l4OEa0lCt5SfHtDq{Tm4&e&vk8W!Ob4QBc9i)b%WM-mvNm>{u>s!FYQE3@^A8$hc z7@`CZIpkC@B!EbUg-{Lh97*s&d@5*1ppX3!fxT!)_<-=&=ompehB;wS0~QD+m%N#d zf+B`1^;#ncGl#H|R2feZiZs*{ASs?Djd}(wc{o=C^~^D0U(WQ5!K_lRPF|1-pcT%) z|IR=*Ifug`mJu^^Cge39)C2kORhUnPHC$H>i!{_t4#Pkzp^kZ3cES|LpG;{4>H~*X zepRm3%e!?~GF6AIELR7Hx4)lQxwOClASL&(<#$J_a?2U6SfsxoOqI8kx4thq!@|wX z6o%%{l<^$e+F;YKYQsd4X}HHnx<68PH zaw9-U$hT?MTTM5yed_slSe7YykpQF>Nm0BDgoF3ZUJ`qyV>~lvztypa$+-0~QZlES ziT9Dz6WY% zYfz|tJ4Ziz{OOA0w%g6NBe%(^dCqQaf8T98{z19nEdTgZj3gYJ&D(^(2tJ7vv4hAd zG9np<-2*j?Mkt6#h9VT+E|E||+LR*{L?(keOb&Zvx!$Y8hynN)6NpBLqw%5w>mdVq z{Y=4Ogh+Ej`p_=iu<(PSfyX9Zw&VB&$AXbKFf@5rHe%**j54=)wQy%xxHS`k4PivO zEWuA0nK6C>uf@Nmo{z_d1(q_YiwR)2P!LTPPA7H2ZhjJO-hi7o5n3bz7!cA;^#xpQ z@lJ_MFN|M>Qrl1@2ss?Klf1yW4ul*jJnTqX?1tI9#zKzW;M0eJaF3h_{7I>n`#1<| zWdfid@Ji<}2ID89UJq$pCD;?`afEaaa>yo&avge752X6Glxhw*91fcqz&Kxg{@IFi z`=)o>`6+Uq7;^=PW=J&xGlh&xL@8>GSLJ%G>R>WQE>PcyDugkF1Pqtp?hvRA0rZHD z_YHC2PzQYJb7Y7YL%4Got_{$%O3!`_F6`iXClH@Gh39>Pxw2O)Fr0r+m;{{O%f6?rB>VBt93%3%n}9J zuk4nZyDw$o&_CeC0Hs6&v>42x%oqd#8tjV>PXj_>&VZ)}qhB5oaYjGq6Qz{R;RM7J z-~Z-3A@XCd_K_Zg6x1i972YRChGK_HoCxQ`;nMDr?>8G)Ho}+zpt3#!eSxbojm6A? zp%meIZLeKEbaBpAb(soiPBu68nx#G0t&Y~gHxqFQlWI72q1i3Q=L*VyKw1~aRLF?n ztc`3ind1PCZh+q-SqCy_E@C2Qi9BTR#AtF)hmXpFG~=X99th_;k4%(XK12eC*UvLV z7+&X-O|OE};);)r3d}&+7fVtUC5e2-Pc}=j6M2)6;8u*Bu1)7CHk~t7Gp}pc^7L=P`$Ae2$ z0ZI~)M4XuEu*y=!nA^y|crBcgO4kr0Bzpa}bPNe8PW*~s>Sd|B+=Bf`^*!)Agrb-@ z{mssp%F%3V2jKbzz7T6|2W5E8&?sASDM5=rinbt8*u<5rhtm)j@I2BONVSf2y zN-4lB!jzf!iev?uSjZG^N)U{B|D+i9LyBM|b)wU8ZLbD9BGPIq)mm_n$W=-49;1ES zZ-9xxalVIa2GSap8*4tF%;V9< zzv{-?Ud^pnkrfK5cgvMu?zh?F%vYNAW*d%|->SR&7&|25T;4o9?S5mW?(X>@xYgM8 zTC1Bs{o$qqJ#^iMb68{dgz#H-tu4wSHrAxYacb4|tgv&ko^`x-Wj)Keo|VN#Bra}x zi`S3QY(ofFmRcQ$vxln(Zf*a-$6Kw=s$8)s9+un42MV$WAZ2nq zS6eV1n3`xu8?SIt&I5&zB`7k77MWFx0oxV@Yd2tiu$f{?;eiOBW(X0F7#pC-VK0NY zhgJj?Czt}TjTl$ql$C*tAQ51VYb!XJHE}YV!pSU-6me2Jg*yCnPX+AcV}ZD&hYdy> z$5$hD#8${I zew+)wi+et}6#}|u1IAuRiF5>`z6$TkUcY%%IRH`;PiRoZ#r=gUX0-L}`8H6ZphEw^ z^|i|TF1}C^Gj(wfWLN5;CB7dzhKTw`52o2NjvPJ5U|{%g4GcUWc6!7KE+oEm038EB z$Anw3$4&E$&-tpI58(RLC}5OAOzMAJ4wEp$6B|GM09U7%9;{(pTqLNMyD)8h(hd3LlTRE2$qdphf`?u~ zcq|Q|6UNJeFOI<1BI=HqiAcPDW902iZ&?9@CAJA=Uf>ypf{;w1E;AS)T*64UYwQ<< z`C-!^6)WL4hE(HyTKn#!XAif3zV+DQz1>*jBSrio_qq}4id38jv<4_rYxx_m%5Awg zoY2?TZ$~gjZQwm_udXcy^x-&C=QfS#I9~IpU2&atr{NwukNJG+xoF7??(qlaR*&oG z-*eVCoMRl3HjfvedxCUR97(wCe3o}7TX6Q~R^43?drVwyR*nua-~zzio?mgeBG=YP?b^f@J zjE7vWH4xo;!5FLTbGD!7CFMe=5EQytIQ~1X=U-Om1{{cRiuv?fex`N*qxUsnjiZYfNy?~GcOZqJA&x=nN z`euxaoCw_h@{9J1}=>A~&YY3X&dhO2LRm|%bt)}5QV>yzCDS!?O?>mPo( ze)s-;Xhp~$p@<7W%6WYJlGrY^j{E^D`!5xun-{5cmmwyh<~!dYX@RW3yZF%!WX<@L z14;y355=YF&p`nRZoB?byYafSA|+g2sNR|9_pu*)exW_gRyHwFp-?xcqt$dd=2+&|OTN9%(a zcQrE9{(9KJuGk z1Fq5_q82S2AIqV?IVcVRCt|6!Mahpv;h-l&f6q|}!^cQsC{hGaMM&|O0Rl42$|Uk- zY7VFWw|rHRWun|BP3b434QFdw9g#$O$L2aJm)0l$011y zA&DvHHXZb*CxiLp`{2k4AJ+lNRdBy2a*3c10>k4CkU*Mc9L@3E(8WBBYSk4af8{0n z!ByZ0(LV_PART_MAIN) | static_cast(LV_STATE_DEFAULT)); lv_obj_set_style_bg_opa(lv_screen_active(), LV_OPA_COVER, static_cast(LV_PART_MAIN) | static_cast(LV_STATE_DEFAULT)); +#ifdef USE_BERRY_LVGL_PANEL + berry.lvgl_panel_loaded = false; // we can load the panel +#endif // USE_BERRY_LVGL_PANEL + #if LV_USE_LOG lv_log_register_print_cb(lvbe_debug); #endif // LV_USE_LOG