Files
school-attendance/tools/stream_deck_teacher_status/stream_deck_teacher_status.py
T
sihooandClaude Sonnet 5 51a106faf4 교무실 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>
2026-09-30 04:06:00 +00:00

271 lines
11 KiB
Python

#!/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()