From ec78be0bda0f1c17ba4d190b24bfa4ab8c70c577 Mon Sep 17 00:00:00 2001 From: cw2007 Date: Sun, 19 Jul 2026 10:46:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(pms7003):=20=EB=88=84=EC=88=98=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=EC=97=90=20=EB=AF=B8=EC=84=B8=EB=A8=BC=EC=A7=80(PM2.5?= =?UTF-8?q?/PM10)=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 센서1·3(누수 노드)에 PMS7003 를 온습도와 동일 sensor_id 로 통합하고 metric_type='pms7003' 로 구분해 수집한다. LED 는 스위칭 없이 상시 점등 방식으로 확정. - raspberry_pi4_pms7003/: PMS7003 노드 풀세트(드라이버·센서·install·systemd·vendor·배선·연결테스트·프레임 단위테스트) - 서버: evaluate_metric_thresholds() 에 pm25/pm10 선택 인자 추가(온습도 전용 호출 후방호환), sensor_data.php 수집 경로에 pms7003 metric 반영, config.php/예시에 PM 임계 추가 - SQL: migration_pms7003.sql · schema_sht30.sql 정합, tools/migration_leak_add_{pms7003,sht30} 마이그레이터 - tests/pm_threshold_test.php + run_tests.sh 러너 등록 - .gitignore: 노드별 config_local.py 커밋 차단 --- .gitignore | 3 + php/api/sensor_data.php | 39 +- php/config.local.example.php | 12 + php/config.php | 40 +- php/sms_send.php | 29 +- raspberry_pi4/README.md | 4 +- raspberry_pi4/WIRING.html | 220 ++++++- raspberry_pi4/config.py | 12 +- raspberry_pi4/leak_sensor.py | 92 ++- raspberry_pi4_pms7003/.gitignore | 14 + raspberry_pi4_pms7003/README.md | 165 ++++++ raspberry_pi4_pms7003/WIRING.html | 535 ++++++++++++++++++ raspberry_pi4_pms7003/config.py | 124 ++++ raspberry_pi4_pms7003/config_local.example.py | 42 ++ raspberry_pi4_pms7003/install.sh | 160 ++++++ raspberry_pi4_pms7003/pms7003_driver.py | 194 +++++++ raspberry_pi4_pms7003/pms7003_sensor.py | 247 ++++++++ raspberry_pi4_pms7003/requirements.txt | 8 + .../systemd/pms7003-sensor.service | 66 +++ raspberry_pi4_pms7003/test_connection.py | 89 +++ .../tests/test_pms_wire_and_frame.py | 152 +++++ raspberry_pi4_pms7003/vendor.sh | 75 +++ raspberry_pi4_pms7003/wire.py | 102 ++++ run_tests.sh | 1 + sql/migration_pms7003.sql | 38 ++ sql/schema_sht30.sql | 7 +- tests/pm_threshold_test.php | 100 ++++ tools/migration_leak_add_pms7003/README.md | 117 ++++ tools/migration_leak_add_pms7003/migrate.sh | 171 ++++++ tools/migration_leak_add_sht30/README.md | 109 ++++ tools/migration_leak_add_sht30/migrate.sh | 169 ++++++ 31 files changed, 3085 insertions(+), 51 deletions(-) create mode 100644 raspberry_pi4_pms7003/.gitignore create mode 100644 raspberry_pi4_pms7003/README.md create mode 100644 raspberry_pi4_pms7003/WIRING.html create mode 100644 raspberry_pi4_pms7003/config.py create mode 100644 raspberry_pi4_pms7003/config_local.example.py create mode 100644 raspberry_pi4_pms7003/install.sh create mode 100644 raspberry_pi4_pms7003/pms7003_driver.py create mode 100644 raspberry_pi4_pms7003/pms7003_sensor.py create mode 100644 raspberry_pi4_pms7003/requirements.txt create mode 100644 raspberry_pi4_pms7003/systemd/pms7003-sensor.service create mode 100644 raspberry_pi4_pms7003/test_connection.py create mode 100644 raspberry_pi4_pms7003/tests/test_pms_wire_and_frame.py create mode 100644 raspberry_pi4_pms7003/vendor.sh create mode 100644 raspberry_pi4_pms7003/wire.py create mode 100644 sql/migration_pms7003.sql create mode 100644 tests/pm_threshold_test.php create mode 100644 tools/migration_leak_add_pms7003/README.md create mode 100644 tools/migration_leak_add_pms7003/migrate.sh create mode 100644 tools/migration_leak_add_sht30/README.md create mode 100644 tools/migration_leak_add_sht30/migrate.sh 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 @@ Signal Sensor signal line GPIO17 Pin 11 - Sensor signal input. Connect an external 4.7MΩ pull-down between Pin 11 ↔ GND. + Sensor signal input. Connect an external 10MΩ pull-down between Pin 11 ↔ GND. Power Sensor power 3.3V @@ -299,7 +299,7 @@

4Wiring Schematic

- + Raspberry Pi 4 · 40-pin header @@ -333,7 +333,7 @@ - 4.7MΩ + 10MΩ External pull-down (required) @@ -343,7 +343,7 @@ Dry → GPIO=LOW(0) · Leak → GPIO=HIGH(1) · ACTIVE_LOW=False
-
How it works: when dry, the sensor sits in an ultra-high-impedance (nearly open) state, so the external 4.7MΩ pull-down holds GPIO17 LOW. When a leak bridges the electrodes with water, 3.3V pulls the signal line up through ~1.5MΩ and GPIO17 switches to HIGH.
+
How it works: when dry, the sensor sits in an ultra-high-impedance (nearly open) state, so the external 10MΩ pull-down holds GPIO17 LOW. When a leak bridges the electrodes with water, 3.3V pulls the signal line up through ~1.5MΩ and GPIO17 switches to HIGH.
@@ -355,7 +355,7 @@

Do not enable the internal pull-down

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)

@@ -365,7 +365,7 @@

External Pull-down (required)

-

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.

Internal Pull Setting

@@ -399,7 +399,7 @@
  • Verify the header orientation (Pin 1 position). Red dashed marker = Pin 1.
  • Connect the sensor signal line to Pin 11 (GPIO17).
  • Connect sensor power (3.3V) to Pin 1 (3V3) and sensor GND to Pin 6 (GND).
  • -
  • Connect an external 4.7MΩ pull-down between Pin 11 ↔ GND. (Required · omitting it causes detection failure)
  • +
  • Connect an external 10MΩ pull-down between Pin 11 ↔ GND. (Required · omitting it causes detection failure)
  • Confirm the internal pull is off — USE_INTERNAL_PULL=False (PUD_OFF). Never use the internal pull-down.
  • Check config.py: ACTIVE_LOW=False, DEBOUNCE_MS=500, GPIO17 registered in SENSORS.
  • Set the per-device sensor_id / location in config_local.py (Pi-A=1/Server Room, Pi-C=3/New).
  • @@ -409,6 +409,210 @@ + +
    +

    7Camera Lighting (Always-On)

    + +
    +

    White LED module — always on, no switching, no code

    +

    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).

    +
    + +
    +
    +

    Wiring

    +

    LED module VCC → 5V (Pin 2), GND → GND (Pin 6). + No signal pin — it is a bare power connection, always on.

    +
    +
    +

    No conflict with sensors

    +

    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.

    +
    +
    +

    NoIR camera + white light → pink tint

    +

    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.

    +
    +
    +
    + + +
    +

    8통합 노드 배선 (누수 + 온습도 + 미세먼지 + 카메라 + 조명)

    + +

    + 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. +

    + +
    + ★ De-conflict · KEY FIX +

    GND is common, but you cannot stack 4 wires on Pin 6

    +

    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.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    DeviceLineBCM / RailPhysical PinNotes
    누수 Leak
    e-con 5C-08
    Signal Sensor signalGPIO17Pin 11External 10MΩ pull-down Pin 11 ↔ GND (required). Internal pull OFF (PUD_OFF).
    Power VCC 3.3V3V3Pin 1First 3V3 rail.
    Ground GNDGNDPin 20Keep the high-impedance leak ground short & solid. (alt: Pin 25)
    온습도 SHT30
    I2C · 3.3V ONLY
    I2C SDAGPIO2Pin 3I2C data (SDA1).
    I2C SCLGPIO3Pin 5I2C clock (SCL1).
    Power VCC 3.3V3V3Pin 17SECOND 3V3, NOT Pin 1. 3.3V only, never 5V (5V damages the SHT30).
    Ground GNDGNDPin 9I2C ground (adjacent to Pin 3 / 5).
    미세먼지 PMS7003
    UART
    UART PMS TXD → Pi RXDGPIO15Pin 10PMS transmit into the Pi; the data line that must be present.
    UART Pi TXD → PMS RXD (opt)GPIO14Pin 8Optional: only for sleep/wake commands. Passive read does not need it.
    Power VCC 5V5VPin 2PMS7003 runs on 5V (its UART logic is 3.3V-safe for the Pi).
    Ground GNDGNDPin 6UART ground.
    카메라 CameraCSI Ribbonn/aCSI (separate)Not on the 40-pin header. 15-pin CSI ribbon into the camera connector (see §5).
    조명 LED
    always-on
    Power VCC 5V5VPin 4SECOND 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 GNDGNDPin 14Always lit: no GPIO, no switching, no code.
    Combined-node master pinout. Header pins used: 1 · 2 · 3 · 4 · 5 · 6 · 8 · 9 · 10 · 11 · 14 · 17 · 20 (+ CSI ribbon). Four grounds spread over Pin 6 / 9 / 14 / 20, never stacked on one pin.
    + +

    Boot configuration (Bookworm · idempotent via install.sh)

    +
    +
    +

    I2C · SHT30

    +

    dtparam=i2c_arm=on in + /boot/firmware/config.txt. Verify with + i2cdetect -y 1 (SHT30 at 0x44 / 0x45).

    +
    +
    +

    UART · PMS7003

    +

    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

    +

    camera_auto_detect=1 in + config.txt. Verify with + rpicam-hello --list-cameras.

    +
    +
    +

    Leak GPIO17 · unaffected

    +

    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.

    +
    +
    +

    One reboot applies everything

    +

    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).

    +
    +
    + +

    Power supply

    +
    +

    Use a genuine 5.1V / 3A USB-C PSU

    +

    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).

    +
    +
    +
    diff --git a/raspberry_pi4/config.py b/raspberry_pi4/config.py index da0e3cf..a5ae912 100644 --- a/raspberry_pi4/config.py +++ b/raspberry_pi4/config.py @@ -18,7 +18,7 @@ SENSORS = [ ] # Sensor active-level setting -# When using the e-con 5C-08 resistive sensor + 4.7MΩ pull-down: +# When using the e-con 5C-08 resistive sensor + 10MΩ pull-down: # Dry: GPIO = LOW(0) → ACTIVE_LOW = False # Leak: GPIO = HIGH(1) ACTIVE_LOW = False @@ -26,7 +26,7 @@ ACTIVE_LOW = False # GPIO internal pull-resistor setting # Must stay False (PUD_OFF)! # Enabling the internal pull-down (50kΩ) keeps the line from going HIGH even on a leak, causing detection failure. -# → Use only the external 4.7MΩ pull-down resistor +# → Use only the external 10MΩ pull-down resistor USE_INTERNAL_PULL = False # True = PUD_DOWN, False = PUD_OFF # ───────────────────────────────────────────── @@ -38,6 +38,14 @@ REPORT_INTERVAL = 600 # Normal-state periodic report interval (sec) = 10 m LEAK_REPORT_INTERVAL = 10 # Re-report interval while a leak is detected (sec) DEBOUNCE_MS = 500 # Debounce time (ms) — set generously due to high impedance +# Confirm-read debounce: an edge alone never changes state. After an edge the pin is +# re-read CONFIRM_READS times, CONFIRM_INTERVAL_SEC apart; ALL reads must agree with the +# new state or the event is discarded as noise. Real water persists; sub-second EMI +# blips on the 10MΩ high-impedance line (server-room noise) do not. +# Total confirmation latency ≈ CONFIRM_READS × CONFIRM_INTERVAL_SEC ≈ 0.9 sec. +CONFIRM_READS = 3 # Consecutive identical reads required to accept a change +CONFIRM_INTERVAL_SEC = 0.3 # Delay between confirmation reads (sec) + # ───────────────────────────────────────────── # Log settings # ───────────────────────────────────────────── diff --git a/raspberry_pi4/leak_sensor.py b/raspberry_pi4/leak_sensor.py index afd8c54..8302120 100644 --- a/raspberry_pi4/leak_sensor.py +++ b/raspberry_pi4/leak_sensor.py @@ -49,6 +49,7 @@ from config import ( SENSORS, ACTIVE_LOW, USE_INTERNAL_PULL, API_URL, API_KEY, REPORT_INTERVAL, LEAK_REPORT_INTERVAL, DEBOUNCE_MS, + CONFIRM_READS, CONFIRM_INTERVAL_SEC, DEVICE_ID, DEVICE_LOCATION, LOG_FILE, LOG_MAX_BYTES, LOG_BACKUP_COUNT, HTTP_TIMEOUT, HTTP_RETRY_COUNT, HTTP_RETRY_DELAY, @@ -400,37 +401,76 @@ def _handle_leak_with_photo(sensor: dict): # ───────────────────────────────────────────── -# GPIO interrupt callback +# GPIO interrupt callback (+ confirm-read debounce) # ───────────────────────────────────────────── +def _read_is_leak(gpio_pin: int) -> bool: + """Read the pin and map to a leak boolean per ACTIVE_LOW polarity.""" + gpio_val = GPIO.input(gpio_pin) + # ACTIVE_LOW: GPIO=0 means leak / ACTIVE_HIGH (transistor-inverted): GPIO=1 means leak + return (gpio_val == 0) if ACTIVE_LOW else (gpio_val == 1) + + +# Sensors with a confirmation in progress — an edge storm must not stack threads. +_confirm_pending = set() +_confirm_lock = threading.Lock() + + +def _confirm_state_change(sensor: dict, candidate: bool): + """Re-read the pin CONFIRM_READS times; apply the change only if every read agrees. + Real water persists across ~1s of reads; sub-second EMI blips on the 10MΩ + high-impedance line do not (server-room noise produced alert→recovery pairs + 0-1s apart in production logs).""" + try: + for _ in range(CONFIRM_READS): + time.sleep(CONFIRM_INTERVAL_SEC) + if _read_is_leak(sensor["gpio"]) != candidate: + log.info(f"[NOISE REJECTED] sensor{sensor['id']} ({sensor['name']}) " + f"transient {'wet' if candidate else 'dry'} blip discarded") + return + + changed = state.set_leak(sensor["id"], candidate) + if not changed: + return + if candidate: + log.warning(f"[LEAK DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!") + # Run leak_alert + photo capture/upload sequentially in one thread + threading.Thread( + target=_handle_leak_with_photo, + args=(sensor,), + daemon=True + ).start() + else: + log.info(f"[LEAK RECOVERED] sensor{sensor['id']} ({sensor['name']}) recovered to normal") + threading.Thread( + target=post_to_server, + args=("leak_recovery", sensor, False), + daemon=True + ).start() + finally: + with _confirm_lock: + _confirm_pending.discard(sensor["id"]) + + def make_callback(sensor: dict): def callback(channel): - gpio_val = GPIO.input(sensor["gpio"]) + candidate = _read_is_leak(sensor["gpio"]) - # ACTIVE_LOW: GPIO=0 means leak, GPIO=1 means normal - # ACTIVE_HIGH (transistor-inverted): GPIO=1 means leak - if ACTIVE_LOW: - is_leak = (gpio_val == 0) - else: - is_leak = (gpio_val == 1) + # No actual change vs confirmed state -> nothing to verify + if candidate == state.is_leaking(sensor["id"]): + return - changed = state.set_leak(sensor["id"], is_leak) + # One confirmation at a time per sensor; the confirm thread reads the live pin, + # so edges arriving meanwhile are naturally folded into its reads. + with _confirm_lock: + if sensor["id"] in _confirm_pending: + return + _confirm_pending.add(sensor["id"]) - if changed: - if is_leak: - log.warning(f"[LEAK DETECTED] sensor{sensor['id']} ({sensor['name']}) !!!") - # Run leak_alert + photo capture/upload sequentially in one thread - threading.Thread( - target=_handle_leak_with_photo, - args=(sensor,), - daemon=True - ).start() - else: - log.info(f"[LEAK RECOVERED] sensor{sensor['id']} ({sensor['name']}) recovered to normal") - threading.Thread( - target=post_to_server, - args=("leak_recovery", sensor, False), - daemon=True - ).start() + threading.Thread( + target=_confirm_state_change, + args=(sensor, candidate), + daemon=True + ).start() return callback @@ -466,7 +506,7 @@ def periodic_reporter(): def setup_gpio(): GPIO.setmode(GPIO.BCM) - # Use only the external 4.7MΩ pull-down -> internal pull resistor must be PUD_OFF + # Use only the external 10MΩ pull-down -> internal pull resistor must be PUD_OFF # (Enabling the internal 50kΩ pull-down prevents the 1.5MΩ sensor from pulling GPIO HIGH) pull = GPIO.PUD_DOWN if USE_INTERNAL_PULL else GPIO.PUD_OFF diff --git a/raspberry_pi4_pms7003/.gitignore b/raspberry_pi4_pms7003/.gitignore new file mode 100644 index 0000000..1e182b6 --- /dev/null +++ b/raspberry_pi4_pms7003/.gitignore @@ -0,0 +1,14 @@ +# Operational secrets (never commit) +config_local.py + +# Python venv / cache +venv/ +__pycache__/ +*.pyc + +# Offline install bundle (vendor.sh output - large; transferred separately) +wheelhouse/ +debs/ + +# Runtime logs +*.log diff --git a/raspberry_pi4_pms7003/README.md b/raspberry_pi4_pms7003/README.md new file mode 100644 index 0000000..e1597f4 --- /dev/null +++ b/raspberry_pi4_pms7003/README.md @@ -0,0 +1,165 @@ +# Particulate-Matter (PMS7003) Sensor Module — Raspberry Pi 4 Model B only + +A module that reads a **Plantower PMS7003** laser particulate-matter sensor over the Raspberry Pi 4 +Model B **UART** and periodically reports **PM1.0 / PM2.5 / PM10** (µg/m³, atmospheric concentration) +to the cafe24 server (`php/api/sensor_data.php`). The server stores the measurements and performs +**high-PM2.5 / high-PM10 threshold evaluation, SMS, and dashboard display**. + +> This is a **Pi-only** metric — there is **no STM32 firmware** for the PMS7003. The module is a +> faithful mirror of the SHT30 module (`../raspberry_pi4_sht30/`): same measurement loop, +> `startup→periodic` events, raw-body HMAC signing, and fail-closed secret handling. PM is a **new +> metric** on the **existing metric pipeline** — distinguished only by `metric_type="pms7003"`. +> +> The PMS7003 **co-locates on an existing leak node** and reports under the **same `sensor_id`** as +> that node's leak sensor (Pi-C = 3, the other node = 1), exactly like the SHT30 co-location. + +--- + +## 1. Files + +| File | Role | +|---|---| +| `pms7003_sensor.py` | Main — periodic measurement → body build/sign → POST (with retry), startup→periodic | +| `pms7003_driver.py` | PMS7003 UART 32-byte frame read + checksum verification + PM decode (pyserial, simulation fallback) | +| `wire.py` | Body JSON + raw-body HMAC signature (`fixed2`/`_esc`/`sign_raw` copied verbatim from the SHT30 module) | +| `config.py` | Non-secret default settings (sensor id/interval/range/UART). Loads secrets from `config_local` | +| `config_local.example.py` | Operational/secret-value template → copy to `config_local.py` for use (not committed) | +| `requirements.txt` | Python dependencies (`requests`, `pyserial`, pinned) | +| `install.sh` | Pi 4 install (air-gapped offline + online fallback, enables UART + disables the serial console) | +| `vendor.sh` | Pre-download dependencies for air-gapped use (run on the build machine) | +| `tests/test_pms_wire_and_frame.py` | Pure unit tests: body byte-string, known HMAC, frame decode + checksum rejection (no hardware) | +| `systemd/pms7003-sensor.service` | Service unit for auto-start on boot | + +--- + +## 2. Hardware (Pi 4 Model B baseline) + +- **Raspberry Pi 4 Model B** (BCM2711) + Raspberry Pi OS **Bookworm 64-bit** +- **PMS7003** laser PM sensor + its breakout/adapter cable +- Power: **USB-C 5V/3A**, network: Pi 4 **Gigabit Ethernet** (wired) or WiFi + +### 2.1 Pinout (primary UART) + +| PMS7003 signal | Pi 4 pin | Note | +|---|---|---| +| VCC | **5V** (physical 2) | PMS7003 needs **5V** power (fan + laser) | +| GND | GND (physical 6) | | +| TXD (sensor → Pi) | GPIO15 / **RXD** (physical 10) | 3.3V logic — safe direct to the Pi | +| RXD (Pi → sensor) | GPIO14 / TXD (physical 8) | optional (only needed for passive-mode commands) | +| SET / RESET | — | optional; leave floating (internal pull-up = enabled/run) | + +> The PMS7003 **TXD idles at 3.3V logic**, so it connects **directly** to the Pi RXD — no level +> shifter needed. We only read the sensor's TXD (active streaming mode), so wiring the Pi TXD→sensor +> RXD is optional. + +--- + +## 3. Server compatibility (must verify) + +This module reports to **the current repo's `php/` server**. The server requires a **raw-body HMAC signature**. + +``` +X-Signature = lowercase_hex( HMAC-SHA256( key=API_KEY, msg=request body bytes ) ) +``` + +- `API_KEY` in `config_local.py` must be **exactly identical** to `API_KEY` in the server's `config.local.php`. +- The server blocks replays via `timestamp` freshness (±600 seconds), so the Pi clock must be **NTP-synchronized** (enabled by default on Raspberry Pi OS). +- It reports with the co-located leak node's `sensor_id` (**3** by default, or **1** on the other node) and `metric_type="pms7003"`. +- Threshold alerts (high PM2.5 / PM10), SMS, and recovery evaluation are **all performed by the server**. This module only reports measurements. +- The Pi-side `metric_status` is **physical plausibility only** (`normal` / `out_of_range`); the server never trusts it for alerting. + +### 3.1 Wire body (fixed order) + +```json +{"device_id":"..","device_location":"..","sensor_id":3,"sensor_name":"..","event_type":"..","timestamp":0,"metric_type":"pms7003","pm1_0":8,"pm2_5":12,"pm10":18,"metric_status":"normal","app_version":".."} +``` + +`pm1_0` / `pm2_5` / `pm10` are **bare integers** (µg/m³, atmospheric concentration, range 0..1000). +The body is built directly and raw-POSTed so the **signed bytes == sent bytes**. + +--- + +## 4. Installation + +### 4.1 Prepare configuration values + +```bash +cp config_local.example.py config_local.py +# Open config_local.py and fill in API_URL / API_KEY / DEVICE_ID / DEVICE_LOCATION +# On the id=1 node also set SENSOR_ID = 1 (Pi-C keeps the default 3). +``` + +If `config_local.py` is missing, execution **stops immediately with a clear error** (fail closed). +**Never commit** `config_local.py` or `API_KEY` (handled by `.gitignore`). + +### 4.2 Online install (internet-capable environment) + +```bash +chmod +x install.sh +./install.sh +sudo reboot # reboot once — the installer enables UART and disables the serial console +``` + +### 4.3 Air-gapped offline install + +On an internet-connected machine of the **same architecture (aarch64, ideally the same Pi 4 + Bookworm 64-bit)**, +download the dependencies first to build a bundle, then move it to the target Pi 4 and install. + +```bash +# (build machine, internet YES) generate wheelhouse/ + debs/ +./vendor.sh + +# after copying wheelhouse/ and debs/ into this folder on the target Pi 4: +# (target Pi 4, internet NO) +./install.sh # if debs/ · wheelhouse/ exist, it installs offline automatically +``` + +> `pyserial` is pure Python (arch-independent), so it can be downloaded on an x86 build machine. +> Other wheels/debs such as `requests` must be the **aarch64** builds (running `vendor.sh` on the target arch is recommended). + +--- + +## 5. Run / check + +```bash +# UART present? (should symlink to ttyS0 or ttyAMA0) +ls -l /dev/serial0 + +# pure wire/frame self-check (passes without hardware or config_local.py) +python3 tests/test_pms_wire_and_frame.py + +# actual run (simulation mode if pyserial missing / UART unavailable) +source venv/bin/activate +python3 pms7003_sensor.py +``` + +In environments without `pyserial` installed or with the UART unavailable (dev PC, etc.), it automatically runs in **simulation mode**. + +--- + +## 6. Auto-start on boot (systemd) + +```bash +# assumes the folder is placed at /home/pi/raspberry_pi4_pms7003 (otherwise edit the path in the .service) +sudo cp systemd/pms7003-sensor.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable pms7003-sensor +sudo systemctl start pms7003-sensor +sudo systemctl status pms7003-sensor +journalctl -u pms7003-sensor -f +``` + +`SupplementaryGroups=dialout` is required for `/dev/serial0` access. + +--- + +## 7. Security notes + +- Secrets (`API_KEY`), server URL, and device info live only in `config_local.py` (not committed). + Committed files contain only placeholders. +- The report body is protected against tampering by an `HMAC-SHA256(API_KEY, raw_body)` signature (`X-Signature` header) + (immune to length-extension attacks). The legacy body-field sha256 fallback is disabled by default on the server. +- The signed bytes and the transmitted bytes are **always identical** (`wire.py` builds the body directly, + signs those bytes, then raw POSTs). `fixed2` / `_esc` / `sign_raw` are copied verbatim from the SHT30 module + so signing stays byte-for-byte identical to the shared reference / server `verify_signature_raw`. +- If `config_local.py` is exposed, immediately rotate `API_KEY` (change it on the server at the same time). diff --git a/raspberry_pi4_pms7003/WIRING.html b/raspberry_pi4_pms7003/WIRING.html new file mode 100644 index 0000000..55e8c55 --- /dev/null +++ b/raspberry_pi4_pms7003/WIRING.html @@ -0,0 +1,535 @@ + + + + + +PM Node Wiring & Pinmap — PMS7003 UART (sensor_id=3 / Pi-C) + + + +
    + + +
    +

    Field Installation Doc · Wiring & Pinmap

    +

    Particulate-Matter Node — Site (Pi-C, sensor_id=3)

    +

    + A measurement node connecting the Plantower PMS7003 laser PM sensor to the Raspberry Pi over the primary UART (9600 8N1). + The sensor co-locates on an existing leak node and reports PM1.0/PM2.5/PM10 (ug/m3) every 10 minutes under the same + sensor_id as that node's leak sensor; high-PM2.5/PM10 threshold decisions and SMS dispatch are handled by the server. +

    +
    + Raspberry Pi 4 Model B + BCM2711 + Raspberry Pi OS Bookworm 64-bit + Plantower PMS7003 + UART /dev/serial0 + 9600 8N1 + metric_type=pms7003 + Report interval 10 min +
    +
    + + +
    +

    240-Pin Header Pinmap Diagram Used pins highlighted · others in gray

    + + + + 40-pin GPIO header (J8) · physical pin numbers + + + + + + + 1 + 3V3 + + 2 + 5V (VCC) + + + + 3 + GPIO2 (SDA1) + + 4 + 5V + + + + 5 + GPIO3 (SCL1) + + 6 + GND + + + + 7 + GPIO4 + + 8 + GPIO14 (TXD) + + + + 9 + GND + + 10 + GPIO15 (RXD) + + + + 11 + GPIO17 + + 12 + GPIO18 + + + + 13 + GPIO27 + + 14 + GND + + + + 15 + GPIO22 + + 16 + GPIO23 + + + + 17 + 3V3 + + 18 + GPIO24 + + + + 19 + GPIO10 (MOSI) + + 20 + GND + + + + 21 + GPIO9 (MISO) + + 22 + GPIO25 + + + + 23 + GPIO11 (SCLK) + + 24 + GPIO8 (CE0) + + + + 25 + GND + + 26 + GPIO7 (CE1) + + + + 27 + GPIO0 (ID_SD) + + 28 + GPIO1 (ID_SC) + + + + 29 + GPIO5 + + 30 + GND + + + + 31 + GPIO6 + + 32 + GPIO12 + + + + 33 + GPIO13 + + 34 + GND + + + + 35 + GPIO19 + + 36 + GPIO16 + + + + 37 + GPIO26 + + 38 + GPIO20 + + + + 39 + GND + + 40 + GPIO21 + + + [#] square marker = pin 1 (relative to board orientation) + + +
    + 5V power — pin 2 + UART RXD (sensor data) — pin 10 + GND — pin 6 + UART TXD (optional) — pin 8 + Unused pins +
    +

    The pin numbers and signals in this diagram match the standard Raspberry Pi 4 Model B (BCM2711) 40-pin header layout.

    +
    + + +
    +

    3Pinmap Table PMS7003 → 40-pin header

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    PMS7003 signalDirectionPhys pinNotes
    VCC (power)Pi → sensor2* 5V required — the PMS7003 fan + laser need 5V (pins 2·4)
    GNDcommon6Any GND works: 6 · 9 · 14 · 20 · 25 · 30 · 34 · 39
    TXD (sensor data out)sensor → Pi RXD10GPIO15 (RXD). 3.3V logic — connects directly, no level shifter
    RXD (command in)Pi TXD → sensor8Optional — only for passive-mode/sleep commands. Active streaming needs only TXD
    SET / RESET—n/cOptional. Leave floating — internal pull-up keeps the sensor enabled/running
    +
    + + +
    +

    4Wiring Schematic sensor ↔ header

    + + + + + + PMS7003 sensor + Pi 40-pin header + + + VCC + GND + TXD + RXD + + + 5V (pin 2) + GND (pin 6) + RXD (pin 10, GPIO15) + TXD (pin 8, GPIO14) + + + + Power 5V + + + + Ground GND + + + + sensor TXD → Pi RXD (GPIO15) + + + + Pi TXD → sensor RXD (optional) + + +

    + Active streaming mode uses only VCC · GND · sensor TXD → Pi RXD. The dashed Pi TXD → sensor RXD + line is optional (needed only to send passive-mode / sleep commands). The PMS7003 TXD is 3.3V logic, + so it wires directly to the Pi — no level shifter. +

    +
    + + +
    +

    5Electrical Design · Cautions

    + +
    + +
    +

    Critical — power VCC from 5V, but NEVER wire the sensor TXD to a Pi 5V pin

    +

    The PMS7003 VCC must be 5V (pin 2 or 4) — its fan and laser will not run on 3.3V. However the + UART data lines are 3.3V logic: the sensor's TXD connects directly to the Pi RXD (pin 10). + Do not connect any 5V line to a GPIO — that would damage the 3.3V-only Pi GPIO. Double-check pin numbers before power-up.

    +
    +
    + +
      +
    • Enable the UART: add enable_uart=1 to /boot/firmware/config.txt (Bookworm) or use raspi-config → Interface Options → Serial Port.
    • +
    • Disable the serial login console: answer "No" to the login-shell question in raspi-config, or remove console=serial0,115200 from cmdline.txt. Otherwise the console fights the sensor for /dev/serial0. install.sh does both automatically.
    • +
    • Port: the module reads /dev/serial0 (alias to the primary UART; also /dev/ttyS0 / /dev/ttyAMA0). 9600 baud, 8 data bits, no parity, 1 stop bit.
    • +
    • dialout group: the service user needs the dialout group to open /dev/serial0 (systemd unit sets SupplementaryGroups=dialout).
    • +
    • Warm-up: the PMS7003 needs a few seconds of fan/laser warm-up after power-on for stable readings; the driver simply reads until a valid checksummed frame arrives.
    • +
    • Role split: this node only reports PM1.0/PM2.5/PM10 every 10 minutes. High-PM2.5/PM10 threshold decisions and SMS dispatch are done by the server.
    • +
    • Config file: raspberry_pi4_pms7003/config.py (UART_PORT=/dev/serial0, UART_BAUD=9600). Per-device values (SENSOR_ID 1 vs 3) are overridden in config_local.py.
    • +
    + +
    +# Verify the UART device exists (symlink to ttyS0 / ttyAMA0) +$ ls -l /dev/serial0 +lrwxrwxrwx 1 root root 5 ... /dev/serial0 -> ttyS0 +
    +
    + + +
    +

    6Installation Checklist

    +
      +
    1. Do wiring work with power disconnected (no hot-plugging)
    2. +
    3. Connect VCC → physical pin 2 (5V) — the sensor fan/laser need 5V
    4. +
    5. Connect GND → physical pin 6 (or any other GND pin)
    6. +
    7. Connect sensor TXD → Pi RXD (pin 10, GPIO15) — this is the data line
    8. +
    9. (Optional) Pi TXD (pin 8) → sensor RXD, only if sending passive-mode commands
    10. +
    11. Leave SET/RESET floating (sensor stays enabled/running)
    12. +
    13. Re-check all wiring — confirm no 5V line touches a GPIO — then power up the Pi
    14. +
    15. Enable UART (enable_uart=1) and disable the serial console (raspi-config / install.sh), then reboot
    16. +
    17. Run ls -l /dev/serial0 → confirm it symlinks to ttyS0/ttyAMA0
    18. +
    19. Verify UART_PORT / UART_BAUD in config.py, and SENSOR_ID (1 or 3) in config_local.py
    20. +
    21. Check measurement log and server reception (10-min PM reporting OK)
    22. +
    +
    + +
    + Particulate-Matter Node · PMS7003 UART wiring doc (co-located on a leak node, sensor_id 1 or 3) — for on-site printing +
    + +
    + + diff --git a/raspberry_pi4_pms7003/config.py b/raspberry_pi4_pms7003/config.py new file mode 100644 index 0000000..8894eb2 --- /dev/null +++ b/raspberry_pi4_pms7003/config.py @@ -0,0 +1,124 @@ +# ============================================================================= +# config.py - Particulate-matter (PMS7003) monitoring module (Pi 4 Model B) config — non-secret defaults +# +# Operational/secret values such as server URL / API_KEY / device identifiers (DEVICE_ID/LOCATION) +# are loaded from config_local.py. config_local.py is excluded from commits via .gitignore. +# Setup: cp config_local.example.py config_local.py → fill in the real values +# +# This module integrates a PMS7003 PM sensor onto an EXISTING leak node (co-located, same +# sensor_id as that node's leak sensor, distinguished by metric_type="pms7003" — exactly like +# the SHT30 co-location). It attaches to BOTH leak nodes: Pi-C (sensor_id=3, the default here) +# and the id=1 node (override SENSOR_ID=1 in config_local.py). Threshold/SMS is server-side. +# ============================================================================= + +# ───────────────────────────────────────────── +# Sensor identity (for server dashboard display; not secret) +# SENSOR_ID is the key in the server's leak_sensor_metric / leak_sensor_status. The PMS row +# shares the co-located leak sensor's id (1 or 3), separated by metric_type. Default = Pi-C (3). +# ───────────────────────────────────────────── +SENSOR_ID = 3 +SENSOR_NAME = "Sensor 3 (PMS7003)" + +# ───────────────────────────────────────────── +# UART config (Pi 4 40-pin header primary UART) +# PMS7003 TXD → Pi RXD = GPIO15 (physical pin 10). 3.3V logic, 9600 8N1. +# Port: /dev/serial0 (alias to the primary UART). Also usable: /dev/ttyS0 / /dev/ttyAMA0. +# Requires: enable UART + DISABLE the serial login console (raspi-config / enable_uart=1). +# ───────────────────────────────────────────── +UART_PORT = "/dev/serial0" +UART_BAUD = 9600 +UART_TIMEOUT = 2.0 # pyserial per-read timeout (seconds) +FRAME_WAIT_TIMEOUT = 5.0 # max time to wait for a valid frame in one measurement (seconds) + +# Force simulation. If True, runs with dummy measurements without hardware (for server integration checks). +# Even if False, the driver auto-falls back to simulation when pyserial is not installed / UART is unavailable. +FORCE_SIMULATION = False + +# ───────────────────────────────────────────── +# Measurement/report intervals +# ───────────────────────────────────────────── +REPORT_INTERVAL = 600 # Normal measure/report interval (seconds) = 10 minutes + # Matches the co-located leak node's SHT30 cadence (600s). + # Server offline detection (METRIC_HEARTBEAT_TIMEOUT_SEC) must exceed this. + # Override per-node in config_local.py if a different cadence is needed. +MEASURE_RETRY_DELAY = 5 # Wait before retry after a failed measurement (seconds) + +# ───────────────────────────────────────────── +# Physical plausibility range for PM values (µg/m³, atmospheric concentration) +# Values outside this range are reported as metric_status="out_of_range". +# * Operational threshold judgments (high PM2.5 / PM10) and SMS are all handled by the +# server (config.php METRIC_PM*). This module only faithfully reports measurements. +# ───────────────────────────────────────────── +PM_VALUE_MIN = 0 +PM_VALUE_MAX = 1000 + +# Module identity version (server app_version field; the server only records it, does not validate) +APP_VERSION = "v2606-pms7003-rpi4" + +# ───────────────────────────────────────────── +# Logging config +# ───────────────────────────────────────────── +LOG_FILE = "/var/log/pms7003_sensor.log" +LOG_MAX_BYTES = 5 * 1024 * 1024 # 5MB +LOG_BACKUP_COUNT = 3 + +# ───────────────────────────────────────────── +# HTTP retry config +# ───────────────────────────────────────────── +HTTP_TIMEOUT = 10 # Single request timeout (seconds) +HTTP_RETRY_COUNT = 3 # Number of retries +HTTP_RETRY_DELAY = 5 # Wait between retries (seconds) + +# ───────────────────────────────────────────── +# Load operational/secret values (fail closed) +# If config_local.py is missing, abort immediately with a clear error instead of +# starting incorrectly with placeholder/hardcoded secrets. +# ───────────────────────────────────────────── +try: + from config_local import ( # noqa: F401 (pms7003_sensor.py re-imports from config) + API_URL, + API_KEY, + DEVICE_ID, + DEVICE_LOCATION, + ) +except ImportError as e: + raise RuntimeError( + "config_local.py not found. " + "Run `cp config_local.example.py config_local.py`, then fill in the real server URL / API_KEY / " + "device info. (Never commit secret values.)" + ) from e + +# ───────────────────────────────────────────── +# Optional override — per-device values from config_local.py. +# Editing the tracked config.py per device causes deployment drift, so each leak node +# overrides SENSOR_ID / SENSOR_NAME here instead (Pi-C keeps id 3; the id=1 node sets +# SENSOR_ID=1). If config_local defines neither, the defaults above (SENSOR_ID=3) are kept. +# ───────────────────────────────────────────── +try: + from config_local import SENSOR_ID # noqa: F811 — per-device sensor_id override +except ImportError: + pass +try: + from config_local import SENSOR_NAME # noqa: F811 — per-device display name override +except ImportError: + pass +try: + # Per-device report cadence. Defaults to 600s (10 min) to match the co-located leak + # node's SHT30 cadence; override here if a node needs a different interval. + from config_local import REPORT_INTERVAL # noqa: F811 — per-device report interval override +except ImportError: + pass +try: + from config_local import UART_PORT # noqa: F811 — per-device UART device override +except ImportError: + pass + +# Validate (fail closed) — a typo must not silently start with a wrong/plausible id. +if not (isinstance(SENSOR_ID, int) and 1 <= SENSOR_ID <= 255): + raise RuntimeError(f"SENSOR_ID must be int 1..255 (check config_local.py): {SENSOR_ID!r}") +if not (isinstance(SENSOR_NAME, str) and SENSOR_NAME): + raise RuntimeError("SENSOR_NAME must be a non-empty string (check config_local.py).") +if not (isinstance(REPORT_INTERVAL, int) and REPORT_INTERVAL > 0): + raise RuntimeError(f"REPORT_INTERVAL must be a positive int (seconds) (check config_local.py): {REPORT_INTERVAL!r}") +if not (isinstance(UART_PORT, str) and UART_PORT): + raise RuntimeError("UART_PORT must be a non-empty string (check config_local.py).") diff --git a/raspberry_pi4_pms7003/config_local.example.py b/raspberry_pi4_pms7003/config_local.example.py new file mode 100644 index 0000000..2938ad8 --- /dev/null +++ b/raspberry_pi4_pms7003/config_local.example.py @@ -0,0 +1,42 @@ +# ============================================================================= +# config_local.example.py - Operational / secret-value template (Pi 4 PMS7003 particulate-matter module) +# +# Usage: cp config_local.example.py config_local.py +# -> Fill in the real values and save. Never commit config_local.py +# (it is excluded via .gitignore). +# +# WARNING: Server compatibility +# This module reports PMS7003 particulate-matter (PM1.0/PM2.5/PM10, ug/m3) readings to the +# cafe24 server php/api/sensor_data.php. The server requires a raw-body signature: +# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=request body bytes)) +# -> API_KEY must be "exactly identical" to API_KEY in the server config.local.php. +# Threshold decisions (high PM2.5 / PM10), SMS, and the dashboard are all done by the server. +# ============================================================================= + +# ----------------------------------------------- +# Server API endpoint (receiving server = cafe24 php/) +# Web root per INSTALL_PI_SERVER.md: public_html/raspi_leck_detecter/ +# ----------------------------------------------- +API_URL = "https://YOUR_SERVER/raspi_leck_detecter/api/sensor_data.php" + +# ----------------------------------------------- +# Shared secret key - must be exactly identical to API_KEY in the server config.local.php. Never commit. +# ----------------------------------------------- +API_KEY = "CHANGE_ME" + +# ----------------------------------------------- +# Device identification. The PMS7003 co-locates on an EXISTING leak node and reports under the +# SAME sensor_id as that node's leak sensor (distinguished by metric_type="pms7003"). +# - Pi-C leak node: keep the config.py default (SENSOR_ID=3) - leave SENSOR_ID commented out. +# - id=1 leak node: uncomment and set SENSOR_ID=1 (and an appropriate SENSOR_NAME). +# ----------------------------------------------- +DEVICE_ID = "rpi4-pms7003-01" +DEVICE_LOCATION = "Server Room" + +# ----------------------------------------------- +# Per-device overrides (optional). Uncomment on the id=1 node. +# ----------------------------------------------- +# SENSOR_ID = 1 +# SENSOR_NAME = "Sensor 1 (PMS7003)" +# REPORT_INTERVAL = 600 +# UART_PORT = "/dev/serial0" diff --git a/raspberry_pi4_pms7003/install.sh b/raspberry_pi4_pms7003/install.sh new file mode 100644 index 0000000..864e9af --- /dev/null +++ b/raspberry_pi4_pms7003/install.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# ============================================================================= +# Particulate-matter (PMS7003) module v2 (Raspberry Pi 4 Model B) install script +# Target OS: Raspberry Pi OS Bookworm (64-bit, aarch64) +# +# Air-gapped support: +# - If debs/ or wheelhouse/ is present, automatically switch to "offline mode". +# - In offline mode, never access the network. If the bundle is only partial, +# do not silently fall back to online; stop with a clear error (air-gapped rule). Pre- +# download is done on the build machine via vendor.sh. +# - Can be forced via the OFFLINE=1/0 environment variable. +# - Log file owner: SERVICE_USER (default pi). +# +# This script also enables the UART (enable_uart=1) and DISABLES the serial login console +# (so the PMS7003 has exclusive use of /dev/serial0), and adds the user to the dialout group. +# ============================================================================= +set -euo pipefail +cd "$(dirname "$0")" + +SERVICE_USER="${SERVICE_USER:-pi}" + +echo "==========================================" +echo " Particulate-matter (PMS7003) module v2 (Pi 4) install" +echo "==========================================" + +# 0) Board model notice (not enforced) +if [ -r /proc/device-tree/model ]; then + MODEL="$(tr -d '\0' < /proc/device-tree/model)" + echo "[INFO] Board: ${MODEL}" + case "${MODEL}" in + *"Raspberry Pi 4"*) : ;; + *) echo "[WARN] This folder is for Raspberry Pi 4 Model B only (you may continue anyway)." ;; + esac +fi + +# Offline mode detection +OFFLINE="${OFFLINE:-}" +if [ -z "${OFFLINE}" ]; then + if compgen -G "debs/*.deb" >/dev/null || [ -d wheelhouse ]; then + OFFLINE=1 + echo "[INFO] Offline bundle detected -> air-gapped offline install mode (no network)" + else + OFFLINE=0 + echo "[INFO] No offline bundle -> online install mode (internet required)" + fi +fi + +# 1) System packages: requests + venv +echo "[1/4] Installing system packages..." +if [ "${OFFLINE}" = "1" ]; then + if ! compgen -G "debs/*.deb" >/dev/null; then + echo "[ERROR] Offline mode but debs/ is empty. Place the vendor.sh output (debs/) in this folder and re-run." >&2 + exit 1 + fi + echo " Offline .deb install (apt-get --no-download - dependency ordering/resolution automatic, no network)" + # --no-install-recommends: vendor.sh does not bundle Recommends (--no-recommends), so match that. + sudo apt-get install -y --no-install-recommends --no-download ./debs/*.deb +else + echo " Online apt install (internet required)" + sudo apt-get update + sudo apt-get install -y python3-requests python3-venv +fi + +# 2) Enable UART + disable the serial login console +echo "[2/4] Enabling UART (and disabling the serial console)..." +# Boot config path: Bookworm=/boot/firmware/config.txt, older=/boot/config.txt +BOOT_CFG="" +for c in /boot/firmware/config.txt /boot/config.txt; do + if [ -f "$c" ]; then BOOT_CFG="$c"; break; fi +done +if [ -n "${BOOT_CFG}" ]; then + if ! grep -q "^enable_uart=1" "${BOOT_CFG}"; then + echo " Adding enable_uart=1 to ${BOOT_CFG} (applied after reboot)" + echo "enable_uart=1" | sudo tee -a "${BOOT_CFG}" >/dev/null + NEED_REBOOT=1 + else + echo " enable_uart=1 already set" + fi +else + echo "[WARN] config.txt not found - enable UART manually via raspi-config" +fi +# Disable the serial login console so /dev/serial0 is free for the sensor. +# Bookworm=/boot/firmware/cmdline.txt, older=/boot/cmdline.txt +CMDLINE="" +for c in /boot/firmware/cmdline.txt /boot/cmdline.txt; do + if [ -f "$c" ]; then CMDLINE="$c"; break; fi +done +if [ -n "${CMDLINE}" ] && grep -q "console=serial0" "${CMDLINE}"; then + echo " Removing 'console=serial0,115200' from ${CMDLINE} (frees the UART for PMS7003)" + sudo sed -i 's/console=serial0,[0-9]* //' "${CMDLINE}" + NEED_REBOOT=1 +fi +# Disable the getty on the primary UART (harmless if already masked). +sudo systemctl disable --now serial-getty@ttyS0.service 2>/dev/null || true +sudo systemctl disable --now serial-getty@ttyAMA0.service 2>/dev/null || true +# Add the service user to the dialout group (/dev/serial0 access permission) +if getent group dialout >/dev/null 2>&1; then + sudo usermod -aG dialout "${SERVICE_USER}" || echo "[WARN] usermod failed - check SERVICE_USER=${SERVICE_USER}" +fi + +# 3) Python venv + requests + pyserial +echo "[3/4] Setting up Python virtual environment..." +if [ ! -d venv ]; then + python3 -m venv --system-site-packages venv + echo " venv created" +else + echo " venv already exists - skipping" +fi +# shellcheck disable=SC1091 +source venv/bin/activate +if [ "${OFFLINE}" = "1" ]; then + if [ ! -d wheelhouse ]; then + echo "[ERROR] Offline mode but wheelhouse/ is missing. Place the vendor.sh output (wheelhouse/) and re-run." >&2 + exit 1 + fi + echo " Offline wheel install (--no-index - no network)" + pip install --no-index --find-links=wheelhouse -r requirements.txt +else + echo " Online pip install (internet required)" + pip install --upgrade pip + pip install -r requirements.txt +fi +echo " Packages installed" + +# 4) Log file (least privilege: owner-only write) +echo "[4/4] Creating log file..." +sudo mkdir -p /var/log +sudo touch /var/log/pms7003_sensor.log +if ! sudo chown "${SERVICE_USER}:${SERVICE_USER}" /var/log/pms7003_sensor.log; then + echo "[WARN] chown failed - check whether SERVICE_USER=${SERVICE_USER} exists" +fi +sudo chmod 640 /var/log/pms7003_sensor.log + +# Lock config_local.py immediately (contains API_KEY - prevent world-readable until harden) +# Hardening (harden_pi.sh) tightens it again later, but to avoid exposure even in +# that window, lock it to 600 right at install time. Protected regardless of harden. +if [ -f config_local.py ]; then + chmod 600 config_local.py + sudo chown "${SERVICE_USER}:${SERVICE_USER}" config_local.py 2>/dev/null || true + echo " config_local.py set to permission 600 (owner ${SERVICE_USER})" +fi + +# config_local notice (fail closed - if missing, execution stops immediately) +if [ ! -f config_local.py ]; then + echo "" + echo "[!] config_local.py is missing. Run the following, then fill in real values:" + echo " cp config_local.example.py config_local.py" +fi + +echo "" +echo "==========================================" +echo " Install complete! (OFFLINE=${OFFLINE})" +echo "==========================================" +if [ "${NEED_REBOOT:-0}" = "1" ]; then + echo " [!] UART was just enabled / serial console disabled -> reboot to apply: 'sudo reboot'." +fi +echo " Port check: ls -l /dev/serial0 (should symlink to ttyS0 or ttyAMA0)" +echo " Run: source venv/bin/activate && python3 pms7003_sensor.py" +echo " Service: see the systemd section in README.md" +echo "" diff --git a/raspberry_pi4_pms7003/pms7003_driver.py b/raspberry_pi4_pms7003/pms7003_driver.py new file mode 100644 index 0000000..d7581f1 --- /dev/null +++ b/raspberry_pi4_pms7003/pms7003_driver.py @@ -0,0 +1,194 @@ +# ============================================================================= +# pms7003_driver.py - PMS7003 UART measurement driver (Raspberry Pi 4, /dev/serial0) +# +# [Sensor] Plantower PMS7003 laser particulate-matter sensor. +# Streams a fixed 32-byte frame over UART (3.3V logic, 9600 8N1). By default the +# sensor powers up in ACTIVE mode and pushes frames continuously (~1 frame/sec in +# stable mode). We do not switch it to passive mode — we just sync on the header and +# read one complete valid frame per measurement, discarding partials/stale bytes. +# +# [Frame layout] (big-endian words; total 32 bytes) +# byte 0 : 0x42 start char 1 +# byte 1 : 0x4D start char 2 +# byte 2-3 : frame length = 2*13 + 2 = 28 (0x001C) +# byte 4-29 : 13 uint16 data words (big-endian) +# data[0..2] = PM1.0 / PM2.5 / PM10, CF=1 (standard particle) +# data[3] = PM1.0 atmospheric (bytes 10-11) <- reported +# data[4] = PM2.5 atmospheric (bytes 12-13) <- reported +# data[5] = PM10 atmospheric (bytes 14-15) <- reported +# data[6..11]= particle counts (>0.3/0.5/1.0/2.5/5.0/10 µm) +# data[12] = reserved (version / error code) +# byte 30-31 : checksum = uint16(sum of bytes 0..29) +# +# We report the ATMOSPHERIC concentration words (µg/m³) — the value meant for ambient +# air-quality reporting (the CF=1 words are for controlled/lab conditions). +# +# [Simulation] If pyserial is not installed or the UART device is absent (dev PC), it +# switches to SIMULATION=True and returns plausible dummy PM readings (for server +# integration/format checks) — same pattern as sht30_driver's SMBUS_AVAILABLE fallback. +# ============================================================================= +from __future__ import annotations + +import time + +# ── Constants ──────────────────────────────────────────────────────────────── +START1 = 0x42 # frame start char 1 ('B') +START2 = 0x4D # frame start char 2 ('M') +FRAME_LEN = 32 # total frame length (bytes) +BODY_LEN_FIELD = 28 # value of the length field (bytes 2-3) = 2*13 data + 2 checksum +DATA_WORD_COUNT = 13 # number of uint16 data words (bytes 4..29) + +# Index of the reported ATMOSPHERIC concentration words within the 13-word data array. +IDX_PM1_0 = 3 # bytes 10-11 +IDX_PM2_5 = 4 # bytes 12-13 +IDX_PM10 = 5 # bytes 14-15 + +try: + import serial # pyserial (pure Python, arch-independent py3-none-any wheel) + SERIAL_AVAILABLE = True +except ImportError: + SERIAL_AVAILABLE = False + + +# ── Frame parsing (pure; importable without serial hardware) ───────────────── +def parse_frame(frame: bytes): + """Verify + decode a 32-byte PMS7003 frame -> (pm1_0, pm2_5, pm10) ints (µg/m³). + + Raises ValueError on wrong length / bad header / length-field mismatch / checksum + mismatch (caller retries/skips). Reports the atmospheric concentration words. + """ + if len(frame) != FRAME_LEN: + raise ValueError(f"frame length error: {len(frame)} (expected {FRAME_LEN})") + if frame[0] != START1 or frame[1] != START2: + raise ValueError(f"bad header: {frame[0]:#04x} {frame[1]:#04x} " + f"(expected {START1:#04x} {START2:#04x})") + + length_field = (frame[2] << 8) | frame[3] + if length_field != BODY_LEN_FIELD: + raise ValueError(f"frame length field mismatch: {length_field} (expected {BODY_LEN_FIELD})") + + # Checksum = sum of bytes 0..29, compared to the uint16 at bytes 30-31. + checksum = sum(frame[0:30]) & 0xFFFF + expected = (frame[30] << 8) | frame[31] + if checksum != expected: + raise ValueError(f"checksum mismatch: {checksum:#06x} (expected {expected:#06x})") + + # Decode the 13 big-endian data words (bytes 4..29). + data = [ + (frame[4 + 2 * i] << 8) | frame[5 + 2 * i] + for i in range(DATA_WORD_COUNT) + ] + pm1_0 = data[IDX_PM1_0] + pm2_5 = data[IDX_PM2_5] + pm10 = data[IDX_PM10] + return pm1_0, pm2_5, pm10 + + +class PMS7003: + """PMS7003 UART measurement driver (active mode, frame-synced read). + + port: serial device (Pi 4: /dev/serial0 -> primary UART; also /dev/ttyS0 / /dev/ttyAMA0) + baud: 9600 (PMS7003 fixed) + simulate: if True, returns dummy values without hardware (forced simulation) + read_timeout: pyserial per-read timeout (seconds) + frame_wait: max time to wait for a valid frame in one measure() call (seconds) + """ + + def __init__(self, port: str = "/dev/serial0", baud: int = 9600, + simulate: bool = False, read_timeout: float = 2.0, + frame_wait: float = 5.0): + self.port = port + self.baud = baud + self.simulate = simulate or not SERIAL_AVAILABLE + self.read_timeout = read_timeout + self.frame_wait = frame_wait + self._ser = None + self._sim_tick = 0 + + def open(self): + if self.simulate: + return + # If the UART device is missing (serial console still enabled / UART not enabled), + # report a clear, actionable error. + try: + self._ser = serial.Serial( + port=self.port, + baudrate=self.baud, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=self.read_timeout, + ) + except (FileNotFoundError, PermissionError, OSError) as e: + raise RuntimeError( + f"Failed to open UART {self.port}: {e}. " + f"Enable UART + disable the serial console via `sudo raspi-config` " + f"(Interface Options -> Serial Port), and verify the user's dialout group." + ) from e + # Discard any stale/partial bytes buffered before we started reading. + try: + self._ser.reset_input_buffer() + except OSError: + pass + + def close(self): + if self._ser is not None: + try: + self._ser.close() + finally: + self._ser = None + + def measure(self): + """Read one valid frame -> (pm1_0, pm2_5, pm10) ints. Raises on failure/timeout. + + In simulation mode, returns dummy values in a plausible indoor range. + Syncs on the 0x42 0x4D header, reads the remaining 30 bytes, and validates via + parse_frame(). Bad/partial frames are discarded and retried until frame_wait elapses. + """ + if self.simulate: + self._sim_tick += 1 + pm1_0 = 5 + (self._sim_tick % 6) # 5..10 + pm2_5 = 10 + (self._sim_tick % 11) # 10..20 + pm10 = 18 + (self._sim_tick % 13) # 18..30 + return pm1_0, pm2_5, pm10 + + if self._ser is None: + self.open() + + deadline = time.time() + self.frame_wait + last_err = None + while time.time() < deadline: + # 1) Sync on start char 1. + b = self._ser.read(1) + if not b: + last_err = "read timeout (no data on UART)" + continue + if b[0] != START1: + continue + # 2) Expect start char 2 immediately after. + b2 = self._ser.read(1) + if not b2 or b2[0] != START2: + continue + # 3) Read the remaining 30 bytes of the frame. + rest = self._ser.read(FRAME_LEN - 2) + if len(rest) != FRAME_LEN - 2: + last_err = "partial frame (short read)" + continue + frame = bytes([START1, START2]) + rest + # 4) Validate + decode (bad checksum -> keep trying within frame_wait). + try: + return parse_frame(frame) + except ValueError as e: + last_err = str(e) + continue + + raise ValueError(f"no valid PMS7003 frame within {self.frame_wait}s " + f"(last: {last_err or 'unknown'})") + + def __enter__(self): + self.open() + return self + + def __exit__(self, *exc): + self.close() + return False diff --git a/raspberry_pi4_pms7003/pms7003_sensor.py b/raspberry_pi4_pms7003/pms7003_sensor.py new file mode 100644 index 0000000..b3af4d7 --- /dev/null +++ b/raspberry_pi4_pms7003/pms7003_sensor.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# ============================================================================= +# pms7003_sensor.py - PMS7003 particulate-matter monitoring main (Raspberry Pi 4 only) +# +# [Target hardware] Raspberry Pi 4 Model B (BCM2711, 40-pin GPIO header) +# - Plantower PMS7003 laser PM sensor connected to the primary UART. +# - PMS7003 TXD -> Pi RXD = GPIO15 (physical 10). VCC=5V (pin 2), GND (pin 6). 3.3V logic, 9600 8N1. +# +# [Design] PM is a NEW metric on the EXISTING metric pipeline; this module mirrors the SHT30 +# module (sht30_sensor.py) exactly - measurement loop, startup->periodic, raw-body signing, +# POST + retry. There is no STM32 firmware for PMS (Pi-only). +# wire.py (build body -> sign) -> wire.pms_event_body / wire.sign_raw +# pms7003_driver.py (UART frame read / checksum / decode) -> PMS7003.measure() +# +# [Operation flow] +# 1. Open PMS7003 driver (fall back to simulation on failure/unavailability) +# 2. Measure every interval (REPORT_INTERVAL) -> POST to server +# 3. Only the "first success" measurement uses event_type="startup", afterwards "periodic" +# 4. Measurement failure (comm/checksum) does not crash; log, sleep briefly, then retry +# 5. Out of physical range -> metric_status="out_of_range" (server re-evaluates operational thresholds) +# +# [Auth] The server requires a raw-body HMAC signature (legacy body-sig disabled). +# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=sent body bytes)) +# -> Build the body directly and raw-POST it so the signed bytes match the sent bytes. +# +# [Config/secrets] config.py holds only non-secret defaults; the server URL/API_KEY/device +# identifiers are loaded from config_local.py (uncommitted). If missing, fail-closed and abort at startup. +# ============================================================================= + +import time +import math +import logging +import logging.handlers +import signal +import sys + +import requests + +from config import ( + SENSOR_ID, SENSOR_NAME, + UART_PORT, UART_BAUD, UART_TIMEOUT, FRAME_WAIT_TIMEOUT, FORCE_SIMULATION, + API_URL, API_KEY, + DEVICE_ID, DEVICE_LOCATION, + REPORT_INTERVAL, MEASURE_RETRY_DELAY, + PM_VALUE_MIN, PM_VALUE_MAX, + APP_VERSION, + LOG_FILE, LOG_MAX_BYTES, LOG_BACKUP_COUNT, + HTTP_TIMEOUT, HTTP_RETRY_COUNT, HTTP_RETRY_DELAY, +) +import wire +from pms7003_driver import PMS7003, SERIAL_AVAILABLE + + +# ----------------------------------------------- +# Logger setup +# ----------------------------------------------- +def setup_logger(): + logger = logging.getLogger("pms7003_sensor") + logger.setLevel(logging.DEBUG) + + fmt = logging.Formatter( + "%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + + try: + fh = logging.handlers.RotatingFileHandler( + LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUP_COUNT, + encoding="utf-8" + ) + fh.setFormatter(fmt) + logger.addHandler(fh) + except PermissionError: + pass # No permission -> console only + + ch = logging.StreamHandler(sys.stdout) + ch.setFormatter(fmt) + logger.addHandler(ch) + + return logger + +log = setup_logger() + + +# ----------------------------------------------- +# Shutdown signal handling (systemd stop = SIGTERM clean shutdown) +# ----------------------------------------------- +class StopFlag: + def __init__(self): + self.stop = False + + def request(self, signum, frame): + self.stop = True + log.info(f"[SIGNAL] {signal.Signals(signum).name} received -> preparing to shut down") + +stop_flag = StopFlag() + + +# ----------------------------------------------- +# PM physical range classification (mirrors the SHT30 classify_metric_status) +# The server does not trust this value and re-evaluates with operational thresholds, so +# here we only check sensor physical plausibility (range / NaN / Inf). +# ----------------------------------------------- +def classify_metric_status(pm1_0, pm2_5, pm10) -> str: + for v in (pm1_0, pm2_5, pm10): + if not math.isfinite(v): + return "out_of_range" + if v < PM_VALUE_MIN or v > PM_VALUE_MAX: + return "out_of_range" + return "normal" + + +# ----------------------------------------------- +# Server reporting (raw-body + X-Signature HMAC) +# ----------------------------------------------- +def post_reading(event_type: str, pm1_0: int, pm2_5: int, pm10: int, metric_status: str) -> bool: + """POST the PMS7003 measurement to the server. Returns True on success (HTTP 200). + + Build the body directly, sign those bytes, and send those exact bytes + (guarantees signed bytes == sent bytes -> matches the server's raw-body verification). + """ + timestamp = int(time.time()) + body = wire.pms_event_body( + device_id=DEVICE_ID, + device_location=DEVICE_LOCATION, + sensor_id=SENSOR_ID, + sensor_name=SENSOR_NAME, + event_type=event_type, + timestamp=timestamp, + pm1_0=pm1_0, + pm2_5=pm2_5, + pm10=pm10, + metric_status=metric_status, + app_version=APP_VERSION, + ) + body_bytes = body.encode("utf-8") + signature = wire.sign_raw(API_KEY, body) + + headers = { + "Content-Type": "application/json", + "X-Signature": signature, + "X-Device-Id": DEVICE_ID, + "User-Agent": "PMS7003Sensor/2.0", + } + + for attempt in range(1, HTTP_RETRY_COUNT + 1): + try: + # Send the raw body as-is via data=body_bytes (do not re-serialize with json=). + resp = requests.post( + API_URL, data=body_bytes, headers=headers, timeout=HTTP_TIMEOUT + ) + if resp.status_code == 200: + log.info(f"[POST OK] {event_type} PM1.0={pm1_0} PM2.5={pm2_5} PM10={pm10} ug/m3 " + f"status={metric_status} -> {resp.text[:100]}") + return True + log.warning(f"[POST {resp.status_code}] attempt {attempt}/{HTTP_RETRY_COUNT}: " + f"{resp.text[:160]}") + except requests.exceptions.Timeout: + log.warning(f"[POST TIMEOUT] attempt {attempt}/{HTTP_RETRY_COUNT}") + except requests.exceptions.ConnectionError as e: + log.warning(f"[POST CONN ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}") + except requests.exceptions.RequestException as e: + log.warning(f"[POST ERR] attempt {attempt}/{HTTP_RETRY_COUNT}: {e}") + + if attempt < HTTP_RETRY_COUNT and not stop_flag.stop: + time.sleep(HTTP_RETRY_DELAY) + + log.error(f"[POST FAIL] all {HTTP_RETRY_COUNT} attempts failed (event={event_type})") + return False + + +# ----------------------------------------------- +# Measurement loop (mirrors the SHT30 measure loop) +# ----------------------------------------------- +def measure_loop(sensor: PMS7003): + # Only the first successful measurement is startup, afterwards periodic (server records startup once in leak_sensor_log) + first_success = True + + log.info(f"[LOOP] measurement started (interval={REPORT_INTERVAL}s, port={UART_PORT}, baud={UART_BAUD})") + + while not stop_flag.stop: + try: + pm1_0, pm2_5, pm10 = sensor.measure() + except (OSError, ValueError, RuntimeError) as e: + # Measurement failure (comm/checksum): no crash. Log, sleep briefly, then retry. + log.error(f"[MEASURE FAIL] {e} -> retry after {MEASURE_RETRY_DELAY}s") + _interruptible_sleep(MEASURE_RETRY_DELAY) + continue + + metric_status = classify_metric_status(pm1_0, pm2_5, pm10) + event_type = "startup" if first_success else "periodic" + + log.info(f"[MEASURE] {event_type}: PM1.0={pm1_0} PM2.5={pm2_5} PM10={pm10} ug/m3 status={metric_status}") + + ok = post_reading(event_type, pm1_0, pm2_5, pm10, metric_status) + if ok and first_success: + # Switch startup->periodic only after the first successful report (first report is always startup) + first_success = False + + # Wait for the normal report interval (wakes immediately on a shutdown signal) + _interruptible_sleep(REPORT_INTERVAL) + + +def _interruptible_sleep(seconds: float): + """Sleep in 1-second chunks to react quickly to shutdown signals.""" + end = time.time() + seconds + while not stop_flag.stop and time.time() < end: + time.sleep(min(1.0, end - time.time())) + + +# ----------------------------------------------- +# Main +# ----------------------------------------------- +def main(): + signal.signal(signal.SIGTERM, stop_flag.request) + signal.signal(signal.SIGINT, stop_flag.request) + + simulate = FORCE_SIMULATION or not SERIAL_AVAILABLE + log.info("=" * 60) + log.info(f"Particulate-matter (PMS7003) monitoring v2 (Pi 4) starting") + log.info(f"device={DEVICE_ID} | location={DEVICE_LOCATION} | sensor={SENSOR_NAME}(id={SENSOR_ID})") + log.info(f"API={API_URL}") + log.info(f"mode={'simulation' if simulate else f'real UART (port={UART_PORT}, baud={UART_BAUD})'}") + if simulate and not FORCE_SIMULATION: + log.warning("pyserial not installed / UART unavailable -> simulation mode. For real readings, run install.sh then restart.") + log.info("=" * 60) + + sensor = PMS7003(port=UART_PORT, baud=UART_BAUD, simulate=simulate, + read_timeout=UART_TIMEOUT, frame_wait=FRAME_WAIT_TIMEOUT) + try: + sensor.open() + except RuntimeError as e: + log.error(f"[INIT] {e}") + sys.exit(1) + + try: + measure_loop(sensor) + except Exception as e: # noqa: BLE001 - top-level safety net (log then exit) + log.exception(f"[MAIN] exception occurred: {e}") + raise + finally: + sensor.close() + log.info("[MAIN] shutdown complete.") + + +if __name__ == "__main__": + main() diff --git a/raspberry_pi4_pms7003/requirements.txt b/raspberry_pi4_pms7003/requirements.txt new file mode 100644 index 0000000..d2b6eed --- /dev/null +++ b/raspberry_pi4_pms7003/requirements.txt @@ -0,0 +1,8 @@ +# Pi 4 particulate-matter (PMS7003) module Python dependencies (exact version pins for air-gapped networks) +# Offline install: pre-download into wheelhouse/ with vendor.sh, then +# pip install --no-index --find-links=wheelhouse -r requirements.txt +# +# pyserial is pure Python (arch-independent py3-none-any wheel), so even if fetched on an x86 build +# machine it runs as-is on Pi 4 (aarch64). requests is version-pinned as well. +requests==2.32.3 +pyserial==3.5 diff --git a/raspberry_pi4_pms7003/systemd/pms7003-sensor.service b/raspberry_pi4_pms7003/systemd/pms7003-sensor.service new file mode 100644 index 0000000..1770458 --- /dev/null +++ b/raspberry_pi4_pms7003/systemd/pms7003-sensor.service @@ -0,0 +1,66 @@ +# ============================================================================= +# /etc/systemd/system/pms7003-sensor.service +# Service that auto-runs the particulate-matter (PMS7003) monitor on Raspberry Pi 4 boot +# +# Installation: +# sudo cp systemd/pms7003-sensor.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable pms7003-sensor +# sudo systemctl start pms7003-sensor +# sudo systemctl status pms7003-sensor +# +# * Adjust the WorkingDirectory/ExecStart paths to match the actual deployment path. +# The example below assumes deployment under /home/pi/raspberry_pi4_pms7003. +# ============================================================================= + +[Unit] +Description=PMS7003 Particulate-Matter Sensor Monitor v2 (Pi 4) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=pi +Group=pi +# dialout group required to access /dev/serial0 (the primary UART) +SupplementaryGroups=dialout +WorkingDirectory=/home/pi/raspberry_pi4_pms7003 +ExecStart=/home/pi/raspberry_pi4_pms7003/venv/bin/python pms7003_sensor.py +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=pms7003-sensor + +# Environment variables +Environment=PYTHONUNBUFFERED=1 + +# Process-private /tmp isolation +PrivateTmp=true + +# -- Sandboxing (hardening) -- blocks root escalation and filesystem tampering if the daemon is compromised -------- +# User=pi is a NOPASSWD sudo account, so NoNewPrivileges blocks the setuid(sudo) escalation path. +NoNewPrivileges=true +# Root filesystem read-only + only /var/log writable (including log rotation). /tmp is PrivateTmp. +ProtectSystem=strict +ReadWritePaths=/var/log +# /home is read-only (reads own code/config_local.py only; blocks writes and access to other accounts' homes). +ProtectHome=read-only +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +RestrictRealtime=true +# HTTPS(POST) + local sockets (DNS/journal) only. UART is unaffected since /dev/serial0 is a char device (not a socket). +# AF_NETLINK required -- glibc getaddrinfo/NSS uses netlink to enumerate local addresses +# (omitting it makes DNS resolution intermittently fail -> server POST impossible). AF_INET/6=HTTPS, AF_UNIX=DNS/journal. +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +# * Do not use PrivateDevices -- it hides /dev/serial0 and makes the sensor inaccessible. + +# Restart cap (stop after 5 failures within 10 minutes) +StartLimitBurst=5 +StartLimitInterval=600s + +[Install] +WantedBy=multi-user.target diff --git a/raspberry_pi4_pms7003/test_connection.py b/raspberry_pi4_pms7003/test_connection.py new file mode 100644 index 0000000..83e2ecf --- /dev/null +++ b/raspberry_pi4_pms7003/test_connection.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Server connection test — run on the Pi 4 PMS7003 (미세먼지) module. + + Builds the body with wire.py, attaches a raw-body HMAC (X-Signature), and + actually sends one signed POST. Secrets (API_URL/API_KEY) are loaded from config_local.py. + The combined-node setup.sh gates hardening on this script's exit code (0 = signed 200). +""" +import sys +import time +import subprocess + +import requests + +signed_ok = False # whether the signed POST returned 200 — setup.sh gates hardening on this exit code + +from config import ( + API_URL, API_KEY, DEVICE_ID, DEVICE_LOCATION, + SENSOR_ID, SENSOR_NAME, APP_VERSION, +) +import wire + +print("=" * 50) +print(" Server connection test (Pi 4 PMS7003 / 미세먼지 module)") +print("=" * 50) +print(f" API_URL = {API_URL}") + +# ── Step 1: check endpoint reachability with curl (no signature → 403 auth failure is expected) ── +print("\n[1/2] Checking sensor_data.php reachability with curl (no signature → 403 auth failure is expected)...") +try: + result = subprocess.run( + ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", + "-A", "PMS7003Sensor/2.0", "-X", "POST", API_URL, + "-H", "Content-Type: application/json", "-d", "{}"], + capture_output=True, text=True, timeout=10 + ) + code = result.stdout.strip() + print(f" HTTP status code: {code}") + if code in ("400", "403"): + print(" → Endpoint reachable OK (rejected without signature/fields — expected)") + elif code == "200": + print(" → 200? Server accepted without a signature — check server configuration") + elif code == "404": + print(" → 404: check the API_URL path") + else: + print(f" → Response: {code} (check hosting/firewall/.htaccess)") +except Exception as e: + print(f" → curl failed: {e}") + +# ── Step 2: real measurement POST signed with wire.py (startup, dummy PM values) ── +print("\n[2/2] Signed measurement POST (startup, dummy PM1.0=8 PM2.5=12 PM10=18)...") +try: + ts = int(time.time()) + body = wire.pms_event_body( + DEVICE_ID, DEVICE_LOCATION, SENSOR_ID, SENSOR_NAME, + "startup", ts, 8, 12, 18, "normal", APP_VERSION, + ) + sig = wire.sign_raw(API_KEY, body) + print(f" Body: {body}") + print(f" X-Signature: {sig[:16]}...") + resp = requests.post( + API_URL, data=body.encode("utf-8"), + headers={ + "Content-Type": "application/json", + "X-Signature": sig, + "X-Device-Id": DEVICE_ID, + "User-Agent": "PMS7003Sensor/2.0", + }, + timeout=10, + ) + print(f" HTTP status code: {resp.status_code}") + print(f" Response body: {resp.text[:200]}") + if resp.status_code == 200: + signed_ok = True + print(f" → Success! Signature verified, measurement stored (check dashboard sensor_id={SENSOR_ID})") + elif resp.status_code == 403: + print(" → 403 auth failure: API_KEY differs from the server's config.local.php, or signature mismatch") + elif resp.status_code == 400: + print(" → 400: check timestamp freshness (±600s) — verify Pi clock (NTP) sync") + else: + print(f" → Status code: {resp.status_code}") +except ImportError: + print(" → requests not installed. Run install.sh or sudo apt install python3-requests") +except Exception as e: + print(f" → Failed: {e}") + +print("\n" + "=" * 50) +print(" Test complete" + ("" if signed_ok else " — signed POST failed (see log above)")) +print("=" * 50) +sys.exit(0 if signed_ok else 1) diff --git a/raspberry_pi4_pms7003/tests/test_pms_wire_and_frame.py b/raspberry_pi4_pms7003/tests/test_pms_wire_and_frame.py new file mode 100644 index 0000000..0718a7d --- /dev/null +++ b/raspberry_pi4_pms7003/tests/test_pms_wire_and_frame.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# ============================================================================= +# tests/test_pms_wire_and_frame.py - pure unit tests for the PMS7003 wire format + frame parser +# +# These tests must pass WITHOUT any serial hardware and WITHOUT config_local.py +# (they import only wire.py and pms7003_driver.py, whose pyserial import is guarded). +# +# Coverage: +# (a) wire.pms_event_body() produces the exact fixed-order contract byte string +# (b) wire.sign_raw() matches a known HMAC-SHA256 digest +# (c) pms7003_driver.parse_frame() decodes a hand-built valid 32-byte frame and +# rejects a corrupted (bad-checksum) frame +# +# Runs standalone (python tests/test_pms_wire_and_frame.py) or under pytest. +# ============================================================================= +import os +import sys + +# Make the module importable when run from anywhere (tests/ is a subdirectory). +_MODULE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _MODULE_DIR not in sys.path: + sys.path.insert(0, _MODULE_DIR) + +import wire +from pms7003_driver import parse_frame, START1, START2, BODY_LEN_FIELD, FRAME_LEN + + +# ── Fixtures / helpers ─────────────────────────────────────────────────────── +# Fixed known inputs (shared with the "known HMAC" expectation below). +_KNOWN_ARGS = ("dev", "loc", 3, "s3", "periodic", 1700000000, 8, 12, 18, "normal", "vX") + +# The exact contract byte string these inputs must serialize to. +_EXPECTED_BODY = ( + '{"device_id":"dev","device_location":"loc","sensor_id":3,' + '"sensor_name":"s3","event_type":"periodic","timestamp":1700000000,' + '"metric_type":"pms7003","pm1_0":8,"pm2_5":12,"pm10":18,' + '"metric_status":"normal","app_version":"vX"}' +) + +# HMAC-SHA256(key="test", msg=_EXPECTED_BODY) — precomputed known-answer. +_KNOWN_KEY = "test" +_KNOWN_SIG = "96f3e8cdfcc6e48545806a9562554b26f18313ffa247a5b1e449c164256526dd" + + +def build_frame(words, checksum=None): + """Build a 32-byte PMS7003 frame from 13 data words. + + If checksum is None, compute the correct checksum (sum of bytes 0..29); + pass an explicit value to force a bad checksum. + """ + assert len(words) == 13 + frame = bytearray([START1, START2, (BODY_LEN_FIELD >> 8) & 0xFF, BODY_LEN_FIELD & 0xFF]) + for w in words: + frame += bytes([(w >> 8) & 0xFF, w & 0xFF]) + chk = (sum(frame[0:30]) & 0xFFFF) if checksum is None else (checksum & 0xFFFF) + frame += bytes([(chk >> 8) & 0xFF, chk & 0xFF]) + return bytes(frame) + + +# ── (a) body byte-string is exactly the contract string ────────────────────── +def test_pms_event_body_exact_string(): + body = wire.pms_event_body(*_KNOWN_ARGS) + assert body == _EXPECTED_BODY, f"body mismatch:\n got={body!r}\n want={_EXPECTED_BODY!r}" + # Bare integers (no quotes, no decimals) for pm values. + assert '"pm1_0":8,' in body + assert '"pm2_5":12,' in body + assert '"pm10":18,' in body + assert '"metric_type":"pms7003"' in body + # The signed/sent bytes are the UTF-8 encoding of this exact string. + assert body.encode("utf-8") == _EXPECTED_BODY.encode("utf-8") + + +# ── (b) sign_raw matches a known HMAC ──────────────────────────────────────── +def test_sign_raw_known_hmac(): + body = wire.pms_event_body(*_KNOWN_ARGS) + sig = wire.sign_raw(_KNOWN_KEY, body) + assert sig == _KNOWN_SIG, f"signature mismatch:\n got={sig}\n want={_KNOWN_SIG}" + assert len(sig) == 64 and all(c in "0123456789abcdef" for c in sig) + + +# ── (c) frame parser: valid frame decodes; bad checksum rejected ───────────── +def test_parse_frame_valid(): + # data[3..5] = atmospheric PM1.0/PM2.5/PM10 = 8/12/18 (the reported words). + words = [6, 10, 15, 8, 12, 18, 500, 140, 30, 5, 1, 0, 0] + frame = build_frame(words) + assert len(frame) == FRAME_LEN + assert parse_frame(frame) == (8, 12, 18) + + +def test_parse_frame_bad_checksum_rejected(): + words = [6, 10, 15, 8, 12, 18, 500, 140, 30, 5, 1, 0, 0] + good = build_frame(words) + # Corrupt a data byte without fixing the checksum → must raise ValueError. + bad = bytearray(good) + bad[10] ^= 0xFF + try: + parse_frame(bytes(bad)) + except ValueError: + pass + else: + raise AssertionError("parse_frame accepted a corrupted (bad-checksum) frame") + + +def test_parse_frame_bad_header_rejected(): + words = [6, 10, 15, 8, 12, 18, 500, 140, 30, 5, 1, 0, 0] + good = bytearray(build_frame(words)) + good[0] = 0x00 # break the 0x42 start byte + try: + parse_frame(bytes(good)) + except ValueError: + pass + else: + raise AssertionError("parse_frame accepted a frame with a bad header") + + +def test_parse_frame_wrong_length_rejected(): + try: + parse_frame(b"\x42\x4d\x00\x1c") # far too short + except ValueError: + pass + else: + raise AssertionError("parse_frame accepted a wrong-length frame") + + +# ── Standalone runner (no pytest required) ─────────────────────────────────── +def _run(): + tests = [ + test_pms_event_body_exact_string, + test_sign_raw_known_hmac, + test_parse_frame_valid, + test_parse_frame_bad_checksum_rejected, + test_parse_frame_bad_header_rejected, + test_parse_frame_wrong_length_rejected, + ] + failed = 0 + for t in tests: + try: + t() + print(f" [OK ] {t.__name__}") + except AssertionError as e: + failed += 1 + print(f" [FAIL] {t.__name__}: {e}") + print("=" * 50) + print(f" Result: {'ALL PASSED' if failed == 0 else str(failed) + ' FAILED'} ({len(tests)} tests)") + return failed + + +if __name__ == "__main__": + print("=" * 50) + print(" PMS7003 wire/frame self-check") + print("=" * 50) + sys.exit(1 if _run() else 0) diff --git a/raspberry_pi4_pms7003/vendor.sh b/raspberry_pi4_pms7003/vendor.sh new file mode 100644 index 0000000..06c5536 --- /dev/null +++ b/raspberry_pi4_pms7003/vendor.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# ============================================================================= +# vendor.sh - Pre-download dependencies for air-gapped install (PMS7003 particulate-matter module) +# Run this on a build/staging machine that has internet access. +# Outputs: wheelhouse/ (pip wheel + sdist), debs/ (apt .deb) -> transfer to the target Pi 4. +# +# requests has few arch dependencies, and pyserial is pure Python (py3-none-any), so it works +# on the Pi 4 no matter which build machine downloads it. Pin wheels to the target +# (Bookworm 64-bit, aarch64, Python 3.11). The .deb files are safest downloaded on the target OS/arch. +# Env vars: TARGET_PLATFORM (default manylinux2014_aarch64), TARGET_PYVER (default 311) +# ============================================================================= +set -euo pipefail +cd "$(dirname "$0")" + +TARGET_PLATFORM="${TARGET_PLATFORM:-manylinux2014_aarch64}" +TARGET_PYVER="${TARGET_PYVER:-311}" +TARGET_ABI="${TARGET_ABI:-cp${TARGET_PYVER}}" + +echo "[vendor] (1/2) Python wheel -> wheelhouse/ (platform=${TARGET_PLATFORM}, py=${TARGET_PYVER})" +mkdir -p wheelhouse +# requests and other arch-dependent wheels: pin the explicit target platform. +python3 -m pip download -r requirements.txt -d wheelhouse \ + --only-binary=:all: \ + --platform "${TARGET_PLATFORM}" \ + --python-version "${TARGET_PYVER}" \ + --implementation cp \ + --abi "${TARGET_ABI}" \ + || echo "[WARN] Target-platform pin failed for some wheels - pure-Python (any) fallback is downloaded below." +# pyserial (py3-none-any) and other pure-Python wheels plus sdist fallback. +python3 -m pip download -r requirements.txt -d wheelhouse \ + || echo "[WARN] Some sdist/any fallbacks failed (safe to ignore if all binary wheels are present)" +WHEEL_COUNT="$(ls -A wheelhouse 2>/dev/null | wc -l)" +echo " wheelhouse file count: ${WHEEL_COUNT}" +[ "${WHEEL_COUNT}" -gt 0 ] || { echo "[ERROR] wheelhouse is empty"; exit 1; } + +echo "[vendor] (2/2) apt .deb -> debs/" +mkdir -p debs +# nftables and unattended-upgrades are included too -> harden_pi.sh can run offline in the air-gapped env. +BASE_PKGS="python3-requests python3-venv nftables unattended-upgrades" +DEPS="$(apt-cache depends --recurse --no-recommends --no-suggests \ + --no-conflicts --no-breaks --no-replaces --no-enhances ${BASE_PKGS} \ + | awk '/^\w/{print $1}' | sort -u)" +( cd debs && apt-get download ${BASE_PKGS} ${DEPS} ) 2>/dev/null \ + || echo "[WARN] Some .deb downloads failed (may be virtual/transitive deps - required packages are verified below)" + +MISS=0 +for p in ${BASE_PKGS}; do + if ! ls debs/"${p}"_*.deb >/dev/null 2>&1; then + echo "[ERROR] Required package .deb missing: ${p}" + MISS=1 + fi +done +[ "${MISS}" = 0 ] || { echo "[ERROR] Required .deb missing - you must run vendor.sh on the target arch (aarch64 Bookworm)."; exit 1; } +echo " debs file count: $(ls debs/*.deb 2>/dev/null | wc -l) (all required packages verified)" + +# Real architecture check - verify the target (arm64/all) via the .deb's internal Architecture field, not the filename. +# (If the host is amd64, apt-get download fetches amd64 .deb files, and dpkg on the target Pi +# breaks with "wrong architecture". A filename-only check can't catch this.) +BAD_ARCH=0 +for deb in debs/*.deb; do + [ -e "$deb" ] || continue + arch="$(dpkg-deb --field "$deb" Architecture 2>/dev/null || echo '?')" + case "$arch" in + arm64|all) : ;; + *) echo "[ERROR] arch mismatch: $(basename "$deb") = ${arch} (not arm64/all)"; BAD_ARCH=1 ;; + esac +done +if [ "${BAD_ARCH}" != 0 ]; then + echo "[ERROR] There are .deb files that are not the target (aarch64/arm64). host arch=$(dpkg --print-architecture 2>/dev/null || echo unknown)." >&2 + echo " You must run vendor.sh on an arm64 Pi (or an arm64 chroot/qemu)." >&2 + exit 1 +fi +echo " arch check passed (all .deb = arm64/all)" + +echo "[vendor] Done. Transfer wheelhouse/ and debs/ to the target Pi 4, then run install.sh." diff --git a/raspberry_pi4_pms7003/wire.py b/raspberry_pi4_pms7003/wire.py new file mode 100644 index 0000000..22ff22a --- /dev/null +++ b/raspberry_pi4_pms7003/wire.py @@ -0,0 +1,102 @@ +# ============================================================================= +# wire.py - wire format for server transmission (body JSON + raw-body HMAC signature) +# +# [Why a separate module] +# The server (php/api/sensor_data.php) verifies, against the received "body bytes as-is", +# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)). +# Therefore the "signed bytes" and the "actually transmitted bytes" must be 100% identical. +# We build the body directly as a string, sign those bytes, then POST those same bytes as-is. +# (Leaving serialization to requests' json= can change float/whitespace/order and break the signature.) +# +# [Byte-level compatibility targets] +# PMS7003 is a Pi-only metric (no STM32 firmware), so pms_event_body() has no firmware +# counterpart. But the three helpers below are copied VERBATIM from the SHT30 module's +# wire.py so raw-body signing stays byte-identical to the shared reference: +# - firmware/common/sig.c::sig_raw_body (STM32 HMAC signature) +# - firmware/test/host/reference.py (verified Python reference) +# - php/config.php::verify_signature_raw (server verification part) +# +# fixed2() <-> jb_fixed2() / reference.fixed2() (kept for parity; PM body is integer-only) +# sign_raw() <-> sig_raw_body() / reference.sign_raw() +# pms_event_body() <- Pi-only PMS7003 body (mirrors sht30_event_body field order/escaping) +# ============================================================================= +from __future__ import annotations + +import hashlib +import hmac + + +def fixed2(v: float) -> str: + """Fixed 2-decimal format (round-half-up, locale-independent). + + Byte-identical to jb_fixed2() / reference.fixed2(). + 24.0 -> "24.00" + 48.5 -> "48.50" + -3.245 -> "-3.25" + """ + neg = v < 0 + if neg: + v = -v + scaled = int(v * 100.0 + 0.5) # round half up (C: (long long)(v*100+0.5)) + ip = scaled // 100 + fp = scaled % 100 + sign = "-" if (neg and scaled != 0) else "" + return f"{sign}{ip}.{fp:02d}" + + +def _esc(s: str) -> str: + r"""Escape JSON string value (identical to jb_estr / reference._esc). + + Escapes only '"', '\\', and control characters (<0x20). '/' is left as-is. + Multibyte UTF-8 (e.g. Korean) is left as-is (server verifies raw bytes). + """ + out = [] + for ch in s: + o = ord(ch) + if ch == '"': + out.append('\\"') + elif ch == '\\': + out.append('\\\\') + elif o < 0x20: + out.append('\\u%04x' % o) + else: + out.append(ch) + return "".join(out) + + +def pms_event_body(device_id: str, device_location: str, sensor_id: int, + sensor_name: str, event_type: str, timestamp: int, + pm1_0: int, pm2_5: int, pm10: int, + metric_status: str, app_version: str) -> str: + """Generate PMS7003 event body JSON string. + + Field order is fixed (shared wire contract) and mirrors sht30_event_body() so the + server can verify the raw body generically. metric_type is always "pms7003". + PM values are serialized as BARE INTEGERS (µg/m³, atmospheric concentration). + """ + return ( + '{' + f'"device_id":"{_esc(device_id)}",' + f'"device_location":"{_esc(device_location)}",' + f'"sensor_id":{int(sensor_id)},' + f'"sensor_name":"{_esc(sensor_name)}",' + f'"event_type":"{_esc(event_type)}",' + f'"timestamp":{int(timestamp)},' + f'"metric_type":"pms7003",' + f'"pm1_0":{int(pm1_0)},' + f'"pm2_5":{int(pm2_5)},' + f'"pm10":{int(pm10)},' + f'"metric_status":"{_esc(metric_status)}",' + f'"app_version":"{_esc(app_version)}"' + '}' + ) + + +def sign_raw(api_key: str, body: str) -> str: + """raw-body signature: lowercase_hex(HMAC-SHA256(key=api_key, msg=body)). + + Identical to sig_raw_body() / reference.sign_raw() / php verify_signature_raw. + The body is encoded as UTF-8 then signed (same as transmitted bytes). + """ + return hmac.new(api_key.encode("utf-8"), body.encode("utf-8"), + hashlib.sha256).hexdigest() diff --git a/run_tests.sh b/run_tests.sh index d1c0777..3ffd197 100644 --- a/run_tests.sh +++ b/run_tests.sh @@ -27,6 +27,7 @@ echo "=== 1) PHP 회귀 테스트 ===" run_php tests/raw_body_signature_test.php run_php tests/security_hardening_test.php run_php tests/threshold_sms_test.php +run_php tests/pm_threshold_test.php # PMS7003 미세먼지(PM2.5/PM10) 임계 판정 run_php tests/cooldown_episode_test.php # 에피소드 인지 쿨다운(복구 후 재발 누락 회귀) run_php tests/ingest_reject_log_test.php # 수집 거부 출처 로그(IP·로그인젝션 방지) run_php tests/chaos_drill.php # SMS 실패 주입 경로 드릴 diff --git a/sql/migration_pms7003.sql b/sql/migration_pms7003.sql new file mode 100644 index 0000000..534c5fe --- /dev/null +++ b/sql/migration_pms7003.sql @@ -0,0 +1,38 @@ +-- migration_pms7003.sql +-- PMS7003 미세먼지 통합: 기존 배포의 leak_sensor_metric 에 미세먼지 컬럼 3개 추가. +-- pm1_0 / pm2_5 / pm10 : PMS7003 이 보고하는 µg/m³(대기환산) 정수. SHT30 온습도 행에선 +-- NULL, PMS7003 행에선 온습도(temperature_c/humidity_percent)가 NULL. +-- +-- PMS7003 은 누수 노드(sensor_id 1·3)에 온습도(SHT30)와 동일 sensor_id 로 통합되며, +-- metric_type='pms7003' 로 구분되어 같은 leak_sensor_metric 테이블에 저장된다(온습도의 +-- SHT30 co-location 과 동일 방식 — 새 테이블/새 sensor_id 없음). +-- +-- 신규 설치는 schema_sht30.sql 에 이미 포함되어 있으므로 실행 불필요. 이 파일은 "기존 +-- 운영 DB" 업그레이드 전용이다. +-- 실행: SOURCE /path/to/sql/migration_pms7003.sql; +-- +-- 멱등성: MySQL 8 은 ADD COLUMN IF NOT EXISTS 를 지원하지 않으므로, 이미 적용된 DB 에서 +-- 재실행하면 "Duplicate column name" 오류가 난다(무해 — 이미 컬럼이 있다는 뜻이니 무시). +-- MariaDB 라면 아래 문에 `IF NOT EXISTS` 를 넣어 안전하게 재실행할 수 있다. +-- (migration_leak_debounce.sql 과 동일한 비멱등 방침.) +-- +-- 참고: 이 컬럼이 없어도 서버는 온습도만 저장하며 계속 동작한다. PMS7003 수집을 켜려면 +-- 이 마이그레이션을 적용하고, Pi 노드에서 metric_type='pms7003' 보고를 시작하면 된다. + +SET NAMES utf8mb4; + +ALTER TABLE `leak_sensor_metric` + ADD COLUMN `pm1_0` SMALLINT UNSIGNED NULL + COMMENT 'PMS7003 PM1.0 (µg/m³ 대기환산; SHT30 행에선 NULL)' + AFTER `humidity_percent`, + ADD COLUMN `pm2_5` SMALLINT UNSIGNED NULL + COMMENT 'PMS7003 PM2.5 (µg/m³ 대기환산; SHT30 행에선 NULL)' + AFTER `pm1_0`, + ADD COLUMN `pm10` SMALLINT UNSIGNED NULL + COMMENT 'PMS7003 PM10 (µg/m³ 대기환산; SHT30 행에선 NULL)' + AFTER `pm2_5`; + +-- metric_status COMMENT 갱신(선택): 서버 재판정 상태에 high_pm25/high_pm10 이 추가됨을 반영. +ALTER TABLE `leak_sensor_metric` + MODIFY COLUMN `metric_status` VARCHAR(30) NOT NULL DEFAULT 'normal' + COMMENT '서버 임계 판정 결과: normal/high_temp/low_temp/high_humidity/low_humidity/condensation_risk/high_pm25/high_pm10'; diff --git a/sql/schema_sht30.sql b/sql/schema_sht30.sql index c5eb9ce..5a0b03d 100644 --- a/sql/schema_sht30.sql +++ b/sql/schema_sht30.sql @@ -73,8 +73,11 @@ CREATE TABLE IF NOT EXISTS `leak_sensor_metric` ( `metric_type` VARCHAR(30) NOT NULL DEFAULT 'sht30', `temperature_c` DECIMAL(6,2) NULL, `humidity_percent` DECIMAL(5,2) NULL, + `pm1_0` SMALLINT UNSIGNED NULL COMMENT 'PMS7003 PM1.0 (µg/m³ 대기환산; SHT30 행에선 NULL)', + `pm2_5` SMALLINT UNSIGNED NULL COMMENT 'PMS7003 PM2.5 (µg/m³ 대기환산; SHT30 행에선 NULL)', + `pm10` SMALLINT UNSIGNED NULL COMMENT 'PMS7003 PM10 (µg/m³ 대기환산; SHT30 행에선 NULL)', `metric_status` VARCHAR(30) NOT NULL DEFAULT 'normal' - COMMENT '서버 임계 판정 결과: normal/high_temp/low_temp/high_humidity/low_humidity/condensation_risk', + COMMENT '서버 임계 판정 결과: normal/high_temp/low_temp/high_humidity/low_humidity/condensation_risk/high_pm25/high_pm10', `measured_at` DATETIME NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -86,7 +89,7 @@ CREATE TABLE IF NOT EXISTS `leak_sensor_metric` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - COMMENT='SHT30 온습도 측정 이력 (온도/습도/임계 상태)'; + COMMENT='측정 이력 (SHT30 온습도 + PMS7003 미세먼지; metric_type 로 구분, 임계 상태)'; CREATE TABLE IF NOT EXISTS `leak_sms_log` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, diff --git a/tests/pm_threshold_test.php b/tests/pm_threshold_test.php new file mode 100644 index 0000000..152bf5a --- /dev/null +++ b/tests/pm_threshold_test.php @@ -0,0 +1,100 @@ + 0, 'PM2.5 threshold sane (> 0).'); +check($P10 > 0, 'PM10 threshold sane (> 0).'); + +// 임계 상대 입력. PM 은 임계 "초과"(strict >)일 때만 발화한다. +$hi25 = $P25 + 5.0; $lo25 = $P25 - 5.0; +$hi10 = $P10 + 5.0; $lo10 = $P10 - 5.0; + +// ── 온습도 없이(순수 PM) — 후방호환: 앞 두 인자 null ────────── +// 1) PM2.5 초과, PM10 정상 → high_pm25 만 (breaching case) +$b = evaluate_metric_thresholds(null, null, $hi25, $lo10)['breaches']; +check(in_array('high_pm25', $b, true), 'pm25 above threshold -> breaches contains high_pm25'); +check(!in_array('high_pm10', $b, true), 'pm10 below threshold -> no high_pm10'); + +// 2) PM10 초과, PM2.5 정상 → high_pm10 만 (pm10 > 임계 필요) +$b = evaluate_metric_thresholds(null, null, $lo25, $hi10)['breaches']; +check(in_array('high_pm10', $b, true), 'pm10 above threshold -> breaches contains high_pm10'); +check(!in_array('high_pm25', $b, true), 'pm25 below threshold -> no high_pm25'); + +// 3) 둘 다 초과 → 두 종류 모두 +$b = evaluate_metric_thresholds(null, null, $hi25, $hi10)['breaches']; +check(in_array('high_pm25', $b, true) && in_array('high_pm10', $b, true), + 'both pm above -> both breaches'); + +// 4) 정상(둘 다 임계 이하) → PM 경보 없음 (normal case) +$b = evaluate_metric_thresholds(null, null, $lo25, $lo10)['breaches']; +eq($b, [], 'both pm below threshold -> no breaches'); + +// 5) 경계: 임계와 정확히 같으면 발화하지 않는다(strict >) +$b = evaluate_metric_thresholds(null, null, $P25, $P10)['breaches']; +check(!in_array('high_pm25', $b, true) && !in_array('high_pm10', $b, true), + 'pm exactly at threshold -> no breach (strict greater-than)'); + +// 6) PM 결측(null)이면 PM 경보 없음 — 온습도 전용 호출(2인자)도 종전대로 동작 +$b = evaluate_metric_thresholds(null, null)['breaches']; +eq($b, [], 'null pm (2-arg call, backward compatible) -> no pm breach'); + +// 7) 온습도 경보와 독립 공존: 고온 + 고 PM2.5 → 두 종류 모두 포함 +$b = evaluate_metric_thresholds((float)METRIC_TEMP_HIGH_C + 5, null, $hi25, $lo10)['breaches']; +check(in_array('high_temp', $b, true) && in_array('high_pm25', $b, true), + 'high temp + high pm25 -> both breaches present'); + +// 8) 기본 임계(75/150)에서의 명시 케이스(오버라이드 없을 때만) — 사양 예시 그대로. +if ($P25 === 75.0) { + check(in_array('high_pm25', evaluate_metric_thresholds(null, null, 80.0, 40.0)['breaches'], true), + 'default: pm25=80 (>75) -> high_pm25, pm10=40 -> no high_pm10'); +} + +// ── 집계 SELECT 에 high_pm 컬럼 포함 ──────────────────────── +check(function_exists('sms_summary_select_sql'), 'sms_summary_select_sql() should exist.'); +check(str_contains(sms_summary_select_sql(), 'AS high_pm'), + "sms_summary_select_sql() should contain 'AS high_pm'"); + +// ── SMS_METRIC_TAGS 매핑: 두 PM 종류가 미세먼지 태그로 매핑 ── +check(defined('SMS_METRIC_TAGS'), 'SMS_METRIC_TAGS should be defined.'); +check((SMS_METRIC_TAGS['high_pm25'] ?? null) === SMS_TAG_HIGH_PM, 'high_pm25 -> SMS_TAG_HIGH_PM'); +check((SMS_METRIC_TAGS['high_pm10'] ?? null) === SMS_TAG_HIGH_PM, 'high_pm10 -> SMS_TAG_HIGH_PM'); + +if ($failures) { + fwrite(STDERR, "PM threshold test failures:\n"); + foreach ($failures as $f) { + fwrite(STDERR, "- {$f}\n"); + } + exit(1); +} + +echo "PMS7003 PM threshold checks passed.\n"; diff --git a/tools/migration_leak_add_pms7003/README.md b/tools/migration_leak_add_pms7003/README.md new file mode 100644 index 0000000..cc43bd0 --- /dev/null +++ b/tools/migration_leak_add_pms7003/README.md @@ -0,0 +1,117 @@ +# 07 — 기존 누수 노드에 미세먼지(PMS7003) 추가 마이그레이션 (USB 원샷) + +이미 **설치·하드닝 완료**된 누수 노드(Pi-A id=1, Pi-C id=3)를 +**누수+미세먼지 결합 노드**로 전환하는 현장 마이그레이션 패키지. +온습도(SHT30) 마이그레이션(05번)과 동일한 방식 — 기존 누수·온습도 서비스·하드닝은 +건드리지 않고 PMS7003 미세먼지 서비스만 얹는다. **하나의 USB 로 두 노드 모두 사용 가능** +(sensor_id·위치·API 키를 기존 설정에서 자동 추출). + +> 미세먼지는 **연기(화재)의 대리 지표**로도 쓰인다 — 연기 입자가 유입되면 PM 농도가 급등한다. +> (구 연기감지 개조 계획은 폐기되고 PMS7003 metric 통합으로 대체됨 — 설계문서 참조.) + +## 전제 + +- 노드: Raspberry Pi 4, Raspberry Pi OS Bookworm, harden_pi.sh 적용 완료 + (SSH 꺼짐 → 현장 모니터+키보드 작업, USB 반입) +- 누수 서비스(leak-sensor) 정상 동작 중, `config_local.py` 존재 +- 하드웨어: PMS7003 을 UART 로 배선 + - VCC → 5V(물리 pin2), GND → GND(물리 pin6) + - PMS7003 TXD → Pi RXD(GPIO15, 물리 pin10) ※ Pi TXD 는 PMS 로 보낼 필요 없음(읽기 전용) + - (배선도: `deploy/02_pi_a_leak_id1/WIRING.html` 및 노드 통합 설계문서 참고) +- **온습도(SHT30)와 UART/I2C 는 서로 다른 버스라 병행 무충돌** — 같은 노드에 온습도+미세먼지 공존 가능. + +## 1. 빌드 PC에서 USB 준비 (인터넷 필요, 1회) + +```bash +cd deploy/07_migration_leak_add_pms7003/pms7003 +bash vendor.sh # debs/ + wheelhouse/ 오프라인 번들 생성 +``` + +`07_migration_leak_add_pms7003/` 폴더 **통째로** USB 에 복사. +번들 포함 확인: `pms7003/debs/*.deb`, `pms7003/wheelhouse/` 가 있어야 +현장에서 네트워크 없이 설치된다(없으면 migrate.sh 가 경고 후 확인을 요구). + +## 2. 현장 노드에서 실행 — 콘솔 전용 절차 (한 번에) + +하드닝된 노드는 GUI·SSH 가 없다. 모니터+키보드 콘솔에서 아래 순서 그대로. +USB 는 **FAT32 포맷 권장** (exFAT/NTFS 드라이버는 노드에 없을 수 있음). + +```bash +# ① 콘솔 로그인: 사용자 pi (하드닝 후에도 로컬 콘솔 로그인은 살아 있음) + +# ② USB 꽂고 장치 이름 확인 — 콘솔은 자동 마운트가 없다 +lsblk -f +# 보통 sda1 로 보임 (SIZE·FSTYPE=vfat 으로 USB 인지 확인. SD 루트는 mmcblk0) + +# ③ 수동 마운트 +sudo mkdir -p /mnt/usb +sudo mount /dev/sda1 /mnt/usb # ②에서 확인한 장치명으로 + +# ④ 마이그레이션 실행 (USB에서 직접 실행 — noexec 이어도 bash 호출이라 무관) +cd /mnt/usb/07_migration_leak_add_pms7003 +sudo bash migrate.sh + +# ⑤ 스크립트가 끝에 안내하는 내용 확인: +# - "마이그레이션 완료" + 서비스 실행 중 → ⑥으로 +# - "재부팅 하세요" (UART 첫 활성화) → ⑥ 후 sudo reboot + +# ⑥ USB 분리 +cd / +sudo umount /mnt/usb # 뽑기 전 반드시 umount +``` + +재부팅한 경우 부팅 후 자동 시작 확인: + +```bash +systemctl status pms7003-sensor # active (running) 이면 완료 +journalctl -u pms7003-sensor -n 20 # 10분 주기 보고 로그 확인 +``` + +문제 시(서비스 fail 등) SSH 가 없으므로 같은 콘솔에서 +`journalctl -u pms7003-sensor -e` 로 원인 확인 — 롤백 절차는 아래 참고. + +migrate.sh 가 자동으로: + +1. 기존 누수 설치 경로 탐지(leak-sensor.service → `/home/pi/raspberry_pi4` 폴백) +2. 기존 `config_local.py` 에서 API_URL·API_KEY·위치·sensor_id 추출 + → `/home/pi/pms7003/config_local.py` 자동 생성(**동일 sensor_id**, 10분 주기, 600 권한) +3. 오프라인 설치(UART 활성화: `enable_uart=1` + 직렬콘솔 분리, venv, pyserial 등) — `pms7003/install.sh` 재사용 +4. systemd `pms7003-sensor` 등록·기동(샌드박스 유닛 그대로) +5. UART 장치(`/dev/serial0`)·서비스 상태 검증 + +UART 를 처음 켠 노드는 마지막에 **재부팅 안내**가 나온다 → `sudo reboot` +(서비스는 부팅 시 자동 시작). + +## 3. 확인 + +- 노드: `systemctl status pms7003-sensor`, `journalctl -u pms7003-sensor -n 20` +- 서버 대시보드(실시간 현황): 해당 번호 센서 카드에 **누수·(온습도)·미세먼지 칩이 함께** 표시되고 + PM1.0/PM2.5/PM10 수치·24h 스파크라인이 보이면 정상 +- 서버 `config.local.php`: `'METRIC_SENSOR_IDS' => [1, 2, 3]` 에 해당 id 포함 확인 +- PM 임계 초과 시 `[미세먼지경보]` SMS 발송(수신자는 기존 sensor_id 그룹 그대로) + +## 롤백 + +```bash +sudo systemctl disable --now pms7003-sensor +sudo rm /etc/systemd/system/pms7003-sensor.service && sudo systemctl daemon-reload +sudo rm -rf /home/pi/pms7003 +``` + +누수·온습도 서비스는 처음부터 독립이므로 영향 없다. +(UART 를 다시 콘솔로 되돌리려면 `sudo raspi-config` → Interface → Serial 에서 로그인 셸 ON.) + +## 폴더 구성 + +``` +07_migration_leak_add_pms7003/ +├── README.md 이 문서 +├── migrate.sh 원샷 마이그레이션 스크립트 (sudo bash migrate.sh) +└── pms7003/ PMS7003 서비스 페이로드 (raspberry_pi4_pms7003 소스와 동일) + ├── install.sh UART 활성화·venv·오프라인 패키지 설치 (migrate.sh 가 호출) + ├── vendor.sh 빌드 PC용 오프라인 번들 생성 → debs/ wheelhouse/ + ├── config_local.example.py 참고용 — 실파일은 migrate.sh 가 자동 생성 + ├── pms7003_sensor.py / pms7003_driver.py + ├── test_sensor.py / test_connection.py + └── systemd/pms7003-sensor.service (경로는 migrate.sh 가 치환) +``` diff --git a/tools/migration_leak_add_pms7003/migrate.sh b/tools/migration_leak_add_pms7003/migrate.sh new file mode 100644 index 0000000..93fb97d --- /dev/null +++ b/tools/migration_leak_add_pms7003/migrate.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# ============================================================================= +# migrate.sh — 기존 누수 노드 → 누수+온습도+미세먼지(PMS7003) 결합 노드 원샷 마이그레이션 +# +# 대상: 이미 설치·하드닝(harden_pi.sh)까지 끝난 누수 전용/누수+온습도 Pi 4 노드 (Pi-A id1 / Pi-C id3). +# SSH 는 꺼져 있으므로 현장에서 USB 로 이 폴더를 통째로 가져가 실행한다. +# +# 하는 일 (한 번에): +# 1) 기존 누수 서비스 설치 경로 자동 탐지 (leak-sensor.service → 폴백 /home/pi/raspberry_pi4) +# 2) 기존 config_local.py 에서 API_URL / API_KEY / DEVICE_LOCATION / sensor_id 자동 추출 +# → pms7003/config_local.py 자동 생성 (동일 sensor_id·동일 위치 라벨, 10분 주기) +# ★ 동일-id 통합 원칙: 미세먼지는 같은 노드 누수 센서와 같은 sensor_id 로 보고한다(SHT30 와 동일). +# 3) /home/pi/pms7003 에 모듈 복사 → install.sh 실행 (debs/·wheelhouse/ 동봉 시 완전 오프라인) +# (install.sh 가 UART 활성화: enable_uart=1 + 직렬콘솔 분리) +# 4) systemd 서비스(pms7003-sensor) 등록·기동 + 권한 잠금(config_local.py 600) +# 5) 검증: UART 장치(/dev/serial0)·서비스 상태. UART 첫 활성화면 재부팅 안내. +# +# 폐쇄망 준비물 (인터넷 되는 빌드 PC에서 먼저): +# cd pms7003 && bash vendor.sh → debs/, wheelhouse/ 생성 → USB 에 그대로 포함 +# +# 실행 (노드 현장, 모니터+키보드): +# sudo bash migrate.sh +# +# 서버 측 사전 작업(이미 되어 있으면 생략): +# config.local.php 의 'METRIC_SENSOR_IDS' 에 이 노드의 sensor_id 포함 확인 +# (미세먼지도 온습도와 같은 metric 파이프라인 · 같은 목록을 사용). +# ============================================================================= +set -euo pipefail + +[ "$(id -u)" -eq 0 ] || { echo "[ERROR] sudo 로 실행하세요: sudo bash migrate.sh"; exit 1; } +cd "$(dirname "$0")" + +SERVICE_USER="${SERVICE_USER:-pi}" +DEST_DIR="${DEST_DIR:-/home/${SERVICE_USER}/pms7003}" + +echo "==============================================" +echo " 누수 노드 → 누수+미세먼지(PMS7003) 결합 마이그레이션" +echo "==============================================" + +# ── 0) 사전 점검: 페이로드·오프라인 번들 ───────────────────────────────────── +[ -d pms7003 ] || { echo "[ERROR] pms7003/ 페이로드 폴더가 없습니다. USB 복사가 불완전합니다."; exit 1; } +if compgen -G "pms7003/debs/*.deb" >/dev/null && [ -d pms7003/wheelhouse ]; then + echo "[INFO] 오프라인 번들(debs/·wheelhouse/) 확인 — 네트워크 없이 설치합니다." +else + echo "[WARN] 오프라인 번들이 없습니다. 폐쇄망 노드면 여기서 중단하고" + echo " 빌드 PC에서 'cd pms7003 && bash vendor.sh' 후 USB 를 다시 만드세요." + echo " (온라인 노드라면 Enter 로 계속 — 인터넷 설치 시도)" + read -r -p "계속하려면 Enter, 중단은 Ctrl+C: " _ +fi + +# ── 1) 기존 누수 설치 경로 탐지 ────────────────────────────────────────────── +LEAK_DIR="${LEAK_DIR:-}" +if [ -z "${LEAK_DIR}" ] && systemctl cat leak-sensor.service >/dev/null 2>&1; then + LEAK_DIR="$(systemctl cat leak-sensor.service | sed -n 's/^WorkingDirectory=//p' | head -1)" +fi +if [ -z "${LEAK_DIR}" ] || [ ! -d "${LEAK_DIR}" ]; then + LEAK_DIR="/home/${SERVICE_USER}/raspberry_pi4" +fi +if [ ! -f "${LEAK_DIR}/config_local.py" ]; then + echo "[ERROR] 기존 누수 설치를 찾지 못했습니다 (${LEAK_DIR}/config_local.py 없음)." + echo " LEAK_DIR=/실제/경로 sudo -E bash migrate.sh 로 경로를 지정하세요." + exit 1 +fi +echo "[1/5] 기존 누수 설치 확인: ${LEAK_DIR}" + +# 이미 마이그레이션된 노드 보호(멱등): 서비스가 살아 있으면 재설치 여부 확인 +if systemctl is-active --quiet pms7003-sensor 2>/dev/null; then + echo "[WARN] pms7003-sensor 서비스가 이미 실행 중입니다. 계속하면 코드·설정을 갱신합니다." + read -r -p "계속하려면 Enter, 중단은 Ctrl+C: " _ +fi + +# ── 2) 기존 설정에서 값 추출 → pms7003 config_local.py 생성 ────────────────── +echo "[2/5] 기존 config_local.py 에서 서버·센서 정보 추출..." +# config_local.py 는 순수 변수 파일(외부 import 없음) — 안전하게 로드해 필드만 추출. +mapfile -t CFG < <(python3 - "${LEAK_DIR}" <<'PYEOF' +import sys, importlib.util +d = sys.argv[1] +spec = importlib.util.spec_from_file_location("leak_cfg", d + "/config_local.py") +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +sensors = getattr(m, "SENSORS", None) +sid = sensors[0]["id"] if sensors else 1 +print(m.API_URL) +print(m.API_KEY) +print(m.DEVICE_LOCATION) +print(sid) +PYEOF +) +API_URL="${CFG[0]}"; API_KEY="${CFG[1]}"; DEVICE_LOCATION="${CFG[2]}"; SENSOR_ID="${CFG[3]}" +case "${SENSOR_ID}" in (*[!0-9]*|'') echo "[ERROR] sensor_id 추출 실패: '${SENSOR_ID}'"; exit 1;; esac +echo " sensor_id=${SENSOR_ID}, location='${DEVICE_LOCATION}' (API_KEY 는 표시하지 않음)" + +# ── 3) 모듈 복사 + config_local.py 생성 ────────────────────────────────────── +echo "[3/5] ${DEST_DIR} 에 모듈 복사..." +mkdir -p "${DEST_DIR}" +cp -r pms7003/. "${DEST_DIR}/" +# 시크릿 포함 파일은 생성 시점부터 소유자 전용(600) +umask 077 +cat > "${DEST_DIR}/config_local.py" < /etc/systemd/system/pms7003-sensor.service +systemctl daemon-reload +systemctl enable pms7003-sensor >/dev/null +echo " systemd 유닛 등록 완료 (pms7003-sensor)" + +# ── 5) 기동·검증 ───────────────────────────────────────────────────────────── +echo "[5/5] 기동·검증..." +NEED_REBOOT=0 +if [ ! -e /dev/serial0 ] && [ ! -e /dev/ttyAMA0 ]; then + NEED_REBOOT=1 + echo " [!] /dev/serial0 이 아직 없습니다 — UART 첫 활성화. 재부팅 후 서비스가 자동 시작됩니다." +elif systemctl is-active --quiet "serial-getty@ttyAMA0" 2>/dev/null \ + || systemctl is-active --quiet "serial-getty@ttyS0" 2>/dev/null; then + # 직렬 로그인 콘솔이 아직 포트를 점유 중이면 재부팅 후에야 해제된다. + NEED_REBOOT=1 + echo " [!] 직렬 콘솔(serial-getty)이 아직 UART 를 점유 중입니다 — 재부팅 후 해제·자동 시작됩니다." +else + echo " UART 장치 확인 (/dev/serial0)" + systemctl restart pms7003-sensor + sleep 3 + if systemctl is-active --quiet pms7003-sensor; then + echo " pms7003-sensor 서비스 실행 중" + else + echo " [ERROR] 서비스 기동 실패 — journalctl -u pms7003-sensor -n 30 으로 확인하세요." + exit 1 + fi +fi + +echo "" +echo "==============================================" +echo " 마이그레이션 완료 — 누수+미세먼지 결합 노드" +echo "==============================================" +echo " sensor_id : ${SENSOR_ID} (누수와 동일 id 통합 보고, metric_type=pms7003)" +echo " 설치 경로 : ${DEST_DIR} · 보고 주기 10분" +echo " 서버 확인 : 대시보드 실시간 현황에서 '${SENSOR_ID}번 센서' 카드에" +echo " 누수·(온습도)·미세먼지 칩이 함께 보이면 정상." +echo " 서버 설정 : config.local.php METRIC_SENSOR_IDS 에 ${SENSOR_ID} 포함 여부 확인." +if [ "${NEED_REBOOT}" = "1" ]; then + echo "" + echo " ⚠️ 지금 'sudo reboot' 하세요 — UART 는 재부팅 후 활성화되고 서비스는 자동 시작됩니다." +fi +echo "" diff --git a/tools/migration_leak_add_sht30/README.md b/tools/migration_leak_add_sht30/README.md new file mode 100644 index 0000000..4112af2 --- /dev/null +++ b/tools/migration_leak_add_sht30/README.md @@ -0,0 +1,109 @@ +# 05 — 기존 누수 노드에 온습도(SHT30) 추가 마이그레이션 (USB 원샷) + +이미 **설치·하드닝 완료**된 누수 전용 노드(Pi-A id=1, Pi-C id=3)를 +**누수+온습도 결합 노드**로 전환하는 현장 마이그레이션 패키지. +신규 설치(02/04 폴더)와 달리 기존 누수 서비스·하드닝은 건드리지 않고 +SHT30 온습도 서비스만 얹는다. **하나의 USB 로 두 노드 모두 사용 가능** +(sensor_id·위치·API 키를 기존 설정에서 자동 추출). + +## 전제 + +- 노드: Raspberry Pi 4, Raspberry Pi OS Bookworm, harden_pi.sh 적용 완료 + (SSH 꺼짐 → 현장 모니터+키보드 작업, USB 반입) +- 누수 서비스(leak-sensor) 정상 동작 중, `config_local.py` 존재 +- 하드웨어: SHT30 을 I2C(3.3V, SDA=GPIO2, SCL=GPIO3)에 배선 + (배선도: `deploy/02_pi_a_leak_id1/WIRING.html` 하단 SHT30 절 참고) + +## 1. 빌드 PC에서 USB 준비 (인터넷 필요, 1회) + +```bash +cd deploy/05_migration_leak_add_sht30/sht30 +bash vendor.sh # debs/ + wheelhouse/ 오프라인 번들 생성 +``` + +`05_migration_leak_add_sht30/` 폴더 **통째로** USB 에 복사. +번들 포함 확인: `sht30/debs/*.deb`, `sht30/wheelhouse/` 가 있어야 +현장에서 네트워크 없이 설치된다(없으면 migrate.sh 가 경고 후 확인을 요구). + +## 2. 현장 노드에서 실행 — 콘솔 전용 절차 (한 번에) + +하드닝된 노드는 GUI·SSH 가 없다. 모니터+키보드 콘솔에서 아래 순서 그대로. +USB 는 **FAT32 포맷 권장** (exFAT/NTFS 드라이버는 노드에 없을 수 있음). + +```bash +# ① 콘솔 로그인: 사용자 pi (하드닝 후에도 로컬 콘솔 로그인은 살아 있음) + +# ② USB 꽂고 장치 이름 확인 — 콘솔은 자동 마운트가 없다 +lsblk -f +# 보통 sda1 로 보임 (SIZE·FSTYPE=vfat 으로 USB 인지 확인. SD 루트는 mmcblk0) + +# ③ 수동 마운트 +sudo mkdir -p /mnt/usb +sudo mount /dev/sda1 /mnt/usb # ②에서 확인한 장치명으로 + +# ④ 마이그레이션 실행 (USB에서 직접 실행 — noexec 이어도 bash 호출이라 무관) +cd /mnt/usb/05_migration_leak_add_sht30 +sudo bash migrate.sh + +# ⑤ 스크립트가 끝에 안내하는 내용 확인: +# - "마이그레이션 완료" + 서비스 실행 중 → ⑥으로 +# - "재부팅 하세요" (I2C 첫 활성화) → ⑥ 후 sudo reboot + +# ⑥ USB 분리 +cd / +sudo umount /mnt/usb # 뽑기 전 반드시 umount +``` + +재부팅한 경우 부팅 후 자동 시작 확인: + +```bash +systemctl status sht30-sensor # active (running) 이면 완료 +journalctl -u sht30-sensor -n 20 # 10분 주기 보고 로그 확인 +``` + +문제 시(서비스 fail 등) SSH 가 없으므로 같은 콘솔에서 +`journalctl -u sht30-sensor -e` 로 원인 확인 — 롤백 절차는 아래 참고. + +migrate.sh 가 자동으로: + +1. 기존 누수 설치 경로 탐지(leak-sensor.service → `/home/pi/raspberry_pi4` 폴백) +2. 기존 `config_local.py` 에서 API_URL·API_KEY·위치·sensor_id 추출 + → `/home/pi/sht30/config_local.py` 자동 생성(**동일 sensor_id**, 10분 주기, 600 권한) +3. 오프라인 설치(I2C 활성화, venv, smbus2 등) — `sht30/install.sh` 재사용 +4. systemd `sht30-sensor` 등록·기동(샌드박스 유닛 그대로) +5. I2C 인식(0x44/0x45)·서비스 상태 검증 + +I2C 를 처음 켠 노드는 마지막에 **재부팅 안내**가 나온다 → `sudo reboot` +(서비스는 부팅 시 자동 시작). + +## 3. 확인 + +- 노드: `systemctl status sht30-sensor`, `journalctl -u sht30-sensor -n 20` +- 서버 대시보드(실시간 현황): 해당 번호 센서 카드에 **누수·온습도 칩이 함께** 표시되고 + 온도/습도 수치·24h 스파크라인이 보이면 정상 +- 서버 `config.local.php`: `'METRIC_SENSOR_IDS' => [1, 2, 3]` 에 해당 id 포함 확인 + +## 롤백 + +```bash +sudo systemctl disable --now sht30-sensor +sudo rm /etc/systemd/system/sht30-sensor.service && sudo systemctl daemon-reload +sudo rm -rf /home/pi/sht30 +``` + +누수 서비스는 처음부터 독립이므로 영향 없다. + +## 폴더 구성 + +``` +05_migration_leak_add_sht30/ +├── README.md 이 문서 +├── migrate.sh 원샷 마이그레이션 스크립트 (sudo bash migrate.sh) +└── sht30/ SHT30 서비스 페이로드 (02/04 의 sht30 모듈과 동일 소스) + ├── install.sh I2C·venv·오프라인 패키지 설치 (migrate.sh 가 호출) + ├── vendor.sh 빌드 PC용 오프라인 번들 생성 → debs/ wheelhouse/ + ├── config_local.example.py 참고용 — 실파일은 migrate.sh 가 자동 생성 + ├── sht30_sensor.py / sht30_driver.py / wire.py + ├── test_sensor.py / test_connection.py + └── systemd/sht30-sensor.service (경로는 migrate.sh 가 치환) +``` diff --git a/tools/migration_leak_add_sht30/migrate.sh b/tools/migration_leak_add_sht30/migrate.sh new file mode 100644 index 0000000..562d483 --- /dev/null +++ b/tools/migration_leak_add_sht30/migrate.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# ============================================================================= +# migrate.sh — 기존 누수 노드 → 누수+온습도(SHT30) 결합 노드 원샷 마이그레이션 +# +# 대상: 이미 설치·하드닝(harden_pi.sh)까지 끝난 누수 전용 Pi 4 노드 (Pi-A/Pi-C). +# SSH 는 꺼져 있으므로 현장에서 USB 로 이 폴더를 통째로 가져가 실행한다. +# +# 하는 일 (한 번에): +# 1) 기존 누수 서비스 설치 경로 자동 탐지 (leak-sensor.service → 폴백 /home/pi/raspberry_pi4) +# 2) 기존 config_local.py 에서 API_URL / API_KEY / DEVICE_LOCATION / sensor_id 자동 추출 +# → sht30/config_local.py 자동 생성 (동일 sensor_id·동일 위치 라벨, 10분 주기) +# ★ 동일-id 통합 원칙: 온습도는 같은 노드 누수 센서와 같은 sensor_id 로 보고한다. +# 3) /home/pi/sht30 에 모듈 복사 → install.sh 실행 (debs/·wheelhouse/ 동봉 시 완전 오프라인) +# 4) systemd 서비스(sht30-sensor) 등록·기동 + 권한 잠금(config_local.py 600) +# 5) 검증: I2C 인식(0x44/0x45)·서비스 상태. I2C 첫 활성화면 재부팅 안내. +# +# 폐쇄망 준비물 (인터넷 되는 빌드 PC에서 먼저): +# cd sht30 && bash vendor.sh → debs/, wheelhouse/ 생성 → USB 에 그대로 포함 +# +# 실행 (노드 현장, 모니터+키보드): +# sudo bash migrate.sh +# +# 서버 측 사전 작업(이미 되어 있으면 생략): +# config.local.php 의 'METRIC_SENSOR_IDS' 에 이 노드의 sensor_id 추가. +# ============================================================================= +set -euo pipefail + +[ "$(id -u)" -eq 0 ] || { echo "[ERROR] sudo 로 실행하세요: sudo bash migrate.sh"; exit 1; } +cd "$(dirname "$0")" + +SERVICE_USER="${SERVICE_USER:-pi}" +DEST_DIR="${DEST_DIR:-/home/${SERVICE_USER}/sht30}" + +echo "==============================================" +echo " 누수 노드 → 누수+온습도 결합 마이그레이션" +echo "==============================================" + +# ── 0) 사전 점검: 페이로드·오프라인 번들 ───────────────────────────────────── +[ -d sht30 ] || { echo "[ERROR] sht30/ 페이로드 폴더가 없습니다. USB 복사가 불완전합니다."; exit 1; } +if compgen -G "sht30/debs/*.deb" >/dev/null && [ -d sht30/wheelhouse ]; then + echo "[INFO] 오프라인 번들(debs/·wheelhouse/) 확인 — 네트워크 없이 설치합니다." +else + echo "[WARN] 오프라인 번들이 없습니다. 폐쇄망 노드면 여기서 중단하고" + echo " 빌드 PC에서 'cd sht30 && bash vendor.sh' 후 USB 를 다시 만드세요." + echo " (온라인 노드라면 Enter 로 계속 — 인터넷 설치 시도)" + read -r -p "계속하려면 Enter, 중단은 Ctrl+C: " _ +fi + +# ── 1) 기존 누수 설치 경로 탐지 ────────────────────────────────────────────── +LEAK_DIR="${LEAK_DIR:-}" +if [ -z "${LEAK_DIR}" ] && systemctl cat leak-sensor.service >/dev/null 2>&1; then + LEAK_DIR="$(systemctl cat leak-sensor.service | sed -n 's/^WorkingDirectory=//p' | head -1)" +fi +if [ -z "${LEAK_DIR}" ] || [ ! -d "${LEAK_DIR}" ]; then + LEAK_DIR="/home/${SERVICE_USER}/raspberry_pi4" +fi +if [ ! -f "${LEAK_DIR}/config_local.py" ]; then + echo "[ERROR] 기존 누수 설치를 찾지 못했습니다 (${LEAK_DIR}/config_local.py 없음)." + echo " LEAK_DIR=/실제/경로 sudo -E bash migrate.sh 로 경로를 지정하세요." + exit 1 +fi +echo "[1/5] 기존 누수 설치 확인: ${LEAK_DIR}" + +# 이미 마이그레이션된 노드 보호(멱등): 서비스가 살아 있으면 재설치 여부 확인 +if systemctl is-active --quiet sht30-sensor 2>/dev/null; then + echo "[WARN] sht30-sensor 서비스가 이미 실행 중입니다. 계속하면 코드·설정을 갱신합니다." + read -r -p "계속하려면 Enter, 중단은 Ctrl+C: " _ +fi + +# ── 2) 기존 설정에서 값 추출 → sht30 config_local.py 생성 ──────────────────── +echo "[2/5] 기존 config_local.py 에서 서버·센서 정보 추출..." +# config_local.py 는 순수 변수 파일(외부 import 없음) — 안전하게 로드해 필드만 추출. +mapfile -t CFG < <(python3 - "${LEAK_DIR}" <<'PYEOF' +import sys, importlib.util +d = sys.argv[1] +spec = importlib.util.spec_from_file_location("leak_cfg", d + "/config_local.py") +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +sensors = getattr(m, "SENSORS", None) +sid = sensors[0]["id"] if sensors else 1 +print(m.API_URL) +print(m.API_KEY) +print(m.DEVICE_LOCATION) +print(sid) +PYEOF +) +API_URL="${CFG[0]}"; API_KEY="${CFG[1]}"; DEVICE_LOCATION="${CFG[2]}"; SENSOR_ID="${CFG[3]}" +case "${SENSOR_ID}" in (*[!0-9]*|'') echo "[ERROR] sensor_id 추출 실패: '${SENSOR_ID}'"; exit 1;; esac +echo " sensor_id=${SENSOR_ID}, location='${DEVICE_LOCATION}' (API_KEY 는 표시하지 않음)" + +# ── 3) 모듈 복사 + config_local.py 생성 ────────────────────────────────────── +echo "[3/5] ${DEST_DIR} 에 모듈 복사..." +mkdir -p "${DEST_DIR}" +cp -r sht30/. "${DEST_DIR}/" +# 시크릿 포함 파일은 생성 시점부터 소유자 전용(600) +umask 077 +cat > "${DEST_DIR}/config_local.py" < /etc/systemd/system/sht30-sensor.service +systemctl daemon-reload +systemctl enable sht30-sensor >/dev/null +echo " systemd 유닛 등록 완료 (sht30-sensor)" + +# ── 5) 기동·검증 ───────────────────────────────────────────────────────────── +echo "[5/5] 기동·검증..." +NEED_REBOOT=0 +if [ ! -e /dev/i2c-1 ]; then + NEED_REBOOT=1 + echo " [!] /dev/i2c-1 이 아직 없습니다 — I2C 첫 활성화. 재부팅 후 서비스가 자동 시작됩니다." +else + if command -v i2cdetect >/dev/null 2>&1; then + if i2cdetect -y 1 2>/dev/null | grep -qE "44|45"; then + echo " I2C 센서 인식 확인 (0x44/0x45)" + else + echo " [WARN] i2cdetect 에서 0x44/0x45 가 보이지 않습니다 — 배선(WIRING) 확인 필요." + fi + fi + systemctl restart sht30-sensor + sleep 3 + if systemctl is-active --quiet sht30-sensor; then + echo " sht30-sensor 서비스 실행 중" + else + echo " [ERROR] 서비스 기동 실패 — journalctl -u sht30-sensor -n 30 으로 확인하세요." + exit 1 + fi +fi + +echo "" +echo "==============================================" +echo " 마이그레이션 완료 — 누수+온습도 결합 노드" +echo "==============================================" +echo " sensor_id : ${SENSOR_ID} (누수와 동일 id 통합 보고)" +echo " 설치 경로 : ${DEST_DIR} · 보고 주기 10분" +echo " 서버 확인 : 대시보드 실시간 현황에서 '${SENSOR_ID}번 센서' 카드에" +echo " 누수·온습도 칩이 함께 보이면 정상." +echo " 서버 설정 : config.local.php METRIC_SENSOR_IDS 에 ${SENSOR_ID} 포함 여부 확인." +if [ "${NEED_REBOOT}" = "1" ]; then + echo "" + echo " ⚠️ 지금 'sudo reboot' 하세요 — I2C 는 재부팅 후 활성화되고 서비스는 자동 시작됩니다." +fi +echo ""