From 78aa8005bdc895986b425d71e24faa061605b7db Mon Sep 17 00:00:00 2001 From: Jack O'Sullivan <jackos1998@gmail.com> Date: Thu, 12 Dec 2024 17:12:13 +0000 Subject: [PATCH] controller: Initial polling and parsing of Valorant presence --- controller/app.py | 38 +++++++----- controller/valconomy.py | 127 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 16 deletions(-) diff --git a/controller/app.py b/controller/app.py index 4baf269..07f0e01 100644 --- a/controller/app.py +++ b/controller/app.py @@ -1,7 +1,6 @@ import logging import os import sys -import time import infi.systray @@ -11,33 +10,40 @@ import valconomy DATA_DIR = os.path.join(os.environ['LOCALAPPDATA'], 'Valconomy') LOG_FILE = os.path.join(DATA_DIR, 'app.log') -log = logging.getLogger('valconomy') +log = logging.getLogger('valconomy-app') def data_file(fname: str) -> str: return os.path.abspath(os.path.join(os.path.dirname(__file__), fname)) -running = True -def do_quit(tray: infi.systray.SysTrayIcon): - global running - log.info('Shutting down') - running = False - -def do_view_logs(tray: infi.systray.SysTrayIcon): +def do_open_datadir(tray: infi.systray.SysTrayIcon): os.startfile(DATA_DIR) def main(): os.makedirs(DATA_DIR, exist_ok=True) + + if sys.stderr is None: + # running in console-less mode, redirect to log file + sys.stderr = open(LOG_FILE, 'a') + logging.basicConfig( format='%(asctime)s %(levelname)s %(message)s', level=logging.INFO, - handlers=( - logging.StreamHandler(sys.stderr), # log to stderr as well as file - logging.FileHandler(LOG_FILE), - )) + stream=sys.stderr) log.info('Starting up') - with infi.systray.SysTrayIcon(data_file('icon.ico'), 'Valconomy', menu_options=(('View logs', None, do_view_logs),), on_quit=do_quit) as tray: - while running: - time.sleep(0.5) + def cb(i: valconomy.RiotPlayerInfo): + print(repr(i)) + + val_client = valconomy.ValorantLocalClient(cb) + def do_quit(tray: infi.systray.SysTrayIcon): + log.info('Shutting down') + val_client.stop() + + with infi.systray.SysTrayIcon( + data_file('icon.ico'), 'Valconomy', on_quit=do_quit, + menu_options=( + ('Open data directory', None, do_open_datadir), + )) as tray: + val_client.run() if __name__ == '__main__': main() diff --git a/controller/valconomy.py b/controller/valconomy.py index b1206e6..7ab7927 100644 --- a/controller/valconomy.py +++ b/controller/valconomy.py @@ -1 +1,128 @@ +import base64 +from dataclasses import dataclass +import json +import logging +from pprint import pprint +import os +import time +from typing import Callable + import hid +import requests +import urllib3 + +# Local HTTPS connection, we need to not verify the cert +urllib3.disable_warnings() + +log = logging.getLogger('valconomy') + +@dataclass +class ValorantPlayerInfo: + is_idle: bool = False + + is_party_owner: bool = False + max_party_size: int = 0 + party_size: int = 0 + party_state: str = None + + queue_type: str = None + game_state: str = None + + map: str = None + team: str = None + score: int = 0 + enemy_score: int = 0 + +@dataclass +class RiotPlayerInfo: + uuid: str + name: str + tag: str + state: str + + valorant: ValorantPlayerInfo = None + +class ValorantLocalClient: + lockfile_path = os.path.join(os.environ['LOCALAPPDATA'], r'Riot Games\Riot Client\Config\lockfile') + poll_interval = 0.5 + + def __init__(self, callback: Callable[[RiotPlayerInfo], None]): + self.callback = callback + + self.port, self.password = None, None + self.running = True + self.players = {} + + def _load_credentials(self): + if not os.path.exists(self.lockfile_path): + return False + + try: + with open(self.lockfile_path) as f: + for line in f: + if line.startswith('Riot Client'): + client, pid, self.port, self.password, proto = line.rstrip().split(':') + assert proto == 'https' + return True + except FileNotFoundError: + return False + + return False + + def _do_poll(self): + try: + resp = requests.get( + f'https://127.0.0.1:{self.port}/chat/v4/presences', + auth=('riot', self.password), verify=False) + resp.raise_for_status() + data = resp.json() + except (requests.HTTPError, requests.ConnectionError) as ex: + # Most likely one of these means the Riot Client shut down + log.error(f'Failed to make request to Riot Client: {ex}') + return False + + for p in data['presences']: + if p['product'] != 'valorant': + continue + + v = json.loads(base64.b64decode(p.pop('private'))) # Valorant-specfic + # pprint(p) + # pprint(v) + + val_info = ValorantPlayerInfo( + is_idle=v['isIdle'], is_party_owner=v['isPartyOwner'], + max_party_size=v['maxPartySize'], party_size=v['partySize'], + party_state=v['partyState'], + queue_type=v['queueId'] or None, game_state=v['sessionLoopState'], + map=v['matchMap'] or None, + score=v['partyOwnerMatchScoreAllyTeam'], enemy_score=v['partyOwnerMatchScoreEnemyTeam']) + match v['partyOwnerMatchCurrentTeam']: + case 'Red': + val_info.team = 'attackers' + case 'Blue': + val_info.team = 'defenders' + + info = RiotPlayerInfo(p['puuid'], p['game_name'], p['game_tag'], p['state'], valorant=val_info) + + last = self.players.get(info.uuid) + if info != last: + self.players[info.uuid] = info + self.callback(info) + + return True + + def run(self): + while self.running: + if self.password is None: + if not self._load_credentials(): + time.sleep(self.poll_interval) + continue + log.info('Detected Riot Client credentials, starting polling') + + if not self._do_poll(): + self.port, self.password = None, None + + time.sleep(self.poll_interval) + + def stop(self): + self.running = False