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' => '수신자 또는 메시지가 비어있습니다.']; } [$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 인코딩 실패']; } $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}", ]; } /** * 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 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): bool { $name = $sensor_data['sensor_name'] ?? "센서{$sensor_data['sensor_id']}"; $allSuccess = true; foreach (SMS_RECIPIENTS as $receiver) { $receiverDigits = preg_replace('/\D+/', '', $receiver); $result = send_sms_cafe24($receiverDigits, $message); $stmt = $db->prepare(" INSERT INTO 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; } } return $allSuccess; } /** * 측정값을 사람이 읽는 짧은 문자열로 (불필요한 소수점 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; } /** * 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다. * @return array{breaches: string[], status: string} * breaches: 발생한 경보 종류 목록, status: 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'; } } 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; } /** * 센서가 현재 임계 경보 상태인지 판정한다(최근 metric SMS가 경보이고 그 뒤 복귀가 * 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(sms_log 기준). */ function metric_was_alerting(PDO $db, int $sensor_id): bool { $stmt = $db->prepare(" SELECT message FROM sms_log WHERE sensor_id = :sid AND status = 'success' AND (message LIKE '[고온경보]%' OR message LIKE '[저온경보]%' OR message LIKE '[고습경보]%' OR message LIKE '[저습경보]%' OR message LIKE '[정상복귀]%') ORDER BY sent_at DESC LIMIT 1 "); $stmt->execute([':sid' => $sensor_id]); $msg = $stmt->fetchColumn(); if ($msg === false) { return false; } return !str_starts_with($msg, '[정상복귀]'); } /** * 온습도 임계 경보 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']; // [태그, 지표명, 단위, 임계값, 방향문구] $labels = [ 'high_temp' => ['[고온경보]', '온도', '℃', METRIC_TEMP_HIGH_C, '이상'], 'low_temp' => ['[저온경보]', '온도', '℃', METRIC_TEMP_LOW_C, '이하'], 'high_humidity' => ['[고습경보]', '습도', '%', METRIC_RH_HIGH, '이상'], 'low_humidity' => ['[저습경보]', '습도', '%', METRIC_RH_LOW, '이하'], ]; if (!isset($labels[$kind])) { error_log("[SMS] 알 수 없는 임계 경보 종류: {$kind}"); return false; } [$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind]; // ── 종류별 쿨다운 (sms_log 메시지 prefix 기준) ────────────── $recent = $db->prepare(" SELECT sent_at FROM sms_log WHERE sensor_id = :sensor_id AND status = 'success' AND message LIKE :tag ORDER BY sent_at DESC LIMIT 1 "); $recent->execute([':sensor_id' => $sensor_id, ':tag' => $tag . '%']); $last_at = $recent->fetchColumn(); if ($last_at !== false && (time() - strtotime($last_at)) < METRIC_ALERT_COOLDOWN_SEC) { $elapsed = time() - strtotime($last_at); 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}"); } /** * 온습도 정상 복귀 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 = "[정상복귀]\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 발송 * * @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 = "[장비 오프라인]\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 = "[장비 복구]\n" . "위치: {$loc}\n" . "센서: {$name}\n" . "복구 시각: {$dt}\n" . "통신이 정상 복구되었습니다."; return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구"); }