diff --git a/custom_components/miner/config_flow.py b/custom_components/miner/config_flow.py index 29d218e..0aba903 100644 --- a/custom_components/miner/config_flow.py +++ b/custom_components/miner/config_flow.py @@ -3,17 +3,14 @@ import pyasic import voluptuous as vol -from homeassistant import config_entries -from homeassistant import exceptions -from homeassistant.helpers.selector import TextSelector -from homeassistant.helpers.selector import TextSelectorConfig -from homeassistant.helpers.selector import TextSelectorType - -from .const import CONF_IP -from .const import CONF_PASSWORD -from .const import CONF_TITLE -from .const import CONF_USERNAME -from .const import DOMAIN +from homeassistant import config_entries, exceptions +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_IP, CONF_PASSWORD, CONF_TITLE, CONF_USERNAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,20 +30,13 @@ async def validate_input(data: dict[str, str]) -> dict[str, str]: miner_username = data.get(CONF_USERNAME) miner_password = data.get(CONF_PASSWORD) - try: - miner = await pyasic.get_miner(miner_ip) - if miner is None: - return {"base": "Unable to connect to Miner, is IP correct?"} - - miner.username = miner_username - miner.pwd = miner_password - await miner.get_data() + miner = await pyasic.get_miner(miner_ip) + if miner is None: + return {"base": "Unable to connect to Miner, is IP correct?"} - except Exception as e: # pylint: disable=broad-except - _LOGGER.error(f"Miner setup error: {e}") - return { - "base": "Unable to authenticate with Miner, is Username & Password correct?" - } + miner.username = miner_username + miner.pwd = miner_password + await miner.get_data(include=["mac"]) return {} diff --git a/custom_components/miner/const.py b/custom_components/miner/const.py index 1bf6e1c..bcf5e9f 100644 --- a/custom_components/miner/const.py +++ b/custom_components/miner/const.py @@ -5,3 +5,8 @@ CONF_TITLE = "title" CONF_PASSWORD = "password" CONF_USERNAME = "username" + +DEVICE_CLASS_HASHRATE = "hashrate" +DEVICE_CLASS_EFFICIENCY = "efficiency" +TERA_HASH_PER_SECOND = "TH/s" +JOULES_PER_TERA_HASH = "J/TH" diff --git a/custom_components/miner/coordinator.py b/custom_components/miner/coordinator.py index 35e4f15..ed5d7d0 100644 --- a/custom_components/miner/coordinator.py +++ b/custom_components/miner/coordinator.py @@ -1,4 +1,4 @@ -"""IoTaWatt DataUpdateCoordinator.""" +"""Miner DataUpdateCoordinator.""" import logging from datetime import timedelta @@ -6,12 +6,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.helpers.update_coordinator import UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_IP -from .const import CONF_PASSWORD -from .const import CONF_USERNAME +from .const import CONF_IP, CONF_PASSWORD, CONF_USERNAME _LOGGER = logging.getLogger(__name__) @@ -20,9 +17,9 @@ class MinerCoordinator(DataUpdateCoordinator): - """Class to manage fetching update data from the IoTaWatt Energy Device.""" + """Class to manage fetching update data from the Miner.""" - miner: pyasic.AnyMiner | None = None + miner: pyasic.AnyMiner = None def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: """Initialize MinerCoordinator object.""" @@ -53,7 +50,20 @@ async def _async_update_data(self): self.miner.username = miner_username self.miner.pwd = miner_password - miner_data = await self.miner.get_data() + miner_data = await self.miner.get_data( + include=[ + pyasic.DataOptions.HOSTNAME, + pyasic.DataOptions.MAC, + pyasic.DataOptions.IS_MINING, + pyasic.DataOptions.FW_VERSION, + pyasic.DataOptions.HASHRATE, + pyasic.DataOptions.EXPECTED_HASHRATE, + pyasic.DataOptions.HASHBOARDS, + pyasic.DataOptions.WATTAGE, + pyasic.DataOptions.WATTAGE_LIMIT, + pyasic.DataOptions.FANS, + ] + ) except pyasic.APIError as err: raise UpdateFailed("API Error") from err @@ -73,7 +83,7 @@ async def _async_update_data(self): "fw_ver": miner_data.fw_ver, "miner_sensors": { "hashrate": miner_data.hashrate, - "ideal_hashrate": miner_data.nominal_hashrate, + "ideal_hashrate": miner_data.expected_hashrate, "temperature": miner_data.temperature_avg, "power_limit": miner_data.wattage_limit, "miner_consumption": miner_data.wattage, @@ -87,5 +97,8 @@ async def _async_update_data(self): } for board in miner_data.hashboards }, + "fan_sensors": { + idx: {"fan_speed": fan.speed} for idx, fan in enumerate(miner_data.fans) + }, } return data diff --git a/custom_components/miner/manifest.json b/custom_components/miner/manifest.json index d8fe9f7..306c8e6 100644 --- a/custom_components/miner/manifest.json +++ b/custom_components/miner/manifest.json @@ -7,7 +7,7 @@ "homekit": {}, "iot_class": "local_polling", "issue_tracker": "https://github.com/Schnitzel/hass-miner/issues", - "requirements": ["pyasic==0.40.3"], + "requirements": ["pyasic==0.45.1"], "ssdp": [], "version": "1.0.1", "zeroconf": [] diff --git a/custom_components/miner/number.py b/custom_components/miner/number.py index a367ac9..211c490 100644 --- a/custom_components/miner/number.py +++ b/custom_components/miner/number.py @@ -1,4 +1,4 @@ -"""Support for IoTaWatt Energy monitor.""" +"""Support for Bitcoin ASIC miners.""" from __future__ import annotations import logging @@ -6,15 +6,13 @@ import pyasic from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import callback -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from pyasic.miners.backends import BOSMiner -from .const import ( - DOMAIN, -) +from .const import DOMAIN from .coordinator import MinerCoordinator _LOGGER = logging.getLogger(__name__) @@ -35,13 +33,14 @@ def _create_entity(key: str): created.add(key) await coordinator.async_config_entry_first_refresh() - async_add_entities( - [ - MinerPowerLimitNumber( - coordinator=coordinator, - ) - ] - ) + if coordinator.miner.supports_autotuning: + async_add_entities( + [ + MinerPowerLimitNumber( + coordinator=coordinator, + ) + ] + ) # @callback # def new_data_received(): @@ -118,7 +117,35 @@ async def async_set_native_value(self, value): f"{self.coordinator.entry.title} does not support setting power limit." ) - result = await miner.set_power_limit(int(value)) + if isinstance(miner, BOSMiner): + max_diff = 500 + try: + try: + current_value = self._attr_native_value + diff = int(value) - int(current_value) + smooth_tune = -max_diff < diff < max_diff + + if smooth_tune: + if diff < 0: + result = await miner.web.grpc.decrement_power_target( + abs(diff) + ) + else: + result = await miner.web.grpc.increment_power_target( + abs(diff) + ) + else: + result = await miner.web.grpc.set_power_target(int(value)) + except TypeError: + result = await miner.web.grpc.set_power_target(int(value)) + except pyasic.APIError: + result = await miner.set_power_limit(int(value)) + + else: + result = await miner.set_power_limit( + int(value) + ) # noqa: ignore miner being assumed to be None + if not result: raise pyasic.APIError("Failed to set wattage.") @@ -127,6 +154,9 @@ async def async_set_native_value(self, value): @callback def _handle_coordinator_update(self) -> None: - self._attr_native_value = self.coordinator.data["miner_sensors"]["power_limit"] + if self.coordinator.data["miner_sensors"]["power_limit"] is not None: + self._attr_native_value = self.coordinator.data["miner_sensors"][ + "power_limit" + ] super()._handle_coordinator_update() diff --git a/custom_components/miner/sensor.py b/custom_components/miner/sensor.py index 7efb27a..8bfecfc 100644 --- a/custom_components/miner/sensor.py +++ b/custom_components/miner/sensor.py @@ -5,22 +5,26 @@ from collections.abc import Callable from dataclasses import dataclass -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.components.sensor import SensorEntity -from homeassistant.components.sensor import SensorEntityDescription -from homeassistant.components.sensor import SensorStateClass +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import POWER_WATT -from homeassistant.const import TEMP_CELSIUS -from homeassistant.core import callback -from homeassistant.core import HomeAssistant +from homeassistant.const import UnitOfPower, UnitOfTemperature, REVOLUTIONS_PER_MINUTE +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + DEVICE_CLASS_EFFICIENCY, + DEVICE_CLASS_HASHRATE, DOMAIN, + JOULES_PER_TERA_HASH, + TERA_HASH_PER_SECOND, ) from .coordinator import MinerCoordinator @@ -29,15 +33,15 @@ @dataclass class MinerSensorEntityDescription(SensorEntityDescription): - """Class describing IotaWatt sensor entities.""" + """Class describing ASIC Miner sensor entities.""" - value: Callable | None = None + value: Callable = None class MinerNumberEntityDescription(SensorEntityDescription): - """Class describing IotaWatt number entities.""" + """Class describing ASIC Miner number entities.""" - value: Callable | None = None + value: Callable = None ENTITY_DESCRIPTION_KEY_MAP: dict[ @@ -45,58 +49,64 @@ class MinerNumberEntityDescription(SensorEntityDescription): ] = { "temperature": MinerSensorEntityDescription( "Temperature", - native_unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, ), "board_temperature": MinerSensorEntityDescription( "Board Temperature", - native_unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, ), "chip_temperature": MinerSensorEntityDescription( "Chip Temperature", - native_unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, ), "hashrate": MinerSensorEntityDescription( "Hashrate", - native_unit_of_measurement="TH/s", + native_unit_of_measurement=TERA_HASH_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, - device_class="Hashrate", + device_class=DEVICE_CLASS_HASHRATE, ), "ideal_hashrate": MinerSensorEntityDescription( "Ideal Hashrate", - native_unit_of_measurement="TH/s", + native_unit_of_measurement=TERA_HASH_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, - device_class="Hashrate", + device_class=DEVICE_CLASS_HASHRATE, ), "board_hashrate": MinerSensorEntityDescription( "Board Hashrate", - native_unit_of_measurement="TH/s", + native_unit_of_measurement=TERA_HASH_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, - device_class="Hashrate", + device_class=DEVICE_CLASS_HASHRATE, ), "power_limit": MinerSensorEntityDescription( "Power Limit", state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=POWER_WATT, + native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), "miner_consumption": MinerSensorEntityDescription( "Miner Consumption", state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=POWER_WATT, + native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), "efficiency": MinerSensorEntityDescription( "Efficiency", - native_unit_of_measurement="W/Ths", + native_unit_of_measurement=JOULES_PER_TERA_HASH, state_class=SensorStateClass.MEASUREMENT, - device_class="Efficiency", + device_class=DEVICE_CLASS_EFFICIENCY, ), + "fan_speed": MinerSensorEntityDescription( + "Fan Speed", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.SPEED + ) } @@ -121,53 +131,79 @@ def _create_miner_entity(key: str) -> MinerSensor: ) @callback - def _create_board_entity(board: str, sensor: str) -> MinerBoardSensor: + def _create_board_entity(board_num: int, sensor: str) -> MinerBoardSensor: """Create a board sensor entity.""" - sensor_created.add(f"{board}-{sensor}") + sensor_created.add(f"board_{board_num}-{sensor}") description = ENTITY_DESCRIPTION_KEY_MAP.get( sensor, MinerSensorEntityDescription("base_sensor") ) return MinerBoardSensor( coordinator=coordinator, - board=board, + board_num=board_num, + sensor=sensor, + entity_description=description, + ) + + @callback + def _create_fan_entity(fan_num: int, sensor: str) -> MinerFanSensor: + """Create a fan sensor entity.""" + sensor_created.add(f"fan_{fan_num}-{sensor}") + description = ENTITY_DESCRIPTION_KEY_MAP.get( + sensor, MinerSensorEntityDescription("base_sensor") + ) + return MinerFanSensor( + coordinator=coordinator, + fan_num=fan_num, sensor=sensor, entity_description=description, ) await coordinator.async_config_entry_first_refresh() + sensors = [] sensors.extend( _create_miner_entity(key) for key in coordinator.data["miner_sensors"] ) - if coordinator.data["board_sensors"]: - for board in coordinator.data["board_sensors"]: - sensors.extend( - _create_board_entity(board, sensor) - for sensor in coordinator.data["board_sensors"][board] - ) + for board in range(coordinator.miner.expected_hashboards): + sensors.extend( + _create_board_entity(board, sensor) + for sensor in ["board_temperature", "chip_temperature", "board_hashrate"] + ) + for fan in range(coordinator.miner.fan_count): + sensors.extend( + _create_fan_entity(fan, sensor) + for sensor in ["fan_speed"] + ) if sensors: async_add_entities(sensors) @callback def new_data_received(): """Check for new sensors.""" - sensors = [] - sensors.extend( + new_sensors = [] + new_sensors.extend( _create_miner_entity(key) for key in coordinator.data["miner_sensors"] if key not in sensor_created ) if coordinator.data["board_sensors"]: - for board in coordinator.data["board_sensors"]: - sensors.extend( - _create_board_entity(board, sensor) - for sensor in coordinator.data["board_sensors"][board] - if f"{board}-{sensor}" not in sensor_created + for new_board in coordinator.data["board_sensors"]: + new_sensors.extend( + _create_board_entity(new_board, sensor) + for sensor in coordinator.data["board_sensors"][new_board] + if f"{new_board}-{sensor}" not in sensor_created + ) + if coordinator.data["fan_sensors"]: + for new_fan in coordinator.data["fan_sensors"]: + new_sensors.extend( + _create_fan_entity(new_fan, sensor) + for sensor in coordinator.data["fan_sensors"][new_fan] + if f"{new_fan}-{sensor}" not in sensor_created ) - if sensors: - async_add_entities(sensors) + if new_sensors: + async_add_entities(new_sensors) coordinator.async_add_listener(new_data_received) @@ -224,21 +260,80 @@ def native_value(self) -> StateType: class MinerBoardSensor(CoordinatorEntity[MinerCoordinator], SensorEntity): - """Defines a Miner Sensor.""" + """Defines a Miner Board Sensor.""" + + entity_description: MinerSensorEntityDescription + + def __init__( + self, + coordinator: MinerCoordinator, + board_num: int, + sensor: str, + entity_description: MinerSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + self._attr_unique_id = f"{self.coordinator.data['mac']}-{board_num}-{sensor}" + self._board_num = board_num + self._sensor = sensor + self.entity_description = entity_description + self._attr_force_update = True + + @property + def _sensor_data(self): + """Return sensor data.""" + if ( + self._board_num in self.coordinator.data["board_sensors"] + and self._sensor in self.coordinator.data["board_sensors"][self._board_num] + ): + return self.coordinator.data["board_sensors"][self._board_num][self._sensor] + else: + return None + + @property + def name(self) -> str | None: + """Return name of the entity.""" + return f"{self.coordinator.entry.title} Board #{self._board_num} {self.entity_description.key}" + + @property + def device_info(self) -> entity.DeviceInfo: + """Return device info.""" + return entity.DeviceInfo( + identifiers={(DOMAIN, self.coordinator.data["mac"])}, + manufacturer=self.coordinator.data["make"], + model=self.coordinator.data["model"], + sw_version=self.coordinator.data["fw_ver"], + name=f"{self.coordinator.entry.title}", + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + super()._handle_coordinator_update() + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self._sensor_data + + +class MinerFanSensor(CoordinatorEntity[MinerCoordinator], SensorEntity): + """Defines a Miner Fan Sensor.""" entity_description: MinerSensorEntityDescription def __init__( self, coordinator: MinerCoordinator, - board: str, + fan_num: int, sensor: str, entity_description: MinerSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator=coordinator) - self._attr_unique_id = f"{self.coordinator.data['mac']}-{board}-{sensor}" - self._board = board + self._attr_unique_id = f"{self.coordinator.data['mac']}-{fan_num}-{sensor}" + self._fan_num = fan_num self._sensor = sensor self.entity_description = entity_description self._attr_force_update = True @@ -247,17 +342,17 @@ def __init__( def _sensor_data(self): """Return sensor data.""" if ( - self._board in self.coordinator.data["board_sensors"] - and self._sensor in self.coordinator.data["board_sensors"][self._board] + self._fan_num in self.coordinator.data["fan_sensors"] + and self._sensor in self.coordinator.data["fan_sensors"][self._fan_num] ): - return self.coordinator.data["board_sensors"][self._board][self._sensor] + return self.coordinator.data["fan_sensors"][self._fan_num][self._sensor] else: return None @property def name(self) -> str | None: """Return name of the entity.""" - return f"{self.coordinator.entry.title} Board #{self._board} {self.entity_description.key}" + return f"{self.coordinator.entry.title} Fan #{self._fan_num} {self.entity_description.key}" @property def device_info(self) -> entity.DeviceInfo: diff --git a/custom_components/miner/switch.py b/custom_components/miner/switch.py index 78abe8b..0324576 100644 --- a/custom_components/miner/switch.py +++ b/custom_components/miner/switch.py @@ -8,15 +8,12 @@ from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import callback -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - DOMAIN, -) +from .const import DOMAIN from .coordinator import MinerCoordinator _LOGGER = logging.getLogger(__name__) @@ -26,7 +23,7 @@ class MinerSensorEntityDescription(SensorEntityDescription): """Class describing IotaWatt sensor entities.""" - value: Callable | None = None + value: Callable = None async def async_setup_entry( @@ -39,18 +36,19 @@ async def async_setup_entry( created = set() @callback - def _create_entity(key: str) -> SwitchEntity: + def _create_entity(key: str): """Create a sensor entity.""" created.add(key) await coordinator.async_config_entry_first_refresh() - async_add_entities( - [ - MinerActiveSwitch( - coordinator=coordinator, - ) - ] - ) + if coordinator.miner.supports_shutdown: + async_add_entities( + [ + MinerActiveSwitch( + coordinator=coordinator, + ) + ] + ) # @callback # def new_data_received(): diff --git a/requirements.txt b/requirements.txt index 0ee7315..ca05151 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ colorlog==6.7.0 -homeassistant==2023.11.2 +homeassistant==2024.1.0 pip>=21.0,<23.2 ruff==0.0.267 -pyasic==0.40.3 +pyasic==0.45.1 pre-commit