diff --git a/.gitignore b/.gitignore index 1d94bc7..13ea85c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ # ── 운영 비밀값 / 로컬 설정 (절대 커밋 금지) ────────────────────────────── php/config.local.php firmware/**/secrets.h +raspberry_pi_zero2w_display/config_local.py +raspberry_pi4/config_local.py +raspberry_pi4_sht30/config_local.py *.env *.env.* !*.env.example diff --git a/php/api/sensor_data.php b/php/api/sensor_data.php index b87637f..e93bec2 100644 --- a/php/api/sensor_data.php +++ b/php/api/sensor_data.php @@ -242,13 +242,25 @@ if ($humidity_percent !== null && ($humidity_percent < 0 || $humidity_percent > $humidity_percent = null; } -$has_metric_payload = ($metric_type !== '') && ($temperature_c !== null || $humidity_percent !== null); +// PMS7003 미세먼지(µg/m³ 대기환산 정수). 누수 노드에 온습도와 동일 sensor_id 로 통합되며 +// metric_type='pms7003' 로 구분된다(온습도 행에서는 NULL, PM 행에서는 온습도가 NULL). +// 물리 범위(0..1000)를 벗어난 값은 결측(null)으로 처리한다(온도와 동일한 방어). +$pm1_0 = isset($data['pm1_0']) && is_numeric($data['pm1_0']) ? (int)$data['pm1_0'] : null; +$pm2_5 = isset($data['pm2_5']) && is_numeric($data['pm2_5']) ? (int)$data['pm2_5'] : null; +$pm10 = isset($data['pm10']) && is_numeric($data['pm10']) ? (int)$data['pm10'] : null; +if ($pm1_0 !== null && ($pm1_0 < 0 || $pm1_0 > 1000)) { $pm1_0 = null; } +if ($pm2_5 !== null && ($pm2_5 < 0 || $pm2_5 > 1000)) { $pm2_5 = null; } +if ($pm10 !== null && ($pm10 < 0 || $pm10 > 1000)) { $pm10 = null; } + +$has_metric_payload = ($metric_type !== '') + && ($temperature_c !== null || $humidity_percent !== null + || $pm1_0 !== null || $pm2_5 !== null || $pm10 !== null); // ── 서버측 임계 판정 (config.php METRIC_*) ───────────────── // 펌웨어 metric_status 는 물리 plausibility 판정이라 운영 경보로는 신뢰하지 않고, // 서버가 운영 임계로 다시 판정하여 leak_sensor_metric.metric_status 에 저장한다. $metric_eval = $has_metric_payload - ? evaluate_metric_thresholds($temperature_c, $humidity_percent) + ? evaluate_metric_thresholds($temperature_c, $humidity_percent, $pm2_5, $pm10) : ['breaches' => [], 'status' => 'normal']; $metric_status = $metric_eval['status']; @@ -348,11 +360,13 @@ try { $metric_stmt = $db->prepare(" INSERT INTO leak_sensor_metric (device_id, device_location, sensor_id, sensor_name, - metric_type, temperature_c, humidity_percent, metric_status, + metric_type, temperature_c, humidity_percent, + pm1_0, pm2_5, pm10, metric_status, measured_at, created_at) VALUES (:device_id, :device_location, :sensor_id, :sensor_name, - :metric_type, :temperature_c, :humidity_percent, :metric_status, + :metric_type, :temperature_c, :humidity_percent, + :pm1_0, :pm2_5, :pm10, :metric_status, :measured_at, NOW()) "); $metric_stmt->execute([ @@ -363,6 +377,9 @@ try { ':metric_type' => $metric_type, ':temperature_c' => $temperature_c, ':humidity_percent' => $humidity_percent, + ':pm1_0' => $pm1_0, + ':pm2_5' => $pm2_5, + ':pm10' => $pm10, ':metric_status' => $metric_status, ':measured_at' => $sensor_dt, ]); @@ -455,6 +472,10 @@ if ($has_metric_payload) { $value = condensation_margin_c($temperature_c, $humidity_percent); // 표시값=결로마진(℃) } elseif (in_array($kind, ['high_temp', 'low_temp'], true)) { $value = $temperature_c; + } elseif ($kind === 'high_pm25') { + $value = $pm2_5; // 표시값=PM2.5(µg/m³) + } elseif ($kind === 'high_pm10') { + $value = $pm10; // 표시값=PM10(µg/m³) } else { $value = $humidity_percent; } @@ -462,8 +483,14 @@ if ($has_metric_payload) { $sms_sent = true; } } - } elseif (metric_was_alerting($db, $sensor_id) && metric_is_recovered($temperature_c, $humidity_percent)) { - // 직전까지 경보 상태였고 이제 (히스테리시스 포함) 정상으로 복귀 → 복귀 SMS 1회 + } elseif (($temperature_c !== null || $humidity_percent !== null) + && metric_was_alerting($db, $sensor_id) + && metric_is_recovered($temperature_c, $humidity_percent)) { + // 직전까지 경보 상태였고 이제 (히스테리시스 포함) 정상으로 복귀 → 복귀 SMS 1회. + // 복귀 판정/메시지는 온습도(SHT30)에 한정한다. PMS 보고(온습도 NULL)는 이 분기를 + // 타면 안 된다 — 온습도 결측을 '정상 복귀'로 오인해 온습도 경보 중에도 거짓 복귀 + // SMS 가 나가는 것을 막는다(같은 sensor_id 에 PMS·SHT30 이 별도 행으로 공존하므로). + // 미세먼지 경보에는 별도 복귀 SMS 가 없다(값이 임계 아래로 내려가면 재발송이 멈출 뿐). $sms_sent = send_metric_recovery_sms( $sensor_info, (float)($temperature_c ?? 0.0), diff --git a/php/config.local.example.php b/php/config.local.example.php index f4aefe1..b4e88a0 100644 --- a/php/config.local.example.php +++ b/php/config.local.example.php @@ -58,6 +58,18 @@ return [ // 1·3 도 포함한다: [1, 2, 3]. (같은 지점에 누수 카드 + 온습도 카드가 함께 표시됨) 'METRIC_SENSOR_IDS' => [1, 2, 3], + // 미세먼지(PMS7003) sensor_id 목록 — 대시보드가 해당 센서 카드에 미세먼지 블록 + // (PM2.5/PM10 µg/m³)을 표시할 대상 (기본 [1, 3]). + // ★ 동일-id 통합 토폴로지: 누수 노드(1·3)는 하나의 sensor_id 아래 누수 + 온습도(SHT30) + // + 미세먼지(PMS7003)를 metric_type('sht30' vs 'pms7003')로 구분해 보고한다. 2번은 + // 온습도 단독. 따라서 운영 토폴로지는: + // LEAK_SENSOR_IDS = [1, 3] (누수) + // METRIC_SENSOR_IDS = [1, 2, 3] (온습도) + // PM_SENSOR_IDS = [1, 3] (미세먼지) + // 대시보드는 metric_type 별 최신값을 각각 버킷팅하므로, 같은 sensor_id 에서 온습도와 + // 미세먼지가 서로를 덮어쓰지 않는다. 신규 미세먼지 지점은 여기서 오버라이드(문자열 "1,3" 허용). + 'PM_SENSOR_IDS' => [1, 3], + // 누수 감지/복구 디바운스(감지↔복구 플래핑 SMS 폭탄 방지). 상태가 확인창 동안 지속돼야 // 확정하고 확정 엣지에서만 SMS 1회. 아래는 기본값(필요 시 조정). sql/migration_leak_debounce.sql // 적용 필요(컬럼 없으면 레거시 즉시 발송으로 자동 폴백). diff --git a/php/config.php b/php/config.php index 0f08ee0..3e886d2 100644 --- a/php/config.php +++ b/php/config.php @@ -9,6 +9,11 @@ define('APP_VERSION', 'v2606'); +// ─── 운영 오류 노출 차단 (공유호스팅은 display_errors=On 이 흔함) ─── +// 스택트레이스/쿼리/경로가 HTTP 응답에 실리지 않도록 전 엔드포인트 공통 차단. +ini_set('display_errors', '0'); +ini_set('log_errors', '1'); + $local_config_file = __DIR__ . '/config.local.php'; $local_config = is_file($local_config_file) ? require $local_config_file : []; @@ -150,6 +155,7 @@ const SMS_TAG_SENSOR_STUCK = '[센서고착]'; // 측정값 고착(live-bu const SMS_TAG_ESCALATION = '[에스컬레이션]'; // 미복귀 경보 2차(야간) 통지 const SMS_TAG_LEAK_ALERT = '[누수감지]'; // 1번 모듈(누수) 누수 발생 const SMS_TAG_LEAK_RECOVERY = '[누수복구]'; // 1번 모듈(누수) 누수 해소(복구) +const SMS_TAG_HIGH_PM = '[미세먼지경보]'; // PMS7003 미세먼지(PM2.5/PM10) 임계 초과 // 경보 종류(metric kind) → 태그 매핑(단일 출처). evaluate_metric_thresholds 의 // breach 종류와 1:1 대응한다. @@ -158,13 +164,16 @@ const SMS_METRIC_TAGS = [ 'low_temp' => SMS_TAG_LOW_TEMP, 'high_humidity' => SMS_TAG_HIGH_HUMIDITY, 'low_humidity' => SMS_TAG_LOW_HUMIDITY, + // PMS7003 미세먼지: PM2.5·PM10 두 종류가 같은 태그([미세먼지경보])로 매핑된다. + 'high_pm25' => SMS_TAG_HIGH_PM, + 'high_pm10' => SMS_TAG_HIGH_PM, ]; /** * leak_sms_log 태그별 건수 집계 SELECT 절(단일 출처). 대시보드/월간보고서가 공유한다. * 태그 문자열은 위 SMS_TAG_* 상수에서만 가져오므로, 태그를 바꿔도 모든 집계가 한 * 곳에서 따라온다(여러 파일의 LIKE 문자열이 어긋나 무성 장애 나는 것 방지). - * 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, recovery, + * 반환 컬럼: high_temp, low_temp, high_humidity, low_humidity, high_pm, recovery, * device_offline, device_online, leak_alert, leak_recovery, sms_fail */ function sms_summary_select_sql(): string { @@ -173,6 +182,7 @@ function sms_summary_select_sql(): string { . " SUM(message LIKE '" . SMS_TAG_LOW_TEMP . "%') AS low_temp,\n" . " SUM(message LIKE '" . SMS_TAG_HIGH_HUMIDITY . "%') AS high_humidity,\n" . " SUM(message LIKE '" . SMS_TAG_LOW_HUMIDITY . "%') AS low_humidity,\n" + . " SUM(message LIKE '" . SMS_TAG_HIGH_PM . "%') AS high_pm,\n" . " SUM(message LIKE '" . SMS_TAG_RECOVERY . "%') AS recovery,\n" . " SUM(message LIKE '" . SMS_TAG_DEVICE_OFFLINE . "%') AS device_offline,\n" . " SUM(message LIKE '" . SMS_TAG_DEVICE_ONLINE . "%') AS device_online,\n" @@ -195,6 +205,17 @@ define('METRIC_RH_HYSTERESIS', (float)cfg('METRIC_RH_HYSTERESIS', 3.0)); // 동일 종류 경보 재발송 억제 쿨다운(초). 기본 30분. define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800)); +// ─── 미세먼지(PMS7003) 임계 경보 (PM2.5/PM10 → SMS) ──────────────────────────── +// PMS7003 은 누수 노드(sensor_id 1·3)에 온습도(SHT30)와 함께 동일 sensor_id 로 통합되어 +// metric_type='pms7003' 로 보고한다. 값은 대기환산 µg/m³ 정수(0..1000). 아래 운영 임계를 +// 초과하면 api/sensor_data.php 가 [미세먼지경보] SMS 를 보낸다(종류별 쿨다운은 온습도와 +// 동일하게 METRIC_ALERT_COOLDOWN_SEC 재사용). 임계 판정은 Pi 가 아니라 서버에서 하므로 +// 폐쇄망에서도 재배포 없이 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³) +// 복구(정상 회복) 히스테리시스: 임계 안쪽으로 이 여유만큼 들어와야 복구로 간주(경계 채터링 방지) +define('METRIC_PM_HYSTERESIS', (float)cfg('METRIC_PM_HYSTERESIS', 10)); // µg/m³ + // 누수(leak) 경보 재발송 억제 쿨다운(초). 1번 모듈은 누수 지속 중 주기(periodic) 보고를 // 계속 보내므로, 같은 누수 에피소드에서 [누수감지] SMS 가 반복 발송되지 않도록 한다. 기본 30분. define('LEAK_ALERT_COOLDOWN_SEC', (int)cfg('LEAK_ALERT_COOLDOWN_SEC', 1800)); @@ -371,6 +392,23 @@ $metric_sensor_ids = array_values(array_filter( )); define('METRIC_SENSOR_IDS', $metric_sensor_ids !== [] ? $metric_sensor_ids : [2]); +// ─── 미세먼지(PMS7003) 센서 sensor_id 목록 ─────────────── +// 누수 노드(1·3)에 PMS7003 을 얹어 "같은 sensor_id" 로 metric_type='pms7003' 미세먼지를 +// 보고하는 구성의 표시 대상. 누수/온습도 목록과 동일 정제(intval, 1..255). 기본 [1, 3]. +// 신규 미세먼지 지점은 config.local.php 로 오버라이드한다. 예: [1, 3] (문자열 "1,3" 허용). +// !defined 가드 — 다른 스트림이 먼저 정의했을 수 있으므로 재정의 fatal 을 피한다(좌표화). +if (!defined('PM_SENSOR_IDS')) { + $pm_sensor_ids = cfg('PM_SENSOR_IDS', [1, 3]); + if (is_string($pm_sensor_ids)) { + $pm_sensor_ids = explode(',', $pm_sensor_ids); + } + $pm_sensor_ids = array_values(array_filter( + array_map('intval', (array)$pm_sensor_ids), + static fn($id) => $id >= 1 && $id <= 255 + )); + define('PM_SENSOR_IDS', $pm_sensor_ids !== [] ? $pm_sensor_ids : [1, 3]); +} + // ─── 센서별 표시 라벨 오버라이드 (서버측) ──────────────────── // SMS·대시보드에 표시할 센서별 "위치"와 "관리주체(표시명)"를 서버에서 지정한다 // (Pi 재배포 없이 config.local.php 만 바꾸면 됨). 지정이 없으면 Pi 가 보고한 diff --git a/php/sms_send.php b/php/sms_send.php index 1b02bf8..ab0c62f 100644 --- a/php/sms_send.php +++ b/php/sms_send.php @@ -321,11 +321,21 @@ function condensation_is_clear(?float $temp_c, ?float $rh): bool { } /** - * 온습도 측정값을 운영 임계(config.php METRIC_*)와 비교해 경보 종류를 산출한다. + * 온습도·미세먼지 측정값을 운영 임계(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): array { +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) { @@ -349,6 +359,13 @@ function evaluate_metric_thresholds(?float $temp_c, ?float $rh): array { $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], @@ -489,8 +506,8 @@ function leak_debounce_decide( * 이내면 발송을 생략한다(중복 폭주 방지). * * @param array $sensor_data ['sensor_id','sensor_name','device_id','device_location'] - * @param string $kind 'high_temp'|'low_temp'|'high_humidity'|'low_humidity' - * @param float $value 측정값 (온도℃ 또는 습도%) + * @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 { @@ -498,12 +515,16 @@ function send_metric_alert_sms(array $sensor_data, string $kind, float $value): $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}"); diff --git a/raspberry_pi4/README.md b/raspberry_pi4/README.md index 48bc845..05e3c0c 100644 --- a/raspberry_pi4/README.md +++ b/raspberry_pi4/README.md @@ -31,7 +31,7 @@ GPIO of a Raspberry Pi 4 Model B. On a leak it immediately reports to the PHP AP - **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit** - e-con **5C-08** leak detection sensor (resistive, high-impedance ~1.5MΩ) -- **External 4.7MΩ pull-down resistor** (required — internal pull resistor is off) +- **External 10MΩ pull-down resistor** (required — internal pull resistor is off) - (Optional) **Pi Camera** — Pi 4 = **15-pin (1.0mm pitch) standard CSI**, Zero 2 W = **22-pin (0.5mm pitch) CSI** → Ribbon/camera boards are not compatible (a Pi 4 15-pin ribbon or a 15↔22 adapter is required) - Power: **USB-C 5V/3A** (a power margin is recommended when using the camera) @@ -53,7 +53,7 @@ GPIO of a Raspberry Pi 4 Model B. On a leak it immediately reports to the PHP AP - `ACTIVE_LOW = False`: when dry GPIO=LOW(0), on leak GPIO=HIGH(1) - `USE_INTERNAL_PULL = False` (**keep PUD_OFF**): if you turn on the internal 50kΩ pull-down, the 1.5MΩ sensor cannot pull GPIO up to HIGH, causing **detection failure**. Use only the - external 4.7MΩ pull-down. + external 10MΩ pull-down. --- diff --git a/raspberry_pi4/WIRING.html b/raspberry_pi4/WIRING.html index b935474..917f300 100644 --- a/raspberry_pi4/WIRING.html +++ b/raspberry_pi4/WIRING.html @@ -134,7 +134,7 @@ Raspberry Pi OS Bookworm 64-bit e-con 5C-08 · Resistive ~1.5MΩ GPIO17 / Pin 11 - External 4.7MΩ pull-down required + External 10MΩ pull-down required @@ -264,7 +264,7 @@
If you enable the internal 50kΩ pull-down, the ~1.5MΩ high-impedance sensor cannot pull the GPIO HIGH, so leaks will not be detected (detection failure). Always turn the internal pull off with PUD_OFF - and use only the external 4.7MΩ pull-down. (USE_INTERNAL_PULL = False)
+ and use only the external 10MΩ pull-down. (USE_INTERNAL_PULL = False)Connect a 4.7MΩ resistor between GPIO17 (Pin 11) ↔ GND. Essential for the high-impedance sensor.
+Connect a 10MΩ resistor between GPIO17 (Pin 11) ↔ GND. Essential for the high-impedance sensor.
Power a small white LED module directly from the Pi so the leak-check camera has light. + VCC → 5V (Pin 2), GND → GND (Pin 6). It stays lit continuously — there is + no GPIO control, no switching, and no software. The module is low-power, so continuous + operation produces negligible heat (harmless to leave on).
+LED module VCC → 5V (Pin 2), GND → GND (Pin 6). + No signal pin — it is a bare power connection, always on.
+Uses only 5V/GND power rails — it does not touch GPIO17 (leak), the I2C pins (SHT30), + or the UART pins (PMS7003 on GPIO15). All can coexist.
+A NoIR camera (no IR-cut filter) under white LED light renders the image with a + pink/magenta tint. This does not hinder leak identification (a wet patch is still + clearly visible). If you need natural color, use a standard camera with a built-in + IR-cut filter instead of the NoIR module.
++ Pi-A (id 1) / Pi-C (id 3) are combined nodes carrying all five loads on one Pi: + leak sensor + SHT30 (온습도) + PMS7003 (미세먼지) + camera + always-on light. The three standalone + sensor sheets each independently claim Pin 1 / Pin 2 / Pin 6, which physically collide + on a shared board. This single table is the de-conflicted master; wire the combined node from + here, not from the per-sensor sections above. +
+ +Every device shares the same ground, but the Pi 4 exposes 8 separate GND pins + (6 · 9 · 14 · 20 · 25 · 30 · 34 · 39). Spread the four grounds across them, one per device. + Likewise there are two 3V3 pins (1, 17) and two 5V pins (2, 4): give the leak sensor + Pin 1 and the SHT30 the second 3V3 (Pin 17); give the PMS7003 Pin 2 and the LED the + second 5V (Pin 4). Never double up two wires on a single rail pin.
+| Device | Line | BCM / Rail | Physical Pin | Notes |
|---|---|---|---|---|
| 누수 Leak e-con 5C-08 |
+ Signal Sensor signal | +GPIO17 | +Pin 11 | +External 10MΩ pull-down Pin 11 ↔ GND (required). Internal pull OFF (PUD_OFF). | +
| Power VCC 3.3V | +3V3 | +Pin 1 | +First 3V3 rail. | +|
| Ground GND | +GND | +Pin 20 | +Keep the high-impedance leak ground short & solid. (alt: Pin 25) | +|
| 온습도 SHT30 I2C · 3.3V ONLY |
+ I2C SDA | +GPIO2 | +Pin 3 | +I2C data (SDA1). | +
| I2C SCL | +GPIO3 | +Pin 5 | +I2C clock (SCL1). | +|
| Power VCC 3.3V | +3V3 | +Pin 17 | +SECOND 3V3, NOT Pin 1. 3.3V only, never 5V (5V damages the SHT30). | +|
| Ground GND | +GND | +Pin 9 | +I2C ground (adjacent to Pin 3 / 5). | +|
| 미세먼지 PMS7003 UART |
+ UART PMS TXD → Pi RXD | +GPIO15 | +Pin 10 | +PMS transmit into the Pi; the data line that must be present. | +
| UART Pi TXD → PMS RXD (opt) | +GPIO14 | +Pin 8 | +Optional: only for sleep/wake commands. Passive read does not need it. | +|
| Power VCC 5V | +5V | +Pin 2 | +PMS7003 runs on 5V (its UART logic is 3.3V-safe for the Pi). | +|
| Ground GND | +GND | +Pin 6 | +UART ground. | +|
| 카메라 Camera | +CSI Ribbon | +n/a | +CSI (separate) | +Not on the 40-pin header. 15-pin CSI ribbon into the camera connector (see §5). | +
| 조명 LED always-on |
+ Power VCC 5V | +5V | +Pin 4 | +SECOND 5V, NOT Pin 2. Must be an LED module with an integrated resistor, or add + a ~150–330Ω series resistor (a bare LED on 5V burns out). | +
| Ground GND | +GND | +Pin 14 | +Always lit: no GPIO, no switching, no code. | +
dtparam=i2c_arm=on in + /boot/firmware/config.txt. Verify with + i2cdetect -y 1 (SHT30 at 0x44 / 0x45).
+enable_uart=1 in + config.txt, and remove + console=serial0,115200 from + /boot/firmware/cmdline.txt so the login console does not fight the sensor.
+camera_auto_detect=1 in + config.txt. Verify with + rpicam-hello --list-cameras.
+Enabling UART / disabling the serial console does not affect the GPIO17 leak read or + the camera. GPIO17 is a plain input, independent of the serial peripheral.
+All three install.sh scripts set these idempotently + (I2C · UART + cmdline · camera). Run them, then a single reboot applies I2C, UART, and the + camera together (no per-sensor reboot needed).
+Combined draw of all five loads is ~1.4–1.8A typical, ~2.2A worst-case, so the official + 5.1V / 3A supply has ample headroom. Avoid phone chargers and unpowered USB hubs: + sag below ~4.8V triggers a brown-out → CPU throttle and SD-card corruption. Check health with + vcgencmd get_throttled (expect 0x0; + any non-zero indicates under-voltage).
+