Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions src/bub_face/plugin.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import asyncio

import aiohttp
from bub import hookimpl
from bub.channels import Channel
from bub.envelope import field_of
from bub.types import Envelope, MessageHandler, State
from loguru import logger

from bub_face.server import PORT
from bub_face.state import Emotion, shared_controller


class FaceChannel(Channel):
Expand All @@ -33,9 +32,12 @@ async def stop(self) -> None:
self._ongoing_task = None


@hookimpl
@hookimpl(tryfirst=True)
def provide_channels(message_handler: MessageHandler) -> list[Channel]:
_ = message_handler # not used
from bub_face.terminal import patch_cli_channel

patch_cli_channel()
return [FaceChannel()]


Expand All @@ -44,9 +46,5 @@ async def load_state(message: Envelope, session_id: str) -> State:
_ = session_id # not used
channel = field_of(message, "channel")
if channel == "xiaoai":
async with aiohttp.ClientSession() as session:
async with session.post(
f"http://localhost:{PORT}/api/emotion", json={"emotion": "neutral"}
):
pass
shared_controller().set_emotion(Emotion.NEUTRAL)
return {}
24 changes: 14 additions & 10 deletions src/bub_face/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from aiohttp.typedefs import Handler

from bub_face import StateController
from bub_face.state import shared_controller

ROOT = Path(__file__).resolve().parent
STATIC_DIR = ROOT / "static"
Expand All @@ -28,7 +29,6 @@ async def set_emotion(request: web.Request) -> web.Response:
payload = await request.json()
emotion = payload["emotion"]
state = controller.set_emotion(emotion)
await broadcast_state(request.app, source="emotion")
return web.json_response(
{
"state": state.to_dict(),
Expand All @@ -41,7 +41,6 @@ async def patch_state(request: web.Request) -> web.Response:
controller: StateController = request.app["controller"]
payload = await request.json()
state = controller.patch(payload)
await broadcast_state(request.app, source="patch")
return web.json_response(
{
"state": state.to_dict(),
Expand All @@ -53,7 +52,6 @@ async def patch_state(request: web.Request) -> web.Response:
async def reset_state(request: web.Request) -> web.Response:
controller: StateController = request.app["controller"]
state = controller.reset()
await broadcast_state(request.app, source="reset")
return web.json_response(
{
"state": state.to_dict(),
Expand All @@ -65,7 +63,6 @@ async def reset_state(request: web.Request) -> web.Response:
async def sleep_state(request: web.Request) -> web.Response:
controller: StateController = request.app["controller"]
controller.sleep()
await broadcast_state(request.app, source="sleep")
return web.json_response(controller.snapshot())


Expand Down Expand Up @@ -104,9 +101,6 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
await ws.send_json(
{"type": "error", "message": f"Unsupported action: {action}"}
)
continue

await broadcast_state(request.app, source="ws")
finally:
sockets.discard(ws)

Expand Down Expand Up @@ -147,16 +141,26 @@ async def on_shutdown(app: web.Application) -> None:


async def on_startup(app: web.Application) -> None:
def _on_state_change() -> None:
asyncio.ensure_future(broadcast_state(app, source="controller"))

controller: StateController = app["controller"]
controller.add_listener(_on_state_change)
app["_state_listener"] = _on_state_change

async def idle_watchdog() -> None:
while True:
await asyncio.sleep(1)
if app["controller"].maybe_sleep():
await broadcast_state(app, source="idle_timeout")
controller.maybe_sleep()

app["idle_watchdog_task"] = asyncio.create_task(idle_watchdog())


async def on_cleanup(app: web.Application) -> None:
listener = app.pop("_state_listener", None)
if listener is not None:
app["controller"].remove_listener(listener)

task: asyncio.Task[None] | None = app.get("idle_watchdog_task")
if task is None:
return
Expand All @@ -169,7 +173,7 @@ async def on_cleanup(app: web.Application) -> None:

def create_app() -> web.Application:
app = web.Application(middlewares=[error_middleware])
app["controller"] = StateController(idle_timeout_seconds=600)
app["controller"] = shared_controller()
app["sockets"] = set()

app.router.add_get("/", index)
Expand Down
28 changes: 28 additions & 0 deletions src/bub_face/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ def __init__(
self._time_fn = time_fn or monotonic
self._display_mode = DisplayMode.FACE
self._last_active_at = self._time_fn()
self._listeners: list[Callable[[], None]] = []

def add_listener(self, callback: Callable[[], None]) -> None:
self._listeners.append(callback)

def remove_listener(self, callback: Callable[[], None]) -> None:
self._listeners.remove(callback)

def _notify(self) -> None:
for listener in self._listeners:
listener()

@property
def state(self) -> EyeState:
Expand All @@ -198,12 +209,14 @@ def snapshot(self) -> dict[str, Any]:
def reset(self) -> EyeState:
self.wake()
self._state = self._preset(Emotion.NEUTRAL)
self._notify()
return self._state

def set_emotion(self, emotion: str | Emotion) -> EyeState:
self.wake()
parsed = emotion if isinstance(emotion, Emotion) else Emotion(emotion)
self._state = self._preset(parsed)
self._notify()
return self._state

def patch(self, payload: dict[str, Any]) -> EyeState:
Expand Down Expand Up @@ -232,6 +245,7 @@ def patch(self, payload: dict[str, Any]) -> EyeState:
)

self._state = EyeState(**filtered)
self._notify()
return self._state

def list_emotions(self) -> list[str]:
Expand All @@ -248,6 +262,7 @@ def sleep(self) -> bool:
if self._display_mode is DisplayMode.CLOCK:
return False
self._display_mode = DisplayMode.CLOCK
self._notify()
return True

def maybe_sleep(self) -> bool:
Expand All @@ -259,3 +274,16 @@ def maybe_sleep(self) -> bool:

def _preset(self, emotion: Emotion) -> EyeState:
return EyeState(emotion=emotion, **EMOTION_PRESETS[emotion])


DEFAULT_IDLE_TIMEOUT_SECONDS = 600
_shared_controller: StateController | None = None


def shared_controller() -> StateController:
global _shared_controller
if _shared_controller is None:
_shared_controller = StateController(
idle_timeout_seconds=DEFAULT_IDLE_TIMEOUT_SECONDS
)
return _shared_controller
Loading
Loading