#!/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, 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 # ───────────────────────────────────────────── def make_callback(sensor: dict): def callback(channel): gpio_val = GPIO.input(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) changed = state.set_leak(sensor["id"], is_leak) 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() 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 4.7MΩ 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()