센서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 커밋 차단
574 lines
22 KiB
Python
574 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
# =============================================================================
|
|
# leak_sensor.py - e-con 5C-08 leak detection sensor monitoring main script (v2, Pi 4 only)
|
|
#
|
|
# [Target hardware] Raspberry Pi 4 Model B (BCM2711, 40-pin GPIO header)
|
|
# - Module originally for Raspberry Pi Zero 2 W, ported to Pi 4 Model B only.
|
|
# - GPIO BCM numbering and the rpicam/libcamera camera stack are identical on
|
|
# both boards, so the sensor/capture logic is unchanged. Only the wiring/
|
|
# power/Ethernet/camera-connector docs differ.
|
|
# (Pi 4 = 15-pin 1.0mm CSI / Zero 2 W = 22-pin 0.5mm CSI -> ribbon incompatible,
|
|
# Gigabit Ethernet, USB-C 5V/3A) -> see README.md.
|
|
#
|
|
# [Operation flow]
|
|
# 1. GPIO init -> configure sensor pins as input
|
|
# 2. POST current state to the PHP API periodically (REPORT_INTERVAL)
|
|
# 3. On leak-detection interrupt, POST an alert immediately
|
|
# 4. While the leak persists, re-report every LEAK_REPORT_INTERVAL
|
|
# 5. On recovery (normal) detection, POST a recovery alert
|
|
# 6. On leak detection, capture with the Pi Camera -> upload to photo_upload.php
|
|
# (even if capture/upload fails, the alert+SMS has already been sent)
|
|
#
|
|
# [Config/secrets] config.py holds only non-secret defaults; the server URL/API_KEY/
|
|
# device identifiers are loaded from config_local.py (not committed). If config_local.py
|
|
# is missing, startup aborts immediately with a clear error (fail closed).
|
|
# =============================================================================
|
|
|
|
import time
|
|
import threading
|
|
import logging
|
|
import logging.handlers
|
|
import hashlib
|
|
import hmac
|
|
import platform
|
|
import sys
|
|
import subprocess
|
|
import os
|
|
import tempfile
|
|
|
|
import requests
|
|
try:
|
|
import RPi.GPIO as GPIO
|
|
SIMULATION = False
|
|
except (ImportError, RuntimeError):
|
|
# Simulation mode when RPi.GPIO is unavailable (dev/test environment)
|
|
SIMULATION = True
|
|
print("[WARNING] RPi.GPIO not found -> running in simulation mode.")
|
|
|
|
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,
|
|
CAMERA_ENABLED, PHOTO_UPLOAD_URL,
|
|
PHOTO_WIDTH, PHOTO_HEIGHT, PHOTO_QUALITY,
|
|
)
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Logger setup
|
|
# ─────────────────────────────────────────────
|
|
def setup_logger():
|
|
logger = logging.getLogger("leak_sensor")
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
fmt = logging.Formatter(
|
|
"%(asctime)s [%(levelname)s] %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S"
|
|
)
|
|
|
|
# File handler (rotating)
|
|
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
|
|
|
|
# Console handler
|
|
ch = logging.StreamHandler(sys.stdout)
|
|
ch.setFormatter(fmt)
|
|
logger.addHandler(ch)
|
|
|
|
return logger
|
|
|
|
log = setup_logger()
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Simulation GPIO (for testing)
|
|
# ─────────────────────────────────────────────
|
|
class SimGPIO:
|
|
BCM = "BCM"
|
|
IN = "IN"
|
|
PUD_UP = "PUD_UP"
|
|
PUD_DOWN = "PUD_DOWN"
|
|
PUD_OFF = "PUD_OFF"
|
|
FALLING = "FALLING"
|
|
RISING = "RISING"
|
|
BOTH = "BOTH"
|
|
_state = {}
|
|
|
|
@staticmethod
|
|
def setmode(mode): pass
|
|
@staticmethod
|
|
def setup(pin, mode, pull_up_down=None): SimGPIO._state[pin] = 1
|
|
@staticmethod
|
|
def input(pin): return SimGPIO._state.get(pin, 1)
|
|
@staticmethod
|
|
def add_event_detect(pin, edge, callback=None, bouncetime=0): pass
|
|
@staticmethod
|
|
def cleanup(): pass
|
|
|
|
if SIMULATION:
|
|
GPIO = SimGPIO()
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Leak state management
|
|
# ─────────────────────────────────────────────
|
|
class SensorState:
|
|
def __init__(self):
|
|
self.lock = threading.Lock()
|
|
# Per-sensor state dictionary {sensor_id: bool}
|
|
self.leak_detected = {s["id"]: False for s in SENSORS}
|
|
self.last_report_time = {s["id"]: 0 for s in SENSORS}
|
|
|
|
def is_leaking(self, sensor_id):
|
|
with self.lock:
|
|
return self.leak_detected.get(sensor_id, False)
|
|
|
|
def any_leaking(self):
|
|
with self.lock:
|
|
return any(self.leak_detected.values())
|
|
|
|
def set_leak(self, sensor_id, value):
|
|
with self.lock:
|
|
changed = self.leak_detected.get(sensor_id) != value
|
|
self.leak_detected[sensor_id] = value
|
|
return changed
|
|
|
|
|
|
state = SensorState()
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# HTTP POST utilities
|
|
# ─────────────────────────────────────────────
|
|
# [Security note — raw-body HMAC signature (same scheme as sensor module 2)]
|
|
# The server (php/api/sensor_data.php + config.php::verify_signature_raw) verifies
|
|
# X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=raw_body)) over the
|
|
# received body bytes exactly as received. So the "signed bytes" and the "actually
|
|
# sent bytes" must be 100% identical. Letting requests' json= serialization handle it
|
|
# could change whitespace/ordering/float formatting and break the signature, so we
|
|
# build the body as a string ourselves, sign those bytes, and POST those same bytes
|
|
# as-is (data=body_bytes).
|
|
# (The old sha256(API_KEY + sorted JSON) body-field signature is retired — the server rejects it.)
|
|
def _esc(s: str) -> str:
|
|
r"""Escape a JSON string value (only '"' / '\\' / control chars <0x20). Multibyte left as-is."""
|
|
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 leak_event_body(event_type: str, sensor: dict, is_leak: bool, timestamp: int) -> str:
|
|
"""Build the leak-event body JSON string with a fixed field order (signature-compatible).
|
|
|
|
Field order (server parsing is order-agnostic, but the signature requires byte
|
|
identity, so it is fixed):
|
|
device_id, device_location, sensor_id, sensor_name, event_type, is_leak, timestamp
|
|
"""
|
|
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'"is_leak":{1 if is_leak else 0},'
|
|
f'"timestamp":{int(timestamp)}'
|
|
'}'
|
|
)
|
|
|
|
|
|
def sign_raw(body: str) -> str:
|
|
"""raw-body signature: lowercase_hex(HMAC-SHA256(key=API_KEY, msg=body bytes))."""
|
|
return hmac.new(API_KEY.encode("utf-8"), body.encode("utf-8"),
|
|
hashlib.sha256).hexdigest()
|
|
|
|
|
|
def _post_event(event_type: str, sensor: dict, is_leak: bool, want_response: bool):
|
|
"""POST a leak event with a raw-body HMAC signature.
|
|
|
|
Builds the body string, signs those bytes, and sends them as-is via data=body_bytes.
|
|
If want_response=False, returns success (bool); if True, returns the response JSON (dict) or None.
|
|
"""
|
|
body = leak_event_body(event_type, sensor, is_leak, int(time.time()))
|
|
body_bytes = body.encode("utf-8")
|
|
signature = sign_raw(body)
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"X-Device-Id": DEVICE_ID,
|
|
"X-Signature": signature,
|
|
"User-Agent": "LeakSensor/2.0",
|
|
}
|
|
|
|
for attempt in range(1, HTTP_RETRY_COUNT + 1):
|
|
try:
|
|
resp = requests.post(
|
|
API_URL,
|
|
data=body_bytes,
|
|
timeout=HTTP_TIMEOUT,
|
|
headers=headers,
|
|
)
|
|
if resp.status_code == 200:
|
|
log.info(f"[POST OK] sensor{sensor['id']} event={event_type} leak={is_leak} -> {resp.text[:80]}")
|
|
if not want_response:
|
|
return True
|
|
try:
|
|
return resp.json()
|
|
except ValueError:
|
|
log.warning("[POST] failed to parse response JSON")
|
|
return None
|
|
else:
|
|
log.warning(f"[POST {resp.status_code}] attempt {attempt}/{HTTP_RETRY_COUNT}: {resp.text[:120]}")
|
|
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}")
|
|
|
|
if attempt < HTTP_RETRY_COUNT:
|
|
time.sleep(HTTP_RETRY_DELAY)
|
|
|
|
log.error(f"[POST FAIL] all {HTTP_RETRY_COUNT} attempts failed. sensor{sensor['id']}")
|
|
return False if not want_response else None
|
|
|
|
|
|
def post_to_server(event_type: str, sensor: dict, is_leak: bool):
|
|
"""POST sensor data to the PHP API server (returns success as bool).
|
|
|
|
event_type: "periodic" | "startup" | "leak_alert" | "leak_recovery"
|
|
"""
|
|
return _post_event(event_type, sensor, is_leak, want_response=False)
|
|
|
|
|
|
def post_to_server_with_response(event_type: str, sensor: dict, is_leak: bool):
|
|
"""Same as post_to_server, but returns the response JSON (dict) or None."""
|
|
return _post_event(event_type, sensor, is_leak, want_response=True)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Camera capture / photo upload
|
|
# ─────────────────────────────────────────────
|
|
def capture_photo(output_path: str) -> bool:
|
|
"""
|
|
Capture a photo, preferring rpicam-still (Bookworm+), falling back to libcamera-still (Bullseye).
|
|
Returns True on success, False on failure.
|
|
"""
|
|
for cmd in ["rpicam-still", "libcamera-still"]:
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
cmd,
|
|
"--width", str(PHOTO_WIDTH),
|
|
"--height", str(PHOTO_HEIGHT),
|
|
"--quality", str(PHOTO_QUALITY),
|
|
"--nopreview",
|
|
"--timeout", "2000",
|
|
"-o", output_path,
|
|
],
|
|
capture_output=True, text=True, timeout=15
|
|
)
|
|
if result.returncode == 0 and os.path.exists(output_path):
|
|
file_size = os.path.getsize(output_path)
|
|
log.info(f"[CAMERA] capture succeeded: {output_path} ({file_size} bytes, cmd={cmd})")
|
|
return True
|
|
else:
|
|
log.warning(f"[CAMERA] {cmd} failed (rc={result.returncode}): {result.stderr[:200]}")
|
|
except FileNotFoundError:
|
|
continue # This command is missing -> try the next one
|
|
except subprocess.TimeoutExpired:
|
|
log.warning(f"[CAMERA] {cmd} timeout")
|
|
except Exception as e:
|
|
log.warning(f"[CAMERA] {cmd} exception: {e}")
|
|
|
|
log.error("[CAMERA] capture failed: neither rpicam-still nor libcamera-still available")
|
|
return False
|
|
|
|
|
|
def upload_photo(photo_path: str, log_id: int, sensor: dict) -> bool:
|
|
"""
|
|
multipart POST the captured photo to photo_upload.php.
|
|
Returns True on success, False on failure.
|
|
|
|
[Auth] X-Signature = lowercase_hex(HMAC-SHA256(key=API_KEY, msg=canonical))
|
|
canonical = f"{DEVICE_ID}|{sensor_id}|{log_id}|{timestamp}" (no whitespace)
|
|
Must be byte-identical to the canonical in the server's php/api/photo_upload.php.
|
|
"""
|
|
timestamp = int(time.time())
|
|
sensor_id = int(sensor["id"])
|
|
# Exact same string as the server: device_id|sensor_id|log_id|timestamp (no whitespace).
|
|
# The form fields must be sent in the same string representation to match the server's reconstructed canonical.
|
|
sensor_id_str = str(sensor_id)
|
|
log_id_str = str(log_id)
|
|
timestamp_str = str(timestamp)
|
|
canonical = f"{DEVICE_ID}|{sensor_id_str}|{log_id_str}|{timestamp_str}"
|
|
signature = hmac.new(API_KEY.encode("utf-8"), canonical.encode("utf-8"),
|
|
hashlib.sha256).hexdigest()
|
|
|
|
try:
|
|
with open(photo_path, "rb") as f:
|
|
files = {"photo": (os.path.basename(photo_path), f, "image/jpeg")}
|
|
form_data = {
|
|
"device_id": DEVICE_ID,
|
|
"sensor_id": sensor_id_str,
|
|
"log_id": log_id_str,
|
|
"timestamp": timestamp_str,
|
|
}
|
|
|
|
resp = requests.post(
|
|
PHOTO_UPLOAD_URL,
|
|
files=files,
|
|
data=form_data,
|
|
timeout=30,
|
|
headers={
|
|
"X-Device-Id": DEVICE_ID,
|
|
"X-Signature": signature,
|
|
"User-Agent": "LeakSensor/2.0",
|
|
}
|
|
)
|
|
|
|
if resp.status_code == 200:
|
|
log.info(f"[PHOTO UPLOAD OK] log_id={log_id} -> {resp.text[:80]}")
|
|
return True
|
|
else:
|
|
log.warning(f"[PHOTO UPLOAD {resp.status_code}] {resp.text[:120]}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
log.error(f"[PHOTO UPLOAD ERR] {e}")
|
|
return False
|
|
|
|
|
|
def _handle_leak_with_photo(sensor: dict):
|
|
"""
|
|
Processing order on leak detection:
|
|
1. POST to sensor_data.php -> receive log_id (+ send SMS)
|
|
2. Capture with camera (when CAMERA_ENABLED)
|
|
3. Upload to photo_upload.php
|
|
4. Delete temp file
|
|
Even if capture/upload fails, step 1 (alert+SMS) is already done.
|
|
"""
|
|
# 1) leak_alert POST -> receive log_id
|
|
resp_data = post_to_server_with_response("leak_alert", sensor, True)
|
|
if resp_data is None:
|
|
log.error(f"[PHOTO] sensor{sensor['id']} leak_alert POST failed -> skipping capture")
|
|
return
|
|
|
|
log_id = resp_data.get("log_id")
|
|
if not log_id:
|
|
log.warning(f"[PHOTO] sensor{sensor['id']} response has no log_id -> skipping capture")
|
|
return
|
|
|
|
# 2) Camera capture
|
|
if not CAMERA_ENABLED:
|
|
log.info(f"[PHOTO] camera disabled -> skipping capture (log_id={log_id})")
|
|
return
|
|
|
|
# Create a temp JPEG file in /tmp
|
|
fd, photo_path = tempfile.mkstemp(suffix=".jpg", prefix="leak_")
|
|
os.close(fd)
|
|
|
|
try:
|
|
if not capture_photo(photo_path):
|
|
log.warning(f"[PHOTO] capture failed -> skipping upload (log_id={log_id})")
|
|
return
|
|
|
|
# 3) Upload
|
|
upload_photo(photo_path, log_id, sensor)
|
|
|
|
finally:
|
|
# 4) Clean up temp file
|
|
try:
|
|
if os.path.exists(photo_path):
|
|
os.unlink(photo_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# 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):
|
|
candidate = _read_is_leak(sensor["gpio"])
|
|
|
|
# No actual change vs confirmed state -> nothing to verify
|
|
if candidate == state.is_leaking(sensor["id"]):
|
|
return
|
|
|
|
# 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"])
|
|
|
|
threading.Thread(
|
|
target=_confirm_state_change,
|
|
args=(sensor, candidate),
|
|
daemon=True
|
|
).start()
|
|
|
|
return callback
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Periodic reporting thread
|
|
# ─────────────────────────────────────────────
|
|
def periodic_reporter():
|
|
"""
|
|
Normal state: report every REPORT_INTERVAL
|
|
Leak state: repeat-report every LEAK_REPORT_INTERVAL (SMS resend suppression handled in PHP)
|
|
"""
|
|
log.info("[REPORTER] periodic reporting thread started")
|
|
last_sent = {s["id"]: 0 for s in SENSORS}
|
|
|
|
while True:
|
|
now = time.time()
|
|
for sensor in SENSORS:
|
|
sid = sensor["id"]
|
|
is_leak = state.is_leaking(sid)
|
|
interval = LEAK_REPORT_INTERVAL if is_leak else REPORT_INTERVAL
|
|
|
|
if now - last_sent[sid] >= interval:
|
|
post_to_server("periodic", sensor, is_leak)
|
|
last_sent[sid] = now
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# GPIO initialization
|
|
# ─────────────────────────────────────────────
|
|
def setup_gpio():
|
|
GPIO.setmode(GPIO.BCM)
|
|
|
|
# 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
|
|
|
|
for sensor in SENSORS:
|
|
gpio = sensor["gpio"]
|
|
GPIO.setup(gpio, GPIO.IN, pull_up_down=pull)
|
|
|
|
# Read initial state
|
|
gpio_val = GPIO.input(gpio)
|
|
is_leak = (gpio_val == 0) if ACTIVE_LOW else (gpio_val == 1)
|
|
state.set_leak(sensor["id"], is_leak)
|
|
log.info(f"[INIT] sensor{sensor['id']} GPIO{gpio} -> {'LEAK!' if is_leak else 'normal'}")
|
|
|
|
# Register the edge-detection interrupt
|
|
GPIO.add_event_detect(
|
|
gpio,
|
|
GPIO.BOTH,
|
|
callback=make_callback(sensor),
|
|
bouncetime=DEBOUNCE_MS
|
|
)
|
|
|
|
log.info(f"[INIT] GPIO setup complete. {len(SENSORS)} sensors total")
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Main
|
|
# ─────────────────────────────────────────────
|
|
def main():
|
|
log.info("=" * 60)
|
|
log.info(f"Leak detection system v2 (Pi 4) starting | device={DEVICE_ID} | location={DEVICE_LOCATION}")
|
|
log.info(f"platform={platform.machine()} | API={API_URL}")
|
|
log.info(f"camera={'enabled' if CAMERA_ENABLED else 'disabled'}")
|
|
log.info(f"sensors={[s['name'] for s in SENSORS]}")
|
|
log.info(f"mode={'simulation' if SIMULATION else 'real GPIO'}")
|
|
log.info("=" * 60)
|
|
|
|
try:
|
|
setup_gpio()
|
|
|
|
# Report current state immediately at startup
|
|
for sensor in SENSORS:
|
|
post_to_server("startup", sensor, state.is_leaking(sensor["id"]))
|
|
|
|
# Start the periodic reporting thread
|
|
reporter_thread = threading.Thread(target=periodic_reporter, daemon=True)
|
|
reporter_thread.start()
|
|
|
|
log.info("[MAIN] monitoring started. Press Ctrl+C to exit.")
|
|
|
|
# The main thread must stay alive for interrupts to work
|
|
while True:
|
|
time.sleep(1)
|
|
|
|
except KeyboardInterrupt:
|
|
log.info("[MAIN] terminated by user.")
|
|
except Exception as e:
|
|
log.exception(f"[MAIN] exception occurred: {e}")
|
|
raise
|
|
finally:
|
|
GPIO.cleanup()
|
|
log.info("[MAIN] GPIO cleanup complete.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|