feat(pms7003): 누수 노드에 미세먼지(PM2.5/PM10) 통합

센서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 커밋 차단
This commit is contained in:
cw2007 2026-07-19 10:46:33 +09:00
parent 86960a8586
commit ec78be0bda
31 changed files with 3085 additions and 51 deletions

3
.gitignore vendored
View file

@ -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

View file

@ -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),

View file

@ -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
// 적용 필요(컬럼 없으면 레거시 즉시 발송으로 자동 폴백).

View file

@ -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 가 보고한

View file

@ -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}");

View file

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

View file

@ -134,7 +134,7 @@
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
<span class="chip">e-con 5C-08 · Resistive ~1.5MΩ</span>
<span class="chip mono">GPIO17 / Pin 11</span>
<span class="chip">External 4.7MΩ pull-down required</span>
<span class="chip">External 10MΩ pull-down required</span>
</div>
</header>
@ -264,7 +264,7 @@
<td><span class="tag sig">Signal</span> Sensor signal line</td>
<td class="mono">GPIO17</td>
<td class="mono">Pin 11</td>
<td>Sensor signal input. Connect an external 4.7MΩ pull-down between <b>Pin 11 ↔ GND</b>.</td>
<td>Sensor signal input. Connect an external 10MΩ pull-down between <b>Pin 11 ↔ GND</b>.</td>
</tr>
<tr>
<td><span class="tag pwr">Power</span> Sensor power 3.3V</td>
@ -299,7 +299,7 @@
<section>
<h2><span class="num">4</span>Wiring Schematic</h2>
<div class="diagram">
<svg viewBox="0 0 640 400" role="img" aria-label="Leak sensor wiring schematic — 3V3·GPIO17·GND and external 4.7MΩ pull-down" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto">
<svg viewBox="0 0 640 400" role="img" aria-label="Leak sensor wiring schematic — 3V3·GPIO17·GND and external 10MΩ pull-down" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto">
<!-- Pi header block -->
<rect x="36" y="70" width="150" height="250" rx="10" fill="#f1f3f5" stroke="#adb5bd" stroke-width="1.5"/>
<text x="111" y="60" text-anchor="middle" font-size="13" font-weight="700" fill="#212529">Raspberry Pi 4 · 40-pin header</text>
@ -333,7 +333,7 @@
<circle cx="340" cy="195" r="4" fill="#2f9e44"/>
<circle cx="340" cy="270" r="4" fill="#1971c2"/>
<polyline points="340,199 340,206 330,210 350,218 330,226 350,234 330,242 340,246 340,266" fill="none" stroke="#343a40" stroke-width="2"/>
<text x="360" y="226" text-anchor="start" font-size="13" font-weight="700" fill="#343a40">4.7MΩ</text>
<text x="360" y="226" text-anchor="start" font-size="13" font-weight="700" fill="#343a40">10MΩ</text>
<text x="360" y="242" text-anchor="start" font-size="11" fill="#495057">External pull-down (required)</text>
<!-- internal pull off note -->
<rect x="60" y="300" width="330" height="26" rx="6" fill="#fff5f5" stroke="#ffa8a8" stroke-width="1.5" stroke-dasharray="4 3"/>
@ -343,7 +343,7 @@
<text x="320" y="369" text-anchor="middle" font-size="11.5" fill="#343a40">Dry → GPIO=LOW(0) · Leak → GPIO=HIGH(1) · ACTIVE_LOW=False</text>
</svg>
</div>
<div class="hint">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 <b>LOW</b>. When a leak bridges the electrodes with water, 3.3V pulls the signal line up through ~1.5MΩ and GPIO17 switches to <b>HIGH</b>.</div>
<div class="hint">How it works: when dry, the sensor sits in an ultra-high-impedance (nearly open) state, so the external 10MΩ pull-down holds GPIO17 <b>LOW</b>. When a leak bridges the electrodes with water, 3.3V pulls the signal line up through ~1.5MΩ and GPIO17 switches to <b>HIGH</b>.</div>
</section>
<!-- 5) Electrical design · cautions -->
@ -355,7 +355,7 @@
<h4>Do not enable the internal pull-down</h4>
<p>If you enable the internal <b>50kΩ</b> pull-down, the ~1.5MΩ high-impedance sensor cannot pull the GPIO HIGH,
so <b>leaks will not be detected (detection failure)</b>. Always turn the internal pull off with <span class="inline-code">PUD_OFF</span>
and use <b>only the external 4.7MΩ pull-down</b>. (<span class="inline-code">USE_INTERNAL_PULL = False</span>)</p>
and use <b>only the external 10MΩ pull-down</b>. (<span class="inline-code">USE_INTERNAL_PULL = False</span>)</p>
</div>
<div class="notes" style="margin-top:14px">
@ -365,7 +365,7 @@
</div>
<div class="note">
<h4>External Pull-down (required)</h4>
<p>Connect a 4.7MΩ resistor between <b>GPIO17 (Pin 11) ↔ GND</b>. Essential for the high-impedance sensor.</p>
<p>Connect a 10MΩ resistor between <b>GPIO17 (Pin 11) ↔ GND</b>. Essential for the high-impedance sensor.</p>
</div>
<div class="note">
<h4>Internal Pull Setting</h4>
@ -399,7 +399,7 @@
<li>Verify the header orientation (Pin 1 position). Red dashed marker = Pin 1.</li>
<li>Connect the sensor signal line to <b>Pin 11 (GPIO17)</b>.</li>
<li>Connect sensor power (3.3V) to <b>Pin 1 (3V3)</b> and sensor GND to <b>Pin 6 (GND)</b>.</li>
<li class="key">Connect an external <b>4.7MΩ pull-down</b> between <b>Pin 11 ↔ GND</b>. <b>(Required · omitting it causes detection failure)</b></li>
<li class="key">Connect an external <b>10MΩ pull-down</b> between <b>Pin 11 ↔ GND</b>. <b>(Required · omitting it causes detection failure)</b></li>
<li class="key">Confirm the internal pull is off — <span class="inline-code">USE_INTERNAL_PULL=False</span> (PUD_OFF). <b>Never use the internal pull-down.</b></li>
<li>Check <span class="inline-code">config.py</span>: <span class="inline-code">ACTIVE_LOW=False</span>, <span class="inline-code">DEBOUNCE_MS=500</span>, GPIO17 registered in SENSORS.</li>
<li>Set the per-device <b>sensor_id / location</b> in <span class="inline-code">config_local.py</span> (Pi-A=1/Server Room, Pi-C=3/New).</li>
@ -409,6 +409,210 @@
</ol>
</section>
<!-- 7) Camera lighting (always-on) -->
<section>
<h2><span class="num">7</span>Camera Lighting (Always-On)</h2>
<div class="cam">
<h4>White LED module — always on, no switching, no code</h4>
<p>Power a small <b>white LED module</b> directly from the Pi so the leak-check camera has light.
<b>VCC → 5V (Pin 2)</b>, <b>GND → GND (Pin 6)</b>. It stays <b>lit continuously</b> — there is
<b>no GPIO control, no switching, and no software</b>. The module is low-power, so continuous
operation produces <b>negligible heat</b> (harmless to leave on).</p>
</div>
<div class="notes" style="margin-top:14px">
<div class="note">
<h4>Wiring</h4>
<p>LED module <span class="inline-code">VCC → 5V (Pin 2)</span>, <span class="inline-code">GND → GND (Pin 6)</span>.
No signal pin — it is a bare power connection, always on.</p>
</div>
<div class="note">
<h4>No conflict with sensors</h4>
<p>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.</p>
</div>
<div class="note" style="grid-column:1 / -1">
<h4>NoIR camera + white light → pink tint</h4>
<p>A <b>NoIR</b> camera (no IR-cut filter) under white LED light renders the image with a
<b>pink/magenta tint</b>. This does <b>not</b> hinder leak identification (a wet patch is still
clearly visible). If you need <b>natural color</b>, use a <b>standard camera with a built-in
IR-cut filter</b> instead of the NoIR module.</p>
</div>
</div>
</section>
<!-- 8) 통합 노드 배선 (combined node master reference) -->
<section>
<h2><span class="num">8</span>통합 노드 배선 (누수 + 온습도 + 미세먼지 + 카메라 + 조명)</h2>
<p style="margin:0 0 16px;font-size:14px;color:#374151">
Pi-A (id 1) / Pi-C (id 3) are <b>combined nodes</b> carrying <b>all five loads on one Pi</b>:
leak sensor + SHT30 (온습도) + PMS7003 (미세먼지) + camera + always-on light. The three standalone
sensor sheets each independently claim <b>Pin 1 / Pin 2 / Pin 6</b>, which <b>physically collide</b>
on a shared board. <b>This single table is the de-conflicted master; wire the combined node from
here, not from the per-sensor sections above.</b>
</p>
<div class="crit">
<span class="badge">★ De-conflict · KEY FIX</span>
<h4>GND is common, but you cannot stack 4 wires on Pin 6</h4>
<p>Every device shares the same ground, but the Pi 4 exposes <b>8 separate GND pins
(6 · 9 · 14 · 20 · 25 · 30 · 34 · 39)</b>. Spread the four grounds across them, one per device.
Likewise there are <b>two 3V3 pins (1, 17)</b> and <b>two 5V pins (2, 4)</b>: give the leak sensor
Pin 1 and the SHT30 the <b>second</b> 3V3 (Pin 17); give the PMS7003 Pin 2 and the LED the
<b>second</b> 5V (Pin 4). Never double up two wires on a single rail pin.</p>
</div>
<table style="margin-top:16px">
<thead>
<tr><th>Device</th><th>Line</th><th class="mono">BCM / Rail</th><th class="mono">Physical Pin</th><th>Notes</th></tr>
</thead>
<tbody>
<!-- Leak -->
<tr>
<td rowspan="3"><b>누수 Leak</b><br><span style="color:var(--muted);font-size:12.5px">e-con 5C-08</span></td>
<td><span class="tag sig">Signal</span> Sensor signal</td>
<td class="mono">GPIO17</td>
<td class="mono">Pin 11</td>
<td>External <b>10MΩ pull-down</b> Pin 11 ↔ GND (required). Internal pull <b>OFF</b> (PUD_OFF).</td>
</tr>
<tr>
<td><span class="tag pwr">Power</span> VCC 3.3V</td>
<td class="mono">3V3</td>
<td class="mono">Pin 1</td>
<td>First 3V3 rail.</td>
</tr>
<tr>
<td><span class="tag gnd">Ground</span> GND</td>
<td class="mono">GND</td>
<td class="mono">Pin 20</td>
<td>Keep the high-impedance leak ground <b>short &amp; solid</b>. (alt: Pin 25)</td>
</tr>
<!-- SHT30 -->
<tr>
<td rowspan="4"><b>온습도 SHT30</b><br><span style="color:var(--muted);font-size:12.5px">I2C · 3.3V ONLY</span></td>
<td><span class="tag sig">I2C</span> SDA</td>
<td class="mono">GPIO2</td>
<td class="mono">Pin 3</td>
<td>I2C data (SDA1).</td>
</tr>
<tr>
<td><span class="tag sig">I2C</span> SCL</td>
<td class="mono">GPIO3</td>
<td class="mono">Pin 5</td>
<td>I2C clock (SCL1).</td>
</tr>
<tr>
<td><span class="tag pwr">Power</span> VCC 3.3V</td>
<td class="mono">3V3</td>
<td class="mono">Pin 17</td>
<td><b>SECOND 3V3, NOT Pin 1.</b> <b>3.3V only</b>, never 5V (5V damages the SHT30).</td>
</tr>
<tr>
<td><span class="tag gnd">Ground</span> GND</td>
<td class="mono">GND</td>
<td class="mono">Pin 9</td>
<td>I2C ground (adjacent to Pin 3 / 5).</td>
</tr>
<!-- PMS7003 -->
<tr>
<td rowspan="4"><b>미세먼지 PMS7003</b><br><span style="color:var(--muted);font-size:12.5px">UART</span></td>
<td><span class="tag sig">UART</span> PMS TXD → Pi RXD</td>
<td class="mono">GPIO15</td>
<td class="mono">Pin 10</td>
<td>PMS transmit into the Pi; the data line that must be present.</td>
</tr>
<tr>
<td><span class="tag sig">UART</span> Pi TXD → PMS RXD <em>(opt)</em></td>
<td class="mono">GPIO14</td>
<td class="mono">Pin 8</td>
<td>Optional: only for sleep/wake commands. Passive read does not need it.</td>
</tr>
<tr>
<td><span class="tag pwr">Power</span> VCC 5V</td>
<td class="mono">5V</td>
<td class="mono">Pin 2</td>
<td>PMS7003 runs on <b>5V</b> (its UART logic is 3.3V-safe for the Pi).</td>
</tr>
<tr>
<td><span class="tag gnd">Ground</span> GND</td>
<td class="mono">GND</td>
<td class="mono">Pin 6</td>
<td>UART ground.</td>
</tr>
<!-- Camera -->
<tr>
<td><b>카메라 Camera</b></td>
<td><span class="tag exp">CSI</span> Ribbon</td>
<td class="mono">n/a</td>
<td class="mono">CSI (separate)</td>
<td><b>Not on the 40-pin header.</b> 15-pin CSI ribbon into the camera connector (see §5).</td>
</tr>
<!-- LED -->
<tr>
<td rowspan="2"><b>조명 LED</b><br><span style="color:var(--muted);font-size:12.5px">always-on</span></td>
<td><span class="tag pwr">Power</span> VCC 5V</td>
<td class="mono">5V</td>
<td class="mono">Pin 4</td>
<td><b>SECOND 5V, NOT Pin 2.</b> Must be an LED <b>module with an integrated resistor</b>, or add
a <b>~150–330Ω series resistor</b> (a bare LED on 5V burns out).</td>
</tr>
<tr>
<td><span class="tag gnd">Ground</span> GND</td>
<td class="mono">GND</td>
<td class="mono">Pin 14</td>
<td>Always lit: no GPIO, no switching, no code.</td>
</tr>
</tbody>
<caption>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.</caption>
</table>
<h3>Boot configuration (Bookworm · idempotent via install.sh)</h3>
<div class="notes">
<div class="note">
<h4>I2C · SHT30</h4>
<p><span class="inline-code">dtparam=i2c_arm=on</span> in
<span class="inline-code">/boot/firmware/config.txt</span>. Verify with
<span class="inline-code">i2cdetect -y 1</span> (SHT30 at 0x44 / 0x45).</p>
</div>
<div class="note">
<h4>UART · PMS7003</h4>
<p><span class="inline-code">enable_uart=1</span> in
<span class="inline-code">config.txt</span>, and <b>remove</b>
<span class="inline-code">console=serial0,115200</span> from
<span class="inline-code">/boot/firmware/cmdline.txt</span> so the login console does not fight the sensor.</p>
</div>
<div class="note">
<h4>Camera</h4>
<p><span class="inline-code">camera_auto_detect=1</span> in
<span class="inline-code">config.txt</span>. Verify with
<span class="inline-code">rpicam-hello --list-cameras</span>.</p>
</div>
<div class="note">
<h4>Leak GPIO17 · unaffected</h4>
<p>Enabling UART / disabling the serial console does <b>not</b> affect the GPIO17 leak read or
the camera. GPIO17 is a plain input, independent of the serial peripheral.</p>
</div>
<div class="note" style="grid-column:1 / -1">
<h4>One reboot applies everything</h4>
<p>All three <span class="inline-code">install.sh</span> scripts set these <b>idempotently</b>
(I2C · UART + cmdline · camera). Run them, then a <b>single reboot</b> applies I2C, UART, and the
camera together (no per-sensor reboot needed).</p>
</div>
</div>
<h3>Power supply</h3>
<div class="note" style="border-left-color:var(--pwr)">
<h4>Use a genuine 5.1V / 3A USB-C PSU</h4>
<p>Combined draw of all five loads is <b>~1.4–1.8A typical, ~2.2A worst-case</b>, so the official
<b>5.1V / 3A</b> supply has ample headroom. <b>Avoid phone chargers and unpowered USB hubs</b>:
sag below ~4.8V triggers a brown-out → CPU throttle and <b>SD-card corruption</b>. Check health with
<span class="inline-code">vcgencmd get_throttled</span> (expect <span class="inline-code">0x0</span>;
any non-zero indicates under-voltage).</p>
</div>
</section>
</div>
</body>
</html>

View file

@ -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
# ─────────────────────────────────────────────

View file

@ -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

14
raspberry_pi4_pms7003/.gitignore vendored Normal file
View file

@ -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

View file

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

View file

@ -0,0 +1,535 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PM Node Wiring &amp; Pinmap — PMS7003 UART (sensor_id=3 / Pi-C)</title>
<style>
:root{
--ink:#1a1d21; --muted:#5c6570; --line:#dfe3e8; --bg:#eef1f4; --card:#ffffff;
--accent:#1971c2; --accent-d:#155a9c; --warn:#e03131; --warnbg:#fff5f5;
--ok:#2f9e44; --gnd:#343a40; --chip:#eef2f6; --code:#0b3d2e;
--mono:"D2Coding","Consolas","Menlo","Courier New",ui-monospace,monospace;
--sans:"Malgun Gothic","Apple SD Gothic Neo","Noto Sans KR","Segoe UI",system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{
font-family:var(--sans); color:var(--ink); background:var(--bg);
line-height:1.6; -webkit-font-smoothing:antialiased;
}
.wrap{max-width:1000px; margin:0 auto; padding:22px 18px 60px;}
/* ---------- Header ---------- */
.hero{
background:linear-gradient(135deg,#1f2933 0%,#283746 55%,#2f4a5a 100%);
color:#fff; border-radius:16px; padding:26px 28px;
box-shadow:0 8px 24px rgba(20,30,40,.18);
}
.hero .eyebrow{font-size:12.5px; letter-spacing:.14em; text-transform:uppercase;
color:#9fc3e0; font-weight:700; margin:0 0 6px;}
.hero h1{margin:0 0 8px; font-size:27px; line-height:1.25; font-weight:800;}
.hero p.sum{margin:0 0 16px; font-size:14.5px; color:#d7e2ec; max-width:72ch;}
.chips{display:flex; flex-wrap:wrap; gap:8px;}
.chip{
display:inline-flex; align-items:center; gap:6px;
background:rgba(255,255,255,.12); border:1px solid rgba(255,255,255,.22);
color:#eef5fb; font-size:12.5px; font-weight:600; padding:5px 11px; border-radius:999px;
}
.chip.mono{font-family:var(--mono); letter-spacing:.01em;}
.chip b{color:#fff;}
/* ---------- Sections ---------- */
section{
background:var(--card); border:1px solid var(--line); border-radius:14px;
padding:22px 24px; margin-top:20px; box-shadow:0 2px 10px rgba(20,30,40,.05);
}
h2{
display:flex; align-items:center; gap:11px; margin:0 0 16px;
font-size:19px; font-weight:800; color:var(--ink);
}
h2 .n{
flex:0 0 auto; width:30px; height:30px; border-radius:9px;
background:var(--accent); color:#fff; font-size:15px; font-weight:800;
display:flex; align-items:center; justify-content:center;
}
h2 small{font-weight:600; font-size:13px; color:var(--muted);}
/* ---------- Pinmap SVG ---------- */
.svg-pin{display:block; width:100%; max-width:460px; height:auto; margin:2px auto 0;}
.diagram-cap{text-align:center; color:var(--muted); font-size:12.5px; margin:12px 0 0;}
.legend{
display:flex; flex-wrap:wrap; justify-content:center; gap:12px 18px;
margin:16px auto 2px; max-width:640px;
}
.legend span{display:inline-flex; align-items:center; gap:7px; font-size:12.5px; color:var(--ink);}
.sw{width:15px; height:15px; border-radius:50%; flex:0 0 auto; border:1px solid rgba(0,0,0,.15);}
.sw.pwr{background:#2f9e44;} .sw.uart{background:#1971c2;}
.sw.gnd{background:#343a40;}
.sw.opt{background:#ced4da; border:1.5px dashed #868e96;}
.sw.off{background:#dee2e6;}
/* SVG element classes */
.p-pwr{fill:#2f9e44; stroke:#248a3b; stroke-width:1.5;}
.p-uart{fill:#1971c2; stroke:#155a9c; stroke-width:1.5;}
.p-gnd{fill:#343a40; stroke:#1f2429; stroke-width:1.5;}
.p-opt{fill:#ced4da; stroke:#868e96; stroke-width:1.5; stroke-dasharray:3 2;}
.p-off{fill:#dee2e6; stroke:#c3c9cf; stroke-width:1;}
.rail{fill:#f1f3f5; stroke:#ced4da; stroke-width:1.5;}
.pnum{font-family:var(--mono); font-size:12px; font-weight:700; text-anchor:middle; dominant-baseline:central;}
.pnum-l{fill:#ffffff;} .pnum-d{fill:#495057;}
.plbl{font-family:var(--mono); font-size:12px; dominant-baseline:central;}
.lbl-l{text-anchor:end;} .lbl-r{text-anchor:start;}
.lbl-used{fill:#1a1d21; font-weight:700;}
.lbl-alt{fill:#5c6570; font-weight:600;}
.lbl-off{fill:#adb5bd; font-weight:500;}
.cap-svg{font-family:var(--sans); font-size:12.5px; fill:#5c6570; font-weight:600; text-anchor:middle;}
/* ---------- Table ---------- */
table{width:100%; border-collapse:collapse; font-size:14px;}
thead th{
background:#f1f4f7; color:#2b3541; text-align:left; font-weight:700;
padding:10px 12px; border-bottom:2px solid #ccd4dc; font-size:13px;
}
tbody td{padding:11px 12px; border-bottom:1px solid var(--line); vertical-align:top;}
tbody tr:last-child td{border-bottom:none;}
.mono{font-family:var(--mono);}
.pinbadge{
display:inline-block; min-width:26px; text-align:center; font-family:var(--mono);
font-weight:700; font-size:13px; color:#fff; background:var(--accent);
border-radius:6px; padding:2px 7px;
}
.pinbadge.gnd{background:var(--gnd);} .pinbadge.pwr{background:var(--ok);}
.pinbadge.na{background:#adb5bd;}
td .warn-inline{color:var(--warn); font-weight:700;}
/* ---------- Schematic ---------- */
.svg-sch{display:block; width:100%; max-width:840px; height:auto; margin:0 auto;}
.box{fill:#f8f9fa; stroke:#495057; stroke-width:2; rx:8;}
.boxtitle{font-family:var(--sans); font-size:14px; font-weight:800; fill:#1a1d21; text-anchor:middle;}
.term{font-family:var(--mono); font-size:12.5px; font-weight:700; fill:#2b3541;}
.w-pwr{stroke:#e03131; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
.w-uart{stroke:#1971c2; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
.w-gnd{stroke:#343a40; stroke-width:3; fill:none; stroke-linecap:round; stroke-linejoin:round;}
.w-opt{stroke:#868e96; stroke-width:2.5; fill:none; stroke-dasharray:6 4; stroke-linecap:round;}
.wlbl{font-family:var(--sans); font-size:12px; font-weight:700; fill:#2b3541;}
.jn{fill:#212529;}
.sch-note{color:var(--muted); font-size:12.5px; text-align:center; margin:12px 0 0;}
/* ---------- Callouts ---------- */
.warn{
background:var(--warnbg); border:1.5px solid #ffc9c9; border-left:6px solid var(--warn);
border-radius:12px; padding:16px 18px; margin:0 0 16px; display:flex; gap:14px; align-items:flex-start;
}
.warn .ic{flex:0 0 auto; width:34px; height:34px;}
.warn h3{margin:0 0 4px; color:#c92a2a; font-size:16px; font-weight:800;}
.warn p{margin:0; font-size:14px; color:#5a2020;}
.warn b{color:#c92a2a;}
ul.notes{margin:6px 0 0; padding:0; list-style:none;}
ul.notes li{position:relative; padding:7px 0 7px 24px; border-bottom:1px dashed var(--line); font-size:14px;}
ul.notes li:last-child{border-bottom:none;}
ul.notes li::before{content:""; position:absolute; left:4px; top:14px; width:8px; height:8px;
background:var(--accent); border-radius:2px; transform:rotate(45deg);}
code, .kbd{font-family:var(--mono); background:#eef2f6; color:#0b3d2e; padding:1px 6px; border-radius:5px; font-size:13px;}
.cmd{
font-family:var(--mono); background:#12211b; color:#c8f7d8; border-radius:10px;
padding:12px 14px; font-size:13.5px; margin:12px 0 0; overflow-x:auto;
}
.cmd .cm{color:#7fd6a3;} .cmd .ok{color:#ffd43b;}
/* ---------- Checklist ---------- */
ol.check{margin:0; padding:0; list-style:none; counter-reset:ck;}
ol.check li{
counter-increment:ck; position:relative; display:flex; align-items:flex-start; gap:12px;
padding:11px 4px 11px 0; border-bottom:1px solid var(--line); font-size:14px;
}
ol.check li:last-child{border-bottom:none;}
ol.check li .num{
flex:0 0 auto; width:24px; height:24px; border-radius:50%; background:#e7eef5; color:#1f5f97;
font-weight:800; font-size:12.5px; display:flex; align-items:center; justify-content:center;
}
ol.check li .num::before{content:counter(ck);}
ol.check li .cbox{flex:0 0 auto; width:18px; height:18px; border:2px solid #9aa6b1; border-radius:4px; margin-top:1px;}
ol.check li .txt{flex:1;}
ol.check li .txt b{color:var(--warn);}
footer{margin-top:26px; text-align:center; color:var(--muted); font-size:12px;}
/* ---------- Print ---------- */
@media print{
body{background:#fff;}
.wrap{max-width:none; padding:0;}
.hero{background:#fff !important; color:#111; border:2px solid #111; box-shadow:none;}
.hero .eyebrow{color:#444;} .hero p.sum{color:#222;}
.chip{background:#f2f2f2; color:#111; border:1px solid #999;}
.chip b{color:#000;}
h2 .n{background:#111;}
section{box-shadow:none; border:1px solid #999; break-inside:avoid; page-break-inside:avoid;}
table, tr, td, th, .warn, .card{break-inside:avoid; page-break-inside:avoid;}
.svg-pin, .svg-sch{max-width:100%;}
.cmd{background:#f2f2f2; color:#111; border:1px solid #999;}
a{color:#000;}
}
@media (max-width:560px){
.hero h1{font-size:22px;} h2{font-size:17px;}
}
</style>
</head>
<body>
<div class="wrap">
<!-- ============ 1. HEADER ============ -->
<header class="hero">
<p class="eyebrow">Field Installation Doc · Wiring &amp; Pinmap</p>
<h1>Particulate-Matter Node — Site (Pi-C, sensor_id=3)</h1>
<p class="sum">
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 <b>10 minutes</b> under the same
<code>sensor_id</code> as that node's leak sensor; high-PM2.5/PM10 threshold decisions and SMS dispatch are handled by the server.
</p>
<div class="chips">
<span class="chip">Raspberry Pi 4 Model B</span>
<span class="chip mono">BCM2711</span>
<span class="chip">Raspberry Pi OS Bookworm 64-bit</span>
<span class="chip">Plantower PMS7003</span>
<span class="chip mono">UART <b>/dev/serial0</b></span>
<span class="chip mono">9600 8N1</span>
<span class="chip mono">metric_type=<b>pms7003</b></span>
<span class="chip">Report interval 10 min</span>
</div>
</header>
<!-- ============ 2. PINMAP SVG ============ -->
<section>
<h2><span class="n">2</span>40-Pin Header Pinmap Diagram <small>Used pins highlighted · others in gray</small></h2>
<svg class="svg-pin" viewBox="0 0 520 672" role="img" aria-label="Raspberry Pi 40-pin GPIO header pinmap">
<!-- caption -->
<text class="cap-svg" x="260" y="20">40-pin GPIO header (J8) · physical pin numbers</text>
<!-- rails -->
<rect class="rail" x="186" y="46" width="28" height="600" rx="14"/>
<rect class="rail" x="306" y="46" width="28" height="600" rx="14"/>
<!-- Row 1 : pin1 (square = pin1 marker) / pin2 (5V power = used) -->
<rect class="p-off" x="188" y="48" width="24" height="24" rx="5"/>
<text class="pnum pnum-d" x="200" y="60">1</text>
<text class="plbl lbl-l lbl-off" x="176" y="60">3V3</text>
<circle class="p-pwr" cx="320" cy="60" r="12"/>
<text class="pnum pnum-l" x="320" y="60">2</text>
<text class="plbl lbl-r lbl-used" x="344" y="60">5V (VCC)</text>
<!-- Row 2 : pin3 / pin4 -->
<circle class="p-off" cx="200" cy="90" r="12"/>
<text class="pnum pnum-d" x="200" y="90">3</text>
<text class="plbl lbl-l lbl-off" x="176" y="90">GPIO2 (SDA1)</text>
<circle class="p-off" cx="320" cy="90" r="12"/>
<text class="pnum pnum-d" x="320" y="90">4</text>
<text class="plbl lbl-r lbl-off" x="344" y="90">5V</text>
<!-- Row 3 : pin5 / pin6 (GND = used) -->
<circle class="p-off" cx="200" cy="120" r="12"/>
<text class="pnum pnum-d" x="200" y="120">5</text>
<text class="plbl lbl-l lbl-off" x="176" y="120">GPIO3 (SCL1)</text>
<circle class="p-gnd" cx="320" cy="120" r="12"/>
<text class="pnum pnum-l" x="320" y="120">6</text>
<text class="plbl lbl-r lbl-used" x="344" y="120">GND</text>
<!-- Row 4 : pin7 / pin8 (GPIO14 TXD = optional) -->
<circle class="p-off" cx="200" cy="150" r="12"/>
<text class="pnum pnum-d" x="200" y="150">7</text>
<text class="plbl lbl-l lbl-off" x="176" y="150">GPIO4</text>
<circle class="p-opt" cx="320" cy="150" r="12"/>
<text class="pnum pnum-d" x="320" y="150">8</text>
<text class="plbl lbl-r lbl-alt" x="344" y="150">GPIO14 (TXD)</text>
<!-- Row 5 : pin9 / pin10 (GPIO15 RXD = used, sensor data) -->
<circle class="p-off" cx="200" cy="180" r="12"/>
<text class="pnum pnum-d" x="200" y="180">9</text>
<text class="plbl lbl-l lbl-off" x="176" y="180">GND</text>
<circle class="p-uart" cx="320" cy="180" r="12"/>
<text class="pnum pnum-l" x="320" y="180">10</text>
<text class="plbl lbl-r lbl-used" x="344" y="180">GPIO15 (RXD)</text>
<!-- Row 6 : pin11 / pin12 -->
<circle class="p-off" cx="200" cy="210" r="12"/>
<text class="pnum pnum-d" x="200" y="210">11</text>
<text class="plbl lbl-l lbl-off" x="176" y="210">GPIO17</text>
<circle class="p-off" cx="320" cy="210" r="12"/>
<text class="pnum pnum-d" x="320" y="210">12</text>
<text class="plbl lbl-r lbl-off" x="344" y="210">GPIO18</text>
<!-- Row 7 : pin13 / pin14 -->
<circle class="p-off" cx="200" cy="240" r="12"/>
<text class="pnum pnum-d" x="200" y="240">13</text>
<text class="plbl lbl-l lbl-off" x="176" y="240">GPIO27</text>
<circle class="p-off" cx="320" cy="240" r="12"/>
<text class="pnum pnum-d" x="320" y="240">14</text>
<text class="plbl lbl-r lbl-off" x="344" y="240">GND</text>
<!-- Row 8 : pin15 / pin16 -->
<circle class="p-off" cx="200" cy="270" r="12"/>
<text class="pnum pnum-d" x="200" y="270">15</text>
<text class="plbl lbl-l lbl-off" x="176" y="270">GPIO22</text>
<circle class="p-off" cx="320" cy="270" r="12"/>
<text class="pnum pnum-d" x="320" y="270">16</text>
<text class="plbl lbl-r lbl-off" x="344" y="270">GPIO23</text>
<!-- Row 9 : pin17 / pin18 -->
<circle class="p-off" cx="200" cy="300" r="12"/>
<text class="pnum pnum-d" x="200" y="300">17</text>
<text class="plbl lbl-l lbl-off" x="176" y="300">3V3</text>
<circle class="p-off" cx="320" cy="300" r="12"/>
<text class="pnum pnum-d" x="320" y="300">18</text>
<text class="plbl lbl-r lbl-off" x="344" y="300">GPIO24</text>
<!-- Row 10 : pin19 / pin20 -->
<circle class="p-off" cx="200" cy="330" r="12"/>
<text class="pnum pnum-d" x="200" y="330">19</text>
<text class="plbl lbl-l lbl-off" x="176" y="330">GPIO10 (MOSI)</text>
<circle class="p-off" cx="320" cy="330" r="12"/>
<text class="pnum pnum-d" x="320" y="330">20</text>
<text class="plbl lbl-r lbl-off" x="344" y="330">GND</text>
<!-- Row 11 : pin21 / pin22 -->
<circle class="p-off" cx="200" cy="360" r="12"/>
<text class="pnum pnum-d" x="200" y="360">21</text>
<text class="plbl lbl-l lbl-off" x="176" y="360">GPIO9 (MISO)</text>
<circle class="p-off" cx="320" cy="360" r="12"/>
<text class="pnum pnum-d" x="320" y="360">22</text>
<text class="plbl lbl-r lbl-off" x="344" y="360">GPIO25</text>
<!-- Row 12 : pin23 / pin24 -->
<circle class="p-off" cx="200" cy="390" r="12"/>
<text class="pnum pnum-d" x="200" y="390">23</text>
<text class="plbl lbl-l lbl-off" x="176" y="390">GPIO11 (SCLK)</text>
<circle class="p-off" cx="320" cy="390" r="12"/>
<text class="pnum pnum-d" x="320" y="390">24</text>
<text class="plbl lbl-r lbl-off" x="344" y="390">GPIO8 (CE0)</text>
<!-- Row 13 : pin25 / pin26 -->
<circle class="p-off" cx="200" cy="420" r="12"/>
<text class="pnum pnum-d" x="200" y="420">25</text>
<text class="plbl lbl-l lbl-off" x="176" y="420">GND</text>
<circle class="p-off" cx="320" cy="420" r="12"/>
<text class="pnum pnum-d" x="320" y="420">26</text>
<text class="plbl lbl-r lbl-off" x="344" y="420">GPIO7 (CE1)</text>
<!-- Row 14 : pin27 / pin28 -->
<circle class="p-off" cx="200" cy="450" r="12"/>
<text class="pnum pnum-d" x="200" y="450">27</text>
<text class="plbl lbl-l lbl-off" x="176" y="450">GPIO0 (ID_SD)</text>
<circle class="p-off" cx="320" cy="450" r="12"/>
<text class="pnum pnum-d" x="320" y="450">28</text>
<text class="plbl lbl-r lbl-off" x="344" y="450">GPIO1 (ID_SC)</text>
<!-- Row 15 : pin29 / pin30 -->
<circle class="p-off" cx="200" cy="480" r="12"/>
<text class="pnum pnum-d" x="200" y="480">29</text>
<text class="plbl lbl-l lbl-off" x="176" y="480">GPIO5</text>
<circle class="p-off" cx="320" cy="480" r="12"/>
<text class="pnum pnum-d" x="320" y="480">30</text>
<text class="plbl lbl-r lbl-off" x="344" y="480">GND</text>
<!-- Row 16 : pin31 / pin32 -->
<circle class="p-off" cx="200" cy="510" r="12"/>
<text class="pnum pnum-d" x="200" y="510">31</text>
<text class="plbl lbl-l lbl-off" x="176" y="510">GPIO6</text>
<circle class="p-off" cx="320" cy="510" r="12"/>
<text class="pnum pnum-d" x="320" y="510">32</text>
<text class="plbl lbl-r lbl-off" x="344" y="510">GPIO12</text>
<!-- Row 17 : pin33 / pin34 -->
<circle class="p-off" cx="200" cy="540" r="12"/>
<text class="pnum pnum-d" x="200" y="540">33</text>
<text class="plbl lbl-l lbl-off" x="176" y="540">GPIO13</text>
<circle class="p-off" cx="320" cy="540" r="12"/>
<text class="pnum pnum-d" x="320" y="540">34</text>
<text class="plbl lbl-r lbl-off" x="344" y="540">GND</text>
<!-- Row 18 : pin35 / pin36 -->
<circle class="p-off" cx="200" cy="570" r="12"/>
<text class="pnum pnum-d" x="200" y="570">35</text>
<text class="plbl lbl-l lbl-off" x="176" y="570">GPIO19</text>
<circle class="p-off" cx="320" cy="570" r="12"/>
<text class="pnum pnum-d" x="320" y="570">36</text>
<text class="plbl lbl-r lbl-off" x="344" y="570">GPIO16</text>
<!-- Row 19 : pin37 / pin38 -->
<circle class="p-off" cx="200" cy="600" r="12"/>
<text class="pnum pnum-d" x="200" y="600">37</text>
<text class="plbl lbl-l lbl-off" x="176" y="600">GPIO26</text>
<circle class="p-off" cx="320" cy="600" r="12"/>
<text class="pnum pnum-d" x="320" y="600">38</text>
<text class="plbl lbl-r lbl-off" x="344" y="600">GPIO20</text>
<!-- Row 20 : pin39 / pin40 -->
<circle class="p-off" cx="200" cy="630" r="12"/>
<text class="pnum pnum-d" x="200" y="630">39</text>
<text class="plbl lbl-l lbl-off" x="176" y="630">GND</text>
<circle class="p-off" cx="320" cy="630" r="12"/>
<text class="pnum pnum-d" x="320" y="630">40</text>
<text class="plbl lbl-r lbl-off" x="344" y="630">GPIO21</text>
<!-- pin1 hint -->
<text class="cap-svg" x="260" y="662" style="font-size:11px;">[#] square marker = pin 1 (relative to board orientation)</text>
</svg>
<div class="legend">
<span><i class="sw pwr"></i>5V power &mdash; pin 2</span>
<span><i class="sw uart"></i>UART RXD (sensor data) &mdash; pin 10</span>
<span><i class="sw gnd"></i>GND &mdash; pin 6</span>
<span><i class="sw opt"></i>UART TXD (optional) &mdash; pin 8</span>
<span><i class="sw off"></i>Unused pins</span>
</div>
<p class="diagram-cap">The pin numbers and signals in this diagram match the standard Raspberry Pi 4 Model B (BCM2711) 40-pin header layout.</p>
</section>
<!-- ============ 3. PINMAP TABLE ============ -->
<section>
<h2><span class="n">3</span>Pinmap Table <small>PMS7003 &rarr; 40-pin header</small></h2>
<table>
<thead>
<tr><th style="width:30%">PMS7003 signal</th><th style="width:24%">Direction</th><th style="width:14%">Phys pin</th><th>Notes</th></tr>
</thead>
<tbody>
<tr>
<td><b>VCC</b> (power)</td>
<td class="mono">Pi &rarr; sensor</td>
<td><span class="pinbadge pwr">2</span></td>
<td><span class="warn-inline">* 5V required</span> &mdash; the PMS7003 fan + laser need 5V (pins 2&middot;4)</td>
</tr>
<tr>
<td><b>GND</b></td>
<td class="mono">common</td>
<td><span class="pinbadge gnd">6</span></td>
<td>Any GND works: <span class="mono">6 &middot; 9 &middot; 14 &middot; 20 &middot; 25 &middot; 30 &middot; 34 &middot; 39</span></td>
</tr>
<tr>
<td><b>TXD</b> (sensor data out)</td>
<td class="mono">sensor &rarr; Pi RXD</td>
<td><span class="pinbadge">10</span></td>
<td>GPIO15 (RXD). 3.3V logic &mdash; connects <b>directly</b>, no level shifter</td>
</tr>
<tr>
<td><b>RXD</b> (command in)</td>
<td class="mono">Pi TXD &rarr; sensor</td>
<td><span class="pinbadge na">8</span></td>
<td><b>Optional</b> &mdash; only for passive-mode/sleep commands. Active streaming needs only TXD</td>
</tr>
<tr>
<td><b>SET / RESET</b></td>
<td class="mono">&mdash;</td>
<td><span class="pinbadge na">n/c</span></td>
<td>Optional. Leave floating &mdash; internal pull-up keeps the sensor enabled/running</td>
</tr>
</tbody>
</table>
</section>
<!-- ============ 4. SCHEMATIC SVG ============ -->
<section>
<h2><span class="n">4</span>Wiring Schematic <small>sensor &harr; header</small></h2>
<svg class="svg-sch" viewBox="0 0 840 380" role="img" aria-label="PMS7003 wiring schematic">
<!-- component boxes -->
<rect class="box" x="70" y="90" width="160" height="250" rx="8"/>
<rect class="box" x="640" y="90" width="150" height="250" rx="8"/>
<text class="boxtitle" x="150" y="116">PMS7003 sensor</text>
<text class="boxtitle" x="715" y="116">Pi 40-pin header</text>
<!-- PMS7003 terminal labels (right side) -->
<text class="term" x="220" y="150" text-anchor="end">VCC</text>
<text class="term" x="220" y="205" text-anchor="end">GND</text>
<text class="term" x="220" y="260" text-anchor="end">TXD</text>
<text class="term" x="220" y="315" text-anchor="end">RXD</text>
<!-- Pi terminal labels (left side) -->
<text class="term" x="650" y="150">5V (pin 2)</text>
<text class="term" x="650" y="205">GND (pin 6)</text>
<text class="term" x="650" y="260">RXD (pin 10, GPIO15)</text>
<text class="term" x="650" y="315">TXD (pin 8, GPIO14)</text>
<!-- POWER wire -->
<path class="w-pwr" d="M230 150 H640"/>
<text class="wlbl" x="330" y="140" fill="#b02a2a">Power 5V</text>
<!-- GND wire -->
<path class="w-gnd" d="M230 205 H640"/>
<text class="wlbl" x="330" y="195" fill="#2b3541">Ground GND</text>
<!-- TXD (sensor) -> RXD (Pi) : the sensor data line -->
<path class="w-uart" d="M230 260 H640"/>
<text class="wlbl" x="300" y="250" fill="#155a9c">sensor TXD &rarr; Pi RXD (GPIO15)</text>
<!-- RXD (sensor) <- TXD (Pi) : optional command line -->
<path class="w-opt" d="M230 315 H640"/>
<text class="wlbl" x="300" y="333" fill="#5c6570">Pi TXD &rarr; sensor RXD (optional)</text>
</svg>
<p class="sch-note">
Active streaming mode uses only <b>VCC &middot; GND &middot; sensor TXD &rarr; Pi RXD</b>. The dashed Pi TXD &rarr; sensor RXD
line is optional (needed only to send passive-mode / sleep commands). The PMS7003 TXD is 3.3V logic,
so it wires <b>directly</b> to the Pi &mdash; no level shifter.
</p>
</section>
<!-- ============ 5. ELECTRICAL NOTES ============ -->
<section>
<h2><span class="n">5</span>Electrical Design &middot; Cautions</h2>
<div class="warn">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2 L23 21 H1 Z" fill="#ffe3e3" stroke="#e03131" stroke-width="1.6" stroke-linejoin="round"/>
<line x1="12" y1="9" x2="12" y2="15" stroke="#e03131" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="12" cy="18" r="1.4" fill="#e03131"/>
</svg>
<div>
<h3>Critical &mdash; power VCC from 5V, but NEVER wire the sensor TXD to a Pi 5V pin</h3>
<p>The PMS7003 <b>VCC must be 5V (pin 2 or 4)</b> &mdash; its fan and laser will not run on 3.3V. However the
UART data lines are <b>3.3V logic</b>: the sensor's TXD connects <b>directly</b> to the Pi RXD (pin 10).
<b>Do not connect any 5V line to a GPIO</b> &mdash; that would damage the 3.3V-only Pi GPIO. Double-check pin numbers before power-up.</p>
</div>
</div>
<ul class="notes">
<li><b>Enable the UART:</b> add <code>enable_uart=1</code> to <code>/boot/firmware/config.txt</code> (Bookworm) or use <code>raspi-config</code> &rarr; Interface Options &rarr; Serial Port.</li>
<li><b>Disable the serial login console:</b> answer "No" to the login-shell question in <code>raspi-config</code>, or remove <code>console=serial0,115200</code> from <code>cmdline.txt</code>. Otherwise the console fights the sensor for <code>/dev/serial0</code>. <code>install.sh</code> does both automatically.</li>
<li><b>Port:</b> the module reads <code>/dev/serial0</code> (alias to the primary UART; also <code>/dev/ttyS0</code> / <code>/dev/ttyAMA0</code>). 9600 baud, 8 data bits, no parity, 1 stop bit.</li>
<li><b>dialout group:</b> the service user needs the <code>dialout</code> group to open <code>/dev/serial0</code> (<code>systemd</code> unit sets <code>SupplementaryGroups=dialout</code>).</li>
<li><b>Warm-up:</b> 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.</li>
<li><b>Role split:</b> this node only reports PM1.0/PM2.5/PM10 every 10 minutes. High-PM2.5/PM10 <b>threshold decisions and SMS dispatch are done by the server</b>.</li>
<li><b>Config file:</b> <code>raspberry_pi4_pms7003/config.py</code> (<code>UART_PORT=/dev/serial0</code>, <code>UART_BAUD=9600</code>). Per-device values (SENSOR_ID 1 vs 3) are overridden in <code>config_local.py</code>.</li>
</ul>
<div class="cmd">
<span class="cm"># Verify the UART device exists (symlink to ttyS0 / ttyAMA0)</span>
$ ls -l /dev/serial0
lrwxrwxrwx 1 root root 5 ... /dev/serial0 -&gt; <span class="ok">ttyS0</span>
</div>
</section>
<!-- ============ 6. INSTALL CHECKLIST ============ -->
<section>
<h2><span class="n">6</span>Installation Checklist</h2>
<ol class="check">
<li><span class="num"></span><span class="cbox"></span><span class="txt">Do wiring work with <b>power disconnected</b> (no hot-plugging)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect VCC &rarr; physical pin 2 (5V) &mdash; the sensor fan/laser need <b>5V</b></span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect GND &rarr; physical pin 6 (or any other GND pin)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Connect sensor <b>TXD &rarr; Pi RXD (pin 10, GPIO15)</b> &mdash; this is the data line</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">(Optional) Pi TXD (pin 8) &rarr; sensor RXD, only if sending passive-mode commands</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Leave SET/RESET floating (sensor stays enabled/running)</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Re-check all wiring &mdash; confirm <b>no 5V line touches a GPIO</b> &mdash; then power up the Pi</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Enable UART (<code>enable_uart=1</code>) and <b>disable the serial console</b> (raspi-config / install.sh), then reboot</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Run <code>ls -l /dev/serial0</code> &rarr; confirm it symlinks to <code>ttyS0</code>/<code>ttyAMA0</code></span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Verify UART_PORT / UART_BAUD in config.py, and SENSOR_ID (1 or 3) in config_local.py</span></li>
<li><span class="num"></span><span class="cbox"></span><span class="txt">Check measurement log and server reception (10-min PM reporting OK)</span></li>
</ol>
</section>
<footer>
Particulate-Matter Node &middot; PMS7003 UART wiring doc (co-located on a leak node, sensor_id 1 or 3) &mdash; for on-site printing
</footer>
</div>
</body>
</html>

View file

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

View file

@ -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"

View file

@ -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 ""

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

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

View file

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

View file

@ -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."

View file

@ -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()

View file

@ -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 실패 주입 경로 드릴

38
sql/migration_pms7003.sql Normal file
View file

@ -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';

View file

@ -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,

100
tests/pm_threshold_test.php Normal file
View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
// PMS7003 미세먼지 임계 판정 회귀 테스트 (DB/네트워크 없이 순수 로직만).
// - evaluate_metric_thresholds(): PM2.5/PM10 고농도 판정(온습도와 독립, 후방호환 인자)
// - SMS_TAG_HIGH_PM: 미세먼지 경보 태그(단일 출처 상수)
// - sms_summary_select_sql(): 대시보드/월간보고 집계에 high_pm 컬럼 포함
// 임계값은 config.php 의 METRIC_PM*_HIGH 상수를 그대로 사용하며, 어떤 운영 임계 설정에서도
// 의미가 유지되도록 상대값(임계±여유)으로 검증한다(threshold_sms_test.php 와 동일 방침).
$root = dirname(__DIR__);
require_once $root . '/php/config.php'; // METRIC_PM*_HIGH / SMS_TAG_HIGH_PM / SMS_METRIC_TAGS
require_once $root . '/php/sms_send.php'; // evaluate_metric_thresholds()
$failures = [];
function check(bool $cond, string $msg): void {
global $failures;
if (!$cond) { $failures[] = $msg; }
}
function eq($a, $b, string $msg): void {
check($a === $b, $msg . ' (expected ' . var_export($b, true) . ', got ' . var_export($a, true) . ')');
}
// ── 상수/함수 존재 ─────────────────────────────────────────
check(function_exists('evaluate_metric_thresholds'), 'evaluate_metric_thresholds() should exist.');
check(defined('SMS_TAG_HIGH_PM'), 'SMS_TAG_HIGH_PM should be defined.');
eq(SMS_TAG_HIGH_PM, '[미세먼지경보]', 'SMS_TAG_HIGH_PM tag value');
check(defined('METRIC_PM25_HIGH'), 'METRIC_PM25_HIGH should be defined.');
check(defined('METRIC_PM10_HIGH'), 'METRIC_PM10_HIGH should be defined.');
check(defined('METRIC_PM_HYSTERESIS'), 'METRIC_PM_HYSTERESIS should be defined.');
$P25 = (float)METRIC_PM25_HIGH;
$P10 = (float)METRIC_PM10_HIGH;
check($P25 > 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";

View file

@ -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 가 치환)
```

View file

@ -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" <<CFGEOF
# =============================================================================
# config_local.py — migrate.sh 가 기존 누수 설정에서 자동 생성 (커밋 금지)
# 동일 sensor_id(${SENSOR_ID}) 통합: 미세먼지를 누수 센서와 같은 id 로 보고한다.
# =============================================================================
API_URL = "${API_URL}"
API_KEY = "${API_KEY}"
DEVICE_ID = "rpi4-pms7003-id${SENSOR_ID}"
DEVICE_LOCATION = "${DEVICE_LOCATION}" # 누수 config_local.py 와 문자열 동일(라벨 플랩 방지)
SENSOR_ID = ${SENSOR_ID} # ★ 누수와 동일 id
SENSOR_NAME = "Sensor ${SENSOR_ID} (PMS7003)"
REPORT_INTERVAL = 600 # 누수 노드 미세먼지 = 10분 주기
UART_PORT = "/dev/serial0" # PMS7003 TXD → Pi RXD(GPIO15, 물리 pin10)
CFGEOF
umask 022
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DEST_DIR}"
chmod 600 "${DEST_DIR}/config_local.py"
echo " config_local.py 생성 완료 (600, ${SERVICE_USER} 소유)"
# ── 4) install.sh 실행 (UART 활성화·venv·오프라인 패키지) + 서비스 등록 ──────
echo "[4/5] PMS7003 설치 스크립트 실행..."
# venv 소유권이 서비스 계정과 일치하도록 서비스 계정으로 실행(내부 sudo 는 NOPASSWD).
sudo -u "${SERVICE_USER}" env SERVICE_USER="${SERVICE_USER}" bash -c "cd '${DEST_DIR}' && bash install.sh"
# systemd 유닛: WorkingDirectory/ExecStart 를 실제 경로로 치환해 설치
sed -e "s|^WorkingDirectory=.*|WorkingDirectory=${DEST_DIR}|" \
-e "s|^ExecStart=.*|ExecStart=${DEST_DIR}/venv/bin/python pms7003_sensor.py|" \
-e "s|^User=.*|User=${SERVICE_USER}|" \
-e "s|^Group=.*|Group=${SERVICE_USER}|" \
"${DEST_DIR}/systemd/pms7003-sensor.service" > /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 ""

View file

@ -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 가 치환)
```

View file

@ -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" <<CFGEOF
# =============================================================================
# config_local.py — migrate.sh 가 기존 누수 설정에서 자동 생성 (커밋 금지)
# 동일 sensor_id(${SENSOR_ID}) 통합: 온습도를 누수 센서와 같은 id 로 보고한다.
# =============================================================================
API_URL = "${API_URL}"
API_KEY = "${API_KEY}"
DEVICE_ID = "rpi4-sht30-id${SENSOR_ID}"
DEVICE_LOCATION = "${DEVICE_LOCATION}" # 누수 config_local.py 와 문자열 동일(라벨 플랩 방지)
SENSOR_ID = ${SENSOR_ID} # ★ 누수와 동일 id
SENSOR_NAME = "Sensor ${SENSOR_ID} (SHT30)"
REPORT_INTERVAL = 600 # 누수 노드 온습도 = 10분 주기
CFGEOF
umask 022
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${DEST_DIR}"
chmod 600 "${DEST_DIR}/config_local.py"
echo " config_local.py 생성 완료 (600, ${SERVICE_USER} 소유)"
# ── 4) install.sh 실행 (I2C 활성화·venv·오프라인 패키지) + 서비스 등록 ───────
echo "[4/5] SHT30 설치 스크립트 실행..."
# venv 소유권이 서비스 계정과 일치하도록 서비스 계정으로 실행(내부 sudo 는 NOPASSWD).
sudo -u "${SERVICE_USER}" env SERVICE_USER="${SERVICE_USER}" bash -c "cd '${DEST_DIR}' && bash install.sh"
# systemd 유닛: WorkingDirectory/ExecStart 를 실제 경로로 치환해 설치
sed -e "s|^WorkingDirectory=.*|WorkingDirectory=${DEST_DIR}|" \
-e "s|^ExecStart=.*|ExecStart=${DEST_DIR}/venv/bin/python sht30_sensor.py|" \
-e "s|^User=.*|User=${SERVICE_USER}|" \
-e "s|^Group=.*|Group=${SERVICE_USER}|" \
"${DEST_DIR}/systemd/sht30-sensor.service" > /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 ""