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