diff --git a/php/cron_heartbeat.php b/php/cron_heartbeat.php index 425e216..905c8c4 100644 --- a/php/cron_heartbeat.php +++ b/php/cron_heartbeat.php @@ -23,7 +23,7 @@ function check_offline_sensors(): int { $db = get_db(); $stmt = $db->prepare(" - SELECT * FROM sensor_status + SELECT * FROM leak_sensor_status WHERE last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND) AND offline_alerted = 0 "); @@ -36,7 +36,7 @@ function check_offline_sensors(): int { // last_seen 조건을 함께 둬, SELECT 와 UPDATE 사이에 장비가 복귀 보고를 하면 // (last_seen 갱신) 선점이 실패하여 오탐 오프라인 SMS 가 나가지 않도록 한다. $claim = $db->prepare(" - UPDATE sensor_status SET offline_alerted = 1 + UPDATE leak_sensor_status SET offline_alerted = 1 WHERE sensor_id = :sensor_id AND offline_alerted = 0 AND last_seen < DATE_SUB(NOW(), INTERVAL :timeout SECOND) @@ -66,7 +66,7 @@ function check_offline_sensors(): int { if (!$sent && SMS_RETRY_OFFLINE) { // 일회성 오프라인 SMS 가 실패하면 선점(offline_alerted=1)을 되돌려, 다음 주기에 // 재시도되게 한다(critical: 게이트웨이 일시오류로 장비 다운을 영영 못 알리던 갭). - $db->prepare("UPDATE sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid") + $db->prepare("UPDATE leak_sensor_status SET offline_alerted = 0 WHERE sensor_id = :sid") ->execute([':sid' => $sensor['sensor_id']]); error_log("[Heartbeat] 오프라인 SMS 실패 - 센서{$sensor['sensor_id']} 재시도 예약"); } diff --git a/php/monthly_report.php b/php/monthly_report.php index 02bf0d3..df1df66 100644 --- a/php/monthly_report.php +++ b/php/monthly_report.php @@ -4,7 +4,7 @@ // // 집계 범위: 온습도 임계 경보(고온/저온/고습/저습), 정상복귀, 장비 오프라인/복구, // SMS 실패, 측정 건수, 평균/최저/최고 온도·습도. -// 데이터 출처: sms_log(message 태그 prefix 기준), sensor_metric(온습도 측정 이력). +// 데이터 출처: leak_sms_log(message 태그 prefix 기준), leak_sensor_metric(온습도 측정 이력). // ============================================================================= require_once __DIR__ . '/config.php'; @@ -80,7 +80,7 @@ $report_scope = '온습도 임계 경보, 장비 오프라인, SMS'; $stmt = $db->prepare(" SELECT " . sms_summary_select_sql() . " - FROM sms_log + FROM leak_sms_log WHERE sent_at >= :start_at AND sent_at < :end_at "); $stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]); @@ -116,7 +116,7 @@ $stmt = $db->prepare(" AVG(humidity_percent) AS avg_humidity, MIN(humidity_percent) AS min_humidity, MAX(humidity_percent) AS max_humidity - FROM sensor_metric + FROM leak_sensor_metric WHERE measured_at >= :start_at AND measured_at < :end_at "); $stmt->execute([':start_at' => $start_at, ':end_at' => $end_at]); @@ -133,7 +133,7 @@ $summary['max_humidity'] = round_or_null($row['max_humidity'] ?? null); $stmt = $db->prepare(" SELECT sensor_id, sensor_name, " . sms_summary_select_sql() . " - FROM sms_log + FROM leak_sms_log WHERE sent_at >= :start_at AND sent_at < :end_at GROUP BY sensor_id, sensor_name "); @@ -154,7 +154,7 @@ $stmt = $db->prepare(" MIN(humidity_percent) AS min_humidity, MAX(humidity_percent) AS max_humidity, MAX(measured_at) AS last_measured_at - FROM sensor_metric + FROM leak_sensor_metric WHERE measured_at >= :start_at AND measured_at < :end_at GROUP BY sensor_id, sensor_name, device_location "); diff --git a/php/ops_checks.php b/php/ops_checks.php index 18f0d40..3d847a6 100644 --- a/php/ops_checks.php +++ b/php/ops_checks.php @@ -48,7 +48,7 @@ function collect_ops_checks(): array { $db = get_db(); $checks[] = ops_check('서버', 'DB 연결', true, DB_NAME . ' 연결 성공', '', 'critical', 'setup_wizard.php 또는 security_evidence.php 화면'); - $required_tables = ['sensor_log', 'sensor_status', 'sensor_metric', 'sms_log']; + $required_tables = ['leak_sensor_log', 'leak_sensor_status', 'leak_sensor_metric', 'leak_sms_log']; foreach ($required_tables as $table) { $exists = ops_table_exists($db, $table); $checks[] = ops_check( @@ -63,30 +63,30 @@ function collect_ops_checks(): array { } // 최근 온습도 측정 수신 여부(센서 동작/수집 확인) - if (ops_table_exists($db, 'sensor_metric')) { + if (ops_table_exists($db, 'leak_sensor_metric')) { $recent_metric_stmt = $db->query(" SELECT COUNT(*) - FROM sensor_metric + FROM leak_sensor_metric WHERE measured_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR) "); $recent_metric = (int)$recent_metric_stmt->fetchColumn(); - $checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'sensor_metric 최근 수신'); + $checks[] = ops_check('센서', '최근 1시간 온습도 수신', $recent_metric > 0, "{$recent_metric}건", 'SHT30 기기 전원/네트워크/배선 및 sensor_data.php 수신 확인', 'warn', 'leak_sensor_metric 최근 수신'); } - if (ops_table_exists($db, 'sms_log')) { + if (ops_table_exists($db, 'leak_sms_log')) { $recent_sms_fail_stmt = $db->query(" SELECT COUNT(*) - FROM sms_log + FROM leak_sms_log WHERE status = 'fail' AND sent_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) "); $recent_sms_fail = (int)$recent_sms_fail_stmt->fetchColumn(); - $checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'sms_log 최근 실패 건수'); + $checks[] = ops_check('SMS', '최근 30일 SMS 실패', $recent_sms_fail === 0, "{$recent_sms_fail}건", 'Cafe24 SMS 계정, 잔액, 발신번호, 수신자 확인', 'warn', 'leak_sms_log 최근 실패 건수'); // SMS 잔여 건수(최근 성공 발송 기준). 임계 이하이면 충전 필요(경보 채널 보호). $sms_balance = latest_sms_balance($db); if ($sms_balance !== null) { - $checks[] = ops_check('SMS', 'SMS 잔여 건수', $sms_balance > SMS_BALANCE_WARN_THRESHOLD, "{$sms_balance}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)", 'Cafe24 SMS 충전 — 잔액 소진 시 임계/오프라인 경보 발송이 중단됩니다', 'warn', 'sms_log 최근 발송 잔여'); + $checks[] = ops_check('SMS', 'SMS 잔여 건수', $sms_balance > SMS_BALANCE_WARN_THRESHOLD, "{$sms_balance}건 (임계 " . SMS_BALANCE_WARN_THRESHOLD . "건)", 'Cafe24 SMS 충전 — 잔액 소진 시 임계/오프라인 경보 발송이 중단됩니다', 'warn', 'leak_sms_log 최근 발송 잔여'); } } } catch (Throwable $e) { diff --git a/php/retention_cleanup.php b/php/retention_cleanup.php index 5607c25..3ab4a8b 100644 --- a/php/retention_cleanup.php +++ b/php/retention_cleanup.php @@ -46,9 +46,9 @@ $summary = [ 'sensor_log_retention_days' => SENSOR_LOG_RETENTION_DAYS, 'sensor_metric_retention_days' => SENSOR_METRIC_RETENTION_DAYS, 'admin_audit_retention_days' => ADMIN_AUDIT_RETENTION_DAYS, - 'sms_log' => ['matched' => 0, 'rows_deleted' => 0], - 'sensor_log' => ['matched' => 0, 'rows_deleted' => 0], - 'sensor_metric' => ['matched' => 0, 'rows_deleted' => 0], + 'leak_sms_log' => ['matched' => 0, 'rows_deleted' => 0], + 'leak_sensor_log' => ['matched' => 0, 'rows_deleted' => 0], + 'leak_sensor_metric' => ['matched' => 0, 'rows_deleted' => 0], 'admin_audit' => ['kept_lines' => null, 'removed_lines' => null], ]; @@ -62,16 +62,16 @@ try { } $smsCutoff = retention_cutoff(SMS_LOG_RETENTION_DAYS); -$summary['sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]); -$summary['sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun); +$summary['leak_sms_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff]); +$summary['leak_sms_log']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sms_log WHERE sent_at < :cutoff", [':cutoff' => $smsCutoff], $dryRun); $sensorCutoff = retention_cutoff(SENSOR_LOG_RETENTION_DAYS); -$summary['sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]); -$summary['sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun); +$summary['leak_sensor_log']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff]); +$summary['leak_sensor_log']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sensor_log WHERE created_at < :cutoff", [':cutoff' => $sensorCutoff], $dryRun); $metricCutoff = retention_cutoff(SENSOR_METRIC_RETENTION_DAYS); -$summary['sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]); -$summary['sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun); +$summary['leak_sensor_metric']['matched'] = retention_count($db, "SELECT COUNT(*) FROM leak_sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff]); +$summary['leak_sensor_metric']['rows_deleted'] = retention_delete($db, "DELETE FROM leak_sensor_metric WHERE created_at < :cutoff", [':cutoff' => $metricCutoff], $dryRun); $auditPath = admin_audit_log_path(); if (is_file($auditPath)) { @@ -113,9 +113,9 @@ if (is_file($auditPath)) { admin_audit_log('retention_cleanup', 'system', true, [ 'mode' => $summary['mode'], - 'sms_matches' => $summary['sms_log']['matched'], - 'sensor_matches' => $summary['sensor_log']['matched'], - 'metric_matches' => $summary['sensor_metric']['matched'], + 'sms_matches' => $summary['leak_sms_log']['matched'], + 'sensor_matches' => $summary['leak_sensor_log']['matched'], + 'metric_matches' => $summary['leak_sensor_metric']['matched'], ]); header('Content-Type: application/json; charset=utf-8'); diff --git a/php/security_evidence.php b/php/security_evidence.php index 7b03807..e3801ab 100644 --- a/php/security_evidence.php +++ b/php/security_evidence.php @@ -30,8 +30,8 @@ $control_rows = [ ['비밀값', 'config.local.php와 /etc/sht30-sensor.env로 분리', 'config.php', '설정 파일 권한 화면'], ['관리자 인증', '비밀번호 해시, CSRF, 세션 보호, 실패 제한, 관리자 MFA', 'login.php, admin_security.php', '로그인 화면, MFA 등록 확인표'], ['감사로그', '로그인 성공/실패, 로그아웃, 관리자 활동 접속기록 저장', 'admin_security.php, php/var/admin_audit.log', '감사로그 파일'], - ['로그관리', '센서 측정 이력(sensor_metric)·SMS 발송 이력(sms_log)·관리자 감사로그 저장 및 보관기간 정리', 'MySQL, retention_cleanup.php', '보관기간 정리 dry-run 결과'], - ['장애대응', '온습도 임계 경보 SMS + 장비 오프라인/복구 알림', 'cron_heartbeat.php', 'SMS 로그, sensor_metric 이력'], + ['로그관리', '센서 측정 이력(leak_sensor_metric)·SMS 발송 이력(leak_sms_log)·관리자 감사로그 저장 및 보관기간 정리', 'MySQL, retention_cleanup.php', '보관기간 정리 dry-run 결과'], + ['장애대응', '온습도 임계 경보 SMS + 장비 오프라인/복구 알림', 'cron_heartbeat.php', 'SMS 로그, leak_sensor_metric 이력'], ['백업', 'DB/설정 백업 목록과 복구 테스트 증적 관리', 'scripts/backup_evidence.php', 'backup-evidence 보고서'], ]; diff --git a/php/sms_send.php b/php/sms_send.php index 66f7913..6ba5d64 100644 --- a/php/sms_send.php +++ b/php/sms_send.php @@ -169,7 +169,7 @@ function send_sms_cafe24(string $receiver, string $message): array { } /** - * 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 sms_log 에 기록한다. + * 완성된 메시지를 모든 수신자(SMS_RECIPIENTS)에게 발송하고 leak_sms_log 에 기록한다. * * @param PDO $db * @param array $sensor_data ['sensor_id','sensor_name','device_id', ...] @@ -191,7 +191,7 @@ function sms_dispatch_to_recipients(PDO $db, array $sensor_data, string $message } $stmt = $db->prepare(" - INSERT INTO sms_log + INSERT INTO leak_sms_log (sensor_id, sensor_name, device_id, message, receivers, status, result_code, result_message, sent_at) VALUES @@ -234,7 +234,7 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void { } // 쿨다운: 최근 '[SMS 잔액부족]' 성공/실패 불문 발송이 있으면 생략(반복 폭주 방지). $recent = $db->prepare(" - SELECT sent_at FROM sms_log + SELECT sent_at FROM leak_sms_log WHERE message LIKE :tag ORDER BY sent_at DESC LIMIT 1 @@ -254,7 +254,7 @@ function maybe_warn_sms_balance(PDO $db, ?int $remaining): void { $receiverDigits = preg_replace('/\D+/', '', $receiver); $result = send_sms_cafe24($receiverDigits, $message); $stmt = $db->prepare(" - INSERT INTO sms_log + INSERT INTO leak_sms_log (sensor_id, sensor_name, device_id, message, receivers, status, result_code, result_message, sent_at) VALUES @@ -329,7 +329,7 @@ function condensation_is_clear(?float $temp_c, ?float $rh): bool { /** * 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다. * @return array{breaches: string[], status: string} - * breaches: 발생한 경보 종류 목록, status: sensor_metric 저장용 대표 상태 + * breaches: 발생한 경보 종류 목록, status: leak_sensor_metric 저장용 대표 상태 */ function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array { $breaches = []; @@ -379,7 +379,7 @@ function metric_is_recovered(?float $temp_c, ?float $rh): bool { /** * 센서가 현재 임계 경보 상태인지 판정한다(최근 metric SMS가 경보이고 그 뒤 복귀가 - * 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(sms_log 기준). + * 없었는지). 복귀 SMS 를 한 번만 보내기 위한 상태 추적(leak_sms_log 기준). */ function metric_was_alerting(PDO $db, int $sensor_id): bool { // 태그는 config.php 상수(단일 출처)에서 가져와 바인딩한다. 문자열 하드코딩 금지. @@ -397,7 +397,7 @@ function metric_was_alerting(PDO $db, int $sensor_id): bool { $likes[] = "message LIKE {$key}"; $params[$key] = $tag . '%'; } - $sql = "SELECT message FROM sms_log + $sql = "SELECT message FROM leak_sms_log WHERE sensor_id = :sid AND status = 'success' AND (" . implode(' OR ', $likes) . ") @@ -442,7 +442,7 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value): [$tag, $metricName, $unit, $threshold, $dir] = $labels[$kind]; // ── 동시 발송 직렬화 (중복 SMS 방지) ──────────────────────── - // 쿨다운 검사→발송→sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이 + // 쿨다운 검사→발송→leak_sms_log 기록 사이에 다른 요청(서로 다른 측정의 동시 수신 등)이 // 끼어들면 둘 다 쿨다운을 통과해 같은 경보를 중복 발송할 수 있다. (센서,종류) 단위 // MySQL 네임드 락으로 직렬화한다. 락이 잡혀 있으면(busy) 다른 프로세스가 같은 경보를 // 처리 중이므로 생략한다. GET_LOCK 미지원/오류(NULL)면 종전 동작(락 없이 진행)으로 @@ -466,9 +466,9 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value): } try { - // ── 종류별 쿨다운 (sms_log 메시지 prefix 기준) ────────────── + // ── 종류별 쿨다운 (leak_sms_log 메시지 prefix 기준) ────────────── $recent = $db->prepare(" - SELECT sent_at FROM sms_log + SELECT sent_at FROM leak_sms_log WHERE sensor_id = :sensor_id AND status = 'success' AND message LIKE :tag @@ -567,9 +567,9 @@ function send_leak_alert_sms(array $sensor_data): bool { } try { - // ── 쿨다운 (sms_log 메시지 prefix 기준) ── + // ── 쿨다운 (leak_sms_log 메시지 prefix 기준) ── $recent = $db->prepare(" - SELECT sent_at FROM sms_log + SELECT sent_at FROM leak_sms_log WHERE sensor_id = :sensor_id AND status = 'success' AND message LIKE :tag @@ -621,7 +621,7 @@ function send_leak_recovery_sms(array $sensor_data): bool { // 직전 누수 상태 SMS([누수감지]/[누수복구]) 중 최신 1건을 본다. 최신이 [누수감지]일 // 때만(=아직 복구 통지 전) 복구 SMS 를 보낸다. $stmt = $db->prepare(" - SELECT message FROM sms_log + SELECT message FROM leak_sms_log WHERE sensor_id = :sid AND status = 'success' AND (message LIKE :alert OR message LIKE :recovery) @@ -702,14 +702,14 @@ function send_device_online_sms(array $sensor_data): bool { * 예: "센서 2/2 정상, 최근수신 3분전, 24h경보 0건, 잔여 95건" */ function build_health_summary(PDO $db): string { - $online = (int)$db->query("SELECT COUNT(*) FROM sensor_status WHERE offline_alerted = 0")->fetchColumn(); - $total = (int)$db->query("SELECT COUNT(*) FROM sensor_status")->fetchColumn(); + $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 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 sms_log + 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 . "%' @@ -735,7 +735,7 @@ function maybe_send_daily_health_sms(PDO $db): void { return; } $today = $db->prepare(" - SELECT 1 FROM sms_log + SELECT 1 FROM leak_sms_log WHERE message LIKE :tag AND DATE(sent_at) = CURDATE() LIMIT 1 "); @@ -785,7 +785,7 @@ function maybe_send_daily_health_sms(PDO $db): void { */ function recent_tag_within(PDO $db, int $sensor_id, string $tag, int $seconds): bool { $stmt = $db->prepare(" - SELECT sent_at FROM sms_log + SELECT sent_at FROM leak_sms_log WHERE sensor_id = :sid AND message LIKE :tag ORDER BY sent_at DESC LIMIT 1 "); @@ -828,7 +828,7 @@ function latest_alert_state_row(PDO $db, int $sensor_id): ?array { } // id(AUTO_INCREMENT) 를 2차 정렬키로 둬, 같은 초에 경보·복귀가 기록돼도 최신 행이 // 결정적으로 선택되게 한다(sent_at 은 1초 해상도). - $sql = "SELECT message, sent_at FROM sms_log + $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"; @@ -848,7 +848,7 @@ function maybe_escalate_alerts(PDO $db): void { 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 sensor_status")->fetchAll(); + $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); @@ -897,12 +897,12 @@ function maybe_detect_stuck_sensors(PDO $db): void { return; } $n = (int)STUCK_DETECT_SAMPLES; // 정수 상수 — LIMIT 에 안전하게 인라인 - $sensors = $db->query("SELECT sensor_id, sensor_name, device_id, device_location FROM sensor_status")->fetchAll(); + $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 sensor_metric + FROM leak_sensor_metric WHERE sensor_id = :sid ORDER BY measured_at DESC LIMIT {$n} diff --git a/raspberry_pi4_sht30/config.py b/raspberry_pi4_sht30/config.py index 4bb0eec..e2c028c 100644 --- a/raspberry_pi4_sht30/config.py +++ b/raspberry_pi4_sht30/config.py @@ -11,7 +11,7 @@ # ───────────────────────────────────────────── # 센서 식별 (서버 대시보드 표시용; 비밀 아님) -# SENSOR_ID 는 서버 sensor_metric / sensor_status 의 키. 대시보드 기본 차트=2. +# SENSOR_ID 는 서버 leak_sensor_metric / leak_sensor_status 의 키. 대시보드 기본 차트=2. # ───────────────────────────────────────────── SENSOR_ID = 2 SENSOR_NAME = "2번 센서 (SHT30)" diff --git a/raspberry_pi4_sht30/sht30_sensor.py b/raspberry_pi4_sht30/sht30_sensor.py index ecc8418..d4ca036 100644 --- a/raspberry_pi4_sht30/sht30_sensor.py +++ b/raspberry_pi4_sht30/sht30_sensor.py @@ -173,7 +173,7 @@ def post_reading(event_type: str, temp_c: float, rh: float, metric_status: str) # 측정 루프 (firmware sht30_measure_task 대응) # ───────────────────────────────────────────── def measure_loop(sensor: SHT30): - # 최초 성공 측정만 startup, 이후 periodic (서버 sensor_log 1회 startup 기록) + # 최초 성공 측정만 startup, 이후 periodic (서버 leak_sensor_log 1회 startup 기록) first_success = True log.info(f"[LOOP] 측정 시작 (간격={REPORT_INTERVAL}s, addr=0x{I2C_ADDR:02X}, bus={I2C_BUS})") diff --git a/scripts/generate_security_plan_images.py b/scripts/generate_security_plan_images.py index 85cb5bc..a2e53bf 100644 --- a/scripts/generate_security_plan_images.py +++ b/scripts/generate_security_plan_images.py @@ -115,7 +115,7 @@ def save_data_flow(): ("1. 온습도 측정", ["SHT30 측정", "온도/습도 5분 주기"]), ("2. 단말 보고", ["측정값 수집", "periodic/startup", "정상 보고"]), ("3. API 전송", ["HTTPS/TLS", "HMAC/서명 검증", "API 키 일치"]), - ("4. 서버 판정/저장", ["임계 판정(METRIC_*)", "sensor_metric", "sensor_log", "sms_log"]), + ("4. 서버 판정/저장", ["임계 판정(METRIC_*)", "leak_sensor_metric", "leak_sensor_log", "leak_sms_log"]), ("5. 알림/대응", ["임계 SMS 발송", "정상복귀 SMS", "대시보드 확인"]), ] xs = [45, 275, 505, 735, 965] diff --git a/sql/migration_add_leak.sql b/sql/migration_add_leak.sql deleted file mode 100644 index 10212dd..0000000 --- a/sql/migration_add_leak.sql +++ /dev/null @@ -1,105 +0,0 @@ --- ============================================================================= --- migration_add_leak.sql - SHT30 전용 서버에 누수(leak) 수신을 다시 추가(통합) --- --- [목적] 1번 모듈(누수+카메라, sensor_id=1)과 2번 모듈(SHT30 온습도, sensor_id=2)이 --- "하나의 cafe24 서버 php/" 로 함께 보고하도록 통합한다. 이 마이그레이션은 --- sql/migration_drop_leak.sql 가 제거했던 누수 요소를 "가산(additive)·멱등"으로 --- 되돌린다(= migration_drop_leak.sql 의 역방향, 단 데이터 삭제 없이 가산만 수행). --- --- [안전성] 운영 중인 SHT30 서버에서 그대로 실행해도 안전하다: --- - sensor_metric(SHT30 측정) / sms_log / sensor_status / ingest_nonce 등 기존 테이블· --- 데이터는 전혀 건드리지 않는다. --- - 모든 변경은 "없을 때만 추가"(IF NOT EXISTS / information_schema 가드)이므로 --- 여러 번 재실행해도 오류가 나지 않는다. --- --- [되돌리는 항목] --- - sensor_log.event_type ENUM 에 leak_alert / leak_recovery 재추가 --- - sensor_log.is_leak 컬럼(없으면 추가) --- - sensor_status.is_leak 컬럼(없으면 추가) --- - leak_incident 테이블(없으면 생성) — 누수 사고(에피소드) 시작/복구 추적 --- - leak_photo 테이블(없으면 생성) — 누수 시 촬영 사진 메타데이터 --- --- 신규(통합) 설치라면 이 파일이 아니라 sql/schema_sht30.sql 하나만 실행하면 된다 --- (schema_sht30.sql 이 이미 누수 요소를 포함하도록 통합되어 있다). --- ============================================================================= - -SET NAMES utf8mb4; - --- 1) sensor_log.event_type: 누수 enum 값을 다시 허용(멱등 — 동일 enum 재적용은 안전). -ALTER TABLE `sensor_log` - MODIFY `event_type` ENUM('startup','periodic','leak_alert','leak_recovery') - NOT NULL DEFAULT 'periodic'; - --- 2~3) 컬럼/인덱스 추가는 "없을 때만" 수행해 재실행해도 오류가 없게 한다. --- (MySQL/MariaDB 는 ADD COLUMN IF NOT EXISTS 호환이 일정치 않으므로 --- information_schema 로 가드하는 멱등 프로시저를 사용한다.) -DROP PROCEDURE IF EXISTS _mig_add_leak; -DELIMITER // -CREATE PROCEDURE _mig_add_leak() -BEGIN - DECLARE db VARCHAR(64); - SELECT DATABASE() INTO db; - - -- sensor_log.is_leak: 누수 여부(0/1) — 없으면 추가 - IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND COLUMN_NAME = 'is_leak') THEN - ALTER TABLE `sensor_log` - ADD COLUMN `is_leak` TINYINT(1) NOT NULL DEFAULT 0 - COMMENT '누수 여부 (1=누수, 0=정상)' AFTER `event_type`; - END IF; - - -- sensor_status.is_leak: 최신 누수 상태(0/1) — 없으면 추가 - IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND COLUMN_NAME = 'is_leak') THEN - ALTER TABLE `sensor_status` - ADD COLUMN `is_leak` TINYINT(1) NOT NULL DEFAULT 0 - COMMENT '최신 누수 상태 (1=누수, 0=정상)' AFTER `last_event_type`; - END IF; -END // -DELIMITER ; - -CALL _mig_add_leak(); -DROP PROCEDURE IF EXISTS _mig_add_leak; - --- 4) leak_incident: 누수 사고(에피소드) 추적. leak_alert 시 행을 열고(open), 같은 --- 센서의 leak_recovery 시 가장 최근 미복구 행을 닫는다(recovered_at 채움). -CREATE TABLE IF NOT EXISTS `leak_incident` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - `sensor_id` TINYINT UNSIGNED NOT NULL COMMENT '센서 번호(누수=1)', - `device_id` VARCHAR(64) NOT NULL DEFAULT '', - `sensor_name` VARCHAR(100) NOT NULL DEFAULT '', - `device_location` VARCHAR(100) NOT NULL DEFAULT '', - `started_at` DATETIME NOT NULL COMMENT '누수 감지(시작) 시각', - `recovered_at` DATETIME NULL DEFAULT NULL COMMENT '누수 복구 시각(미복구면 NULL)', - `log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert sensor_log.id', - `log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery sensor_log.id', - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY (`id`), - INDEX `idx_sensor_open` (`sensor_id`, `recovered_at`), - INDEX `idx_started_at` (`started_at`) -) ENGINE=InnoDB - DEFAULT CHARSET=utf8mb4 - COLLATE=utf8mb4_unicode_ci - COMMENT='누수 사고(에피소드) 추적 (시작/복구)'; - --- 5) leak_photo: 누수 시 촬영한 사진 메타데이터. 실제 파일은 php/var/photos/ 에 저장되고 --- filename 은 서버가 생성한 무작위 파일명(클라이언트 경로 미사용 — 경로주입 방지). -CREATE TABLE IF NOT EXISTS `leak_photo` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - `log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert sensor_log.id', - `sensor_id` TINYINT UNSIGNED NOT NULL, - `device_id` VARCHAR(64) NOT NULL DEFAULT '', - `filename` VARCHAR(128) NOT NULL COMMENT '서버 생성 파일명(var/photos/ 하위)', - `mime` VARCHAR(40) NOT NULL DEFAULT 'image/jpeg', - `bytes` INT UNSIGNED NOT NULL DEFAULT 0, - `captured_at` DATETIME NOT NULL COMMENT '촬영(업로드) 시각', - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY (`id`), - INDEX `idx_log_id` (`log_id`), - INDEX `idx_captured_at` (`captured_at`) -) ENGINE=InnoDB - DEFAULT CHARSET=utf8mb4 - COLLATE=utf8mb4_unicode_ci - COMMENT='누수 사진 메타데이터 (파일은 var/photos/)'; diff --git a/sql/migration_drop_leak.sql b/sql/migration_drop_leak.sql deleted file mode 100644 index c97412c..0000000 --- a/sql/migration_drop_leak.sql +++ /dev/null @@ -1,106 +0,0 @@ --- ============================================================================= --- migration_drop_leak.sql - 기존 누수(v2605) 설치 → SHT30 온습도 전용 전환 --- --- ⚠️ 비가역(DROP COLUMN/TABLE) 변경입니다. 반드시 DB 전체 백업 후 실행하세요. --- 운영 데이터(과거 누수 이벤트/사진/사고 이력)가 삭제됩니다. --- --- 이미 v2605 스키마(sensor_log/sensor_status/sensor_metric/sms_log + leak_*)가 --- 적용된 운영 DB를 SHT30 전용으로 정리합니다. 측정 이력(sensor_metric)과 SMS --- 이력(sms_log)은 보존합니다. --- --- 신규 설치라면 이 파일이 아니라 sql/schema_sht30.sql 하나만 실행하세요. --- ============================================================================= - -SET NAMES utf8mb4; - --- 0) sensor_metric 이 없던 구버전 대비: 없으면 생성(있으면 무시). -CREATE TABLE IF NOT EXISTS `sensor_metric` ( - `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - `device_id` VARCHAR(64) NOT NULL DEFAULT '', - `device_location` VARCHAR(100) NOT NULL DEFAULT '', - `sensor_id` TINYINT UNSIGNED NOT NULL, - `sensor_name` VARCHAR(100) NOT NULL DEFAULT '', - `metric_type` VARCHAR(30) NOT NULL DEFAULT 'sht30', - `temperature_c` DECIMAL(6,2) NULL, - `humidity_percent` DECIMAL(5,2) NULL, - `metric_status` VARCHAR(30) NOT NULL DEFAULT 'normal', - `measured_at` DATETIME NOT NULL, - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - INDEX `idx_sensor_measured` (`sensor_id`, `measured_at`), - INDEX `idx_device_sensor` (`device_id`, `sensor_id`), - INDEX `idx_metric_type` (`metric_type`, `created_at`), - INDEX `idx_status_measured` (`metric_status`, `measured_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - --- 1) 누수 전용 테이블 제거 -DROP TABLE IF EXISTS `leak_photo`; -DROP TABLE IF EXISTS `leak_incident`; - --- 2) sensor_log: 누수 enum 값 정리 → event_type 재정의 (둘 다 멱등) --- (leak_alert/leak_recovery 행이 있으면 periodic 으로 강등 후 enum 축소. --- 이미 정리된 DB면 매칭 행이 없어 no-op, MODIFY 도 동일 enum 재적용이라 안전.) -UPDATE `sensor_log` SET `event_type` = 'periodic' - WHERE `event_type` IN ('leak_alert', 'leak_recovery'); -ALTER TABLE `sensor_log` - MODIFY `event_type` ENUM('startup','periodic') NOT NULL DEFAULT 'periodic'; - --- 3~4) 인덱스/컬럼 제거·추가는 "존재할 때만" 수행하여 재실행해도 오류가 없게 한다. --- (MySQL/MariaDB 는 DROP COLUMN/INDEX 에 IF EXISTS 호환이 일정치 않으므로 --- information_schema 로 가드하는 멱등 프로시저를 사용한다.) -DROP PROCEDURE IF EXISTS _mig_drop_leak; -DELIMITER // -CREATE PROCEDURE _mig_drop_leak() -BEGIN - DECLARE db VARCHAR(64); - SELECT DATABASE() INTO db; - - -- sensor_log: is_leak 인덱스/컬럼 제거, event_type 인덱스 추가 - IF EXISTS (SELECT 1 FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND INDEX_NAME = 'idx_is_leak') THEN - ALTER TABLE `sensor_log` DROP INDEX `idx_is_leak`; - END IF; - IF EXISTS (SELECT 1 FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND COLUMN_NAME = 'is_leak') THEN - ALTER TABLE `sensor_log` DROP COLUMN `is_leak`; - END IF; - IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_log' AND INDEX_NAME = 'idx_event_type') THEN - ALTER TABLE `sensor_log` ADD INDEX `idx_event_type` (`event_type`, `created_at`); - END IF; - - -- sensor_status: 누수 컬럼/인덱스 제거 (offline_alerted 는 오프라인 SMS에 필요 → 유지) - IF EXISTS (SELECT 1 FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND INDEX_NAME = 'idx_is_leak') THEN - ALTER TABLE `sensor_status` DROP INDEX `idx_is_leak`; - END IF; - IF EXISTS (SELECT 1 FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND COLUMN_NAME = 'is_leak') THEN - ALTER TABLE `sensor_status` DROP COLUMN `is_leak`; - END IF; - IF EXISTS (SELECT 1 FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_status' AND COLUMN_NAME = 'leak_periodic_count') THEN - ALTER TABLE `sensor_status` DROP COLUMN `leak_periodic_count`; - END IF; - - -- sensor_metric: 임계 경보 조회용 인덱스 추가(없을 때만) - IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = db AND TABLE_NAME = 'sensor_metric' AND INDEX_NAME = 'idx_status_measured') THEN - ALTER TABLE `sensor_metric` ADD INDEX `idx_status_measured` (`metric_status`, `measured_at`); - END IF; -END // -DELIMITER ; - -CALL _mig_drop_leak(); -DROP PROCEDURE IF EXISTS _mig_drop_leak; - --- 5) 수집 재전송(replay) 멱등 nonce 테이블 추가(없을 때만 — 가산/멱등). --- api/sensor_data.php 가 서명을 nonce 로 기록해 시계오차 창 내 중복 수신을 멱등 --- 처리한다(중복 저장·중복 SMS 방지). 이 테이블이 없어도 수집은 동작한다. -CREATE TABLE IF NOT EXISTS `ingest_nonce` ( - `signature` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, - `seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`signature`), - INDEX `idx_seen_at` (`seen_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - COMMENT='수집 재전송 멱등 nonce (시계오차 창 내 중복 차단; 주기적 정리)'; diff --git a/sql/schema_sht30.sql b/sql/schema_sht30.sql index 2882d48..5284a98 100644 --- a/sql/schema_sht30.sql +++ b/sql/schema_sht30.sql @@ -5,24 +5,23 @@ -- 신규 설치: 이 파일 하나만 실행합니다. -- SOURCE /path/to/sql/schema_sht30.sql; -- --- 기존(누수 v2605) 설치를 정리·재설치할 때는 운영 데이터를 보존하려면 마이그레이션을 --- 사용합니다: 누수를 빼려면 sql/migration_drop_leak.sql, 빠진 서버에 누수를 다시 --- 더하려면 sql/migration_add_leak.sql(가산·멱등). +-- v2607: 모든 테이블에 leak_ 접두사 적용 — 같은 DB 의 다른(비관련) 테이블과 격리. +-- (구버전 무접두 테이블 대상 마이그레이션 파일들은 리네임과 함께 폐기 — git 이력 참고.) -- -- 이 파일은 두 모듈을 하나의 서버에서 받는 "통합" 스키마입니다. 포함 요소: --- - 온습도(SHT30): sensor_metric 테이블 + 서버 임계 판정(config.php METRIC_*). --- - 누수(leak): sensor_log.is_leak / sensor_status.is_leak 컬럼, +-- - 온습도(SHT30): leak_sensor_metric 테이블 + 서버 임계 판정(config.php METRIC_*). +-- - 누수(leak): leak_sensor_log.is_leak / leak_sensor_status.is_leak 컬럼, -- event_type 의 leak_alert / leak_recovery, -- leak_incident(사고 추적) + leak_photo(사진 메타) 테이블. -- 임계/누수 경보 SMS 발송은 서버 PHP(api/sensor_data.php + sms_send.php)에서 수행하며, --- 발송 이력은 sms_log 에 남깁니다(sms_log 기준 쿨다운/복구 판정 — 별도 경보 상태 테이블 없음). +-- 발송 이력은 leak_sms_log 에 남깁니다(leak_sms_log 기준 쿨다운/복구 판정 — 별도 경보 상태 테이블 없음). -- 누수 사진 실제 파일은 php/var/photos/ 에 저장되고, leak_photo 에는 메타만 둡니다. -- ============================================================================= SET NAMES utf8mb4; SET time_zone = '+09:00'; -CREATE TABLE IF NOT EXISTS `sensor_log` ( +CREATE TABLE IF NOT EXISTS `leak_sensor_log` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `device_id` VARCHAR(64) NOT NULL COMMENT 'STM32 기기 ID', `device_location` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '설치 위치', @@ -43,7 +42,7 @@ CREATE TABLE IF NOT EXISTS `sensor_log` ( COLLATE=utf8mb4_unicode_ci COMMENT='센서 이벤트 전체 로그'; -CREATE TABLE IF NOT EXISTS `sensor_status` ( +CREATE TABLE IF NOT EXISTS `leak_sensor_status` ( `sensor_id` TINYINT UNSIGNED NOT NULL, `device_id` VARCHAR(64) NOT NULL, `sensor_name` VARCHAR(100) NOT NULL DEFAULT '', @@ -61,7 +60,7 @@ CREATE TABLE IF NOT EXISTS `sensor_status` ( COLLATE=utf8mb4_unicode_ci COMMENT='센서별 최신 상태 (업서트)'; -CREATE TABLE IF NOT EXISTS `sensor_metric` ( +CREATE TABLE IF NOT EXISTS `leak_sensor_metric` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `device_id` VARCHAR(64) NOT NULL DEFAULT '', `device_location` VARCHAR(100) NOT NULL DEFAULT '', @@ -85,7 +84,7 @@ CREATE TABLE IF NOT EXISTS `sensor_metric` ( COLLATE=utf8mb4_unicode_ci COMMENT='SHT30 온습도 측정 이력 (온도/습도/임계 상태)'; -CREATE TABLE IF NOT EXISTS `sms_log` ( +CREATE TABLE IF NOT EXISTS `leak_sms_log` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `sensor_id` TINYINT UNSIGNED NOT NULL, `sensor_name` VARCHAR(100) NOT NULL DEFAULT '', @@ -110,7 +109,7 @@ CREATE TABLE IF NOT EXISTS `sms_log` ( -- 안에서 같은 서명 본문이 다시 오면(펌웨어 재시도/캡처 재생) 중복 저장·중복 SMS 없이 -- 멱등 처리된다. 오래된 행은 서버가 주기적으로 정리한다(이 테이블이 없어도 수집은 -- 동작하며 멱등 방어만 비활성화된다 — graceful degrade). -CREATE TABLE IF NOT EXISTS `ingest_nonce` ( +CREATE TABLE IF NOT EXISTS `leak_ingest_nonce` ( `signature` VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '요청 서명(X-Signature) — 멱등 키', `seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -133,8 +132,8 @@ CREATE TABLE IF NOT EXISTS `leak_incident` ( `device_location` VARCHAR(100) NOT NULL DEFAULT '', `started_at` DATETIME NOT NULL COMMENT '누수 감지(시작) 시각', `recovered_at` DATETIME NULL DEFAULT NULL COMMENT '누수 복구 시각(미복구면 NULL)', - `log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert sensor_log.id', - `log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery sensor_log.id', + `log_id_alert` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_alert leak_sensor_log.id', + `log_id_recovery` BIGINT UNSIGNED NULL DEFAULT NULL COMMENT 'leak_recovery leak_sensor_log.id', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), @@ -149,7 +148,7 @@ CREATE TABLE IF NOT EXISTS `leak_incident` ( -- (클라이언트 파일명/경로 미사용 — 경로주입 방지). api/photo_upload.php 가 기록한다. CREATE TABLE IF NOT EXISTS `leak_photo` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - `log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert sensor_log.id', + `log_id` BIGINT UNSIGNED NOT NULL COMMENT 'leak_alert leak_sensor_log.id', `sensor_id` TINYINT UNSIGNED NOT NULL, `device_id` VARCHAR(64) NOT NULL DEFAULT '', `filename` VARCHAR(128) NOT NULL COMMENT '서버 생성 파일명(var/photos/ 하위)', diff --git a/tests/security_hardening_test.php b/tests/security_hardening_test.php index e882194..145f575 100644 --- a/tests/security_hardening_test.php +++ b/tests/security_hardening_test.php @@ -156,7 +156,7 @@ $opsChecks = file_text($root . '/php/ops_checks.php'); check(str_contains($config, 'INGEST_NONCE_ENABLED'), 'config.php should define INGEST_NONCE_ENABLED.'); check(str_contains($sensorApi, 'ingest_nonce_claim'), 'sensor_data.php should claim a replay nonce.'); check(str_contains($sensorApi, 'ingest_nonce_release'), 'sensor_data.php should release the nonce on failure.'); -check(str_contains($schemaSql, 'ingest_nonce'), 'schema_sht30.sql should create the ingest_nonce table.'); +check(str_contains($schemaSql, 'leak_ingest_nonce'), 'schema_sht30.sql should create the leak_ingest_nonce table.'); // sensor_id 범위 검증 (TINYINT 경계 → 깔끔한 400) check(str_contains($sensorApi, 'sensor_id 범위'), 'sensor_data.php should validate sensor_id range.');