diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31eafdda3..8af4e0a94 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file.
- ESP32 expand `Pixels` with reverse, height and alternate (#22755)
- Berry add light_pixels values to `tasmota.settings` (#22762)
- Berry add `bytes().appendhex()` (#22767)
+- WS2812 real-time Leds panel as Berry app
### Breaking Changed
diff --git a/tasmota/berry/leds_panel/leds_panel.be b/tasmota/berry/leds_panel/leds_panel.be
new file mode 100644
index 000000000..f0e0b2aff
--- /dev/null
+++ b/tasmota/berry/leds_panel/leds_panel.be
@@ -0,0 +1,964 @@
+#
+# leds_panel.be - implements a real-time mirroring of the WS2812 leds on the main page
+#
+# Copyright (C) 2023 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 leds_panel
+ var port
+ var web
+ var sampling_interval
+ var p1, p_leds
+ var strip # strip object
+ var h, w, cell_size, cell_space
+
+ static var SAMPLING = 100
+ static var PORT = 8887 # default port 8886
+ static var HTML_HEAD1 =
+ "
"
+ static var HTML_URL_F =
+ ""
+ static var HTML_HEAD2 =
+ ''
+ ''
+ ''
+ ''
+ static var HTML_CONTENT =
+ '
'
+ ''
+ '
'
+ ''
+ '
'
+ ''
+ '
'
+ static var HTML_END =
+ ''
+ ''
+
+ def init(port)
+ import gpio
+ if !gpio.pin_used(gpio.WS2812, 0)
+ log("LED: no leds configured, can't start leds_panel", 3)
+ return
+ end
+ if (port == nil) port = 8886 end
+ self.port = port
+ self.web = global.webserver_async(port)
+ self.sampling_interval = self.SAMPLING
+
+ self.strip = Leds()
+ self.p1 = bytes(100)
+ self.p_leds = self.strip.pixels_buffer()
+
+ self.web.set_chunked(true)
+ self.web.set_cors(true)
+ self.web.on("/leds_feed", self, self.send_info_feed) # feed with leds values
+ self.web.on("/leds", self, self.send_info_page) # display leds page
+
+ tasmota.add_driver(self)
+ end
+
+ def close()
+ tasmota.remove_driver(self)
+ self.web.close()
+ end
+
+ def update()
+ self.p_leds = self.strip.pixels_buffer(self.p_leds) # update buffer
+ self.h = tasmota.settings.light_pixels_height_1 + 1
+ self.w = self.strip.pixel_count() / (tasmota.settings.light_pixels_height_1 + 1)
+ self.cell_size = tasmota.int(330 / self.w, 4, 25)
+ self.cell_space = (self.cell_size <= 6) ? 1 : 2
+ end
+
+ def send_info_page(cnx, uri, verb)
+ import string
+
+ self.update()
+ 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(self.HTML_CONTENT)
+ cnx.write(self.HTML_END)
+
+ cnx.content_stop()
+ end
+
+ static class feeder
+ var app # overarching app (debug_panel)
+ var cnx # connection object
+
+ def init(app, cnx)
+ self.app = app
+ self.cnx = cnx
+ tasmota.add_driver(self)
+ end
+
+ def close()
+ tasmota.remove_driver(self)
+ end
+
+ def every_100ms()
+ self.send_feed()
+ end
+
+ def send_feed()
+ var cnx = self.cnx
+ if !cnx.connected()
+ self.close()
+ return
+ 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
+ app.update()
+ var payload1 = app.p1
+ var p_leds = app.p_leds
+
+ payload1.clear()
+ payload1 .. "id:"
+ server.bytes_append_int(payload1, tasmota.millis())
+ payload1 .. "\r\nevent:leds\r\ndata:"
+
+ payload1 .. '{"w":'
+ server.bytes_append_int(payload1, app.w)
+ payload1 .. ',"h":'
+ server.bytes_append_int(payload1, app.h)
+ payload1 .. ',"clsz":'
+ server.bytes_append_int(payload1, app.cell_size)
+ payload1 .. ',"clsp":'
+ server.bytes_append_int(payload1, app.cell_space)
+ payload1 .. ',"rev":'
+ server.bytes_append_int(payload1, tasmota.settings.light_pixels_reverse)
+ payload1 .. ',"alt":'
+ server.bytes_append_int(payload1, tasmota.settings.light_pixels_alternate)
+ payload1 .. ',"hex":"'
+ payload1.appendhex(p_leds)
+ payload1 .. '"}\r\n\r\n'
+ cnx.write(payload1)
+ 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)
+ feed.send_feed() # send first values immediately
+ 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 = self.h * self.cell_size + 10
+ webserver.content_send(
+ f'
'
+ ''
+ ''
+ ''
+ ''
+ '
'
+ )
+ end
+ end
+
+end
+
+return leds_panel()
diff --git a/tasmota/berry/leds_panel/leds_panel.bec b/tasmota/berry/leds_panel/leds_panel.bec
new file mode 100644
index 000000000..861a83c7f
Binary files /dev/null and b/tasmota/berry/leds_panel/leds_panel.bec differ
diff --git a/tasmota/my_user_config.h b/tasmota/my_user_config.h
index acfcbe62c..920ea0f15 100644
--- a/tasmota/my_user_config.h
+++ b/tasmota/my_user_config.h
@@ -1200,6 +1200,8 @@
// Note that only one cipher is enabled: ECDHE_RSA_WITH_AES_128_GCM_SHA256 which is very commonly used and highly secure
#define USE_BERRY_WEBCLIENT_USERAGENT "TasmotaClient" // default user-agent used, can be changed with `wc.set_useragent()`
#define USE_BERRY_WEBCLIENT_TIMEOUT 2000 // Default timeout in milliseconds
+ #define USE_BERRY_LEDS_PANEL // Add button to dynamically load the Leds Panel from a bec file online
+ #define USE_BERRY_LEDS_PANEL_URL "http://ota.tasmota.com/tapp/leds_panel.bec"
//#define USE_BERRY_PARTITION_WIZARD // Add a button to dynamically load the Partion Wizard from a bec file online (+1.3KB Flash)
#define USE_BERRY_PARTITION_WIZARD_URL "http://ota.tasmota.com/tapp/partition_wizard.bec"
//#define USE_BERRY_GPIOVIEWER // Add a button to dynamocally load the GPIO Viewer from a bec file online
diff --git a/tasmota/tasmota_xdrv_driver/xdrv_52_0_berry_struct.ino b/tasmota/tasmota_xdrv_driver/xdrv_52_0_berry_struct.ino
index 86098f79f..ad1fd1f8f 100644
--- a/tasmota/tasmota_xdrv_driver/xdrv_52_0_berry_struct.ino
+++ b/tasmota/tasmota_xdrv_driver/xdrv_52_0_berry_struct.ino
@@ -93,6 +93,9 @@ public:
int32_t timeout = 0; // Berry heartbeat timeout, preventing code to run for too long. `0` means not enabled
bool rules_busy = false; // are we already processing rules, avoid infinite loop
bool web_add_handler_done = false; // did we already sent `web_add_handler` event
+#ifdef USE_BERRY_LEDS_PANEL
+ bool leds_panel_loaded = false; // did we already load Parition_Wizard
+#endif // USE_BERRY_LEDS_PANEL
#ifdef USE_BERRY_PARTITION_WIZARD
bool partition_wizard_loaded = false; // did we already load Parition_Wizard
#endif // USE_BERRY_PARTITION_WIZARD
@@ -124,6 +127,16 @@ struct BeBECCode_t {
};
const BeBECCode_t BECCode[] = {
+#ifdef USE_BERRY_LEDS_PANEL
+ {
+ "Leds Panel",
+ "leds_panel",
+ USE_BERRY_LEDS_PANEL_URL,
+ "/?",
+ &berry.leds_panel_loaded
+ },
+#endif // USE_BERRY_LEDS_PANEL
+
#ifdef USE_BERRY_PARTITION_WIZARD
{
"Partition Wizard",
diff --git a/tasmota/tasmota_xdrv_driver/xdrv_52_9_berry.ino b/tasmota/tasmota_xdrv_driver/xdrv_52_9_berry.ino
index c5bad3c9e..66bbf3436 100644
--- a/tasmota/tasmota_xdrv_driver/xdrv_52_9_berry.ino
+++ b/tasmota/tasmota_xdrv_driver/xdrv_52_9_berry.ino
@@ -384,6 +384,12 @@ void BerryInit(void) {
AddLog(LOG_LEVEL_INFO, PSTR(D_LOG_BERRY "Berry initialized, RAM used %u bytes"), callBerryGC());
berry_init_ok = true;
+ // reinit some specific information
+#ifdef USE_BERRY_LEDS_PANEL
+ // if no WS2812 configured, hide "Leds Panel" download button
+ berry.leds_panel_loaded = !PinUsed(GPIO_WS2812);
+#endif // USE_BERRY_LEDS_PANEL
+
// we generate a synthetic event `autoexec`
callBerryEventDispatcher(PSTR("preinit"), nullptr, 0, nullptr);