교무실 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:
@@ -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()
|
||||
Reference in New Issue
Block a user