센서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 커밋 차단
165 lines
7.5 KiB
Markdown
165 lines
7.5 KiB
Markdown
# 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).
|