doodle3d-firmware/src/script/d3d-updater.lua

669 lines
23 KiB
Lua
Executable File

#!/usr/bin/env lua
-- TODO/NOTES:
-- add to status: validImage: none|<version> (can use checkValidImage for this)
-- any more TODO's across this file?
-- max 1 image tegelijk (moet api doen), en rekening houden met printbuffer (printen blokkeren?)
-- MAYBE/LATER:
-- add API calls to retrieve a list of all versions with their info (i.e., the result of getAvailableVersions)
-- wget: add provision (in verbose mode?) to use -v instead of -q and disable output redirection
-- document index file format (Version first, then in any order: Files: sysup; factory, FileSize: sysup; factory, MD5: sysup; factory, ChangelogStart:, ..., ChangelogEnd:)
-- remove /etc/wifibox-version on macbook...
-- copy improved fileSize back to utils (add unit tests!)
-- create new utils usable by updater as well as api? (remove dependencies on uci and logger etc)
-- note: take care not to print any text in module functions, as this breaks http responses
-- change representation of sysupgrade/factory info in versionInfo? (and also in image index?) <- create api call to get all info on all versions?
local M = {}
-- NOTE: 'INSTALLED' will never be returned (and probably neither will 'INSTALLING') since in that case the device is flashing or rebooting
M.STATE = { NONE = 1, DOWNLOADING = 2, DOWNLOAD_FAILED = 3, IMAGE_READY = 4, INSTALLING = 5, INSTALLED = 6, INSTALL_FAILED = 7 }
M.STATE_NAMES = {
[M.STATE.NONE] = 'none', [M.STATE.DOWNLOADING] = 'downloading', [M.STATE.DOWNLOAD_FAILED] = 'download_failed', [M.STATE.IMAGE_READY] = 'image_ready',
[M.STATE.INSTALLING] = 'installing', [M.STATE.INSTALLED] = 'installed', [M.STATE.INSTALL_FAILED] = 'install_failed'
}
M.DEFAULT_BASE_URL = 'http://doodle3d.com/updates'
--M.DEFAULT_BASE_URL = 'http://localhost/~USERNAME/wifibox/updates'
M.IMAGE_INDEX_FILE = 'wifibox-image.index'
M.CACHE_PATH = '/tmp/d3d-updater'
M.STATE_FILE = 'update-state'
M.WGET_OPTIONS = "-q -t 1 -T 30"
--M.WGET_OPTIONS = "-v -t 1 -T 30"
local verbosity = 0 -- set by parseCommandlineArguments()
local log = nil -- wifibox API can use M.setLogger to enable this module to use its logger
local useCache = true -- default, can be overwritten using M.setUseCache()
local baseUrl = M.DEFAULT_BASE_URL -- default, can be overwritten by M.setBaseUrl()
---------------------
-- LOCAL FUNCTIONS --
---------------------
-- use level==1 for important messages, 0 for regular messages and -1 for less important messages
local function P(lvl, msg)
if log then
if lvl == -1 then log:debug(msg)
elseif lvl == 0 or lvl == 1 then log:info(msg)
end
else
if (-lvl <= verbosity) then print(msg) end
end
end
local function D(msg) P(-1, (log and msg or "(DBG) " .. msg)) end
local function E(msg)
if log then log:error(msg)
else io.stderr:write(msg .. '\n')
end
end
-- splits the return status from os.execute (see: http://stackoverflow.com/questions/16158436/how-to-shift-and-mask-bits-from-integer-in-lua)
local function splitExitStatus(exitStatus)
if exitStatus == -1 then return -1,-1 end
local cmdStatus = math.floor(exitStatus / 256)
local systemStatus = exitStatus - cmdStatus * 256
return cmdStatus, systemStatus
end
local function wgetStatusToString(exitStatus)
local wgetStatus,systemStatus = splitExitStatus(exitStatus)
if systemStatus ~= 0 then
return "interrupted:" .. systemStatus
end
-- adapted from man(1) wget on OSX
local statusTexts = {
['0'] = 'Ok',
['1'] = 'Generic error',
['2'] = 'Parse error', -- for instance, when parsing command-line options, the .wgetrc or .netrc...
['3'] = 'File I/O error',
['4'] = 'Network failure',
['5'] = 'SSL verification failure',
['6'] = 'Username/password authentication failure',
['7'] = 'Protocol error',
['8'] = 'Server issued an error response'
}
local result = statusTexts[tostring(wgetStatus)]
if result then return exitStatus .. ": " .. result
else return exitStatus
end
end
local function createCacheDirectory()
if os.execute('mkdir -p ' .. M.CACHE_PATH) ~= 0 then
return nil,"Error: could not create cache directory '" .. M.CACHE_PATH .. "'"
end
return true
end
local function getState()
local file,msg = io.open(M.CACHE_PATH .. '/' .. M.STATE_FILE, 'r')
if not file then return M.STATE.NONE,"" end
local state = file:read('*a')
file:close()
local code,msg = string.match(state, '([^|]+)|+(.*)')
return code,msg
end
-- trim whitespace from both ends of string (from http://snippets.luacode.org/?p=snippets/trim_whitespace_from_string_76)
local function trim(s)
if type(s) ~= 'string' then return s end
return (s:find('^%s*$') and '' or s:match('^%s*(.*%S)'))
end
-- from utils.lua
local function readFile(filePath, trimResult)
local f, msg, nr = io.open(filePath, 'r')
if not f then return nil,msg,nr end
local res = f:read('*all')
f:close()
if trimResult then
res = trim(res)
end
return res
end
-- from utils.lua
local function exists(file)
if not file or type(file) ~= 'string' or file:len() == 0 then
return nil, "file must be a non-empty string"
end
local r = io.open(file, 'r') -- ignore returned message
if r then r:close() end
return r ~= nil
end
-- from utils.lua
--argument: either an open file or a filename
local function fileSize(file)
local size = nil
if type(file) == 'file' then
local current = file:seek()
size = file:seek('end')
file:seek('set', current)
elseif type(file) == 'string' then
local f = io.open(file)
if f then
size = f:seek('end')
f:close()
end
end
return size
end
-- returns return value of command
local function runCommand(command, dryRun)
D("about to run: '" .. command .. "'")
return (not dryRun) and os.execute(command) or -1
end
local function removeFile(filePath)
return runCommand('rm ' .. filePath)
end
-- returns return value of wget (or nil if saveDir is nil or empty), filename is optional
-- NOTE: leaving out filename will cause issues with files not being overwritten but suffixed with '.1', '.2',etc instead
local function downloadFile(url, saveDir, filename)
if not saveDir or saveDir:len() == 0 then return nil, "saveDir must be non-empty" end
local outArg = (filename:len() > 0) and (' -O' .. filename) or ''
if filename:len() > 0 then
return runCommand('wget ' .. M.WGET_OPTIONS .. ' -O ' .. saveDir .. '/' .. filename .. ' ' .. url .. ' 2> /dev/null')
else
return runCommand('wget ' .. M.WGET_OPTIONS .. ' -P ' .. saveDir .. ' ' .. url .. ' 2> /dev/null')
end
end
local function parseCommandlineArguments(arglist)
local result = { verbosity = 0, baseUrl = M.DEFAULT_BASE_URL, action = nil }
local nextIsVersion, nextIsUrl = false, false
for index,argument in ipairs(arglist) do
if nextIsVersion then
result.version = argument; nextIsVersion = false
elseif nextIsUrl then
result.baseUrl = argument; nextIsUrl = false
else
if argument == '-h' then result.action = 'showHelp'
elseif argument == '-q' then result.verbosity = -1
elseif argument == '-V' then result.verbosity = 1
elseif argument == '-c' then result.useCache = true
elseif argument == '-C' then result.useCache = false
elseif argument == '-u' then nextIsUrl = true
elseif argument == '-v' then result.action = 'showCurrentVersion'
elseif argument == '-s' then result.action = 'showStatus'
elseif argument == '-l' then result.action = 'showAvailableVersions'
elseif argument == '-i' then result.action = 'showVersionInfo'; nextIsVersion = true
elseif argument == '-d' then result.action = 'imageDownload'; nextIsVersion = true
elseif argument == '-f' then result.action = 'imageInstall'; nextIsVersion = true
elseif argument == '-r' then result.action = 'clear'
else return nil,"unrecognized argument '" .. argument .. "'"
end
end
end
if result.version then
result.version = M.parseVersion(result.version)
if not result.version then
return nil,"error parsing specified version"
end
end
if nextIsVersion then return nil, "missing required version argument" end
if nextIsUrl then return nil, "missing required URL argument" end
return result
end
----------------------
-- MODULE FUNCTIONS --
----------------------
function M.setLogger(logger)
log = logger
end
function M.setUseCache(use)
useCache = use
end
function M.setBaseUrl(url)
baseUrl = url
end
function M.getStatus()
if not baseUrl then baseUrl = M.DEFAULT_BASE_URL end
local unknownVersion = { major = 0, minor = 0, patch = 0 }
local result = {}
result.currentVersion = M.getCurrentVersion()
result.stateCode, result.stateText = getState()
result.stateCode = tonumber(result.stateCode)
local verTable,msg = M.getAvailableVersions()
if not verTable then
D("could not obtain available versions (" .. msg .. ")")
-- TODO: set an error state in result to signify we probably do not have internet access?
return false, result, msg
end
local newest = verTable and verTable[#verTable]
result.newestVersion = newest and newest.version or unknownVersion
if result.stateCode == M.STATE.DOWNLOADING then
result.progress = fileSize(M.CACHE_PATH .. '/' .. newest.sysupgradeFilename)
if not result.progress then result.progress = 0 end -- in case the file does not exist yet (which yields nil)
result.imageSize = newest.sysupgradeFileSize
end
return true, result
end
-- Turns a plain-text version into a table.
-- tables as argument are ignored so you can safely pass in an already parsed
-- version and expect it back unmodified.
function M.parseVersion(versionText)
if type(versionText) == 'table' then return versionText end
if not versionText or versionText:len() == 0 then return nil end
local major,minor,patch = versionText:match("^%s*(%d+)%.(%d+)%.(%d+)%s*$")
if not major or not minor or not patch then return nil end
return { ['major'] = major, ['minor'] = minor, ['patch'] = patch }
end
-- Formats a version as returned by parseVersion().
-- Strings are returned unmodified, so an 'already formatted' version can be
-- passed in safely and expected back unmodified.
function M.formatVersion(version)
if type(version) == 'string' then return version end
return version.major .. "." .. version.minor .. "." .. version.patch
end
-- expects two tables as created by M.parseVersion()
function M.compareVersions(versionA, versionB)
if type(versionA) ~= 'table' or type(versionB) ~= 'table' then return nil end
local diff = versionA.major - versionB.major
if diff == 0 then diff = versionA.minor - versionB.minor end
if diff == 0 then diff = versionA.patch - versionB.patch end
return diff > 0 and 1 or (diff < 0 and -1 or 0)
end
-- verTable is optional, getAvailableVersions will be used to obtain it if nil
function M.findVersion(version, verTable)
local msg = nil
version = M.parseVersion(version)
if not verTable then verTable,msg = M.getAvailableVersions() end
if not verTable then return nil,msg end
for _,ent in pairs(verTable) do
if M.compareVersions(ent.version, version) == 0 then return ent end
end
return nil,"no such version"
end
-- version may be a table or a string, devtype and isFactory are optional
function M.constructImageFilename(version, devType, isFactory)
local sf = isFactory and 'factory' or 'sysupgrade'
local v = M.formatVersion(version)
local dt = devType and devType or 'tl-mr3020'
return 'doodle3d-wifibox-' .. M.formatVersion(v) .. '-' .. dt .. '-' .. sf .. '.bin'
end
--TODO: move up to locals
local function md5sum(filepath)
-- TODO [osx: md5 -q <file>], [linux: ?]
end
function M.checkValidImage(versionEntry, devType, isFactory)
local filename = M.constructImageFilename(versionEntry.version, devType, isFactory)
--return versionEntry.md5 == md5sum(M.CACHE_PATH .. '/' .. filename)
local size = fileSize(M.CACHE_PATH .. '/' .. filename)
versionEntry.isValid = versionEntry.sysupgradeFileSize == size
return versionEntry.isValid
end
-- returns a plain text version
function M.getCurrentVersionText()
local res,msg,nr = readFile('/etc/wifibox-version', true)
if res then return res else return nil,msg,nr end
end
-- returns a table with major, minor and patch as keys
function M.getCurrentVersion()
local vt,msg = M.getCurrentVersionText()
return vt and M.parseVersion(vt) or nil,msg
end
-- returns an indexed (and sorted) table containing version tables
function M.getAvailableVersions()
if not baseUrl then baseUrl = M.DEFAULT_BASE_URL end
local indexFilename = M.CACHE_PATH .. '/' .. M.IMAGE_INDEX_FILE
local ccRv,ccMsg = createCacheDirectory()
if not ccRv then return nil,ccMsg end
if not useCache or not exists(indexFilename) then
local rv = downloadFile(baseUrl .. '/images/' .. M.IMAGE_INDEX_FILE, M.CACHE_PATH, M.IMAGE_INDEX_FILE)
if rv ~= 0 then return nil,"could not download image index file (" .. wgetStatusToString(rv) .. ")" end
end
local status,idxLines = pcall(io.lines, indexFilename)
if not status then return nil,"could not open image index file '" .. indexFilename .. "'" end --do not include io.lines error message
local result,entry = {}, nil
local lineno,changelogMode = 1, false
for line in idxLines do
local k,v = line:match('^(.-):(.*)$')
k,v = trim(k), trim(v)
if not log then D("#" .. lineno .. ": considering '" .. line .. "' (" .. (k or '<nil>') .. " / " .. (v or '<nil>') .. ")") end
if not changelogMode and (not k or not v) then return nil,"incorrectly formatted line in index file (line " .. lineno .. ")" end
if k == 'ChangelogEnd' then
changelogMode = false
elseif changelogMode then
entry.changelog = entry.changelog .. line .. '\n'
else
if k == 'Version' then
if entry ~= nil then table.insert(result, entry) end
local pv = M.parseVersion(v)
if not pv then return nil,"incorrect version text in index file (line " .. lineno .. ")" end
entry = { version = pv }
elseif k == 'ChangelogStart' then
changelogMode = true
entry.changelog = ""
elseif k == 'Files' then
local sName,fName = v:match('^(.-);(.*)$')
sName,fName = trim(sName), trim(fName)
if sName then entry.sysupgradeFilename = sName end
if fName then entry.factoryFilename = fName end
elseif k == 'FileSize' then
local sSize,fSize = v:match('^(.-);(.*)$')
sSize,fSize = trim(sSize), trim(fSize)
if sSize then entry.sysupgradeFileSize = tonumber(sSize) end
if fSize then entry.factoryFileSize = tonumber(fSize) end
elseif k == 'MD5' then
local sSum,fSum = v:match('^(.-);(.*)$')
sSum,fSum = trim(sSum), trim(fSum)
if sSum then entry.sysupgradeMD5 = sSum end
if fSum then entry.factoryMD5 = fSum end
else
P(-1, "ignoring unrecognized field in index file '" .. k .. "' (line " .. lineno .. ")")
end
end
lineno = lineno + 1
end
if entry ~= nil then table.insert(result, entry) end
table.sort(result, function(a,b)
return M.compareVersions(a.version,b.version) < 0
end)
return result
end
-- devtype and isFactory are optional
-- returns true or nil+msg or nil + return value from wget
function M.downloadImageFile(versionEntry, devType, isFactory)
if not baseUrl then baseUrl = M.DEFAULT_BASE_URL end
local filename = M.constructImageFilename(versionEntry.version, devType, isFactory)
local doDownload = not useCache
local ccRv,ccMsg = createCacheDirectory()
if not ccRv then return nil,ccMsg end
if versionEntry.isValid == false then
doDownload = true
elseif versionEntry.isValid == nil then
M.checkValidImage(versionEntry, devType, isFactory)
if versionEntry.isValid == false then doDownload = true end
end
local rv = 0
if doDownload then
M.setState(M.STATE.DOWNLOADING, "Downloading image (" .. filename .. ")")
rv = downloadFile(baseUrl .. '/images/' .. filename, M.CACHE_PATH, filename)
end
if rv == 0 then
if M.checkValidImage(versionEntry, devType, isFactory) then
M.setState(M.STATE.IMAGE_READY, "Image downloaded, ready to install (image name: " .. filename .. ")")
return true
else
removeFile(M.CACHE_PATH .. '/' .. filename)
local ws = "Image download failed (invalid image)"
M.setState(M.STATE.DOWNLOAD_FAILED, ws)
return nil,ws
end
else
local ws = wgetStatusToString(rv)
removeFile(M.CACHE_PATH .. '/' .. filename)
M.setState(M.STATE.DOWNLOAD_FAILED, "Image download failed (wget error: " .. ws .. ")")
return nil,ws
end
end
-- this function will not return if everything goes to plan
-- noRetain, devType and isFactory are optional
-- returns true or nil + wget return value
function M.flashImageVersion(versionEntry, noRetain, devType, isFactory)
log:info("flashImageVersion")
local imgName = M.constructImageFilename(versionEntry.version, devType, isFactory)
local cmd = noRetain and 'sysupgrade -n ' or 'sysupgrade '
cmd = cmd .. M.CACHE_PATH .. '/' .. imgName
local ccRv,ccMsg = createCacheDirectory()
if not ccRv then return nil,ccMsg end
if not M.checkValidImage(versionEntry) then
return nil,"no valid image for requested version present"
end
M.setState(M.STATE.INSTALLING, "Installing new image (" .. imgName .. ")") -- yes this is rather pointless
local rv = runCommand(cmd) -- if everything goes to plan, this will not return
if rv == 0 then
M.setState(M.STATE.INSTALLED, "Image installed")
else
-- NOTE: if cmdrv == 127, this means the command was not found
local cmdrv,sysrv = splitExitStatus(rv)
M.setState(M.STATE.INSTALL_FAILED, "Image installation failed (sysupgrade returned " .. cmdrv .. ", execution status: " .. sysrv .. ")")
end
return (rv == 0) and true or nil,rv
end
--returns true on success, or nil+msg otherwise
function M.clear()
local ccRv,ccMsg = createCacheDirectory()
if not ccRv then return nil,ccMsg end
D("Removing " .. M.CACHE_PATH .. "/doodle3d-wifibox-*.bin")
M.setState(M.STATE.NONE, "")
local rv = os.execute('rm -f ' .. M.CACHE_PATH .. '/doodle3d-wifibox-*.bin')
return (rv == 0) and true or nil,"could not remove image files"
end
-- NOTE: make sure the cache directory exists before calling this function or it will fail.
-- NOTE: this function _can_ fail but we don't expect this to happen so the return value is ignored for now
function M.setState(code, msg)
local s = code .. '|' .. msg
D("set update state: " .. M.STATE_NAMES[code] .. " ('" .. s .. "')")
local file,msg = io.open(M.CACHE_PATH .. '/' .. M.STATE_FILE, 'w')
if not file then
E("error: could not open state file for writing (" .. msg .. ")")
return false
end
file:write(s)
file:close()
return true
end
----------
-- MAIN --
----------
local function main()
local argTable,msg = parseCommandlineArguments(arg)
if not argTable then
E("error interpreting command-line arguments, try '-h' for help (".. msg ..")")
os.exit(1)
end
verbosity = argTable.verbosity
if argTable.useCache ~= nil then useCache = argTable.useCache end
P(0, "Doodle3D Wifibox firmware updater")
local cacheCreated,msg = createCacheDirectory()
if not cacheCreated then
E(msg)
os.exit(1)
end
if argTable.action == 'showHelp' then
P(1, "\t-h\t\tShow this help message")
P(1, "\t-q\t\tquiet mode")
P(1, "\t-V\t\tverbose mode")
P(1, "\t-c\t\tUse cache as much as possible")
P(1, "\t-C\t\tDo not use the cache")
P(1, "\t-u <base_url>\tUse specified base URL (default: " .. M.DEFAULT_BASE_URL .. ")")
P(1, "\t-v\t\tShow current image version")
P(1, "\t-s\t\tShow current update status")
P(1, "\t-l\t\tShow list of available image versions (and which one has been downloaded, if any)")
P(1, "\t-i <version>\tShow information (changelog) about the requested image version")
P(1, "\t-d <version>\tDownload requested image version")
P(1, "\t-f <version>\tFlash to requested image version (by means of sysupgrade)")
P(1, "\t-r\t\tClear downloaded images and reset state")
os.exit(10)
elseif argTable.action == 'showCurrentVersion' then
local vText,msg,nr = M.getCurrentVersionText()
if not vText then E("error reading firmware version (" .. nr .. ": " .. msg .. ")"); os.exit(1) end
local v = M.parseVersion(vText)
if not v then E("error parsing version '" .. vText .. "'"); os.exit(2) end
P(1, "version: " .. M.formatVersion(v))
elseif argTable.action == 'showStatus' then
local status = M.getStatus()
P(0, "Current update status:")
P(1, " currentVersion:\t" .. (M.formatVersion(status.currentVersion) or '?'))
P(1, " newestVersion:\t" .. (M.formatVersion(status.newestVersion) or '?'))
if status.stateText and status.stateText:len() > 0 then
P(1, " state:\t\t" .. M.STATE_NAMES[status.stateCode] .. " (" .. status.stateText .. ")")
else
P(1, " state:\t\t" .. M.STATE_NAMES[status.stateCode])
end
if status.stateCode == M.STATE.DOWNLOADING then
local percent = (status.imageSize > 0) and (math.ceil(status.progress / status.imageSize * 1000) / 10) or 0
P(1, " download progress:\t" .. status.progress .. "/" .. status.imageSize .. " (" .. percent .. "%)")
end
elseif argTable.action == 'showAvailableVersions' then
local verTable,msg = M.getAvailableVersions()
if not verTable then
E("error collecting version information (" .. msg .. ")")
os.exit(2)
end
P(0, "Available versions:")
for _,ent in ipairs(verTable) do P(1, M.formatVersion(ent.version)) end
elseif argTable.action == 'showVersionInfo' then
local vEnt,msg = M.findVersion(argTable.version)
if vEnt then
P(0, "Information on version:")
P(1, " version:\t\t" .. M.formatVersion(vEnt.version))
P(1, " sysupgradeFilename:\t" .. (vEnt.sysupgradeFilename or '-'))
P(1, " sysupgradeFileSize:\t" .. (vEnt.sysupgradeFileSize or '-'))
P(1, " sysupgradeMD5:\t" .. (vEnt.sysupgradeMD5 or '-'))
P(1, " factoryFilename:\t" .. (vEnt.factoryFilename or '-'))
P(1, " factoryFileSize:\t" .. (vEnt.factoryFileSize or '-'))
P(1, " factoryMD5:\t\t" .. (vEnt.factoryMD5 or '-'))
if vEnt.changelog then
P(1, "\n--- Changelog ---\n" .. vEnt.changelog .. '---')
else
P(1, " changelog:\t\t-")
end
elseif vEnt == false then
P(1, "no such version")
os.exit(4)
elseif vEnt == nil then
E("error searching version index (" .. msg .. ")")
os.exit(2)
end
elseif argTable.action == 'imageDownload' then
local vEnt,msg = M.findVersion(argTable.version)
if vEnt == false then
P(1, "no such version")
os.exit(4)
elseif vEnt == nil then
E("error searching version index (" .. msg .. ")")
os.exit(2)
end
local rv,msg = M.downloadImageFile(vEnt)
if not rv then E("could not download file (" .. msg .. ")")
else P(1, "success")
end
elseif argTable.action == 'clear' then
local rv,msg = M.clear()
if not rv then P(1, "error (" .. msg .. ")")
else P(1, "success")
end
elseif argTable.action == 'imageInstall' then
local vEnt, msg = nil, nil
vEnt,msg = M.findVersion(argTable.version)
if vEnt == false then
P(1, "no such version")
os.exit(4)
elseif vEnt == nil then
E("error searching version index (" .. msg .. ")")
os.exit(2)
end
local rv
rv,msg = M.flashImageVersion(vEnt)
E("error: failed to flash image to device (" .. msg .. ")")
os.exit(3)
else
P(0, "usage: d3d-updater [-hqVcCvslr] [-u base_url] [-i version] [-d version] [-f version]")
end
os.exit(0)
end
-- only execute the main function if an arg table is present, this enables usage both as module and as standalone script
if arg ~= nil then main() end
return M