diff --git a/tools/stream_deck_teacher_status/README.md b/tools/stream_deck_teacher_status/README.md new file mode 100644 index 0000000..57c29df --- /dev/null +++ b/tools/stream_deck_teacher_status/README.md @@ -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`가 나면 캡처해서 알려주세요. diff --git a/tools/stream_deck_teacher_status/requirements.txt b/tools/stream_deck_teacher_status/requirements.txt new file mode 100644 index 0000000..f2d2962 --- /dev/null +++ b/tools/stream_deck_teacher_status/requirements.txt @@ -0,0 +1,3 @@ +streamdeck +pillow +requests diff --git a/tools/stream_deck_teacher_status/stream_deck_teacher_status.py b/tools/stream_deck_teacher_status/stream_deck_teacher_status.py new file mode 100644 index 0000000..42a21ea --- /dev/null +++ b/tools/stream_deck_teacher_status/stream_deck_teacher_status.py @@ -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()