summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--mqtt_log.py120
-rw-r--r--sensors.py151
-rw-r--r--templates/sensors.html474
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>