diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..9b00d58 --- /dev/null +++ b/.env.dist @@ -0,0 +1,5 @@ +RABBITMQ_HOST= +QUEUE_NAME= +LOGGING_LEVEL= +WEATHER_API_KEY= +WEATHER_API_URL= \ No newline at end of file diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml new file mode 100644 index 0000000..a12f030 --- /dev/null +++ b/.github/workflows/linters.yml @@ -0,0 +1,31 @@ + +name: Linters + +on: 'push' + +jobs: + run-linters: + name: Run linters + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: 3.8 + + - name: Install Python dependencies + run: pip install black flake8 + + - name: Run linters + uses: wearerequired/lint-action@v1 + with: + black: true + + - name: flake8 Lint + uses: py-actions/flake8@v1.2.0 + with: + max-line-length: "88" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..35b8645 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.9.7-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src src + +CMD ["python", "src/main.py"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2a6b8a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +default: docker-compose-up + +all: + +docker-image: + docker build -f Dockerfile -t main-app . +.PHONY: docker-image + +docker-compose-up: docker-image + docker-compose -f docker-compose.yaml up -d --build +.PHONY: docker-compose-up + +docker-compose-down: + docker-compose -f docker-compose.yaml stop -t 20 + docker-compose -f docker-compose.yaml down --remove-orphans +.PHONY: docker-compose-down + +docker-compose-logs: + docker-compose -f docker-compose.yaml logs -f +.PHONY: docker-compose-logs \ No newline at end of file diff --git a/README.md b/README.md index 72bf8c0..77ef73f 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,17 @@ 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 +The repository includes a **Makefile** that encapsulates various commands used frequently in the project as targets. The targets are executed by invoking: + +* **make \**: +The essential targets to start and stop the system are **docker-compose-up** and **docker-compose-down**, with the remaining targets being useful for debugging and troubleshooting. + +Available targets are: +* **docker-compose-up**: Initializes the development environment (builds docker images for the server and client, initializes the network used by docker, etc.) and starts the containers of the applications that make up the project. +* **docker-compose-down**: Performs a `docker-compose stop` to stop the containers associated with the compose and then performs a `docker-compose down` to destroy all resources associated with the initialized project. It is recommended to execute this command at the end of each run to prevent the host machine's disk from filling up. +* **docker-compose-logs**: Allows viewing the current logs of the project. Use with `grep` to filter messages from a specific application within the compose. +* **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). diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..0d1b974 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,16 @@ +version: '3.9' + +services: + main-app: + build: + context: . + dockerfile: Dockerfile + image: main-app + env_file: + - .env + networks: + - common_network + +networks: + common_network: + external: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2cb404a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pika +requests +python-dotenv \ No newline at end of file diff --git a/src/common/middleware.py b/src/common/middleware.py new file mode 100644 index 0000000..1a48914 --- /dev/null +++ b/src/common/middleware.py @@ -0,0 +1,52 @@ +import pika +import os + + +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) + + def __del__(self): + self._connection.close() diff --git a/src/data_packet.py b/src/data_packet.py index 6940631..229ab54 100644 --- a/src/data_packet.py +++ b/src/data_packet.py @@ -1,84 +1,167 @@ -import random as rnd +import requests as req +from requests import Response +from dotenv import load_dotenv +from os import environ +from typing import Tuple +from datetime import datetime +import logging +import math +import uuid + +load_dotenv() +logging.getLogger("urllib3").setLevel(logging.WARNING) +UUID = str(uuid.uuid4()).replace("-", "") + + +def fetch_temperature_and_humidity(location: str) -> Tuple[int, int]: + + base_params = { + "q": location, + "APPID": environ['WEATHER_API_KEY'] + } + res: Response = req.get(environ["WEATHER_API_URL"], params=base_params) + + if not res.ok: + raise Exception( + "Could not fetch temperature and humidity from weather API" + ) + + result = res.json() + temperature = int(result["main"]["temp"] - 273.15) + humidity = result["main"]["humidity"] + + return temperature, humidity + + +def get_decimal_hour(): + current_hour = datetime.now() + return current_hour.hour + ( + 60 * current_hour.minute + current_hour.second + ) / 3600 + + +def solar_irradiation_simulator(x): + if x < 7 or x > 19: + return 0 + + x += 14 + x *= (math.pi / 6) + x = math.sin(x) + x += 1 + x *= 500 + return x -''' -Creates a data packet with simulated data, validating the data before that. -If all the parameters are empty, returns None. Else, return a dictionary with the data. +def fetch_solar_irradiation(): + x = get_decimal_hour() + return round(solar_irradiation_simulator(x)) -Constraints: -- Humidity and Watering should be between 0 and 100, since they are percentages. -- Light has to be positive or 0. -''' +def watering_simulator(x): + x %= 6 + x /= 6 + x *= 100 + x = 100 - x + return x -def create_packet(temperature: float = None, humidity: float = None, light: float = None, watering: float = None): - if not (temperature and humidity and light and watering): +def fetch_watering(): + x = get_decimal_hour() + return round(watering_simulator(x)) + + +def create_packet(temperature: float = None, humidity: float = None, + light: float = None, watering: float = None): + ''' + Creates a data packet with simulated data, validating the data before that. + + If all the parameters are empty, returns None. Else, return a dictionary + with the data. + + + Constraints: + - Humidity and Watering should be between 0 and 100, since they are + percentages. + - Light has to be positive or 0. + ''' + if not (temperature or humidity or light or watering): return None if humidity < 0 or humidity > 100: - raise Exception(f"Humidity has to be between 0 and 100. Current value: {humidity}") + raise Exception(f"Humidity has to be between 0 and 100." + f"Current value: {humidity}") if watering < 0 or watering > 100: - raise Exception(f"Watering has to be between 0 and 100. Current value: {watering}") + raise Exception(f"Watering has to be between 0 and 100. " + f"Current value: {watering}") if light < 0: - raise Exception(f"Light has to be positive or 0. Current value: {light}") + raise Exception(f"Light has to be positive or 0. " + f"Current value: {light}") return { "temperature": temperature, "humidity": humidity, "light": light, - "watering": watering + "watering": watering, + "time_stamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "id_device": UUID } -''' -Generates the parameters data: temperature, humidity, light and watering. +def generate_data(location="Pilar, AR") -> Tuple[int, int, int, int]: + ''' + Generates the parameters data: temperature, humidity, light and watering. -[Describe the criteria of every parameter here] -- -- -- -''' + [Describe the criteria of every parameter here] + - + - + - + ''' - -def generate_data(): - # TODO - temperature = rnd.randint(-5, 30) - humidity = rnd.randint(0, 100) - light = rnd.randint(0, 400) - watering = rnd.randint(0, 100) + temperature, humidity = fetch_temperature_and_humidity(location) + # esto no va a producir cambios instantaneamente, pero es para probar + light = fetch_solar_irradiation() + watering = fetch_watering() return temperature, humidity, light, watering -''' -Compares the current packet and the last sent packet, based in the deviations. - -If any parameter differs enough from the last sent packet, then the packet -must be sent. +def data_has_changed(current, last_sent, deviations): + ''' + Compares the current packet and the last sent packet, + based in the deviations. -In other words: if (|current[parameter] - last_sent[parameter]| > deviations[parameter]), return True. -Else, return False + If any parameter differs enough from the last sent packet, then the packet + must be sent. -If there is no last_sent, then the current must be sent. + In other words: + if (|current[parameter] - last_sent[parameter]| > deviations[parameter]), + return True. + Else, return False -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} -''' + 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} + ''' -def data_has_changed(current, last_sent, deviations): if not last_sent: return True - if parameter_has_changed(current["temperature"], last_sent["temperature"], deviations["temperature"])\ - 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"], deviations["watering"]): + if parameter_has_changed(current["temperature"], last_sent["temperature"], + deviations["temperature"])\ + 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"], + deviations["watering"]): return True return False diff --git a/src/main.py b/src/main.py index e08abe8..5feadb8 100644 --- a/src/main.py +++ b/src/main.py @@ -10,31 +10,43 @@ ''' import time -import json 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) + current_packet = create_packet(temperature, humidity, light, + watering) - if not current_packet or not data_has_changed(current_packet, last_sent_packet, config["deviations"]): + if not current_packet or not data_has_changed( + current_packet, + last_sent_packet, + config["deviations"] + ): continue - - # TODO: Send packet to the RabbitMQ queue + 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(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: @@ -48,9 +60,28 @@ def read_config_file(path): raise -if __name__ == '__main__': - logging.basicConfig(level=logging.INFO) +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) + + +def initialize_log(logging_level): + """ + Python custom logging initialization + + Current timestamp is added to be able to identify in docker + compose logs the date when the log has arrived + """ + logging.basicConfig( + format='%(asctime)s %(levelname)-8s %(message)s', + level=logging_level, + datefmt='%Y-%m-%d %H:%M:%S', + ) + logging.getLogger("pika").setLevel(logging.WARNING) + + +if __name__ == '__main__': + main() diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..1d36346 --- /dev/null +++ b/tox.ini @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 88 \ No newline at end of file