From a157659fe3f42761a98d9aff7c002867f22c255d Mon Sep 17 00:00:00 2001 From: Federico Jose Pacheco <51347305+fjpacheco@users.noreply.github.com> Date: Fri, 21 Jun 2024 15:18:56 -0300 Subject: [PATCH] Cliente MQTT + Simulador mejorado (#7) * changed protocol from AMQP to MQTT in rabbitmq client * add unused device_id * change int to float in temperature & light. light sent with lux unit. add id_device. add time_stamp with arg tz * add executable to send simple package * add main as cli to run executables more easily * fix lint * fix requeriment & dockerfile * remove unused requeriment * fix lint * fix unique id_device in simulator * add light with LUX unity to send ! * add executable to send deviated package * rename command of simulator * add README * update .env.dist * add -pn lower case --- .env.dist | 11 +- Dockerfile | 9 +- README.md | 92 +++++++++++++- requirements.txt | 8 +- src/common/middleware.py | 140 ++++++++++++++------- src/config.json | 1 - src/data_packet.py | 50 ++++---- src/deviated_generator.py | 210 +++++++++++++++++++++++++++++++ src/main.py | 142 +++++++++++---------- src/resources/plants_dataset.csv | 23 ++++ src/simple_package.py | 76 +++++++++++ src/simulator.py | 61 +++++++++ 12 files changed, 679 insertions(+), 144 deletions(-) create mode 100644 src/deviated_generator.py create mode 100644 src/resources/plants_dataset.csv create mode 100644 src/simple_package.py create mode 100644 src/simulator.py diff --git a/.env.dist b/.env.dist index 9b00d58..72bd581 100644 --- a/.env.dist +++ b/.env.dist @@ -1,5 +1,10 @@ -RABBITMQ_HOST= -QUEUE_NAME= LOGGING_LEVEL= WEATHER_API_KEY= -WEATHER_API_URL= \ No newline at end of file +WEATHER_API_URL= + +# RabbitMQ - MQTT broker details +MQTT_HOST= +MQTT_PORT= +MQTT_USERNAME= +MQTT_PASSWORD= +MQTT_TOPIC= diff --git a/Dockerfile b/Dockerfile index 35b8645..4fcb6fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,11 @@ WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY src src +COPY ./src . -CMD ["python", "src/main.py"] +COPY .env .env + +CMD ["python", "main.py", "run-simulator"] + + +# docker build -t simulator . && docker run simulator && docker rmi simulator \ No newline at end of file diff --git a/README.md b/README.md index 77ef73f..264460d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,22 @@ # Sensor simulator Indoor plant sensor simulator for early testing. + +## Environment variables + +Configure the service and the connection to the OpenWeatherMap API: +* **LOGGING_LEVEL** (Default: "INFO"): Controls the level of logging output. +* **WEATHER_API_KEY**: API key to access the OpenWeatherMap API. +* **WEATHER_API_URL**: URL to access the OpenWeatherMap API. + +Configure the connection to your RabbitMQ server: +* **MQTT_HOST** +* **MQTT_PORT** +* **MQTT_USERNAME** +* **MQTT_PASSWORD** +* **MQTT_TOPIC** + + ## How it works The simulator creates a data packet every fixed period. This packet contains simulated parameters data like: @@ -21,7 +37,9 @@ This parameters are fetched from the [OpenWeatherMap API](https://openweathermap ## Commands It would be nice to accept commands from TUI to simulate deviations fixes. Something like ` ` -## Usage Instructions +## Usage Instructions to Simulator of Packages + +### Docker The repository includes a **Makefile** that encapsulates various commands used frequently in the project as targets. The targets are executed by invoking: * **make \**: @@ -34,3 +52,75 @@ Available targets are: * **docker-image**: Builds the images to be used. This target is used by **docker-compose-up**, so it can be used to test new changes in the images before starting the project. Important Note: This service assumes a running instance of RabbitMQ and connects to it. Therefore, to run this service, it is necessary to first have the **measurements** service running. Please make sure to also check [measurements repository](https://github.com/Hanagotchi/measurements). + +### CLI (command-line interface) with Virtual Environment + +To run the simulator using the CLI, you need to install the dependencies. Its highly recommended to use a virtual environment. You can create a virtual environment and install the dependencies by running the following commands: + +```bash +cd simulator +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +After installing the dependencies, you can run the simulator by executing the following command: + +```bash +cd src +python3 main.py simulator +``` + +By default, the simulator assigns a random device ID to the sensor. If you want to specify a device ID, you can pass it as an argument: + +``` +cd src +python3 main.py simulator --id-device +``` + +## Usage Instructions to Send Custom Package + +### Docker + +Sorry, this is not available yet. . . + +### CLI (command-line interface) with Virtual Environment + +#### Sending a Simple Package with custom parameters + +To send a custom package, you can run the following command: + +```bash +cd src +python3 main.py simple-package --id-device --temperature --light --humidity --watering +``` + +or reduce the typing by using the short version of the arguments: + +```bash +cd src +python3 main.py simple-package -i -t -l -h -w +``` + +By executing the command above, the simulator will send a package with the specified parameters. + + +#### Sending a deviated package + +To send a deviated package, you can run the following command: + +```bash +cd src +python3 main.py deviated-package --id-device --plant-name --deviated-temperature --deviated-light --deviated-humidity --deviated-watering +``` + +or reduce the typing by using the short version of the arguments: + +```bash +cd src +python3 main.py deviated-package -i -pn -dt -dl -dh -dw +``` + +By executing the command above, the simulator will send a package with the specified deviations. + +The parameters `deviated-temperature`, `deviated-light`, `deviated-humidity`, and `deviated-watering` can be set to `higher`, `lower`, or `ideal`. The simulator will generate a package with the specified deviations based on the plant's ideal values [indicated in the dataset](src/resources/plants_dataset.csv) and rules defined in measurements service. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2cb404a..538024b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ -pika requests -python-dotenv \ No newline at end of file +python-dotenv +paho-mqtt +cachetools==3.1. +typer[all] +pandas +typing-extensions \ No newline at end of file diff --git a/src/common/middleware.py b/src/common/middleware.py index 1a48914..a162fc6 100644 --- a/src/common/middleware.py +++ b/src/common/middleware.py @@ -1,52 +1,102 @@ -import pika import os +import time +import logging +import paho.mqtt.client as mqtt +FIRST_RECONNECT_DELAY = 1 +RECONNECT_RATE = 2 +MAX_RECONNECT_COUNT = 12 +MAX_RECONNECT_DELAY = 60 +logger = logging.getLogger("simulator") -class Middleware: +class Middleware: def __init__(self): - rabbitmq_host = os.environ.get("RABBITMQ_HOST", "localhost") - self._connection = pika.BlockingConnection( - pika.ConnectionParameters(host=rabbitmq_host) - ) - self._channel = self._connection.channel() - self._exit = False - self._remake = False - - def create_queue(self, queue_name): - self._channel.queue_declare(queue=queue_name) - - def _setup_message_consumption(self, queue_name, user_function): - self._channel.basic_consume(queue=queue_name, - on_message_callback=lambda channel, - method, properties, body: - (user_function(body), - channel.basic_ack - (delivery_tag=method.delivery_tag), - self._verify_connection_end())) - self._channel.start_consuming() - - def _verify_connection_end(self): - if self._exit: - self._channel.close() - if self._remake: - self._exit = False - self._channel = self._connection.channel() - - def finish(self, open_new_channel=False): - self._exit = True - self._remake = open_new_channel - - # Work queue methods - def listen_on(self, queue_name, user_function): - self.create_queue(queue_name) - self._channel.basic_qos(prefetch_count=30) - self._setup_message_consumption(queue_name, user_function) - - def send_message(self, queue_name, message): - self._channel.basic_publish(exchange='', - routing_key=queue_name, - body=message) + self._client = mqtt.Client() + self._client.username_pw_set(os.environ.get("MQTT_USERNAME"), + os.environ.get("MQTT_PASSWORD")) + self._topic = None + + def _on_connect(self, client, userdata, flags, rc): + if rc == 0 and self._client.is_connected(): + logger.info(f"Connected successfully with code {rc}: " + + f"{mqtt.connack_string(rc)}") + if self._topic is not None: + self._client.subscribe(self._topic) + logger.info(f"Subscribed to '{self._topic}' topic") + else: + logger.info(f"Connect failed with code {rc}: " + + f"{mqtt.connack_string(rc)}") + + def _on_disconnect(self, client, userdata, rc): + logger.info(f"Disconnected with result code {rc}: " + + f"{mqtt.error_string(rc)}") + if rc == 0: + logger.info(f"Disconnect successfully with code {rc}: " + + f"{mqtt.error_string(rc)}") + return + reconnect_count, reconnect_delay = 0, FIRST_RECONNECT_DELAY + while reconnect_count < MAX_RECONNECT_COUNT: + logger.info("Reconnecting in %d seconds...", reconnect_delay) + time.sleep(reconnect_delay) + + try: + client.reconnect() + logger.info("Reconnected successfully!") + return + except Exception as err: + logger.error("%s. Reconnect failed. Retrying...", err) + + reconnect_delay *= RECONNECT_RATE + reconnect_delay = min(reconnect_delay, MAX_RECONNECT_DELAY) + reconnect_count += 1 + logger.info("Reconnect failed after %s attempts. Exiting...", + reconnect_count) + + def _on_publish(self, client, userdata, mid): + logger.info(f"Message publishd! Message id: {mid}") + + def listen_on(self, user_function, topic): + self._topic = topic + self._client.on_message = user_function + + def send_message(self, topic, msg, qos=2): + result = self._client.publish(topic, msg, qos=qos) + result.wait_for_publish() + status = result[0] + if status == 0: + logger.info(f"Succesfully sent message '{msg}' to topic {topic} " + + f"with qos {qos} and message id {result[1]}. " + + "Result code: " + + f"{status}: {mqtt.error_string(status)}") + else: + logger.info(f"Failed to send message '{msg}' to topic {topic}. " + + f"with qos {qos} and message id {result[1]}. " + + "Result code: " + + f"{status}: {mqtt.error_string(status)}") def __del__(self): - self._connection.close() + self.finish() + + def connect(self): + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_publish = self._on_publish + self._client.connect(os.environ.get("MQTT_HOST"), + int(os.environ.get("MQTT_PORT"))) + logger.info("Connecting to " + + f"{os.environ.get('MQTT_HOST')}:" + + f"{os.environ.get('MQTT_PORT')}") + + def run(self): + self._client.loop_start() + while True: + self._client.loop() + if self._client.is_connected(): + logger.info("Connected to broker. Listening for messages or " + + "ready to send.") + break + + def finish(self): + self._client.loop_stop() + self._client.disconnect() diff --git a/src/config.json b/src/config.json index 50648f3..5e2b210 100644 --- a/src/config.json +++ b/src/config.json @@ -1,6 +1,5 @@ { "packet_period": 1, - "device_id": "97bebd21-a9c8-41a3-9a88-969cd3dd7bba", "deviations": { "temperature": 3, "humidity": 5, diff --git a/src/data_packet.py b/src/data_packet.py index 229ab54..df362db 100644 --- a/src/data_packet.py +++ b/src/data_packet.py @@ -3,17 +3,22 @@ from dotenv import load_dotenv from os import environ from typing import Tuple -from datetime import datetime import logging import math -import uuid +from datetime import datetime, timedelta, timezone +import cachetools.func + load_dotenv() -logging.getLogger("urllib3").setLevel(logging.WARNING) -UUID = str(uuid.uuid4()).replace("-", "") +logger = logging.getLogger("simulator") +ARG_TZ = timezone(timedelta(hours=-3), name="America/Argentina/Buenos_Aires") +FTC_TO_LUX = 10.764 +# 10 minutes +TTL_CACHE = 10 * 60 -def fetch_temperature_and_humidity(location: str) -> Tuple[int, int]: +@cachetools.func.ttl_cache(maxsize=128, ttl=TTL_CACHE) +def fetch_temperature_and_humidity(location: str) -> Tuple[float, int]: base_params = { "q": location, @@ -27,14 +32,14 @@ def fetch_temperature_and_humidity(location: str) -> Tuple[int, int]: ) result = res.json() - temperature = int(result["main"]["temp"] - 273.15) + temperature = round(result["main"]["temp"] - 273.15, 2) humidity = result["main"]["humidity"] return temperature, humidity def get_decimal_hour(): - current_hour = datetime.now() + current_hour = datetime.now(tz=ARG_TZ) return current_hour.hour + ( 60 * current_hour.minute + current_hour.second ) / 3600 @@ -70,8 +75,9 @@ def fetch_watering(): return round(watering_simulator(x)) -def create_packet(temperature: float = None, humidity: float = None, - light: float = None, watering: float = None): +def create_packet(temperature: float = None, humidity: int = None, + light: float = None, watering: int = None, + id_device: str = None) -> dict: ''' Creates a data packet with simulated data, validating the data before that. @@ -102,14 +108,14 @@ def create_packet(temperature: float = None, humidity: float = None, return { "temperature": temperature, "humidity": humidity, - "light": light, + "light": light * FTC_TO_LUX, "watering": watering, - "time_stamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - "id_device": UUID + "time_stamp": datetime.now(tz=ARG_TZ).isoformat(), + "id_device": id_device } -def generate_data(location="Pilar, AR") -> Tuple[int, int, int, int]: +def generate_data(location="Pilar, AR") -> Tuple[float, int, float, int]: ''' Generates the parameters data: temperature, humidity, light and watering. @@ -143,12 +149,12 @@ def data_has_changed(current, last_sent, deviations): If there is no last_sent, then the current must be sent. Types: - - current: {temperature: float, humidity: float, light: float, - watering: float} - - last_sent: {temperature: float, humidity: float, light: float, - watering: float} - - deviations: {temperature: float, humidity: float, light: float, - watering: float} + - current: {temperature: float, humidity: int, light: float, + watering: int} + - last_sent: {temperature: float, humidity: int, light: float, + watering: int} + - deviations: {temperature: float, humidity: int, light: float, + watering: int} ''' if not last_sent: @@ -156,11 +162,13 @@ def data_has_changed(current, last_sent, deviations): if parameter_has_changed(current["temperature"], last_sent["temperature"], deviations["temperature"])\ - or parameter_has_changed(current["humidity"], last_sent["humidity"], + or parameter_has_changed(current["humidity"], + last_sent["humidity"], deviations["humidity"])\ or parameter_has_changed(current["light"], last_sent["light"], deviations["light"])\ - or parameter_has_changed(current["watering"], last_sent["watering"], + or parameter_has_changed(current["watering"], + last_sent["watering"], deviations["watering"]): return True diff --git a/src/deviated_generator.py b/src/deviated_generator.py new file mode 100644 index 0000000..e4e69a0 --- /dev/null +++ b/src/deviated_generator.py @@ -0,0 +1,210 @@ +from enum import Enum +import logging +import pandas as pd +from simple_package import execute_simple_package + +TEMP_DELTA = 6 + + +class Deviated(str, Enum): + HIGH = "higher" + LOW = "lower" + IDEAL = "ideal" + + +# TEMPERATURE (°C) +TEMP_RULES_MAP = { + 1: (10, 18), + 2: (18, 24), + 3: (21, 30) +} + +# FOOT CANDLE (ftc) +LIGHT_RULES_MAP = { + 1: (350, 500), + 2: (200, 350), + 3: (75, 200), + 4: (25, 75), +} + +# WATERING (%) +WATERING_RULES_MAP = { + 1: (70, 100), + 2: (40, 70), + 3: (10, 40), +} + +# HUMIDITY (%) +HUMIDITY_RULES_MAP = { + 1: (50, 100), + 2: (25, 50), + 3: (5, 25), +} + + +def parse_values(string): + values = string.split('-') + return [int(value) for value in values] + + +def generate_new_columns(df, plant_name, plant_data): + t_values, h_values, l_values, w_values = get_ideal_values(df, + plant_name) + + t_first_element = t_values[0] + t_last_element = t_first_element + try: + t_last_element = t_values[-1] + except IndexError: + t_last_element = t_first_element + + df.loc[ + df['Botanical_Name'] == plant_name, 'T_OPT_MIN' + ] = TEMP_RULES_MAP.get(t_first_element)[0] + df.loc[ + df['Botanical_Name'] == plant_name, 'T_OPT_MAX' + ] = TEMP_RULES_MAP.get(t_last_element)[1] + + h_first_element = h_values[0] + h_last_element = h_first_element + try: + h_last_element = h_values[-1] + except IndexError: + h_last_element = h_first_element + + df.loc[ + df['Botanical_Name'] == plant_name, 'H_OPT_MIN' + ] = HUMIDITY_RULES_MAP.get(h_last_element)[0] + df.loc[ + df['Botanical_Name'] == plant_name, 'H_OPT_MAX' + ] = HUMIDITY_RULES_MAP.get(h_first_element)[1] + + l_first_element = l_values[0] + l_last_element = l_first_element + + try: + l_last_element = l_values[-1] + except IndexError: + l_last_element = l_first_element + + df.loc[ + df['Botanical_Name'] == plant_name, 'L_OPT_MIN' + ] = LIGHT_RULES_MAP.get(l_last_element)[0] + df.loc[ + df['Botanical_Name'] == plant_name, 'L_OPT_MAX' + ] = LIGHT_RULES_MAP.get(l_first_element)[1] + + w_first_element = w_values[0] + w_last_element = w_first_element + try: + w_last_element = w_values[-1] + except IndexError: + w_last_element = w_first_element + + df.loc[ + df['Botanical_Name'] == plant_name, 'W_OPT_MIN' + ] = WATERING_RULES_MAP.get(w_last_element)[0] + df.loc[ + df['Botanical_Name'] == plant_name, 'W_OPT_MAX' + ] = WATERING_RULES_MAP.get(w_first_element)[1] + + +def generate_ideal_package(plant_name, df): + plant_data = df[df['Botanical_Name'] == plant_name] + package = { + "temperature": (plant_data[ + 'T_OPT_MIN' + ].values[0] + plant_data[ + 'T_OPT_MAX' + ].values[0]) / 2, + "humidity": (plant_data[ + 'H_OPT_MIN' + ].values[0] + plant_data[ + 'H_OPT_MAX' + ].values[0]) / 2, + "light": (plant_data[ + 'L_OPT_MIN' + ].values[0] + plant_data[ + 'L_OPT_MAX' + ].values[0]) / 2, + "watering": (plant_data[ + 'W_OPT_MIN' + ].values[0] + plant_data[ + 'W_OPT_MAX' + ].values[0]) / 2 + } + return package + + +def adjust_percentage(package): + if package["watering"] < 0: + package["watering"] = 0 + if package["watering"] > 100: + package["watering"] = 100 + if package["humidity"] < 0: + package["humidity"] = 0 + if package["humidity"] > 100: + package["humidity"] = 100 + + +def add_deviations_to_package(package, deviations, plant_data): + if deviations.get("temperature") == "lower": + package["temperature"] = plant_data['T_OPT_MIN'].values[0] - TEMP_DELTA + elif deviations.get("temperature") == "higher": + package["temperature"] = plant_data['T_OPT_MAX'].values[0] + TEMP_DELTA + if deviations.get("light") == "lower": + package["light"] = plant_data['L_OPT_MIN'].values[0] - 1 + elif deviations.get("light") == "higher": + package["light"] = plant_data['L_OPT_MAX'].values[0] + 1 + if deviations.get("humidity") == "lower": + package["humidity"] = plant_data['H_OPT_MIN'].values[0] - 1 + elif deviations.get("humidity") == "higher": + package["humidity"] = plant_data['H_OPT_MAX'].values[0] + 1 + if deviations.get("watering") == "lower": + package["watering"] = plant_data['W_OPT_MIN'].values[0] - 1 + elif deviations.get("watering") == "higher": + package["watering"] = plant_data['W_OPT_MAX'].values[0] + 1 + + adjust_percentage(package) + + +def get_ideal_values(df, plant_name): + plant_data = df[df['Botanical_Name'] == plant_name] + h_value = plant_data['H'].values[0] + l_value = plant_data['L'].values[0] + t_value = plant_data['T'].values[0] + w_value = plant_data['W'].values[0] + + t_values = parse_values(t_value) + h_values = parse_values(h_value) + l_values = parse_values(l_value) + w_values = parse_values(w_value) + + return t_values, h_values, l_values, w_values + + +def send_deviated_package(id_device: str, plant_name: str, deviations: dict): + file_path = 'resources/plants_dataset.csv' + df = pd.read_csv(file_path) + df['Botanical_Name'] = df['Botanical_Name'].str.lower() + plant_name = plant_name.lower() + + new_df = df.copy() + + plant_data = df[df['Botanical_Name'] == plant_name] + + generate_new_columns(new_df, plant_name, plant_data) + + plant_data = new_df[new_df['Botanical_Name'] == plant_name] + package = generate_ideal_package(plant_name, new_df) + + add_deviations_to_package(package, deviations, plant_data) + + for key, value in deviations.items(): + logging.info(f"Ready to send package with parameter {key} as {value}") + + execute_simple_package(id_device, + float(package["temperature"]), + float(package["light"]), + int(package["humidity"]), + int(package["watering"])) diff --git a/src/main.py b/src/main.py index 5feadb8..cdef38a 100644 --- a/src/main.py +++ b/src/main.py @@ -1,71 +1,72 @@ -''' -Leer archivo de configuracion - -Cada N segundos: - - Generar un paquete de datos - - Decidir si el paquete es apto para ser enviado - - Si no es apto, descartar y esperar al siguiente. - - Agregar timestamp y device_id al paquete. - - Enviar paquete a la queue. -''' - -import time -import logging -import json import os -from common.middleware import Middleware - -from data_packet import generate_data, create_packet, data_has_changed - - -def simulate_packets(config): - middleware = Middleware() - queue_name = os.environ.get("QUEUE_NAME") - middleware.create_queue(queue_name) - last_sent_packet = None - current_packet = None - while True: - try: - temperature, humidity, light, watering = generate_data() - current_packet = create_packet(temperature, humidity, light, - watering) - - if not current_packet or not data_has_changed( - current_packet, - last_sent_packet, - config["deviations"] - ): - continue - middleware.send_message(queue_name, json.dumps(current_packet)) - logging.info(f"Packet sent: {current_packet}") - last_sent_packet = current_packet - - except Exception as err: - logging.warning(f"{err}") - finally: - print(current_packet) - time.sleep(config["packet_period"]) - - -def read_config_file(path): - try: - with open(path, 'r') as file: - config_data = json.load(file) - return config_data - except FileNotFoundError: - logging.error(f"Config file not found at: {path}") - raise - except json.JSONDecodeError as json_err: - logging.error(f"Error decoding config file: {json_err}") - raise - - -def main(): - logging_level = os.environ.get("LOGGING_LEVEL") - initialize_log(logging_level) - config_path = "config.json" - config = read_config_file(config_path) - simulate_packets(config) +import typer +import logging +from dotenv import load_dotenv +from simulator import execute_simulator +from simple_package import execute_simple_package +from deviated_generator import Deviated, send_deviated_package +from typing_extensions import Optional + +app = typer.Typer() + + +@app.command("deviated-package") +def deviated_package( + id_device: str = typer.Option(..., "--id-device", "-i"), + plant_name: str = typer.Option(..., "--plant-name", "-pn", + help="Botanical name of the plant"), + deviated_temperature: Deviated = typer.Option(Deviated.IDEAL, + "--deviated-temperature", + "-dt"), + deviated_light: Deviated = typer.Option(Deviated.IDEAL, + "--deviated-light", "-dl"), + deviated_humidity: Deviated = typer.Option(Deviated.IDEAL, + "--deviated-humidity", "-dh"), + deviated_watering: Deviated = typer.Option(Deviated.IDEAL, + "--deviated-watering", "-dw") +): + send_deviated_package(id_device, + plant_name, + {"temperature": deviated_temperature, + "light": deviated_light, + "humidity": deviated_humidity, + "watering": deviated_watering}) + + +@app.command("simulator") +def simulator(id_device: Optional[str] = typer.Option(None, + "--id-device", + "-i")): + execute_simulator(id_device) + + +@app.command("simple-package") +def simple_package( + id_device: str = typer.Option(..., "--id-device", "-i"), + temperature: Optional[float] = typer.Option(None, + "--temperature", + "-t", + help="Temperature in Celsius"), + light: Optional[float] = typer.Option(None, + "--light", + "-l", + help="Light in FT-C"), + humidity: Optional[int] = typer.Option(None, + "--humidity", + "-h", + help="Humidity in percentage"), + watering: Optional[int] = typer.Option(None, + "--watering", + "-w", + help="Watering in percentage") +): + execute_simple_package( + id_device=id_device, + temperature=temperature, + light=light, + humidity=humidity, + watering=watering + ) def initialize_log(logging_level): @@ -80,8 +81,11 @@ def initialize_log(logging_level): level=logging_level, datefmt='%Y-%m-%d %H:%M:%S', ) - logging.getLogger("pika").setLevel(logging.WARNING) + logging.getLogger("simulator").setLevel(logging_level) if __name__ == '__main__': - main() + load_dotenv() + logging_level = os.environ.get("LOGGING_LEVEL", "DEBUG") + initialize_log(logging_level) + app() diff --git a/src/resources/plants_dataset.csv b/src/resources/plants_dataset.csv new file mode 100644 index 0000000..c79740e --- /dev/null +++ b/src/resources/plants_dataset.csv @@ -0,0 +1,23 @@ +Botanical_Name,Common_Name,L,T,H,W,S +Passiflora caerulea,Pasionaria,1,2,1-2,1,2 +Kalanchoe daigremontiana,Aranto,1-2,2,2-3,3,5 +Dracaena fragrans,Palo de Brasil,2-3,2,2,2,2 +Sansevieria trifasciata,Lengua de tigre,2-4,2,3,2-3,5 +Epipremnum aureum,Potus,2-4,2,2,2,2 +Crassula ovata,Árbol de jade,2-3,2,2,2,2 +Kalanchoe blossfeldiana,Kalanchoe,1-2,2,2,2,1 +Portulaca oleracea,Verdolaga,1-2,1-3,2,3,5 +Canna indica,Caña de las indias,1-2,3,1-2,1,1 +Thunbergia alata,Susana de Ojos negros,1,3,1-2,1,1 +Tradescantia zebrina,Zebrina,2-3,2,2,2,2 +Setcreasea pallida,Amor de hombre,1-2,2,2,2,2 +Duranta erecta,Duranta,1-2,3,2,2,1 +Acer negundo,Arce Negundo,1-2,2-3,2,2,2 +Tropaeolum majus,Capuchina,1,3,1,1,3 +Arum italicum,Tragontina,2,2-3,2,2,2 +Lagerstroemia indica,Árbol de Júpiter,1,2-3,1-2,1-2,1 +Monstera deliciosa,Monstera,2-4,2,2,2,2 +Rosa chinensis,Rosa china,1,2,2,2,1 +Coleus scutellarioides,Cóleo,2-3,2,2,2,1 +Haworthia fasciata,Cebra,1-2,2,3,2-3,5 +Echeveria secunda,Tememetla,1-2,2,3,3,5 \ No newline at end of file diff --git a/src/simple_package.py b/src/simple_package.py new file mode 100644 index 0000000..cbf4417 --- /dev/null +++ b/src/simple_package.py @@ -0,0 +1,76 @@ +import os +import time +import json +import logging +from typing import Optional +from common.middleware import Middleware +from data_packet import FTC_TO_LUX + + +def check_percentage_value(value: Optional[int], parameter: str): + if value is None: + return False + + if value < 0 or value > 100: + logging.error(f"{parameter} has to be between 0 and 100. " + + f"Current value: {value}") + return True + + return False + + +def value_is_positive(value: Optional[float], parameter: str): + if value is None: + return False + + if value < 0: + logging.error(f"{parameter} has to be positive or 0. " + + f"Current value: {value}") + return True + + return False + + +def execute_simple_package( + id_device: str, + temperature: Optional[float], + light: Optional[float], + humidity: Optional[int], + watering: Optional[int] +): + logging.info(f"Id_device: {id_device} | Temperature: {temperature} " + f"| Light: {light} | Humidity: {humidity} | " + f"Watering: {watering}") + if not (temperature or humidity or light or watering): + logging.error("All parameters are empty. At least one " + + "parameter is required.") + return + checks = [check_percentage_value(humidity, "humidity"), + check_percentage_value(watering, "watering"), + value_is_positive(temperature, "temperature"), + value_is_positive(light, "light")] + if any(checks): + return + + topic_name = os.environ.get("MQTT_TOPIC", "measurements") + middleware = Middleware() + middleware.connect() + middleware.run() + + package = { + "time_stamp": int(time.time()), + "id_device": id_device + } + + if temperature is not None: + package["temperature"] = temperature + if light is not None: + package["light"] = light * FTC_TO_LUX + if humidity is not None: + package["humidity"] = humidity + if watering is not None: + package["watering"] = watering + + middleware.send_message(topic_name, json.dumps(package)) + + middleware.finish() diff --git a/src/simulator.py b/src/simulator.py new file mode 100644 index 0000000..82f3170 --- /dev/null +++ b/src/simulator.py @@ -0,0 +1,61 @@ +import time +import logging +import json +import os +from typing import Optional +import uuid +from common.middleware import Middleware +from data_packet import generate_data, create_packet, data_has_changed + + +def simulate_packets(config, id_device: str): + middleware = Middleware() + topic_name = os.environ.get("MQTT_TOPIC", "measurements") + middleware.connect() + middleware.run() + last_sent_packet = None + current_packet = None + while True: + try: + temperature, humidity, light, watering = generate_data() + current_packet = create_packet(temperature, humidity, light, + watering, id_device) + + if not current_packet or not data_has_changed( + current_packet, + last_sent_packet, + config["deviations"] + ): + continue + middleware.send_message(topic_name, json.dumps(current_packet)) + logging.info(f"Packet sent: {current_packet}") + last_sent_packet = current_packet + + except Exception as err: + logging.warning(f"{err}") + finally: + print(current_packet) + time.sleep(config["packet_period"]) + + +def read_config_file(path): + try: + with open(path, 'r') as file: + config_data = json.load(file) + return config_data + except FileNotFoundError: + logging.error(f"Config file not found at: {path}") + raise + except json.JSONDecodeError as json_err: + logging.error(f"Error decoding config file: {json_err}") + raise + + +def execute_simulator(id_device: Optional[str]): + if id_device is not None: + logging.info(f"Simulating packets for device: {id_device}") + + config_path = "config.json" + config = read_config_file(config_path) + id_dev = id_device if id_device else str(uuid.uuid4()).replace("-", "") + simulate_packets(config, id_dev)