Skip to content

Commit c12cdbb

Browse files
committed
Initial commit
0 parents  commit c12cdbb

12 files changed

+1331
-0
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.vscode
2+
__pycache__/
3+
.DS_Store

LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2023 MizterB
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# aiophyn
2+
3+
An asynchronous library for Phyn Smart Water devices.
4+
5+
This library is initially focused on supporting a Phyn integration for Home Assistant, providing:
6+
7+
- Device state
8+
- Water consumption
9+
- Shutoff valve control
10+
11+
## Acknowledgements
12+
13+
This work follows the example of @bachya's excellent [aioflo](https://github.com/bachya/aioflo) library for Moen Flo devices.

aiophyn/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""Define the aiophyn package."""
2+
from .api import async_get_api

aiophyn/api.py

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Define a base client for interacting with Flo."""
2+
import logging
3+
from datetime import datetime, timedelta
4+
from typing import Optional
5+
from urllib.parse import urlparse
6+
7+
import boto3
8+
from aiohttp import ClientSession, ClientTimeout
9+
from aiohttp.client_exceptions import ClientError
10+
from pycognito.aws_srp import AWSSRP
11+
12+
from .const import API_BASE
13+
from .device import Device
14+
from .errors import RequestError
15+
from .home import Home
16+
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
DEFAULT_HEADER_CONTENT_TYPE: str = "application/json"
20+
DEFAULT_HEADER_USER_AGENT: str = "phyn/18 CFNetwork/1331.0.7 Darwin/21.4.0"
21+
DEFAULT_HEADER_CONNECTION = "keep-alive"
22+
DEFAULT_HEADER_API_KEY = "E7nfOgW6VI64fYpifiZSr6Me5w1Upe155zbu4lq8"
23+
DEFAULT_HEADER_ACCEPT: str = "application/json"
24+
DEFAULT_HEADER_ACCEPT_ENCODING = "gzip, deflate, br"
25+
26+
COGNITO_REGION = "us-east-1"
27+
COGNITO_POOL_ID = "us-east-1_UAv6IUsyh"
28+
COGNITO_CLIENT_ID = "5q2m8ti0urmepg4lup8q0ptldq"
29+
30+
DEFAULT_TIMEOUT: int = 10
31+
32+
33+
class API:
34+
"""Define the API object."""
35+
36+
def __init__(
37+
self, username: str, password: str, *, session: Optional[ClientSession] = None
38+
) -> None:
39+
"""Initialize."""
40+
self._username: str = username
41+
self._password: str = password
42+
self._session: ClientSession = session
43+
44+
self._token: Optional[str] = None
45+
self._token_expiration: Optional[datetime] = None
46+
self._user_id: Optional[str] = None
47+
self._username: str = username
48+
49+
self.home: Home = Home(self._request)
50+
self.device: Device = Device(self._request)
51+
52+
async def _request(self, method: str, url: str, **kwargs) -> dict:
53+
"""Make a request against the API."""
54+
if self._token_expiration and datetime.now() >= self._token_expiration:
55+
_LOGGER.info("Requesting new access token to replace expired one")
56+
57+
# Nullify the token so that the authentication request doesn't use it:
58+
self._token = None
59+
60+
# Nullify the expiration so the authentication request doesn't get caught
61+
# here:
62+
self._token_expiration = None
63+
64+
await self.async_authenticate()
65+
66+
kwargs.setdefault("headers", {})
67+
kwargs["headers"].update(
68+
{
69+
"Content-Type": DEFAULT_HEADER_CONTENT_TYPE,
70+
"User-Agent": DEFAULT_HEADER_USER_AGENT,
71+
"Connection": DEFAULT_HEADER_CONNECTION,
72+
"x-api-key": DEFAULT_HEADER_API_KEY,
73+
"Accept": DEFAULT_HEADER_ACCEPT,
74+
"Accept-Encoding": DEFAULT_HEADER_ACCEPT_ENCODING,
75+
}
76+
)
77+
78+
if self._token:
79+
kwargs["headers"]["Authorization"] = self._token
80+
81+
use_running_session = self._session and not self._session.closed
82+
83+
if use_running_session:
84+
session = self._session
85+
else:
86+
session = ClientSession(timeout=ClientTimeout(total=DEFAULT_TIMEOUT))
87+
88+
try:
89+
async with session.request(method, url, **kwargs) as resp:
90+
data: dict = await resp.json(content_type=None)
91+
resp.raise_for_status()
92+
return data
93+
except ClientError as err:
94+
raise RequestError(f"There was an error while requesting {url}") from err
95+
finally:
96+
if not use_running_session:
97+
await session.close()
98+
99+
async def async_authenticate(self) -> None:
100+
"""Authenticate the user and set the access token with its expiration."""
101+
client = boto3.client("cognito-idp", region_name=COGNITO_REGION)
102+
aws = AWSSRP(
103+
username=self._username,
104+
password=self._password,
105+
pool_id=COGNITO_POOL_ID,
106+
client_id=COGNITO_CLIENT_ID,
107+
client=client,
108+
)
109+
auth_response: dict = aws.authenticate_user()
110+
111+
access_token = auth_response["AuthenticationResult"]["AccessToken"]
112+
expires_in = auth_response["AuthenticationResult"]["ExpiresIn"]
113+
114+
self._token = access_token
115+
self._token_expiration = datetime.now() + timedelta(seconds=expires_in)
116+
117+
118+
async def async_get_api(
119+
username: str, password: str, *, session: Optional[ClientSession] = None
120+
) -> API:
121+
"""Instantiate an authenticated API object.
122+
123+
:param session: An ``aiohttp`` ``ClientSession``
124+
:type session: ``aiohttp.client.ClientSession``
125+
:param email: A Phyn email address
126+
:type email: ``str``
127+
:param password: A Phyn password
128+
:type password: ``str``
129+
:rtype: :meth:`aiophyn.api.API`
130+
"""
131+
api = API(username, password, session=session)
132+
await api.async_authenticate()
133+
return api

aiophyn/const.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""Define package constants."""
2+
API_BASE: str = "https://api.phyn.com"

aiophyn/device.py

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Define /devices endpoints."""
2+
from typing import Awaitable, Callable, Optional
3+
4+
from .const import API_BASE
5+
6+
7+
class Device:
8+
"""Define an object to handle the endpoints."""
9+
10+
def __init__(self, request: Callable[..., Awaitable]) -> None:
11+
"""Initialize."""
12+
self._request: Callable[..., Awaitable] = request
13+
14+
async def get_state(self, device_id: str) -> dict:
15+
"""Return state of a device.
16+
17+
:param device_id: Unique identifier for the device
18+
:type device_id: ``str``
19+
:rtype: ``dict``
20+
"""
21+
return await self._request("get", f"{API_BASE}/devices/{device_id}/state")
22+
23+
async def get_consumption(
24+
self,
25+
device_id: str,
26+
duration: str,
27+
precision: int = 6,
28+
details: Optional[str] = False,
29+
event_count: Optional[str] = False,
30+
comparison: Optional[str] = False,
31+
) -> dict:
32+
"""Return water consumption of a device.
33+
34+
:param device_id: Unique identifier for the device
35+
:type device_id: ``str``
36+
:param duration: Date string formatted as 'YYYY/MM/DD', 'YYYY/MM', or 'YYYY'
37+
:type duration: ``str``
38+
:param precision: Decimal places of measurement precision
39+
:type precision: ``int``
40+
:param details: Include detailed breakdown of consumption
41+
:type details: ``bool``
42+
:param event_count: Include the event count
43+
:type event_count: ``bool``
44+
:param comparison: Include comparison data
45+
:type comparison: ``bool``
46+
:rtype: ``dict``
47+
"""
48+
49+
params = {
50+
"device_id": device_id,
51+
"duration": duration,
52+
"precision": precision,
53+
}
54+
55+
if details:
56+
params["details"] = "Y"
57+
58+
if event_count:
59+
params["event_count"] = "Y"
60+
61+
if comparison:
62+
params["comparison"] = "Y"
63+
64+
return await self._request(
65+
"get", f"{API_BASE}/devices/{device_id}/consumption/details", params=params
66+
)
67+
68+
async def open_valve(self, device_id: str) -> None:
69+
"""Open a device shutoff valve.
70+
71+
:param device_id: Unique identifier for the device
72+
:type device_id: ``str``
73+
:rtype: ``dict``
74+
"""
75+
return await self._request(
76+
"post",
77+
f"{API_BASE}/devices/{device_id}/sov/Open",
78+
)
79+
80+
async def close_valve(self, device_id: str) -> None:
81+
"""Close a device shutoff valve.
82+
83+
:param device_id: Unique identifier for the device
84+
:type device_id: ``str``
85+
:rtype: ``dict``
86+
"""
87+
return await self._request(
88+
"post",
89+
f"{API_BASE}/devices/{device_id}/sov/Close",
90+
)

aiophyn/errors.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Define package errors."""
2+
3+
4+
class PhynError(Exception):
5+
"""Define a base error."""
6+
7+
...
8+
9+
10+
class RequestError(PhynError):
11+
"""Define an error related to invalid requests."""
12+
13+
...

aiophyn/home.py

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Define /home endpoints."""
2+
from typing import Awaitable, Callable
3+
4+
from .const import API_BASE
5+
6+
7+
class Home:
8+
"""Define an object to handle the endpoints."""
9+
10+
def __init__(self, request: Callable[..., Awaitable]) -> None:
11+
"""Initialize."""
12+
self._request: Callable[..., Awaitable] = request
13+
14+
async def get_info(
15+
self,
16+
user_id: str,
17+
) -> dict:
18+
"""Return info for all homes.
19+
20+
:param user_id: Phyn username (email)
21+
:type user_id: ``str``
22+
:rtype: ``list``
23+
"""
24+
params = {"user_id": user_id}
25+
26+
return await self._request("get", f"{API_BASE}/homes", params=params)

examples/test_api.py

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Run an example script to quickly test."""
2+
import asyncio
3+
import logging
4+
from datetime import date
5+
6+
from aiohttp import ClientSession
7+
8+
from aiophyn import async_get_api
9+
from aiophyn.errors import PhynError
10+
11+
_LOGGER = logging.getLogger()
12+
13+
USERNAME = "USERNAME_HERE"
14+
PASSWORD = "PASSWORD_HERE"
15+
16+
17+
async def main() -> None:
18+
"""Create the aiohttp session and run the example."""
19+
logging.basicConfig(level=logging.INFO)
20+
async with ClientSession() as session:
21+
try:
22+
api = await async_get_api(USERNAME, PASSWORD, session=session)
23+
24+
all_home_info = await api.home.get_info(USERNAME)
25+
_LOGGER.info(all_home_info)
26+
27+
home_info = all_home_info[0]
28+
_LOGGER.info(home_info)
29+
30+
first_device_id = home_info["device_ids"][0]
31+
device_state = await api.device.get_state(first_device_id)
32+
_LOGGER.info(device_state)
33+
34+
duration_today = date.today().strftime("%Y/%m/%d")
35+
consumption_info = await api.device.get_consumption(
36+
first_device_id, duration_today, details=True
37+
)
38+
_LOGGER.info(consumption_info)
39+
40+
valve_status = device_state["sov_status"]["v"]
41+
_LOGGER.info(valve_status)
42+
43+
# close_valve_response = await api.device.close_valve(first_device_id)
44+
# _LOGGER.info(close_valve_response)
45+
46+
# open_valve_response = await api.device.open_valve(first_device_id)
47+
# _LOGGER.info(open_valve_response)
48+
49+
except PhynError as err:
50+
_LOGGER.error("There was an error: %s", err)
51+
52+
53+
asyncio.run(main())

0 commit comments

Comments
 (0)