diff --git a/CHANGELOG.md b/CHANGELOG.md
index 654c974aa343..66b447fad6de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file.
- Berry `gpio.read_pwm` and `gpio.read_pwm_resolution`
- Berry `gpio.get_pin_type` and `gpio.ger_pin_type_index`
- Berry `gpio.read_pwm` and `gpio.read_pwm_resolution` (#20414)
+- GPIO viewer in Berry initial version using async webserver
### Breaking Changed
diff --git a/tasmota/berry/gpio_viewer/gpioviewer.bec b/tasmota/berry/gpio_viewer/gpioviewer.bec
new file mode 100644
index 000000000000..776f65e90016
Binary files /dev/null and b/tasmota/berry/gpio_viewer/gpioviewer.bec differ
diff --git a/tasmota/berry/gpio_viewer/webserver_async.be b/tasmota/berry/gpio_viewer/webserver_async.be
new file mode 100644
index 000000000000..61d9972747af
--- /dev/null
+++ b/tasmota/berry/gpio_viewer/webserver_async.be
@@ -0,0 +1,477 @@
+#
+# webserber_async.be - implements a generic async non-blocking HTTP server
+#
+# 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 .
+#
+
+# restrictions for now:
+#
+# Listen to all interfaces
+# - GET only
+# - no HTTPS
+# - support for limited headers
+# - HTTP 1.0 only
+
+#@ solidify:Webserver_async
+#@ solidify:Webserver_async_cnx
+
+class Webserver_async_cnx
+ var server # link to server object
+ var cnx # holds the tcpclientasync instance
+ 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
+ var req_uri
+ var req_version
+ var header_host
+ # response
+ var resp_headers
+ var resp_version
+ var mode_chunked
+ # conversion
+ static var CODE_TO_STRING = {
+ 100: "Continue",
+ 200: "OK",
+ 204: "No Content",
+ 301: "Moved Permanently",
+ 400: "Bad Request",
+ 401: "Unauthorized",
+ 403: "Payment Required",
+ 404: "Not Found",
+ 500: "Internal Server Error",
+ 501: "Not Implemented"
+ }
+
+ #############################################################
+ # init
+ 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
+ # response
+ self.resp_headers = ''
+ self.resp_version = 1 # HTTP 1.1 # TODO
+ self.mode_chunked = true
+ # register cb
+ self.fastloop_cb = def () self.loop() end
+ tasmota.add_fast_loop(self.fastloop_cb)
+ end
+
+ def set_mode_chunked(mode_chunked)
+ self.mode_chunked = bool(mode_chunked)
+ end
+
+ #############################################################
+ # test if connected
+ def connected()
+ return self.cnx ? self.cnx.connected() : false
+ end
+ #############################################################
+ # closing web server
+ def close()
+ tasmota.log(f"WEB: closing cnx", 3)
+ if (self.cnx != nil) self.cnx.close() end
+ self.cnx = nil
+ end
+
+ #############################################################
+ # called by fastloop
+ def loop()
+ if self.cnx == nil # marked for deletion
+ # mark as closed with self.cnx == nil
+ tasmota.remove_fast_loop(self.fastloop_cb)
+ self.fastloop_cb = nil
+ return
+ end
+
+ # any incoming data?
+ var cnx = self.cnx
+
+ if cnx.available() > 0
+ var buf_in_new = cnx.read()
+ if (!self.buf_in)
+ self.buf_in = buf_in_new
+ else
+ self.buf_in += buf_in_new
+ end
+ end
+
+ # parse incoming if any
+ if (self.buf_in)
+ self.parse()
+ end
+ end
+
+ #############################################################
+ # parse incoming
+ #
+ # pre: self.buf_in is not empty
+ # post: self.buf_in has made progress (smaller or '')
+ def parse()
+ tasmota.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
+ tasmota.log(f"WEB: HTTP verb: {self.req_verb} URI: '{self.req_uri}' Version:{self.req_version}", 3)
+ self.parse_http_headers()
+ elif size(self.buf_in) > 100 # if no match and we still have 100 bytes, then it fails
+ tasmota.log("WEB: error invalid request", 3)
+ 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
+ tasmota.log("WEB: error header is bigger than 1KB", 3)
+ self.close()
+ self.buf_in = ''
+ end
+ return
+ end
+ end
+
+
+ self.close()
+ self.buf_in = ''
+ end
+
+ #############################################################
+ # event_http_header
+ #
+ # Received header
+ def event_http_header(header_key, header_value)
+ tasmota.log(f"WEB: header key '{header_key}' = '{header_value}'")
+
+ if (header_key == "Host")
+ self.header_host = header_value
+ end
+ # import string
+ # header_key = string.tolower(header_key)
+ # header_value = string.tolower(header_value)
+ # print("header=", header_key, header_value)
+ # if header_key == 'transfer-encoding' && string.tolower(header_value) == 'chunked'
+ # self.is_chunked = true
+ # end
+ end
+
+ #############################################################
+ # event_http_headers_end
+ #
+ # All headers are received
+ def event_http_headers_end()
+ # print("event_http_headers_end")
+ # truncate to save space
+ # if self.buf_in_offset > 0
+ # self.buf_in = self.buf_in[self.buf_in_offset .. ]
+ # self.buf_in_offset = 0
+ # end
+ end
+
+ #############################################################
+ # parse incoming payload (if any)
+ def parse_http_payload()
+ tasmota.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
+ #############################################################
+ #############################################################
+ # parse incoming payload (if any)
+ 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
+
+ def send(code, content_type, content)
+ var response = f"HTTP/1.{self.resp_version} {code} {self.code_to_string(code)}\r\n"
+ if (content_type == nil) content_type = "text/html" end
+ self.send_header("Content-Type", content_type, true)
+
+ # force chunked TODO
+ self.send_header("Accept-Ranges", "none")
+ if self.mode_chunked
+ self.send_header("Transfer-Encoding", "chunked")
+ end
+ # cors
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "*")
+ self.send_header("Access-Control-Allow-Headers", "*")
+ # others
+ self.send_header("Connection", "close")
+
+ response += self.resp_headers
+ response += "\r\n"
+ self.resp_headers = nil
+
+ # send
+ self._write(response)
+
+ if (content) self.write(content) end
+ end
+
+ static def code_to_string(code)
+ return _class.CODE_TO_STRING.find(code, "UNKNOWN")
+ end
+
+ #############################################################
+ # async write
+ def write(s)
+ # use chunk encoding
+ if self.mode_chunked
+ var chunk = f"{size(s):X}\r\n{s}\r\n"
+ tasmota.log(f"WEB: sending chunk '{bytes().fromstring(chunk).tohex()}'")
+ self._write(chunk)
+ else
+ self._write(s)
+ end
+ end
+
+ #############################################################
+ # async write
+ def _write(s)
+ self.cnx.write(s) # TODO move to async later
+ end
+
+ def content_stop()
+ self.write('')
+ self.close()
+ end
+end
+
+class Webserver_dispatcher
+ var uri_prefix # prefix string, must start with '/'
+ var verb # verb to match, or nil for ANY
+ var cb_obj
+ var cb_mth
+
+ def init(uri, cb_obj, cb_mth, verb)
+ self.uri_prefix = uri
+ 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
+ self.cb_mth(self.cb_obj, cnx, uri, verb)
+ return true
+ end
+ end
+ return false
+ end
+end
+
+class Webserver_async
+ 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 timeout # timeout in ms
+ # var auth # web authentication string (Basic Auth) or `nil`, in format `user:password` as bade64
+ # var cmd # GET url command
+ var dispatchers
+
+ 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
+ self.connections = []
+ self.dispatchers = []
+ self.server = tcpserver(port) # throws an exception if port is not available
+ # 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)
+ end
+ 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()
+ # tasmota.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 = Webserver_async_cnx(self, self.server.accept()) # TODO move to self.server.acceptasync
+ self.connections.push(cnx)
+ tasmota.log(f"WEB: received connection from XXX")
+ end
+ end
+
+ #############################################################
+ # add to dispatcher
+ def on(prefix, obj, mth, verb)
+ var dispatcher = Webserver_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
+
+#- Test
+
+var web = Webserver_async(888)
+
+def send_more(cnx, i)
+ cnx.write(f"
Hello world {i}
")
+ if i < 10
+ tasmota.set_timer(1000, def () send_more(cnx, i+1) end)
+ else
+ cnx.content_stop()
+ end
+end
+
+def f(obj, cnx, uri, verb)
+ cnx.send(200, "text/html")
+ cnx.write("")
+ send_more(cnx, 0)
+ # cnx.write("Hello world")
+ # cnx.content_stop()
+end
+
+web.on("/hello", nil, f)
+
+-#
diff --git a/tasmota/berry/gpio_viewer/webserver_gpioviewer.be b/tasmota/berry/gpio_viewer/webserver_gpioviewer.be
new file mode 100644
index 000000000000..a145629256cd
--- /dev/null
+++ b/tasmota/berry/gpio_viewer/webserver_gpioviewer.be
@@ -0,0 +1,212 @@
+#
+# webserber_gpioviewer.be - implements a generic async non-blocking HTTP server
+#
+# 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 .
+#
+
+var gpio_viewer = module('gpio_viewer')
+
+gpio_viewer.Webserver_async_cnx = Webserver_async_cnx
+gpio_viewer.Webserver_dispatcher = Webserver_dispatcher
+gpio_viewer.Webserver_async = Webserver_async
+
+class GPIO_viewer
+ var web
+ var sampling_interval
+ var free_space
+ var pin_actual # actual value
+ var last_pin_states # state converted to 0..255
+ var new_pin_states # get a snapshot of newest values
+ var pin_types # array of types
+
+ static var TYPE_DIGITAL = 0
+ static var TYPE_PWM = 1
+ static var TYPE_ANALOG = 2
+
+ static var SAMPLING = 100
+ static var GPIO_RELEASE = "1.0.7"
+ static var HTML_HEAD =
+ "ESP32 GPIO State"
+ ""
+ ""
+ ""
+ ""
+
+ ""
+ ""
+ ""
+ static var HTML_BODY =
+ "
\n"
+ ""
+ ""
+ # Image
+ "
\n"
+ "
"
+ "\n"
+ ""
+ "
"
+ static var HTML_SCRIPT =
+ # Append the script variables
+ ""
+ ""
+ ""
+ ""
+ ""
+ ""
+
+ def init(port)
+ self.web = Webserver_async(5555)
+ self.sampling_interval = self.SAMPLING
+ self.free_space = 500
+
+ # pins
+ import gpio
+ self.pin_actual = []
+ self.pin_actual.resize(gpio.MAX_GPIO) # full of nil
+ self.last_pin_states = []
+ self.last_pin_states.resize(gpio.MAX_GPIO) # full of nil
+ self.new_pin_states = []
+ self.new_pin_states.resize(gpio.MAX_GPIO) # full of nil
+ self.pin_types = []
+ self.pin_types.resize(gpio.MAX_GPIO) # full of nil
+
+ self.web.on("/release", self, self.send_release_page)
+ self.web.on("/events", self, self.send_events_page)
+ self.web.on("/", self, self.send_index_page)
+ end
+
+ def close()
+ self.web.close()
+ end
+
+ def send_index_page(cnx, uri, verb)
+ import string
+
+ cnx.send(200, "text/html")
+ cnx.write(self.HTML_HEAD)
+ cnx.write(self.HTML_BODY)
+
+ 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
+
+ var html = format(self.HTML_SCRIPT, port, ip, port, ip, self.sampling_interval, self.free_space)
+ cnx.write(html)
+ cnx.content_stop()
+ end
+
+ def send_release_page(cnx, uri, verb)
+ var release = f'{{"release":"{self.GPIO_RELEASE}"}}'
+ cnx.send(200, "application/json", release)
+ cnx.content_stop()
+ end
+
+ def send_events_page(cnx, uri, verb)
+ cnx.set_mode_chunked(false) # no chunking since we use EventSource
+ cnx.send(200, "text/event-stream")
+
+ self.send_events_tick(cnx)
+ end
+
+ def send_events_tick(cnx)
+ import gpio
+ var max_gpio = gpio.MAX_GPIO
+ var msg = "{"
+ var dirty = false
+ var pin = 0
+ self.read_states()
+
+ while pin < max_gpio
+ var prev = self.last_pin_states[pin]
+ var val = self.new_pin_states[pin]
+ if (prev != val) || (val != nil) # TODO for now send everything every time
+ if dirty msg += "," end
+ msg += f'"{pin}":{{"s":{val},"v":{prev},"t":{self.pin_types[pin]}}}'
+ dirty = true
+
+ self.last_pin_states[pin] = val
+ end
+ pin += 1
+ end
+ msg += "}"
+
+ if dirty
+ # prepare payload
+ var payload = f"id:{tasmota.millis()}\r\n"
+ "event:gpio-state\r\n"
+ "data:{msg}\r\n\r\n"
+
+ # tasmota.log(f"GPV: sending '{msg}'", 3)
+ cnx.write(payload)
+ end
+
+ # send free heap
+ var payload = f"id:{tasmota.millis()}\r\n"
+ "event:free_heap\r\n"
+ "data:{tasmota.memory().find('heap_free', 0)}\r\n\r\n"
+ cnx.write(payload)
+
+ tasmota.set_timer(self.sampling_interval, def () self.send_events_tick(cnx) end)
+ end
+
+ # read all GPIO values, store in `pin_actual` and `new_pin_states`
+ def read_states()
+ import gpio
+ var max_gpio = gpio.MAX_GPIO
+ var pin = 0
+ while pin < max_gpio
+ # check if PWM
+ var pwm_resolution = gpio.read_pwm_resolution(pin)
+ if (pwm_resolution > 0)
+ var pwm_val = gpio.read_pwm(pin)
+ var pwm_state = tasmota.scale_uint(pwm_val, 0, pwm_resolution, 0, 255) # bring back to 0..255
+ self.pin_actual[pin] = pwm_val
+ self.new_pin_states[pin] = pwm_state
+ self.pin_types[pin] = self.TYPE_PWM
+ elif gpio.get_pin_type(pin) > 0
+ # digital read
+ var digital_val = gpio.digital_read(pin) # returns 0 or 1
+ self.pin_actual[pin] = digital_val
+ self.new_pin_states[pin] = digital_val ? 256 : 0
+ self.pin_types[pin] = self.TYPE_DIGITAL
+ else
+ self.pin_actual[pin] = nil
+ self.new_pin_states[pin] = nil
+ self.pin_types[pin] = self.TYPE_DIGITAL
+ end
+ pin += 1
+ end
+ end
+
+end
+
+gpio_viewer.GPIO_viewer = GPIO_viewer
+
+if tasmota
+ var gpio_viewer = GPIO_viewer(5555)
+end
+
+return gpio_viewer
+
+#- Test
+
+var gpio_viewer = GPIO_viewer(5555)
+
+-#