Firmware Release 1.4.0-21

Glowforge released firmware version 1.4.0-21 for their Pro and Basic model CNC lasers.

It appears to have been released on May 16, 2018. However, it has a build date of April 18th, 2018.

I haven’t completely analyzed all the changes yet, but here’s some diff’s. Feel free to get a head start (and maybe save me some effort, PLEASE :smiley:)!

--- a/etc/build
+++ b/etc/build
@@ -2,17 +2,18 @@
 Build Configuration:  |
 -----------------------
 DISTRO = glowforge
-DISTRO_VERSION = 1.3.3-17
+DISTRO_VERSION = 1.4.0-21
 MACHINE = glowforge
 DEVICE_MODEL = glowforge
-FIRMWARE_VERSION = 1.3.3-17
+FIRMWARE_VERSION = 1.4.0-21
 IMAGE_TYPE = production
 -----------------------
 Layer Revisions:      |
 -----------------------
 meta              = (detachedfromc53ddb2):c53ddb2006f04051b00df3189fe8d35bb49ef3c7 
 meta-fsl-arm      = (nobranch):c9f259a4bf8472dfa3ff75f1c3fcbe5e0ded7aaf 
-meta-glowforge    = (detachedfrom027df84):027df84a675b0c729a5a4f581fc8ea841a03b88b 
+meta-glowforge    = (detachedfrom5c16cdc):5c16cdc8a5f4c4aa6ee3c2dc695344746913fafe 
+meta-glowforge-gpl = (detachedfrom588a5a3):588a5a3ec093d5889752a30a239f0889f1048c17 
 meta-oe           = (nobranch):df2f700d66bace65f5d802225232d01cf511fe81 
 meta-python       = (nobranch):df2f700d66bace65f5d802225232d01cf511fe81 
 meta-networking   = (nobranch):df2f700d66bace65f5d802225232d01cf511fe81 
--- a/etc/network/if-pre-up.d/wpa-supplicant
+++ b/etc/network/if-pre-up.d/wpa-supplicant
@@ -8,11 +8,13 @@
 
 VERBOSITY=0
 
-
+if [ -z "$IF_WPA_CONF" ]; then
+  exit 0
+fi
 if [ -s "$IF_WPA_CONF" ]; then
 	WPA_SUP_CONF="-c $IF_WPA_CONF"
 else
-	exit 0
+	WPA_SUP_CONF="-C /var/run/wpa_supplicant"
 fi
 
 if [ ! -x "$WPA_SUP_BIN" ]; then
--- a/glowforge/python/glowforge/bugeggs/bugeggs.py
+++ b/glowforge/python/glowforge/bugeggs/bugeggs.py
@@ -2,14 +2,18 @@
 # Crash report uploader
 
 import argparse
+import errno
 import logging
 import os
 import requests
 import signal
 import socket
+import subprocess
 import sys
+import tempfile
 import time
 import urlparse
+from datetime import datetime
 from setproctitle import setproctitle
 
 import glowforge.util.http as http
@@ -19,12 +23,46 @@
 from glowforge.util.build_info import BuildInfo
 
 LOG_DIR = '/data/log'
-CRASH_REPORT_DIR = '/data/log/crash'
+CRASH_REPORT_DIR = os.path.join(LOG_DIR, 'crash')
+UNKNOWN_REPORT_DIR = os.path.join(CRASH_REPORT_DIR, 'unknown')
+
 FILE_UPLOADED_SUFFIX = '.uploaded'
 LOG_TAIL_SIZE_BYTES = 262144
 
 received_signal = False
 buildinfo = BuildInfo()
+
+def mkdirp(dir_name):
+    try:
+        os.makedirs(dir_name)
+    except OSError as e:
+        if e.errno == errno.EEXIST and os.path.isdir(dir_name):
+            pass
+        else:
+            raise
+
+
+def boot_device():
+    # Allow `rdev` value to be overridden by an environment variable for testing
+    return os.getenv('GF_BOOT_DEVICE', subprocess.check_output('rdev'))
+
+
+def in_recovery_partition():
+    try:
+        return boot_device().startswith('/dev/mmcblk2boot')
+    except:
+        return False
+
+
+def in_recovery_mode():
+    try:
+        # `in_recovery_mode`'s return code is 0 (shell truthiness) if in user-initiated recovery mode.
+        # Also allow it to be overridden with an environment variable for testing.
+        override = os.getenv('GF_RECOVERY_MODE')
+        return int(override) if override is not None else (subprocess.call('in_recovery_mode') == 0)
+    except:
+        return False
+
 
 def abs_path_iter(dirpath, ignore_suffix=None):
     for dirname, subdirs, files in os.walk(dirpath):
@@ -52,7 +90,7 @@
         reachable = True
         logging.debug('Host is reachable')
     except Exception as e:
-        logging.debug('Host is not reachable: {}'.format(e))
+        logging.exception('Host is not reachable')
     finally:
         if s is not None:
             s.close()
@@ -86,7 +124,7 @@
             logging.debug(r)
         return True
     except Exception as e:
-        logging.debug('Upload failed: {}'.format(e))
+        logging.exception('Upload failed')
     return False
 
 
@@ -118,6 +156,22 @@
         logging.debug('No associated log data')
 
 
+def report_unknown_crash():
+    # Save a marker file that will be uploaded
+    message = 'In recovery OS but nothing to upload (possible CPU lockup?)'
+    logging.critical(message)
+    mkdirp(UNKNOWN_REPORT_DIR)
+    datestr = datetime.utcnow().strftime('%Y%m%d-%H%M%S')
+    filename = 'unknown-crash-{}-'.format(datestr)
+    try:
+        fd, abspath = tempfile.mkstemp(suffix='.dmp', prefix=filename, dir=UNKNOWN_REPORT_DIR)
+        f = os.fdopen(fd, 'w')
+        f.write('{} {}\n'.format(message, datestr))
+        f.close()
+    except OSError as e:
+        logging.exception('Unable to create file at {}'.format(abspath))
+
+
 def get_file_tail(f, nbytes):
     try:
         f.seek(-nbytes, os.SEEK_END)
@@ -130,9 +184,14 @@
     logging.debug('Marking {} as uploaded'.format(path))
     if not dry_run:
         try:
-            os.rename(path, path+FILE_UPLOADED_SUFFIX)
+            newpath = path + FILE_UPLOADED_SUFFIX
+            os.rename(path, newpath)
         except:
-            pass
+            logging.exception('Renaming {} to {} failed'.format(path, newpath))
+
+
+def is_crash_report(path):
+    return not os.path.basename(path).startswith('head-debug-log-')
 
 
 def signal_handler(signum, frame):
@@ -151,6 +210,20 @@
     parser.add_argument('-k', '--keep', dest='keep', action='store_true', help='keep file name unmodified after uploading')
     parser.add_argument('-n', '--dry-run', dest='dry_run', action='store_true', help='dry run; do not upload or rename files')
     args = parser.parse_args()
+
+    # If we're running from the recovery OS, but it wasn't user-initiated,
+    # this is a "postmortem" boot caused by a watchdog timeout
+    postmortem = (in_recovery_partition() and not in_recovery_mode())
+    # If this is a postmortem boot, but the previous boot didn't leave any crash logs,
+    # the watchdog reset could have been from a total CPU lockup
+    if postmortem:
+        have_dump = False
+        for abspath in abs_path_iter(args.src_dir, ignore_suffix=FILE_UPLOADED_SUFFIX):
+            if is_crash_report(abspath):
+                have_dump = True
+                break
+        if not have_dump:
+            report_unknown_crash()
 
     # if we don't have an internet connection, don't start
     if not args.dry_run:
--- a/glowforge/python/glowforge/devicesetup/fw_update.py
+++ b/glowforge/python/glowforge/devicesetup/fw_update.py
@@ -4,7 +4,7 @@
 import subprocess
 
 UPLOAD_BUF_SIZE = 8192
-MAX_FIRMWARE_UPDATE_SIZE = 50*1024*1024     # in bytes
+MAX_FIRMWARE_UPDATE_SIZE = 70*1024*1024     # in bytes
 FIRMWARE_UPDATE_FILE_TEMP_PATH = '/var/volatile/tmp/update.fw'
 UPDATER_PROCESS_NAME = 'glowforge-updater'
 UPDATER_PATH = '/glowforge/python/glowforge/updater/glowforge-updater.sh'
@@ -21,7 +21,7 @@
     else:
         return None, None
 
-def run_updater(filepath):
+def run_updater(filepath, complete=False):
     try:
         args = [
             UPDATER_PATH,
@@ -31,6 +31,8 @@
             '-v', # capture verbose log output
             filepath
         ]
+        if complete:
+            args.insert(1, '-c') # complete update
         logging.info('Installing update')
         output = subprocess.check_output(args, stderr=subprocess.STDOUT)
         logging.info('Update complete')
@@ -43,7 +45,7 @@
     # (e.g. if the updater couldn't be launched)
 
 
-def install_firmware_update(response, fileobj):
+def install_firmware_update(response, fileobj, complete=False):
     # Ensure the updater is not running
     status, error = check_updater_status()
     if status is not None:
@@ -97,7 +99,7 @@
     if tmpfile is not None:
         tmpfile.close()
 
-    status, output = run_updater(FIRMWARE_UPDATE_FILE_TEMP_PATH)
+    status, output = run_updater(FIRMWARE_UPDATE_FILE_TEMP_PATH, complete)
     delete_update_file()
 
     response.status = status
--- a/glowforge/python/glowforge/devicesetup/webapp.py
+++ b/glowforge/python/glowforge/devicesetup/webapp.py
@@ -1,4 +1,4 @@
-WIFI_SETUP_API_VERSION = '0.9'
+WIFI_SETUP_API_VERSION = '1.0'
 
 import SocketServer
 
@@ -84,7 +84,7 @@
 @app.post('/setup/connect_and_check')
 def connect():
     params = request.forms
-    return wifi.connect_and_check(params.get('ssid'), params.get('passphrase'), params.get('token'))
+    return wifi.connect_and_check(params.get('ssid'), params.get('passphrase'), params.get('token'), params.get('security'))
 
 @app.route('/logs/<dirpath:path>/list')
 def get_logs(dirpath):
@@ -155,11 +155,12 @@
 @app.post('/firmware_update')
 @recovery_mode_only
 def firmware_update():
+    complete = (request.forms.get('complete') == '1')
     f = request.files.get('file')
     if len(request.files) != 1 or f is None:
         response.status = 400
         return None
-    return fw_update.install_firmware_update(response, f)
+    return fw_update.install_firmware_update(response, f, complete=complete)
 
 def stop():
     server.stop()
--- a/glowforge/python/glowforge/devicesetup/wifi.py
+++ b/glowforge/python/glowforge/devicesetup/wifi.py
@@ -121,7 +121,7 @@
     result['in_recovery_mode'] = machine.in_recovery_mode()
     return result
 
-def connect_and_check(ssid, passphrase, token):
+def connect_and_check(ssid, passphrase, token, encryption):
     logging.info('devicesetup:connect_and_check')
 
     result = {}
@@ -139,8 +139,11 @@
         logging.error(result)
         return result
 
+    # default to WPA/WPA2 Personal if no encryption type specified
+    if encryption is None:
+        encryption = wifi_creds.WPA_WPA2_PERSONAL if passphrase else wifi_creds.UNSECURED
+
     try:
-        encryption = wifi_creds.WPA_WPA2_PERSONAL if passphrase else wifi_creds.UNSECURED
         wifi_creds.set_credentials(ssid, passphrase, encryption, apply=True, iface=sta_interface)
     except wifi_creds.InvalidSSIDLength as e:
         response.status = 400
@@ -154,6 +157,14 @@
         response.status = 400
         result = {
             'error': 'invalid_passphrase_length',
+            'details': repr(e)
+        }
+        logging.error(result)
+        return result
+    except wifi_creds.InvalidPassphraseCharacters as e:
+        response.status = 400
+        result = {
+            'error': 'invalid_passphrase_chars',
             'details': repr(e)
         }
         logging.error(result)
@@ -248,19 +259,24 @@
     logging.error(result)
     return result
 
-def _iw_scan():
-    return subprocess.check_output(['/usr/sbin/iw', 'dev', sta_interface, 'scan'], close_fds=True)
+def _wpa_cli_scan():
+    logging.info('devicesetup:_wpa_cli_scan calling /usr/sbin/wpa_cli scan')
+    subprocess.check_output(['/usr/sbin/wpa_cli', 'scan'], close_fds=True)
+    logging.info('devicesetup:_wpa_cli_scan calling /usr/sbin/wpa_cli scan_results')
+    return subprocess.check_output(['/usr/sbin/wpa_cli', 'scan_results'], close_fds=True)
 
 def scan():
     try:
-        output = _iw_scan()
-        seq = re.split('BSS ..:..:..:..:..:..', output)
-        seq = map(parse_cell, seq)
-        seq = filter(has_ssid, seq)
+        output = _wpa_cli_scan().splitlines()
+        # split into fields and filter out header lines
+        seq = map(lambda l: l.split('\t', 5), output)
+        seq = filter(lambda f: len(f) == 5, seq)
+        # parse information out of the fields
+        seq = map(parse_fields, seq)
+        seq = filter(supported_freq, seq)
         seq = sort_by_ssid(seq)
-        seq = filter(supported_freq, seq)
         seq = unique_ssid(seq)
-        return seq
+        return list(seq)
     except Exception as e:
         response.status = 500
         response.content_type = 'application/json'
@@ -269,41 +285,31 @@
             'details': e.message,
         }
 
-def sort_by_ssid(seq):
-    return sorted(seq, key=lambda v: v['ssid'].lower(), cmp=locale.strcoll)
-
-def has_ssid(dict):
-    return dict.has_key('ssid')
+def parse_fields(fields):
+    return {
+        'freq':     int(fields[1]),
+        'signal':   fields[2] + ' dBm',
+        'security': security_type_from_flags(fields[3]),
+        'ssid':     fields[4].decode('string_escape')
+    }
+
+def security_type_from_flags(flagstr):
+    # TODO: "[WPA2-EAP-" is WPA2 Enterprise, "[WPA-EAP-" is WPA Enterprise
+    if '[WPA2' in flagstr:
+        return wifi_creds.WPA2_PERSONAL
+    elif '[WPA' in flagstr:
+        return wifi_creds.WPA_PERSONAL
+    elif '[WEP' in flagstr:
+        return wifi_creds.WEP
+    else:
+        return wifi_creds.UNSECURED
 
 def supported_freq(dict):
     freq = dict['freq']
     return freq >= 2400 and freq < 2500
 
+def sort_by_ssid(seq):
+    return sorted(seq, key=lambda v: v['ssid'].lower(), cmp=locale.strcoll)
+
 def unique_ssid(lst):
     return [] if lst==[] else [lst[0]] + unique_ssid(filter(lambda x: x['ssid'] != lst[0]['ssid'], lst[1:]))
-
-def parse_cell(cell):
-  parsed = {}
-
-  match = re.search('SSID: ([^\n]+)', cell)
-  if match:
-    parsed['ssid'] = match.group(1).decode('string_escape')
-
-  match = re.search('freq: ([0-9]+)', cell)
-  if match:
-    parsed['freq'] = int(match.group(1))
-
-  match = re.search('signal: (-[0-9]+.[0-9]+ dBm)', cell)
-  if match:
-    parsed['signal'] = match.group(1)
-
-  if re.search('RSN:', cell):
-    parsed['security'] = 'wpa2'
-  elif re.search('WPA:\s+\* Version: 1\n', cell):
-    parsed['security'] = 'wpa'
-  elif re.search('WEP:', cell):
-    parsed['security'] = 'wep'
-  else:
-    parsed['security'] = 'open'
-
-  return parsed
--- a/glowforge/python/glowforge/devicesetup/wifi_creds.py
+++ b/glowforge/python/glowforge/devicesetup/wifi_creds.py
@@ -4,7 +4,8 @@
 from hashlib import pbkdf2_hmac
 
 # Encryption modes
-UNSECURED = 'unsecured'
+UNSECURED = 'open'
+WEP = 'wep'
 WPA_PERSONAL = 'wpa'
 WPA2_PERSONAL = 'wpa2'
 WPA_WPA2_PERSONAL = 'wpa/wpa2'
@@ -19,6 +20,9 @@
 class InvalidPassphraseLength(Exception):
     pass
 
+class InvalidPassphraseCharacters(Exception):
+    pass
+
 class InvalidEncryptionType(Exception):
     pass
 
@@ -26,9 +30,7 @@
 class EncryptionType(object):
     pass_len_min = 0
     pass_len_max = 0
-    proto = None
     key_mgmt = None
-    group = None
     @classmethod
     def validate_passphrase_length(cls, passphrase):
         length = len(passphrase) if passphrase is not None else 0
@@ -36,10 +38,12 @@
             raise InvalidPassphraseLength(
                 'Length was {}, must be between {} and {}'.format(
                 length, cls.pass_len_min, cls.pass_len_max))
-        return True
     @classmethod
-    def psk(cls, ssid, passphrase):
-        return None
+    def validate_passphrase_character_set(cls, passphrase):
+        pass
+    @classmethod
+    def fields(cls, ssid, passphrase):
+        return {}
 
 class EncryptionType_Unsecured(EncryptionType):
     key_mgmt = 'NONE'
@@ -47,12 +51,35 @@
     def validate_passphrase_length(cls, passphrase):
         if passphrase is not None and len(passphrase) != 0:
             raise InvalidPassphraseLength('Passphrase must be empty')
-        return True
+
+class EncryptionType_WEP(EncryptionType):
+    key_mgmt = 'NONE'
+    # WEP keys must be 10 (64-bit), 26 (128-bit), or 32 (152-bit) hex digits
+    # wpa_supplicant does not support 256-bit WEP keys
+    valid_key_lengths = (10, 26, 32)
+    @classmethod
+    def validate_passphrase_length(cls, passphrase):
+        length = len(passphrase) if passphrase is not None else 0
+        if length not in cls.valid_key_lengths:
+            raise InvalidPassphraseLength(
+                'Passphrase length was {}, must be one of {}'.format(
+                length, str(cls.valid_key_lengths)))
+    @classmethod
+    def validate_passphrase_character_set(cls, passphrase):
+        # Only accept hexadecimal keys. The conversion from ASCII "passphrase"
+        # to hex key is vendor-specific.
+        if re.search(r'[^0-9A-Fa-f]', passphrase):
+            raise InvalidPassphraseCharacters(
+                'Passphrase can only contain 0-9, A-F, and a-f')
+    @classmethod
+    def fields(cls, ssid, passphrase):
+        return {'wep_key0': passphrase,
+                'wep_tx_keyidx': '0'}
+
 
 class EncryptionType_WPAPersonal(EncryptionType):
     proto = 'WPA'
     key_mgmt = 'WPA-PSK'
-    group = 'CCMP TKIP'
     pass_len_min = 8
     pass_len_max = 63
     @classmethod
@@ -61,6 +88,13 @@
         # SSID as a salt. Python has a PBKDF2 implementation built in, so we
         # don't have to shell out to wpa_passhprase.
         return pbkdf2_hmac('sha1', passphrase, ssid, 4096, 32).encode('hex')
+    @classmethod
+    def fields(cls, ssid, passphrase):
+        # create hex key from SSID and passphrase to prevent issues with special
+        # characters
+        return {'proto': cls.proto,
+                'group': 'CCMP TKIP',
+                'psk': cls.psk(ssid, passphrase)}
 
 class EncryptionType_WPA2Personal(EncryptionType_WPAPersonal):
     proto = 'RSN'
@@ -71,6 +105,7 @@
 
 ENCRYPTION_TYPES = {
     UNSECURED: EncryptionType_Unsecured,
+    WEP: EncryptionType_WEP,
     WPA_PERSONAL: EncryptionType_WPAPersonal,
     WPA2_PERSONAL: EncryptionType_WPA2Personal,
     WPA_WPA2_PERSONAL: EncryptionType_WPA_WPA2Personal
@@ -97,6 +132,8 @@
 
     # raises InvalidPassphraseLength on failure
     enctype.validate_passphrase_length(passphrase)
+    # raises InvalidPassphraseCharacters on failure
+    enctype.validate_passphrase_character_set(passphrase)
 
     lines = [
         'ctrl_interface=/var/run/wpa_supplicant',
@@ -107,17 +144,12 @@
     ]
     # encode the SSID as hex to prevent issues with special characters
     lines.append('  ssid='+ssid.encode('hex'))
-    if enctype.proto:
-        lines.append('  proto='+enctype.proto)
     if enctype.key_mgmt:
         lines.append('  key_mgmt='+enctype.key_mgmt)
-    if enctype.group:
-        lines.append('  group='+enctype.group)
-    # create hex key from SSID and passphrase to prevent issues with special
-    # characters
-    psk = enctype.psk(ssid, passphrase)
-    if psk:
-        lines.append('  psk='+psk)
+    # add extra fields
+    for k, v in enctype.fields(ssid, passphrase).iteritems():
+        if v is not None:
+            lines.append('  {}={}'.format(k, v))
     lines.append('}\n')
     output = '\n'.join(lines)
--- a/glowforge/python/glowforge/updater/apply_update.py
+++ b/glowforge/python/glowforge/updater/apply_update.py
@@ -49,7 +49,12 @@
 
     def apply(self):
         apply_func = None
-        if self.env.fwenv.is_device_a_active():
+        if self.env.args.complete:
+            if self.env.fwenv.is_recovery_device_active():
+                apply_func = self.apply_complete
+            else:
+                raise Exception("Complete update cannot be performed on currently mounted root device")
+        elif self.env.fwenv.is_device_a_active():
             apply_func = self.apply_b
         elif self.env.fwenv.is_device_b_active():
             apply_func = self.apply_a
@@ -79,10 +84,15 @@
             pass
 
     def commit(self):
+        env_func = None
+        if self.env.args.complete:
+            env_func = self.env.fwenv.write_env_full
+        else:
+            env_func = self.env.fwenv.switch_env
         logging.info("Committing update")
         data = { 'firmware_version': self.update_version }
         event.send('firmware_update:committing', data)
-        self.env.fwenv.switch_env()
+        env_func()
         if not self.env.args.no_reboot:
             event.send('firmware_update:rebooting', data)
             machine.reboot()
@@ -101,3 +111,6 @@
 
     def apply_b(self):
         self.fwup.apply_b()
+
+    def apply_complete(self):
+        self.fwup.apply_complete()
--- a/glowforge/python/glowforge/updater/fwup.py
+++ b/glowforge/python/glowforge/updater/fwup.py
@@ -49,10 +49,13 @@
     def apply_b(self):
         self.__apply(params.device_b, 'upgrade.b')
 
-    def __apply(self, device, task):
+    def apply_complete(self):
+        self.__apply(params.root_device, 'complete', extra_args=['-U'])
+
+    def __apply(self, device, task, extra_args=[]):
         logging.info('Applying update to device %s', device)
         return self.__run_fwup(subprocess.check_call,
-                               ['-v', '-a', '-d', device, '-i', self.fw_file, '-t', task])
+                               extra_args + ['-v', '-a', '-d', device, '-i', self.fw_file, '-t', task])
 
     def __run_fwup(self, fn, cmdargs):
         """fn should be subprocess.check_call or subprocess.check_output."""
--- a/glowforge/python/glowforge/updater/params.py
+++ b/glowforge/python/glowforge/updater/params.py
@@ -1,12 +1,18 @@
 import subprocess
 
+mmcdev_env_key = 'mmcdev'
+hwpart_env_key = 'mmchwpart'
 bootpart_env_key = 'mmcpart'
 rootdev_env_key = 'mmcroot'
+recovery_env_key = 'boot_recovery'
+mmc_hwpart_num = '0'
 part_a = '1'
 part_b = '2'
 current_root = subprocess.check_output('/usr/sbin/rdev').split()[0] # rdev returns '/dev/mmcblkXpY /'
 device = current_root[5:12] # 'mmcblkX'
+mmc_dev_num = str(int(current_root[11])-1)
 device_a = '/dev/' + device + 'p' + part_a
 device_b = '/dev/' + device + 'p' + part_b
+root_device = '/dev/' + device
 recovery_device = '/dev/mmcblk2boot0p1'
 uboot_config = '/etc/fw_env_' + device + '.config'
--- a/glowforge/python/glowforge/updater/uboot_env.py
+++ b/glowforge/python/glowforge/updater/uboot_env.py
@@ -41,6 +41,15 @@
             raise Exception("Could not detect current root device")
 
         self.set_multiple({
-            params.bootpart_env_key : part,
-            params.rootdev_env_key : root
+            params.bootpart_env_key: part,
+            params.rootdev_env_key: root
             })
+
+    def write_env_full(self):
+        self.set_multiple({
+            params.mmcdev_env_key:   params.mmc_dev_num,
+            params.hwpart_env_key:   params.mmc_hwpart_num,
+            params.bootpart_env_key: params.part_a,
+            params.rootdev_env_key:  params.device_a,
+            params.recovery_env_key: 'no'
+        })
--- a/glowforge/python/glowforge/updater/updater.py
+++ b/glowforge/python/glowforge/updater/updater.py
@@ -30,6 +30,7 @@
         parser.add_argument('-L', dest='no_update_lock', action='store_true')
         parser.add_argument('-R', dest='no_reboot', action='store_true')
         parser.add_argument('-v', dest='verbose', action='store_true')
+        parser.add_argument('-c', dest='complete', action='store_true')
         self.env.args = parser.parse_args()
         if self.env.args.verbose:
             logging.getLogger().level = logging.DEBUG