Skip to content

Commit 79d47e3

Browse files
authored
Merge pull request #18 from 74th/feat/web-api
Add Web API endpoints for StackChan state and wakeword management
2 parents b9505f1 + d5402eb commit 79d47e3

2 files changed

Lines changed: 62 additions & 1 deletion

File tree

stackchan_server/app.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
from logging import getLogger
55
from typing import Awaitable, Callable, Optional
66

7-
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
7+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
8+
from pydantic import BaseModel
89

910
from .speech_recognition import create_speech_recognizer
1011
from .speech_synthesis import create_speech_synthesizer
@@ -14,6 +15,15 @@
1415
logger = getLogger(__name__)
1516

1617

18+
class StackChanInfo(BaseModel):
19+
ip: str
20+
state: str
21+
22+
23+
class SpeakRequest(BaseModel):
24+
text: str
25+
26+
1727
class StackChanApp:
1828
def __init__(
1929
self,
@@ -25,6 +35,7 @@ def __init__(
2535
self.fastapi = FastAPI(title="StackChan WebSocket Server")
2636
self._setup_fn: Optional[Callable[[WsProxy], Awaitable[None]]] = None
2737
self._talk_session_fn: Optional[Callable[[WsProxy], Awaitable[None]]] = None
38+
self._proxies: dict[str, WsProxy] = {}
2839

2940
@self.fastapi.get("/health")
3041
async def _health() -> dict[str, str]:
@@ -34,6 +45,34 @@ async def _health() -> dict[str, str]:
3445
async def _ws_audio(websocket: WebSocket):
3546
await self._handle_ws(websocket)
3647

48+
@self.fastapi.get("/v1/stackchan", response_model=list[StackChanInfo])
49+
async def _list_stackchans():
50+
return [
51+
StackChanInfo(ip=ip, state=proxy.current_state.name.lower())
52+
for ip, proxy in self._proxies.items()
53+
]
54+
55+
@self.fastapi.get("/v1/stackchan/{stackchan_ip}", response_model=StackChanInfo)
56+
async def _get_stackchan(stackchan_ip: str):
57+
proxy = self._proxies.get(stackchan_ip)
58+
if proxy is None:
59+
raise HTTPException(status_code=404, detail="stackchan not connected")
60+
return StackChanInfo(ip=stackchan_ip, state=proxy.current_state.name.lower())
61+
62+
@self.fastapi.post("/v1/stackchan/{stackchan_ip}/wakeword", status_code=204)
63+
async def _trigger_wakeword(stackchan_ip: str):
64+
proxy = self._proxies.get(stackchan_ip)
65+
if proxy is None:
66+
raise HTTPException(status_code=404, detail="stackchan not connected")
67+
proxy.trigger_wakeword()
68+
69+
@self.fastapi.post("/v1/stackchan/{stackchan_ip}/speak", status_code=204)
70+
async def _speak(stackchan_ip: str, body: SpeakRequest):
71+
proxy = self._proxies.get(stackchan_ip)
72+
if proxy is None:
73+
raise HTTPException(status_code=404, detail="stackchan not connected")
74+
await proxy.speak(body.text)
75+
3776
def setup(self, fn: Callable[["WsProxy"], Awaitable[None]]):
3877
self._setup_fn = fn
3978
return fn
@@ -44,11 +83,21 @@ def talk_session(self, fn: Callable[["WsProxy"], Awaitable[None]]):
4483

4584
async def _handle_ws(self, websocket: WebSocket) -> None:
4685
await websocket.accept()
86+
client_ip = websocket.client.host if websocket.client else "unknown"
87+
88+
# 同一 IP からの既存接続があれば切断する
89+
existing = self._proxies.get(client_ip)
90+
if existing is not None:
91+
logger.info("Duplicate connection from %s, closing old one", client_ip)
92+
await existing.close()
93+
self._proxies.pop(client_ip, None)
94+
4795
proxy = WsProxy(
4896
websocket,
4997
speech_recognizer=self.speech_recognizer,
5098
speech_synthesizer=self.speech_synthesizer,
5199
)
100+
self._proxies[client_ip] = proxy
52101
await proxy.start()
53102
try:
54103
if self._setup_fn:
@@ -82,6 +131,7 @@ async def _handle_ws(self, websocket: WebSocket) -> None:
82131
pass
83132
finally:
84133
await proxy.close()
134+
self._proxies.pop(client_ip, None)
85135

86136
def run(self, host: str = "0.0.0.0", port: int = 8000, reload: bool = True) -> None:
87137
import uvicorn

stackchan_server/ws_proxy.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,15 +100,25 @@ def __init__(
100100
self._closed = False
101101

102102
self._down_seq = 0
103+
self._current_firmware_state: FirmwareState = FirmwareState.IDLE
103104

104105
@property
105106
def closed(self) -> bool:
106107
return self._closed
107108

109+
@property
110+
def current_state(self) -> FirmwareState:
111+
return self._current_firmware_state
112+
108113
@property
109114
def receive_task(self) -> Optional[asyncio.Task]:
110115
return self._receiving_task
111116

117+
def trigger_wakeword(self) -> None:
118+
"""Web API から擬似的に WAKEWORD_EVT を発火させる。"""
119+
logger.info("Triggered wakeword via API")
120+
self._wakeword_event.set()
121+
112122
async def wait_for_talk_session(self) -> None:
113123
while True:
114124
if self._wakeword_event.is_set():
@@ -232,6 +242,7 @@ def _handle_state_event(self, msg_type: int, payload: bytes) -> None:
232242
raw_state = int(payload[0])
233243
try:
234244
state = FirmwareState(raw_state)
245+
self._current_firmware_state = state
235246
logger.info("Received firmware state=%s(%d)", state.name, raw_state)
236247
except ValueError:
237248
logger.info("Received firmware state=%d", raw_state)

0 commit comments

Comments
 (0)