diff --git a/IOTDeviceScript/IOTDeviceScript.ino b/IOTDeviceScript/IOTDeviceScript.ino new file mode 100644 index 00000000..8435a61e --- /dev/null +++ b/IOTDeviceScript/IOTDeviceScript.ino @@ -0,0 +1,410 @@ +#include +#include +#include +#include +#include +#include + +// ===================== +// DEFINICIONES +// ===================== + +#define DHTPIN 2 +#define DHTTYPE DHT11 + +#define MEASURE_INTERVAL 2 +#define ALERT_DURATION 60 +#define LED_PIN 12 // D6 en NodeMCU (LED externo en protoboard: D6 y GND) +#define LED_EVENT_DURATION 60 // segundos que parpadea el LED +#define LED_BLINK_MS 400 // intervalo de parpadeo en ms + +// ===================== +// DECLARACIONES +// ===================== + +// OLED SH1106 128x64 I2C (sin pin reset) +U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE); + +// Sensor DHT +DHT dht(DHTPIN, DHTTYPE); + +// Cliente WiFi / MQTT +WiFiClient net; +PubSubClient client(net); + +// ===================== +// VARIABLES A EDITAR +// ===================== + +// WiFi +const char ssid[] = "FAMILIA FRANCO"; +const char pass[] = "1014290398"; + +// Mosquitto +#define USER "ironman" +const char MQTT_HOST[] = "18.207.213.158"; +const int MQTT_PORT = 8082; +const char MQTT_USER[] = USER; +const char MQTT_PASS[] = "jarvis123"; + +// Topics +const char MQTT_TOPIC_PUB[] = "colombia/cundinamarca/bogota/" USER "/out"; +const char MQTT_TOPIC_SUB[] = "colombia/cundinamarca/bogota/" USER "/in"; + +// ===================== +// GLOBALES +// ===================== + +time_t now; + +unsigned long measureTime = 0; +unsigned long alertTime = 0; + +String alert = ""; +float temp = NAN; +float humi = NAN; + +// Evento LED (parpadeo + mensaje en OLED) +bool ledEventActive = false; +unsigned long ledEventStartTime = 0; +unsigned long ledBlinkLastToggle = 0; +bool ledBlinkState = false; + +// ===================== +// MQTT CONNECT +// ===================== + +void mqtt_connect() { + while (!client.connected()) { + Serial.print("MQTT connecting ... "); + + if (client.connect(MQTT_USER, MQTT_USER, MQTT_PASS)) { + Serial.println("connected."); + client.subscribe(MQTT_TOPIC_SUB); + Serial.print("Subscrito a: "); + Serial.println(MQTT_TOPIC_SUB); + } else { + Serial.println("Problema con la conexión, revise los valores de las constantes MQTT"); + int state = client.state(); + Serial.print("Código de error = "); + alert = "MQTT error: " + String(state); + Serial.println(state); + + if (client.state() == MQTT_CONNECT_UNAUTHORIZED) { + ESP.deepSleep(0); + } + delay(5000); + } + } +} + +void sendSensorData(float temperatura, float humedad) { + String data = "{"; + data += "\"temperatura\": " + String(temperatura, 1) + ", "; + data += "\"humedad\": " + String(humedad, 1); + data += "}"; + + char payload[data.length() + 1]; + data.toCharArray(payload, data.length() + 1); + + client.publish(MQTT_TOPIC_PUB, payload); +} + +// ===================== +// SENSOR DHT +// ===================== + +float readTemperatura() { + float t = dht.readTemperature(); + return t; +} + +float readHumedad() { + float h = dht.readHumidity(); + return h; +} + +bool checkMeasures(float t, float h) { + if (isnan(t) || isnan(h)) { + Serial.println("Error obteniendo los datos del sensor DHT11"); + return false; + } + return true; +} + +// ===================== +// DISPLAY +// ===================== + +void startDisplay() { + // ESP8266 I2C: SDA=D2 (GPIO4), SCL=D1 (GPIO5) + Wire.begin(D2, D1); + Wire.setClock(100000); + + u8g2.begin(); +} + +void displayNoSignal() { + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_ncenB08_tr); + u8g2.drawStr(10, 18, "No hay senal"); + u8g2.drawStr(10, 34, "WiFi/MQTT..."); + u8g2.sendBuffer(); +} + +void displayConnecting(const char* ssidName) { + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_6x12_tf); + u8g2.drawStr(0, 14, "Connecting to:"); + u8g2.drawStr(0, 30, ssidName); + u8g2.sendBuffer(); +} + +String getHourString() { + long long int milli = now + millis() / 1000; + struct tm* tinfo = localtime(&milli); + String hour = String(asctime(tinfo)).substring(11, 19); + return hour; +} + +void renderScreen(const String& message) { + u8g2.clearBuffer(); + + // Header + u8g2.setFont(u8g2_font_6x12_tf); + String header = "IOT Sensors " + getHourString(); + u8g2.drawStr(0, 12, header.c_str()); + u8g2.drawHLine(0, 14, 128); + + // Medidas + u8g2.setFont(u8g2_font_6x12_tf); + + char line1[32]; + char line2[32]; + + // Formato con 1 decimal, evita strings gigantes + if (!isnan(temp)) snprintf(line1, sizeof(line1), "T: %.1f C", temp); + else snprintf(line1, sizeof(line1), "T: --.- C"); + + if (!isnan(humi)) snprintf(line2, sizeof(line2), "H: %.1f %%", humi); + else snprintf(line2, sizeof(line2), "H: --.- %%"); + + u8g2.drawStr(0, 30, line1); + u8g2.drawStr(0, 42, line2); + + // Mensaje / alerta + u8g2.drawStr(0, 54, "Msg:"); + u8g2.setFont(u8g2_font_6x10_tf); + + if (message == "OK") { + u8g2.drawStr(40, 54, "OK"); + } else { + // recorta a lo que cabe (pantalla 128; "Evento: LED activado" = 21) + String msg = message; + if (msg.length() > 21) msg = msg.substring(0, 21); + u8g2.drawStr(40, 54, msg.c_str()); + } + + u8g2.sendBuffer(); +} + +// ===================== +// ALERTAS MQTT +// ===================== + +String checkAlert() { + String message = "OK"; + if (alert.length() != 0) { + message = alert; + if ((millis() - alertTime) >= (unsigned long)ALERT_DURATION * 1000UL) { + alert = ""; + alertTime = millis(); + } + } + return message; +} + +// Actualiza parpadeo del LED por evento y devuelve mensaje para OLED +String updateLedEventAndMessage() { + if (!ledEventActive) return checkAlert(); + + unsigned long elapsed = millis() - ledEventStartTime; + if (elapsed >= (unsigned long)LED_EVENT_DURATION * 1000UL) { + ledEventActive = false; + digitalWrite(LED_PIN, LOW); // LED off (D6: HIGH = on, LOW = off) + return checkAlert(); + } + + if (millis() - ledBlinkLastToggle >= (unsigned long)LED_BLINK_MS) { + ledBlinkLastToggle = millis(); + ledBlinkState = !ledBlinkState; + digitalWrite(LED_PIN, ledBlinkState ? HIGH : LOW); // D6: HIGH = encendido + } + return "Evento: LED activado"; +} + +void receivedCallback(char* topic, byte* payload, unsigned int length) { + Serial.println(""); + Serial.println(">>> MENSAJE MQTT RECIBIDO <<<"); + Serial.print("Topic: "); + Serial.println(topic); + Serial.print("Payload: "); + + String data = ""; + for (unsigned int i = 0; i < length; i++) { + data += (char)payload[i]; + } + Serial.println(data); + Serial.println("================================"); + Serial.println(""); + + if (data.indexOf("ALERT") >= 0) { + alert = data; + alertTime = millis(); // importante: marca inicio de la alerta + } + + if (data.indexOf("LED_ON") >= 0) { + Serial.println(""); + Serial.println("*** EVENTO LED DETECTADO: temperatura_promedio > umbral ***"); + Serial.println(" -> LED parpadeando y OLED: Evento: LED activado"); + Serial.println(""); + ledEventActive = true; + ledEventStartTime = millis(); + ledBlinkLastToggle = millis(); + ledBlinkState = false; + digitalWrite(LED_PIN, LOW); // apagado al inicio (D6: LOW = off) + } + +} + +// ===================== +// WIFI +// ===================== + +void checkWiFi() { + if (WiFi.status() != WL_CONNECTED) { + Serial.print("Checking wifi"); + while (WiFi.waitForConnectResult() != WL_CONNECTED) { + WiFi.begin(ssid, pass); + Serial.print("."); + displayNoSignal(); + delay(300); + } + Serial.println("connected"); + } else { + if (!client.connected()) { + mqtt_connect(); + } else { + client.loop(); + } + } +} + +void listWiFiNetworks() { + int numberOfNetworks = WiFi.scanNetworks(); + Serial.println("\nNumber of networks: "); + Serial.println(numberOfNetworks); + for (int i = 0; i < numberOfNetworks; i++) { + Serial.println(WiFi.SSID(i)); + } +} + +void startWiFi() { + WiFi.hostname(USER); + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, pass); + + Serial.println("\nAttempting to connect to SSID: "); + Serial.println(ssid); + + while (WiFi.status() != WL_CONNECTED) { + Serial.print("."); + delay(1000); + } + Serial.println("\nconnected!"); +} + +// ===================== +// TIME +// ===================== + +void setTime() { + Serial.print("Setting time using SNTP"); + configTime(-5 * 3600, 0, "pool.ntp.org", "time.nist.gov"); + + now = time(nullptr); + while (now < 1510592825) { + delay(500); + Serial.print("."); + now = time(nullptr); + } + Serial.println("done!"); + + struct tm timeinfo; + gmtime_r(&now, &timeinfo); + Serial.print("Current time: "); + Serial.println(asctime(&timeinfo)); +} + +// ===================== +// MQTT +// ===================== + +void configureMQTT() { + client.setServer(MQTT_HOST, MQTT_PORT); + client.setCallback(receivedCallback); + mqtt_connect(); +} + +// ===================== +// MEASURES +// ===================== + +void measure() { + if ((millis() - measureTime) >= (unsigned long)MEASURE_INTERVAL * 1000UL) { + measureTime = millis(); + + float t = readTemperatura(); + float h = readHumedad(); + + if (checkMeasures(t, h)) { + temp = t; + humi = h; + sendSensorData(temp, humi); + } + } +} + +// ===================== +// ARDUINO +// ===================== + +void setup() { + Serial.begin(115200); + + listWiFiNetworks(); + + startDisplay(); + displayConnecting(ssid); + + startWiFi(); + + dht.begin(); + + setTime(); + + configureMQTT(); + + pinMode(LED_PIN, OUTPUT); + digitalWrite(LED_PIN, LOW); // LED apagado al inicio (D6) + + measureTime = millis(); + alertTime = millis(); +} + +void loop() { + checkWiFi(); + String message = updateLedEventAndMessage(); + measure(); + renderScreen(message); +} diff --git a/IOTMonitoringServer/settings.py b/IOTMonitoringServer/settings.py index 114ccc2f..264220c7 100644 --- a/IOTMonitoringServer/settings.py +++ b/IOTMonitoringServer/settings.py @@ -27,7 +27,13 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ["localhost", "ip.maquina.visualizador"] +ALLOWED_HOSTS = [ + "localhost", + "44.201.127.40", + "ec2-44-201-127-40.compute-1.amazonaws.com", + "ip-10-0-18-78.ec2.internal", + ".compute-1.amazonaws.com", # cualquier EC2 en esa región por hostname +] # Application definition @@ -96,7 +102,7 @@ "NAME": "iot_data", # Nombre de la base de datos "USER": "dbadmin", # Nombre de usuario "PASSWORD": "uniandesIOT1234*", # Contraseña - "HOST": "ip.maquina.db", # Dirección IP de la base de datos + "HOST": "44.203.71.44", # Dirección IP de la base de datos "PORT": "", # Puerto de la base de datos } } @@ -156,7 +162,7 @@ CRISPY_TEMPLATE_PACK = 'bootstrap4' # Dirección del bróker MQTT -MQTT_HOST = "ip.maquina.mqtt" +MQTT_HOST = "44.201.151.218" # Puerto del bróker MQTT MQTT_PORT = 8082 diff --git a/README.md b/README.md new file mode 100644 index 00000000..c620d618 --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# Reto: Procesamiento de eventos con consulta a BD y actuador + +**Repositorio:** https://github.com/HectorFranco-MISO/IOTMonitoringServer + +--- + +## 1. Objetivo del reto + +Se modificó el código del tutorial de la capa de aplicación (lógica) para agregar el **procesamiento de un nuevo evento** que cumple: + +- **Condición:** con pre-requisito de **consulta a la base de datos** (temperatura promedio por estación en la última hora). +- **Acción:** ejecutada por un **actuador del dispositivo IoT** (LED en protoboard + mensaje en pantalla OLED). + +--- + +## 2. Descripción del nuevo evento + +| Aspecto | Descripción | +|--------|-------------| +| **Condición** | Si la **temperatura promedio** (última hora, por estación) es **mayor a 22 °C**. El valor de temperatura promedio se obtiene mediante una consulta a la base de datos. | +| **Acción** | El servidor envía el comando `LED_ON` por MQTT al dispositivo. El dispositivo **parpadea un LED** conectado en D6 y muestra en la **OLED** el mensaje *"Evento: LED activado"* durante 60 segundos. | +| **Frecuencia** | La evaluación se ejecuta cada 2 minutos (junto con el resto del servicio de control). | + +--- + +## 3. Modificaciones realizadas + +### 3.1 Servidor (capa de aplicación) — `control/monitor.py` + +Se agregó un **nuevo evento** independiente del de alertas ya existente: + +1. **Umbral y función del evento LED** + +La condición usa un umbral de temperatura (en °C) y una función que consulta la BD y publica `LED_ON` cuando se cumple la condición: + +```python +# Umbral de temperatura (ºC) para activar el evento LED +LED_EVENT_TEMP_THRESHOLD = 22.0 + +def evaluate_led_event(): + """ + Nuevo evento: si temperatura_promedio (última hora, por estación) > umbral, + se envía LED_ON al dispositivo. La temperatura_promedio se obtiene por consulta a la BD. + Acción: el dispositivo parpadea el LED y muestra "Evento: LED activado" en la OLED. + """ + # ... +``` + +2. **Consulta a la base de datos (pre-requisito de la condición)** + +La **temperatura promedio** se obtiene con una consulta al modelo `Data`, filtrando por la última hora y por la medición `temperatura`, agrupando por estación y calculando el promedio de `avg_value`: + +```python + # Consulta a la BD: promedio de temperatura por estación en la última hora + data = Data.objects.filter( + base_time__gte=timezone.now() - timedelta(hours=1), + measurement__name='temperatura' + ) + aggregation = data.values( + 'station__user__username', + 'station__location__city__name', + 'station__location__state__name', + 'station__location__country__name' + ).annotate(temperatura_promedio=Avg('avg_value')) +``` + +3. **Evaluación de la condición y envío de la acción** + +Se recorre la agregación y, para cada estación cuyo `temperatura_promedio` supere el umbral, se publica `LED_ON` en el tópico MQTT de entrada del dispositivo: + +```python + for item in aggregation: + temp_prom = item.get('temperatura_promedio') + if temp_prom is not None and temp_prom > LED_EVENT_TEMP_THRESHOLD: + # ... arma topic desde item ... + if mqtt_connected: + client.publish(topic, 'LED_ON') + # ... +``` + +4. **Programación en el cron** + +La función del nuevo evento se ejecuta periódicamente junto con el análisis de alertas: + +```python + schedule.every(2).minutes.do(analyze_data) + schedule.every(2).minutes.do(evaluate_led_event) +``` + +--- + +### 3.2 Dispositivo (sketch) — `IOTDeviceScript.ino` + +1. **Definiciones para el actuador LED y duración del evento** + +```cpp +#define LED_PIN 12 // D6 en NodeMCU (LED externo en protoboard: D6 y GND) +#define LED_EVENT_DURATION 60 // segundos que parpadea el LED +#define LED_BLINK_MS 400 // intervalo de parpadeo en ms +``` + +2. **Variables de estado del evento LED** + +```cpp +// Evento LED (parpadeo + mensaje en OLED) +bool ledEventActive = false; +unsigned long ledEventStartTime = 0; +unsigned long ledBlinkLastToggle = 0; +bool ledBlinkState = false; +``` + +3. **Recepción del comando MQTT y activación del evento** + +En el callback de MQTT, si el mensaje contiene `LED_ON`, se activa el evento (parpadeo + mensaje en OLED): + +```cpp + if (data.indexOf("LED_ON") >= 0) { + Serial.println("*** EVENTO LED DETECTADO: temperatura_promedio > umbral ***"); + ledEventActive = true; + ledEventStartTime = millis(); + ledBlinkLastToggle = millis(); + ledBlinkState = false; + digitalWrite(LED_PIN, LOW); // apagado al inicio (D6: LOW = off) + } +``` + +4. **Lógica del parpadeo y mensaje para la OLED** + +En cada iteración del `loop`, se actualiza el estado del LED y se elige el mensaje que se envía a la pantalla; mientras el evento está activo se devuelve *"Evento: LED activado"* y se hace parpadear el LED: + +```cpp +// Actualiza parpadeo del LED por evento y devuelve mensaje para OLED +String updateLedEventAndMessage() { + if (!ledEventActive) return checkAlert(); + + unsigned long elapsed = millis() - ledEventStartTime; + if (elapsed >= (unsigned long)LED_EVENT_DURATION * 1000UL) { + ledEventActive = false; + digitalWrite(LED_PIN, LOW); + return checkAlert(); + } + + if (millis() - ledBlinkLastToggle >= (unsigned long)LED_BLINK_MS) { + ledBlinkLastToggle = millis(); + ledBlinkState = !ledBlinkState; + digitalWrite(LED_PIN, ledBlinkState ? HIGH : LOW); // D6: HIGH = encendido + } + return "Evento: LED activado"; +} +``` + +5. **Uso en `setup` y `loop`** + +En `setup` se configura el pin del LED como salida y estado inicial: + +```cpp + pinMode(LED_PIN, OUTPUT); + digitalWrite(LED_PIN, LOW); // LED apagado al inicio (D6) +``` + +En `loop` se usa el mensaje devuelto por `updateLedEventAndMessage()` para actualizar la pantalla: + +```cpp +void loop() { + checkWiFi(); + String message = updateLedEventAndMessage(); + measure(); + renderScreen(message); +} +``` + +--- + +## 4. Configuración física (NodeMCU, protoboard y actuadores) + +--- + +### 4.1 Conexiones utilizadas + +| Componente | Conexión en NodeMCU | +|------------|---------------------| +| DHT11 | GND, 3V3, D4 (datos) | +| OLED | D1 (SCL), D2 (SDA), 3V3, GND | +| LED | D6 (ánodo con resistencia ~220Ω) y GND | + diff --git a/control/management/commands/check_led_event.py b/control/management/commands/check_led_event.py new file mode 100644 index 00000000..a3cecc84 --- /dev/null +++ b/control/management/commands/check_led_event.py @@ -0,0 +1,135 @@ +""" +Comando para verificar si hay datos de temperatura y si se dispararía el evento LED. +Uso: python manage.py check_led_event + python manage.py check_led_event --send # envía LED_ON si temp > umbral + python manage.py check_led_event --send --force # envía LED_ON sin importar la temperatura (prueba) + +El comando usa un client_id distinto al de start_control para no desconectar el broker. +""" +import time +import paho.mqtt.client as mqtt +from django.conf import settings +from django.core.management.base import BaseCommand +from django.db.models import Avg +from datetime import timedelta, datetime +from django.utils import timezone +from receiver.models import Data, Station + +from control.monitor import LED_EVENT_TEMP_THRESHOLD, evaluate_led_event + + +def send_led_on_to_topics(topics): + """ + Envía LED_ON a los tópicos usando un cliente MQTT propio (client_id distinto a start_control). + Así no se desconecta el proceso start_control que ya está corriendo. + """ + client_id = settings.MQTT_USER_PUB + "_check_led" + c = mqtt.Client(client_id=client_id) + c.username_pw_set(settings.MQTT_USER_PUB, settings.MQTT_PASSWORD_PUB) + c.connect(settings.MQTT_HOST, settings.MQTT_PORT, keepalive=60) + c.loop_start() + time.sleep(1) # dar tiempo a conectar + for topic in topics: + c.publish(topic, 'LED_ON') + time.sleep(0.5) + c.loop_stop() + c.disconnect() + + +def get_topics_from_db(): + """Obtiene los tópicos in de todas las estaciones (para enviar LED_ON).""" + stations = Station.objects.select_related( + 'user', 'location', 'location__city', 'location__state', 'location__country' + ).filter(active=True) + topics = [] + for s in stations: + topic = '{}/{}/{}/{}/in'.format( + s.location.country.name, + s.location.state.name, + s.location.city.name, + s.user.username, + ) + topics.append(topic) + return topics + + +class Command(BaseCommand): + help = 'Verifica datos de temperatura y opcionalmente envía LED_ON (--send). Use --send --force para probar sin esperar 22°C.' + + def add_arguments(self, parser): + parser.add_argument( + '--send', + action='store_true', + help='Enviar LED_ON (si temp > umbral o si usa --force)', + ) + parser.add_argument( + '--force', + action='store_true', + help='Enviar LED_ON a todas las estaciones sin comprobar temperatura (solo para prueba)', + ) + + def handle(self, *args, **options): + data = Data.objects.filter( + base_time__gte=timezone.now() - timedelta(hours=1), + measurement__name='temperatura' + ) + aggregation = data.values( + 'station__user__username', + 'station__location__city__name', + 'station__location__state__name', + 'station__location__country__name' + ).annotate(temperatura_promedio=Avg('avg_value')) + + if not aggregation and not options['force']: + self.stdout.write( + self.style.WARNING( + 'No hay datos de temperatura en la última hora. ' + '¿El receptor (start_mqtt) está corriendo y el dispositivo publicando?' + ) + ) + if options['send'] and options['force']: + topics = get_topics_from_db() + if not topics: + self.stdout.write(self.style.WARNING('No hay estaciones en la BD. Publique algo desde el dispositivo primero.')) + return + self.stdout.write('Enviando LED_ON (--force) a: ' + ', '.join(topics)) + send_led_on_to_topics(topics) + self.stdout.write(self.style.SUCCESS('LED_ON enviado. Revisa el NodeMCU.')) + return + + if aggregation: + self.stdout.write('Umbral para LED: {} °C\n'.format(LED_EVENT_TEMP_THRESHOLD)) + for item in aggregation: + temp = item.get('temperatura_promedio') + topic = '{}/{}/{}/{}/in'.format( + item['station__location__country__name'], + item['station__location__state__name'], + item['station__location__city__name'], + item['station__user__username'], + ) + dispara = temp is not None and temp > LED_EVENT_TEMP_THRESHOLD + self.stdout.write( + ' {} | temp_prom = {:.1f} °C | ¿Dispara LED? {}'.format( + topic, temp or 0, 'Sí' if dispara else 'No' + ) + ) + + if options['send']: + self.stdout.write('') + if options['force']: + topics = get_topics_from_db() if not aggregation else [ + '{}/{}/{}/{}/in'.format( + item['station__location__country__name'], + item['station__location__state__name'], + item['station__location__city__name'], + item['station__user__username'], + ) + for item in aggregation + ] + send_led_on_to_topics(topics) + self.stdout.write(self.style.SUCCESS('LED_ON enviado (--force) a {} tópico(s). Revisa el NodeMCU.'.format(len(topics)))) + else: + from control import monitor + monitor.setup_mqtt() + evaluate_led_event() + self.stdout.write(self.style.SUCCESS('Listo. Si había temp > umbral, se envió LED_ON. Revisa el NodeMCU (Serial/OLED/LED).')) diff --git a/control/monitor.py b/control/monitor.py index 43d7af0c..b81cd8b5 100644 --- a/control/monitor.py +++ b/control/monitor.py @@ -2,13 +2,16 @@ import ssl from django.db.models import Avg from datetime import timedelta, datetime +from django.utils import timezone from receiver.models import Data, Measurement import paho.mqtt.client as mqtt import schedule import time from django.conf import settings -client = mqtt.Client(settings.MQTT_USER_PUB) +# Client ID único para no chocar con el receptor (admin/admin2 en el broker) +client = mqtt.Client(client_id=settings.MQTT_USER_PUB + "_control") +mqtt_connected = False def analyze_data(): @@ -19,7 +22,7 @@ def analyze_data(): print("Calculando alertas...") data = Data.objects.filter( - base_time__gte=datetime.now() - timedelta(hours=1)) + base_time__gte=timezone.now() - timedelta(hours=1)) aggregation = data.annotate(check_value=Avg('avg_value')) \ .select_related('station', 'measurement') \ .select_related('station__user', 'station__location') \ @@ -51,40 +54,104 @@ def analyze_data(): if alert: message = "ALERT {} {} {}".format(variable, min_value, max_value) topic = '{}/{}/{}/{}/in'.format(country, state, city, user) - print(datetime.now(), "Sending alert to {} {}".format(topic, variable)) - client.publish(topic, message) + if not mqtt_connected: + try: + client.reconnect() + time.sleep(1) + except Exception as e: + print("Reconnect falló:", e) + if mqtt_connected: + client.publish(topic, message) + print(timezone.now(), "Sending alert to {} {}".format(topic, variable)) + else: + print(timezone.now(), "NO enviado (desconectado): alert a", topic) alerts += 1 print(len(aggregation), "dispositivos revisados") print(alerts, "alertas enviadas") +# Umbral de temperatura (ºC) para activar el evento LED +LED_EVENT_TEMP_THRESHOLD = 22.0 + + +def evaluate_led_event(): + """ + Nuevo evento: si temperatura_promedio (última hora, por estación) > umbral, + se envía LED_ON al dispositivo. La temperatura_promedio se obtiene por consulta a la BD. + Acción: el dispositivo parpadea el LED y muestra "Evento: LED activado" en la OLED. + """ + print("Evaluando evento LED (temperatura_promedio > {} °C)...".format(LED_EVENT_TEMP_THRESHOLD)) + + # Consulta a la BD: promedio de temperatura por estación en la última hora + data = Data.objects.filter( + base_time__gte=timezone.now() - timedelta(hours=1), + measurement__name='temperatura' + ) + aggregation = data.values( + 'station__user__username', + 'station__location__city__name', + 'station__location__state__name', + 'station__location__country__name' + ).annotate(temperatura_promedio=Avg('avg_value')) + + sent = 0 + for item in aggregation: + temp_prom = item.get('temperatura_promedio') + if temp_prom is not None and temp_prom > LED_EVENT_TEMP_THRESHOLD: + country = item['station__location__country__name'] + state = item['station__location__state__name'] + city = item['station__location__city__name'] + user = item['station__user__username'] + topic = '{}/{}/{}/{}/in'.format(country, state, city, user) + if not mqtt_connected: + try: + client.reconnect() + time.sleep(1) + except Exception as e: + print("Reconnect falló:", e) + if mqtt_connected: + client.publish(topic, 'LED_ON') + print(timezone.now(), "LED_ON enviado a", topic, "(temperatura_promedio = {:.1f} °C)".format(temp_prom)) + sent += 1 + else: + print(timezone.now(), "NO enviado LED_ON (desconectado):", topic) + + print(sent, "comandos LED_ON enviados") + + def on_connect(client, userdata, flags, rc): ''' Función que se ejecuta cuando se conecta al bróker. ''' - print("Conectando al broker MQTT...", mqtt.connack_string(rc)) + global mqtt_connected + if rc == 0: + mqtt_connected = True + print("Conectado al broker MQTT correctamente.") + else: + mqtt_connected = False + print("Error al conectar al broker MQTT:", mqtt.connack_string(rc)) def on_disconnect(client: mqtt.Client, userdata, rc): ''' Función que se ejecuta cuando se desconecta del broker. - Intenta reconectar al bróker. ''' - print("Desconectado con mensaje:" + str(mqtt.connack_string(rc))) - print("Reconectando...") - client.reconnect() + global mqtt_connected + mqtt_connected = False + print("Desconectado del broker:", mqtt.connack_string(rc)) def setup_mqtt(): ''' - Configura el cliente MQTT para conectarse al broker. + Configura el cliente MQTT y se conecta al broker. Usa loop_start() para + mantener la conexión en un hilo en segundo plano. ''' - + global client, mqtt_connected + mqtt_connected = False print("Iniciando cliente MQTT...", settings.MQTT_HOST, settings.MQTT_PORT) - global client try: - client = mqtt.Client(settings.MQTT_USER_PUB) + client = mqtt.Client(client_id=settings.MQTT_USER_PUB + "_control") client.on_connect = on_connect client.on_disconnect = on_disconnect @@ -94,19 +161,34 @@ def setup_mqtt(): client.username_pw_set(settings.MQTT_USER_PUB, settings.MQTT_PASSWORD_PUB) - client.connect(settings.MQTT_HOST, settings.MQTT_PORT) - + client.connect(settings.MQTT_HOST, settings.MQTT_PORT, keepalive=60) + # Mantener la conexión en un hilo (importante: sin esto la conexión se pierde) + client.loop_start() + # Dar tiempo a que on_connect se ejecute + for _ in range(20): + if mqtt_connected: + break + time.sleep(0.5) + if not mqtt_connected: + print("Aviso: conexión MQTT aún no confirmada. ¿El broker acepta usuario", settings.MQTT_USER_PUB, "? ¿La EC2 puede alcanzar", settings.MQTT_HOST, ":", settings.MQTT_PORT, "?") except Exception as e: - print('Ocurrió un error al conectar con el bróker MQTT:', e) + print('Error al conectar con el bróker MQTT:', e) def start_cron(): ''' - Inicia el cron que se encarga de ejecutar la función analyze_data cada 5 minutos. + Inicia el cron: analyze_data y evaluate_led_event cada 2 minutos. ''' print("Iniciando cron...") - schedule.every(5).minutes.do(analyze_data) - print("Servicio de control iniciado") + schedule.every(2).minutes.do(analyze_data) + schedule.every(2).minutes.do(evaluate_led_event) + print("Servicio de control iniciado (eventos cada 2 min)") while 1: + if not mqtt_connected: + try: + client.reconnect() + time.sleep(1) + except Exception: + pass schedule.run_pending() time.sleep(1) diff --git a/receiver/management/commands/fix_placeholder_location.py b/receiver/management/commands/fix_placeholder_location.py new file mode 100644 index 00000000..69a100eb --- /dev/null +++ b/receiver/management/commands/fix_placeholder_location.py @@ -0,0 +1,93 @@ +""" +Corrige ubicaciones que tienen los placeholders "ciudad", "estado", "pais": +reasigna sus estaciones a la Location real (bogota, cundinamarca, colombia) y elimina la placeholder. +Si la Location real no existe, la crea. + +Uso: + python manage.py fix_placeholder_location + python manage.py fix_placeholder_location --city bogota --state cundinamarca --country colombia +""" +from django.core.management.base import BaseCommand +from receiver.models import Location, City, State, Country +from receiver.utils import get_coordinates + + +class Command(BaseCommand): + help = 'Reasigna estaciones de ciudad/estado/pais a la ubicación real (ej. bogota, cundinamarca, colombia)' + + def add_arguments(self, parser): + parser.add_argument('--city', default='bogota', help='Nombre de ciudad (default: bogota)') + parser.add_argument('--state', default='cundinamarca', help='Nombre de estado/departamento (default: cundinamarca)') + parser.add_argument('--country', default='colombia', help='Nombre de país (default: colombia)') + + def handle(self, *args, **options): + city_name = options['city'] + state_name = options['state'] + country_name = options['country'] + + placeholder_locations = Location.objects.filter( + city__name='ciudad', + state__name='estado', + country__name='pais' + ).select_related('city', 'state', 'country') + + if not placeholder_locations.exists(): + self.stdout.write(self.style.WARNING('No hay ubicaciones con "ciudad, estado, pais". Nada que corregir.')) + return + + city_o, _ = City.objects.get_or_create(name=city_name, defaults={}) + state_o, _ = State.objects.get_or_create(name=state_name, defaults={}) + country_o, _ = Country.objects.get_or_create(name=country_name, defaults={}) + + # Location real (donde deben quedar las estaciones) + real_location, created = Location.objects.get_or_create( + city=city_o, state=state_o, country=country_o, + defaults={'active': True} + ) + if created: + try: + lat, lng = get_coordinates(city_name, state_name, country_name) + if lat and lng: + real_location.lat = lat + real_location.lng = lng + real_location.save() + except Exception as e: + self.stdout.write(self.style.WARNING('Coordenadas no actualizadas: {}'.format(e))) + self.stdout.write('Creada Location {}, {}, {}'.format(city_name, state_name, country_name)) + + # Reasignar todas las estaciones de las placeholder locations a la Location real + from receiver.models import Station, Data + moved = 0 + for loc in placeholder_locations: + stations = Station.objects.filter(location=loc) + for st in stations: + existing = Station.objects.filter(user=st.user, location=real_location).first() + if existing: + # El usuario ya tiene estación en la Location real: mover los Data a esa estación y borrar la duplicada + Data.objects.filter(station=st).update(station=existing) + st.delete() + self.stdout.write('Estación id={} (user={}) fusionada en estación id={}'.format(st.pk, st.user.username, existing.pk)) + else: + st.location = real_location + st.save() + self.stdout.write('Estación id={} (user={}) -> {}, {}, {}'.format( + st.pk, st.user.username, city_name, state_name, country_name)) + moved += 1 + loc.delete() + self.stdout.write('Eliminada Location placeholder id={}'.format(loc.pk)) + + # Borrar City/State/Country placeholder si ya no los usa nadie + old_city = City.objects.filter(name='ciudad').first() + old_state = State.objects.filter(name='estado').first() + old_country = Country.objects.filter(name='pais').first() + if old_city and not Location.objects.filter(city=old_city).exists(): + old_city.delete() + self.stdout.write('Eliminada City "ciudad"') + if old_state and not Location.objects.filter(state=old_state).exists(): + old_state.delete() + self.stdout.write('Eliminado State "estado"') + if old_country and not Location.objects.filter(country=old_country).exists(): + old_country.delete() + self.stdout.write('Eliminado Country "pais"') + + self.stdout.write(self.style.SUCCESS('Listo. {} estación(es) reasignada(s) a {}, {}, {}. Recarga el mapa.'.format(moved, city_name, state_name, country_name))) diff --git a/receiver/mqtt.py b/receiver/mqtt.py index 15fd8136..db3f948b 100644 --- a/receiver/mqtt.py +++ b/receiver/mqtt.py @@ -1,35 +1,22 @@ -from datetime import datetime from . import utils import json -import os +from django.utils import timezone import ssl import paho.mqtt.client as mqtt from django.conf import settings def on_message(client: mqtt.Client, userdata, message: mqtt.MQTTMessage): - ''' + """ Función que se ejecuta cada que llega un mensaje al tópico. - Recibe el mensaje con formato: - { - "variable1": mediciónVariable1, - "variable2": mediciónVariable2 - } - en un tópico con formato: - pais/estado/ciudad/usuario - ej: colombia/cundinamarca/cajica/ja.avelino - Si el tópico tiene la forma de: - pais/estado/ciudad/usuario/mensaje - se salta el procesamiento pues el mensaje es para el dispositivo de medición. - A partir de esos datos almacena la medición en el sistema. - ''' + """ try: - time = datetime.now() + time = timezone.now() payload = message.payload.decode("utf-8") - print("payload: " + payload) + print("Payload recibido:", payload) + payloadJson = json.loads(payload) - country, state, city, user = utils.get_topic_data( - message.topic) + country, state, city, user = utils.get_topic_data(message.topic) user_obj = utils.get_user(user) location_obj = utils.get_or_create_location(city, state, country) @@ -39,42 +26,55 @@ def on_message(client: mqtt.Client, userdata, message: mqtt.MQTTMessage): unit = utils.get_units(str(variable).lower()) variable_obj = utils.get_or_create_measurement(variable, unit) sensor_obj = utils.get_or_create_station(user_obj, location_obj) + utils.create_data( - float(payloadJson[measure]), sensor_obj, variable_obj, time) + float(payloadJson[measure]), + sensor_obj, + variable_obj, + time + ) except Exception as e: - print('Ocurrió un error procesando el paquete MQTT', e) + print("❌ Error procesando mensaje MQTT:", e) def on_connect(client, userdata, flags, rc): - print("Suscribiendo al tópico: " + settings.TOPIC) - client.subscribe(settings.TOPIC) - print("Servicio de recepcion de datos iniciado") + if rc == 0: + print("✅ Conectado al broker") + print("Suscribiendo al tópico:", settings.TOPIC) + client.subscribe(settings.TOPIC) + print("Servicio de recepción de datos iniciado") + else: + print("❌ Error de conexión:", mqtt.connack_string(rc)) + +def on_disconnect(client, userdata, rc): + print("⚠️ Desconectado:", mqtt.connack_string(rc)) + print("Intentando reconectar...") -def on_disconnect(client: mqtt.Client, userdata, rc): - ''' - Función que se ejecuta cuando se desconecta del broker. - Intenta reconectar al bróker. - ''' - print("Desconectado con mensaje:" + str(mqtt.connack_string(rc))) - print("Reconectando...") - client.reconnect() +print("🚀 Iniciando cliente MQTT...", settings.MQTT_HOST, settings.MQTT_PORT) -print("Iniciando cliente MQTT...", settings.MQTT_HOST, settings.MQTT_PORT) try: - client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1, settings.MQTT_USER) + # ✅ MQTT TCP normal (Mosquitto en 8082 sin websockets) + client = mqtt.Client(client_id=settings.MQTT_USER) + client.on_connect = on_connect client.on_message = on_message client.on_disconnect = on_disconnect if settings.MQTT_USE_TLS: - client.tls_set(ca_certs=settings.CA_CRT_PATH, - tls_version=ssl.PROTOCOL_TLSv1_2, cert_reqs=ssl.CERT_NONE) + client.tls_set( + ca_certs=settings.CA_CRT_PATH, + tls_version=ssl.PROTOCOL_TLSv1_2, + cert_reqs=ssl.CERT_NONE + ) client.username_pw_set(settings.MQTT_USER, settings.MQTT_PASSWORD) - client.connect(settings.MQTT_HOST, settings.MQTT_PORT) + client.connect(settings.MQTT_HOST, settings.MQTT_PORT, keepalive=60) + + # Loop bloqueante (mantiene conexión viva) + client.loop_forever() except Exception as e: - print('Ocurrió un error al conectar con el bróker MQTT:', e) + print("❌ Error conectando al broker MQTT:", e) \ No newline at end of file diff --git a/receiver/utils.py b/receiver/utils.py index 04cbd71f..81dbdada 100644 --- a/receiver/utils.py +++ b/receiver/utils.py @@ -110,13 +110,15 @@ def create_data( value: float, station: Station, measure: Measurement, - time: datetime = datetime.now(), + time: datetime = None, ): ''' Crea un nuevo dato con valor {value}, estación {station} y variable {measure}. Hace las operaciones necesarias para insertarlo en la base de datos con el patrón Blob. Calcula promedio, mínimo y máximo de los datos anteriores. ''' + if time is None: + time = timezone.now() base_time = datetime(time.year, time.month, time.day, time.hour, tzinfo=time.tzinfo) diff --git a/viewer/templates/historical.html b/viewer/templates/historical.html index ca9a51d2..29c16d7f 100644 --- a/viewer/templates/historical.html +++ b/viewer/templates/historical.html @@ -27,13 +27,17 @@

Datos históricos

Descarga los datos de las estaciones de monitoreo.

+ +
+ Última semana (por defecto) + +
Descargar datos @@ -102,50 +106,55 @@

Datos históricos

$('button[name="daterange"]').click(); } + function setDownloadUrl(start, end) { + var startTime = start ? +start : null; + var endTime = end ? +end : null; + var url = '/historic/data/'; + if (startTime != null && endTime != null) { + url += '?from=' + startTime + '&to=' + endTime; + } + $("#downloadAnchor").attr("href", url).attr("data-url", url); + } + $(function () { + setDownloadUrl(null, null); $('button[name="daterange"]').daterangepicker( { opens: "right", + startDate: moment().subtract(6, 'days'), + endDate: moment() }, function (start, end, label) { - let startTime = +start; - let endTime = +end; - $("#daterange-label").text( start.format("DD/MM/YYYY") + " - " + end.format("DD/MM/YYYY") ); - - $("#downloadAnchor") - .attr("name", `/historical/data?from=${startTime}&to=${endTime}`) - .removeClass("disabled"); + setDownloadUrl(start, end); + $("#downloadAnchor").removeClass("disabled"); } ); }); diff --git a/viewer/templates/map.html b/viewer/templates/map.html index b590684e..2b52eae4 100644 --- a/viewer/templates/map.html +++ b/viewer/templates/map.html @@ -49,7 +49,7 @@ class="float-right my-3 my-md-0" aria-label="Seleccionar variable" aria-placeholder="Variable" - onchange="this.options[this.selectedIndex].value && (window.location = '/rema/' + this.options[this.selectedIndex].value);" + onchange="var v=this.options[this.selectedIndex].value; if(v) { var url='/map/?measure='+encodeURIComponent(v); var params=new URLSearchParams(window.location.search); if(params.get('from')) url+='&from='+params.get('from'); if(params.get('to')) url+='&to='+params.get('to'); window.location=url; }" > {% for measure in measurements %}