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" + "
" + "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) + +-#