= 0 && ord($cut[$i]) >= 0x80; $i--) { $highBytes++; } if ($highBytes % 2 === 1) { $cut = substr($cut, 0, -1); } return $cut; } /** * Cafe24 SMS API로 문자 발송 * * @param string $receiver 수신자 번호 (숫자만) * @param string $message 메시지 내용 * @return array ['success'=>bool, 'result_code'=>string, 'message'=>string] */ function send_sms_cafe24(string $receiver, string $message): array { $receiver = preg_replace('/\D+/', '', $receiver); if ($receiver === '' || trim($message) === '') { return ['success' => false, 'result_code' => 'PARAM_ERR', 'message' => '수신자 또는 메시지가 비어있습니다.']; } // 카오스 드릴: 실제 전송 없이 실패를 반환한다(실패 경로 시험). 운영에선 false. if (SMS_FORCE_FAIL) { return ['success' => false, 'result_code' => 'FORCED_FAIL', 'message' => '드릴: 강제 실패', 'remaining' => null]; } [$sphone1, $sphone2, $sphone3] = split_sender_number(SMS_SENDER); $encodedMessage = iconv('UTF-8', 'EUC-KR//IGNORE', trim($message)); if ($encodedMessage === false || $encodedMessage === '') { return ['success' => false, 'result_code' => 'ENCODE_ERR', 'message' => 'EUC-KR 인코딩 실패']; } // 단문(SMS, 'S') 길이 상한(EUC-KR 바이트). 초과분은 게이트웨이가 잘라/거부하므로 // 멀티바이트 경계를 보존하며 발송 전에 안전하게 자른다(무성 절단 방지). $origBytes = strlen($encodedMessage); $encodedMessage = sms_truncate_euckr($encodedMessage, SMS_MAX_EUCKR_BYTES); if (strlen($encodedMessage) < $origBytes) { error_log(sprintf('[Cafe24 SMS] 메시지 길이 초과 절단: %dB → %dB (상한 %dB)', $origBytes, strlen($encodedMessage), SMS_MAX_EUCKR_BYTES)); } $postData = [ 'action' => 'go', 'user_id' => base64_encode(SMS_USER_ID), 'secure' => base64_encode(SMS_SECURE), 'msg' => base64_encode($encodedMessage), 'rphone' => base64_encode($receiver), 'sphone1' => base64_encode($sphone1), 'sphone2' => base64_encode($sphone2), 'sphone3' => base64_encode($sphone3), 'rdate' => base64_encode(''), 'rtime' => base64_encode(''), 'mode' => base64_encode('1'), 'returnurl' => base64_encode(''), 'testflag' => base64_encode(''), 'destination'=> strtr(base64_encode(''), '+/=', '-,'), 'repeatFlag' => base64_encode(''), 'repeatNum' => base64_encode(''), 'repeatTime' => base64_encode(''), 'smsType' => base64_encode('S'), ]; $ch = curl_init(); if ($ch === false) { return ['success' => false, 'result_code' => 'CURL_INIT_ERR', 'message' => 'cURL 초기화 실패']; } curl_setopt_array($ch, [ CURLOPT_URL => SMS_ENDPOINT, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $postData, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); $response = curl_exec($ch); $errorNo = curl_errno($ch); $errorMsg = curl_error($ch); curl_close($ch); if ($response === false || $errorNo !== 0) { $errText = $errorMsg !== '' ? $errorMsg : '알 수 없는 오류'; error_log(sprintf('[Cafe24 SMS] cURL 오류(code=%d): %s', $errorNo, $errText)); return ['success' => false, 'result_code' => 'CURL_ERR', 'message' => $errText]; } $responseBody = trim($response); if ($responseBody === '') { return ['success' => false, 'result_code' => 'EMPTY_RESP', 'message' => '빈 응답']; } $parts = explode(',', $responseBody); $resultCode = $parts[0] ?? ''; $remaining = $parts[1] ?? ''; error_log(sprintf('[Cafe24 SMS] 응답: result=%s, remaining=%s', $resultCode, $remaining)); $success = in_array(strtolower($resultCode), ['success', 'reserved'], true); return [ 'success' => $success, 'result_code' => $resultCode, 'message' => $success ? "발송 성공 (잔여 {$remaining}건)" : "발송 실패: {$resultCode}", // 게이트웨이가 돌려준 잔여 건수(숫자면 int, 아니면 null). 잔액 사전경보에 사용. 'remaining' => is_numeric($remaining) ? (int)$remaining : null, ]; } /** * 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 leak_sms_log 에 기록한다. * * @param PDO $db * @param array $sensor_data ['sensor_id','sensor_name','device_id', ...] * @param string $message 발송할 메시지 본문 * @param string $logLabel error_log 식별 라벨 * @return bool 전원 발송 성공 여부 */ function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message, string $logLabel, ?array $recipients = null): bool { $name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}"; $allSuccess = true; $lastRemaining = null; // 게이트웨이가 알려준 최신 잔여 건수(잔액 사전경보용) $targets = $recipients ?? SMS_RECIPIENTS; // 기본 1차, 에스컬레이션은 TIER2 전달 foreach ($targets as $receiver) { $receiverDigits = preg_replace('/\D+/', '', $receiver); $result = send_sms_cafe24($receiverDigits, $message); if (($result['remaining'] ?? null) !== null) { $lastRemaining = (int)$result['remaining']; } $stmt = $db->prepare(" INSERT INTO leak_sms_log (sensor_id, sensor_name, device_id, message, receivers, status, result_code, result_message, sent_at) VALUES (:sensor_id, :sensor_name, :device_id, :message, :receivers, :status, :result_code, :result_message, NOW()) "); $stmt->execute([ ':sensor_id' => $sensor_data['sensor_id'], ':sensor_name' => $name, ':device_id' => $sensor_data['device_id'] ?? '', ':message' => $message, ':receivers' => $receiverDigits, ':status' => $result['success'] ? 'success' : 'fail', ':result_code' => $result['result_code'], ':result_message' => $result['message'], ]); if ($result['success']) { error_log("[SMS] {$logLabel} 발송 성공 - 센서{$sensor_data['sensor_id']} → {$receiverDigits}"); } else { error_log("[SMS] {$logLabel} 발송 실패 - {$receiverDigits}: {$result['result_code']} {$result['message']}"); $allSuccess = false; } } // 발송 직후 잔여 건수가 임계 이하이면(쿨다운 1회) 사전경보. 직접 발송이라 재귀 없음. maybe_warn_sms_balance($db, $lastRemaining); return $allSuccess; } /** * SMS 잔여 건수가 임계 이하이면 '[SMS 잔액부족]' 경고를 1회 발송한다(쿨다운 적용). * 게이트웨이 응답에서 얻은 잔여 건수를 그대로 받는다(추가 외부호출 없음). * 경고는 sms_dispatch 를 거치지 않고 직접 발송·기록하여 재귀를 피한다. */ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void { if ($remaining === null || $remaining > SMS_BALANCE_WARN_THRESHOLD) { return; } // 쿨다운: 최근 '[SMS 잔액부족]' 성공/실패 불문 발송이 있으면 생략(반복 폭주 방지). $recent = $db->prepare(" SELECT sent_at FROM leak_sms_log WHERE message LIKE :tag ORDER BY sent_at DESC LIMIT 1 "); $recent->execute([':tag' => SMS_TAG_BALANCE_LOW . '%']); $last_at = $recent->fetchColumn(); if ($last_at !== false && (time() - strtotime($last_at)) < SMS_BALANCE_WARN_COOLDOWN_SEC) { return; } $message = SMS_TAG_BALANCE_LOW . "\n" . "SMS 잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건 이하)\n" . "충전하지 않으면 경보 발송이 중단됩니다.\n" . "시각: " . date('Y-m-d H:i:s'); foreach (SMS_RECIPIENTS as $receiver) { $receiverDigits = preg_replace('/\D+/', '', $receiver); $result = send_sms_cafe24($receiverDigits, $message); $stmt = $db->prepare(" INSERT INTO leak_sms_log (sensor_id, sensor_name, device_id, message, receivers, status, result_code, result_message, sent_at) VALUES (0, 'SMS잔액', '', :message, :receivers, :status, :result_code, :result_message, NOW()) "); $stmt->execute([ ':message' => $message, ':receivers' => $receiverDigits, ':status' => $result['success'] ? 'success' : 'fail', ':result_code' => $result['result_code'], ':result_message' => $result['message'], ]); } error_log("[SMS] 잔액부족 경고 발송 - 잔여 {$remaining}건"); } /** * 측정값을 사람이 읽는 짧은 문자열로 (불필요한 소수점 0 제거). 예: 30.0 -> "30". */ function metric_value_str(float $value): string { $s = number_format($value, 1, '.', ''); if (strpos($s, '.') !== false) { $s = rtrim(rtrim($s, '0'), '.'); } return $s; } // dew_point_c() 정의는 forecast.php 로 이동(단일 출처). 여기서는 require 로 로드해 사용. /** * 결로마진(℃) = 현재온도 − 이슬점. 좁을수록 결로 위험. 측정 결측/산출불가면 null. */ function condensation_margin_c(?float $temp_c, ?float $rh): ?float { if ($temp_c === null || $rh === null) { return null; } $td = dew_point_c($temp_c, $rh); if ($td === null) { return null; } return $temp_c - $td; } /** * 결로 위험이 해소(정상)인지(히스테리시스 적용). 결로 경보 비활성/결측/산출불가는 * 정상으로 간주한다. 복귀 판정에서 사용. */ function condensation_is_clear(?float $temp_c, ?float $rh): bool { if (METRIC_DEWPOINT_MARGIN_C <= 0 || $temp_c === null || $rh === null) { return true; } $margin = condensation_margin_c($temp_c, $rh); if ($margin === null) { return true; } return $margin > METRIC_DEWPOINT_MARGIN_C + METRIC_DEWPOINT_HYSTERESIS_C; } /** * 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다. * @return array{breaches: string[], status: string} * breaches: 발생한 경보 종류 목록, status: leak_sensor_metric 저장용 대표 상태 */ function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array { $breaches = []; if ($temp_c !== null) { if ($temp_c >= METRIC_TEMP_HIGH_C) { $breaches[] = 'high_temp'; } elseif ($temp_c <= METRIC_TEMP_LOW_C) { $breaches[] = 'low_temp'; } } if ($rh !== null) { if ($rh >= METRIC_RH_HIGH) { $breaches[] = 'high_humidity'; } elseif ($rh <= METRIC_RH_LOW) { $breaches[] = 'low_humidity'; } } // 결로(이슬점) 위험: 온도·습도가 각각 정상이어도 결로마진이 좁으면 발화(말미에 추가 // 하여, 동시 발생 시 대표 status 는 온/습도 임계가 우선하도록 한다). if (METRIC_DEWPOINT_MARGIN_C > 0 && $temp_c !== null && $rh !== null) { $margin = condensation_margin_c($temp_c, $rh); if ($margin !== null && $margin <= METRIC_DEWPOINT_MARGIN_C) { $breaches[] = 'condensation_risk'; } } return [ 'breaches' => $breaches, 'status' => empty($breaches) ? 'normal' : $breaches[0], ]; } /** * 정상 복귀 판정(히스테리시스 적용). 임계 안쪽으로 여유만큼 들어와야 true. * 결로 위험도 해소되어야 복귀로 본다. 측정 결측(null)은 해당 지표를 정상으로 간주한다. */ function metric_is_recovered(?float $temp_c, ?float $rh): bool { $tOk = ($temp_c === null) || ( $temp_c < METRIC_TEMP_HIGH_C - METRIC_TEMP_HYSTERESIS_C && $temp_c > METRIC_TEMP_LOW_C + METRIC_TEMP_HYSTERESIS_C ); $hOk = ($rh === null) || ( $rh < METRIC_RH_HIGH - METRIC_RH_HYSTERESIS && $rh > METRIC_RH_LOW + METRIC_RH_HYSTERESIS ); return $tOk && $hOk && condensation_is_clear($temp_c, $rh); } /** * 센서가 현재 임계 경보 상태인지 판정한다(최근 metric SMS가 경보이고 그 뒤 복귀가 * 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(leak_sms_log 기준). */ function metric_was_alerting(PDO $db, int $sensor_id): bool { // 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지. // 결로(결로주의)도 1급 경보이므로 포함해야, 복귀(정상복귀) SMS 1회가 정상 발화한다. $tags = [ SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY, SMS_TAG_CONDENSATION, SMS_TAG_RECOVERY, ]; $likes = []; $params = [':sid' => $sensor_id]; foreach ($tags as $i => $tag) { $key = ":t{$i}"; $likes[] = "message LIKE {$key}"; $params[$key] = $tag . '%'; } $sql = "SELECT message FROM leak_sms_log WHERE sensor_id = :sid AND status = 'success' AND (" . implode(' OR ', $likes) . ") ORDER BY sent_at DESC, id DESC LIMIT 1"; $stmt = $db->prepare($sql); $stmt->execute($params); $msg = $stmt->fetchColumn(); if ($msg === false) { return false; } return !str_starts_with($msg, SMS_TAG_RECOVERY); } /** * 에피소드 인지 쿨다운 판정(순수 함수 — DB/네트워크 없음, 테스트 용이). * * $latestMessage/$latestSentAt 은 "이 경보 태그" 또는 "복구 태그" 로 시작하는 SMS 중 * 가장 최근 1건(없으면 둘 다 null). 최신이 경보 태그로 시작하고(=아직 미복구) 발송 * 시각이 쿨다운 이내이면 true(생략). 최신이 복구면 에피소드 종료 → false(발송). * * 이렇게 하면 "같은 경보 지속 중"의 중복 폭주는 계속 억제하되, 복구 후 재발한 경보는 * 별개 에피소드로 보아 즉시 발송한다(쿨다운이 복구 직후의 새 사고를 삼키던 버그 수정). * * @param ?string $latestMessage 최신 경보/복구 SMS 본문(없으면 null) * @param ?string $latestSentAt 그 sent_at(파싱 가능한 일시 문자열, 없으면 null) * @param string $alertTag 이 경보의 태그(prefix). 최신이 이걸로 시작해야 억제 후보. * @param int $cooldownSec 쿨다운(초) * @param int $now 현재 Unix 시각(테스트 주입용) */ function alert_cooldown_suppresses( ?string $latestMessage, ?string $latestSentAt, string $alertTag, int $cooldownSec, int $now ): bool { if ($latestMessage === null || $latestSentAt === null) { return false; // 이력 없음 → 발송 } if (!str_starts_with($latestMessage, $alertTag)) { return false; // 최신이 복구(또는 다른 상태) → 새 에피소드 → 발송 } return ($now - strtotime($latestSentAt)) < $cooldownSec; } /** * 온습도 임계 경보 SMS 발송 (종류별 쿨다운 포함). * * 쿨다운: 같은 종류([고온경보] 등)의 최근 성공 SMS가 METRIC_ALERT_COOLDOWN_SEC * 이내면 발송을 생략한다(중복 폭주 방지). * * @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location'] * @param string $kind 'high_temp'|'low_temp'|'high_humidity'|'low_humidity' * @param float $value 측정값 (온도℃ 또는 습도%) * @return bool 발송 시도 여부 (쿨다운/오류로 생략 시 false) */ function send_metric_alert_sms(array $sensor_data, string $kind, float $value): bool { $db = get_db(); $sensor_id = (int)$sensor_data['sensor_id']; // [태그, 지표명, 단위, 임계값, 방향문구] (태그는 config.php 단일 출처 상수) $labels = [ 'high_temp' => [SMS_TAG_HIGH_TEMP, '온도', '℃', METRIC_TEMP_HIGH_C, '이상'], 'low_temp' => [SMS_TAG_LOW_TEMP, '온도', '℃', METRIC_TEMP_LOW_C, '이하'], 'high_humidity' => [SMS_TAG_HIGH_HUMIDITY, '습도', '%', METRIC_RH_HIGH, '이상'], 'low_humidity' => [SMS_TAG_LOW_HUMIDITY, '습도', '%', METRIC_RH_LOW, '이하'], 'condensation_risk' => [SMS_TAG_CONDENSATION, '결로마진', '℃', METRIC_DEWPOINT_MARGIN_C, '이하'], ]; if (!isset($labels[$kind])) { error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}"); return false; } [$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind]; // ── 동시 발송 직렬화 (중복 SMS 방지) ──────────────────────── // 쿨다운 검사→발송→leak_sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이 // 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위 // MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를 // 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로 // 안전하게 폴백한다. (같은 본문 재시도/재생은 sensor_data.php 의 nonce 멱등에서 선차단) $lockName = substr("sms_alert_{$sensor_id}_{$kind}", 0, 64); $lockState = 'unsupported'; try { $lk = $db->prepare('SELECT GET_LOCK(:n, 0)'); $lk->execute([':n' => $lockName]); $res = $lk->fetchColumn(); $lockState = ($res === null || $res === false) ? 'unsupported' : (((int)$res === 1) ? 'acquired' : 'busy'); } catch (Throwable $e) { $lockState = 'unsupported'; error_log("[SMS] GET_LOCK 사용 불가({$lockName}): " . $e->getMessage()); } if ($lockState === 'busy') { error_log("[SMS] {$tag} 동시 처리 감지 - 센서{$sensor_id} 중복 발송 생략"); return false; } try { // ── 종류별 쿨다운 (에피소드 인지) ──────────────────────────────── // 이 종류 경보([고온경보] 등)와 정상복귀([정상복귀]) 중 최신 1건을 본다. 최신이 // 이 종류 경보이고(=아직 미복귀) 쿨다운 이내일 때만 생략한다. 최신이 정상복귀면 // 에피소드가 끝난 것이라 재발한 경보는 별개 에피소드 → 즉시 발송한다. // (과거 버그: 종류 태그만 봐서, 복귀 직후 재발한 경보가 이전 경보의 쿨다운에 // 걸려 통째로 누락됐다.) $recent = $db->prepare(" SELECT message, sent_at FROM leak_sms_log WHERE sensor_id = :sensor_id AND status = 'success' AND (message LIKE :alert OR message LIKE :recovery) ORDER BY sent_at DESC, id DESC LIMIT 1 "); $recent->execute([ ':sensor_id' => $sensor_id, ':alert' => $tag . '%', ':recovery' => SMS_TAG_RECOVERY . '%', ]); $row = $recent->fetch(); $lastMsg = ($row === false) ? null : (string)$row['message']; $lastAt = ($row === false) ? null : (string)$row['sent_at']; if (alert_cooldown_suppresses($lastMsg, $lastAt, $tag, METRIC_ALERT_COOLDOWN_SEC, time())) { $elapsed = time() - strtotime((string)$lastAt); error_log("[SMS] {$tag} 쿨다운 생략(지속중) - 센서{$sensor_id} ({$elapsed}s/" . METRIC_ALERT_COOLDOWN_SEC . "s)"); return false; } // ── 메시지 구성 ────────────────────────────────────────── $dt = date('Y-m-d H:i:s'); $name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}"; $loc = $sensor_data['device_location'] ?? '알 수 없음'; $valStr = metric_value_str($value); $thrStr = metric_value_str((float)$threshold); $message = "{$tag}\n" . "위치: {$loc}\n" . "센서: {$name}\n" . "{$metricName}: {$valStr}{$unit} (임계 {$thrStr}{$unit} {$dir})\n" . "시각: {$dt}"; return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}"); } finally { if ($lockState === 'acquired') { try { $db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]); } catch (Throwable $e) { error_log("[SMS] RELEASE_LOCK 실패({$lockName}): " . $e->getMessage()); } } } } /** * 온습도 정상 복귀 SMS 발송. * * @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location'] * @param float $temp_c 현재 온도(℃) * @param float $rh 현재 습도(%) * @return bool 발송 성공 여부 */ function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh): bool { $db = get_db(); $dt = date('Y-m-d H:i:s'); $name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}"; $loc = $sensor_data['device_location'] ?? '알 수 없음'; $message = SMS_TAG_RECOVERY . "\n" . "위치: {$loc}\n" . "센서: {$name}\n" . "온도 " . metric_value_str($temp_c) . "℃ / 습도 " . metric_value_str($rh) . "%\n" . "시각: {$dt}\n" . "온습도가 정상 범위로 복귀했습니다."; return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀"); } /** * 누수 감지 SMS 발송 (1번 모듈). 쿨다운 포함. * * 쿨다운: 같은 센서의 최근 성공한 [누수감지] SMS가 LEAK_ALERT_COOLDOWN_SEC 이내면 * 발송을 생략한다(누수 지속 중 주기 보고로 인한 중복 폭주 방지). * 동시 처리 직렬화는 sms_send 의 metric 경보와 동일하게 MySQL 네임드 락으로 보장한다. * * @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location'] * @return bool 발송 시도 여부 (쿨다운/오류로 생략 시 false) */ function send_leak_alert_sms(array $sensor_data): bool { $db = get_db(); $sensor_id = (int)$sensor_data['sensor_id']; // ── 동시 발송 직렬화 (중복 SMS 방지) ── $lockName = substr("sms_leak_alert_{$sensor_id}", 0, 64); $lockState = 'unsupported'; try { $lk = $db->prepare('SELECT GET_LOCK(:n, 0)'); $lk->execute([':n' => $lockName]); $res = $lk->fetchColumn(); $lockState = ($res === null || $res === false) ? 'unsupported' : (((int)$res === 1) ? 'acquired' : 'busy'); } catch (Throwable $e) { $lockState = 'unsupported'; error_log("[SMS] GET_LOCK 사용 불가({$lockName}): " . $e->getMessage()); } if ($lockState === 'busy') { error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 동시 처리 감지 - 센서{$sensor_id} 중복 발송 생략"); return false; } try { // ── 쿨다운 (에피소드 인지) ── // [누수감지]와 [누수복구] 중 최신 1건을 본다. 최신이 [누수감지](=아직 미복구)이고 // 쿨다운 이내일 때만 생략(지속 누수 중 주기보고 폭주 방지). 최신이 [누수복구]면 // 사고가 끝난 것이므로 재발한 누수는 별개 에피소드 → 즉시 발송한다. // (과거 버그: [누수감지] 태그만 봐서, 복구 직후 재발한 누수가 이전 감지의 쿨다운에 // 걸려 통째로 누락됐다.) $recent = $db->prepare(" SELECT message, sent_at FROM leak_sms_log WHERE sensor_id = :sensor_id AND status = 'success' AND (message LIKE :alert OR message LIKE :recovery) ORDER BY sent_at DESC, id DESC LIMIT 1 "); $recent->execute([ ':sensor_id' => $sensor_id, ':alert' => SMS_TAG_LEAK_ALERT . '%', ':recovery' => SMS_TAG_LEAK_RECOVERY . '%', ]); $row = $recent->fetch(); $lastMsg = ($row === false) ? null : (string)$row['message']; $lastAt = ($row === false) ? null : (string)$row['sent_at']; if (alert_cooldown_suppresses($lastMsg, $lastAt, SMS_TAG_LEAK_ALERT, LEAK_ALERT_COOLDOWN_SEC, time())) { $elapsed = time() - strtotime((string)$lastAt); error_log("[SMS] " . SMS_TAG_LEAK_ALERT . " 쿨다운 생략(지속중) - 센서{$sensor_id} ({$elapsed}s/" . LEAK_ALERT_COOLDOWN_SEC . "s)"); return false; } // ── 메시지 구성 ── $dt = date('Y-m-d H:i:s'); $name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}"; $loc = $sensor_data['device_location'] ?? '알 수 없음'; $message = SMS_TAG_LEAK_ALERT . "\n" . "위치: {$loc}\n" . "센서: {$name}\n" . "누수가 감지되었습니다. 즉시 확인하세요!\n" . "시각: {$dt}"; return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수감지"); } finally { if ($lockState === 'acquired') { try { $db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]); } catch (Throwable $e) { error_log("[SMS] RELEASE_LOCK 실패({$lockName}): " . $e->getMessage()); } } } } /** * 누수 복구 SMS 발송 (1번 모듈). 직전에 누수 경보([누수감지])가 있었고 그 뒤 복구 * 통지가 없었을 때만 1회 발송한다(복구만 단독으로 발송되는 것 방지). * * @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location'] * @return bool 발송 성공 여부 (경보 이력 없거나 이미 복구 통지면 false) */ function send_leak_recovery_sms(array $sensor_data): bool { $db = get_db(); $sensor_id = (int)$sensor_data['sensor_id']; // 직전 누수 상태 SMS([누수감지]/[누수복구]) 중 최신 1건을 본다. 최신이 [누수감지]일 // 때만(=아직 복구 통지 전) 복구 SMS 를 보낸다. $stmt = $db->prepare(" SELECT message FROM leak_sms_log WHERE sensor_id = :sid AND status = 'success' AND (message LIKE :alert OR message LIKE :recovery) ORDER BY sent_at DESC, id DESC LIMIT 1 "); $stmt->execute([ ':sid' => $sensor_id, ':alert' => SMS_TAG_LEAK_ALERT . '%', ':recovery' => SMS_TAG_LEAK_RECOVERY . '%', ]); $last = $stmt->fetchColumn(); if ($last === false || !str_starts_with((string)$last, SMS_TAG_LEAK_ALERT)) { return false; // 직전 누수 경보 없음(또는 이미 복구 통지함) → 발송 안 함 } $dt = date('Y-m-d H:i:s'); $name = $sensor_data['sensor_name'] ?? "센서{$sensor_id}"; $loc = $sensor_data['device_location'] ?? '알 수 없음'; $message = SMS_TAG_LEAK_RECOVERY . "\n" . "위치: {$loc}\n" . "센서: {$name}\n" . "누수가 해소(복구)되었습니다.\n" . "시각: {$dt}"; return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수복구"); } /** * 장비 오프라인 SMS 발송 * * @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location','last_seen'] * @return bool 발송 성공 여부 */ function send_device_offline_sms(array $sensor_data): bool { $db = get_db(); $dt = date('Y-m-d H:i:s'); $name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}"; $loc = $sensor_data['device_location'] ?? '알 수 없음'; $last = $sensor_data['last_seen'] ?? '알 수 없음'; $message = SMS_TAG_DEVICE_OFFLINE . "\n" . "위치: {$loc}\n" . "센서: {$name}\n" . "마지막 통신: {$last}\n" . "감지 시각: {$dt}\n" . "장비 상태를 확인하세요!"; return sms_dispatch_to_recipients($db, $sensor_data, $message, "오프라인"); } /** * 장비 온라인 복구 SMS 발송 * * @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location'] * @return bool 발송 성공 여부 */ function send_device_online_sms(array $sensor_data): bool { $db = get_db(); $dt = date('Y-m-d H:i:s'); $name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}"; $loc = $sensor_data['device_location'] ?? '알 수 없음'; $message = SMS_TAG_DEVICE_ONLINE . "\n" . "위치: {$loc}\n" . "센서: {$name}\n" . "복구 시각: {$dt}\n" . "통신이 정상 복구되었습니다."; return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구"); } /** * 일일 헬스(생존신호) 요약 문자열. 짧게 유지(SMS 단문 길이 고려). * 예: "센서 2/2 정상, 최근수신 3분전, 24h경보 0건, 잔여 95건" */ function build_health_summary(PDO $db): string { $online = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status WHERE offline_alerted = 0")->fetchColumn(); $total = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status")->fetchColumn(); $lastSeen = $db->query("SELECT TIMESTAMPDIFF(MINUTE, MAX(last_seen), NOW()) FROM leak_sensor_status")->fetchColumn(); $recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분전'); $alerts = (int)$db->query(" SELECT COUNT(*) FROM leak_sms_log WHERE sent_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) AND status = 'success' AND (message LIKE '" . SMS_TAG_HIGH_TEMP . "%' OR message LIKE '" . SMS_TAG_LOW_TEMP . "%' OR message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%' OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') ")->fetchColumn(); $summary = "센서 {$online}/{$total} 정상, 최근수신 {$recvStr}, 24h경보 {$alerts}건"; $bal = latest_sms_balance($db); if ($bal !== null) { $summary .= ", 잔여 {$bal}건"; } return $summary; } /** * 하루 1회 '[정기점검]' 생존신호 SMS 를 보낸다(데드맨). HEALTH_SMS_HOUR 시 이후의 * 첫 호출에서, 오늘자 발송이 없을 때만 발송한다. cron_heartbeat 가 주기적으로 호출. * 날짜 dedup + 네임드 락으로 중복 발송을 막는다(락 미지원 시 날짜 dedup 만으로 방어). */ function maybe_send_daily_health_sms(PDO $db): void { if (!HEALTH_SMS_ENABLED || (int)date('G') < HEALTH_SMS_HOUR) { return; } $today = $db->prepare(" SELECT 1 FROM leak_sms_log WHERE message LIKE :tag AND DATE(sent_at) = CURDATE() LIMIT 1 "); $today->execute([':tag' => SMS_TAG_HEALTH . '%']); if ($today->fetchColumn() !== false) { return; // 오늘 이미 발송함 } $lockState = 'unsupported'; try { $lk = $db->prepare('SELECT GET_LOCK(:n, 0)'); $lk->execute([':n' => 'daily_health_sms']); $res = $lk->fetchColumn(); $lockState = ($res === null || $res === false) ? 'unsupported' : (((int)$res === 1) ? 'acquired' : 'busy'); } catch (Throwable $e) { $lockState = 'unsupported'; } if ($lockState === 'busy') { return; // 다른 프로세스가 처리 중 } try { // 락 획득 후 이중 확인(획득 사이에 다른 프로세스가 발송했을 수 있음). $today->execute([':tag' => SMS_TAG_HEALTH . '%']); if ($today->fetchColumn() !== false) { return; } $message = SMS_TAG_HEALTH . "\n" . build_health_summary($db); $info = ['sensor_id' => 0, 'sensor_name' => '정기점검', 'device_id' => '', 'device_location' => '']; sms_dispatch_to_recipients($db, $info, $message, "정기점검"); error_log("[SMS] 정기점검(생존신호) 발송"); } finally { if ($lockState === 'acquired') { try { $db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => 'daily_health_sms']); } catch (Throwable $e) { /* 해제 실패 무시 */ } } } } /** * 특정 태그의 최근 발송이 $seconds 이내인지(쿨다운 판정). 태그별 1회 발송 억제에 사용. */ function recent_tag_within(PDO $db, int $sensor_id, string $tag, int $seconds): bool { $stmt = $db->prepare(" SELECT sent_at FROM leak_sms_log WHERE sensor_id = :sid AND message LIKE :tag ORDER BY sent_at DESC LIMIT 1 "); $stmt->execute([':sid' => $sensor_id, ':tag' => $tag . '%']); $last = $stmt->fetchColumn(); return $last !== false && (time() - strtotime($last)) < $seconds; } /** * 모집단 표준편차(σ). 측정값 고착(변화 없음) 판정에 사용. */ function stddev_pop(array $values): float { $n = count($values); if ($n === 0) { return 0.0; } $mean = array_sum($values) / $n; $sum = 0.0; foreach ($values as $v) { $d = (float)$v - $mean; $sum += $d * $d; } return sqrt($sum / $n); } /** * 센서별 최신 경보-상태 SMS 1건(경보/복귀/에스컬레이션 태그 중). 에스컬레이션 판정용. */ function latest_alert_state_row(PDO $db, int $sensor_id): ?array { $tags = [ SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY, SMS_TAG_CONDENSATION, SMS_TAG_RECOVERY, SMS_TAG_ESCALATION, ]; $likes = []; $params = [':sid' => $sensor_id]; foreach ($tags as $i => $t) { $k = ":t{$i}"; $likes[] = "message LIKE {$k}"; $params[$k] = $t . '%'; } // id(AUTO_INCREMENT) 를 2차 정렬키로 둬, 같은 초에 경보·복귀가 기록돼도 최신 행이 // 결정적으로 선택되게 한다(sent_at 은 1초 해상도). $sql = "SELECT message, sent_at FROM leak_sms_log WHERE sensor_id = :sid AND status = 'success' AND (" . implode(' OR ', $likes) . ") ORDER BY sent_at DESC, id DESC LIMIT 1"; $stmt = $db->prepare($sql); $stmt->execute($params); $row = $stmt->fetch(); return $row ?: null; } /** * 미복귀 임계 경보를 2차(야간) 수신자에게 에스컬레이션한다(에피소드당 1회). 각 센서의 * 최신 경보-상태가 '경보'이고 ESCALATE_AFTER_SEC 이상 지속(미복귀·미에스컬레이션)이면 * SMS_RECIPIENTS_TIER2 로 1회 통지. TIER2 가 비면 비활성(opt-in). cron_heartbeat 가 호출. */ function maybe_escalate_alerts(PDO $db): void { if (empty(SMS_RECIPIENTS_TIER2) || ESCALATE_AFTER_SEC <= 0) { return; } $alertTags = [SMS_TAG_HIGH_TEMP, SMS_TAG_LOW_TEMP, SMS_TAG_HIGH_HUMIDITY, SMS_TAG_LOW_HUMIDITY, SMS_TAG_CONDENSATION]; $sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM leak_sensor_status")->fetchAll(); foreach ($sensors as $s) { $sid = (int)$s['sensor_id']; $row = latest_alert_state_row($db, $sid); if ($row === null) { continue; } $msg = (string)$row['message']; // 최신이 복귀/이미 에스컬레이션이면 에피소드 종료/처리됨 → 스킵. if (str_starts_with($msg, SMS_TAG_RECOVERY) || str_starts_with($msg, SMS_TAG_ESCALATION)) { continue; } $isAlert = false; foreach ($alertTags as $t) { if (str_starts_with($msg, $t)) { $isAlert = true; break; } } if (!$isAlert || (time() - strtotime((string)$row['sent_at'])) < ESCALATE_AFTER_SEC) { continue; } // 이미 에스컬레이션을 시도했으면(성공/실패 불문) 재발화 금지. latest_alert_state_row // 는 success 만 보므로, 실패한 에스컬레이션 행이 보이지 않아 매 주기 재발송되는 것을 // 막는다(상태 무관 recent_tag_within 으로 가드 — 에피소드당 1회 보장). if (recent_tag_within($db, $sid, SMS_TAG_ESCALATION, ESCALATE_AFTER_SEC)) { continue; } $firstLine = strtok($msg, "\n"); $name = $s['sensor_name'] ?: "센서{$sid}"; $loc = $s['device_location'] ?: '알 수 없음'; $mins = (int)floor((time() - strtotime((string)$row['sent_at'])) / 60); $message = SMS_TAG_ESCALATION . "\n" . "위치: {$loc}\n센서: {$name}\n" . "{$firstLine} 미복귀 {$mins}분 경과\n" . "1차 미대응 — 확인 바랍니다.\n시각: " . date('Y-m-d H:i:s'); $info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc]; sms_dispatch_to_recipients($db, $info, $message, "에스컬레이션", SMS_RECIPIENTS_TIER2); error_log("[SMS] 에스컬레이션 발송 - 센서{$sid} ({$mins}분 미복귀)"); } } /** * 측정값 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 온도·습도 표준편차가 * 둘 다 STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장을 의심해 1회 알린다(쿨다운). * 하트비트(수신 끊김)로는 못 잡는 무성실패를 보완. cron_heartbeat 가 호출. */ function maybe_detect_stuck_sensors(PDO $db): void { if (!STUCK_DETECT_ENABLED || STUCK_DETECT_SAMPLES < 3) { return; } $n = (int)STUCK_DETECT_SAMPLES; // 정수 상수 — LIMIT 에 안전하게 인라인 $sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM leak_sensor_status")->fetchAll(); foreach ($sensors as $s) { $sid = (int)$s['sensor_id']; $stmt = $db->prepare(" SELECT temperature_c, humidity_percent FROM leak_sensor_metric WHERE sensor_id = :sid ORDER BY measured_at DESC LIMIT {$n} "); $stmt->execute([':sid' => $sid]); $rows = $stmt->fetchAll(); if (count($rows) < $n) { continue; // 표본 부족 } $temps = array_values(array_filter(array_column($rows, 'temperature_c'), fn($v) => $v !== null)); $hums = array_values(array_filter(array_column($rows, 'humidity_percent'), fn($v) => $v !== null)); // 온도·습도 둘 다 충분한 표본이 완전 평탄해야 고착으로 본다(한쪽만 평탄은 정상 가능). $tStuck = count($temps) >= $n && stddev_pop($temps) < STUCK_DETECT_EPSILON; $hStuck = count($hums) >= $n && stddev_pop($hums) < STUCK_DETECT_EPSILON; if (!($tStuck && $hStuck)) { continue; } if (recent_tag_within($db, $sid, SMS_TAG_SENSOR_STUCK, STUCK_ALERT_COOLDOWN_SEC)) { continue; // 쿨다운 } $name = $s['sensor_name'] ?: "센서{$sid}"; $loc = $s['device_location'] ?: '알 수 없음'; $tval = number_format((float)$rows[0]['temperature_c'], 2); $hval = number_format((float)$rows[0]['humidity_percent'], 2); $message = SMS_TAG_SENSOR_STUCK . "\n위치: {$loc}\n센서: {$name}\n" . "최근 {$n}회 측정값 고착(온도 {$tval}℃ / 습도 {$hval}%)\n" . "센서/배선 점검 필요\n시각: " . date('Y-m-d H:i:s'); $info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc]; sms_dispatch_to_recipients($db, $info, $message, "센서고착"); error_log("[SMS] 센서고착 의심 - 센서{$sid} (최근 {$n}회 평탄)"); } }