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