diff --git a/custom_components/nicehash/__init__.py b/custom_components/nicehash/__init__.py new file mode 100644 index 0000000..034fea8 --- /dev/null +++ b/custom_components/nicehash/__init__.py @@ -0,0 +1,69 @@ +""" +Integrates NiceHash with Home Assistant + +For more details about this integration, please refer to +https://github.com/brianberg/ha-nicehash +""" +import asyncio +from datetime import timedelta +import logging +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICES, CONF_TIMEOUT +from homeassistant.core import Config, HomeAssistant +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import ( + CONF_API_KEY, + CONF_API_SECRET, + CONF_CURRENCY, + CONF_ORGANIZATION_ID, + CURRENCY_BTC, + DOMAIN, + STARTUP_MESSAGE, +) +from .nicehash import NiceHashPrivateClient + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_ORGANIZATION_ID): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_API_SECRET): cv.string, + vol.Required(CONF_CURRENCY, default=CURRENCY_BTC): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config: Config): + """Set up this integration""" + if hass.data.get(DOMAIN) is None: + hass.data.setdefault(DOMAIN, {}) + _LOGGER.debug(STARTUP_MESSAGE) + + nicehash_config = config[DOMAIN] + organization_id = nicehash_config.get(CONF_ORGANIZATION_ID) + api_key = nicehash_config.get(CONF_API_KEY) + api_secret = nicehash_config.get(CONF_API_SECRET) + currency = nicehash_config.get(CONF_CURRENCY) + + client = NiceHashPrivateClient(organization_id, api_key, api_secret) + + try: + await client.get_accounts() + hass.data[DOMAIN]["client"] = client + hass.data[DOMAIN]["currency"] = currency + await discovery.async_load_platform(hass, "sensor", DOMAIN, {}, config) + return True + except Exception as err: + _LOGGER.error(f"Unable to access NiceHash accounts\n{err}") + return False diff --git a/custom_components/nicehash/const.py b/custom_components/nicehash/const.py new file mode 100644 index 0000000..e7d72b9 --- /dev/null +++ b/custom_components/nicehash/const.py @@ -0,0 +1,49 @@ +""" +Constants for NiceHash +""" +# Base component constants +NAME = "NiceHash" +DOMAIN = "nicehash" +DOMAIN_DATA = f"{DOMAIN}_data" +VERSION = "0.1.0" + +ISSUE_URL = "https://github.com/brianberg/ha-nicehash/issues" + +# Icons +ICON = "mdi:pickaxe" +ICON_CURRENCY_USD = "mdi:currency-usd" +ICON_CURRENCY_BTC = "mdi:currency-btc" +ICON_TEMPERATURE = "mdi:thermometer" + +# Platforms +SENSOR = "sensor" +PLATFORMS = [SENSOR] + + +# Configuration and options +CONF_ENABLED = "enabled" +CONF_API_KEY = "api_key" +CONF_API_SECRET = "api_secret" +CONF_ORGANIZATION_ID = "organization_id" +CONF_CURRENCY = "currency" + + +# Defaults +DEFAULT_NAME = NAME + +# Startup +STARTUP_MESSAGE = f""" +------------------------------------------------------------------- +{NAME} +Version: {VERSION} +This is a custom integration! +If you have any issues with this you need to open an issue here: +{ISSUE_URL} +------------------------------------------------------------------- +""" + +# NiceHash +NICEHASH_API_URL = "https://api2.nicehash.com" +CURRENCY_BTC = "BTC" +CURRENCY_USD = "USD" +CURRENCY_EUR = "EUR" diff --git a/custom_components/nicehash/manifest.json b/custom_components/nicehash/manifest.json new file mode 100644 index 0000000..3d4a331 --- /dev/null +++ b/custom_components/nicehash/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "nicehash", + "name": "NiceHash", + "documentation": "https://github.com/brianberg/ha-nicehash", + "issue_tracker": "https://github.com/brianberg/ha-nicehash/issues", + "dependencies": [], + "codeowners": [ + "@brianberg" + ], + "requirements": [ + "requests_async" + ] +} \ No newline at end of file diff --git a/custom_components/nicehash/nicehash.py b/custom_components/nicehash/nicehash.py new file mode 100644 index 0000000..7a684cd --- /dev/null +++ b/custom_components/nicehash/nicehash.py @@ -0,0 +1,110 @@ +""" +NiceHash API interface + +https://github.com/nicehash/rest-clients-demo/blob/master/python/nicehash.py +""" +from datetime import datetime +from hashlib import sha256 +import hmac +import json +import requests_async as requests +import sys +from time import mktime +import uuid + +from .const import NICEHASH_API_URL + + +class NiceHashPublicClient: + async def get_exchange_rates(self): + exchange_data = await self.request("GET", "/main/api/v2/exchangeRate/list") + return exchange_data["list"] + + async def request(self, method, path, query=None, body=None): + url = NICEHASH_API_URL + path + + if query is not None: + url += f"?{query}" + + async with requests.Session() as session: + if body: + data = json.dumps(body) + response = await session.request(method, url, data=data) + else: + response = await session.request(method, url) + + if response.status_code == 200: + return response.json() + else: + err_messages = [str(response.status_code), response.reason] + if response.content: + err_messages.append(str(response.content)) + raise Exception(": ".join(err_messages)) + + +class NiceHashPrivateClient: + def __init__(self, organization_id, key, secret, verbose=False): + self.organization_id = organization_id + self.key = key + self.secret = secret + self.verbose = verbose + + async def get_accounts(self): + return await self.request("GET", "/main/api/v2/accounting/accounts2") + + async def get_mining_rigs(self): + return await self.request("GET", "/main/api/v2/mining/rigs2") + + async def get_mining_rig(self, rig_id): + return await self.request("GET", f"/main/api/v2/mining/rig2/{rig_id}") + + async def request(self, method, path, query="", body=None): + xtime = self.get_epoch_ms_from_now() + xnonce = str(uuid.uuid4()) + + message = f"{self.key}\00{str(xtime)}\00{xnonce}\00\00{self.organization_id}\00\00{method}\00{path}\00{query}" + + data = None + if body: + data = json.dumps(body) + message += f"\00{data}" + + digest = hmac.new(self.secret.encode(), message.encode(), sha256).hexdigest() + xauth = f"{self.key}:{digest}" + + headers = { + "X-Time": str(xtime), + "X-Nonce": xnonce, + "X-Auth": xauth, + "Content-Type": "application/json", + "X-Organization-Id": self.organization_id, + "X-Request-Id": str(uuid.uuid4()), + } + + async with requests.Session() as session: + session.headers = headers + + url = NICEHASH_API_URL + path + if query: + url += "?" + query + + if self.verbose: + print(method, url) + + if data: + response = await session.request(method, url, data=data) + else: + response = await session.request(method, url) + + if response.status_code == 200: + return response.json() + else: + err_messages = [str(response.status_code), response.reason] + if response.content: + err_messages.append(str(response.content)) + raise Exception(": ".join(err_messages)) + + def get_epoch_ms_from_now(self): + now = datetime.now() + now_ec_since_epoch = mktime(now.timetuple()) + now.microsecond / 1000000.0 + return int(now_ec_since_epoch * 1000) diff --git a/custom_components/nicehash/sensor.py b/custom_components/nicehash/sensor.py new file mode 100644 index 0000000..c729d93 --- /dev/null +++ b/custom_components/nicehash/sensor.py @@ -0,0 +1,192 @@ +""" +Sensor platform for NiceHash +""" +from datetime import datetime, timedelta +import logging +import os + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Config, HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +from .const import ( + CURRENCY_BTC, + DEFAULT_NAME, + DOMAIN, + ICON_CURRENCY_BTC, + ICON_TEMPERATURE, +) +from .nicehash import NiceHashPrivateClient, NiceHashPublicClient + +FORMAT_DATETIME = "%d-%m-%Y %H:%M" +SCAN_INTERVAL_RIGS = timedelta(minutes=1) +SCAN_INTERVAL_ACCOUNTS = timedelta(minutes=60) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass: HomeAssistant, config: Config, async_add_entities, discovery_info=None +): + """Setup NiceHash sensor platform""" + _LOGGER.debug("Creating new NiceHash sensor components") + + client = hass.data[DOMAIN]["client"] + currency = hass.data[DOMAIN]["currency"] + + # Add account balance sensor + async_add_entities( + [NiceHashBalanceSensor(client, currency, SCAN_INTERVAL_ACCOUNTS)], True + ) + + # Add mining rig sensors + rig_data = await client.get_mining_rigs() + mining_rigs = rig_data["miningRigs"] + # Add temperature sensors + async_add_entities( + [ + NiceHashRigTemperatureSensor(client, rig, SCAN_INTERVAL_RIGS) + for rig in mining_rigs + ], + True, + ) + + +class NiceHashBalanceSensor(Entity): + """NiceHash Account Balance Sensor""" + + def __init__(self, client, currency, update_frequency): + """Initialize the sensor""" + _LOGGER.debug(f"Account Balance Sensor: {currency}") + self._client = client + self._public_client = NiceHashPublicClient() + self._currency = currency + self._state = None + self._last_update = None + self.async_update = Throttle(update_frequency)(self._async_update) + + @property + def name(self): + """Sensor name""" + return f"{DEFAULT_NAME} Account Balance" + + @property + def unique_id(self): + """Unique entity id""" + return f"{self._client.organization_id}:{self._currency}" + + @property + def icon(self): + """Sensor icon""" + return ICON_CURRENCY_BTC + + @property + def state(self): + """Sensor state""" + return self._state + + @property + def unit_of_measurement(self): + """Sensor unit of measurement""" + return self._currency + + @property + def device_state_attributes(self): + """Sensor device attributes""" + return {"last_update": self._last_update} + + async def _async_update(self): + + try: + account_data = await self._client.get_accounts() + available = float(account_data["total"]["available"]) + + if self._currency == CURRENCY_BTC: + # Account balance is in BTC + self._state = available + else: + # Convert to selected currency via exchange rates + exchange_rates = await self._public_client.get_exchange_rates() + self._last_update = datetime.today().strftime(FORMAT_DATETIME) + for rate in exchange_rates: + isBTC = rate["fromCurrency"] == CURRENCY_BTC + toCurrency = rate["toCurrency"] + if isBTC and toCurrency == self._currency: + rate = float(rate["exchangeRate"]) + self._state = round(available * rate, 2) + except Exception as err: + _LOGGER.error(f"Unable to get account balance\n{err}") + pass + + +class NiceHashRigTemperatureSensor(Entity): + """NichHash Mining Rig Temperature Sensor""" + + def __init__(self, client, rig, update_frequency): + """Initialize the sensor""" + self._client = client + self._rig_id = rig["rigId"] + self._name = rig["name"] + _LOGGER.debug(f"Mining Rig Temperature Sensor: {self._name} ({self._rig_id})") + self._state = None + self._last_update = None + self.async_update = Throttle(update_frequency)(self._async_update) + + @property + def name(self): + """Sensor name""" + return self._name + + @property + def unique_id(self): + """Unique entity id""" + return self._rig_id + + @property + def state(self): + """Sensor state""" + return self._state + + @property + def icon(self): + """Sensor icon""" + return ICON_TEMPERATURE + + @property + def unit_of_measurement(self): + """Sensor unit of measurement""" + return "C" + + @property + def device_state_attributes(self): + """Sensore device state attributes""" + return { + "last_update": self._last_update, + } + + async def _async_update(self): + try: + data = await self._client.get_mining_rig(self._rig_id) + self._last_update = datetime.today().strftime(FORMAT_DATETIME) + devices = data["devices"] + highest_temp = 0 + + if len(devices) > 0: + _LOGGER.debug(f"{self._name}: Found {len(devices)} devices") + for device in devices: + temp = int(device["temperature"]) + if temp < 0: + # Ignore inactive devices + continue + if temp > highest_temp: + highest_temp = temp + self._state = highest_temp + else: + _LOGGER.debug(f"{self._name}: No devices found") + self._state = None + + except Exception as err: + _LOGGER.error(f"Unable to get mining rig {self._rig_id}\n{err}") + pass diff --git a/custom_components/nicehash/translations/en.json b/custom_components/nicehash/translations/en.json new file mode 100644 index 0000000..2b934a0 --- /dev/null +++ b/custom_components/nicehash/translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "title": "NiceHash", + "step": { + "user": { + "title": "NiceHash", + "description": "If you need help with the configuration have a look here: https://github.com/brianberg/ha-nicehash", + "data": { + "organization_id": "Organization ID", + "api_key": "API Key Code", + "api_secret": "API Secret Key Code" + } + } + }, + "error": { + "auth": "Credentials are invalid", + "name_exists": "Configuration already exists" + }, + "abort": { + "single_instance_allowed": "Only one instance of NiceHash is allowed" + } + }, + "options": { + "step": { + "user": { + "data": { + "sensor": "Sensor enabled" + } + } + } + } +} \ No newline at end of file diff --git a/info.md b/info.md index aa6ceed..9feba92 100644 --- a/info.md +++ b/info.md @@ -20,13 +20,26 @@ Platform | Description {% if not installed %} ## Installation -1. Click install -1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "NiceHash" + + +1. Generate [NiceHash][nicehash] API key + - Supported API Permissions + - Wallet Permissions > View balances... + - Mining Permissions > View mining data... + - See this [repository](https://github.com/nicehash/rest-clients-demo) for assistance +1. Add `nicehash` to `configuration.yaml` + ``` + nicehash: + organization_id: + api_key: + api_secret: + ``` {% endif %} -## Configuration is done in the UI +