Esempio n. 1
0
 def _effected_zones(self, preset_id):
     """ Aggregate the zones that will be modified by changes """
     st = self.status
     _, preset = utils.find(st['presets'], preset_id)
     effected = set()
     if preset is None:
         return effected
     ps = preset['state']
     src_zones = {
         src['id']:
         [z['id'] for z in st['zones'] if z['source_id'] == src['id']]
         for src in st['sources']
     }
     if 'sources' in ps:
         for src in ps['sources']:
             if 'id' in src:
                 effected.update(src_zones[src['id']])
     if 'groups' in ps:
         for g in ps['groups']:
             if 'id' in g:
                 _, gf = utils.find(st['groups'], g['id'])
                 if gf:
                     effected.update(gf['zones'])
     if 'zones' in ps:
         for z in ps['zones']:
             if 'id' in z:
                 effected.add(z['id'])
     return effected
Esempio n. 2
0
  def _load_preset_state(self, preset_state: models.PresetState) -> None:
    """ Load a preset configuration """

    # determine which zones will be effected and mute them
    # we do this just in case there is intermediate state that causes audio issues. TODO: test with and without this feature (it adds a lot of complexity to presets, lets make sure its worth it)
    zones_effected = self._effected_zones(preset_state)
    zones_temp_muted = [zid for zid in zones_effected if not self.status.zones[zid].mute]
    zone_update = models.ZoneUpdate(mute=True)
    for zid in zones_temp_muted:
      self.set_zone(zid, zone_update, internal=True)

    # keep track of the zones muted by the preset configuration
    zones_muted: Set[int] = set()

    # execute changes source by source in increasing order
    for src in preset_state.sources or []:
      if src.id is not None:
        self.set_source(src.id, src.as_update(), internal=True)
      else:
        pass # TODO: support some id-less source concept that allows dynamic source allocation

    # execute changes group by group in increasing order
    for group in preset_state.groups or []:
      _, groups_to_update = utils.find(self.status.groups, group.id)
      if groups_to_update is None:
        raise NameError('group {} does not exist'.format(group.id))
      self.set_group(group.id, group.as_update(), internal=True)
      if group.mute is not None:
        # use the updated group's zones just in case the group's zones were just changed
        _, g_updated = utils.find(self.status.groups, group.id)
        if g_updated is not None:
          zones_changed = g_updated.zones
          if group.mute:
            # keep track of the muted zones
            zones_muted.update(zones_changed)
          else:
            # handle odd mute thrashing case where zone was muted by one group then unmuted by another
            zones_muted.difference_update()

    # execute change zone by zone in increasing order
    for zone in preset_state.zones or []:
      self.set_zone(zone.id, zone.as_update(), internal=True)
      if zone.mute is not None:
        if zone.mute:
          zones_muted.add(zone.id)
        elif zone.id in zones_muted:
          zones_muted.remove(zone.id)

    # unmute effected zones that were not muted by the preset configuration
    zones_to_unmute = set(zones_temp_muted).difference(zones_muted)
    zone_update = models.ZoneUpdate(mute=False)
    for zid in zones_to_unmute:
      self.set_zone(zid, zone_update, internal=True)

    # update stats
    self._update_groups()
Esempio n. 3
0
  def load_preset(self, pid: int, internal=False) -> ApiResponse:
    """ To avoid any issues with audio coming out of the wrong speakers, we will need to carefully load a preset configuration.
    Below is an idea of how a preset configuration could be loaded to avoid any weirdness.
    We are also considering adding a "Last config" preset that allows us to easily revert the configuration changes.

    1. Grab system modification mutex to avoid accidental changes (requests during this time return some error)
    2. Save current configuration as "Last config" preset
    3. Mute any effected zones
    4. Execute changes to source, zone, group each in increasing order
    5. Unmute effected zones that were not muted
    6. Execute any stream commands
    7. Release system mutex, future requests are successful after this
    """
    # Get the preset to load
    i, preset = utils.find(self.status.presets, pid)
    if i is None or preset is None:
      return ApiResponse.error('load preset failed: {} does not exist'.format(pid))

    # TODO: acquire lock (all api methods that change configuration will need this)

    # update last config preset for restore capabilities (creating if missing)
    # TODO: "last config" does not currently support restoring streaming state, how would that work? (maybe we could just support play/pause state?)
    last_pid, _ = utils.find(self.status.presets, self._LAST_PRESET_ID)
    status = self.status
    last_config = models.Preset(
      id=9999,
      name='Restore last config',
      last_used=None, # this need to be in javascript time format
      state=models.PresetState(
        sources=deepcopy(status.sources),
        zones=deepcopy(status.zones),
        groups=deepcopy(status.groups)
      )
    )
    if last_pid is None:
      self.status.presets.append(last_config)
    else:
      self.status.presets[last_pid] = last_config

    if preset.state is not None:
      try:
        self._load_preset_state(preset.state)
      except Exception as exc:
        return ApiResponse.error(str(exc))

    # TODO: execute stream commands

    preset.last_used = int(time.time())

    # TODO: release lock
    return ApiResponse.ok()
Esempio n. 4
0
    def set_group(self,
                  id,
                  name=None,
                  source_id=None,
                  zones=None,
                  mute=None,
                  vol_delta=None):
        """Configures an existing group
        parameters will be used to configure each sone in the group's zones
        all parameters besides the group id, @id, are optional

        Args:
          id: group id (a guid)
          name: group name
          source_id: group source
          zones: zones that belong to the group
          mute: group mute setting (muted=True)
          vol_delta: volume adjustment to apply to each zone [-79,79]
        Returns:
            'None' on success, otherwise error (dict)
    """
        _, g = utils.find(self.status['groups'], id)
        if g is None:
            return utils.error(
                'set group failed, group {} not found'.format(id))
        if type(zones) is str:
            try:
                zones = eval(zones)
            except Exception as e:
                return utils.error(
                    'failed to configure group, error parsing zones: {}'.
                    format(e))
        try:
            name, _ = utils.updated_val(name, g['name'])
            zones, _ = utils.updated_val(zones, g['zones'])
            vol_delta, vol_updated = utils.updated_val(vol_delta,
                                                       g['vol_delta'])
            if vol_updated:
                vol_change = vol_delta - g['vol_delta']
            else:
                vol_change = 0
        except Exception as e:
            return utils.error(
                'failed to configure group, error getting current state: {}'.
                format(e))

        g['name'] = name
        g['zones'] = zones

        for z in [self.status['zones'][zone] for zone in zones]:
            if vol_change != 0:
                # TODO: make this use volume delta adjustment, for now its a fixed group volume
                vol = vol_delta  # vol = z['vol'] + vol_change
            else:
                vol = None
            self.set_zone(z['id'], None, source_id, mute, vol)
        g['vol_delta'] = vol_delta

        # update the group stats
        self._update_groups()
Esempio n. 5
0
 def delete_group(self, id):
     """Deletes an existing group"""
     try:
         i, _ = utils.find(self.status['groups'], id)
         if i is not None:
             del self.status['groups'][i]
     except KeyError:
         return utils.error(
             'delete group failed: {} does not exist'.format(id))
Esempio n. 6
0
 def delete_group(self, gid: int) -> ApiResponse:
   """Deletes an existing group"""
   try:
     i, _ = utils.find(self.status.groups, gid)
     if i is not None:
       del self.status.groups[i]
       return ApiResponse.ok()
     return ApiResponse.error('delete group failed: {} does not exist'.format(gid))
   except KeyError:
     return ApiResponse.error('delete group failed: {} does not exist'.format(gid))
Esempio n. 7
0
 def delete_preset(self, pid: int) -> ApiResponse:
   """ Deletes an existing preset """
   try:
     idx, _ = utils.find(self.status.presets, pid)
     if idx is not None:
       del self.status.presets[idx] # delete the cached preset state just in case
       return ApiResponse.ok()
     return ApiResponse.error('delete preset failed: {} does not exist'.format(pid))
   except KeyError:
     return ApiResponse.error('delete preset failed: {} does not exist'.format(pid))
Esempio n. 8
0
 def delete_stream(self, id):
     """Deletes an existing stream"""
     try:
         del self.streams[id]
         i, _ = utils.find(self.status['streams'], id)
         if i is not None:
             del self.status['streams'][
                 i]  # delete the cached stream state just in case
     except KeyError:
         return utils.error(
             'delete stream failed: {} does not exist'.format(id))
Esempio n. 9
0
  def set_source(self, sid: int, update: models.SourceUpdate, force_update: bool = False, internal: bool = False) -> ApiResponse:
    """Modifes the configuration of one of the 4 system sources

      Args:
        id (int): source id [0,3]
        update: changes to source
        force_update: bool, update source even if no changes have been made (for hw startup)
        internal: called by a higher-level ctrl function:

      Returns:
        'None' on success, otherwise error (dict)
    """
    idx, src = utils.find(self.status.sources, sid)
    if idx is not None and src is not None:
      name, _ = utils.updated_val(update.name, src.name)
      input_, input_updated = utils.updated_val(update.input, src.input)
      try:
        # update the name
        src.name = str(name)
        if input_updated or force_update:
          # shutdown old stream
          old_stream = self.get_stream(src)
          if old_stream:
            old_stream.disconnect()
          # start new stream
          last_input = src.input
          src.input = input_ # reconfigure the input so get_stream knows which stream to get
          stream = self.get_stream(src)
          if stream:
            # update the streams last connected source to have no input, since we have stolen its input
            if stream.src is not None and stream.src != idx:
              other_src = self.status.sources[stream.src]
              print('stealing {} from source {}'.format(stream.name, other_src.name))
              other_src.input = ''
            stream.disconnect()
            stream.connect(idx)
          rt_needs_update = self._is_digital(input_) != self._is_digital(last_input)
          if rt_needs_update or force_update:
            # get the current underlying type of each of the sources, for configuration of the runtime
            src_cfg = [self._is_digital(self.status.sources[s].input) for s in range(4)]
            # update this source
            src_cfg[idx] = self._is_digital(input_)
            if not self._rt.update_sources(src_cfg):
              return ApiResponse.error('failed to set source')
          self._update_src_info(src) # synchronize the source's info
        if not internal:
          self.mark_changes()
        return ApiResponse.ok()
      except Exception as exc:
        return ApiResponse.error('failed to set source: ' + str(exc))
    else:
      return ApiResponse.error('failed to set source: index {} out of bounds'.format(idx))
Esempio n. 10
0
 def delete_preset(self, id):
     """Deletes an existing preset"""
     try:
         i, _ = utils.find(self.status['presets'], id)
         if i is not None:
             del self.status['presets'][
                 i]  # delete the cached preset state just in case
         else:
             return utils.error(
                 'delete preset failed: {} does not exist'.format(id))
     except KeyError:
         return utils.error(
             'delete preset failed: {} does not exist'.format(id))
Esempio n. 11
0
  def set_preset(self, pid: int, update: models.PresetUpdate) -> ApiResponse:
    """ Reconfigure a preset """
    i, preset = utils.find(self.status.presets, pid)
    changes = update.dict(exclude_none=True)
    if i is None:
      return ApiResponse.error('Unable to find preset to redefine')

    try:
      # TODO: validate preset
      for field in changes.keys():
        preset.__dict__[field] = update.__dict__[field]
      return ApiResponse.ok()
    except Exception as exc:
      return ApiResponse.error('Unable to reconfigure preset {}: {}'.format(pid, exc))
Esempio n. 12
0
    def set_preset(self, id, preset_changes):
        i, preset = utils.find(self.status['presets'], id)
        configurable_fields = ['name', 'commands', 'state']
        if i is None:
            return utils.error('Unable to find preset to redefine')

        try:
            # TODO: validate preset
            for f in configurable_fields:
                if f in preset_changes:
                    preset[f] = preset_changes[f]
        except Exception as e:
            return utils.error('Unable to reconfigure preset {}: {}'.format(
                id, e))
Esempio n. 13
0
 def create_stream(self, data: models.Stream, internal=False) -> models.Stream:
   """ Create a new stream """
   try:
     # Make a new stream and add it to streams
     stream = amplipi.streams.build_stream(data, mock=self._mock_streams)
     sid = self._new_stream_id()
     self.streams[sid] = stream
     # Use get state to populate the contents of the newly created stream and find it in the stream list
     _, new_stream = utils.find(self.get_state().streams, sid)
     if new_stream:
       if not internal:
         self.mark_changes()
       return new_stream
     return ApiResponse.error('create stream failed: no stream created')
   except Exception as exc:
     return ApiResponse.error('create stream failed: {}'.format(exc))
Esempio n. 14
0
 def _effected_zones(self, preset_state: models.PresetState) -> Set[int]:
   """ Aggregate the zones that will be modified by changes """
   status = self.status
   effected: Set[int] = set()
   src_zones = utils.src_zones(status)
   for src_update in preset_state.sources or []:
     if src_update.id in src_zones:
       effected.update(src_zones[src_update.id])
   for group_update in preset_state.groups or []:
     if group_update.id:
       _, group_found = utils.find(status.groups, group_update.id)
       if group_found:
         effected.update(group_found.zones)
   for zone_update in preset_state.zones or []:
     if zone_update.id:
       effected.add(zone_update.id)
   return effected
Esempio n. 15
0
 def delete_stream(self, sid: int, internal=False) -> ApiResponse:
   """Deletes an existing stream"""
   try:
     # if input is connected to a source change that input to nothing
     for src in self.status.sources:
       if src.get_stream() == sid and src.id is not None:
         self.set_source(src.id, models.SourceUpdate(input=''), internal=True)
     # actually delete it
     del self.streams[sid]
     i, _ = utils.find(self.status.streams, sid)
     if i is not None:
       del self.status.streams[i] # delete the cached stream state just in case
     if not internal:
       self.mark_changes()
     return ApiResponse.ok()
   except KeyError:
     return ApiResponse.error('delete stream failed: {} does not exist'.format(sid))
Esempio n. 16
0
  def get_stream(self, src: models.Source = None, sid: int = None) -> Optional[amplipi.streams.AnyStream]:
    """Gets the stream from a source

    Args:
      src: An audio source that may have a stream connected
      sid: ID of an audio source
    Returns:
      a stream, or None if input does not specify a valid stream
    """
    if sid is not None:
      _, src = utils.find(self.status.sources, sid)
    if src is None:
      return None
    idx = src.get_stream()
    if idx is not None:
      return self.streams.get(idx, None)
    return None
Esempio n. 17
0
  def set_group(self, gid, update: models.GroupUpdate, internal: bool = False) -> ApiResponse:
    """Configures an existing group
        parameters will be used to configure each sone in the group's zones
        all parameters besides the group id, @id, are optional

        Args:
          gid: group id (a guid)
          update: changes to group
          internal: called by a higher-level ctrl function
        Returns:
          'None' on success, otherwise error (dict)
    """
    _, group = utils.find(self.status.groups, gid)
    if group is None:
      return ApiResponse.error('set group failed, group {} not found'.format(gid))
    name, _ = utils.updated_val(update.name, group.name)
    zones, _ = utils.updated_val(update.zones, group.zones)
    vol_delta, vol_updated = utils.updated_val(update.vol_delta, group.vol_delta)
    if vol_updated and (group.vol_delta is not None and vol_delta is not None):
      vol_change = vol_delta - group.vol_delta
    else:
      vol_change = 0

    group.name = name
    group.zones = zones

    # update each of the member zones
    zone_update = models.ZoneUpdate(source_id=update.source_id, mute=update.mute)
    if vol_change != 0:
      # TODO: make this use volume delta adjustment, for now its a fixed group volume
      zone_update.vol = vol_delta # vol = z.vol + vol_change
    for zone in [self.status.zones[zone] for zone in zones]:
      self.set_zone(zone.id, zone_update, internal=True)

    # save the volume
    group.vol_delta = vol_delta

    if not internal:
      # update the group stats
      self._update_groups()
      self.mark_changes()

    return ApiResponse.ok()
Esempio n. 18
0
def get_stream(sid):
    _, stream = utils.find(app.api.get_state()['streams'], sid)
    if stream is not None:
        return stream
    else:
        return {}, 404
Esempio n. 19
0
def get_stream(ctrl: Api = Depends(get_ctrl), sid: int = params.StreamID) -> models.Stream:
  """ Get Stream with id=**sid** """
  _, stream = utils.find(ctrl.get_state().streams, sid)
  if stream is not None:
    return stream
  raise HTTPException(404, f'stream {sid} not found')
Esempio n. 20
0
def get_preset(pid):
    _, preset = utils.find(app.api.get_state()['presets'], pid)
    if preset is not None:
        return preset
    else:
        return {}, 404
Esempio n. 21
0
    def load_preset(self, id):
        """
    To avoid any issues with audio coming out of the wrong speakers, we will need to carefully load a preset configuration. Below is an idea of how a preset configuration could be loaded to avoid any weirdness. We are also considering adding a "Last config" preset that allows us to easily revert the configuration changes.

    1. Grab system modification mutex to avoid accidental changes (requests during this time return some error)
    2. Save current configuration as "Last config" preset
    3. Mute any effected zones
    4. Execute changes to source, zone, group each in increasing order
    5. Unmute effected zones that were not muted
    6. Execute any stream commands
    7. Release system mutex, future requests are successful after this
    """
        # Get the preset to load
        LAST_PRESET = 9999
        i, preset = utils.find(self.status['presets'], id)
        if i is None:
            return utils.error(
                'load preset failed: {} does not exist'.format(id))

        # TODO: acquire lock (all api methods that change configuration will need this)

        # update last config preset for restore capabilities (creating if missing)
        # TODO: "last config" does not currently support restoring streaming state, how would that work? (maybe we could just support play/pause state?)
        lp, _ = utils.find(self.status['presets'], LAST_PRESET)
        st = self.status
        last_config = {
            'id': 9999,
            'name': 'Restore last config',
            'last_used': None,  # this need to be in javascript time format
            'state': {
                'sources': deepcopy(st['sources']),
                'zones': deepcopy(st['zones']),
                'groups': deepcopy(st['groups'])
            }
        }
        if lp is None:
            self.status['presets'].append(last_config)
        else:
            self.status['presets'][lp] = last_config

        # keep track of the zones muted by the preset configuration
        z_muted = set()

        # determine which zones will be effected and mute them
        # we do this just in case there is intermediate state that causes audio issues. TODO: test with and without this feature (it adds a lot of complexity to presets, lets make sure its worth it)
        z_effected = self._effected_zones(id)
        z_temp_muted = [
            zid for zid in z_effected if not self.status['zones'][zid]['mute']
        ]
        for zid in z_temp_muted:
            self.set_zone(zid, mute=True)

        ps = preset['state']

        # execute changes source by source in increasing order
        for src in ps.get('sources', []):
            if 'id' in src:
                self.set_source(**src)
            else:
                pass  # TODO: support some id-less source concept that allows dynamic source allocation

        # execute changes group by group in increasing order
        for g in utils.with_id(ps.get('groups')):
            _, g_to_update = utils.find(self.status['groups'], g['id'])
            if g_to_update is None:
                return utils.error('group {} does not exist'.format(g['id']))
            self.set_group(**g)
            if 'mute' in g:
                # use the updated group's zones just in case the group's zones were just changed
                _, g_updated = utils.find(self.status['groups'], g['id'])
                zones_changed = g_updated['zones']
                if g['mute']:
                    # keep track of the muted zones
                    z_muted.update(zones_changed)
                else:
                    # handle odd mute thrashing case where zone was muted by one group then unmuted by another
                    z_muted.difference_update()

        # execute change zone by zone in increasing order
        for z in utils.with_id(ps.get('zones')):
            self.set_zone(**z)
            if 'mute' in z:
                if z['mute']:
                    z_muted.add(z['id'])
                elif z['id'] in z_muted:
                    z_muted.remove(z['id'])

        # unmute effected zones that were not muted by the preset configuration
        z_to_unmute = set(z_temp_muted).difference(z_muted)
        for zid in z_to_unmute:
            self.set_zone(id=zid, mute=False)

        # TODO: execute stream commands

        preset['last_used'] = int(time.time())
Esempio n. 22
0
def get_group(ctrl: Api = Depends(get_ctrl), gid: int = params.GroupID) -> models.Group:
  """ Get Group with id=**gid** """
  _, grp = utils.find(ctrl.get_state().groups, gid)
  if grp is not None:
    return grp
  raise HTTPException(404, f'group {gid} not found')
Esempio n. 23
0
  def set_zone(self, zid, update: models.ZoneUpdate, force_update: bool = False, internal: bool = False) -> ApiResponse:
    """Reconfigures a zone

      Args:
        id: any valid zone [0,p*6-1] (6 zones per preamp)
        update: changes to zone
        force_update: update source even if no changes have been made (for hw startup)
        internal: called by a higher-level ctrl function
      Returns:
        ApiResponse
    """
    idx, zone = utils.find(self.status.zones, zid)
    if idx is not None and zone is not None:
      name, _ = utils.updated_val(update.name, zone.name)
      source_id, update_source_id = utils.updated_val(update.source_id, zone.source_id)
      mute, update_mutes = utils.updated_val(update.mute, zone.mute)
      vol, update_vol = utils.updated_val(update.vol, zone.vol)
      disabled, _ = utils.updated_val(update.disabled, zone.disabled)
      try:
        sid = utils.parse_int(source_id, [0, 1, 2, 3])
        vol = utils.parse_int(vol, range(-79, 79)) # hold additional state for group delta volume adjustments, output volume will be saturated to 0dB
        zones = self.status.zones
        # update non hw state
        zone.name = name
        zone.disabled = disabled
        if update_source_id or force_update:
          zone_sources = [zone.source_id for zone in zones]
          zone_sources[idx] = sid
          if self._rt.update_zone_sources(idx, zone_sources):
            zone.source_id = sid
          else:
            return ApiResponse.error('set zone failed: unable to update zone source')

        def set_mute():
          mutes = [zone.mute for zone in zones]
          mutes[idx] = mute
          if self._rt.update_zone_mutes(idx, mutes):
            zone.mute = mute
          else:
            raise Exception('set zone failed: unable to update zone mute')

        def set_vol():
          real_vol = utils.clamp(vol, -79, 0)
          if self._rt.update_zone_vol(idx, real_vol):
            zone.vol = vol
          else:
            raise Exception('set zone failed: unable to update zone volume')

        # To avoid potential unwanted loud output:
        # If muting, mute before setting volumes
        # If un-muting, set desired volume first
        try:
          if force_update or (update_mutes and update_vol):
            if mute:
              set_mute()
              set_vol()
            else:
              set_vol()
              set_mute()
          elif update_vol:
            set_vol()
          elif update_mutes:
            set_mute()
        except Exception as exc:
          return ApiResponse.error(str(exc))

        if not internal:
          # update the group stats (individual zone volumes, sources, and mute configuration can effect a group)
          self._update_groups()
          self.mark_changes()

        return ApiResponse.ok()
      except Exception as exc:
        return ApiResponse.error('set zone: '  + str(exc))
    else:
        return ApiResponse.error('set zone: index {} out of bounds'.format(idx))
Esempio n. 24
0
def get_preset(ctrl: Api = Depends(get_ctrl), pid: int = params.PresetID) -> models.Preset:
  """ Get Preset with id=**pid** """
  _, preset = utils.find(ctrl.get_state().presets, pid)
  if preset is not None:
    return preset
  raise HTTPException(404, f'preset {pid} not found')
Esempio n. 25
0
  def reinit(self, settings: models.AppSettings = models.AppSettings(), change_notifier: Optional[Callable[[models.Status], None]] = None, config: Optional[models.Status] = None):
    """ Initialize or Reinitialize the controller

    Intitializes the system to to base configuration """
    self._change_notifier = change_notifier
    self._mock_hw = settings.mock_ctrl
    self._mock_streams = settings.mock_streams
    self._save_timer = None
    self._delay_saves = settings.delay_saves
    self._settings = settings

    # Create firmware interface. If one already exists delete then re-init.
    if self._initialized:
      # we need to make sure to mute every zone before resetting the fw
      zones_update = models.MultiZoneUpdate(zones=[z.id for z in self.status.zones], update=models.ZoneUpdate(mute=True))
      self.set_zones(zones_update, force_update=True, internal=True)
      try:
        del self._rt # remove the low level hardware connection
      except AttributeError:
        pass
    self._rt = rt.Mock() if settings.mock_ctrl else rt.Rpi() # reset the fw

    # test open the config file, this will throw an exception if there are issues writing to the file
    with open(settings.config_file, 'a'): # use append more to make sure we have read and write permissions, but won't overrite the file
      pass
    self.config_file = settings.config_file
    self.backup_config_file = settings.config_file + '.bak'
    self.config_file_valid = True # initially we assume the config file is valid
    errors = []
    if config:
      self.status = config
      loaded_config = True
    else:
      # try to load the config file or its backup
      config_paths = [self.config_file, self.backup_config_file]
      loaded_config = False
      for cfg_path in config_paths:
        try:
          if os.path.exists(cfg_path):
            self.status = models.Status.parse_file(cfg_path)
            loaded_config = True
            break
          errors.append('config file "{}" does not exist'.format(cfg_path))
        except Exception as exc:
          self.config_file_valid = False # mark the config file as invalid so we don't try to back it up
          errors.append('error loading config file: {}'.format(exc))

    if not loaded_config:
      print(errors[0])
      print('using default config')
      self.status = models.Status.parse_obj(self.DEFAULT_CONFIG)
      self.save()

    self.status.info = models.Info(
      mock_ctrl=self._mock_hw,
      mock_streams=self._mock_streams,
      config_file=self.config_file,
      version=utils.detect_version()
    )

    # TODO: detect missing sources

    # detect missing zones
    if self._mock_hw:
      # only allow 6 zones when mocked to simplify testing
      # add more if needed by specifying them in the config
      potential_zones = range(6)
    else:
      potential_zones = range(rt.MAX_ZONES)
    added_zone = False
    for zid in potential_zones:
      _, zone = utils.find(self.status.zones, zid)
      if zone is None and self._rt.exists(zid):
        added_zone = True
        self.status.zones.append(models.Zone(id=zid, name=f'Zone {zid+1}'))
    # save new config if zones were added
    if added_zone:
      self.save()

    # configure all streams into a known state
    self.streams: Dict[int, amplipi.streams.AnyStream] = {}
    failed_streams: List[int] = []
    for stream in self.status.streams:
      if stream.id:
        try:
          self.streams[stream.id] = amplipi.streams.build_stream(stream, self._mock_streams)
        except Exception as exc:
          print(f"Failed to create '{stream.name}' stream: {exc}")
          failed_streams.append(stream.id)
    # only keep the successful streams, this fixes a common problem of loading a stream that doesn't exist in the current developement
    # [:] does an in-place modification to the list suggested by https://stackoverflow.com/a/1208792/1110730
    self.status.streams[:] = [s for s in self.status.streams if s.id not in failed_streams]

    # configure all sources so that they are in a known state
    for src in self.status.sources:
      if src.id is not None:
        update = models.SourceUpdate(input=src.input)
        self.set_source(src.id, update, force_update=True, internal=True)
    # configure all of the zones so that they are in a known state
    #   we mute all zones on startup to keep audio from playing immediately at startup
    for zone in self.status.zones:
      # TODO: disable zones that are not found
      # we likely need an additional field for this, maybe auto-disabled?
      zone_update = models.ZoneUpdate(source_id=zone.source_id, mute=True, vol=zone.vol)
      self.set_zone(zone.id, zone_update, force_update=True, internal=True)
    # configure all of the groups (some fields may need to be updated)
    self._update_groups()