From f58c1c888abe7a915cf4fdd0145f640e4ec609f0 Mon Sep 17 00:00:00 2001 From: Jan Lipponen Date: Mon, 6 May 2024 20:12:56 +0300 Subject: [PATCH] Move into factory pattern for client implementations --- README.md | 5 +++ pyproject.toml | 2 - ruuvigate/__init__.py | 2 - ruuvigate/__main__.py | 76 +++++++++++++++------------------ ruuvigate/architecture.md | 59 +++++++++++++++++++++++++ ruuvigate/clients/__init__.py | 3 -- ruuvigate/clients/azure_iotc.py | 40 ++++++++--------- ruuvigate/clients/client.py | 39 +++++++++++++++++ ruuvigate/clients/stdout.py | 17 ++++++++ 9 files changed, 176 insertions(+), 67 deletions(-) create mode 100644 ruuvigate/architecture.md create mode 100644 ruuvigate/clients/client.py create mode 100644 ruuvigate/clients/stdout.py diff --git a/README.md b/README.md index bb8c7b1..e035448 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ Examples of configuration files: ## Development +### Run local +``` +> poetry run python -m ruuvigate -r /path/to/ruuvitags.yml --mode stdout --interval 5 --loglevel INFO --simulate +``` + ### Install dependencies ``` > poetry install diff --git a/pyproject.toml b/pyproject.toml index 3dff1d2..9a1366d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,6 @@ packages = [ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12" diff --git a/ruuvigate/__init__.py b/ruuvigate/__init__.py index 8893167..ab1a65c 100644 --- a/ruuvigate/__init__.py +++ b/ruuvigate/__init__.py @@ -1,5 +1,3 @@ """ The RuuviGate package """ - -from ruuvigate.clients import AzureIOTC diff --git a/ruuvigate/__main__.py b/ruuvigate/__main__.py index e28c608..20d44b5 100644 --- a/ruuvigate/__main__.py +++ b/ruuvigate/__main__.py @@ -9,45 +9,46 @@ from random import randint from ruuvitag_sensor.ruuvi import RuuviTagSensor # type: ignore -from ruuvigate.clients import AzureIOTC + +from .clients.client import FACTORIES, Connectable, DataPublisher class RuuviTags: lock = asyncio.Lock() def __init__(self, path): - self.macs_file_ = path - self.macs_ = [] + self._macs_file = path + self._macs = [] - if not os.path.exists(self.macs_file_): - open(self.macs_file_, "x") + if not os.path.exists(self._macs_file): + open(self._macs_file, "x") else: self.__parse_ruuvitag_file() async def add_mac(self, mac): async with self.lock: - if self.macs_.count(mac) != 0: + if self._macs.count(mac) != 0: return False if not self.is_legal_mac(mac): raise ValueError("Malformed MAC: {}".format(mac)) - self.macs_.append(mac) + self._macs.append(mac) await self.__write_macs_to_ruuvitag_file() return True async def remove_mac(self, mac): async with self.lock: - if self.macs_.count(mac) == 0: + if self._macs.count(mac) == 0: return False - self.macs_.remove(mac) + self._macs.remove(mac) await self.__write_macs_to_ruuvitag_file() return True async def get_macs(self): async with self.lock: - return self.macs_ + return self._macs def __parse_ruuvitag_file(self): - with open(self.macs_file_, "r") as f: + with open(self._macs_file, "r") as f: lines = f.read().splitlines() for line in lines: if not line: @@ -55,12 +56,12 @@ def __parse_ruuvitag_file(self): if not self.is_legal_mac(line): raise ValueError( "Malformed line in RuuviTags file: {}".format(line)) - self.macs_.append(line) + self._macs.append(line) async def __write_macs_to_ruuvitag_file(self): async with self.lock: - with open(self.macs_file_, "w") as stream: - for mac in self.macs_: + with open(self._macs_file, "w") as stream: + for mac in self._macs: stream.write(mac + '\n') @staticmethod @@ -106,7 +107,7 @@ async def get_ruuvitags(data, ruuvitags): return {"result": True, "data": macs} -async def get_ruuvi_data(args, ruuvitags): +async def get_ruuvi_data(args, ruuvitags: RuuviTags): if args.simulate: data = {} for tag in ruuvitags: @@ -127,9 +128,9 @@ async def get_ruuvi_data(args, ruuvitags): return data -async def send_ruuvi_data(client, ruuvitags, data): +async def send_ruuvi_data(publisher: DataPublisher, ruuvitags, data): for mac, data in data.items(): - await client.buffer_data({ + await publisher.buffer_data({ "Temperature" + str(ruuvitags.index(mac) + 1): data["temperature"], "Humidity" + str(ruuvitags.index(mac) + 1): @@ -141,20 +142,17 @@ async def send_ruuvi_data(client, ruuvitags, data): "Sequence" + str(ruuvitags.index(mac) + 1): data["measurement_sequence_number"] }) - await client.send_data() + await publisher.publish_data() -async def publish_ruuvi_data(args, client, ruuvitags): +async def publish_ruuvi_data(args, publisher: DataPublisher, ruuvitags: RuuviTags): while True: try: macs = await ruuvitags.get_macs() if macs: data = await get_ruuvi_data(args, macs) if data: - if args.mode == 'stdout' or client is None: - print(data) - else: - await send_ruuvi_data(client, macs, data) + await send_ruuvi_data(publisher, macs, data) else: logging.warning( "Could not read any RuuviTag data. Please make sure that the specified RuuviTags are within range." @@ -245,21 +243,14 @@ def report_and_exit(err_msg: str, exit_code): return args -async def main(): - assert sys.version_info >= (3, 8), "Python 3.8 or greater required" - args = parse_args() - logging.basicConfig(level=args.log_level) - tags = RuuviTags(args.ruuvitags.name) - client = AzureIOTC() if args.mode == "azure" else None - - if client is None: - listeners = [asyncio.create_task(dummy_task())] - else: - try: - await client.connect(args.config) - except ConnectionError: - sys.exit(os.EX_UNAVAILABLE) +async def main(args, tags: RuuviTags, client: Connectable): + try: + await client.connect(args.config) + except ConnectionError: + sys.exit(os.EX_UNAVAILABLE) + listeners = [] + if args.mode == "azure": listeners = [ asyncio.create_task( client.execute_method_listener("AddRuuviTag", add_ruuvitag, @@ -274,9 +265,7 @@ async def main(): client.execute_method_listener("GetRuuviTags", get_ruuvitags, tags))) - publisher = [asyncio.create_task(publish_ruuvi_data(args, client, tags))] - - tasks = listeners + publisher + tasks = listeners + [asyncio.create_task(publish_ruuvi_data(args, client, tags))] loop = asyncio.get_event_loop() # Signals to initiate a graceful shutdown @@ -289,5 +278,10 @@ async def main(): if __name__ == '__main__': - asyncio.run(main()) + assert sys.version_info >= (3, 10), "Python 3.10 or greater required" + args = parse_args() + logging.basicConfig(level=args.log_level) + tags = RuuviTags(args.ruuvitags.name) + client = FACTORIES[args.mode]() + asyncio.run(main(args, tags, client)) logging.info("RuuviGate was shutdown") diff --git a/ruuvigate/architecture.md b/ruuvigate/architecture.md new file mode 100644 index 0000000..1306e07 --- /dev/null +++ b/ruuvigate/architecture.md @@ -0,0 +1,59 @@ +```mermaid +--- +title: RuuviGate +--- +classDiagram + + class Connectable { + <> + +connect(config)* + } + + class DataPublisher { + <> + +publish_data(data)* + +buffer_data(data)* + } + + class ConnectableFactory { + <> + +Connectable con_class + +__call__() Connectable() + } + + class AzureIOTC { + -IoTHubDeviceClient _client + -Dict~str, str~ _databuf + +connect(data) + +disconnect() + +publish_data(data) + +buffer_data(data) + +execute_method_listener(method_name, handler, cookie) + -parse_config()$ + -provision_device()$ + } + + class StdOut { + +connect(data) + +publish_data(data) + +buffer_data(data) + } + + class RuuviTags { + -str _macs_file + -List~str~ _macs + +add_mac(mac) + +remove_mac(mac) + +get_macs() + +is_legal_mac(mac)$ + -parse_ruuvitag_file() + -write_macs_to_ruuvitag_file + + } + + Connectable <|-- ConnectableFactory : create + Connectable <|-- AzureIOTC : adheres + Connectable <|-- StdOut : adheres + DataPublisher <|-- AzureIOTC : adheres + DataPublisher <|-- StdOut : adheres +``` diff --git a/ruuvigate/clients/__init__.py b/ruuvigate/clients/__init__.py index dc42d9d..e69de29 100644 --- a/ruuvigate/clients/__init__.py +++ b/ruuvigate/clients/__init__.py @@ -1,3 +0,0 @@ -from .azure_iotc import AzureIOTC - -__all__ = ["azure_iotc"] diff --git a/ruuvigate/clients/azure_iotc.py b/ruuvigate/clients/azure_iotc.py index b5af27c..f5be85b 100644 --- a/ruuvigate/clients/azure_iotc.py +++ b/ruuvigate/clients/azure_iotc.py @@ -1,3 +1,4 @@ +import atexit import os import json import uuid @@ -37,19 +38,20 @@ class Message(Enum): ContentType = "application/json" def __init__(self): - self.client_ = None - self.dataBuf_ = {} + self._client = None + self._databuf = {} + atexit.register(self.disconnect) def __connected(func: Callable) -> Any: # type: ignore def wrapper(self, *args, **kwargs): - assert self.client_ is not None, "AzureIOTC not connected" + assert self._client is not None, "AzureIOTC not connected" return func(self, *args, **kwargs) return wrapper async def connect(self, config_path: str): - config = self.parse_config(config_path) + config = self.__parse_config(config_path) # Provision the device device_host = await self.__provision_device( @@ -63,30 +65,30 @@ async def connect(self, config_path: str): # Open the connection try: - self.client_ = IoTHubDeviceClient.create_from_symmetric_key( + self._client = IoTHubDeviceClient.create_from_symmetric_key( symmetric_key=config[self.AzureParams.DeviceKey.value], hostname=device_host, device_id=config[self.AzureParams.DeviceID.value], ) - await self.client_.connect() + await self._client.connect() except Exception as ex: logging.error("Unable to connect to Azure: {} {}".format( type(ex).__name__, ex.args)) - self.client_ = None + self._client = None raise ConnectionError() async def disconnect(self): - if self.client_ != None: + if self._client != None: logging.info("Disconnecting AzureIOTC") - await self.client_.shutdown() - self.client_ = None + await self._client.shutdown() + self._client = None @__connected async def execute_method_listener(self, method_name, handler, cookie): logging.info("Executing a listener for \"" + method_name + "\" method") while True: try: - method_request = await self.client_.receive_method_request( + method_request = await self._client.receive_method_request( self.MethodNames.get(method_name)) logging.info("Received method request \"" + method_name + "\"") @@ -98,7 +100,7 @@ async def execute_method_listener(self, method_name, handler, cookie): command_response = MethodResponse.create_from_method_request( method_request, response_status, response_payload) try: - await self.client_.send_method_response(command_response) + await self._client.send_method_response(command_response) except RuntimeError: logging.error( "Responding to command request \"{}\" failed".format( @@ -108,28 +110,28 @@ async def execute_method_listener(self, method_name, handler, cookie): break @__connected - async def send_data(self, data={}): + async def publish_data(self, data={}): """ param data: dictionary of values to send """ - self.dataBuf_.update(data) + self._databuf.update(data) - msg = Message(json.dumps(self.dataBuf_)) + msg = Message(json.dumps(self._databuf)) msg.content_encoding = AzureIOTC.Message.Encoding.value msg.content_type = AzureIOTC.Message.ContentType.value msg.message_id = uuid.uuid4() - await self.client_.send_message(msg) + await self._client.send_message(msg) logging.info("Sent message " + str(msg) + " with id " + str(msg.message_id)) - self.dataBuf_.clear() + self._databuf.clear() async def buffer_data(self, data={}): - self.dataBuf_.update(data) + self._databuf.update(data) @staticmethod - def parse_config(config_path: str): + def __parse_config(config_path: str): if not os.path.exists(config_path): logging.error("Can't find file: " + config_path) raise FileNotFoundError(config_path) diff --git a/ruuvigate/clients/client.py b/ruuvigate/clients/client.py new file mode 100644 index 0000000..42419c7 --- /dev/null +++ b/ruuvigate/clients/client.py @@ -0,0 +1,39 @@ + +from abc import abstractmethod +from typing import Protocol, Type +from dataclasses import dataclass + +from .azure_iotc import AzureIOTC +from .stdout import StdOut + + +class Connectable(Protocol): + + @abstractmethod + async def connect(self, config): + ... + + +class DataPublisher(Protocol): + + @abstractmethod + async def publish_data(self, data): + ... + + @abstractmethod + async def buffer_data(self, data): + ... + + +@dataclass +class ConnectableFactory: + con_class: Type[Connectable] + + def __call__(self) -> Connectable: + return self.con_class() + + +FACTORIES = { + "azure": ConnectableFactory(AzureIOTC), + "stdout": ConnectableFactory(StdOut), +} diff --git a/ruuvigate/clients/stdout.py b/ruuvigate/clients/stdout.py new file mode 100644 index 0000000..a343c05 --- /dev/null +++ b/ruuvigate/clients/stdout.py @@ -0,0 +1,17 @@ +from typing import Dict + + +class StdOut: + def __init__(self): + self._dataBuf: Dict[str, str] = {} + + async def connect(self, _): + pass + + async def publish_data(self, data={}): + self._dataBuf.update(data) + print(self._dataBuf) + self._dataBuf.clear() + + async def buffer_data(self, data: Dict[str, str]): + self._dataBuf.update(data)