diff --git a/Tools/AP_Periph/Web/scripts/pppgw_webui.lua b/Tools/AP_Periph/Web/scripts/pppgw_webui.lua index 58cf3590bad51..f837386ea1fba 100644 --- a/Tools/AP_Periph/Web/scripts/pppgw_webui.lua +++ b/Tools/AP_Periph/Web/scripts/pppgw_webui.lua @@ -7,997 +7,10 @@ ---@diagnostic disable: redundant-parameter ---@diagnostic disable: undefined-field -PARAM_TABLE_KEY = 47 -PARAM_TABLE_PREFIX = "WEB_" +local web = require("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) +function printf(str) + periph:can_printf(str) 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, 1) - ---[[ - // @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, 80) - ---[[ - // @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) - ---[[ - // @Param: WEB_TIMEOUT - // @DisplayName: web server timeout - // @Description: timeout for inactive connections - // @Units: s - // @Range: 0.1 60 - // @User: Advanced ---]] -local WEB_TIMEOUT = bind_add_param('TIMEOUT', 5, 2.0) - ---[[ - // @Param: WEB_SENDFILE_MIN - // @DisplayName: web server minimum file size for sendfile - // @Description: sendfile is an offloading mechanism for faster file download. If this is non-zero and the file is larger than this size then sendfile will be used for file download - // @Range: 0 10000000 - // @User: Advanced ---]] -local WEB_SENDFILE_MIN = bind_add_param('SENDFILE_MIN', 6, 100000) - -if WEB_ENABLE:get() ~= 1 then - periph:can_printf("WebServer: disabled") - return -end - -periph:can_printf(string.format("WebServer: starting on port %u", WEB_BIND_PORT:get())) - -local sock_listen = Socket(0) -local clients = {} - -local DOCTYPE = "" -local SERVER_VERSION = "net_webserver 1.0" -local CONTENT_TEXT_HTML = "text/html;charset=UTF-8" -local CONTENT_OCTET_STREAM = "application/octet-stream" - -local HIDDEN_FOLDERS = { "@SYS", "@ROMFS", "@MISSION", "@PARAM" } - -local MNT_PREFIX = "/mnt" -local MNT_PREFIX2 = MNT_PREFIX .. "/" - -local MIME_TYPES = { - ["apj"] = CONTENT_OCTET_STREAM, - ["dat"] = CONTENT_OCTET_STREAM, - ["o"] = CONTENT_OCTET_STREAM, - ["obj"] = CONTENT_OCTET_STREAM, - ["lua"] = "text/x-lua", - ["py"] = "text/x-python", - ["shtml"] = CONTENT_TEXT_HTML, - ["js"] = "text/javascript", - -- thanks to https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types - ["aac"] = "audio/aac", - ["abw"] = "application/x-abiword", - ["arc"] = "application/x-freearc", - ["avif"] = "image/avif", - ["avi"] = "video/x-msvideo", - ["azw"] = "application/vnd.amazon.ebook", - ["bin"] = "application/octet-stream", - ["bmp"] = "image/bmp", - ["bz"] = "application/x-bzip", - ["bz2"] = "application/x-bzip2", - ["cda"] = "application/x-cdf", - ["csh"] = "application/x-csh", - ["css"] = "text/css", - ["csv"] = "text/csv", - ["doc"] = "application/msword", - ["docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ["eot"] = "application/vnd.ms-fontobject", - ["epub"] = "application/epub+zip", - ["gz"] = "application/gzip", - ["gif"] = "image/gif", - ["htm"] = CONTENT_TEXT_HTML, - ["html"] = CONTENT_TEXT_HTML, - ["ico"] = "image/vnd.microsoft.icon", - ["ics"] = "text/calendar", - ["jar"] = "application/java-archive", - ["jpeg"] = "image/jpeg", - ["json"] = "application/json", - ["jsonld"] = "application/ld+json", - ["mid"] = "audio/x-midi", - ["mjs"] = "text/javascript", - ["mp3"] = "audio/mpeg", - ["mp4"] = "video/mp4", - ["mpeg"] = "video/mpeg", - ["mpkg"] = "application/vnd.apple.installer+xml", - ["odp"] = "application/vnd.oasis.opendocument.presentation", - ["ods"] = "application/vnd.oasis.opendocument.spreadsheet", - ["odt"] = "application/vnd.oasis.opendocument.text", - ["oga"] = "audio/ogg", - ["ogv"] = "video/ogg", - ["ogx"] = "application/ogg", - ["opus"] = "audio/opus", - ["otf"] = "font/otf", - ["png"] = "image/png", - ["pdf"] = "application/pdf", - ["php"] = "application/x-httpd-php", - ["ppt"] = "application/vnd.ms-powerpoint", - ["pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ["rar"] = "application/vnd.rar", - ["rtf"] = "application/rtf", - ["sh"] = "application/x-sh", - ["svg"] = "image/svg+xml", - ["tar"] = "application/x-tar", - ["tif"] = "image/tiff", - ["tiff"] = "image/tiff", - ["ts"] = "video/mp2t", - ["ttf"] = "font/ttf", - ["txt"] = "text/plain", - ["vsd"] = "application/vnd.visio", - ["wav"] = "audio/wav", - ["weba"] = "audio/webm", - ["webm"] = "video/webm", - ["webp"] = "image/webp", - ["woff"] = "font/woff", - ["woff2"] = "font/woff2", - ["xhtml"] = "application/xhtml+xml", - ["xls"] = "application/vnd.ms-excel", - ["xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ["xml"] = "default.", - ["xul"] = "application/vnd.mozilla.xul+xml", - ["zip"] = "application/zip", - ["3gp"] = "video", - ["3g2"] = "video", - ["7z"] = "application/x-7z-compressed", -} - ---[[ - builtin dynamic pages ---]] -local DYNAMIC_PAGES = { - --- main home page -["/"] = [[ - - - - - - ArduPilot - - - -

ArduPilot PPP Gateway

- - -
- -
-

Controller Status

-
- - -]], - --- board status section on front page -["@DYNAMIC/board_status.shtml"] = [[ - - - - - - - - -
Firmware
GIT Hash
Uptime
IP
Netmask
Gateway
MCU Temperature
-]] -} - -reboot_counter = 0 - -local ACTION_PAGES = { - ["/?FWUPDATE"] = function() - periph:can_printf("Rebooting for firmware update") - reboot_counter = 50 - end -} - ---[[ - builtin javascript library functions ---]] -JS_LIBRARY = { - ["dynamic_load"] = [[ - function dynamic_load(div_id, uri, period_ms) { - var xhr = new XMLHttpRequest(); - xhr.open('GET', uri); - - xhr.setRequestHeader("Cache-Control", "no-cache, no-store, max-age=0"); - xhr.setRequestHeader("Expires", "Tue, 01 Jan 1980 1:00:00 GMT"); - xhr.setRequestHeader("Pragma", "no-cache"); - - xhr.onload = function () { - if (xhr.status === 200) { - var output = document.getElementById(div_id); - if (uri.endsWith('.shtml') || uri.endsWith('.html')) { - output.innerHTML = xhr.responseText; - } else { - output.textContent = xhr.responseText; - } - } - setTimeout(function() { dynamic_load(div_id,uri, period_ms); }, period_ms); - } - xhr.send(); - } -]] -} - -if not sock_listen:bind("0.0.0.0", WEB_BIND_PORT:get()) then - periph:can_printf(string.format("WebServer: failed to bind to TCP %u", WEB_BIND_PORT:get())) - return -end - -if not sock_listen:listen(20) then - periph:can_printf("WebServer: failed to listen") - return -end - -function hms_uptime() - local s = (millis()/1000):toint() - local min = math.floor(s / 60) % 60 - local hr = math.floor(s / 3600) - return string.format("%u hours %u minutes %u seconds", hr, min, s%60) -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,len1) == s -end - ---[[ - return true if a string starts with the 2nd string ---]] -local function startswith(str, s) - return string.sub(str,1,#s) == s -end - -local debug_count=0 - -function DEBUG(txt) - if WEB_DEBUG:get() ~= 0 then - periph:can_printf(txt .. string.format(" [%u]", debug_count)) - debug_count = debug_count + 1 - end -end - ---[[ - return index of element in a table ---]] -function table_index(t,el) - for i,v in ipairs(t) do - if v == el then - return i - end - end - return nil -end - ---[[ - return true if a table contains a given element ---]] -function table_contains(t,el) - local i = table_index(t, el) - return i ~= nil -end - -function is_hidden_dir(path) - return table_contains(HIDDEN_FOLDERS, path) -end - -local DAYS = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" } -local MONTHS = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" } - -function isdirectory(path) - local s = fs:stat(path) - return s and s:is_directory() -end - ---[[ - time string for directory listings ---]] -function file_timestring(path) - local s = fs:stat(path) - if not s then - return "" - end - local mtime = s:mtime() - local year, month, day, hour, min, sec, _ = rtc:clock_s_to_date_fields(mtime) - if not year then - return "" - end - return string.format("%04u-%02u-%02u %02u:%02u", year, month+1, day, hour, min, sec) -end - ---[[ - time string for Last-Modified ---]] -function file_timestring_http(mtime) - local year, month, day, hour, min, sec, wday = rtc:clock_s_to_date_fields(mtime) - if not year then - return "" - end - return string.format("%s, %02u %s %u %02u:%02u:%02u GMT", - DAYS[wday+1], - day, - MONTHS[month+1], - year, - hour, - min, - sec) -end - ---[[ - parse a http time string to a uint32_t seconds timestamp ---]] -function file_timestring_http_parse(tstring) - local dayname, day, monthname, year, hour, min, sec = - string.match(tstring, - '(%w%w%w), (%d+) (%w%w%w) (%d%d%d%d) (%d%d):(%d%d):(%d%d) GMT') - if not dayname then - return nil - end - local mon = table_index(MONTHS, monthname) - return rtc:date_fields_to_clock_s(year, mon-1, day, hour, min, sec) -end - ---[[ - return true if path exists and is not a directory ---]] -function file_exists(path) - local s = fs:stat(path) - if not s then - return false - end - return not s:is_directory() -end - ---[[ - substitute variables of form {xxx} from a table - from http://lua-users.org/wiki/StringInterpolation ---]] -function substitute_vars(s, vars) - s = (string.gsub(s, "({([^}]+)})", - function(whole,i) - return vars[i] or whole - end)) - return s -end - ---[[ - lat or lon as a string, working around limited type in ftoa_engine ---]] -function latlon_str(ll) - local ipart = tonumber(string.match(tostring(ll*1.0e-7), '(.*[.]).*')) - local fpart = math.abs(ll - ipart*10000000) - return string.format("%d.%u", ipart, fpart, ipart*10000000, ll) -end - ---[[ - location string for home page ---]] -function location_string(loc) - return substitute_vars([[{lat} {lon} {alt}]], - { ["lat"] = latlon_str(loc:lat()), - ["lon"] = latlon_str(loc:lng()), - ["alt"] = string.format("%.1fm", loc:alt()*1.0e-2) }) -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 - local start_time = millis() - local offset = 0 - - function self.read_header() - local s = sock:recv(2048) - if not s then - local now = millis() - if not sock:is_connected() or now - start_time > WEB_TIMEOUT:get()*1000 then - -- EOF while looking for header - DEBUG(string.format("%u: EOF", idx)) - self.remove() - return false - end - 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(string.format("%u: got header", idx)) - have_header = true - header_lines = split(header, "[^\r\n]+") - -- blocking for reply - sock:set_blocking(true) - 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} - --]] - function self.sendstring_vars(s, vars) - self.sendstring(substitute_vars(s, vars)) - 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) - local s = fs:stat(fname) - if not s then - return 0 - end - local ret = s:size():toint() - DEBUG(string.format("%u: size of '%s' -> %u", idx, fname, ret)) - return ret - end - - - --[[ - return full path with .. resolution - --]] - function self.full_path(path, name) - DEBUG(string.format("%u: full_path(%s,%s)", idx, 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, _ = 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("%u: full_path(%s,%s) -> %s", idx, path, name, ret)) - return ret - end - - function self.directory_list(path) - sock:set_blocking(true) - if startswith(path, "/@") then - path = string.sub(path, 2, #path-1) - end - DEBUG(string.format("%u: directory_list(%s)", idx, path)) - local dlist = dirlist(path) - if not dlist then - dlist = {} - end - if not table_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 not skip then - local fullpath = self.full_path(path, d) - local name = d - local sizestr = "0" - local stat = fs:stat(fullpath) - local size = stat and stat:size() or 0 - if is_hidden_dir(fullpath) or (stat and stat:is_directory()) then - name = name .. "/" - elseif size >= 100*1000*1000 then - sizestr = string.format("%uM", (size/(1000*1000)):toint()) - else - sizestr = tostring(size) - end - local modtime = file_timestring(fullpath) - self.sendstring_vars([[ -]], { name=name, size=sizestr, modtime=modtime }) - end - end - self.sendstring([[ -
NameLast modifiedSize
{name}{modtime}{size}
- - -]]) - end - - -- send file content - function self.send_file() - if not sock:pollout(0) then - return - end - local chunk = WEB_BLOCK_SIZE:get() - local b = file:read(chunk) - sock:set_blocking(true) - if b and #b > 0 then - local sent = sock:send(b, #b) - if sent == -1 then - run = nil - self.remove() - return - end - if sent < #b then - file:seek(offset+sent) - end - offset = offset + sent - end - if not b or #b < chunk then - -- EOF - DEBUG(string.format("%u: sent file", idx)) - run = nil - self.remove() - return - end - end - - --[[ - load whole file as a string - --]] - function self.load_file() - local chunk = WEB_BLOCK_SIZE:get() - local ret = "" - while true do - local b = file:read(chunk) - if not b or #b == 0 then - break - end - ret = ret .. b - end - return ret - end - - --[[ - evaluate some lua code and return as a string - --]] - function self.evaluate(code) - local eval_code = "function eval_func()\n" .. code .. "\nend\n" - local f, errloc, err = load(eval_code, "eval_func", "t", _ENV) - if not f then - DEBUG(string.format("load failed: err=%s errloc=%s", err, errloc)) - return nil - end - local success, err2 = pcall(f) - if not success then - DEBUG(string.format("pcall failed: err=%s", err2)) - return nil - end - local ok, s2 = pcall(eval_func) - eval_func = nil - if ok then - return s2 - end - return nil - end - - --[[ - process a file as a lua CGI - --]] - function self.send_cgi() - sock:set_blocking(true) - local contents = self.load_file() - local s = self.evaluate(contents) - if s then - self.sendstring(s) - end - self.remove() - end - - --[[ - send file content with server side processsing - files ending in .shtml can have embedded lua lika this: - - - - Using 'lstr' a return tostring(yourcode) is added to the code - automatically - --]] - function self.send_processed_file(dynamic_page) - sock:set_blocking(true) - local contents - if dynamic_page then - contents = file - else - contents = self.load_file() - end - while #contents > 0 do - local pat1 = "(.-)[<][?]lua[ \n](.-)[?][>](.*)" - local pat2 = "(.-)[<][?]lstr[ \n](.-)[?][>](.*)" - local p1, p2, p3 = string.match(contents, pat1) - if not p1 then - p1, p2, p3 = string.match(contents, pat2) - if not p1 then - break - end - p2 = "return tostring(" .. p2 .. ")" - end - self.sendstring(p1) - local s2 = self.evaluate(p2) - if s2 then - self.sendstring(s2) - end - contents = p3 - end - self.sendstring(contents) - self.remove() - end - - -- return a content type - function self.content_type(path) - if path == "/" then - return MIME_TYPES["html"] - end - local _, 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("%u: file_download(%s)", idx, path)) - file = DYNAMIC_PAGES[path] - dynamic_page = file ~= nil - if not dynamic_page then - file = io.open(path,"rb") - if not file then - DEBUG(string.format("%u: Failed to open '%s'", idx, path)) - return false - end - end - local vars = {["Content-Type"]=self.content_type(path)} - local cgi_processing = startswith(path, "/cgi-bin/") and endswith(path, ".lua") - local server_side_processing = endswith(path, ".shtml") - local stat = fs:stat(path) - if not startswith(path, "@") and - not server_side_processing and - not cgi_processing and stat and - not dynamic_page then - local fsize = stat:size() - local mtime = stat:mtime() - vars["Content-Length"]= tostring(fsize) - local modtime = file_timestring_http(mtime) - if modtime then - vars["Last-Modified"] = modtime - end - local if_modified_since = header_vars['If-Modified-Since'] - if if_modified_since then - local tsec = file_timestring_http_parse(if_modified_since) - if tsec and tsec >= mtime then - DEBUG(string.format("%u: Not modified: %s %s", idx, modtime, if_modified_since)) - self.send_header(304, "Not Modified", vars) - return true - end - end - end - self.send_header(200, "OK", vars) - if server_side_processing or dynamic_page then - DEBUG(string.format("%u: shtml processing %s", idx, path)) - run = self.send_processed_file(dynamic_page) - elseif cgi_processing then - DEBUG(string.format("%u: CGI processing %s", idx, path)) - run = self.send_cgi - elseif stat and - WEB_SENDFILE_MIN:get() > 0 and - stat:size() >= WEB_SENDFILE_MIN:get() and - sock:sendfile(file) then - return true - else - run = self.send_file - end - 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("%u: Redirect -> %s", idx, 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(string.format("%u: empty request", idx)) - 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("%u: path='%s'", idx, path)) - - -- extract header variables - for i = 2,#header_lines do - local key, var = string.match(header_lines[i], '(.*): (.*)') - if key then - header_vars[key] = var - end - end - - if ACTION_PAGES[path] ~= nil then - DEBUG(string.format("Running ACTION %s", path)) - local fn = ACTION_PAGES[path] - self.send_header(200, "OK", {["Content-Type"]=CONTENT_TEXT_HTML}) - self.sendstring([[ - - - - - -]]) - fn() - return - end - - if DYNAMIC_PAGES[path] ~= nil then - self.file_download(path) - return - end - - if path == MNT_PREFIX then - path = "/" - end - if startswith(path, MNT_PREFIX2) then - path = string.sub(path,#MNT_PREFIX2,#path) - 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(string.format("%u: found index.html", idx)) - if self.file_download(path .. "/index.html") then - return - end - end - - -- see if it is a directory - if (path == "/" or - DYNAMIC_PAGES[path] == nil) and - (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("%u: removing client OFFSET=%u", idx, offset)) - if self.closed then - return - end - 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() - while sock_listen:pollin(0) do - local sock = sock_listen:accept() - if not sock then - return - end - -- non-blocking for header read - sock:set_blocking(false) - -- find free client slot - for i = 1, #clients+1 do - if clients[i] == nil then - local idx = i - local client = Client(sock, idx) - DEBUG(string.format("%u: New client", idx)) - clients[idx] = client - end - end - 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_new_clients() - check_clients() - if reboot_counter then - reboot_counter = reboot_counter - 1 - if reboot_counter == 0 then - periph:can_printf("Rebooting") - periph:reboot(true) - end - end - return update,5 -end - -return update,100 +web.init(printf) \ No newline at end of file diff --git a/Tools/ardupilotwaf/boards.py b/Tools/ardupilotwaf/boards.py index c8d1bbbc42b2e..e1b62ff85bc94 100644 --- a/Tools/ardupilotwaf/boards.py +++ b/Tools/ardupilotwaf/boards.py @@ -513,6 +513,9 @@ def configure_env(self, cfg, env): "-D__AP_LINE__=__LINE__", ] + if not cfg.options.disable_networking: + env.ROMFS_FILES += [('scripts/modules/web.lua','libraries/AP_Scripting/modules/webserver.lua'), + ('scripts/webgui.lua','libraries/AP_Scripting/applets/net_webserver.lua')] # add files from ROMFS_custom custom_dir = 'ROMFS_custom' if os.path.exists(custom_dir): diff --git a/libraries/AP_HAL_ChibiOS/hwdef/CubeRedPrimary/hwdef.dat b/libraries/AP_HAL_ChibiOS/hwdef/CubeRedPrimary/hwdef.dat index d6e7dfa8e38aa..04de5c0c48f24 100644 --- a/libraries/AP_HAL_ChibiOS/hwdef/CubeRedPrimary/hwdef.dat +++ b/libraries/AP_HAL_ChibiOS/hwdef/CubeRedPrimary/hwdef.dat @@ -288,3 +288,11 @@ define HAL_FORWARD_OTG2_SERIAL 7 define HAL_HAVE_DUAL_USB_CDC 1 define DEFAULT_SERIAL7_PROTOCOL SerialProtocol_MAVLink2 define DEFAULT_SERIAL7_BAUD 2000000 + +// ROMFS filesystem only +define AP_FILESYSTEM_ROMFS_ENABLED 1 + +// allow scripts to add parameters +define AP_PARAM_DYNAMIC_ENABLED 1 + +ROMFS_DIRECTORY libraries/AP_Networking/Web diff --git a/libraries/AP_Scripting/applets/net_webserver.lua b/libraries/AP_Scripting/applets/net_webserver.lua index e8f7e2ad0fc52..7a759f3d3d5de 100644 --- a/libraries/AP_Scripting/applets/net_webserver.lua +++ b/libraries/AP_Scripting/applets/net_webserver.lua @@ -1,974 +1,17 @@ --[[ - example script to test lua socket API + example script to test lua webserver API --]] ----@diagnostic disable: param-type-mismatch ----@diagnostic disable: undefined-field ----@diagnostic disable: need-check-nil ----@diagnostic disable: redundant-parameter -local MAV_SEVERITY = {EMERGENCY=0, ALERT=1, CRITICAL=2, ERROR=3, WARNING=4, NOTICE=5, INFO=6, DEBUG=7} +local web = require("web") -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, 1) - ---[[ - // @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) - ---[[ - // @Param: WEB_TIMEOUT - // @DisplayName: web server timeout - // @Description: timeout for inactive connections - // @Units: s - // @Range: 0.1 60 - // @User: Advanced ---]] -local WEB_TIMEOUT = bind_add_param('TIMEOUT', 5, 2.0) - ---[[ - // @Param: WEB_SENDFILE_MIN - // @DisplayName: web server minimum file size for sendfile - // @Description: sendfile is an offloading mechanism for faster file download. If this is non-zero and the file is larger than this size then sendfile will be used for file download - // @Range: 0 10000000 - // @User: Advanced ---]] -local WEB_SENDFILE_MIN = bind_add_param('SENDFILE_MIN', 6, 100000) - -if WEB_ENABLE:get() ~= 1 then - gcs:send_text(MAV_SEVERITY.INFO, "WebServer: disabled") - return -end - -local BRD_RTC_TZ_MIN = Parameter("BRD_RTC_TZ_MIN") - -gcs:send_text(MAV_SEVERITY.INFO, string.format("WebServer: starting on port %u", WEB_BIND_PORT:get())) - -local sock_listen = Socket(0) -local clients = {} - -local DOCTYPE = "" -local SERVER_VERSION = "net_webserver 1.0" -local CONTENT_TEXT_HTML = "text/html;charset=UTF-8" -local CONTENT_OCTET_STREAM = "application/octet-stream" - -local HIDDEN_FOLDERS = { "@SYS", "@ROMFS", "@MISSION", "@PARAM" } - -local MNT_PREFIX = "/mnt" -local MNT_PREFIX2 = MNT_PREFIX .. "/" - -local MIME_TYPES = { - ["apj"] = CONTENT_OCTET_STREAM, - ["dat"] = CONTENT_OCTET_STREAM, - ["o"] = CONTENT_OCTET_STREAM, - ["obj"] = CONTENT_OCTET_STREAM, - ["lua"] = "text/x-lua", - ["py"] = "text/x-python", - ["shtml"] = CONTENT_TEXT_HTML, - ["js"] = "text/javascript", - -- thanks to https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types - ["aac"] = "audio/aac", - ["abw"] = "application/x-abiword", - ["arc"] = "application/x-freearc", - ["avif"] = "image/avif", - ["avi"] = "video/x-msvideo", - ["azw"] = "application/vnd.amazon.ebook", - ["bin"] = "application/octet-stream", - ["bmp"] = "image/bmp", - ["bz"] = "application/x-bzip", - ["bz2"] = "application/x-bzip2", - ["cda"] = "application/x-cdf", - ["csh"] = "application/x-csh", - ["css"] = "text/css", - ["csv"] = "text/csv", - ["doc"] = "application/msword", - ["docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ["eot"] = "application/vnd.ms-fontobject", - ["epub"] = "application/epub+zip", - ["gz"] = "application/gzip", - ["gif"] = "image/gif", - ["htm"] = CONTENT_TEXT_HTML, - ["html"] = CONTENT_TEXT_HTML, - ["ico"] = "image/vnd.microsoft.icon", - ["ics"] = "text/calendar", - ["jar"] = "application/java-archive", - ["jpeg"] = "image/jpeg", - ["json"] = "application/json", - ["jsonld"] = "application/ld+json", - ["mid"] = "audio/x-midi", - ["mjs"] = "text/javascript", - ["mp3"] = "audio/mpeg", - ["mp4"] = "video/mp4", - ["mpeg"] = "video/mpeg", - ["mpkg"] = "application/vnd.apple.installer+xml", - ["odp"] = "application/vnd.oasis.opendocument.presentation", - ["ods"] = "application/vnd.oasis.opendocument.spreadsheet", - ["odt"] = "application/vnd.oasis.opendocument.text", - ["oga"] = "audio/ogg", - ["ogv"] = "video/ogg", - ["ogx"] = "application/ogg", - ["opus"] = "audio/opus", - ["otf"] = "font/otf", - ["png"] = "image/png", - ["pdf"] = "application/pdf", - ["php"] = "application/x-httpd-php", - ["ppt"] = "application/vnd.ms-powerpoint", - ["pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ["rar"] = "application/vnd.rar", - ["rtf"] = "application/rtf", - ["sh"] = "application/x-sh", - ["svg"] = "image/svg+xml", - ["tar"] = "application/x-tar", - ["tif"] = "image/tiff", - ["tiff"] = "image/tiff", - ["ts"] = "video/mp2t", - ["ttf"] = "font/ttf", - ["txt"] = "text/plain", - ["vsd"] = "application/vnd.visio", - ["wav"] = "audio/wav", - ["weba"] = "audio/webm", - ["webm"] = "video/webm", - ["webp"] = "image/webp", - ["woff"] = "font/woff", - ["woff2"] = "font/woff2", - ["xhtml"] = "application/xhtml+xml", - ["xls"] = "application/vnd.ms-excel", - ["xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ["xml"] = "default.", - ["xul"] = "application/vnd.mozilla.xul+xml", - ["zip"] = "application/zip", - ["3gp"] = "video", - ["3g2"] = "video", - ["7z"] = "application/x-7z-compressed", -} - ---[[ - builtin dynamic pages ---]] -local DYNAMIC_PAGES = { - --- main home page -["/"] = [[ - - - - - - ArduPilot - - - -

ArduPilot Web Server

- - -
- -
-

Controller Status

-
- - -]], - --- board status section on front page -["@DYNAMIC/board_status.shtml"] = [[ - - - - - - - -
Firmware
GIT Hash
Uptime
Arm Status
AHRS Location
GPS Location
-]] -} - ---[[ - builtin javascript library functions ---]] -JS_LIBRARY = { - ["dynamic_load"] = [[ - function dynamic_load(div_id, uri, period_ms) { - var xhr = new XMLHttpRequest(); - xhr.open('GET', uri); - - xhr.setRequestHeader("Cache-Control", "no-cache, no-store, max-age=0"); - xhr.setRequestHeader("Expires", "Tue, 01 Jan 1980 1:00:00 GMT"); - xhr.setRequestHeader("Pragma", "no-cache"); - - xhr.onload = function () { - if (xhr.status === 200) { - var output = document.getElementById(div_id); - if (uri.endsWith('.shtml') || uri.endsWith('.html')) { - output.innerHTML = xhr.responseText; - } else { - output.textContent = xhr.responseText; - } - } - setTimeout(function() { dynamic_load(div_id,uri, period_ms); }, period_ms); - } - xhr.send(); - } -]] -} - -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(20) then - gcs:send_text(MAV_SEVERITY.ERROR, "WebServer: failed to listen") - return -end - -function hms_uptime() - local s = (millis()/1000):toint() - local min = math.floor(s / 60) % 60 - local hr = math.floor(s / 3600) - return string.format("%u hours %u minutes %u seconds", hr, min, s%60) -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,len1) == s -end - ---[[ - return true if a string starts with the 2nd string ---]] -local function startswith(str, s) - return string.sub(str,1,#s) == s -end - -local debug_count=0 - -function DEBUG(txt) - if WEB_DEBUG:get() ~= 0 then - gcs:send_text(MAV_SEVERITY.DEBUG, txt .. string.format(" [%u]", debug_count)) - debug_count = debug_count + 1 - end -end - ---[[ - return index of element in a table ---]] -function table_index(t,el) - for i,v in ipairs(t) do - if v == el then - return i - end - end - return nil -end - ---[[ - return true if a table contains a given element ---]] -function table_contains(t,el) - local i = table_index(t, el) - return i ~= nil -end - -function is_hidden_dir(path) - return table_contains(HIDDEN_FOLDERS, path) -end - -local DAYS = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" } -local MONTHS = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" } - -function isdirectory(path) - local s = fs:stat(path) - return s and s:is_directory() -end - ---[[ - time string for directory listings ---]] -function file_timestring(path) - local s = fs:stat(path) - if not s then - return "" - end - local mtime = s:mtime() - mtime = mtime + BRD_RTC_TZ_MIN:get()*60 - local year, month, day, hour, min, sec, _ = rtc:clock_s_to_date_fields(mtime) - if not year then - return "" - end - return string.format("%04u-%02u-%02u %02u:%02u", year, month+1, day, hour, min, sec) -end - ---[[ - time string for Last-Modified ---]] -function file_timestring_http(mtime) - local year, month, day, hour, min, sec, wday = rtc:clock_s_to_date_fields(mtime) - if not year then - return "" - end - return string.format("%s, %02u %s %u %02u:%02u:%02u GMT", - DAYS[wday+1], - day, - MONTHS[month+1], - year, - hour, - min, - sec) -end - ---[[ - parse a http time string to a uint32_t seconds timestamp ---]] -function file_timestring_http_parse(tstring) - local dayname, day, monthname, year, hour, min, sec = - string.match(tstring, - '(%w%w%w), (%d+) (%w%w%w) (%d%d%d%d) (%d%d):(%d%d):(%d%d) GMT') - if not dayname then - return nil - end - local mon = table_index(MONTHS, monthname) - return rtc:date_fields_to_clock_s(year, mon-1, day, hour, min, sec) -end - ---[[ - return true if path exists and is not a directory ---]] -function file_exists(path) - local s = fs:stat(path) - if not s then - return false - end - return not s:is_directory() -end - ---[[ - substitute variables of form {xxx} from a table - from http://lua-users.org/wiki/StringInterpolation ---]] -function substitute_vars(s, vars) - s = (string.gsub(s, "({([^}]+)})", - function(whole,i) - return vars[i] or whole - end)) - return s -end - ---[[ - lat or lon as a string, working around limited type in ftoa_engine ---]] -function latlon_str(ll) - local ipart = tonumber(string.match(tostring(ll*1.0e-7), '(.*[.]).*')) - local fpart = math.abs(ll - ipart*10000000) - return string.format("%d.%u", ipart, fpart, ipart*10000000, ll) -end - ---[[ - location string for home page ---]] -function location_string(loc) - return substitute_vars([[{lat} {lon} {alt}]], - { ["lat"] = latlon_str(loc:lat()), - ["lon"] = latlon_str(loc:lng()), - ["alt"] = string.format("%.1fm", loc:alt()*1.0e-2) }) -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 - local start_time = millis() - local offset = 0 - - function self.read_header() - local s = sock:recv(2048) - if not s then - local now = millis() - if not sock:is_connected() or now - start_time > WEB_TIMEOUT:get()*1000 then - -- EOF while looking for header - DEBUG(string.format("%u: EOF", idx)) - self.remove() - return false - end - 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(string.format("%u: got header", idx)) - have_header = true - header_lines = split(header, "[^\r\n]+") - -- blocking for reply - sock:set_blocking(true) - 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} - --]] - function self.sendstring_vars(s, vars) - self.sendstring(substitute_vars(s, vars)) - 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) - local s = fs:stat(fname) - if not s then - return 0 - end - local ret = s:size():toint() - DEBUG(string.format("%u: size of '%s' -> %u", idx, fname, ret)) - return ret - end - - - --[[ - return full path with .. resolution - --]] - function self.full_path(path, name) - DEBUG(string.format("%u: full_path(%s,%s)", idx, 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, _ = 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("%u: full_path(%s,%s) -> %s", idx, path, name, ret)) - return ret - end - - function self.directory_list(path) - sock:set_blocking(true) - if startswith(path, "/@") then - path = string.sub(path, 2, #path-1) - end - DEBUG(string.format("%u: directory_list(%s)", idx, path)) - local dlist = dirlist(path) - if not dlist then - dlist = {} - end - if not table_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 not skip then - local fullpath = self.full_path(path, d) - local name = d - local sizestr = "0" - local stat = fs:stat(fullpath) - local size = stat and stat:size() or 0 - if is_hidden_dir(fullpath) or (stat and stat:is_directory()) then - name = name .. "/" - elseif size >= 100*1000*1000 then - sizestr = string.format("%uM", (size/(1000*1000)):toint()) - else - sizestr = tostring(size) - end - local modtime = file_timestring(fullpath) - self.sendstring_vars([[ -]], { name=name, size=sizestr, modtime=modtime }) - end - end - self.sendstring([[ -
NameLast modifiedSize
{name}{modtime}{size}
- - -]]) - end - - -- send file content - function self.send_file() - if not sock:pollout(0) then - return - end - local chunk = WEB_BLOCK_SIZE:get() - local b = file:read(chunk) - sock:set_blocking(true) - if b and #b > 0 then - local sent = sock:send(b, #b) - if sent == -1 then - run = nil - self.remove() - return - end - if sent < #b then - file:seek(offset+sent) - end - offset = offset + sent - end - if not b or #b < chunk then - -- EOF - DEBUG(string.format("%u: sent file", idx)) - run = nil - self.remove() - return - end - end - - --[[ - load whole file as a string - --]] - function self.load_file() - local chunk = WEB_BLOCK_SIZE:get() - local ret = "" - while true do - local b = file:read(chunk) - if not b or #b == 0 then - break - end - ret = ret .. b - end - return ret - end - - --[[ - evaluate some lua code and return as a string - --]] - function self.evaluate(code) - local eval_code = "function eval_func()\n" .. code .. "\nend\n" - local f, errloc, err = load(eval_code, "eval_func", "t", _ENV) - if not f then - DEBUG(string.format("load failed: err=%s errloc=%s", err, errloc)) - return nil - end - local success, err2 = pcall(f) - if not success then - DEBUG(string.format("pcall failed: err=%s", err2)) - return nil - end - local ok, s2 = pcall(eval_func) - eval_func = nil - if ok then - return s2 - end - return nil - end - - --[[ - process a file as a lua CGI - --]] - function self.send_cgi() - sock:set_blocking(true) - local contents = self.load_file() - local s = self.evaluate(contents) - if s then - self.sendstring(s) - end - self.remove() - end - - --[[ - send file content with server side processsing - files ending in .shtml can have embedded lua lika this: - - - - Using 'lstr' a return tostring(yourcode) is added to the code - automatically - --]] - function self.send_processed_file(dynamic_page) - sock:set_blocking(true) - local contents - if dynamic_page then - contents = file - else - contents = self.load_file() - end - while #contents > 0 do - local pat1 = "(.-)[<][?]lua[ \n](.-)[?][>](.*)" - local pat2 = "(.-)[<][?]lstr[ \n](.-)[?][>](.*)" - local p1, p2, p3 = string.match(contents, pat1) - if not p1 then - p1, p2, p3 = string.match(contents, pat2) - if not p1 then - break - end - p2 = "return tostring(" .. p2 .. ")" - end - self.sendstring(p1) - local s2 = self.evaluate(p2) - if s2 then - self.sendstring(s2) - end - contents = p3 - end - self.sendstring(contents) - self.remove() - end - - -- return a content type - function self.content_type(path) - if path == "/" then - return MIME_TYPES["html"] - end - local _, 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("%u: file_download(%s)", idx, path)) - file = DYNAMIC_PAGES[path] - dynamic_page = file ~= nil - if not dynamic_page then - file = io.open(path,"rb") - if not file then - DEBUG(string.format("%u: Failed to open '%s'", idx, path)) - return false - end - end - local vars = {["Content-Type"]=self.content_type(path)} - local cgi_processing = startswith(path, "/cgi-bin/") and endswith(path, ".lua") - local server_side_processing = endswith(path, ".shtml") - local stat = fs:stat(path) - if not startswith(path, "@") and - not server_side_processing and - not cgi_processing and stat and - not dynamic_page then - local fsize = stat:size() - local mtime = stat:mtime() - vars["Content-Length"]= tostring(fsize) - local modtime = file_timestring_http(mtime) - if modtime then - vars["Last-Modified"] = modtime - end - local if_modified_since = header_vars['If-Modified-Since'] - if if_modified_since then - local tsec = file_timestring_http_parse(if_modified_since) - if tsec and tsec >= mtime then - DEBUG(string.format("%u: Not modified: %s %s", idx, modtime, if_modified_since)) - self.send_header(304, "Not Modified", vars) - return true - end - end - end - self.send_header(200, "OK", vars) - if server_side_processing or dynamic_page then - DEBUG(string.format("%u: shtml processing %s", idx, path)) - run = self.send_processed_file(dynamic_page) - elseif cgi_processing then - DEBUG(string.format("%u: CGI processing %s", idx, path)) - run = self.send_cgi - elseif stat and - WEB_SENDFILE_MIN:get() > 0 and - stat:size() >= WEB_SENDFILE_MIN:get() and - sock:sendfile(file) then - return true - else - run = self.send_file - end - 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("%u: Redirect -> %s", idx, 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(string.format("%u: empty request", idx)) - 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("%u: path='%s'", idx, path)) - - -- extract header variables - for i = 2,#header_lines do - local key, var = string.match(header_lines[i], '(.*): (.*)') - if key then - header_vars[key] = var - end - end - - if DYNAMIC_PAGES[path] ~= nil then - self.file_download(path) - return - end - - if path == MNT_PREFIX then - path = "/" - end - if startswith(path, MNT_PREFIX2) then - path = string.sub(path,#MNT_PREFIX2,#path) - 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(string.format("%u: found index.html", idx)) - if self.file_download(path .. "/index.html") then - return - end - end - - -- see if it is a directory - if (path == "/" or - DYNAMIC_PAGES[path] == nil) and - (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("%u: removing client OFFSET=%u", idx, offset)) - if sock then - sock:close() - sock = nil - end - self.closed = true - end - - -- return the instance - return self -end - ---[[ - see if any new clients want to connect ---]] -local function check_new_clients() - while sock_listen:pollin(0) do - local sock = sock_listen:accept() - if not sock then - return - end - -- non-blocking for header read - sock:set_blocking(false) - -- find free client slot - for i = 1, #clients+1 do - if clients[i] == nil then - local idx = i - local client = Client(sock, idx) - DEBUG(string.format("%u: New client", idx)) - clients[idx] = client - end - end - 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 +web.printf(web.MAV_SEVERITY.INFO, "Starting web server") +web.add_board_status("Arm Status", 'arming:is_armed() and "ARMED" or "DISARMED"') +web.add_board_status("AHRS Location", 'location_string(ahrs:get_location())') +web.add_board_status("GPS Location", 'location_string(ahrs:get_location())') local function update() - check_new_clients() - check_clients() - return update,5 + web.update() + return update, 5 end -return update,100 +return update, 100 diff --git a/libraries/AP_Scripting/applets/net_webserver.md b/libraries/AP_Scripting/applets/net_webserver.md index fa45efe780b46..acfab57e94bee 100644 --- a/libraries/AP_Scripting/applets/net_webserver.md +++ b/libraries/AP_Scripting/applets/net_webserver.md @@ -1,91 +1 @@ -# Web Server Application - -This implements a web server for boards that have networking support. - -# Parameters - -The web server has a small number of parameters - -## WEB_ENABLE - -This must be set to 1 to enable the web server - -## WEB_BIND_PORT - -This sets the network port to use for the server. It defaults to 8080 - -## WEB_DEBUG - -This enables verbose debugging - -## WEB_BLOCK_SIZE - -This sets the block size for network and file read/write -operations. Setting a larger value can increase performance at the -cost of more memory - -## WEB_TIMEOUT - -This sets the timeout in seconds for inactive client connections. - -# Operation - -By default the web server serves the root of your microSD card. You -can include html, javascript (*.js), image files etc on your microSD -to create a full web server with any structure you want. - -## Server Side Scripting - -The web server supports embedding lua script elements inside html -files for files with a filename of *.shtml. Here is an example: - -``` - - - - - -

Server Side Scripting Test

- - - - - - - -
RollPitchYaw
- - -``` -In this example we are using two forms of embedded lua scripts. The -first form starts with "= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + [ "nil" ] = encode_nil, + [ "table" ] = encode_table, + [ "string" ] = encode_string, + [ "number" ] = encode_number, + [ "boolean" ] = tostring, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + +function json.encode(val) + return ( encode(val) ) +end + + +------------------------------------------------------------------------------- +-- Decode +------------------------------------------------------------------------------- + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[ select(i, ...) ] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + [ "true" ] = true, + [ "false" ] = false, + [ "null" ] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + local line_count = 1 + local col_count = 1 + for i = 1, idx - 1 do + col_count = col_count + 1 + if str:sub(i, i) == "\n" then + line_count = line_count + 1 + col_count = 1 + end + end + error( string.format("%s at line %d col %d", msg, line_count, col_count) ) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error( string.format("invalid unicode codepoint '%x'", n) ) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber( s:sub(1, 4), 16 ) + local n2 = tonumber( s:sub(7, 10), 16 ) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local res = "" + local j = i + 1 + local k = j + + while j <= #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + + elseif x == 92 then -- `\`: Escape + res = res .. str:sub(k, j - 1) + j = j + 1 + local c = str:sub(j, j) + if c == "u" then + local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1) + or str:match("^%x%x%x%x", j + 1) + or decode_error(str, j - 1, "invalid unicode escape in string") + res = res .. parse_unicode_escape(hex) + j = j + #hex + else + if not escape_chars[c] then + decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string") + end + res = res .. escape_char_map_inv[c] + end + k = j + 1 + + elseif x == 34 then -- `"`: End of string + res = res .. str:sub(k, j - 1) + return res, j + 1 + end + + j = j + 1 + end + + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + [ '"' ] = parse_string, + [ "0" ] = parse_number, + [ "1" ] = parse_number, + [ "2" ] = parse_number, + [ "3" ] = parse_number, + [ "4" ] = parse_number, + [ "5" ] = parse_number, + [ "6" ] = parse_number, + [ "7" ] = parse_number, + [ "8" ] = parse_number, + [ "9" ] = parse_number, + [ "-" ] = parse_number, + [ "t" ] = parse_literal, + [ "f" ] = parse_literal, + [ "n" ] = parse_literal, + [ "[" ] = parse_array, + [ "{" ] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +function json.decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + local res, idx = parse(str, next_char(str, 1, space_chars, true)) + idx = next_char(str, idx, space_chars, true) + if idx <= #str then + decode_error(str, idx, "trailing garbage") + end + return res +end + + +return json diff --git a/libraries/AP_Scripting/modules/webserver.lua b/libraries/AP_Scripting/modules/webserver.lua new file mode 100644 index 0000000000000..4e79ed0ed96a5 --- /dev/null +++ b/libraries/AP_Scripting/modules/webserver.lua @@ -0,0 +1,1090 @@ +--[[ + example script to test lua socket API +--]] +---@diagnostic disable: param-type-mismatch +---@diagnostic disable: undefined-field +---@diagnostic disable: need-check-nil +---@diagnostic disable: redundant-parameter + +local json = require('json') +local web = {} + +web.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, 1) + +--[[ + // @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) + +--[[ + // @Param: WEB_TIMEOUT + // @DisplayName: web server timeout + // @Description: timeout for inactive connections + // @Units: s + // @Range: 0.1 60 + // @User: Advanced +--]] +local WEB_TIMEOUT = bind_add_param('TIMEOUT', 5, 2.0) + +--[[ + // @Param: WEB_SENDFILE_MIN + // @DisplayName: web server minimum file size for sendfile + // @Description: sendfile is an offloading mechanism for faster file download. If this is non-zero and the file is larger than this size then sendfile will be used for file download + // @Range: 0 10000000 + // @User: Advanced +--]] +local WEB_SENDFILE_MIN = bind_add_param('SENDFILE_MIN', 6, 100000) + +local BRD_RTC_TZ_MIN = Parameter("BRD_RTC_TZ_MIN") + +if periph ~= nil then + function web.printf(severity, str) + return periph:can_printf_severity(severity, str) + end +else + function web.printf(severity, str) + return gcs:send_text(severity, str) + end +end + +if WEB_ENABLE:get() ~= 1 then + web.printf(web.MAV_SEVERITY.INFO, "WebServer: disabled") + return +end + +web.printf(web.MAV_SEVERITY.INFO, string.format("WebServer: starting on port %u", WEB_BIND_PORT:get())) + +local sock_listen = Socket(0) +local clients = {} + +local DOCTYPE = "" +local SERVER_VERSION = "net_webserver 1.0" +local CONTENT_TEXT_HTML = "text/html;charset=UTF-8" +local CONTENT_OCTET_STREAM = "application/octet-stream" + +local HIDDEN_FOLDERS = { "@SYS", "@ROMFS", "@MISSION", "@PARAM" } + +local MNT_PREFIX = "/mnt" +local MNT_PREFIX2 = MNT_PREFIX .. "/" + +local MIME_TYPES = { + ["apj"] = CONTENT_OCTET_STREAM, + ["dat"] = CONTENT_OCTET_STREAM, + ["o"] = CONTENT_OCTET_STREAM, + ["obj"] = CONTENT_OCTET_STREAM, + ["lua"] = "text/x-lua", + ["py"] = "text/x-python", + ["shtml"] = CONTENT_TEXT_HTML, + ["js"] = "text/javascript", + -- thanks to https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + ["aac"] = "audio/aac", + ["abw"] = "application/x-abiword", + ["arc"] = "application/x-freearc", + ["avif"] = "image/avif", + ["avi"] = "video/x-msvideo", + ["azw"] = "application/vnd.amazon.ebook", + ["bin"] = "application/octet-stream", + ["bmp"] = "image/bmp", + ["bz"] = "application/x-bzip", + ["bz2"] = "application/x-bzip2", + ["cda"] = "application/x-cdf", + ["csh"] = "application/x-csh", + ["css"] = "text/css", + ["csv"] = "text/csv", + ["doc"] = "application/msword", + ["docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ["eot"] = "application/vnd.ms-fontobject", + ["epub"] = "application/epub+zip", + ["gz"] = "application/gzip", + ["gif"] = "image/gif", + ["htm"] = CONTENT_TEXT_HTML, + ["html"] = CONTENT_TEXT_HTML, + ["ico"] = "image/vnd.microsoft.icon", + ["ics"] = "text/calendar", + ["jar"] = "application/java-archive", + ["jpeg"] = "image/jpeg", + ["json"] = "application/json", + ["jsonld"] = "application/ld+json", + ["mid"] = "audio/x-midi", + ["mjs"] = "text/javascript", + ["mp3"] = "audio/mpeg", + ["mp4"] = "video/mp4", + ["mpeg"] = "video/mpeg", + ["mpkg"] = "application/vnd.apple.installer+xml", + ["odp"] = "application/vnd.oasis.opendocument.presentation", + ["ods"] = "application/vnd.oasis.opendocument.spreadsheet", + ["odt"] = "application/vnd.oasis.opendocument.text", + ["oga"] = "audio/ogg", + ["ogv"] = "video/ogg", + ["ogx"] = "application/ogg", + ["opus"] = "audio/opus", + ["otf"] = "font/otf", + ["png"] = "image/png", + ["pdf"] = "application/pdf", + ["php"] = "application/x-httpd-php", + ["ppt"] = "application/vnd.ms-powerpoint", + ["pptx"] = "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ["rar"] = "application/vnd.rar", + ["rtf"] = "application/rtf", + ["sh"] = "application/x-sh", + ["svg"] = "image/svg+xml", + ["tar"] = "application/x-tar", + ["tif"] = "image/tiff", + ["tiff"] = "image/tiff", + ["ts"] = "video/mp2t", + ["ttf"] = "font/ttf", + ["txt"] = "text/plain", + ["vsd"] = "application/vnd.visio", + ["wav"] = "audio/wav", + ["weba"] = "audio/webm", + ["webm"] = "video/webm", + ["webp"] = "image/webp", + ["woff"] = "font/woff", + ["woff2"] = "font/woff2", + ["xhtml"] = "application/xhtml+xml", + ["xls"] = "application/vnd.ms-excel", + ["xlsx"] = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ["xml"] = "default.", + ["xul"] = "application/vnd.mozilla.xul+xml", + ["zip"] = "application/zip", + ["3gp"] = "video", + ["3g2"] = "video", + ["7z"] = "application/x-7z-compressed", +} + +--[[ + builtin dynamic pages +--]] +local DYNAMIC_PAGES = { + +-- main home page +["/"] = [[ + + + + + + ArduPilot + + + +

ArduPilot Web Server

+ +
+ +
+

Controller Status

+
+ + +]], + +-- board status section on front page +["@DYNAMIC/board_status.shtml"] = [[ + + + + +
Firmware
GIT Hash
Uptime
+]] +} + +--[[ + builtin javascript library functions +--]] +JS_LIBRARY = { + ["dynamic_load"] = [[ + function dynamic_load(div_id, uri, period_ms) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', uri); + xhr.setRequestHeader("Cache-Control", "no-cache, no-store, max-age=0"); + xhr.setRequestHeader("Expires", "Tue, 01 Jan 1980 1:00:00 GMT"); + xhr.setRequestHeader("Pragma", "no-cache"); + + xhr.onload = function () { + if (xhr.status === 200) { + var output = document.getElementById(div_id); + if (uri.endsWith('.shtml') || uri.endsWith('.html')) { + output.innerHTML = xhr.responseText; + } else { + output.textContent = xhr.responseText; + } + } + setTimeout(function() { dynamic_load(div_id,uri, period_ms); }, period_ms); + } + xhr.send(); + } +]], + ["populate_json_objects"] = [[ + function populate_json_objects(object_name, period_ms) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', '/' + object_name + '.json'); + + xhr.setRequestHeader("Cache-Control", "no-cache, no-store, max-age=0"); + xhr.setRequestHeader("Expires", "Tue, 01 Jan 1980 1:00:00 GMT"); + xhr.setRequestHeader("Pragma", "no-cache"); + + xhr.onload = function () { + if (xhr.status === 200) { + var json = JSON.parse(xhr.responseText); + var table = ""; + for (var key in json) { + table += ""; + } + table += "
" + key + "" + JSON.stringify(json[key]) + "
"; + output.innerHTML = table; + } + setTimeout(function() { populate_json_objects(period_ms); }, period_ms); + } + xhr.send(); + } +]] +} + +if not sock_listen:bind("0.0.0.0", WEB_BIND_PORT:get()) then + web.printf(web.MAV_SEVERITY.ERROR, string.format("WebServer: failed to bind to TCP %u", WEB_BIND_PORT:get())) + return +end + +if not sock_listen:listen(20) then + web.printf(web.MAV_SEVERITY.ERROR, "WebServer: failed to listen") + return +end + +function hms_uptime() + local s = (millis()/1000):toint() + local min = math.floor(s / 60) % 60 + local hr = math.floor(s / 3600) + return string.format("%u hours %u minutes %u seconds", hr, min, s%60) +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,len1) == s +end + +--[[ + return true if a string starts with the 2nd string +--]] +local function startswith(str, s) + return string.sub(str,1,#s) == s +end + +local debug_count=0 + +function DEBUG(txt) + if WEB_DEBUG:get() ~= 0 then + web.printf(web.MAV_SEVERITY.DEBUG, txt .. string.format(" [%u]", debug_count)) + debug_count = debug_count + 1 + end +end + +--[[ + return index of element in a table +--]] +function table_index(t,el) + for i,v in ipairs(t) do + if v == el then + return i + end + end + return nil +end + +--[[ + return true if a table contains a given element +--]] +function table_contains(t,el) + local i = table_index(t, el) + return i ~= nil +end + +function is_hidden_dir(path) + return table_contains(HIDDEN_FOLDERS, path) +end + +local DAYS = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" } +local MONTHS = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" } + +function isdirectory(path) + local s = fs:stat(path) + return s and s:is_directory() +end + +--[[ + time string for directory listings +--]] +function file_timestring(path) + local s = fs:stat(path) + if not s then + return "" + end + local mtime = s:mtime() + mtime = mtime + BRD_RTC_TZ_MIN:get()*60 + local year, month, day, hour, min, sec, _ = rtc:clock_s_to_date_fields(mtime) + if not year then + return "" + end + return string.format("%04u-%02u-%02u %02u:%02u", year, month+1, day, hour, min, sec) +end + +--[[ + time string for Last-Modified +--]] +function file_timestring_http(mtime) + local year, month, day, hour, min, sec, wday = rtc:clock_s_to_date_fields(mtime) + if not year then + return "" + end + return string.format("%s, %02u %s %u %02u:%02u:%02u GMT", + DAYS[wday+1], + day, + MONTHS[month+1], + year, + hour, + min, + sec) +end + +--[[ + parse a http time string to a uint32_t seconds timestamp +--]] +function file_timestring_http_parse(tstring) + local dayname, day, monthname, year, hour, min, sec = + string.match(tstring, + '(%w%w%w), (%d+) (%w%w%w) (%d%d%d%d) (%d%d):(%d%d):(%d%d) GMT') + if not dayname then + return nil + end + local mon = table_index(MONTHS, monthname) + return rtc:date_fields_to_clock_s(year, mon-1, day, hour, min, sec) +end + +--[[ + return true if path exists and is not a directory +--]] +function file_exists(path) + local s = fs:stat(path) + if not s then + return false + end + return not s:is_directory() +end + +--[[ + substitute variables of form {xxx} from a table + from http://lua-users.org/wiki/StringInterpolation +--]] +function substitute_vars(s, vars) + s = (string.gsub(s, "({([^}]+)})", + function(whole,i) + return vars[i] or whole + end)) + return s +end + +--[[ + lat or lon as a string, working around limited type in ftoa_engine +--]] +function latlon_str(ll) + local ipart = tonumber(string.match(tostring(ll*1.0e-7), '(.*[.]).*')) + local fpart = math.abs(ll - ipart*10000000) + return string.format("%d.%u", ipart, fpart, ipart*10000000, ll) +end + +--[[ + location string for home page +--]] +function location_string(loc) + return substitute_vars([[{lat} {lon} {alt}]], + { ["lat"] = latlon_str(loc:lat()), + ["lon"] = latlon_str(loc:lng()), + ["alt"] = string.format("%.1fm", loc:alt()*1.0e-2) }) +end + +--[[ + evaluate some lua code and return as a string +--]] +function web.evaluate(code) + local eval_code = "function eval_func()\n" .. code .. "\nend\n" + local f, errloc, err = load(eval_code, "eval_func", "t", _ENV) + if not f then + DEBUG(string.format("load failed: err=%s errloc=%s", err, errloc)) + return nil + end + local success, err2 = pcall(f) + if not success then + DEBUG(string.format("pcall failed: err=%s", err2)) + return nil + end + local ok, s2 = pcall(eval_func) + eval_func = nil + if ok then + return s2 + end + return nil +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 + local start_time = millis() + local offset = 0 + + function self.read_header() + local s = sock:recv(2048) + if not s then + local now = millis() + if not sock:is_connected() or now - start_time > WEB_TIMEOUT:get()*1000 then + -- EOF while looking for header + DEBUG(string.format("%u: EOF", idx)) + self.remove() + return false + end + 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(string.format("%u: got header", idx)) + have_header = true + header_lines = split(header, "[^\r\n]+") + -- blocking for reply + sock:set_blocking(true) + 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} + --]] + function self.sendstring_vars(s, vars) + self.sendstring(substitute_vars(s, vars)) + 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) + local s = fs:stat(fname) + if not s then + return 0 + end + local ret = s:size():toint() + DEBUG(string.format("%u: size of '%s' -> %u", idx, fname, ret)) + return ret + end + + + --[[ + return full path with .. resolution + --]] + function self.full_path(path, name) + DEBUG(string.format("%u: full_path(%s,%s)", idx, path, name)) + local ret = path + if path == "/" and startswith(name,"@") then + return name + end + if name == ".." then + if path == "/" then + end + if endswith(path,"/") then + path = string.sub(path, 1, #path-1) + end + local dir, _ = 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("%u: full_path(%s,%s) -> %s", idx, path, name, ret)) + return ret + end + + function self.directory_list(path) + sock:set_blocking(true) + if startswith(path, "/@") then + path = string.sub(path, 2, #path-1) + end + DEBUG(string.format("%u: directory_list(%s)", idx, path)) + local dlist = dirlist(path) + if not dlist then + dlist = {} + end + if not table_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 not skip then + local fullpath = self.full_path(path, d) + local name = d + local sizestr = "0" + local stat = fs:stat(fullpath) + local size = stat and stat:size() or 0 + if is_hidden_dir(fullpath) or (stat and stat:is_directory()) then + name = name .. "/" + elseif size >= 100*1000*1000 then + sizestr = string.format("%uM", (size/(1000*1000)):toint()) + else + sizestr = tostring(size) + end + local modtime = file_timestring(fullpath) + self.sendstring_vars([[ +]], { name=name, size=sizestr, modtime=modtime }) + end + end + self.sendstring([[ +
NameLast modifiedSize
{name}{modtime}{size}
+ + +]]) + end + + -- send file content + function self.send_file() + if not sock:pollout(0) then + return + end + local chunk = WEB_BLOCK_SIZE:get() + local b = file:read(chunk) + sock:set_blocking(true) + if b and #b > 0 then + local sent = sock:send(b, #b) + if sent == -1 then + run = nil + self.remove() + return + end + if sent < #b then + file:seek(offset+sent) + end + offset = offset + sent + end + if not b or #b < chunk then + -- EOF + DEBUG(string.format("%u: sent file", idx)) + run = nil + self.remove() + return + end + end + + --[[ + load whole file as a string + --]] + function self.load_file() + local chunk = WEB_BLOCK_SIZE:get() + local ret = "" + while true do + local b = file:read(chunk) + if not b or #b == 0 then + break + end + ret = ret .. b + end + return ret + end + + --[[ + process a file as a lua CGI + --]] + function self.send_cgi() + sock:set_blocking(true) + local contents = self.load_file() + local s = web.evaluate(contents) + if s then + self.sendstring(s) + end + self.remove() + end + + --[[ + send file content with server side processsing + files ending in .shtml can have embedded lua lika this: + + + + Using 'lstr' a return tostring(yourcode) is added to the code + automatically + --]] + function self.send_processed_file(dynamic_page) + sock:set_blocking(true) + local contents + if dynamic_page then + contents = file + else + contents = self.load_file() + end + while #contents > 0 do + local pat1 = "(.-)[<][?]lua[ \n](.-)[?][>](.*)" + local pat2 = "(.-)[<][?]lstr[ \n](.-)[?][>](.*)" + local p1, p2, p3 = string.match(contents, pat1) + if not p1 then + p1, p2, p3 = string.match(contents, pat2) + if not p1 then + break + end + p2 = "return tostring(" .. p2 .. ")" + end + self.sendstring(p1) + local s2 = web.evaluate(p2) + if s2 then + self.sendstring(s2) + end + contents = p3 + end + self.sendstring(contents) + self.remove() + end + + -- return a content type + function self.content_type(path) + if path == "/" then + return MIME_TYPES["html"] + end + local _, 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("%u: file_download(%s)", idx, path)) + file = DYNAMIC_PAGES[path] + dynamic_page = file ~= nil + if not dynamic_page then + file = io.open(path,"rb") + if not file then + DEBUG(string.format("%u: Failed to open '%s'", idx, path)) + return false + end + end + local vars = {["Content-Type"]=self.content_type(path)} + local cgi_processing = startswith(path, "/cgi-bin/") and endswith(path, ".lua") + local server_side_processing = endswith(path, ".shtml") + local stat = fs:stat(path) + if not startswith(path, "@") and + not server_side_processing and + not cgi_processing and stat and + not dynamic_page then + local fsize = stat:size() + local mtime = stat:mtime() + vars["Content-Length"]= tostring(fsize) + local modtime = file_timestring_http(mtime) + if modtime then + vars["Last-Modified"] = modtime + end + local if_modified_since = header_vars['If-Modified-Since'] + if if_modified_since then + local tsec = file_timestring_http_parse(if_modified_since) + if tsec and tsec >= mtime then + DEBUG(string.format("%u: Not modified: %s %s", idx, modtime, if_modified_since)) + self.send_header(304, "Not Modified", vars) + return true + end + end + end + self.send_header(200, "OK", vars) + if server_side_processing or dynamic_page then + DEBUG(string.format("%u: shtml processing %s", idx, path)) + run = self.send_processed_file(dynamic_page) + elseif cgi_processing then + DEBUG(string.format("%u: CGI processing %s", idx, path)) + run = self.send_cgi + elseif stat and + WEB_SENDFILE_MIN:get() > 0 and + stat:size() >= WEB_SENDFILE_MIN:get() and + sock:sendfile(file) then + return true + else + run = self.send_file + end + 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("%u: Redirect -> %s", idx, 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(string.format("%u: empty request", idx)) + 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("%u: path='%s'", idx, path)) + + -- extract header variables + for i = 2,#header_lines do + local key, var = string.match(header_lines[i], '(.*): (.*)') + if key then + header_vars[key] = var + end + end + + if DYNAMIC_PAGES[path] ~= nil then + self.file_download(path) + return + end + + if path == MNT_PREFIX then + path = "/" + end + if startswith(path, MNT_PREFIX2) then + path = string.sub(path,#MNT_PREFIX2,#path) + 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(string.format("%u: found index.html", idx)) + if self.file_download(path .. "/index.html") then + return + end + end + + -- see if it is a directory + if (path == "/" or + DYNAMIC_PAGES[path] == nil) and + (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("%u: removing client OFFSET=%u", idx, offset)) + if sock then + sock:close() + sock = nil + end + self.closed = true + end + + -- return the instance + return self +end + +--[[ + see if any new clients want to connect +--]] +local function check_new_clients() + while sock_listen:pollin(0) do + local sock = sock_listen:accept() + if not sock then + return + end + -- non-blocking for header read + sock:set_blocking(false) + -- find free client slot + for i = 1, #clients+1 do + if clients[i] == nil then + local idx = i + local client = Client(sock, idx) + DEBUG(string.format("%u: New client", idx)) + clients[idx] = client + end + end + 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 + +function web.update() + check_new_clients() + check_clients() +end + +local sections = {} + +function web.add_board_status(field, func) + table.insert(user_board_status, {field=field, func=func}) +end + +function custom_board_status() + -- run through the user board status functions + local ret = "" + for _,board_status in pairs(user_board_status) do + run = "return tostring(" .. board_status.func .. ")" + ret = ret .. string.format("%s%s\n", board_status.field, web.evaluate(run)) + end + return ret +end + +function web.Sections() + local self = {} + local _section_table = {} + + function self.add_to_table(section_table, section) + for _,v in ipairs(section_table) do + if v.section == section then + return + end + end + table.insert(section_table, {section=section, values={}}) + end + + function self.add_split_field_to_section_table(section_table, fields, fmt, values) + if #fields == 0 then + return + end + + local section = fields[1] + if #fields > 2 then + for i,v in ipairs(section_table) do + if v.section == section then + -- this is an existing section + self.add_split_field_to_section_table(v.values, {table.unpack(fields, 2)}, fmt, values) + return + end + end + -- this is a new section + table.insert(section_table, {section=section, values={}}) + self.add_split_field_to_section_table(section_table[#section_table].values, {table.unpack(fields, 2)}, fmt, values) + return + else + for i,v in ipairs(section_table) do + if v.section == section then + -- this is an existing section + local field = {field=fields[2], fmt=fmt, values=values} + table.insert(v.values, field) + return + end + end + -- this is a new section + table.insert(section_table, {section=section, values={}}) + self.add_field_to_table(section_table[#section_table].values, fields[2], fmt, values) + return + end + end + + function self.add(section) + self.add_to_table(_section_table, section) + return self + end + + function self.add_field(field, fmt, values) + local fields = split(field, "[^:]+") + self.add_section_field_to_table(_section_table, field, fmt, values, 0) + return self + end + + return self +end + +function populate_sections() + local ret = "" + for _,section in ipairs(sections) do + ret = ret .. string.format("\n
\n%s\n
\n", section.title, section.content) + end + return ret +end + +return web diff --git a/libraries/AP_Scripting/modules/webserver.md b/libraries/AP_Scripting/modules/webserver.md new file mode 100644 index 0000000000000..fa45efe780b46 --- /dev/null +++ b/libraries/AP_Scripting/modules/webserver.md @@ -0,0 +1,91 @@ +# Web Server Application + +This implements a web server for boards that have networking support. + +# Parameters + +The web server has a small number of parameters + +## WEB_ENABLE + +This must be set to 1 to enable the web server + +## WEB_BIND_PORT + +This sets the network port to use for the server. It defaults to 8080 + +## WEB_DEBUG + +This enables verbose debugging + +## WEB_BLOCK_SIZE + +This sets the block size for network and file read/write +operations. Setting a larger value can increase performance at the +cost of more memory + +## WEB_TIMEOUT + +This sets the timeout in seconds for inactive client connections. + +# Operation + +By default the web server serves the root of your microSD card. You +can include html, javascript (*.js), image files etc on your microSD +to create a full web server with any structure you want. + +## Server Side Scripting + +The web server supports embedding lua script elements inside html +files for files with a filename of *.shtml. Here is an example: + +``` + + + + + +

Server Side Scripting Test

+ + + + + + + +
RollPitchYaw
+ + +``` +In this example we are using two forms of embedded lua scripts. The +first form starts with "