Last active
April 21, 2020 02:41
-
-
Save bafoah/16c026777216e197e2eb3eb89a4d4ec9 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# python folding bot by bafo_ah | |
# go to line 18 for host configuration | |
# and line 37 for Telegram Bot configuration | |
import os | |
import platform | |
import socket | |
import threading | |
import time | |
import urllib.request | |
import urllib.parse | |
from collections import OrderedDict | |
from datetime import datetime | |
from datetime import timedelta | |
# define host list | |
# you MUST allow connection on FAHClient and host FIREWALL | |
# make sure you get welcome message when running "telnet host_address port_no" on your favorite terminal/command line | |
host_list = [ | |
{'hostname': 'kerys', 'address': '10.8.0.2', 'port': 36330}, | |
{'hostname': 'monuc', 'address': '10.8.0.9', 'port': 36330}, | |
{'hostname': 'puck', 'address': '10.8.0.12', 'port': 36330}, | |
#{'hostname': 'rx3', 'address': 'localhost', 'port': 36330}, | |
#{'hostname': 'puck2', 'address': '10.8.0.12', 'port': 36330}, | |
#{'hostname': 'localhost', 'address': 'localhost', 'port': 36330} | |
] | |
""" Configuration class, this class only hold configuration | |
""" | |
class Config : | |
NEXT_ATTEMPT_LIMIT = 300 # time (in second) needed before doing pause-wait-unpause action! please do NOT set below 900 (15 minutes), it will hammering fah server so hard - unless... you know something wrong on your side.... | |
SOCKET_REFRESH_RATE = 5.0 # queue-info refresh rate in second - if no response from FAHClient on this period, socket will timeout and we can make another queue-info update | |
SOCKET_NEXT_TRY_DELAY = 5 | |
DISPLAY_AUTO_REFRESH = 10 | |
# setting for telegram bot | |
# if you're too lazy to setup a bot, just use mine, just... do NOT abuse it please.... | |
# make sure you talk to your bot... search for bot username (ex. mine is @foldabot), just talk to her (so her allowed to talk to you), don't wait for reply.... | |
TGBOT_TOKEN = '1148858736:AAGu8ufE0G_3PHFuumW_zxQ3RZk7A_kGTqk' # your bot token - see https://core.telegram.org/bots#6-botfather for more information | |
TGBOT_RECEPIENT = '70796122' # talk to @RawDataBot on telegram to get your id | |
TGBOT_WARN_LAST_LOG_UPDATE = 900 # last log-update time delta (in second) - bot will listen to FAHClient log message, on rare occasion FAHClient can be "not responding", bot will send you a message when no log update in this time limit | |
# if one of your machine longest TPF (Time per Frame) higher than TGBOT_WARN_LAST_LOG_UPDATE you may get false-alarm-message | |
# lets say, you have machine with smallest 16 mins TPF, when your machine folding, log will update when 1 Frame complete (that mean every 960 secs - higher than 900 secs defined limit) | |
# newer hardware will have TPF below 5 mins (300 secs), but in case you have very old crap of hardware... feel free to set it to 120 or even 60 if you have badass hw | |
CONFIG_FAH = 'fahclient' | |
CONFIG_MACHINE = 'machine' | |
CONFIG_BOT = 'bot' | |
CONFIG_TGBOT = 'tgbot' | |
def __init__ (self) : | |
self.config = Config.load_config() | |
def is_loaded (self) : | |
return True if self.config is not None else False | |
def get_bot_config (self, bot_config) : | |
return self.config[Config.CONFIG_BOT][bot_config] | |
def save_config (self) : | |
try : | |
config_path = os.path.dirname(__file__) + '/config.pyon'; | |
config = open(config_path, 'w+') | |
config.write(str(self.config)) | |
return True | |
except : | |
return False | |
finally : | |
config.close() | |
@staticmethod | |
def load_config () : | |
try : | |
config_path = os.path.dirname(__file__) + '/config.pyon'; | |
if not os.path.isfile(config_path) : | |
config = open(config_path, 'w+') | |
config.write(str(Config.get_default_config())) | |
config = open(config_path, 'r') | |
config_content = '' | |
for line in config : | |
config_content = config_content + line | |
if len(config_content) > 0 : | |
return Bold.pyon_eval(config_content) | |
return None | |
except : | |
return None | |
finally : | |
config.close() | |
@staticmethod | |
def get_default_config () : | |
default_config = {} | |
default_config[Config.CONFIG_FAH] = {'user': None, 'passkey': None, 'team': "0"} | |
default_config[Config.CONFIG_MACHINE] = [{'hostname': 'localhost', 'address': 'localhost', 'port': 36330}] | |
default_config[Config.CONFIG_BOT] = {'display-auto-refresh': 10, 'last-log-warn': 900, 'next-attempt-limit': 900, 'socket-next-try-delay': 5, 'socket-refresh-rate': 5.0} | |
default_config[Config.CONFIG_TGBOT] = {'token': None, 'recepient': None} | |
return default_config | |
""" Bold - Bot for fOLDing - if you can think better name, just tell me, I open for suggestion! | |
1 object will handle 1 host, if you have 32 host (you're awesome!) you will need 32 object from Bold class.. | |
""" | |
class Bold : | |
def __init__ (self, machine, display_manager) : | |
# storing parameter in class variable | |
self.machine, self.display_manager = machine, display_manager | |
self.is_alive = True # flag to terminate | |
self.is_listening = True # flag to reborn | |
self.options = None | |
self.worker_slot = {} # FAHClient folding slot (FS), we just call it worker.. because its work on work unit! | |
self.log_updates = {} # log update/other info cache - many information can only be found on log (mainly triggered by "log-updates start" command) | |
self.log_updates['last_update'] = datetime.now() # record first run-time | |
self.make_report({}, 'CONNECT') | |
# socket setting | |
self.client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # define socket - hint AF is NOT for As F*ck | |
self.client_socket.settimeout(Config.SOCKET_REFRESH_RATE) # define socket timeout | |
self.client_socket.connect((self.machine['address'], self.machine['port'])) # connecting to host - good luck! | |
self.start() | |
# understand socket better https://docs.python.org/3/library/socket.html | |
""" Get machine name - thats obvious | |
""" | |
def get_machine_name (self) : | |
return self.machine['hostname'] | |
def get_options (self) : | |
return self.options | |
""" Starting bot | |
""" | |
def start (self) : | |
self.listen_for_message() # listen for welcome message, you MUST getting welcome message (just to make sure) | |
self.send_message('info') # getting host folding info - well we're looking for CPU info! | |
self.send_message('slot-info') # getting slot-info | |
self.send_message('log-updates start') # telling FAHClient to send update log - so many info here! | |
threading.Thread(target=self.listen_thread, daemon=True).start() # bot will run on thread-mill | |
""" Set cache | |
""" | |
def set_cache (self, wuid, fsid, cache_id, cache_value) : | |
self.log_updates[wuid + '-' + fsid + '-' + cache_id] = cache_value | |
""" Get cache value, if any | |
""" | |
def get_cache (self, wuid, fsid, cache_id, fallback_value) : | |
if wuid + '-' + fsid + '-' + cache_id in self.log_updates : # check if cache exist | |
return self.log_updates[wuid + '-' + fsid + '-' + cache_id] | |
return fallback_value # return fallback value | |
""" Making report to display manager | |
writing draw cache to display manager, it also updating bot status without spaming user (does NOT trigger Tgbot.send_message) | |
""" | |
def make_report (self, queue_info, status) : | |
self.display_manager.set_draw_cache(self.get_machine_name(), | |
{'queue': queue_info, 'last_update': self.log_updates['last_update'], 'ppd': self.log_updates['ppd'] if 'ppd' in self.log_updates else '0', 'status': status}) | |
""" Die and born again as a new object | |
wow, I never though to write this... | |
""" | |
def reborn (self) : | |
self.display_manager.update_status(self.get_machine_name(), 'OFFLINE') # telling user we gonna die (it will trigger Tgbot.send_message) | |
self.is_listening = False # stopping listen_thread looping | |
# nope, you can NOT make an Object inside yourself.. by yourself.., it seem python does NOT allow Kage Bunshin | |
# Bold(self.machine, self.display_manager) | |
if self.is_alive : | |
self.display_manager.summon_bot(self.machine) # want to live forever? TALK TO YOUR MANAGER! | |
def rip (self) : | |
self.is_alive = False | |
self.send_message('exit') | |
""" Sending command to FAHClient | |
""" | |
def send_message (self, message) : | |
if self.client_socket and self.is_listening : # if socket alive | |
message = message + '\n' # append newline so FAHClient can execute command | |
self.client_socket.sendall(message.encode()) # sending all command (in bytes - .encode() will convert str to bytes) to server (handled by sendall function) | |
""" Start to listen (wait) response from FAHClient | |
This function also handle when something wrong happen with socket (like connection lost) | |
""" | |
def listen_thread (self) : | |
# try and catch (python call it except.. but I still call it catch) | |
try : | |
while self.is_listening : # always listening, always understanding... until it doesn't... | |
self.listen_for_message() # expect exception on this... | |
except : # when exception happen (ex. connection lost) we ready! | |
self.reborn() | |
""" Listen for incoming message (if any) | |
will open thread for waiting | |
when timeout will send queue-info command | |
""" | |
def listen_for_message (self) : | |
try : | |
if self.client_socket and self.is_listening : | |
data_buffer = b'' # buffering data in bytes/binary | |
while True : # keep receiving data until there is no more | |
data_part = self.client_socket.recv(1024) # receiving data with 1024 byte buffer size - why 1024? - just change if you know a better number (dont forget to change another 1024) | |
data_buffer += data_part # collection data to buffer | |
if len(data_part) < 1024 : # check if we reach 0 bytes of end of data | |
break # take a break, take a kitkat (stopping while statement) | |
incoming_data = data_buffer.decode() # converting bytes to str | |
threading.Thread(target=self.on_message_received, kwargs={'incoming_data': incoming_data}, daemon=True).start() # open thread to handle incoming message | |
except socket.timeout : # not getting response in defined time-frame (SOCKET_REFRESH_RATE) | |
self.send_message('queue-info') # fire queue-info command! | |
except : # this will NEVER gonna happen, exception will happen on this function caller (listen_thread) | |
self.reborn() | |
finally : | |
pass # reserved line to remember - there is something missing here.. | |
""" Message / FAHClient response handler | |
when there is response from FAHClient, this function will be fired! - or hired? | |
""" | |
def on_message_received (self, incoming_data) : | |
if not incoming_data : # we does NOT received anything, WTF! | |
pass # What a Terrible Failure | |
else : # something is coming... Yay! | |
message = str(incoming_data) # converting incoming data to string | |
if (message.find('Welcome') > -1) : # welcome message | |
self.send_message('ppd') # getting point-per-day | |
else : | |
# more info and explanation at https://github.com/FoldingAtHome/fah-control/wiki/3rd-party-FAHClient-API#detecting-pyon-messages | |
# If I read it correctly | |
# response from FAHClient start with "\nPyON " and then followed by newline and message-content, and end with "\n---\n" | |
# note there are PyON Version... you know whats version mean? Yeah, another headache on the future release... | |
pyon_content = message[message.find('\nPyON ') + 6 : message.find('\n---\n')] # extract PyON content | |
# PyON content will be "<PyOn Version> <message_type>\n<message>" | |
pyon_version = pyon_content[:pyon_content.find(' ')] # dunno what todo with version, just leave it here... maybe you'll need it in future | |
pyon_content = pyon_content[pyon_content.find(' ') + 1:] | |
message_type = pyon_content[:pyon_content.find('\n')] # extract message-type | |
message = pyon_content[pyon_content.find('\n') + 1 :] # extract message-content | |
# now we can handle response by type | |
if message_type == 'ppd' : # point-per-day, message just plain number string | |
self.log_updates['ppd'] = message # write to cache | |
elif message_type == 'units' : # queue-info, message is array of dictionary (format in PyON - Python Object Notation) | |
# we need to make sure worker_slot already loaded | |
if len(self.worker_slot) == 0 : # if slot-info not loaded - not likely happen - we already do it on main block, do we? | |
self.send_message('slot-info') # fire in a hole! | |
self.send_message('queue-info') # repeat queue-info command | |
else : | |
pyon = Bold.pyon_eval(message) # eval-uate PyON | |
# iterate queue | |
queue_info = [] # store variable | |
for queue in pyon : | |
slot_id = queue['slot'] # folding_slot_id/worker_id | |
# check if queue have a "registered" slot (sometimes slot can be deleted, FAHClient automatically dump it) | |
if slot_id in self.worker_slot : | |
work_id = queue['id'] # work_unit_id | |
last_update = self.log_updates['last_update'] | |
delta = datetime.now() - last_update | |
delta_secs = int(delta.total_seconds()) | |
if delta_secs > Config.TGBOT_WARN_LAST_LOG_UPDATE : | |
self.display_manager.update_status(self.get_machine_name(), 'TIMEOUT') | |
self.log_updates['last_update'] = datetime.now() # updating "last update" - we dont want to spam you! | |
queue_state = queue['state'] # working state - you will stare at this and ask: oh really? | |
# description is looks like cpu:4 or gpu:0:your_gpu_really_cool_name | |
slot_info = self.worker_slot[slot_id]['description'].split(':', 3) # split it, max 3 part! | |
if len(slot_info) > 2 : # if there is 3 part (GPU) | |
slot_description = slot_info[2][:33] # extract GPU description | |
elif 'cpu-info' in self.log_updates : # if there is CPU name in cache | |
slot_description = self.log_updates['cpu-info'] # extract cpu name from cache | |
else : | |
slot_description = platform.processor()[:33] # just display the best python can offer! do you have better offer? | |
project_details = str(queue['run']) + '-' + str(queue['clone']) + '-' + str(queue['gen']) | |
slot_status = self.worker_slot[slot_id]['status'] # folding slot status | |
if slot_status == 'PAUSED' : # slot is paused! | |
self.send_message('unpause ' + slot_id) # unpause! release the power! | |
if queue_state == 'DOWNLOAD' : # this is the most ambiguous state, DOWNLOAD can mean DOWNLOADING/WAITING or CONNECTING for download - yeah that suck! | |
if len(queue['waitingon']) > 0 : # explanation why we must WAIT! | |
if queue['waitingon'] == 'Unit Download' : # sometime it say Unit Download, sometimes not... dunno, dont ask... | |
queue_state = 'LOADING' | |
progress = self.get_cache(work_id, slot_id, 'download', 'don\'t ask') | |
eta = self.get_cache(work_id, slot_id, 'download-eta', 'unknown') | |
else : | |
queue_state = 'WAITING' # I know its a bitter truth, why are we WAITING? what are we WAITING for? | |
progress = str(queue['attempts']) + ' times' # to make it worse, also display how many we are failed... | |
eta = queue['nextattempt'] # the good news is we not wait FOREVER! | |
# are we WAITING too long? | |
secs = Bold.get_a_second_value(eta) | |
# ONLY pause when there are NO RUNNING folding - SEND (upload) process will keep running when PAUSED | |
# remember next-unit-percentage can be between 90-100 | |
# so our client might be download-and-fail-then-wait meanwhile still folding - just let remaining wu finished! do NOT pause! | |
if secs > Config.NEXT_ATTEMPT_LIMIT and slot_status == 'READY' : | |
self.send_message('pause ' + slot_id) | |
else : # there is nothing to wait... | |
progress = self.get_cache(work_id, slot_id, 'download', 'unknown') | |
if progress == 'unknown' : | |
queue_state = 'CONNECT' | |
eta = self.get_cache(work_id, slot_id, 'download-eta', 'unknown') | |
elif queue_state == 'SEND' : # sending / upload wu | |
if len(queue['waitingon']) > 0 : # explanation why we must WAIT! | |
if queue['waitingon'] == 'Send Results' : # it say Send Results when it trying to connect... maybe... | |
queue_state = 'COMPLETE' | |
else : | |
pass | |
else : | |
queue_state = 'UPLOAD' # why they say SEND? not UPLOAD? if they DOWNLOAD, so they MUST UPLOAD | |
self.send_message('slot-info') | |
progress = self.get_cache(work_id, slot_id, 'upload', 'unknown') | |
eta = self.get_cache(work_id, slot_id, 'upload-eta', 'unknown') | |
else : | |
progress = queue['percentdone'] | |
eta = queue['eta'] | |
project_id = str(queue['project']) | |
if project_id != "0" and project_id in self.display_manager.project_cause and self.display_manager.project_cause[project_id] != None : | |
cause = self.display_manager.project_cause[project_id] | |
else : | |
threading.Thread(target=self.display_manager.load_project_cause, kwargs={'project_id': project_id}, daemon=True).start() | |
cause = 'loading...' | |
queue_info.append({'SLID': slot_id + '/' + work_id, | |
'TYPE': slot_info[0].upper(), | |
'THR': slot_info[1], | |
'STATUS': slot_status, | |
'FOLDING_DEVICE_DESCRIPTION': slot_description, | |
'PROJECT': project_id + ' ' + queue['core'], | |
'DETAILS': project_details, | |
'CREDIT': Bold.get_point_value(queue['creditestimate']), | |
'PPD': Bold.get_point_value(queue['ppd']), | |
'BASE': Bold.get_point_value(queue['basecredit']), | |
'ERROR': queue['error'], | |
'WORK_SERVER': queue['ws'], | |
'COLLECTION': queue['cs'], | |
'STATE': queue_state, | |
'PROGRESS': progress, | |
'TPF': str(queue['tpf']), | |
'WAITING_ON': queue['waitingon'], | |
'WORK_UNIT_ID': queue['unit'][2:], | |
'CAUSE': cause, | |
'TIME_LIMIT': queue['timeremaining'], | |
'ETA/NEXT_ATTEMPT': eta}) | |
elif queue['error'] == 'DUMPED' : # "dumped" slot, let it be, FAHClient will delete it automatically | |
pass | |
else : # "unregistered" slot? | |
self.send_message('slot-info') | |
self.make_report(queue_info, 'ONLINE') | |
elif message_type == 'info' : # info, message is array of array of array of something (format in PyON - Python Object Notation) | |
# this is a little bit trickery, because so many info here - many times, only partial data is received, thus we can NOT just eval it! | |
cpu_idx = message.find('"CPU", "') | |
if cpu_idx > -1 : | |
cpu_idx += 8 | |
cpu_info = message[cpu_idx:message.find('"],', cpu_idx)] | |
self.log_updates['cpu-info'] = cpu_info[-33:] # getting the last part of info (because it's the important one) | |
elif message_type == 'slots' : # slot-info, message is array of dictionary (format in PyON - Python Object Notation) | |
self.send_message('options -a') # getting options | |
pyon = Bold.pyon_eval(message) # eval-uate PyON | |
for slot in pyon : # iterate worker slot | |
self.worker_slot[slot['id']] = {'description': slot['description'], 'status': slot['status']} # save it in worker_slot variable | |
elif message_type == 'log-update' and len(message) < 100 : # ignore long log-update message | |
self.send_message('ppd') | |
if message.find(':Download ') > -1 : # download progress update | |
wuid, fsid, dlprogress = Bold.parse_log_update(message, 'Download') | |
last_report = self.get_cache(wuid, fsid, 'download-report', datetime.now()) | |
new_report = datetime.now() | |
delta_report = new_report - last_report | |
delta_secs = float(delta_report.total_seconds()) | |
self.set_cache(wuid, fsid, 'download-report', new_report) | |
last_progress = float(self.get_cache(wuid, fsid, 'download', '0%').strip('%')) | |
new_progress = float(dlprogress.strip('%')) | |
increment = new_progress - last_progress | |
if delta_secs > 0 and increment > 0 : | |
tpp = delta_secs / increment # time per percentage - time required to get 1% download | |
eta_secs = int((100.0 - new_progress) * tpp) | |
self.set_cache(wuid, fsid, 'download-eta', Bold.get_time_format(eta_secs)) | |
self.set_cache(wuid, fsid, 'download', dlprogress) | |
elif message.find(':Upload ') > -1 : # download progress update | |
wuid, fsid, ulprogress = Bold.parse_log_update(message, 'Upload') | |
last_report = self.get_cache(wuid, fsid, 'upload-report', datetime.now()) | |
new_report = datetime.now() | |
delta_report = new_report - last_report | |
delta_secs = float(delta_report.total_seconds()) | |
self.set_cache(wuid, fsid, 'upload-report', new_report) | |
last_progress = float(self.get_cache(wuid, fsid, 'upload', '0%').strip('%')) | |
new_progress = float(ulprogress.strip('%')) | |
increment = new_progress - last_progress | |
if delta_secs > 0 and increment > 0 : | |
tpp = delta_secs / increment # time per percentage - time required to get 1% download | |
eta_secs = int((100.0 - new_progress) * tpp) | |
self.set_cache(wuid, fsid, 'upload-eta', Bold.get_time_format(eta_secs)) | |
self.set_cache(wuid, fsid, 'upload', ulprogress) | |
elif message.find(':Completed ') > -1 : # progress update | |
wuid, fsid, frprogress = Bold.parse_log_update(message, 'Completed') | |
elif message.find(':FahCore ') > -1 : # core download | |
core_name = message[message.find(':FahCore ') : message.find(': ')] | |
wuid, fsid, coreProgress = Bold.parse_log_update(message, 'FahCore ' + core_name + ':') | |
last_report = self.get_cache(wuid, fsid, 'upload-report', datetime.now()) | |
new_report = datetime.now() | |
delta_report = new_report - last_report | |
delta_secs = float(delta_report.total_seconds()) | |
self.set_cache(wuid, fsid, 'upload-report', new_report) | |
last_progress = float(self.get_cache(wuid, fsid, 'upload', '0%').strip('%')) | |
new_progress = float(coreProgress.strip('%')) | |
increment = new_progress - last_progress | |
if delta_secs > 0 and increment > 0 : | |
tpp = delta_secs / increment # time per percentage - time required to get 1% download | |
eta_secs = int((100.0 - new_progress) * tpp) | |
self.set_cache(wuid, fsid, 'upload-eta', Bold.get_time_format(eta_secs)) | |
self.set_cache(wuid, fsid, 'core_update', coreProgress) | |
self.log_updates['last_update'] = datetime.now() # setting last_update flag | |
elif message_type == 'options' : # options -a | |
self.options = Bold.pyon_eval(message) # eval-uate PyON | |
if self.options is None : | |
self.send_message('options -a') | |
else : # unknown message - maybe there is change into slot-info? | |
self.send_message('slot-info') | |
""" Closing socket | |
""" | |
def close_socket (self) : | |
if self.client_socket is not None : | |
self.client_socket.close() | |
""" Safe-evaluate pyon string | |
""" | |
@staticmethod | |
def pyon_eval (pyon_string) : | |
try : | |
return eval(pyon_string, {}, {}) | |
except : | |
return None | |
""" | |
pyon_string = pyon_string.strip() # removing whitespace, if any | |
if len(pyon_string) > 0 : | |
if pyon_string[0] == '[' and pyon_string[len(pyon_string) - 1] == ']' : # its an array | |
array_string = pyon_string[1:len(pyon_string) - 2] | |
elif pyon_string[0] == '{' and pyon_string[len(pyon_string) - 1] == '}' : # its an dictionary | |
dictionary_string = pyon_string[1:len(pyon_string) - 2] | |
else : # just a string, maybe... | |
return pyon_string | |
else : | |
return None | |
""" | |
""" parsing log | |
download wu => "10:02:51:WU00:FS01:Download 67.75%\n" | |
"10:02:51:WU00:FS01:Download complete\n" | |
frame update => "10:14:49:WU00:FS01:0x22:Completed 20000 out of 1000000 steps (2%)\n" | |
core update => "09:39:05:WU00:FS00:FahCore a7: 1.92%" | |
""" | |
@staticmethod | |
def parse_log_update (log_update, log_type) : | |
fs_idx = log_update.find(':FS') | |
wuid = log_update[log_update.find(':WU') + 3 : fs_idx] # this is queue_id | |
fsid = log_update[fs_idx + 3 : log_update.find(':', fs_idx + 1)] # this is slot_id | |
update_content = log_update[log_update.find(':' + log_type + ' ') + len(log_type) + 2 : log_update.find('\\n')] | |
if update_content == 'complete' : # well, we need to do math here... I think python does NOT understand "complete" | |
update_content = '100%' | |
return wuid, fsid, update_content | |
""" Converting days, hours, mins, secs string to second (int) | |
""" | |
@staticmethod | |
def get_a_second_value (value) : | |
second_value = 0 | |
raw = value.split(' ') | |
for rawIdx in range (0, len(raw), 2) : | |
if raw[rawIdx + 1] == 'days' : | |
second_value += int(raw[rawIdx]) * 86400 # 24 hours * 60 mins * 60 secs | |
elif raw[rawIdx + 1] == 'hours' : | |
second_value += int(raw[rawIdx]) * 3600 # 60 mins * 60 secs | |
elif raw[rawIdx + 1] == 'mins' : | |
second_value += int(raw[rawIdx]) * 60 | |
elif raw[rawIdx + 1] == 'secs' : | |
second_value += int(float(raw[rawIdx])) * 1 | |
return second_value | |
@staticmethod | |
def get_time_format (secs_value) : | |
if secs_value >= 86400 : | |
days = secs_value // 86400 | |
secs_value = secs_value % 86400 | |
if secs_value > 3600 : | |
hours = secs_value // 3600 | |
return str(days) + ' days ' + str(hours) + ' hours' | |
else : | |
return str(days) + ' days' | |
elif secs_value >= 3600 : | |
hours = secs_value // 3600 | |
secs_value = secs_value % 3600 | |
if secs_value > 60 : | |
mins = secs_value // 60 | |
return str(hours) + ' hours ' + str(mins) + ' mins' | |
else : | |
return str(hours) + ' hours' | |
elif secs_value > 60 : | |
mins = secs_value // 60 | |
secs_value = secs_value % 60 | |
return str(mins) + ' mins ' + str(secs_value) + ' secs' | |
return str(secs_value) + ' secs' | |
""" Convert int-string to game-style-point format | |
return max 3 decimal point | |
""" | |
@staticmethod | |
def get_point_value (value) : | |
int_val = int(float(value)) # convert string to int - for some reason float-string like 12.3456 cannot directly converted to int, so we need to make a float first! | |
# do we need Q for QUADRO / QUANTUM? | |
if int_val > 1000000000000 : # that's 12 zero | |
return f'{(int_val // 1000000000) / 1000 :.3f}' + 'T' # what are you? a TITAN? - wOOOOOOOOOOOOw I give you.... 12 'O' - trust me, it 12 | |
elif int_val > 1000000000 : | |
return f'{(int_val // 1000000) / 1000 :.3f}' + 'B' # the BIG BEAST BEAUTY BADASS is here! | |
elif int_val > 1000000 : | |
return f'{(int_val // 1000) / 1000 :.3f}' + 'M' # M for MASTER / MONSTER / MYTHICAL | |
elif int_val > 1000 : | |
return f'{int_val / 1000 :.3f}' + 'K' # K for it's OKAY | |
else : | |
return str(int_val) # well, all fold, is a good fold | |
""" Countdown class | |
Class that manage thread for countdown timer - yeah, another class just to manage countdown... you have better suggestion? | |
so python - yes, its a dangerous snake, dont mess with them - does NOT allow us to KILL thread! Thats a CRIME! | |
Thread can RIP when they want to - or at least when thread set to daemon, python itself will kill thread when the main program exit | |
""" | |
class Countdown : | |
def __init__ (self, display_manager, update_delay, countdown_timer) : | |
self.display_manager, self.countdown_timer = display_manager, countdown_timer | |
self.is_alive = True # alive flag | |
threading.Thread(target=self.show_countdown, kwargs={'update_delay': update_delay, 'countdown_timer': countdown_timer}, daemon=True).start() # start thread as a daemon | |
""" Showing countdown | |
this function SHOULD BE run in thread | |
""" | |
def show_countdown (self, update_delay, countdown_timer) : | |
if self.is_alive : | |
if countdown_timer > 0 : | |
# as long as thread alive they work - sleep - repeat, what a simple life, I hope they happy | |
self.display_manager.redraw_countdown(countdown_timer) # work, work, work! | |
time.sleep(update_delay) # so sleepy... better to take a nap... | |
self.show_countdown(update_delay, countdown_timer - update_delay) # repeat | |
else : | |
self.display_manager.redraw() | |
# we can recursively call show_countdown, but python have a limit for recursive calling | |
# yes my friend, there are limit for everything! | |
#self.show_countdown(update_delay, self.countdown_timer) | |
else : | |
pass # thread is RIP-ing! | |
""" Stop thread | |
set thread flag, so thread can voluntarily RIP | |
whoever called this is called the "threadrip-er" | |
""" | |
def rip (self) : | |
self.is_alive = False # Hey thread, you can Rest in peace now, thanks... | |
""" Display class | |
Display manager to manage terminal display | |
""" | |
class Display : | |
# DISPLAY_SIZE | |
# you need some calculation if want to change this... | |
# for width you will need to calculate ROW width (see ROW1, ROW2, and ROW3) | |
# for height (if you have NOT change anything) we need 6 rows for header, 4 rows for footer, and 4 row (+1 row for every machine) for every folding slot | |
# so if we want to displaying 4 folding slot at a time, we need MIN_HEIGHT 6 + 4 + (5 * 4) = 30 (with assumption one slot in one machine) | |
DS_MIN_WIDTH = 120 | |
DS_MIN_HEIGHT = 30 | |
# Display Filling Char | |
DFC_SELECTED = ['> ', ' <', '..', '='] | |
DFC_UNSELECTED = [' ', ' ', '. ', '-'] | |
# Display screen name | |
SCREEN_QUEUE = 0 | |
SCREEN_MACHINE = 1 | |
# FAHClient option | |
FAH_OPTIONS_USER = ['user', 'team', 'passkey'] | |
FAH_OPTIONS_FOLDING = ['cause', 'client-type', 'next-unit-percentage', 'cpu-usage', 'gpu-usage'] | |
FAH_OPTIONS_CONNECT = ['command-port', 'command-address', 'command-allow-no-pass'] | |
# alignment flag | |
ALIGNMENT_LEFT = 1 | |
ALIGNMENT_RIGHT = 2 | |
ALIGNMENT_CENTER = 3 | |
NEWLINE = '\n' | |
NONE = '' | |
# display rules | |
# label MUST MATCH key from queue-info handler | |
# minimal length is length-of-label + 1 (for empty-space) | |
# alignment is content aligment - header aligment is always ALIGNMENT_LEFT | |
ROW1 = [{'label': 'SLID', 'length': 6, 'alignment': ALIGNMENT_LEFT}, | |
{'label': 'TYPE', 'length': 5, 'alignment': ALIGNMENT_LEFT}, | |
{'label': 'THR', 'length': 4, 'alignment': ALIGNMENT_RIGHT}, | |
{'label': 'STATUS', 'length': 10, 'alignment': ALIGNMENT_LEFT}, | |
{'label': 'PPD', 'length': 9, 'alignment': ALIGNMENT_RIGHT}, | |
{'label': 'PROJECT', 'length': 12, 'alignment': ALIGNMENT_LEFT}, | |
{'label': 'WORK_SERVER', 'length': 16, 'alignment': ALIGNMENT_CENTER}, | |
{'label': 'CREDIT', 'length': 9, 'alignment': ALIGNMENT_RIGHT}, | |
{'label': 'STATE', 'length': 11, 'alignment': ALIGNMENT_LEFT}, | |
{'label': 'PROGRESS', 'length': 17, 'alignment': ALIGNMENT_RIGHT}, | |
{'label': 'ETA/NEXT_ATTEMPT', 'length': 17, 'alignment': ALIGNMENT_RIGHT}] | |
ROW2 = [{'label': 'WORK_UNIT_ID', 'length': 34, 'alignment': ALIGNMENT_LEFT}, | |
{'label': 'DETAILS', 'length': 12, 'alignment': ALIGNMENT_LEFT}, | |
{'label': 'COLLECTION', 'length': 16, 'alignment': ALIGNMENT_CENTER}, | |
{'label': 'BASE', 'length': 9, 'alignment': ALIGNMENT_RIGHT}, | |
{'label': 'ERROR', 'length': 11, 'alignment': ALIGNMENT_LEFT}, | |
{'label': 'TPF', 'length': 17, 'alignment': ALIGNMENT_RIGHT}, | |
{'label': 'WAITING_ON', 'length': 17, 'alignment': ALIGNMENT_RIGHT}] | |
ROW3 = [{'label': 'FOLDING_DEVICE_DESCRIPTION', 'length': 34, 'alignment': ALIGNMENT_LEFT}, | |
{'label': 'CAUSE', 'length': 65, 'alignment': ALIGNMENT_LEFT}, | |
{'label': 'TIME_LIMIT', 'length': 17, 'alignment': ALIGNMENT_RIGHT}] | |
def __init__ (self) : | |
self.display_width, self.display_height = Display.get_terminal_size() | |
self.is_alive = True | |
self.visible_slot_index = 1 | |
self.bot = {} | |
self.project_cause = {} # loaded from https://stats.foldingathome.org/project?p=<project_id> | |
self.display_index = {} | |
self.countdown_object = None | |
self.draw_cache = OrderedDict() | |
self.screen = [] | |
self.screen.append(Display.SCREEN_QUEUE) | |
self.display_index[Display.SCREEN_QUEUE] = {'machine': 0, 'slot': 0, 'start': 0, 'selected': 0} | |
""" setting drawing cache by bot | |
""" | |
def set_draw_cache (self, machine_name, data) : | |
self.draw_cache[machine_name] = data | |
def get_machine_selected_key (self) : | |
caches = list(self.draw_cache.items()) | |
return caches[self.display_index[Display.SCREEN_QUEUE]['machine']][0] | |
def get_vfs_count (self) : # visible folding slot | |
return (self.display_height - 10) // 5 | |
def get_absolute_selection_index (self) : | |
index = 0 | |
selected_machine_name = self.get_machine_selected_key() | |
for machine_name in self.draw_cache : | |
if machine_name == selected_machine_name : | |
index += self.display_index[Display.SCREEN_QUEUE]['slot'] | |
break | |
else : | |
slot_count = len(self.draw_cache[machine_name]['queue']) | |
index += slot_count if slot_count > 0 else 1 | |
return index | |
""" Updating machine status | |
will trigger Tgbot.send_message() | |
""" | |
def update_status (self, machine_name, new_status) : | |
if (self.draw_cache[machine_name] is not None) and (self.draw_cache[machine_name]['status'] != new_status) : | |
self.draw_cache[machine_name]['status'] = new_status | |
Tgbot.send_message(machine_name + ' goes ' + new_status) | |
""" Starting redraw() auto-refresh | |
""" | |
def start_countdown (self) : | |
self.stop_countdown() | |
self.countdown_object = Countdown(self, 1, Config.DISPLAY_AUTO_REFRESH) # starting countdown (refresh/update every 5 second - countdown every 1 second) | |
""" Stopping redraw() auto-refresh | |
Updating by bot still happen in the background, it just not displayed to user... | |
""" | |
def stop_countdown (self) : | |
if self.countdown_object is not None : | |
self.countdown_object.rip() # the threadrip-er | |
self.redraw_countdown(None) # using None will display "PAUSED" instead of remaining second | |
self.countdown_object = None | |
""" Re-draw / refresh content | |
will overwrite (clearing) current content | |
will start_countdown() | |
""" | |
def redraw (self) : | |
Display.clear_display() # clear screen / terminal / display | |
self.display_width, self.display_height = Display.get_terminal_size() # get screen / terminal / display size | |
# print top border and terminal window size | |
self.draw_textline('=== FOLDING ANYWHERE HUMAN ', '=', self.display_width - 35, Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline(' ' + str(self.display_width) + 'x' + str(self.display_height) + ' ==== bafo.ah@gmail.com =', '=', 35, Display.ALIGNMENT_RIGHT, Display.NEWLINE) | |
self.draw_textline('- SERVER/MACHINE TIME ', '-', 30, Display.ALIGNMENT_LEFT, Display.NONE) # print host/machine name | |
self.draw_textline(' ' + datetime.utcnow().strftime('%H:%M:%S') + ' / ' + datetime.now().strftime('%H:%M:%S') + ' -', '--', self.display_width - 30, Display.ALIGNMENT_RIGHT, Display.NEWLINE) # print last log time | |
if self.display_width < 120 or self.display_height < 30 : | |
print('your terminal window is to small, please resize to at least 120x30') | |
else : | |
max_vfs_count = self.get_vfs_count() # maximum visible folding slot count | |
active_screen = self.screen[len(self.screen) - 1] | |
if active_screen == Display.SCREEN_QUEUE : | |
# print header row (6 row) | |
self.draw_textline('- ', '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
for table_column in Display.ROW1 : | |
self.draw_textline(table_column['label'] + ' ', ' ', table_column['length'], Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline(' -', '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline('\n', '', 0, Display.ALIGNMENT_LEFT, Display.NEWLINE) | |
# 2nd row | |
self.draw_textline('- ', '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
for table_column in Display.ROW2 : | |
self.draw_textline(table_column['label'] + ' ', ' ', table_column['length'], Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline(' -', '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
# 3rd row | |
self.draw_textline('- ', '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
for table_column in Display.ROW3 : | |
self.draw_textline(table_column['label'] + ' ', ' ', table_column['length'], Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline(' -', '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline('-', '-', self.display_width, Display.ALIGNMENT_LEFT, Display.NEWLINE) | |
total_ppd = online_count = machine_count = gpu_count = thread_count = 0 | |
qv_idx = 0 # queue-visible-index | |
selected = self.get_machine_selected_key() | |
# check if selection slot still exist | |
if self.display_index[Display.SCREEN_QUEUE]['slot'] > len(self.draw_cache[selected]['queue']) - 1 : | |
self.display_index[Display.SCREEN_QUEUE]['slot'] = 0 | |
self.display_index[Display.SCREEN_QUEUE]['selected'] = self.get_absolute_selection_index() | |
start_drift = self.display_index[Display.SCREEN_QUEUE]['start'] - self.display_index[Display.SCREEN_QUEUE]['selected'] | |
if start_drift > 0 : | |
self.display_index[Display.SCREEN_QUEUE]['start'] -= start_drift | |
else : | |
start_drift = ((self.display_index[Display.SCREEN_QUEUE]['selected'] - self.display_index[Display.SCREEN_QUEUE]['start']) + 1) - self.get_vfs_count() | |
if start_drift > 0 : | |
self.display_index[Display.SCREEN_QUEUE]['start'] += start_drift | |
# print queue-item (max 4 folding slot, up to 20 rows) | |
# ONLY print "visible" slot | |
# because we print so many information (even unnecessary one) so maybe not all slot is fit! | |
for key in self.draw_cache : | |
last_update = self.draw_cache[key]['last_update'] | |
machine_ppd = self.draw_cache[key]['ppd'] | |
if self.draw_cache[key]['status'] != 'CONNECT' : | |
if key not in self.bot : | |
status = 'LOADING' # loading options | |
elif (self.bot[key].get_options() == None and self.draw_cache[key]['status'] == 'ONLINE') : | |
self.bot[key].send_message('options -a') # re-request if something wrong happen... or just too long... | |
status = 'LOADING' # loading options | |
else : | |
status = self.draw_cache[key]['status'] | |
else : | |
status = 'CONNECT' | |
is_slot_visible = qv_idx + 1 > self.display_index[Display.SCREEN_QUEUE]['start'] and qv_idx < max_vfs_count + self.display_index[Display.SCREEN_QUEUE]['start'] # "visible" area boundary | |
filling_char = Display.DFC_SELECTED if key == selected else Display.DFC_UNSELECTED | |
# MACHINE HEADER | |
if is_slot_visible : # print if slot in "visible" area | |
self.draw_textline(filling_char[0] + key + ' ', '>', 16, Display.ALIGNMENT_LEFT, Display.NONE) # print host/machine name | |
self.draw_textline(' ' + status + ' ', '<', 10, Display.ALIGNMENT_LEFT, Display.NONE) # print machine status CONNECT/LOADING/ONLINE/OFFLINE | |
self.draw_textline(' ' + Bold.get_point_value(machine_ppd) + ' ', '<', 10, Display.ALIGNMENT_RIGHT, Display.NONE) # print ppd | |
self.draw_textline(' ' + last_update.strftime('%H:%M:%S') + filling_char[1], filling_char[3], self.display_width - 36, Display.ALIGNMENT_RIGHT, Display.NEWLINE) # print last log-updates time | |
# SLOT INFO | |
qr_idx = 0 # queue-relative-index | |
if len(self.draw_cache[key]['queue']) == 0 : # machine doesn't have queue, still count as 1 queue | |
qv_idx += 1 # queue-visible-index | |
qsi = None # queue-slot-id | |
for queue_item in self.draw_cache[key]['queue'] : | |
if is_slot_visible : # print if slot in "visible" area | |
filling_char = Display.DFC_SELECTED if key == selected and self.display_index[Display.SCREEN_QUEUE]['slot'] == qr_idx else Display.DFC_UNSELECTED | |
self.add_row(queue_item, filling_char) | |
if queue_item['SLID'][:2] != qsi : # check if slot_id like previous slot (1 slot can have up to 3 queue - upload, download, and running - and more if there is server-error) to prevent "duplicate" | |
qsi = queue_item['SLID'][:2] | |
gpu_count += 1 if queue_item['TYPE'] == 'GPU' else 0 | |
thread_count += int(queue_item['THR']) if queue_item['TYPE'] == 'CPU' else 0 | |
qr_idx += 1 # queue-relative-index | |
qv_idx += 1 # queue-visible-index or can be describe as queue-absolute-index | |
total_ppd += int(float(machine_ppd)) | |
machine_count += 1 | |
online_count += 1 if self.draw_cache[key]['status'] == 'ONLINE' else 0 | |
# footer line for queue-info | |
self.draw_textline('- SLOT ' + str(self.display_index[Display.SCREEN_QUEUE]['selected'] + 1) + '/' + str(self.display_index[Display.SCREEN_QUEUE]['start'] + 1) + '-' + str(self.display_index[Display.SCREEN_QUEUE]['start'] + max_vfs_count if qv_idx > max_vfs_count else qv_idx) + '/' + str(qv_idx) + ' ', | |
'-', 20, Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline(' ' + str(online_count) + '/' + str(machine_count) + ' ONLINE - ' + str(gpu_count) + ' GPU - ' + str(thread_count) + ' THREAD - ' + Bold.get_point_value(total_ppd) + ' PPD -', | |
'-', self.display_width - 20, Display.ALIGNMENT_RIGHT, Display.NEWLINE) | |
elif active_screen == Display.SCREEN_MACHINE : | |
caches = list(self.draw_cache.items()) | |
selected = caches[self.display_index[Display.SCREEN_QUEUE]['machine']][0] | |
self.draw_textline('- ' + selected + ' ', '-', self.display_width, Display.ALIGNMENT_LEFT, Display.NEWLINE) | |
options = self.bot[selected].get_options() | |
if options['fold-anon'] == 'false' : | |
for flag_name in Display.FAH_OPTIONS_USER : | |
self.draw_textline(flag_name, ' ', 50, Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline(options[flag_name], ' ', self.display_width - 50, Display.ALIGNMENT_LEFT, Display.NEWLINE) | |
else : | |
self.draw_textline('Anony', ' ', self.display_width, Display.ALIGNMENT_LEFT, Display.NEWLINE) | |
pass | |
# print footer (4 rows) | |
self.draw_textline(' [F1] HELP | [ARROW UP/DOWN] CHOOSE | [ENTER] SELECT | [BACKSPACE] BACK | [F10] EXIT ', '#', self.display_width, Display.ALIGNMENT_CENTER, Display.NEWLINE) | |
self.draw_textline(' FOLDING ANYWHERE HUMAN ===', '=', self.display_width, Display.ALIGNMENT_RIGHT, Display.NEWLINE) | |
self.start_countdown() # start countdown | |
# left 1 empty row for blinking cursor.... | |
""" Re-draw countdown timer - overwrite footer | |
user hate when screen looks freeze (we can NOT tell if it freeze or not) so we need illusion (countdown timer) to make user believe our program still running (IT IS) | |
""" | |
def redraw_countdown (self, countdown) : | |
# \033[F is escape character - will set cursor position on previous row, first columns, so basicly its overwrite last line | |
if countdown is None : | |
self.draw_textline('\033[FPaused ', '=', 20, Display.ALIGNMENT_LEFT, Display.NONE) | |
elif countdown > 0 : | |
self.draw_textline('\033[FUpdate in ' + str(countdown) + ' ', '=', 20, Display.ALIGNMENT_LEFT, Display.NONE) | |
else : | |
self.draw_textline('\033[FUpdating ', '=', 20, Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline(' FOLDING ANYWHERE HUMAN ===', '=', self.display_width - 20, Display.ALIGNMENT_RIGHT, Display.NEWLINE) | |
""" Draw text in line? | |
yup teminal only know character, so we need somehow make our char looks like a table | |
line_text is text to display | |
line_char is char/text to fill the empty space (there is NO TAB on terminal - oh there is... but its another case...) | |
line_length is out line_text + repeated line_char length | |
this function can align text with LEFT/RIGHT/CENTER alignment | |
end_char is char to end line there is NEWLINE or NONE | |
""" | |
def draw_textline (self, line_text, line_char, line_length, alignment, end_char) : | |
if (len(line_char) == 0) : # empty string... There is no empty string python print cannot multiply zero-length-string we must do something! | |
line_char = ' ' # assign empty string become space - empty-space | |
if line_text is None : | |
display_text = '' | |
else : | |
display_text = line_text[:line_length] # deprecate text if too long, must be <= line_length | |
if alignment == Display.ALIGNMENT_LEFT : # left alignment | |
# print display_text, then repeat line_char | |
print(display_text + (line_char * int((line_length - len(display_text)) / len(line_char))), end=end_char) | |
elif alignment == Display.ALIGNMENT_RIGHT : | |
# print repeated line_char first | |
print((line_char * int((line_length - len(display_text)) / len(line_char))) + display_text, end=end_char) | |
else : | |
# calculate so line_char will fit in before and after our display_text | |
line_char_count = int(line_length / len(line_char)) - len(display_text) | |
print((line_char * int(line_char_count / 2)) + display_text + (line_char * (int(line_char_count / 2) + line_char_count % 2)), end=end_char) | |
""" Add queue-slot row | |
Follow display rules | |
""" | |
def add_row (self, row_content, filling_char) : | |
self.draw_textline(filling_char[0], '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
for table_column in Display.ROW1 : # 1st row | |
self.draw_textline(row_content[table_column['label']] + ' ', ' ', table_column['length'], table_column['alignment'], Display.NONE) | |
self.draw_textline(filling_char[1], '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline('', ' ', 0, Display.ALIGNMENT_LEFT, Display.NEWLINE) # NEWLINE separator | |
self.draw_textline(filling_char[0], '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
for table_column in Display.ROW2 : # 2nd row | |
self.draw_textline(row_content[table_column['label']] + ' ', ' ', table_column['length'], table_column['alignment'], Display.NONE) | |
self.draw_textline(filling_char[1], '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline('', ' ', 0, Display.ALIGNMENT_LEFT, Display.NEWLINE) # NEWLINE separator | |
self.draw_textline(filling_char[0], '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
for table_column in Display.ROW3 : # 3rd row | |
self.draw_textline(row_content[table_column['label']] + ' ', ' ', table_column['length'], table_column['alignment'], Display.NONE) | |
self.draw_textline(filling_char[1], '', 2, Display.ALIGNMENT_LEFT, Display.NONE) | |
self.draw_textline('\n' + filling_char[0], filling_char[2], self.display_width - 1, Display.ALIGNMENT_LEFT, Display.NONE) # another separator . . . . . . . . | |
self.draw_textline(filling_char[1], '', 2, Display.ALIGNMENT_LEFT, Display.NEWLINE) | |
""" Summon bot! | |
""" | |
def summon_bot (self, bot_host) : | |
is_summoned = False | |
while not is_summoned : | |
try : | |
self.bot[bot_host['hostname']] = Bold(bot_host, self) | |
is_summoned = True | |
except : | |
time.sleep(Config.SOCKET_NEXT_TRY_DELAY) | |
def rip (self) : | |
self.stop_countdown() | |
self.clear_display(); | |
print('Guess its time to say goodbye...') | |
print('Thank you for using BOLD, remember, your FAHClient will still folding...') | |
print('shutting down...') | |
for bot_name in self.bot : | |
print('exiting ' + bot_name + '...') | |
self.bot[bot_name].rip() | |
time.sleep(2) | |
print('waiting for main block...') | |
self.is_alive = False | |
""" On Keyboard Hit - when user make a keystroke this function is fired! | |
some key is case-sensitive | |
""" | |
def on_key_hit (self, key) : | |
if key.upper() == 'S' : | |
self.stop_countdown() | |
elif key.upper() == 'P' : | |
self.start_countdown() | |
elif key == 'BACKSPACE' : | |
self.stop_countdown() | |
if len(self.screen) > 1 : | |
self.screen.pop() | |
self.redraw() | |
elif key == 'HOME' : | |
self.stop_countdown() | |
self.display_index[Display.SCREEN_QUEUE]['machine'] = 0 | |
self.display_index[Display.SCREEN_QUEUE]['slot'] = 0 | |
self.display_index[Display.SCREEN_QUEUE]['start'] = 0 | |
self.display_index[Display.SCREEN_QUEUE]['selected'] = 0 | |
self.redraw() | |
elif key == 'F10' : | |
self.rip() | |
elif key == 'ARROW_UP' : | |
if self.display_index[Display.SCREEN_QUEUE]['slot'] > 0 : | |
self.display_index[Display.SCREEN_QUEUE]['slot'] -= 1 | |
self.redraw() | |
elif self.display_index[Display.SCREEN_QUEUE]['machine'] > 0 : | |
self.display_index[Display.SCREEN_QUEUE]['machine'] -= 1 | |
slot_count = len(self.draw_cache[self.get_machine_selected_key()]['queue']) | |
self.display_index[Display.SCREEN_QUEUE]['slot'] = slot_count - 1 if slot_count > 0 else 0 | |
self.stop_countdown() | |
self.redraw() | |
elif key == 'ARROW_DOWN' : | |
slot_count = len(self.draw_cache[self.get_machine_selected_key()]['queue']) | |
if slot_count > self.display_index[Display.SCREEN_QUEUE]['slot'] + 1 : # there is another slot | |
self.display_index[Display.SCREEN_QUEUE]['slot'] += 1 | |
self.redraw() | |
elif self.display_index[Display.SCREEN_QUEUE]['machine'] + 1 < len(self.draw_cache) : | |
self.display_index[Display.SCREEN_QUEUE]['slot'] = 0 | |
self.display_index[Display.SCREEN_QUEUE]['machine'] += 1 | |
self.stop_countdown() | |
self.redraw() | |
elif key == 'ENTER' : | |
self.stop_countdown() | |
self.screen.append(Display.SCREEN_MACHINE) | |
self.redraw() | |
""" Loading project cause | |
please run on background (thread) | |
""" | |
def load_project_cause (self, project_id) : | |
if project_id not in self.project_cause or self.project_cause[project_id] != 'loading' : | |
self.project_cause[project_id] = 'loading' # this to prevent there is no other thread checking same project cause | |
project_info = Display.load_html('https://stats.foldingathome.org/project?p=' + project_id) | |
if project_info is not None : | |
self.project_cause[project_id] = 'loaded' | |
html_string = project_info.decode('utf-8') | |
html_string = " ".join(html_string.split()) | |
h2_start_index = html_string.find('<h2') # project detail is in h2, for the moment... | |
if h2_start_index > -1 : | |
h2_end_index = html_string.find('</h2>') | |
project_title = html_string[html_string.find('>', h2_start_index) + 1 : h2_end_index] | |
cause_end_index = html_string.find('</p>', h2_end_index) | |
cause = html_string[html_string.find('<p>', h2_end_index) + 3 : cause_end_index] # Cause: <project_cause> | |
project_details = html_string[html_string.find('<p>', cause_end_index + 4) + 3 : html_string.find('</p>', cause_end_index + 4)] | |
self.project_cause[project_id] = cause[7:] | |
else : | |
self.project_cause[project_id] = 'unspecified' | |
else : | |
self.project_cause[project_id] = None | |
""" Clearing display - I hope so... | |
""" | |
@staticmethod | |
def clear_display () : | |
os.system('cls' if os.name == 'nt' else 'clear') | |
""" Get terminal size (width, height) | |
Fallback to (120, 30) if fail | |
I NOT fully understand this, Maybe you want to go to http://granitosaurus.rocks/getting-terminal-size.html for more explanation | |
""" | |
@staticmethod | |
def get_terminal_size (fallback=(120, 30)) : | |
for file_descriptor in range(0, 3): # looping for Standard Input/Standard Output/Standard Error | |
try : | |
columns, rows = os.get_terminal_size(file_descriptor) | |
except OSError : | |
continue | |
break | |
else : # there is no file_descriptor | |
columns, rows = fallback | |
return columns, rows | |
@staticmethod | |
def load_html (url) : | |
try : | |
with urllib.request.urlopen(url) as response : | |
return response.read() | |
except : | |
return None | |
""" Telegram bot class | |
for more reference: https://core.telegram.org/bots/api | |
""" | |
class Tgbot : | |
def __init__ (self) : | |
pass | |
@staticmethod | |
def send_message (message) : | |
try: | |
if Config.TGBOT_RECEPIENT is not None : | |
with urllib.request.urlopen('https://api.telegram.org/bot' + Config.TGBOT_TOKEN + '/sendMessage?chat_id=' + Config.TGBOT_RECEPIENT + '&parse_mode=HTML&text=' + urllib.parse.quote_plus(message)) as response : | |
pass | |
#html = response.read() # {"ok":true,"result":{"message_id":2,"from":{"id":1148858736,"is_bot":true,"first_name":"Folding Anywhere Human","username":"foldabot"},"chat":{"id":70796122,"first_name":"Bafo","last_name":"Hutiva","username":"bafo_ah","type":"private"},"date":1586914000,"text":"hello from the other side"}} | |
except : | |
pass | |
""" Keyboard event listener | |
Its so complicated, because keyboard event is very OS dependent... | |
I only cover windows and linux only | |
""" | |
class Keyboard : | |
# Windows special keys after b'\x00' keystroke | |
WINDOWS_KEY = {b'H': 'ARROW_UP', b'M': 'ARROW_RIGHT', b'P': 'ARROW_DOWN', b'K': 'ARROW_LEFT', | |
b';': 'F1', b'<': 'F2', b'=': 'F3', b'>': 'F4', b'?': 'F5', b'@': 'F6', b'A': 'F7', b'B': 'F8', b'C': 'F9', b'D': 'F10', | |
b'G': 'HOME', b'R': 'INSERT', b'S': 'DELETE', b'O': 'END', b'I': 'PAGE_UP', b'Q': 'PAGE_DOWN', | |
b'\x1b': 'ESCAPE', b'\r': 'ENTER', b'\x08': 'BACKSPACE', b' ': 'SPACE', b'\t': 'TAB'} | |
LINUX_KEY = {b'\n': 'ENTER', b'\x7f': 'BACKSPACE', b' ': 'SPACE', b'\t': 'TAB'} | |
# so, linux will send several (yes, I dont know how many) after b'\x1b' (yes, thats ESC key... so its hard to tell when user press the ESC key) | |
LINUX_KEY_SEQ = { 'ARROW_UP': [b'[', b'A'], | |
'ARROW_RIGHT': [b'[', b'C'], | |
'ARROW_DOWN': [b'[', b'B'], | |
'ARROW_LEFT': [b'[', b'D'], | |
'F1': [b'O', b'P'], | |
'F2': [b'O', b'Q'], | |
'F3': [b'O', b'R'], | |
'F4': [b'O', b'S'], | |
'F5': [b'[', b'1', b'5', b'~'], | |
'F6': [b'[', b'1', b'7', b'~'], | |
'F7': [b'[', b'1', b'8', b'~'], | |
'F8': [b'[', b'1', b'9', b'~'], | |
'F9': [b'[', b'2', b'0', b'~'], | |
'F10': [b'[', b'2', b'1', b'~'], | |
'F12': [b'[', b'2', b'4', b'~'], | |
'HOME': [b'[', b'1', b'~'], | |
'INSERT': [b'[', b'2', b'~'], | |
'DELETE': [b'[', b'3', b'~'], | |
'END': [b'[', b'4', b'~'], | |
'PAGE_UP': [b'[', b'5', b'~'], | |
'PAGE_DOWN': [b'[', b'6', b'~'] } | |
def __init__ (self) : | |
pass | |
""" Start keyboard listener | |
note for running this in Linux : | |
there are several "preparation" before start ("hijack" stdin) | |
and another "finishing" task that must do (restore stdin) | |
""" | |
@staticmethod | |
def start_listener (handler, sys=None) : | |
if platform.system() == 'Linux' : # hmm... a little bit complicated here... | |
is_seq_mode = False # sequence mode | |
key_buffer = [] # buffering key sequence - because we do NOT know how many it is! | |
try : | |
while True : # remember 1 keystoke from user, may translate into up to 5 keystoke... | |
key_press = bytes(sys.stdin.read(1), 'utf-8') # wait and get user input | |
if key_press == b'\x1b' and not is_seq_mode : # if there is ESC key and we are not in sequence mode | |
is_seq_mode = True | |
elif is_seq_mode : # we in sequence mode | |
if key_press == b'\x1b' or len(key_buffer) > 3 : # if there are another ESC key or more than 5 keystoke - 1 is first ESC key, 3 in buffer, and 1 in key_press | |
is_seq_mode = False # maybe we can NOT define what key it is... | |
key_buffer = [] | |
handler('ESCAPE') # just define it as ESCAPE... | |
else : | |
key_buffer.append(key_press) | |
for key_name in Keyboard.LINUX_KEY_SEQ : | |
if Keyboard.LINUX_KEY_SEQ[key_name] == key_buffer : # check if our buffer match with defined key sequence | |
handler(key_name) | |
is_seq_mode = False | |
key_buffer = [] | |
break | |
else : # just regular key remember, this is case-sensitive | |
if key_press in Keyboard.LINUX_KEY : # another special key | |
handler(Keyboard.LINUX_KEY[key_press]) | |
else : # just really regular key | |
handler(key_press.decode('utf-8')) | |
except : # when user terminate, exception does NOT happen here! it happpen on main block! | |
pass | |
elif platform.system() == 'Windows' : # windows? its easy-peasy | |
import msvcrt # windows-only library "MicroSoft Visual C++ RunTime" | |
while True : # 1 keystroke from user only generate up to 2 keystroke | |
if msvcrt.kbhit(): # if keyboard hit, msvcrt cover so many other different event | |
key_stroke = msvcrt.getch() # getting keystoke | |
if key_stroke == b'\x00' : # special keystroke to indicate special key | |
key_stroke = msvcrt.getch() # getting 2nd keystroke | |
if key_stroke in Keyboard.WINDOWS_KEY : # mapping | |
handler(Keyboard.WINDOWS_KEY[key_stroke]) # send to handler | |
else : # regular key | |
if key_stroke in Keyboard.WINDOWS_KEY : # a little bit special key | |
handler(Keyboard.WINDOWS_KEY[key_stroke]) | |
else : # a really regular key | |
handler(key_stroke.decode('utf-8')) | |
# main block | |
def main() : | |
try : | |
#setting = Config() | |
#if setting.is_loaded() : | |
# print(str(setting.get_bot_config('next-attempt-limit'))) | |
#else : | |
# pass # die | |
#time.sleep(60) | |
display_manager = Display() # our display manager, he is the one and the only one! | |
# keyboard event | |
if platform.system() == 'Linux' : # detecting keyboard in Linux is like pain-in-the-ass... really it is! | |
import sys, tty, termios # we need to "hijack" how terminal accept user input | |
initial_settings = termios.tcgetattr(sys.stdin) # first before "break" anything make sure to backup it.. | |
tty.setcbreak(sys.stdin) # you see... we already break something... you'll NEVER learn if you NOT break something... isn't it? | |
threading.Thread(target=Keyboard.start_listener, kwargs={'handler': display_manager.on_key_hit, 'sys': sys}, daemon=True).start() | |
elif platform.system() == 'Windows' : | |
threading.Thread(target=Keyboard.start_listener, kwargs={'handler': display_manager.on_key_hit}, daemon=True).start() | |
# starting bot for EVERY host - 1 host, 1 bot! | |
for host in host_list : | |
threading.Thread(target=display_manager.summon_bot, kwargs={'bot_host': host}, daemon=True).start() # bot will run on thread-mill | |
# so the idea is every bot-thread will write an update (if any) to cache | |
# then every few second our manager will read cache and update display - so expect DELAY! | |
# this code build with many thread in mind, and every bot maybe make an update so often, so it will be a waste if we update display for one bot but the other does NOT have an update | |
# sure there is workaround for this, ex. we can only update the line that need update - but... only if someone one would write that code... | |
# in the mean time redraw will have to DRAW-THE-WHOLE-INFORMATION in 1 go... | |
display_manager.redraw() # drawing table - trigger countdown etc... | |
# well, I know it can be better... it just prevent main program to exit... out thread is on daemon mode (so it will killed when main program exit) | |
while display_manager.is_alive : | |
time.sleep(5) | |
if platform.system() == 'Linux' : | |
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, initial_settings) # we are done, restore everything... | |
except Exception as e: | |
print(str(e)) | |
# before exiting program make sure to restore stdin | |
if platform.system() == 'Linux' : | |
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, initial_settings) # we are done, restore everything... | |
main() # this is main block, if nothing something execution will "stop" here.. | |
print('-- Stay Hungry, Stay Foolish, Stay Folding -- ') # reaching this line when user press Ctrl + C | |
# ignore this... just for future reference | |
""" | |
# https://stats.foldingathome.org/api/donors?team=223518&search_type=exact&name=bafo_ah | |
with urllib.request.urlopen('https://stats.foldingathome.org/api/donors?team=223518&search_type=exact&name=bafo_ah') as response : | |
html = response.read() | |
print(html.decode()) | |
""" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment