Мониторинг аварии котла + телеграмм
Добавлено: Пт дек 26, 2025 8:39 pm
Пришлось с ИИ написать сигнализатор для своевременного включения котла, который стал неожиданно отключаться из-за стартовой горелки.
Следит за ритмом включения горелок и снижением температуры ниже среднего минимума, пишет в ТГ причину, включает аварийную лампочку. Пауза на отправку в ТГ 30 минут, что бы не спамить. По восстановлению температуры выше средней "выключает" аварийный статус, лампочку, шлет статус в ТГ и шлет график температуры как картинку. Еще добавил тревогу по прекращению данных с датчика температуры.
Код записан в новый Сценарий "sensor_alarm" и вызывается из скрипта everyminutes, который уже из ClockChime:onNewMinute.
Модуль телеграмм - стандартный в библиотеке MjD. В команды Телеграм еще добавил "Статус котла" с кодом runScript('sensor_alarm', array('force_debug' => 1)); который отправляет текущий статус на телефон по запросу.
Отладил, работает. Но если вручную сильно регулировать температуру котла, то может тоже срабатывать.
Датчик стоит на выходе горячей воды из котла.
Следит за ритмом включения горелок и снижением температуры ниже среднего минимума, пишет в ТГ причину, включает аварийную лампочку. Пауза на отправку в ТГ 30 минут, что бы не спамить. По восстановлению температуры выше средней "выключает" аварийный статус, лампочку, шлет статус в ТГ и шлет график температуры как картинку. Еще добавил тревогу по прекращению данных с датчика температуры.
Код записан в новый Сценарий "sensor_alarm" и вызывается из скрипта everyminutes, который уже из ClockChime:onNewMinute.
Модуль телеграмм - стандартный в библиотеке MjD. В команды Телеграм еще добавил "Статус котла" с кодом runScript('sensor_alarm', array('force_debug' => 1)); который отправляет текущий статус на телефон по запросу.
Отладил, работает. Но если вручную сильно регулировать температуру котла, то может тоже срабатывать.
Датчик стоит на выходе горячей воды из котла.
Код: Выделить всё
// =====================================================================
// МОНИТОР КОТЛА: РИТМ И СТРАХОВКА (v6.3 - Исправленная версия)
// =====================================================================
// =====================================================================
// ОПИСАНИЕ АЛГОРИТМА РАБОТЫ СКРИПТА
// =====================================================================
//
// Алгоритм работает по принципу двух независимых "стражей", которые следят за котлом.
// Если хотя бы один из них фиксирует проблему, поднимается тревога.
//
// --- СЦЕНАРИИ ТРЕВОГИ ---
// 1. Сценарий "Сбой Ритма" — умный и основной. Он анализирует цикличность работы котла.
// 2. Сценарий "Критическое Падение" — страховочный, реагирует на аномальное падение температуры.
//
// --- ПОШАГОВАЯ ЛОГИКА РАБОТЫ ---
//
// ШАГ 1: СБОР И ПОДГОТОВКА ДАННЫХ
// - Загружается история температуры за период, указанный в $HISTORY_HOURS.
// - Данные очищаются от аномальных значений (выходящих за рамки $MIN_TEMP_VALID и $MAX_TEMP_VALID).
// - Проверяется "зависание" датчика: если новые данные не поступали дольше N минут, скрипт завершается с тревогой.
//
// ШАГ 2: АНАЛИЗ РИТМА (умный сценарий)
// - Определение "экватора" ($midTemp): Вычисляется средняя температура за короткий период ($ADAPTIVE_WINDOW_HOURS).
// Этот порог нужен, чтобы точно фиксировать момент начала нагрева. Он адаптивный и "плывет" вместе с режимом котла.
// - Поиск стартов нагрева ($risingEdges): Алгоритм находит все моменты, когда температура пересекала "экватор" снизу вверх.
// Это и есть моменты включения котла.
// - Расчет среднего цикла: Вычисляется среднее время между зафиксированными стартами нагрева.
// - Прогноз и "дедлайн": На основе последнего старта и среднего цикла прогнозируется время следующего включения.
// К этому времени добавляется допустимое опоздание ($DELAY_THRESHOLD). Если текущее время превысило этот "дедлайн",
// а котел так и не включился, фиксируется "Сбой ритма".
//
// ШАГ 3: АНАЛИЗ ПАДЕНИЯ (страховочный сценарий)
// - Определение "дна" нормы ($avgLowTemp): находим минимумы по циклам и усредняем последние N минимумов.
// - Расчет порога тревоги: От "дна" отнимается запас прочности ($DROP_MARGIN). Это и есть критический порог.
// - Подтверждение падения: Если температура непрерывно находится ниже критического порога дольше времени,
// указанного в $DROP_TIME_CONFIRM, фиксируется "Критическое падение".
//
// ШАГ 4: ПРИНЯТИЕ РЕШЕНИЯ
// - Если сработал хотя бы один из сценариев (Ритм ИЛИ Падение), активируется тревога.
// - Тревога автоматически сбрасывается, когда котел возобновляет работу и его температура поднимается выше "экватора" ($midTemp).
//
// =====================================================================
// =====================================================================
// НАСТРОЙКИ (ПАРАМЕТРЫ)
// =====================================================================
// --- ОБЩИЕ НАСТРОЙКИ И ОБЪЕКТЫ СИСТЕМЫ "УМНЫЙ ДОМ" ---
$DEBUG_MODE = false; // Режим отладки. true = писать подробный лог и слать тех. данные в Telegram каждый запуск.
// !!! НОВАЯ ПРОВЕРКА: Проверяем, был ли передан параметр 'force_debug' при вызове скрипта.
// Это позволяет принудительно включить режим отладки для одного конкретного запуска.
if (isset($params['force_debug']) && $params['force_debug']) {
$DEBUG_MODE = true;
}
$SEND_TO_TELEGRAM = true; // Глобальный "рубильник" для всех сообщений в Telegram.
$sensor_obj = 'Sensor_temp12'; // Имя объекта, с которого берется температура (источник данных).
$monitor_obj = 'Sensor_general05'; // Имя объекта-хранилища. Он нужен, чтобы хранить состояние тревоги (alarmActive)
// и время последней тревоги между запусками скрипта.
$relay_obj = 'Relay27'; // Имя объекта-исполнителя. Это реле, которое будет мигать
// (или включать сирену/лампу) при тревоге.
// --- СЦЕНАРИЙ "РИТМ": Анализ цикличности работы котла ---
// Эта группа настроек отвечает за "умную" детекцию сбоя, основанную на ожидаемом поведении котла.
$HISTORY_HOURS = 2; // ГЛУБИНА АНАЛИЗА (часы). Сколько часов истории температуры нужно загрузить для анализа.
// Это "память" алгоритма. Чем больше значение, тем стабильнее средний цикл,
// но тем медленнее реакция на изменение режима работы. 2-4 часа - оптимально.
$DELAY_THRESHOLD = 5; // ДОПУСТИМОЕ ОПОЗДАНИЕ (минуты). На сколько минут котел может "опоздать" с включением
// относительно своего среднего ритма, прежде чем будет поднята тревога.
// Это запас времени на случай небольших отклонений в работе.
$DEBOUNCE_SEC = 180; // ЗАЩИТА ОТ ДРЕБЕЗГА (секунды). Минимальный интервал между двумя фиксациями "старта нагрева".
// Нужно, чтобы исключить ложные срабатывания, если температура колеблется
// на границе средней температуры. 3 минуты (180 сек) - хороший выбор.
// --- СЦЕНАРИЙ "ПАДЕНИЕ": Страховка от критического падения температуры ---
// Эта группа настроек отвечает за "страховочный" сценарий, который сработает, даже если ритм определить не удалось.
$DROP_MARGIN = 5.0; // ЗАПАС ПРОЧНОСТИ (°C). На сколько градусов температура должна упасть НИЖЕ
// своего обычного минимального значения за последние часы, чтобы это считалось
// началом критического падения. Это отступ от нормальной работы.
$DROP_TIME_CONFIRM = 3; // ВРЕМЯ ПОДТВЕРЖДЕНИЯ ПАДЕНИЯ (минуты). Сколько минут температура должна НЕПРЕРЫВНО
// находиться ниже критического порога, чтобы сработала тревога.
// Это защита от ложных тревог из-за кратковременных провалов.
// --- ФИЛЬТР ДАННЫХ И НОВЫЕ ПАРАМЕТРЫ ---
// Эти параметры нужны для очистки истории и новых проверок.
$MIN_TEMP_VALID = 0.1; // МИНИМАЛЬНО ДОПУСТИМАЯ ТЕМПЕРАТУРА. Все значения ниже этого будут отброшены.
$MAX_TEMP_VALID = 100.0; // МАКСИМАЛЬНО ДОПУСТИМАЯ ТЕМПЕРАТУРА. Все значения выше этого будут отброшены.
$NO_DATA_THRESHOLD = 10; // ПОРОГ ЗАВИСАНИЯ ДАТЧИКА (минуты). Если с момента последнего полученного значения
// прошло больше этого времени, будет отправлена тревога о зависании датчика.
$ADAPTIVE_WINDOW_HOURS = 1; // ДЛИНА АДАПТИВНОГО ОКНА (час). Длина "короткого" периода для вычисления
// актуальной средней температуры ($midTemp). Позволяет алгоритму быстро
// адаптироваться к изменению погодных условий.
// Параметры для расчета средней нижней по минимумам
$CYCLES_FOR_AVG = 5;
$MIN_RISE_DELTA = 0.2; // ИСПРАВЛЕНО: было 0.2, теперь используется переменная
// ===== ИНИЦИАЛИЗАЦИЯ TELEGRAM =====
$GLOBALS['telegram_module'] = null;
if ($SEND_TO_TELEGRAM) {
include_once(DIR_MODULES . 'telegram/telegram.class.php');
if (class_exists('telegram')) {
$GLOBALS['telegram_module'] = new telegram();
}
}
// ===== ФУНКЦИЯ ОТПРАВКИ =====
function sendMessage($text) {
$SEND_TO_TELEGRAM = isset($GLOBALS['SEND_TO_TELEGRAM']) ? $GLOBALS['SEND_TO_TELEGRAM'] : true;
$telegram_module = isset($GLOBALS['telegram_module']) ? $GLOBALS['telegram_module'] : null;
if (!$SEND_TO_TELEGRAM || !is_object($telegram_module)) {
return;
}
if (method_exists($telegram_module, 'sendMessageToAdmin')) {
$telegram_module->sendMessageToAdmin($text);
}
}
/**
* Создание и отправка PNG-графика температуры.
* Принимает нормализованный $history (массив элементов ['VALUE','ADDED']),
* имя датчика, текущую температуру и длительность в часах (для подписи).
* Возвращает true при успешной отправке.
*/
function createAndSendTemperatureChart($normalized, $sensor_obj, $currentTemp, $historyHours = 2) {
if (empty($normalized) || !is_array($normalized)) {
DebMes("ГРАФИК: Нет данных для построения");
return false;
}
$width = 1000;
$height = 420;
$padding = 70;
$chartWidth = $width - 2 * $padding;
$chartHeight = $height - 2 * $padding;
$image = imagecreatetruecolor($width, $height);
if (!$image) {
DebMes("ГРАФИК: Не удалось создать изображение");
return false;
}
// цвета
$white = imagecolorallocate($image, 255, 255, 255);
$black = imagecolorallocate($image, 0, 0, 0);
$blue = imagecolorallocate($image, 30, 144, 255);
$lightGray = imagecolorallocate($image, 235, 235, 235);
$red = imagecolorallocate($image, 200, 40, 40);
$darkBlue = imagecolorallocate($image, 0, 80, 160);
imagefill($image, 0, 0, $white);
// шрифт (попытаться найти)
$font = '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf';
if (!file_exists($font)) {
$font = '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
}
$useFont = file_exists($font);
// фиксированный диапазон для видимости — можно менять
$minVal = 40;
$maxVal = 80;
$valRange = max(0.1, $maxVal - $minVal);
// время
$minTime = PHP_INT_MAX;
$maxTime = PHP_INT_MIN;
foreach ($normalized as $p) {
if ($p['ADDED'] < $minTime) $minTime = $p['ADDED'];
if ($p['ADDED'] > $maxTime) $maxTime = $p['ADDED'];
}
$timeRange = max(1, $maxTime - $minTime);
// Заголовок
$title = "График температуры: {$sensor_obj}";
if ($useFont) {
imagettftext($image, 14, 0, $padding, 28, $darkBlue, $font, $title);
} else {
imagestring($image, 5, $padding, 8, $title, $darkBlue);
}
// Сетка Y и подписи
$rows = 5;
for ($i = 0; $i <= $rows; $i++) {
$y = $padding + ($chartHeight * $i / $rows);
imageline($image, $padding, $y, $width - $padding, $y, $lightGray);
$value = $maxVal - ($valRange * $i / $rows);
if ($useFont) {
imagettftext($image, 10, 0, 8, $y + 4, $black, $font, number_format($value, 1) . "°C");
} else {
imagestring($image, 2, 6, $y - 6, number_format($value, 0), $black);
}
}
// Сетка X и подписи (6 меток)
$cols = 6;
for ($i = 0; $i <= $cols; $i++) {
$x = $padding + ($chartWidth * $i / $cols);
imageline($image, $x, $padding, $x, $height - $padding, $lightGray);
$t = $minTime + ($timeRange * $i / $cols);
$label = date("d.m H:i", $t);
if ($useFont) imagettftext($image, 9, 0, $x - 28, $height - $padding + 18, $black, $font, $label);
else imagestring($image, 2, $x - 28, $height - $padding + 2, $label, $black);
}
// рамка
imagerectangle($image, $padding, $padding, $width - $padding, $height - $padding, $black);
// рисуем линию
$prevX = null; $prevY = null;
foreach ($normalized as $p) {
$x = $padding + (($p['ADDED'] - $minTime) / $timeRange) * $chartWidth;
$val = max($minVal, min($maxVal, $p['VALUE']));
$y = $height - $padding - (($val - $minVal) / $valRange) * $chartHeight;
if ($prevX !== null) {
imagesetthickness($image, 2);
imageline($image, (int)$prevX, (int)$prevY, (int)$x, (int)$y, $blue);
imagesetthickness($image, 1);
}
$prevX = $x; $prevY = $y;
}
// текущая точка и подпись
if ($prevX !== null) {
imagefilledellipse($image, (int)$prevX, (int)$prevY, 10, 10, $red);
$label = number_format($currentTemp, 1) . "°C";
if ($useFont) {
imagettftext($image, 10, 0, (int)$prevX + 12, (int)$prevY + 4, $black, $font, $label);
} else {
imagestring($image, 3, (int)$prevX + 12, (int)$prevY - 6, $label, $black);
}
}
// инфо-строка
$actualMin = min(array_column($normalized, 'VALUE'));
$actualMax = max(array_column($normalized, 'VALUE'));
$avgVal = array_sum(array_column($normalized, 'VALUE')) / count($normalized);
$info = sprintf("Период: %dч Мин: %.1f°C Макс: %.1f°C Средн: %.1f°C Точек: %d",
$historyHours, $actualMin, $actualMax, $avgVal, count($normalized));
if ($useFont) imagettftext($image, 9, 0, $padding, $height - 10, $black, $font, $info);
else imagestring($image, 2, $padding, $height - 25, $info, $black);
// Сохраняем файл во временную папку
$filename = "boiler_chart_" . preg_replace('/[^a-zA-Z0-9_]/', '_', $sensor_obj) . "_" . time() . ".png";
$basePath = defined('ROOT') ? rtrim(ROOT, '/') . "/cms/cached/" : sys_get_temp_dir() . "/";
if (!is_dir($basePath)) @mkdir($basePath, 0755, true);
$filepath = $basePath . $filename;
$saved = imagepng($image, $filepath);
imagedestroy($image);
if (!$saved) {
DebMes("ГРАФИК: Ошибка сохранения $filepath");
return false;
}
// Отправка в Telegram — используем уже созданный модуль, если есть
$telegram_module = isset($GLOBALS['telegram_module']) && is_object($GLOBALS['telegram_module'])
? $GLOBALS['telegram_module']
: null;
if (!is_object($telegram_module)) {
if (file_exists(DIR_MODULES . 'telegram/telegram.class.php')) {
include_once(DIR_MODULES . 'telegram/telegram.class.php');
if (class_exists('telegram')) $telegram_module = new telegram();
}
}
if (!is_object($telegram_module)) {
DebMes("ГРАФИК: Telegram модуль не доступен, файл сохранён: $filepath");
return false;
}
$caption = sprintf("t котла %.1f°C, за %dч, мин: %.1f°C макс: %.1f°C",
$currentTemp, $historyHours, $actualMin, $actualMax);
$ok = false;
if (method_exists($telegram_module, 'sendImageToAdmin')) {
try {
$ok = (bool)$telegram_module->sendImageToAdmin($filepath, $caption);
} catch (Exception $e) {
DebMes("ГРАФИК: Ошибка отправки в Telegram: " . $e->getMessage());
$ok = false;
}
} else {
DebMes("ГРАФИК: метод sendImageToAdmin не найден в telegram_module");
}
@unlink($filepath);
return $ok;
}
// ===== ИНИЦИАЛИЗАЦИЯ =====
$currentTemp = (float)gg($sensor_obj . '.value');
$currentTime = time();
$alarmActive = (int)gg($monitor_obj . '.alarmActive');
$lastAlarmTs = (int)gg($monitor_obj . '.lastAlarmTime');
$lastAlarmDate = $lastAlarmTs ? date('Y-m-d H:i:s', $lastAlarmTs) : "Нет";
// ===== ИСТОРИЯ =====
$histStart = $currentTime - ($HISTORY_HOURS * 3600);
$history = getHistory($sensor_obj . '.value', $histStart, $currentTime);
if (!is_array($history) || count($history) === 0) {
DebMes("КОТЕЛ: История пуста или getHistory вернуло немассив. Проверьте Keep history у свойства {$sensor_obj}.value");
sendMessage("КОТЕЛ: ❌ История пуста! Проверьте датчик {$sensor_obj}. T={$currentTemp}°C");
return;
}
// ===== НОРМАЛИЗАЦИЯ =====
$normalized = array();
foreach ($history as $item) {
// Получаем значение
$rawVal = null;
if (isset($item['VALUE'])) $rawVal = $item['VALUE'];
elseif (isset($item['value'])) $rawVal = $item['value'];
elseif (isset($item['val'])) $rawVal = $item['val'];
// Получаем время
$rawTime = null;
if (isset($item['ADDED'])) $rawTime = $item['ADDED'];
elseif (isset($item['added'])) $rawTime = $item['added'];
elseif (isset($item['TIME'])) $rawTime = $item['TIME'];
elseif (isset($item['time'])) $rawTime = $item['time'];
elseif (isset($item['timestamp'])) $rawTime = $item['timestamp'];
// Приведение значения к float
if ($rawVal === null) continue;
$val = (float)$rawVal;
// Приведение времени к unix секундам
$ts = 0;
if (is_numeric($rawTime)) {
$ts = (int)$rawTime;
if ($ts > 1000000000000) {
$ts = (int)round($ts / 1000);
} elseif ($ts > 10000000000) {
$ts = (int)round($ts / 1000);
}
} else {
$parsed = strtotime($rawTime);
if ($parsed === false) {
continue;
}
$ts = $parsed;
}
if ($ts <= 0) continue;
$normalized[] = array('VALUE' => $val, 'ADDED' => $ts);
}
if (count($normalized) === 0) {
DebMes("КОТЕЛ: После нормализации история пуста. Проверьте формат ADDED и ключи истории.");
return;
}
// ===== ФИЛЬТРАЦИЯ И СОРТИРОВКА =====
$normalized = array_values(array_filter($normalized, function($p) use ($MIN_TEMP_VALID, $MAX_TEMP_VALID) {
$v = (float)$p['VALUE'];
return ($v >= $MIN_TEMP_VALID && $v <= $MAX_TEMP_VALID);
}));
if (count($normalized) < 6) {
DebMes("КОТЕЛ: Недостаточно валидных точек после фильтрации. count=" . count($normalized));
}
usort($normalized, function($a, $b) {
if ($a['ADDED'] == $b['ADDED']) return 0;
return ($a['ADDED'] < $b['ADDED']) ? -1 : 1;
});
$history = $normalized;
// ===== НОВОЕ: ДЕТЕКЦИЯ ЗАВИСАНИЯ ДАТЧИКА =====
$lastPoint = end($history);
$lastPointTime = isset($lastPoint['ADDED']) ? (int)$lastPoint['ADDED'] : 0;
$timeSinceLastPoint = $currentTime - $lastPointTime;
if ($lastPointTime > 0 && $timeSinceLastPoint > ($NO_DATA_THRESHOLD * 60)) {
$minutesAgo = round($timeSinceLastPoint / 60);
$msg = "КОТЕЛ: ❌ Датчик '{$sensor_obj}' завис! Последние данные были {$minutesAgo} минут назад.";
DebMes($msg);
sendMessage($msg);
return; // Прерываем выполнение
}
// ===== АНАЛИЗ =====
$vals = array_column($history, 'VALUE');
$hMin = min($vals);
$hMax = max($vals);
// ===== НОВОЕ: АДАПТИВНЫЙ MID (СКОЛЬЗЯЩЕЕ ОКНО) =====
$adaptiveWindowStart = $currentTime - ($ADAPTIVE_WINDOW_HOURS * 3600);
$windowHistory = array_filter($history, function($p) use ($adaptiveWindowStart) {
return $p['ADDED'] >= $adaptiveWindowStart;
});
if (count($windowHistory) > 2) {
$windowVals = array_column($windowHistory, 'VALUE');
// ИСПРАВЛЕНИЕ 1: Критическая ошибка синтаксиса
$midTemp = (min($windowVals) + max($windowVals)) / 2.0;
} else {
// Если в окне мало данных, используем общую историю
$midTemp = ($hMin + $hMax) / 2.0;
}
// NOTE: исправление потенциальной ошибки: если $windowVals не корректен, обеспечить midTemp корректным
if (!isset($midTemp) || !is_numeric($midTemp)) {
$midTemp = ($hMin + $hMax) / 2.0;
}
// ===== НОВЫЙ БЛОК: РАСЧЁТ СРЕДНЕЙ НИЖНЕЙ ПО МИНИМУМАМ ЦИКЛОВ =====
// Сначала собираем плоский массив температур T из history
$T = array_column($history, 'VALUE');
// По предложенному тобой алгоритму:
$cycleMins = [];
for ($i = 2; $i < count($T) - 2; $i++) {
if (
$T[$i-1] > $T[$i] &&
$T[$i] < $T[$i+1] &&
// ИСПРАВЛЕНИЕ 2: Используем переменную из настроек вместо константы
($T[$i+1] - $T[$i]) > $MIN_RISE_DELTA
) {
$cycleMins[] = $T[$i];
}
}
// Если мало минимумов — делаем fallback (используем локальные минимумы старого алгоритма)
if (count($cycleMins) < 2) {
$localMinima = array();
for ($i = 2; $i < count($history) - 2; $i++) {
$v = $history[$i]['VALUE'];
if ($v < $history[$i-1]['VALUE'] && $v < $history[$i+1]['VALUE']) {
$localMinima[] = $v;
}
}
// если нашли локальные minima и cycleMins пуст — заполним
if (count($cycleMins) == 0 && count($localMinima) > 0) {
$cycleMins = $localMinima;
}
}
// Усреднение последних N минимумов
$N = $CYCLES_FOR_AVG;
$avgLowTemp = $hMin; // безопасный дефолт
if (count($cycleMins) > 0) {
$recent = array_slice($cycleMins, -$N);
if (count($recent) > 0) {
$avgLowTemp = array_sum($recent) / count($recent);
}
}
// ===== РИТМ: Поиск взлетов (Rising Edges) =====
$risingEdges = array();
$prevVal = $history[0]['VALUE'];
$prevTime = $history[0]['ADDED'];
for ($i = 1; $i < count($history); $i++) {
$currVal = (float)$history[$i]['VALUE'];
$currTime = (int)$history[$i]['ADDED'];
if ($prevVal < $midTemp && $currVal >= $midTemp && ($currVal - $prevVal) > 0.01) {
$lastEdge = end($risingEdges);
$lastEdgeTs = $lastEdge ? (int)$lastEdge : 0;
if (empty($risingEdges) || (($currTime - $lastEdgeTs) > $DEBOUNCE_SEC)) {
$risingEdges[] = $currTime;
}
}
$prevVal = $currVal;
$prevTime = $currTime;
}
// Расчёт параметров цикла
$avgCycleMinutes = 0.0;
$lastRiseTime = 0;
$nextExpectedRise = 0;
$deadline = 0;
$cycleDetected = false;
if (count($risingEdges) >= 2) {
$intervals = array();
for ($i = 1; $i < count($risingEdges); $i++) {
$intervals[] = $risingEdges[$i] - $risingEdges[$i-1];
}
// ИСПРАВЛЕНИЕ 5a: Защита от деления на ноль
if (count($intervals) > 0) {
$avgSeconds = array_sum($intervals) / count($intervals);
$avgCycleMinutes = round($avgSeconds / 60.0, 1);
$lastRiseTime = end($risingEdges);
$nextExpectedRise = (int)round($lastRiseTime + $avgSeconds);
$deadline = $nextExpectedRise + ($DELAY_THRESHOLD * 60);
$cycleDetected = true;
}
}
// ===== ПАДЕНИЕ: Анализ =====
$dropThreshold = $avgLowTemp - $DROP_MARGIN;
$dropAlarmInfo = "Норма (T > Порога)";
// Считаем непрерывный период времени в конце истории, где VALUE < dropThreshold
$revHist = array_reverse($history);
$belowStartTs = null;
$belowEndTs = null;
foreach ($revHist as $p) {
if ($p['VALUE'] < $dropThreshold) {
$ts = (int)$p['ADDED'];
if ($belowEndTs === null) $belowEndTs = $ts;
$belowStartTs = $ts;
} else {
break;
}
}
$belowDurationSec = 0;
if ($belowStartTs !== null && $belowEndTs !== null) {
$belowDurationSec = max(0, $belowEndTs - $belowStartTs);
}
// Если сейчас ниже порога - считаем таймер подтверждения
if ($currentTemp < $dropThreshold) {
$neededSec = $DROP_TIME_CONFIRM * 60;
$remainingSec = $neededSec - $belowDurationSec;
if ($remainingSec <= 0) {
$dropAlarmInfo = "УЖЕ СРАБОТАЛО";
} else {
$dropAlarmInfo = date('H:i:s', $currentTime + $remainingSec) . " (через " . ceil($remainingSec/60) . " мин)";
}
}
// ===== ПРОВЕРКА УСЛОВИЙ =====
$failRhythm = false;
$failDrop = false;
if ($cycleDetected) {
if ($currentTime > $deadline && $currentTemp < $midTemp) {
$failRhythm = true;
}
}
if ($belowDurationSec >= ($DROP_TIME_CONFIRM * 60)) {
$failDrop = true;
}
// ===== ЛОГИРОВАНИЕ =====
if ($DEBUG_MODE) {
$statusText = ($alarmActive || $failRhythm || $failDrop) ? "Тревога" : "Наблюдение";
$rhythmLast = $lastRiseTime ? date('Y-m-d H:i:s', $lastRiseTime) : "Нет данных";
$rhythmNext = $nextExpectedRise ? date('Y-m-d H:i:s', $nextExpectedRise) : "Нет данных";
$rhythmDeadline = $deadline ? date('Y-m-d H:i:s', $deadline) : "Нет данных";
// Лог MajorDomo (как в рабочей версии)
$logMsg = "\n=== МОНИТОР КОТЛА ===";
$logMsg .= "\nТекущий статус: $statusText";
$logMsg .= "\nСценарий \"ритм\":";
$logMsg .= " средняя t " . round($midTemp, 1) . "°C (адапт.),"; // Добавлено "(адапт.)" для ясности
$logMsg .= " средний цикл " . $avgCycleMinutes . " мин,";
$logMsg .= " последнее событие пересечения $rhythmLast,";
$logMsg .= " ожидаемое время следующего события $rhythmNext,";
$logMsg .= " время тревоги если события не будет $rhythmDeadline,";
$logMsg .= " дата-время последней тревоги $lastAlarmDate.";
$logMsg .= "\n(РisingEdges count=" . count($risingEdges) . ")";
$logMsg .= "\nСценарий \"падение\":";
$logMsg .= " средняя нижняя t " . round($avgLowTemp, 1) . "°C (мин $hMin),";
$logMsg .= " уставка/разброс от t " . $DROP_MARGIN . "°C,";
$logMsg .= " температура тревоги " . round($dropThreshold, 1) . "°C,";
$logMsg .= " время тревоги если температура будет ниже тревожной: $dropAlarmInfo.";
$logMsg .= "\n(Время ниже порога sec=" . $belowDurationSec . ")";
DebMes($logMsg);
// Telegram (красивый формат)
$telegramMsg = " <b>МОНИТОР КОТЛА</b>\n\n";
$telegramMsg .= " <b>Статус:</b> $statusText\n\n";
$telegramMsg .= " <b>Температура:</b> " . round($currentTemp, 1) . "°C\n";
$telegramMsg .= " <b>Диапазон (общий):</b> " . round($hMin, 1) . " … " . round($hMax, 1) . "°C\n";
$telegramMsg .= "⚖️ <b>Средняя (адапт.):</b> " . round($midTemp, 1) . "°C\n\n";
$telegramMsg .= "⏱ <b>Ритм нагрева:</b>\n";
$telegramMsg .= " • Циклов: " . count($risingEdges) . "\n";
$telegramMsg .= " • Средний: " . $avgCycleMinutes . " мин\n";
$telegramMsg .= " • Последний: " . ($lastRiseTime ? date('H:i', $lastRiseTime) : 'Нет') . "\n";
$telegramMsg .= " • Следующий: " . ($nextExpectedRise ? date('H:i', $nextExpectedRise) : 'Нет') . "\n";
$telegramMsg .= " • Дедлайн: " . ($deadline ? date('H:i', $deadline) : 'Нет') . "\n\n";
$telegramMsg .= " <b>Падение:</b>\n";
$telegramMsg .= " • Порог: " . round($dropThreshold, 1) . "°C\n";
$telegramMsg .= " • Ниже: " . (int)$belowDurationSec . " сек\n";
$telegramMsg .= " • Статус: $dropAlarmInfo\n\n";
$statusRhythm = $failRhythm ? "❌ СБОЙ" : "✅ OK";
$statusDrop = $failDrop ? "❌ СБОЙ" : "✅ OK";
$telegramMsg .= " <b>Проверки:</b>\n";
$telegramMsg .= " • Ритм: $statusRhythm\n";
$telegramMsg .= " • Падение: $statusDrop";
sendMessage($telegramMsg);
// Отправить график при DEBUG
$chartSentDebug = createAndSendTemperatureChart($history, $sensor_obj, $currentTemp, $HISTORY_HOURS);
if ($chartSentDebug) {
DebMes("КОТЕЛ: DEBUG - график отправлен");
} else {
DebMes("КОТЕЛ: DEBUG - не удалось отправить график");
}
}
// --------------------- УПРАВЛЕНИЕ АВАРИЕЙ ---------------------------
if ($failRhythm || $failDrop) {
$reason = $failRhythm ? "Сбой ритма" : "Падение температуры";
if (!$alarmActive) {
sg($monitor_obj . '.alarmActive', 1);
sg($monitor_obj . '.lastAlarmTime', time());
sg($monitor_obj . '.lastMsgTime', time());
$logMessage = "КОТЕЛ: АВАРИЯ! Причина: $reason. Текущая температура: {$currentTemp}°C";
DebMes($logMessage); // <<-- ДОБАВЛЕНА СТРОКА ДЛЯ ЛОГА
say("Внимание! Авария котла. $reason", 5);
// ИСПРАВЛЕНИЕ 5b: Опечатка в иконке
sendMessage("🚨 <b>АВАРИЯ КОТЛА</b>\n\nПричина: $reason\nТемпература: {$currentTemp}°C");
// Отправить график при входе в аварию (один раз при первом срабатывании)
$chartSentAlarm = createAndSendTemperatureChart($history, $sensor_obj, $currentTemp, $HISTORY_HOURS);
if ($chartSentAlarm) {
DebMes("КОТЕЛ: АВАРИЯ - график отправлен");
} else {
DebMes("КОТЕЛ: АВАРИЯ - не удалось отправить график");
}
}
// Мигание
cm($relay_obj . '.turnOn');
SetTimeOut("boiler_blink_" . time(), "cm('" . $relay_obj . ".turnOff');", 50);
// Напоминание
$lastMsg = (int)gg($monitor_obj . '.lastMsgTime');
if ($alarmActive && (time() - $lastMsg > 1800)) {
say("Котел в аварии. T=$currentTemp", 2);
sendMessage("⏰ <b>КОТЕЛ В АВАРИИ</b>\n\nТемпература: {$currentTemp}°C");
sg($monitor_obj . '.lastMsgTime', time());
}
} elseif ($alarmActive && $currentTemp >= $midTemp) {
// Сброс тревоги
sg($monitor_obj . '.alarmActive', 0);
cm($relay_obj . '.turnOff');
$msg = "Котел восстановлен. T=$currentTemp";
DebMes("КОТЕЛ: РАБОТА ВОССТАНОВЛЕНА. Текущая температура: {$currentTemp}°C"); // <<-- ДОБАВЛЕНА СТРОКА ДЛЯ ЛОГА
say($msg, 2);
sendMessage("✅ <b>КОТЕЛ ВОССТАНОВЛЕН</b>\n\nТемпература: {$currentTemp}°C");
}