| [2] | 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 | |
|---|
| 18 | local cli = { |
|---|
| 19 | cmds={}, |
|---|
| 20 | names={}, |
|---|
| 21 | finished = false, |
|---|
| 22 | } |
|---|
| 23 | |
|---|
| [93] | 24 | --[[ |
|---|
| 25 | get command args of the form -a[=value] -bar[=value] .. [wordarg1] [wordarg2] [wordarg...] |
|---|
| 26 | --]] |
|---|
| 27 | local argparser = { } |
|---|
| 28 | cli.argparser = argparser |
|---|
| 29 | |
|---|
| 30 | -- trim leading spaces |
|---|
| 31 | function argparser:trimspace(str) |
|---|
| 32 | local s, e = string.find(str,'^[%c%s]*') |
|---|
| 33 | return string.sub(str,e+1) |
|---|
| 34 | end |
|---|
| 35 | --[[ |
|---|
| 36 | get a 'word' argument, either a sequence of non-white space characters, or a quoted string |
|---|
| 37 | inside " \ is treated as an escape character |
|---|
| 38 | return word, end position on success or false, error message |
|---|
| 39 | ]] |
|---|
| 40 | function 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 |
|---|
| 76 | end |
|---|
| 77 | |
|---|
| 78 | function 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 |
|---|
| 91 | end |
|---|
| 92 | |
|---|
| 93 | --[[ |
|---|
| 94 | parse a command string into switches and word arguments |
|---|
| 95 | switches are in the form -swname[=value] |
|---|
| 96 | word arguments are anything else |
|---|
| 97 | any portion of the string may be quoted with '' or "" |
|---|
| 98 | inside "", \ is treated as an escape |
|---|
| 99 | on success returns table with args as array elements and switches as named elements |
|---|
| 100 | on failure returns false, error |
|---|
| 101 | defs defines the valid switches and their default values. Can also define default values of numeric args |
|---|
| 102 | TODO enforce switch values, number of args, integrate with help |
|---|
| 103 | ]] |
|---|
| 104 | function 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 |
|---|
| 135 | end |
|---|
| 136 | |
|---|
| 137 | -- a default for comands that want the raw string |
|---|
| 138 | argparser.nop = { |
|---|
| 139 | parse =function(self,str) |
|---|
| 140 | return str |
|---|
| 141 | end |
|---|
| 142 | } |
|---|
| 143 | |
|---|
| 144 | function argparser.create(defs) |
|---|
| 145 | local r={ defs=defs } |
|---|
| 146 | return util.mt_inherit(r,argparser) |
|---|
| 147 | end |
|---|
| 148 | |
|---|
| [2] | 149 | cli.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, |
|---|
| [92] | 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, |
|---|
| [2] | 168 | } |
|---|
| 169 | |
|---|
| 170 | cli.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 | |
|---|
| 179 | function 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 |
|---|
| [93] | 186 | if not cmd.args then |
|---|
| 187 | cmd.args = argparser.nop |
|---|
| 188 | end |
|---|
| [2] | 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 |
|---|
| 198 | end |
|---|
| 199 | |
|---|
| 200 | function cli:prompt() |
|---|
| [60] | 201 | if con:is_connected() then |
|---|
| [58] | 202 | local script_id = con:get_script_id() |
|---|
| [2] | 203 | if script_id then |
|---|
| 204 | printf("con %d> ",script_id) |
|---|
| 205 | else |
|---|
| 206 | printf("con> ") |
|---|
| 207 | end |
|---|
| 208 | else |
|---|
| 209 | printf("___> ") |
|---|
| 210 | end |
|---|
| 211 | end |
|---|
| 212 | |
|---|
| 213 | -- execute command given by a single line |
|---|
| 214 | -- returns status,message |
|---|
| 215 | -- message is an error message or printable value |
|---|
| 216 | function cli:execute(line) |
|---|
| 217 | -- single char shortcuts |
|---|
| [92] | 218 | local s,e,cmd = string.find(line,'^[%c%s]*([!.#=])[%c%s]*') |
|---|
| [2] | 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 |
|---|
| [93] | 225 | local status,msg |
|---|
| 226 | args,msg = self.names[cmd].args:parse(args) |
|---|
| 227 | if not args then |
|---|
| 228 | return false,msg |
|---|
| 229 | end |
|---|
| [135] | 230 | local cstatus |
|---|
| [119] | 231 | local t0=ustime.new() |
|---|
| [135] | 232 | cstatus,status,msg = xpcall( |
|---|
| 233 | function() |
|---|
| 234 | return self.names[cmd](args) |
|---|
| 235 | end, |
|---|
| 236 | util.err_traceback) |
|---|
| [119] | 237 | if cli.showtime then |
|---|
| 238 | printf("time %.4f\n",ustime.diff(t0)/1000000) |
|---|
| 239 | end |
|---|
| [135] | 240 | if not cstatus then |
|---|
| 241 | return false,status |
|---|
| 242 | end |
|---|
| [51] | 243 | if not status and not msg then |
|---|
| 244 | msg=cmd .. " failed" |
|---|
| 245 | end |
|---|
| 246 | return status,msg |
|---|
| [2] | 247 | else |
|---|
| 248 | return false,string.format("unknown command '%s'\n",cmd) |
|---|
| 249 | end |
|---|
| 250 | elseif string.find(line,'[^%c%s]') then |
|---|
| 251 | return false, string.format("bad input '%s'\n",line) |
|---|
| 252 | end |
|---|
| 253 | -- blank input is OK |
|---|
| 254 | return true,"" |
|---|
| 255 | end |
|---|
| 256 | |
|---|
| [78] | 257 | function cli:print_status(status,msg) |
|---|
| 258 | if not status then |
|---|
| 259 | errf("%s\n",tostring(msg)) |
|---|
| 260 | elseif msg and string.len(msg) ~= 0 then |
|---|
| 261 | printf("%s",msg) |
|---|
| 262 | if string.sub(msg,-1,-1) ~= '\n' then |
|---|
| 263 | printf("\n") |
|---|
| 264 | end |
|---|
| 265 | end |
|---|
| 266 | end |
|---|
| 267 | |
|---|
| [2] | 268 | function cli:run() |
|---|
| 269 | self:prompt() |
|---|
| 270 | for line in io.lines() do |
|---|
| [78] | 271 | self:print_status(self:execute(line)) |
|---|
| [2] | 272 | if self.finished then |
|---|
| 273 | break |
|---|
| 274 | end |
|---|
| 275 | self:prompt() |
|---|
| 276 | end |
|---|
| 277 | end |
|---|
| 278 | |
|---|
| 279 | cli:add_commands{ |
|---|
| 280 | { |
|---|
| [92] | 281 | names={'help','h'}, |
|---|
| 282 | arghelp='[cmd]|[-v]', |
|---|
| [93] | 283 | args=argparser.create{v=false}, |
|---|
| [92] | 284 | help='help on [cmd] or all commands', |
|---|
| 285 | help_detail=[[ |
|---|
| 286 | help -v gives full help on all commands, otherwise as summary is printed |
|---|
| 287 | ]], |
|---|
| [2] | 288 | func=function(self,args) |
|---|
| [93] | 289 | cmd = args[1] |
|---|
| 290 | if cmd and cli.names[cmd] then |
|---|
| 291 | return true, cli.names[cmd]:get_help_detail() |
|---|
| [2] | 292 | end |
|---|
| [93] | 293 | if cmd then |
|---|
| 294 | return false, string.format("unknown command '%s'\n",cmd) |
|---|
| [2] | 295 | end |
|---|
| 296 | msg = "" |
|---|
| 297 | for i,c in ipairs(cli.cmds) do |
|---|
| [93] | 298 | if args.v then |
|---|
| [92] | 299 | msg = msg .. c:get_help_detail() |
|---|
| 300 | else |
|---|
| 301 | msg = msg .. c:get_help() |
|---|
| 302 | end |
|---|
| [2] | 303 | end |
|---|
| 304 | return true, msg |
|---|
| 305 | end, |
|---|
| 306 | }, |
|---|
| 307 | { |
|---|
| 308 | names={'#'}, |
|---|
| 309 | help='comment', |
|---|
| 310 | func=function(self,args) |
|---|
| 311 | return true |
|---|
| 312 | end, |
|---|
| 313 | }, |
|---|
| 314 | { |
|---|
| 315 | names={'exec','!'}, |
|---|
| 316 | help='execute local lua', |
|---|
| 317 | arghelp='<lua code>', |
|---|
| [92] | 318 | help_detail=[[ |
|---|
| 319 | Execute lua in chdkptp. |
|---|
| 320 | The global variable con accesses the current CLI connection. |
|---|
| 321 | Return values are printed in the console. |
|---|
| 322 | ]], |
|---|
| [2] | 323 | func=function(self,args) |
|---|
| 324 | local f,r = loadstring(args) |
|---|
| 325 | if f then |
|---|
| [135] | 326 | r={xpcall(f,util.err_traceback)} |
|---|
| [2] | 327 | if not r[1] then |
|---|
| 328 | return false, string.format("call failed:%s\n",r[2]) |
|---|
| 329 | end |
|---|
| 330 | local s |
|---|
| 331 | if #r > 1 then |
|---|
| [18] | 332 | s='=' .. serialize(r[2],{pretty=true,err_type=false,err_cycle=false}) |
|---|
| [2] | 333 | for i = 3, #r do |
|---|
| [18] | 334 | s = s .. ',' .. serialize(r[i],{pretty=true,err_type=false,err_cycle=false}) |
|---|
| [2] | 335 | end |
|---|
| 336 | end |
|---|
| 337 | return true, s |
|---|
| 338 | else |
|---|
| 339 | return false, string.format("compile failed:%s\n",r) |
|---|
| 340 | end |
|---|
| 341 | end, |
|---|
| 342 | }, |
|---|
| 343 | { |
|---|
| 344 | names={'quit','q'}, |
|---|
| 345 | help='quit program', |
|---|
| 346 | func=function() |
|---|
| 347 | cli.finished = true |
|---|
| 348 | return true,"bye" |
|---|
| 349 | end, |
|---|
| 350 | }, |
|---|
| 351 | { |
|---|
| [92] | 352 | names={'lua','.'}, |
|---|
| [2] | 353 | help='execute remote lua', |
|---|
| 354 | arghelp='<lua code>', |
|---|
| [92] | 355 | help_detail=[[ |
|---|
| 356 | Execute Lua code on the camera. |
|---|
| 357 | Returns immediately after the script is started. |
|---|
| 358 | Return values or error messages can be retrieved with getm after the script is completed. |
|---|
| 359 | ]], |
|---|
| [2] | 360 | func=function(self,args) |
|---|
| [58] | 361 | return con:exec(args) |
|---|
| [2] | 362 | end, |
|---|
| 363 | }, |
|---|
| 364 | { |
|---|
| 365 | names={'getm'}, |
|---|
| 366 | help='get messages', |
|---|
| 367 | func=function(self,args) |
|---|
| 368 | local msgs='' |
|---|
| 369 | local msg,err |
|---|
| 370 | while true do |
|---|
| [58] | 371 | msg,err=con:read_msg() |
|---|
| [2] | 372 | if type(msg) ~= 'table' then |
|---|
| 373 | return false,msgs..err |
|---|
| 374 | end |
|---|
| 375 | if msg.type == 'none' then |
|---|
| 376 | return true,msgs |
|---|
| 377 | end |
|---|
| 378 | msgs = msgs .. chdku.format_script_msg(msg) .. "\n" |
|---|
| 379 | end |
|---|
| 380 | end, |
|---|
| 381 | }, |
|---|
| 382 | { |
|---|
| 383 | names={'putm'}, |
|---|
| 384 | help='send message', |
|---|
| 385 | arghelp='<msg string>', |
|---|
| 386 | func=function(self,args) |
|---|
| [58] | 387 | return con:write_msg(args) |
|---|
| [2] | 388 | end, |
|---|
| 389 | }, |
|---|
| 390 | { |
|---|
| 391 | names={'luar','='}, |
|---|
| 392 | help='execute remote lua, wait for result', |
|---|
| 393 | arghelp='<lua code>', |
|---|
| [92] | 394 | help_detail=[[ |
|---|
| 395 | Execute Lua code on the camera, waiting for the script to end. |
|---|
| 396 | Return values or error messages are printed after the script completes. |
|---|
| 397 | ]], |
|---|
| [2] | 398 | func=function(self,args) |
|---|
| [31] | 399 | local rets={} |
|---|
| 400 | local msgs={} |
|---|
| [58] | 401 | local status,err = con:execwait(args,{rets=rets,msgs=msgs}) |
|---|
| [2] | 402 | if not status then |
|---|
| [31] | 403 | return false,err |
|---|
| [2] | 404 | end |
|---|
| [31] | 405 | local r='' |
|---|
| 406 | for i=1,#msgs do |
|---|
| 407 | r=r .. chdku.format_script_msg(msgs[i]) .. '\n' |
|---|
| 408 | end |
|---|
| 409 | for i=1,table.maxn(rets) do |
|---|
| 410 | r=r .. chdku.format_script_msg(rets[i]) .. '\n' |
|---|
| 411 | end |
|---|
| 412 | return true,r |
|---|
| [2] | 413 | end, |
|---|
| 414 | }, |
|---|
| 415 | { |
|---|
| 416 | -- TODO support display as words |
|---|
| 417 | names={'rmem'}, |
|---|
| [92] | 418 | help='read memory', |
|---|
| [93] | 419 | args=argparser.create(), -- only word args |
|---|
| [2] | 420 | arghelp='<address> [count]', |
|---|
| 421 | func=function(self,args) |
|---|
| [93] | 422 | local addr = tonumber(args[1]) |
|---|
| 423 | local count = tonumber(args[2]) |
|---|
| [2] | 424 | if not addr then |
|---|
| 425 | return false, "bad args" |
|---|
| 426 | end |
|---|
| 427 | if not count then |
|---|
| 428 | count = 1 |
|---|
| 429 | end |
|---|
| [89] | 430 | |
|---|
| [58] | 431 | r,msg = con:getmem(addr,count) |
|---|
| [2] | 432 | if not r then |
|---|
| 433 | return false,msg |
|---|
| 434 | end |
|---|
| [89] | 435 | return true,string.format("0x%x %u\n",addr,count)..hexdump(r,addr) |
|---|
| [2] | 436 | end, |
|---|
| 437 | }, |
|---|
| 438 | { |
|---|
| 439 | names={'list'}, |
|---|
| 440 | help='list devices', |
|---|
| [92] | 441 | help_detail=[[ |
|---|
| 442 | Lists all recognized PTP devices in the following format |
|---|
| 443 | <status><num><modelname> b=<bus> d=<device> v=<usb vendor> p=<usb pid> s=<serial number> |
|---|
| 444 | status values |
|---|
| 445 | * connected, current target for CLI commands (con global variable) |
|---|
| 446 | + connected, not CLI target |
|---|
| 447 | - not connected |
|---|
| 448 | serial numbers are not available from all models |
|---|
| 449 | ]], |
|---|
| [2] | 450 | func=function() |
|---|
| 451 | local msg = '' |
|---|
| [78] | 452 | local devs = chdk.list_usb_devices() |
|---|
| 453 | for i,desc in ipairs(devs) do |
|---|
| 454 | local lcon = chdku.connection(desc) |
|---|
| 455 | local tempcon = false |
|---|
| [90] | 456 | local status = "+" |
|---|
| [78] | 457 | if not lcon:is_connected() then |
|---|
| 458 | tempcon = true |
|---|
| [92] | 459 | status = "-" |
|---|
| [78] | 460 | lcon:connect() |
|---|
| [134] | 461 | else |
|---|
| 462 | -- existing con wrapped in new object won't have info set |
|---|
| 463 | lcon:update_connection_info() |
|---|
| [78] | 464 | end |
|---|
| 465 | |
|---|
| [90] | 466 | if lcon._con == con._con then |
|---|
| 467 | status = "*" |
|---|
| [78] | 468 | end |
|---|
| 469 | |
|---|
| [87] | 470 | msg = msg .. string.format("%s%d:%s b=%s d=%s v=0x%x p=0x%x s=%s\n", |
|---|
| 471 | status, i, |
|---|
| [134] | 472 | tostring(lcon.ptpdev.model), |
|---|
| 473 | lcon.usbdev.bus, lcon.usbdev.dev, |
|---|
| 474 | tostring(lcon.usbdev.vendor_id), |
|---|
| 475 | tostring(lcon.usbdev.product_id), |
|---|
| 476 | tostring(lcon.ptpdev.serial_number)) |
|---|
| [78] | 477 | if tempcon then |
|---|
| 478 | lcon:disconnect() |
|---|
| 479 | end |
|---|
| [2] | 480 | end |
|---|
| 481 | return true,msg |
|---|
| 482 | end, |
|---|
| 483 | }, |
|---|
| 484 | { |
|---|
| 485 | names={'upload','u'}, |
|---|
| 486 | help='upload a file to the camera', |
|---|
| [98] | 487 | arghelp="[-nolua] <local> [remote]", |
|---|
| 488 | args=argparser.create{nolua=false}, |
|---|
| [92] | 489 | help_detail=[[ |
|---|
| [98] | 490 | <local> file to upload |
|---|
| 491 | [remote] destination |
|---|
| 492 | If not specified, file is uploaded to A/ |
|---|
| 493 | If remote is a directory or ends in / uploaded to remote/<local file name> |
|---|
| 494 | -nolua skip lua based checks on remote |
|---|
| 495 | Allows upload while running script |
|---|
| 496 | Prevents detecting if remote is a directory |
|---|
| 497 | Some cameras have problems with paths > 32 characters |
|---|
| 498 | Dryos cameras do not handle non 8.3 filenames well |
|---|
| [92] | 499 | ]], |
|---|
| [2] | 500 | func=function(self,args) |
|---|
| [93] | 501 | local src = args[1] |
|---|
| [55] | 502 | if not src then |
|---|
| 503 | return false, "missing source" |
|---|
| 504 | end |
|---|
| [98] | 505 | if lfs.attributes(src,'mode') ~= 'file' then |
|---|
| 506 | return false, 'src is not a file: '..src |
|---|
| 507 | end |
|---|
| 508 | |
|---|
| 509 | local dst_dir |
|---|
| [93] | 510 | local dst = args[2] |
|---|
| [2] | 511 | -- no dst, use filename of source |
|---|
| [98] | 512 | if dst then |
|---|
| [115] | 513 | dst = fsutil.make_camera_path(dst) |
|---|
| [98] | 514 | if string.find(dst,'[\\/]$') then |
|---|
| 515 | -- trailing slash, append filename of source |
|---|
| 516 | dst = string.sub(dst,1,-2) |
|---|
| 517 | if not args.nolua then |
|---|
| 518 | local st,err = con:stat(dst) |
|---|
| 519 | if not st then |
|---|
| 520 | return false, 'stat dest '..dst..' failed: ' .. err |
|---|
| 521 | end |
|---|
| 522 | if not st.is_dir then |
|---|
| 523 | return false, 'not a directory: '..dst |
|---|
| 524 | end |
|---|
| 525 | end |
|---|
| [111] | 526 | dst = fsutil.joinpath(dst,fsutil.basename(src)) |
|---|
| [98] | 527 | else |
|---|
| 528 | if not args.nolua then |
|---|
| 529 | local st = con:stat(dst) |
|---|
| 530 | if st and st.is_dir then |
|---|
| [111] | 531 | dst = fsutil.joinpath(dst,fsutil.basename(src)) |
|---|
| [98] | 532 | end |
|---|
| 533 | end |
|---|
| 534 | end |
|---|
| 535 | else |
|---|
| [115] | 536 | dst = fsutil.make_camera_path(fsutil.basename(src)) |
|---|
| [2] | 537 | end |
|---|
| [98] | 538 | |
|---|
| [2] | 539 | local msg=string.format("%s->%s\n",src,dst) |
|---|
| [58] | 540 | local r, msg2 = con:upload(src,dst) |
|---|
| [2] | 541 | if msg2 then |
|---|
| 542 | msg = msg .. msg2 |
|---|
| 543 | end |
|---|
| 544 | return r, msg |
|---|
| 545 | end, |
|---|
| 546 | }, |
|---|
| 547 | { |
|---|
| 548 | names={'download','d'}, |
|---|
| 549 | help='download a file from the camera', |
|---|
| [98] | 550 | arghelp="[-nolua] <remote> [local]", |
|---|
| 551 | args=argparser.create{nolua=false}, |
|---|
| [92] | 552 | help_detail=[[ |
|---|
| [98] | 553 | <remote> file to download |
|---|
| 554 | A/ is prepended if not present |
|---|
| 555 | [local] destination |
|---|
| 556 | If not specified, the file will be downloaded to the current directory |
|---|
| 557 | If a directory, the file will be downloaded into it |
|---|
| 558 | -nolua skip lua based checks on remote |
|---|
| 559 | Allows download while running script |
|---|
| [92] | 560 | ]], |
|---|
| 561 | |
|---|
| [2] | 562 | func=function(self,args) |
|---|
| [93] | 563 | local src = args[1] |
|---|
| [55] | 564 | if not src then |
|---|
| 565 | return false, "missing source" |
|---|
| 566 | end |
|---|
| [93] | 567 | local dst = args[2] |
|---|
| [2] | 568 | if not dst then |
|---|
| [96] | 569 | -- no dest, use final component of source path |
|---|
| [111] | 570 | dst = fsutil.basename(src) |
|---|
| [96] | 571 | elseif string.match(dst,'[\\/]+$') then |
|---|
| 572 | -- explicit / treat it as a directory |
|---|
| [111] | 573 | dst = fsutil.joinpath(dst,fsutil.basename(src)) |
|---|
| [96] | 574 | -- and check if it is |
|---|
| [111] | 575 | local dst_dir = fsutil.dirname(dst) |
|---|
| [96] | 576 | -- TODO should create it |
|---|
| 577 | if lfs.attributes(dst_dir,'mode') ~= 'directory' then |
|---|
| 578 | return false,'not a directory: '..dst_dir |
|---|
| 579 | end |
|---|
| 580 | elseif lfs.attributes(dst,'mode') == 'directory' then |
|---|
| 581 | -- if target is a directory download into it |
|---|
| [111] | 582 | dst = fsutil.joinpath(dst,fsutil.basename(src)) |
|---|
| [2] | 583 | end |
|---|
| [96] | 584 | |
|---|
| [115] | 585 | src = fsutil.make_camera_path(src) |
|---|
| [98] | 586 | if not args.nolua then |
|---|
| [96] | 587 | local src_st,err = con:stat(src) |
|---|
| 588 | if not src_st then |
|---|
| 589 | return false, 'stat source '..src..' failed: ' .. err |
|---|
| 590 | end |
|---|
| 591 | if not src_st.is_file then |
|---|
| 592 | return false, src..' is not a file' |
|---|
| 593 | end |
|---|
| [2] | 594 | end |
|---|
| 595 | local msg=string.format("%s->%s\n",src,dst) |
|---|
| [58] | 596 | local r, msg2 = con:download(src,dst) |
|---|
| [2] | 597 | if msg2 then |
|---|
| 598 | msg = msg .. msg2 |
|---|
| 599 | end |
|---|
| 600 | return r, msg |
|---|
| 601 | end, |
|---|
| 602 | }, |
|---|
| 603 | { |
|---|
| [117] | 604 | names={'mdownload','mdl'}, |
|---|
| 605 | help='download file/directories from the camera', |
|---|
| 606 | arghelp="[options] <remote, remote, ...> <target dir>", |
|---|
| 607 | args=argparser.create{ |
|---|
| 608 | fmatch=false, |
|---|
| 609 | dmatch=false, |
|---|
| 610 | rmatch=false, |
|---|
| 611 | nodirs=false, |
|---|
| 612 | maxdepth=100, |
|---|
| [126] | 613 | nomtime=false, |
|---|
| [117] | 614 | }, |
|---|
| 615 | help_detail=[[ |
|---|
| 616 | <remote...> files/directories to download |
|---|
| 617 | <target dir> directory to download into |
|---|
| 618 | options: |
|---|
| [126] | 619 | -fmatch=<pattern> download only file with path/name matching <pattern> |
|---|
| 620 | -dmatch=<pattern> only create directories with path/name matching <pattern> |
|---|
| 621 | -rmatch=<pattern> only recurse into directories with path/name matching <pattern> |
|---|
| 622 | -nodirs only create directories needed to download file |
|---|
| [117] | 623 | -maxdepth=n only recurse into N levels of directory |
|---|
| [126] | 624 | -nomtime don't preserve modification time of remote files |
|---|
| [117] | 625 | note <pattern> is a lua pattern, not a filesystem glob like *.JPG |
|---|
| 626 | ]], |
|---|
| 627 | |
|---|
| 628 | func=function(self,args) |
|---|
| 629 | if #args < 2 then |
|---|
| 630 | return false,'expected source(s) and destination' |
|---|
| 631 | end |
|---|
| 632 | local dst=table.remove(args) |
|---|
| 633 | local srcs={} |
|---|
| 634 | for i,v in ipairs(args) do |
|---|
| 635 | srcs[i]=fsutil.make_camera_path(v) |
|---|
| 636 | end |
|---|
| 637 | -- TODO some of these need translating, so can't pass direct |
|---|
| 638 | local opts={ |
|---|
| 639 | fmatch=args.fmatch, |
|---|
| 640 | dmatch=args.dmatch, |
|---|
| 641 | rmatch=args.rmatch, |
|---|
| 642 | dirs=not args.nodirs, |
|---|
| 643 | maxdepth=tonumber(args.maxdepth), |
|---|
| [126] | 644 | mtime=not args.nomtime |
|---|
| [117] | 645 | } |
|---|
| 646 | return con:mdownload(srcs,dst,opts) |
|---|
| 647 | end, |
|---|
| 648 | }, |
|---|
| 649 | { |
|---|
| 650 | names={'mupload','mup'}, |
|---|
| 651 | help='upload file/directories to the camera', |
|---|
| 652 | arghelp="[options] <local, local, ...> <target dir>", |
|---|
| 653 | args=argparser.create{ |
|---|
| 654 | fmatch=false, |
|---|
| 655 | dmatch=false, |
|---|
| 656 | rmatch=false, |
|---|
| 657 | nodirs=false, |
|---|
| 658 | maxdepth=100, |
|---|
| 659 | pretend=false, |
|---|
| [126] | 660 | nomtime=false, |
|---|
| [117] | 661 | }, |
|---|
| 662 | help_detail=[[ |
|---|
| 663 | <local...> files/directories to upload |
|---|
| 664 | <target dir> directory to upload into |
|---|
| 665 | options: |
|---|
| [126] | 666 | -fmatch=<pattern> upload only file with path/name matching <pattern> |
|---|
| 667 | -dmatch=<pattern> only create directories with path/name matching <pattern> |
|---|
| 668 | -rmatch=<pattern> only recurse into directories with path/name matching <pattern> |
|---|
| 669 | -nodirs only create directories needed to upload file |
|---|
| [117] | 670 | -maxdepth=n only recurse into N levels of directory |
|---|
| 671 | -pretend print actions instead of doing them |
|---|
| [126] | 672 | -nomtime don't preserve local modification time |
|---|
| [117] | 673 | note <pattern> is a lua pattern, not a filesystem glob like *.JPG |
|---|
| 674 | ]], |
|---|
| 675 | |
|---|
| 676 | func=function(self,args) |
|---|
| 677 | if #args < 2 then |
|---|
| 678 | return false,'expected source(s) and destination' |
|---|
| 679 | end |
|---|
| 680 | local dst=fsutil.make_camera_path(table.remove(args)) |
|---|
| 681 | local srcs={} |
|---|
| 682 | -- args has other stuff in it, copy array parts |
|---|
| 683 | srcs={unpack(args)} |
|---|
| 684 | -- TODO some of these need translating, so can't pass direct |
|---|
| 685 | local opts={ |
|---|
| 686 | fmatch=args.fmatch, |
|---|
| 687 | dmatch=args.dmatch, |
|---|
| 688 | rmatch=args.rmatch, |
|---|
| 689 | dirs=not args.nodirs, |
|---|
| 690 | maxdepth=tonumber(args.maxdepth), |
|---|
| 691 | pretend=args.pretend, |
|---|
| [126] | 692 | mtime=not args.nomtime, |
|---|
| [117] | 693 | } |
|---|
| 694 | return con:mupload(srcs,dst,opts) |
|---|
| 695 | end, |
|---|
| 696 | }, |
|---|
| 697 | { |
|---|
| 698 | names={'delete','rm'}, |
|---|
| 699 | help='delete file/directories from the camera', |
|---|
| 700 | arghelp="[options] <target, target,...>", |
|---|
| 701 | args=argparser.create{ |
|---|
| 702 | fmatch=false, |
|---|
| 703 | dmatch=false, |
|---|
| 704 | rmatch=false, |
|---|
| 705 | nodirs=false, |
|---|
| 706 | maxdepth=100, |
|---|
| 707 | pretend=false, |
|---|
| 708 | ignore_errors=false, |
|---|
| 709 | skip_topdirs=false, |
|---|
| 710 | }, |
|---|
| 711 | help_detail=[[ |
|---|
| 712 | <target...> files/directories to remote |
|---|
| 713 | options: |
|---|
| 714 | -fmatch=<pattern> upload only file with names matching <pattern> |
|---|
| 715 | -dmatch=<pattern> only delete directories with names matching <pattern> |
|---|
| 716 | -rmatch=<pattern> only recurse into directories with names matching <pattern> |
|---|
| 717 | -nodirs don't delete drictories recursed into, only files |
|---|
| 718 | -maxdepth=n only recurse into N levels of directory |
|---|
| 719 | -pretend print actions instead of doing them |
|---|
| 720 | -ignore_errors don't abort if delete fails, continue to next item |
|---|
| 721 | -skip_topdirs don't delete directories given in command line, only contents |
|---|
| 722 | note <pattern> is a lua pattern, not a filesystem glob like *.JPG |
|---|
| 723 | ]], |
|---|
| 724 | |
|---|
| 725 | func=function(self,args) |
|---|
| 726 | if #args < 1 then |
|---|
| 727 | return false,'expected at least one target' |
|---|
| 728 | end |
|---|
| 729 | -- args has other stuff in it, copy array parts |
|---|
| 730 | local tgts={} |
|---|
| 731 | for i,v in ipairs(args) do |
|---|
| 732 | tgts[i]=fsutil.make_camera_path(v) |
|---|
| 733 | end |
|---|
| 734 | -- TODO some of these need translating, so can't pass direct |
|---|
| 735 | local opts={ |
|---|
| 736 | fmatch=args.fmatch, |
|---|
| 737 | dmatch=args.dmatch, |
|---|
| 738 | rmatch=args.rmatch, |
|---|
| 739 | dirs=not args.nodirs, |
|---|
| 740 | maxdepth=tonumber(args.maxdepth), |
|---|
| 741 | pretend=args.pretend, |
|---|
| 742 | ignore_errors=args.ignore_errors, |
|---|
| 743 | skip_topdirs=args.skip_topdirs, |
|---|
| 744 | } |
|---|
| 745 | -- TODO use msg_handler to print as they are deleted instead of all at the end |
|---|
| 746 | local results,err = con:mdelete(tgts,opts) |
|---|
| 747 | if not results then |
|---|
| 748 | return false,err |
|---|
| 749 | end |
|---|
| 750 | for i,v in ipairs(results) do |
|---|
| 751 | printf("%s: ",v.file) |
|---|
| 752 | if v.status then |
|---|
| 753 | printf('OK') |
|---|
| 754 | else |
|---|
| 755 | printf('FAILED') |
|---|
| 756 | end |
|---|
| 757 | if v.msg then |
|---|
| 758 | printf(": %s",v.msg) |
|---|
| 759 | end |
|---|
| 760 | printf('\n') |
|---|
| 761 | end |
|---|
| 762 | return true |
|---|
| 763 | end, |
|---|
| 764 | }, |
|---|
| 765 | { |
|---|
| [118] | 766 | names={'mkdir'}, |
|---|
| 767 | help='create directories on camera', |
|---|
| 768 | arghelp="<directory>", |
|---|
| 769 | args=argparser.create{ }, |
|---|
| 770 | help_detail=[[ |
|---|
| 771 | <directory> directory to create. Intermediate directories will be created as needed |
|---|
| 772 | ]], |
|---|
| 773 | func=function(self,args) |
|---|
| 774 | if #args ~= 1 then |
|---|
| 775 | return false,'expected exactly one arg' |
|---|
| 776 | end |
|---|
| 777 | return con:mkdir_m(fsutil.make_camera_path(args[1])) |
|---|
| 778 | end |
|---|
| 779 | }, |
|---|
| 780 | { |
|---|
| [2] | 781 | names={'version','ver'}, |
|---|
| 782 | help='print API versions', |
|---|
| 783 | func=function(self,args) |
|---|
| 784 | local host_ver = string.format("host:%d.%d cam:",chdk.host_api_version()) |
|---|
| [60] | 785 | if con:is_connected() then |
|---|
| [58] | 786 | local cam_major, cam_minor = con:camera_api_version() |
|---|
| [2] | 787 | if not cam_major then |
|---|
| 788 | return false, host_ver .. string.format("error %s",cam_minor) |
|---|
| 789 | end |
|---|
| 790 | return true, host_ver .. string.format("%d.%d",cam_major,cam_minor) |
|---|
| 791 | else |
|---|
| 792 | return true, host_ver .. "not connected" |
|---|
| 793 | end |
|---|
| 794 | end, |
|---|
| 795 | }, |
|---|
| 796 | { |
|---|
| 797 | names={'connect','c'}, |
|---|
| [78] | 798 | help='connect to device', |
|---|
| [93] | 799 | arghelp="[-b=<bus>] [-d=<dev>] [-p=<pid>] [-s=<serial>] [model] ", |
|---|
| 800 | args=argparser.create{ |
|---|
| 801 | b='.*', |
|---|
| 802 | d='.*', |
|---|
| 803 | p=false, |
|---|
| 804 | s=false, |
|---|
| 805 | }, |
|---|
| 806 | |
|---|
| [92] | 807 | help_detail=[[ |
|---|
| 808 | If no options are given, connects to the first available device. |
|---|
| [93] | 809 | <pid> is the USB product ID, as a decimal or hexadecimal number. |
|---|
| [92] | 810 | All other options are treated as a Lua pattern. For alphanumerics, this is a case sensitive substring match. |
|---|
| 811 | If the serial or model are specified, a temporary connection will be made to each device |
|---|
| 812 | If <model> includes spaces, it must be quoted. |
|---|
| 813 | If multiple devices match, the first matching device will be connected. |
|---|
| 814 | ]], |
|---|
| [2] | 815 | func=function(self,args) |
|---|
| [93] | 816 | local match = {} |
|---|
| [85] | 817 | local opt_map = { |
|---|
| 818 | b='bus', |
|---|
| 819 | d='dev', |
|---|
| 820 | p='product_id', |
|---|
| 821 | s='serial_number', |
|---|
| [93] | 822 | [1]='model', |
|---|
| [85] | 823 | } |
|---|
| [93] | 824 | for k,v in pairs(opt_map) do |
|---|
| 825 | -- TODO matches expect nil |
|---|
| 826 | if type(args[k]) == 'string' then |
|---|
| 827 | match[v] = args[k] |
|---|
| 828 | end |
|---|
| 829 | -- printf('%s=%s\n',v,tostring(args[k])) |
|---|
| 830 | end |
|---|
| 831 | |
|---|
| [60] | 832 | if con:is_connected() then |
|---|
| 833 | con:disconnect() |
|---|
| [58] | 834 | end |
|---|
| [93] | 835 | |
|---|
| [92] | 836 | if match.product_id and not tonumber(match.product_id) then |
|---|
| 837 | return false,"expected number for product id" |
|---|
| 838 | end |
|---|
| [85] | 839 | local devices = chdk.list_usb_devices() |
|---|
| 840 | local lcon |
|---|
| 841 | for i, devinfo in ipairs(devices) do |
|---|
| 842 | lcon = nil |
|---|
| 843 | if chdku.match_device(devinfo,match) then |
|---|
| 844 | lcon = chdku.connection(devinfo) |
|---|
| [88] | 845 | -- if we are looking for model or serial, need to connect to the dev to check |
|---|
| [85] | 846 | if match.model or match.serial_number then |
|---|
| [88] | 847 | local tempcon = false |
|---|
| [85] | 848 | -- printf('model check %s %s\n',tostring(match.model),tostring(match.serial_number)) |
|---|
| 849 | if not lcon:is_connected() then |
|---|
| 850 | lcon:connect() |
|---|
| 851 | tempcon = true |
|---|
| [134] | 852 | else |
|---|
| 853 | lcon:update_connection_info() |
|---|
| [85] | 854 | end |
|---|
| 855 | if not lcon:match_ptp_info(match) then |
|---|
| 856 | if tempcon then |
|---|
| 857 | lcon:disconnect() |
|---|
| 858 | end |
|---|
| 859 | lcon = nil |
|---|
| 860 | end |
|---|
| 861 | end |
|---|
| 862 | if lcon then |
|---|
| 863 | break |
|---|
| 864 | end |
|---|
| 865 | end |
|---|
| 866 | end |
|---|
| 867 | if lcon then |
|---|
| 868 | con = lcon |
|---|
| 869 | if con:is_connected() then |
|---|
| 870 | return true |
|---|
| 871 | end |
|---|
| [78] | 872 | return con:connect() |
|---|
| 873 | end |
|---|
| [85] | 874 | return false,"no matching devices found" |
|---|
| [78] | 875 | end, |
|---|
| 876 | }, |
|---|
| 877 | { |
|---|
| 878 | names={'reconnect','r'}, |
|---|
| 879 | help='reconnect to current device', |
|---|
| [134] | 880 | -- NOTE camera may connect to a different device, |
|---|
| 881 | -- will detect and fail if serial, model or pid don't match |
|---|
| [78] | 882 | func=function(self,args) |
|---|
| [134] | 883 | return con:reconnect() |
|---|
| [2] | 884 | end, |
|---|
| 885 | }, |
|---|
| 886 | { |
|---|
| 887 | names={'disconnect','dis'}, |
|---|
| 888 | help='disconnect from device', |
|---|
| 889 | func=function(self,args) |
|---|
| [58] | 890 | return con:disconnect() |
|---|
| [2] | 891 | end, |
|---|
| 892 | }, |
|---|
| [16] | 893 | { |
|---|
| 894 | names={'ls'}, |
|---|
| 895 | help='list files/directories on camera', |
|---|
| [93] | 896 | args=argparser.create{l=false}, |
|---|
| [16] | 897 | arghelp="[-l] [path]", |
|---|
| 898 | func=function(self,args) |
|---|
| [93] | 899 | local listops |
|---|
| 900 | local path=args[1] |
|---|
| [115] | 901 | path = fsutil.make_camera_path(path) |
|---|
| [93] | 902 | if args.l then |
|---|
| [17] | 903 | listopts = { stat='*' } |
|---|
| 904 | else |
|---|
| 905 | listopts = { stat='/' } |
|---|
| 906 | end |
|---|
| [58] | 907 | local list,msg = con:listdir(path,listopts) |
|---|
| [16] | 908 | if type(list) == 'table' then |
|---|
| [45] | 909 | local r = '' |
|---|
| [93] | 910 | if args.l then |
|---|
| [17] | 911 | -- alphabetic sort TODO sorting/grouping options |
|---|
| [45] | 912 | chdku.sortdir_stat(list) |
|---|
| 913 | for i,st in ipairs(list) do |
|---|
| [125] | 914 | local name = st.name |
|---|
| 915 | local size = st.size |
|---|
| [17] | 916 | if st.is_dir then |
|---|
| [125] | 917 | name = name..'/' |
|---|
| 918 | size = '<dir>' |
|---|
| [16] | 919 | else |
|---|
| 920 | end |
|---|
| [126] | 921 | -- print(i,name,chdku.ts_cam2pc(st.mtime)) |
|---|
| [125] | 922 | r = r .. string.format("%s %10s %s\n",os.date('%c',chdku.ts_cam2pc(st.mtime)),tostring(size),name) |
|---|
| [16] | 923 | end |
|---|
| [17] | 924 | else |
|---|
| 925 | table.sort(list) |
|---|
| 926 | for i,name in ipairs(list) do |
|---|
| 927 | r = r .. name .. '\n' |
|---|
| 928 | end |
|---|
| [16] | 929 | end |
|---|
| [17] | 930 | |
|---|
| [16] | 931 | return true,r |
|---|
| 932 | end |
|---|
| 933 | return false,msg |
|---|
| 934 | end, |
|---|
| 935 | }, |
|---|
| [51] | 936 | { |
|---|
| 937 | names={'reboot'}, |
|---|
| 938 | help='reboot the camera', |
|---|
| [134] | 939 | arghelp="[options] [file]", |
|---|
| 940 | args=argparser.create({ |
|---|
| 941 | wait=3500, |
|---|
| 942 | norecon=false, |
|---|
| 943 | }), |
|---|
| [92] | 944 | help_detail=[[ |
|---|
| [134] | 945 | file: Optional file to boot. |
|---|
| 946 | Must be an unencoded binary or for DryOS only, an encoded .FI2 |
|---|
| 947 | Format is assumed based on extension |
|---|
| 948 | If not set, firmware boots normally, loading diskboot.bin if configured |
|---|
| 949 | options: |
|---|
| 950 | -norecon don't try to reconnect |
|---|
| 951 | -wait=<N> wait N ms before attempting to reconnect, default 3500 |
|---|
| [92] | 952 | ]], |
|---|
| [51] | 953 | func=function(self,args) |
|---|
| [93] | 954 | local bootfile=args[1] |
|---|
| [51] | 955 | if bootfile then |
|---|
| [115] | 956 | bootfile = fsutil.make_camera_path(bootfile) |
|---|
| [51] | 957 | bootfile = string.format("'%s'",bootfile) |
|---|
| 958 | else |
|---|
| 959 | bootfile = '' |
|---|
| 960 | end |
|---|
| 961 | -- sleep and disconnect to avoid later connection problems on some cameras |
|---|
| 962 | -- clobber because we don't care about memory leaks |
|---|
| [58] | 963 | local status,err=con:exec('sleep(1000);reboot('..bootfile..')',{clobber=true}) |
|---|
| [51] | 964 | if not status then |
|---|
| 965 | return false,err |
|---|
| 966 | end |
|---|
| [134] | 967 | if args.norecon then |
|---|
| 968 | return true |
|---|
| 969 | end |
|---|
| 970 | return con:reconnect({wait=args.wait}) |
|---|
| [51] | 971 | end, |
|---|
| 972 | }, |
|---|
| [2] | 973 | }; |
|---|
| 974 | |
|---|
| 975 | return cli; |
|---|