From 08a769a720b9b886fe2117ce2d68f28a63977fd7 Mon Sep 17 00:00:00 2001 From: s-hadinger <49731213+s-hadinger@users.noreply.github.com> Date: Sat, 6 Jan 2024 22:36:42 +0100 Subject: [PATCH] GPIO viewer in Berry initial version using async webserver (#20416) --- CHANGELOG.md | 1 + tasmota/berry/gpio_viewer/gpioviewer.bec | Bin 0 -> 10821 bytes tasmota/berry/gpio_viewer/webserver_async.be | 477 ++++++++++++++++++ .../berry/gpio_viewer/webserver_gpioviewer.be | 212 ++++++++ 4 files changed, 690 insertions(+) create mode 100644 tasmota/berry/gpio_viewer/gpioviewer.bec create mode 100644 tasmota/berry/gpio_viewer/webserver_async.be create mode 100644 tasmota/berry/gpio_viewer/webserver_gpioviewer.be diff --git a/CHANGELOG.md b/CHANGELOG.md index 654c974aa..66b447fad 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 0000000000000000000000000000000000000000..776f65e90016b9d2dae89dd725b125a9c3a5c29f GIT binary patch literal 10821 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 literal 0 HcmV?d00001 diff --git a/tasmota/berry/gpio_viewer/webserver_async.be b/tasmota/berry/gpio_viewer/webserver_async.be new file mode 100644 index 000000000..61d997274 --- /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 000000000..a14562925 --- /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" + "
" + "Board Image\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) + +-#