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.
- {loqedAPI-2.1.5/src/loqedAPI.egg-info → loqedAPI-2.1.7}/PKG-INFO +4 -4
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/setup.cfg +1 -1
- loqedAPI-2.1.7/setup.py +24 -0
- loqedAPI-2.1.7/src/loqedAPI/cloud_loqed.py +19 -0
- loqedAPI-2.1.7/src/loqedAPI/loqed.py +346 -0
- loqedAPI-2.1.7/src/loqedAPI/urls.py +7 -0
- {loqedAPI-2.1.5 → loqedAPI-2.1.7/src/loqedAPI.egg-info}/PKG-INFO +4 -4
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/src/loqedAPI.egg-info/SOURCES.txt +4 -1
- loqedAPI-2.1.7/tests/test.py +40 -0
- loqedAPI-2.1.5/setup.py +0 -24
- loqedAPI-2.1.5/src/loqedAPI/loqed.py +0 -267
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/LICENSE +0 -0
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/README.md +0 -0
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/pyproject.toml +0 -0
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/src/loqedAPI/__init__.py +0 -0
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/src/loqedAPI/apiclient.py +0 -0
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/src/loqedAPI/exceptions.py +0 -0
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/src/loqedAPI/local_loqed.py +0 -0
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/src/loqedAPI.egg-info/dependency_links.txt +0 -0
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/src/loqedAPI.egg-info/requires.txt +0 -0
- {loqedAPI-2.1.5 → loqedAPI-2.1.7}/src/loqedAPI.egg-info/top_level.txt +0 -0
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: loqedAPI
|
|
3
|
-
Version: 2.1.
|
|
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
|
loqedAPI-2.1.7/setup.py
ADDED
|
@@ -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."""
|
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: loqedAPI
|
|
3
|
-
Version: 2.1.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|