-
Notifications
You must be signed in to change notification settings - Fork 17
[Python] Add async components for WebBridgeController #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
VanitasCodes
wants to merge
12
commits into
constellation-daq:main
Choose a base branch
from
VanitasCodes:async-components
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
d69ba86
Added async components for WebBridgeController prototype
1960f24
Hot loop fix, callback as member, DEPART handling, socket leak fix
5683bde
Refactor async components: pool backed heartbeat receiver, fix subscr…
0eecc3f
Add async CHIRP using DatagramProtocol, eliminating the dedicated dis…
1e838e9
Restructure async CHIRP and apply MR 1131 casing fixes
1eb4fb7
Discovery logic, register_service, addresses list, socket architecture
e888374
elif Comment Addressed
b76c2ce
Fix REUSE headers and formatting in async_experimental files
dd7875f
Added async task factory infrastructure to BaseSatelliteFrame
a132311
Add async framework integration classes
46fc678
Added async_experimental package to meson build
1cdc041
Refactored async_experimental
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
146 changes: 146 additions & 0 deletions
146
python/constellation/core/async_experimental/async_basecontroller.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| """ | ||
| SPDX-FileCopyrightText: 2026 DESY and the Constellation authors | ||
| SPDX-License-Identifier: EUPL-1.2 | ||
|
|
||
| Async base and scriptable controller framework classes. | ||
| """ | ||
|
|
||
| import asyncio | ||
| from typing import Any | ||
| from uuid import UUID | ||
|
|
||
| import zmq | ||
| import zmq.asyncio | ||
|
|
||
| from constellation.core.async_experimental.async_chirp import CHIRPEvent, DiscoveredService | ||
| from constellation.core.async_experimental.async_heartbeat import AsyncHeartbeatChecker | ||
| from constellation.core.async_experimental.async_monitoringlistener import AsyncMonitoringListener | ||
| from constellation.core.chirp import CHIRPServiceIdentifier | ||
| from constellation.core.controller import SatelliteUpdate | ||
| from constellation.core.logging import setup_cli_logging | ||
| from constellation.core.message.cscp1 import CSCP1Message | ||
|
|
||
|
|
||
| class AsyncBaseController(AsyncMonitoringListener, AsyncHeartbeatChecker): | ||
| """Async equivalent of BaseController. | ||
|
|
||
| CSCP commands use zmq.asyncio with await send/recv directly, eliminating | ||
| the dedicated executor and synchronous context. _async_ctx from | ||
| BaseSatelliteFrame is used for all ZMQ sockets including CSCP REQ sockets. | ||
| Discovery and heartbeat run as coroutines. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| loop: asyncio.AbstractEventLoop | None = None, | ||
| **kwds: Any, | ||
| ) -> None: | ||
| super().__init__(**kwds) | ||
| self._transmitters: dict[str, zmq.asyncio.Socket] = {} | ||
| self._transmitter_uuids: dict[UUID, str] = {} | ||
| self._cscp_lock = asyncio.Lock() | ||
|
|
||
| self.register_chirp_callback("basecontroller_control", self._on_control_service) | ||
| self.register_chirp_callback("basecontroller_heartbeat", self._on_heartbeat_service) | ||
|
|
||
| def _on_control_service(self, event: CHIRPEvent, service: DiscoveredService) -> None: | ||
| """Handle CONTROL service connect/disconnect.""" | ||
| if service.service_id != CHIRPServiceIdentifier.CONTROL: | ||
| return | ||
| if event == CHIRPEvent.SERVICE_CONNECTED: | ||
| asyncio.ensure_future(self._setup_transmitter(service.host_id, service.addresses[0], service.port)) | ||
| elif event == CHIRPEvent.SERVICE_DISCONNECTED: | ||
| self._cleanup_transmitter(service.host_id) | ||
|
|
||
| def _on_heartbeat_service(self, event: CHIRPEvent, service: DiscoveredService) -> None: | ||
| """Handle HEARTBEAT service connect/disconnect.""" | ||
| if service.service_id != CHIRPServiceIdentifier.HEARTBEAT: | ||
| return | ||
| if event == CHIRPEvent.SERVICE_CONNECTED: | ||
| name = self._transmitter_uuids.get(service.host_id, f"Unknown-{str(service.host_id)[:8]}") | ||
| self.register_heartbeat_host(service.host_id, service.addresses[0], service.port, name) | ||
| elif event == CHIRPEvent.SERVICE_DISCONNECTED: | ||
| self.unregister_heartbeat_host(service.host_id) | ||
|
|
||
| async def _cscp_request( | ||
| self, | ||
| sock: zmq.asyncio.Socket, | ||
| command: str, | ||
| payload: Any = None, | ||
| ) -> CSCP1Message: | ||
| """Send a CSCP1 request and await the response. | ||
|
|
||
| _cscp_lock serialises all REQ/REP exchanges so no two coroutines | ||
| share a socket simultaneously. | ||
| """ | ||
| request = CSCP1Message(self.name, (CSCP1Message.Type.REQUEST, command)) | ||
| if payload is not None: | ||
| request.payload = payload | ||
| async with self._cscp_lock: | ||
| await sock.send_multipart(request.assemble().frames) | ||
| response_frames = await sock.recv_multipart() | ||
| return CSCP1Message.disassemble(response_frames) | ||
|
|
||
| async def command( | ||
| self, | ||
| cmd: str, | ||
| sat: str = "", | ||
| satcls: str = "", | ||
| payload: Any = None, | ||
| ) -> str: | ||
| """Send a CSCP command on the event loop.""" | ||
| key = f"{satcls}.{sat}".strip(".") | ||
| sock = self._transmitters.get(key) | ||
| if sock is None: | ||
| return f"ERROR: No transmitter for {key}" | ||
| try: | ||
| result = await self._cscp_request(sock, cmd, payload) | ||
| return f"{result.verb_msg}" | ||
| except Exception as e: | ||
| return f"ERROR: {e}" | ||
|
|
||
| def _on_satellite_update(self, name: str, update_type: SatelliteUpdate) -> None: | ||
| """Called on satellite connect/disconnect. Override in subclass.""" | ||
|
|
||
| async def _setup_transmitter(self, uuid: UUID, address: str, port: int) -> None: | ||
| """Connect to a satellite CSCP port on the event loop.""" | ||
| if uuid in self._transmitter_uuids: | ||
| return | ||
| sock = self._async_ctx.socket(zmq.REQ) | ||
| sock.connect(f"tcp://{address}:{port}") | ||
| sock.setsockopt(zmq.LINGER, 2000) | ||
| try: | ||
| msg = await asyncio.wait_for(self._cscp_request(sock, "get_commands"), timeout=5.0) | ||
| except Exception: | ||
| sock.close() | ||
| return | ||
| canonical_name = msg.sender | ||
| self._transmitters[canonical_name] = sock | ||
| self._transmitter_uuids[uuid] = canonical_name | ||
| self._on_satellite_update(canonical_name, SatelliteUpdate.ADDED) | ||
|
|
||
| def _cleanup_transmitter(self, uuid: UUID) -> None: | ||
| """Remove and close a CSCP socket on satellite departure.""" | ||
| canonical_name = self._transmitter_uuids.pop(uuid, None) | ||
| if canonical_name is None: | ||
| return | ||
| sock = self._transmitters.pop(canonical_name, None) | ||
| if sock is not None: | ||
| sock.close() | ||
| self._on_satellite_update(canonical_name, SatelliteUpdate.REMOVED) | ||
|
|
||
| def shutdown(self) -> None: | ||
| """Shut down all components.""" | ||
| for sock in self._transmitters.values(): | ||
| sock.close() | ||
| self._hb_receiver.close() | ||
| self._cmdp_pool.close() | ||
| self._async_ctx.term() | ||
|
|
||
|
|
||
| class AsyncScriptableController(AsyncBaseController): | ||
| """Async equivalent of ScriptableController.""" | ||
|
|
||
| def __init__(self, log_level: str = "INFO", **kwds: Any) -> None: | ||
| setup_cli_logging(log_level) | ||
| super().__init__(**kwds) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.