diff options
| -rw-r--r-- | mqtt_log.py | 120 | ||||
| -rw-r--r-- | sensors.py | 151 | ||||
| -rw-r--r-- | templates/sensors.html | 474 |
3 files changed, 745 insertions, 0 deletions
diff --git a/mqtt_log.py b/mqtt_log.py new file mode 100644 index 0000000..cac4a01 --- /dev/null +++ b/mqtt_log.py @@ -0,0 +1,120 @@ +import os +import json +import time +from pathlib import Path +import requests +import paho.mqtt.client as mqtt +from dotenv import load_dotenv + +load_dotenv() + +def file_print(data_string, data_data): + """ Обработчик данных от сервера MQTT. Передаем: топик, данные """ + try: + # Извлекаем имя файла из топика + if "/" in data_string: + file_name = data_string[1:data_string.rfind("/")] + # Получаем расширение или вторую часть + value_part = data_string[data_string.rfind("/") + 1:] + else: + file_name = data_string + value_part = "" + + # Формируем пути + csv_path = f"{file_name}.csv" + timestamp = time.strftime('%Y/%m/%d - %H:%M:%S', time.localtime()) + formatted_data = f"/{timestamp}_{data_data}" + + # Запись в файл + fileWrite(os.getenv('FOLDER_ARHIVE_NAME'), csv_path, formatted_data) + + # Обновление кэша JSON + update_json_cache(file_name, f"{data_data}_{time.strftime('%H:%M:%S_%d/%m/%Y', time.localtime())}") + + except Exception as e: + print(f"Ошибка в file_print: {e}") + +def update_json_cache(file_name, value): + """Обновление JSON кэша""" + path = os.getenv('FILE_CACHE_JSON_NAME') + data_json = {file_name: value} + + try: + if Path(path).exists(): + with open(path, 'r', encoding='utf-8') as json_file: + data = json.load(json_file) + data.update(data_json) + else: + data = data_json + + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Ошибка при обновлении JSON: {e}") + +def connect_mqtt(): + """ Подключение к серверу MQTT """ + try: + # Попытка для новых версий + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + except AttributeError: + # Для старых версий + client = mqtt.Client() + + client.username_pw_set(os.getenv('USERNAME'), os.getenv('PASSWORD')) + client.connect( + os.getenv('SERVER_IP'), + int(os.getenv('PORT_MQTT')) + ) + return client + +def subscribe(client: mqtt): + """ Подписываемся на топики """ + def on_message(client, userdata, msg): + try: + file_print(msg.topic, msg.payload.decode()) + except Exception as error: + print(f"Ошибка в on_message: {error}") + + client.subscribe(os.getenv('TOPIC')) + client.on_message = on_message + +def fileWrite(folderArhiveName, path, myFile): + """ Запись в файл. Передаем: название папки архива, название файла (топика), данные """ + try: + # Извлекаем имя папки + space_pos = myFile.find(' ') + if space_pos == -1: + folder_name = folderArhiveName + myFile + else: + folder_name = folderArhiveName + myFile[:space_pos] + + # Создаем путь + path_temp = Path.cwd() / folder_name + path_temp.mkdir(parents=True, exist_ok=True) + + # Записываем данные + file_path = path_temp / path + with open(file_path, "a", encoding='utf-8') as f: + # Извлекаем данные после пробела + data_part = myFile[myFile.rfind(' ') + 1:] if ' ' in myFile else myFile + f.write(data_part + '\n') + + except Exception as e: + print(f"Ошибка записи файла: {e}") + +def run(): + try: + print("Подключение к MQTT...") + client = connect_mqtt() + subscribe(client) + print("Ожидание сообщений...") + client.loop_forever() + except KeyboardInterrupt: + print("\nПрограмма остановлена") + except Exception as e: + print(f"Критическая ошибка: {e}") + +if __name__ == "__main__": + run() + diff --git a/sensors.py b/sensors.py new file mode 100644 index 0000000..d454a13 --- /dev/null +++ b/sensors.py @@ -0,0 +1,151 @@ +import os +import json +import time +import threading +from flask import Flask, jsonify, request, render_template +from dotenv import load_dotenv +from datetime import datetime + +load_dotenv() + +app = Flask(__name__) + +# Создаем папку для логов при запуске +UPLOAD_FOLDER = 'data/battery_logs' +os.makedirs(UPLOAD_FOLDER, exist_ok=True) + +# Глобальная переменная для хранения данных +cached_data = {} + +def file_read(filename, interval=1.0): + """Функция для постоянного чтения файла в отдельном потоке""" + global cached_data + while True: + try: + with open(filename, 'r', encoding='utf-8') as file: + cached_data = json.load(file) + except FileNotFoundError: + print(f"Файл {filename} не найден. Ожидание...") + except json.JSONDecodeError as e: + print(f"Ошибка декодирования JSON в файле {filename}: {e}") + except Exception as e: + print(f"Неожиданная ошибка при чтении файла {filename}: {e}") + + time.sleep(interval) + +@app.route('/upload', methods=['POST']) +def upload_file(): + """ + Принимаем CSV файл от ESP32-C3 + """ + try: + device_id = request.headers.get('X-Device-ID', 'unknown') + file_name = request.headers.get('X-File-Name', 'log.csv') + + csv_data = request.get_data(as_text=True) + + if not csv_data: + return jsonify({'error': 'No data received'}), 400 + + device_folder = os.path.join(UPLOAD_FOLDER, device_id) + os.makedirs(device_folder, exist_ok=True) + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + safe_filename = f"{timestamp}_{file_name}" + file_path = os.path.join(device_folder, safe_filename) + + with open(file_path, 'w', encoding='utf-8') as f: + f.write(csv_data) + + print(f"✅ Получен файл от {device_id}: {len(csv_data)} байт") + print(f" Сохранен как: {file_path}") + + return jsonify({ + 'status': 'success', + 'message': 'File saved', + 'filename': safe_filename, + 'size': len(csv_data) + }), 200 + + except Exception as e: + print(f"❌ Ошибка: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/', methods=['POST']) +def receive_string(): + """Прием простой текстовой строки""" + text_data = request.data.decode('utf-8') + clean_list = [item.strip() for item in text_data.split(',')] + + if not cached_data: + return '*no data*' + + result_values = [] + for key in clean_list: + if key in cached_data: + strValue = str(cached_data[key]) + if '_' in strValue: + result_values.append(strValue[:strValue.index('_')]) + else: + result_values.append(strValue) + else: + result_values.append(f'? {key} ?') + + return '*' + '*'.join(result_values) + '*' + + +@app.route('/', methods=['GET']) +def sample(): + global cached_data + if cached_data: + return jsonify(cached_data) + else: + test_data = { + "Temp": 23.5, + "Hum": 45, + "Pres": 750, + "status": "no_file" + } + return jsonify(test_data) + +@app.route('/sensors', methods=['GET']) +def get_sensors(): + """Красивое отображение данных датчиков в браузере""" + global cached_data + + # Если запрошен JSON формат + if request.args.get('format') == 'json': + if cached_data: + return jsonify(cached_data) + else: + test_data = { + "Temp": 23.5, + "Hum": 45, + "Pres": 750, + "status": "no_data" + } + return jsonify(test_data) + + # Иначе возвращаем красивую HTML страницу + return render_template('sensors.html', data=cached_data) + +if __name__ == '__main__': + filename = os.getenv('FILE_CACHE_JSON_NAME') + + if not filename: + print("❌ Ошибка: переменная FILE_CACHE_JSON_NAME не задана в .env") + filename = 'sensors_cache.json' + + print(f"📁 Файлы будут сохраняться в: {os.path.abspath(UPLOAD_FOLDER)}") + print("🌐 Доступ по адресу: http://localhost:5000") + print("📤 Endpoint для загрузки: http://localhost:5000/upload") + print("📊 Endpoint для датчиков: http://localhost:5000/sensors") + print("-" * 50) + + data_thread = threading.Thread(target=file_read, args=(filename, 2), daemon=True) + data_thread.start() + + host = os.getenv('SERVER_IP', '0.0.0.0') + port = int(os.getenv('PORT_HTTP', 5000)) + + app.run(host=host, port=port) diff --git a/templates/sensors.html b/templates/sensors.html new file mode 100644 index 0000000..3bc21a0 --- /dev/null +++ b/templates/sensors.html @@ -0,0 +1,474 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Данные датчиков</title> + <style> + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: #1a1a2e; + padding: 20px; + min-height: 100vh; + } + .container { + max-width: 1200px; + margin: 0 auto; + background: #16213e; + border-radius: 15px; + padding: 30px; + box-shadow: 0 10px 30px rgba(0,0,0,0.5); + border: 1px solid #0f3460; + } + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; + padding-bottom: 15px; + border-bottom: 2px solid #0f3460; + flex-wrap: wrap; + gap: 10px; + } + h1 { + color: #e94560; + font-size: 28px; + font-weight: 700; + display: flex; + align-items: center; + gap: 10px; + } + h1 span { + color: #fff; + font-weight: 300; + } + .stats { + color: #aaa; + font-size: 14px; + background: #0f3460; + padding: 8px 16px; + border-radius: 20px; + } + .stats strong { + color: #e94560; + } + .table-wrapper { + overflow-x: auto; + background: #1a1a2e; + border-radius: 10px; + border: 1px solid #0f3460; + } + table { + width: 100%; + border-collapse: collapse; + min-width: 600px; + } + thead { + background: #0f3460; + } + th { + color: #fff; + padding: 15px 20px; + text-align: left; + font-weight: 600; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 1px; + border-bottom: 2px solid #e94560; + white-space: nowrap; + } + td { + padding: 12px 20px; + color: #ddd; + border-bottom: 1px solid #0f3460; + font-size: 15px; + } + tbody tr { + transition: all 0.3s ease; + } + tbody tr:hover { + background: #0f3460; + transform: scale(1.01); + } + tbody tr:last-child td { + border-bottom: none; + } + .sensor-key { + font-weight: 600; + color: #e94560; + font-size: 16px; + } + .sensor-value { + color: #4fc3f7; + font-family: 'Courier New', monospace; + font-size: 16px; + font-weight: 500; + } + .sensor-unit { + color: #888; + font-size: 12px; + margin-left: 5px; + } + .sensor-status { + display: inline-block; + padding: 4px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + } + .status-active { + background: #00e67620; + color: #00e676; + border: 1px solid #00e67640; + } + .status-inactive { + background: #ff174420; + color: #ff1744; + border: 1px solid #ff174440; + } + .status-warning { + background: #ffea0020; + color: #ffea00; + border: 1px solid #ffea0040; + } + .no-data { + text-align: center; + padding: 60px 20px; + color: #888; + } + .no-data .icon { + font-size: 48px; + margin-bottom: 15px; + display: block; + } + .no-data p { + font-size: 18px; + } + .no-data .sub { + font-size: 14px; + color: #666; + margin-top: 10px; + } + .footer { + margin-top: 20px; + display: flex; + justify-content: space-between; + align-items: center; + color: #666; + font-size: 13px; + padding-top: 15px; + border-top: 1px solid #0f3460; + flex-wrap: wrap; + gap: 10px; + } + .footer .badge { + display: inline-block; + background: #00e67620; + color: #00e676; + padding: 2px 12px; + border-radius: 20px; + font-size: 12px; + } + .refresh-btn { + background: #e94560; + color: white; + border: none; + padding: 8px 20px; + border-radius: 20px; + cursor: pointer; + font-weight: 600; + transition: all 0.3s ease; + } + .refresh-btn:hover { + background: #c62840; + transform: scale(1.05); + } + .auto-refresh { + display: flex; + align-items: center; + gap: 10px; + } + .auto-refresh label { + color: #aaa; + font-size: 13px; + } + .auto-refresh input[type="checkbox"] { + accent-color: #e94560; + width: 18px; + height: 18px; + cursor: pointer; + } + .json-link { + color: #4fc3f7; + text-decoration: none; + font-size: 14px; + } + .json-link:hover { + text-decoration: underline; + } + @media (max-width: 768px) { + .container { + padding: 15px; + } + .header { + flex-direction: column; + align-items: flex-start; + } + h1 { + font-size: 22px; + } + th, td { + padding: 10px 15px; + font-size: 13px; + } + } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <h1>📊 <span>Данные датчиков</span></h1> + <div class="stats"> + Всего датчиков: <strong id="sensorCount">0</strong> + <span style="margin:0 10px;">|</span> + <span id="updateTimeDisplay">Обновлено: --</span> + </div> + </div> + + <div style="margin-bottom: 15px; text-align: right;"> + <a href="/sensors?format=json" class="json-link">📄 Показать как JSON</a> + </div> + + <div class="table-wrapper"> + <table> + <thead> + <tr> + <th style="width: 30%;">📌 Датчик</th> + <th style="width: 40%;">📈 Значение</th> + <th style="width: 30%;">⚡ Статус</th> + </tr> + </thead> + <tbody id="sensorsTableBody"> + <tr> + <td colspan="3"> + <div class="no-data"> + <span class="icon">🔄</span> + <p>Загрузка данных...</p> + <div class="sub">Ожидайте поступления данных от датчиков</div> + </div> + </td> + </tr> + </tbody> + </table> + </div> + + <div class="footer"> + <div> + <span class="badge">● LIVE</span> + <span style="margin-left: 15px;">Данные обновляются автоматически</span> + </div> + <div class="auto-refresh"> + <label for="autoRefresh">Автообновление</label> + <input type="checkbox" id="autoRefresh" checked> + <button class="refresh-btn" onclick="loadData()">🔄 Обновить</button> + </div> + </div> + </div> + + <script> + let autoRefreshInterval = null; + const AUTO_REFRESH_TIME = 3000; + + function getSensorStatus(value) { + if (value === null || value === undefined || value === '') { + return { status: 'inactive', text: 'Нет данных' }; + } + + let cleanValue = value; + if (typeof value === 'string' && value.includes('_')) { + cleanValue = value.split('_')[0]; + } + + if (!isNaN(cleanValue) && typeof cleanValue !== 'boolean') { + const numValue = parseFloat(cleanValue); + if (numValue > 100) { + return { status: 'warning', text: '⚠ Высокое' }; + } else if (numValue < 0) { + return { status: 'warning', text: '⚠ Низкое' }; + } else { + return { status: 'active', text: '✅ Норма' }; + } + } + + if (typeof value === 'string') { + if (value.toLowerCase().includes('error') || value.toLowerCase().includes('fail')) { + return { status: 'inactive', text: '❌ Ошибка' }; + } + if (value.toLowerCase().includes('warn')) { + return { status: 'warning', text: '⚠ Предупреждение' }; + } + return { status: 'active', text: '✅ OK' }; + } + + return { status: 'active', text: '✅ Активен' }; + } + + function getUnit(key) { + const units = { + 'Temp': '°C', + 'Temperature': '°C', + 'Hum': '%', + 'Humidity': '%', + 'Pres': 'мм рт. ст.', + 'Pressure': 'мм рт. ст.', + 'Batt': 'V', + 'Battery': 'V', + 'Volt': 'V', + 'Voltage': 'V', + 'Amp': 'A', + 'Current': 'A', + 'Power': 'W', + 'Energy': 'кВт·ч', + 'Freq': 'Гц', + 'Frequency': 'Гц' + }; + return units[key] || ''; + } + + function formatValue(value) { + if (value === null || value === undefined) { + return '--'; + } + + if (typeof value === 'string' && value.includes('_')) { + const parts = value.split('_'); + value = parts[0]; + } + + if (!isNaN(value) && typeof value !== 'boolean') { + const num = parseFloat(value); + if (Number.isInteger(num)) { + return num.toString(); + } else { + return num.toFixed(2); + } + } + + return value; + } + + function loadData() { + fetch('/sensors?format=json') + .then(response => { + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); + }) + .then(data => { + updateTable(data); + }) + .catch(error => { + console.error('Error fetching data:', error); + document.getElementById('sensorsTableBody').innerHTML = ` + <tr> + <td colspan="3"> + <div class="no-data"> + <span class="icon">⚠️</span> + <p>Ошибка загрузки данных</p> + <div class="sub">Проверьте подключение к серверу</div> + </div> + </td> + </tr> + `; + }); + } + + function updateTable(data) { + const tbody = document.getElementById('sensorsTableBody'); + const sensorCount = document.getElementById('sensorCount'); + + const now = new Date(); + document.getElementById('updateTimeDisplay').textContent = + `Обновлено: ${now.toLocaleTimeString('ru-RU')}`; + + if (!data || typeof data !== 'object' || Object.keys(data).length === 0) { + tbody.innerHTML = ` + <tr> + <td colspan="3"> + <div class="no-data"> + <span class="icon">📡</span> + <p>Нет данных от датчиков</p> + <div class="sub">Ожидайте поступления данных...</div> + </div> + </td> + </tr> + `; + sensorCount.textContent = '0'; + return; + } + + const keys = Object.keys(data); + sensorCount.textContent = keys.length; + keys.sort(); + + let rows = ''; + keys.forEach(key => { + const rawValue = data[key]; + const value = formatValue(rawValue); + const unit = getUnit(key); + const status = getSensorStatus(rawValue); + + let statusClass = 'status-active'; + if (status.status === 'inactive') statusClass = 'status-inactive'; + if (status.status === 'warning') statusClass = 'status-warning'; + + rows += ` + <tr> + <td class="sensor-key">${key}</td> + <td class="sensor-value"> + ${value} + ${unit ? `<span class="sensor-unit">${unit}</span>` : ''} + </td> + <td> + <span class="sensor-status ${statusClass}">${status.text}</span> + </td> + </tr> + `; + }); + + tbody.innerHTML = rows; + } + + function toggleAutoRefresh() { + const isChecked = document.getElementById('autoRefresh').checked; + + if (autoRefreshInterval) { + clearInterval(autoRefreshInterval); + autoRefreshInterval = null; + } + + if (isChecked) { + autoRefreshInterval = setInterval(loadData, AUTO_REFRESH_TIME); + loadData(); + } + } + + document.addEventListener('DOMContentLoaded', function() { + loadData(); + + const autoRefreshCheckbox = document.getElementById('autoRefresh'); + autoRefreshCheckbox.addEventListener('change', toggleAutoRefresh); + + if (autoRefreshCheckbox.checked) { + autoRefreshInterval = setInterval(loadData, AUTO_REFRESH_TIME); + } + }); + </script> +</body> +</html> |
