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
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()
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()
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()
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))
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))
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))
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))
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))
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))
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))
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))
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))
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
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))
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
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()
def get_stream(sid): _, stream = utils.find(app.api.get_state()['streams'], sid) if stream is not None: return stream else: return {}, 404
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')
def get_preset(pid): _, preset = utils.find(app.api.get_state()['presets'], pid) if preset is not None: return preset else: return {}, 404
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())
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')
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))
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')
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()