aiohomematic 2026.1.29__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.
- aiohomematic/__init__.py +110 -0
- aiohomematic/_log_context_protocol.py +29 -0
- aiohomematic/api.py +410 -0
- aiohomematic/async_support.py +250 -0
- aiohomematic/backend_detection.py +462 -0
- aiohomematic/central/__init__.py +103 -0
- aiohomematic/central/async_rpc_server.py +760 -0
- aiohomematic/central/central_unit.py +1152 -0
- aiohomematic/central/config.py +463 -0
- aiohomematic/central/config_builder.py +772 -0
- aiohomematic/central/connection_state.py +160 -0
- aiohomematic/central/coordinators/__init__.py +38 -0
- aiohomematic/central/coordinators/cache.py +414 -0
- aiohomematic/central/coordinators/client.py +480 -0
- aiohomematic/central/coordinators/connection_recovery.py +1141 -0
- aiohomematic/central/coordinators/device.py +1166 -0
- aiohomematic/central/coordinators/event.py +514 -0
- aiohomematic/central/coordinators/hub.py +532 -0
- aiohomematic/central/decorators.py +184 -0
- aiohomematic/central/device_registry.py +229 -0
- aiohomematic/central/events/__init__.py +104 -0
- aiohomematic/central/events/bus.py +1392 -0
- aiohomematic/central/events/integration.py +424 -0
- aiohomematic/central/events/types.py +194 -0
- aiohomematic/central/health.py +762 -0
- aiohomematic/central/rpc_server.py +353 -0
- aiohomematic/central/scheduler.py +794 -0
- aiohomematic/central/state_machine.py +391 -0
- aiohomematic/client/__init__.py +203 -0
- aiohomematic/client/_rpc_errors.py +187 -0
- aiohomematic/client/backends/__init__.py +48 -0
- aiohomematic/client/backends/base.py +335 -0
- aiohomematic/client/backends/capabilities.py +138 -0
- aiohomematic/client/backends/ccu.py +487 -0
- aiohomematic/client/backends/factory.py +116 -0
- aiohomematic/client/backends/homegear.py +294 -0
- aiohomematic/client/backends/json_ccu.py +252 -0
- aiohomematic/client/backends/protocol.py +316 -0
- aiohomematic/client/ccu.py +1857 -0
- aiohomematic/client/circuit_breaker.py +459 -0
- aiohomematic/client/config.py +64 -0
- aiohomematic/client/handlers/__init__.py +40 -0
- aiohomematic/client/handlers/backup.py +157 -0
- aiohomematic/client/handlers/base.py +79 -0
- aiohomematic/client/handlers/device_ops.py +1085 -0
- aiohomematic/client/handlers/firmware.py +144 -0
- aiohomematic/client/handlers/link_mgmt.py +199 -0
- aiohomematic/client/handlers/metadata.py +436 -0
- aiohomematic/client/handlers/programs.py +144 -0
- aiohomematic/client/handlers/sysvars.py +100 -0
- aiohomematic/client/interface_client.py +1304 -0
- aiohomematic/client/json_rpc.py +2068 -0
- aiohomematic/client/request_coalescer.py +282 -0
- aiohomematic/client/rpc_proxy.py +629 -0
- aiohomematic/client/state_machine.py +324 -0
- aiohomematic/const.py +2207 -0
- aiohomematic/context.py +275 -0
- aiohomematic/converter.py +270 -0
- aiohomematic/decorators.py +390 -0
- aiohomematic/exceptions.py +185 -0
- aiohomematic/hmcli.py +997 -0
- aiohomematic/i18n.py +193 -0
- aiohomematic/interfaces/__init__.py +407 -0
- aiohomematic/interfaces/central.py +1067 -0
- aiohomematic/interfaces/client.py +1096 -0
- aiohomematic/interfaces/coordinators.py +63 -0
- aiohomematic/interfaces/model.py +1921 -0
- aiohomematic/interfaces/operations.py +217 -0
- aiohomematic/logging_context.py +134 -0
- aiohomematic/metrics/__init__.py +125 -0
- aiohomematic/metrics/_protocols.py +140 -0
- aiohomematic/metrics/aggregator.py +534 -0
- aiohomematic/metrics/dataclasses.py +489 -0
- aiohomematic/metrics/emitter.py +292 -0
- aiohomematic/metrics/events.py +183 -0
- aiohomematic/metrics/keys.py +300 -0
- aiohomematic/metrics/observer.py +563 -0
- aiohomematic/metrics/stats.py +172 -0
- aiohomematic/model/__init__.py +189 -0
- aiohomematic/model/availability.py +65 -0
- aiohomematic/model/calculated/__init__.py +89 -0
- aiohomematic/model/calculated/climate.py +276 -0
- aiohomematic/model/calculated/data_point.py +315 -0
- aiohomematic/model/calculated/field.py +147 -0
- aiohomematic/model/calculated/operating_voltage_level.py +286 -0
- aiohomematic/model/calculated/support.py +232 -0
- aiohomematic/model/custom/__init__.py +214 -0
- aiohomematic/model/custom/capabilities/__init__.py +67 -0
- aiohomematic/model/custom/capabilities/climate.py +41 -0
- aiohomematic/model/custom/capabilities/light.py +87 -0
- aiohomematic/model/custom/capabilities/lock.py +44 -0
- aiohomematic/model/custom/capabilities/siren.py +63 -0
- aiohomematic/model/custom/climate.py +1130 -0
- aiohomematic/model/custom/cover.py +722 -0
- aiohomematic/model/custom/data_point.py +360 -0
- aiohomematic/model/custom/definition.py +300 -0
- aiohomematic/model/custom/field.py +89 -0
- aiohomematic/model/custom/light.py +1174 -0
- aiohomematic/model/custom/lock.py +322 -0
- aiohomematic/model/custom/mixins.py +445 -0
- aiohomematic/model/custom/profile.py +945 -0
- aiohomematic/model/custom/registry.py +251 -0
- aiohomematic/model/custom/siren.py +462 -0
- aiohomematic/model/custom/switch.py +195 -0
- aiohomematic/model/custom/text_display.py +289 -0
- aiohomematic/model/custom/valve.py +78 -0
- aiohomematic/model/data_point.py +1416 -0
- aiohomematic/model/device.py +1840 -0
- aiohomematic/model/event.py +216 -0
- aiohomematic/model/generic/__init__.py +327 -0
- aiohomematic/model/generic/action.py +40 -0
- aiohomematic/model/generic/action_select.py +62 -0
- aiohomematic/model/generic/binary_sensor.py +30 -0
- aiohomematic/model/generic/button.py +31 -0
- aiohomematic/model/generic/data_point.py +177 -0
- aiohomematic/model/generic/dummy.py +150 -0
- aiohomematic/model/generic/number.py +76 -0
- aiohomematic/model/generic/select.py +56 -0
- aiohomematic/model/generic/sensor.py +76 -0
- aiohomematic/model/generic/switch.py +54 -0
- aiohomematic/model/generic/text.py +33 -0
- aiohomematic/model/hub/__init__.py +100 -0
- aiohomematic/model/hub/binary_sensor.py +24 -0
- aiohomematic/model/hub/button.py +28 -0
- aiohomematic/model/hub/connectivity.py +190 -0
- aiohomematic/model/hub/data_point.py +342 -0
- aiohomematic/model/hub/hub.py +864 -0
- aiohomematic/model/hub/inbox.py +135 -0
- aiohomematic/model/hub/install_mode.py +393 -0
- aiohomematic/model/hub/metrics.py +208 -0
- aiohomematic/model/hub/number.py +42 -0
- aiohomematic/model/hub/select.py +52 -0
- aiohomematic/model/hub/sensor.py +37 -0
- aiohomematic/model/hub/switch.py +43 -0
- aiohomematic/model/hub/text.py +30 -0
- aiohomematic/model/hub/update.py +221 -0
- aiohomematic/model/support.py +592 -0
- aiohomematic/model/update.py +140 -0
- aiohomematic/model/week_profile.py +1827 -0
- aiohomematic/property_decorators.py +719 -0
- aiohomematic/py.typed +0 -0
- aiohomematic/rega_scripts/accept_device_in_inbox.fn +51 -0
- aiohomematic/rega_scripts/create_backup_start.fn +28 -0
- aiohomematic/rega_scripts/create_backup_status.fn +89 -0
- aiohomematic/rega_scripts/fetch_all_device_data.fn +97 -0
- aiohomematic/rega_scripts/get_backend_info.fn +25 -0
- aiohomematic/rega_scripts/get_inbox_devices.fn +61 -0
- aiohomematic/rega_scripts/get_program_descriptions.fn +31 -0
- aiohomematic/rega_scripts/get_serial.fn +44 -0
- aiohomematic/rega_scripts/get_service_messages.fn +83 -0
- aiohomematic/rega_scripts/get_system_update_info.fn +39 -0
- aiohomematic/rega_scripts/get_system_variable_descriptions.fn +31 -0
- aiohomematic/rega_scripts/set_program_state.fn +17 -0
- aiohomematic/rega_scripts/set_system_variable.fn +19 -0
- aiohomematic/rega_scripts/trigger_firmware_update.fn +67 -0
- aiohomematic/schemas.py +256 -0
- aiohomematic/store/__init__.py +55 -0
- aiohomematic/store/dynamic/__init__.py +43 -0
- aiohomematic/store/dynamic/command.py +250 -0
- aiohomematic/store/dynamic/data.py +175 -0
- aiohomematic/store/dynamic/details.py +187 -0
- aiohomematic/store/dynamic/ping_pong.py +416 -0
- aiohomematic/store/persistent/__init__.py +71 -0
- aiohomematic/store/persistent/base.py +285 -0
- aiohomematic/store/persistent/device.py +233 -0
- aiohomematic/store/persistent/incident.py +380 -0
- aiohomematic/store/persistent/paramset.py +241 -0
- aiohomematic/store/persistent/session.py +556 -0
- aiohomematic/store/serialization.py +150 -0
- aiohomematic/store/storage.py +689 -0
- aiohomematic/store/types.py +526 -0
- aiohomematic/store/visibility/__init__.py +40 -0
- aiohomematic/store/visibility/parser.py +141 -0
- aiohomematic/store/visibility/registry.py +722 -0
- aiohomematic/store/visibility/rules.py +307 -0
- aiohomematic/strings.json +237 -0
- aiohomematic/support.py +706 -0
- aiohomematic/tracing.py +236 -0
- aiohomematic/translations/de.json +237 -0
- aiohomematic/translations/en.json +237 -0
- aiohomematic/type_aliases.py +51 -0
- aiohomematic/validator.py +128 -0
- aiohomematic-2026.1.29.dist-info/METADATA +296 -0
- aiohomematic-2026.1.29.dist-info/RECORD +188 -0
- aiohomematic-2026.1.29.dist-info/WHEEL +5 -0
- aiohomematic-2026.1.29.dist-info/entry_points.txt +2 -0
- aiohomematic-2026.1.29.dist-info/licenses/LICENSE +21 -0
- aiohomematic-2026.1.29.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2021-2026
|
|
3
|
+
"""
|
|
4
|
+
Async XML-RPC server module.
|
|
5
|
+
|
|
6
|
+
Provides an asyncio-native XML-RPC server using aiohttp for
|
|
7
|
+
receiving callbacks from the Homematic backend.
|
|
8
|
+
|
|
9
|
+
This is an experimental alternative to the thread-based XML-RPC server
|
|
10
|
+
(see ADR 0012). Enable via OptionalSettings.ASYNC_RPC_SERVER.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
19
|
+
from xml.parsers.expat import ExpatError
|
|
20
|
+
import xmlrpc.client
|
|
21
|
+
|
|
22
|
+
from aiohttp import web
|
|
23
|
+
import orjson
|
|
24
|
+
|
|
25
|
+
from aiohomematic import client as hmcl, i18n
|
|
26
|
+
from aiohomematic.const import IP_ANY_V4, PORT_ANY, SystemEventType, UpdateDeviceHint
|
|
27
|
+
from aiohomematic.interfaces.central import RpcServerCentralProtocol
|
|
28
|
+
from aiohomematic.metrics import MetricKeys, emit_counter, emit_gauge, emit_latency
|
|
29
|
+
from aiohomematic.schemas import normalize_device_description
|
|
30
|
+
from aiohomematic.support import get_device_address, log_boundary_error
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Awaitable, Callable
|
|
34
|
+
|
|
35
|
+
from aiohomematic.central.events import EventBus
|
|
36
|
+
|
|
37
|
+
_LOGGER: Final = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# Type alias for async method handlers
|
|
40
|
+
type AsyncMethodHandler = Callable[..., Awaitable[Any]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class XmlRpcProtocolError(Exception):
|
|
44
|
+
"""Exception for XML-RPC protocol errors."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AsyncXmlRpcDispatcher:
|
|
48
|
+
"""
|
|
49
|
+
Dispatcher for async XML-RPC method calls.
|
|
50
|
+
|
|
51
|
+
Parses XML-RPC requests and dispatches to registered async handlers.
|
|
52
|
+
Uses stdlib xmlrpc.client for parsing (no external dependencies).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
"""Initialize the dispatcher."""
|
|
57
|
+
self._methods: Final[dict[str, AsyncMethodHandler]] = {}
|
|
58
|
+
|
|
59
|
+
async def dispatch(self, *, xml_data: bytes) -> bytes:
|
|
60
|
+
"""
|
|
61
|
+
Parse XML-RPC request and dispatch to handler.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
xml_data: Raw XML-RPC request body
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
XML-RPC response as bytes
|
|
68
|
+
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
params, method_name = xmlrpc.client.loads(
|
|
72
|
+
xml_data,
|
|
73
|
+
use_builtin_types=True,
|
|
74
|
+
)
|
|
75
|
+
except ExpatError as err:
|
|
76
|
+
raise XmlRpcProtocolError(i18n.tr(key="exception.central.rpc_server.invalid_xml", error=err)) from err
|
|
77
|
+
except Exception as err:
|
|
78
|
+
raise XmlRpcProtocolError(i18n.tr(key="exception.central.rpc_server.parse_error", error=err)) from err
|
|
79
|
+
|
|
80
|
+
_LOGGER.debug(
|
|
81
|
+
"XML-RPC dispatch: method=%s, params=%s",
|
|
82
|
+
method_name,
|
|
83
|
+
params[:2] if len(params) > 2 else params,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Look up method
|
|
87
|
+
if method_name not in self._methods:
|
|
88
|
+
fault = xmlrpc.client.Fault(
|
|
89
|
+
faultCode=-32601,
|
|
90
|
+
faultString=f"Method not found: {method_name}",
|
|
91
|
+
)
|
|
92
|
+
return xmlrpc.client.dumps(fault, allow_none=True).encode("utf-8")
|
|
93
|
+
|
|
94
|
+
# Execute method
|
|
95
|
+
try:
|
|
96
|
+
handler = self._methods[method_name]
|
|
97
|
+
# XML-RPC requires a tuple for response
|
|
98
|
+
# Homematic expects acknowledgment (True) for None results
|
|
99
|
+
if (result := await handler(*params)) is None:
|
|
100
|
+
result = True
|
|
101
|
+
|
|
102
|
+
return xmlrpc.client.dumps(
|
|
103
|
+
(result,),
|
|
104
|
+
methodresponse=True,
|
|
105
|
+
allow_none=True,
|
|
106
|
+
).encode("utf-8")
|
|
107
|
+
except Exception as err:
|
|
108
|
+
_LOGGER.exception(i18n.tr(key="log.central.rpc_server.method_failed", method_name=method_name))
|
|
109
|
+
fault = xmlrpc.client.Fault(
|
|
110
|
+
faultCode=-32603,
|
|
111
|
+
faultString=str(err),
|
|
112
|
+
)
|
|
113
|
+
return xmlrpc.client.dumps(fault, allow_none=True).encode("utf-8")
|
|
114
|
+
|
|
115
|
+
def register_instance(self, *, instance: object) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Register all public methods of an instance.
|
|
118
|
+
|
|
119
|
+
Methods starting with underscore are ignored.
|
|
120
|
+
camelCase methods are registered as-is (required by Homematic protocol).
|
|
121
|
+
"""
|
|
122
|
+
for name in dir(instance):
|
|
123
|
+
if name.startswith("_"):
|
|
124
|
+
continue
|
|
125
|
+
method = getattr(instance, name)
|
|
126
|
+
if callable(method):
|
|
127
|
+
self._methods[name] = method
|
|
128
|
+
|
|
129
|
+
def register_introspection_functions(self) -> None:
|
|
130
|
+
"""Register XML-RPC introspection methods."""
|
|
131
|
+
self._methods["system.listMethods"] = self._system_list_methods
|
|
132
|
+
self._methods["system.methodHelp"] = self._system_method_help
|
|
133
|
+
self._methods["system.methodSignature"] = self._system_method_signature
|
|
134
|
+
self._methods["system.multicall"] = self._system_multicall
|
|
135
|
+
|
|
136
|
+
async def _system_list_methods(
|
|
137
|
+
self,
|
|
138
|
+
interface_id: str | None = None,
|
|
139
|
+
/,
|
|
140
|
+
) -> list[str]:
|
|
141
|
+
"""Return list of available methods."""
|
|
142
|
+
return sorted(self._methods.keys())
|
|
143
|
+
|
|
144
|
+
async def _system_method_help(self, method_name: str, /) -> str:
|
|
145
|
+
"""Return help string for a method."""
|
|
146
|
+
if method := self._methods.get(method_name):
|
|
147
|
+
return method.__doc__ or ""
|
|
148
|
+
return ""
|
|
149
|
+
|
|
150
|
+
async def _system_method_signature(
|
|
151
|
+
self,
|
|
152
|
+
method_name: str,
|
|
153
|
+
/,
|
|
154
|
+
) -> str:
|
|
155
|
+
"""Return signature for a method (not implemented)."""
|
|
156
|
+
return "signatures not supported"
|
|
157
|
+
|
|
158
|
+
async def _system_multicall(
|
|
159
|
+
self,
|
|
160
|
+
calls: list[dict[str, Any]],
|
|
161
|
+
/,
|
|
162
|
+
) -> list[Any]:
|
|
163
|
+
"""
|
|
164
|
+
Execute multiple method calls in a single request.
|
|
165
|
+
|
|
166
|
+
This is the standard XML-RPC multicall method used by the Homematic
|
|
167
|
+
backend to batch multiple event notifications together.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
calls: List of dicts with 'methodName' and 'params' keys
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
List of results (each wrapped in a list) or fault dicts.
|
|
174
|
+
|
|
175
|
+
"""
|
|
176
|
+
results: list[Any] = []
|
|
177
|
+
for call in calls:
|
|
178
|
+
method_name = call.get("methodName", "")
|
|
179
|
+
params = call.get("params", [])
|
|
180
|
+
|
|
181
|
+
if method_name not in self._methods:
|
|
182
|
+
results.append({"faultCode": -32601, "faultString": f"Method not found: {method_name}"})
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
handler = self._methods[method_name]
|
|
187
|
+
result = await handler(*params)
|
|
188
|
+
# XML-RPC multicall wraps each result in a list
|
|
189
|
+
results.append([result if result is not None else True])
|
|
190
|
+
except Exception as err: # noqa: BLE001
|
|
191
|
+
_LOGGER.debug("Multicall method %s failed: %s", method_name, err)
|
|
192
|
+
results.append({"faultCode": -32603, "faultString": str(err)})
|
|
193
|
+
|
|
194
|
+
return results
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# pylint: disable=invalid-name
|
|
198
|
+
class AsyncRPCFunctions:
|
|
199
|
+
"""
|
|
200
|
+
Async implementation of RPC callback functions.
|
|
201
|
+
|
|
202
|
+
Method names use camelCase as required by Homematic XML-RPC protocol.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
# Disable kw-only linter for protocol compatibility
|
|
206
|
+
__kwonly_check__ = False
|
|
207
|
+
|
|
208
|
+
def __init__(self, *, rpc_server: AsyncXmlRpcServer) -> None:
|
|
209
|
+
"""Initialize AsyncRPCFunctions."""
|
|
210
|
+
self._rpc_server: Final = rpc_server
|
|
211
|
+
# Store task references to prevent garbage collection (RUF006)
|
|
212
|
+
self._background_tasks: Final[set[asyncio.Task[None]]] = set()
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def active_tasks_count(self) -> int:
|
|
216
|
+
"""Return the number of active background tasks."""
|
|
217
|
+
return len(self._background_tasks)
|
|
218
|
+
|
|
219
|
+
async def cancel_background_tasks(self) -> None:
|
|
220
|
+
"""Cancel all background tasks and wait for them to complete."""
|
|
221
|
+
if not self._background_tasks:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
_LOGGER.debug(
|
|
225
|
+
"Cancelling %d background tasks",
|
|
226
|
+
len(self._background_tasks),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Cancel all tasks
|
|
230
|
+
for task in self._background_tasks:
|
|
231
|
+
task.cancel()
|
|
232
|
+
|
|
233
|
+
# Wait for all tasks to complete (with timeout)
|
|
234
|
+
if self._background_tasks:
|
|
235
|
+
await asyncio.wait(
|
|
236
|
+
self._background_tasks,
|
|
237
|
+
timeout=5.0,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
async def deleteDevices(
|
|
241
|
+
self,
|
|
242
|
+
interface_id: str,
|
|
243
|
+
addresses: list[str],
|
|
244
|
+
/,
|
|
245
|
+
) -> None:
|
|
246
|
+
"""Delete devices sent from the backend."""
|
|
247
|
+
if entry := self._get_central_entry(interface_id=interface_id):
|
|
248
|
+
# Fire-and-forget: schedule task and return immediately
|
|
249
|
+
self._create_background_task(
|
|
250
|
+
entry.central.device_coordinator.delete_devices(
|
|
251
|
+
interface_id=interface_id,
|
|
252
|
+
addresses=tuple(addresses),
|
|
253
|
+
),
|
|
254
|
+
name=f"deleteDevices-{interface_id}",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def error(
|
|
258
|
+
self,
|
|
259
|
+
interface_id: str,
|
|
260
|
+
error_code: str,
|
|
261
|
+
msg: str,
|
|
262
|
+
/,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Handle error notification from backend."""
|
|
265
|
+
try:
|
|
266
|
+
raise RuntimeError(str(msg))
|
|
267
|
+
except RuntimeError as err:
|
|
268
|
+
log_boundary_error(
|
|
269
|
+
logger=_LOGGER,
|
|
270
|
+
boundary="rpc-server",
|
|
271
|
+
action="error",
|
|
272
|
+
err=err,
|
|
273
|
+
level=logging.WARNING,
|
|
274
|
+
log_context={"interface_id": interface_id, "error_code": int(error_code)},
|
|
275
|
+
)
|
|
276
|
+
_LOGGER.error(
|
|
277
|
+
i18n.tr(
|
|
278
|
+
key="log.central.rpc_server.error",
|
|
279
|
+
interface_id=interface_id,
|
|
280
|
+
error_code=int(error_code),
|
|
281
|
+
msg=str(msg),
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
self._publish_system_event(interface_id=interface_id, system_event=SystemEventType.ERROR)
|
|
285
|
+
|
|
286
|
+
async def event(
|
|
287
|
+
self,
|
|
288
|
+
interface_id: str,
|
|
289
|
+
channel_address: str,
|
|
290
|
+
parameter: str,
|
|
291
|
+
value: Any,
|
|
292
|
+
/,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Handle data point event from backend."""
|
|
295
|
+
if entry := self._get_central_entry(interface_id=interface_id):
|
|
296
|
+
# Fire-and-forget: schedule task and return immediately
|
|
297
|
+
self._create_background_task(
|
|
298
|
+
entry.central.event_coordinator.data_point_event(
|
|
299
|
+
interface_id=interface_id,
|
|
300
|
+
channel_address=channel_address,
|
|
301
|
+
parameter=parameter,
|
|
302
|
+
value=value,
|
|
303
|
+
),
|
|
304
|
+
name=f"event-{interface_id}-{channel_address}-{parameter}",
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
_LOGGER.debug(
|
|
308
|
+
"EVENT: No central found for interface_id=%s, channel=%s, param=%s",
|
|
309
|
+
interface_id,
|
|
310
|
+
channel_address,
|
|
311
|
+
parameter,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
async def listDevices(
|
|
315
|
+
self,
|
|
316
|
+
interface_id: str,
|
|
317
|
+
/,
|
|
318
|
+
) -> list[dict[str, Any]]:
|
|
319
|
+
"""Return existing devices to the backend."""
|
|
320
|
+
# No normalization needed here - data is already normalized in cache
|
|
321
|
+
if entry := self._get_central_entry(interface_id=interface_id):
|
|
322
|
+
return [
|
|
323
|
+
dict(device_description)
|
|
324
|
+
for device_description in entry.central.device_coordinator.list_devices(interface_id=interface_id)
|
|
325
|
+
]
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
async def newDevices(
|
|
329
|
+
self,
|
|
330
|
+
interface_id: str,
|
|
331
|
+
device_descriptions: list[dict[str, Any]],
|
|
332
|
+
/,
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Handle new devices from backend (normalized)."""
|
|
335
|
+
if entry := self._get_central_entry(interface_id=interface_id):
|
|
336
|
+
# Normalize at callback entry point
|
|
337
|
+
normalized = tuple(normalize_device_description(device_description=desc) for desc in device_descriptions)
|
|
338
|
+
# Fire-and-forget: schedule task and return immediately
|
|
339
|
+
self._create_background_task(
|
|
340
|
+
entry.central.device_coordinator.add_new_devices(
|
|
341
|
+
interface_id=interface_id,
|
|
342
|
+
device_descriptions=normalized,
|
|
343
|
+
),
|
|
344
|
+
name=f"newDevices-{interface_id}",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
async def readdedDevice(
|
|
348
|
+
self,
|
|
349
|
+
interface_id: str,
|
|
350
|
+
addresses: list[str],
|
|
351
|
+
/,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Handle re-added device after re-pairing in learn mode.
|
|
355
|
+
|
|
356
|
+
Gets called when a known device is put into learn-mode while installation
|
|
357
|
+
mode is active. The device parameters may have changed, so we refresh
|
|
358
|
+
the device data.
|
|
359
|
+
"""
|
|
360
|
+
_LOGGER.debug(
|
|
361
|
+
"READDEDDEVICES: interface_id = %s, addresses = %s",
|
|
362
|
+
interface_id,
|
|
363
|
+
str(addresses),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Filter to device addresses only (exclude channel addresses)
|
|
367
|
+
if (entry := self._get_central_entry(interface_id=interface_id)) and (
|
|
368
|
+
device_addresses := tuple(addr for addr in addresses if ":" not in addr)
|
|
369
|
+
):
|
|
370
|
+
self._create_background_task(
|
|
371
|
+
entry.central.device_coordinator.readd_device(
|
|
372
|
+
interface_id=interface_id, device_addresses=device_addresses
|
|
373
|
+
),
|
|
374
|
+
name=f"readdedDevice-{interface_id}",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
async def replaceDevice(
|
|
378
|
+
self,
|
|
379
|
+
interface_id: str,
|
|
380
|
+
old_device_address: str,
|
|
381
|
+
new_device_address: str,
|
|
382
|
+
/,
|
|
383
|
+
) -> None:
|
|
384
|
+
"""
|
|
385
|
+
Handle device replacement from CCU.
|
|
386
|
+
|
|
387
|
+
Gets called when a user replaces a broken device with a new one using the
|
|
388
|
+
CCU's "Replace device" function. The old device is removed and the new
|
|
389
|
+
device is created with fresh descriptions.
|
|
390
|
+
"""
|
|
391
|
+
_LOGGER.debug(
|
|
392
|
+
"REPLACEDEVICE: interface_id = %s, oldDeviceAddress = %s, newDeviceAddress = %s",
|
|
393
|
+
interface_id,
|
|
394
|
+
old_device_address,
|
|
395
|
+
new_device_address,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if entry := self._get_central_entry(interface_id=interface_id):
|
|
399
|
+
self._create_background_task(
|
|
400
|
+
entry.central.device_coordinator.replace_device(
|
|
401
|
+
interface_id=interface_id,
|
|
402
|
+
old_device_address=old_device_address,
|
|
403
|
+
new_device_address=new_device_address,
|
|
404
|
+
),
|
|
405
|
+
name=f"replaceDevice-{interface_id}-{old_device_address}-{new_device_address}",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
async def updateDevice(
|
|
409
|
+
self,
|
|
410
|
+
interface_id: str,
|
|
411
|
+
address: str,
|
|
412
|
+
hint: int,
|
|
413
|
+
/,
|
|
414
|
+
) -> None:
|
|
415
|
+
"""
|
|
416
|
+
Handle device update notification after firmware update or link partner change.
|
|
417
|
+
|
|
418
|
+
When hint=0 (firmware update), this method triggers cache invalidation
|
|
419
|
+
and reloading of device/paramset descriptions. When hint=1 (link partner
|
|
420
|
+
change), it refreshes the link peer information for all channels.
|
|
421
|
+
"""
|
|
422
|
+
_LOGGER.debug(
|
|
423
|
+
"UPDATEDEVICE: interface_id = %s, address = %s, hint = %s",
|
|
424
|
+
interface_id,
|
|
425
|
+
address,
|
|
426
|
+
str(hint),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
if entry := self._get_central_entry(interface_id=interface_id):
|
|
430
|
+
device_address = get_device_address(address=address)
|
|
431
|
+
if hint == UpdateDeviceHint.FIRMWARE:
|
|
432
|
+
# Firmware update: invalidate cache and reload device
|
|
433
|
+
self._create_background_task(
|
|
434
|
+
entry.central.device_coordinator.update_device(
|
|
435
|
+
interface_id=interface_id, device_address=device_address
|
|
436
|
+
),
|
|
437
|
+
name=f"updateDevice-firmware-{interface_id}-{device_address}",
|
|
438
|
+
)
|
|
439
|
+
elif hint == UpdateDeviceHint.LINKS:
|
|
440
|
+
# Link partner change: refresh link peer information
|
|
441
|
+
self._create_background_task(
|
|
442
|
+
entry.central.device_coordinator.refresh_device_link_peers(device_address=device_address),
|
|
443
|
+
name=f"updateDevice-links-{interface_id}-{device_address}",
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def _create_background_task(self, coro: Any, /, *, name: str) -> None:
|
|
447
|
+
"""Create a background task and track it to prevent garbage collection."""
|
|
448
|
+
task: asyncio.Task[None] = asyncio.create_task(coro, name=name)
|
|
449
|
+
self._background_tasks.add(task)
|
|
450
|
+
task.add_done_callback(self._on_background_task_done)
|
|
451
|
+
|
|
452
|
+
def _get_central_entry(self, *, interface_id: str) -> _AsyncCentralEntry | None:
|
|
453
|
+
"""Return central entry by interface_id."""
|
|
454
|
+
return self._rpc_server.get_central_entry(interface_id=interface_id)
|
|
455
|
+
|
|
456
|
+
def _on_background_task_done(self, task: asyncio.Task[None]) -> None:
|
|
457
|
+
"""Handle background task completion and log any errors."""
|
|
458
|
+
self._background_tasks.discard(task)
|
|
459
|
+
if task.cancelled():
|
|
460
|
+
return
|
|
461
|
+
if exc := task.exception():
|
|
462
|
+
_LOGGER.warning(
|
|
463
|
+
i18n.tr(
|
|
464
|
+
key="log.central.rpc_server.background_task_failed",
|
|
465
|
+
task_name=task.get_name(),
|
|
466
|
+
error=exc,
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def _publish_system_event(self, *, interface_id: str, system_event: SystemEventType) -> None:
|
|
471
|
+
"""Publish a system event to the event coordinator."""
|
|
472
|
+
if client := hmcl.get_client(interface_id=interface_id):
|
|
473
|
+
client.central.event_coordinator.publish_system_event(system_event=system_event)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class _AsyncCentralEntry:
|
|
477
|
+
"""Container for central unit registration."""
|
|
478
|
+
|
|
479
|
+
__slots__ = ("central",)
|
|
480
|
+
|
|
481
|
+
def __init__(self, *, central: RpcServerCentralProtocol) -> None:
|
|
482
|
+
"""Initialize central entry."""
|
|
483
|
+
self.central: Final = central
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class AsyncXmlRpcServer:
|
|
487
|
+
"""
|
|
488
|
+
Async XML-RPC server using aiohttp.
|
|
489
|
+
|
|
490
|
+
Singleton per (ip_addr, port) combination.
|
|
491
|
+
"""
|
|
492
|
+
|
|
493
|
+
# Disable kw-only linter for aiohttp callback compatibility
|
|
494
|
+
__kwonly_check__ = False
|
|
495
|
+
|
|
496
|
+
_initialized: bool = False
|
|
497
|
+
_instances: Final[dict[tuple[str, int], AsyncXmlRpcServer]] = {}
|
|
498
|
+
|
|
499
|
+
def __init__(
|
|
500
|
+
self,
|
|
501
|
+
*,
|
|
502
|
+
ip_addr: str = IP_ANY_V4,
|
|
503
|
+
port: int = PORT_ANY,
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Initialize the async XML-RPC server."""
|
|
506
|
+
if self._initialized:
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
self._ip_addr: Final = ip_addr
|
|
510
|
+
self._requested_port: Final = port
|
|
511
|
+
self._actual_port: int = port
|
|
512
|
+
|
|
513
|
+
self._centrals: Final[dict[str, _AsyncCentralEntry]] = {}
|
|
514
|
+
self._dispatcher: Final = AsyncXmlRpcDispatcher()
|
|
515
|
+
# Set client_max_size to 10MB to handle large XML-RPC requests
|
|
516
|
+
self._app: Final = web.Application(client_max_size=10 * 1024 * 1024)
|
|
517
|
+
self._runner: web.AppRunner | None = None
|
|
518
|
+
self._site: web.TCPSite | None = None
|
|
519
|
+
self._started: bool = False
|
|
520
|
+
|
|
521
|
+
# Register RPC functions
|
|
522
|
+
self._rpc_functions: Final = AsyncRPCFunctions(rpc_server=self)
|
|
523
|
+
self._dispatcher.register_instance(instance=self._rpc_functions)
|
|
524
|
+
self._dispatcher.register_introspection_functions()
|
|
525
|
+
|
|
526
|
+
# Local counters for health endpoint (work without central)
|
|
527
|
+
self._request_count: int = 0
|
|
528
|
+
self._error_count: int = 0
|
|
529
|
+
|
|
530
|
+
# Configure routes
|
|
531
|
+
self._app.router.add_post("/", self._handle_request)
|
|
532
|
+
self._app.router.add_post("/RPC2", self._handle_request)
|
|
533
|
+
self._app.router.add_get("/health", self._handle_health_check)
|
|
534
|
+
|
|
535
|
+
self._initialized = True
|
|
536
|
+
|
|
537
|
+
def __new__( # noqa: PYI034
|
|
538
|
+
cls,
|
|
539
|
+
*,
|
|
540
|
+
ip_addr: str = IP_ANY_V4,
|
|
541
|
+
port: int = PORT_ANY,
|
|
542
|
+
) -> AsyncXmlRpcServer:
|
|
543
|
+
"""Return existing instance or create new one."""
|
|
544
|
+
if (key := (ip_addr, port)) not in cls._instances:
|
|
545
|
+
_LOGGER.debug("Creating AsyncXmlRpcServer")
|
|
546
|
+
instance = super().__new__(cls)
|
|
547
|
+
cls._instances[key] = instance
|
|
548
|
+
return cls._instances[key]
|
|
549
|
+
|
|
550
|
+
@property
|
|
551
|
+
def _event_bus(self) -> EventBus | None:
|
|
552
|
+
"""Return event bus from first registered central (for metrics)."""
|
|
553
|
+
for entry in self._centrals.values():
|
|
554
|
+
return entry.central.event_coordinator.event_bus
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
@property
|
|
558
|
+
def listen_ip_addr(self) -> str:
|
|
559
|
+
"""Return the listening IP address."""
|
|
560
|
+
return self._ip_addr
|
|
561
|
+
|
|
562
|
+
@property
|
|
563
|
+
def listen_port(self) -> int:
|
|
564
|
+
"""Return the actual listening port."""
|
|
565
|
+
return self._actual_port
|
|
566
|
+
|
|
567
|
+
@property
|
|
568
|
+
def no_central_assigned(self) -> bool:
|
|
569
|
+
"""Return True if no central is registered."""
|
|
570
|
+
return len(self._centrals) == 0
|
|
571
|
+
|
|
572
|
+
@property
|
|
573
|
+
def started(self) -> bool:
|
|
574
|
+
"""Return True if server is running."""
|
|
575
|
+
return self._started
|
|
576
|
+
|
|
577
|
+
def add_central(
|
|
578
|
+
self,
|
|
579
|
+
*,
|
|
580
|
+
central: RpcServerCentralProtocol,
|
|
581
|
+
) -> None:
|
|
582
|
+
"""Register a central unit."""
|
|
583
|
+
if central.name not in self._centrals:
|
|
584
|
+
self._centrals[central.name] = _AsyncCentralEntry(central=central)
|
|
585
|
+
|
|
586
|
+
def get_central_entry(
|
|
587
|
+
self,
|
|
588
|
+
*,
|
|
589
|
+
interface_id: str,
|
|
590
|
+
) -> _AsyncCentralEntry | None:
|
|
591
|
+
"""Return central entry by interface_id."""
|
|
592
|
+
for entry in self._centrals.values():
|
|
593
|
+
if entry.central.client_coordinator.has_client(interface_id=interface_id):
|
|
594
|
+
return entry
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
def remove_central(
|
|
598
|
+
self,
|
|
599
|
+
*,
|
|
600
|
+
central: RpcServerCentralProtocol,
|
|
601
|
+
) -> None:
|
|
602
|
+
"""Unregister a central unit."""
|
|
603
|
+
if central.name in self._centrals:
|
|
604
|
+
del self._centrals[central.name]
|
|
605
|
+
|
|
606
|
+
async def start(self) -> None:
|
|
607
|
+
"""Start the HTTP server."""
|
|
608
|
+
if self._started:
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
self._runner = web.AppRunner(
|
|
612
|
+
self._app,
|
|
613
|
+
access_log=None, # Disable access logging
|
|
614
|
+
)
|
|
615
|
+
await self._runner.setup()
|
|
616
|
+
|
|
617
|
+
self._site = web.TCPSite(
|
|
618
|
+
self._runner,
|
|
619
|
+
self._ip_addr,
|
|
620
|
+
self._requested_port,
|
|
621
|
+
reuse_address=True,
|
|
622
|
+
)
|
|
623
|
+
await self._site.start()
|
|
624
|
+
|
|
625
|
+
# Get actual port (important when PORT_ANY is used)
|
|
626
|
+
# pylint: disable=protected-access
|
|
627
|
+
if (
|
|
628
|
+
self._site._server # noqa: SLF001
|
|
629
|
+
and hasattr(self._site._server, "sockets") # noqa: SLF001
|
|
630
|
+
and (sockets := self._site._server.sockets) # noqa: SLF001
|
|
631
|
+
):
|
|
632
|
+
self._actual_port = sockets[0].getsockname()[1]
|
|
633
|
+
|
|
634
|
+
self._started = True
|
|
635
|
+
_LOGGER.debug(
|
|
636
|
+
"AsyncXmlRpcServer started on %s:%d",
|
|
637
|
+
self._ip_addr,
|
|
638
|
+
self._actual_port,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
async def stop(self) -> None:
|
|
642
|
+
"""Stop the HTTP server."""
|
|
643
|
+
if not self._started:
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
_LOGGER.debug("Stopping AsyncXmlRpcServer")
|
|
647
|
+
|
|
648
|
+
if self._site:
|
|
649
|
+
await self._site.stop()
|
|
650
|
+
self._site = None
|
|
651
|
+
|
|
652
|
+
if self._runner:
|
|
653
|
+
await self._runner.cleanup()
|
|
654
|
+
self._runner = None
|
|
655
|
+
|
|
656
|
+
# Cancel and wait for background tasks
|
|
657
|
+
await self._cancel_background_tasks()
|
|
658
|
+
|
|
659
|
+
self._started = False
|
|
660
|
+
|
|
661
|
+
# Remove from instances
|
|
662
|
+
if (key := (self._ip_addr, self._requested_port)) in self._instances:
|
|
663
|
+
del self._instances[key]
|
|
664
|
+
|
|
665
|
+
_LOGGER.debug("AsyncXmlRpcServer stopped")
|
|
666
|
+
|
|
667
|
+
async def _cancel_background_tasks(self) -> None:
|
|
668
|
+
"""Cancel all background tasks and wait for them to complete."""
|
|
669
|
+
await self._rpc_functions.cancel_background_tasks()
|
|
670
|
+
|
|
671
|
+
async def _handle_health_check(
|
|
672
|
+
self,
|
|
673
|
+
request: web.Request, # noqa: ARG002
|
|
674
|
+
) -> web.Response:
|
|
675
|
+
"""Handle health check request."""
|
|
676
|
+
health_data = {
|
|
677
|
+
"status": "healthy" if self._started else "stopped",
|
|
678
|
+
"started": self._started,
|
|
679
|
+
"centrals_count": len(self._centrals),
|
|
680
|
+
"centrals": list(self._centrals.keys()),
|
|
681
|
+
"active_background_tasks": self._rpc_functions.active_tasks_count,
|
|
682
|
+
"request_count": self._request_count,
|
|
683
|
+
"error_count": self._error_count,
|
|
684
|
+
"listen_address": f"{self._ip_addr}:{self._actual_port}",
|
|
685
|
+
}
|
|
686
|
+
return web.Response(
|
|
687
|
+
body=orjson.dumps(health_data),
|
|
688
|
+
content_type="application/json",
|
|
689
|
+
charset="utf-8",
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
async def _handle_request(
|
|
693
|
+
self,
|
|
694
|
+
request: web.Request,
|
|
695
|
+
) -> web.Response:
|
|
696
|
+
"""Handle incoming XML-RPC request."""
|
|
697
|
+
start_time = time.perf_counter()
|
|
698
|
+
self._request_count += 1
|
|
699
|
+
|
|
700
|
+
# Emit request counter metric (if central registered)
|
|
701
|
+
if event_bus := self._event_bus:
|
|
702
|
+
emit_counter(event_bus=event_bus, key=MetricKeys.rpc_server_request())
|
|
703
|
+
emit_gauge(
|
|
704
|
+
event_bus=event_bus,
|
|
705
|
+
key=MetricKeys.rpc_server_active_tasks(),
|
|
706
|
+
value=self._rpc_functions.active_tasks_count,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
try:
|
|
710
|
+
body = await request.read()
|
|
711
|
+
response_xml = await self._dispatcher.dispatch(xml_data=body)
|
|
712
|
+
return web.Response(
|
|
713
|
+
body=response_xml,
|
|
714
|
+
content_type="text/xml",
|
|
715
|
+
charset="utf-8",
|
|
716
|
+
)
|
|
717
|
+
except XmlRpcProtocolError as err:
|
|
718
|
+
self._error_count += 1
|
|
719
|
+
if event_bus := self._event_bus:
|
|
720
|
+
emit_counter(event_bus=event_bus, key=MetricKeys.rpc_server_error())
|
|
721
|
+
_LOGGER.warning(i18n.tr(key="log.central.rpc_server.protocol_error", error=err))
|
|
722
|
+
return web.Response(
|
|
723
|
+
status=400,
|
|
724
|
+
text="XML-RPC protocol error",
|
|
725
|
+
)
|
|
726
|
+
except Exception:
|
|
727
|
+
self._error_count += 1
|
|
728
|
+
if event_bus := self._event_bus:
|
|
729
|
+
emit_counter(event_bus=event_bus, key=MetricKeys.rpc_server_error())
|
|
730
|
+
_LOGGER.exception(i18n.tr(key="log.central.rpc_server.unexpected_error"))
|
|
731
|
+
return web.Response(
|
|
732
|
+
status=500,
|
|
733
|
+
text="Internal Server Error",
|
|
734
|
+
)
|
|
735
|
+
finally:
|
|
736
|
+
# Emit latency metric
|
|
737
|
+
if event_bus := self._event_bus:
|
|
738
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
739
|
+
emit_latency(
|
|
740
|
+
event_bus=event_bus,
|
|
741
|
+
key=MetricKeys.rpc_server_request_latency(),
|
|
742
|
+
duration_ms=duration_ms,
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
async def create_async_xml_rpc_server(
|
|
747
|
+
*,
|
|
748
|
+
ip_addr: str = IP_ANY_V4,
|
|
749
|
+
port: int = PORT_ANY,
|
|
750
|
+
) -> AsyncXmlRpcServer:
|
|
751
|
+
"""Create and start an async XML-RPC server."""
|
|
752
|
+
server = AsyncXmlRpcServer(ip_addr=ip_addr, port=port)
|
|
753
|
+
if not server.started:
|
|
754
|
+
await server.start()
|
|
755
|
+
_LOGGER.debug(
|
|
756
|
+
"CREATE_ASYNC_XML_RPC_SERVER: Starting AsyncXmlRpcServer listening on %s:%i",
|
|
757
|
+
server.listen_ip_addr,
|
|
758
|
+
server.listen_port,
|
|
759
|
+
)
|
|
760
|
+
return server
|