diff --git a/.github/workflows/dev-release-pr.yml b/.github/workflows/dev-release-pr.yml new file mode 100644 index 0000000..e004421 --- /dev/null +++ b/.github/workflows/dev-release-pr.yml @@ -0,0 +1,69 @@ +name: Open dev -> master release PR (with version bump) + +on: + push: + branches: [dev] + +permissions: + contents: write + pull-requests: write + +jobs: + release-pr: + if: "!contains(github.event.head_commit.message, 'chore: bump version')" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: dev + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Check for existing dev -> master PR + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + existing=$(gh pr list --base master --head dev --state open --json number --jq '.[0].number') + echo "existing=$existing" >> "$GITHUB_OUTPUT" + if [ -n "$existing" ]; then + echo "PR #$existing already open; skipping version bump." + fi + + - name: Bump patch version in setup.py + if: steps.check.outputs.existing == '' + run: | + python - <<'PY' + import re, pathlib + p = pathlib.Path("setup.py") + s = p.read_text() + m = re.search(r'version="(\d+)\.(\d+)\.(\d+)"', s) + if not m: + raise SystemExit("version not found") + maj, mnr, pat = map(int, m.groups()) + new = f'version="{maj}.{mnr}.{pat+1}"' + p.write_text(s[:m.start()] + new + s[m.end():]) + print("Bumped to", new) + PY + + - name: Commit bump + if: steps.check.outputs.existing == '' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add setup.py + if ! git diff --cached --quiet; then + git commit -m "chore: bump version [skip ci]" + git push + fi + + - name: Create dev -> master PR + if: steps.check.outputs.existing == '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr create \ + --base master \ + --head dev \ + --title "Release: dev -> master" \ + --body "Automated release PR. Contains merged feature PRs and a single version bump." diff --git a/.github/workflows/guard-master.yml b/.github/workflows/guard-master.yml new file mode 100644 index 0000000..f5f98cc --- /dev/null +++ b/.github/workflows/guard-master.yml @@ -0,0 +1,17 @@ +name: Guard master branch + +on: + pull_request: + branches: [master] + +jobs: + source-branch-is-dev: + runs-on: ubuntu-latest + steps: + - name: Ensure PR source is dev + run: | + if [ "${{ github.head_ref }}" != "dev" ]; then + echo "::error::PRs into master must come from 'dev' (got '${{ github.head_ref }}')." + exit 1 + fi + echo "Source branch is dev. OK." diff --git a/.gitignore b/.gitignore index 80496ef..fc2821f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,38 @@ custom_tests/* # virtual environment folder .venv/ + + +# IDE specific files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS specific files +.DS_Store + + +# Python cache files +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info/ +dist/ +build/ +*.manifest +*.spec + +# Virtual Environment +venv/ +env/ +ENV/ +.venv + + +# Claude superpowers +superpowers/ \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index e3ff3ab..5a44002 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,5 @@ tox pylint pytest +pytest-asyncio pbr \ No newline at end of file diff --git a/src/api/hive_api.py b/src/api/hive_api.py index 6f21c81..07983c7 100644 --- a/src/api/hive_api.py +++ b/src/api/hive_api.py @@ -18,7 +18,6 @@ class HiveApi: def __init__(self, hiveSession=None, websession=None, token=None): """Hive API initialisation.""" - self.cameraBaseUrl = "prod.hcam.bgchtest.info" self.urls = { "properties": "https://sso.hivehome.com/", "login": "https://beekeeper.hivehome.com/1.0/cognito/login", @@ -29,8 +28,6 @@ def __init__(self, hiveSession=None, websession=None, token=None): "holiday_mode": "/holiday-mode", "all": "/nodes/all?products=true&devices=true&actions=true", "alarm": "/security-lite?homeId=", - "cameraImages": f"https://event-history-service.{self.cameraBaseUrl}/v1/events/cameras?latest=true&cameraId={{0}}", - "cameraRecordings": f"https://event-history-service.{self.cameraBaseUrl}/v1/playlist/cameras/{{0}}/events/{{1}}.m3u8", "devices": "/devices", "products": "/products", "actions": "/actions", @@ -44,40 +41,24 @@ def __init__(self, hiveSession=None, websession=None, token=None): self.session = hiveSession self.token = token - def request(self, type, url, jsc=None, camera=False): + def request(self, type, url, jsc=None): """Make API request.""" _LOGGER.debug("request - Making %s request to: %s", type, url) if jsc: _LOGGER.debug("request - Request payload: %s", jsc) if self.session is not None: - if camera: - self.headers = { - "content-type": "application/json", - "Accept": "*/*", - "Authorization": f"Bearer {self.session.tokens.tokenData['token']}", - "x-jwt-token": self.session.tokens.tokenData["token"], - } - else: - self.headers = { - "content-type": "application/json", - "Accept": "*/*", - "authorization": self.session.tokens.tokenData["token"], - } + self.headers = { + "content-type": "application/json", + "Accept": "*/*", + "authorization": self.session.tokens.tokenData["token"], + } else: - if camera: - self.headers = { - "content-type": "application/json", - "Accept": "*/*", - "Authorization": f"Bearer {self.token}", - "x-jwt-token": self.token, - } - else: - self.headers = { - "content-type": "application/json", - "Accept": "*/*", - "authorization": self.token, - } + self.headers = { + "content-type": "application/json", + "Accept": "*/*", + "authorization": self.token, + } _LOGGER.debug( "request - Request headers: %s", @@ -119,7 +100,6 @@ def refreshTokens(self, tokens={}): ) self.session.updateTokens(data) self.urls.update({"base": data["platform"]["endpoint"]}) - self.urls.update({"camera": data["platform"]["cameraPlatform"]}) self.json_return.update({"original": info.status_code}) self.json_return.update({"parsed": info.json()}) except (OSError, RuntimeError, ZeroDivisionError, json.JSONDecodeError) as e: @@ -200,34 +180,6 @@ def getAlarm(self, homeID=None): return self.json_return - def getCameraImage(self, device=None, accessToken=None): - """Build and query camera endpoint.""" - json_return = {} - url = self.urls["cameraImages"].format(device["props"]["hardwareIdentifier"]) - try: - info = self.request("GET", url, camera=True) - json_return.update({"original": info.status_code}) - json_return.update({"parsed": info.json()}) - except (OSError, RuntimeError, ZeroDivisionError): - self.error() - - return json_return - - def getCameraRecording(self, device=None, eventId=None): - """Build and query camera endpoint.""" - json_return = {} - url = self.urls["cameraRecordings"].format( - device["props"]["hardwareIdentifier"], eventId - ) - try: - info = self.request("GET", url, camera=True) - json_return.update({"original": info.status_code}) - json_return.update({"parsed": info.text.split("\n")[3]}) - except (OSError, RuntimeError, ZeroDivisionError): - self.error() - - return json_return - def getDevices(self): """Call the get devices endpoint.""" url = self.urls["base"] + self.urls["devices"] diff --git a/src/api/hive_async_api.py b/src/api/hive_async_api.py index 9ff9d18..061a722 100644 --- a/src/api/hive_async_api.py +++ b/src/api/hive_async_api.py @@ -26,7 +26,6 @@ class HiveApiAsync: def __init__(self, hiveSession=None, websession: Optional[ClientSession] = None): """Hive API initialisation.""" self.baseUrl = "https://beekeeper.hivehome.com/1.0" - self.cameraBaseUrl = "prod.hcam.bgchtest.info" self.urls = { "properties": "https://sso.hivehome.com/", "login": f"{self.baseUrl}/cognito/login", @@ -34,8 +33,6 @@ def __init__(self, hiveSession=None, websession: Optional[ClientSession] = None) "holiday_mode": f"{self.baseUrl}/holiday-mode", "all": f"{self.baseUrl}/nodes/all?products=true&devices=true&actions=true", "alarm": f"{self.baseUrl}/security-lite?homeId=", - "cameraImages": f"https://event-history-service.{self.cameraBaseUrl}/v1/events/cameras?latest=true&cameraId={{0}}", - "cameraRecordings": f"https://event-history-service.{self.cameraBaseUrl}/v1/playlist/cameras/{{0}}/events/{{1}}.m3u8", "devices": f"{self.baseUrl}/devices", "products": f"{self.baseUrl}/products", "actions": f"{self.baseUrl}/actions", @@ -51,9 +48,7 @@ def __init__(self, hiveSession=None, websession: Optional[ClientSession] = None) self.session = hiveSession self.websession = ClientSession() if websession is None else websession - async def request( - self, method: str, url: str, camera: bool = False, **kwargs - ) -> ClientResponse: + async def request(self, method: str, url: str, **kwargs) -> ClientResponse: """Make a request.""" _LOGGER.debug("API %s request to %s", method.upper(), url) data = kwargs.get("data", None) @@ -64,13 +59,7 @@ async def request( "User-Agent": "Hive/12.04.0 iOS/18.3.1 Apple", } try: - if camera: - headers["Authorization"] = ( - f"Bearer {self.session.tokens.tokenData['token']}" - ) - headers["x-jwt-token"] = self.session.tokens.tokenData["token"] - else: - headers["Authorization"] = self.session.tokens.tokenData["token"] + headers["Authorization"] = self.session.tokens.tokenData["token"] except KeyError: if "sso" in url: pass @@ -158,7 +147,6 @@ async def refreshTokens(self): if "token" in info: await self.session.updateTokens(info) self.baseUrl = info["platform"]["endpoint"] - self.cameraBaseUrl = info["platform"]["cameraPlatform"] return True except (ConnectionError, OSError, RuntimeError, ZeroDivisionError): await self.error() @@ -194,35 +182,6 @@ async def getAlarm(self): return json_return - async def getCameraImage(self, device): - """Build and query alarm endpoint.""" - json_return = {} - url = self.urls["cameraImages"].format(device["props"]["hardwareIdentifier"]) - try: - resp = await self.request("get", url, True) - json_return.update({"original": resp.status}) - json_return.update({"parsed": await resp.json(content_type=None)}) - except (OSError, RuntimeError, ZeroDivisionError): - await self.error() - - return json_return - - async def getCameraRecording(self, device, eventId): - """Build and query alarm endpoint.""" - json_return = {} - url = self.urls["cameraRecordings"].format( - device["props"]["hardwareIdentifier"], eventId - ) - try: - resp = await self.request("get", url, True) - recUrl = await resp.text() - json_return.update({"original": resp.status}) - json_return.update({"parsed": recUrl.split("\n")[3]}) - except (OSError, RuntimeError, ZeroDivisionError): - await self.error() - - return json_return - async def getDevices(self): """Call the get devices endpoint.""" json_return = {} diff --git a/src/camera.py b/src/camera.py deleted file mode 100644 index 4418979..0000000 --- a/src/camera.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Hive Camera Module.""" - -# pylint: skip-file -import logging - -_LOGGER = logging.getLogger(__name__) - - -class HiveCamera: - """Hive camera. - - Returns: - object: Hive camera - """ - - cameraType = "Camera" - - async def getCameraTemperature(self, device: dict): - """Get the camera state. - - Returns: - boolean: True/False if camera is on. - """ - state = None - - try: - data = self.session.data.devices[device["hiveID"]] - state = data["props"]["temperature"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def getCameraState(self, device: dict): - """Get the camera state. - - Returns: - boolean: True/False if camera is on. - """ - state = None - - try: - data = self.session.data.devices[device["hiveID"]] - state = True if data["state"]["mode"] == "ARMED" else False - except KeyError as e: - _LOGGER.error(e) - - return state - - async def getCameraImageURL(self, device: dict): - """Get the camera image url. - - Returns: - str: image url. - """ - state = None - - try: - state = self.session.data.camera[device["hiveID"]]["cameraImage"][ - "thumbnailUrls" - ][0] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def getCameraRecodringURL(self, device: dict): - """Get the camera recording url. - - Returns: - str: image url. - """ - state = None - - try: - state = self.session.data.camera[device["hiveID"]]["cameraRecording"] - except KeyError as e: - _LOGGER.error(e) - - return state - - async def setCameraOn(self, device: dict, mode: str): - """Set the camera state to on. - - Args: - device (dict): Camera device. - - Returns: - boolean: True/False if successful. - """ - final = False - - if ( - device["hiveID"] in self.session.data.devices - and device["deviceData"]["online"] - ): - _LOGGER.debug("setCameraOn - Setting camera ON for %s.", device["haName"]) - await self.session.hiveRefreshTokens() - resp = await self.session.api.setState(mode=mode) - if resp["original"] == 200: - final = True - await self.session.getCamera() - - return final - - async def setCameraOff(self, device: dict, mode: str): - """Set the camera state to on. - - Args: - device (dict): Camera device. - - Returns: - boolean: True/False if successful. - """ - final = False - - if ( - device["hiveID"] in self.session.data.devices - and device["deviceData"]["online"] - ): - _LOGGER.debug("setCameraOff - Setting camera OFF for %s.", device["haName"]) - await self.session.hiveRefreshTokens() - resp = await self.session.api.setState(mode=mode) - if resp["original"] == 200: - final = True - await self.session.getCamera() - - return final - - -class Camera(HiveCamera): - """Home assistant camera. - - Args: - HiveCamera (object): Class object. - """ - - def __init__(self, session: object = None): - """Initialise camera. - - Args: - session (object, optional): Used to interact with the hive account. Defaults to None. - """ - self.session = session - - async def getCamera(self, device: dict): - """Get camera data. - - Args: - device (dict): Device to update. - - Returns: - dict: Updated device. - """ - if self.session.shouldUseCachedData(): - cached = self.session.getCachedDevice(device) - if cached is not None: - _LOGGER.debug( - "getCamera - Returning cached state for camera %s (slow/busy poll).", - device["haName"], - ) - return cached - device["deviceData"].update( - {"online": await self.session.attr.onlineOffline(device["device_id"])} - ) - dev_data = {} - - if device["deviceData"]["online"]: - self.session.helper.deviceRecovered(device["device_id"]) - _LOGGER.debug("getCamera - Updating camera data for %s.", device["haName"]) - data = self.session.data.devices[device["device_id"]] - dev_data = { - "hiveID": device["hiveID"], - "hiveName": device["hiveName"], - "hiveType": device["hiveType"], - "haName": device["haName"], - "haType": device["haType"], - "device_id": device["device_id"], - "device_name": device["device_name"], - "status": { - "temperature": await self.getCameraTemperature(device), - "state": await self.getCameraState(device), - "imageURL": await self.getCameraImageURL(device), - "recordingURL": await self.getCameraRecodringURL(device), - }, - "deviceData": data.get("props", None), - "parentDevice": data.get("parent", None), - "custom": device.get("custom", None), - "attributes": await self.session.attr.stateAttributes( - device["device_id"], device["hiveType"] - ), - } - - return self.session.setCachedDevice(device, dev_data) - else: - await self.session.helper.errorCheck( - device["device_id"], "ERROR", device["deviceData"]["online"] - ) - device.setdefault("status", {"state": None}) - return device diff --git a/src/data/camera.json b/src/data/camera.json deleted file mode 100644 index 604ffbe..0000000 --- a/src/data/camera.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "cameraImage": { - "parsed": { - "events": [ - { - "thumbnailUrls": [ - "https://test.com/image" - ], - "hasRecording": true - } - ] - } - }, - "camaeraRecording": { - "parsed": "https://test.com/video" - } -} \ No newline at end of file diff --git a/src/helper/const.py b/src/helper/const.py index 3fa5588..326f242 100644 --- a/src/helper/const.py +++ b/src/helper/const.py @@ -60,7 +60,6 @@ "SMOKE_CO": "self.session.hub.getSmokeStatus(device)", "DOG_BARK": "self.session.hub.getDogBarkStatus(device)", "GLASS_BREAK": "self.session.hub.getGlassBreakStatus(device)", - "Camera_Temp": "self.session.camera.getCameraTemperature(device)", "Current_Temperature": "self.session.heating.getCurrentTemperature(device)", "Heating_Current_Temperature": "self.session.heating.getCurrentTemperature(device)", "Heating_Target_Temperature": "self.session.heating.getTargetTemperature(device)", @@ -127,12 +126,6 @@ 'addList("sensor", p, haName=" Mode", hiveType="Mode", category="diagnostic")', 'addList("sensor", p, haName=" Availability", hiveType="Availability", category="diagnostic")', ], - # "hivecamera": [ - # 'addList("camera", p)', - # 'addList("sensor", p, haName=" Mode", hiveType="Mode", category="diagnostic")', - # 'addList("sensor", p, haName=" Availability", hiveType="Availability", category="diagnostic")', - # 'addList("sensor", p, haName=" Temperature", hiveType="Camera_Temp", category="diagnostic")', - # ], "motionsensor": [ 'addList("binary_sensor", p)', 'addList("sensor", p, haName=" Current Temperature", hiveType="Current_Temperature", category="diagnostic")', diff --git a/src/hive.py b/src/hive.py index 30cdbeb..d7f5f24 100644 --- a/src/hive.py +++ b/src/hive.py @@ -1,6 +1,7 @@ """Start Hive Session.""" # pylint: skip-file +import asyncio import logging import sys import traceback @@ -11,7 +12,6 @@ from .action import HiveAction from .alarm import Alarm -from .camera import Camera from .heating import Climate from .hotwater import WaterHeater from .hub import HiveHub @@ -106,7 +106,6 @@ def __init__( self.session = self self.action = HiveAction(self.session) self.alarm = Alarm(self.session) - self.camera = Camera(self.session) self.heating = Climate(self.session) self.hotwater = WaterHeater(self.session) self.hub = HiveHub(self.session) @@ -131,3 +130,19 @@ def setDebugging(self, debugger: list): if debug: return sys.settrace(trace_debug) return sys.settrace(None) + + async def forceUpdate(self) -> bool: + """Immediately poll the Hive API, bypassing the 2-minute interval. + + For power users only. If a poll is already in progress, skips and + returns False. Otherwise polls and returns True on success. + """ + if self.updateLock.locked(): + _LOGGER.debug("forceUpdate called while poll in progress — skipping.") + return False + async with self.updateLock: + self._updateTask = asyncio.current_task() + try: + return await self._pollDevices() + finally: + self._updateTask = None diff --git a/src/session.py b/src/session.py index a61a8fd..525abda 100644 --- a/src/session.py +++ b/src/session.py @@ -26,11 +26,12 @@ HiveReauthRequired, HiveRefreshTokenExpired, HiveUnknownConfiguration, - NoApiToken, ) from .helper.hive_helper import HiveHelper from .helper.map import Map +_SCAN_INTERVAL = timedelta(seconds=120) + _LOGGER = logging.getLogger(__name__) @@ -82,13 +83,12 @@ def __init__( { "alarm": False, "battery": [], - "camera": False, "errorList": {}, "file": False, "homeID": None, "lastUpdate": datetime.now(), "mode": [], - "scanInterval": timedelta(seconds=120), + "scanInterval": _SCAN_INTERVAL, "userID": None, "username": username, } @@ -101,7 +101,6 @@ def __init__( "user": {}, "minMax": {}, "alarm": {}, - "camera": {}, } ) self.entityCache = {} @@ -146,6 +145,10 @@ def shouldUseCachedData(self): return self._updateTask is None or current_task is not self._updateTask return False + async def _pollDevices(self) -> bool: + """Fetch latest device state from the Hive API.""" + return await self.getDevices("No_ID") + def openFile(self, file: str): """Open a file. @@ -211,20 +214,6 @@ def addList(self, entityType: str, data: dict, **kwargs: dict): _LOGGER.error(error) return None - async def updateInterval(self, new_interval: timedelta): - """Update the scan interval. - - Args: - new_interval (int): New interval for polling. - """ - if isinstance(new_interval, int): - new_interval = timedelta(seconds=new_interval) - - interval = new_interval - if interval < timedelta(seconds=15): - interval = timedelta(seconds=15) - self.config.scanInterval = interval - async def useFile(self, username: str = None): """Update to check if file is being used. @@ -586,13 +575,8 @@ async def updateData(self, device: dict): return updated self._updateTask = current_task try: - _LOGGER.debug("updateData - Polling Hive API for device updates.") - updated = await self.getDevices(device["hiveID"]) - if updated and len(self.deviceList["camera"]) > 0: - for camera in self.data.camera: - camera_device = self.data.devices.get(camera) - if camera_device is not None: - await self.getCamera(camera_device) + _LOGGER.debug("Polling Hive API for device updates.") + updated = await self._pollDevices() if updated: _LOGGER.debug( "updateData - Device update completed successfully." @@ -625,54 +609,6 @@ async def getAlarm(self): self.data.alarm = api_resp_d["parsed"] - async def getCamera(self, device): - """Get camera data. - - Raises: - HTTPException: HTTP error has occurred updating the devices. - HiveApiError: An API error code has been returned. - """ - cameraImage = None - cameraRecording = None - hasCameraImage = False - hasCameraRecording = False - - if self.config.file: - cameraImage = self.openFile("camera.json") - cameraRecording = self.openFile("camera.json") - elif self.tokens is not None: - cameraImage = await self.api.getCameraImage(device) - hasCameraRecording = bool( - cameraImage["parsed"]["events"][0]["hasRecording"] - ) - if hasCameraRecording: - cameraRecording = await self.api.getCameraRecording( - device, cameraImage["parsed"]["events"][0]["eventId"] - ) - - if operator.contains(str(cameraImage["original"]), "20") is False: - raise HTTPException - elif cameraImage["parsed"] is None: - raise HiveApiError - else: - raise NoApiToken - - hasCameraImage = bool(cameraImage["parsed"]["events"][0]) - - self.data.camera[device["id"]] = {} - self.data.camera[device["id"]]["cameraImage"] = None - self.data.camera[device["id"]]["cameraRecording"] = None - - if cameraImage is not None and hasCameraImage: - self.data.camera[device["id"]] = {} - self.data.camera[device["id"]]["cameraImage"] = cameraImage["parsed"][ - "events" - ][0] - if cameraRecording is not None and hasCameraRecording: - self.data.camera[device["id"]]["cameraRecording"] = cameraRecording[ - "parsed" - ] - async def getDevices(self, n_id: str): """Get latest data for Hive nodes. @@ -757,8 +693,6 @@ async def getDevices(self, n_id: str): tmpDevices.update({aDevice["id"]: aDevice}) if aDevice["type"] == "siren": self.config.alarm = True - # if aDevice["type"] == "hivecamera": - # await self.getCamera(aDevice) if hiveType == "actions": for aAction in api_resp_p[hiveType]: tmpActions.update({aAction["id"]: aAction}) @@ -826,9 +760,6 @@ async def startSession(self, config: dict = None): "startSession - Config: %s", self.helper._sanitize_payload(config) ) await self.useFile(config.get("username", self.config.username)) - await self.updateInterval( - config.get("options", {}).get("scan_interval", self.config.scanInterval) - ) if config != {}: if "tokens" in config and not self.config.file: @@ -873,7 +804,6 @@ async def createDevices(self): self.deviceList["parent"] = [] self.deviceList["alarm_control_panel"] = [] self.deviceList["binary_sensor"] = [] - self.deviceList["camera"] = [] self.deviceList["climate"] = [] self.deviceList["light"] = [] self.deviceList["sensor"] = [] diff --git a/tests/test_hub.py b/tests/test_hub.py index 6c83435..468e10b 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -1,8 +1,37 @@ -"""Test hub framework.""" +"""Tests for session polling behaviour.""" + +# pylint: disable=protected-access +from unittest.mock import AsyncMock + +import pytest +from apyhiveapi import Hive def test_hub_smoke(): - """Test for hub smoke.""" - result = None + """Placeholder smoke test.""" + assert True + + +@pytest.mark.asyncio +async def test_force_update_polls_when_idle(): + """forceUpdate() calls _pollDevices and returns its result when no poll is running.""" + hive = Hive(username="test@example.com", password="pass") + hive._pollDevices = AsyncMock(return_value=True) + + result = await hive.forceUpdate() + + assert result is True + hive._pollDevices.assert_called_once() + + +@pytest.mark.asyncio +async def test_force_update_skips_when_locked(): + """forceUpdate() returns False without polling when the update lock is already held.""" + hive = Hive(username="test@example.com", password="pass") + hive._pollDevices = AsyncMock(return_value=True) + + async with hive.updateLock: + result = await hive.forceUpdate() - assert result + assert result is False + hive._pollDevices.assert_not_called()