feat: PM 급상승을 화재의심으로 전환 + SMS 단문화 + 센서고착 검출 비활성

서버실/항온항습: 여과·양압이라 평상 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
This commit is contained in:
cw2007 2026-07-19 23:34:57 +09:00
parent 2046b2f292
commit f7fbeacbdd
8 changed files with 76 additions and 84 deletions

View file

@ -155,7 +155,7 @@ const SMS_TAG_SENSOR_STUCK = '[센서고착]'; // 측정값 고착(live-bu
const SMS_TAG_ESCALATION = '[에스컬레이션]'; // 미복귀 경보 2차(야간) 통지 const SMS_TAG_ESCALATION = '[에스컬레이션]'; // 미복귀 경보 2차(야간) 통지
const SMS_TAG_LEAK_ALERT = '[누수감지]'; // 1번 모듈(누수) 누수 발생 const SMS_TAG_LEAK_ALERT = '[누수감지]'; // 1번 모듈(누수) 누수 발생
const SMS_TAG_LEAK_RECOVERY = '[누수복구]'; // 1번 모듈(누수) 누수 해소(복구) const SMS_TAG_LEAK_RECOVERY = '[누수복구]'; // 1번 모듈(누수) 누수 해소(복구)
const SMS_TAG_HIGH_PM = '[미세먼지경보]'; // PMS7003 미세먼지(PM2.5/PM10) 임계 초과 const SMS_TAG_HIGH_PM = '[화재의심]'; // PMS7003 PM 급상승(연기 추정) — 화재 조기징후. 서버실/항온항습 화재 프록시
// 경보 종류(metric kind) → 태그 매핑(단일 출처). evaluate_metric_thresholds 의 // 경보 종류(metric kind) → 태그 매핑(단일 출처). evaluate_metric_thresholds 의
// breach 종류와 1:1 대응한다. // breach 종류와 1:1 대응한다.
@ -164,7 +164,7 @@ const SMS_METRIC_TAGS = [
'low_temp' => SMS_TAG_LOW_TEMP, 'low_temp' => SMS_TAG_LOW_TEMP,
'high_humidity' => SMS_TAG_HIGH_HUMIDITY, 'high_humidity' => SMS_TAG_HIGH_HUMIDITY,
'low_humidity' => SMS_TAG_LOW_HUMIDITY, 'low_humidity' => SMS_TAG_LOW_HUMIDITY,
// PMS7003 미세먼지: PM2.5·PM10 두 종류가 같은 태그([미세먼지경보])로 매핑된다. // PMS7003 화재의심(PM): PM2.5·PM10 두 종류가 같은 태그([화재의심])로 매핑된다.
'high_pm25' => SMS_TAG_HIGH_PM, 'high_pm25' => SMS_TAG_HIGH_PM,
'high_pm10' => SMS_TAG_HIGH_PM, 'high_pm10' => SMS_TAG_HIGH_PM,
]; ];
@ -208,11 +208,16 @@ define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800))
// ─── 미세먼지(PMS7003) 임계 경보 (PM2.5/PM10 → SMS) ──────────────────────────── // ─── 미세먼지(PMS7003) 임계 경보 (PM2.5/PM10 → SMS) ────────────────────────────
// PMS7003 은 누수 노드(sensor_id 1·3)에 온습도(SHT30)와 함께 동일 sensor_id 로 통합되어 // PMS7003 은 누수 노드(sensor_id 1·3)에 온습도(SHT30)와 함께 동일 sensor_id 로 통합되어
// metric_type='pms7003' 로 보고한다. 값은 대기환산 µg/m³ 정수(0..1000). 아래 운영 임계를 // metric_type='pms7003' 로 보고한다. 값은 대기환산 µg/m³ 정수(0..1000). 아래 운영 임계를
// 초과하면 api/sensor_data.php 가 [미세먼지경보] SMS 를 보낸다(종류별 쿨다운은 온습도와 // 초과하면 api/sensor_data.php 가 [화재의심] SMS 를 보낸다(종류별 쿨다운은 온습도와
// 동일하게 METRIC_ALERT_COOLDOWN_SEC 재사용). 임계 판정은 Pi 가 아니라 서버에서 하므로 // 동일하게 METRIC_ALERT_COOLDOWN_SEC 재사용). 임계 판정은 Pi 가 아니라 서버에서 하므로
// 폐쇄망에서도 재배포 없이 config.local.php 로 조정할 수 있다. // 폐쇄망에서도 재배포 없이 config.local.php 로 조정할 수 있다.
define('METRIC_PM25_HIGH', (float)cfg('METRIC_PM25_HIGH', 75)); // PM2.5 고농도 임계(µg/m³) //
define('METRIC_PM10_HIGH', (float)cfg('METRIC_PM10_HIGH', 150)); // PM10 고농도 임계(µg/m³) // 화재 프록시 기준(서버실/항온항습): 여과·양압 환경이라 평상 PM ≈ 0. PM 급상승 = 외부
// 유입 아니면 연소 연기 → 화재 조기징후로 본다. 대기질 "나쁨"(75/150)까지 기다리면 연기가
// 이미 자욱해 늦으므로, 조기감지용으로 낮춘 값을 쓴다. (PMS7003 은 광학 먼지센서지 인증
// 화재감지기가 아니다 — 보조 조기징후로만 쓰고 인증 연기/열 감지기를 병행할 것.)
define('METRIC_PM25_HIGH', (float)cfg('METRIC_PM25_HIGH', 35)); // PM2.5 화재의심 임계(µg/m³) — 조기감지용 하향
define('METRIC_PM10_HIGH', (float)cfg('METRIC_PM10_HIGH', 75)); // PM10 화재의심 임계(µg/m³) — 조기감지용 하향
// 복구(정상 회복) 히스테리시스: 임계 안쪽으로 이 여유만큼 들어와야 복구로 간주(경계 채터링 방지) // 복구(정상 회복) 히스테리시스: 임계 안쪽으로 이 여유만큼 들어와야 복구로 간주(경계 채터링 방지)
define('METRIC_PM_HYSTERESIS', (float)cfg('METRIC_PM_HYSTERESIS', 10)); // µg/m³ define('METRIC_PM_HYSTERESIS', (float)cfg('METRIC_PM_HYSTERESIS', 10)); // µg/m³
@ -299,7 +304,11 @@ define('ESCALATE_AFTER_SEC', (int)cfg('ESCALATE_AFTER_SEC', 1800));
// 센서 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 표준편차가 // 센서 고착(live-but-stuck) 검출: 최근 STUCK_DETECT_SAMPLES 측정의 표준편차가
// STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다. // STUCK_DETECT_EPSILON 미만(완전 평탄)이면 소자 고장(같은 값 반복)을 의심해 알린다.
// (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.) // (하트비트는 '수신 끊김'만 본다 — I2C 는 살아있는데 값이 고착된 무성실패를 놓침.)
define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', true)); //
// 기본 비활성: 대상이 항온항습 문서고라 온습도가 원래 거의 평탄하다. 고착 검출을 켜면
// 정상 평탄값을 '고착'으로 오탐해 [센서고착] 문자가 반복된다. 변동이 있는 환경에서만
// config.local.php 로 STUCK_DETECT_ENABLED=true 로 켠다.
define('STUCK_DETECT_ENABLED', (bool)cfg('STUCK_DETECT_ENABLED', false));
// 온습도 보고 주기가 1시간이므로 표본 수가 크면 고착 감지가 너무 늦다. 기본 3(= 3회 연속 // 온습도 보고 주기가 1시간이므로 표본 수가 크면 고착 감지가 너무 늦다. 기본 3(= 3회 연속
// 완전 평탄 ≈ 3시간). 주기를 바꾸면 config.local.php 로 조정. // 완전 평탄 ≈ 3시간). 주기를 바꾸면 config.local.php 로 조정.
define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 3)); // 3 × 1시간 ≈ 3시간 define('STUCK_DETECT_SAMPLES', (int)cfg('STUCK_DETECT_SAMPLES', 3)); // 3 × 1시간 ≈ 3시간

View file

@ -90,8 +90,8 @@ function metric_status_label(string $status): string {
'high_humidity' => '고습', 'high_humidity' => '고습',
'low_humidity' => '저습', 'low_humidity' => '저습',
'condensation_risk' => '결로주의', 'condensation_risk' => '결로주의',
'high_pm25' => '미세먼지(PM2.5)', 'high_pm25' => '화재의심(PM2.5)',
'high_pm10' => '미세먼지(PM10)', 'high_pm10' => '화재의심(PM10)',
]; ];
return $labels[$status] ?? $status; return $labels[$status] ?? $status;
} }
@ -429,7 +429,7 @@ function dash_hero_state(array $g): array {
$pm_alert = (int)($g['pm_alert_count'] ?? 0); $pm_alert = (int)($g['pm_alert_count'] ?? 0);
$parts = []; $parts = [];
if ($g['leak_active_count'] > 0) { $parts[] = "누수 {$g['leak_active_count']}개"; } if ($g['leak_active_count'] > 0) { $parts[] = "누수 {$g['leak_active_count']}개"; }
if ($pm_alert > 0) { $parts[] = "미세먼지 {$pm_alert}개"; } if ($pm_alert > 0) { $parts[] = "화재의심 {$pm_alert}개"; }
if ($g['alert_count'] > 0) { $parts[] = "임계 경보 {$g['alert_count']}개"; } if ($g['alert_count'] > 0) { $parts[] = "임계 경보 {$g['alert_count']}개"; }
if ($g['offline_count'] > 0) { $parts[] = "오프라인 {$g['offline_count']}개"; } if ($g['offline_count'] > 0) { $parts[] = "오프라인 {$g['offline_count']}개"; }
if ($g['unrecovered_count'] > 0) { $parts[] = "미복구 사고 {$g['unrecovered_count']}건"; } if ($g['unrecovered_count'] > 0) { $parts[] = "미복구 사고 {$g['unrecovered_count']}건"; }
@ -445,10 +445,10 @@ function dash_hero_state(array $g): array {
} }
// 미세먼지(누수 다음, 온습도보다 우선) // 미세먼지(누수 다음, 온습도보다 우선)
if ($pm_alert > 0 && $g['alert_count'] > 0) { if ($pm_alert > 0 && $g['alert_count'] > 0) {
return ['danger', '미세먼지·온습도 경보 발생', $detail]; return ['danger', '화재의심·온습도 경보 발생', $detail];
} }
if ($pm_alert > 0) { if ($pm_alert > 0) {
return ['danger', '미세먼지 경보 발생', $detail]; return ['danger', '화재의심 경보 발생', $detail];
} }
if ($g['alert_count'] > 0) { if ($g['alert_count'] > 0) {
return ['danger', '온습도 경보 발생', $detail]; return ['danger', '온습도 경보 발생', $detail];
@ -462,7 +462,7 @@ function dash_hero_state(array $g): array {
if ($g['ops_critical_count'] > 0) { if ($g['ops_critical_count'] > 0) {
return ['warning', '운영 확인 필요', "중요 진단 {$g['ops_critical_count']}건을 확인하세요."]; return ['warning', '운영 확인 필요', "중요 진단 {$g['ops_critical_count']}건을 확인하세요."];
} }
return ['normal', '정상 감시 중', '현재 누수·미세먼지·온습도 경보와 중요 운영 경고가 없습니다.']; return ['normal', '정상 감시 중', '현재 누수·화재·온습도 경보와 중요 운영 경고가 없습니다.'];
} }
// 히어로 상세 줄 — 경보/누수 중 센서별 "누가·어디서·얼마나(값)·언제부터·SMS 나갔는지". // 히어로 상세 줄 — 경보/누수 중 센서별 "누가·어디서·얼마나(값)·언제부터·SMS 나갔는지".
@ -483,7 +483,7 @@ function dash_hero_alert_lines(PDO $db, array $g): array {
WHERE sensor_id = :sid AND message LIKE '" . SMS_TAG_LEAK_ALERT . "%' WHERE sensor_id = :sid AND message LIKE '" . SMS_TAG_LEAK_ALERT . "%'
ORDER BY id DESC LIMIT 1 ORDER BY id DESC LIMIT 1
"); ");
// 미세먼지 경보 SMS(가장 최근) — [미세먼지경보] 태그. // 화재의심 경보 SMS(가장 최근) — [화재의심] 태그.
$sms_pm_stmt = $db->prepare(" $sms_pm_stmt = $db->prepare("
SELECT sent_at, status FROM leak_sms_log SELECT sent_at, status FROM leak_sms_log
WHERE sensor_id = :sid AND message LIKE '" . SMS_TAG_HIGH_PM . "%' WHERE sensor_id = :sid AND message LIKE '" . SMS_TAG_HIGH_PM . "%'
@ -811,7 +811,7 @@ function load_tab_trend(PDO $db, array $g): array {
$donut_segs = [ $donut_segs = [
['label' => '고온', 'val' => (int)($mstatus_map['high_temp'] ?? 0), 'cat' => 'hot'], ['label' => '고온', 'val' => (int)($mstatus_map['high_temp'] ?? 0), 'cat' => 'hot'],
['label' => '고습', 'val' => (int)($mstatus_map['high_humidity'] ?? 0), 'cat' => 'humid'], ['label' => '고습', 'val' => (int)($mstatus_map['high_humidity'] ?? 0), 'cat' => 'humid'],
['label' => '미세먼지', 'val' => $pm_30d, 'cat' => 'pm'], ['label' => '화재의심', 'val' => $pm_30d, 'cat' => 'pm'],
['label' => '누수', 'val' => $leak_alert_30d, 'cat' => 'leak'], ['label' => '누수', 'val' => $leak_alert_30d, 'cat' => 'leak'],
['label' => '저온', 'val' => (int)($mstatus_map['low_temp'] ?? 0), 'cat' => 'cold'], ['label' => '저온', 'val' => (int)($mstatus_map['low_temp'] ?? 0), 'cat' => 'cold'],
['label' => '저습', 'val' => (int)($mstatus_map['low_humidity'] ?? 0), 'cat' => 'dry'], ['label' => '저습', 'val' => (int)($mstatus_map['low_humidity'] ?? 0), 'cat' => 'dry'],

View file

@ -33,9 +33,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'send_
exit; exit;
} }
$message = "[온습도 모니터링 테스트]\n" $message = "[모니터링 테스트]\n"
. "설치/운영 점검 문자입니다.\n" . "설치/운영 점검 문자\n"
. "시각: " . date('Y-m-d H:i:s'); . date('m-d H:i');
$results = []; $results = [];
foreach (SMS_RECIPIENTS as $receiver) { foreach (SMS_RECIPIENTS as $receiver) {
$digits = preg_replace('/\D+/', '', $receiver); $digits = preg_replace('/\D+/', '', $receiver);

View file

@ -252,9 +252,8 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void {
} }
$message = SMS_TAG_BALANCE_LOW . "\n" $message = SMS_TAG_BALANCE_LOW . "\n"
. "SMS 잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건 이하)\n" . "잔여 {$remaining}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)\n"
. "충전하지 않으면 경보 발송이 중단됩니다.\n" . "충전 필요 · " . date('m-d H:i');
. "시각: " . date('Y-m-d H:i:s');
foreach (SMS_RECIPIENTS as $receiver) { foreach (SMS_RECIPIENTS as $receiver) {
$receiverDigits = preg_replace('/\D+/', '', $receiver); $receiverDigits = preg_replace('/\D+/', '', $receiver);
@ -515,7 +514,7 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
$sensor_id = (int)$sensor_data['sensor_id']; $sensor_id = (int)$sensor_data['sensor_id'];
// [태그, 지표명, 단위, 임계값, 방향문구] (태그는 config.php 단일 출처 상수) // [태그, 지표명, 단위, 임계값, 방향문구] (태그는 config.php 단일 출처 상수)
// 미세먼지(high_pm25/high_pm10)는 PM2.5·PM10 이 같은 태그([미세먼지경보])를 쓰되 지표명· // 화재의심(high_pm25/high_pm10)은 PM2.5·PM10 이 같은 태그([화재의심])를 쓰되 지표명·
// 임계값으로 구분한다. 쿨다운은 태그 기준이므로 두 종류가 같은 쿨다운 창을 공유한다. // 임계값으로 구분한다. 쿨다운은 태그 기준이므로 두 종류가 같은 쿨다운 창을 공유한다.
$labels = [ $labels = [
'high_temp' => [SMS_TAG_HIGH_TEMP, '온도', '℃', METRIC_TEMP_HIGH_C, '이상'], 'high_temp' => [SMS_TAG_HIGH_TEMP, '온도', '℃', METRIC_TEMP_HIGH_C, '이상'],
@ -585,17 +584,16 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
return false; return false;
} }
// ── 메시지 구성 ────────────────────────────────────────── // ── 메시지 구성 (단문 90B EUC-KR 이내) ─────────────────────
$dt = date('Y-m-d H:i:s'); $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); [$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$valStr = metric_value_str($value); $valStr = metric_value_str($value);
$thrStr = metric_value_str((float)$threshold); $thrStr = metric_value_str((float)$threshold);
$op = ($dir === '이하') ? '>' : '<'; // 정상 범위 방향(임계 미만/초과)
$message = "{$tag}\n" $message = "{$tag} {$loc}\n"
. "위치: {$loc}\n" . "{$metricName} {$valStr}{$unit} 정상{$op}{$thrStr}\n"
. "관리주체: {$name}\n" . "{$dt} 즉시확인";
. "{$metricName}: {$valStr}{$unit} (임계 {$thrStr}{$unit} {$dir})\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}"); return sms_dispatch_to_recipients($db, $sensor_data, $message, "임계경보 {$kind}");
} finally { } finally {
@ -620,15 +618,12 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value):
function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh): bool { function send_metric_recovery_sms(array $sensor_data, float $temp_c, float $rh): bool {
$db = get_db(); $db = get_db();
$dt = date('Y-m-d H:i:s'); $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); [$loc, $name] = sensor_label((int)$sensor_data['sensor_id'], $sensor_data['device_location'] ?? null, $sensor_data['sensor_name'] ?? null);
$message = SMS_TAG_RECOVERY . "\n" $message = SMS_TAG_RECOVERY . " {$loc}\n"
. "위치: {$loc}\n" . "온도 " . metric_value_str($temp_c) . "℃ 습도 " . metric_value_str($rh) . "%\n"
. "관리주체: {$name}\n" . "{$dt} 정상복귀";
. "온도 " . metric_value_str($temp_c) . "℃ / 습도 " . metric_value_str($rh) . "%\n"
. "시각: {$dt}\n"
. "온습도가 정상 범위로 복귀했습니다.";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀"); return sms_dispatch_to_recipients($db, $sensor_data, $message, "정상복귀");
} }
@ -696,14 +691,12 @@ function send_leak_alert_sms(array $sensor_data): bool {
} }
// ── 메시지 구성 ── // ── 메시지 구성 ──
$dt = date('Y-m-d H:i:s'); $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); [$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" $message = SMS_TAG_LEAK_ALERT . " {$loc}\n"
. "위치: {$loc}\n" . "누수 감지! 즉시 확인\n"
. "관리주체: {$name}\n" . "{$dt}";
. "누수가 감지되었습니다. 즉시 확인하세요!\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수감지"); return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수감지");
} finally { } finally {
@ -748,14 +741,12 @@ function send_leak_recovery_sms(array $sensor_data): bool {
return false; // 직전 누수 경보 없음(또는 이미 복구 통지함) → 발송 안 함 return false; // 직전 누수 경보 없음(또는 이미 복구 통지함) → 발송 안 함
} }
$dt = date('Y-m-d H:i:s'); $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); [$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" $message = SMS_TAG_LEAK_RECOVERY . " {$loc}\n"
. "위치: {$loc}\n" . "누수 해소(복구)\n"
. "관리주체: {$name}\n" . "{$dt}";
. "누수가 해소(복구)되었습니다.\n"
. "시각: {$dt}";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수복구"); return sms_dispatch_to_recipients($db, $sensor_data, $message, "누수복구");
} }
@ -769,16 +760,11 @@ function send_leak_recovery_sms(array $sensor_data): bool {
function send_device_offline_sms(array $sensor_data): bool { function send_device_offline_sms(array $sensor_data): bool {
$db = get_db(); $db = get_db();
$dt = date('Y-m-d H:i:s'); $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); [$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 . " {$loc}\n"
. "통신 두절 · 장비 확인\n"
$message = SMS_TAG_DEVICE_OFFLINE . "\n" . "{$dt}";
. "위치: {$loc}\n"
. "관리주체: {$name}\n"
. "마지막 통신: {$last}\n"
. "감지 시각: {$dt}\n"
. "장비 상태를 확인하세요!";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "오프라인"); return sms_dispatch_to_recipients($db, $sensor_data, $message, "오프라인");
} }
@ -792,14 +778,12 @@ function send_device_offline_sms(array $sensor_data): bool {
function send_device_online_sms(array $sensor_data): bool { function send_device_online_sms(array $sensor_data): bool {
$db = get_db(); $db = get_db();
$dt = date('Y-m-d H:i:s'); $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); [$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" $message = SMS_TAG_DEVICE_ONLINE . " {$loc}\n"
. "위치: {$loc}\n" . "통신 정상 복구\n"
. "관리주체: {$name}\n" . "{$dt}";
. "복구 시각: {$dt}\n"
. "통신이 정상 복구되었습니다.";
return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구"); return sms_dispatch_to_recipients($db, $sensor_data, $message, "장비복구");
} }
@ -813,7 +797,7 @@ function build_health_summary(PDO $db): string {
$total = (int)$db->query("SELECT COUNT(*) FROM leak_sensor_status")->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(); $lastSeen = $db->query("SELECT TIMESTAMPDIFF(MINUTE, MAX(last_seen), NOW()) FROM leak_sensor_status")->fetchColumn();
$recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분전'); $recvStr = ($lastSeen === null) ? '수신없음' : ((int)$lastSeen . '분');
$alerts = (int)$db->query(" $alerts = (int)$db->query("
SELECT COUNT(*) FROM leak_sms_log SELECT COUNT(*) FROM leak_sms_log
@ -824,10 +808,10 @@ function build_health_summary(PDO $db): string {
OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') OR message LIKE '" . SMS_TAG_CONDENSATION . "%' OR message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%')
")->fetchColumn(); ")->fetchColumn();
$summary = "센서 {$online}/{$total} 정상, 최근수신 {$recvStr}, 24h경보 {$alerts}건"; $summary = "센서 {$online}/{$total} 최근{$recvStr} 24h경보{$alerts}건";
$bal = latest_sms_balance($db); $bal = latest_sms_balance($db);
if ($bal !== null) { if ($bal !== null) {
$summary .= ", 잔여 {$bal}건"; $summary .= " 잔여{$bal}";
} }
return $summary; return $summary;
} }
@ -984,9 +968,8 @@ function maybe_escalate_alerts(PDO $db): void {
[$loc, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null); [$loc, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null);
$mins = (int)floor((time() - strtotime((string)$row['sent_at'])) / 60); $mins = (int)floor((time() - strtotime((string)$row['sent_at'])) / 60);
$message = SMS_TAG_ESCALATION . "\n" $message = SMS_TAG_ESCALATION . "\n"
. "위치: {$loc}\n관리주체: {$name}\n" . "{$firstLine} 미복귀 {$mins}분\n"
. "{$firstLine} 미복귀 {$mins}분 경과\n" . date('m-d H:i') . " 즉시확인";
. "1차 미대응 — 확인 바랍니다.\n시각: " . date('Y-m-d H:i:s');
$info = ['sensor_id' => $sid, 'sensor_name' => $name, 'device_id' => $s['device_id'], 'device_location' => $loc]; $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); sms_dispatch_to_recipients($db, $info, $message, "에스컬레이션", SMS_RECIPIENTS_TIER2);
error_log("[SMS] 에스컬레이션 발송 - 센서{$sid} ({$mins}분 미복귀)"); error_log("[SMS] 에스컬레이션 발송 - 센서{$sid} ({$mins}분 미복귀)");

View file

@ -98,9 +98,9 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
</details> </details>
<!-- 최근 미세먼지 측정 이력 (PMS7003 — 온습도와 별도 계통) --> <!-- 최근 미세먼지 측정 이력 (PMS7003 — 온습도와 별도 계통) -->
<h2 id="pm-section" class="table-title">최근 미세먼지 측정 <span class="h2-sub">30건 기준</span></h2> <h2 id="pm-section" class="table-title">최근 화재감시(PM) 측정 <span class="h2-sub">30건 기준</span></h2>
<details class="mobile-detail" id="d-pm" data-mobile-collapsed open> <details class="mobile-detail" id="d-pm" data-mobile-collapsed open>
<summary>최근 미세먼지 측정 <span>30건 기준</span></summary> <summary>최근 화재감시(PM) 측정 <span>30건 기준</span></summary>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
@ -125,7 +125,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($recent_pm)): ?> <?php if (empty($recent_pm)): ?>
<tr><td colspan="7" class="empty-cell">아직 미세먼지 측정 데이터가 없습니다.</td></tr> <tr><td colspan="7" class="empty-cell">아직 화재감시(PM) 측정 데이터가 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>
@ -248,7 +248,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<strong class="<?= $sms_stats['alert_total'] > 0 ? 'warn-color' : '' ?>"><?= (int)$sms_stats['alert_total'] ?>건</strong> <strong class="<?= $sms_stats['alert_total'] > 0 ? 'warn-color' : '' ?>"><?= (int)$sms_stats['alert_total'] ?>건</strong>
</div> </div>
<div class="report-stat"> <div class="report-stat">
<span>미세먼지 경보 SMS</span> <span>화재의심 SMS</span>
<strong class="<?= ($sms_stats['high_pm'] ?? 0) > 0 ? 'warn-color' : '' ?>"><?= (int)($sms_stats['high_pm'] ?? 0) ?>건</strong> <strong class="<?= ($sms_stats['high_pm'] ?? 0) > 0 ? 'warn-color' : '' ?>"><?= (int)($sms_stats['high_pm'] ?? 0) ?>건</strong>
</div> </div>
<div class="report-stat"> <div class="report-stat">

View file

@ -39,7 +39,7 @@ ksort($sensor_cards);
$is_pm_node = in_array((int)$sid, array_map('intval', PM_SENSOR_IDS), true); $is_pm_node = in_array((int)$sid, array_map('intval', PM_SENSOR_IDS), true);
$type_labels = []; $type_labels = [];
if ($is_leak_node) { $type_labels[] = ['leak', '누수']; } if ($is_leak_node) { $type_labels[] = ['leak', '누수']; }
if ($is_pm_node) { $type_labels[] = ['pm', '미세먼지']; } if ($is_pm_node) { $type_labels[] = ['pm', '화재감시']; }
if ($is_metric) { $type_labels[] = ['metric', '온습도']; } if ($is_metric) { $type_labels[] = ['metric', '온습도']; }
// ── 미수신 placeholder ── // ── 미수신 placeholder ──
@ -198,7 +198,7 @@ ksort($sensor_cards);
</div> </div>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="metric-time">미세먼지 측정값 없음</div> <div class="metric-time">화재감시(PM) 측정값 없음</div>
<?php endif; endif; ?> <?php endif; endif; ?>
<?php <?php

View file

@ -217,7 +217,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<!-- 미세먼지 추이 차트 (PM2.5/PM10 µg/m³ — 온습도와 별도 스케일; PM 센서에서만 표시) --> <!-- 미세먼지 추이 차트 (PM2.5/PM10 µg/m³ — 온습도와 별도 스케일; PM 센서에서만 표시) -->
<?php if (!empty($chart_pm_points)): ?> <?php if (!empty($chart_pm_points)): ?>
<h2 id="pm-chart-section">미세먼지 추이 <h2 id="pm-chart-section">화재감시 추이 (PM)
<span class="h2-sub">· <?= htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 <?= count($chart_pm_points) ?>개</span> <span class="h2-sub">· <?= htmlspecialchars($chart_sensor_name !== '' ? $chart_sensor_name : ('센서 ' . $chart_sensor_id)) ?> 최근 <?= count($chart_pm_points) ?>개</span>
</h2> </h2>
<div class="chart-panel"> <div class="chart-panel">
@ -272,7 +272,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
?> ?>
<svg viewBox="0 0 <?= $pmc_w ?> <?= $pmc_h ?>" width="100%" height="<?= $pmc_h ?>" <svg viewBox="0 0 <?= $pmc_w ?> <?= $pmc_h ?>" width="100%" height="<?= $pmc_h ?>"
preserveAspectRatio="xMidYMid meet" role="img" preserveAspectRatio="xMidYMid meet" role="img"
aria-label="미세먼지 추이 라인 차트" class="chart-svg"> aria-label="화재감시 추이 라인 차트" class="chart-svg">
<rect x="<?= $pmp_l ?>" y="<?= $pmp_t ?>" width="<?= $pmplot_w ?>" height="<?= $pmplot_h ?>" class="plot-bg"></rect> <rect x="<?= $pmp_l ?>" y="<?= $pmp_t ?>" width="<?= $pmplot_w ?>" height="<?= $pmplot_h ?>" class="plot-bg"></rect>
<?php for ($g = 0; $g <= 4; $g++): <?php for ($g = 0; $g <= 4; $g++):
$gy = $pmp_t + $pmplot_h * $g / 4; $gy = $pmp_t + $pmplot_h * $g / 4;
@ -326,7 +326,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
</div> </div>
<p class="caption donut-note">임계 — PM2.5 <?= htmlspecialchars($pm_fmt((float)METRIC_PM25_HIGH)) ?> · PM10 <?= htmlspecialchars($pm_fmt((float)METRIC_PM10_HIGH)) ?> µg/m³ 초과 시 경보</p> <p class="caption donut-note">임계 — PM2.5 <?= htmlspecialchars($pm_fmt((float)METRIC_PM25_HIGH)) ?> · PM10 <?= htmlspecialchars($pm_fmt((float)METRIC_PM10_HIGH)) ?> µg/m³ 초과 시 경보</p>
<?php else: ?> <?php else: ?>
<div class="chart-empty">미세먼지 추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div> <div class="chart-empty">화재감시(PM) 추이를 표시할 측정 데이터가 충분하지 않습니다 (2개 이상 필요).</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
@ -340,7 +340,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<h3>경보 발생 추이 <span class="sub">최근 7일 · 유형별</span></h3> <h3>경보 발생 추이 <span class="sub">최근 7일 · 유형별</span></h3>
<div class="mini-legend"> <div class="mini-legend">
<span><i style="background:var(--warn)"></i>온습도 경보</span> <span><i style="background:var(--warn)"></i>온습도 경보</span>
<span><i style="background:var(--pm)"></i>미세먼지 경보</span> <span><i style="background:var(--pm)"></i>화재의심</span>
<span><i style="background:var(--leak)"></i>누수 감지</span> <span><i style="background:var(--leak)"></i>누수 감지</span>
</div> </div>
<?php <?php
@ -406,7 +406,7 @@ if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div> </div>
<p class="caption donut-note">단위 — 온습도·미세먼지: 비정상 측정 건수 · 누수: 감지 이벤트 건수</p> <p class="caption donut-note">단위 — 온습도·화재의심: 비정상 측정 건수 · 누수: 감지 이벤트 건수</p>
<?php else: ?> <?php else: ?>
<div class="donut-empty">최근 30일 경보 없음</div> <div class="donut-empty">최근 30일 경보 없음</div>
<?php endif; ?> <?php endif; ?>

View file

@ -24,7 +24,7 @@ function eq($a, $b, string $msg): void {
// ── 상수/함수 존재 ───────────────────────────────────────── // ── 상수/함수 존재 ─────────────────────────────────────────
check(function_exists('evaluate_metric_thresholds'), 'evaluate_metric_thresholds() should exist.'); check(function_exists('evaluate_metric_thresholds'), 'evaluate_metric_thresholds() should exist.');
check(defined('SMS_TAG_HIGH_PM'), 'SMS_TAG_HIGH_PM should be defined.'); check(defined('SMS_TAG_HIGH_PM'), 'SMS_TAG_HIGH_PM should be defined.');
eq(SMS_TAG_HIGH_PM, '[미세먼지경보]', 'SMS_TAG_HIGH_PM tag value'); eq(SMS_TAG_HIGH_PM, '[화재의심]', 'SMS_TAG_HIGH_PM tag value');
check(defined('METRIC_PM25_HIGH'), 'METRIC_PM25_HIGH should be defined.'); check(defined('METRIC_PM25_HIGH'), 'METRIC_PM25_HIGH should be defined.');
check(defined('METRIC_PM10_HIGH'), 'METRIC_PM10_HIGH should be defined.'); check(defined('METRIC_PM10_HIGH'), 'METRIC_PM10_HIGH should be defined.');
@ -73,10 +73,10 @@ $b = evaluate_metric_thresholds((float)METRIC_TEMP_HIGH_C + 5, null, $hi25, $lo1
check(in_array('high_temp', $b, true) && in_array('high_pm25', $b, true), check(in_array('high_temp', $b, true) && in_array('high_pm25', $b, true),
'high temp + high pm25 -> both breaches present'); 'high temp + high pm25 -> both breaches present');
// 8) 기본 임계(75/150)에서의 명시 케이스(오버라이드 없을 때만) — 사양 예시 그대로. // 8) 기본 임계(화재의심 35/75)에서의 명시 케이스(오버라이드 없을 때만) — 사양 예시 그대로.
if ($P25 === 75.0) { if ($P25 === 35.0) {
check(in_array('high_pm25', evaluate_metric_thresholds(null, null, 80.0, 40.0)['breaches'], true), check(in_array('high_pm25', evaluate_metric_thresholds(null, null, 40.0, 20.0)['breaches'], true),
'default: pm25=80 (>75) -> high_pm25, pm10=40 -> no high_pm10'); 'default: pm25=40 (>35) -> high_pm25, pm10=20 -> no high_pm10');
} }
// ── 집계 SELECT 에 high_pm 컬럼 포함 ──────────────────────── // ── 집계 SELECT 에 high_pm 컬럼 포함 ────────────────────────