Skip to content
Merged

Dev #115

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
39d06eb
refactor: Replace session.log with standard Python logging
Feb 14, 2026
824575d
Bump version to 1.0.8_dev
Feb 14, 2026
dea34a2
Add GitHub Actions workflow for manual dev package publishing
Feb 14, 2026
67f720b
Improve API error handling and token validation
Feb 15, 2026
abe1663
Bump version to 1.0.8_dev_2
Feb 15, 2026
5bc9c58
Update version format to PEP 440 compliant dev release notation
Feb 15, 2026
1412351
Bump version to 1.0.8.dev3 and enhance token expiry logging
Feb 15, 2026
70c71b2
Add 10s timeout to API requests and return cached state for offline d…
Feb 15, 2026
869f36f
Bump version to 1.0.8.dev5 and add API request timing with slow poll …
Feb 15, 2026
9f96ee5
Bump version to 1.0.8.dev6 and reduce API timeout and slow poll thres…
Feb 15, 2026
492db2a
Bump version to 1.0.8.dev7 and add defensive null state handling with…
Feb 16, 2026
d4df85e
Fix device_login failing when login returns AuthenticationResult dire…
juliangall Feb 18, 2026
e759452
Bump version to 1.0.8.dev8 and enhance authentication logging with re…
Feb 18, 2026
35d5e44
Bump version to 1.0.8.dev9 and make token refresh threshold configura…
Feb 19, 2026
0e833d7
fix: login extract decice metadata
nathankellenicki Feb 19, 2026
895a894
Bump version to 1.0.8.dev10 and add target temperature validation wit…
Feb 20, 2026
a4445fb
Merge pull request #111 from juliangall/fix/device-login-auth-result
KJonline Feb 20, 2026
0a64c38
Merge branch 'dev' into Fix-refresh-token-and-add-logging
KJonline Feb 20, 2026
2b2aac1
Reorder imports to follow PEP 8 style conventions
Feb 20, 2026
2078fbb
Reorder imports to follow PEP 8 style conventions
Feb 20, 2026
82b3eac
Merge branch 'Fix-refresh-token-and-add-logging' of https://github.co…
KJonline Feb 20, 2026
0161aee
Merge pull request #113 from Pyhass/Fix-refresh-token-and-add-logging
KJonline Feb 20, 2026
e1514c8
Merge branch 'dev' into fix/login-extract-device-metadata
KJonline Feb 20, 2026
6a108a7
Merge pull request #112 from nathankellenicki/fix/login-extract-devic…
KJonline Feb 20, 2026
3815cda
Bump version to 1.0.8 and implement entity-level caching with concurr…
KJonline Feb 24, 2026
c6021a5
Merge pull request #114 from Pyhass/Add-caching-Strategy
KJonline Feb 24, 2026
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
67 changes: 67 additions & 0 deletions .github/workflows/dev-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Manual workflow to build and publish dev Python packages from non-master branches.
# Publishes to TestPyPI for development/testing purposes.

name: Dev Publish

# yamllint disable-line rule:truthy
on:
workflow_dispatch:

permissions:
contents: read

jobs:
guard:
name: Block master branch
runs-on: ubuntu-latest
steps:
- name: Fail if running on master
if: github.ref == 'refs/heads/master'
run: |
echo "::error::Dev publish is not allowed on the master branch."
exit 1

dev-build:
name: Build dev package
runs-on: ubuntu-latest
needs: guard
steps:
- uses: actions/checkout@v5

- uses: actions/setup-python@v5
with:
python-version: "3.x"

- name: Install build dependencies
run: python -m pip install build

- name: Build package
run: python -m build

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dev-dists
path: dist/

dev-publish:
name: Publish to PyPI
runs-on: ubuntu-latest
needs: dev-build
permissions:
id-token: write
environment:
name: pypi
url: https://pypi.org/project/pyhive-integration

steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: dev-dists
path: dist/

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def requirements_from_file(filename="requirements.txt"):


setup(
version="1.0.7",
version="1.0.8",
packages=["apyhiveapi", "apyhiveapi.api", "apyhiveapi.helper"],
package_dir={"apyhiveapi": "src"},
package_data={"data": ["*.json"]},
Expand Down
18 changes: 15 additions & 3 deletions src/action.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Hive Action Module."""

# pylint: skip-file
import logging

_LOGGER = logging.getLogger(__name__)


class HiveAction:
Expand Down Expand Up @@ -29,6 +32,14 @@ async def getAction(self, device: dict):
Returns:
dict: Updated device.
"""
if self.session.shouldUseCachedData():
cached = self.session.getCachedDevice(device)
if cached is not None:
_LOGGER.debug(
"Returning cached state for action %s (slow/busy poll).",
device["haName"],
)
return cached
dev_data = {}

if device["hiveID"] in self.data["action"]:
Expand All @@ -44,8 +55,7 @@ async def getAction(self, device: dict):
"custom": device.get("custom", None),
}

self.session.devices.update({device["hiveID"]: dev_data})
return self.session.devices[device["hiveID"]]
return self.session.setCachedDevice(device, dev_data)
else:
exists = self.session.data.actions.get("hiveID", False)
if exists is False:
Expand All @@ -67,7 +77,7 @@ async def getState(self, device: dict):
data = self.session.data.actions[device["hiveID"]]
final = data["enabled"]
except KeyError as e:
await self.session.log.error(e)
_LOGGER.error(e)

return final

Expand All @@ -85,6 +95,7 @@ async def setStatusOn(self, device: dict):
final = False

if device["hiveID"] in self.session.data.actions:
_LOGGER.debug("Enabling action %s.", device["haName"])
await self.session.hiveRefreshTokens()
data = self.session.data.actions[device["hiveID"]]
data.update({"enabled": True})
Expand All @@ -110,6 +121,7 @@ async def setStatusOff(self, device: dict):
final = False

if device["hiveID"] in self.session.data.actions:
_LOGGER.debug("Disabling action %s.", device["haName"])
await self.session.hiveRefreshTokens()
data = self.session.data.actions[device["hiveID"]]
data.update({"enabled": False})
Expand Down
23 changes: 18 additions & 5 deletions src/alarm.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""Hive Alarm Module."""

# pylint: skip-file
import logging

_LOGGER = logging.getLogger(__name__)


class HiveHomeShield:
Expand All @@ -24,7 +27,7 @@ async def getMode(self):
data = self.session.data.alarm
state = data["mode"]
except KeyError as e:
await self.session.log.error(e)
_LOGGER.error(e)

return state

Expand All @@ -40,7 +43,7 @@ async def getState(self, device: dict):
data = self.session.data.devices[device["hiveID"]]
state = data["state"]["alarmActive"]
except KeyError as e:
await self.session.log.error(e)
_LOGGER.error(e)

return state

Expand All @@ -59,6 +62,7 @@ async def setMode(self, device: dict, mode: str):
device["hiveID"] in self.session.data.devices
and device["deviceData"]["online"]
):
_LOGGER.debug("Setting alarm mode to %s.", mode)
await self.session.hiveRefreshTokens()
resp = await self.session.api.setAlarm(mode=mode)
if resp["original"] == 200:
Expand Down Expand Up @@ -92,13 +96,22 @@ async def getAlarm(self, device: dict):
Returns:
dict: Updated device.
"""
if self.session.shouldUseCachedData():
cached = self.session.getCachedDevice(device)
if cached is not None:
_LOGGER.debug(
"Returning cached state for alarm %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("Updating alarm data for %s.", device["haName"])
data = self.session.data.devices[device["device_id"]]
dev_data = {
"hiveID": device["hiveID"],
Expand All @@ -120,10 +133,10 @@ async def getAlarm(self, device: dict):
),
}

self.session.devices.update({device["hiveID"]: dev_data})
return self.session.devices[device["hiveID"]]
return self.session.setCachedDevice(device, dev_data)
else:
await self.session.log.errorCheck(
await self.session.helper.errorCheck(
device["device_id"], "ERROR", device["deviceData"]["online"]
)
device.setdefault("status", {"state": None, "mode": None})
return device
2 changes: 1 addition & 1 deletion src/api/hive_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def __init__(self, hiveSession=None, websession=None, token=None):
"actions": "/actions",
"nodes": "/nodes/{0}/{1}",
}
self.timeout = 10
self.timeout = 5
self.json_return = {
"original": "No response to Hive API request",
"parsed": "No response to Hive API request",
Expand Down
81 changes: 56 additions & 25 deletions src/api/hive_async_api.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
"""Hive API Module."""

# pylint: skip-file
import asyncio
import json
import logging
import time
from typing import Optional

import requests
import urllib3
from aiohttp import ClientResponse, ClientSession, web_exceptions
from aiohttp import ClientResponse, ClientSession, ClientTimeout, web_exceptions
from pyquery import PyQuery

from ..helper.const import HTTP_UNAUTHORIZED
from ..helper.hive_exceptions import FileInUse, HiveApiError, NoApiToken
from ..helper.const import HTTP_FORBIDDEN, HTTP_UNAUTHORIZED
from ..helper.hive_exceptions import FileInUse, HiveApiError, HiveAuthError, NoApiToken

_LOGGER = logging.getLogger(__name__)

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

Expand Down Expand Up @@ -38,7 +43,7 @@ def __init__(self, hiveSession=None, websession: Optional[ClientSession] = None)
"long_lived": "https://api.prod.bgchprod.info/omnia/accessTokens",
"weather": "https://weather.prod.bgchprod.info/weather",
}
self.timeout = 10
self.timeout = 5
self.json_return = {
"original": "No response to Hive API request",
"parsed": "No response to Hive API request",
Expand All @@ -50,46 +55,64 @@ async def request(
self, method: str, url: str, camera: bool = False, **kwargs
) -> ClientResponse:
"""Make a request."""
_LOGGER.debug("API %s request to %s", method.upper(), url)
data = kwargs.get("data", None)

headers = {
"content-type": "application/json",
"Accept": "*/*",
"User-Agent": "Hive/12.04.0 iOS/18.3.1 Apple",
}
try:
if camera:
headers = {
"content-type": "application/json",
"Accept": "*/*",
"Authorization": f"Bearer {self.session.tokens.tokenData['token']}",
"x-jwt-token": self.session.tokens.tokenData["token"],
"User-Agent": "Hive/12.04.0 iOS/18.3.1 Apple",
}
headers["Authorization"] = (
f"Bearer {self.session.tokens.tokenData['token']}"
)
headers["x-jwt-token"] = self.session.tokens.tokenData["token"]
else:
headers = {
"content-type": "application/json",
"Accept": "*/*",
"Authorization": self.session.tokens.tokenData["token"],
"User-Agent": "Hive/12.04.0 iOS/18.3.1 Apple",
}
headers["Authorization"] = self.session.tokens.tokenData["token"]
except KeyError:
if "sso" in url:
pass
else:
raise NoApiToken

auth_token = headers.get("Authorization", "")
_LOGGER.debug(
"Using token (len=%d, tail=…%s)",
len(auth_token),
auth_token[-4:] if len(auth_token) >= 4 else auth_token,
)

timeout = ClientTimeout(total=self.timeout)
req_start = time.monotonic()
async with self.websession.request(
method, url, headers=headers, data=data
method, url, headers=headers, data=data, timeout=timeout
) as resp:
await resp.text()
resp_body = await resp.text()
req_duration = time.monotonic() - req_start
_LOGGER.debug(
"API %s %s completed in %.2fs — HTTP %s",
method.upper(),
url,
req_duration,
resp.status,
)
if str(resp.status).startswith("20"):
return resp

if resp.status == HTTP_UNAUTHORIZED:
self.session.logger.error(
f"Hive token has expired when calling {url} - "
f"HTTP status is - {resp.status}"
if resp.status in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN):
_LOGGER.error(
f"Hive token rejected calling {url} - "
f"HTTP {resp.status} — response: {resp_body[:200]}"
)
raise HiveAuthError(
f"Token expired or forbidden calling {url} — HTTP {resp.status}"
)
elif url is not None and resp.status is not None:
self.session.logger.error(
_LOGGER.error(
f"Something has gone wrong calling {url} - "
f"HTTP status is - {resp.status}"
f"HTTP status is - {resp.status} — response: {resp_body[:200]}"
)

raise HiveApiError
Expand Down Expand Up @@ -146,10 +169,14 @@ async def getAll(self):
"""Build and query all endpoint."""
json_return = {}
url = self.urls["all"]
_LOGGER.debug("Fetching all nodes from Hive API.")
try:
resp = await self.request("get", url)
json_return.update({"original": resp.status})
json_return.update({"parsed": await resp.json(content_type=None)})
except asyncio.TimeoutError:
_LOGGER.warning("Hive API request timed out fetching all nodes.")
raise
except (OSError, RuntimeError, ZeroDivisionError):
await self.error()

Expand Down Expand Up @@ -276,6 +303,7 @@ async def getWeather(self, weather_url):

async def setState(self, n_type, n_id, **kwargs):
"""Set the state of a Device."""
_LOGGER.debug("Setting state for %s/%s: %s", n_type, n_id, kwargs)
json_return = {}
jsc = (
"{"
Expand All @@ -301,6 +329,7 @@ async def setState(self, n_type, n_id, **kwargs):

async def setAlarm(self, **kwargs):
"""Set the state of the alarm."""
_LOGGER.debug("Setting alarm state: %s", kwargs)
json_return = {}
jsc = (
"{"
Expand All @@ -326,6 +355,7 @@ async def setAlarm(self, **kwargs):

async def setAction(self, n_id, data):
"""Set the state of a Action."""
_LOGGER.debug("Setting action %s", n_id)
jsc = data
url = self.urls["actions"] + "/" + n_id
try:
Expand All @@ -341,6 +371,7 @@ async def setAction(self, n_id, data):

async def error(self):
"""An error has occurred interacting with the Hive API."""
_LOGGER.error("HTTP error occurred during Hive API interaction.")
raise web_exceptions.HTTPError

async def isFileBeingUsed(self):
Expand Down
Loading
Loading