Chapter 13. [종합] IoT 스마트 보안 시스템

지금까지 배운 모든 기술을 하나로 합칩니다. PIR 센서로 침입을 감지하고, 초음파 센서로 거리를 측정하며, LCD에 상태를 표시하고, 웹 서버를 통해 원격으로 경보를 해제하는 통합 시스템입니다.

Pico 2 W 스마트 보안 시스템 연결도 PIR 센서, 초음파 센서, I2C LCD, 부저, Wi-Fi 대시보드를 Pico 2 W에 연결한 구성도입니다. 입력 센서 제어 보드 출력 / 원격 제어 PIR 움직임 감지 OUT HC-SR04 Trig / Echo 거리 측정 TRIG/ECHO Pico 2 W MicroPython Web Server 센서 감지 + LCD 표시 + 경보 제어 GP18 PIR GP17/16 초음파 GP8/9 I2C GP14 부저 SECURE Dist: 35.2cm LCD 상태 표시 Buzzer 사이렌 Wi-Fi Dashboard ARM / DISARM / RESET 무선 제어 센서 입력 I2C 표시 경보 출력 Wi-Fi 원격 제어

초음파 센서는 GP17(Trig)과 GP16(Echo), LCD는 GP8/GP9 I2C, 부저는 GP14, PIR은 GP18을 사용합니다.

13.1 준비물과 연결 핀

아래 핀맵대로 연결하면 지금까지 배운 센서 입력, 출력 장치, 웹 제어가 한 프로젝트 안에서 함께 동작합니다.

부품 Pico 2 W 핀 역할
PIR 센서 OUT GP18 사람 움직임 감지
초음파 Trig / Echo GP17 / GP16 거리 측정
I2C LCD SDA / SCL GP8 / GP9 상태 메시지 표시
부저 + GP14 침입 감지 시 경보음

🧩 이 프로젝트는 어떻게 조립되었나요?

이번 종합 프로젝트는 지금까지 배운 4가지 기술을 하나의 while True 반복문 안에서 순서대로 실행하는 구조입니다. 각 부분이 어디서 왔는지 정리하면 다음과 같습니다.

  • PIR 센서 (Chapter 5): machine.Pin(18, machine.Pin.IN)으로 움직임을 감지합니다. 값이 1이면 사람이 움직였다는 뜻입니다.
  • 초음파 센서 HC-SR04 (Chapter 5): trig에 짧은 펄스를 보내고 echo가 반응하기까지 걸린 시간을 재서 거리(cm)로 환산합니다.
  • I2C LCD (Chapter 7/9): I2cLcd 객체로 현재 상태(정상/경고/Wi-Fi 정보)를 2줄짜리 화면에 표시합니다.
  • 웹 서버 (Chapter 10): socket 모듈로 80번 포트에서 요청을 받아, /arm, /disarm, /reset 경로에 따라 시스템 상태를 원격으로 바꿉니다.

전체 흐름은 "센서 읽기 → 판단 → LCD 갱신 → (있다면) 웹 요청 처리 → 잠깐 대기"를 계속 반복하는 것입니다. 이렇게 하나의 반복문 안에서 여러 작업을 짧게 나누어 처리하는 방식을 협력적 멀티태스킹(cooperative multitasking)이라고 부르며, 마이크로컨트롤러처럼 운영체제 없이 동작하는 환경에서 자주 쓰이는 패턴입니다.

server.settimeout(0.05)accept() 대기시간을 아주 짧게 제한한 점이 핵심입니다. 만약 이 타임아웃이 없었다면 웹 브라우저가 접속하지 않는 동안 accept()에서 코드가 멈춰버려(blocking) PIR/초음파 센서를 실시간으로 확인하지 못했을 것입니다.

13.2 통합 시스템 메인 소스 코드

이 코드는 Ch 10(Web), Ch 5(Sensors), Ch 9(LCD)의 내용을 실제로 합친 예시입니다. 브라우저에서 Pico의 IP 주소로 접속하면 보안 모드 켜기/끄기, 경보 초기화, 현재 거리와 움직임 상태를 확인할 수 있습니다.

import machine
import network
import socket
import time
from pico_i2c_lcd import I2cLcd

# Wi-Fi 설정
SSID = "YOUR_WIFI_NAME"
PASSWORD = "YOUR_WIFI_PASSWORD"

# 보안 기준값
DIST_LIMIT_CM = 20
BUZZER_TIME = 0.25

# 핀 설정
pir = machine.Pin(18, machine.Pin.IN)
trig = machine.Pin(17, machine.Pin.OUT)
echo = machine.Pin(16, machine.Pin.IN)
buzzer = machine.Pin(14, machine.Pin.OUT)

# LCD 설정
i2c = machine.I2C(0, sda=machine.Pin(8), scl=machine.Pin(9))
lcd = I2cLcd(i2c, 0x27, 2, 16)

is_armed = True
alarm_on = False
last_distance = 0
last_motion = 0

def lcd_message(line1, line2=""):
    lcd.clear()
    lcd.move_to(0, 0)
    lcd.putstr(line1[:16])
    lcd.move_to(0, 1)
    lcd.putstr(line2[:16])

def connect_wifi():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect(SSID, PASSWORD)
    lcd_message("Wi-Fi Connect", "Please wait...")

    for _ in range(20):
        if wlan.isconnected():
            ip = wlan.ifconfig()[0]
            lcd_message("Web Server IP", ip)
            print("Connected:", ip)
            return ip
        time.sleep(0.5)

    lcd_message("Wi-Fi Failed", "Check SSID/PW")
    raise RuntimeError("Wi-Fi connection failed")

def wait_pin_value(pin, target, timeout_us=30000):
    start = time.ticks_us()
    while pin.value() != target:
        if time.ticks_diff(time.ticks_us(), start) > timeout_us:
            return None
    return time.ticks_us()

def get_distance():
    trig.low()
    time.sleep_us(2)
    trig.high()
    time.sleep_us(10)
    trig.low()

    start = wait_pin_value(echo, 1)
    if start is None:
        return 999

    end = wait_pin_value(echo, 0)
    if end is None:
        return 999

    return (time.ticks_diff(end, start) * 0.0343) / 2

def beep(count=1):
    for _ in range(count):
        buzzer.high()
        time.sleep(BUZZER_TIME)
        buzzer.low()
        time.sleep(0.08)

def render_page():
    armed_text = "ON" if is_armed else "OFF"
    alarm_text = "DETECTED" if alarm_on else "CLEAR"
    motion_text = "YES" if last_motion else "NO"

    return f"""<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Pico Security</title>
    <style>
        body {{ font-family: Arial, sans-serif; max-width: 560px; margin: 40px auto; color: #0f172a; }}
        .box {{ border: 1px solid #e2e8f0; border-radius: 12px; padding: 20px; }}
        .danger {{ color: #dc2626; font-weight: bold; }}
        .ok {{ color: #16a34a; font-weight: bold; }}
        a {{ display: inline-block; margin: 6px; padding: 10px 14px; border-radius: 8px; background: #0ea5e9; color: white; text-decoration: none; }}
        a.warn {{ background: #f97316; }}
    </style>
</head>
<body>
    <h1>Pico 2 W Security</h1>
    <div class="box">
        <p>보안 모드: <strong>{armed_text}</strong></p>
        <p>경보 상태: <span class="{'danger' if alarm_on else 'ok'}">{alarm_text}</span></p>
        <p>움직임 감지: {motion_text}</p>
        <p>거리: {last_distance:.1f} cm</p>
        <a href="/arm">ARM</a>
        <a href="/disarm" class="warn">DISARM</a>
        <a href="/reset">RESET</a>
    </div>
</body>
</html>"""

def handle_request(request):
    global is_armed, alarm_on

    if "GET /arm" in request:
        is_armed = True
        lcd_message("Security Mode", "ARMED")
    elif "GET /disarm" in request:
        is_armed = False
        alarm_on = False
        buzzer.low()
        lcd_message("Security Mode", "DISARMED")
    elif "GET /reset" in request:
        alarm_on = False
        buzzer.low()
        lcd_message("Alarm Reset", "Monitoring...")

def start_server(ip):
    addr = socket.getaddrinfo("0.0.0.0", 80)[0][-1]
    server = socket.socket()
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(addr)
    server.listen(1)
    server.settimeout(0.05)
    print("Open browser: http://" + ip)
    return server

ip = connect_wifi()
server = start_server(ip)
lcd_message("System Ready", ip)

while True:
    last_motion = pir.value()
    last_distance = get_distance()

    if is_armed:
        if last_motion == 1 or last_distance < DIST_LIMIT_CM:
            alarm_on = True
            lcd_message("!! WARNING !!", "INTRUDER")
            beep(2)
        else:
            lcd.move_to(0, 0)
            lcd.putstr("Status: Secure  ")
            lcd.move_to(0, 1)
            lcd.putstr(f"Dist:{last_distance:5.1f}cm ")

    try:
        client, addr = server.accept()
        request = client.recv(1024).decode()
        handle_request(request)
        response = render_page()
        client.send("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n")
        client.send(response)
        client.close()
    except OSError:
        pass

    time.sleep(0.1)

💡 테스트 순서

먼저 SSIDPASSWORD를 수정한 뒤 실행하세요. LCD에 표시된 IP 주소를 같은 Wi-Fi에 연결된 컴퓨터나 스마트폰 브라우저에 입력하면 제어 화면이 열립니다.

13.3 동작 흐름

시스템은 0.1초 간격으로 PIR과 초음파 센서를 읽습니다. 보안 모드가 켜져 있고 움직임이 감지되거나 거리가 20cm보다 가까워지면 LCD에 경고를 띄우고 부저를 울립니다. 웹 대시보드에서 DISARM을 누르면 감지를 잠시 끄고, ARM을 누르면 다시 감시를 시작합니다.

🚀 확장 과제. Wi-Fi 알림 기능 추가하기

현재 시스템은 침입이 감지되면 LCD와 부저로만 알려줍니다. 만약 집에 아무도 없다면 이 알림을 놓칠 수 있습니다. 침입이 감지되는 순간, Adafruit IO(Chapter 11)의 feed에 "INTRUDER_ALERT" 메시지를 발행(publish)해서 스마트폰 대시보드로 실시간 알림을 받도록 코드를 확장해 보세요.

💻 정답 코드 보기
# 1. 파일 상단에 umqtt 관련 import와 설정을 추가합니다.
from umqtt.simple import MQTTClient

AIO_USER = "your_username"
AIO_KEY = "your_aio_key"
ALERT_FEED = AIO_USER + "/feeds/security-alert"

mqtt_client = MQTTClient("pico2-security-node", "io.adafruit.com", user=AIO_USER, password=AIO_KEY)
mqtt_client.connect()

# 2. 중복 알림을 막기 위한 플래그를 전역 변수로 추가합니다.
alert_sent = False

# 3. while True 반복문 안, 침입 감지 블록을 아래처럼 수정합니다.
if is_armed:
    if last_motion == 1 or last_distance < DIST_LIMIT_CM:
        alarm_on = True
        lcd_message("!! WARNING !!", "INTRUDER")
        beep(2)

        if not alert_sent:
            try:
                mqtt_client.publish(ALERT_FEED, "INTRUDER_ALERT")
                alert_sent = True
                print("Adafruit IO로 침입 알림을 발행했습니다.")
            except OSError as e:
                print("MQTT 발행 실패:", e)
    else:
        alert_sent = False   # 상태가 정상으로 돌아오면 다음 침입에 다시 알림 가능
        lcd.move_to(0, 0)
        lcd.putstr("Status: Secure  ")
        lcd.move_to(0, 1)
        lcd.putstr(f"Dist:{last_distance:5.1f}cm ")

13.4 Wokwi 시뮬레이션으로 실습하기

Chapter 1(1.8)에서 소개한 Wokwi 온라인 시뮬레이터에서 센서와 LCD 부분을 미리 연습해볼 수 있습니다.

⚠️ 웹 서버/Wi-Fi 부분은 시뮬레이터에서 테스트할 수 없어요

Wokwi 시뮬레이션 Pico 보드는 실제 인터넷에 연결되지 않으므로, network.WLAN 연결과 socket 웹 서버 부분은 동작을 확인할 수 없습니다. 대신 PIR 센서 + 초음파 센서 + LCD 조합의 감지·표시 로직은 Wokwi에서 그대로 실습할 수 있습니다. 완성된 코드는 반드시 실제 Pico 2 W와 Wi-Fi 환경에서 웹 서버까지 함께 테스트하세요.

아래 diagram.json을 Wokwi 프로젝트의 diagram 편집기에 붙여넣고, main.py에는 PIR·초음파·LCD 로직만 남긴 축소 버전(웹 서버 부분 제외)을 넣어 초록색 ▶️ 재생 버튼으로 실행해 보세요.

{
  "version": 1,
  "author": "Pico 2 Guide",
  "editor": "wokwi",
  "parts": [
    { "type": "wokwi-pi-pico", "id": "pico", "top": 0, "left": 0, "attrs": { "env": "micropython-20231005-v1.21.0" } },
    { "type": "wokwi-pir-motion-sensor", "id": "pir1", "top": -80, "left": -40, "attrs": {} },
    { "type": "wokwi-hc-sr04", "id": "us1", "top": -80, "left": 100, "attrs": {} },
    { "type": "wokwi-lcd1602", "id": "lcd1", "top": 60, "left": 40, "attrs": { "pins": "i2c" } }
  ],
  "connections": [
    [ "pico:GP18", "pir1:OUT", "green", [] ],
    [ "pir1:VCC", "pico:3V3", "red", [] ],
    [ "pir1:GND", "pico:GND", "black", [] ],
    [ "pico:GP17", "us1:TRIG", "blue", [] ],
    [ "pico:GP16", "us1:ECHO", "orange", [] ],
    [ "us1:VCC", "pico:VBUS", "red", [] ],
    [ "us1:GND", "pico:GND", "black", [] ],
    [ "pico:GP8", "lcd1:SDA", "green", [] ],
    [ "pico:GP9", "lcd1:SCL", "yellow", [] ],
    [ "lcd1:VCC", "pico:3V3", "red", [] ],
    [ "lcd1:GND", "pico:GND", "black", [] ]
  ]
}

💡 Tip: 정확한 부품 이름과 핀 속성은 Wokwi 편집기 좌측의 부품 검색창에서 다시 확인하는 것을 권장합니다. HC-SR04는 보통 5V 전원을 필요로 하므로 Wokwi에서도 VBUS(5V) 핀에 연결하는 것이 안전합니다.

🏁 완강을 축하드립니다!

이제 여러분은 하드웨어 제어, 통신, 클라우드 연동까지 가능한 Pico 2 마스터가 되었습니다. 이 가이드북의 기초를 바탕으로 여러분만의 더 멋진 발명품을 만들어보세요!