Этот файл — главная страница моей IoT-панели. Он не просто отображает данные, а собирает их из разных источников: базы данных, системных команд и MQTT-истории. Всё это превращается в удобный интерфейс с карточками, графиками и предупреждениями.
iot_db) и забирает актуальные значения датчиков.Файл начинается с подключения к MySQL:
new PDO("mysql:host=127.0.0.1;dbname=iot_db;charset=utf8mb4", "iot_user", "123456")
Затем он делает запрос к таблице sensor_data, чтобы получить последнее значение каждого датчика:
SELECT s1.topic, s1.value, s1.timestamp, i.name, i.room, i.group_name, i.icon FROM sensor_data s1 LEFT JOIN sensor_info i ON s1.topic = i.topic WHERE s1.timestamp = (SELECT MAX(s2.timestamp) FROM sensor_data s2 WHERE s2.topic = s1.topic)
Эти данные используются для создания карточек устройств.
Файл запрашивает две группы правил из таблицы automations:
schedule_type = 'daily'/'weekly'): рассчитывает, когда следующее срабатывание (например, «в 8:00 каждый день»).schedule_type = 'none'): показывает условия («если температура > 30°C») и действия («включить вентилятор»).Скрипт проверяет историю по ключевым топикам (температура, батарея, напряжение) и ищет аномалии:
Эти алерты появляются в разделе «📈 Анализ тенденций».
Файл берёт последние данные из таблицы rpi_monitor и показывает:
systemctl is-active).Каждое устройство выводится в виде карточки с:
Карточки группируются по комнатам и типам устройств.
Когда вы нажимаете «ВКЛ» на карточке клапана:
/status на /set)./admin/send_mqtt.php?topic=...&payload=....Для каждого датчика (температура, влажность и т.д.) создаётся canvas-элемент. При загрузке страницы JavaScript запрашивает историю через /api/history.php и рисует график.
<meta http-equiv="refresh" content="30">).Эта статья — описание основной страницы. В будущем планирую написать подробные статьи про:
/admin/send_mqtt.php/scripts/iot-automation.php/scripts/mqtt-to-mysql.phpКаждую такую статью можно будет читать независимо, но эта — даст общую картину.
Обновлено: = date('Y-m-d') ?>
<?php
// ЖЕСТКАЯ ОТЛАДКА
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Логируем ВСЁ
file_put_contents('/tmp/php_errors.log', "=== START " . date('Y-m-d H:i:s') . " ===\n", FILE_APPEND);
// Перехватываем фатальные ошибки
function shutdownHandler() {
$error = error_get_last();
if ($error !== null) {
file_put_contents('/tmp/php_errors.log', "FATAL: " . print_r($error, true) . "\n", FILE_APPEND);
}
}
register_shutdown_function('shutdownHandler');
// Перехватываем все ошибки
set_error_handler(function($errno, $errstr, $errfile, $errline) {
file_put_contents('/tmp/php_errors.log', "ERROR: $errno: $errstr in $errfile on line $errline\n", FILE_APPEND);
return false;
});
date_default_timezone_set('Europe/Moscow');
// ... остальной код ...
date_default_timezone_set('Europe/Moscow');
try {
$pdo = new PDO("mysql:host=127.0.0.1;dbname=iot_db;charset=utf8mb4", "iot_user", "123456");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// === Данные для первой отрисовки ===
$stmt = $pdo->query("
SELECT s1.topic, s1.value, s1.timestamp as last_time, i.name, i.room, i.group_name, i.icon
FROM sensor_data s1
LEFT JOIN sensor_info i ON s1.topic = i.topic
WHERE s1.timestamp = (
SELECT MAX(s2.timestamp)
FROM sensor_data s2
WHERE s2.topic = s1.topic
)
GROUP BY s1.topic
");
$rows = $stmt->fetchAll();
$topics = [];
foreach ($rows as $row) {
$type = basename($row['topic']);
if ($type === 'status') $type = 'valve';
$unit = match($type) {
'temperature' => '°C',
'humidity' => '%',
'light' => 'лк',
'pm25' => 'мкг/м³',
'co2' => 'ppm',
'water_pressure' => 'бар',
'valve' => 'Вкл/Выкл',
'current' => 'мА',
'illuminance' => 'лк', // 💡 Освещенность
'presence' => '', // 🚶 Движение (пустая строка - будет "Да/Нет")
'battery' => '%', // 📶 Качество связи
default => ''
};
// Убираем жёсткую фильтрацию — оставляем всё, что есть в базе
// Но чтобы не сломать UI, оставим проверку только на безопасные типы что показывать в карточках на главной
if (!in_array($type, ['valve', 'temperature', 'humidity', 'light', 'pm25', 'co2', 'status', 'current', 'power', 'contact', 'vibration', 'illuminance', 'presence', 'battery'])) {
continue;
}
$topics[] = [
'topic' => $row['topic'],
'value' => $row['value'] ?? 'Нет данных',
'timestamp' => $row['last_time'] ?? '—',
'name' => $row['name'] ?: ucfirst($type),
'room' => $row['room'] ?: 'Неизвестная комната',
'group' => $row['group_name'] ?: 'Без группы',
'type' => $type,
'unit' => $unit,
'icon' => $row['icon'] ?? '📡'
];
}
// === Получаем активные автоматизации (задачи) ===
$upcoming_tasks = [];
try {
$stmt_tasks = $pdo->prepare("
SELECT
id, name, schedule_type, schedule_time, schedule_days,
action_topic, action_payload, delay_seconds, enabled,
last_triggered, last_confirmed, failure_count
FROM automations
WHERE enabled = 1
AND schedule_type != 'none'
ORDER BY
CASE WHEN schedule_type = 'daily' THEN schedule_time END ASC,
CASE WHEN schedule_type = 'weekly' THEN schedule_time END ASC
");
$stmt_tasks->execute();
$tasks = $stmt_tasks->fetchAll(PDO::FETCH_ASSOC);
foreach ($tasks as $task) {
// Определяем, когда следующее срабатывание
$next_run = null;
$display_time = '';
$status = '⏳ Ожидает';
if ($task['schedule_type'] === 'daily') {
$now = new DateTime();
$run_time = new DateTime($task['schedule_time']);
$run_time->setDate($now->format('Y'), $now->format('m'), $now->format('d'));
if ($run_time < $now) {
// Уже прошло сегодня — будет завтра
$run_time->modify('+1 day');
}
$next_run = $run_time;
$display_time = $task['schedule_time'];
}
elseif ($task['schedule_type'] === 'weekly') {
$days_map = ['mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6, 'sun' => 0];
$now = new DateTime();
$run_time = new DateTime($task['schedule_time']);
// Разбиваем дни недели
$scheduled_days = explode(',', $task['schedule_days'] ?? '');
$next_day = null;
foreach ($scheduled_days as $day) {
if (!isset($days_map[$day])) continue;
$day_num = $days_map[$day];
$current_day = (int)$now->format('N'); // 1=Пн, 7=Вс
$days_ahead = ($day_num - $current_day + 7) % 7;
if ($days_ahead == 0 && $now->format('H:i:s') >= $task['schedule_time']) {
$days_ahead = 7; // Сегодня уже прошло — брать следующую неделю
}
$candidate = clone $now;
$candidate->modify("+{$days_ahead} days");
$candidate->setTime(
(int)substr($task['schedule_time'], 0, 2),
(int)substr($task['schedule_time'], 3, 2),
(int)substr($task['schedule_time'], 6, 2)
);
if (!$next_day || $candidate < $next_day) {
$next_day = $candidate;
}
}
$next_run = $next_day;
$display_time = $task['schedule_time'] . " (" . implode(', ', array_map(fn($d) => ucfirst(substr($d, 0, 2)), $scheduled_days)) . ")";
}
// Формируем описание действия
$action_desc = json_decode($task['action_payload'], true);
if (is_array($action_desc)) {
$payload_str = implode(', ', array_map(fn($k, $v) => "$k: $v", array_keys($action_desc), array_values($action_desc)));
} else {
$payload_str = $task['action_payload'];
}
// Добавляем задержку, если есть
$delay_text = $task['delay_seconds'] > 0 ? " (через {$task['delay_seconds']} сек)" : '';
// Формируем задачу
$upcoming_tasks[] = [
'name' => $task['name'],
'time' => $display_time,
'action' => $task['action_topic'],
'payload' => $payload_str,
'next_run' => $next_run ? $next_run->format('Y-m-d H:i:s') : null,
'status' => $status,
'delay' => $task['delay_seconds'],
'id' => $task['id']
];
}
} catch (PDOException $e) {
error_log("Ошибка получения автоматизаций: " . $e->getMessage());
}
// === Определяем ближайшее запланированное правило ===
$next_scheduled = null;
if (!empty($upcoming_tasks)) {
usort($upcoming_tasks, fn($a, $b) => strtotime($a['next_run']) <=> strtotime($b['next_run']));
$next_scheduled = $upcoming_tasks[0];
}
// === Получаем статистику логов за сегодня ===
$log_stats = [];
try {
$stmt_log = $pdo->prepare("SELECT keyword, count FROM log_stats WHERE date = ?");
$stmt_log->execute([date('Y-m-d')]);
$log_rows = $stmt_log->fetchAll(PDO::FETCH_ASSOC);
foreach ($log_rows as $row) {
$log_stats[$row['keyword']] = (int)$row['count'];
}
} catch (PDOException $e) {
error_log("Ошибка получения статистики логов: " . $e->getMessage());
}
// === Получаем последние данные Raspberry Pi ===
$rpi_data = null;
try {
$stmt_rpi = $pdo->query("
SELECT * FROM rpi_monitor
ORDER BY timestamp DESC
LIMIT 1
");
$rpi_data = $stmt_rpi->fetch(PDO::FETCH_ASSOC);
} catch (PDOException $e) {
error_log("Ошибка получения данных RPi: " . $e->getMessage());
}
// === Проверка статусов системных сервисов ===
$services_to_check = [
'iot-automation.service',
'mqtt-to-mysql.service',
'zigbee2mqtt.service',
'iot-mqtt-listener.service',
'mosquitto.service',
];
$service_statuses = [];
foreach ($services_to_check as $service) {
$command = "systemctl is-active " . escapeshellarg($service) . " 2>&1";
$status = trim(shell_exec($command));
$is_active = ($status === 'active');
$service_statuses[] = [
'name' => $service,
'status' => $status,
'is_active' => $is_active,
'color' => $is_active ? 'ok' : 'critical'
];
}
// === Статус автоматизации ===
$stmt_last = $pdo->prepare("SELECT name, last_triggered, action_topic, action_payload FROM automations WHERE last_triggered IS NOT NULL ORDER BY last_triggered DESC LIMIT 1");
$stmt_last->execute();
$last_executed_rule = $stmt_last->fetch(PDO::FETCH_ASSOC);
if ($last_executed_rule && !empty($last_executed_rule['action_payload'])) {
$payload = json_decode($last_executed_rule['action_payload'], true);
$last_executed_rule['payload_str'] = is_array($payload)
? implode(', ', array_map(fn($k, $v) => "$k: $v", array_keys($payload), array_values($payload)))
: $last_executed_rule['action_payload'];
}
$stmt_check = $pdo->prepare("SELECT 1 FROM daily_resets WHERE reset_date = ?");
$stmt_check->execute([date('Y-m-d')]);
$daily_reset_today = $stmt_check->fetch() !== false;
} catch (Exception $e) {
die("Ошибка БД: " . htmlspecialchars($e->getMessage()));
}
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8">
<title>⚡️ IoT Суперпанель</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body { font-family: 'Segoe UI', sans-serif; background-color: #121212; color: #ffffff; margin: 0; padding: 20px; line-height: 1.6; }
h1 { color: #00c6ff; font-size: 24px; margin-bottom: 30px; text-align: center; }
.group-section { margin-bottom: 40px; }
.group-section h2 { color: #cccccc; font-size: 20px; border-left: 4px solid #00c6ff; padding-left: 10px; margin-bottom: 20px; cursor: pointer; background-color: #1f1f1f; padding: 10px; border-radius: 6px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; margin-top: 10px; }
.card { background-color: #1f1f1f; border-radius: 12px; padding: 20px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4); }
.title { font-size: 18px; font-weight: bold; display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.value { margin-bottom: 10px; }
.dannie {font-size: 28px;}
.timestamp { font-size: 12px; color: #aaaaaa; margin-bottom: 15px; display: block; }
.status-indicator { display: inline-block; width: 12px; height: 12px; border-radius: 50%; vertical-align: middle; margin-right: 10px; }
.status-on { background-color: #4caf50; }
.status-off { background-color: #f44336; }
.controls { display: flex; gap: 10px; margin-top: 15px; }
.btn { padding: 10px 16px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; font-size: 14px; }
.btn-on { background-color: #00c6ff; color: #000; }
.btn-off { background-color: #333; color: #ccc; }
canvas { width: 100% !important; height: auto !important; margin-top: 15px; border-radius: 8px; background-color: #111; }
.hidden { display: none; }
.arrow { float: right; transition: transform 0.3s ease; }
.weather-card { background-color: #2c2c2c; margin-bottom: 30px; border-left: 4px solid #00c6ff; padding: 15px; border-radius: 8px; }
.critical { color: #e74c3c; font-weight: bold; }
.warning { color: #f39c12; }
.ok { color: #2ecc71; }
</style>
</head>
<body>
<h1>⚡️ IoT Суперпанель</h1>
<!-- Статус автоматизации -->
<div class="card" style="background-color: #1a1a1a; border-left: 4px solid #ffffff; margin-bottom: 20px;">
<div class="title">🧠 Статус автоматизации</div>
<hr>
<div class="grid">
<div class="card" style="background: #2a2a2a; padding: 15px;">
<div class="value"><strong>🕔 Текущее время:</strong> <span id="current-time"><?= date('Y-m-d H:i:s') ?></span></div>
<div class="value"><strong>🔄 Ежедневный сброс:</strong> <span id="daily-reset" class="<?= $daily_reset_today ? 'ok' : 'warning' ?>"><?= $daily_reset_today ? '✅ Выполнен сегодня' : '⚠️ Не выполнен' ?></span></div>
<div class="value" id="last-rule">
<?php if ($last_executed_rule): ?>
<strong>✅ Последнее правило:</strong><br>
<small><strong><?= htmlspecialchars($last_executed_rule['name']) ?></strong></small><br>
<small>Действие: <code><?= htmlspecialchars($last_executed_rule['action_topic']) ?></code></small><br>
<small>Payload: <code><?= htmlspecialchars($last_executed_rule['payload_str']) ?></code></small><br>
<small>Время: <strong><?= htmlspecialchars($last_executed_rule['last_triggered']) ?></strong></small>
<?php else: ?>
<strong>✅ Последнее правило:</strong> <span style="color: #888;">—</span>
<?php endif; ?>
</div>
<?php if ($next_scheduled): ?>
<div class="value">
<strong>⏱️ Следующее срабатывание:</strong>
<br><small>
<span style="color: #00c6ff; font-weight: bold;"><?= htmlspecialchars($next_scheduled['name']) ?></span>
в <strong><?= htmlspecialchars($next_scheduled['next_run']) ?></strong>
</small>
</div>
</div>
</div>
<?php endif; ?>
<div class="timestamp">Обновляется каждые 30 сек</div>
</div>
<!-- === Ближайшие автоматизации === -->
<div class="card" style="background: #1a1a1a; border-left: 4px solid #ffa500; margin-bottom: 20px;">
<div class="title">⏰ Ближайшие автоматизации</div>
<hr>
<?php if (!empty($upcoming_tasks)): ?>
<div class="grid">
<?php foreach (array_slice($upcoming_tasks, 0, 6) as $task): ?>
<div class="card" style="background: #2a2a2a; padding: 15px;">
<div class="title">📅 <?= htmlspecialchars($task['name']) ?></div>
<div class="value">
<div>⏰ <strong>Время:</strong> <?= htmlspecialchars($task['time']) ?></div>
<div>🔧 <strong>Действие:</strong> <code><?= htmlspecialchars($task['action']) ?></code></div>
<div>📦 <strong>Payload:</strong> <code><?= htmlspecialchars($task['payload']) ?></code></div>
<div>⏱️ <strong>Следующий запуск:</strong><br><?= htmlspecialchars($task['next_run']) ?></div>
<div>📊 <strong>Статус:</strong> <?= htmlspecialchars($task['status']) ?></div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="value">Нет активных автоматизаций</div>
<?php endif; ?>
</div>
<!-- Raspberry Pi Monitoring -->
<?php if ($rpi_data): ?>
<div class="group-section" id="rpi-monitor">
<h2 onclick="toggleGroup(this)">
🖥️ Raspberry Pi Статус
<span class="arrow">▼</span>
</h2>
<div class="grid group-content" id="group-content-rpi-monitor">
<!-- Карточка: Статус железа -->
<div class="card">
<?php
$temp = floatval($rpi_data['cpu_temp'] ?? 0);
$temp_class = $temp > 70 ? 'critical' : ($temp > 60 ? 'warning' : 'ok');
$disk = floatval($rpi_data['disk_usage'] ?? 0);
$disk_class = $disk > 90 ? 'critical' : ($disk > 60 ? 'warning' : 'ok');
?>
<div class="title">🖥️ Статус железа</div>
<hr>
<div class="value">🌡️ Температура CPU: <strong class="<?= $temp_class ?>"><?= htmlspecialchars($rpi_data['cpu_temp']) ?>°C</strong></div>
<div class="value">⚡ Загрузка CPU: <strong><?= htmlspecialchars($rpi_data['cpu_load']) ?>%</strong></div>
<div class="value">🧠 Память: <strong><?= htmlspecialchars($rpi_data['mem_used']) ?>MB / <?= htmlspecialchars($rpi_data['mem_total']) ?>MB</strong></div>
<div class="value">💽 Диск: <strong class="<?= $disk_class ?>"><?= htmlspecialchars($rpi_data['disk_usage']) ?>%</strong></div>
<div class="value">🔋 Напряжение ядра: <strong><?= htmlspecialchars($rpi_data['volt_core'] ?? 'N/A') ?>V</strong></div>
<hr>
<div class="timestamp">🏠 IP: <?= htmlspecialchars($rpi_data['ip_addresses']) ?></div>
<div class="timestamp">⏱️ Uptime: <?= htmlspecialchars($rpi_data['uptime']) ?></div>
<div class="timestamp">🕒 Последнее обновление: <?= htmlspecialchars($rpi_data['timestamp']) ?></div>
</div>
<!-- Карточка: Анализ логов -->
<?php if (!empty($log_stats)): ?>
<div class="card">
<div class="title">📊 Анализ логов (<?= date('Y-m-d') ?>)</div>
<hr>
<div class="value">
<?php foreach ($log_stats as $keyword => $count): ?>
<div><?= ucfirst(htmlspecialchars($keyword)) ?>: <strong><?= $count ?></strong></div>
<?php endforeach; ?>
</div>
<small class="timestamp">Обновляется каждый час</small>
</div>
<?php endif; ?>
<!-- Карточка: Активность сервисов -->
<div class="card">
<div class="title">🔧 Активность сервисов</div>
<?php foreach ($service_statuses as $svc): ?>
<div class="value">
<strong><?= htmlspecialchars($svc['name']) ?>:</strong>
<span class="<?= $svc['color'] ?>">
<?= $svc['is_active'] ? '✅' : '❌' ?> <?= htmlspecialchars($svc['status']) ?>
</span>
</div>
<?php endforeach; ?>
<div class="timestamp">🕒 Проверено: <?= date('H:i:s') ?></div>
</div>
</div>
</div>
<?php endif; ?>
<!-- Погода -->
<div class="card weather-card">
<div class="title">🌤️ Погода сейчас</div>
<div id="weather-moscow">Загрузка... Москва</div>
<div id="weather-kaluga">Загрузка... Калуга</div>
</div>
<!-- Датчики -->
<?php if (empty($topics)): ?>
<p>Нет доступных датчиков.</p>
<?php else:
// Улучшенная группировка - объединяем "Без группы" с "Общие"
$groups = [];
foreach ($topics as $data) {
$groupName = $data['group'];
if (empty($groupName) || $groupName === 'Без группы') {
$groupName = 'Общие';
}
if (!isset($groups[$groupName])) $groups[$groupName] = [];
$groups[$groupName][] = $data;
}
// Сортируем группы в нужном порядке
$preferredOrder = ['Дом', 'Дача', 'Общие'];
uksort($groups, function($a, $b) use ($preferredOrder) {
$posA = array_search($a, $preferredOrder);
$posB = array_search($b, $preferredOrder);
if ($posA === false) $posA = 999;
if ($posB === false) $posB = 999;
return $posA - $posB;
});
foreach ($groups as $groupName => $groupItems): ?>
<div class="group-section">
<h2 onclick="toggleGroup(this)">
<?= htmlspecialchars($groupName) ?>
<span class="arrow">▼</span>
</h2>
<div class="grid group-content">
<?php foreach ($groupItems as $data):
$safeId = 'sensor-' . base64_encode($data['topic']);
?>
<div class="card" id="<?= htmlspecialchars($safeId) ?>">
<div class="title">
<?= $data['icon'] ?>
<?= htmlspecialchars($data['name']) ?>
<small style="color:#888">(<?= htmlspecialchars($data['room']) ?>)</small>
</div>
<!-- 👇 БЛОК ОТОБРАЖЕНИЯ ЗНАЧЕНИЯ -->
<?php if ($data['type'] === 'valve' && !str_contains($data['topic'], '/set/')): ?>
<div class="value dannie">
<span class="status-indicator status-<?= strtolower($data['value']) === 'on' ? 'on' : 'off' ?>"></span>
<?= htmlspecialchars($data['value']) ?>
</div>
<div class="controls">
<button class="btn btn-on" onclick="sendCommand('<?= rawurlencode($data['topic']) ?>', 'ON')">ВКЛ</button>
<button class="btn btn-off" onclick="sendCommand('<?= rawurlencode($data['topic']) ?>', 'OFF')">ВЫКЛ</button>
</div>
<?php else: ?>
<div class="value dannie"><?= htmlspecialchars($data['value']) ?> <?= htmlspecialchars($data['unit']) ?></div>
<?php endif; ?>
<div class="timestamp">🕔 <?= htmlspecialchars($data['timestamp']) ?></div>
<!--графики что показывать-->
<?php if (in_array($data['type'], ['temperature', 'humidity', 'light', 'pm25', 'co2'])): ?>
<canvas id="chart-<?= htmlspecialchars($safeId) ?>" height="100"></canvas>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<script>
// Графики
const charts = {};
document.querySelectorAll('canvas[id^="chart-"]').forEach(canvas => {
const safeId = canvas.id.replace('chart-', '');
const topic = atob(safeId.replace('sensor-', ''));
const chart = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
labels: [],
datasets: [{
label: '',
data: [],
borderColor: '#00c6ff',
backgroundColor: 'rgba(0, 198, 255, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 0
}]
},
options: {
responsive: true,
plugins: { legend: false },
scales: { x: { display: false }, y: { beginAtZero: false } },
animation: false
}
});
charts[topic] = chart;
loadChartData(topic);
});
function loadChartData(topic) {
fetch('/api/history.php?topic=' + encodeURIComponent(topic))
.then(r => r.json())
.then(data => {
if (!Array.isArray(data) || data.length < 2) return;
const labels = data.map(p => p.timestamp.split(' ')[1]);
const values = data.map(p => parseFloat(p.value)).filter(v => !isNaN(v));
if (values.length === 0) return;
const chart = charts[topic];
if (chart) {
chart.data.labels = labels;
chart.data.datasets[0].data = values;
chart.update('none');
}
});
}
function escapeHtml(text) {
return String(text)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
}
function getSafeId(topic) {
return 'sensor-' + btoa(topic);
}
// Обновление панели
function updatePanel() {
fetch('/api/live_data.php')
.then(r => r.json())
.then(data => {
// Время
document.getElementById('current-time').textContent = data.current_time;
// Ежедневный сброс
const resetEl = document.getElementById('daily-reset');
if (resetEl) {
resetEl.className = data.daily_reset_today ? 'ok' : 'warning';
resetEl.textContent = data.daily_reset_today ? '✅ Выполнен сегодня' : '⚠️ Не выполнен';
}
// Последнее правило
if (data.last_executed_rule) {
const r = data.last_executed_rule;
document.getElementById('last-rule').innerHTML = `
<strong>✅ Последнее правило:</strong><br>
<small><strong>${escapeHtml(r.name)}</strong></small><br>
<small>Действие: <code>${escapeHtml(r.action_topic)}</code></small><br>
<small>Payload: <code>${escapeHtml(r.payload_str)}</code></small><br>
<small>Время: <strong>${escapeHtml(r.last_triggered)}</strong></small>
`;
}
// Датчики
data.topics.forEach(item => {
const id = getSafeId(item.topic);
const card = document.getElementById(id);
if (!card) return;
const valueEl = card.querySelector('.value');
const timeEl = card.querySelector('.timestamp');
if (valueEl && !valueEl.querySelector('.controls')) {
if (item.type === 'valve') {
const on = item.value.toLowerCase() === 'on';
valueEl.innerHTML = `<span class="status-indicator status-${on ? 'on' : 'off'}"></span>${escapeHtml(item.value)}`;
} else {
valueEl.textContent = `${item.value} ${item.unit}`;
}
}
if (timeEl) timeEl.textContent = '🕔 ' + item.timestamp;
});
})
.catch(console.error);
function updatePanel() {
console.log('=== START UPDATE ===', new Date().toLocaleTimeString());
fetch('/api/live_data.php')
.then(r => {
console.log('Response status:', r.status);
return r.json();
})
.then(data => {
console.log('Received data:', data);
console.log('Topics count:', data.topics.length);
// Логируем конкретные датчики
data.topics.forEach(item => {
if (item.topic.includes('illuminance') || item.topic.includes('humidity')) {
console.log('Sensor:', item.topic, 'Value:', item.value, 'Time:', item.timestamp);
}
});
// Остальная обработка...
data.topics.forEach(item => {
const id = getSafeId(item.topic);
const card = document.getElementById(id);
if (!card) {
console.log('Card not found for:', item.topic);
return;
}
// ... остальной код ...
});
})
.catch(e => console.error('Update error:', e));
}
}
function sendCommand(topic, cmd) {
const setTopic = decodeURIComponent(topic).replace('/status', '/set');
fetch(`/admin/send_mqtt.php?topic=${encodeURIComponent(setTopic)}&payload=${encodeURIComponent(JSON.stringify({state: cmd}))}`)
.then(r => r.text())
.then(() => alert('Команда отправлена'))
.catch(e => alert('Ошибка: ' + e));
}
function toggleGroup(h2) {
const content = h2.nextElementSibling;
const arrow = h2.querySelector('.arrow');
const hidden = content.classList.contains('hidden');
content.classList.toggle('hidden', !hidden);
arrow.style.transform = hidden ? 'rotate(-90deg)' : 'rotate(0deg)';
}
// Погода
async function getWeather(lat, lon, city) {
try {
const res = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t_weather=true&hourly=relativehumidity_2m,pressure_msl&timezone=auto`);
const d = await res.json();
const t = d.current_weather.temperature;
const w = d.current_weather.windspeed;
const p = d.hourly.pressure_msl[0]?.toFixed(0) || '?';
const h = d.hourly.relativehumidity_2m[0] || '?';
document.getElementById(`weather-${city.toLowerCase()}`).innerHTML = `
<strong>📍 ${city}</strong>: ${t}°C | 💨 ${w} км/ч | 💧 ${h}% | 📊 ${p} гПа
`;
} catch (e) {
document.getElementById(`weather-${city.toLowerCase()}`).textContent = `⚠️ Ошибка (${city})`;
}
}
// Запуск
document.addEventListener('DOMContentLoaded', () => {
getWeather(55.75, 37.62, 'Moscow');
getWeather(54.52, 36.25, 'Kaluga');
updatePanel();
setInterval(updatePanel, 60_000);
setInterval(() => Object.keys(charts).forEach(loadChartData), 60_000);
});
</script>
</body>
</html>
Обновлено:
буду обновлять по мере изменения файла, как минимум стили надо в отдельный файл а то жирный код получился
/* style.css */
:root {
--bg-color: #1a1a1a;
--card-bg: #2d2d2d;
--accent-color: #00c6ff;
--accent-gradient-start: #00c6ff;
--accent-gradient-end: #0072ff;
--text-color: #f5f5f5;
--switch-bg-off: #444;
--switch-bg-on: #00c6ff;
--border-radius: 16px;
--grid-gap: 1.5rem;
--padding: 1.5rem;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
}
header {
background: linear-gradient(90deg, var(--accent-gradient-start), var(--accent-gradient-end));
color: white;
padding: 1.5rem 2rem;
text-align: center;
font-size: 1.8rem;
font-weight: bold;
}
main {
max-width: 1400px;
margin: 2rem auto;
padding: var(--padding);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--grid-gap);
}
.card {
background: var(--card-bg);
border-radius: var(--border-radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
padding: 1.5rem;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-6px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.title {
font-weight: bold;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
font-size: 1.2rem;
}
.icon {
font-size: 1.5rem;
margin-right: 0.5rem;
color: var(--accent-color);
}
.value {
font-size: 1.6rem;
margin-top: 0.5rem;
color: white;
}
.topic {
font-size: 0.8rem;
color: #aaa;
margin-top: 0.5rem;
}
form {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
label {
font-size: 0.95rem;
}
input[type="number"] {
width: 100%;
padding: 0.5rem;
border: none;
border-radius: 8px;
background: #444;
color: white;
font-size: 1rem;
}
button {
padding: 0.6rem;
font-size: 1rem;
background: var(--accent-color);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.3s ease;
}
button:hover {
background: #00aaff;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0;
right: 0; bottom: 0;
background-color: var(--switch-bg-off);
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 2px;
bottom: 2px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--switch-bg-on);
}
input:checked + .slider:before {
transform: translateX(26px);
}
footer {
text-align: center;
padding: 2rem 1rem;
font-size: 0.9rem;
color: #666;
margin-top: 2rem;
}
.timestamp {
font-size: 0.8rem;
color: #aaa;
margin-top: 0.3rem;
}
.delete-button {
display: inline-block;
margin-top: 1rem;
padding: 0.4rem 0.8rem;
background-color: #4169E1;
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 0.9rem;
transition: background-color 0.3s ease;
}
.delete-button:hover {
background-color: #e60000;
}
.delete-button:active {
background-color: #cc0000;
}
.delete-button::selection {
background: #ff4d4d;
}
.card.humidity .icon::before { content: "\1F4A7"; } /* Капля воды */
.card.temperature .icon::before { content: "\2103"; } /* °C */
.card.light .icon::before { content: "\1F506"; } /* 💡 */
.card.door .icon::before { content: "\1F6AA"; } /* 🚪 */
.card.motion .icon::before { content: "\1F6E3"; } /* 🛣️ */
.card.power .icon::before { content: "\26A1"; } /* ⚡ */
.group-divider {
width: 100%;
height: 24px;
margin: 30px 0;
background-color: #f5f5f5;
border-top: 1px solid #ddd;
position: relative;
}
/* Опционально: подпись внутри разделителя */
.group-divider::after {
content: "🌿 Конец группы";
display: block;
text-align: center;
font-size: 0.9em;
color: #888;
margin-top: -16px;
background: white;
width: 120px;
padding: 0 10px;
margin-left: auto;
margin-right: auto;
}
Блог только запустил, все статьи генерирую через нейросеть т.к. лень, возможны ошибки. Просто чтобы вы знали и не запускали ядерный реактор по моим статьям ))
Если у вас есть вопросы, или Нашли неточность? пишите в коментах — вместе поправим и сделаем статью более качественной. Я лично объясню нюансы из практики.
Комментарии
Пока нет комментариев. Будьте первым!