Implements the room-control page from the design mockup: a scenes row above cards for shutters, color lamps, and brightness-only lamps, all driven by a new `general.ha` config section (server URL, token, ordered device/scene lists). The backend proxies every Home Assistant call server-side (GET/POST /api/ha/...) rather than the browser calling Home Assistant directly, so the long-lived token never leaves the LAN device and Home Assistant's own CORS settings don't need to know about musicmouse at all. Card kind (shutter/color/brightness-only) is inferred at runtime from what Home Assistant reports about each entity, not configured explicitly. Also stops tracking python-backend/config.yml, which had drifted into the repo despite its own header saying it shouldn't be - it now carries real credentials locally and needs to stay untracked. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
103 lines
3.7 KiB
Python
103 lines
3.7 KiB
Python
"""The web front-end, as a :class:`~musicmouse.services.base.Service`.
|
|
|
|
Everything runs on the one event loop the rest of the app already has: uvicorn's
|
|
``Server.serve()`` is a coroutine, so there is no second loop and no thread. The
|
|
service owns no state of its own - it turns HTTP into intents on the bus, and bus
|
|
events into websocket frames.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
import httpx2
|
|
import uvicorn
|
|
from fastapi import FastAPI
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from musicmouse.app import App
|
|
from musicmouse.config import WebConfig
|
|
from musicmouse.services.web.api import build_router
|
|
from musicmouse.services.web.hub import StateHub
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
__all__ = ["WebService", "build_app"]
|
|
|
|
|
|
def build_app(
|
|
app: App,
|
|
config: WebConfig,
|
|
config_path: Path,
|
|
ha_client: httpx2.AsyncClient | None = None,
|
|
) -> tuple[FastAPI, StateHub]:
|
|
"""Assemble the ASGI app. Separate from the service so tests can drive it directly.
|
|
|
|
``ha_client`` is the outbound client the room-control routes proxy Home Assistant
|
|
calls through; tests inject one on a mock transport, production gets a real one
|
|
that :class:`WebService` closes on shutdown.
|
|
"""
|
|
hub = StateHub(app)
|
|
ha_client = ha_client or httpx2.AsyncClient(timeout=10.0)
|
|
api = FastAPI(title="MusicMouse", docs_url="/api/docs", openapi_url="/api/openapi.json")
|
|
api.state.ha_client = ha_client
|
|
api.include_router(build_router(app, hub, config_path, ha_client))
|
|
|
|
if config.static_dir is not None:
|
|
if config.static_dir.is_dir():
|
|
# Mounted last and at the root so every /api route still wins; html=True
|
|
# falls back to index.html, which is what a client-side router needs.
|
|
api.mount("/", StaticFiles(directory=config.static_dir, html=True), name="web")
|
|
else:
|
|
_log.warning(
|
|
"web.static_dir %s does not exist; serving the API only "
|
|
"(run `npm run build` in web/, or drop the setting)",
|
|
config.static_dir,
|
|
)
|
|
return api, hub
|
|
|
|
|
|
class WebService:
|
|
name = "web"
|
|
|
|
def __init__(self, app: App, config: WebConfig, config_path: Path) -> None:
|
|
self.config = config
|
|
self.api, self.hub = build_app(app, config, config_path)
|
|
|
|
async def run(self) -> None:
|
|
self.hub.start()
|
|
server = uvicorn.Server(
|
|
uvicorn.Config(
|
|
self.api,
|
|
host=self.config.host,
|
|
port=self.config.port,
|
|
# Access logs for a progress-bar poll every 500ms are noise.
|
|
access_log=False,
|
|
log_level="warning",
|
|
)
|
|
)
|
|
_log.info("Web front-end on http://%s:%d", self.config.host, self.config.port)
|
|
|
|
# serve() borrows SIGINT for a graceful shutdown and then re-raises it, so
|
|
# Ctrl-C still reaches __main__'s KeyboardInterrupt handler afterwards.
|
|
position = asyncio.create_task(self.hub.run(), name="web-position")
|
|
serving = asyncio.create_task(server.serve(), name="web-serve")
|
|
try:
|
|
await asyncio.shield(serving)
|
|
except asyncio.CancelledError:
|
|
# Shutdown reaches us as a cancellation. Cancelling uvicorn mid-accept
|
|
# would leave its listening socket to the garbage collector, so ask it to
|
|
# wind down and wait for it to let the port go.
|
|
server.should_exit = True
|
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
await asyncio.wait_for(serving, timeout=5.0)
|
|
raise
|
|
finally:
|
|
position.cancel()
|
|
serving.cancel()
|
|
self.hub.stop()
|
|
await self.api.state.ha_client.aclose()
|