From 69cbae58734e4155928eb6644069c19187e4ed4b Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Mon, 4 Dec 2023 10:12:07 +1100 Subject: [PATCH] AP_Scripting: started on web server example --- libraries/AP_Scripting/examples/net_test.lua | 13 +- .../AP_Scripting/examples/net_webserver.lua | 508 ++++++++++++++++++ 2 files changed, 511 insertions(+), 10 deletions(-) create mode 100644 libraries/AP_Scripting/examples/net_webserver.lua diff --git a/libraries/AP_Scripting/examples/net_test.lua b/libraries/AP_Scripting/examples/net_test.lua index e40f4d59aa9d0b..1c9f408e5e8c24 100644 --- a/libraries/AP_Scripting/examples/net_test.lua +++ b/libraries/AP_Scripting/examples/net_test.lua @@ -7,17 +7,10 @@ local MAV_SEVERITY = {EMERGENCY=0, ALERT=1, CRITICAL=2, ERROR=3, WARNING=4, NOTI PARAM_TABLE_KEY = 46 PARAM_TABLE_PREFIX = "NT_" --- bind a parameter to a variable given -function bind_param(name) - local p = Parameter() - assert(p:init(name), string.format('could not find %s parameter', name)) - return p -end - -- add a parameter and bind it to a variable function bind_add_param(name, idx, default_value) assert(param:add_param(PARAM_TABLE_KEY, idx, name, default_value), string.format('could not add param %s', name)) - return bind_param(PARAM_TABLE_PREFIX .. name) + return Parameter(PARAM_TABLE_PREFIX .. name) end -- Setup Parameters @@ -68,7 +61,7 @@ if not sock_udp_echo then end if not sock_tcp_in:bind("0.0.0.0", NT_BIND_PORT:get()) then - gcs:send_text(MAV_SEVERITY.ERROR, "net_test: failed to bind to TCP 5001") + gcs:send_text(MAV_SEVERITY.ERROR, string.format("net_test: failed to bind to TCP %u", NT_BIND_PORT:get())) end if not sock_tcp_in:listen(1) then @@ -76,7 +69,7 @@ if not sock_tcp_in:listen(1) then end if not sock_udp_in:bind("0.0.0.0", NT_BIND_PORT:get()) then - gcs:send_text(MAV_SEVERITY.ERROR, "net_test: failed to bind to UDP 5001") + gcs:send_text(MAV_SEVERITY.ERROR, string.format("net_test: failed to bind to UDP %u", NT_BIND_PORT:get())) end --[[ diff --git a/libraries/AP_Scripting/examples/net_webserver.lua b/libraries/AP_Scripting/examples/net_webserver.lua new file mode 100644 index 00000000000000..1b964603d72df1 --- /dev/null +++ b/libraries/AP_Scripting/examples/net_webserver.lua @@ -0,0 +1,508 @@ +--[[ + example script to test lua socket API +--]] + +local MAV_SEVERITY = {EMERGENCY=0, ALERT=1, CRITICAL=2, ERROR=3, WARNING=4, NOTICE=5, INFO=6, DEBUG=7} + +PARAM_TABLE_KEY = 47 +PARAM_TABLE_PREFIX = "WEB_" + +-- add a parameter and bind it to a variable +function bind_add_param(name, idx, default_value) + assert(param:add_param(PARAM_TABLE_KEY, idx, name, default_value), string.format('could not add param %s', name)) + return Parameter(PARAM_TABLE_PREFIX .. name) +end + +-- Setup Parameters +assert(param:add_table(PARAM_TABLE_KEY, PARAM_TABLE_PREFIX, 6), 'net_test: could not add param table') + +--[[ + // @Param: WEB_ENABLE + // @DisplayName: enable web server + // @Description: enable web server + // @Values: 0:Disabled,1:Enabled + // @User: Standard +--]] +local WEB_ENABLE = bind_add_param('ENABLE', 1, 0) + +--[[ + // @Param: WEB_BIND_PORT + // @DisplayName: web server TCP port + // @Description: web server TCP port + // @Range: 1 65535 + // @User: Standard +--]] +local WEB_BIND_PORT = bind_add_param('BIND_PORT', 2, 8080) + +--[[ + // @Param: WEB_DEBUG + // @DisplayName: web server debugging + // @Description: web server debugging + // @Values: 0:Disabled,1:Enabled + // @User: Advanced +--]] +local WEB_DEBUG = bind_add_param('DEBUG', 3, 0) + +--[[ + // @Param: WEB_BLOCK_SIZE + // @DisplayName: web server block size + // @Description: web server block size for download + // @Range: 1 65535 + // @User: Advanced +--]] +local WEB_BLOCK_SIZE = bind_add_param('BLOCK_SIZE', 4, 10240) + +gcs:send_text(MAV_SEVERITY.INFO, string.format("WebServer: starting on port %u", WEB_BIND_PORT:get())) + +local counter = 0 +local sock_listen = SocketAPM(0) +local clients = {} + +local DOCTYPE = "" +local SERVER_VERSION = "net_webserver 1.0" +local CONTENT_TEXT_HTML = "text/html;charset=UTF-8" +local CONTENT_TEXT_PLAIN = "text/plain" +local CONTENT_OCTET_STREAM = "application/octet-stream" + +local HIDDEN_FOLDERS = { "@SYS", "@ROMFS", "@MISSION", "@PARAM" } + +local MIME_TYPES = { + ["bin"] = CONTENT_OCTET_STREAM, + ["apj"] = CONTENT_OCTET_STREAM, + ["dat"] = CONTENT_OCTET_STREAM, + ["o"] = CONTENT_OCTET_STREAM, + ["obj"] = CONTENT_OCTET_STREAM, + ["lua"] = "text/x-lua", + ["py"] = "text/x-python", + ["txt"] = CONTENT_TEXT_PLAIN, + ["htm"] = CONTENT_TEXT_HTML, + ["html"] = CONTENT_TEXT_HTML, + ["js"] = CONTENT_TEXT_HTML, +} + +if not sock_listen:bind("0.0.0.0", WEB_BIND_PORT:get()) then + gcs:send_text(MAV_SEVERITY.ERROR, string.format("WebServer: failed to bind to TCP %u", WEB_BIND_PORT:get())) + return +end + +if not sock_listen:listen(8) then + gcs:send_text(MAV_SEVERITY.ERROR, "WebServer: failed to listen") + return +end + +--[[ + split string by pattern +--]] +local function split(str, pattern) + local ret = {} + for s in string.gmatch(str, pattern) do + table.insert(ret, s) + end + return ret +end + +--[[ + return true if a string ends in the 2nd string +--]] +local function endswith(str, s) + local len1 = #str + local len2 = #s + return string.sub(str,1+len1-len2,1+len1-len2) == s +end + +--[[ + return true if a string starts with the 2nd string +--]] +local function startswith(str, s) + local len1 = #str + local len2 = #s + return string.sub(str,1,len2) == s +end + +function DEBUG(txt) + if WEB_DEBUG:get() ~= 0 then + gcs:send_text(MAV_SEVERITY.DEBUG, txt) + end +end + +--[[ + return true if a table contains a given element +--]] +function contains(t,el) + for _,v in ipairs(t) do + if v == el then + return true + end + end + return false +end + +function is_hidden_dir(path) + return contains(HIDDEN_FOLDERS, path) +end + +function file_exists(path) + local f = io.open(path, "rb") + if f then + return true + end + return false +end + +--[[ + client class for open connections +--]] +local function Client(_sock, _idx) + local self = {} + + self.closed = false + + local sock = _sock + local idx = _idx + local have_header = false + local header = "" + local header_lines = {} + local header_vars = {} + local run = nil + local protocol = nil + local file = nil + + function self.read_header() + local s = sock:recv(1024) + if not s then + -- EOF while looking for header + DEBUG("EOF") + self.remove() + return false + end + if not s or #s == 0 then + return false + end + header = header .. s + local eoh = string.find(s, '\r\n\r\n') + if eoh then + DEBUG("got header") + have_header = true + header_lines = split(header, "[^\r\n]+") + return true + end + return false + end + + function self.sendstring(s) + sock:send(s, #s) + end + + function self.sendline(s) + self.sendstring(s .. "\r\n") + end + + --[[ + send a string with variable substitution using {varname} + from http://lua-users.org/wiki/StringInterpolation + --]] + function self.sendstring_vars(s, vars) + s = (string.gsub(s, "({([^}]+)})", + function(whole,i) + return vars[i] or whole + end)) + self.sendstring(s) + end + + function self.send_header(code, codestr, vars) + self.sendline(string.format("%s %u %s", protocol, code, codestr)) + self.sendline(string.format("Server: %s", SERVER_VERSION)) + for k,v in pairs(vars) do + self.sendline(string.format("%s: %s", k, v)) + end + self.sendline("Connection: close") + self.sendline("") + end + + -- get size of a file + function self.file_size(fname) + DEBUG(string.format("size of '%s'", fname)) + local f = io.open(fname, "rb") + if not f then + return -1 + end + local ret = f:seek("end") + f:close() + DEBUG(string.format("size of '%s' -> %u", fname, ret)) + return ret + end + + + --[[ + return full path with .. resolution + --]] + function self.full_path(path, name) + DEBUG(string.format("full_path(%s,%s)", path, name)) + local ret = path + if path == "/" and startswith(name,"@") then + return name + end + if name == ".." then + if path == "/" then + return "/" + end + if endswith(path,"/") then + path = string.sub(path, 1, #path-1) + end + local dir, file = string.match(path, '(.*/)(.*)') + if not dir then + return path + end + return dir + end + if not endswith(ret, "/") then + ret = ret .. "/" + end + ret = ret .. name + DEBUG(string.format("full_path(%s,%s) -> %s", path, name, ret)) + return ret + end + + function self.directory_list(path) + if startswith(path, "/@") then + path = string.sub(path, 2, #path-1) + end + DEBUG(string.format("directory_list(%s)", path)) + local dlist = dirlist(path) + if not dlist then + dlist = {} + end + if not contains(dlist, "..") then + -- on ChibiOS we don't get .. + table.insert(dlist, "..") + end + if path == "/" then + for _,v in ipairs(HIDDEN_FOLDERS) do + table.insert(dlist, v) + end + end + + table.sort(dlist) + self.send_header(200, "OK", {["Content-Type"]=CONTENT_TEXT_HTML}) + self.sendline(DOCTYPE) + self.sendstring_vars([[ + + + Index of {path} + + +

Index of {path}

+ + +]], {path=path}) + for _,d in ipairs(dlist) do + local skip = d == "." + if path == "/" and d == ".." then + skip = true + end + if not skip then + local fullpath = self.full_path(path, d) + local name = d + local size = 0 + if is_hidden_dir(fullpath) or isdirectory(fullpath) then + name = name .. "/" + else + size = math.max(self.file_size(fullpath),0) + end + self.sendstring_vars([[ +]], { name=name, size=tostring(size) }) + end + end + self.sendstring([[ +
NameSize
{name}{size}
+ + +]]) + end + + -- send file content + function self.send_file() + local chunk = WEB_BLOCK_SIZE:get() + local b = file:read(chunk) + if b and #b > 0 then + sock:send(b, #b) + end + if not b or #b < chunk then + -- EOF + DEBUG("sent file") + run = nil + self.remove() + return + end + end + + -- return a content type + function self.content_type(path) + local file, ext = string.match(path, '(.*[.])(.*)') + ext = string.lower(ext) + local ret = MIME_TYPES[ext] + if not ret then + return CONTENT_OCTET_STREAM + end + return ret + end + + -- perform a file download + function self.file_download(path) + if startswith(path, "/@") then + path = string.sub(path, 2, #path) + end + DEBUG(string.format("file_download(%s)", path)) + file = io.open(path,"rb") + if not file then + DEBUG(string.format("Failed to open '%s'", path)) + return false + end + local vars = {["Content-Type"]=self.content_type(path)} + if not startswith(path, "@") then + local fsize = self.file_size(path) + vars["Content-Length"]= tostring(fsize) + end + self.send_header(200, "OK", vars) + run = self.send_file + return true + end + + function self.not_found() + self.send_header(404, "Not found", {}) + end + + function self.moved_permanently(relpath) + if not startswith(relpath, "/") then + relpath = "/" .. relpath + end + local location = string.format("http://%s%s", header_vars['Host'], relpath) + DEBUG(string.format("Redirect -> %s", location)) + self.send_header(301, "Moved Permanently", {["Location"]=location}) + end + + -- process a single request + function self.process_request() + local h1 = header_lines[1] + if not h1 or #h1 == 0 then + DEBUG("empty request") + return + end + local cmd = split(header_lines[1], "%S+") + if not cmd or #cmd < 3 then + DEBUG(string.format("bad request: %s", header_lines[1])) + return + end + if cmd[1] ~= "GET" then + DEBUG(string.format("bad op: %s", cmd[1])) + return + end + protocol = cmd[3] + if protocol ~= "HTTP/1.0" and protocol ~= "HTTP/1.1" then + DEBUG(string.format("bad protocol: %s", protocol)) + return + end + local path = cmd[2] + DEBUG(string.format("path='%s'", path)) + + -- extract header variables + for i = 2,#header_lines do + local key, var = string.match(header_lines[i], '(.*): (.*)') + header_vars[key] = var + end + + if isdirectory(path) and not endswith(path,"/") and header_vars['Host'] and not is_hidden_dir(path) then + self.moved_permanently(path .. "/") + return + end + + if path ~= "/" and endswith(path,"/") then + path = string.sub(path, 1, #path-1) + end + + if startswith(path,"/@") then + path = string.sub(path, 2, #path) + end + + -- see if we have an index file + if isdirectory(path) and file_exists(path .. "/index.html") then + DEBUG("found index.html") + if self.file_download(path .. "/index.html") then + return + end + end + + -- see if it is a directory + if endswith(path,"/") or isdirectory(path) or is_hidden_dir(path) then + self.directory_list(path) + return + end + -- or a file + if self.file_download(path) then + return + end + self.not_found(path) + end + + -- update the client + function self.update() + if run then + run() + return + end + if not have_header then + if not self.read_header() then + return + end + end + self.process_request() + if not run then + -- nothing more to do + self.remove() + end + end + + function self.remove() + DEBUG(string.format("Removing client %u", idx)) + sock:close() + self.closed = true + end + + -- return the instance + return self +end + +--[[ + see if any new clients want to connect +--]] +local function check_new_clients() + local sock = sock_listen:accept() + if sock then + local idx = #clients+1 + local client = Client(sock, idx) + DEBUG(string.format("New client %u", idx)) + table.insert(clients, client) + end +end + +--[[ + check for client activity +--]] +local function check_clients() + for idx,client in ipairs(clients) do + if not client.closed then + client.update() + end + if client.closed then + table.remove(clients,idx) + end + end +end + +local function update() + check_clients() + check_new_clients() + return update,1 +end + +return update,100