firmware_utils.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. #!/usr/bin/env python3
  2. # Copyright (c) 2020 Project CHIP Authors
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. """Utitilies to flash or erase a device."""
  16. import argparse
  17. import errno
  18. import locale
  19. import os
  20. import stat
  21. import subprocess
  22. import sys
  23. import textwrap
  24. # Here are the options that can be use to configure a `Flasher`
  25. # object (as dictionary keys) and/or passed as command line options.
  26. OPTIONS = {
  27. # Configuration options define properties used in flashing operations.
  28. # (The outer level of an options definition corresponds to option groups
  29. # in the command-line help message.)
  30. 'configuration': {
  31. # Script configuration options.
  32. 'verbose': {
  33. 'help': 'Report more verbosely',
  34. 'default': 0,
  35. 'alias': ['-v'],
  36. 'argparse': {
  37. 'action': 'count',
  38. },
  39. # Levels:
  40. # 0 - error message
  41. # 1 - action to be taken
  42. # 2 - results of action, even if successful
  43. # 3+ - details
  44. },
  45. },
  46. # Action control options specify operations that Flasher.action() or
  47. # the function interface flash_command() will perform.
  48. 'operations': {
  49. # Action control options.
  50. 'erase': {
  51. 'help': 'Erase device',
  52. 'default': False,
  53. 'argparse': {
  54. 'action': 'store_true'
  55. },
  56. },
  57. 'application': {
  58. 'help': 'Flash an image',
  59. 'default': None,
  60. 'argparse': {
  61. 'metavar': 'FILE'
  62. },
  63. },
  64. 'verify_application': {
  65. 'help': 'Verify the image after flashing',
  66. 'default': False,
  67. 'argparse': {
  68. 'action': 'store_true'
  69. },
  70. },
  71. # 'reset' is a three-way switch; if None, action() will reset the
  72. # device if and only if an application image is flashed. So, we add
  73. # an explicit option to set it false.
  74. 'reset': {
  75. 'help': 'Reset device after flashing',
  76. 'default': None, # None = Reset iff application was flashed.
  77. 'argparse': {
  78. 'action': 'store_true'
  79. },
  80. },
  81. 'skip_reset': {
  82. 'help': 'Do not reset device after flashing',
  83. 'default': None, # None = Reset iff application was flashed.
  84. 'argparse': {
  85. 'dest': 'reset',
  86. 'action': 'store_false'
  87. },
  88. }
  89. },
  90. # Internal; these properties do not have command line options
  91. # (because they don't have an `argparse` key).
  92. 'internal': {
  93. # Script configuration options.
  94. 'platform': {
  95. 'help': 'Short name of the current platform',
  96. 'default': None,
  97. },
  98. 'module': {
  99. 'help': 'Invoking Python module, for generating scripts',
  100. 'default': None,
  101. },
  102. },
  103. }
  104. class Flasher:
  105. """Manage flashing."""
  106. def __init__(self, **options):
  107. # An integer giving the current Flasher status.
  108. # 0 if OK, and normally an errno value if positive.
  109. self.err = 0
  110. # Namespace of option values.
  111. self.option = argparse.Namespace(**options)
  112. # Namespace of option metadata. This contains the option specification
  113. # information one level down from `define_options()`, i.e. without the
  114. # group; the keys are mostly the same as those of `self.option`.
  115. # (Exceptions include options with no metadata and only defined when
  116. # constructing the Flasher, and options where different command line
  117. # options (`info` keys) affect a single attribute (e.g. `reset` and
  118. # `skip-reset` have distinct `info` entries but one option).
  119. self.info = argparse.Namespace()
  120. # `argv[0]` from the most recent call to parse_argv(); that is,
  121. # the path used to invoke the script. This is used to find files
  122. # relative to the script.
  123. self.argv0 = None
  124. # Argument parser for `parse_argv()`. Normally defines command-line
  125. # options for most of the `self.option` keys.
  126. self.parser = argparse.ArgumentParser(
  127. description='Flash {} device'.format(self.option.platform or 'a'))
  128. # Argument parser groups.
  129. self.group = {}
  130. # Construct the global options for all Flasher()s.
  131. self.define_options(OPTIONS)
  132. def define_options(self, options):
  133. """Define options, including setting defaults and argument parsing."""
  134. for group, group_options in options.items():
  135. if group not in self.group:
  136. self.group[group] = self.parser.add_argument_group(group)
  137. for key, info in group_options.items():
  138. setattr(self.info, key, info)
  139. if 'argparse' not in info:
  140. continue
  141. argument = info['argparse']
  142. attribute = argument.get('dest', key)
  143. # Set default value.
  144. if attribute not in self.option:
  145. setattr(self.option, attribute, info['default'])
  146. # Add command line argument.
  147. names = ['--' + key]
  148. if '_' in key:
  149. names.append('--' + key.replace('_', '-'))
  150. if 'alias' in info:
  151. names += info['alias']
  152. self.group[group].add_argument(
  153. *names,
  154. help=info['help'],
  155. default=getattr(self.option, attribute),
  156. **argument)
  157. return self
  158. def status(self):
  159. """Return the current error code."""
  160. return self.err
  161. def actions(self):
  162. """Perform actions on the device according to self.option."""
  163. raise NotImplementedError()
  164. def log(self, level, *args):
  165. """Optionally log a message to stderr."""
  166. if self.option.verbose >= level:
  167. print(*args, file=sys.stderr)
  168. def run_tool(self,
  169. tool,
  170. arguments,
  171. options=None,
  172. name=None,
  173. pass_message=None,
  174. fail_message=None,
  175. fail_level=0,
  176. capture_output=False):
  177. """Run an external tool."""
  178. if name is None:
  179. name = 'Run ' + tool
  180. self.log(1, name)
  181. option_map = vars(self.option)
  182. if options:
  183. option_map.update(options)
  184. arguments = self.format_command(arguments, opt=option_map)
  185. if not getattr(self.option, tool, None):
  186. setattr(self.option, tool, self.locate_tool(tool))
  187. tool_info = getattr(self.info, tool)
  188. command_template = tool_info.get('command', ['{' + tool + '}', ()])
  189. command = self.format_command(command_template, arguments, option_map)
  190. self.log(3, 'Execute:', *command)
  191. try:
  192. if capture_output:
  193. result = None
  194. result = subprocess.run(
  195. command,
  196. check=True,
  197. encoding=locale.getpreferredencoding(),
  198. capture_output=True)
  199. else:
  200. result = self
  201. self.err = subprocess.call(command)
  202. except FileNotFoundError as exception:
  203. self.err = exception.errno
  204. if self.err == errno.ENOENT:
  205. # This likely means that the program was not found.
  206. # But if it seems OK, rethrow the exception.
  207. if self.verify_tool(tool):
  208. raise exception
  209. if self.err:
  210. self.log(fail_level, fail_message or ('FAILED: ' + name))
  211. else:
  212. self.log(2, pass_message or (name + ' complete'))
  213. return result
  214. def locate_tool(self, tool):
  215. """Called to find an undefined tool. (Override in platform.)"""
  216. return tool
  217. def verify_tool(self, tool):
  218. """Run a command to verify that an external tool is available.
  219. Prints a configurable error and returns False if not.
  220. """
  221. tool_info = getattr(self.info, tool)
  222. command_template = tool_info.get('verify')
  223. if not command_template:
  224. return True
  225. command = self.format_command(command_template, opt=vars(self.option))
  226. try:
  227. self.err = subprocess.call(command)
  228. except OSError as ex:
  229. self.err = ex.errno
  230. if self.err:
  231. note = tool_info.get('error', 'Unable to execute {tool}.')
  232. note = textwrap.dedent(note).format(tool=tool, **vars(self.option))
  233. # textwrap.fill only handles single paragraphs:
  234. note = '\n\n'.join((textwrap.fill(p) for p in note.split('\n\n')))
  235. print(note, file=sys.stderr)
  236. return False
  237. return True
  238. def format_command(self, template, args=None, opt=None):
  239. """Construct a tool command line.
  240. This provides a few conveniences over a simple list of fixed strings,
  241. that in most cases eliminates any need for custom code to build a tool
  242. command line. In this description, φ(τ) is the result of formatting a
  243. template τ.
  244. template ::= list | () | str | dict
  245. Typically the caller provides a list, and `format_command()` returns a
  246. formatted list. The results of formatting sub-elements get interpolated
  247. into the end result.
  248. list ::= [τ₀, …, τₙ]
  249. ↦ φ(τ₀) + … + φ(τₙ)
  250. An empty tuple returns the supplied `args`. Typically this would be
  251. used for things like subcommands or file names at the end of a command.
  252. () ↦ args or []
  253. Formatting a string uses the Python string formatter with the `opt`
  254. map as arguments. Typically used to interpolate an option value into
  255. the command line, e.g. ['--flag', '{flag}'] or ['--flag={flag}'].
  256. str ::= σ
  257. ↦ [σ.format_map(opt)]
  258. A dictionary element provides a convenience feature. For any dictionary
  259. template, if it contains an optional 'expand' key that tests true, the
  260. result is recursively passed to format_command(); otherwise it is taken
  261. as is.
  262. The simplest case is an option propagated to the tool command line,
  263. as a single option if the value is exactly boolean True or as an
  264. option-argument pair if otherwise set.
  265. optional ::= {'optional': name}
  266. ↦ ['--name'] if opt[name] is True
  267. ['--name', opt[name]] if opt[name] tests true
  268. [] otherwise
  269. A dictionary with an 'option' can insert command line arguments based
  270. on the value of an option. The 'result' is optional defaults to the
  271. option value itself, and 'else' defaults to nothing.
  272. option ::= {'option': name, 'result': ρ, 'else': δ}
  273. ↦ ρ if opt[name]
  274. δ otherwise
  275. A dictionary with a 'match' key returns a result comparing the value of
  276. an option against a 'test' list of tuples. The 'else' is optional and
  277. defaults to nothing.
  278. match ::= {'match': name, 'test': [(σᵢ, ρᵢ), …], 'else': ρ}
  279. ↦ ρᵢ if opt[name]==σᵢ
  280. ρ otherwise
  281. """
  282. if isinstance(template, str):
  283. result = [template.format_map(opt)]
  284. elif isinstance(template, list):
  285. result = []
  286. for i in template:
  287. result += self.format_command(i, args, opt)
  288. elif template == ():
  289. result = args or []
  290. elif isinstance(template, dict):
  291. if 'optional' in template:
  292. name = template['optional']
  293. value = opt.get(name)
  294. if value is True:
  295. result = ['--' + name]
  296. elif value:
  297. result = ['--' + name, value]
  298. else:
  299. result = []
  300. elif 'option' in template:
  301. name = template['option']
  302. value = opt.get(name)
  303. if value:
  304. result = template.get('result', value)
  305. else:
  306. result = template.get('else')
  307. elif 'match' in template:
  308. value = template['match']
  309. for compare, result in template['test']:
  310. if value == compare:
  311. break
  312. else:
  313. result = template.get('else')
  314. if result and template.get('expand'):
  315. result = self.format_command(result, args, opt)
  316. elif result is None:
  317. result = []
  318. elif not isinstance(result, list):
  319. result = [result]
  320. else:
  321. raise ValueError('Unknown: {}'.format(template))
  322. return result
  323. def find_file(self, filename, dirs=None):
  324. """Resolve a file name; also checks the script directory."""
  325. if os.path.isabs(filename) or os.path.exists(filename):
  326. return filename
  327. dirs = dirs or []
  328. if self.argv0:
  329. dirs.append(os.path.dirname(self.argv0))
  330. for directory in dirs:
  331. name = os.path.join(directory, filename)
  332. if os.path.exists(name):
  333. return name
  334. raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT),
  335. filename)
  336. def optional_file(self, filename, dirs=None):
  337. """Resolve a file name, if present."""
  338. if filename is None:
  339. return None
  340. return self.find_file(filename, dirs)
  341. def parse_argv(self, argv):
  342. """Handle command line options."""
  343. self.argv0 = argv[0]
  344. self.parser.parse_args(argv[1:], namespace=self.option)
  345. self._postprocess_argv()
  346. return self
  347. def _postprocess_argv(self):
  348. """Called after parse_argv() for platform-specific processing."""
  349. def flash_command(self, argv):
  350. """Perform device actions according to the command line."""
  351. return self.parse_argv(argv).actions().status()
  352. def _platform_wrapper_args(self, args):
  353. """Called from make_wrapper() to optionally manipulate arguments."""
  354. def make_wrapper(self, argv):
  355. """Generate script to flash a device.
  356. The generated script is a minimal wrapper around `flash_command()`,
  357. containing any option values that differ from the class defaults.
  358. """
  359. # Note: this modifies the argument parser, so the same Flasher instance
  360. # should not be used for both parse_argv() and make_wrapper().
  361. self.parser.description = 'Generate a flashing script.'
  362. self.parser.add_argument(
  363. '--output',
  364. metavar='FILENAME',
  365. required=True,
  366. help='flashing script name')
  367. self.argv0 = argv[0]
  368. args = self.parser.parse_args(argv[1:])
  369. # Give platform-specific code a chance to manipulate the arguments
  370. # for the wrapper script.
  371. self._platform_wrapper_args(args)
  372. # Find any option values that differ from the class defaults.
  373. # These will be inserted into the wrapper script.
  374. defaults = []
  375. for key, value in vars(args).items():
  376. if key in self.option and value != getattr(self.option, key):
  377. defaults.append(' {}: {},'.format(repr(key), repr(value)))
  378. script = """
  379. import sys
  380. DEFAULTS = {{
  381. {defaults}
  382. }}
  383. import {module}
  384. if __name__ == '__main__':
  385. sys.exit({module}.Flasher(**DEFAULTS).flash_command(sys.argv))
  386. """
  387. script = ('#!/usr/bin/env python3' + textwrap.dedent(script).format(
  388. module=self.option.module, defaults='\n'.join(defaults)))
  389. try:
  390. with open(args.output, 'w') as script_file:
  391. script_file.write(script)
  392. os.chmod(args.output, (stat.S_IXUSR | stat.S_IRUSR | stat.S_IWUSR
  393. | stat.S_IXGRP | stat.S_IRGRP
  394. | stat.S_IXOTH | stat.S_IROTH))
  395. except OSError as exception:
  396. print(exception, sys.stderr)
  397. return 1
  398. return 0