Skip to content

Commit

Permalink
Merge pull request #1 from betaboon/initial-implementation
Browse files Browse the repository at this point in the history
initial implementation
  • Loading branch information
betaboon authored Dec 28, 2020
2 parents 433e936 + 4cab0c8 commit 6e4ced5
Show file tree
Hide file tree
Showing 11 changed files with 408 additions and 0 deletions.
15 changes: 15 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[flake8]
max-line-length = 100
max-complexity = 18
select = B,C,E,F,W,T4,B9
per-file-ignores=__init__.py:F401,F403
# Stuff we ignore thanks to black: https://github.com/ambv/black/issues/429
ignore =
E111,E121,E122,E123,E124,E125,E126,
E201,E202,E203,E221,E222,E225,E226,E227,E231,E241,E251,E261,E262,E265,E271,E272,
E302,E303,E306,
E502,
E701,E702,E703,E704,
W291,W292,W293,
W391,
W503
1 change: 1 addition & 0 deletions aioairctrl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from aioairctrl.coap.client import Client as CoAPClient
4 changes: 4 additions & 0 deletions aioairctrl/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from aioairctrl.cli import main

if __name__ == "__main__":
main()
142 changes: 142 additions & 0 deletions aioairctrl/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import argparse
import asyncio
import json
import logging

from aioairctrl import CoAPClient

logging.basicConfig(level=logging.WARN)
logger = logging.getLogger(__package__)


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(
dest="command",
required=True,
help="sub-command help",
)
parser.add_argument(
"-H",
"--host",
metavar="HOST",
dest="host",
type=str,
required=True,
help="Address of CoAP-device",
)
parser.add_argument(
"-P",
"--port",
metavar="PORT",
dest="port",
type=int,
required=False,
default=5683,
help="Port of CoAP-device (default: %(default)s)",
)
parser.add_argument(
"-D",
"--debug",
dest="debug",
action="store_true",
help="Enable debug output",
)
parser_status = subparsers.add_parser(
"status",
help="get status of device",
)
parser_status.add_argument(
"-J",
"--json",
dest="json",
action="store_true",
help="Output status as JSON",
)
parser_status_observe = subparsers.add_parser(
"status-observe",
help="Observe status of device",
)
parser_status_observe.add_argument(
"-J",
"--json",
dest="json",
action="store_true",
help="Output status as JSON",
)
parser_set = subparsers.add_parser(
"set",
help="Set value of device",
)
parser_set.add_argument(
"values",
metavar="K=V",
type=str,
nargs="+",
help="Key-Value pairs to set",
)
parser_set.add_argument(
"-I",
"--int",
dest="value_as_int",
action="store_true",
help="Encode value as integer",
)
return parser.parse_args()


async def async_main() -> None:
args = parse_args()
if args.debug:
logger.setLevel(logging.DEBUG)
logging.getLogger("coap").setLevel(logging.DEBUG)
logging.getLogger("philips_airpurifier").setLevel(logging.DEBUG)
client = None
try:
client = await CoAPClient.create(host=args.host, port=args.port)
if args.command == "status":
status = await client.get_status()
if args.json:
print(json.dumps(status))
else:
print(status)
elif args.command == "status-observe":
async for status in client.observe_status():
if args.json:
print(json.dumps(status))
else:
print(status)
elif args.command == "set":
data = {}
for e in args.values:
k, v = e.split("=")
if v == "true":
v = True
elif v == "false":
v = False
if args.value_as_int:
try:
v = int(v)
except ValueError:
print("Cannot encode value '%s' as int" % v)
data = None
break
data[k] = v
if data:
await client.set_control_values(data=data)
except (KeyboardInterrupt, asyncio.CancelledError):
pass
finally:
if client:
await client.shutdown()


def main():
try:
asyncio.run(async_main())
except KeyboardInterrupt:
pass


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions aioairctrl/coap/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from aioairctrl.coap.client import Client
23 changes: 23 additions & 0 deletions aioairctrl/coap/aiocoap_monkeypatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import asyncio

import aiocoap
from aiocoap import error


def __del__(self):
if self._future.done():
try:
# Fetch the result so any errors show up at least in the
# finalizer output
self._future.result()
except (error.ObservationCancelled, error.NotObservable):
# This is the case at the end of an observation cancelled
# by the server.
pass
except error.LibraryShutdown:
pass
except asyncio.CancelledError:
pass


aiocoap.protocol.ClientObservation._Iterator.__del__ = __del__
128 changes: 128 additions & 0 deletions aioairctrl/coap/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import json
import logging
import os

from aioairctrl.coap import aiocoap_monkeypatch # noqa: F401
from aiocoap import (
Context,
GET,
Message,
NON,
POST,
)

from aioairctrl.coap.encryption import EncryptionContext

logger = logging.getLogger(__name__)


class Client:
STATUS_PATH = "/sys/dev/status"
CONTROL_PATH = "/sys/dev/control"
SYNC_PATH = "/sys/dev/sync"

def __init__(self, host, port=5683):
self.host = host
self.port = port
self._client_context = None
self._encryption_context = None

async def _init(self):
self._client_context = await Context.create_client_context()
self._encryption_context = EncryptionContext()
await self._sync()

@classmethod
async def create(cls, *args, **kwargs):
obj = cls(*args, **kwargs)
await obj._init()
return obj

async def shutdown(self) -> None:
if self._client_context:
await self._client_context.shutdown()

async def _sync(self):
logger.debug("syncing")
sync_request = os.urandom(4).hex().upper()
request = Message(
code=POST,
mtype=NON,
uri=f"coap://{self.host}:{self.port}{self.SYNC_PATH}",
payload=sync_request.encode(),
)
response = await self._client_context.request(request).response
client_key = response.payload.decode()
logger.debug("synced: %s", client_key)
self._encryption_context.set_client_key(client_key)

async def get_status(self):
logger.debug("retrieving status")
request = Message(
code=GET,
mtype=NON,
uri=f"coap://{self.host}:{self.port}{self.STATUS_PATH}",
)
request.opt.observe = 0
response = await self._client_context.request(request).response
payload_encrypted = response.payload.decode()
payload = self._encryption_context.decrypt(payload_encrypted)
logger.debug("status: %s", payload)
state_reported = json.loads(payload)
return state_reported["state"]["reported"]

async def observe_status(self):
logger.debug("observing status")
request = Message(
code=GET,
mtype=NON,
uri=f"coap://{self.host}:{self.port}{self.STATUS_PATH}",
)
request.opt.observe = 0
requester = self._client_context.request(request)
async for response in requester.observation:
payload_encrypted = response.payload.decode()
payload = self._encryption_context.decrypt(payload_encrypted)
logger.debug("observation status: %s", payload)
status = json.loads(payload)
yield status["state"]["reported"]

async def set_control_value(self, key, value, retry_count=5, resync=True) -> None:
return await self.set_control_values(
data={key: value}, retry_count=retry_count, resync=resync
)

async def set_control_values(self, data: dict, retry_count=5, resync=True) -> None:
state_desired = {
"state": {
"desired": {
"CommandType": "app",
"DeviceId": "",
"EnduserId": "",
**data,
}
}
}
payload = json.dumps(state_desired)
logger.debug("REQUEST: %s", payload)
payload_encrypted = self._encryption_context.encrypt(payload)
request = Message(
code=POST,
mtype=NON,
uri=f"coap://{self.host}:{self.port}{self.CONTROL_PATH}",
payload=payload_encrypted.encode(),
)
response = await self._client_context.request(request).response
logger.debug("RESPONSE: %s", response.payload)
result = json.loads(response.payload)
if result.get("status") == "success":
return True
else:
if resync:
logger.debug("set_control_value failed. resyncing...")
await self._sync()
if retry_count > 0:
logger.debug("set_control_value failed. retrying...")
return await self.set_control_values(data, retry_count - 1, resync)
logger.error("set_control_value failed: %s", data)
return False
55 changes: 55 additions & 0 deletions aioairctrl/coap/encryption.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import hashlib

from Cryptodome.Cipher import AES
from Cryptodome.Util.Padding import pad, unpad


class DigestMismatchException(Exception):
pass


class EncryptionContext:
SECRET_KEY = "JiangPan"

def __init__(self):
self._client_key = None

def set_client_key(self, client_key):
self._client_key = client_key

def _increment_client_key(self):
client_key_next = (int(self._client_key, 16) + 1).to_bytes(4, byteorder="big").hex().upper()
self._client_key = client_key_next

def _create_cipher(self, key: str):
key_and_iv = hashlib.md5((self.SECRET_KEY + key).encode()).hexdigest().upper()
half_keylen = len(key_and_iv) // 2
secret_key = key_and_iv[0:half_keylen]
iv = key_and_iv[half_keylen:]
cipher = AES.new(
key=secret_key.encode(),
mode=AES.MODE_CBC,
iv=iv.encode(),
)
return cipher

def encrypt(self, payload: str) -> str:
self._increment_client_key()
key = self._client_key
plaintext_padded = pad(payload.encode(), 16, style="pkcs7")
cipher = self._create_cipher(key)
ciphertext = cipher.encrypt(plaintext_padded).hex().upper()
digest = hashlib.sha256((key + ciphertext).encode()).hexdigest().upper()
return key + ciphertext + digest

def decrypt(self, payload_encrypted: str) -> str:
key = payload_encrypted[0:8]
ciphertext = payload_encrypted[8:-64]
digest = payload_encrypted[-64:]
digest_calculated = hashlib.sha256((key + ciphertext).encode()).hexdigest().upper()
if digest != digest_calculated:
raise DigestMismatchException
cipher = self._create_cipher(key)
plaintext_padded = cipher.decrypt(bytes.fromhex(ciphertext))
plaintext_unpadded = unpad(plaintext_padded, 16, style="pkcs7")
return plaintext_unpadded.decode()
17 changes: 17 additions & 0 deletions examples/example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import asyncio

from aioairctrl import CoAPClient


async def main():
client = await CoAPClient.create(host="192.168.10.58")
print("GETTING STATUS")
print(await client.get_status())
print("OBSERVING")
async for s in client.observe_status():
print("GOT STATE")
await asyncio.sleep(10)


if __name__ == "__main__":
asyncio.run(main())
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[tool.black]
line-length = 100
target-version = ['py36', 'py37', 'py38']
Loading

0 comments on commit 6e4ced5

Please sign in to comment.