From cbfa008e1a78341fd31ec2047d5baa3d57e02e91 Mon Sep 17 00:00:00 2001 From: Wilfred Allyn Date: Sat, 7 Feb 2026 15:20:10 +0000 Subject: [PATCH 1/5] feat: add set_preset() stub and tests (RED) --- pyasic/miners/base.py | 11 ++ .../backends_tests/luxminer_tests/__init__.py | 0 .../luxminer_tests/test_set_preset.py | 110 ++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 tests/miners_tests/backends_tests/luxminer_tests/__init__.py create mode 100644 tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py diff --git a/pyasic/miners/base.py b/pyasic/miners/base.py index 1b374f73f..43135bf8a 100644 --- a/pyasic/miners/base.py +++ b/pyasic/miners/base.py @@ -174,6 +174,17 @@ async def resume_mining(self) -> bool: """ return False + async def set_preset(self, name: str) -> bool: + """Set the mining preset by name. + + Parameters: + name: The name of the preset to switch to. + + Returns: + A boolean value of the success of setting the preset. + """ + return False + async def set_power_limit(self, wattage: int) -> bool: """Set the power limit to be used by the miner. diff --git a/tests/miners_tests/backends_tests/luxminer_tests/__init__.py b/tests/miners_tests/backends_tests/luxminer_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py b/tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py new file mode 100644 index 000000000..e73121377 --- /dev/null +++ b/tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py @@ -0,0 +1,110 @@ +"""Tests for LuxOS set_preset() method.""" + +import logging +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from pyasic.config.mining import MiningModeConfig +from pyasic.config.mining.presets import MiningPreset + + +def _make_preset(name, power=1000, frequency=400, voltage=8.9): + """Helper to create a MiningPreset.""" + return MiningPreset( + name=name, + power=power, + hashrate=80.0, + tuned=True, + modded_psu=False, + frequency=frequency, + voltage=voltage, + ) + + +def _make_config(active_preset_name="415MHz", available_preset_names=("190MHz", "415MHz", "565MHz")): + """Helper to create a mock config with presets.""" + presets = [_make_preset(n) for n in available_preset_names] + active = next((p for p in presets if p.name == active_preset_name), presets[0]) + config = MagicMock() + config.mining_mode.available_presets = presets + config.mining_mode.active_preset = active + return config + + +class TestSetPreset(unittest.IsolatedAsyncioTestCase): + """Test LuxOS set_preset() behavior.""" + + def _make_miner(self, atm_enabled=True, config=None, profileset_result=None): + """Create a mock LuxOS miner with controllable behavior.""" + from pyasic.miners.backends.luxminer import LUXMiner + + miner = LUXMiner.__new__(LUXMiner) + miner.rpc = MagicMock() + miner.rpc.atmset = AsyncMock() + miner.rpc.profileset = AsyncMock( + return_value=profileset_result or {"PROFILE": [{"Profile": "415MHz"}]} + ) + miner.atm_enabled = AsyncMock(return_value=atm_enabled) + miner.get_config = AsyncMock(return_value=config or _make_config()) + miner.ip = "192.168.1.237" + return miner + + async def test_switches_preset_with_atm_on(self): + """When ATM is on, should disable ATM, switch, then re-enable ATM.""" + miner = self._make_miner( + atm_enabled=True, + profileset_result={"PROFILE": [{"Profile": "190MHz"}]}, + ) + + result = await miner.set_preset("190MHz") + + self.assertTrue(result) + miner.rpc.atmset.assert_any_call(enabled=False) + miner.rpc.profileset.assert_called_once_with("190MHz") + miner.rpc.atmset.assert_any_call(enabled=True) + + async def test_switches_preset_with_atm_off(self): + """When ATM is off, should switch without toggling ATM.""" + miner = self._make_miner( + atm_enabled=False, + profileset_result={"PROFILE": [{"Profile": "190MHz"}]}, + ) + + result = await miner.set_preset("190MHz") + + self.assertTrue(result) + miner.rpc.atmset.assert_not_called() + miner.rpc.profileset.assert_called_once_with("190MHz") + + async def test_invalid_preset_name_returns_false(self): + """Should return False when preset name doesn't exist.""" + miner = self._make_miner() + + result = await miner.set_preset("nonexistent_profile") + + self.assertFalse(result) + miner.rpc.profileset.assert_not_called() + + async def test_atm_re_enable_failure_returns_true_and_warns(self): + """If preset switches but ATM re-enable fails, return True and log warning.""" + miner = self._make_miner( + atm_enabled=True, + profileset_result={"PROFILE": [{"Profile": "190MHz"}]}, + ) + # First call (disable) succeeds, second call (re-enable) fails + miner.rpc.atmset.side_effect = [None, Exception("ATM re-enable failed")] + + with self.assertLogs(level=logging.WARNING): + result = await miner.set_preset("190MHz") + + self.assertTrue(result) + miner.rpc.profileset.assert_called_once_with("190MHz") + + async def test_profileset_failure_returns_false(self): + """If RPC profileset fails, should return False.""" + miner = self._make_miner(atm_enabled=False) + miner.rpc.profileset.side_effect = Exception("RPC error") + + result = await miner.set_preset("190MHz") + + self.assertFalse(result) From 3c9cf3788802ba559af6ae02635fdc9f3e51309d Mon Sep 17 00:00:00 2001 From: Wilfred Allyn Date: Sat, 7 Feb 2026 15:55:47 +0000 Subject: [PATCH 2/5] feat: implement set_preset() for LuxOS backend (GREEN) --- pyasic/miners/backends/luxminer.py | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/pyasic/miners/backends/luxminer.py b/pyasic/miners/backends/luxminer.py index 48dbc7353..456ac27e1 100644 --- a/pyasic/miners/backends/luxminer.py +++ b/pyasic/miners/backends/luxminer.py @@ -177,6 +177,47 @@ async def atm_enabled(self) -> bool | None: pass return None + async def set_preset(self, name: str) -> bool: + config = await self.get_config() + + # Validate preset name exists + if not hasattr(config.mining_mode, "available_presets"): + logging.warning(f"{self} - Mining mode does not support presets") + return False + + available_presets = getattr(config.mining_mode, "available_presets", []) + preset_names = [p.name for p in available_presets if p.name is not None] + + if name not in preset_names: + logging.warning(f"{self} - Preset '{name}' not found in available presets: {preset_names}") + return False + + # If ATM enabled, must disable it before switching profile + re_enable_atm = False + try: + if await self.atm_enabled(): + re_enable_atm = True + await self.rpc.atmset(enabled=False) + + result = await self.rpc.profileset(name) + except APIError: + raise + except Exception as e: + logging.warning(f"{self} - Failed to set preset: {e}") + return False + + # Re-enable ATM if it was on (separate try so preset switch isn't rolled back) + if re_enable_atm: + try: + await self.rpc.atmset(enabled=True) + except Exception as e: + logging.warning(f"{self} - Preset switched to '{name}' but failed to re-enable ATM: {e}") + + if result["PROFILE"][0]["Profile"] == name: + return True + else: + return False + async def set_power_limit(self, wattage: int) -> bool: config = await self.get_config() From 65c78ea2a729605c036ffc3b42b32b0ea21568fe Mon Sep 17 00:00:00 2001 From: Wilfred Allyn Date: Sun, 8 Feb 2026 19:02:55 +0000 Subject: [PATCH 3/5] feat: add set_preset() and refactor ATM toggle into _switch_profile() Add set_preset(name) method to LUXMiner for switching mining presets by name. Validates preset against available presets before making RPC calls. Refactored the ATM disable/re-enable logic shared by set_preset() and set_power_limit() into a private _switch_profile() helper. This fixes a bug in set_power_limit() where ATM could stay disabled if the profile switch failed (the re-enable was inside the same try block as the switch). The new _switch_profile() uses a finally block to ensure ATM is always re-enabled if it was disabled, regardless of whether the profile switch succeeds or fails. --- pyasic/miners/backends/luxminer.py | 56 ++++++++++++++++++------------ 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/pyasic/miners/backends/luxminer.py b/pyasic/miners/backends/luxminer.py index 456ac27e1..0c71fff9b 100644 --- a/pyasic/miners/backends/luxminer.py +++ b/pyasic/miners/backends/luxminer.py @@ -177,6 +177,38 @@ async def atm_enabled(self) -> bool | None: pass return None + async def _switch_profile(self, preset_name: str) -> dict: + """Switch LuxOS profile with ATM temporarily disabled if needed. + + Handles the ATM disable/re-enable dance required by LuxOS when + switching profiles. ATM is always re-enabled if it was on, even + if the profile switch fails. + + Parameters: + preset_name: The name of the preset/profile to switch to. + + Returns: + The raw response dict from profileset. + + Raises: + APIError: If the RPC call returns an API-level error. + Exception: If the profile switch fails for other reasons. + """ + re_enable_atm = False + try: + if await self.atm_enabled(): + re_enable_atm = True + await self.rpc.atmset(enabled=False) + return await self.rpc.profileset(preset_name) + finally: + if re_enable_atm: + try: + await self.rpc.atmset(enabled=True) + except Exception as e: + logging.warning( + f"{self} - Failed to re-enable ATM after profile switch: {e}" + ) + async def set_preset(self, name: str) -> bool: config = await self.get_config() @@ -192,27 +224,14 @@ async def set_preset(self, name: str) -> bool: logging.warning(f"{self} - Preset '{name}' not found in available presets: {preset_names}") return False - # If ATM enabled, must disable it before switching profile - re_enable_atm = False try: - if await self.atm_enabled(): - re_enable_atm = True - await self.rpc.atmset(enabled=False) - - result = await self.rpc.profileset(name) + result = await self._switch_profile(name) except APIError: raise except Exception as e: logging.warning(f"{self} - Failed to set preset: {e}") return False - # Re-enable ATM if it was on (separate try so preset switch isn't rolled back) - if re_enable_atm: - try: - await self.rpc.atmset(enabled=True) - except Exception as e: - logging.warning(f"{self} - Preset switched to '{name}' but failed to re-enable ATM: {e}") - if result["PROFILE"][0]["Profile"] == name: return True else: @@ -242,17 +261,10 @@ async def set_power_limit(self, wattage: int) -> bool: return False # Set power to highest preset <= wattage - # If ATM enabled, must disable it before setting power limit new_preset = max(valid_presets, key=lambda x: valid_presets[x]) - re_enable_atm = False try: - if await self.atm_enabled(): - re_enable_atm = True - await self.rpc.atmset(enabled=False) - result = await self.rpc.profileset(new_preset) - if re_enable_atm: - await self.rpc.atmset(enabled=True) + result = await self._switch_profile(new_preset) except APIError: raise except Exception as e: From f1271b6b67cb1cbff07142c87ae882d9e3452908 Mon Sep 17 00:00:00 2001 From: Wilfred Allyn Date: Mon, 9 Feb 2026 17:30:10 -0800 Subject: [PATCH 4/5] feat: rename set_preset() to set_profile() with fuzzy matching Rename public API to match LuxOS terminology. Accept flexible input: "190", "190mhz", or "190MHz" all resolve to the correct firmware profile name. --- pyasic/miners/backends/luxminer.py | 46 ++++++++++++++----- pyasic/miners/base.py | 8 ++-- .../luxminer_tests/test_set_preset.py | 40 ++++++++++++---- 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/pyasic/miners/backends/luxminer.py b/pyasic/miners/backends/luxminer.py index 0c71fff9b..1a241c3b3 100644 --- a/pyasic/miners/backends/luxminer.py +++ b/pyasic/miners/backends/luxminer.py @@ -177,7 +177,7 @@ async def atm_enabled(self) -> bool | None: pass return None - async def _switch_profile(self, preset_name: str) -> dict: + async def _switch_profile(self, profile_name: str) -> dict: """Switch LuxOS profile with ATM temporarily disabled if needed. Handles the ATM disable/re-enable dance required by LuxOS when @@ -185,7 +185,7 @@ async def _switch_profile(self, preset_name: str) -> dict: if the profile switch fails. Parameters: - preset_name: The name of the preset/profile to switch to. + profile_name: The name of the profile to switch to. Returns: The raw response dict from profileset. @@ -199,7 +199,7 @@ async def _switch_profile(self, preset_name: str) -> dict: if await self.atm_enabled(): re_enable_atm = True await self.rpc.atmset(enabled=False) - return await self.rpc.profileset(preset_name) + return await self.rpc.profileset(profile_name) finally: if re_enable_atm: try: @@ -209,30 +209,52 @@ async def _switch_profile(self, preset_name: str) -> dict: f"{self} - Failed to re-enable ATM after profile switch: {e}" ) - async def set_preset(self, name: str) -> bool: + @staticmethod + def _match_profile_name(name: str, profile_names: list[str]) -> str | None: + """Match user input to an available profile name. + + Tries exact match first, then case-insensitive, then checks if + appending "MHz" yields a match (so "190" matches "190MHz"). + """ + if name in profile_names: + return name + + lower = name.lower() + for p in profile_names: + if p.lower() == lower: + return p + + for p in profile_names: + if p.lower() == lower + "mhz": + return p + + return None + + async def set_profile(self, name: str) -> bool: config = await self.get_config() - # Validate preset name exists + # Validate profile name exists if not hasattr(config.mining_mode, "available_presets"): - logging.warning(f"{self} - Mining mode does not support presets") + logging.warning(f"{self} - Mining mode does not support profiles") return False available_presets = getattr(config.mining_mode, "available_presets", []) - preset_names = [p.name for p in available_presets if p.name is not None] + profile_names = [p.name for p in available_presets if p.name is not None] - if name not in preset_names: - logging.warning(f"{self} - Preset '{name}' not found in available presets: {preset_names}") + matched_name = self._match_profile_name(name, profile_names) + if matched_name is None: + logging.warning(f"{self} - Profile '{name}' not found in available profiles: {profile_names}") return False try: - result = await self._switch_profile(name) + result = await self._switch_profile(matched_name) except APIError: raise except Exception as e: - logging.warning(f"{self} - Failed to set preset: {e}") + logging.warning(f"{self} - Failed to set profile: {e}") return False - if result["PROFILE"][0]["Profile"] == name: + if result["PROFILE"][0]["Profile"] == matched_name: return True else: return False diff --git a/pyasic/miners/base.py b/pyasic/miners/base.py index 43135bf8a..c67a362c7 100644 --- a/pyasic/miners/base.py +++ b/pyasic/miners/base.py @@ -174,14 +174,14 @@ async def resume_mining(self) -> bool: """ return False - async def set_preset(self, name: str) -> bool: - """Set the mining preset by name. + async def set_profile(self, name: str) -> bool: + """Set the mining profile by name. Parameters: - name: The name of the preset to switch to. + name: The name of the profile to switch to. Returns: - A boolean value of the success of setting the preset. + A boolean value of the success of setting the profile. """ return False diff --git a/tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py b/tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py index e73121377..c612828ad 100644 --- a/tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py +++ b/tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py @@ -1,4 +1,4 @@ -"""Tests for LuxOS set_preset() method.""" +"""Tests for LuxOS set_profile() method.""" import logging import unittest @@ -31,8 +31,8 @@ def _make_config(active_preset_name="415MHz", available_preset_names=("190MHz", return config -class TestSetPreset(unittest.IsolatedAsyncioTestCase): - """Test LuxOS set_preset() behavior.""" +class TestSetProfile(unittest.IsolatedAsyncioTestCase): + """Test LuxOS set_profile() behavior.""" def _make_miner(self, atm_enabled=True, config=None, profileset_result=None): """Create a mock LuxOS miner with controllable behavior.""" @@ -56,7 +56,7 @@ async def test_switches_preset_with_atm_on(self): profileset_result={"PROFILE": [{"Profile": "190MHz"}]}, ) - result = await miner.set_preset("190MHz") + result = await miner.set_profile("190MHz") self.assertTrue(result) miner.rpc.atmset.assert_any_call(enabled=False) @@ -70,7 +70,7 @@ async def test_switches_preset_with_atm_off(self): profileset_result={"PROFILE": [{"Profile": "190MHz"}]}, ) - result = await miner.set_preset("190MHz") + result = await miner.set_profile("190MHz") self.assertTrue(result) miner.rpc.atmset.assert_not_called() @@ -80,7 +80,7 @@ async def test_invalid_preset_name_returns_false(self): """Should return False when preset name doesn't exist.""" miner = self._make_miner() - result = await miner.set_preset("nonexistent_profile") + result = await miner.set_profile("nonexistent_profile") self.assertFalse(result) miner.rpc.profileset.assert_not_called() @@ -95,7 +95,31 @@ async def test_atm_re_enable_failure_returns_true_and_warns(self): miner.rpc.atmset.side_effect = [None, Exception("ATM re-enable failed")] with self.assertLogs(level=logging.WARNING): - result = await miner.set_preset("190MHz") + result = await miner.set_profile("190MHz") + + self.assertTrue(result) + miner.rpc.profileset.assert_called_once_with("190MHz") + + async def test_fuzzy_match_case_insensitive(self): + """Should match preset name case-insensitively.""" + miner = self._make_miner( + atm_enabled=False, + profileset_result={"PROFILE": [{"Profile": "190MHz"}]}, + ) + + result = await miner.set_profile("190mhz") + + self.assertTrue(result) + miner.rpc.profileset.assert_called_once_with("190MHz") + + async def test_fuzzy_match_number_only(self): + """Should match '190' to '190MHz'.""" + miner = self._make_miner( + atm_enabled=False, + profileset_result={"PROFILE": [{"Profile": "190MHz"}]}, + ) + + result = await miner.set_profile("190") self.assertTrue(result) miner.rpc.profileset.assert_called_once_with("190MHz") @@ -105,6 +129,6 @@ async def test_profileset_failure_returns_false(self): miner = self._make_miner(atm_enabled=False) miner.rpc.profileset.side_effect = Exception("RPC error") - result = await miner.set_preset("190MHz") + result = await miner.set_profile("190MHz") self.assertFalse(result) From 4e9fa5979c409448b430032182cb425c20c24809 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:39:03 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyasic/miners/backends/luxminer.py | 4 +++- .../backends_tests/luxminer_tests/test_set_preset.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyasic/miners/backends/luxminer.py b/pyasic/miners/backends/luxminer.py index 1a241c3b3..7671b01aa 100644 --- a/pyasic/miners/backends/luxminer.py +++ b/pyasic/miners/backends/luxminer.py @@ -243,7 +243,9 @@ async def set_profile(self, name: str) -> bool: matched_name = self._match_profile_name(name, profile_names) if matched_name is None: - logging.warning(f"{self} - Profile '{name}' not found in available profiles: {profile_names}") + logging.warning( + f"{self} - Profile '{name}' not found in available profiles: {profile_names}" + ) return False try: diff --git a/tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py b/tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py index c612828ad..c1a841b07 100644 --- a/tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py +++ b/tests/miners_tests/backends_tests/luxminer_tests/test_set_preset.py @@ -21,7 +21,9 @@ def _make_preset(name, power=1000, frequency=400, voltage=8.9): ) -def _make_config(active_preset_name="415MHz", available_preset_names=("190MHz", "415MHz", "565MHz")): +def _make_config( + active_preset_name="415MHz", available_preset_names=("190MHz", "415MHz", "565MHz") +): """Helper to create a mock config with presets.""" presets = [_make_preset(n) for n in available_preset_names] active = next((p for p in presets if p.name == active_preset_name), presets[0])