source: trunk/lua/cli.lua @ 115

Revision 115, 19.5 KB checked in by reyalp, 17 months ago (diff)

move make_camera_path to 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
267cli:add_commands{
268        {
269                names={'help','h'},
270                arghelp='[cmd]|[-v]',
271                args=argparser.create{v=false},
272                help='help on [cmd] or all commands',
273                help_detail=[[
274 help -v gives full help on all commands, otherwise as summary is printed
275]],
276                func=function(self,args) 
277                        cmd = args[1]
278                        if cmd and cli.names[cmd] then
279                                return true, cli.names[cmd]:get_help_detail()
280                        end
281                        if cmd then
282                                return false, string.format("unknown command '%s'\n",cmd)
283                        end
284                        msg = ""
285                        for i,c in ipairs(cli.cmds) do
286                                if args.v then
287                                        msg = msg .. c:get_help_detail()
288                                else
289                                        msg = msg .. c:get_help()
290                                end
291                        end
292                        return true, msg
293                end,
294        },
295        {
296                names={'#'},
297                help='comment',
298                func=function(self,args) 
299                        return true
300                end,
301        },
302        {
303                names={'exec','!'},
304                help='execute local lua',
305                arghelp='<lua code>',
306                help_detail=[[
307 Execute lua in chdkptp.
308 The global variable con accesses the current CLI connection.
309 Return values are printed in the console.
310]],
311                func=function(self,args) 
312                        local f,r = loadstring(args)
313                        if f then
314                                r={pcall(f)};
315                                if not r[1] then 
316                                        return false, string.format("call failed:%s\n",r[2])
317                                end
318                                local s
319                                if #r > 1 then
320                                        s='=' .. serialize(r[2],{pretty=true,err_type=false,err_cycle=false})
321                                        for i = 3, #r do
322                                                s = s .. ',' .. serialize(r[i],{pretty=true,err_type=false,err_cycle=false})
323                                        end
324                                end
325                                return true, s
326                        else
327                                return false, string.format("compile failed:%s\n",r)
328                        end
329                end,
330        },
331        {
332                names={'quit','q'},
333                help='quit program',
334                func=function() 
335                        cli.finished = true
336                        return true,"bye"
337                end,
338        },
339        {
340                names={'lua','.'},
341                help='execute remote lua',
342                arghelp='<lua code>',
343                help_detail=[[
344 Execute Lua code on the camera.
345 Returns immediately after the script is started.
346 Return values or error messages can be retrieved with getm after the script is completed.
347]],
348                func=function(self,args) 
349                        return con:exec(args)
350                end,
351        },
352        {
353                names={'getm'},
354                help='get messages',
355                func=function(self,args) 
356                        local msgs=''
357                        local msg,err
358                        while true do
359                                msg,err=con:read_msg()
360                                if type(msg) ~= 'table' then 
361                                        return false,msgs..err
362                                end
363                                if msg.type == 'none' then
364                                        return true,msgs
365                                end
366                                msgs = msgs .. chdku.format_script_msg(msg) .. "\n"
367                        end
368                end,
369        },
370        {
371                names={'putm'},
372                help='send message',
373                arghelp='<msg string>',
374                func=function(self,args) 
375                        return con:write_msg(args)
376                end,
377        },
378        {
379                names={'luar','='},
380                help='execute remote lua, wait for result',
381                arghelp='<lua code>',
382                help_detail=[[
383 Execute Lua code on the camera, waiting for the script to end.
384 Return values or error messages are printed after the script completes.
385]],
386                func=function(self,args) 
387                        local rets={}
388                        local msgs={}
389                        local status,err = con:execwait(args,{rets=rets,msgs=msgs})
390                        if not status then
391                                return false,err
392                        end
393                        local r=''
394                        for i=1,#msgs do
395                                r=r .. chdku.format_script_msg(msgs[i]) .. '\n'
396                        end
397                        for i=1,table.maxn(rets) do
398                                r=r .. chdku.format_script_msg(rets[i]) .. '\n'
399                        end
400                        return true,r
401                end,
402        },
403        {
404                -- TODO support display as words
405                names={'rmem'},
406                help='read memory',
407                args=argparser.create(), -- only word args
408                arghelp='<address> [count]',
409                func=function(self,args) 
410                        local addr = tonumber(args[1])
411                        local count = tonumber(args[2])
412                        if not addr then
413                                return false, "bad args"
414                        end
415                        if not count then
416                                count = 1
417                        end
418
419                        r,msg = con:getmem(addr,count)
420                        if not r then
421                                return false,msg
422                        end
423                        return true,string.format("0x%x %u\n",addr,count)..hexdump(r,addr)
424                end,
425        },
426        {
427                names={'list'},
428                help='list devices',
429                help_detail=[[
430 Lists all recognized PTP devices in the following format
431  <status><num><modelname> b=<bus> d=<device> v=<usb vendor> p=<usb pid> s=<serial number>
432 status values
433  * connected, current target for CLI commands (con global variable)
434  + connected, not CLI target
435  - not connected
436 serial numbers are not available from all models
437]],
438                func=function() 
439                        local msg = ''
440                        local devs = chdk.list_usb_devices()
441                        for i,desc in ipairs(devs) do
442                                local lcon = chdku.connection(desc)
443                                local usb_info = lcon:get_usb_devinfo()
444                                local tempcon = false
445                                local status = "+"
446                                if not lcon:is_connected() then
447                                        tempcon = true
448                                        status = "-"
449                                        lcon:connect()
450                                end
451                                local ptp_info = lcon:get_ptp_devinfo()
452                                if not ptp_info then
453                                        ptp_info = { model = "<unknown>" }
454                                end
455                                if not ptp_info.serial_number then
456                                        ptp_info.serial_number ='(none)'
457                                end
458
459                                if lcon._con == con._con then
460                                        status = "*"
461                                end
462
463                                msg = msg .. string.format("%s%d:%s b=%s d=%s v=0x%x p=0x%x s=%s\n",
464                                                                                        status, i,
465                                                                                        ptp_info.model,
466                                                                                        usb_info.bus, usb_info.dev,
467                                                                                        usb_info.vendor_id, usb_info.product_id,
468                                                                                        ptp_info.serial_number)
469                                if tempcon then
470                                        lcon:disconnect()
471                                end
472                        end
473                        return true,msg
474                end,
475        },
476        {
477                names={'upload','u'},
478                help='upload a file to the camera',
479                arghelp="[-nolua] <local> [remote]",
480                args=argparser.create{nolua=false},
481                help_detail=[[
482 <local>  file to upload
483 [remote] destination
484   If not specified, file is uploaded to A/
485   If remote is a directory or ends in / uploaded to remote/<local file name>
486 -nolua   skip lua based checks on remote
487   Allows upload while running script
488   Prevents detecting if remote is a directory
489 Some cameras have problems with paths > 32 characters
490 Dryos cameras do not handle non 8.3 filenames well
491]],
492                func=function(self,args) 
493                        local src = args[1]
494                        if not src then
495                                return false, "missing source"
496                        end
497                        if lfs.attributes(src,'mode') ~= 'file' then
498                                return false, 'src is not a file: '..src
499                        end
500
501                        local dst_dir
502                        local dst = args[2]
503                        -- no dst, use filename of source
504                        if dst then
505                                dst = fsutil.make_camera_path(dst)
506                                if string.find(dst,'[\\/]$') then
507                                        -- trailing slash, append filename of source
508                                        dst = string.sub(dst,1,-2)
509                                        if not args.nolua then
510                                                local st,err = con:stat(dst)
511                                                if not st then
512                                                        return false, 'stat dest '..dst..' failed: ' .. err
513                                                end
514                                                if not st.is_dir then
515                                                        return false, 'not a directory: '..dst
516                                                end
517                                        end
518                                        dst = fsutil.joinpath(dst,fsutil.basename(src))
519                                else
520                                        if not args.nolua then
521                                                local st = con:stat(dst)
522                                                if st and st.is_dir then
523                                                        dst = fsutil.joinpath(dst,fsutil.basename(src))
524                                                end
525                                        end
526                                end
527                        else
528                                dst = fsutil.make_camera_path(fsutil.basename(src))
529                        end
530
531                        local msg=string.format("%s->%s\n",src,dst)
532                        local r, msg2 = con:upload(src,dst)
533                        if msg2 then
534                                msg = msg .. msg2
535                        end
536                        return r, msg
537                end,
538        },
539        {
540                names={'download','d'},
541                help='download a file from the camera',
542                arghelp="[-nolua] <remote> [local]",
543                args=argparser.create{nolua=false},
544                help_detail=[[
545 <remote> file to download
546        A/ is prepended if not present
547 [local]  destination
548   If not specified, the file will be downloaded to the current directory
549   If a directory, the file will be downloaded into it
550 -nolua   skip lua based checks on remote
551   Allows download while running script
552]],
553
554                func=function(self,args) 
555                        local src = args[1]
556                        if not src then
557                                return false, "missing source"
558                        end
559                        local dst = args[2]
560                        if not dst then
561                                -- no dest, use final component of source path
562                                dst = fsutil.basename(src)
563                        elseif string.match(dst,'[\\/]+$') then
564                                -- explicit / treat it as a directory
565                                dst = fsutil.joinpath(dst,fsutil.basename(src))
566                                -- and check if it is
567                                local dst_dir = fsutil.dirname(dst)
568                                -- TODO should create it
569                                if lfs.attributes(dst_dir,'mode') ~= 'directory' then
570                                        return false,'not a directory: '..dst_dir
571                                end
572                        elseif lfs.attributes(dst,'mode') == 'directory' then
573                                -- if target is a directory download into it
574                                dst = fsutil.joinpath(dst,fsutil.basename(src))
575                        end
576
577                        src = fsutil.make_camera_path(src)
578                        if not args.nolua then
579                                local src_st,err = con:stat(src)
580                                if not src_st then
581                                        return false, 'stat source '..src..' failed: ' .. err
582                                end
583                                if not src_st.is_file then
584                                        return false, src..' is not a file'
585                                end
586                        end
587                        local msg=string.format("%s->%s\n",src,dst)
588                        local r, msg2 = con:download(src,dst)
589                        if msg2 then
590                                msg = msg .. msg2
591                        end
592                        return r, msg
593                end,
594        },
595        {
596                names={'version','ver'},
597                help='print API versions',
598                func=function(self,args) 
599                        local host_ver = string.format("host:%d.%d cam:",chdk.host_api_version())
600                        if con:is_connected() then
601                                local cam_major, cam_minor = con:camera_api_version()
602                                if not cam_major then
603                                        return false, host_ver .. string.format("error %s",cam_minor)
604                                end
605                                return true, host_ver .. string.format("%d.%d",cam_major,cam_minor)
606                        else
607                                return true, host_ver .. "not connected"
608                        end
609                end,
610        },
611        {
612                names={'connect','c'},
613                help='connect to device',
614                arghelp="[-b=<bus>] [-d=<dev>] [-p=<pid>] [-s=<serial>] [model] ",
615                args=argparser.create{
616                        b='.*',
617                        d='.*',
618                        p=false,
619                        s=false,
620                },
621               
622                help_detail=[[
623 If no options are given, connects to the first available device.
624 <pid> is the USB product ID, as a decimal or hexadecimal number.
625 All other options are treated as a Lua pattern. For alphanumerics, this is a case sensitive substring match.
626 If the serial or model are specified, a temporary connection will be made to each device
627 If <model> includes spaces, it must be quoted.
628 If multiple devices match, the first matching device will be connected.
629]],
630                func=function(self,args) 
631                        local match = {}
632                        local opt_map = {
633                                b='bus',
634                                d='dev',
635                                p='product_id',
636                                s='serial_number',
637                                [1]='model',
638                        }
639                        for k,v in pairs(opt_map) do
640                                -- TODO matches expect nil
641                                if type(args[k]) == 'string' then
642                                        match[v] = args[k]
643                                end
644--                              printf('%s=%s\n',v,tostring(args[k]))
645                        end
646
647                        if con:is_connected() then
648                                con:disconnect()
649                        end
650
651                        if match.product_id and not tonumber(match.product_id) then
652                                return false,"expected number for product id"
653                        end
654                        local devices = chdk.list_usb_devices()
655                        local lcon
656                        for i, devinfo in ipairs(devices) do
657                                lcon = nil
658                                if chdku.match_device(devinfo,match) then
659                                        lcon = chdku.connection(devinfo)
660                                        -- if we are looking for model or serial, need to connect to the dev to check
661                                        if match.model or match.serial_number then
662                                                local tempcon = false
663--                                              printf('model check %s %s\n',tostring(match.model),tostring(match.serial_number))
664                                                if not lcon:is_connected() then
665                                                        lcon:connect()
666                                                        tempcon = true
667                                                end
668                                                if not lcon:match_ptp_info(match) then
669                                                        if tempcon then
670                                                                lcon:disconnect()
671                                                        end
672                                                        lcon = nil
673                                                end
674                                        end
675                                        if lcon then
676                                                break
677                                        end
678                                end
679                        end
680                        if lcon then
681                                con = lcon
682                                if con:is_connected() then
683                                        return true
684                                end
685                                return con:connect()
686                        end
687                        return false,"no matching devices found"
688                end,
689        },
690        {
691                names={'reconnect','r'},
692                help='reconnect to current device',
693                -- TODO depends on camera coming back on current dev/bus, not guaranteed
694                -- caching model/serial could help
695                func=function(self,args) 
696                        if con:is_connected() then
697                                con:disconnect()
698                        end
699                        -- appears to be needed to avoid device numbers changing (reset too soon ?)
700                        sys.sleep(2000)
701                        return con:connect()
702                end,
703        },
704        {
705                names={'disconnect','dis'},
706                help='disconnect from device',
707                func=function(self,args) 
708                        return con:disconnect()
709                end,
710        },
711        {
712                names={'ls'},
713                help='list files/directories on camera',
714                args=argparser.create{l=false},
715                arghelp="[-l] [path]",
716                func=function(self,args) 
717                        local listops
718                        local path=args[1]
719                        path = fsutil.make_camera_path(path)
720                        if args.l then
721                                listopts = { stat='*' }
722                        else
723                                listopts = { stat='/' }
724                        end
725                        local list,msg = con:listdir(path,listopts)
726                        if type(list) == 'table' then
727                                local r = ''
728                                if args.l then
729                                        -- alphabetic sort TODO sorting/grouping options
730                                        chdku.sortdir_stat(list)
731                                        for i,st in ipairs(list) do
732                                                if st.is_dir then
733                                                        r = r .. string.format("%s/\n",st.name)
734                                                else
735                                                        r = r .. string.format("%-13s %10d\n",st.name,st.size)
736                                                end
737                                        end
738                                else
739                                        table.sort(list)
740                                        for i,name in ipairs(list) do
741                                                r = r .. name .. '\n'
742                                        end
743                                end
744
745                                return true,r
746                        end
747                        return false,msg
748                end,
749        },
750        {
751                names={'reboot'},
752                help='reboot the camera',
753                arghelp="[file]",
754                args=argparser.create(),
755                help_detail=[[
756 [file] is an optional file to boot.
757  If not given, the normal boot process is used.
758  The file may be an unencoded binary or on DryOS only, an encoded .FI2
759 chdkptp attempts to reconnect to the camera after it boots.
760]],
761                -- TODO reconnect depends on camera coming back on current dev/bus, not guaranteed
762                -- caching model/serial could help
763                func=function(self,args) 
764                        local bootfile=args[1]
765                        if bootfile then
766                                bootfile = fsutil.make_camera_path(bootfile)
767                                bootfile = string.format("'%s'",bootfile)
768                        else
769                                bootfile = ''
770                        end
771                        -- sleep and disconnect to avoid later connection problems on some cameras
772                        -- clobber because we don't care about memory leaks
773                        local status,err=con:exec('sleep(1000);reboot('..bootfile..')',{clobber=true})
774                        if not status then
775                                return false,err
776                        end
777                        con:disconnect()
778                        -- sleep locally to avoid clobbering the reboot, and allow time for the camera to come up before trying to connect
779                        sys.sleep(3000)
780
781                        return con:connect()
782                end,
783        },
784};
785
786return cli;
Note: See TracBrowser for help on using the repository browser.