loqedAPI 2.1.5__tar.gz → 2.1.7__tar.gz

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.
@@ -1,15 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: loqedAPI
3
- Version: 2.1.5
3
+ Version: 2.1.7
4
4
  Summary: Python package to use the Loqed Smart Door Lock APIs in a local network. To be used by Home Assistant.
5
5
  Home-page: https://github.com/cpolhout/loqedAPI
6
6
  Author: Casper Polhout
7
7
  Author-email: cpolhout@gmail.com
8
8
  License: BSD 2-clause
9
9
  Project-URL: Bug Tracker, https://github.com/cpolhout/loqedAPI/issues
10
- Description: # Loqed Touch Smart Lock Python library
11
-
12
- Platform: UNKNOWN
13
10
  Classifier: Development Status :: 1 - Planning
14
11
  Classifier: Intended Audience :: Science/Research
15
12
  Classifier: License :: OSI Approved :: BSD License
@@ -21,3 +18,6 @@ Classifier: Programming Language :: Python :: 3.4
21
18
  Classifier: Programming Language :: Python :: 3.5
22
19
  Requires-Python: >=3.6
23
20
  Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+
23
+ # Loqed Touch Smart Lock Python library
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = loqedAPI
3
- version = 2.1.5
3
+ version = 2.1.7
4
4
  author = Casper Polhout
5
5
  author_email = cpolhout@gmail.com
6
6
  description = Python package to use the Loqed Smart Door Lock APIs in a local network. To be used by Home Assistant.
@@ -0,0 +1,24 @@
1
+ from setuptools import find_packages, setup
2
+
3
+ setup(
4
+ name="loqedAPI",
5
+ version="2.1.7",
6
+ description="Python package to use the Loqed Smart Door Lock APIs in a local network. To be used by Home Assistant.",
7
+ url="https://github.com/cpolhout/loqedAPI",
8
+ author="Casper Polhout",
9
+ author_email="cpolhout@gmail.com",
10
+ license="BSD 2-clause",
11
+ packages=find_packages(exclude=["tests", "generator"]),
12
+ install_requires=["aiohttp", "async-timeout"],
13
+ classifiers=[
14
+ "Development Status :: 1 - Planning",
15
+ "Intended Audience :: Science/Research",
16
+ "License :: OSI Approved :: BSD License",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Programming Language :: Python :: 2",
19
+ "Programming Language :: Python :: 2.7",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.4",
22
+ "Programming Language :: Python :: 3.5",
23
+ ],
24
+ )
@@ -0,0 +1,19 @@
1
+ from aiohttp import ClientSession
2
+ from .loqed import AbstractAPIClient
3
+ from .urls import CLOUD_BASE_URL
4
+
5
+
6
+ class CloudAPIClient(AbstractAPIClient):
7
+ def __init__(self, websession: ClientSession, token: str | None = None):
8
+ """Initialize the auth."""
9
+ super().__init__(websession, CLOUD_BASE_URL, token)
10
+
11
+
12
+ class LoqedCloudAPI:
13
+ def __init__(self, apiclient: CloudAPIClient):
14
+ self.apiclient = apiclient
15
+
16
+ async def async_get_locks(self):
17
+ resp = await self.apiclient.request("get", "api/locks/")
18
+ resp.raise_for_status()
19
+ return await resp.json()
@@ -0,0 +1,346 @@
1
+ """
2
+ Loqed API integration
3
+ This is the local API integration. For the remote integration look at LoqedAPI_internet
4
+ """
5
+
6
+ from enum import Enum
7
+ import logging
8
+ from typing import List
9
+ import os
10
+ import json
11
+ from abc import abstractmethod
12
+ from asyncio import get_event_loop
13
+ from aiohttp import ClientSession, ClientResponse
14
+ from typing import List
15
+ import struct
16
+ import time
17
+ import hmac
18
+ import base64
19
+ import hashlib
20
+ import urllib
21
+
22
+ logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
23
+ _LOGGER = logging.getLogger(__name__)
24
+
25
+
26
+ class Action(Enum):
27
+ OPEN = 1
28
+ UNLOCK = 2
29
+ LOCK = 3
30
+
31
+
32
+ class AbstractAPIClient:
33
+ """Client to handle API calls."""
34
+
35
+ def __init__(self, websession: ClientSession, host: str, token: str | None = None):
36
+ """Initialize the client."""
37
+ self.websession = websession
38
+ self.host = host
39
+ self.token = token
40
+ _LOGGER.debug("API client created")
41
+
42
+ async def request(self, method, url, **kwargs) -> ClientResponse:
43
+ """Make a request."""
44
+ headers = kwargs.pop("headers", {})
45
+ headers = dict(headers)
46
+
47
+ if self.token:
48
+ headers["authorization"] = f"Bearer {self.token}"
49
+
50
+ return await self.websession.request(
51
+ method,
52
+ f"{self.host}/{url}",
53
+ **kwargs,
54
+ headers=headers,
55
+ )
56
+
57
+
58
+ class APIClient(AbstractAPIClient):
59
+ def __init__(self, websession: ClientSession, host: str):
60
+ """Initialize the auth."""
61
+ super().__init__(websession, host)
62
+
63
+
64
+ class Lock:
65
+ """Class that represents a Lock object in the LoqedAPI."""
66
+
67
+ def __init__(
68
+ self,
69
+ raw_data: dict,
70
+ secret: str,
71
+ bridgekey: str,
72
+ key_id: int,
73
+ name: str,
74
+ apiclient: APIClient,
75
+ ):
76
+ """Initialize a lock object."""
77
+ self.raw_data = raw_data
78
+ self.secret = secret
79
+ self.bridgekey = bridgekey
80
+ self.key_id = key_id
81
+ self.apiclient = apiclient
82
+ self.webhooks = {}
83
+ self.name = name
84
+ self.bolt_state = raw_data["bolt_state"]
85
+ self.battery_percentage = raw_data["battery_percentage"]
86
+ self.last_key_id = ""
87
+ self.waitforstate = False
88
+ self.last_event = ""
89
+ self.id = self.raw_data["bridge_mac_wifi"]
90
+
91
+ @property
92
+ def battery_voltage(self) -> str:
93
+ """Return the ID of the lock."""
94
+ return self.raw_data["battery_voltage"]
95
+
96
+ @property
97
+ def wifi_strength(self) -> str:
98
+ """Return the ID of the lock."""
99
+ return self.raw_data["wifi_strength"]
100
+
101
+ @property
102
+ def ble_strength(self) -> str:
103
+ """Return the ID of the lock."""
104
+ return self.raw_data["ble_strength"]
105
+
106
+ @property
107
+ def battery_type(self) -> str:
108
+ """Return the name of the lock."""
109
+ return self.raw_data["battery_type"]
110
+
111
+ def getcommand(self, action: Action) -> str:
112
+ """Creates a hashed command to send to the loqed lock"""
113
+ messageId_bin = struct.pack("Q", 0)
114
+ protocol_bin = struct.pack("B", 2)
115
+ command_type_bin = struct.pack("B", 7)
116
+ local_key_id_bin = struct.pack("B", self.key_id)
117
+ device_id_bin = struct.pack("B", 1)
118
+ action_bin = struct.pack("B", action.value)
119
+ now = int(time.time())
120
+ timenow_bin = now.to_bytes(8, "big", signed=False)
121
+ local_generated_binary_hash = (
122
+ protocol_bin
123
+ + command_type_bin
124
+ + timenow_bin
125
+ + local_key_id_bin
126
+ + device_id_bin
127
+ + action_bin
128
+ )
129
+ hm = hmac.new(
130
+ base64.b64decode(self.secret), local_generated_binary_hash, hashlib.sha256
131
+ ).digest()
132
+ command = (
133
+ messageId_bin
134
+ + protocol_bin
135
+ + command_type_bin
136
+ + timenow_bin
137
+ + hm
138
+ + local_key_id_bin
139
+ + device_id_bin
140
+ + action_bin
141
+ )
142
+ return urllib.parse.quote(base64.b64encode(command).decode("ascii"))
143
+
144
+ async def sendcommand(self, type: Action) -> ClientResponse:
145
+ """Sends the hashed command to the loqed lock"""
146
+ command = self.getcommand(type)
147
+ resp = await self.apiclient.request(
148
+ "get", f"to_lock?command_signed_base64={command}"
149
+ )
150
+ resp.raise_for_status()
151
+ return resp
152
+
153
+ async def open(self) -> ClientResponse:
154
+ "Open the lock"
155
+ return await self.sendcommand(Action.OPEN)
156
+
157
+ async def lock(self) -> ClientResponse:
158
+ "Set night-lock"
159
+ return await self.sendcommand(Action.LOCK)
160
+
161
+ async def unlock(self) -> ClientResponse:
162
+ "Set day-lock"
163
+ return await self.sendcommand(Action.UNLOCK)
164
+
165
+ async def update(self):
166
+ "Update status"
167
+ resp = await self.apiclient.request("get", "status")
168
+ resp.raise_for_status()
169
+ json_data = await resp.json(content_type="text/html")
170
+ self.raw_data = json_data
171
+ self.bolt_state = self.raw_data["bolt_state"]
172
+ return json_data
173
+
174
+ async def getWebhooks(self):
175
+ "Get webhooks for this lock"
176
+ now = int(time.time())
177
+ hash = hashlib.sha256(
178
+ now.to_bytes(8, "big", signed=False) + base64.b64decode(self.bridgekey)
179
+ ).hexdigest()
180
+ headers = {"TIMESTAMP": str(now), "HASH": hash}
181
+ resp = await self.apiclient.request("get", f"webhooks", headers=headers)
182
+ resp.raise_for_status()
183
+ json_data = await resp.json(content_type="text/html")
184
+ _LOGGER.debug("get Webhooks Response: %s", str(json_data))
185
+ self.webhooks = {}
186
+ return json_data
187
+
188
+ async def registerWebhook(self, url: str, flags: int = 511) -> str:
189
+ "Register webhook for this lock subscribed to all events, first checks if its not already there"
190
+ webhooks = await self.getWebhooks()
191
+ for hook in webhooks:
192
+ if hook["url"] == url:
193
+ return "EXISTS ALREADY"
194
+ now = int(time.time())
195
+ hash = hashlib.sha256(
196
+ url.encode()
197
+ + flags.to_bytes(4, "big")
198
+ + now.to_bytes(8, "big", signed=False)
199
+ + base64.b64decode(self.bridgekey)
200
+ ).hexdigest()
201
+ headers = {"TIMESTAMP": str(now), "HASH": hash}
202
+ json = {
203
+ "url": url,
204
+ "trigger_state_changed_open": flags >> 0 & 1,
205
+ "trigger_state_changed_latch": flags >> 1 & 1,
206
+ "trigger_state_changed_night_lock": flags >> 2 & 1,
207
+ "trigger_state_changed_unknown": flags >> 3 & 1,
208
+ "trigger_state_goto_open": flags >> 4 & 1,
209
+ "trigger_state_goto_latch": flags >> 5 & 1,
210
+ "trigger_state_goto_night_lock": flags >> 6 & 1,
211
+ "trigger_battery": flags >> 7 & 1,
212
+ "trigger_online_status": flags >> 8 & 1,
213
+ }
214
+ resp = await self.apiclient.request(
215
+ "post", "webhooks", json=json, headers=headers
216
+ )
217
+ resp.raise_for_status()
218
+ _LOGGER.debug("Create webhook Response: %s", await resp.text())
219
+ return "CREATED"
220
+
221
+ async def deleteWebhook(self, id: int) -> None:
222
+ "Delete webhook for this lock"
223
+ now = int(time.time())
224
+ hash = hashlib.sha256(
225
+ id.to_bytes(8, "big", signed=False)
226
+ + now.to_bytes(8, "big", signed=False)
227
+ + base64.b64decode(self.bridgekey)
228
+ ).hexdigest()
229
+ headers = {"TIMESTAMP": str(now), "HASH": hash}
230
+ resp = await self.apiclient.request("delete", f"webhooks/{id}", headers=headers)
231
+ resp.raise_for_status()
232
+ _LOGGER.debug("Delete webhook Response: %s", await resp.text())
233
+
234
+ async def receiveWebhook(self, body, hash: str, timestamp: str):
235
+ "Received webhook with hash. This method checks the hash."
236
+ timestamp = int(timestamp)
237
+ now = int(time.time())
238
+ data = json.loads(body) if body else {}
239
+ if data == {}:
240
+ error = {
241
+ "error": "Received invalid data from LOQED. Data needs to be formatted as JSON",
242
+ "body": body,
243
+ "hash": hash,
244
+ "timestamp": timestamp,
245
+ "now": now,
246
+ }
247
+ _LOGGER.error("ERROR: %s", error)
248
+ return error
249
+
250
+ if not isinstance(data, dict):
251
+ error = {
252
+ "error": "Received invalid data from LOQED. Data needs to be a dictionary",
253
+ "body": body,
254
+ "hash": hash,
255
+ "timestamp": timestamp,
256
+ "now": now,
257
+ }
258
+ _LOGGER.error("ERROR: %s", error)
259
+ return error
260
+ _LOGGER.debug(
261
+ " Received timestamp: %s , current timestamp: %s", str(timestamp), str(now)
262
+ )
263
+ # check timestamp within 10 seconds
264
+ if (now - timestamp > 10) or (timestamp - now > 10):
265
+ error = {
266
+ "error": "Timestamp incorrect, possible replaying",
267
+ "body": body,
268
+ "hash": hash,
269
+ "timestamp": timestamp,
270
+ "now": now,
271
+ }
272
+ _LOGGER.error("ERROR: %s", error)
273
+ return error
274
+ chash = hashlib.sha256(
275
+ body.encode()
276
+ + timestamp.to_bytes(8, "big", signed=False)
277
+ + base64.b64decode(self.bridgekey)
278
+ ).hexdigest()
279
+ _LOGGER.debug("Received hash: %s , calculated hash: %s", hash, chash)
280
+ if chash != hash:
281
+ error = {
282
+ "error": "Hash incorrect",
283
+ "body": body,
284
+ "hash": hash,
285
+ "calculated_hash": chash,
286
+ "timestamp": timestamp,
287
+ "now": now,
288
+ }
289
+ _LOGGER.error("ERROR: %s", error)
290
+ return error
291
+ if "battery_percentage" in data:
292
+ self.battery_percentage = data["battery_percentage"]
293
+ elif "ble_strength" in data:
294
+ self.raw_data["ble_strength"] = data["ble_strength"]
295
+ else:
296
+ self.last_event = data["event_type"].strip().lower()
297
+ # BOLT STATE CHANGE
298
+ if self.last_event.split("_")[0] == "state":
299
+ self.bolt_state = str.replace(self.last_event, "state_changed_", "")
300
+ else:
301
+ # BOLT IS CHANGING TO A STATE
302
+ if "night_lock" in self.last_event:
303
+ self.bolt_state = "locking"
304
+ if "open" in self.last_event:
305
+ self.bolt_state = "opening"
306
+ if "latch" in self.last_event:
307
+ self.bolt_state = "unlocking"
308
+ self.last_key_id = data["key_local_id"]
309
+ return data
310
+
311
+ async def updateState(self, state):
312
+ self.bolt_state = state
313
+
314
+
315
+ class LoqedAPI:
316
+ def __init__(self, apiclient: APIClient):
317
+ """Initialize the API and store the auth so we can make requests."""
318
+ self.apiclient = apiclient
319
+
320
+ async def async_get_lock_details(self):
321
+ """Return lock_info"""
322
+ resp = await self.apiclient.request("get", "status")
323
+ resp.raise_for_status()
324
+ _LOGGER.debug("Response get lock details: %s", await resp.text())
325
+ json_data = await resp.json(content_type="text/html")
326
+ return json_data
327
+
328
+ async def async_get_lock(
329
+ self, secret: str, bridgekey: str, key_id: int, name: str, json_data=None
330
+ ) -> Lock:
331
+ """Return the locks with lock-data"""
332
+ if not json_data:
333
+ resp = await self.apiclient.request("get", "status")
334
+ json_data = await resp.json(content_type="text/html")
335
+ return Lock(json_data, secret, bridgekey, key_id, name, self.apiclient)
336
+
337
+
338
+ """Loqed: Exceptions"""
339
+
340
+
341
+ class LoqedException(BaseException):
342
+ """Raise this when something is off."""
343
+
344
+
345
+ class LoqedAuthenticationException(LoqedException):
346
+ """Raise this when there is an authentication issue."""
@@ -0,0 +1,7 @@
1
+ """
2
+ Loqed URLs
3
+
4
+
5
+
6
+ """
7
+ CLOUD_BASE_URL = "https://integrations.production.loqed.com"
@@ -1,15 +1,12 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: loqedAPI
3
- Version: 2.1.5
3
+ Version: 2.1.7
4
4
  Summary: Python package to use the Loqed Smart Door Lock APIs in a local network. To be used by Home Assistant.
5
5
  Home-page: https://github.com/cpolhout/loqedAPI
6
6
  Author: Casper Polhout
7
7
  Author-email: cpolhout@gmail.com
8
8
  License: BSD 2-clause
9
9
  Project-URL: Bug Tracker, https://github.com/cpolhout/loqedAPI/issues
10
- Description: # Loqed Touch Smart Lock Python library
11
-
12
- Platform: UNKNOWN
13
10
  Classifier: Development Status :: 1 - Planning
14
11
  Classifier: Intended Audience :: Science/Research
15
12
  Classifier: License :: OSI Approved :: BSD License
@@ -21,3 +18,6 @@ Classifier: Programming Language :: Python :: 3.4
21
18
  Classifier: Programming Language :: Python :: 3.5
22
19
  Requires-Python: >=3.6
23
20
  Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+
23
+ # Loqed Touch Smart Lock Python library
@@ -5,11 +5,14 @@ setup.cfg
5
5
  setup.py
6
6
  src/loqedAPI/__init__.py
7
7
  src/loqedAPI/apiclient.py
8
+ src/loqedAPI/cloud_loqed.py
8
9
  src/loqedAPI/exceptions.py
9
10
  src/loqedAPI/local_loqed.py
10
11
  src/loqedAPI/loqed.py
12
+ src/loqedAPI/urls.py
11
13
  src/loqedAPI.egg-info/PKG-INFO
12
14
  src/loqedAPI.egg-info/SOURCES.txt
13
15
  src/loqedAPI.egg-info/dependency_links.txt
14
16
  src/loqedAPI.egg-info/requires.txt
15
- src/loqedAPI.egg-info/top_level.txt
17
+ src/loqedAPI.egg-info/top_level.txt
18
+ tests/test.py
@@ -0,0 +1,40 @@
1
+ import asyncio
2
+ import aiohttp
3
+ import logging
4
+ import os
5
+ print('cwd is %s' %(os.getcwd()))
6
+
7
+ from loqedAPI.loqed import APIClient
8
+ from loqedAPI.loqed import LoqedAPI
9
+ from loqedAPI.loqed import Lock
10
+
11
+ logging.basicConfig(level=logging.DEBUG)
12
+
13
+ async def main():
14
+ async with aiohttp.ClientSession() as session:
15
+ #apiclient = APIClient(session, "https://bc615891-1f7d-4237-8b72-f20e5719d50e.mock.pstmn.io/api", "")
16
+ apiclient = APIClient(session, "http://xxx")
17
+ api = LoqedAPI(apiclient)
18
+ secret='xxxx'
19
+ bridgekey='xxxx'
20
+ lock = await api.async_get_lock(secret,bridgekey,2, "xxxx")
21
+ print(f"The lock name: {lock.name}")
22
+ print(f"The lock ID: {lock.id}")
23
+ print(f"Locking the lock...")
24
+ # await lock.unlock()
25
+
26
+ print(f"Registering dummy hook..")
27
+ json_data=await lock.getWebhooks()
28
+ for hook in json_data:
29
+ print("FOUND WEBHOOK:" + str(hook))
30
+
31
+ status=await lock.registerWebhook("https://webhook.site/xxx")
32
+ print(status)
33
+
34
+ json_data=await lock.getWebhooks()
35
+ for hook in json_data:
36
+ print("FOUND WEBHOOK:" + str(hook))
37
+
38
+
39
+
40
+ asyncio.run(main())
loqedAPI-2.1.5/setup.py DELETED
@@ -1,24 +0,0 @@
1
- from setuptools import find_packages, setup
2
-
3
- setup(
4
- name='loqedAPI',
5
- version='2.1.5',
6
- description='Python package to use the Loqed Smart Door Lock APIs in a local network. To be used by Home Assistant.',
7
- url='https://github.com/cpolhout/loqedAPI',
8
- author='Casper Polhout',
9
- author_email='cpolhout@gmail.com',
10
- license='BSD 2-clause',
11
- packages=find_packages(exclude=["tests", "generator"]),
12
- install_requires=["aiohttp", "async-timeout"],
13
- classifiers=[
14
- 'Development Status :: 1 - Planning',
15
- 'Intended Audience :: Science/Research',
16
- 'License :: OSI Approved :: BSD License',
17
- 'Operating System :: POSIX :: Linux',
18
- 'Programming Language :: Python :: 2',
19
- 'Programming Language :: Python :: 2.7',
20
- 'Programming Language :: Python :: 3',
21
- 'Programming Language :: Python :: 3.4',
22
- 'Programming Language :: Python :: 3.5',
23
- ],
24
- )
@@ -1,267 +0,0 @@
1
- """
2
- Loqed API integration
3
- This is the local API integration. For the remote integration look at LoqedAPI_internet
4
- """
5
-
6
- from enum import Enum
7
- import logging
8
- from typing import List
9
- import os
10
- import json
11
- from abc import abstractmethod
12
- from asyncio import get_event_loop
13
- from aiohttp import ClientSession, ClientResponse
14
- from typing import List
15
- import struct
16
- import time
17
- import hmac
18
- import base64
19
- import hashlib
20
- import urllib
21
-
22
- logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
23
- _LOGGER = logging.getLogger(__name__)
24
-
25
- class Action(Enum):
26
- OPEN = 1
27
- UNLOCK = 2
28
- LOCK = 3
29
-
30
- class AbstractAPIClient():
31
- """Client to handle API calls."""
32
-
33
- def __init__(self, websession: ClientSession, host: str):
34
- """Initialize the client."""
35
- self.websession = websession
36
- self.host = host
37
- _LOGGER.debug("API client created")
38
-
39
- async def request(self, method, url, **kwargs) -> ClientResponse:
40
- """Make a request."""
41
- return await self.websession.request(
42
- method, f"{self.host}/{url}", **kwargs,
43
- )
44
-
45
- class APIClient(AbstractAPIClient):
46
- def __init__(self, websession: ClientSession, host: str):
47
- """Initialize the auth."""
48
- super().__init__(websession, host)
49
-
50
-
51
- class Lock:
52
- """Class that represents a Lock object in the LoqedAPI."""
53
-
54
- def __init__(self, raw_data: dict, secret: str, bridgekey: str, key_id: int, name: str, apiclient: APIClient):
55
- """Initialize a lock object."""
56
- self.raw_data = raw_data
57
- self.secret = secret
58
- self.bridgekey = bridgekey
59
- self.key_id=key_id
60
- self.apiclient = apiclient
61
- self.webhooks = {}
62
- self.name = name
63
- self.bolt_state= raw_data["bolt_state"]
64
- self.battery_percentage = raw_data["battery_percentage"]
65
- self.last_key_id = ""
66
- self.waitforstate = False
67
- self.last_event = ""
68
- self.id = self.raw_data["bridge_mac_wifi"]
69
-
70
- @property
71
- def battery_voltage(self) -> str:
72
- """Return the ID of the lock."""
73
- return self.raw_data["battery_voltage"]
74
-
75
- @property
76
- def wifi_strength(self) -> str:
77
- """Return the ID of the lock."""
78
- return self.raw_data["wifi_strength"]
79
-
80
- @property
81
- def ble_strength(self) -> str:
82
- """Return the ID of the lock."""
83
- return self.raw_data["ble_strength"]
84
-
85
- @property
86
- def battery_type(self) -> str:
87
- """Return the name of the lock."""
88
- return self.raw_data["battery_type"]
89
-
90
- def getcommand(self, action:Action) -> str:
91
- """Creates a hashed command to send to the loqed lock"""
92
- messageId_bin = struct.pack("Q", 0)
93
- protocol_bin = struct.pack("B", 2)
94
- command_type_bin = struct.pack("B", 7)
95
- local_key_id_bin = struct.pack("B", self.key_id)
96
- device_id_bin = struct.pack("B", 1)
97
- action_bin = struct.pack("B", action.value)
98
- now=int(time.time())
99
- timenow_bin=now.to_bytes(8, 'big', signed=False)
100
- local_generated_binary_hash = protocol_bin + command_type_bin + timenow_bin + local_key_id_bin + device_id_bin + action_bin
101
- hm=hmac.new(base64.b64decode(self.secret), local_generated_binary_hash,hashlib.sha256).digest()
102
- command = messageId_bin + protocol_bin + command_type_bin + timenow_bin + hm + local_key_id_bin + device_id_bin + action_bin
103
- return urllib.parse.quote(base64.b64encode(command).decode("ascii"))
104
-
105
- async def sendcommand(self, type: Action) -> ClientResponse:
106
- """Sends the hashed command to the loqed lock"""
107
- command=self.getcommand(type)
108
- resp = await self.apiclient.request("get", f"to_lock?command_signed_base64={command}")
109
- resp.raise_for_status()
110
- return resp
111
-
112
- async def open(self) -> ClientResponse:
113
- "Open the lock"
114
- return await self.sendcommand(Action.OPEN)
115
-
116
- async def lock(self) -> ClientResponse:
117
- "Set night-lock"
118
- return await self.sendcommand(Action.LOCK)
119
-
120
- async def unlock(self) -> ClientResponse:
121
- "Set day-lock"
122
- return await self.sendcommand(Action.UNLOCK)
123
-
124
- async def update(self):
125
- "Update status"
126
- resp = await self.apiclient.request("get", "status")
127
- resp.raise_for_status()
128
- json_data = await resp.json(content_type='text/html')
129
- self.raw_data=json_data
130
- self.bolt_state=self.raw_data["bolt_state"]
131
- return json_data
132
-
133
- async def getWebhooks(self):
134
- "Get webhooks for this lock"
135
- now=int(time.time())
136
- hash=hashlib.sha256(now.to_bytes(8, 'big', signed=False)+base64.b64decode(self.bridgekey)).hexdigest()
137
- headers = {'TIMESTAMP': str(now), 'HASH': hash}
138
- resp = await self.apiclient.request("get", f"webhooks", headers=headers)
139
- resp.raise_for_status()
140
- json_data = await resp.json(content_type='text/html')
141
- _LOGGER.debug("get Webhooks Response: %s", str(json_data))
142
- self.webhooks={}
143
- return json_data
144
-
145
- async def registerWebhook(self, url: str, flags:int = 511) -> str:
146
- "Register webhook for this lock subscribed to all events, first checks if its not already there"
147
- webhooks=await self.getWebhooks()
148
- for hook in webhooks:
149
- if hook["url"]==url: return "EXISTS ALREADY"
150
- now=int(time.time())
151
- hash=hashlib.sha256(url.encode() + flags.to_bytes(4, 'big') + now.to_bytes(8, 'big', signed=False)+base64.b64decode(self.bridgekey)).hexdigest()
152
- headers = {'TIMESTAMP': str(now), 'HASH': hash}
153
- json = {
154
- "url" : url,
155
- "trigger_state_changed_open" : flags >> 0 & 1,
156
- "trigger_state_changed_latch" : flags >> 1 & 1,
157
- "trigger_state_changed_night_lock" : flags >> 2 & 1,
158
- "trigger_state_changed_unknown" : flags >> 3 & 1,
159
- "trigger_state_goto_open" : flags >> 4 & 1,
160
- "trigger_state_goto_latch" : flags >> 5 & 1,
161
- "trigger_state_goto_night_lock" : flags >> 6 & 1,
162
- "trigger_battery" : flags >> 7 & 1,
163
- "trigger_online_status" : flags >> 8 & 1
164
- }
165
- resp = await self.apiclient.request("post", "webhooks", json=json, headers=headers)
166
- resp.raise_for_status()
167
- _LOGGER.debug("Create webhook Response: %s", await resp.text())
168
- return "CREATED"
169
-
170
- async def deleteWebhook(self, id: int) -> None:
171
- "Delete webhook for this lock"
172
- now=int(time.time())
173
- hash=hashlib.sha256(id.to_bytes(8, 'big', signed=False) + now.to_bytes(8, 'big', signed=False)+base64.b64decode(self.bridgekey)).hexdigest()
174
- headers = {'TIMESTAMP': str(now), 'HASH': hash}
175
- resp = await self.apiclient.request("delete", f"webhooks/{id}", headers=headers)
176
- resp.raise_for_status()
177
- _LOGGER.debug("Delete webhook Response: %s", await resp.text())
178
-
179
- async def receiveWebhook(self, body, hash: str, timestamp: str):
180
- "Received webhook with hash. This method checks the hash."
181
- timestamp=int(timestamp)
182
- now=int(time.time())
183
- data = json.loads(body) if body else {}
184
- if data=={}:
185
- error={"error": "Received invalid data from LOQED. Data needs to be formatted as JSON", "body": body, "hash": hash, "timestamp": timestamp, "now": now}
186
- _LOGGER.error(
187
- "ERROR: %s",error
188
- )
189
- return error
190
-
191
- if not isinstance(data, dict):
192
- error={"error": "Received invalid data from LOQED. Data needs to be a dictionary", "body": body, "hash": hash, "timestamp": timestamp, "now": now}
193
- _LOGGER.error(
194
- "ERROR: %s",error
195
- )
196
- return error
197
- _LOGGER.debug(" Received timestamp: %s , current timestamp: %s", str(timestamp), str(now))
198
- # check timestamp within 10 seconds
199
- if (now-timestamp>10) or (timestamp-now>10):
200
- error={"error": "Timestamp incorrect, possible replaying", "body": body, "hash": hash, "timestamp": timestamp, "now": now}
201
- _LOGGER.error(
202
- "ERROR: %s",error
203
- )
204
- return error
205
- chash=hashlib.sha256(body.encode() + timestamp.to_bytes(8, 'big', signed=False)+base64.b64decode(self.bridgekey)).hexdigest()
206
- _LOGGER.debug("Received hash: %s , calculated hash: %s", hash, chash)
207
- if chash!=hash:
208
- error={"error": "Hash incorrect", "body": body, "hash": hash, "calculated_hash": chash, "timestamp": timestamp, "now": now}
209
- _LOGGER.error(
210
- "ERROR: %s",error
211
- )
212
- return error
213
- if "battery_percentage" in data:
214
- self.battery_percentage=data["battery_percentage"]
215
- elif "ble_strength" in data:
216
- self.raw_data["ble_strength"] = data["ble_strength"]
217
- else:
218
- self.last_event=data["event_type"].strip().lower()
219
- # BOLT STATE CHANGE
220
- if self.last_event.split("_")[0]=="state":
221
- self.bolt_state=str.replace(self.last_event,"state_changed_","")
222
- else:
223
- # BOLT IS CHANGING TO A STATE
224
- if "night_lock" in self.last_event: self.bolt_state="locking"
225
- if "open" in self.last_event: self.bolt_state="opening"
226
- if "latch" in self.last_event: self.bolt_state="unlocking"
227
- self.last_key_id=data["key_local_id"]
228
- return data
229
-
230
- async def updateState(self, state):
231
- self.bolt_state=state
232
-
233
- class LoqedAPI:
234
-
235
- def __init__(self, apiclient: APIClient):
236
- """Initialize the API and store the auth so we can make requests."""
237
- self.apiclient = apiclient
238
-
239
- async def async_get_lock_details(self):
240
- """Return lock_info"""
241
- resp = await self.apiclient.request("get", "status")
242
- resp.raise_for_status()
243
- _LOGGER.debug("Response get lock details: %s",await resp.text())
244
- json_data = await resp.json(content_type='text/html')
245
- return json_data
246
-
247
- async def async_get_lock(self, secret: str, bridgekey: str, key_id: int, name: str, json_data=None) -> Lock:
248
- """Return the locks with lock-data"""
249
- if not json_data:
250
- resp = await self.apiclient.request("get", "status")
251
- json_data = await resp.json(content_type='text/html')
252
- return Lock(json_data, secret, bridgekey, key_id, name, self.apiclient)
253
-
254
-
255
-
256
-
257
- """Loqed: Exceptions"""
258
-
259
-
260
- class LoqedException(BaseException):
261
- """Raise this when something is off."""
262
-
263
-
264
- class LoqedAuthenticationException(LoqedException):
265
- """Raise this when there is an authentication issue."""
266
-
267
-
File without changes
File without changes
File without changes