홍쌤의 인포클래스AI + X · 기술 블로그

← 목록

[10/30 강의자료] Thonny로 세우는 가을 관측소: 라즈베리파이 피코 2 W 150분

작성 2026.10.27 · 홍창민

10월 30일 라즈베리파이 피코 2 W 수업(150분) 자료예요. Thonny 설치부터 센서, if 조건문, OLED, 구글 시트 전송, 그리고 시간이 되면 제미나이까지 이어집니다. 코드는 모두 MicroPython 인터프리터로 실행해 오류가 없는 것을 확인했어요. 코드 블록을 마우스로 드래그해서 복사한 뒤 Thonny에 붙여 넣으면 돼요.

오늘 완성할 가을 관측소의 흐름


오늘의 길 안내

시간 내용
0–20분 ① Thonny 설치 · 피코 연결
20–35분 ② 새 파일 만들기 · main.py
35–60분 ③ 온습도(DHT11) · 조도(KY-018) 센서 연결
60–70분 쉬는 시간
70–95분 ④ 센서 값에 따라 3색 LED · 부저 제어 (if)
95–115분 ⑤ OLED(128×32)에 센서 정보 띄우기
115–140분 ⑥ 구글 앱스 스크립트로 데이터 전송
140–150분 ⑦ 시간 되면: 제미나이 API (선생님 시연)

모둠당 준비물


① Thonny 설치하고 피코와 인사하기

비유: Thonny는 피코와 통하는 무전기예요. 컴퓨터에서 쓴 코드를 USB 선을 타고 피코에게 건네주고, 피코가 하는 말(print)을 받아 적어 줘요. 아래쪽 쉘 창이 피코의 목소리가 들리는 스피커예요.

1-1 Thonny 설치

  1. thonny.org 접속 → 내 운영체제(Windows / Mac / Linux) 버튼 클릭
  2. 설치 파일 실행 → 기본값 그대로 Next
  3. 처음 켤 때 언어(Language)에서 한국어를 고르면 메뉴가 한글로 나와요

이 자료는 2026년 4월에 나온 Thonny 5.0 기준이에요. 4.1 버전도 메뉴 위치는 같아요.

영어 메뉴 한국어 메뉴
Run current script 현재 스크립트 실행 (F5)
Stop/Restart backend 정지/재시작 (Ctrl+F2)
Configure interpreter... 인터프리터 환경설정...
View → Files 보기 → 파일
This computer 이 컴퓨터
Upload to / / 에 업로드

1-2 피코에 MicroPython 깔기 (처음 한 번만)

비유: MicroPython은 피코가 쓰는 말이에요. 갓 산 피코는 아직 파이썬을 못 알아들어요. 펌웨어를 한 번 깔아 주면 그때부터 우리 코드를 읽을 수 있어요.

  1. 피코의 하얀 BOOTSEL 버튼을 누른 채로 USB를 꽂고, 그다음 손을 떼요.
  2. 컴퓨터에 RP2350 드라이브가 나타나면 준비 완료.
  3. Thonny 오른쪽 아래 인터프리터 글자 클릭 → Install MicroPython...
  4. Target volume은 자동으로 RP2350, MicroPython family: RP2, variant: Raspberry Pi • Pico 2 W 선택
  5. Install → Done!이 뜨면 창 닫기. 피코가 저절로 다시 켜지고 드라이브는 사라져요(정상).

Install MicroPython... 메뉴가 안 보이면 피코가 BOOTSEL 모드가 아닌 거예요. USB를 뽑고 BOOTSEL을 누른 채 다시 꽂아 보세요. 수동으로 하려면 micropython.org의 RPI_PICO2_W 페이지에서 .uf2 파일을 받아 RP2350 드라이브에 끌어 넣으면 똑같아요.

1-3 피코 연결하고 첫 대화

Thonny 화면 구성 (예시 그림)

오른쪽 아래(⑤)에서 MicroPython (Raspberry Pi Pico) 를 고르면 쉘에 MicroPython v1.29.0 ... Raspberry Pi Pico 2 W with RP2350이 찍혀요. 쉘의 >>> 뒤에 print("안녕"), 1 + 2를 쳐 보세요. 포트가 여러 개면 인터프리터 환경설정에서 Board CDC가 들어간 포트를 고르세요.

# 파일: s1_hello.py
# [1] 피코야, 안녕? (Thonny 연결 확인)
# 부품 연결 없이 USB만 꽂고 실행하세요.
from machine import Pin
import time

led = Pin("LED", Pin.OUT)       # 보드에 붙어 있는 초록 LED (Pico W·2 W는 "LED")

print("안녕, 피코! 가을 관측소를 만들 준비가 됐어요.")
for i in range(5):
    led.on()
    print(i + 1, "번째 반짝")
    time.sleep(0.3)
    led.off()
    time.sleep(0.3)
print("끝! 아래 Shell에 글자가 보이면 연결 성공이에요.")

처음 F5를 누르면 저장 위치를 묻는 창이 뜰 수 있어요. Raspberry Pi Pico에 hello.py로 저장하세요. 보드 위 초록 LED가 5번 깜빡이면 성공!


② 새 파일 만들기와 main.py

비유: 피코의 책상 서랍. 피코 안에는 작은 서랍(플래시 4MB)이 있어서 파일을 넣어 두면 전원을 꺼도 남아 있어요. 그중 main.py라는 이름표가 붙은 파일은 피코가 아침에 일어나자마자(전원이 들어오면) 가장 먼저 꺼내 읽는 '오늘 할 일' 쪽지예요.

새 파일 → 피코에 저장

  1. 파일 → 새 (Ctrl+N)로 빈 탭 열기
  2. 코드 붙여 넣기
  3. 파일 → 저장 (Ctrl+S) → 저장 위치 창에서 Raspberry Pi Pico 클릭
  4. 파일 이름 입력 (예: light.py) → 확인
  5. F5로 실행, 멈출 때는 정지 (Ctrl+F2)

저장할 때 뜨는 창: 오늘은 항상 Raspberry Pi Pico 쪽

'이 컴퓨터'에 저장하면 피코 안에는 안 들어가요. 피코에서 import해야 하는 파일(config.py, 라이브러리)은 반드시 Raspberry Pi Pico 쪽에 저장하세요.

파일 창 열기: 보기 → 파일. 위쪽은 이 컴퓨터, 아래쪽은 Raspberry Pi Pico 안의 파일이에요. 피코 쪽 빈 곳을 오른쪽 클릭 → 새로운 디렉토리로 폴더를 만들 수 있어요.

오늘 수업이 끝났을 때 피코 안의 모습

# 파일: s2_main.py
# [2] main.py : 전원만 넣어도 혼자 움직이는 프로그램
# 이 파일을 피코에 'main.py' 라는 이름으로 저장하면,
# USB 보조배터리에 꽂기만 해도 자동으로 실행돼요.
from machine import Pin
import time

led = Pin("LED", Pin.OUT)
count = 0

print("main.py 시작! (멈추려면 Thonny의 정지 버튼)")
while True:
    led.toggle()                # 켜져 있으면 끄고, 꺼져 있으면 켜기
    count += 1
    if count % 10 == 0:
        print("심장 박동", count, "번")
    time.sleep(0.5)
  1. 위 코드를 Raspberry Pi Pico에 main.py라는 이름으로 저장
  2. Thonny의 정지 버튼 → USB를 뽑았다가 컴퓨터나 보조배터리에 다시 꽂기
  3. 아무것도 안 눌러도 초록 LED가 0.5초마다 깜빡이면 성공!

main.py 규칙

근거: MicroPython 공식 문서 'Reset and Boot Sequence'


③ 온습도 센서와 조도 센서 연결

핀맵: 피코의 40개 다리

Pico 2 W 핀아웃과 오늘 쓰는 핀 (공식 데이터시트 Figure 2 기준)

바깥 숫자는 물리 핀 번호(1~40), 이름표는 코드에 쓰는 GP 번호예요. 둘을 헷갈리는 게 첫 번째 단골 실수!

물리 핀 이름 오늘 연결할 것 단계
6번 GP4 OLED SDA (I2C0) ⑤
7번 GP5 OLED SCL (I2C0) ⑤
17번 GP13 3색 LED R ④
18번 GND 3색 LED − (공통) ④
19번 GP14 3색 LED G ④
20번 GP15 3색 LED B ④
21번 GP16 부저 + (S) ④
23번 GND 부저 − ④
29번 GP22 DHT11 S(DATA) ③
31번 GP26 · ADC0 조도 센서 KY-018 S ③
36번 3V3(OUT) + 레일 (센서·OLED 전원) 전원
38번 GND − 레일 (센서·OLED GND) 전원

먼저 전원 레일부터: 36번 → + 레일, 38번 → − 레일

꼭 지킬 안전 세 가지

비유: DHT11은 성실한 우체부. 피코가 "지금 몇 도야?" 하고 편지를 보내면 DHT11은 선 하나(S)로 답장을 보내 줘요. 다만 2초에 한 번만 배달해요.

비유: 조도 센서는 눈을 가늘게 뜨는 고양이. 빛이 많으면 저항이 작아지고 어두우면 커지는 부품(CdS)이에요. 피코의 ADC가 이 변화를 0~65535 숫자로 바꿔 줘요.

DHT11 배선: S → GP22(29번), + → 3V3 레일, − → GND 레일

KY-018 배선: S → GP26(31번), 가운데(+) → 3V3 레일, − → GND 레일

KY-018은 핀이 3개뿐이에요. 핀 옆 글씨(S, −)를 꼭 확인하세요. 보통 S가 한쪽 끝, −가 반대쪽 끝, 이름 없는 가운데가 +예요. 안에 10kΩ 저항이 함께 들어 있어서 빛에 따라 S 핀 전압이 바뀌어요. 참고로 KY-019는 5V 릴레이 모듈이라 다른 부품이에요.

ADC: 0~3.3V를 0~65535 숫자로

데이터시트 핵심값 DHT11 (Aosong) 조도 센서 모듈 KY-018
전원 3.3 ~ 5.5V 3.3 ~ 5V (피코에서는 3.3V)
측정 범위 습도 20~90%RH · 온도 0~50°C 밝을수록 S 전압이 낮아지고, 어두울수록 높아져요
정확도 습도 ±5%RH · 온도 ±2°C (25°C) 밝다/어둡다 비교용
측정 간격 2초 이상 (전원 켜고 1초 기다리기) 반응 20~30ms
주의 3.3V로 쓸 때는 선을 짧게 S는 반드시 ADC 핀(GP26·27·28)에
# 파일: s3_1_dht11.py
# [3-1] 온습도 센서(DHT11) 읽기
# 연결: S(DATA) → GP22, + → 3V3, − → GND
from machine import Pin
import dht
import time

sensor = dht.DHT11(Pin(22))
time.sleep(1)               # 전원 켜고 1초는 기다려 주기 (데이터시트)

while True:
    try:
        sensor.measure()    # "지금 재 주세요!"
        t = sensor.temperature()
        h = sensor.humidity()
        print("온도:", t, "°C   습도:", h, "%")
    except OSError:
        print("DHT11 응답 없음 → 선(S, +, −)을 확인하세요")
    time.sleep(2)           # DHT11은 2초에 한 번만 물어보기

손 가리기 실험 (2분): 아래 코드를 실행하고 센서를 손으로 덮었을 때 '밝기'가 작아지면 정상이에요. 오히려 커지면 INVERT = False로 바꾸세요. KY-018은 대부분 INVERT = True가 맞아요. 이 값은 이후 모든 코드에 그대로 써요.

# 파일: s3_2_light.py
# [3-2] 조도 센서(KY-018) 읽기
# 연결: S → GP26(ADC0), 가운데(+) → 3V3, − → GND
from machine import ADC, Pin
import time

light = ADC(Pin(26))

# KY-018은 어두울수록 숫자(raw)가 커져요. 그래서 INVERT = True 로 뒤집어 '밝기'로 만들어요.
# 확인: 손으로 가렸는데 '밝기'가 커지면 → INVERT = False 로 바꾸기
INVERT = True

def light_percent():
    total = 0
    for _ in range(8):      # 8번 재서 평균 → 숫자가 덜 흔들려요
        total += light.read_u16()
        time.sleep_ms(5)
    raw = total // 8        # 0 ~ 65535
    p = raw * 100 // 65535  # 0 ~ 100 %
    if INVERT:
        p = 100 - p
    return raw, p

while True:
    raw, p = light_percent()
    bar = "#" * (p // 5)    # 5% 마다 # 하나
    print("raw:", raw, " 밝기:", p, "%", bar)
    time.sleep(0.5)

Thonny 꿀팁: 보기 → 플로터(Plotter)를 켜면 쉘에 찍히는 숫자가 실시간 그래프로 그려져요. print(p)처럼 숫자만 찍게 한 줄 바꾸면 가장 깔끔해요.


④ 센서 값에 따라 3색 LED와 부저 제어하기 (if)

3색 LED 배선: R·G·B → GP13·14·15, − → GND

부저 배선: + (S) → GP16, − → GND

비유: 물감 세 통으로 가을 칠하기. 3색 LED 안에는 빨강·초록·파랑 작은 전구가 있어요. 빨강+초록 = 노랑(은행잎), 빨강+조금의 초록 = 주황(홍시).

비유: PWM은 빠른 눈 깜빡임. 1초에 1000번 껐다 켜면 눈은 "밝기"로 느껴요. 부저에서는 같은 PWM의 빠르기(주파수)가 음의 높이가 돼요.

PWM 듀티: 켜진 시간 비율 = 밝기

# 파일: s4_1_test.py
# [4-1] 3색 LED와 부저 점검
# 연결: LED R → GP13, G → GP14, B → GP15, − → GND / 부저 S(+) → GP16, − → GND
from machine import Pin, PWM
import time

red, green, blue = PWM(Pin(13)), PWM(Pin(14)), PWM(Pin(15))
for led in (red, green, blue):
    led.freq(1000)              # 1초에 1000번 깜빡 → 눈에는 '밝기'로 보여요
buzzer = PWM(Pin(16))
buzzer.duty_u16(0)

def color(r, g, b):
    """0~255 세 숫자로 색 섞기 (255 × 257 = 65535)"""
    red.duty_u16(r * 257)
    green.duty_u16(g * 257)
    blue.duty_u16(b * 257)

def beep(freq, ms):
    """freq(Hz) 높이의 소리를 ms 동안"""
    buzzer.freq(freq)
    buzzer.duty_u16(32768)      # 50% = 소리 ON
    time.sleep_ms(ms)
    buzzer.duty_u16(0)          # 0% = 소리 OFF
    time.sleep_ms(30)

for name, rgb in [("단풍 빨강", (255, 0, 0)), ("은행 노랑", (255, 150, 0)),
                  ("가을 하늘", (0, 80, 255)), ("소나무 초록", (0, 255, 0))]:
    print(name)
    color(*rgb)
    beep(523, 120)              # 높은 도
    time.sleep(0.8)

for f in [262, 294, 330, 349, 392, 440, 494, 523]:   # 도레미파솔라시도
    beep(f, 200)
color(0, 0, 0)
print("점검 끝!")

if: 조건이 맞으면 하고, 아니면 다른 걸 해

비유: 위에서부터 거르는 체. if–elif–else는 구멍 크기가 다른 체를 겹쳐 둔 것과 같아요. 낙엽이 처음 걸린 체에서 멈추고 아래 체는 보지도 않아요. 그래서 순서가 곧 우선순위. 들여쓰기(스페이스 4칸)가 "이 줄은 if에 속해요"라는 표시예요.

# 파일: s4_2_if_light.py
# [4-2] 첫 번째 if : 어두우면 가로등 켜기
# 연결: 조도(KY-018) S → GP26 / LED R·G·B → GP13·14·15
from machine import Pin, PWM, ADC
import time

light = ADC(Pin(26))
INVERT = True                   # 3-2에서 정한 값 그대로

red, green, blue = PWM(Pin(13)), PWM(Pin(14)), PWM(Pin(15))
for led in (red, green, blue):
    led.freq(1000)

def color(r, g, b):
    red.duty_u16(r * 257)
    green.duty_u16(g * 257)
    blue.duty_u16(b * 257)

def light_percent():
    total = 0
    for _ in range(8):
        total += light.read_u16()
        time.sleep_ms(5)
    p = (total // 8) * 100 // 65535
    return 100 - p if INVERT else p

while True:
    p = light_percent()
    if p < 30:                  # 조건이 참이면 ↓ 들여쓴 줄 실행
        color(255, 60, 0)       # 어두움 → 감 주황 가로등
        print(p, "% 어두워요 → 가로등 ON")
    else:                       # 조건이 거짓이면 ↓ 실행
        color(0, 0, 0)
        print(p, "% 밝아요 → 가로등 OFF")
    time.sleep(0.5)

불쾌지수(DI): 기온(T, °C)과 상대습도(H, %)로 사람이 느끼는 끈적함을 나타낸 지수예요(Thom, 1959). DI = 0.81T + 0.01H(0.99T − 14.3) + 46.3. 보통 68 미만 쾌적, 68~75 일부 불쾌, 75~80 절반 정도 불쾌, 80 이상 대부분 불쾌. 30°C·70%면 81.4!

4-3의 판단 순서

# 파일: s4_3_rules.py
# [4-3] if · elif · else : 관측소가 스스로 판단하기
# 연결: LED GP13·14·15 / 부저 GP16 / DHT11 GP22 / 조도 GP26
from machine import Pin, PWM, ADC
import dht
import time

sensor = dht.DHT11(Pin(22))
light = ADC(Pin(26))
INVERT = True

red, green, blue = PWM(Pin(13)), PWM(Pin(14)), PWM(Pin(15))
for led in (red, green, blue):
    led.freq(1000)
buzzer = PWM(Pin(16))
buzzer.duty_u16(0)

def color(r, g, b):
    red.duty_u16(r * 257)
    green.duty_u16(g * 257)
    blue.duty_u16(b * 257)

def beep(freq, ms):
    buzzer.freq(freq)
    buzzer.duty_u16(32768)
    time.sleep_ms(ms)
    buzzer.duty_u16(0)
    time.sleep_ms(30)

def light_percent():
    total = 0
    for _ in range(8):
        total += light.read_u16()
        time.sleep_ms(5)
    p = (total // 8) * 100 // 65535
    return 100 - p if INVERT else p

def discomfort(t, h):
    """불쾌지수 = 0.81T + 0.01H(0.99T - 14.3) + 46.3"""
    return 0.81 * t + 0.01 * h * (0.99 * t - 14.3) + 46.3

def judge(t, h, p):
    """위에서부터 차례로 검사 → 처음 맞는 칸에서 멈춰요"""
    if t is None:
        return "SENSOR?", (120, 0, 160)   # 센서 확인 → 보라
    di = discomfort(t, h)
    if p < 25:
        return "NIGHT", (60, 15, 0)       # 어두움 → 은은한 가로등
    elif di >= 75:
        return "STUFFY", (255, 0, 0)      # 후텁지근 → 빨강
    elif h >= 70:
        return "HUMID", (0, 80, 255)      # 축축 → 파랑
    elif t <= 15:
        return "CHILLY", (255, 60, 0)     # 쌀쌀 → 감 주황
    else:
        return "COZY", (0, 255, 0)        # 딱 좋은 가을 → 초록

last_state = ""
time.sleep(1)
while True:
    try:
        sensor.measure()
        t, h = sensor.temperature(), sensor.humidity()
    except OSError:
        t = h = None
    p = light_percent()
    state, rgb = judge(t, h, p)
    color(*rgb)

    if state != last_state:               # 상태가 '바뀔 때만' 소리
        if state == "STUFFY":
            for _ in range(3):
                beep(4000, 40)            # 귀뚜라미 경보
        else:
            beep(880, 80)
        print("상태 변화:", last_state, "→", state)
        last_state = state
    print("온도", t, " 습도", h, " 밝기", p, "% →", state)
    time.sleep(2)                         # DHT11은 2초에 한 번

last_state에 직전 상태를 기억하고 바뀔 때만 소리를 내요. 2초마다 "삐!"가 울리면 아무도 이 관측소를 켜 두지 않겠죠.

더 해 보기: 기준값 75, 70, 15, 25를 우리 반 친구 5명에게 물어 다시 정하기 · 색약 친구도 알 수 있게 색 + 깜빡임 횟수로 표현 · 밝기가 24↔26%를 오가며 깜빡이는 문제 해결하기(들어갈 때 25%, 나올 때 35%)


⑤ OLED(128×32)에 센서 정보 띄우기

비유: 그리고, 한꺼번에 보여 주기. OLED는 칠판이에요. fill(0)으로 지우고, text()로 쓰고, 마지막에 show()를 해야 한 번에 보여요.

비유: I2C는 한 전깃줄에 걸린 우편함들. SDA(데이터)와 SCL(박자) 두 선에 여러 장치를 매달 수 있고, 이 OLED의 주소는 0x3C예요.

5-1 OLED 라이브러리를 피코에 넣기

  1. 보기 → 파일 창 → 아래쪽 Raspberry Pi Pico 빈 곳 오른쪽 클릭 → 새로운 디렉토리 → 이름 lib
  2. 파일 → 새 → 아래 ssd1306.py 전체를 붙여 넣기
  3. 저장 → Raspberry Pi Pico → lib 폴더를 더블클릭해서 들어간 뒤 → 이름 ssd1306.py
  4. 쉘에서 import ssd1306을 쳤을 때 아무 말이 없으면 성공

ssd1306.py (MicroPython 공식 라이브러리 · lib/ssd1306.py로 저장 · 실행하지 않아요)

# 파일: ssd1306.py
# MicroPython SSD1306 OLED driver, I2C and SPI interfaces

from micropython import const
import framebuf


# register definitions
SET_CONTRAST = const(0x81)
SET_ENTIRE_ON = const(0xA4)
SET_NORM_INV = const(0xA6)
SET_DISP = const(0xAE)
SET_MEM_ADDR = const(0x20)
SET_COL_ADDR = const(0x21)
SET_PAGE_ADDR = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP = const(0xA0)
SET_MUX_RATIO = const(0xA8)
SET_IREF_SELECT = const(0xAD)
SET_COM_OUT_DIR = const(0xC0)
SET_DISP_OFFSET = const(0xD3)
SET_COM_PIN_CFG = const(0xDA)
SET_DISP_CLK_DIV = const(0xD5)
SET_PRECHARGE = const(0xD9)
SET_VCOM_DESEL = const(0xDB)
SET_CHARGE_PUMP = const(0x8D)


# Subclassing FrameBuffer provides support for graphics primitives
# http://docs.micropython.org/en/latest/pyboard/library/framebuf.html
class SSD1306(framebuf.FrameBuffer):
    def __init__(self, width, height, external_vcc):
        self.width = width
        self.height = height
        self.external_vcc = external_vcc
        self.pages = self.height // 8
        self.buffer = bytearray(self.pages * self.width)
        super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
        self.init_display()

    def init_display(self):
        for cmd in (
            SET_DISP,  # display off
            # address setting
            SET_MEM_ADDR,
            0x00,  # horizontal
            # resolution and layout
            SET_DISP_START_LINE,  # start at line 0
            SET_SEG_REMAP | 0x01,  # column addr 127 mapped to SEG0
            SET_MUX_RATIO,
            self.height - 1,
            SET_COM_OUT_DIR | 0x08,  # scan from COM[N] to COM0
            SET_DISP_OFFSET,
            0x00,
            SET_COM_PIN_CFG,
            0x02 if self.width > 2 * self.height else 0x12,
            # timing and driving scheme
            SET_DISP_CLK_DIV,
            0x80,
            SET_PRECHARGE,
            0x22 if self.external_vcc else 0xF1,
            SET_VCOM_DESEL,
            0x30,  # 0.83*Vcc
            # display
            SET_CONTRAST,
            0xFF,  # maximum
            SET_ENTIRE_ON,  # output follows RAM contents
            SET_NORM_INV,  # not inverted
            SET_IREF_SELECT,
            0x30,  # enable internal IREF during display on
            # charge pump
            SET_CHARGE_PUMP,
            0x10 if self.external_vcc else 0x14,
            SET_DISP | 0x01,  # display on
        ):  # on
            self.write_cmd(cmd)
        self.fill(0)
        self.show()

    def poweroff(self):
        self.write_cmd(SET_DISP)

    def poweron(self):
        self.write_cmd(SET_DISP | 0x01)

    def contrast(self, contrast):
        self.write_cmd(SET_CONTRAST)
        self.write_cmd(contrast)

    def invert(self, invert):
        self.write_cmd(SET_NORM_INV | (invert & 1))

    def rotate(self, rotate):
        self.write_cmd(SET_COM_OUT_DIR | ((rotate & 1) << 3))
        self.write_cmd(SET_SEG_REMAP | (rotate & 1))

    def show(self):
        x0 = 0
        x1 = self.width - 1
        if self.width != 128:
            # narrow displays use centred columns
            col_offset = (128 - self.width) // 2
            x0 += col_offset
            x1 += col_offset
        self.write_cmd(SET_COL_ADDR)
        self.write_cmd(x0)
        self.write_cmd(x1)
        self.write_cmd(SET_PAGE_ADDR)
        self.write_cmd(0)
        self.write_cmd(self.pages - 1)
        self.write_data(self.buffer)


class SSD1306_I2C(SSD1306):
    def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False):
        self.i2c = i2c
        self.addr = addr
        self.temp = bytearray(2)
        self.write_list = [b"\x40", None]  # Co=0, D/C#=1
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.temp[0] = 0x80  # Co=1, D/C#=0
        self.temp[1] = cmd
        self.i2c.writeto(self.addr, self.temp)

    def write_data(self, buf):
        self.write_list[1] = buf
        self.i2c.writevto(self.addr, self.write_list)


class SSD1306_SPI(SSD1306):
    def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
        self.rate = 10 * 1024 * 1024
        dc.init(dc.OUT, value=0)
        res.init(res.OUT, value=0)
        cs.init(cs.OUT, value=1)
        self.spi = spi
        self.dc = dc
        self.res = res
        self.cs = cs
        import time

        self.res(1)
        time.sleep_ms(1)
        self.res(0)
        time.sleep_ms(10)
        self.res(1)
        super().__init__(width, height, external_vcc)

    def write_cmd(self, cmd):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs(1)
        self.dc(0)
        self.cs(0)
        self.spi.write(bytearray([cmd]))
        self.cs(1)

    def write_data(self, buf):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs(1)
        self.dc(1)
        self.cs(0)
        self.spi.write(buf)
        self.cs(1)

이 파일은 MicroPython 공식 저장소 micropython-lib의 ssd1306.py와 같아요. Wi-Fi가 연결된 피코라면 쉘에서 import mip → mip.install("ssd1306")으로도 설치할 수 있어요.

5-2 연결하고 띄우기

OLED 배선: SDA → GP4(6번), SCL → GP5(7번), VCC → 3V3 레일, GND → GND 레일

모듈마다 VCC-GND 순서가 다르니 글씨를 보고 꽂으세요. 거꾸로 꽂으면 OLED가 망가질 수 있어요.

128×32 화면 좌표: 글자 8×8픽셀, 한 줄에 16글자, 딱 4줄

# 파일: s5_oled.py
# [5] OLED에 관측 정보 띄우기  (먼저 lib/ssd1306.py 를 피코에 저장!)
# 연결: OLED(128×32) SDA → GP4, SCL → GP5, VCC → 3V3, GND → GND (+ 4단계 부품 그대로)
from machine import Pin, PWM, I2C, ADC
import ssd1306
import dht
import time

i2c = I2C(0, sda=Pin(4), scl=Pin(5), freq=400000)
print("I2C 주소:", [hex(a) for a in i2c.scan()])   # ['0x3c'] 가 보이면 성공
oled = ssd1306.SSD1306_I2C(128, 32, i2c)   # 우리 화면은 128×32

sensor = dht.DHT11(Pin(22))
light = ADC(Pin(26))
INVERT = True
red, green, blue = PWM(Pin(13)), PWM(Pin(14)), PWM(Pin(15))
for led in (red, green, blue):
    led.freq(1000)
buzzer = PWM(Pin(16))
buzzer.duty_u16(0)

def color(r, g, b):
    red.duty_u16(r * 257)
    green.duty_u16(g * 257)
    blue.duty_u16(b * 257)

def beep(freq, ms):
    buzzer.freq(freq)
    buzzer.duty_u16(32768)
    time.sleep_ms(ms)
    buzzer.duty_u16(0)
    time.sleep_ms(30)

def light_percent():
    total = 0
    for _ in range(8):
        total += light.read_u16()
        time.sleep_ms(5)
    p = (total // 8) * 100 // 65535
    return 100 - p if INVERT else p

def discomfort(t, h):
    return 0.81 * t + 0.01 * h * (0.99 * t - 14.3) + 46.3

def judge(t, h, p):
    if t is None:
        return "SENSOR?", (120, 0, 160)
    di = discomfort(t, h)
    if p < 25:
        return "NIGHT", (60, 15, 0)
    elif di >= 75:
        return "STUFFY", (255, 0, 0)
    elif h >= 70:
        return "HUMID", (0, 80, 255)
    elif t <= 15:
        return "CHILLY", (255, 60, 0)
    else:
        return "COZY", (0, 255, 0)

LEAF = ["00010000",                       # 8×8 단풍잎 (1 = 켜기)
        "01010100",
        "00111000",
        "11111110",
        "01111100",
        "00111000",
        "00010000",
        "00010000"]

def draw_icon(icon, x, y, c):
    for row, bits in enumerate(icon):
        for col, bit in enumerate(bits):
            if bit == "1":
                oled.pixel(x + col, y + row, c)

def show(state, t, h, p):
    oled.fill(0)                          # ① 칠판 지우기
    oled.fill_rect(0, 0, 128, 10, 1)      # ② 1줄: 제목 띠 (흰 바탕, y 0~9)
    oled.text(state, 2, 1, 0)             #    글자는 검정(0)
    draw_icon(LEAF, 118, 1, 0)
    if t is None:                         # ③ 2줄 (y=12): 온도·습도
        oled.text("DHT11 ERROR", 0, 12)
    else:
        oled.text("T {}C  H {}%".format(t, h), 0, 12)
    oled.text("L {}%".format(p), 0, 24)   # ④ 3줄 (y=24): 밝기 + 막대
    oled.rect(56, 24, 72, 8, 1)
    oled.fill_rect(58, 26, 68 * p // 100, 4, 1)
    oled.show()                           # ⑤ 한 번에 보여 주기

last_state = ""
time.sleep(1)
while True:
    try:
        sensor.measure()
        t, h = sensor.temperature(), sensor.humidity()
    except OSError:
        t = h = None
    p = light_percent()
    state, rgb = judge(t, h, p)
    color(*rgb)
    if state != last_state:
        if state == "STUFFY":
            for _ in range(3):
                beep(4000, 40)
        else:
            beep(880, 80)
        last_state = state
    show(state, t, h, p)
    print(state, t, h, p)
    time.sleep(2)

상황 6가지를 넣었을 때 이 코드가 실제로 만든 화면이에요.

COZY · NIGHT · STUFFY · HUMID · CHILLY · SENSOR? 화면


⑥ 구글 앱스 스크립트로 데이터 전송하기

비유: 엽서에 적어 우체통에 넣기. 관측값을 주소 뒤에 ?temp=21&humi=55처럼 적어 보내요. Apps Script가 엽서를 받아 시트 한 줄에 옮겨 적어 줘요.

피코 2 W가 Wi-Fi로 데이터를 보내는 길

6-1 구글 시트 쪽 준비 (Apps Script)

  1. 구글 드라이브 → 새 스프레드시트
  2. 메뉴 확장 프로그램 → Apps Script
  3. 아래 Code.gs를 붙여 넣고 저장
  4. 배포 → 새 배포 → 유형 웹 앱
  5. 실행 사용자: 나 · 액세스 권한: 모든 사용자 → 배포 → 권한 승인
  6. 웹 앱 URL(…/exec)을 복사
// 파일: Code.gs
// 구글 시트 → 확장 프로그램 → Apps Script 에 붙여 넣기
// 배포 → 새 배포 → 유형: 웹 앱 → 실행: 나 / 액세스: 모든 사용자 → 배포 → URL 복사
const SHEET_NAME = '관측일지';

function doGet(e) {
  const p = e.parameter;
  if (!p.temp) {
    return ContentService.createTextOutput('관측소 서버 OK (값 없음)');
  }
  const lock = LockService.getScriptLock();
  lock.waitLock(10000);                       // 여러 모둠이 동시에 보내도 줄 서기
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    let sheet = ss.getSheetByName(SHEET_NAME);
    if (!sheet) {
      sheet = ss.insertSheet(SHEET_NAME);
      sheet.appendRow(['시간', '모둠', '온도(°C)', '습도(%)', '밝기(%)', '상태', 'AI 한마디']);
    }
    sheet.appendRow([new Date(), p.device || '', Number(p.temp), Number(p.humi),
                     Number(p.light), p.state || '', p.ai || '']);
    return ContentService.createTextOutput('OK');
  } catch (err) {
    return ContentService.createTextOutput('ERROR ' + err);
  } finally {
    lock.releaseLock();
  }
}

피코 전에 브라우저로 먼저 시험! URL 뒤에 ?temp=20&humi=50&light=70&device=test를 붙여 주소창에 넣어 보세요. OK가 뜨고 시트에 한 줄이 생기면 서버 쪽은 완성이에요. 학교 구글 계정은 "모든 사용자" 옵션이 안 보일 수 있어요. 코드를 고친 뒤에는 배포 관리 → 새 버전으로 다시 배포해야 반영돼요.

6-2 피코 쪽: config.py 새 파일 만들기

②단계에서 배운 대로 새 파일을 만들어 Wi-Fi 이름·비밀번호·시트 주소를 따로 저장해요. Raspberry Pi Pico에 config.py로 저장하고 실행하지는 않아요.

# 파일: config.py
# config.py : 비밀 정보는 여기에만! (피코에 'config.py'로 저장)
# 코드를 친구에게 보여 줄 때 이 파일은 빼고 보여 주세요.
SSID = "와이파이이름"            # 2.4GHz Wi-Fi만 가능
PASSWORD = "비밀번호"
GAS_URL = "https://script.google.com/macros/s/여기에배포ID/exec"
DEVICE = "team01"                # 우리 모둠 이름 (영어·숫자)
GEMINI_KEY = ""                  # 7단계(선생님 시연)에서만 사용

6-3 완성본: Raspberry Pi Pico에 main.py로 저장

# 파일: s6_station.py
# [6] 가을 관측소 + 구글 시트 기록  ※ Pico 2 W 전용
# 준비: config.py 와 lib/ssd1306.py 가 피코에 저장되어 있어야 해요.
# 연결: LED GP13·14·15 / 부저 GP16 / DHT11 GP22 / 조도 GP26 / OLED GP4·5
from machine import Pin, PWM, I2C, ADC
import network
import ssd1306
import dht
import time
import requests
from config import SSID, PASSWORD, GAS_URL, DEVICE

SHEET_EVERY = 60_000            # 시트 기록 간격 (ms) = 1분

i2c = I2C(0, sda=Pin(4), scl=Pin(5), freq=400000)
oled = ssd1306.SSD1306_I2C(128, 32, i2c)   # 우리 화면은 128×32
sensor = dht.DHT11(Pin(22))
light = ADC(Pin(26))
INVERT = True
red, green, blue = PWM(Pin(13)), PWM(Pin(14)), PWM(Pin(15))
for led in (red, green, blue):
    led.freq(1000)
buzzer = PWM(Pin(16))
buzzer.duty_u16(0)

def color(r, g, b):
    red.duty_u16(r * 257)
    green.duty_u16(g * 257)
    blue.duty_u16(b * 257)

def beep(freq, ms):
    buzzer.freq(freq)
    buzzer.duty_u16(32768)
    time.sleep_ms(ms)
    buzzer.duty_u16(0)
    time.sleep_ms(30)

def light_percent():
    total = 0
    for _ in range(8):
        total += light.read_u16()
        time.sleep_ms(5)
    p = (total // 8) * 100 // 65535
    return 100 - p if INVERT else p

def discomfort(t, h):
    return 0.81 * t + 0.01 * h * (0.99 * t - 14.3) + 46.3

def judge(t, h, p):
    if t is None:
        return "SENSOR?", (120, 0, 160)
    di = discomfort(t, h)
    if p < 25:
        return "NIGHT", (60, 15, 0)
    elif di >= 75:
        return "STUFFY", (255, 0, 0)
    elif h >= 70:
        return "HUMID", (0, 80, 255)
    elif t <= 15:
        return "CHILLY", (255, 60, 0)
    else:
        return "COZY", (0, 255, 0)

LEAF = ["00010000",                       # 8×8 단풍잎 (1 = 켜기)
        "01010100",
        "00111000",
        "11111110",
        "01111100",
        "00111000",
        "00010000",
        "00010000"]

def draw_icon(icon, x, y, c):
    for row, bits in enumerate(icon):
        for col, bit in enumerate(bits):
            if bit == "1":
                oled.pixel(x + col, y + row, c)

def show(state, t, h, p):
    oled.fill(0)                          # ① 칠판 지우기
    oled.fill_rect(0, 0, 128, 10, 1)      # ② 1줄: 제목 띠 (흰 바탕, y 0~9)
    oled.text(state, 2, 1, 0)             #    글자는 검정(0)
    draw_icon(LEAF, 118, 1, 0)
    if t is None:                         # ③ 2줄 (y=12): 온도·습도
        oled.text("DHT11 ERROR", 0, 12)
    else:
        oled.text("T {}C  H {}%".format(t, h), 0, 12)
    oled.text("L {}%".format(p), 0, 24)   # ④ 3줄 (y=24): 밝기 + 막대
    oled.rect(56, 24, 72, 8, 1)
    oled.fill_rect(58, 26, 68 * p // 100, 4, 1)
    oled.show()                           # ⑤ 한 번에 보여 주기

# ── Wi-Fi · 구글 시트 ─────────────────────────
wlan = network.WLAN(network.STA_IF)

def wifi_ok():
    if wlan.isconnected():
        return True
    wlan.active(True)
    wlan.connect(SSID, PASSWORD)
    for _ in range(15):              # 최대 15초 기다리기
        if wlan.isconnected():
            print("Wi-Fi 연결:", wlan.ifconfig()[0])
            return True
        time.sleep(1)
    print("Wi-Fi 실패: 이름·비밀번호·2.4GHz 확인")
    return False

def urlencode(value):
    """한글·공백도 주소에 넣을 수 있게 %EC%95%88 모양으로 바꾸기"""
    out = ""
    for b in str(value).encode("utf-8"):
        if (48 <= b <= 57) or (65 <= b <= 90) or (97 <= b <= 122) or b in b"-_.~":
            out += chr(b)
        else:
            out += "%{:02X}".format(b)
    return out

def send_to_sheet(data):
    query = "&".join("{}={}".format(k, urlencode(v)) for k, v in data.items())
    r = requests.get(GAS_URL + "?" + query, timeout=20)
    reply = r.text
    r.close()
    return reply

# ── 메인 반복 ─────────────────────────────────
oled.fill(0)
oled.text("Wi-Fi ...", 0, 0)
oled.show()
wifi_ok()

last_state = ""
last_sheet = time.ticks_ms() - SHEET_EVERY   # 시작하자마자 한 번 기록
time.sleep(1)
while True:
    try:
        sensor.measure()
        t, h = sensor.temperature(), sensor.humidity()
    except OSError:
        t = h = None
    p = light_percent()
    state, rgb = judge(t, h, p)
    color(*rgb)
    if state != last_state:
        if state == "STUFFY":
            for _ in range(3):
                beep(4000, 40)
        else:
            beep(880, 80)
        last_state = state
    show(state, t, h, p)

    now = time.ticks_ms()
    if t is not None and time.ticks_diff(now, last_sheet) >= SHEET_EVERY:
        last_sheet = now
        try:
            if wifi_ok():
                row = {"device": DEVICE, "temp": t, "humi": h,
                       "light": p, "state": state}
                print("시트:", send_to_sheet(row))
        except Exception as e:           # 인터넷이 끊겨도 관측은 계속!
            print("시트 실패:", e)
    time.sleep(2)

USB 보조배터리에 꽂고 창가에 두면 2초마다 측정·판단·표시하고 1분마다 시트에 한 줄을 적어요. 시트에서 삽입 → 차트로 시간별 온도 그래프를 그려 보세요.

왜 POST가 아니라 주소(GET)로 보내나요? Apps Script는 데이터를 받은 뒤 다른 주소로 한 번 넘겨주는(302) 방식으로 답해요. 피코의 requests 라이브러리는 POST를 넘겨받아 다시 요청할 때 '내용 길이' 표시를 붙인 채 빈 GET을 보내는 것을 검증 중에 확인했어요. 서버가 멈춰 기다릴 수 있어서 처음부터 GET으로 만들었어요.


⑦ 시간이 되면: 제미나이에게 한마디 듣기 (선생님 시연)

먼저 확인: Gemini API 추가 약관은 API를 쓰려면 만 18세 이상이어야 한다고 정하고 있어요. 그래서 이 단계는 선생님 키로 선생님 피코에서 시연해요. 무료 등급에서 보낸 내용은 서비스 개선에 쓰일 수 있으니 이름 같은 개인정보는 넣지 않아요.

비유: 기상캐스터에게 편지 보내기. 숫자와 질문을 봉투(JSON)에 담고 겉봉에 열쇠(API 키)를 붙여 보내요. 답장 봉투에서 model_output → text 칸만 꺼내 읽어요.

요청과 응답의 모양 (Interactions API)

# 파일: s7_gemini_test.py
# [7-1] 제미나이에게 관측값 보여 주고 한마디 듣기  ※ 선생님 시연용
# 준비: config.py 의 GEMINI_KEY 에 선생님 API 키 (Google AI Studio에서 발급)
from machine import Pin, ADC
import network
import dht
import json
import time
import requests
from config import SSID, PASSWORD, GEMINI_KEY

MODEL = "gemini-3.5-flash-lite"   # 빠르고 가벼운 모델 (무료 등급 있음)
URL = "https://generativelanguage.googleapis.com/v1beta/interactions"

sensor = dht.DHT11(Pin(22))
light = ADC(Pin(26))
INVERT = True

def light_percent():
    total = 0
    for _ in range(8):
        total += light.read_u16()
        time.sleep_ms(5)
    p = (total // 8) * 100 // 65535
    return 100 - p if INVERT else p

def wifi_connect():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        wlan.connect(SSID, PASSWORD)
        for _ in range(20):
            if wlan.isconnected():
                break
            time.sleep(1)
    if not wlan.isconnected():
        raise RuntimeError("Wi-Fi 연결 실패")
    print("Wi-Fi OK:", wlan.ifconfig()[0])

def ask_gemini(prompt):
    body = json.dumps({
        "model": MODEL,
        "input": prompt,
        "store": False,                       # 대화를 서버에 저장하지 않기
        "generation_config": {
            "thinking_level": "minimal",      # 생각은 짧게 → 빠른 답
            "max_output_tokens": 300,
        },
    }).encode()
    headers = {"x-goog-api-key": GEMINI_KEY, "Content-Type": "application/json"}
    r = requests.post(URL, data=body, headers=headers, timeout=30)
    status = r.status_code
    text = r.text
    r.close()
    if status != 200:
        print("제미나이 오류", status, ":", " ".join(text.split())[:150])
        return None
    answer = ""
    for step in json.loads(text).get("steps", []):      # 답장 봉투 열기
        if step.get("type") == "model_output":
            for part in step.get("content", []):
                if part.get("type") == "text":
                    answer += part.get("text", "")
    return answer or None

wifi_connect()
time.sleep(1)
sensor.measure()
t, h, p = sensor.temperature(), sensor.humidity(), light_percent()
print("관측값:", t, "°C", h, "%", p, "%")

prompt = ("교실 관측값이야. 온도 {}도, 습도 {}%, 밝기 {}%. "
          "고등학생에게 지금 교실 상태를 한 문장으로 말하고, "
          "할 일 하나를 추천해 줘. 80자 이내로.").format(t, h, p)
answer = ask_gemini(prompt)
print("제미나이:", answer)

7-2 완성본 + 제미나이 (5분마다 한마디를 시트의 'AI 한마디' 칸에)

# 파일: s7_station_ai.py
# [7-2] 완성본: 가을 관측소 + 구글 시트 + 제미나이 한마디  ※ 선생님 시연용
# 준비: config.py(GEMINI_KEY 포함)와 lib/ssd1306.py 가 피코에 저장되어 있어야 해요.
# 연결: LED GP13·14·15 / 부저 GP16 / DHT11 GP22 / 조도 GP26 / OLED GP4·5
from machine import Pin, PWM, I2C, ADC
import network
import ssd1306
import dht
import json
import time
import requests
from config import SSID, PASSWORD, GAS_URL, DEVICE, GEMINI_KEY

SHEET_EVERY = 60_000            # 시트 기록 간격 (ms) = 1분
AI_EVERY = 300_000              # 제미나이 질문 간격 (ms) = 5분
MODEL = "gemini-3.5-flash-lite"
AI_URL = "https://generativelanguage.googleapis.com/v1beta/interactions"

i2c = I2C(0, sda=Pin(4), scl=Pin(5), freq=400000)
oled = ssd1306.SSD1306_I2C(128, 32, i2c)   # 우리 화면은 128×32
sensor = dht.DHT11(Pin(22))
light = ADC(Pin(26))
INVERT = True
red, green, blue = PWM(Pin(13)), PWM(Pin(14)), PWM(Pin(15))
for led in (red, green, blue):
    led.freq(1000)
buzzer = PWM(Pin(16))
buzzer.duty_u16(0)

def color(r, g, b):
    red.duty_u16(r * 257)
    green.duty_u16(g * 257)
    blue.duty_u16(b * 257)

def beep(freq, ms):
    buzzer.freq(freq)
    buzzer.duty_u16(32768)
    time.sleep_ms(ms)
    buzzer.duty_u16(0)
    time.sleep_ms(30)

def light_percent():
    total = 0
    for _ in range(8):
        total += light.read_u16()
        time.sleep_ms(5)
    p = (total // 8) * 100 // 65535
    return 100 - p if INVERT else p

def discomfort(t, h):
    return 0.81 * t + 0.01 * h * (0.99 * t - 14.3) + 46.3

def judge(t, h, p):
    if t is None:
        return "SENSOR?", (120, 0, 160)
    di = discomfort(t, h)
    if p < 25:
        return "NIGHT", (60, 15, 0)
    elif di >= 75:
        return "STUFFY", (255, 0, 0)
    elif h >= 70:
        return "HUMID", (0, 80, 255)
    elif t <= 15:
        return "CHILLY", (255, 60, 0)
    else:
        return "COZY", (0, 255, 0)

LEAF = ["00010000",                       # 8×8 단풍잎 (1 = 켜기)
        "01010100",
        "00111000",
        "11111110",
        "01111100",
        "00111000",
        "00010000",
        "00010000"]

def draw_icon(icon, x, y, c):
    for row, bits in enumerate(icon):
        for col, bit in enumerate(bits):
            if bit == "1":
                oled.pixel(x + col, y + row, c)

def show(state, t, h, p):
    oled.fill(0)                          # ① 칠판 지우기
    oled.fill_rect(0, 0, 128, 10, 1)      # ② 1줄: 제목 띠 (흰 바탕, y 0~9)
    oled.text(state, 2, 1, 0)             #    글자는 검정(0)
    draw_icon(LEAF, 118, 1, 0)
    if t is None:                         # ③ 2줄 (y=12): 온도·습도
        oled.text("DHT11 ERROR", 0, 12)
    else:
        oled.text("T {}C  H {}%".format(t, h), 0, 12)
    oled.text("L {}%".format(p), 0, 24)   # ④ 3줄 (y=24): 밝기 + 막대
    oled.rect(56, 24, 72, 8, 1)
    oled.fill_rect(58, 26, 68 * p // 100, 4, 1)
    oled.show()                           # ⑤ 한 번에 보여 주기

# ── Wi-Fi · 구글 시트 ─────────────────────────
wlan = network.WLAN(network.STA_IF)

def wifi_ok():
    if wlan.isconnected():
        return True
    wlan.active(True)
    wlan.connect(SSID, PASSWORD)
    for _ in range(15):              # 최대 15초 기다리기
        if wlan.isconnected():
            print("Wi-Fi 연결:", wlan.ifconfig()[0])
            return True
        time.sleep(1)
    print("Wi-Fi 실패: 이름·비밀번호·2.4GHz 확인")
    return False

def urlencode(value):
    """한글·공백도 주소에 넣을 수 있게 %EC%95%88 모양으로 바꾸기"""
    out = ""
    for b in str(value).encode("utf-8"):
        if (48 <= b <= 57) or (65 <= b <= 90) or (97 <= b <= 122) or b in b"-_.~":
            out += chr(b)
        else:
            out += "%{:02X}".format(b)
    return out

def send_to_sheet(data):
    query = "&".join("{}={}".format(k, urlencode(v)) for k, v in data.items())
    r = requests.get(GAS_URL + "?" + query, timeout=20)
    reply = r.text
    r.close()
    return reply

def ask_gemini(prompt):
    body = json.dumps({
        "model": MODEL,
        "input": prompt,
        "store": False,                       # 대화를 서버에 저장하지 않기
        "generation_config": {
            "thinking_level": "minimal",      # 생각은 짧게 → 빠른 답
            "max_output_tokens": 300,
        },
    }).encode()
    headers = {"x-goog-api-key": GEMINI_KEY, "Content-Type": "application/json"}
    r = requests.post(AI_URL, data=body, headers=headers, timeout=30)
    status = r.status_code
    text = r.text
    r.close()
    if status != 200:
        print("제미나이 오류", status, ":", " ".join(text.split())[:150])
        return None
    answer = ""
    for step in json.loads(text).get("steps", []):      # 답장 봉투 열기
        if step.get("type") == "model_output":
            for part in step.get("content", []):
                if part.get("type") == "text":
                    answer += part.get("text", "")
    return answer or None

# ── 메인 반복 ─────────────────────────────────
oled.fill(0)
oled.text("Wi-Fi ...", 0, 0)
oled.show()
wifi_ok()

last_state = ""
ai_comment = ""
last_sheet = time.ticks_ms() - SHEET_EVERY   # 시작하자마자 한 번 기록
last_ai = time.ticks_ms() - AI_EVERY
time.sleep(1)
while True:
    try:
        sensor.measure()
        t, h = sensor.temperature(), sensor.humidity()
    except OSError:
        t = h = None
    p = light_percent()
    state, rgb = judge(t, h, p)
    color(*rgb)
    if state != last_state:
        if state == "STUFFY":
            for _ in range(3):
                beep(4000, 40)
        else:
            beep(880, 80)
        last_state = state
    show(state, t, h, p)

    now = time.ticks_ms()
    if t is not None and time.ticks_diff(now, last_ai) >= AI_EVERY:
        last_ai = now
        try:
            if wifi_ok():
                prompt = ("교실 관측값: 온도 {}도, 습도 {}%, 밝기 {}%, 상태 {}. "
                          "지금 교실 상태를 한 문장으로 말하고 할 일 하나를 추천해 줘. "
                          "60자 이내로.").format(t, h, p, state)
                ans = ask_gemini(prompt)
                if ans:
                    ai_comment = ans[:100]        # 시트 칸에 맞게 100자까지만
                    print("제미나이:", ai_comment)
        except Exception as e:
            print("제미나이 실패:", e)
    if t is not None and time.ticks_diff(now, last_sheet) >= SHEET_EVERY:
        last_sheet = now
        try:
            if wifi_ok():
                row = {"device": DEVICE, "temp": t, "humi": h,
                       "light": p, "state": state, "ai": ai_comment}
                print("시트:", send_to_sheet(row))
                ai_comment = ""                   # 한 번 적은 한마디는 비우기
        except Exception as e:           # 인터넷이 끊겨도 관측은 계속!
            print("시트 실패:", e)
    time.sleep(2)

같이 생각할 질문: 질문 문장을 "시인처럼", "숫자 근거를 들어서"로 바꾸면 답이 어떻게 달라질까? · AI 답이 센서 값과 맞지 않는 말을 하면 어떻게 알아챌까? · if 조건문으로 충분한 일과 AI가 필요한 일은 어떻게 다를까?


피코의 한계 (작품 설계 전에 꼭 확인)

한계 무슨 뜻인가요 해결책
컬러 카메라 사용 불가 메모리 520KB로는 컬러 영상을 처리할 수 없어요 영상 인식은 젯슨·스마트폰에 맡기고 피코는 반응만
3.3V 전용 입출력 5V 신호를 GP 핀에 넣으면 고장날 수 있어요 전압 분배나 레벨 변환기
아날로그 입력 3개뿐 ADC 핀은 GP26·27·28 I2C ADC 모듈(ADS1115 등)
힘이 약한 핀 모터·펌프를 직접 돌릴 힘이 없어요 모터 드라이버, 릴레이 + 별도 전원
Wi-Fi 2.4GHz만 5GHz, 학교 인증형 Wi-Fi는 어려워요 2.4GHz 공유기나 휴대폰 핫스팟
시계가 없음 전원을 끄면 시간이 초기화돼요 시간은 시트가 기록, 또는 RTC 모듈
OLED에 한글 불가 기본 글꼴이 영문 8×8픽셀 상태는 영어로, 한글은 시트·쉘에
비교 Raspberry Pi Pico 2 W NVIDIA Jetson Orin Nano Super
정체 마이크로컨트롤러 (운영체제 없음) 리눅스가 도는 AI 컴퓨터
기억 SRAM 520KB · 플래시 4MB LPDDR5 8GB
AI 성능 아주 작은 모델(TinyML) 정도 67 INT8 TOPS
전력 USB 보조배터리로 충분 7W ~ 25W
공식 가격 US$7 (출시가) US$249

센서를 읽고 바로 반응하거나 배터리로 오래 기록할 때는 피코, 카메라로 사람·물체를 알아봐야 할 때는 젯슨. 젯슨이 인식하고 UART로 피코에 명령을 보내 피코가 모터·LED를 움직이게 하면 둘이 한 팀이 돼요.


막혔을 때 보는 사전

증상 가장 흔한 원인 해결
Thonny 오른쪽 아래에 피코가 안 보여요 충전 전용 USB 케이블 데이터 케이블로 바꾸기, USB 다시 꽂기
Install MicroPython 메뉴가 없어요 BOOTSEL 모드가 아님 BOOTSEL 누른 채로 다시 꽂기
쉘이 응답 없음 main.py가 무한 반복 중 정지 버튼(Ctrl+F2) 한두 번
저장할 때 '이 컴퓨터'만 보여요 인터프리터가 로컬 Python 3 오른쪽 아래에서 MicroPython (Raspberry Pi Pico) 선택
no module named 'ssd1306' lib 폴더에 저장 안 함 Raspberry Pi Pico 안 lib/ssd1306.py 확인
no module named 'config' config.py를 이 컴퓨터에 저장 Raspberry Pi Pico 쪽에 저장
i2c.scan() 결과가 [] SDA·SCL 바뀜, 전원 미연결 SDA→GP4(6번), SCL→GP5(7번)
OLED 글자가 세로로 늘어나요 크기를 128, 64로 적음 SSD1306_I2C(128, 32, i2c)
DHT11 응답 없음 (OSError) S 핀 위치, 너무 자주 측정 S→GP22(29번), 2초 간격
조도 값이 안 움직여요 S·+·− 순서가 바뀜 S→GP26(31번), 가운데→3V3, −→GND
가렸는데 밝기가 올라가요 INVERT 방향 반대 INVERT 값을 True ↔ False
3색 LED 색이 반대로 나와요 공통 양극(+)형 모듈 공통 핀을 3V3에, 65535 - r * 257로 뒤집기
부저가 '틱틱' 소리만 나요 능동(Active) 부저 수동(Passive) 부저로 바꾸기
Wi-Fi 실패 5GHz 또는 학교 인증형 Wi-Fi 2.4GHz 핫스팟으로 시험
시트의 대답이 HTML이에요 편집기 URL을 넣음 …/exec URL, 액세스 '모든 사용자' 확인
제미나이 오류 400 / 429 키 오타 / 너무 자주 물어봄 키 확인 / AI_EVERY 늘리기

메이커톤으로 키우기

오늘 만든 관측소는 씨앗이에요. "누구를 위해, 어디에, 왜" 를 정해서 우리만의 작품으로 키워 보세요.

해외 학생들은 피코로 체스 두는 판(PicoChess), 소리 방향을 맞히는 AI 귀(Earie), 열을 쫓는 네 발 로봇도 만들었어요. (Raspberry Pi 뉴스)


이 자료는 이렇게 검증했어요

출처: Thonny · MicroPython RPI_PICO2_W · MicroPython RP2 Quick reference · Raspberry Pi Pico 2 W Datasheet · Aosong DHT11 Datasheet · Joy-IT KY-018 · Google Apps Script Web Apps · Gemini Interactions API · Gemini 모델 목록 · NVIDIA Jetson Orin Nano Super