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 (선생님 시연) |
모둠당 준비물
- Raspberry Pi Pico 2 W(헤더 납땜된 2WH) 1, 마이크로 USB 데이터 케이블 1
- 브레드보드 1, 점퍼선(수-수, 수-암) 20
- DHT11 온습도 센서 모듈 1 (3핀: S, +, −)
- 조도 센서 모듈 KY-018 1 (3핀: S, 가운데 +, −)
- 3색(RGB) LED 모듈 1 (공통 음극 −)
- 부저 1 (수동형 Passive 권장)
- 0.91인치 OLED SSD1306 128×32 I2C 모듈 1 (4핀: GND, VCC, SCL, SDA)
- Thonny가 설치된 컴퓨터
① Thonny 설치하고 피코와 인사하기
비유: Thonny는 피코와 통하는 무전기예요. 컴퓨터에서 쓴 코드를 USB 선을 타고 피코에게 건네주고, 피코가 하는 말(print)을 받아 적어 줘요. 아래쪽 쉘 창이 피코의 목소리가 들리는 스피커예요.
1-1 Thonny 설치
- thonny.org 접속 → 내 운영체제(Windows / Mac / Linux) 버튼 클릭
- 설치 파일 실행 → 기본값 그대로 Next
- 처음 켤 때 언어(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은 피코가 쓰는 말이에요. 갓 산 피코는 아직 파이썬을 못 알아들어요. 펌웨어를 한 번 깔아 주면 그때부터 우리 코드를 읽을 수 있어요.
- 피코의 하얀 BOOTSEL 버튼을 누른 채로 USB를 꽂고, 그다음 손을 떼요.
- 컴퓨터에 RP2350 드라이브가 나타나면 준비 완료.
- Thonny 오른쪽 아래 인터프리터 글자 클릭 → Install MicroPython...
- Target volume은 자동으로 RP2350, MicroPython family: RP2, variant: Raspberry Pi • Pico 2 W 선택
- Install → Done!이 뜨면 창 닫기. 피코가 저절로 다시 켜지고 드라이브는 사라져요(정상).
Install MicroPython... 메뉴가 안 보이면 피코가 BOOTSEL 모드가 아닌 거예요. USB를 뽑고 BOOTSEL을 누른 채 다시 꽂아 보세요. 수동으로 하려면 micropython.org의 RPI_PICO2_W 페이지에서 .uf2 파일을 받아 RP2350 드라이브에 끌어 넣으면 똑같아요.
1-3 피코 연결하고 첫 대화
오른쪽 아래(⑤)에서 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라는 이름표가 붙은 파일은 피코가 아침에 일어나자마자(전원이 들어오면) 가장 먼저 꺼내 읽는 '오늘 할 일' 쪽지예요.
새 파일 → 피코에 저장
- 파일 → 새 (Ctrl+N)로 빈 탭 열기
- 코드 붙여 넣기
- 파일 → 저장 (Ctrl+S) → 저장 위치 창에서 Raspberry Pi Pico 클릭
- 파일 이름 입력 (예:
light.py) → 확인 - F5로 실행, 멈출 때는 정지 (Ctrl+F2)
'이 컴퓨터'에 저장하면 피코 안에는 안 들어가요. 피코에서 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)
- 위 코드를 Raspberry Pi Pico에 main.py라는 이름으로 저장
- Thonny의 정지 버튼 → USB를 뽑았다가 컴퓨터나 보조배터리에 다시 꽂기
- 아무것도 안 눌러도 초록 LED가 0.5초마다 깜빡이면 성공!
main.py 규칙
- 이름은 정확히 소문자
main.py - 전원이 들어오면 자동 실행 (그 전에 boot.py가 있으면 그것부터)
- 무한 반복(
while True)이 있어도 괜찮아요. 원래 그렇게 쓰는 파일이에요. - 자동 실행을 끄고 싶으면 정지 버튼으로 멈춘 뒤 파일 창에서 main.py 이름을
main_old.py등으로 바꾸기 - 단계마다
step3.py,step4.py처럼 따로 저장해 두고, 마지막 완성본만 main.py로!
근거: MicroPython 공식 문서 'Reset and Boot Sequence'
③ 온습도 센서와 조도 센서 연결
핀맵: 피코의 40개 다리
바깥 숫자는 물리 핀 번호(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) | 전원 |
꼭 지킬 안전 세 가지
- 피코 GPIO는 3.3V 전용이에요. 5V(40번 VBUS)를 GP 핀에 직접 넣지 않기.
- 3V3과 GND를 선 하나로 직접 잇지 않기(합선). 배선은 USB를 뽑고 바꾸기.
- 3V3 출력은 데이터시트 권장 300mA 이하. 모터·펌프는 피코 전원으로 돌리지 않기.
비유: DHT11은 성실한 우체부. 피코가 "지금 몇 도야?" 하고 편지를 보내면 DHT11은 선 하나(S)로 답장을 보내 줘요. 다만 2초에 한 번만 배달해요.
비유: 조도 센서는 눈을 가늘게 뜨는 고양이. 빛이 많으면 저항이 작아지고 어두우면 커지는 부품(CdS)이에요. 피코의 ADC가 이 변화를 0~65535 숫자로 바꿔 줘요.
KY-018은 핀이 3개뿐이에요. 핀 옆 글씨(S, −)를 꼭 확인하세요. 보통 S가 한쪽 끝, −가 반대쪽 끝, 이름 없는 가운데가 +예요. 안에 10kΩ 저항이 함께 들어 있어서 빛에 따라 S 핀 전압이 바뀌어요. 참고로 KY-019는 5V 릴레이 모듈이라 다른 부품이에요.
| 데이터시트 핵심값 | 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 안에는 빨강·초록·파랑 작은 전구가 있어요. 빨강+초록 = 노랑(은행잎), 빨강+조금의 초록 = 주황(홍시).
비유: PWM은 빠른 눈 깜빡임. 1초에 1000번 껐다 켜면 눈은 "밝기"로 느껴요. 부저에서는 같은 PWM의 빠르기(주파수)가 음의 높이가 돼요.
freq(1000): 1초에 1000번duty_u16(32768): 절반(50%) · 65535 = 100%r * 257: 0~255를 0~65535로 (255 × 257 = 65535)- 도 262Hz · 레 294 · 미 330 · 파 349 · 솔 392 · 라 440 · 시 494 · 도 523
- 수동(Passive) 부저는 음계 연주 가능, 능동(Active) 부저는 켜기만 하면 "삐-"
# 파일: 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!
# 파일: 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 라이브러리를 피코에 넣기
- 보기 → 파일 창 → 아래쪽 Raspberry Pi Pico 빈 곳 오른쪽 클릭 → 새로운 디렉토리 → 이름
lib - 파일 → 새 → 아래 ssd1306.py 전체를 붙여 넣기
- 저장 → Raspberry Pi Pico → lib 폴더를 더블클릭해서 들어간 뒤 → 이름
ssd1306.py - 쉘에서
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 연결하고 띄우기
모듈마다 VCC-GND 순서가 다르니 글씨를 보고 꽂으세요. 거꾸로 꽂으면 OLED가 망가질 수 있어요.
- 해상도 128×32, I2C 주소 0x3C, 기본 글꼴은 영문·숫자만(한글 불가)
- 코드에
SSD1306_I2C(128, 32, i2c)로 크기를 정확히! 64로 적으면 글자가 세로로 늘어나요.
# 파일: 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가지를 넣었을 때 이 코드가 실제로 만든 화면이에요.
⑥ 구글 앱스 스크립트로 데이터 전송하기
비유: 엽서에 적어 우체통에 넣기. 관측값을 주소 뒤에
?temp=21&humi=55처럼 적어 보내요. Apps Script가 엽서를 받아 시트 한 줄에 옮겨 적어 줘요.
6-1 구글 시트 쪽 준비 (Apps Script)
- 구글 드라이브 → 새 스프레드시트
- 메뉴 확장 프로그램 → Apps Script
- 아래 Code.gs를 붙여 넣고 저장
- 배포 → 새 배포 → 유형 웹 앱
- 실행 사용자: 나 · 액세스 권한: 모든 사용자 → 배포 → 권한 승인
- 웹 앱 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 칸만 꺼내 읽어요.
- 키 준비: aistudio.google.com/apikey → API 키 만들기 → config.py의
GEMINI_KEY에 붙여 넣기 - 모델:
gemini-3.5-flash-lite(빠르고 가벼움, 무료 등급 있음, 기본 생각 수준 minimal) - 발표 화면에 키가 보이지 않게! 수업이 끝나면 키를 삭제하거나 새로 만들기
# 파일: 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 늘리기 |
메이커톤으로 키우기
오늘 만든 관측소는 씨앗이에요. "누구를 위해, 어디에, 왜" 를 정해서 우리만의 작품으로 키워 보세요.
- 아이디어 씨앗: 교실 환기 알리미(+CO₂ 센서) · 곶감 건조 일지 · 반려식물 SOS(+토양 수분) · 도서관 독서등 도우미 · 체육관 더위 신호등 · 등굣길 미세먼지 신호 · 교실 소음 귀뚜라미 · 단풍 예보 노트(일교차 탐구) · 화분 자동 그늘막(+서보)
- 문제 찾기: 우리 학교에서 온도·습도·빛 때문에 불편한 사람은 누구일까? 그 사람에게 직접 물어보면?
- 데이터: 1주일 기록하면 어떤 패턴이 보일까? 센서 값을 믿어도 될까?
- 판단: 기준값은 누가 정했지? 경보가 너무 자주 울리면 사람들은 어떻게 할까?
- 윤리·안전: 센서 기록으로 누가 언제 교실에 있었는지 알 수 있다면 개인정보일까? 시트 URL이나 API 키가 공개되면?
해외 학생들은 피코로 체스 두는 판(PicoChess), 소리 방향을 맞히는 AI 귀(Earie), 열을 쫓는 네 발 로봇도 만들었어요. (Raspberry Pi 뉴스)
이 자료는 이렇게 검증했어요
- 코드 12개를 MicroPython v1.30 인터프리터로 실행했어요. 피코 부품은 데이터시트 규칙을 지키는 가짜 부품으로 대신했고(DHT11 2초 간격 위반, 없는 핀, ADC 아닌 핀 사용 시 오류), 모두 오류 없이 동작했어요.
- if 판단은 6가지 상황을 넣어 상태가 정확히 바뀌는 것을 확인했고, OLED 그림은 공식 ssd1306.py가 128×32로 보낸 화면 데이터를 그대로 그림으로 바꾼 거예요.
- 구글 시트 코드는 Apps Script처럼 302로 답하는 가짜 서버로 피코 요청을 받아 Code.gs를 실행해 확인했어요.
- 제미나이 코드는 문서 형식의 응답을 해석하는 것과, 실제 구글 서버에 MicroPython이 TLS로 접속하는 것(키가 없어 "API key not valid"까지)을 확인했어요.
- 실물 보드, 실제 구글 배포, 실제 제미나이 키로는 수업 전에 한 번 더 확인할 예정이에요.
출처: 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