Skip to content

Instantly share code, notes, and snippets.

@bafoah
Last active April 21, 2020 02:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bafoah/16c026777216e197e2eb3eb89a4d4ec9 to your computer and use it in GitHub Desktop.
Save bafoah/16c026777216e197e2eb3eb89a4d4ec9 to your computer and use it in GitHub Desktop.
# 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