1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349
|
"""Async IO client library for Modern Forms fans."""
from __future__ import annotations
import asyncio
import json
import socket
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, Union
import aiohttp
import async_timeout
import backoff # type: ignore
from yarl import URL
from .__version__ import __version__
from .const import (
COMMAND_ADAPTIVE_LEARNING,
COMMAND_AWAY_MODE,
COMMAND_FAN_DIRECTION,
COMMAND_FAN_POWER,
COMMAND_FAN_SLEEP_TIMER,
COMMAND_FAN_SPEED,
COMMAND_LIGHT_BRIGHTNESS,
COMMAND_LIGHT_POWER,
COMMAND_LIGHT_SLEEP_TIMER,
COMMAND_QUERY_STATIC_DATA,
COMMAND_QUERY_STATUS,
COMMAND_REBOOT,
DEFAULT_API_ENDPOINT,
DEFAULT_PORT,
DEFAULT_TIMEOUT_SECS,
FAN_DIRECTION_FORWARD,
FAN_DIRECTION_REVERSE,
FAN_SPEED_HIGH_VALUE,
FAN_SPEED_LOW_VALUE,
LIGHT_BRIGHTNESS_HIGH_VALUE,
LIGHT_BRIGHTNESS_LOW_VALUE,
SLEEP_TIMER_CANCEL,
)
from .exceptions import (
ModernFormsConnectionError,
ModernFormsConnectionTimeoutError,
ModernFormsEmptyResponseError,
ModernFormsError,
ModernFormsInvalidSettingsError,
ModernFormsNotInitializedError,
)
from .models import Device
class ModernFormsDevice:
"""Modern Forms device reppresentation."""
_device: Optional[Device] = None
def __init__(
self,
host: str,
port: int = DEFAULT_PORT,
base_path: str = "/",
username: str = "",
password: str = "",
request_timeout: float = DEFAULT_TIMEOUT_SECS,
session: aiohttp.client.ClientSession = None,
tls: bool = False,
verify_ssl: bool = True,
user_agent: str = None, # type: ignore
) -> None:
"""Initialize connection with Modern Forms Fan."""
self._session = session
self._close_session = False
self._base_path = base_path
self._host = host
self._password = password
self._port = port
self._socketaddr = None
self._request_timeout = request_timeout
self._tls = tls
self._username = username
self._verify_ssl = verify_ssl
self._user_agent = user_agent
if self._user_agent is None:
self._user_agent = f"AIOModernForms/{__version__}"
if self._base_path[-1] != "/":
self._base_path += "/"
self._base_path += DEFAULT_API_ENDPOINT
@backoff.on_exception(
backoff.expo, ModernFormsEmptyResponseError, max_tries=3, logger=None
)
async def update(self, full_update: bool = False) -> Device:
"""Get all information about the device in a single call."""
info_data = await self._request({COMMAND_QUERY_STATIC_DATA: True})
state_data = await self._request()
if not state_data:
raise ModernFormsEmptyResponseError(
f"Modern Forms device at {self._host}"
+ " returned an empty API response on full update"
)
if self._device is None or full_update:
self._device = Device(state_data=state_data, info_data=info_data)
self._device.update_from_dict(state_data=state_data)
return self._device
@backoff.on_exception(
backoff.expo, ModernFormsConnectionError, max_tries=3, logger=None
)
async def _request(self, commands: Optional[dict] = None) -> Any:
"""Handle a request to a Modern Forms Fan device."""
scheme = "https" if self._tls else "http"
url = URL.build(
scheme=scheme,
host=self._host,
port=self._port,
path=self._base_path,
)
auth = None
if self._username and self._password:
auth = aiohttp.BasicAuth(self._username, self._password)
headers = {
"User-Agent": self._user_agent,
"Accept": "application/json",
}
if self._session is None:
self._session = aiohttp.ClientSession()
self._close_session = True
# If updating the state, always request for a state response
if commands is None:
commands = {COMMAND_QUERY_STATUS: True}
try:
with async_timeout.timeout(self._request_timeout):
response = await self._session.request(
"POST",
url,
auth=auth,
json=commands,
headers=headers,
ssl=self._verify_ssl,
)
except asyncio.TimeoutError as exception:
raise ModernFormsConnectionTimeoutError(
"Timeout occurred while connecting to Modern Forms device at"
+ f" {self._host}"
) from exception
except (aiohttp.ClientError, socket.gaierror) as exception:
raise ModernFormsConnectionError(
"Error occurred while communicating with Modern Forms device at"
+ f" {self._host}"
) from exception
content_type = response.headers.get("Content-Type", "")
if (response.status // 100) in [4, 5]:
contents = await response.read()
response.close()
if content_type == "application/json":
raise ModernFormsError(
response.status, json.loads(contents.decode("utf8"))
)
raise ModernFormsError(
response.status, {"message": contents.decode("utf8")}
)
data = await response.json()
return data
async def request(self, commands: Optional[dict] = None):
"""Issue one or more commands to the Modern Forms fan."""
if self._device is None:
await self.update()
data = await self._request(commands=commands)
self._device.update_from_dict(state_data=data) # type: ignore
return self._device.state # type: ignore
@property
def status(self):
"""Fan get status."""
if self._device is None:
raise ModernFormsNotInitializedError(
"The device has not been initialized. "
+ "Please run update on the device before getting state"
)
return self._device.state
@property
def info(self):
"""Fan get info."""
if self._device is None:
raise ModernFormsNotInitializedError(
"The device has not been initialized. "
+ "Please run update on the device before getting state"
)
return self._device.info
async def light(
self,
*,
brightness: Optional[int] = None,
on: Optional[bool] = None,
sleep: Optional[Union[int, datetime]] = None,
):
"""Change Fans Light state."""
commands: Dict[str, Union[bool, int]] = {}
if brightness is not None:
if (
not isinstance(brightness, int)
or int(brightness) < LIGHT_BRIGHTNESS_LOW_VALUE
or int(brightness) > LIGHT_BRIGHTNESS_HIGH_VALUE
):
raise ModernFormsInvalidSettingsError(
"brightness value must be between"
+ f" {LIGHT_BRIGHTNESS_LOW_VALUE} and {LIGHT_BRIGHTNESS_HIGH_VALUE}"
)
commands[COMMAND_LIGHT_BRIGHTNESS] = brightness
if on is not None:
if not isinstance(on, bool):
raise ModernFormsInvalidSettingsError("on must be a boolean")
commands[COMMAND_LIGHT_POWER] = on
if sleep is not None:
if isinstance(sleep, int):
# turns off sleep timer
commands[COMMAND_LIGHT_SLEEP_TIMER] = SLEEP_TIMER_CANCEL
if sleep > 0:
# count as number of seconds to sleep
sleep_till = datetime.now() + timedelta(seconds=sleep)
commands[COMMAND_LIGHT_SLEEP_TIMER] = int(sleep_till.timestamp())
elif isinstance(sleep, datetime) and not (
sleep < datetime.now() or sleep > (datetime.now() + timedelta(hours=24))
):
commands[COMMAND_LIGHT_SLEEP_TIMER] = int(sleep.timestamp())
else:
raise ModernFormsInvalidSettingsError(
"The time to sleep till must be a datetime object that is not more"
+ " then 24 hours into the future, or an interger for number of"
+ " seconds to sleep. 0 cancels the sleep timer."
)
await self.request(commands=commands)
async def fan(
self,
*,
on: Optional[bool] = None,
sleep: Optional[Union[int, datetime]] = None,
speed: Optional[int] = None,
direction: Optional[str] = None,
):
"""Change Fans Fan state."""
commands: Dict[str, Union[bool, int, str]] = {}
if speed is not None:
if (
not isinstance(speed, int)
or int(speed) < FAN_SPEED_LOW_VALUE
or int(speed) > FAN_SPEED_HIGH_VALUE
):
raise ModernFormsInvalidSettingsError(
"speed value must be between"
+ f" {FAN_SPEED_LOW_VALUE} and {FAN_SPEED_HIGH_VALUE}"
)
commands[COMMAND_FAN_SPEED] = speed
if on is not None:
if not isinstance(on, bool):
raise ModernFormsInvalidSettingsError("on must be a boolean")
commands[COMMAND_FAN_POWER] = on
if sleep is not None:
if isinstance(sleep, int):
# turns off sleep timer
commands[COMMAND_FAN_SLEEP_TIMER] = SLEEP_TIMER_CANCEL
if sleep > 0:
# count as number of seconds to sleep
sleep_till = datetime.now() + timedelta(seconds=sleep)
commands[COMMAND_FAN_SLEEP_TIMER] = int(sleep_till.timestamp())
elif isinstance(sleep, datetime) and not (
sleep < datetime.now() or sleep > (datetime.now() + timedelta(hours=24))
):
commands[COMMAND_FAN_SLEEP_TIMER] = int(sleep.timestamp())
else:
raise ModernFormsInvalidSettingsError(
"The time to sleep till must be a datetime object that is not more"
+ " then 24 hours into the future, or an interger for number of"
+ " seconds to sleep. 0 cancels the sleep timer."
)
if direction is not None:
if not isinstance(direction, str) or direction not in [
FAN_DIRECTION_FORWARD,
FAN_DIRECTION_REVERSE,
]:
raise ModernFormsInvalidSettingsError(
f"fan direction must be {FAN_DIRECTION_FORWARD}"
+ f" or {FAN_DIRECTION_REVERSE}"
)
commands[COMMAND_FAN_DIRECTION] = direction
await self.request(commands=commands)
async def away(self, away=bool):
"""Change the away state of the device."""
await self.request(
commands={COMMAND_AWAY_MODE: away, COMMAND_QUERY_STATUS: True}
)
async def adaptive_learning(self, adaptive_learning=bool):
"""Change the adaptive learning state of the device."""
await self.request(
commands={
COMMAND_ADAPTIVE_LEARNING: adaptive_learning,
COMMAND_QUERY_STATUS: True,
}
)
async def reboot(self):
"""Send a reboot to the Fan."""
try:
await self.request(commands={COMMAND_REBOOT: True})
except ModernFormsConnectionTimeoutError:
# a successful reboot drops the connection
pass
async def close(self) -> None:
"""Close open client session."""
if self._session and self._close_session:
await self._session.close()
async def __aenter__(self) -> ModernFormsDevice:
"""Async enter."""
return self
async def __aexit__(self, *exc_info) -> None:
"""Async exit."""
await self.close()
|