POSA_LEAKSMS/php/sms_send.php
cw2007 ec78be0bda feat(pms7003): 누수 노드에 미세먼지(PM2.5/PM10) 통합
센서1·3(누수 노드)에 PMS7003 를 온습도와 동일 sensor_id 로 통합하고 metric_type='pms7003'
로 구분해 수집한다. LED 는 스위칭 없이 상시 점등 방식으로 확정.

- raspberry_pi4_pms7003/: PMS7003 노드 풀세트(드라이버·센서·install·systemd·vendor·배선·연결테스트·프레임 단위테스트)
- 서버: evaluate_metric_thresholds() 에 pm25/pm10 선택 인자 추가(온습도 전용 호출 후방호환),
  sensor_data.php 수집 경로에 pms7003 metric 반영, config.php/예시에 PM 임계 추가
- SQL: migration_pms7003.sql · schema_sht30.sql 정합, tools/migration_leak_add_{pms7003,sht30} 마이그레이터
- tests/pm_threshold_test.php + run_tests.sh 러너 등록
- .gitignore: 노드별 config_local.py 커밋 차단
2026-07-19 10:46:33 +09:00

1252 lines
54 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// =============================================================================
// sms_send.php - SMS 발송 모듈 (Cafe24 SMS API 사용)
// Cafe24: https://sslsms.cafe24.com/sms_sender.php
//
// 담당 알림:
// - 온습도 임계 경보 (고온/저온/고습/저습) : send_metric_alert_sms()
// - 온습도 정상 복귀 : send_metric_recovery_sms()
// - 누수 감지 / 복구 (1번 모듈) : send_leak_alert_sms() / send_leak_recovery_sms()
// - 장비 오프라인 / 복구 : send_device_offline_sms() / online
// 임계 판정 자체는 api/sensor_data.php(서버, config.php 의 METRIC_* 상수)에서 한다.
// =============================================================================
require_once __DIR__ . '/config.php';
// dew_point_c() 는 forecast.php 의 정규(nullable-safe) 정의를 단일 출처로 사용한다.
// (과거 이 파일에도 중복 정의가 있어 forecast.php 와 함께 로드되는 dashboard 에서
// Cannot redeclare 치명오류가 났다 — 단일 정의로 통합.)
require_once __DIR__ . '/forecast.php';
/**
* 발신번호를 3분할 (sphone1, sphone2, sphone3)
*
* @return array{0:string, 1:string, 2:string}
*/
function split_sender_number(string $phone): array {
$digits = preg_replace('/\D+/', '', $phone);
$len = strlen($digits);
if ($len < 9) {
throw new RuntimeException('발신번호 형식이 올바르지 않습니다: ' . $phone);
}
if ($len === 10) {
return [substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6, 4)];
}
return [substr($digits, 0, 3), substr($digits, 3, 4), substr($digits, 7)];
}
/**
* EUC-KR 로 인코딩된 문자열을 최대 바이트 수로 자른다(멀티바이트 경계 보존).
* Cafe24 단문(SMS)의 EUC-KR 길이 상한을 넘지 않도록 발송 직전에 사용한다.
*
* @param string $euckr EUC-KR 로 이미 인코딩된 바이트열
* @param int $maxBytes 허용 최대 바이트
*/
function sms_truncate_euckr(string $euckr, int $maxBytes): string {
if ($maxBytes <= 0 || strlen($euckr) <= $maxBytes) {
return $euckr;
}
if (function_exists('mb_strcut')) {
// mb_strcut 은 지정 바이트 안에서 멀티바이트 글자를 쪼개지 않는다.
$cut = mb_strcut($euckr, 0, $maxBytes, 'EUC-KR');
if ($cut !== '') {
return $cut;
}
}
// mbstring 미가용 폴백: EUC-KR 2바이트 글자 경계를 직접 맞춘다(0x00-0x7F 는 1바이트,
// 0xA1-0xFE 선행 바이트는 2바이트). maxBytes 위치가 2바이트 글자 중간이면 한 글자 뺀다.
$cut = substr($euckr, 0, $maxBytes);
$highBytes = 0;
for ($i = strlen($cut) - 1; $i >= 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_label((int)($sensor_data['sensor_id'] ?? 0), null, $sensor_data['sensor_name'] ?? null);
$allSuccess = true;
$lastRemaining = null; // 게이트웨이가 알려준 최신 잔여 건수(잔액 사전경보용)
// 명시 전달($recipients: 에스컬레이션 TIER2)이 우선. 없으면 센서별 수신자 그룹
// (SENSOR_RECIPIENTS[sid]) → 지정 없으면 전역 SMS_RECIPIENTS 로 폴백.
$targets = $recipients ?? recipients_for_sensor((int)($sensor_data['sensor_id'] ?? 0));
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_*)와 비교해 경보 종류를 산출한다.
*
* pm25/pm10 은 PMS7003(µg/m³ 대기환산 정수)용 선택 인자다(온습도 전용 호출은 종전대로
* 두 인자만 넘기면 된다 — 후방호환). PMS 는 누수 노드(sensor_id 1·3)에 온습도와 같은
* sensor_id 로 통합되며 metric_type='pms7003' 로 구분되므로, 한 보고에는 온습도 또는
* 미세먼지 중 한쪽만 실린다.
*
* @param ?float $temp_c 온도(℃) — 없으면 null
* @param ?float $rh 습도(%) — 없으면 null
* @param ?float $pm25 PM2.5(µg/m³) — 없으면 null
* @param ?float $pm10 PM10(µg/m³) — 없으면 null
* @return array{breaches: string[], status: string}
* breaches: 발생한 경보 종류 목록, status: leak_sensor_metric 저장용 대표 상태
*/
function evaluate_metric_thresholds(?float $temp_c, ?float $rh, ?float $pm25 = null, ?float $pm10 = null): 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';
}
}
// 미세먼지(PMS7003): PM2.5·PM10 각각 고농도 임계 초과 시 발화. 온습도와 독립.
if ($pm25 !== null && $pm25 > METRIC_PM25_HIGH) {
$breaches[] = 'high_pm25';
}
if ($pm10 !== null && $pm10 > METRIC_PM10_HIGH) {
$breaches[] = 'high_pm10';
}
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;
}
/**
* 누수 감지/복구 디바운스 판정(순수 함수 — DB/네트워크 없음, 테스트 용이).
*
* raw(현재 실제) 상태가 확정 상태와 어긋나면 확인창 동안 지속돼야 확정한다. 단일 타이머로
* 감지·복구를 대칭 처리한다. raw 가 확정과 같아지면(짧은 블립이 원상 복귀) pending 을
* 즉시 해제해, 감지↔복구 채터링(플래핑)이 SMS 로 번지는 것을 원천 차단한다.
*
* @param int $raw 0/1 현재 raw 누수 상태
* @param int $confirmed 0/1 현재 확정 누수 상태(SMS/사고추적 기준)
* @param ?int $pendingSinceTs 미확정 전이 시작 Unix (안정이면 null)
* @param int $alertConfirmSec raw=1 확정에 필요한 지속(초)
* @param int $recoveryConfirmSec raw=0 확정에 필요한 지속(초)
* @param int $now 현재 Unix (테스트 주입)
* @return array{confirmed:int, pending_since:?int, sms:?string}
* sms: 확정 시 'leak'|'recovery', 아니면 null.
*/
function leak_debounce_decide(
int $raw,
int $confirmed,
?int $pendingSinceTs,
int $alertConfirmSec,
int $recoveryConfirmSec,
int $now
): array {
// raw 가 확정과 같음 → 안정. 진행 중이던 반대 전이(pending)를 해제한다(블립 필터).
if ($raw === $confirmed) {
return ['confirmed' => $confirmed, 'pending_since' => null, 'sms' => null];
}
// raw != confirmed → 전이 대기.
if ($pendingSinceTs === null) {
return ['confirmed' => $confirmed, 'pending_since' => $now, 'sms' => null]; // 타이머 시작
}
$threshold = ($raw === 1) ? $alertConfirmSec : $recoveryConfirmSec;
if (($now - $pendingSinceTs) >= $threshold) {
// 확정: 확정 상태를 raw 로 갱신하고 SMS 1회.
return [
'confirmed' => $raw,
'pending_since' => null,
'sms' => ($raw === 1) ? 'leak' : 'recovery',
];
}
return ['confirmed' => $confirmed, 'pending_since' => $pendingSinceTs, 'sms' => null]; // 대기 유지
}
/**
* 온습도 임계 경보 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'|'high_pm25'|'high_pm10'
* @param float $value 측정값 (온도℃ / 습도% / 미세먼지 µg/m³)
* @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 단일 출처 상수)
// 미세먼지(high_pm25/high_pm10)는 PM2.5·PM10 이 같은 태그([미세먼지경보])를 쓰되 지표명·
// 임계값으로 구분한다. 쿨다운은 태그 기준이므로 두 종류가 같은 쿨다운 창을 공유한다.
$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, '이하'],
'high_pm25' => [SMS_TAG_HIGH_PM, 'PM2.5', '㎍/㎥', METRIC_PM25_HIGH, '초과'],
'high_pm10' => [SMS_TAG_HIGH_PM, 'PM10', '㎍/㎥', METRIC_PM10_HIGH, '초과'],
];
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');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$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');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$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');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$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');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$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');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$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');
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$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");
[$loc, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
$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; // 쿨다운
}
[$loc, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
$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}회 평탄)");
}
}
// ─── 누수 디바운스 (감지/복구 확정 상태머신) ──────────────────────────────────
/**
* leak_sensor_status 에 디바운스 컬럼(leak_confirmed/leak_pending_since)이 있는지.
* 없으면(구배포) 디바운스를 비활성하고 레거시 즉시 발송 경로로 graceful degrade 한다.
* 1회 조회 후 캐시.
*/
function leak_status_has_debounce_cols(PDO $db): bool {
static $cached = null;
if ($cached !== null) {
return $cached;
}
try {
$stmt = $db->prepare("
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'leak_sensor_status'
AND COLUMN_NAME IN ('leak_confirmed', 'leak_pending_since')
");
$stmt->execute();
$cached = ((int)$stmt->fetchColumn() === 2);
} catch (Throwable $e) {
error_log("[Leak] 디바운스 컬럼 확인 실패: " . $e->getMessage());
$cached = false;
}
return $cached;
}
/**
* 확정 누수 사고(leak_incident)를 연다(미복구 사고 없을 때만). started_at=확정 시각(NOW),
* log_id_alert=해당 센서의 최근 leak_alert 로그. 사고 테이블 없으면 조용히 스킵.
*/
function leak_incident_open(PDO $db, int $sensor_id, array $info): void {
try {
$open = $db->prepare("
SELECT id FROM leak_incident
WHERE sensor_id = :sid AND recovered_at IS NULL
ORDER BY started_at DESC, id DESC LIMIT 1
");
$open->execute([':sid' => $sensor_id]);
if ($open->fetchColumn() !== false) {
return; // 이미 미복구 사고 있음
}
$lg = $db->prepare("
SELECT id FROM leak_sensor_log
WHERE sensor_id = :sid AND event_type = 'leak_alert'
ORDER BY id DESC LIMIT 1
");
$lg->execute([':sid' => $sensor_id]);
$logId = $lg->fetchColumn();
[$loc, $name] = sensor_label($sensor_id, $info['device_location'] ?? null, $info['sensor_name'] ?? null);
$ins = $db->prepare("
INSERT INTO leak_incident
(sensor_id, device_id, sensor_name, device_location, started_at, log_id_alert, created_at)
VALUES
(:sid, :dev, :name, :loc, NOW(), :log, NOW())
");
$ins->execute([
':sid' => $sensor_id,
':dev' => $info['device_id'] ?? '',
':name' => $name,
':loc' => $loc,
':log' => $logId !== false ? (int)$logId : null,
]);
} catch (PDOException $e) {
if ($e->getCode() !== '42S02') { // 테이블 없음이면 조용히
error_log("[Leak] incident open 오류: " . $e->getMessage());
}
}
}
/**
* 해당 센서의 가장 최근 미복구 사고를 닫는다(recovered_at=NOW, log_id_recovery 링크).
*/
function leak_incident_close(PDO $db, int $sensor_id): void {
try {
$lg = $db->prepare("
SELECT id FROM leak_sensor_log
WHERE sensor_id = :sid AND event_type = 'leak_recovery'
ORDER BY id DESC LIMIT 1
");
$lg->execute([':sid' => $sensor_id]);
$logId = $lg->fetchColumn();
$close = $db->prepare("
UPDATE leak_incident
SET recovered_at = NOW(), log_id_recovery = :log
WHERE sensor_id = :sid AND recovered_at IS NULL
ORDER BY started_at DESC, id DESC LIMIT 1
");
$close->execute([
':log' => $logId !== false ? (int)$logId : null,
':sid' => $sensor_id,
]);
} catch (PDOException $e) {
if ($e->getCode() !== '42S02') {
error_log("[Leak] incident close 오류: " . $e->getMessage());
}
}
}
/**
* 누수 디바운스 상태머신 1스텝. raw(현재 실제 누수) 상태를 확정 상태와 비교해 확정 엣지에서만
* SMS 를 1회 보낸다(감지→[누수감지], 복구→[누수복구]). 확정 시 leak_incident 도 함께 갱신.
* (센서) 네임드 락으로 read-decide-update 를 직렬화해 동시 보고 경쟁을 막는다.
*
* 디바운스 비활성/컬럼 부재/상태행 부재면 false 를 반환하고, 호출자가 레거시 경로로 처리한다.
*
* @return bool 이번 호출에서 확정 SMS 를 발송했는지
*/
function apply_leak_debounce(PDO $db, int $sensor_id, int $raw, array $sensor_info): bool {
if (!LEAK_DEBOUNCE_ENABLED || !leak_status_has_debounce_cols($db)) {
return false;
}
// ── 센서 단위 직렬화 ──
$lockName = substr("leak_debounce_{$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';
}
if ($lockState === 'busy') {
return false; // 다른 프로세스가 이 센서를 처리 중
}
try {
$sel = $db->prepare("
SELECT leak_confirmed, UNIX_TIMESTAMP(leak_pending_since) AS pend
FROM leak_sensor_status WHERE sensor_id = :sid
");
$sel->execute([':sid' => $sensor_id]);
$row = $sel->fetch();
if ($row === false) {
return false; // 상태행 없음(업서트 전) — 다음 보고에서 처리
}
$confirmed = (int)$row['leak_confirmed'];
$pend = ($row['pend'] !== null) ? (int)$row['pend'] : null;
$d = leak_debounce_decide(
$raw, $confirmed, $pend,
LEAK_ALERT_CONFIRM_SEC, LEAK_RECOVERY_CONFIRM_SEC, time()
);
// 상태 저장(변경 없어도 pending 해제/유지 반영 위해 항상 갱신).
$upd = $db->prepare("
UPDATE leak_sensor_status
SET leak_confirmed = :c, leak_pending_since = :p
WHERE sensor_id = :sid
");
$upd->execute([
':c' => $d['confirmed'],
':p' => $d['pending_since'] !== null ? date('Y-m-d H:i:s', $d['pending_since']) : null,
':sid' => $sensor_id,
]);
if ($d['sms'] === 'leak') {
leak_incident_open($db, $sensor_id, $sensor_info);
return function_exists('send_leak_alert_sms') ? send_leak_alert_sms($sensor_info) : false;
}
if ($d['sms'] === 'recovery') {
leak_incident_close($db, $sensor_id);
return function_exists('send_leak_recovery_sms') ? send_leak_recovery_sms($sensor_info) : false;
}
return false;
} finally {
if ($lockState === 'acquired') {
try {
$db->prepare('SELECT RELEASE_LOCK(:n)')->execute([':n' => $lockName]);
} catch (Throwable $e) {
/* 해제 실패 무시 */
}
}
}
}
/**
* 보고가 끊겨도(예: 복구 대기 중 장기 주기, 장비 무응답) 미확정 전이를 확정으로 해소한다.
* leak_pending_since 가 세팅된 센서를 raw=is_leak 로 재평가한다. cron_heartbeat 가 주기 호출.
*/
function sweep_pending_leak_transitions(PDO $db): void {
if (!LEAK_DEBOUNCE_ENABLED || !leak_status_has_debounce_cols($db)) {
return;
}
try {
$rows = $db->query("
SELECT sensor_id, sensor_name, device_id, device_location, is_leak
FROM leak_sensor_status WHERE leak_pending_since IS NOT NULL
")->fetchAll();
} catch (Throwable $e) {
error_log("[Leak] pending sweep 조회 실패: " . $e->getMessage());
return;
}
foreach ($rows as $r) {
$sid = (int)$r['sensor_id'];
$info = [
'sensor_id' => $sid,
'sensor_name' => $r['sensor_name'],
'device_id' => $r['device_id'],
'device_location' => $r['device_location'],
];
apply_leak_debounce($db, $sid, (int)$r['is_leak'], $info);
}
}