def discover_ur_install_path(): """ Tries to find Saved Games on this system Returns: Saved Games dir """ from esst import FS LOGGER.debug('discovering UR install path') if not ATISConfig.UR_PATH(): LOGGER.debug( 'no UR install path in Config, looking it up in the registry') _ur_install_path = _get_ur_install_path_from_registry() else: LOGGER.debug('UR install path found in Config') _ur_install_path = Path(ATISConfig.UR_PATH()) if not _ur_install_path.is_dir(): LOGGER.error( 'UR install path provided in config file is not a directory: %s', _ur_install_path) sys.exit(1) LOGGER.debug('using UR install path: %s', _ur_install_path) FS.ur_install_path = _ur_install_path URStatus.install_path = _ur_install_path FS.ur_settings_folder = Path(FS.saved_games_path, 'UniversRadio') URStatus.settings_folder = Path(FS.saved_games_path, 'UniversRadio') LOGGER.debug('UR settings folder: %s', FS.ur_settings_folder) FS.ur_voice_settings_file = Path(FS.ur_settings_folder, 'VoiceService.dat') URStatus.voice_settings_file = Path(FS.ur_settings_folder, 'VoiceService.dat') LOGGER.debug('UR voice service data file: %s', FS.ur_voice_settings_file) utils.create_simple_backup(FS.ur_voice_settings_file, file_must_exist=False)
def showfor(icao: list): """ Show ATIS info for a specific airfield """ icao_str = ''.join(icao).upper() try: info = get_info_for_icao(icao_str) except KeyError: LOGGER.error('ICAO not found in the list of currently active ATIS: %s', icao_str) return if core.Status.metar == 'unknown': LOGGER.error('no weather information available at this time') return running = 'running' if URVoiceService.is_running() else 'not running' # type: ignore _weather = core.Status.metar.as_str() # pylint: disable=no-member # type: ignore _metar = core.Status.metar.raw_metar_str # pylint: disable=no-member info_str = f'UR voice service is {running}\n\n' \ f'METAR:\n{_metar}\n\n' \ f'Weather:\n{_weather}\n\n' \ f'Active runway: {info.active_runway}\n' \ f'Information ID: {info.info_letter}\n' \ f'ATIS speech: {core.CTX.atis_speech}' LOGGER.info(info_str)
async def run(self): """ Infinite loop that manages a UDP socket and does two things: 1. Retrieve incoming messages from DCS and update :py:class:`esst.core.status.status` 2. Sends command to the DCS application via the socket """ if not CTX.start_listener_loop: LOGGER.debug('skipping startup of socket loop') return try: self.sock.bind(self.server_address) except socket.error as exc: if exc.errno == 10048: LOGGER.error( 'cannot bind socket, maybe another instance of ESST is already running?' ) sys.exit(-1) self.sock.settimeout(1) while not CTX.exit: await self._read_socket() await self._parse_commands() await self._monitor_server_startup() await self._monitor_server() await asyncio.sleep(0.1) self.sock.close() LOGGER.debug('end of listener loop')
def set_priority(self): """ Sets the DCS process CPU priority to the CFG value """ def _command(): if self.app.nice() != self.valid_priorities[ DCSConfig.DCS_CPU_PRIORITY()]: LOGGER.debug('setting DCS process priority to: %s', DCSConfig.DCS_CPU_PRIORITY()) self.app.nice( self.valid_priorities[DCSConfig.DCS_CPU_PRIORITY()]) time.sleep(15) while True: if DCSConfig.DCS_CPU_PRIORITY(): if core.CTX.exit: return if DCSConfig.DCS_CPU_PRIORITY( ) not in self.valid_priorities.keys(): LOGGER.error( f'invalid priority: %s\n' f'Choose one of: %s', DCSConfig.DCS_CPU_PRIORITY(), self.valid_priorities.keys(), ) return self._work_with_dcs_process(_command) else: LOGGER.warning( 'no CPU priority given in config file for dcs.exe') return time.sleep(30)
def get_running_mission() -> typing.Union['MissionPath', str]: """ Returns: currently running mission as a MissionPath instance """ mission = None if core.Status.mission_file and core.Status.mission_file != 'unknown': mission_path = Path(core.Status.mission_file) if mission_path.parent == 'AUTO': mission_path = Path(mission_path.parent.parent, mission_path.name) mission = MissionPath(mission_path) else: try: dcs_settings = _get_settings_file_path().read_text() except FileNotFoundError: LOGGER.error( 'please start a DCS server at least once before using ESST') sys.exit(1) else: for line in dcs_settings.split('\n'): if '[1]' in line: mission = MissionPath(line.split('"')[1]) break if mission: LOGGER.debug('returning active mission: %s', mission.name) return mission LOGGER.error('current mission is "%s", but that file does not exist', mission) return ''
async def on_ready(self): """ Triggers when the bot is ready. """ if not self.ready: self._user = self.client.user await self._update_profile() LOGGER.debug('Logged in as: %s', self.client.user.name) try: self._server = set(self.client.servers).pop() except KeyError: LOGGER.error('Your discord bot has not server to connect to\n' 'Go to https://discordapp.com/developers/applications/me to create a bot, and note ' 'the client ID.\n' 'Use the client ID in the following URL to join you bot to your Discord server:\n' 'https://discordapp.com/oauth2/authorize?client_id=CLIENT_ID&scope=bot') else: self._member = self.server.get_member(self.user.id) if self.user.display_name != DiscordBotConfig.DISCORD_BOT_NAME(): await self.client.change_nickname(self.member, DiscordBotConfig.DISCORD_BOT_NAME()) await self._update_presence() await self.get_channel() self._ready = True
def get_latest_mission_from_github(): """ Downloads the latest mission from a Github repository The repository needs to have releases (tagged) The function will download the first MIZ file found in the latest release """ if core.CTX.dcs_auto_mission: LOGGER.debug('getting latest mission from Github') commands.DCS.block_start('loading mission') if DCSConfig.DCS_AUTO_MISSION_GH_OWNER( ) and DCSConfig.DCS_AUTO_MISSION_GH_REPO(): LOGGER.debug('looking for newer mission file') latest_version, asset_name, download_url = utils.get_latest_release( DCSConfig.DCS_AUTO_MISSION_GH_OWNER(), DCSConfig.DCS_AUTO_MISSION_GH_REPO()) LOGGER.debug('latest release: %s', latest_version) local_file = MissionPath(Path(_get_mission_folder(), asset_name)) if not local_file: LOGGER.info('downloading new mission: %s', asset_name) req = requests.get(download_url) if req.ok: local_file.path.write_bytes(req.content) local_file.set_as_active() else: LOGGER.error('failed to download latest mission') else: LOGGER.warning('no config values given for [auto mission]') commands.DCS.unblock_start('loading mission') else: LOGGER.debug('skipping mission update')
def init() -> None: """ Makes sure the configuration is valid before starting ESST :raise: SystemExit """ # Setup elib_config elib_config.ELIBConfig.setup( app_version=__version__, app_name='ESST', config_file_path='esst.toml', config_sep_str='__', ) # Write example config file elib_config.write_example_config('esst.toml.example') # Validate config try: elib_config.validate_config() except elib_config.ConfigMissingValueError as error: LOGGER.error('missing mandatory config value: %s', error.value_name) LOGGER.error( 'please read "esst.toml.example" for instructions on how to setup the configuration for ESST' ) sys.exit(1) for config in SentryConfigContext.__subclasses__(): SENTRY.register_context(context_name=config.__name__, context_provider=config)
def _get_server_settings_path() -> Path: if not FS.dcs_server_settings: LOGGER.error('FS.dcs_server_settings undefined') sys.exit(1) if not FS.dcs_server_settings.exists(): LOGGER.error('please start a DCS server at least once before using ESST') sys.exit(1) return FS.dcs_server_settings
def external_ip(): """ Returns: external IP of this machine """ try: return requests.get('https://api.ipify.org').text except requests.ConnectionError: LOGGER.error('unable to obtain external IP') return 'unknown'
async def _monitor_server(self): await asyncio.sleep(0.1) if self.monitoring: if time.time() - self.last_ping > DCSConfig.DCS_PING_INTERVAL(): LOGGER.error( 'It has been %s seconds since I heard from DCS. ' 'It is likely that the server has crashed.', DCSConfig.DCS_PING_INTERVAL()) CTX.dcs_do_restart = True self.monitoring = False
def get_base_missions_folder() -> Path: """ Returns the folder in which all ESST related missions are contained Returns: Path object """ if FS.dcs_mission_folder: return FS.dcs_mission_folder LOGGER.error('FS.dcs_mission_folder undefined') sys.exit(1)
async def _monitor_server_startup(self): await asyncio.sleep(0.1) if CTX.listener_monitor_server_startup: if self.startup_age is None: self.startup_age = time.time() if time.time( ) - self.startup_age > DCSConfig.DCS_START_GRACE_PERIOD(): LOGGER.error( f'DCS is taking more than %s seconds to start a ' 'multiplayer server.\n' 'Something is wrong ...', DCSConfig.DCS_START_GRACE_PERIOD()) CTX.listener_monitor_server_startup = False
def initial_setup(): """ Runs at the start of the DCS loop, to initialize the first mission """ LOGGER.debug('initializing first mission') mission = get_running_mission() if isinstance(mission, MissionPath): LOGGER.info('building METAR for initial mission: %s', mission.orig_name) weather = elib_wx.Weather(str(mission.path)) core.Status.metar = weather esst.atis.create.generate_atis(weather) else: LOGGER.error('no initial mission found')
def restart(force: bool = False): """ Sets the context to restart the DCS application Returns: None if restart is OK, err as a str otherwise """ CANCEL_QUEUED_KILL.put(1) if DCS.there_are_connected_players() and not force: LOGGER.error( 'there are connected players; cannot restart the server now ' ' (use "--force" to kill anyway)') return LOGGER.debug('setting context for DCS restart') CTX.dcs_do_restart = True
def _get_current_mission_path() -> str: """ Extracts the path of the current (first) defined mission in "serverSettings.lua" :return: path to the mission :rtype: str """ server_settings = _get_server_settings_path() text: str = Path(server_settings).read_text() for line in text.split('\n'): match = _CURRENT_MIS_RE.match(line) if match: return match.group('mission_path') LOGGER.error('please start a DCS server at least once before using ESST') sys.exit(1)
async def kill_running_app(self): # noqa: C901 """ Kills the running DCS.exe process """ async def _ask_politely(): if not self.app or not self.app.is_running(): return True LOGGER.debug('sending socket command to DCS for graceful exit') commands.LISTENER.exit_dcs() await asyncio.sleep(1) LOGGER.debug( 'waiting on DCS to close itself (grace period: %s seconds)', DCSConfig.DCS_CLOSE_GRACE_PERIOD()) now_ = utils.now() while self.app.is_running(): await asyncio.sleep(1) if utils.now() - now_ > DCSConfig.DCS_CLOSE_GRACE_PERIOD(): LOGGER.debug('grace period time out!') return False LOGGER.info('DCS closed itself, nice') return True async def _no_more_mr_nice_guy(): if not self.app or not self.app.is_running(): return True LOGGER.debug('killing dcs.exe application') self.app.kill() now_ = utils.now() while self.app.is_running(): await asyncio.sleep(1) if utils.now() - now_ > 10: return False return True core.CTX.dcs_do_kill = False await self._check_if_dcs_is_running() if not self.app or not self.app.is_running(): LOGGER.debug('DCS process was not running') return LOGGER.info('closing DCS') if not await _ask_politely(): LOGGER.info('DCS will not exit gracefully, killing it') if not await _no_more_mr_nice_guy(): LOGGER.error('I was not able to kill DCS, something is wrong') raise RuntimeError() await self._check_if_dcs_is_running()
def download_mission_from_discord(discord_attachment, overwrite: bool = False, load: bool = False, force: bool = False): """ Downloads a mission from a discord message attachment Args: force: force restart even with players connected discord_attachment: url to download the mission from overwrite: whether or not to overwrite an existing file load: whether or not to restart the server with the downloaded mission """ url = discord_attachment['url'] size = discord_attachment['size'] filename = discord_attachment['filename'] local_file = MissionPath(Path(_get_mission_folder(), filename)) overwriting = '' if local_file: if overwrite: overwriting = ' (replacing existing file)' else: LOGGER.warning( 'this mission already exists: %s\nuse "overwrite" to replace it', local_file.path) return LOGGER.info( 'downloading: %s (%s) %s', filename, humanize.naturalsize(size), overwriting, ) with requests.get(url) as response: local_file.path.write_bytes(response.content) if load: if commands.DCS.there_are_connected_players() and not force: LOGGER.error( 'there are connected players; cannot restart the server now (use "force" to kill anyway)' ) return LOGGER.info('restarting the server with this mission') local_file.set_as_active() commands.DCS.restart(force=force) else: LOGGER.info('download successful, mission is now available')
def kill(force: bool = False, queue: bool = False): """Kills DCS application""" CANCEL_QUEUED_KILL.put(1) if DCS.there_are_connected_players(): if not force: if queue: DCS.queue_kill() else: LOGGER.error( 'there are connected players; cannot kill the server now' ' (use "--force" to kill anyway)') return else: LOGGER.warning('forcing kill with connected players') LOGGER.debug('setting context for DCS kill') CTX.dcs_do_kill = True
def get_esst_changelog_path() -> str: """ Returns: changelog path """ changelog_path = os.path.join(os.path.dirname(__file__), 'CHANGELOG.rst') if not os.path.exists(changelog_path): LOGGER.debug('changelog not found, trying from pkg_resource') changelog_path = pkg_resources.resource_filename( 'esst', 'CHANGELOG.rst') if not os.path.exists(changelog_path): LOGGER.error('changelog not found') return '' return changelog_path
def delete(name: str): """ Removes a mission file from the server """ try: mission_number = int(name) except ValueError: mission = missions_manager.MissionPath(name) if not mission: LOGGER.error('mission file does not exist: %s', mission.path) return else: mission = _mission_index_to_mission_name(mission_number) if not mission: LOGGER.error( 'invalid mission index: %s; use "!mission show" to see available indices', mission_number) return missions_manager.delete(mission)
def discover_saved_games_path(): """ Tries to find Saved Games on this system Returns: Saved Games dir """ from esst import ESSTConfig if not ESSTConfig.SAVED_GAMES_DIR(): LOGGER.debug('no Saved Games path in Config, looking it up') return FS._get_saved_games_from_registry() LOGGER.debug('Saved Games path found in Config') base_sg = Path(ESSTConfig.SAVED_GAMES_DIR()) if not base_sg.is_dir(): LOGGER.error( 'Saved Games dir provided in config file is invalid: %s', base_sg) return FS._get_saved_games_from_registry() return base_sg
async def on_message(self, message: discord.Message): # noqa: C901 """ Triggers on any message received from the Discord server Args: message: message received """ if message.author.id == self.member.id: return if message.channel != self.channel: return if DiscordBotConfig.DISCORD_ADMIN_ROLES(): is_admin = bool([ role for role in DiscordBotConfig.DISCORD_ADMIN_ROLES() # pylint: disable=not-an-iterable if role in [role.name for role in message.author.roles] ]) else: is_admin = True if message.attachments: for attach in message.attachments: if attach['filename'].endswith('.miz'): if not is_admin: LOGGER.error( f'only users with privileges can load missions on the server' ) return overwrite = 'overwrite' in message.content load = 'load' in message.content force = 'force' in message.content missions_manager.download_mission_from_discord( attach, overwrite, load, force) if message.content.startswith('!'): LOGGER.debug('received "%s" command from: %s%s', message.content, message.author.display_name, " (admin)" if is_admin else "") self.parser.parse_discord_message(message.content, is_admin)
def set_as_active(self, weather: elib_wx.Weather = None): """ Write the settings file to set this mission as active :param weather: current weather; if not provided, will be inferred from MIZ file :type weather: elib_wx.Weather """ LOGGER.info('setting active mission to: %s', self.name) if not self: LOGGER.error('mission file not found: %s', self.path) return write_server_settings(str(self.path).replace('\\', '/')) if weather is None: LOGGER.debug('building metar from mission: %s', self.name) # noinspection SpellCheckingInspection weather = elib_wx.Weather(str(self.path)) LOGGER.info('metar for %s:\n%s', self.name, weather) else: esst.atis.create.generate_atis(weather) core.Status.metar = weather
def text_to_speech(text: str, file_path: typing.Union[str, Path], overwrite: bool = False) -> Path: """ Creates MP3 file from text Args: text: text to encode file_path: path to MP3 file overwrite: whether or not to overwrite existing file Returns: path to saved MP3 """ LOGGER.debug('%s\n->%s', text, file_path) file_path = Path(file_path) if file_path.exists() and not overwrite: LOGGER.error('"%s" already exists', file_path) raise FileExistsError(file_path) LOGGER.debug('encoding text') tts = gtts.gTTS(text=text, lang_check=False) LOGGER.debug('saving MP3 file') tts.save(str(file_path)) return file_path
def _load(name, metar_or_icao, time, max_wind, min_wind, force): # noqa: C901 if max_wind or min_wind: LOGGER.warning( '"min_wind" and "max_wind" have been disabled for the time being') if name is None: mission = missions_manager.get_running_mission() if not mission: LOGGER.error('unable to retrieve current mission') return else: try: LOGGER.debug('trying to cast mission name into an int: %s', name) mission_number = int(name) except ValueError: LOGGER.debug('loading mission name: %s', name) mission = missions_manager.MissionPath(name) if not mission: LOGGER.debug('mission path not found: %s', mission.path) LOGGER.error('mission file not found: %s', mission.name) return else: LOGGER.debug('loading mission number: %s', mission_number) mission = _mission_index_to_mission_name(mission_number) if not mission: LOGGER.error( 'invalid mission index: %s; use "!mission show" to see available indices', mission_number) return LOGGER.info('loading mission file: %s', mission.path) if time: try: mission_time = elib_miz.MissionTime.from_string(time) LOGGER.info('setting mission time: %s', mission_time.iso_format) except elib_miz.exc.InvalidDateTimeString: LOGGER.error('invalid date-time string: %s', time) return except ValueError as err: LOGGER.error(err) return else: mission_time = None if metar_or_icao: LOGGER.info('analyzing METAR string: %s', metar_or_icao) try: weather_ = elib_wx.Weather(metar_or_icao) LOGGER.info('setting mission weather: %s', weather_.as_str()) except elib_wx.BadStationError: LOGGER.error('wrong ICAO code: %s', metar_or_icao) return LOGGER.info('METAR: %s', weather_.raw_metar_str) else: LOGGER.info('building METAR from mission file') # noinspection SpellCheckingInspection weather_ = elib_wx.Weather(str(mission.path)) LOGGER.info('METAR: %s', weather_.as_str()) commands.DCS.block_start('loading mission') commands.DCS.kill(force=force) try: LOGGER.debug('waiting on DCS application to close') while core.Status.dcs_application != 'not running': sleep(1) LOGGER.debug('DCS has closed, carrying on') active_mission = mission if time: mission_time.apply_to_miz(str(mission.path), str(mission.auto.path), overwrite=True) active_mission = mission.auto if metar_or_icao: weather_.apply_to_miz(str(mission.path), str(mission.auto.path), overwrite=True) active_mission = mission.auto active_mission.set_as_active(weather_) finally: commands.DCS.unblock_start('loading mission')
def _execute_command(func, namespace_obj, pre_call=None): # noqa: C901 # noinspection SpellCheckingInspection """ Assumes that `function` is a callable. Tries different approaches to call it (with `namespace_obj` or with ordinary signature). Yields the results line by line. If :class:`~argh.exceptions.CommandError` is raised, its message is appended to the results (i.e. yielded by the generator as a string). All other exceptions propagate unless marked as wrappable by :func:`wrap_errors`. """ if pre_call: LOGGER.debug('running pre_call: %s', pre_call) pre_call(namespace_obj) # namespace -> dictionary def _flat_key(key): return key.replace('-', '_') # noinspection SpellCheckingInspection def _call(): # Actually call the function if getattr(func, ATTR_EXPECTS_NAMESPACE_OBJECT, False): result_ = func(namespace_obj) else: all_input = dict((_flat_key(k), v) for k, v in vars(namespace_obj).items()) # filter the namespace variables so that only those expected # by the actual function will pass spec = get_arg_spec(func) positional = [all_input[k] for k in spec.args] # noinspection SpellCheckingInspection kw_only = getattr(spec, 'kwonlyargs', []) keywords = dict((k, all_input[k]) for k in kw_only) # *args if spec.varargs: positional += getattr(namespace_obj, spec.varargs) # **kwargs varkw = getattr(spec, 'varkw', getattr(spec, 'keywords', [])) if varkw: not_kwargs = [DEST_FUNCTION] + spec.args + [spec.varargs] + kw_only for k in vars(namespace_obj): if k.startswith('_') or k in not_kwargs: continue keywords[k] = getattr(namespace_obj, k) result_ = func(*positional, **keywords) # Yield the results if isinstance(result_, (GeneratorType, list, tuple)): # yield each line ASAP, convert CommandError message to a line for line_ in result_: yield line_ else: # yield non-empty non-iterable result as a single line if result_ is not None: yield result_ # noinspection SpellCheckingInspection wrappable_exceptions = [CommandError, Exception] wrappable_exceptions += getattr(func, ATTR_WRAPPED_EXCEPTIONS, []) try: LOGGER.debug('running func: %s', func) result = _call() return '\n'.join(result) # pylint: disable=catching-non-exception except tuple(wrappable_exceptions) as exc: # pylint: disable=unnecessary-lambda processor = getattr( func, ATTR_WRAPPED_EXCEPTIONS_PROCESSOR, lambda exc_: '{0.__class__.__name__}: {0}'.format(exc_) ) LOGGER.error(compat.text_type(processor(exc))) LOGGER.exception(exc)
def dispatch(self, # noqa: C901 argv=None, add_help_command=True, completion=True, # pylint: disable=unused-argument pre_call=None, output_file=sys.stdout, # pylint: disable=unused-argument errors_file=sys.stderr, # pylint: disable=unused-argument raw_output=False, # pylint: disable=unused-argument namespace=None, skip_unknown_args=False, is_admin: bool = False, ): """ Passes arguments to linked function Args: is_admin: is the user that issued the command an admin ? argv: add_help_command: completion: pre_call: output_file: errors_file: raw_output: namespace: skip_unknown_args: """ try: for arg in argv: if arg == 'help': argv.remove('help') argv.append('--help') for arg in argv: if arg in ['-h', '--help']: pre_call = _cancel_execution if argv is None: argv = sys.argv[1:] if add_help_command: if argv: if argv[0] in ['help', '-h']: argv.pop(0) argv.append('--help') if skip_unknown_args: parse_args = self.parse_known_args else: parse_args = self.parse_args if not namespace: namespace = ArghNamespace() # this will raise SystemExit if parsing fails namespace_obj = parse_args(argv, namespace=namespace) func = _get_function_from_namespace_obj(namespace_obj) if func: if hasattr(func, 'protected_') and not is_admin: LOGGER.error(f'only users with privileges have access to this command') return None LOGGER.debug('running func: %s', func) return _execute_command(func, namespace_obj, pre_call=pre_call) # no commands declared, can't dispatch; display help message return [self.format_usage()] except SystemExit: pass
def _get_mission_folder() -> Path: if FS.dcs_mission_folder: return FS.dcs_mission_folder LOGGER.error('FS.dcs_mission_folder undefined') sys.exit(0)
def _get_settings_file_path() -> Path: if FS.dcs_server_settings: return FS.dcs_server_settings LOGGER.error('FS.dcs_server_settings undefined') sys.exit(1)