교무실 Stream Deck 선생님 위치 표시기 스크립트 추가

교사가 앱에 직접 접속해서 위치를 바꾸는 대신, 교무실 출입구의
Stream Deck(15키)에서 이름 버튼 하나로 "수업 중"을 표시하고,
설정 버튼으로 과학실/출장 등 다른 위치도 고를 수 있게 하는 라즈베리파이용
독립 스크립트. 기존 POST /api/teacher-call/location API를 그대로
호출하므로 서버/앱 변경 없이 별도 기기에서 실행하면 된다. 선생님
목록은 /api/teacher-call/teachers에서 1분마다 자동 새로고침해서
새로 가입한 교사도 재시작 없이 반영된다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 04:06:00 +00:00
co-authored by Claude Sonnet 5
parent 521355626d
commit 51a106faf4
3 changed files with 365 additions and 0 deletions
@@ -0,0 +1,92 @@
# 교무실 Stream Deck 선생님 위치 표시기
교무실 출입구에 놓인 Stream Deck으로 선생님이 본인 이름 버튼만 누르면
백품타 앱의 "선생님 호출" 위치가 바뀝니다. 이미 배포돼 있는 서버 API
(`POST /api/teacher-call/location`)를 그대로 호출하기 때문에, 서버나
앱은 전혀 건드릴 필요 없이 이 스크립트 하나만 라즈베리파이 같은 작은
컴퓨터에서 계속 띄워두면 됩니다.
## 준비물
- Stream Deck 오리지널/MK.2 (15키, 5x3) — USB로 라즈베리파이에 연결
- 라즈베리파이(또는 인터넷 되는 리눅스 미니PC) 1대
- 교무실 회원가입(교사 회원가입)을 마친 선생님만 목록에 뜹니다 — 아직
가입 안 한 선생님은 버튼이 안 보이니, 먼저 앱에서 "교사 회원가입"부터
해달라고 안내해주세요.
## 설치
```bash
# 1) 한글 폰트 설치 (버튼에 이름이 네모(□)로 안 뜨게)
sudo apt update
sudo apt install -y fonts-nanum
# 2) Stream Deck을 일반 사용자 권한으로 인식하게 하는 udev 규칙
# (이거 안 하면 sudo로만 인식되거나 아예 인식이 안 될 수 있어요)
sudo tee /etc/udev/rules.d/50-streamdeck.rules > /dev/null << 'UDEV'
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", MODE="0666"
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="0fd9", MODE="0666"
UDEV
sudo udevadm control --reload-rules
sudo udevadm trigger
# 3) 파이썬 패키지 설치
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# 4) 실행
python3 stream_deck_teacher_status.py
```
USB 케이블을 뽑았다 다시 꽂아야 udev 규칙이 적용되는 경우가 있어요.
## 사용법
- **선생님 이름 버튼을 그냥 누르면** → "수업 중"으로 표시됩니다.
- **과학실/도서관/출장 등 다른 위치로 표시하고 싶으면** → 화면 오른쪽 아래
"⚙ 설정" 버튼을 누르고, 뜨는 위치 목록에서 하나를 고른 뒤(예: "출장"),
이어서 그 선생님 이름 버튼을 누르면 됩니다. 한 번 적용하면 자동으로
다시 기본 모드("수업 중")로 돌아갑니다.
- 선생님이 18명을 넘어가면 자동으로 페이지가 나뉘고, "다음 ▶" 버튼으로
넘길 수 있습니다.
- 새로 교사 회원가입을 한 선생님은 최대 1분 안에 자동으로 목록에 나타납니다
(재시작 필요 없음).
## 부팅할 때 자동 실행하게 하기 (선택)
라즈베리파이를 켤 때마다 자동으로 이 스크립트가 실행되게 하려면
systemd 서비스로 등록하세요:
```bash
sudo tee /etc/systemd/system/streamdeck-teacher.service > /dev/null << 'UNIT'
[Unit]
Description=교무실 Stream Deck 선생님 위치 표시기
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/home/pi/stream_deck_teacher_status/venv/bin/python3 /home/pi/stream_deck_teacher_status/stream_deck_teacher_status.py
Restart=on-failure
RestartSec=5
User=pi
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now streamdeck-teacher.service
```
경로(`/home/pi/...`)는 실제로 이 폴더를 둔 위치에 맞게 바꿔주세요.
로그 확인은 `journalctl -u streamdeck-teacher -f`.
## 참고
- 이 스크립트는 이 저장소(school-backend)와는 별개로, 이미 배포된
`api.backsanhi.shop`의 기존 API만 호출합니다. 서버/앱 배포와는 무관하게
라즈베리파이에서 이 파일만 갖고 있으면 바로 동작합니다.
- `python-elgato-streamdeck` 라이브러리 버전에 따라 내부 헬퍼 함수 이름이
달라서(create_key_image/create_image 등) 코드에 호환 처리를 넣어뒀지만,
혹시 실행 중 `AttributeError`가 나면 캡처해서 알려주세요.
@@ -0,0 +1,3 @@
streamdeck
pillow
requests
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
🎛️ 교무실 출입구용 Stream Deck 선생님 위치 표시기.
선생님이 교무실을 나가실 때 본인 이름 버튼을 한 번 누르면 "수업 중"으로,
"설정" 버튼을 눌러 과학실/도서관/출장 같은 위치를 먼저 고른 뒤 이름 버튼을
누르면 그 위치로 우리 학교 앱(백품타 선생님 호출)에 표시된다.
이미 만들어져 있는 백엔드 API(POST /api/teacher-call/location)를 그대로
호출하기 때문에 서버/앱 쪽은 아무것도 건드릴 필요가 없다. 이 스크립트는
라즈베리파이 같은 작은 컴퓨터에 Stream Deck을 USB로 꽂아두고 계속 띄워두면 된다.
설치/실행 방법은 같은 폴더의 README.md 참고.
"""
import threading
import time
from typing import Optional
import requests
from PIL import Image, ImageDraw, ImageFont
from StreamDeck.DeviceManager import DeviceManager
from StreamDeck.ImageHelpers import PILHelper
# 🩹 [버전 호환] python-elgato-streamdeck 버전에 따라 이 헬퍼 함수 이름이
# create_key_image/create_image, to_native_key_format/to_native_format로
# 다르게 붙어있어서, 설치된 버전에 맞는 이름을 찾아 쓴다.
_create_key_image = getattr(PILHelper, "create_key_image", None) or PILHelper.create_image
_to_native_key_format = getattr(PILHelper, "to_native_key_format", None) or PILHelper.to_native_format
# ──────────────────────────────────────────────────────────────────────────
# ⚙️ 설정
# ──────────────────────────────────────────────────────────────────────────
API_BASE = "https://api.backsanhi.shop"
TEACHERS_URL = f"{API_BASE}/api/teacher-call/teachers"
LOCATION_URL_TMPL = f"{API_BASE}/api/teacher-call/location"
# 이름 버튼을 그냥 한 번 눌렀을 때(설정모드 아닐 때) 적용되는 기본 위치.
DEFAULT_TAP_LOCATION = "수업 중"
# "설정" 모드에서 고를 수 있는 위치 목록 (앱의 kLocationOptions와 맞춰둠).
LOCATION_OPTIONS = [
"교무실", "급식실", "과학실", "도서관",
"출장", "조퇴/퇴근", "상담실", "기숙사",
]
ROSTER_REFRESH_SECONDS = 60 # 선생님 목록(누가 회원가입했는지)을 다시 불러오는 주기.
FONT_PATH_CANDIDATES = [
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
]
TEACHERS_PER_PAGE = 13 # 페이지당 선생님 버튼 개수 (나머지 2칸은 다음/설정 버튼용).
def _find_font(size: int) -> ImageFont.FreeTypeFont:
for path in FONT_PATH_CANDIDATES:
try:
return ImageFont.truetype(path, size)
except OSError:
continue
print(
"⚠️ 한글 폰트를 못 찾았어요. 'sudo apt install fonts-nanum' 등으로 설치해주세요. "
"일단 기본 폰트로 진행합니다 (한글이 네모로 보일 수 있어요)."
)
return ImageFont.load_default()
class TeacherStatusDeck:
def __init__(self, deck):
self.deck = deck
self.key_font = _find_font(14)
self.small_font = _find_font(11)
self.roster: list[dict] = [] # [{studentId, name, location}]
self.page = 0
self.settings_mode = False
self.pending_location: Optional[str] = None
self._lock = threading.Lock()
# ── 선생님 목록 새로고침 ────────────────────────────────────────────
def refresh_roster(self):
try:
resp = requests.get(TEACHERS_URL, timeout=5)
resp.raise_for_status()
teachers = resp.json().get("teachers", [])
with self._lock:
self.roster = sorted(teachers, key=lambda t: t["name"])
except Exception as e:
print(f"🚨 선생님 목록 불러오기 실패: {e}")
def roster_watcher(self):
while True:
self.refresh_roster()
self.redraw()
time.sleep(ROSTER_REFRESH_SECONDS)
# ── 이미지 그리기 ───────────────────────────────────────────────────
def _blank_key_image(self, color=(30, 30, 30)):
image = _create_key_image(self.deck)
draw = ImageDraw.Draw(image)
draw.rectangle((0, 0, image.width, image.height), fill=color)
return image, draw
def _text_key_image(self, lines, bg=(30, 30, 30), fg=(255, 255, 255)):
image, draw = self._blank_key_image(bg)
total_h = len(lines) * 16
y = (image.height - total_h) // 2
for i, (text, font) in enumerate(lines):
bbox = draw.textbbox((0, 0), text, font=font)
w = bbox[2] - bbox[0]
draw.text(
((image.width - w) // 2, y + i * 16),
text, font=font, fill=fg,
)
return _to_native_key_format(self.deck, image)
def _nav_key_image(self, label, bg=(60, 60, 70)):
return self._text_key_image(
[(part, self.key_font) for part in label.split("\n")], bg=bg
)
def _blank_native_image(self):
image, _ = self._blank_key_image()
return _to_native_key_format(self.deck, image)
# ── 화면(버튼 배치) 그리기 ──────────────────────────────────────────
def redraw(self):
with self._lock:
for key in range(self.deck.key_count()):
self.deck.set_key_image(key, self._render_key(key))
def _render_key(self, key: int):
if self.settings_mode:
return self._render_settings_key(key)
return self._render_teacher_key(key)
def _render_settings_key(self, key: int):
if key < len(LOCATION_OPTIONS):
loc = LOCATION_OPTIONS[key]
selected = self.pending_location == loc
bg = (0, 110, 60) if selected else (45, 45, 45)
return self._text_key_image([(loc, self.key_font)], bg=bg)
if key == self.deck.key_count() - 1:
return self._nav_key_image("취소", bg=(120, 40, 40))
return self._blank_native_image()
def _render_teacher_key(self, key: int):
last_key = self.deck.key_count() - 1
nav_key = self.deck.key_count() - 2
start = self.page * TEACHERS_PER_PAGE
page_roster = self.roster[start:start + TEACHERS_PER_PAGE]
if key < len(page_roster):
teacher = page_roster[key]
bg = (0, 90, 140) if self.pending_location else (35, 35, 35)
return self._text_key_image(
[
(teacher["name"], self.key_font),
(teacher.get("location", ""), self.small_font),
],
bg=bg,
)
if key == nav_key:
has_more_pages = len(self.roster) > TEACHERS_PER_PAGE
if not has_more_pages:
return self._blank_native_image()
max_page = (len(self.roster) - 1) // TEACHERS_PER_PAGE
return self._nav_key_image(f"다음 ▶\n({self.page + 1}/{max_page + 1})")
if key == last_key:
label = f"설정\n({self.pending_location})" if self.pending_location else "⚙ 설정"
bg = (150, 90, 0) if self.pending_location else (60, 60, 70)
return self._text_key_image(
[(part, self.key_font) for part in label.split("\n")], bg=bg
)
return self._blank_native_image()
# ── 버튼 입력 처리 ──────────────────────────────────────────────────
def on_key_change(self, deck, key, pressed):
if not pressed:
return
if self.settings_mode:
self._handle_settings_press(key)
else:
self._handle_teacher_press(key)
self.redraw()
def _handle_settings_press(self, key: int):
if key < len(LOCATION_OPTIONS):
self.pending_location = LOCATION_OPTIONS[key]
self.settings_mode = False
return
# 마지막 키 = 취소
self.settings_mode = False
def _handle_teacher_press(self, key: int):
last_key = self.deck.key_count() - 1
nav_key = self.deck.key_count() - 2
start = self.page * TEACHERS_PER_PAGE
page_roster = self.roster[start:start + TEACHERS_PER_PAGE]
if key < len(page_roster):
teacher = page_roster[key]
location = self.pending_location or DEFAULT_TAP_LOCATION
self._set_location(teacher, location)
self.pending_location = None # 한 번 적용하면 자동으로 기본 모드로.
return
if key == nav_key and len(self.roster) > TEACHERS_PER_PAGE:
max_page = (len(self.roster) - 1) // TEACHERS_PER_PAGE
self.page = (self.page + 1) % (max_page + 1)
return
if key == last_key:
if self.pending_location:
# 설정 모드에서 위치를 이미 골라둔 상태로 설정 버튼을 또 누르면 취소.
self.pending_location = None
else:
self.settings_mode = True
def _set_location(self, teacher: dict, location: str):
try:
resp = requests.post(
LOCATION_URL_TMPL,
json={"teacherId": teacher["studentId"], "location": location},
timeout=5,
)
resp.raise_for_status()
print(f"✅ {teacher['name']} → {location}")
teacher["location"] = location # 다음 새로고침 전까지 화면에 바로 반영.
except Exception as e:
print(f"🚨 위치 업데이트 실패 ({teacher['name']} → {location}): {e}")
def main():
decks = DeviceManager().enumerate()
if not decks:
print("🚨 연결된 Stream Deck을 찾을 수 없어요. USB 연결과 udev 규칙을 확인해주세요 (README 참고).")
return
deck = decks[0]
deck.open()
deck.reset()
deck.set_brightness(70)
print(f"🎛️ Stream Deck 연결됨: {deck.deck_type()} ({deck.key_count()}키)")
controller = TeacherStatusDeck(deck)
deck.set_key_callback(controller.on_key_change)
controller.refresh_roster()
controller.redraw()
threading.Thread(target=controller.roster_watcher, daemon=True).start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
deck.reset()
deck.close()
if __name__ == "__main__":
main()