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