Files
pyasic/API/btminer.py

706 lines
24 KiB
Python

import asyncio
import re
import json
import hashlib
import binascii
import base64
import logging
from passlib.handlers.md5_crypt import md5_crypt
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from API import BaseMinerAPI, APIError
from settings import WHATSMINER_PWD
### IMPORTANT ###
# you need to change the password of the miners using the Whatsminer
# tool, then you can set them back to admin with this tool, but they
# must be changed to something else and set back to admin with this
# or the privileged API will not work using admin as the password. If
# you change the password, you can pass that to the this class as pwd,
# or add it as the Whatsminer_pwd in the settings.toml file.
def _crypt(word: str, salt: str) -> str:
"""Encrypts a word with a salt, using a standard salt format.
Encrypts a word using a salt with the format
'\s*\$(\d+)\$([\w\./]*)\$'. If this format is not used, a
ValueError is raised.
:param word: The word to be encrypted.
:param salt: The salt to encrypt the word.
:return: An MD5 hash of the word with the salt.
"""
# compile a standard format for the salt
standard_salt = re.compile("\s*\$(\d+)\$([\w\./]*)\$")
# check if the salt matches
match = standard_salt.match(salt)
# if the matching fails, the salt is incorrect
if not match:
raise ValueError("Salt format is not correct.")
# save the matched salt in a new variable
new_salt = match.group(2)
# encrypt the word with the salt using md5
result = md5_crypt.hash(word, salt=new_salt)
return result
def _add_to_16(string: str) -> bytes:
"""Add null bytes to a string until the length is a multiple 16
:param string: The string to lengthen to a multiple of 16 and
encode.
:return: The input string as bytes with a multiple of 16 as the
length.
"""
while len(string) % 16 != 0:
string += "\0"
return str.encode(string) # return bytes
def parse_btminer_priviledge_data(token_data: dict, data: dict):
"""Parses data returned from the BTMiner privileged API.
Parses data from the BTMiner privileged API using the the token
from the API in an AES format.
:param token_data: The token information from self.get_token().
:param data: The data to parse, returned from the API.
:return: A decoded dict version of the privileged command output.
"""
# get the encoded data from the dict
enc_data = data["enc"]
# get the aes key from the token data
aeskey = hashlib.sha256(token_data["host_passwd_md5"].encode()).hexdigest()
# unhexlify the aes key
aeskey = binascii.unhexlify(aeskey.encode())
# create the required decryptor
aes = Cipher(algorithms.AES(aeskey), modes.ECB())
decryptor = aes.decryptor()
# decode the message with the decryptor
ret_msg = json.loads(
decryptor.update(base64.decodebytes(bytes(enc_data, encoding="utf8")))
.rstrip(b"\0")
.decode("utf8")
)
return ret_msg
def create_privileged_cmd(token_data: dict, command: dict) -> bytes:
"""Create a privileged command to send to the BTMiner API.
Creates a privileged command using the token from the API and the
command as a dict of {'command': cmd}, with cmd being any command
that the miner API accepts.
:param token_data: The token information from self.get_token().
:param command: The command to turn into a privileged command.
:return: The encrypted privileged command to be sent to the miner.
"""
# add token to command
command["token"] = token_data["host_sign"]
# encode host_passwd data and get hexdigest
aeskey = hashlib.sha256(token_data["host_passwd_md5"].encode()).hexdigest()
# unhexlify the encoded host_passwd
aeskey = binascii.unhexlify(aeskey.encode())
# create a new AES key
aes = Cipher(algorithms.AES(aeskey), modes.ECB())
encryptor = aes.encryptor()
# dump the command to json
api_json_str = json.dumps(command)
# encode the json command with the aes key
api_json_str_enc = (
base64.encodebytes(encryptor.update(_add_to_16(api_json_str)))
.decode("utf-8")
.replace("\n", "")
)
# label the data as being encoded
data_enc = {"enc": 1, "data": api_json_str_enc}
# dump the labeled data to json
api_packet_str = json.dumps(data_enc)
return api_packet_str.encode("utf-8")
class BTMinerAPI(BaseMinerAPI):
"""An abstraction of the API for MicroBT Whatsminers, BTMiner.
Each method corresponds to an API command in BMMiner.
This class abstracts use of the BTMiner API, as well as the
methods for sending commands to it. The self.send_command()
function handles sending a command to the miner asynchronously, and
as such is the base for many of the functions in this class, which
rely on it to send the command for them.
All privileged commands for BTMiner's API require that you change
the password of the miners using the Whatsminer tool, and it can be
changed back to admin with this tool after. Set the new password
either by passing it to the __init__ method, or changing it in
settings.toml.
Additionally, the API commands for the privileged API must be
encoded using a token from the miner, all privileged commands do
this automatically for you and will decode the output to look like
a normal output from a miner API.
:param ip: The IP of the miner to reference the API on.
:param port: The port to reference the API on. Default is 4028.
:param pwd: The admin password of the miner. Default is admin.
"""
def __init__(self, ip, port=4028, pwd: str = WHATSMINER_PWD):
super().__init__(ip, port)
self.admin_pwd = pwd
self.current_token = None
async def send_command(
self,
command: str or bytes,
parameters: str or int or bool = None,
ignore_errors: bool = False,
**kwargs,
) -> dict:
"""Send a command to the miner API.
Send a command using an asynchronous connection, load the data,
parse encoded data if needed, and return the result.
:param command: The command to send to the miner.
:param parameters: Parameters to pass to the command.
:param ignore_errors: Ignore the E (Error) status code from the
API.
:return: The data received from the API after sending the
command.
"""
# check if command is a string
# if its bytes its encoded and needs to be sent raw
if isinstance(command, str):
# if it is a string, put it into the standard command format
command = json.dumps({"command": command}).encode("utf-8")
try:
# get reader and writer streams
reader, writer = await asyncio.open_connection(str(self.ip), self.port)
# handle OSError 121
except OSError as e:
if e.winerror == "121":
print("Semaphore Timeout has Expired.")
return {}
# send the command
writer.write(command)
await writer.drain()
# instantiate data
data = b""
# loop to receive all the data
try:
while True:
d = await reader.read(4096)
if not d:
break
data += d
except Exception as e:
logging.info(f"{str(self.ip)}: {e}")
data = self.load_api_data(data)
# close the connection
writer.close()
await writer.wait_closed()
# check if the returned data is encoded
if "enc" in data.keys():
# try to parse the encoded data
try:
data = parse_btminer_priviledge_data(self.current_token, data)
except Exception as e:
logging.info(f"{str(self.ip)}: {e}")
if not ignore_errors:
# if it fails to validate, it is likely an error
validation = self.validate_command_output(data)
if not validation[0]:
raise APIError(validation[1])
# return the parsed json as a dict
return data
async def get_token(self):
"""Gets token information from the API.
:return: An encoded token and md5 password, which are used
for the privileged API.
"""
# get the token
data = await self.send_command("get_token")
# encrypt the admin password with the salt
pwd = _crypt(self.admin_pwd, "$1$" + data["Msg"]["salt"] + "$")
pwd = pwd.split("$")
# take the 4th item from the pwd split
host_passwd_md5 = pwd[3]
# encrypt the pwd with the time and new salt
tmp = _crypt(pwd[3] + data["Msg"]["time"], "$1$" + data["Msg"]["newsalt"] + "$")
tmp = tmp.split("$")
# take the 4th item from the encrypted pwd split
host_sign = tmp[3]
# set the current token
self.current_token = {
"host_sign": host_sign,
"host_passwd_md5": host_passwd_md5,
}
return self.current_token
#### PRIVILEGED COMMANDS ####
# Please read the top of this file to learn
# how to configure the Whatsminer API to
# use these commands.
async def update_pools(
self,
pool_1: str,
worker_1: str,
passwd_1: str,
pool_2: str = None,
worker_2: str = None,
passwd_2: str = None,
pool_3: str = None,
worker_3: str = None,
passwd_3: str = None,
):
"""Update the pools of the miner using the API.
Update the pools of the miner using the API, only works after
changing the password of the miner using the Whatsminer tool.
:param pool_1: The URL to update pool 1 to.
:param worker_1: The worker name for pool 1 to update to.
:param passwd_1: The password for pool 1 to update to.
:param pool_2: The URL to update pool 2 to.
:param worker_2: The worker name for pool 2 to update to.
:param passwd_2: The password for pool 2 to update to.
:param pool_3: The URL to update pool 3 to.
:param worker_3: The worker name for pool 3 to update to.
:param passwd_3: The password for pool 3 to update to.
:return: A dict from the API to confirm the pools were updated.
"""
# get the token and password from the miner
token_data = await self.get_token()
# parse pool data
if not pool_1:
raise APIError("No pools set.")
elif pool_2 and pool_3:
command = {
"cmd": "update_pools",
"pool1": pool_1,
"worker1": worker_1,
"passwd1": passwd_1,
"pool2": pool_2,
"worker2": worker_2,
"passwd2": passwd_2,
"pool3": pool_3,
"worker3": worker_3,
"passwd3": passwd_3,
}
elif pool_2:
command = {
"cmd": "update_pools",
"pool1": pool_1,
"worker1": worker_1,
"passwd1": passwd_1,
"pool2": pool_2,
"worker2": worker_2,
"passwd2": passwd_2,
}
else:
command = {
"cmd": "update_pools",
"pool1": pool_1,
"worker1": worker_1,
"passwd1": passwd_1,
}
# encode the command with the token data
enc_command = create_privileged_cmd(token_data, command)
# send the command
return await self.send_command(enc_command)
async def restart(self):
"""Restart BTMiner using the API.
Restart BTMiner using the API, only works after changing
the password of the miner using the Whatsminer tool.
:return: A reply informing of the restart.
"""
command = {"cmd": "restart_btminer"}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def power_off(self, respbefore: bool = True):
"""Power off the miner using the API.
Power off the miner using the API, only works after changing
the password of the miner using the Whatsminer tool.
:param respbefore: Whether to respond before powering off.
:return: A reply informing of the status of powering off.
"""
if respbefore:
command = {"cmd": "power_off", "respbefore": "true"}
else:
command = {"cmd": "power_off", "respbefore": "false"}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def power_on(self):
"""Power on the miner using the API.
Power on the miner using the API, only works after changing
the password of the miner using the Whatsminer tool.
:return: A reply informing of the status of powering on.
"""
command = {"cmd": "power_on"}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def reset_led(self):
"""Reset the LED on the miner using the API.
Reset the LED on the miner using the API, only works after
changing the password of the miner using the Whatsminer tool.
:return: A reply informing of the status of resetting the LED.
"""
command = {"cmd": "set_led", "param": "auto"}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def set_led(
self,
color: str = "red",
period: int = 2000,
duration: int = 1000,
start: int = 0,
):
"""Set the LED on the miner using the API.
Set the LED on the miner using the API, only works after
changing the password of the miner using the Whatsminer tool.
:param color: The LED color to set, either 'red' or 'green'.
:param period: The flash cycle in ms.
:param duration: LED on time in the cycle in ms.
:param start: LED on time offset in the cycle in ms.
:return: A reply informing of the status of setting the LED.
"""
command = {
"cmd": "set_led",
"color": color,
"period": period,
"duration": duration,
"start": start,
}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def set_low_power(self):
"""Set low power mode on the miner using the API.
Set low power mode on the miner using the API, only works after
changing the password of the miner using the Whatsminer tool.
:return: A reply informing of the status of setting low power
mode.
"""
command = {"cmd": "set_low_power"}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def update_firmware(self): # noqa - static
# to be determined if this will be added later
# requires a file stream in bytes
return NotImplementedError
async def reboot(self):
"""Reboot the miner using the API.
:return: A reply informing of the status of the reboot.
"""
command = {"cmd": "reboot"}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def factory_reset(self):
"""Reset the miner to factory defaults.
:return: A reply informing of the status of the reset.
"""
command = {"cmd": "factory_reset"}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def update_pwd(self, old_pwd: str, new_pwd: str):
"""Update the admin user's password.
Update the admin user's password, only works after changing the
password of the miner using the Whatsminer tool. New password
has a max length of 8 bytes, using letters, numbers, and
underscores.
:param old_pwd: The old admin password.
:param new_pwd: The new password to set.
:return: A reply informing of the status of setting the
password.
"""
# check if password length is greater than 8 bytes
if len(new_pwd.encode("utf-8")) > 8:
return APIError(
f"New password too long, the max length is 8. "
f"Password size: {len(new_pwd.encode('utf-8'))}"
)
command = {"cmd": "update_pwd", "old": old_pwd, "new": new_pwd}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def set_target_freq(self, percent: int):
"""Update the target frequency.
Update the target frequency, only works after changing the
password of the miner using the Whatsminer tool. The new
frequency must be between -10% and 100%.
:param percent: The frequency % to set.
:return: A reply informing of the status of setting the
frequency.
"""
if not -10 < percent < 100:
return APIError(
f"Frequency % is outside of the allowed "
f"range. Please set a % between -10 and "
f"100"
)
command = {"cmd": "set_target_freq", "percent": str(percent)}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def enable_fast_boot(self):
"""Turn on fast boot.
Turn on fast boot, only works after changing the password of
the miner using the Whatsminer tool.
:return: A reply informing of the status of enabling fast boot.
"""
command = {"cmd": "enable_btminer_fast_boot"}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def disable_fast_boot(self):
"""Turn off fast boot.
Turn off fast boot, only works after changing the password of
the miner using the Whatsminer tool.
:return: A reply informing of the status of disabling fast boot.
"""
command = {"cmd": "disable_btminer_fast_boot"}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def enable_web_pools(self):
"""Turn on web pool updates.
Turn on web pool updates, only works after changing the
password of the miner using the Whatsminer tool.
:return: A reply informing of the status of enabling web pools.
"""
command = {"cmd": "enable_web_pools"}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def disable_web_pools(self):
"""Turn off web pool updates.
Turn off web pool updates, only works after changing the
password of the miner using the Whatsminer tool.
:return: A reply informing of the status of disabling web
pools.
"""
command = {"cmd": "disable_web_pools"}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def set_hostname(self, hostname: str):
"""Set the hostname of the miner.
Set the hostname of the miner, only works after changing the
password of the miner using the Whatsminer tool.
:param hostname: The new hostname to use.
:return: A reply informing of the status of setting the
hostname.
"""
command = {"cmd": "set_hostname", "hostname": hostname}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def set_power_pct(self, percent: int):
"""Set the power percentage of the miner.
Set the power percentage of the miner, only works after changing
the password of the miner using the Whatsminer tool.
:param percent: The power percentage to set.
:return: A reply informing of the status of setting the
power percentage.
"""
if not 0 < percent < 100:
return APIError(
f"Power PCT % is outside of the allowed "
f"range. Please set a % between 0 and "
f"100"
)
command = {"cmd": "set_power_pct", "percent": str(percent)}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
async def pre_power_on(self, complete: bool, msg: str):
"""Configure or check status of pre power on.
Configure or check status of pre power on, only works after
changing the password of the miner using the Whatsminer tool.
:param complete: check whether pre power on is complete.
:param msg: the message to check.
"wait for adjust temp" or
"adjust complete" or
"adjust continue"
:return: A reply informing of the status of pre power on.
"""
if not msg == "wait for adjust temp" or "adjust complete" or "adjust continue":
return APIError(
"Message is incorrect, please choose one of "
'["wait for adjust temp", '
'"adjust complete", '
'"adjust continue"]'
)
if complete:
complete = "true"
else:
complete = "false"
command = {"cmd": "pre_power_on", "complete": complete, "msg": msg}
token_data = await self.get_token()
enc_command = create_privileged_cmd(token_data, command)
return await self.send_command(enc_command)
#### END privileged COMMANDS ####
async def summary(self):
"""Get the summary status from the miner.
:return: Summary status of the miner.
"""
return await self.send_command("summary")
async def pools(self):
"""Get the pool status from the miner.
:return: Pool status of the miner.
"""
return await self.send_command("pools")
async def devs(self):
"""Get data on each PGA/ASC with their details.
:return: Data on each PGA/ASC with their details.
"""
return await self.send_command("devs")
async def edevs(self):
"""Get data on each PGA/ASC with their details, ignoring
blacklisted and zombie devices.
:return: Data on each PGA/ASC with their details.
"""
return await self.send_command("edevs")
async def devdetails(self):
"""Get data on all devices with their static details.
:return: Data on all devices with their static details.
"""
return await self.send_command("devdetails")
async def get_psu(self):
"""Get data on the PSU and power information.
:return: Data on the PSU and power information.
"""
return await self.send_command("get_psu")
async def version(self):
"""Get version data for the miner.
Get version data for the miner. This calls another function,
self.get_version(), but is named version to stay consistent
with the other miner APIs.
:return: Version data for the miner.
"""
return await self.get_version()
async def get_version(self):
"""Get version data for the miner.
:return: Version data for the miner.
"""
return await self.send_command("get_version")
async def status(self):
"""Get BTMiner status and firmware version.
:return: BTMiner status and firmware version.
"""
return await self.send_command("status")
async def get_miner_info(self):
"""Get general miner info.
:return: General miner info.
"""
return await self.send_command("get_miner_info")