Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion custom_components/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
"""Dummy init so that pytest works."""
53 changes: 42 additions & 11 deletions custom_components/miner/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
"""The Miner integration."""
from __future__ import annotations


try:
import pyasic
except ImportError:
from .patch import install_package
from .const import PYASIC_VERSION

install_package(f"pyasic=={PYASIC_VERSION}")
import pyasic
import sys

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
Expand All @@ -18,8 +10,7 @@

from .const import CONF_IP
from .const import DOMAIN
from .coordinator import MinerCoordinator
from .services import async_setup_services
from .const import PYASIC_VERSION

PLATFORMS: list[Platform] = [
Platform.SENSOR,
Expand All @@ -29,8 +20,48 @@
]


def _ensure_pyasic():
"""Ensure pyasic is installed and imported (runs in executor)."""
import importlib

def try_import():
try:
from importlib.metadata import version
import pyasic
# Verify the module actually loaded correctly
if not hasattr(pyasic, "get_miner"):
raise ImportError("pyasic module incomplete")
if version("pyasic") != PYASIC_VERSION:
raise ImportError("Version mismatch")
return pyasic
except Exception:
return None

pyasic = try_import()
if pyasic:
return pyasic

# Need to install/reinstall
from .patch import install_package
install_package(f"pyasic=={PYASIC_VERSION}", force_reinstall=True)

# Clear any cached broken imports
for mod_name in list(sys.modules.keys()):
if mod_name.startswith("pyasic"):
del sys.modules[mod_name]

import pyasic
return pyasic


async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up Miner from a config entry."""
# Import pyasic in executor to avoid blocking the event loop
pyasic = await hass.async_add_executor_job(_ensure_pyasic)

# Import coordinator and services AFTER pyasic is installed
from .coordinator import MinerCoordinator
from .services import async_setup_services

miner_ip = config_entry.data[CONF_IP]
miner = await pyasic.get_miner(miner_ip)
Expand Down
57 changes: 37 additions & 20 deletions custom_components/miner/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
"""Config flow for Miner."""
import logging
from importlib.metadata import version
from typing import TYPE_CHECKING

from .const import PYASIC_VERSION

try:
if TYPE_CHECKING:
import pyasic

if not version("pyasic") == PYASIC_VERSION:
raise ImportError
except ImportError:
from .patch import install_package

install_package(f"pyasic=={PYASIC_VERSION}")
import pyasic

from pyasic import MinerNetwork
from pyasic.device.makes import MinerMake

import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import network
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_flow import register_discovery_flow
from homeassistant.helpers.selector import TextSelector
from homeassistant.helpers.selector import TextSelectorConfig
from homeassistant.helpers.selector import TextSelectorType
Expand All @@ -41,8 +29,26 @@
_LOGGER = logging.getLogger(__name__)


def _ensure_pyasic():
"""Ensure pyasic is installed and imported."""
try:
from importlib.metadata import version
import pyasic
if version("pyasic") != PYASIC_VERSION:
raise ImportError("Version mismatch")
return pyasic
except (ImportError, Exception):
from .patch import install_package
install_package(f"pyasic=={PYASIC_VERSION}")
import pyasic
return pyasic


async def _async_has_devices(hass: HomeAssistant) -> bool:
"""Return if there are devices that can be discovered."""
pyasic = await hass.async_add_executor_job(_ensure_pyasic)
from pyasic import MinerNetwork

adapters = await network.async_get_adapters(hass)

for adapter in adapters:
Expand All @@ -56,13 +62,12 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
return False


register_discovery_flow(DOMAIN, "miner", _async_has_devices)


async def validate_ip_input(
data: dict[str, str]
) -> tuple[dict[str, str], pyasic.AnyMiner | None]:
hass: HomeAssistant,
data: dict[str, str],
):
"""Validate the user input allows us to connect."""
pyasic = await hass.async_add_executor_job(_ensure_pyasic)
miner_ip = data.get(CONF_IP)

miner = await pyasic.get_miner(miner_ip)
Expand Down Expand Up @@ -102,7 +107,7 @@ async def async_step_user(self, user_input=None):
if not user_input:
return self.async_show_form(step_id="user", data_schema=schema)

errors, miner = await validate_ip_input(user_input)
errors, miner = await validate_ip_input(self.hass, user_input)

if errors:
return self.async_show_form(
Expand All @@ -119,6 +124,7 @@ async def async_step_login(self, user_input=None):
user_input = {}

# Detect BitAxe miners and skip credential prompts
from pyasic.device.makes import MinerMake
if self._miner.make == MinerMake.BITAXE:
return await self.async_step_title()

Expand Down Expand Up @@ -226,3 +232,14 @@ async def async_step_title(self, user_input=None):
self._data.update(user_input)

return self.async_create_entry(title=self._data[CONF_TITLE], data=self._data)

async def async_step_discovery(self, discovery_info):
"""Handle discovery."""
if self._async_current_entries():
return self.async_abort(reason="already_configured")

has_devices = await _async_has_devices(self.hass)
if not has_devices:
return self.async_abort(reason="no_devices_found")

return await self.async_step_user()
2 changes: 1 addition & 1 deletion custom_components/miner/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@
JOULES_PER_TERA_HASH = "J/TH"


PYASIC_VERSION = "0.75.0"
PYASIC_VERSION = "0.78.8"
102 changes: 60 additions & 42 deletions custom_components/miner/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
"""Miner DataUpdateCoordinator."""
import logging
from datetime import timedelta
from importlib.metadata import version
from typing import TYPE_CHECKING, Any

from .const import PYASIC_VERSION

try:
import pyasic

if not version("pyasic") == PYASIC_VERSION:
raise ImportError
except ImportError:
from .patch import install_package

install_package(f"pyasic=={PYASIC_VERSION}")
if TYPE_CHECKING:
import pyasic

from homeassistant.config_entries import ConfigEntry
Expand Down Expand Up @@ -62,7 +52,7 @@
class MinerCoordinator(DataUpdateCoordinator):
"""Class to manage fetching update data from the Miner."""

miner: pyasic.AnyMiner = None
miner: "pyasic.AnyMiner" = None

def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize MinerCoordinator object."""
Expand All @@ -89,6 +79,8 @@ def available(self):

async def get_miner(self):
"""Get a valid Miner instance."""
import pyasic # lazy import to avoid blocking event loop

miner_ip = self.config_entry.data[CONF_IP]
miner = await pyasic.get_miner(miner_ip)
if miner is None:
Expand All @@ -110,6 +102,7 @@ async def get_miner(self):

async def _async_update_data(self):
"""Fetch sensors from miners."""
import pyasic # lazy import to avoid blocking event loop

miner = await self.get_miner()

Expand All @@ -133,39 +126,64 @@ async def _async_update_data(self):
# At this point, miner is valid
_LOGGER.debug(f"Found miner: {self.miner}")

# Base data options to fetch
data_options = [
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,
pyasic.DataOptions.CONFIG,
]

try:
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,
pyasic.DataOptions.CONFIG,
]
)
miner_data = await self.miner.get_data(include=data_options)
except Exception as err:
self._failure_count += 1

if self._failure_count == 1:
# Some firmwares have issues with CONFIG - retry without it
if "config" in str(err).lower():
_LOGGER.warning(
f"Error fetching miner data: {err} – returning zeroed data (first failure)."
f"Config fetch failed for {self.miner}, retrying without CONFIG: {err}"
)
return {
**DEFAULT_DATA,
"power_limit_range": {
"min": self.config_entry.data.get(CONF_MIN_POWER, 15),
"max": self.config_entry.data.get(CONF_MAX_POWER, 10000),
},
}

_LOGGER.exception(err)
raise UpdateFailed from err
data_options.remove(pyasic.DataOptions.CONFIG)
try:
miner_data = await self.miner.get_data(include=data_options)
except Exception as retry_err:
self._failure_count += 1
if self._failure_count == 1:
_LOGGER.warning(
f"Error fetching miner data: {retry_err} – returning zeroed data (first failure)."
)
return {
**DEFAULT_DATA,
"power_limit_range": {
"min": self.config_entry.data.get(CONF_MIN_POWER, 15),
"max": self.config_entry.data.get(CONF_MAX_POWER, 10000),
},
}
_LOGGER.exception(retry_err)
raise UpdateFailed from retry_err
else:
self._failure_count += 1

if self._failure_count == 1:
_LOGGER.warning(
f"Error fetching miner data: {err} – returning zeroed data (first failure)."
)
return {
**DEFAULT_DATA,
"power_limit_range": {
"min": self.config_entry.data.get(CONF_MIN_POWER, 15),
"max": self.config_entry.data.get(CONF_MAX_POWER, 10000),
},
}

_LOGGER.exception(err)
raise UpdateFailed from err

_LOGGER.debug(f"Got data: {miner_data}")

Expand Down
2 changes: 1 addition & 1 deletion custom_components/miner/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
"issue_tracker": "https://github.com/Schnitzel/hass-miner/issues",
"requirements": [],
"ssdp": [],
"version": "1.2.7-rc2",
"version": "1.2.8",
"zeroconf": []
}
15 changes: 3 additions & 12 deletions custom_components/miner/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,9 @@
from __future__ import annotations

import logging
from importlib.metadata import version
from typing import TYPE_CHECKING

from .const import PYASIC_VERSION

try:
import pyasic

if not version("pyasic") == PYASIC_VERSION:
raise ImportError
except ImportError:
from .patch import install_package

install_package(f"pyasic=={PYASIC_VERSION}")
if TYPE_CHECKING:
import pyasic

from homeassistant.components.number import NumberEntityDescription, NumberDeviceClass
Expand Down Expand Up @@ -124,6 +114,7 @@ def native_unit_of_measurement(self):

async def async_set_native_value(self, value):
"""Update the current value."""
import pyasic # lazy import to avoid blocking event loop

miner = self.coordinator.miner

Expand Down
3 changes: 3 additions & 0 deletions custom_components/miner/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def install_package(
target: str | None = None,
constraints: str | None = None,
timeout: int | None = None,
force_reinstall: bool = False,
) -> bool:
"""Install a package on PyPi. Accepts pip compatible package strings.

Expand Down Expand Up @@ -50,6 +51,8 @@ def install_package(
env["HTTP_TIMEOUT"] = str(timeout)
if upgrade:
args.append("--upgrade")
if force_reinstall:
args.append("--reinstall")
if constraints is not None:
args += ["--constraint", constraints]
if target:
Expand Down
Loading
Loading