서버실/항온항습: 여과·양압이라 평상 PM≈0 → PM 급상승을 화재 조기징후로 본다. - 임계 하향: METRIC_PM25_HIGH 75→35, METRIC_PM10_HIGH 150→75 (연기 조기감지). - SMS 태그 [미세먼지경보] → [화재의심]. 대시보드 라벨 통일(경보=화재의심, PM 측정=화재감시). metric_status(high_pm25/10) 코드·값·판정 로직 불변 — 표시단만 변경. - SMS 단문화: 전 본문을 EUC-KR 90B 이내로 축약(관리주체 제거, 날짜 m-d H:i, 태그+위치 1줄). 실측 45~70B ≤ 90B → 게이트웨이(Cafe24 smsType=S) 단문 절단 해소. - 센서고착(live-but-stuck) 검출 기본 비활성(STUCK_DETECT_ENABLED=false): 항온항습 문서고는 온습도 원래 평탄 → 평탄값을 고착으로 오탐하던 [센서고착] 문자 차단. - 테스트: 태그·기본임계 단언 갱신. 회귀 스위트 PASS(9 run/0 fail), php -l 통과. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Dj4n17ZvGAhga6D4pNRjn
1235 lines
53 KiB
PHP
1235 lines
53 KiB
PHP
<?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"
|
||
. "잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)\n"
|
||
. "충전 필요 · " . date('m-d H:i');
|
||
|
||
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;
|
||
}
|
||
|
||
// ── 메시지 구성 (단문 90B EUC-KR 이내) ─────────────────────
|
||
$dt = date('m-d H:i');
|
||
[$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);
|
||
$op = ($dir === '이하') ? '>' : '<'; // 정상 범위 방향(임계 미만/초과)
|
||
|
||
$message = "{$tag} {$loc}\n"
|
||
. "{$metricName} {$valStr}{$unit} 정상{$op}{$thrStr}\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('m-d H:i');
|
||
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||
|
||
$message = SMS_TAG_RECOVERY . " {$loc}\n"
|
||
. "온도 " . metric_value_str($temp_c) . "℃ 습도 " . metric_value_str($rh) . "%\n"
|
||
. "{$dt} 정상복귀";
|
||
|
||
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('m-d H:i');
|
||
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||
|
||
$message = SMS_TAG_LEAK_ALERT . " {$loc}\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('m-d H:i');
|
||
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||
|
||
$message = SMS_TAG_LEAK_RECOVERY . " {$loc}\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('m-d H:i');
|
||
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||
$message = SMS_TAG_DEVICE_OFFLINE . " {$loc}\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']
|
||
* @return bool 발송 성공 여부
|
||
*/
|
||
function send_device_online_sms(array $sensor_data): bool {
|
||
$db = get_db();
|
||
|
||
$dt = date('m-d H:i');
|
||
[$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
|
||
|
||
$message = SMS_TAG_DEVICE_ONLINE . " {$loc}\n"
|
||
. "통신 정상 복구\n"
|
||
. "{$dt}";
|
||
|
||
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"
|
||
. "{$firstLine} 미복귀 {$mins}분\n"
|
||
. date('m-d H:i') . " 즉시확인";
|
||
$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);
|
||
}
|
||
}
|