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 ADDED
@@ -0,0 +1,3 @@
1
+ from pycync.auth import Auth, User
2
+ from pycync.cync import Cync
3
+ from pycync.devices import CyncDevice, CyncLight, CyncRoom, CyncGroup, CyncHome
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
@@ -0,0 +1,5 @@
1
+ """Constants for the API."""
2
+
3
+ GE_CORP_ID = "1007d2ad150c4000"
4
+
5
+ REST_API_BASE_URL = "https://api.gelighting.com"
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()
@@ -0,0 +1,2 @@
1
+ from .devices import CyncControllable, CyncDevice, CyncLight, create_device
2
+ from .groups import CyncHome, CyncGroup, CyncRoom