source: trunk/lua/cli.lua @ 111

Revision 111, 19.8 KB checked in by reyalp, 16 months ago (diff)

split filesystem / path utilities into fsutil

  • Property svn:eol-style set to native
Line 
1--[[
2 Copyright (C) 2010-2011 <reyalp (at) gmail dot com>
3
4  This program is free software; you can redistribute it and/or modify
5  it under the terms of the GNU General Public License version 2 as
6  published by the Free Software Foundation.
7
8  This program is distributed in the hope that it will be useful,
9  but WITHOUT ANY WARRANTY; without even the implied warranty of
10  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11  GNU General Public License for more details.
12
13  You should have received a copy of the GNU General Public License
14  along with this program; if not, write to the Free Software
15  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
16--]]
17
18local cli = {
19        cmds={},
20        names={},
21        finished = false,
22}
23
24--[[
25get command args of the form -a[=value] -bar[=value] .. [wordarg1] [wordarg2] [wordarg...]
26--]]
27local argparser = { }
28cli.argparser = argparser
29
30-- trim leading spaces
31function argparser:trimspace(str)
32        local s, e = string.find(str,'^[%c%s]*')
33        return string.sub(str,e+1)
34end
35--[[
36get a 'word' argument, either a sequence of non-white space characters, or a quoted string
37inside " \ is treated as an escape character
38return word, end position on success or false, error message
39]]
40function argparser:get_word(str)
41        local result = ''
42        local esc = false
43        local qchar = false
44        local pos = 1
45        while pos <= string.len(str) do
46                local c = string.sub(str,pos,pos)
47                -- in escape, append next character unconditionally
48                if esc then
49                        result = result .. c
50                        esc = false
51                -- inside double quote, start escape and discard backslash
52                elseif qchar == '"' and c == '\\' then
53                        esc = true
54                -- character is the current quote char, close quote and discard
55                elseif c == qchar then
56                        qchar = false
57                -- not hit a space and not inside a quote, end
58                elseif not qchar and string.match(c,"[%c%s]") then
59                        break
60                -- hit a quote and not inside a quote, enter quote and discard
61                elseif not qchar and c == '"' or c == "'" then
62                        qchar = c
63                -- anything else, copy
64                else
65                        result = result .. c
66                end
67                pos = pos + 1
68        end
69        if esc then
70                return false,"unexpected \\"
71        end
72        if qchar then
73                return false,"unclosed " .. qchar
74        end
75        return result,pos
76end
77
78function argparser:parse_words(str)
79        local words={}
80        str = self:trimspace(str)
81        while string.len(str) > 0 do
82                local w,pos = self:get_word(str)
83                if not w then
84                        return false,pos -- pos is error string
85                end
86                table.insert(words,w)
87                str = string.sub(str,pos)
88                str = self:trimspace(str)
89        end
90        return words
91end
92
93--[[
94parse a command string into switches and word arguments
95switches are in the form -swname[=value]
96word arguments are anything else
97any portion of the string may be quoted with '' or ""
98inside "", \ is treated as an escape
99on success returns table with args as array elements and switches as named elements
100on failure returns false, error
101defs defines the valid switches and their default values. Can also define default values of numeric args
102TODO enforce switch values, number of args, integrate with help
103]]
104function argparser:parse(str)
105        -- default values
106        local results=util.extend_table({},self.defs)
107        local words,errmsg=self:parse_words(str)
108        if not words then
109                return false,errmsg
110        end
111        for i, w in ipairs(words) do
112                -- look for -name
113                local s,e,swname=string.find(w,'^-(%a[%w_-]*)')
114                -- found a switch
115                if s then               
116                        if type(self.defs[swname]) == 'nil' then
117                                return false,'unknown switch '..swname
118                        end
119                        local swval
120                        -- no value
121                        if e == string.len(w) then
122                                swval = true
123                        elseif string.sub(w,e+1,e+1) == '=' then
124                                -- note, may be empty string but that's ok
125                                swval = string.sub(w,e+2)
126                        else
127                                return false,"invalid switch value "..string.sub(w,e+1)
128                        end
129                        results[swname]=swval
130                else
131                        table.insert(results,w)
132                end
133        end
134        return results
135end
136
137-- a default for comands that want the raw string
138argparser.nop = {
139        parse =function(self,str)
140                return str
141        end
142}
143
144function argparser.create(defs)
145        local r={ defs=defs }
146        return util.mt_inherit(r,argparser)
147end
148
149cli.cmd_proto = {
150        get_help = function(self)
151                local namestr = self.names[1]
152                if #self.names > 1 then
153                        namestr = namestr .. " (" .. self.names[2]
154                        for i=3,#self.names do
155                                namestr = namestr .. "," .. self.names[i]
156                        end
157                        namestr = namestr .. ")"
158                end
159                return string.format("%-12s %-12s: - %s\n",namestr,self.arghelp,self.help)
160        end,
161        get_help_detail = function(self)
162                local msg=self:get_help()
163                if self.help_detail then
164                        msg = msg..self.help_detail..'\n'
165                end
166                return msg
167        end,
168}
169
170cli.cmd_meta = {
171        __index = function(cmd, key)
172                return cli.cmd_proto[key]
173        end,
174        __call = function(cmd,...)
175                return cmd:func(...)
176        end,
177}
178
179function cli:add_commands(cmds)
180        for i = 1, #cmds do
181                cmd = cmds[i]
182                table.insert(self.cmds,cmd)
183                if not cmd.arghelp then
184                        cmd.arghelp = ''
185                end
186                if not cmd.args then
187                        cmd.args = argparser.nop
188                end
189                for _,name in ipairs(cmd.names) do
190                        if self.names[name] then
191                                warnf("duplicate command name %s\n",name)
192                        else
193                                self.names[name] = cmd
194                        end
195                end
196                setmetatable(cmd,cli.cmd_meta)
197        end
198end
199
200function cli:prompt()
201        if con:is_connected() then
202                local script_id = con:get_script_id()
203                if script_id then
204                        printf("con %d> ",script_id)
205                else
206                        printf("con> ")
207                end
208        else
209                printf("___> ")
210        end
211end
212
213-- execute command given by a single line
214-- returns status,message
215-- message is an error message or printable value
216function cli:execute(line)
217        -- single char shortcuts
218        local s,e,cmd = string.find(line,'^[%c%s]*([!.#=])[%c%s]*')
219        if not cmd then
220                s,e,cmd = string.find(line,'^[%c%s]*([%w_]+)[%c%s]*')
221        end
222        if s then
223                local args = string.sub(line,e+1)
224                if self.names[cmd] then
225                        local status,msg
226                        args,msg = self.names[cmd].args:parse(args)
227                        if not args then
228                                return false,msg
229                        end
230                        status,msg = self.names[cmd](args)
231                        if not status and not msg then
232                                msg=cmd .. " failed"
233                        end
234                        return status,msg
235                else 
236                        return false,string.format("unknown command '%s'\n",cmd)
237                end
238        elseif string.find(line,'[^%c%s]') then
239                return false, string.format("bad input '%s'\n",line)
240        end
241        -- blank input is OK
242        return true,""
243end
244
245function cli:print_status(status,msg) 
246        if not status then
247                errf("%s\n",tostring(msg))
248        elseif msg and string.len(msg) ~= 0 then
249                printf("%s",msg)
250                if string.sub(msg,-1,-1) ~= '\n' then
251                        printf("\n")
252                end
253        end
254end
255
256function cli:run()
257        self:prompt()
258        for line in io.lines() do
259                self:print_status(self:execute(line))
260                if self.finished then
261                        break
262                end
263                self:prompt()
264        end
265end
266
267-- add/correct A/ as needed, replace \ with /
268function cli:make_camera_path(path)
269        if not path then
270                return 'A/'
271        end
272        -- fix slashes
273        path = string.gsub(path,'\\','/')
274        local pfx = string.sub(path,1,2)
275        if pfx == 'A/' then
276                return path
277        end
278        if pfx == 'a/' then
279                return 'A' .. string.sub(path,2,-1)
280        end
281        return 'A/' .. path
282end
283
284cli:add_commands{
285        {
286                names={'help','h'},
287                arghelp='[cmd]|[-v]',
288                args=argparser.create{v=false},
289                help='help on [cmd] or all commands',
290                help_detail=[[
291 help -v gives full help on all commands, otherwise as summary is printed
292]],
293                func=function(self,args) 
294                        cmd = args[1]
295                        if cmd and cli.names[cmd] then
296                                return true, cli.names[cmd]:get_help_detail()
297                        end
298                        if cmd then
299                                return false, string.format("unknown command '%s'\n",cmd)
300                        end
301                        msg = ""
302                        for i,c in ipairs(cli.cmds) do
303                                if args.v then
304                                        msg = msg .. c:get_help_detail()
305                                else
306                                        msg = msg .. c:get_help()
307                                end
308                        end
309                        return true, msg
310                end,
311        },
312        {
313                names={'#'},
314                help='comment',
315                func=function(self,args) 
316                        return true
317                end,
318        },
319        {
320                names={'exec','!'},
321                help='execute local lua',
322                arghelp='<lua code>',
323                help_detail=[[
324 Execute lua in chdkptp.
325 The global variable con accesses the current CLI connection.
326 Return values are printed in the console.
327]],
328                func=function(self,args) 
329                        local f,r = loadstring(args)
330                        if f then
331                                r={pcall(f)};
332                                if not r[1] then 
333                                        return false, string.format("call failed:%s\n",r[2])
334                                end
335                                local s
336                                if #r > 1 then
337                                        s='=' .. serialize(r[2],{pretty=true,err_type=false,err_cycle=false})
338                                        for i = 3, #r do
339                                                s = s .. ',' .. serialize(r[i],{pretty=true,err_type=false,err_cycle=false})
340                                        end
341                                end
342                                return true, s
343                        else
344                                return false, string.format("compile failed:%s\n",r)
345                        end
346                end,
347        },
348        {
349                names={'quit','q'},
350                help='quit program',
351                func=function() 
352                        cli.finished = true
353                        return true,"bye"
354                end,
355        },
356        {
357                names={'lua','.'},
358                help='execute remote lua',
359                arghelp='<lua code>',
360                help_detail=[[
361 Execute Lua code on the camera.
362 Returns immediately after the script is started.
363 Return values or error messages can be retrieved with getm after the script is completed.
364]],
365                func=function(self,args) 
366                        return con:exec(args)
367                end,
368        },
369        {
370                names={'getm'},
371                help='get messages',
372                func=function(self,args) 
373                        local msgs=''
374                        local msg,err
375                        while true do
376                                msg,err=con:read_msg()
377                                if type(msg) ~= 'table' then 
378                                        return false,msgs..err
379                                end
380                                if msg.type == 'none' then
381                                        return true,msgs
382                                end
383                                msgs = msgs .. chdku.format_script_msg(msg) .. "\n"
384                        end
385                end,
386        },
387        {
388                names={'putm'},
389                help='send message',
390                arghelp='<msg string>',
391                func=function(self,args) 
392                        return con:write_msg(args)
393                end,
394        },
395        {
396                names={'luar','='},
397                help='execute remote lua, wait for result',
398                arghelp='<lua code>',
399                help_detail=[[
400 Execute Lua code on the camera, waiting for the script to end.
401 Return values or error messages are printed after the script completes.
402]],
403                func=function(self,args) 
404                        local rets={}
405                        local msgs={}
406                        local status,err = con:execwait(args,{rets=rets,msgs=msgs})
407                        if not status then
408                                return false,err
409                        end
410                        local r=''
411                        for i=1,#msgs do
412                                r=r .. chdku.format_script_msg(msgs[i]) .. '\n'
413                        end
414                        for i=1,table.maxn(rets) do
415                                r=r .. chdku.format_script_msg(rets[i]) .. '\n'
416                        end
417                        return true,r
418                end,
419        },
420        {
421                -- TODO support display as words
422                names={'rmem'},
423                help='read memory',
424                args=argparser.create(), -- only word args
425                arghelp='<address> [count]',
426                func=function(self,args) 
427                        local addr = tonumber(args[1])
428                        local count = tonumber(args[2])
429                        if not addr then
430                                return false, "bad args"
431                        end
432                        if not count then
433                                count = 1
434                        end
435
436                        r,msg = con:getmem(addr,count)
437                        if not r then
438                                return false,msg
439                        end
440                        return true,string.format("0x%x %u\n",addr,count)..hexdump(r,addr)
441                end,
442        },
443        {
444                names={'list'},
445                help='list devices',
446                help_detail=[[
447 Lists all recognized PTP devices in the following format
448  <status><num><modelname> b=<bus> d=<device> v=<usb vendor> p=<usb pid> s=<serial number>
449 status values
450  * connected, current target for CLI commands (con global variable)
451  + connected, not CLI target
452  - not connected
453 serial numbers are not available from all models
454]],
455                func=function() 
456                        local msg = ''
457                        local devs = chdk.list_usb_devices()
458                        for i,desc in ipairs(devs) do
459                                local lcon = chdku.connection(desc)
460                                local usb_info = lcon:get_usb_devinfo()
461                                local tempcon = false
462                                local status = "+"
463                                if not lcon:is_connected() then
464                                        tempcon = true
465                                        status = "-"
466                                        lcon:connect()
467                                end
468                                local ptp_info = lcon:get_ptp_devinfo()
469                                if not ptp_info then
470                                        ptp_info = { model = "<unknown>" }
471                                end
472                                if not ptp_info.serial_number then
473                                        ptp_info.serial_number ='(none)'
474                                end
475
476                                if lcon._con == con._con then
477                                        status = "*"
478                                end
479
480                                msg = msg .. string.format("%s%d:%s b=%s d=%s v=0x%x p=0x%x s=%s\n",
481                                                                                        status, i,
482                                                                                        ptp_info.model,
483                                                                                        usb_info.bus, usb_info.dev,
484                                                                                        usb_info.vendor_id, usb_info.product_id,
485                                                                                        ptp_info.serial_number)
486                                if tempcon then
487                                        lcon:disconnect()
488                                end
489                        end
490                        return true,msg
491                end,
492        },
493        {
494                names={'upload','u'},
495                help='upload a file to the camera',
496                arghelp="[-nolua] <local> [remote]",
497                args=argparser.create{nolua=false},
498                help_detail=[[
499 <local>  file to upload
500 [remote] destination
501   If not specified, file is uploaded to A/
502   If remote is a directory or ends in / uploaded to remote/<local file name>
503 -nolua   skip lua based checks on remote
504   Allows upload while running script
505   Prevents detecting if remote is a directory
506 Some cameras have problems with paths > 32 characters
507 Dryos cameras do not handle non 8.3 filenames well
508]],
509                func=function(self,args) 
510                        local src = args[1]
511                        if not src then
512                                return false, "missing source"
513                        end
514                        if lfs.attributes(src,'mode') ~= 'file' then
515                                return false, 'src is not a file: '..src
516                        end
517
518                        local dst_dir
519                        local dst = args[2]
520                        -- no dst, use filename of source
521                        if dst then
522                                dst = cli:make_camera_path(dst)
523                                if string.find(dst,'[\\/]$') then
524                                        -- trailing slash, append filename of source
525                                        dst = string.sub(dst,1,-2)
526                                        if not args.nolua then
527                                                local st,err = con:stat(dst)
528                                                if not st then
529                                                        return false, 'stat dest '..dst..' failed: ' .. err
530                                                end
531                                                if not st.is_dir then
532                                                        return false, 'not a directory: '..dst
533                                                end
534                                        end
535                                        dst = fsutil.joinpath(dst,fsutil.basename(src))
536                                else
537                                        if not args.nolua then
538                                                local st = con:stat(dst)
539                                                if st and st.is_dir then
540                                                        dst = fsutil.joinpath(dst,fsutil.basename(src))
541                                                end
542                                        end
543                                end
544                        else
545                                dst = cli:make_camera_path(fsutil.basename(src))
546                        end
547
548                        local msg=string.format("%s->%s\n",src,dst)
549                        local r, msg2 = con:upload(src,dst)
550                        if msg2 then
551                                msg = msg .. msg2
552                        end
553                        return r, msg
554                end,
555        },
556        {
557                names={'download','d'},
558                help='download a file from the camera',
559                arghelp="[-nolua] <remote> [local]",
560                args=argparser.create{nolua=false},
561                help_detail=[[
562 <remote> file to download
563        A/ is prepended if not present
564 [local]  destination
565   If not specified, the file will be downloaded to the current directory
566   If a directory, the file will be downloaded into it
567 -nolua   skip lua based checks on remote
568   Allows download while running script
569]],
570
571                func=function(self,args) 
572                        local src = args[1]
573                        if not src then
574                                return false, "missing source"
575                        end
576                        local dst = args[2]
577                        if not dst then
578                                -- no dest, use final component of source path
579                                dst = fsutil.basename(src)
580                        elseif string.match(dst,'[\\/]+$') then
581                                -- explicit / treat it as a directory
582                                dst = fsutil.joinpath(dst,fsutil.basename(src))
583                                -- and check if it is
584                                local dst_dir = fsutil.dirname(dst)
585                                -- TODO should create it
586                                if lfs.attributes(dst_dir,'mode') ~= 'directory' then
587                                        return false,'not a directory: '..dst_dir
588                                end
589                        elseif lfs.attributes(dst,'mode') == 'directory' then
590                                -- if target is a directory download into it
591                                dst = fsutil.joinpath(dst,fsutil.basename(src))
592                        end
593
594                        src = cli:make_camera_path(src)
595                        if not args.nolua then
596                                local src_st,err = con:stat(src)
597                                if not src_st then
598                                        return false, 'stat source '..src..' failed: ' .. err
599                                end
600                                if not src_st.is_file then
601                                        return false, src..' is not a file'
602                                end
603                        end
604                        local msg=string.format("%s->%s\n",src,dst)
605                        local r, msg2 = con:download(src,dst)
606                        if msg2 then
607                                msg = msg .. msg2
608                        end
609                        return r, msg
610                end,
611        },
612        {
613                names={'version','ver'},
614                help='print API versions',
615                func=function(self,args) 
616                        local host_ver = string.format("host:%d.%d cam:",chdk.host_api_version())
617                        if con:is_connected() then
618                                local cam_major, cam_minor = con:camera_api_version()
619                                if not cam_major then
620                                        return false, host_ver .. string.format("error %s",cam_minor)
621                                end
622                                return true, host_ver .. string.format("%d.%d",cam_major,cam_minor)
623                        else
624                                return true, host_ver .. "not connected"
625                        end
626                end,
627        },
628        {
629                names={'connect','c'},
630                help='connect to device',
631                arghelp="[-b=<bus>] [-d=<dev>] [-p=<pid>] [-s=<serial>] [model] ",
632                args=argparser.create{
633                        b='.*',
634                        d='.*',
635                        p=false,
636                        s=false,
637                },
638               
639                help_detail=[[
640 If no options are given, connects to the first available device.
641 <pid> is the USB product ID, as a decimal or hexadecimal number.
642 All other options are treated as a Lua pattern. For alphanumerics, this is a case sensitive substring match.
643 If the serial or model are specified, a temporary connection will be made to each device
644 If <model> includes spaces, it must be quoted.
645 If multiple devices match, the first matching device will be connected.
646]],
647                func=function(self,args) 
648                        local match = {}
649                        local opt_map = {
650                                b='bus',
651                                d='dev',
652                                p='product_id',
653                                s='serial_number',
654                                [1]='model',
655                        }
656                        for k,v in pairs(opt_map) do
657                                -- TODO matches expect nil
658                                if type(args[k]) == 'string' then
659                                        match[v] = args[k]
660                                end
661--                              printf('%s=%s\n',v,tostring(args[k]))
662                        end
663
664                        if con:is_connected() then
665                                con:disconnect()
666                        end
667
668                        if match.product_id and not tonumber(match.product_id) then
669                                return false,"expected number for product id"
670                        end
671                        local devices = chdk.list_usb_devices()
672                        local lcon
673                        for i, devinfo in ipairs(devices) do
674                                lcon = nil
675                                if chdku.match_device(devinfo,match) then
676                                        lcon = chdku.connection(devinfo)
677                                        -- if we are looking for model or serial, need to connect to the dev to check
678                                        if match.model or match.serial_number then
679                                                local tempcon = false
680--                                              printf('model check %s %s\n',tostring(match.model),tostring(match.serial_number))
681                                                if not lcon:is_connected() then
682                                                        lcon:connect()
683                                                        tempcon = true
684                                                end
685                                                if not lcon:match_ptp_info(match) then
686                                                        if tempcon then
687                                                                lcon:disconnect()
688                                                        end
689                                                        lcon = nil
690                                                end
691                                        end
692                                        if lcon then
693                                                break
694                                        end
695                                end
696                        end
697                        if lcon then
698                                con = lcon
699                                if con:is_connected() then
700                                        return true
701                                end
702                                return con:connect()
703                        end
704                        return false,"no matching devices found"
705                end,
706        },
707        {
708                names={'reconnect','r'},
709                help='reconnect to current device',
710                -- TODO depends on camera coming back on current dev/bus, not guaranteed
711                -- caching model/serial could help
712                func=function(self,args) 
713                        if con:is_connected() then
714                                con:disconnect()
715                        end
716                        -- appears to be needed to avoid device numbers changing (reset too soon ?)
717                        sys.sleep(2000)
718                        return con:connect()
719                end,
720        },
721        {
722                names={'disconnect','dis'},
723                help='disconnect from device',
724                func=function(self,args) 
725                        return con:disconnect()
726                end,
727        },
728        {
729                names={'ls'},
730                help='list files/directories on camera',
731                args=argparser.create{l=false},
732                arghelp="[-l] [path]",
733                func=function(self,args) 
734                        local listops
735                        local path=args[1]
736                        path = cli:make_camera_path(path)
737                        if args.l then
738                                listopts = { stat='*' }
739                        else
740                                listopts = { stat='/' }
741                        end
742                        local list,msg = con:listdir(path,listopts)
743                        if type(list) == 'table' then
744                                local r = ''
745                                if args.l then
746                                        -- alphabetic sort TODO sorting/grouping options
747                                        chdku.sortdir_stat(list)
748                                        for i,st in ipairs(list) do
749                                                if st.is_dir then
750                                                        r = r .. string.format("%s/\n",st.name)
751                                                else
752                                                        r = r .. string.format("%-13s %10d\n",st.name,st.size)
753                                                end
754                                        end
755                                else
756                                        table.sort(list)
757                                        for i,name in ipairs(list) do
758                                                r = r .. name .. '\n'
759                                        end
760                                end
761
762                                return true,r
763                        end
764                        return false,msg
765                end,
766        },
767        {
768                names={'reboot'},
769                help='reboot the camera',
770                arghelp="[file]",
771                args=argparser.create(),
772                help_detail=[[
773 [file] is an optional file to boot.
774  If not given, the normal boot process is used.
775  The file may be an unencoded binary or on DryOS only, an encoded .FI2
776 chdkptp attempts to reconnect to the camera after it boots.
777]],
778                -- TODO reconnect depends on camera coming back on current dev/bus, not guaranteed
779                -- caching model/serial could help
780                func=function(self,args) 
781                        local bootfile=args[1]
782                        if bootfile then
783                                bootfile = cli:make_camera_path(bootfile)
784                                bootfile = string.format("'%s'",bootfile)
785                        else
786                                bootfile = ''
787                        end
788                        -- sleep and disconnect to avoid later connection problems on some cameras
789                        -- clobber because we don't care about memory leaks
790                        local status,err=con:exec('sleep(1000);reboot('..bootfile..')',{clobber=true})
791                        if not status then
792                                return false,err
793                        end
794                        con:disconnect()
795                        -- sleep locally to avoid clobbering the reboot, and allow time for the camera to come up before trying to connect
796                        sys.sleep(3000)
797
798                        return con:connect()
799                end,
800        },
801};
802
803return cli;
Note: See TracBrowser for help on using the repository browser.