firmware_utils.py 17 KB

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