pyplejd 0.1__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.
- pyplejd-0.1/LICENSE +21 -0
- pyplejd-0.1/PKG-INFO +12 -0
- pyplejd-0.1/README.md +0 -0
- pyplejd-0.1/pyplejd/__init__.py +90 -0
- pyplejd-0.1/pyplejd/api.py +135 -0
- pyplejd-0.1/pyplejd/ble_device.py +21 -0
- pyplejd-0.1/pyplejd/const.py +12 -0
- pyplejd-0.1/pyplejd/crypto.py +29 -0
- pyplejd-0.1/pyplejd/mesh.py +255 -0
- pyplejd-0.1/pyplejd/plejd_device.py +135 -0
- pyplejd-0.1/pyplejd.egg-info/PKG-INFO +12 -0
- pyplejd-0.1/pyplejd.egg-info/SOURCES.txt +16 -0
- pyplejd-0.1/pyplejd.egg-info/dependency_links.txt +1 -0
- pyplejd-0.1/pyplejd.egg-info/requires.txt +1 -0
- pyplejd-0.1/pyplejd.egg-info/top_level.txt +1 -0
- pyplejd-0.1/setup.cfg +7 -0
- pyplejd-0.1/setup.py +20 -0
pyplejd-0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Thomas Lovén
|
|
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.
|
pyplejd-0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pyplejd
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: A python library for communicating with Plejd devices via bluetooth
|
|
5
|
+
Home-page: https://github.com/thomasloven/pyplejd
|
|
6
|
+
Download-URL: https://github.com/thomasloven/pyplejd/archive/v0.1.tar.gz
|
|
7
|
+
Author: Thomas Lovén
|
|
8
|
+
Author-email: thomasloven@gmail.com
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: plejd,bluetooth,homeassistant
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
License-File: LICENSE
|
pyplejd-0.1/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
|
|
4
|
+
from bleak_retry_connector import close_stale_connections
|
|
5
|
+
|
|
6
|
+
from .mesh import PlejdMesh
|
|
7
|
+
from .api import get_cryptokey, get_devices, get_site_data, get_scenes
|
|
8
|
+
from .plejd_device import PlejdDevice, PlejdScene
|
|
9
|
+
|
|
10
|
+
from .const import PLEJD_SERVICE, LIGHT, SWITCH
|
|
11
|
+
|
|
12
|
+
_LOGGER = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
class PlejdManager:
|
|
15
|
+
|
|
16
|
+
def __init__(self, credentials):
|
|
17
|
+
self.credentials = credentials
|
|
18
|
+
self.mesh = PlejdMesh()
|
|
19
|
+
self.mesh.statecallback = self._update_device
|
|
20
|
+
self.devices = { }
|
|
21
|
+
self.scenes = []
|
|
22
|
+
|
|
23
|
+
def add_mesh_device(self, device, rssi):
|
|
24
|
+
_LOGGER.debug("Adding plejd %s", device)
|
|
25
|
+
# for d in self.devices.values():
|
|
26
|
+
# addr = device.address.replace(":","").replace("-","").upper()
|
|
27
|
+
# if d.BLE_address.upper() == addr or addr in device.name:
|
|
28
|
+
return self.mesh.add_mesh_node(device, rssi)
|
|
29
|
+
# _LOGGER.debug("Device was not expected in current mesh")
|
|
30
|
+
|
|
31
|
+
async def close_stale(self, device):
|
|
32
|
+
_LOGGER.debug("Closing stale connections for %s", device)
|
|
33
|
+
await close_stale_connections(device)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def connected(self):
|
|
37
|
+
return self.mesh is not None and self.mesh.connected
|
|
38
|
+
|
|
39
|
+
async def get_site_data(self):
|
|
40
|
+
return await get_site_data(**self.credentials)
|
|
41
|
+
|
|
42
|
+
async def get_devices(self):
|
|
43
|
+
devices = await get_devices(**self.credentials)
|
|
44
|
+
self.devices = {k: PlejdDevice(self, **v) for (k,v) in devices.items()}
|
|
45
|
+
_LOGGER.debug("Devices")
|
|
46
|
+
_LOGGER.debug(self.devices)
|
|
47
|
+
return self.devices
|
|
48
|
+
|
|
49
|
+
async def get_scenes(self):
|
|
50
|
+
scenes = await get_scenes(**self.credentials)
|
|
51
|
+
self.scenes = [PlejdScene(self, **s) for s in scenes]
|
|
52
|
+
_LOGGER.debug("Scenes")
|
|
53
|
+
_LOGGER.debug(self.scenes)
|
|
54
|
+
return self.scenes
|
|
55
|
+
|
|
56
|
+
async def _update_device(self, deviceState):
|
|
57
|
+
address = deviceState["address"]
|
|
58
|
+
if address in self.devices:
|
|
59
|
+
await self.devices[address].new_state(deviceState.get("state"), deviceState.get("dim", 0))
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def keepalive_interval(self):
|
|
63
|
+
if self.mesh.pollonWrite:
|
|
64
|
+
return timedelta(seconds=10)
|
|
65
|
+
else:
|
|
66
|
+
return timedelta(minutes=10)
|
|
67
|
+
|
|
68
|
+
async def keepalive(self):
|
|
69
|
+
if self.mesh.crypto_key is None:
|
|
70
|
+
self.mesh.set_crypto_key(await get_cryptokey(**self.credentials))
|
|
71
|
+
if not self.mesh.connected:
|
|
72
|
+
if not await self.mesh.connect():
|
|
73
|
+
return False
|
|
74
|
+
retval = await self.mesh.ping()
|
|
75
|
+
if retval and self.mesh.pollonWrite:
|
|
76
|
+
await self.mesh.poll()
|
|
77
|
+
return retval
|
|
78
|
+
|
|
79
|
+
async def disconnect(self):
|
|
80
|
+
_LOGGER.debug("DISCONNECT")
|
|
81
|
+
await self.mesh.disconnect()
|
|
82
|
+
|
|
83
|
+
async def poll(self):
|
|
84
|
+
await self.mesh.poll()
|
|
85
|
+
|
|
86
|
+
async def ping(self):
|
|
87
|
+
retval = await self.mesh.ping()
|
|
88
|
+
if self.mesh.pollonWrite:
|
|
89
|
+
await self.poll()
|
|
90
|
+
return retval
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from aiohttp import ClientSession
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
_LOGGER = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
API_APP_ID = 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak'
|
|
8
|
+
API_BASE_URL = 'https://cloud.plejd.com'
|
|
9
|
+
API_LOGIN_URL = '/parse/login'
|
|
10
|
+
API_SITE_LIST_URL = '/parse/functions/getSiteList'
|
|
11
|
+
API_SITE_DETAILS_URL = '/parse/functions/getSiteById'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
headers = {
|
|
15
|
+
"X-Parse-Application-Id": API_APP_ID,
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async def _login(session, username, password):
|
|
20
|
+
body = {
|
|
21
|
+
"username": username,
|
|
22
|
+
"password": password,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async with session.post(API_LOGIN_URL, json=body, raise_for_status=True) as resp:
|
|
26
|
+
data = await resp.json()
|
|
27
|
+
return data.get("sessionToken")
|
|
28
|
+
|
|
29
|
+
async def _get_sites(session):
|
|
30
|
+
resp = await session.post(API_SITE_LIST_URL, raise_for_status=True)
|
|
31
|
+
return await resp.json()
|
|
32
|
+
|
|
33
|
+
async def _get_site_details(session, siteId):
|
|
34
|
+
async with session.post(
|
|
35
|
+
API_SITE_DETAILS_URL,
|
|
36
|
+
params={"siteId": siteId},
|
|
37
|
+
raise_for_status=True
|
|
38
|
+
) as resp:
|
|
39
|
+
data = await resp.json()
|
|
40
|
+
data = data.get("result")
|
|
41
|
+
data = data[0]
|
|
42
|
+
# with open("site_details.json", "w") as fp:
|
|
43
|
+
# fp.write(json.dumps(data))
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
site_data = {}
|
|
47
|
+
async def get_site_data(username, password, siteId, **_):
|
|
48
|
+
global site_data
|
|
49
|
+
if site_data.get(siteId) is not None:
|
|
50
|
+
return site_data.get(siteId)
|
|
51
|
+
async with ClientSession(base_url=API_BASE_URL, headers=headers) as session:
|
|
52
|
+
session_token = await _login(session, username, password)
|
|
53
|
+
session.headers["X-Parse-Session-Token"] = session_token
|
|
54
|
+
details = await _get_site_details(session, siteId)
|
|
55
|
+
site_data[siteId] = details
|
|
56
|
+
return details
|
|
57
|
+
|
|
58
|
+
async def get_sites(username, password, **_):
|
|
59
|
+
async with ClientSession(base_url=API_BASE_URL, headers=headers) as session:
|
|
60
|
+
session_token = await _login(session, username, password)
|
|
61
|
+
session.headers["X-Parse-Session-Token"] = session_token
|
|
62
|
+
sites = await _get_sites(session)
|
|
63
|
+
return sites["result"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def get_cryptokey(**credentials):
|
|
67
|
+
sitedata = await get_site_data(**credentials)
|
|
68
|
+
return sitedata["plejdMesh"]["cryptoKey"]
|
|
69
|
+
|
|
70
|
+
async def get_devices(**credentials):
|
|
71
|
+
site_data = await get_site_data(**credentials)
|
|
72
|
+
|
|
73
|
+
retval = {}
|
|
74
|
+
for device in site_data["devices"]:
|
|
75
|
+
BLE_address = device["deviceId"]
|
|
76
|
+
|
|
77
|
+
address = site_data["deviceAddress"][BLE_address]
|
|
78
|
+
dimmable = None
|
|
79
|
+
|
|
80
|
+
settings = next((s for s in site_data["outputSettings"]
|
|
81
|
+
if s["deviceParseId"] == device["objectId"]), None)
|
|
82
|
+
|
|
83
|
+
if settings is not None and "output" in settings:
|
|
84
|
+
outputs = site_data["outputAddress"][BLE_address]
|
|
85
|
+
address = outputs[str(settings["output"])]
|
|
86
|
+
|
|
87
|
+
if settings is not None:
|
|
88
|
+
if settings.get("dimCurve") is not None:
|
|
89
|
+
if settings.get("dimCurve") in ["nonDimmable", "RelayNormal"]:
|
|
90
|
+
dimmable = False
|
|
91
|
+
else:
|
|
92
|
+
dimmable = True
|
|
93
|
+
if settings.get("predefinedLoad",{}).get("loadType") == "No load":
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
plejdDevice = next((s for s in site_data["plejdDevices"]
|
|
97
|
+
if s["deviceId"] == BLE_address), None)
|
|
98
|
+
room = next((r for r in site_data["rooms"] if r["roomId"] == device["roomId"]), {})
|
|
99
|
+
|
|
100
|
+
retval[address] = {
|
|
101
|
+
"address": address,
|
|
102
|
+
"BLE_address": BLE_address,
|
|
103
|
+
"data": {
|
|
104
|
+
"name": device["title"],
|
|
105
|
+
"hardwareId": plejdDevice["hardwareId"],
|
|
106
|
+
"dimmable": dimmable,
|
|
107
|
+
"outputType": convertType(device.get("outputType")),
|
|
108
|
+
"room": room.get("title"),
|
|
109
|
+
"firmware": plejdDevice["firmware"]["version"],
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return retval
|
|
114
|
+
|
|
115
|
+
def convertType(outputType):
|
|
116
|
+
if (outputType == "LIGHT"):
|
|
117
|
+
return "light"
|
|
118
|
+
elif (outputType == "RELAY"):
|
|
119
|
+
return "switch"
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
async def get_scenes(**credentials):
|
|
123
|
+
site_data = await get_site_data(**credentials)
|
|
124
|
+
retval = []
|
|
125
|
+
for scene in site_data["scenes"]:
|
|
126
|
+
if scene["hiddenFromSceneList"]: continue
|
|
127
|
+
sceneId = scene["sceneId"]
|
|
128
|
+
index = site_data["sceneIndex"].get(sceneId)
|
|
129
|
+
|
|
130
|
+
retval.append({
|
|
131
|
+
"index": index,
|
|
132
|
+
"title": scene["title"],
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
return retval
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wrapper class for BLEDevice with RSSI from AdvertisementData.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from bleak.backends.device import BLEDevice
|
|
6
|
+
|
|
7
|
+
class BLEDeviceWithRssi:
|
|
8
|
+
def __init__(self, device: BLEDevice, rssi: int):
|
|
9
|
+
self.device = device
|
|
10
|
+
self.rssi = rssi
|
|
11
|
+
|
|
12
|
+
def __str__(self):
|
|
13
|
+
return self.device.__str__()
|
|
14
|
+
|
|
15
|
+
def __repr__(self):
|
|
16
|
+
return self.device.__repr__()
|
|
17
|
+
|
|
18
|
+
def __eq__(self, other):
|
|
19
|
+
if isinstance(other, BLEDeviceWithRssi):
|
|
20
|
+
return self.device == other.device
|
|
21
|
+
return False
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
BLE_UUID_SUFFIX = '6085-4726-be45-040c957391b5'
|
|
2
|
+
PLEJD_SERVICE = f'31ba0001-{BLE_UUID_SUFFIX}'
|
|
3
|
+
PLEJD_LIGHTLEVEL = f'31ba0003-{BLE_UUID_SUFFIX}'
|
|
4
|
+
PLEJD_DATA = f'31ba0004-{BLE_UUID_SUFFIX}'
|
|
5
|
+
PLEJD_LASTDATA = f'31ba0005-{BLE_UUID_SUFFIX}'
|
|
6
|
+
PLEJD_AUTH = f'31ba0009-{BLE_UUID_SUFFIX}'
|
|
7
|
+
PLEJD_PING = f'31ba000a-{BLE_UUID_SUFFIX}'
|
|
8
|
+
|
|
9
|
+
LIGHT = "light"
|
|
10
|
+
SENSOR = "sensor"
|
|
11
|
+
SWITCH = "switch"
|
|
12
|
+
UNKNOWN = "unknown"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import struct
|
|
3
|
+
|
|
4
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
5
|
+
from cryptography.hazmat.backends import default_backend
|
|
6
|
+
|
|
7
|
+
def encrypt_decrypt(key, addr, data):
|
|
8
|
+
buf = addr + addr + addr[:4]
|
|
9
|
+
|
|
10
|
+
ct = Cipher(
|
|
11
|
+
algorithms.AES(bytearray(key)),
|
|
12
|
+
modes.ECB(),
|
|
13
|
+
backend=default_backend()
|
|
14
|
+
)
|
|
15
|
+
ct = ct.encryptor()
|
|
16
|
+
ct = ct.update(buf)
|
|
17
|
+
|
|
18
|
+
output = b""
|
|
19
|
+
for i,d in enumerate(data):
|
|
20
|
+
output += struct.pack("B", d^ct[i%16])
|
|
21
|
+
return output
|
|
22
|
+
|
|
23
|
+
def auth_response(key, challenge):
|
|
24
|
+
k = int.from_bytes(key, "big")
|
|
25
|
+
c = int.from_bytes(challenge, "big")
|
|
26
|
+
intermediate = hashlib.sha256((k^c).to_bytes(16, "big")).digest()
|
|
27
|
+
part1 = intermediate[:16]
|
|
28
|
+
part2 = intermediate[16:]
|
|
29
|
+
return bytearray([(a^b) for (a,b) in zip(part1, part2)])
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import binascii
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import struct
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from bleak import BleakClient, BleakError
|
|
8
|
+
from bleak_retry_connector import establish_connection
|
|
9
|
+
from bleak.backends.device import BLEDevice
|
|
10
|
+
|
|
11
|
+
from .ble_device import BLEDeviceWithRssi
|
|
12
|
+
from .const import PLEJD_AUTH, PLEJD_LASTDATA, PLEJD_LIGHTLEVEL, PLEJD_PING, PLEJD_DATA
|
|
13
|
+
from .crypto import auth_response, encrypt_decrypt
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
class PlejdMesh():
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self._connected = False
|
|
21
|
+
self.client = None
|
|
22
|
+
self.connected_node = None
|
|
23
|
+
self.crypto_key = None
|
|
24
|
+
self.mesh_nodes = []
|
|
25
|
+
|
|
26
|
+
self.pollonWrite = True # TODO: Deprecate this
|
|
27
|
+
self.statecallback = None
|
|
28
|
+
self.scenecallback = None
|
|
29
|
+
self.buttoncallback = None
|
|
30
|
+
|
|
31
|
+
self._ble_lock = asyncio.Lock()
|
|
32
|
+
|
|
33
|
+
def add_mesh_node(self, ble_device: BLEDevice, rssi: int):
|
|
34
|
+
device = BLEDeviceWithRssi(ble_device, rssi)
|
|
35
|
+
if device not in self.mesh_nodes:
|
|
36
|
+
self.mesh_nodes.append(device)
|
|
37
|
+
else:
|
|
38
|
+
_LOGGER.debug("Plejd already added")
|
|
39
|
+
|
|
40
|
+
def set_crypto_key(self, key):
|
|
41
|
+
self.crypto_key = binascii.a2b_hex(key.replace("-", ""))
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def connected(self):
|
|
45
|
+
if self._connected and self.client and self.client.is_connected:
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
async def disconnect(self):
|
|
50
|
+
if self.connected and self.client:
|
|
51
|
+
try:
|
|
52
|
+
await self.client.stop_notify(PLEJD_LASTDATA)
|
|
53
|
+
await self.client.stop_notify(PLEJD_LIGHTLEVEL)
|
|
54
|
+
await self.client.disconnect()
|
|
55
|
+
except BleakError:
|
|
56
|
+
pass
|
|
57
|
+
self._connected = False
|
|
58
|
+
self.client = None
|
|
59
|
+
|
|
60
|
+
async def connect(self, disconnect_callback=None, key=None):
|
|
61
|
+
await self.disconnect()
|
|
62
|
+
_LOGGER.debug("Trying to connect to mesh")
|
|
63
|
+
|
|
64
|
+
def _disconnect(arg):
|
|
65
|
+
if not self.connected: return
|
|
66
|
+
_LOGGER.debug("_disconnect %s", arg)
|
|
67
|
+
self.client = None
|
|
68
|
+
self._connected = False
|
|
69
|
+
if disconnect_callback:
|
|
70
|
+
disconnect_callback()
|
|
71
|
+
|
|
72
|
+
self.mesh_nodes.sort(key = lambda a: a.rssi, reverse = True)
|
|
73
|
+
for plejd in self.mesh_nodes:
|
|
74
|
+
try:
|
|
75
|
+
_LOGGER.debug("Connecting to %s", plejd)
|
|
76
|
+
client = await establish_connection(BleakClient, plejd.device, "plejd", _disconnect)
|
|
77
|
+
address = plejd.device.address
|
|
78
|
+
self._connected = True
|
|
79
|
+
self.client = client
|
|
80
|
+
_LOGGER.debug("Connected to Plejd mesh")
|
|
81
|
+
if not await self._authenticate():
|
|
82
|
+
await self.client.disconnect()
|
|
83
|
+
self._connected = False
|
|
84
|
+
continue
|
|
85
|
+
break
|
|
86
|
+
except (BleakError, asyncio.TimeoutError) as e:
|
|
87
|
+
_LOGGER.warning("Error connecting to Plejd device: %s", str(e))
|
|
88
|
+
else:
|
|
89
|
+
if len(self.mesh_nodes) == 0:
|
|
90
|
+
_LOGGER.debug("Failed to connect to plejd mesh - no devices discovered")
|
|
91
|
+
else:
|
|
92
|
+
_LOGGER.warning("Failed to connect to plejd mesh - %s", self.mesh_nodes)
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
self.connected_node = binascii.a2b_hex(address.replace(":", "").replace("-", ""))[::-1]
|
|
96
|
+
|
|
97
|
+
async def _lastdata(_, lastdata):
|
|
98
|
+
self.pollonWrite = False
|
|
99
|
+
data = encrypt_decrypt(self.crypto_key, self.connected_node, lastdata)
|
|
100
|
+
_LOGGER.debug("Received LastData %s", data.hex())
|
|
101
|
+
deviceState = decode_state(data)
|
|
102
|
+
_LOGGER.debug("Decoded LastData %s", deviceState)
|
|
103
|
+
if deviceState is None:
|
|
104
|
+
return
|
|
105
|
+
if self.statecallback and "state" in deviceState:
|
|
106
|
+
await self.statecallback(deviceState)
|
|
107
|
+
if self.scenecallback and "scene" in deviceState:
|
|
108
|
+
await self.scenecallback(deviceState)
|
|
109
|
+
if self.buttoncallback and "button" in deviceState:
|
|
110
|
+
await self.buttoncallback(deviceState)
|
|
111
|
+
|
|
112
|
+
async def _lightlevel(_, lightlevel):
|
|
113
|
+
_LOGGER.debug("Received LightLevel %s", lightlevel.hex())
|
|
114
|
+
for i in range(0, len(lightlevel), 10):
|
|
115
|
+
ll = lightlevel[i:i+10]
|
|
116
|
+
deviceState = {
|
|
117
|
+
"address": int(ll[0]),
|
|
118
|
+
"state": bool(ll[1]),
|
|
119
|
+
"dim": int.from_bytes(ll[5:7], "little"),
|
|
120
|
+
}
|
|
121
|
+
_LOGGER.debug("Decoded LightLevel %s", deviceState)
|
|
122
|
+
if self.statecallback and deviceState is not None:
|
|
123
|
+
await self.statecallback(deviceState)
|
|
124
|
+
|
|
125
|
+
await client.start_notify(PLEJD_LASTDATA, _lastdata)
|
|
126
|
+
await client.start_notify(PLEJD_LIGHTLEVEL, _lightlevel)
|
|
127
|
+
|
|
128
|
+
await self.poll()
|
|
129
|
+
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
async def write(self, payload):
|
|
133
|
+
try:
|
|
134
|
+
# TODO:
|
|
135
|
+
async with self._ble_lock:
|
|
136
|
+
_LOGGER.debug("Writing data to Plejd mesh CT: %s", payload.hex())
|
|
137
|
+
data = encrypt_decrypt(self.crypto_key, self.connected_node, payload)
|
|
138
|
+
await self.client.write_gatt_char(PLEJD_DATA, data, response=True)
|
|
139
|
+
except (BleakError, asyncio.TimeoutError) as e:
|
|
140
|
+
_LOGGER.warning("Plejd mesh write command failed: %s", str(e))
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
async def set_state(self, address, state, dim=0):
|
|
145
|
+
payload = encode_state(address, state, dim)
|
|
146
|
+
retval = await self.write(payload)
|
|
147
|
+
if self.pollonWrite:
|
|
148
|
+
await self.poll()
|
|
149
|
+
return retval
|
|
150
|
+
|
|
151
|
+
async def activate_scene(self, index):
|
|
152
|
+
payload = binascii.a2b_hex(f"0201100021{index:02x}")
|
|
153
|
+
retval = await self.write(payload)
|
|
154
|
+
if self.pollonWrite:
|
|
155
|
+
await self.poll()
|
|
156
|
+
return retval
|
|
157
|
+
|
|
158
|
+
async def ping(self):
|
|
159
|
+
async with self._ble_lock:
|
|
160
|
+
return await self._ping()
|
|
161
|
+
|
|
162
|
+
async def _ping(self):
|
|
163
|
+
if self.client is None:
|
|
164
|
+
return False
|
|
165
|
+
try:
|
|
166
|
+
ping = bytearray(os.urandom(1))
|
|
167
|
+
_LOGGER.debug("Ping(%s)", int.from_bytes(ping, "little"))
|
|
168
|
+
await self.client.write_gatt_char(PLEJD_PING, ping, response=True)
|
|
169
|
+
pong = await self.client.read_gatt_char(PLEJD_PING)
|
|
170
|
+
_LOGGER.debug("Pong(%s)", int.from_bytes(pong, "little"))
|
|
171
|
+
if (ping[0] + 1) & 0xFF == pong[0]:
|
|
172
|
+
return True
|
|
173
|
+
except (BleakError, asyncio.TimeoutError) as e:
|
|
174
|
+
_LOGGER.warning("Plejd mesh keepalive signal failed: %s", str(e))
|
|
175
|
+
self.pollonWrite = True
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
async def poll(self):
|
|
179
|
+
if self.client is None:
|
|
180
|
+
return
|
|
181
|
+
_LOGGER.debug("Polling Plejd mesh for current state")
|
|
182
|
+
async with self._ble_lock:
|
|
183
|
+
await self.client.write_gatt_char(PLEJD_LIGHTLEVEL, b"\x01", response=True)
|
|
184
|
+
|
|
185
|
+
async def _authenticate(self):
|
|
186
|
+
if self.client is None:
|
|
187
|
+
return False
|
|
188
|
+
try:
|
|
189
|
+
async with self._ble_lock:
|
|
190
|
+
_LOGGER.debug("Authenticating to plejd mesh")
|
|
191
|
+
await self.client.write_gatt_char(PLEJD_AUTH, b"\0x00", response=True)
|
|
192
|
+
challenge = await self.client.read_gatt_char(PLEJD_AUTH)
|
|
193
|
+
response = auth_response(self.crypto_key, challenge)
|
|
194
|
+
await self.client.write_gatt_char(PLEJD_AUTH, response, response=True)
|
|
195
|
+
if not await self._ping():
|
|
196
|
+
_LOGGER.debug("Authenticion failed")
|
|
197
|
+
return False
|
|
198
|
+
_LOGGER.debug("Authenticated successfully")
|
|
199
|
+
return True
|
|
200
|
+
except (BleakError, asyncio.TimeoutError) as e:
|
|
201
|
+
_LOGGER.warning("Plejd authentication failed: %s", str(e))
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def decode_state(data):
|
|
206
|
+
address = int(data[0])
|
|
207
|
+
cmdtype = data[1:3]
|
|
208
|
+
if not cmdtype == b"\x01\x10":
|
|
209
|
+
_LOGGER.debug("Got non-command data: %s", cmdtype)
|
|
210
|
+
return None
|
|
211
|
+
cmd = data[3:5]
|
|
212
|
+
|
|
213
|
+
# if address == 0:
|
|
214
|
+
# # Broadcast
|
|
215
|
+
# pass
|
|
216
|
+
if address == 1 and cmd == b"\x00\x1b":
|
|
217
|
+
_LOGGER.debug("Got time data?")
|
|
218
|
+
ts = struct.unpack_from("<I", data, 5)[0]
|
|
219
|
+
dt = datetime.fromtimestamp(ts)
|
|
220
|
+
_LOGGER.debug("Timestamp: %s (%s)", ts, dt)
|
|
221
|
+
return None
|
|
222
|
+
# if address == 2:
|
|
223
|
+
# # Scene update?
|
|
224
|
+
# pass
|
|
225
|
+
|
|
226
|
+
retval = {"address": address}
|
|
227
|
+
|
|
228
|
+
if cmd == b"\x00\xc8" or cmd == b"\x00\x98":
|
|
229
|
+
retval["state"] = bool(data[5])
|
|
230
|
+
retval["dim"] = int.from_bytes(data[6:8], "little")
|
|
231
|
+
elif cmd == b"\x00\x97":
|
|
232
|
+
retval["state"] = bool(data[5])
|
|
233
|
+
elif cmd == b"\x00\x16":
|
|
234
|
+
retval["address"] = int(data[5])
|
|
235
|
+
retval["button"] = int(data[6])
|
|
236
|
+
_LOGGER.info("Button pressed: %s", retval)
|
|
237
|
+
elif cmd == b"\x00\x21":
|
|
238
|
+
del retval["address"]
|
|
239
|
+
retval["scene"] = int(data[5])
|
|
240
|
+
_LOGGER.info("Scene triggered: %s", retval)
|
|
241
|
+
else:
|
|
242
|
+
_LOGGER.debug("Unknown command %s", cmd)
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
return retval
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def encode_state(address, state, dim):
|
|
249
|
+
if state:
|
|
250
|
+
if dim is None:
|
|
251
|
+
return binascii.a2b_hex(f"{address:02x}0110009701")
|
|
252
|
+
brightness = dim << 8 | dim
|
|
253
|
+
return binascii.a2b_hex(f"{address:02x}0110009801{brightness:04x}")
|
|
254
|
+
else:
|
|
255
|
+
return binascii.a2b_hex(f"{address:02x}0110009700")
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
|
|
2
|
+
from builtins import property
|
|
3
|
+
from collections import namedtuple
|
|
4
|
+
import logging
|
|
5
|
+
from .const import LIGHT, SENSOR, SWITCH, UNKNOWN
|
|
6
|
+
|
|
7
|
+
_LOGGER = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
Device = namedtuple("Device", ["model", "type", "dimmable"])
|
|
10
|
+
|
|
11
|
+
HARDWARE_TYPES = {
|
|
12
|
+
"0": Device("-unknown-", UNKNOWN, False),
|
|
13
|
+
"1": Device("DIM-01", LIGHT, True),
|
|
14
|
+
"2": Device("DIM-02", LIGHT, True),
|
|
15
|
+
"3": Device("CTR-01", LIGHT, False),
|
|
16
|
+
"4": Device("GWY-01", SENSOR, False),
|
|
17
|
+
"5": Device("LED-10", LIGHT, True),
|
|
18
|
+
"6": Device("WPH-01", SWITCH, False),
|
|
19
|
+
"7": Device("REL-01", SWITCH, False),
|
|
20
|
+
"8": Device("SPR-01", SWITCH, False),
|
|
21
|
+
"10": Device("WRT-01", SWITCH, False),
|
|
22
|
+
"11": Device("DIM-01-2P", LIGHT, True),
|
|
23
|
+
"13": Device("Generic", LIGHT, False),
|
|
24
|
+
"14": Device("DIM-01-LC", LIGHT, True),
|
|
25
|
+
"15": Device("DIM-02-LC", LIGHT, True),
|
|
26
|
+
"17": Device("REL-01-2P", SWITCH, False),
|
|
27
|
+
"18": Device("REL-02", SWITCH, False),
|
|
28
|
+
"20": Device("SPR-01", SWITCH, False),
|
|
29
|
+
"36": Device("LED-75", LIGHT, True),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class PlejdDevice:
|
|
33
|
+
|
|
34
|
+
def __init__(self, manager, address, BLE_address, data):
|
|
35
|
+
self.manager = manager
|
|
36
|
+
self.address = address
|
|
37
|
+
self._BLE_address = BLE_address
|
|
38
|
+
self.data = data #{name, hardwareId, dimmable, outputType, room, firmware}
|
|
39
|
+
|
|
40
|
+
self.updateCallback = None
|
|
41
|
+
|
|
42
|
+
self._state = None
|
|
43
|
+
self._dim = None
|
|
44
|
+
|
|
45
|
+
def __repr__(self):
|
|
46
|
+
return f"<PlejdDevice(<manager>, {self.address}, {self.BLE_address}, {self.data}>"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def available(self):
|
|
50
|
+
return self.manager.connected and self._state is not None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def state(self):
|
|
54
|
+
return self._state if self.available else False
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def dim(self):
|
|
58
|
+
return self._dim/256 if self._dim else 0
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def BLE_address(self):
|
|
62
|
+
return self._BLE_address
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def name(self):
|
|
66
|
+
return self.data["name"]
|
|
67
|
+
@property
|
|
68
|
+
def room(self):
|
|
69
|
+
return self.data["room"]
|
|
70
|
+
@property
|
|
71
|
+
def firmware(self):
|
|
72
|
+
return self.data["firmware"]
|
|
73
|
+
@property
|
|
74
|
+
def hardwareId(self):
|
|
75
|
+
return self.data["hardwareId"]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def type(self):
|
|
79
|
+
return self.data.get("outputType") or self.hardware_data.type
|
|
80
|
+
@property
|
|
81
|
+
def model(self):
|
|
82
|
+
return self.hardware_data.model
|
|
83
|
+
@property
|
|
84
|
+
def dimmable(self):
|
|
85
|
+
if self.data["dimmable"] is not None:
|
|
86
|
+
return self.data["dimmable"]
|
|
87
|
+
return self.hardware_data.dimmable
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def hardware_data(self):
|
|
91
|
+
deviceType = HARDWARE_TYPES.get(self.data["hardwareId"], HARDWARE_TYPES["0"])
|
|
92
|
+
return deviceType
|
|
93
|
+
|
|
94
|
+
async def new_state(self, state, dim):
|
|
95
|
+
update = False
|
|
96
|
+
if state != self._state:
|
|
97
|
+
update = True
|
|
98
|
+
self._state = state
|
|
99
|
+
if dim != self._dim:
|
|
100
|
+
update = True
|
|
101
|
+
self._dim = dim
|
|
102
|
+
if update:
|
|
103
|
+
if self.updateCallback:
|
|
104
|
+
self.updateCallback({"state": self._state, "dim": self._dim})
|
|
105
|
+
|
|
106
|
+
async def turn_on(self, dim=0):
|
|
107
|
+
await self.manager.mesh.set_state(self.address, True, dim)
|
|
108
|
+
|
|
109
|
+
async def turn_off(self):
|
|
110
|
+
await self.manager.mesh.set_state(self.address, False)
|
|
111
|
+
|
|
112
|
+
class PlejdScene:
|
|
113
|
+
|
|
114
|
+
def __init__(self, manager, index, title):
|
|
115
|
+
self._manager = manager
|
|
116
|
+
self._index = index
|
|
117
|
+
self._title = title
|
|
118
|
+
|
|
119
|
+
def __repr__(self):
|
|
120
|
+
return f"<PlejdScene(<manager>, {self._index}, '{self._title}'>"
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def name(self):
|
|
124
|
+
return self._title
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def index(self):
|
|
128
|
+
return self._index
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def available(self):
|
|
132
|
+
return self._manager.connected
|
|
133
|
+
|
|
134
|
+
async def activate(self):
|
|
135
|
+
await self._manager.mesh.activate_scene(self._index)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pyplejd
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: A python library for communicating with Plejd devices via bluetooth
|
|
5
|
+
Home-page: https://github.com/thomasloven/pyplejd
|
|
6
|
+
Download-URL: https://github.com/thomasloven/pyplejd/archive/v0.1.tar.gz
|
|
7
|
+
Author: Thomas Lovén
|
|
8
|
+
Author-email: thomasloven@gmail.com
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: plejd,bluetooth,homeassistant
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
License-File: LICENSE
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
setup.cfg
|
|
4
|
+
setup.py
|
|
5
|
+
pyplejd/__init__.py
|
|
6
|
+
pyplejd/api.py
|
|
7
|
+
pyplejd/ble_device.py
|
|
8
|
+
pyplejd/const.py
|
|
9
|
+
pyplejd/crypto.py
|
|
10
|
+
pyplejd/mesh.py
|
|
11
|
+
pyplejd/plejd_device.py
|
|
12
|
+
pyplejd.egg-info/PKG-INFO
|
|
13
|
+
pyplejd.egg-info/SOURCES.txt
|
|
14
|
+
pyplejd.egg-info/dependency_links.txt
|
|
15
|
+
pyplejd.egg-info/requires.txt
|
|
16
|
+
pyplejd.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiohttp
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyplejd
|
pyplejd-0.1/setup.cfg
ADDED
pyplejd-0.1/setup.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from setuptools import find_packages, setup
|
|
2
|
+
|
|
3
|
+
MIN_PY_VERSION = "3.10"
|
|
4
|
+
PACKAGES = find_packages()
|
|
5
|
+
VERSION = "0.1"
|
|
6
|
+
|
|
7
|
+
setup(
|
|
8
|
+
name="pyplejd",
|
|
9
|
+
packages=PACKAGES,
|
|
10
|
+
version=VERSION,
|
|
11
|
+
description="A python library for communicating with Plejd devices via bluetooth",
|
|
12
|
+
author="Thomas Lovén",
|
|
13
|
+
author_email="thomasloven@gmail.com",
|
|
14
|
+
license="MIT",
|
|
15
|
+
url="https://github.com/thomasloven/pyplejd",
|
|
16
|
+
download_url=f"https://github.com/thomasloven/pyplejd/archive/v{VERSION}.tar.gz",
|
|
17
|
+
install_requires=["aiohttp"],
|
|
18
|
+
keywords=["plejd", "bluetooth", "homeassistant"],
|
|
19
|
+
python_requires=f">={MIN_PY_VERSION}",
|
|
20
|
+
)
|