sofabaton-x-server 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ """sofabaton-x-server: REST + WebSocket over the sofabaton-x library.
2
+
3
+ The server is a consumer of the library exactly like the Home Assistant
4
+ integration is. It owns persistence, discovery policy and network
5
+ exposure; everything it does against a hub goes through ``sofabaton``
6
+ root names, never the engine. Plan: docs/internal/sofabaton-x-server-plan.md.
7
+ """
8
+
9
+ __version__ = "0.2.0"
10
+
11
+ # The API contract version advertised in mDNS TXT and reported by
12
+ # GET /api/v1/server. Bumps only when the OpenAPI document changes in a
13
+ # way generated clients must know about.
14
+ API_VERSION = "1"
15
+ API_PREFIX = "/api/v1"
@@ -0,0 +1,219 @@
1
+ """FastAPI application factory.
2
+
3
+ Wires settings, hub management, discovery, jobs, callbacks, REST routes
4
+ and the WebSocket lifecycle. The OpenAPI document uses stable operation
5
+ IDs, named components, the advertised server URL and any root path for
6
+ generated clients.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from contextlib import asynccontextmanager
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, AsyncIterator, Optional
15
+
16
+ from fastapi import FastAPI
17
+
18
+ from . import API_PREFIX, API_VERSION, __version__
19
+ from .callbacks import CallbackService, ListenerState
20
+ from .config import Settings
21
+ from .discovery import DiscoveryService
22
+ from .jobs import JobRunner
23
+ from .manager import HubManager
24
+ from .problems import install as install_problem_handler, problem_body
25
+ from .routes_callbacks import router as callbacks_router, server_router as callback_listener_router
26
+ from .routes_apply import router as apply_router
27
+ from .routes_discovery import router as discovery_router
28
+ from .routes_edit import router as edit_router
29
+ from .routes_hub_data import router as hub_data_router
30
+ from .routes_hubs import router as hubs_router
31
+ from .routes_payload import router as payload_router
32
+ from .routes_snapshot import router as snapshot_router
33
+ from .store import ApplyStore
34
+ from .routes_ui import router as ui_router, ui_pages_router
35
+ from .ws import WS_MESSAGE_TYPES, EventRelay, WsPress, router as events_router
36
+
37
+ # ``sofabaton`` is the library's import name (PyPI: sofabaton-x). Only
38
+ # its version is needed at the skeleton stage; the hub manager (S1) is
39
+ # where AsyncXProxy comes in.
40
+ from sofabaton import __version__ as library_version
41
+
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ServerInfo:
46
+ """What ``GET /api/v1/server`` returns (and what the mDNS TXT says, in full)."""
47
+
48
+ name: str
49
+ version: str
50
+ library_version: str
51
+ api_version: str
52
+ api_path: str
53
+ hubs: int
54
+ uptime_seconds: float
55
+ base_url: str | None = None
56
+ features: list[str] = field(default_factory=list)
57
+ # Minted at boot; the press sequence and ring belong to it.
58
+ instance_id: str = ""
59
+ callback_listener: ListenerState | None = None
60
+
61
+
62
+ def create_app(settings: Settings | None = None, *, manager: Optional[HubManager] = None,
63
+ discovery: Optional[DiscoveryService] = None,
64
+ ws_queue_size: Optional[int] = None,
65
+ callbacks: Optional[CallbackService] = None) -> FastAPI:
66
+ """Build the application. ``manager`` is injectable for tests; by
67
+ default one is created from the settings and started with the app."""
68
+
69
+ settings = settings or Settings()
70
+ started = time.monotonic()
71
+ hub_manager = manager or HubManager(settings)
72
+ discovery_service = discovery or DiscoveryService(settings, hub_manager)
73
+ job_runner = JobRunner(problem_for=problem_body)
74
+ callback_service = callbacks or CallbackService(hub_manager, settings)
75
+
76
+ @asynccontextmanager
77
+ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
78
+ # Discovery first: it owns the shared Zeroconf the proxies adopt.
79
+ await discovery_service.start()
80
+ await hub_manager.start()
81
+ # Then the callback devices: pending intents reconcile against
82
+ # the running hubs, and the listener comes up when any exist.
83
+ await callback_service.start()
84
+ try:
85
+ yield
86
+ finally:
87
+ await job_runner.shutdown()
88
+ await callback_service.stop()
89
+ await hub_manager.stop()
90
+ await discovery_service.stop()
91
+
92
+ app = FastAPI(
93
+ lifespan=lifespan,
94
+ title="sofabaton-x-server",
95
+ version=__version__,
96
+ description=(
97
+ "REST + WebSocket over the sofabaton-x library for Sofabaton "
98
+ "X1 / X1S / X2 hubs. LAN service; no authentication in v1."
99
+ ),
100
+ root_path=settings.root_path,
101
+ # An advertised URL already carries any public prefix, so it must
102
+ # be the one and only servers entry; without one, FastAPI's own
103
+ # root-path entry is the best hint a client gets.
104
+ servers=[{"url": settings.advertise_url}] if settings.advertise_url else None,
105
+ root_path_in_servers=not bool(settings.advertise_url),
106
+ openapi_url=f"{API_PREFIX}/openapi.json",
107
+ docs_url=f"{API_PREFIX}/docs",
108
+ redoc_url=None,
109
+ )
110
+ app.state.settings = settings
111
+ app.state.hub_manager = hub_manager
112
+ app.state.discovery = discovery_service
113
+ app.state.job_runner = job_runner
114
+ app.state.apply_store = ApplyStore(settings.data_dir, keep=settings.apply_keep)
115
+ relay = EventRelay(hub_manager, jobs=job_runner, **({"maxsize": ws_queue_size} if ws_queue_size else {}))
116
+ relay.instance_id = callback_service.ring.instance_id
117
+ callback_service.on_press(lambda press: relay.publish(press.hub_id, WsPress(**press.to_dict())))
118
+ app.state.event_relay = relay
119
+ app.state.callbacks = callback_service
120
+ install_problem_handler(app)
121
+ app.include_router(hubs_router)
122
+ app.include_router(callbacks_router)
123
+ app.include_router(callback_listener_router)
124
+ app.include_router(hub_data_router)
125
+ app.include_router(snapshot_router)
126
+ app.include_router(edit_router)
127
+ app.include_router(apply_router)
128
+ app.include_router(payload_router)
129
+ app.include_router(events_router)
130
+ app.include_router(discovery_router)
131
+ app.include_router(ui_router)
132
+ app.include_router(ui_pages_router)
133
+ _publish_ws_components(app)
134
+
135
+
136
+ @app.get(
137
+ f"{API_PREFIX}/server",
138
+ operation_id="getServerInfo",
139
+ response_model=ServerInfo,
140
+ summary="Identify the server",
141
+ tags=["server"],
142
+ )
143
+ async def get_server_info() -> ServerInfo:
144
+ return ServerInfo(
145
+ name="sofabaton-x-server",
146
+ version=__version__,
147
+ library_version=library_version,
148
+ api_version=API_VERSION,
149
+ api_path=API_PREFIX,
150
+ hubs=_hub_count(app),
151
+ uptime_seconds=round(time.monotonic() - started, 1),
152
+ base_url=settings.advertise_url,
153
+ features=(["discovery"] if discovery_service.enabled else []) + ["callbacks"],
154
+ instance_id=callback_service.ring.instance_id,
155
+ callback_listener=callback_service.listener_state(),
156
+ )
157
+
158
+ return app
159
+
160
+
161
+ def _hub_count(app: FastAPI) -> int:
162
+ manager: Any = getattr(app.state, "hub_manager", None)
163
+ return int(manager.count()) if manager is not None else 0
164
+
165
+
166
+ def _publish_ws_components(app: FastAPI) -> None:
167
+ """Add the WebSocket message types to the OpenAPI components.
168
+
169
+ OpenAPI cannot describe a WebSocket route, but generated clients
170
+ still want the message types. The document's ``info.description``
171
+ points at the endpoint; the schemas ride along as components under
172
+ their dataclass names.
173
+ """
174
+
175
+ from pydantic import TypeAdapter
176
+
177
+ default_openapi = app.openapi
178
+
179
+ def openapi() -> dict:
180
+ if app.openapi_schema:
181
+ return app.openapi_schema
182
+ spec = default_openapi()
183
+ components = spec.setdefault("components", {}).setdefault("schemas", {})
184
+ for message_type in WS_MESSAGE_TYPES:
185
+ schema = TypeAdapter(message_type).json_schema(
186
+ mode="serialization", ref_template="#/components/schemas/{model}"
187
+ )
188
+ for name, definition in schema.pop("$defs", {}).items():
189
+ components.setdefault(name, definition)
190
+ components[message_type.__name__] = schema
191
+ # The document is committed and compared byte for byte, so nothing
192
+ # in it may depend on the framework version: FastAPI's default 422
193
+ # description changed wording between releases, and its
194
+ # HTTPValidationError body is not what this app sends. Every 422
195
+ # the app can produce is a Problem (the validation handler in
196
+ # problems.py makes that true for bad input too), so the contract
197
+ # states its own description and schema.
198
+ problem_ref = {"$ref": "#/components/schemas/Problem"}
199
+ for operations in spec.get("paths", {}).values():
200
+ for operation in operations.values():
201
+ responses = operation.get("responses", {}) if isinstance(operation, dict) else {}
202
+ if "422" in responses:
203
+ responses["422"] = {
204
+ "description": "Validation error",
205
+ "content": {"application/json": {"schema": problem_ref}},
206
+ }
207
+ for orphan in ("HTTPValidationError", "ValidationError"):
208
+ components.pop(orphan, None)
209
+ blank = chr(10) + chr(10)
210
+ spec["info"]["description"] = (
211
+ spec["info"].get("description", "")
212
+ + blank
213
+ + f"WebSocket: `{API_PREFIX}/events` (optional `?hub_id=` filter, repeatable) streams "
214
+ "WsHello once, then WsHubEvent / WsServerEvent / WsJobEvent / WsPress / WsDropped messages (see components)."
215
+ )
216
+ app.openapi_schema = spec
217
+ return spec
218
+
219
+ app.openapi = openapi # type: ignore[method-assign]