From acdcc2693c9a269a34d08f82996e7e8a60d5f9e5 Mon Sep 17 00:00:00 2001 From: Harald Heigl Date: Sun, 2 May 2021 14:33:17 +0200 Subject: [PATCH 1/4] refactoring coap-devices --- pyairctrl/coap_client.py | 166 +++++++++++++++++++-------------- pyairctrl/plain_coap_client.py | 128 ++++++------------------- testing/coap_resources.py | 8 +- testing/test_coap.py | 2 +- testing/test_plain_coap.py | 12 +-- 5 files changed, 137 insertions(+), 179 deletions(-) diff --git a/pyairctrl/coap_client.py b/pyairctrl/coap_client.py index 9d5b142..9eff915 100644 --- a/pyairctrl/coap_client.py +++ b/pyairctrl/coap_client.py @@ -12,6 +12,7 @@ from coapthon import defines from coapthon.client.helperclient import HelperClient +from coapthon.messages.request import Request from Cryptodome.Cipher import AES from Cryptodome.Util.Padding import pad, unpad @@ -24,13 +25,28 @@ class NotSupportedException(Exception): pass -class HTTPAirClientBase(ABC): +class CoAPAirClientBase(ABC): + STATUS_PATH = "/sys/dev/status" + CONTROL_PATH = "/sys/dev/control" + SYNC_PATH = "/sys/dev/sync" + def __init__(self, host, port, debug=False): self.logger = logging.getLogger(self.__class__.__name__) self.logger.setLevel("WARN") self.server = host self.port = port self.debug = debug + self.client = self._create_coap_client(self.server, self.port) + self.response = None + self._initConnection() + + def __del__(self): + if self.response: + self.client.cancel_observing(self.response, True) + self.client.stop() + + def _create_coap_client(self, host, port): + return HelperClient(server=(host, port)) def get_status(self, debug=False): if debug: @@ -48,20 +64,65 @@ def set_values(self, values, debug=False): return result - @abstractmethod def _get(self): + payload = None + + try: + request = self.client.mk_request(defines.Codes.GET, self.STATUS_PATH) + request.observe = 0 + self.response = self.client.send_request(request, None, 2) + if self.response: + payload = self._transform_payload_after_receiving(self.response.payload) + except Exception as e: + print("Unexpected error:{}".format(e)) + + if payload: + try: + return json.loads(payload, object_pairs_hook=OrderedDict)["state"][ + "reported" + ] + except json.decoder.JSONDecodeError: + print("JSONDecodeError, you may have choosen the wrong coap protocol!") + + return {} + + def _set(self, key, payload): + try: + payload = self._transform_payload_before_sending(json.dumps(payload)) + response = self.client.post(self.CONTROL_PATH, payload) + + if self.debug: + print(response) + return response.payload == '{"status":"success"}' + except Exception as e: + print("Unexpected error:{}".format(e)) + + def _send_empty_message(self): + request = Request() + request.destination = server = (self.server, self.port) + request.code = defines.Codes.EMPTY.number + self.client.send_empty(request) + + @abstractmethod + def _initConnection(self): pass @abstractmethod - def _set(self, key, value): + def _transform_payload_after_receiving(self, payload): + pass + + @abstractmethod + def _transform_payload_before_sending(self, payload): pass def get_firmware(self): status = self._get() + # TODO Really transmit full status here? return status def get_filters(self): status = self._get() + # TODO Really transmit full status here? return status def get_wifi(self): @@ -71,50 +132,43 @@ def set_wifi(self, ssid, pwd): raise NotSupportedException -class CoAPAirClient(HTTPAirClientBase): +class CoAPAirClient(CoAPAirClientBase): SECRET_KEY = "JiangPan" def __init__(self, host, port=5683, debug=False): super().__init__(host, port, debug) - self.client = self._create_coap_client(self.server, self.port) - self.response = None - self._sync() - - def __del__(self): - # TODO call a close method explicitly instead - if self.response: - self.client.cancel_observing(self.response, True) - self.client.stop() - - def _create_coap_client(self, host, port): - return HelperClient(server=(host, port)) - def _sync(self): + def _initConnection(self): self.syncrequest = binascii.hexlify(os.urandom(4)).decode("utf8").upper() - resp = self.client.post("/sys/dev/sync", self.syncrequest, timeout=5) + resp = self.client.post(self.SYNC_PATH, self.syncrequest, timeout=5) if resp: self.client_key = resp.payload else: self.client.stop() raise Exception("sync timeout") - def _decrypt_payload(self, encrypted_payload): - encoded_counter = encrypted_payload[0:8] - aes = self._handle_AES(encoded_counter) - encoded_message = encrypted_payload[8:-64].upper() - digest = encrypted_payload[-64:] - calculated_digest = self._create_digest(encoded_counter, encoded_message) - if digest != calculated_digest: - raise WrongDigestException - decoded_message = aes.decrypt(bytes.fromhex(encoded_message)) - unpaded_message = unpad(decoded_message, 16, style="pkcs7") - return unpaded_message.decode("utf8") - - def _encrypt_payload(self, payload): + def _transform_payload_after_receiving(self, encrypted_payload): + try: + encoded_counter = encrypted_payload[0:8] + aes = self._handle_AES(encoded_counter) + encoded_message = encrypted_payload[8:-64].upper() + digest = encrypted_payload[-64:] + calculated_digest = self._create_digest(encoded_counter, encoded_message) + if digest != calculated_digest: + raise WrongDigestException + decoded_message = aes.decrypt(bytes.fromhex(encoded_message)) + unpaded_message = unpad(decoded_message, 16, style="pkcs7") + return unpaded_message.decode("utf8") + except WrongDigestException: + print("Message from device got corrupted") + + def _transform_payload_before_sending(self, payload): self._update_client_key() aes = self._handle_AES(self.client_key) paded_message = pad(bytes(payload.encode("utf8")), 16, style="pkcs7") - encoded_message = binascii.hexlify(aes.encrypt(paded_message)).decode("utf8").upper() + encoded_message = ( + binascii.hexlify(aes.encrypt(paded_message)).decode("utf8").upper() + ) digest = self._create_digest(self.client_key, encoded_message) return self.client_key + encoded_message + digest @@ -138,45 +192,15 @@ def _handle_AES(self, id): bytes(secret_key.encode("utf8")), AES.MODE_CBC, bytes(iv.encode("utf8")) ) - def _get(self): - path = "/sys/dev/status" - decrypted_payload = None - - try: - request = self.client.mk_request(defines.Codes.GET, path) - request.observe = 0 - self.response = self.client.send_request(request, None, 2) - encrypted_payload = self.response.payload - decrypted_payload = self._decrypt_payload(encrypted_payload) - except WrongDigestException: - print("Message from device got corrupted") - except Exception as e: - print("Unexpected error:{}".format(e)) - - if decrypted_payload is not None: - return json.loads(decrypted_payload, object_pairs_hook=OrderedDict)[ - "state" - ]["reported"] - else: - return {} - def _set(self, key, value): - path = "/sys/dev/control" - try: - payload = { - "state": { - "desired": { - "CommandType": "app", - "DeviceId": "", - "EnduserId": "", - key: value, - } + payload = { + "state": { + "desired": { + "CommandType": "app", + "DeviceId": "", + "EnduserId": "", + key: value, } } - encrypted_payload = self._encrypt_payload(json.dumps(payload)) - response = self.client.post(path, encrypted_payload) - if self.debug: - print(response) - return response.payload == '{"status":"success"}' - except Exception as e: - print("Unexpected error:{}".format(e)) + } + return super()._set(key, payload) diff --git a/pyairctrl/plain_coap_client.py b/pyairctrl/plain_coap_client.py index 1d632bc..68a7e36 100644 --- a/pyairctrl/plain_coap_client.py +++ b/pyairctrl/plain_coap_client.py @@ -11,25 +11,41 @@ import time from collections import OrderedDict -from coapthon import defines -from coapthon.client.helperclient import HelperClient -from coapthon.messages.request import Request -from coapthon.utils import generate_random_token +from .coap_client import CoAPAirClientBase +class PlainCoAPAirClient(CoAPAirClientBase): + def __init__(self, host, port=5683, debug=False): + super().__init__(host, port, debug) + # TODO is this really needed for _get? + #request.type = defines.Types["ACK"] + #request.token = generate_random_token(4) -class NotSupportedException(Exception): - pass + def _set(self, key, value): + payload = {"state": {"desired": {key: value}}} + return super()._set(key, payload) + + def _transform_payload_after_receiving(self, payload): + return payload + + def _transform_payload_before_sending(self, payload): + return payload + + def _initConnection(self): + try: + ownIp = self._get_ip() + header = self._create_icmp_header() + data = self._create_icmp_data(ownIp, self.port, self.server, self.port) + packet = header + data + packet = self._create_icmp_header(self._checksum_icmp(packet)) + data -class PlainCoAPAirClient: - def __init__(self, host, port=5683): - self.coapthon_logger = logging.getLogger("coapthon") - self.coapthon_logger.setLevel("WARN") - self.server = host - self.port = port + self._send_over_socket(self.server, packet) - def _create_coap_client(self, host, port): - return HelperClient(server=(host, port)) + # that is needed to give device time to open coap port, otherwise it may not respond properly + time.sleep(0.5) + self._send_empty_message() + finally: + pass def _send_over_socket(self, destination, packet): protocol = socket.getprotobyname("icmp") @@ -44,57 +60,6 @@ def _send_over_socket(self, destination, packet): finally: s.close() - def _get(self): - path = "/sys/dev/status" - response = None - try: - client = self._create_coap_client(self.server, self.port) - self._send_hello_sequence(client) - request = client.mk_request(defines.Codes.GET, path) - request.destination = server = (self.server, self.port) - request.type = defines.Types["ACK"] - request.token = generate_random_token(4) - request.observe = 0 - response = client.send_request(request, None, 2) - finally: - if response: - client.cancel_observing(response, True) - client.stop() - - if response: - return json.loads(response.payload, object_pairs_hook=OrderedDict)["state"]["reported"] - else: - return {} - - def _set(self, key, value): - path = "/sys/dev/control" - try: - client = self._create_coap_client(self.server, self.port) - self._send_hello_sequence(client) - payload = {"state": {"desired": {key: value}}} - response = client.post(path, json.dumps(payload)) - return response.payload == '{"status":"success"}' - finally: - client.stop() - - def _send_hello_sequence(self, client): - ownIp = self._get_ip() - - header = self._create_icmp_header() - data = self._create_icmp_data(ownIp, self.port, self.server, self.port) - packet = header + data - packet = self._create_icmp_header(self._checksum_icmp(packet)) + data - - self._send_over_socket(self.server, packet) - - # that is needed to give device time to open coap port, otherwise it may not respond properly - time.sleep(0.5) - - request = Request() - request.destination = server = (self.server, self.port) - request.code = defines.Codes.EMPTY.number - client.send_empty(request) - def _get_ip(self): s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: @@ -211,35 +176,4 @@ def _create_udp_data(self, srcPort, dstPort): def _create_icmp_data(self, srcIp, srcPort, dstIp, dstPort): return self._create_tcp_data(srcIp, dstIp) + self._create_udp_data( srcPort, dstPort - ) - - def set_values(self, values, debug=False): - if debug: - self.coapthon_logger.setLevel("DEBUG") - result = True - for key in values: - result = result and self._set(key, values[key]) - - return result - - def get_status(self, debug=False): - if debug: - self.coapthon_logger.setLevel("DEBUG") - status = self._get() - return status - - def get_wifi(self): - raise NotSupportedException - - def set_wifi(self, ssid, pwd): - raise NotSupportedException - - def get_firmware(self): - status = self._get() - # TODO Really transmit full status here? - return status - - def get_filters(self): - status = self._get() - # TODO Really transmit full status here? - return status + ) \ No newline at end of file diff --git a/testing/coap_resources.py b/testing/coap_resources.py index 40a6308..6235c68 100644 --- a/testing/coap_resources.py +++ b/testing/coap_resources.py @@ -74,12 +74,12 @@ def render_GET_advanced(self, request, response): response.payload = '{{"state":{{"reported": {} }} }}'.format( self.test_data["coap"][self.dataset]["data"] ) - response.payload = self._encrypt_payload(response.payload) + response.payload = self._transform_payload_before_sending(response.payload) if self.render_callback is not None: response.payload = self.render_callback(response.payload) return self, response - def _encrypt_payload(self, payload): + def _transform_payload_before_sending(self, payload): aes = self._handle_AES(self.encryption_key) paded_message = pad(bytes(payload.encode("utf8")), 16, style="pkcs7") encoded_message = binascii.hexlify(aes.encrypt(paded_message)).decode("utf8").upper() @@ -102,14 +102,14 @@ def render_POST_advanced(self, request, response): raise Exception("ControlResource: set data before running tests") encrypted_payload = request.payload - decrypted_payload = self._decrypt_payload(encrypted_payload) + decrypted_payload = self._transform_payload_after_receiving(encrypted_payload) change_request = json.loads(decrypted_payload)["state"]["desired"] success = "success" if json.loads(self.data) == change_request else "failed" response.payload = '{{"status":"{}"}}'.format(success) return self, response - def _decrypt_payload(self, encrypted_payload): + def _transform_payload_after_receiving(self, encrypted_payload): self.encoded_counter = encrypted_payload[0:8] aes = self._handle_AES(self.encoded_counter) encoded_message = encrypted_payload[8:-64].upper() diff --git a/testing/test_coap.py b/testing/test_coap.py index d62da48..b145bfb 100644 --- a/testing/test_coap.py +++ b/testing/test_coap.py @@ -58,7 +58,7 @@ def coap_server(self, sync_resource, status_resource, control_resource): yield server server.stop() - def test_sync_was_called(self, air_client): + def test_initConnection_was_called(self, air_client): assert air_client.client_key == SyncResource.SYNC_KEY def test_set_values(self, air_client): diff --git a/testing/test_plain_coap.py b/testing/test_plain_coap.py index e65dbf4..1b91f08 100644 --- a/testing/test_plain_coap.py +++ b/testing/test_plain_coap.py @@ -50,10 +50,10 @@ def plain_coap_server(self, status_resource, control_resource): server.stop() def test_set_values(self, air_client, monkeypatch): - def send_hello_sequence(client): + def initConnection(client): return - monkeypatch.setattr(air_client, "_send_hello_sequence", send_hello_sequence) + monkeypatch.setattr(air_client, "_initConnection", initConnection) values = {} values["mode"] = "A" @@ -96,10 +96,10 @@ def test_get_cli_filters_is_valid(self, air_cli, test_data, monkeypatch, capfd): ) def assert_json_data(self, air_func, dataset, test_data, monkeypatch, air_client): - def send_hello_sequence(client): + def initConnection(client): return - monkeypatch.setattr(air_client, "_send_hello_sequence", send_hello_sequence) + monkeypatch.setattr(air_client, "_initConnection", initConnection) result = air_func() data = test_data["plain-coap"][dataset]["data"] @@ -109,11 +109,11 @@ def send_hello_sequence(client): def assert_cli_data( self, air_func, dataset, test_data, monkeypatch, air_cli, capfd ): - def send_hello_sequence(client): + def initConnection(client): return monkeypatch.setattr( - air_cli._client, "_send_hello_sequence", send_hello_sequence + air_cli._client, "_initConnection", initConnection ) air_func() From 2db0d8186e70f72df8fff3d50ccc4db6fab2d69c Mon Sep 17 00:00:00 2001 From: Harald Heigl Date: Sat, 8 May 2021 23:43:56 +0200 Subject: [PATCH 2/4] monkeypatched initconnection in tests --- pyairctrl/plain_coap_client.py | 7 ++-- testing/test_plain_coap.py | 63 +++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/pyairctrl/plain_coap_client.py b/pyairctrl/plain_coap_client.py index 68a7e36..b20aaec 100644 --- a/pyairctrl/plain_coap_client.py +++ b/pyairctrl/plain_coap_client.py @@ -13,12 +13,13 @@ from collections import OrderedDict from .coap_client import CoAPAirClientBase + class PlainCoAPAirClient(CoAPAirClientBase): def __init__(self, host, port=5683, debug=False): super().__init__(host, port, debug) # TODO is this really needed for _get? - #request.type = defines.Types["ACK"] - #request.token = generate_random_token(4) + # request.type = defines.Types["ACK"] + # request.token = generate_random_token(4) def _set(self, key, value): payload = {"state": {"desired": {key: value}}} @@ -153,7 +154,7 @@ def _create_tcp_data(self, srcIp, dstIp, checksum=0): tcp = struct.pack( "!BBHHHBBH4s4s", ip_ver, # IP Version - ip_dfc, # Differentiate Service Feild + ip_dfc, # Differentiate Service Field ip_tol, # Total Length ip_idf, # Identification ip_flg, # Flags diff --git a/testing/test_plain_coap.py b/testing/test_plain_coap.py index 1b91f08..aa31cf9 100644 --- a/testing/test_plain_coap.py +++ b/testing/test_plain_coap.py @@ -11,7 +11,19 @@ class TestPlainCoap: @pytest.fixture(scope="class") - def air_client(self): + def monkeyclass(self): + from _pytest.monkeypatch import MonkeyPatch + + mpatch = MonkeyPatch() + yield mpatch + mpatch.undo() + + @pytest.fixture(scope="class") + def air_client(self, monkeyclass): + def initConnection(client): + return + + monkeyclass.setattr(PlainCoAPAirClient, "_initConnection", initConnection) return PlainCoAPAirClient("127.0.0.1") @pytest.fixture(scope="class") @@ -50,11 +62,6 @@ def plain_coap_server(self, status_resource, control_resource): server.stop() def test_set_values(self, air_client, monkeypatch): - def initConnection(client): - return - - monkeypatch.setattr(air_client, "_initConnection", initConnection) - values = {} values["mode"] = "A" result = air_client.set_values(values) @@ -62,22 +69,39 @@ def initConnection(client): def test_get_status_is_valid(self, air_client, test_data, monkeypatch): self.assert_json_data( - air_client.get_status, "status", test_data, monkeypatch, air_client, + air_client.get_status, + "status", + test_data, + monkeypatch, + air_client, ) def test_get_firmware_is_valid(self, air_client, test_data, monkeypatch): self.assert_json_data( - air_client.get_firmware, "status", test_data, monkeypatch, air_client, + air_client.get_firmware, + "status", + test_data, + monkeypatch, + air_client, ) def test_get_filters_is_valid(self, air_client, test_data, monkeypatch): self.assert_json_data( - air_client.get_filters, "status", test_data, monkeypatch, air_client, + air_client.get_filters, + "status", + test_data, + monkeypatch, + air_client, ) def test_get_cli_status_is_valid(self, air_cli, test_data, monkeypatch, capfd): self.assert_cli_data( - air_cli.get_status, "status-cli", test_data, monkeypatch, air_cli, capfd, + air_cli.get_status, + "status-cli", + test_data, + monkeypatch, + air_cli, + capfd, ) def test_get_cli_firmware_is_valid(self, air_cli, test_data, monkeypatch, capfd): @@ -92,15 +116,15 @@ def test_get_cli_firmware_is_valid(self, air_cli, test_data, monkeypatch, capfd) def test_get_cli_filters_is_valid(self, air_cli, test_data, monkeypatch, capfd): self.assert_cli_data( - air_cli.get_filters, "fltsts-cli", test_data, monkeypatch, air_cli, capfd, + air_cli.get_filters, + "fltsts-cli", + test_data, + monkeypatch, + air_cli, + capfd, ) def assert_json_data(self, air_func, dataset, test_data, monkeypatch, air_client): - def initConnection(client): - return - - monkeypatch.setattr(air_client, "_initConnection", initConnection) - result = air_func() data = test_data["plain-coap"][dataset]["data"] json_data = json.loads(data) @@ -109,13 +133,6 @@ def initConnection(client): def assert_cli_data( self, air_func, dataset, test_data, monkeypatch, air_cli, capfd ): - def initConnection(client): - return - - monkeypatch.setattr( - air_cli._client, "_initConnection", initConnection - ) - air_func() result, err = capfd.readouterr() From cf4b42709e2ab1c5a0677ef0bdbe76e8c0ec23b7 Mon Sep 17 00:00:00 2001 From: Harald Heigl Date: Sun, 9 May 2021 00:43:58 +0200 Subject: [PATCH 3/4] some parts of monkeypatching not needed anymore --- testing/test_plain_coap.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/testing/test_plain_coap.py b/testing/test_plain_coap.py index aa31cf9..7afb427 100644 --- a/testing/test_plain_coap.py +++ b/testing/test_plain_coap.py @@ -61,77 +61,71 @@ def plain_coap_server(self, status_resource, control_resource): yield server server.stop() - def test_set_values(self, air_client, monkeypatch): + def test_set_values(self, air_client): values = {} values["mode"] = "A" result = air_client.set_values(values) assert result - def test_get_status_is_valid(self, air_client, test_data, monkeypatch): + def test_get_status_is_valid(self, air_client, test_data): self.assert_json_data( air_client.get_status, "status", test_data, - monkeypatch, air_client, ) - def test_get_firmware_is_valid(self, air_client, test_data, monkeypatch): + def test_get_firmware_is_valid(self, air_client, test_data): self.assert_json_data( air_client.get_firmware, "status", test_data, - monkeypatch, air_client, ) - def test_get_filters_is_valid(self, air_client, test_data, monkeypatch): + def test_get_filters_is_valid(self, air_client, test_data): self.assert_json_data( air_client.get_filters, "status", test_data, - monkeypatch, air_client, ) - def test_get_cli_status_is_valid(self, air_cli, test_data, monkeypatch, capfd): + def test_get_cli_status_is_valid(self, air_cli, test_data, capfd): self.assert_cli_data( air_cli.get_status, "status-cli", test_data, - monkeypatch, air_cli, capfd, ) - def test_get_cli_firmware_is_valid(self, air_cli, test_data, monkeypatch, capfd): + def test_get_cli_firmware_is_valid(self, air_cli, test_data, capfd): self.assert_cli_data( air_cli.get_firmware, "firmware-cli", test_data, - monkeypatch, air_cli, capfd, ) - def test_get_cli_filters_is_valid(self, air_cli, test_data, monkeypatch, capfd): + def test_get_cli_filters_is_valid(self, air_cli, test_data, capfd): self.assert_cli_data( air_cli.get_filters, "fltsts-cli", test_data, - monkeypatch, air_cli, capfd, ) - def assert_json_data(self, air_func, dataset, test_data, monkeypatch, air_client): + def assert_json_data(self, air_func, dataset, test_data, air_client): result = air_func() data = test_data["plain-coap"][dataset]["data"] json_data = json.loads(data) assert result == json_data def assert_cli_data( - self, air_func, dataset, test_data, monkeypatch, air_cli, capfd + self, air_func, dataset, test_data, air_cli, capfd ): air_func() result, err = capfd.readouterr() From 710166046cdd19f9ca109c3d25b8df39c55d8054 Mon Sep 17 00:00:00 2001 From: Harald Heigl Date: Mon, 10 May 2021 00:03:28 +0200 Subject: [PATCH 4/4] added spike for aiocoap - disclaimer: - no adjustments to http and plain_coap (need to change to asyncio) - no adjustments on base-classes (changed coap-class directly) - no adjustments to tests --- README.md | 4 -- pyairctrl/aiocoap_monkeypatch.py | 40 ++++++++++++++++++ pyairctrl/airctrl.py | 31 ++++++++++---- pyairctrl/coap_client.py | 69 +++++++++++++++++++++----------- requirements.txt | 3 +- setup.py | 2 +- 6 files changed, 111 insertions(+), 38 deletions(-) create mode 100644 pyairctrl/aiocoap_monkeypatch.py diff --git a/README.md b/README.md index 6af7330..dcc53d5 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,6 @@ Python 3.4+ is required. Install with `pip3`: ``` $ pip3 install py-air-control ``` -If your device is using CoAP then update the `CoAPthon3` dependency to get fixes for several known bugs: -``` -$ pip3 install -U git+https://github.com/rgerganov/CoAPthon3 -``` Wi-Fi setup --- diff --git a/pyairctrl/aiocoap_monkeypatch.py b/pyairctrl/aiocoap_monkeypatch.py new file mode 100644 index 0000000..47eb837 --- /dev/null +++ b/pyairctrl/aiocoap_monkeypatch.py @@ -0,0 +1,40 @@ +import asyncio +import functools + +from aiocoap.messagemanager import MessageManager +from aiocoap.numbers.constants import EXCHANGE_LIFETIME + + +def _deduplicate_message(self, message): + key = (message.remote, message.mid) + self.log.debug("MP: New unique message received") + self.loop.call_later( + EXCHANGE_LIFETIME, functools.partial(self._recent_messages.pop, key) + ) + self._recent_messages[key] = None + return False + + +MessageManager._deduplicate_message = _deduplicate_message + +from aiocoap.protocol import ClientObservation +from aiocoap.error import ObservationCancelled, NotObservable, LibraryShutdown + + +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 (ObservationCancelled, NotObservable): + # This is the case at the end of an observation cancelled + # by the server. + pass + except LibraryShutdown: + pass + except asyncio.CancelledError: + pass + + +ClientObservation._Iterator.__del__ = __del__ \ No newline at end of file diff --git a/pyairctrl/airctrl.py b/pyairctrl/airctrl.py index 9b35a21..8e238c3 100755 --- a/pyairctrl/airctrl.py +++ b/pyairctrl/airctrl.py @@ -3,6 +3,7 @@ import argparse import sys import pprint +import asyncio from pyairctrl.status_transformer import STATUS_TRANSFORMER from pyairctrl.coap_client import CoAPAirClient @@ -28,8 +29,8 @@ def _dump_keys(self, status, subset, printKey): ).expandtabs(30) ) - def get_status(self, debug=False): - status = self._client.get_status(debug) + async def get_status(self, debug=False): + status = await self._client.get_status(debug) if status is None: print("No info found") return @@ -95,8 +96,16 @@ def set_wifi(self, ssid, pwd): class CoAPCli(CoAPCliBase): - def __init__(self, host, port=5683, debug=False): - super().__init__(CoAPAirClient(host, port, debug)) + def __init__(self, client): + super().__init__(client) + + @classmethod + async def create(cls, host, port=5683, debug=False): + return cls(await CoAPAirClient.create(host, port, debug)) + + async def shutdown(self): + if self._client: + await self._client.shutdown() class PlainCoAPAirCli(CoAPCliBase): @@ -135,7 +144,7 @@ def get_firmware(self): self._dump_keys(firmware, None, False) -def main(): +async def async_main(): parser = argparse.ArgumentParser() parser.add_argument("--ipaddr", help="IP address of air purifier") parser.add_argument( @@ -202,7 +211,7 @@ def main(): elif args.protocol == "plain_coap": c = PlainCoAPAirCli(device["ip"]) elif args.protocol == "coap": - c = CoAPCli(device["ip"], debug=args.debug) + c = await CoAPCli.create(device["ip"], debug=args.debug) if args.wifi: c.get_wifi() @@ -242,7 +251,15 @@ def main(): if values: c.set_values(values, debug=args.debug) else: - c.get_status(debug=args.debug) + await c.get_status(debug=args.debug) + await c.shutdown() + + +def main(): + try: + asyncio.run(async_main()) + except KeyboardInterrupt: + pass if __name__ == "__main__": diff --git a/pyairctrl/coap_client.py b/pyairctrl/coap_client.py index 9eff915..17b47c9 100644 --- a/pyairctrl/coap_client.py +++ b/pyairctrl/coap_client.py @@ -10,9 +10,14 @@ from abc import ABC, abstractmethod from collections import OrderedDict -from coapthon import defines -from coapthon.client.helperclient import HelperClient -from coapthon.messages.request import Request +from pyairctrl import aiocoap_monkeypatch +from aiocoap import ( + Context, + GET, + Message, + NON, + POST, +) from Cryptodome.Cipher import AES from Cryptodome.Util.Padding import pad, unpad @@ -36,22 +41,26 @@ def __init__(self, host, port, debug=False): self.server = host self.port = port self.debug = debug - self.client = self._create_coap_client(self.server, self.port) - self.response = None - self._initConnection() + self.client = None - def __del__(self): - if self.response: - self.client.cancel_observing(self.response, True) - self.client.stop() + @classmethod + async def create(cls, *args, **kwargs): + obj = cls(*args, **kwargs) + await obj._init() + return obj - def _create_coap_client(self, host, port): - return HelperClient(server=(host, port)) + async def _init(self): + self.client = await Context.create_client_context() + await self._initConnection() - def get_status(self, debug=False): + async def shutdown(self) -> None: + if self.client: + await self.client.shutdown() + + async def get_status(self, debug=False): if debug: self.logger.setLevel("DEBUG") - status = self._get() + status = await self._get() return status def set_values(self, values, debug=False): @@ -64,15 +73,21 @@ def set_values(self, values, debug=False): return result - def _get(self): + async def _get(self): payload = None try: - request = self.client.mk_request(defines.Codes.GET, self.STATUS_PATH) - request.observe = 0 - self.response = self.client.send_request(request, None, 2) - if self.response: - payload = self._transform_payload_after_receiving(self.response.payload) + request = Message( + code=GET, + mtype=NON, + uri=f"coap://{self.server}:{self.port}{self.STATUS_PATH}", + ) + request.opt.observe = 0 + response = await self.client.request(request).response + if response: + payload = self._transform_payload_after_receiving( + response.payload.decode() + ) except Exception as e: print("Unexpected error:{}".format(e)) @@ -104,7 +119,7 @@ def _send_empty_message(self): self.client.send_empty(request) @abstractmethod - def _initConnection(self): + async def _initConnection(self): pass @abstractmethod @@ -138,9 +153,15 @@ class CoAPAirClient(CoAPAirClientBase): def __init__(self, host, port=5683, debug=False): super().__init__(host, port, debug) - def _initConnection(self): - self.syncrequest = binascii.hexlify(os.urandom(4)).decode("utf8").upper() - resp = self.client.post(self.SYNC_PATH, self.syncrequest, timeout=5) + async def _initConnection(self): + syncrequest = os.urandom(4).hex().upper() + request = Message( + code=POST, + mtype=NON, + uri=f"coap://{self.server}:{self.port}{self.SYNC_PATH}", + payload=syncrequest.encode(), + ) + resp = await self.client.request(request).response if resp: self.client_key = resp.payload else: diff --git a/requirements.txt b/requirements.txt index 7caf570..874f8fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ pycryptodomex>=3.4.7 -# see https://github.com/Tanganelli/CoAPthon3/issues/29 -CoAPthon3 @ git+https://github.com/Tanganelli/CoAPthon3@89d5173 +aiocoap=0.4.1 \ No newline at end of file diff --git a/setup.py b/setup.py index eed1767..a806ef8 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ packages=['pyairctrl'], install_requires=[ 'pycryptodomex>=3.4.7', - 'CoAPthon3>=1.0.1' + 'aiocoap==0.4.1' ], entry_points={ 'console_scripts': [