diff --git a/README.rst b/README.rst index 8480092..486e9bf 100644 --- a/README.rst +++ b/README.rst @@ -60,4 +60,15 @@ Changelog - Raw hub data updates made via defined function (`request_raw_data`, `request_home_data`, `request_raw_firware`, `detect_api_version`) - Parse Gen 3 hub name based on serial + mac - Find API version based on firmware revision -- Remove async_timeout and move to asyncio \ No newline at end of file +- Remove async_timeout and move to asyncio + +**v3.0.2** + +- Add type 19 (Provenance Woven Wood) +- Fix Positioning for ShadeVerticalTiltAnywhere + ShadeTiltOnly (Mid only) +- Fix logging regression on initial setup +- Fixes for ShadeVerticalTiltAnywhere + ShadeTiltOnly +- Fix tests +- Remove unneeded declerations +- Fix shade position reporting for v2 shades +- Dandle empty hub data being returned \ No newline at end of file diff --git a/aiopvapi/__version__.py b/aiopvapi/__version__.py index dd0d624..00f1cb1 100644 --- a/aiopvapi/__version__.py +++ b/aiopvapi/__version__.py @@ -1,3 +1,3 @@ """Aio PowerView api version.""" -__version__ = "3.0.1" +__version__ = "3.0.2" diff --git a/aiopvapi/helpers/aiorequest.py b/aiopvapi/helpers/aiorequest.py index de6bf61..9be33cd 100644 --- a/aiopvapi/helpers/aiorequest.py +++ b/aiopvapi/helpers/aiorequest.py @@ -25,6 +25,10 @@ class PvApiConnectionError(PvApiError): """Problem connecting to PowerView hub.""" +class PvApiEmptyData(PvApiError): + """PowerView hub returned empty data.""" + + class AioRequest: """Request class managing hub connection.""" diff --git a/aiopvapi/helpers/constants.py b/aiopvapi/helpers/constants.py index bd59bf5..4e6c361 100644 --- a/aiopvapi/helpers/constants.py +++ b/aiopvapi/helpers/constants.py @@ -61,6 +61,13 @@ MID_POSITION = 50 MAX_POSITION = 100 CLOSED_POSITION = 0 +# there are a number of shades (duette variety) that despite +# being closed visually, actually report a position that is not 0 +# this number is generally below 491.5125, and if not a calibration +# can bring the shade within this realm +# essentially treat a v2 shade that reports a position of 491.5125 or +# less as closed. Still use percentage based for compatability +CLOSED_POSITION_V2 = 0.75 # v2 FWVERSION = "fwversion" diff --git a/aiopvapi/hub.py b/aiopvapi/hub.py index 7feb720..f19b256 100644 --- a/aiopvapi/hub.py +++ b/aiopvapi/hub.py @@ -1,6 +1,6 @@ """Hub class acting as the base for the PowerView API.""" import logging -from aiopvapi.helpers.aiorequest import PvApiConnectionError +from aiopvapi.helpers.aiorequest import PvApiConnectionError, PvApiEmptyData from aiopvapi.helpers.api_base import ApiBase from aiopvapi.helpers.constants import ( @@ -41,9 +41,7 @@ def __init__(self, revision, sub_revision, build, name=None) -> None: self._name = name def __repr__(self): - return "REVISION: {} SUB_REVISION: {} BUILD: {} ".format( - self._revision, self._sub_revision, self._build - ) + return f"REVISION: {self._revision} SUB_REVISION: {self._sub_revision} BUILD: {self._build}" @property def name(self) -> str: @@ -177,6 +175,9 @@ async def _query_firmware_g2(self): # self._raw_data = await self.request.get(join_path(self._base_path, "userdata")) self._raw_data = await self.request_raw_data() + if not self._raw_data or self._raw_data == {}: + raise PvApiEmptyData("Hub returned empty data") + _main = self._parse(USER_DATA, FIRMWARE, FIRMWARE_MAINPROCESSOR) if not _main: # do some checking for legacy v1 failures @@ -213,6 +214,9 @@ async def _query_firmware_g3(self): # self._raw_data = await self.request.get(gateway) self._raw_data = await self.request_raw_data() + if not self._raw_data or self._raw_data == {}: + raise PvApiEmptyData("Hub returned empty data") + _main = self._parse(CONFIG, FIRMWARE, FIRMWARE_MAINPROCESSOR) if _main: self._main_processor_version = self._make_version(_main) @@ -235,16 +239,19 @@ async def _query_firmware_g3(self): home = await self.request_home_data() # Find the hub based on the serial number or MAC hub = None - for gateway in home["gateways"]: - if gateway.get("serial") == self.serial_number: - self.hub_name = gateway.get("name") - break - if gateway.get("mac") == self.mac_address: - self.hub_name = gateway.get("name") - break + if "gateways" in home: + for gateway in home["gateways"]: + if gateway.get("serial") == self.serial_number: + hub = gateway.get("name") + self.hub_name = gateway.get("name") + break + if gateway.get("mac") == self.mac_address: + hub = gateway.get("name") + self.hub_name = gateway.get("name") + break if hub is None: - _LOGGER.debug(f"Hub with serial {self.serial_number} not found.") + _LOGGER.debug("Hub with serial %s not found.",self.serial_number) def _make_version(self, data: dict) -> Version: return Version( @@ -254,12 +261,12 @@ def _make_version(self, data: dict) -> Version: data.get(FIRMWARE_NAME), ) - def _make_version_data_from_str(self, fwVersion: str, name: str = None) -> dict: + def _make_version_data_from_str(self, fw_version: str, name: str = None) -> dict: # Split the version string into components - components = fwVersion.split(".") + components = fw_version.split(".") if len(components) != 3: - raise ValueError("Invalid version format: {}".format(fwVersion)) + raise ValueError(f"Invalid version format: {fw_version}") revision, sub_revision, build = map(int, components) @@ -349,7 +356,7 @@ async def detect_api_version(self): if _main: self._main_processor_version = self._make_version(_main) self.request.api_version = self._main_processor_version.api - _LOGGER.error(self._main_processor_version.api) + _LOGGER.debug("API Version: %s", self._main_processor_version.api) if not self.api_version: - _LOGGER.error(self._raw_firmware) + _LOGGER.error("Unable to decipher firmware %s", self._raw_firmware) diff --git a/aiopvapi/resources/shade.py b/aiopvapi/resources/shade.py index 442d318..a87cb22 100644 --- a/aiopvapi/resources/shade.py +++ b/aiopvapi/resources/shade.py @@ -23,6 +23,8 @@ ATTR_TILT, ATTR_BATTERY_KIND, ATTR_POWER_TYPE, + CLOSED_POSITION, + CLOSED_POSITION_V2, FIRMWARE, FIRMWARE_REVISION, FIRMWARE_SUB_REVISION, @@ -186,7 +188,12 @@ def firmware(self) -> str | None: if FIRMWARE not in self.raw_data: return None firmware = self.raw_data[FIRMWARE] - return f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}" + + revision = firmware[FIRMWARE_REVISION] + sub_revision = firmware[FIRMWARE_SUB_REVISION] + build = firmware[FIRMWARE_BUILD] + + return f"{revision}.{sub_revision}.{build}" @property def url(self) -> str: @@ -252,8 +259,8 @@ def api_to_percent(self, position: float, position_type: str) -> int: if self.api_version < 3: max_position_api = MAX_POSITION_V2 * max_position_api - percent = self.position_limit(round((position / max_position_api) * 100)) - return percent + percent = self.position_limit((position / max_position_api) * 100) + return round(percent) def structured_to_raw(self, data: ShadePosition) -> dict[str, Any]: """Convert structured ShadePosition to API relevant dict""" @@ -415,6 +422,15 @@ def position_limit(self, position: int, position_type: str = ""): min_limit, max_limit = limits.get(position_type, (0, 100)) + if self.api_version < 3 and position != 0 and position < CLOSED_POSITION_V2: + _LOGGER.debug( + "%s: Assuming shade is closed as %s is less than %s", + self.name, + position, + CLOSED_POSITION, + ) + position = CLOSED_POSITION + return min(max(min_limit, position), max_limit) async def _motion(self, motion): @@ -629,6 +645,7 @@ class ShadeBottomUp(BaseShade): ShadeType(5, "Bottom Up"), ShadeType(6, "Duette"), ShadeType(10, "Duette and Applause SkyLift"), + ShadeType(19, "Provenance Woven Wood"), ShadeType(31, "Vignette"), ShadeType(32, "Vignette"), ShadeType(42, "M25T Roller Blind"), @@ -673,7 +690,7 @@ class ShadeBottomUpTiltOnClosed180(BaseShadeTilt): tilt_onclosed=True, tilt_180=True, ), - "Bottom Up Tilt 180°", + "Bottom Up TiltOnClosed 180°", ) def __init__( @@ -707,7 +724,7 @@ class ShadeBottomUpTiltOnClosed90(BaseShadeTilt): tilt_onclosed=True, tilt_90=True, ), - "Bottom Up Tilt 90°", + "Bottom Up TiltOnClosed 90°", ) def __init__( @@ -742,7 +759,7 @@ class ShadeBottomUpTiltAnywhere(BaseShadeTilt): tilt_anywhere=True, tilt_180=True, ), - "Bottom Up Tilt 180°", + "Bottom Up TiltAnywhere 180°", ) def __init__( @@ -809,6 +826,14 @@ class ShadeVerticalTiltAnywhere(ShadeBottomUpTiltAnywhere): "Vertical Tilt Anywhere", ) + def get_additional_positions(self, positions: ShadePosition) -> ShadePosition: + """Returns additonal positions not reported by the hub""" + if positions.primary is None: + positions.primary = MIN_POSITION + if positions.tilt is None: + positions.tilt = MIN_POSITION + return positions + class ShadeTiltOnly(BaseShadeTilt): """Type 5 - Tilt Only 180° @@ -833,14 +858,8 @@ def __init__( super().__init__(raw_data, shade_type, request) self._open_position = ShadePosition() self._close_position = ShadePosition() - self._open_position_tilt = ShadePosition(tilt=MAX_POSITION) + self._open_position_tilt = ShadePosition(tilt=MID_POSITION) self._close_position_tilt = ShadePosition(tilt=MIN_POSITION) - if self.api_version < 3: - self._open_position_tilt = ShadePosition(tilt=MID_POSITION) - - async def move(self, position_data=None): - _LOGGER.error("Move unsupported. Position request(%s) ignored", position_data) - return def get_additional_positions(self, positions: ShadePosition) -> ShadePosition: """Returns additonal positions not reported by the hub""" @@ -1040,6 +1059,7 @@ def __init__( if self.api_version < 3: self.shade_limits = ShadeLimits(tilt_max=MAX_POSITION) + def factory(raw_data: dict, request: AioRequest): """Class factory to create different types of shades depending on shade type.""" diff --git a/readme.md b/readme.md index b769ef7..6cbc73b 100644 --- a/readme.md +++ b/readme.md @@ -5,11 +5,74 @@ # Aio PowerView API -A python async API for PowerView blinds. -Written for Home-Assistant. Adding features as I go... +A python async API for PowerView blinds written for Home-Assistant. Have a look at the examples folder for some guidance how to use it. +## Capabilities + +| Description | Capabilities | Primary | Secondary | Tilt | Tilt Position | Vertical | DualShade | +| :------------------------------------ | :----------: | :-----: | :-------: | :--: | :-----------: | :------: | :-------: | +| Bottom Up | 0 | X | | | | | | +| Bottom Up Tilt 180° | 1* | X | | 180° | Closed | | | +| Bottom Up Tilt 90° | 1 | X | | 90° | Closed | | | +| Bottom Up Tilt 180° | 2 | X | | 180° | Anywhere | | | +| Vertical | 3 | X | | | | X | | +| Vertical Tilt Anywhere | 4 | X | | 180° | Anywhere | X | | +| Tilt Only 180° | 5 | | | 180° | Anywhere | | | +| Top Down | 6 | | X | | | | | +| Top Down Bottom Up | 7 | X | X | | | | | +| Dual Shade Overlapped | 8 | X | X | | | | X | +| Dual Shade Overlapped Tilt 90° | 9 | X | X | 90° | Closed | | X | +| Dual Shade Overlapped Tilt 90° | 10 | X | X | 180° | Closed | | X | + +## Shades + +Shades that have been directly added to the API are listed below and should function correctly. In **most** cases this is identification is purely aestetic. + +Shades not listed will get their features from their **capabilities**, unfortunately the json returned from the shade can sometimes be incorrect and we need to override the features for the API (and Home-Assistant) to read them correctly. + +| Name | Type | Capability | +| :------------------------------------ | :--: | :--------: | +| AC Roller | 49 | 0 | +| Banded Shades | 52 | 0 | +| Bottom Up | 5 | 0 | +| Curtain, Left Stack | 69 | 3 | +| Curtain, Right Stack | 70 | 3 | +| Curtain, Split Stack | 71 | 3 | +| Designer Roller | 1 | 0 | +| Duette | 6 | 0 | +| Duette, Top Down Bottom Up | 8 | 7 | +| Duette Architella, Top Down Bottom Up | 33 | 7 | +| Duette DuoLite, Top Down Bottom Up | 9 | 7 | +| Duolite Lift | 79 | 9 | +| Facette | 43 | 1 | +| M25T Roller Blind | 42 | 0 | +| Palm Beach Shutters | 66 | 5 | +| Pirouette | 18 | 1 | +| Pleated, Top Down Bottom Up | 47 | 7 | +| Provenance Woven Wood | 19 | 0 | +| Roman | 4 | 0 | +| Silhouette | 23 | 1 | +| Silhouette Duolite | 38 | 9 | +| Skyline Panel, Left Stack | 26 | 3 | +| Skyline Panel, Right Stack | 27 | 3 | +| Skyline Panel, Split Stack | 28 | 3 | +| Top Down | 7 | 6 | +| Twist | 44 | 1* | +| Vignette | 31 | 0 | +| Vignette | 32 | 0 | +| Vignette | 84 | 0 | +| Vignette Duolite | 65 | 8 | +| Vertical | 3 | 0 | +| Vertical Slats, Left Stack | 54 | 4 | +| Vertical Slats, Right Stack | 55 | 4 | +| Vertical Slats, Split Stack | 56 | 4 | +| Venetian, Tilt Anywhere | 51 | 2 | +| Venetian, Tilt Anywhere | 62 | 2 | + +\* No other shade are known to have this capability and the only way to get this functionality is by hardcoding in the API + ## Development - Install dev requirements. @@ -71,6 +134,17 @@ Have a look at the examples folder for some guidance how to use it. - Find API version based on firmware revision - Remove async_timeout and move to asyncio +### v3.0.2 + +- Add type 19 (Provenance Woven Wood) +- Fix Positioning for ShadeVerticalTiltAnywhere + ShadeTiltOnly (Mid only) +- Fix logging regression on initial setup +- Fixes for ShadeVerticalTiltAnywhere + ShadeTiltOnly +- Fix tests +- Remove unneeded declerations +- Fix shade position reporting for v2 shades +- Dandle empty hub data being returned + ## Links --- diff --git a/setup.py b/setup.py index e78ac8f..b5c9177 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ VERSION = None # What packages are required for this module to be executed? -REQUIRED = ["asyncio", "aiohttp>=3.7.4,<4"] +REQUIRED = ["aiohttp>=3.7.4,<4"] # What packages are optional? EXTRAS = {}