44from logging import getLogger
55from 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
910from .speech_recognition import create_speech_recognizer
1011from .speech_synthesis import create_speech_synthesizer
1415logger = 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+
1727class 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
0 commit comments