pycync 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycync/__init__.py +3 -0
- pycync/auth.py +166 -0
- pycync/const.py +5 -0
- pycync/cync.py +117 -0
- pycync/devices/__init__.py +2 -0
- pycync/devices/capabilities.py +1605 -0
- pycync/devices/controllable.py +41 -0
- pycync/devices/device_storage.py +82 -0
- pycync/devices/device_types.py +339 -0
- pycync/devices/devices.py +186 -0
- pycync/devices/groups.py +189 -0
- pycync/exceptions.py +20 -0
- pycync/tcp/command_client.py +125 -0
- pycync/tcp/inner_packet_builder.py +151 -0
- pycync/tcp/packet.py +56 -0
- pycync/tcp/packet_builder.py +124 -0
- pycync/tcp/packet_parser.py +158 -0
- pycync/tcp/tcp_manager.py +202 -0
- pycync/user.py +45 -0
- pycync-0.1.0.dist-info/METADATA +113 -0
- pycync-0.1.0.dist-info/RECORD +23 -0
- pycync-0.1.0.dist-info/WHEEL +4 -0
- pycync-0.1.0.dist-info/licenses/LICENSE +674 -0
pycync/__init__.py
ADDED
pycync/auth.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Contains auth information about an authenticated user, and performs generic REST API calls for the user.
|
|
3
|
+
"""
|
|
4
|
+
import time
|
|
5
|
+
from asyncio import TimeoutError
|
|
6
|
+
from json import dumps
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from aiohttp import ClientSession, ClientResponseError
|
|
10
|
+
|
|
11
|
+
from .const import GE_CORP_ID, REST_API_BASE_URL
|
|
12
|
+
from .user import User
|
|
13
|
+
from .exceptions import BadRequestError, TwoFactorRequiredError, AuthFailedError, CyncError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Auth:
|
|
17
|
+
def __init__(self, session: ClientSession, user: User = None, username: str = None, password: str = None) -> None:
|
|
18
|
+
"""Initialize the auth."""
|
|
19
|
+
self._session = session
|
|
20
|
+
self._user = user
|
|
21
|
+
self._username = username
|
|
22
|
+
self._password = password
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def user(self):
|
|
26
|
+
return self._user
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def session(self):
|
|
30
|
+
return self._session
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def username(self):
|
|
34
|
+
return self._username
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def password(self):
|
|
38
|
+
return self._password
|
|
39
|
+
|
|
40
|
+
async def login(self, two_factor_code: str = None) -> User:
|
|
41
|
+
"""
|
|
42
|
+
Attempts to log in with the configured user information.
|
|
43
|
+
|
|
44
|
+
If a two factor code is not provided, and the server requests a two factor code, a TwoFactorRequiredError
|
|
45
|
+
will be raised, and at the same time a two factor code will be sent to the user's email.
|
|
46
|
+
This function can then be called again with the two factor code provided to authenticate with the code.
|
|
47
|
+
"""
|
|
48
|
+
if two_factor_code is None:
|
|
49
|
+
try:
|
|
50
|
+
user_info = await self._async_auth_user()
|
|
51
|
+
self._user = User(user_info["access_token"], user_info["refresh_token"], user_info["authorize"],
|
|
52
|
+
user_info["user_id"], expire_in=user_info["expire_in"])
|
|
53
|
+
return self._user
|
|
54
|
+
except TwoFactorRequiredError:
|
|
55
|
+
two_factor_request = {'corp_id': GE_CORP_ID, 'email': self.username, 'local_lang': "en-us"}
|
|
56
|
+
|
|
57
|
+
await self._send_user_request(url=f'{REST_API_BASE_URL}/v2/two_factor/email/verifycode', method="POST",
|
|
58
|
+
json=two_factor_request)
|
|
59
|
+
raise TwoFactorRequiredError('Two factor verification required. Code sent to user email.')
|
|
60
|
+
else:
|
|
61
|
+
user_info = await self._async_auth_user_two_factor(two_factor_code)
|
|
62
|
+
self._user = User(user_info["access_token"], user_info["refresh_token"], user_info["authorize"],
|
|
63
|
+
user_info["user_id"], expire_in=user_info["expire_in"])
|
|
64
|
+
return self._user
|
|
65
|
+
|
|
66
|
+
async def _async_auth_user(self):
|
|
67
|
+
"""Attempt to authenticate user without a two factor code."""
|
|
68
|
+
auth_data = {'corp_id': GE_CORP_ID, 'email': self.username, 'password': self.password}
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
auth_response = await self._send_user_request(url=f'{REST_API_BASE_URL}/v2/user_auth', method="POST",
|
|
72
|
+
json=auth_data)
|
|
73
|
+
return auth_response
|
|
74
|
+
except BadRequestError:
|
|
75
|
+
raise TwoFactorRequiredError("Two factor verification required.")
|
|
76
|
+
|
|
77
|
+
async def _async_auth_user_two_factor(self, two_factor_code: str):
|
|
78
|
+
"""Attempt to authenticate user with a two factor code."""
|
|
79
|
+
two_factor_request = {'corp_id': GE_CORP_ID, 'email': self.username, 'password': self.password,
|
|
80
|
+
'two_factor': two_factor_code, 'resource': 1}
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
auth_response = await self._send_user_request(url=f'{REST_API_BASE_URL}/v2/user_auth/two_factor',
|
|
84
|
+
method="POST", json=two_factor_request)
|
|
85
|
+
return auth_response
|
|
86
|
+
except BadRequestError as ex:
|
|
87
|
+
raise AuthFailedError("Invalid two-factor code") from ex
|
|
88
|
+
|
|
89
|
+
async def async_refresh_user_token(self):
|
|
90
|
+
"""
|
|
91
|
+
Refresh the user's session token. If the token has already expired, a new login will be required.
|
|
92
|
+
(Likely also requiring a new two factor code to be provided.)
|
|
93
|
+
"""
|
|
94
|
+
refresh_request = {'refresh_token': self._user.refresh_token}
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
body = dumps(refresh_request)
|
|
98
|
+
|
|
99
|
+
resp = await self.session.request(method="POST", url=f'{REST_API_BASE_URL}/v2/user/token/refresh',
|
|
100
|
+
data=body)
|
|
101
|
+
if resp.status != 200:
|
|
102
|
+
raise AuthFailedError('Refresh token failed')
|
|
103
|
+
|
|
104
|
+
auth_response = await resp.json()
|
|
105
|
+
|
|
106
|
+
self._user.set_new_access_token(auth_response["access_token"], auth_response["refresh_token"],
|
|
107
|
+
auth_response["expire_in"])
|
|
108
|
+
except Exception as ex:
|
|
109
|
+
raise AuthFailedError(ex)
|
|
110
|
+
|
|
111
|
+
async def _send_user_request(
|
|
112
|
+
self,
|
|
113
|
+
url: str,
|
|
114
|
+
method: str = "GET",
|
|
115
|
+
json: dict[Any, Any] | None = None,
|
|
116
|
+
raise_for_status: bool = True,
|
|
117
|
+
) -> dict:
|
|
118
|
+
"""Send an HTTP request with the provided parameters."""
|
|
119
|
+
headers = {}
|
|
120
|
+
if self.user:
|
|
121
|
+
if self.user.expires_at - time.time() < 3600:
|
|
122
|
+
await self.async_refresh_user_token()
|
|
123
|
+
headers["Access-Token"] = self.user.access_token
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
if json:
|
|
127
|
+
body = dumps(json)
|
|
128
|
+
|
|
129
|
+
resp = await self.session.request(method, url, headers=headers, data=body)
|
|
130
|
+
else:
|
|
131
|
+
resp = await self.session.request(method, url, headers=headers)
|
|
132
|
+
|
|
133
|
+
except TimeoutError as ex:
|
|
134
|
+
msg = f"Timeout error during query of url {url}: {ex}"
|
|
135
|
+
raise CyncError(msg) from ex
|
|
136
|
+
except Exception as ex:
|
|
137
|
+
msg = f"Unknown error during query of url {url}: {ex}"
|
|
138
|
+
raise CyncError(msg) from ex
|
|
139
|
+
|
|
140
|
+
async with resp:
|
|
141
|
+
if resp.status == 400:
|
|
142
|
+
raise BadRequestError("Bad Request")
|
|
143
|
+
|
|
144
|
+
if resp.status == 401 or resp.status == 403:
|
|
145
|
+
await self.async_refresh_user_token()
|
|
146
|
+
|
|
147
|
+
headers["Access-Token"] = self.user.access_token
|
|
148
|
+
|
|
149
|
+
if json:
|
|
150
|
+
body = dumps(json)
|
|
151
|
+
|
|
152
|
+
resp = await self.session.request(method, url, headers=headers, data=body)
|
|
153
|
+
else:
|
|
154
|
+
resp = await self.session.request(method, url, headers=headers)
|
|
155
|
+
|
|
156
|
+
if raise_for_status:
|
|
157
|
+
try:
|
|
158
|
+
resp.raise_for_status()
|
|
159
|
+
except ClientResponseError as ex:
|
|
160
|
+
msg = (
|
|
161
|
+
f"HTTP error with status code {resp.status} "
|
|
162
|
+
f"during query of url {url}: {ex}"
|
|
163
|
+
)
|
|
164
|
+
raise CyncError(msg) from ex
|
|
165
|
+
|
|
166
|
+
return await resp.json()
|
pycync/const.py
ADDED
pycync/cync.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The main API interface for the Cync library.
|
|
3
|
+
Each instance of this class corresponds to one user, and all devices/homes associated with that user.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from .auth import Auth
|
|
10
|
+
from .devices import create_device, CyncDevice, device_storage
|
|
11
|
+
from .exceptions import MissingAuthError
|
|
12
|
+
from .const import REST_API_BASE_URL
|
|
13
|
+
from pycync.devices.groups import CyncRoom, CyncGroup, CyncHome
|
|
14
|
+
from pycync.tcp.command_client import CommandClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Cync:
|
|
18
|
+
def __init__(self, auth: Auth):
|
|
19
|
+
"""
|
|
20
|
+
Initialize a Cync object.
|
|
21
|
+
The static create function should be used to create a new Cync object.
|
|
22
|
+
"""
|
|
23
|
+
if not auth.user:
|
|
24
|
+
raise MissingAuthError("No logged in user exists on auth object.")
|
|
25
|
+
self._auth = auth
|
|
26
|
+
self._command_client = CommandClient(auth.user)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
async def create(cls, auth: Auth):
|
|
30
|
+
cync_api = Cync(auth)
|
|
31
|
+
|
|
32
|
+
await cync_api.refresh_home_info()
|
|
33
|
+
cync_api._command_client.start_connection()
|
|
34
|
+
|
|
35
|
+
return cync_api
|
|
36
|
+
|
|
37
|
+
def get_logged_in_user(self):
|
|
38
|
+
"""Get logged in user."""
|
|
39
|
+
|
|
40
|
+
return self._auth.user
|
|
41
|
+
|
|
42
|
+
async def refresh_credentials(self):
|
|
43
|
+
"""Refresh user credentials."""
|
|
44
|
+
await self._auth.async_refresh_user_token()
|
|
45
|
+
return self.get_logged_in_user()
|
|
46
|
+
|
|
47
|
+
def set_update_callback(self, update_callback: Callable):
|
|
48
|
+
"""
|
|
49
|
+
Set the callback function that will be called when a device's state changes,
|
|
50
|
+
or when a poll request for device state receives a response.
|
|
51
|
+
"""
|
|
52
|
+
device_storage.set_user_device_callback(self._auth.user.user_id, update_callback)
|
|
53
|
+
|
|
54
|
+
def update_device_states(self):
|
|
55
|
+
"""Query the server for current device states, and update the devices."""
|
|
56
|
+
asyncio.create_task(self._command_client.update_mesh_devices())
|
|
57
|
+
|
|
58
|
+
def get_devices(self):
|
|
59
|
+
"""Get a flat list of devices associated with this user."""
|
|
60
|
+
return device_storage.get_flattened_devices(self._auth.user.user_id)
|
|
61
|
+
|
|
62
|
+
def get_homes(self):
|
|
63
|
+
"""Get all homes, devices, and groups for the account."""
|
|
64
|
+
return device_storage.get_user_homes(self._auth.user.user_id)
|
|
65
|
+
|
|
66
|
+
async def refresh_home_info(self):
|
|
67
|
+
"""Refresh all nested home information for this account, and update the device storage.."""
|
|
68
|
+
device_info = await self._auth._send_user_request(
|
|
69
|
+
f"{REST_API_BASE_URL}/v2/user/{self._auth.user.user_id}/subscribe/devices")
|
|
70
|
+
home_entries = [device for device in device_info if device["source"] == 5]
|
|
71
|
+
homes = []
|
|
72
|
+
|
|
73
|
+
for home_json in home_entries:
|
|
74
|
+
home_devices: list[CyncDevice] = []
|
|
75
|
+
rooms: list[CyncRoom] = []
|
|
76
|
+
groups: list[CyncGroup] = []
|
|
77
|
+
home: CyncHome = CyncHome(home_json["name"], home_json["id"], [], [])
|
|
78
|
+
|
|
79
|
+
mesh_device_info = await self._auth._send_user_request(
|
|
80
|
+
f"{REST_API_BASE_URL}/v2/product/{home_json["product_id"]}/device/{home_json["id"]}/property")
|
|
81
|
+
if "bulbsArray" in mesh_device_info:
|
|
82
|
+
mesh_devices = mesh_device_info["bulbsArray"]
|
|
83
|
+
for mesh_device in mesh_devices:
|
|
84
|
+
matching_device = next(device for device in device_info if device["id"] == mesh_device["switchID"])
|
|
85
|
+
created_device = create_device(matching_device, mesh_device, home, self._command_client)
|
|
86
|
+
|
|
87
|
+
home_devices.append(created_device)
|
|
88
|
+
|
|
89
|
+
room_json = []
|
|
90
|
+
group_json = []
|
|
91
|
+
if "groupsArray" in mesh_device_info:
|
|
92
|
+
room_json = [group for group in mesh_device_info["groupsArray"] if group["isSubgroup"] == False]
|
|
93
|
+
group_json = [group for group in mesh_device_info["groupsArray"] if group["isSubgroup"] == True]
|
|
94
|
+
|
|
95
|
+
for group in group_json:
|
|
96
|
+
group_devices = [device for device in home_devices if
|
|
97
|
+
device.isolated_mesh_id in group.get("deviceIDArray", [])]
|
|
98
|
+
groups.append(
|
|
99
|
+
CyncGroup(group["displayName"], group["groupID"], home, group_devices, self._command_client))
|
|
100
|
+
home_devices = [device for device in home_devices if device not in group_devices]
|
|
101
|
+
|
|
102
|
+
for room in room_json:
|
|
103
|
+
room_devices = [device for device in home_devices if device.isolated_mesh_id in room["deviceIDArray"]]
|
|
104
|
+
room_groups = [group for group in groups if group.group_id in room.get("subgroupIDArray", [])]
|
|
105
|
+
rooms.append(CyncRoom(room["displayName"], room["groupID"], home, room_groups, room_devices,
|
|
106
|
+
self._command_client))
|
|
107
|
+
home_devices = [device for device in home_devices if device not in room_devices]
|
|
108
|
+
|
|
109
|
+
home.global_devices = home_devices
|
|
110
|
+
home.rooms = rooms
|
|
111
|
+
homes.append(home)
|
|
112
|
+
|
|
113
|
+
device_storage.set_user_homes(self._auth.user.user_id, homes)
|
|
114
|
+
|
|
115
|
+
def shut_down(self):
|
|
116
|
+
"""Shut down the command client instance and close its associated connections."""
|
|
117
|
+
self._command_client.shut_down()
|