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.
- sofabaton_server/__init__.py +15 -0
- sofabaton_server/app.py +219 -0
- sofabaton_server/callbacks.py +1079 -0
- sofabaton_server/cli.py +119 -0
- sofabaton_server/config.py +183 -0
- sofabaton_server/discovery.py +284 -0
- sofabaton_server/jobs.py +232 -0
- sofabaton_server/manager.py +406 -0
- sofabaton_server/models.py +126 -0
- sofabaton_server/openapi.py +40 -0
- sofabaton_server/problems.py +245 -0
- sofabaton_server/routes_apply.py +424 -0
- sofabaton_server/routes_callbacks.py +367 -0
- sofabaton_server/routes_discovery.py +32 -0
- sofabaton_server/routes_edit.py +530 -0
- sofabaton_server/routes_hub_data.py +271 -0
- sofabaton_server/routes_hubs.py +108 -0
- sofabaton_server/routes_payload.py +256 -0
- sofabaton_server/routes_snapshot.py +276 -0
- sofabaton_server/routes_ui.py +208 -0
- sofabaton_server/store.py +244 -0
- sofabaton_server/ui/panel/index.html +22 -0
- sofabaton_server/ui/panel/panel.js +12264 -0
- sofabaton_server/ui/remote/icon.svg +8 -0
- sofabaton_server/ui/remote/index.html +25 -0
- sofabaton_server/ui/remote/manifest.webmanifest +14 -0
- sofabaton_server/ui/remote/remote-web.js +10593 -0
- sofabaton_server/ws.py +255 -0
- sofabaton_x_server-0.2.0.dist-info/METADATA +758 -0
- sofabaton_x_server-0.2.0.dist-info/RECORD +32 -0
- sofabaton_x_server-0.2.0.dist-info/WHEEL +4 -0
- sofabaton_x_server-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -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"
|
sofabaton_server/app.py
ADDED
|
@@ -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]
|