# # 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 ############################################################# class Webserver_async_cnx var server # link to server object var cnx # holds the tcpclientasync instance var close_after_send # if true, close after sending 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 chunked # if true enable chunked encoding (default true) var cors # if true send CORS headers (default false) # bytes objects to be reused var payload1 # 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 # util self.payload1 = bytes() self.close_after_send = false # response self.resp_headers = '' self.resp_version = 1 # HTTP 1.1 # TODO self.chunked = true self.cors = false # register cb self.fastloop_cb = def () self.loop() end tasmota.add_fast_loop(self.fastloop_cb) end def set_chunked(chunked) self.chunked = bool(chunked) end def set_cors(cors) self.cors = bool(cors) end ############################################################# # test if connected def connected() return self.cnx ? self.cnx.connected() : false end ############################################################# # test if out buffer is empty, meaning all was sent def buf_out_empty() return size(self.buf_out) == 0 end ############################################################# # write bytes or string # # v must be bytes() def _write(v) if (size(v) == 0) return end var buf_out = self.buf_out var buf_out_sz = size(buf_out) buf_out.resize(buf_out_sz + size(v)) buf_out.setbytes(buf_out_sz, v) self._send() # try sending this now 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 self._send() # try sending outgoing var cnx = self.cnx if (cnx == nil) return end # it's possible that it was closed after _send() # any incoming data? 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 ############################################################# # try sending what we can immediately def _send() # any data waiting to go out? var cnx = self.cnx if (cnx == nil) return end var buf_out = self.buf_out if size(buf_out) > 0 if cnx.listening() var sent = cnx.write(buf_out) if sent > 0 # we did sent something if sent >= size(buf_out) # all sent self.buf_out.clear() else # remove the first bytes already sent self.buf_out.setbytes(0, buf_out, sent) self.buf_out.resize(size(buf_out) - sent) end end end else # empty buffer, do the cleaning self.buf_out.clear() self.buf_in_offset = 0 if self.close_after_send 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() # 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 end ############################################################# # event_http_headers_end # # All headers are received def event_http_headers_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.chunked self.send_header("Transfer-Encoding", "chunked") end # cors if self.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 self.write_raw(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(v) if type(v) == 'string' # if string, convert to bytes v = bytes().fromstring(v) end # use chunk encoding if self.chunked var payload1 = self.payload1 payload1.clear() payload1 .. f"{size(v):X}\r\n" payload1 .. v payload1 .. "\r\n" # var chunk = f"{size(v):X}\r\n{v}\r\n" # tasmota.log(f"WEB: sending chunk '{payload1.tohex()}'") self._write(payload1) else self._write(v) end end ############################################################# # async write 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 def content_stop() self.write('') self.close_after_send = true end end ############################################################# # class Webserver_dispatcher ############################################################# 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 # # This is the main class to call ############################################################# 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 # copied in each connection var chunked # if true enable chunked encoding (default true) var cors # if true send CORS headers (default false) # var payload1, payload2 # 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 self.connections = [] self.dispatchers = [] self.server = tcpserver(port) # throws an exception if port is not available self.chunked = true self.cors = false self.payload1 = bytes(100) # reserve 100 bytes by default self.payload2 = 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) 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_format_int(b, i, default) b.clear() if (i == nil) b .. default return end var negative = false # sanity check if (i < 0) i = -i negative = true 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(11) # max size for 32 bits ints '-2147483647' var len = 0 while i > 0 var digit = i % 10 b[len] = 0x30 + digit len += 1 i = (i / 10) end if negative b[len] = 0x2D len += 1 end # reverse order b.resize(len) b.reverse() 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.acceptasync()) cnx.set_chunked(self.chunked) cnx.set_cors(self.cors) self.connections.push(cnx) 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 return webserver_async #- 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) -#