diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index fc00642..58771ac 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -14,9 +14,9 @@ jobs: os: [ubuntu-latest, windows-latest] py: ['3.9', '3'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.py }} check-latest: True @@ -31,7 +31,7 @@ jobs: run: | prospector - name: Upload coverage artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: coverage-${{ github.run_id }}-${{ matrix.os }}-${{ matrix.py }} path: .coverage.* @@ -41,14 +41,14 @@ jobs: runs-on: ubuntu-latest needs: build steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3' check-latest: true - name: Download coverage artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: artifacts - name: Combine coverage files @@ -66,9 +66,9 @@ jobs: build-doc: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3' check-latest: true diff --git a/.gitignore b/.gitignore index e14c9dc..69a2b47 100644 --- a/.gitignore +++ b/.gitignore @@ -182,9 +182,9 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder # .vscode/ @@ -206,4 +206,4 @@ marimo/_static/ marimo/_lsp/ __marimo__/ - +**.swp diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 5cdd87a..51252ee 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,7 +8,7 @@ version: 2 build: os: ubuntu-24.04 tools: - python: "3.13" + python: "3.14" # Build documentation in the "docs/" directory with Sphinx sphinx: diff --git a/discos_client/__init__.py b/discos_client/__init__.py index 0bd20d4..c0842ac 100644 --- a/discos_client/__init__.py +++ b/discos_client/__init__.py @@ -1,27 +1,5 @@ -from functools import partial -from .client import DISCOSClient, DEFAULT_PORT +from .client import DISCOSClient, SRTClient, MedicinaClient, NotoClient -SRTClient = partial( - DISCOSClient, - address="192.168.200.203", - port=DEFAULT_PORT, - telescope="SRT" -) -MedicinaClient = partial( - DISCOSClient, - address="192.168.1.100", - port=DEFAULT_PORT, - telescope="Medicina" -) -NotoClient = partial( - DISCOSClient, - address="192.167.187.17", - port=DEFAULT_PORT, - telescope="Noto" -) - -del partial -del DEFAULT_PORT __all__ = [ "DISCOSClient", diff --git a/discos_client/client.py b/discos_client/client.py index bdf3662..3cf6db9 100644 --- a/discos_client/client.py +++ b/discos_client/client.py @@ -3,81 +3,86 @@ import weakref from threading import Thread, Lock, Event from collections import defaultdict +from typing import Any +from pathlib import Path import zmq +from zmq.utils.monitor import recv_monitor_message from .namespace import DISCOSNamespace -from .utils import rand_id +from .utils import rand_id, get_auth_keys, timestamp from .initializer import NSInitializer -__all__ = [ - "DEFAULT_PORT", - "DISCOSClient" -] +__all__ = ["DISCOSClient", "SRTClient", "MedicinaClient", "NotoClient"] -DEFAULT_PORT = 16000 +DEFAULT_SUB_PORT = 16000 +DEFAULT_REQ_PORT = 16010 class DISCOSClient: """ - Class that implements a DISCOSClient. It handles incoming ZMQ messages from - the DISCOS control software. + Class that implements a generic DISCOSClient. It handles incoming ZMQ + messages from the DISCOS control software and eventually allows the user + to send remote commands. """ def __init__( self, *topics: str, address: str, - port: int, - telescope: str | None = None + sub_port: int = DEFAULT_SUB_PORT, + req_port: int = DEFAULT_REQ_PORT, + telescope: str | None = None, + identity: str | None = None, + server_public_key_file: str | Path | None = None, ) -> None: """ - Initializes the class instance. - :param topics: topic names to subscribe to. :param address: IP address to subscribe to. - :param port: TCP port to subscribe to. + :param sub_port: TCP port where the subscriber socket will connect. + :param req_port: TCP port where the requester socket will connect. :param telescope: name of the telescope the client is connecting to. + :param identity: name of the key file to be used for sending remote + commands. Ideally, each application should have and + use its own identity. + :param server_public_key_file: path to a ZMQ public certificate file + containing the RPC server public key. + Useful when using plain DISCOSClient + without a predefined telescope profile. :raises ValueError: If one or more given topics are not known. + :raises FileNotFoundError: If the provided identity file is missing. + :raises ValueError: If the the provided identity file does not contain + a valid key pair. """ - initializer = NSInitializer(telescope) - valid_topics = initializer.get_topics() - invalid = [t for t in topics if t not in valid_topics] - if invalid: - if len(invalid) > 1: - invalid = \ - f"""s '{"', '".join(invalid[:-1])}'""" \ - f" and '{invalid[-1]}' are" - else: - invalid = f""" '{invalid[0]}' is""" - raise ValueError( - f"Topic{invalid} not known, choose among " - f"""'{"', '".join(valid_topics[:-1])} and """ - f"'{valid_topics[-1]}'" - ) - if not topics: - topics = initializer.get_topics() - self._topics = topics + if telescope not in ("Medicina", "Noto", "SRT", None): + raise ValueError(f"Unknown telescope: '{telescope}'") + self._initializer = NSInitializer(telescope) + self._topics = self.__validate_topics__(self._initializer, topics) self._client_id = rand_id() - self._event = Event() + self._stop = Event() self._context = zmq.Context() - self._socket = self._context.socket(zmq.SUB) - self._socket.setsockopt(zmq.LINGER, 0) - self._socket.setsockopt(zmq.RCVTIMEO, 10) - self._socket.connect(f"tcp://{address}:{port}") - self._locks = defaultdict(Lock) + self._events = {} + self._events["stop"] = self._stop - for topic in self._topics: - self.__dict__[topic] = initializer.initialize(topic) + self._sub = self._context.socket(zmq.SUB) + self._sub.setsockopt(zmq.LINGER, 0) + self._sub.setsockopt(zmq.RECONNECT_IVL, 1000) + self._sub.setsockopt(zmq.CONNECT_TIMEOUT, 500) + self._sub.connect(f"tcp://{address}:{sub_port}") + + self._sockets = {} + self._sockets["sub"] = self._sub + + self._locks = defaultdict(Lock) self._receiver = Thread( target=self.__receive__, args=( - self._socket, + self._sub, self._locks, self._client_id, self.__dict__, - self._event + self._stop ), daemon=True ) @@ -85,43 +90,147 @@ def __init__( self._finalizer = weakref.finalize( self, self.__cleanup__, - self._event, - self._socket, - self._context, - self._receiver + self._stop, + self._receiver, + self._sockets, + self._context ) + if identity is not None: + try: + public, secret, server = get_auth_keys( + identity=identity, + telescope=telescope, + server_public_key_file=server_public_key_file, + ) + except OSError as ex: + raise ValueError( + f"Unknown or invalid identity '{identity}', " + "or invalid server public key." + ) from ex + self._client_public = public + self._client_secret = secret + self._server_public = server + self.__init_req_socket__(f"tcp://{address}:{req_port}") + self.command = self.__command__ + + for topic in self._topics: + self.__dict__[topic] = self._initializer.initialize(topic) + self._receiver.start() for topic in self._topics: - self._socket.subscribe(f"{self._client_id}{topic}") + self._sub.subscribe(f"{self._client_id}{topic}") + + def __command__(self, cmd: str, *args) -> DISCOSNamespace: + """ + Sends a command to the remote server. + + This method is only available if the DISCOSClient instance finds the + correct authentication keys. + + :param cmd: The name of the command. + :param args: A series of arguments to be inclueded in the command. + :return: A DISCOSNamespace containing the command answer. + """ + answer = self._initializer.initialize("command_answer", False) + answer <<= {"command": cmd} + if not self.__req_connected__(): + answer <<= self.__unreachable_error__() + return answer + + payload = {"command": cmd, "async": True} + if args: + payload["args"] = args + + payload = json.dumps(payload, separators=(",", ":")) + self._req.send_string(payload) + + while self.__req_connected__(strict=True): + if (self._req.poll(10) & zmq.POLLIN) != 0: + answer <<= json.loads(self._req.recv_string()) + return answer + + # We lost connection between send and receive, we need to reinitialize + # the REQ socket + endpoint = self._req.get_string(zmq.LAST_ENDPOINT) + self._mon.close() + self._req.close() + self.__init_req_socket__(endpoint) + answer <<= self.__unreachable_error__() + return answer + + def __init_req_socket__(self, endpoint: str) -> None: + self._req = self._context.socket(zmq.REQ) + self._req.setsockopt(zmq.LINGER, 0) + self._req.setsockopt(zmq.IMMEDIATE, 1) + self._req.setsockopt(zmq.RECONNECT_IVL, 1000) + self._req.setsockopt(zmq.CONNECT_TIMEOUT, 500) + self._req.setsockopt(zmq.HEARTBEAT_IVL, 1000) + self._req.setsockopt(zmq.HEARTBEAT_TIMEOUT, 1000) + self._req.curve_publickey = self._client_public + self._req.curve_secretkey = self._client_secret + self._req.curve_serverkey = self._server_public + self._mon = self._req.get_monitor_socket() + self._online = Event() + self._events["online"] = self._online + self._req.connect(endpoint) + self._sockets["req"] = self._req + self._sockets["mon"] = self._mon + + @staticmethod + def __validate_topics__( + initializer: NSInitializer, + topics: tuple[str] + ) -> list[str]: + valid_topics = initializer.get_topics() + invalid = [t for t in topics if t not in valid_topics] + if not invalid: + return topics or valid_topics + + if len(invalid) > 1: + invalid = f"""s '{"', '".join(invalid[:-1])}'""" \ + f" and '{invalid[-1]}' are" + else: + invalid = f""" '{invalid[0]}' is""" + + raise ValueError( + f"Topic{invalid} not known, choose among " + f"""'{"', '".join(valid_topics[:-1])} and """ + f"'{valid_topics[-1]}'" + ) @staticmethod def __cleanup__( - event: Event, - socket: zmq.Socket, - context: zmq.Context, - receiver: Thread + stop: Event, + receiver: Thread, + sockets: dict[str, zmq.Socket], + context: zmq.Context ) -> None: """ - Joins the receiver thread and closes the ZMQ socket and context. + Joins the updater thread and closes the ZMQ sockets and context. - :param event: the Event object that will stop the receiver thread. - :param socket: the ZMQ socket object. + :param stop: the Event object that will stop the updater thread. + :param sub: the ZMQ SUB socket object. :param context: the ZMQ context object. - :param receiver: the receiver thread object. + :param updater: the updater thread object. """ - event.set() - receiver.join() - socket.close() + stop.set() + try: + receiver.join() + except RuntimeError: # pragma: no cover + pass + for _, socket in sockets.items(): + socket.disable_monitor() + socket.close() context.term() @staticmethod def __receive__( - socket: zmq.Socket, + sub: zmq.Socket, locks: dict[str, Lock], client_id: str, - d: dict[str, DISCOSNamespace], - event: Event + namespaces: dict[str, DISCOSNamespace], + stop: Event ) -> None: """ Loops continuously waiting for new ZMQ messages. @@ -132,20 +241,57 @@ def __receive__( :param d: The client __dict__ object. :param event: The Event object that will break the receiver loop. """ - while not event.is_set(): - try: - t, p = socket.recv_multipart() # noqa + while not stop.is_set(): + if (sub.poll(10) & zmq.POLLIN) != 0: + t, p = sub.recv_multipart() # noqa t = t.decode("ascii") if t.startswith(client_id): - socket.unsubscribe(t) + sub.unsubscribe(t) t = t[len(client_id):] - socket.subscribe(t) + sub.subscribe(t) p = json.loads(p) with locks[t]: - d[t] <<= p - except zmq.Again: - # No data received, cycle again - pass + namespaces[t] <<= p + + def __req_connected__(self, strict: bool = False) -> bool: + """ + Checks if the REQ socket is connected. + + :param strict: If True, this method will return false if a + disconnection happened, even if the socket was + finally reconnected. + :return: A boolean indicating where the REQ socket is connected. + """ + disconnection_detected = False + while self._mon.poll(0) & zmq.POLLIN: + msg = recv_monitor_message(self._mon) + event = msg["event"] + if event == zmq.EVENT_CONNECTED: + self._online.set() + elif event in (zmq.EVENT_DISCONNECTED, zmq.EVENT_CLOSED): + self._online.clear() + disconnection_detected = True + currently_online = self._online.is_set() + if strict: + return currently_online and not disconnection_detected + return currently_online + + @staticmethod + def __unreachable_error__() -> dict[str, Any]: + """ + Returns a DISCOSUnreachableError answer. + + :return: The DISCOSUnreachable answer. + """ + return { + "executed": False, + "error_trace": [{ + "message": "DISCOS is unreachable", + "category": 2101, # ClientErrors + "code": 14, # DISCOSUnreachableError + }], + "timestamp": timestamp() + } def __repr__(self) -> str: """ @@ -169,27 +315,27 @@ def __format__(self, spec: str) -> str: :param spec: Format specifier. - | 'c' - compact JSON + | 't' - tight JSON | 'i' - indented JSON \ with optional indentation level (default is 2) - | 'f' - full representation with metadata + | 'e' - entire representation with metadata | 'm' - metadata only representation :return: A JSON formatted string. :raises ValueError: If the given format specifier is not known or contains errors. """ - has_f = "f" in spec + has_e = "e" in spec has_m = "m" in spec - if has_f and has_m: + if has_e and has_m: raise ValueError( - "Format specifier cannot contain both 'f' and 'm'." + "Format specifier cannot contain both 'e' and 'm'." ) - if has_f: - fmt_spec = spec[1:] if spec.startswith("f") else spec - fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("f") else fmt_spec + if has_e: + fmt_spec = spec[1:] if spec.startswith("e") else spec + fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("e") else fmt_spec elif has_m: fmt_spec = spec[1:] if spec.startswith("m") else spec fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("m") else fmt_spec @@ -199,14 +345,14 @@ def __format__(self, spec: str) -> str: indent = None separators = None default = ( - DISCOSNamespace.__full_dict__ if has_f + DISCOSNamespace.__full_dict__ if has_e else DISCOSNamespace.__metadata_dict__ if has_m else DISCOSNamespace.__message_dict__ ) if fmt_spec == "": pass - elif fmt_spec == "c": + elif fmt_spec == "t": separators = (",", ":") elif fmt_spec.endswith("i"): fmt_par = fmt_spec[:-1] @@ -251,3 +397,63 @@ def __public_dict__(self) -> dict[str, DISCOSNamespace]: for topic in self._topics: self._locks[topic].release() return result + + +class SRTClient(DISCOSClient): + # pylint: disable=too-few-public-methods + """ + Creates a client configured for the **Sardinia Radio Telescope (SRT)**. + """ + + def __init__(self, *topics: str, identity: str | None = None) -> None: + """ + :param topics: Topic names to subscribe to. + :param identity: Name of the key pair file to be used in order to + send commands. + """ + super().__init__( + *topics, + address="192.168.200.203", + telescope="SRT", + identity=identity + ) + + +class MedicinaClient(DISCOSClient): + # pylint: disable=too-few-public-methods + """ + Creates a client configured for the **Medicina Radio Telescope**. + """ + + def __init__(self, *topics: str, identity: str | None = None) -> None: + """ + :param topics: Topic names to subscribe to. + :param identity: Name of the key pair file to be used in order to + send commands. + """ + super().__init__( + *topics, + address="192.168.1.100", + telescope="Medicina", + identity=identity + ) + + +class NotoClient(DISCOSClient): + # pylint: disable=too-few-public-methods + """ + Creates a client configured for the **Noto Radio Telescope**. + """ + + def __init__(self, *topics: str, identity: str | None = None) -> None: + """ + :param topics: Topic names to subscribe to. + :param identity: Name of the key pair file to be used in order to + send commands. + """ + super().__init__( + *topics, + address="192.167.187.17", + telescope="Noto", + identity=identity + ) diff --git a/discos_client/initializer.py b/discos_client/initializer.py index 19d5f75..b5bad08 100644 --- a/discos_client/initializer.py +++ b/discos_client/initializer.py @@ -59,9 +59,13 @@ def __init__(self, telescope: str | None = None): self._precompile_patternprops(schema) self.schemas[schema_id] = schema + self.available_topics = list(self.node_to_id.keys()) + self.available_topics.remove("command_answer") + def initialize( self, - topic: str + topic: str, + reactive: bool = True, ) -> DISCOSNamespace: """ Build the initial :class:`DISCOSNamespace` for the given topic. @@ -79,10 +83,15 @@ def initialize( """ if topic not in self.node_to_id: # pragma: no cover raise ValueError(f"Schema '{topic}' was not loaded.") - topic = self.node_to_id[topic] - schema = self.schemas[topic] + node_id = self.node_to_id[topic] + schema = self.schemas[node_id] payload = self._initialize_from_schema(schema) - return DISCOSNamespace(schema=schema, **payload) + return DISCOSNamespace( + schema=schema, + node_name=topic, + reactive=reactive, + **payload + ) def get_topics(self) -> list[str]: """ @@ -90,7 +99,7 @@ def get_topics(self) -> list[str]: :return: All topic names loaded from schema files. """ - return list(self.node_to_id.keys()) + return self.available_topics def _literal_prefix(self, pat: str) -> str: """ diff --git a/discos_client/namespace.py b/discos_client/namespace.py index 8aa192e..9d4118f 100644 --- a/discos_client/namespace.py +++ b/discos_client/namespace.py @@ -29,6 +29,9 @@ class DISCOSNamespace: "_observers", "_observers_lock", "_schema", + "_reactive", + "_node_name", + "get_value", "bind", "unbind", "wait", @@ -36,7 +39,10 @@ class DISCOSNamespace: ) def __init__( - self, schema: dict[str, Any] | None = None, + self, + schema: dict[str, Any] | None = None, + node_name: str | None = None, + reactive: bool = True, **kwargs: Any ) -> None: """ @@ -48,12 +54,22 @@ def __init__( Key "schema" represent the schema of the object tree, holding metadata. :param schema: The schema of the object tree. + :param reactive: Whether the object should expose the bind, copy, + unbind and wait methods. :param kwargs: Arbitrary keyword arguments to initialize attributes. """ object.__setattr__(self, "_lock", threading.RLock()) object.__setattr__(self, "_observers", {}) object.__setattr__(self, "_observers_lock", threading.Lock()) object.__setattr__(self, "_schema", schema) + object.__setattr__(self, "_node_name", node_name) + object.__setattr__(self, "_reactive", reactive) + + if reactive: + object.__setattr__(self, "bind", self.__bind__) + object.__setattr__(self, "copy", self.__copy__) + object.__setattr__(self, "unbind", self.__unbind__) + object.__setattr__(self, "wait", self.__wait__) meta: dict[str, Any] = {} if schema is not None: @@ -65,13 +81,25 @@ def __init__( clean_kwargs: dict[str, Any] = {} for k, v in list(kwargs.items()): if k in ["items", "value"]: - clean_kwargs["_value"] = self._wrap_value(v, schema) + clean_kwargs["_value"] = self._wrap_value( + v, + schema, + k, + reactive + ) else: subschema = None if not k.startswith("_"): subschema = self._find_subschema(schema, k) - clean_kwargs[k] = self._wrap_value(v, subschema) + clean_kwargs[k] = self._wrap_value( + v, + subschema, + k, + reactive + ) self.__dict__.update(clean_kwargs) + if self.__has_value__(self) and not self.__is__(self._value): + object.__setattr__(self, "get_value", self.__get_value__) @staticmethod def _find_subschema( @@ -111,51 +139,59 @@ def _find_subschema( return None @staticmethod - def _wrap_value(value: Any, schema: dict[str, Any] | None) -> Any: + def _wrap_value( + value: Any, + schema: dict[str, Any] | None, + node_name: str | None, + reactive: bool = True + ) -> Any: """ - Transform dictionaries and lists to DISCOSNamespace objects. + Transforms dictionaries and lists to DISCOSNamespace objects. :param value: The value to be transformed to DISCOSNamespace if dict or list. :param schema: The schema representing the object. + :param reactive: Whether the object should expose the bind, copy, + unbind and wait methods. :return: The wrapped value if dict or list, value otherwise. """ if isinstance(value, dict): - return DISCOSNamespace(schema=schema, **value) + return DISCOSNamespace( + schema=schema, + node_name=node_name, + reactive=reactive, + **value + ) if isinstance(value, list): item_schema = None if schema is not None and schema.get("type") == "array": item_schema = schema.get("items") return DISCOSNamespace( schema=schema, + node_name=node_name, + reactive=reactive, value=tuple( - DISCOSNamespace(schema=item_schema, **v) + DISCOSNamespace( + schema=item_schema, + node_name=node_name, + reactive=reactive, + **v + ) if isinstance(v, dict) else v for v in value ) ) return value - def get_value(self) -> Any: + def __get_value__(self) -> Any: """ Return the internal primitive value. :return: The internal value of the instance. - :raises AttributeError: If the object does not hold a primitive value. """ - def raise_error(): - raise AttributeError( - f"'{self.__typename__}' object has no attribute 'get_value'" - ) + return self._value - if not self.__has_value__(self): - raise_error() - value = self._value - if DISCOSNamespace.__is__(value): - raise_error() - return value - - def bind( + def __bind__( self, callback: Callable[[DISCOSNamespace], None], predicate: Callable[[DISCOSNamespace], bool] = None @@ -175,7 +211,7 @@ def bind( else lambda _: True ) - def unbind( + def __unbind__( self, callback: Callable[[DISCOSNamespace], None] | None = None, predicate: Callable[[DISCOSNamespace], bool] = None @@ -200,7 +236,7 @@ def unbind( if predicate is None or not self._observers[callback]: del self._observers[callback] - def wait( + def __wait__( self, predicate: Callable[[DISCOSNamespace], bool] = None, timeout: float | None = None @@ -225,7 +261,7 @@ def callback(_): with self._lock: return self - def copy(self) -> DISCOSNamespace: + def __copy__(self) -> DISCOSNamespace: """ Return a copy of the DISCOSNamespace. @@ -532,7 +568,11 @@ def _ilshift_dict(self, other: dict) -> bool: node = self.__dict__.get(k) if node is None: schema = DISCOSNamespace._find_subschema(self._schema, k) - node = DISCOSNamespace(schema=schema) + node = DISCOSNamespace( + schema=schema, + node_name=k, + reactive=self._reactive + ) self.__dict__[k] = node notify = True if DISCOSNamespace.__is__(node): @@ -556,7 +596,7 @@ def _ilshift_list(self, other: list) -> bool: schema = schema.get("items", None) value = [] for item in other: - d = DISCOSNamespace(schema=schema) + d = DISCOSNamespace(schema=schema, reactive=self._reactive) d <<= item value.append(d) self.__dict__["_value"] = sv = tuple(value) @@ -584,54 +624,71 @@ def _ilshift_value(self, other: bool | int | float | str) -> bool: sdict["_value"] = other return True + # pylint: disable=too-many-branches def __format__(self, spec: str) -> str: """ Custom format method. :param spec: Format specifier. - | 'c' - compact JSON + | 't' - tight JSON | 'i' - indented JSON \ with optional indentation level (default is 2) - | 'f' - full representation with metadata + | 'e' - entire representation with metadata | 'm' - metadata only representation + | 'w' - wrap the representation in a container prepending the \ +node key :return: A JSON formatted string for non-leaf nodes. If self is a leaf node, it delegates to `format(self._value, spec)`. :raise ValueError: If the format specifier is unknown or malformed. """ - if self.__has_value__(self) and not isinstance(self._value, tuple): - with self._lock: - return format(self._value, spec) + reserved = set("tiemw") + is_container = any(c in spec for c in reserved) - has_f = "f" in spec + if self.__has_value__(self) and not \ + isinstance(self._value, (tuple, list)): + if not is_container: + with self._lock: + return format(self._value, spec) + + has_e = "e" in spec has_m = "m" in spec + has_w = "w" in spec - if has_f and has_m: + if has_e and has_m: raise ValueError( - "Format specifier cannot contain both 'f' and 'm'." + "Format specifier cannot contain both 'e' and 'm'." ) - if has_f: - fmt_spec = spec[1:] if spec.startswith("f") else spec - fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("f") else fmt_spec + if has_e: + fmt_spec = spec[1:] if spec.startswith("e") else spec + fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("e") else fmt_spec elif has_m: fmt_spec = spec[1:] if spec.startswith("m") else spec fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("m") else fmt_spec else: fmt_spec = spec + data_to_serialize = self + if has_w: + if self._node_name is None: + raise ValueError("Cannot wrap node without a key!") + data_to_serialize = {self._node_name: self} + fmt_spec = spec[1:] if spec.startswith("w") else spec + fmt_spec = fmt_spec[:-1] if fmt_spec.endswith("w") else fmt_spec + indent = None separators = None default = ( - self.__full_dict__ if has_f + self.__full_dict__ if has_e else self.__metadata_dict__ if has_m else self.__message_dict__ ) if fmt_spec == "": pass - elif fmt_spec == "c": + elif fmt_spec == "t": separators = (",", ":") elif fmt_spec.endswith("i"): fmt_par = fmt_spec[:-1] @@ -652,7 +709,7 @@ def __format__(self, spec: str) -> str: with self._lock: return json.dumps( - self, + data_to_serialize, default=default, indent=indent, separators=separators, @@ -671,10 +728,10 @@ def __deepcopy__(self, memo): cls = self.__class__ public = cls.__full_dict__(self) copied = deepcopy(public, memo) - return cls(**copied) + return cls(reactive=self._reactive, **copied) @classmethod - def __get_value__(cls, obj: DISCOSNamespace) -> Any: + def __retrieve_value__(cls, obj: DISCOSNamespace) -> Any: """ Retrieve the internal stored value of a given DISCOSNamespace instance. @@ -721,7 +778,7 @@ def __full_dict__(cls, obj: DISCOSNamespace) -> dict[str, Any]: return public_dict( obj, cls.__is__, - cls.__get_value__ + cls.__retrieve_value__ ) @classmethod @@ -736,7 +793,7 @@ def __message_dict__(cls, obj: DISCOSNamespace) -> dict[str, Any]: def unwrap(value: Any) -> Any: if cls.__is__(value): if cls.__has_value__(value): - return unwrap(cls.__get_value__(value)) + return unwrap(cls.__retrieve_value__(value)) retval = {} for k, v in vars(value).items(): if k in cls.__private__ or k in META_KEYS: @@ -764,7 +821,7 @@ def strip(value: Any) -> Any: if isinstance(value, (list, tuple)): return [strip(v) for v in value] return value - return strip(public_dict(obj, cls.__is__, cls.__get_value__)) + return strip(public_dict(obj, cls.__is__, cls.__retrieve_value__)) @classmethod def __value_repr__(cls, obj: Any) -> Any: @@ -776,12 +833,12 @@ def __value_repr__(cls, obj: Any) -> Any: """ if cls.__is__(obj): if cls.__has_value__(obj): - val = cls.__get_value__(obj) + val = cls.__retrieve_value__(obj) return cls.__value_repr__(val) return { k: cls.__value_repr__(v) for k, v in vars(obj).items() - if not k.startswith("_") + if not k.startswith("_") and k not in cls.__private__ } if isinstance(obj, (tuple, list)): return [cls.__value_repr__(v) for v in obj] @@ -815,7 +872,7 @@ def __getattr__(self, name: str): :raises AttributeError: If the attribute is not present. """ with self._lock: - if self.__has_value__(self): + if name not in self.__private__ and self.__has_value__(self): value = self._value if hasattr(value, name): return getattr(value, name) @@ -836,11 +893,7 @@ def __dir__(self) -> list[str]: :return: Sorted list of attribute names. """ attrs = set(super().__dir__()) - if not self.__has_value__(self): - attrs.discard("get_value") - else: + if self.__has_value__(self): value = self._value - if DISCOSNamespace.__is__(value): - attrs.discard("get_value") attrs = set(dir(value)).union(attrs) return sorted(attrs) diff --git a/discos_client/schemas/common/antenna.json b/discos_client/schemas/common/antenna.json index ff46230..ab38c52 100644 --- a/discos_client/schemas/common/antenna.json +++ b/discos_client/schemas/common/antenna.json @@ -21,7 +21,7 @@ "correctionEnabled": { "type": "boolean", "title": "Pointing Corrections", - "description": "Indicates whether automatic pointing correction is enabled." + "description": "Indicates whether the pointing correction is applied." }, "declinationOffset": { "type": "number", @@ -192,6 +192,18 @@ "description": "Y component of the Earth's polar motion (yp), in arcseconds.", "unit": "arcseconds" }, + "gast": { + "type": "string", + "title": "Greenwich Apparent Sidereal Time", + "description": "Greenwich Apparent Sidereal Time formatted as HH:MM:SS.mmm.", + "format": "time" + }, + "lst": { + "type": "string", + "title": "Local Apparent Sidereal Time", + "description": "Local Apparent Sidereal Time formatted as HH:MM:SS.mmm.", + "format": "time" + }, "DUT1": { "type": "number", "title": "DUT1", @@ -215,8 +227,10 @@ "zPosition", "xPolarMotion", "yPolarMotion", - "ellipsoid", - "DUT1" + "gast", + "lst", + "DUT1", + "ellipsoid" ], "additionalProperties": false }, @@ -224,33 +238,129 @@ "$ref": "../definitions/status.json" }, "target": { - "type": "string", - "title": "Target ID", - "description": "Identifier or name of the current observed target." - }, - "targetDeclination": { - "type": "string", - "title": "Target Declination", - "description": "Declination coordinate of the observed target.", - "unit": "dms" - }, - "targetFlux": { - "type": "number", - "title": "Target Flux", - "description": "Flux density of the target.", - "unit": "Jansky" - }, - "targetRightAscension": { - "type": "string", - "title": "Target Right Ascension", - "description": "Right ascension coordinate of the observed target.", - "unit": "hms" - }, - "targetVrad": { - "type": "number", - "title": "Target Radial Velocity", - "description": "Radial velocity of the target.", - "unit": "km/s" + "type": "object", + "title": "Target Information", + "description": "Properties of the currently tracked target, including catalog data and generator-computed apparent coordinates.", + "properties": { + "apparentAzimuth": { + "type": "number", + "title": "Apparent Azimuth", + "description": "Apparent azimuth computed by the generator.", + "unit": "degrees" + }, + "apparentDeclination": { + "type": "string", + "title": "Apparent Declination", + "description": "Apparent declination computed by the generator.", + "unit": "dms" + }, + "apparentElevation": { + "type": "number", + "title": "Apparent Elevation", + "description": "Apparent elevation computed by the generator.", + "unit": "degrees" + }, + "apparentGalLatitude": { + "type": "string", + "title": "Apparent Galactic Latitude", + "description": "Apparent galactic latitude computed by the generator.", + "unit": "dms" + }, + "apparentGalLongitude": { + "type": "string", + "title": "Apparent Galactic Longitude", + "description": "Apparent galactic longitude computed by the generator.", + "unit": "dms" + }, + "apparentRightAscension": { + "type": "string", + "title": "Apparent Right Ascension", + "description": "Apparent right ascension computed by the generator.", + "unit": "hms" + }, + "catalogDeclination": { + "type": "string", + "title": "Catalog Declination", + "description": "J2000 catalog declination of the target.", + "unit": "dms" + }, + "catalogRightAscension": { + "type": "string", + "title": "Catalog Right Ascension", + "description": "J2000 catalog right ascension of the target.", + "unit": "hms" + }, + "flux": { + "type": "number", + "title": "Flux", + "description": "Flux density of the target.", + "unit": "Jansky" + }, + "julianEpoch": { + "type": "number", + "title": "Julian Epoch", + "description": "Julian Epoch used for target calculations." + }, + "name": { + "type": "string", + "title": "Target Name", + "description": "Identifier or name of the currently observed target." + }, + "parallacticAngle": { + "type": "number", + "title": "Parallactic Angle", + "description": "Current parallactic angle.", + "unit": "degrees" + }, + "radialVelocity": { + "type": "number", + "title": "Radial Velocity", + "description": "Radial velocity of the target.", + "unit": "km/s" + }, + "radialVelocityDefinition": { + "type": "string", + "title": "Radial Velocity Definition", + "description": "Radial velocity definition of the target.", + "enum": [ + "RADIO", + "OPTICAL", + "REDSHIFT", + "UNDEFINED" + ] + }, + "radialVelocityFrame": { + "type": "string", + "title": "Radial Velocity Reference Frame", + "description": "Reference frame used for the target radial velocity measurements.", + "enum": [ + "TOPOCENTRIC", + "BARYCENTRIC", + "KINEMATIC LOCAL STANDARD OF REST", + "DYNAMIC LOCAL STANDARD OF REST", + "GALACTIC CENTER", + "LOCAL GROUP", + "UNDEFINED" + ] + } + }, + "required": [ + "apparentAzimuth", + "apparentDeclination", + "apparentElevation", + "apparentGalLatitude", + "apparentGalLongitude", + "apparentRightAscension", + "catalogDeclination", + "catalogRightAscension", + "flux", + "julianEpoch", + "name", + "parallacticAngle", + "radialVelocity", + "radialVelocityDefinition", + "radialVelocityFrame" + ] }, "timestamp": { "$ref": "../definitions/timestamp.json" @@ -260,31 +370,6 @@ "title": "Tracking Flag", "description": "Indicates whether the antenna is tracking the given coordinates." }, - "vradDefinition": { - "type": "string", - "title": "Target Radial Velocity Definition", - "description": "Radial velocity definition of the target.", - "enum": [ - "RADIO", - "OPTICAL", - "REDSHIFT", - "UNDEFINED" - ] - }, - "vradReferenceFrame": { - "type": "string", - "title": "Target Radial Velocity Reference Frame", - "description": "Reference frame used for the target radial velocity measurements.", - "enum": [ - "TOPOCENTRIC", - "BARYCENTRIC", - "KINEMATIC LOCAL STANDARD OF REST", - "DYNAMIC LOCAL STANDARD OF REST", - "GALACTIC CENTER", - "LOCAL GROUP", - "UNDEFINED" - ] - }, "waveLength": { "type": "number", "title": "Observation Wavelength", @@ -316,14 +401,8 @@ "site", "status", "target", - "targetDeclination", - "targetFlux", - "targetRightAscension", - "targetVrad", "timestamp", "tracking", - "vradDefinition", - "vradReferenceFrame", "waveLength" ], "additionalProperties": false diff --git a/discos_client/schemas/common/command_answer.json b/discos_client/schemas/common/command_answer.json new file mode 100644 index 0000000..2354d07 --- /dev/null +++ b/discos_client/schemas/common/command_answer.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "definitions/command_answer.json", + "title": "Command Answer", + "description": "Schema representing an answer from DISCOS following a command.", + "node": "command_answer", + "type": "object", + "properties": { + "answer": { + "type": "array", + "title": "Command Answer", + "description": "Return values of the issued command.", + "items": { + "oneOf": [ + { "type": "string" }, + { "type": "number" }, + { "type": "boolean" } + ] + } + }, + "command": { + "type": "string", + "title": "Command Name", + "description": "Name of the issued command." + }, + "error_trace": { + "type": "array", + "title": "Error Trace", + "description": "List of errors occurred during the execution of the issued command.", + "items": { + "type": "object", + "title": "Error Detail", + "description": "Detailed information about a single error in the stack.", + "properties": { + "code": { + "type": "number", + "title": "Error Code", + "description": "Code of the returned error." + }, + "category": { + "type": "number", + "title": "Error Category", + "description": "Category of the returned error." + }, + "message": { + "type": "string", + "title": "Error Message", + "description": "Text description of the returned error." + } + }, + "required": ["code", "category", "message"], + "additionalProperties": false + } + }, + "executed": { + "type": "boolean", + "title": "Command Executed", + "description": "Boolean indicating whether the command was executed or not." + }, + "timestamp": { + "$ref": "../definitions/timestamp.json" + } + }, + "required": ["command", "executed", "timestamp"], + "additionalProperties": false +} diff --git a/discos_client/schemas/common/derotators.json b/discos_client/schemas/common/derotators.json index 0ca129d..196593c 100644 --- a/discos_client/schemas/common/derotators.json +++ b/discos_client/schemas/common/derotators.json @@ -52,6 +52,11 @@ "title": "Slewing", "description": "Indicates whether the derotator is currently slewing." }, + "socketConnected": { + "type": "boolean", + "title": "Socket connected", + "description": "Derotator socket is connected." + }, "timestamp": { "$ref": "../definitions/timestamp.json" }, @@ -75,6 +80,7 @@ "ready", "rewindingStep", "slewing", + "socketConnected", "timestamp", "tracking", "trackingError" diff --git a/discos_client/schemas/definitions/active_surface.json b/discos_client/schemas/definitions/active_surface.json index 028a21c..03381f4 100644 --- a/discos_client/schemas/definitions/active_surface.json +++ b/discos_client/schemas/definitions/active_surface.json @@ -7,7 +7,7 @@ "properties": { "LUTFilename": { "type": "string", - "title": "LookUp Table file name", + "title": "LookUp Table File Name", "description": "Name of the lookup table file being used." }, "profile": { diff --git a/discos_client/schemas/medicina/active_surface.json b/discos_client/schemas/medicina/active_surface.json index 29b010b..a7fbbef 100644 --- a/discos_client/schemas/medicina/active_surface.json +++ b/discos_client/schemas/medicina/active_surface.json @@ -42,6 +42,20 @@ } ] }, + "LAN_1_7": { + "allOf": [ + { "$ref": "#/$defs/LAN_1_6" }, + { + "type": "object", + "properties": { + "USD07": { "$ref": "../definitions/USD.json" } + }, + "initialize": [ + "USD07" + ] + } + ] + }, "SECTOR": { "type": "object", "title": "Active surface sector", @@ -53,7 +67,7 @@ "LAN04": { "$ref": "#/$defs/LAN_2_6" }, "LAN05": { "$ref": "#/$defs/LAN_1_6" }, "LAN06": { "$ref": "#/$defs/LAN_2_6" }, - "LAN07": { "$ref": "#/$defs/LAN_1_6" }, + "LAN07": { "$ref": "#/$defs/LAN_1_7" }, "LAN08": { "$ref": "#/$defs/LAN_2_6" }, "LAN09": { "$ref": "#/$defs/LAN_1_6" }, "LAN10": { "$ref": "#/$defs/LAN_2_6" }, diff --git a/discos_client/schemas/noto/active_surface.json b/discos_client/schemas/noto/active_surface.json index 5dc1c44..5e61169 100644 --- a/discos_client/schemas/noto/active_surface.json +++ b/discos_client/schemas/noto/active_surface.json @@ -28,6 +28,20 @@ } ] }, + "LAN_2_7": { + "allOf": [ + { "$ref": "#/$defs/LAN_2_6" }, + { + "type": "object", + "properties": { + "USD07": { "$ref": "../definitions/USD.json" } + }, + "initialize": [ + "USD07" + ] + } + ] + }, "SECTOR": { "type": "object", "title": "Active surface sector", @@ -39,7 +53,7 @@ "LAN04": { "$ref": "#/$defs/LAN_2_6" }, "LAN05": { "$ref": "#/$defs/LAN_2_6" }, "LAN06": { "$ref": "#/$defs/LAN_2_6" }, - "LAN07": { "$ref": "#/$defs/LAN_2_6" }, + "LAN07": { "$ref": "#/$defs/LAN_2_7" }, "LAN08": { "$ref": "#/$defs/LAN_2_6" }, "LAN09": { "$ref": "#/$defs/LAN_2_6" }, "LAN10": { "$ref": "#/$defs/LAN_2_6" }, diff --git a/discos_client/scripts.py b/discos_client/scripts.py new file mode 100644 index 0000000..7414f0e --- /dev/null +++ b/discos_client/scripts.py @@ -0,0 +1,165 @@ +import os +import shutil +from pathlib import Path +from argparse import ArgumentParser +from platformdirs import user_config_dir +from zmq.auth import create_certificates, load_certificate + + +def get_config_paths(identity: str): + base_config = Path(user_config_dir("discos")) + config_dir = base_config / "rpc" / "client" + public = config_dir / f"{identity}.key" + secret = config_dir / f"{identity}.key_secret" + return config_dir, public, secret + + +def set_permissions(public_path, secret_path): + if os.name == "posix": + try: + public_path.chmod(0o644) + secret_path.chmod(0o600) + except OSError as e: + print(f"Warning: Could not set permissions: {e}") + + +def import_discos_keys(secret_src, identity, overwrite): + config_dir, public_dst, secret_dst = get_config_paths(identity) + + if not secret_src.exists(): + print(f"Error: Secret key file not found: {secret_src}") + return 1 + + try: + user_pub, _ = load_certificate(str(secret_src)) + user_pub = user_pub.decode("utf-8") + except (OSError, ValueError) as e: + print(f"Error reading secret key file: {e}") + print("Ensure if is a valid ZMQ secret certificate.") + return 1 + + if (secret_dst.exists() or public_dst.exists()) and not overwrite: + print(f"Kept previously created key pair '{identity}'. " + "Use --overwrite to replace it.\n") + return 0 + + try: + config_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + print(f"Error creating the configuration directory: {e}") + return 1 + + create_certificates(str(config_dir), identity) + generated_pub, _ = load_certificate(str(public_dst)) + generated_pub = generated_pub.decode("utf-8") + + shutil.copy2(secret_src, secret_dst) + + with open(secret_src, "r", encoding="utf-8") as f: + creation_date = f.readline() + + lines = [] + with open(public_dst, "r", encoding="utf-8") as f: + lines = f.readlines() + + lines[0] = creation_date + + with open(public_dst, "w", encoding="utf-8") as f: + for line in lines: + if generated_pub in line: + line = line.replace(generated_pub, user_pub) + f.write(line) + + set_permissions(public_dst, secret_dst) + print(f"Keys '{identity}' imported successfully to : '{config_dir}'.") + return 0 + + +def create_discos_keys(identity, overwrite): + config_dir, public, secret = get_config_paths(identity) + + if secret.exists() and not overwrite: + print(f"Kept previously created key pair '{identity}'. " + "Use --overwrite to replace it.\n") + return 0 + + try: + config_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + print(f"Error creating the configuration directory: {e}") + return 1 + + create_certificates(str(config_dir), identity) + set_permissions(public, secret) + + print(f"Key pair '{identity}' created in: '{config_dir}'.") + return 0 + + +def print_discos_keys(identity): + _, public, _ = get_config_paths(identity) + + if not public.exists(): + print(f"No key named '{identity}' was found.") + return 0 + + with open(public, "r", encoding="utf-8") as f: + print(f.read()) + + print(f"\nPath of the public key file: {public}") + print(f"Remember to never share the '{identity}.key_secret' file with " + "anyone.") + print( + "In order to be authorized to send command to any of the telescopes, " + f"remember to send a copy of the '{identity}.key' file to the " + "DISCOS team, asking for authorization. Your request will be taken " + "into consideration and you will hear back from the team." + ) + return 0 + + +def keygen(): + parser = ArgumentParser( + description="DISCOS CURVE key pairs generator." + ) + parser.add_argument( + "identity", + type=str, + help="The identity of the application that will use the DISCOSClient " + "to send remote commands." + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite existing keys. Dafaults to False." + ) + parser.add_argument( + "--show-only", + action="store_true", + help="Only prints the public key and its path without \ + generating a new pair." + ) + parser.add_argument( + "--import-pair", + type=Path, + help="Path to an existing secret key file to import " + "The public key will be automatically derived from it." + ) + args = parser.parse_args() + + if args.show_only: + return print_discos_keys(args.identity) + + if args.import_pair: + return_code = import_discos_keys( + args.import_pair, + args.identity, + args.overwrite + ) + else: + return_code = create_discos_keys(args.identity, args.overwrite) + + if return_code != 0: + return return_code + + return print_discos_keys(args.identity) diff --git a/discos_client/servers/srt/server.key b/discos_client/servers/srt/server.key new file mode 100644 index 0000000..a68a264 --- /dev/null +++ b/discos_client/servers/srt/server.key @@ -0,0 +1,9 @@ +# **** Generated on 2025-12-18 10:39:47.317361 by pyzmq **** +# ZeroMQ CURVE Public Certificate +# Exchange securely, or use a secure mechanism to verify the contents +# of this file after exchange. Store public certificates in your home +# directory, in the .curve subdirectory. + +metadata +curve + public-key = "c2u6x}C-+P{P0.K(Fp+Qjj0*x.)IeX}Z}1v" diff --git a/discos_client/utils.py b/discos_client/utils.py index d070c1e..6826b0e 100644 --- a/discos_client/utils.py +++ b/discos_client/utils.py @@ -2,7 +2,13 @@ import operator import secrets import string +import time +from datetime import datetime, timezone from typing import Any, Callable +from importlib.resources import files +from pathlib import Path +from zmq.auth import load_certificate +from platformdirs import user_config_dir __all__ = [ @@ -10,7 +16,9 @@ "rand_id", "delegated_operations", "delegated_comparisons", - "public_dict" + "public_dict", + "get_auth_keys", + "timestamp" ] META_KEYS = ("type", "title", "description", "format", "unit", "enum") @@ -113,6 +121,9 @@ def public_dict( """ d = {} for k, v in vars(obj).items(): + if callable(v): + # We don't need to include methods + continue if k == "_value": if isinstance(v, (list, tuple)): d["items"] = __unwrap(v, is_fn, get_value_fn) @@ -145,3 +156,106 @@ def __unwrap(value: Any, is_fn, get_value_fn) -> Any: while is_fn(value): value = get_value_fn(value) return list(value) if isinstance(value, (list, tuple)) else value + + +def get_client_auth_keys(identity: str) -> tuple[bytes, bytes]: + """Retrieve the CURVE client key pair associated with a given identity. + + The key pair is loaded from the user's configuration directory, + typically located at: + ``~/.config/discos/rpc/client/.key_secret`` + + :param identity: The name of the client identity (key file prefix). + :return: A tuple containing the client public and secret keys. + :raises OSError: If the key file cannot be found or loaded. + """ + config_base = Path(user_config_dir("discos")) + curve_directory = config_base / "rpc" / "client" + client_pair = curve_directory / f"{identity}.key_secret" + client_public, client_secret = load_certificate(client_pair) + return client_public, client_secret + + +def get_server_public_key( + telescope: str | None = None, + server_public_key_file: str | Path | None = None, +) -> bytes: + """Retrieve the CURVE public key of a DISCOS RPC server. + + If ``server_public_key_file`` is provided, it overrides the default + server key associated with ``telescope``. Otherwise, the bundled key + for the given telescope is used. + + :param telescope: The telescope name used to locate a bundled server key. + :param server_public_key_file: Path to a server public key file. + If provided, it overrides the default key associated with + ``telescope``. + :return: The server public key. + :raises ValueError: If neither ``telescope`` nor + ``server_public_key_file`` is provided. + :raises OSError: If the selected key file cannot be found or loaded. + """ + if server_public_key_file is not None: + server_public, _ = load_certificate(Path(server_public_key_file)) + return server_public + + if telescope is not None: + server_pair = files("discos_client") / "servers" \ + / telescope.lower() / "server.key" + server_public, _ = load_certificate(server_pair) + return server_public + + raise ValueError( + "Either 'telescope' or 'server_public_key_file' must be provided " + "to enable RPC." + ) + + +def get_auth_keys( + identity: str, + telescope: str | None = None, + server_public_key_file: str | Path | None = None +) -> tuple[bytes, bytes, bytes]: + """Retrieve CURVE authentication keys for both client and server. + + The client key pair is loaded from the user configuration associated + with ``identity``. The server public key is loaded from + ``server_public_key_file`` if provided; otherwise, the bundled key + for ``telescope`` is used. + + :param identity: The name of the client identity (key file prefix). + :param telescope: The telescope name used to locate a bundled server key. + :param server_public_key_file: Path to a server public key file. + If provided, it overrides the default key associated with + ``telescope``. + :return: A tuple containing (client_public, client_secret, server_public). + :raises ValueError: If neither ``telescope`` nor + ``server_public_key_file`` is provided. + :raises OSError: If any selected key file cannot be found or loaded. + """ + client_public, client_secret = get_client_auth_keys(identity) + server_public = get_server_public_key(telescope, server_public_key_file) + return client_public, client_secret, server_public + + +def timestamp() -> dict[str, Any]: + """Return the current timestamp in multiple standard formats. + + The returned dictionary contains: + - ``unix_time``: seconds since the Unix epoch (float) + - ``omg_time``: time in 100-nanosecond intervals since + 1582-10-15 (UUID/OMG timestamp format) + - ``mjd``: Modified Julian Date (days since 1858-11-17) + - ``iso8601``: UTC time in ISO 8601 format with millisecond precision + + :return: A dictionary containing the current time in multiple formats. + """ + now = time.time() + iso8601 = datetime.fromtimestamp(now, tz=timezone.utc) + iso8601 = iso8601.isoformat(timespec="milliseconds").replace("+00:00", "Z") + return { + "unix_time": now, + "omg_time": int((now + 12219292800) * 10_000_000), + "mjd": (now / 86400.0) + 40587.0, + "iso8601": iso8601 + } diff --git a/docs/developer/inner_classes.rst b/docs/api/api.rst similarity index 92% rename from docs/developer/inner_classes.rst rename to docs/api/api.rst index 8aa91c7..b948bb7 100644 --- a/docs/developer/inner_classes.rst +++ b/docs/api/api.rst @@ -1,5 +1,5 @@ -Internal Classes -================ +API Reference +============= These are the core classes used to implement the behavior of the DISCOS client system. In general, users interacts primarily with a specific telescope client: @@ -9,14 +9,14 @@ providing IP address and port of the DISCOS Manager machine (for example when connecting to a simulated instance of DISCOS). All values read inside the client are :class:`~discos_client.namespace.DISCOSNamespace` objects. + DISCOSClient ------------ .. autoclass:: discos_client.client.DISCOSClient - :members: + DISCOSNamespace --------------- .. autoclass:: discos_client.namespace.DISCOSNamespace - :members: diff --git a/docs/conf.py b/docs/conf.py index 0d415b3..1f40876 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,12 +1,25 @@ import os import importlib import sys -from sphinx.ext.autodoc import ClassDocumenter +from sphinx import addnodes +from sphinx.pycode import ModuleAnalyzer +from sphinx.ext.autodoc import MethodDocumenter, ClassDocumenter + sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) sys.path.insert(0, os.path.abspath('../discos_client')) from patches import _simpletype, _complexstructures, _reference, _transform +# We add these methods which are normally "hidden" +from discos_client.client import DISCOSClient +DISCOSClient.command = DISCOSClient.__command__ +from discos_client.namespace import DISCOSNamespace +DISCOSNamespace.bind = DISCOSNamespace.__bind__ +DISCOSNamespace.copy = DISCOSNamespace.__copy__ +DISCOSNamespace.get_value = DISCOSNamespace.__get_value__ +DISCOSNamespace.unbind = DISCOSNamespace.__unbind__ +DISCOSNamespace.wait = DISCOSNamespace.__wait__ + # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -26,6 +39,8 @@ 'sphinx-jsonschema', ] +autoclass_content = "both" + autodoc_typehints = "none" autodoc_default_options = { @@ -53,6 +68,8 @@ html_theme_options = { "style_external_links": True, "collapse_navigation": False, + "sticky_navigation": True, + "titles_only": False, "navigation_depth": 3, "prev_next_buttons_location": "bottom", } @@ -64,29 +81,22 @@ html_static_path = ['_static'] -class SkipMembersClassDocumenter(ClassDocumenter): - objtype = "class" - - option_spec = ClassDocumenter.option_spec.copy() - option_spec["skip-members"] = lambda arg: [name.strip() for name in arg.split(",")] if arg else [] - - def parse_name(self): - return super().parse_name() - - -def skip_special_members(app, what, name, obj, skip, options): - global_skips = app.config.autoclass_skip_members_default or set() - directive_skips = set(options.get("skip-members", [])) - - if name in global_skips or name in directive_skips: - return True - return None - - def setup(app): - app.add_config_value("autoclass_skip_members_default", set(), "env") - app.add_autodocumenter(SkipMembersClassDocumenter, override=True) - app.connect("autodoc-skip-member", skip_special_members) + app.connect("viewcode-find-source", on_viewcode_find_source) + +def on_viewcode_find_source(app, modname): + analyzer = ModuleAnalyzer.for_module(modname) + analyzer.find_tags() + if modname == "discos_client.client": + if "DISCOSClient.__command__" in analyzer.tags: + analyzer.tags["DISCOSClient.command"] = analyzer.tags.get("DISCOSClient.__command__") + analyzer.tags["SRTClient.command"] = analyzer.tags.get("DISCOSClient.__command__") + analyzer.tags["MedicinaClient.command"] = analyzer.tags.get("DISCOSClient.__command__") + analyzer.tags["NotoClient.command"] = analyzer.tags.get("DISCOSClient.__command__") + if modname == "discos_client.namespace": + if "DISCOSNamespace.__get_value__" in analyzer.tags: + analyzer.tags["DISCOSNamespace.get_value"] = analyzer.tags.get("DISCOSNamespace.__get_value__") + return analyzer.code, analyzer.tags sjs_wide_format = importlib.import_module("sphinx-jsonschema.wide_format") diff --git a/docs/developer/developer.rst b/docs/developer/developer.rst index e019895..69d73c0 100644 --- a/docs/developer/developer.rst +++ b/docs/developer/developer.rst @@ -9,4 +9,3 @@ for testing and code quality checks. :maxdepth: 2 contributing - inner_classes diff --git a/docs/index.rst b/docs/index.rst index 99584ea..f3fa6d2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,13 +2,14 @@ DISCOSClient ============ **DISCOSClient** is a Python library that provides a high-level interface to -access real-time telemetry data published by the DISCOS (Development of the -Italian Single-dish COntrol System) infrastructure. This system is used by -INAF telescopes such as the Sardinia Radio Telescope (SRT), the Medicina Radio -Telescope, and the Noto Radio Telescope. +access real-time telemetry data and send control commands to DISCOS +(Development of the Italian Single-dish COntrol System), the control software +of the three INAF single-dish radiotelescopes, the Sardinia Radio Telescope +(SRT), the Medicina Radio Telescope, and the Noto Radio Telescope. The library provides an interface for accessing the internal state of telescope -components such as the `antenna`, `mount`, etc. +components such as the `antenna`, `mount`, etc., for interacting with them, and +for controlling the telescope by sending it commands. It is designed to be minimal, thread-safe, and self-describing via JSON schemas. DISCOSClient is built on top of `ZeroMQ `_, a @@ -16,46 +17,44 @@ high-performance asynchronous messaging library for distributed systems. All message transport is handled through `pyzmq `_, the official Python bindings for ZeroMQ. -This documentation guides you through installation, usage, and advanced functionality. - Key Features ------------ - Subscribe to one or multiple topics (e.g., `mount`, `antenna`) +- Send commands to DISCOS - Automatically decodes incoming JSON messages into nested Python namespaces - Supports both blocking and non-blocking access to telemetry data - Cross-platform: works on Linux, macOS, and Windows +- Minimal dependencies: only 2 non-built-in Python libraries are used for + maximum portability Goals ----- The main goals of `DISCOSClient` are: -- To simplify the development of monitoring and visualization tools +- To simplify the development of monitoring, visualization and control tools for telescope systems - To allow easy integration with third-party Python applications and dashboards - To provide a minimal yet powerful abstraction over ZMQ-based message - subscriptions + subscriptions and command dispatching Overview -------- The library exposes a main class `DISCOSClient` in order to access the telescope -telemetry, along with three pre-configured classes, one for each telescope: - -- `SRTClient` to access telemetry data from the Sardinia Radio Telescope -- `MedicinaClient` to access telemetry data from the Medicina Radio Telescope -- `NotoClient` to access telemetry data from the Noto Radio Telescope +telemetry data and send commands to it, along with three pre-configured classes, +one for each telescope: -These partial clients simplify the setup by pre-filling the `telescope`, `address` -and `port` arguments, making it easier to instantiate clients for the corresponding -observatory. +- `SRTClient` client for the Sardinia Radio Telescope +- `MedicinaClient` client for the Medicina Radio Telescope +- `NotoClient` client for the Noto Radio Telescope -Internally, each client instance manages a subscription socket and a background -thread that continuously listens for messages published by the telescope systems. - -Incoming JSON messages are automatically parsed and stored as structured Python -objects, which can be queried or watched. +These specialized clients simplify the setup by pre-filling the `telescope`, `address` +and `port` arguments. Internally, each client instance manages a subscription socket +for telemetry and a dedicated interface for command transmission. Incoming JSON messages +are automatically parsed and stored as structured Python objects, which can be queried +or watched. For further details, installation instructions, and usage examples, see the sections below. @@ -63,13 +62,31 @@ sections below. .. toctree:: :maxdepth: 2 - :caption: Contents: + :caption: Getting Started install - user - developer/developer + +.. toctree:: + :maxdepth: 2 + :caption: User Guide + :name: user-guide + + user/user schemas/schemas +.. toctree:: + :maxdepth: 2 + :caption: API Reference + :name: api-ref + + api/api + +.. toctree:: + :maxdepth: 1 + :caption: Project Info + + developer/developer + Indices and tables ================== diff --git a/docs/install.rst b/docs/install.rst index 6a012fe..3458c33 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -19,21 +19,8 @@ To install it on **Linux**, **macOS** or **Windows**, , move into the cd DISCOSClient pip install . -The last executed command will install the package along with its only required -dependency: `pyzmq`. - -Verifying the installation --------------------------- - -After installation, you can verify that ZeroMQ and its Python bindings are correctly -installed by performing this small test: - -.. code-block:: python - - import zmq - print(zmq.zmq_version(), zmq.__version__) - -If you see version numbers printed without errors, you're ready to use DISCOSClient. +The last executed command will install the package along with its required +dependencies: `pyzmq` and `platformdirs`. .. note:: @@ -47,3 +34,17 @@ If you see version numbers printed without errors, you're ready to use DISCOSCli apk add zeromq-dev pip install . + + +Verifying the installation +-------------------------- + +After installation, you can verify that ZeroMQ and its Python bindings are correctly +installed by performing this small test: + +.. code-block:: python + + import zmq + print(zmq.zmq_version(), zmq.__version__) + +If you see version numbers printed without errors, you're ready to use DISCOSClient. diff --git a/docs/schemas/schemas.rst b/docs/schemas/schemas.rst index 1a80b1e..6a88a09 100644 --- a/docs/schemas/schemas.rst +++ b/docs/schemas/schemas.rst @@ -1,13 +1,14 @@ .. _schemas: -Messages JSON Schemas -===================== +Telemetry data schemas +====================== -The following sections contain the message schemas, describing the properties -each schema includes, along with their title, unit of measure, and description. -Some messages are sent by DISCOS components common to all three telescopes, -while others are station-specific. Although certain messages may be delivered -on the same topics, their structure might differ depending on the station. +The following sections contain the telemetry message schemas, describing the +properties each schema includes, along with their title, unit of measure, and +description. Some messages are sent by DISCOS components common to all three +telescopes, while others are station-specific. Although certain messages may +be delivered on the same topics, their structure might differ depending on the +station. .. jsonschema:: example.json :auto_reference: true @@ -16,7 +17,7 @@ on the same topics, their structure might differ depending on the station. More details regarding each schema can be found in the following sections. .. toctree:: - :maxdepth: 2 + :maxdepth: 1 definitions active_surface diff --git a/docs/user/client.rst b/docs/user/client.rst new file mode 100644 index 0000000..74c7712 --- /dev/null +++ b/docs/user/client.rst @@ -0,0 +1,123 @@ +Creating and Configuring a Client +================================= + +The :class:`~discos_client.client.DISCOSClient` is the entry point for all +interactions with the telescope. Whether you are connecting to a real telescope +(SRT, Medicina, Noto) or a simulated environment, the client handles the +underlying ZeroMQ connections and maps the incoming telemetry into a structured +Python object tree based on the telescope's metadata definitions. + +.. note:: + For a complete list of methods and attributes, please refer to the + :doc:`/api/api` section. + +Connecting to DISCOS +------------------------------- + +The library provides three specialized classes pre-configured for the production +environments of INAF's single-dish radio telescopes. Using these classes is the +recommended way to connect, as they automatically handle: + +1. **Network Configuration**: IP addresses and Ports are pre-filled. +2. **Metadata Loading**: They automatically load the correct telescope-specific + schemas (e.g., specific *mount* or *minor_servo* structures) to build the + object hierarchy and expose descriptions and units. + +You can initialize a client by simply importing the class corresponding to your +observatory: + +**Sardinia Radio Telescope (SRT)** + +.. code-block:: python + + from discos_client import SRTClient + # Connects to SRT and subscribes to "mount" and "antenna" topics + SRT = SRTClient("mount", "antenna") + +**Medicina Radio Telescope** + +.. code-block:: python + + from discos_client import MedicinaClient + # Subscribes to ALL available topics + MED = MedicinaClient() + +**Noto Radio Telescope** + +.. code-block:: python + + from discos_client import NotoClient + NOTO = NotoClient() + +Topic Subscription +~~~~~~~~~~~~~~~~~~ + +When creating a client, the arguments you pass (e.g., ``"mount"``, ``"antenna"``) determine +which telemetry data you will receive. + +* **Specific Topics:** If you provide strings as arguments, the client subscribes *only* to those topics. This is bandwidth-efficient and recommended for specific control scripts. +* **All Topics:** If no arguments are provided, the client defaults to subscribing to **all** available telemetry topics for that telescope. + +.. seealso:: + See :ref:`schemas` for a detailed description of the data structures and metadata + available for each topic. + +Connecting to Custom or Simulated Environments +---------------------------------------------- + +If you are developing offline (e.g., using a Virtual Machine) or connecting to a +simulator, the specialized classes cannot be used as the network addresses will differ. +In this scenario, you must use the generic :class:`~discos_client.client.DISCOSClient`. + +When using the generic client, you **must** specify two key parameters: + +1. **address**: The IP address of the custom DISCOS Manager. +2. **telescope**: The telescope identifier (e.g., ``"SRT"``). This is crucial because + it tells the library which JSON schemas to load to correctly populate the object tree. + +**Example: Connecting to a simulated DISCOS instance** + +.. code-block:: python + + from discos_client import DISCOSClient + + # Connect to a local VM simulating SRT + # Note: We explicitly request the "active_surface" topic + client = DISCOSClient("active_surface", address="192.168.56.200", telescope="SRT") + +.. warning:: + **Metadata Consistency** + If you omit the ``telescope`` argument, the client will fall back to a "generic mode". + It will only load common schemas (like ``antenna``, ``weather``, ``scheduler``). + Telescope-specific components such as ``active_surface``, ``minor_servo``, and + ``mount`` **will not appear**, as their definition is station-specific. + +RPC server key override +~~~~~~~~~~~~~~~~~~~~~~~ + +When using :class:`~discos_client.client.DISCOSClient`, the ``telescope`` +parameter is still used to load the correct JSON schemas for telemetry +decoding. + +If remote commands are enabled through the ``identity`` parameter, the client +normally uses the default RPC server public key bundled for that telescope. +This behavior can be overridden by passing ``server_public_key_file``, which +forces the client to use the specified server public key file instead. + +This is useful when connecting to test environments, simulators, or custom +DISCOS deployments whose RPC server key differs from the default production +one. + +Example: connecting to a simulated DISCOS instance with a custom RPC key + +.. code-block:: python + + from discos_client import DISCOSClient + + client = DISCOSClient( + "active_surface", + address="192.168.56.200", + telescope="SRT", + identity="myclient", + server_public_key_file="/path/to/server.key", + ) diff --git a/docs/user/commands.rst b/docs/user/commands.rst new file mode 100644 index 0000000..16fdf29 --- /dev/null +++ b/docs/user/commands.rst @@ -0,0 +1,89 @@ +Sending commands to DISCOS +========================== + +The :class:`~discos_client.client.DISCOSClient` provides a simple interface to +send remote commands to the telescope control software. + +Command interface +----------------- + +If the client is correctly authenticated, it exposes a +:meth:`~discos_client.client.DISCOSClient.command` method. This method allows +you to send a command name along with any required arguments. + +To send a simple command: + +.. code-block:: python + + >>> answer = SRT.command("setupKKG") + >>> print(answer) + {'executed': True, 'command': 'setupKKG'} + +To send a command with arguments: + +.. code-block:: python + + >>> answer = SRT.command("goTo", 180, 45) + >>> print(answer) + {'executed': True, 'command': 'goTo'} + + +Client authentication +--------------------- + +In order for a client to be able to send commands to DISCOS, it has to +authenticate itself to the server. The authentication mechanism uses +`CurveZMQ `_, an authentication and encryption protocol +for ZeroMQ based on secure elliptic-curve cryptography. +The DISCOSClient package comes with an utility script capable of generating a +pair of CurveZMQ keys and store them in the default DISCOSClient configuration +directory. + +How to generate the keys +........................ + +The generation process: +1. Creates a configuration directory in the standard user path. +2. Generates a public key (``identity.key``) and a secret key (``identity.key_secret``). +3. Sets appropriate file permissions (644 for public, 600 for secret) on POSIX systems. + +Usage +..... + +You can run the generator from your terminal: + +.. code-block:: bash + + discos-keygen [options] + +* ``--overwrite``: Replace existing keys. +* ``--show-only``: Prints the current public key and its path without generating new ones. + +.. warning:: + **Never share your secret key!** Only the ``identity.key`` file should be shared. + The ``identity.key_secret`` file **must** remain private. + +Authorization Process +~~~~~~~~~~~~~~~~~~~~~ + +To be authorized to send commands to any of the telescopes: + +#. Generate your keys with ``discos-keygen``. +#. Locate your ``identity.key`` file. +#. Send a copy of the ``identity.key`` file to the **DISCOS team**, + asking for authorization for the desired telescopes. Your request + will be reviewed and you will hear back from the team. + +Server public key selection +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To send commands, the client must know the public key of the RPC server. + +For telescope-specific clients, or when using ``DISCOSClient`` with the +``telescope`` parameter, the default server public key bundled with the +package is used. + +If needed, this default can be overridden by passing +``server_public_key_file`` to :class:`~discos_client.client.DISCOSClient`. +This is especially useful for development environments, local simulators, +or custom deployments using a different server key. diff --git a/docs/user.rst b/docs/user/telemetry.rst similarity index 67% rename from docs/user.rst rename to docs/user/telemetry.rst index bc503eb..d7cdbcc 100644 --- a/docs/user.rst +++ b/docs/user/telemetry.rst @@ -1,98 +1,5 @@ -User Guide -========== - -This guide shows how to use :class:`~discos_client.client.DISCOSClient` to -read real-time telemetry data from INAF's single-dish radio telescopes. -The client provides an interface with structured data exposed as nested -Python objects. - -Instantiating a Client ----------------------- - -The :class:`~discos_client.client.DISCOSClient` can be instantiated for -different telescopes using predefined functions named after the supported -observatories. - -These helper functions automatically configure the network settings -and telescope name for you, so you won't have to provide an IP address and -port. You will only need to specify the topics you want to subscribe to. -If no topics are specified, the client will automatically subscribe -to all available ones for the selected telescope. - -.. |topics| replace:: One or more topic names to subscribe to. -.. |rettype| replace:: :class:`~discos_client.client.DISCOSClient` -.. |return| replace:: An instance of :class:`~discos_client.client.DISCOSClient` - -SRTClient -......... - -.. function:: SRTClient(*topics: str) - - Creates a client configured for the **Sardinia Radio Telescope (SRT)**. - - :param topics: |topics| - :type topics: str - :return: |return| - :return type: |rettype| - -MedicinaClient -.............. - -.. function:: MedicinaClient(*topics: str) - - Creates a client configured for the **Medicina Radio Telescope**. - - :param topics: |topics| - :type topics: str - :return: |return| - :return type: |rettype| - -NotoClient -.......... - -.. function:: NotoClient(*topics: str) - - Creates a client configured for the **Noto Radio Telescope**. - - :param topics: |topics| - :type topics: str - :return: |return| - :return type: |rettype| - -Generic DISCOSClient -.................... -If you are working with a DISCOS environment different from the three telescope -production lines, you might want to create an instance of a -:class:`~discos_client.client.DISCOSClient` with a custom pair of IP address -and port. In this case you will also need to specify the telescope line the -DISCOS instance you are pointing to is simulating, so that all the corresponding -schemas are correctly accessible. - -To create a client for the **Sardinia Radio Telescope**: - -.. code-block:: python - - from discos_client import SRTClient - - SRT = SRTClient("mount", "antenna") - -To create a client for the **Medicina Radio Telescope** and subscribe to all its available topics: - -.. code-block:: python - - MED = MedicinaClient() - -To create a client which points to a custom instance of DISCOS, simulating the -**Noto Radio Telescope**: - -.. code-block:: python - - from discos_client import DISCOSClient - - NOTO = DISCOSClient(address="192.168.56.200", port=16000, telescope="Noto") - -Direct access to values ------------------------ +Accessing telemetry data +======================== Clients maintain an internal view of the most recent values for each subscribed topic. You can access these data directly, at any time, @@ -112,6 +19,39 @@ lists). The entire tree updates in place as new messages are received. Since the whole tree is constantly updated, we provide several ways of accessing members, to cover different scenarios. +Accessing the value of a leaf :class:`~discos_client.namespace.DISCOSNamespace` object +-------------------------------------------------------------------------------------- + +The method shown above provides access to a +:class:`~discos_client.namespace.DISCOSNamespace` node of the status tree. + + + + +This class acts as a wrapper for the inner value, allowing it to be part of comparisons +and operations just like you were working with a pure string, integer, floating point +number or boolean value. In case you are accessing a leaf node of the tree, you may want +to access the internal value directly instead of using this wrapper functionality. +In order to retrieve the internal value, the +:class:`~discos_client.namespace.DISCOSNamespace` class offers a method called +:meth:`~discos_client.namespace.DISCOSNamespace.get_value`. + +.. code-block:: python + + projectCode = SRT.scheduler.projectCode + # projectCode is a DISCOSNamespace object + projectCode = SRT.scheduler.projectCode.get_value() + # projectCode is now a pure str object + +Most of the times you won't need to access inner values, but for more complex +operations, sometimes your Python distribution might raise some exceptions when +using a :class:`~discos_client.namespace.DISCOSNamespace` object +as indexer for a list or a dictionary. In case you bump into some weird behavior, +try using :meth:`~discos_client.namespace.DISCOSNamespace.get_value`. You will +also benefit in case you need to work with a fixed value and avoid the continuous +updating of a :class:`~discos_client.namespace.DISCOSNamespace` node. + + Immutable snapshots with :meth:`~discos_client.namespace.DISCOSNamespace.copy` ------------------------------------------------------------------------------ @@ -130,9 +70,10 @@ with the :meth:`~discos_client.namespace.DISCOSNamespace.copy` method. Waiting for updates with :meth:`~discos_client.namespace.DISCOSNamespace.wait` ------------------------------------------------------------------------------ -Sometimes you only need to check the value of a property only for changes, for -example to check if the antenna was stowed due to high winds. The -:meth:`~discos_client.namespace.DISCOSNamespace.wait` method comes to your help. +Sometimes you only need to check the value of a property for changes, for +example, when a new subscan is being performed, or to check if the antenna was +stowed due to high winds. The :meth:`~discos_client.namespace.DISCOSNamespace.wait` +method comes to your help. .. code-block:: python @@ -168,9 +109,9 @@ changes. ... True -The example above will call ``printValue`` and print the value of -scheduler.tracking as soon as it changes. The three dots ... represent some -other code that the application will continue to execute in the main thread. +The example above will call the simple ``printValue`` function, which will print +the value of scheduler.tracking as soon as it changes. The three dots ... represent +some other code that the application will continue to execute in the main thread. A callback registered with :meth:`~discos_client.namespace.DISCOSNamespace.bind` might not be needed anymore at some point in time. The @@ -212,7 +153,10 @@ method. # send an alarm to someone that is waiting for the antenna to be stowed The same principle can be applied to the -:meth:`~discos_client.namespace.DISCOSNamespace.bind` method. +:meth:`~discos_client.namespace.DISCOSNamespace.bind` method. The following +example shows how to bind a callback that would send an alarm via e-mail +whenever the temperature of the cool head of the SRTKBandMFReceiver is greater +or equal to 30K. .. code-block:: python @@ -259,35 +203,10 @@ the callback a one-time called function. In the last example, the ``sendAlarm`` callback is called once as soon as the newly received value for cryoTemperatureCoolHead of the SRTKBandMFReceiver is -greater or equal to 30. As soon as the alarm logic is executed, the callback +greater or equal to 30K. As soon as the alarm logic is executed, the callback can be unregistered, preventing the application to send another unwanted alarm. - -Accessing the inner value of a :class:`~discos_client.namespace.DISCOSNamespace` object ---------------------------------------------------------------------------------------- -Methods shown above always provide access to a -:class:`~discos_client.namespace.DISCOSNamespace` node of the status tree. This class -acts as a wrapper for the inner value, allowing it to be part of comparisons and -operations just like you were working with a pure string, integer, floating point -number or boolean value. Sometimes you would want to get rid of the -:class:`~discos_client.namespace.DISCOSNamespace` wrapper and work with the inner -value. In order to retrieve it the -:class:`~discos_client.namespace.DISCOSNamespace` class offers a method called -:meth:`~discos_client.namespace.DISCOSNamespace.get_value`. - -.. code-block:: python - - projectCode = SRT.scheduler.projectCode - # projectCode is a DISCOSNamespace object - projectCode = SRT.scheduler.projectCode.get_value() - # projectCode is now a pure str object - -Most of the times you won't need to access inner value, but for more complex -operations, sometimes your Python distribution might raise some exceptions when -using a :class:`~discos_client.namespace.DISCOSNamespace` object -as indexer for a list or a dictionary. In case you bump into some weird behavior, -try using :meth:`~discos_client.namespace.DISCOSNamespace.get_value`. You will -also benefit in case you need to work with a fixed value and avoid the continuous -updating of a :class:`~discos_client.namespace.DISCOSNamespace` node. +The callback must be registered again later if you would like to receive +another alarm if the temperature rises again. Tips and best practices ----------------------- @@ -297,5 +216,5 @@ Tips and best practices processing so that the value stays the same * Always unregister callbacks you no longer need with :meth:`~discos_client.namespace.DISCOSNamespace.unbind` so that code is not - executed when is no longer needed + executed again and again * Refer to the :class:`~discos_client.namespace.DISCOSNamespace` class for more details. diff --git a/docs/user/user.rst b/docs/user/user.rst new file mode 100644 index 0000000..92e9299 --- /dev/null +++ b/docs/user/user.rst @@ -0,0 +1,12 @@ +User Documentation +================== + +This section shows how to use :class:`~discos_client.client.DISCOSClient` to +interact with the desired telescope. + +.. toctree:: + :maxdepth: 2 + + client + telemetry + commands diff --git a/pyproject.toml b/pyproject.toml index 86ad494..356a567 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,14 +16,18 @@ classifiers = [ ] dependencies = [ "pyzmq", + "platformdirs" ] [tool.setuptools] packages = ["discos_client"] +[project.scripts] +discos-keygen = "discos_client.scripts:keygen" + [tool.setuptools.package-data] -"discos_client" = ["schemas/**"] +discos_client = ["schemas/**", "servers/**"] [project.optional-dependencies] test = ["coverage", "prospector", "jsonschema", "referencing"] -docs = ["sphinx", "sphinx-rtd-theme", "sphinx-autodoc-typehints", "sphinx-jsonschema"] +docs = ["sphinx<9,>=6", "sphinx-rtd-theme", "sphinx-autodoc-typehints", "sphinx-jsonschema"] diff --git a/tests/messages/common/antenna.json b/tests/messages/common/antenna.json index 52d5a12..f36a428 100644 --- a/tests/messages/common/antenna.json +++ b/tests/messages/common/antenna.json @@ -1 +1 @@ -{"FWHM":0.012389297379652888,"azimuthOffset":0.0,"correctionEnabled":true,"declinationOffset":0.0,"elevationOffset":0.0,"generatorType":"SIDEREAL","latitudeOffset":0.0,"longitudeOffset":0.0,"observedAzimuth":322.8937349822626,"observedDeclination":"61:52:27.248","observedElevation":49.9659909874578,"observedGalLatitude":"01:03:54.545","observedGalLongitude":"133:56:51.946","observedRightAscension":"02:27:04.197","pointingAzimuthCorrection":-2.288588982262583,"pointingElevationCorrection":0.1301309811997843,"rawAzimuth":320.605146,"rawElevation":50.112084,"refractionCorrection":0.01596203134241162,"rightAscensionOffset":0.0,"site":{"DUT1":0.0,"ellipsoid":"WGS84","height":700.0,"latitude":"39:29:34.000","longitude":"09:14:42.000","name":"SRT","xPolarMotion":0.0,"xPosition":4865226.971722066,"yPolarMotion":0.0,"yPosition":791916.7014386645,"zPosition":4035129.9493953995},"status":"OK","target":"w3oh","targetDeclination":"61:52:27.100","targetFlux":0.0,"targetRightAscension":"02:27:04.100","targetVrad":0.0,"timestamp":{"iso8601":"2025-07-28T09:09:42.622Z","mjd":60884.38174331002,"omg_time":139729865826220000,"unix_time":1753693782.622},"tracking":true,"vradDefinition":"UNDEFINED","vradReferenceFrame":"UNDEFINED","waveLength":0.013016344998263285} +{"FWHM":0.012389297379652888,"azimuthOffset":0.0,"correctionEnabled":true,"declinationOffset":0.0,"elevationOffset":0.0,"generatorType":"SIDEREAL","latitudeOffset":0.0,"longitudeOffset":0.0,"observedAzimuth":37.180069496300824,"observedDeclination":"61:52:27.129","observedElevation":42.42075013494604,"observedGalLatitude":"01:03:54.248","observedGalLongitude":"133:56:51.507","observedRightAscension":"02:27:04.125","pointingAzimuthCorrection":-2.2897404963007992,"pointingElevationCorrection":0.13905472728890966,"rawAzimuth":394.890329,"rawElevation":42.580579,"refractionCorrection":0.02077413776504994,"rightAscensionOffset":0.0,"site":{"DUT1":0.0,"ellipsoid":"WGS84","gast":"21:04:46.339","height":700.0,"latitude":"39:29:34.000","longitude":"09:14:42.000","lst":"21:41:45.139","name":"SRT","xPolarMotion":0.0,"xPosition":4865226.971722066,"yPolarMotion":0.0,"yPosition":791916.7014386645,"zPosition":4035129.9493953995},"status":"OK","target":{"apparentAzimuth":37.180161964931294,"apparentDeclination":"61:59:39.625","apparentElevation":42.420769117741976,"apparentGalLatitude":"01:03:54.159","apparentGalLongitude":"133:56:51.356","apparentRightAscension":"02:29:01.768","catalogDeclination":"61:52:27.100","catalogRightAscension":"02:27:04.100","flux":0.0,"julianEpoch":2026.2255254374852,"name":"w3oh","parallacticAngle":-83.30656977327779,"radialVelocity":0.0,"radialVelocityDefinition":"UNDEFINED","radialVelocityFrame":"UNDEFINED"},"timestamp":{"iso8601":"2026-03-24T08:57:21.546Z","mjd":61123.37316604145,"omg_time":139936354415460000,"unix_time":1774342641.546},"tracking":true,"waveLength":0.013016344998263285} diff --git a/tests/messages/common/derotators.json b/tests/messages/common/derotators.json index 49c61ec..8308914 100644 --- a/tests/messages/common/derotators.json +++ b/tests/messages/common/derotators.json @@ -1 +1 @@ -{"DR_GFR1":{"commandedPosition":136.93804590474818,"currentPosition":136.937158,"maxLimit":142.5,"minLimit":-117.5,"ready":true,"rewindingStep":60.0,"slewing":true,"timestamp":{"iso8601":"2025-12-05T21:40:58.558Z","mjd":61014.90345553262,"omg_time":139842636585581120,"unix_time":1764970858.558112},"tracking":true,"trackingError":0.0008879047481684665},"currentConfiguration":"BSC","currentDerotator":"DR_GFR1","currentSetup":"KKG","rewinding":false,"rewindingMode":"AUTO","rewindingRequired":false,"status":"OK","timestamp":{"iso8601":"2025-12-05T21:40:58.536Z","mjd":61014.903455277905,"omg_time":139842636585361332,"unix_time":1764970858.5361333},"tracking":true,"updating":true} +{"DR_GFR1":{"commandedPosition":136.93804590474818,"currentPosition":136.937158,"maxLimit":142.5,"minLimit":-117.5,"ready":true,"rewindingStep":60.0,"slewing":true,"socketConnected":true,"timestamp":{"iso8601":"2025-12-05T21:40:58.558Z","mjd":61014.90345553262,"omg_time":139842636585581120,"unix_time":1764970858.558112},"tracking":true,"trackingError":0.0008879047481684665},"currentConfiguration":"BSC","currentDerotator":"DR_GFR1","currentSetup":"KKG","rewinding":false,"rewindingMode":"AUTO","rewindingRequired":false,"status":"OK","timestamp":{"iso8601":"2025-12-05T21:40:58.536Z","mjd":61014.903455277905,"omg_time":139842636585361332,"unix_time":1764970858.5361333},"tracking":true,"updating":true} diff --git a/tests/test_client.py b/tests/test_client.py index fd119ac..77440cd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,26 +2,65 @@ import unittest import time import re +import asyncio +import sys +from unittest.mock import patch from pathlib import Path from threading import Thread, Event import zmq -from discos_client.client import DISCOSClient, DEFAULT_PORT +from zmq.auth import load_certificate +from zmq.auth.thread import ThreadAuthenticator +from discos_client.client import DISCOSClient, \ + SRTClient, MedicinaClient, NotoClient, \ + DEFAULT_SUB_PORT, DEFAULT_REQ_PORT + + +if sys.platform == "win32": + # pylint: disable=deprecated-class + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +keys_path = Path(__file__).resolve().parent / "test_keys" +dummy_public, dummy_secret = load_certificate( + keys_path / "dummy.key_secret" +) class TestPublisher: - def __init__(self, telescope=None): + def __init__(self, telescope=None, router=False): self.context = zmq.Context() - self.socket = self.context.socket(zmq.XPUB) - self.socket.setsockopt(zmq.LINGER, 0) - self.socket.setsockopt(zmq.SNDHWM, 10) + self.pub = self.context.socket(zmq.XPUB) + self.pub.setsockopt(zmq.LINGER, 0) + self.pub.setsockopt(zmq.SNDHWM, 10) + self.router = self.context.socket(zmq.ROUTER) + self.router.curve_publickey = dummy_public + self.router.curve_secretkey = dummy_secret + self.router.curve_server = True + self.auth = ThreadAuthenticator(self.context) + self.auth.configure_curve(domain="*", location=keys_path) # This loop is necessary to wait for the client to close between tests while True: try: - self.socket.bind(f"tcp://127.0.0.1:{DEFAULT_PORT}") + self.pub.bind(f"tcp://127.0.0.1:{DEFAULT_SUB_PORT}") break except zmq.ZMQError: - pass + continue + if router: + while True: + try: + self.auth.start() + break + except zmq.ZMQError: + continue + while True: + try: + self.router.bind(f"tcp://127.0.0.1:{DEFAULT_REQ_PORT}") + break + except zmq.ZMQError: + continue + self.poller = zmq.Poller() + self.poller.register(self.pub, zmq.POLLIN) + self.poller.register(self.router, zmq.POLLIN) messages_dir = Path(__file__).resolve().parent / "messages" message_files = list(messages_dir.glob("common/*.json")) if telescope: @@ -46,34 +85,32 @@ def recurse(obj): recurse(item) for payload in self.messages.values(): recurse(payload) - self.t = Thread(target=self.publish) + self.t = Thread(target=self.loop) self.event = Event() self.t.start() def __enter__(self): return self - def _handle_subscription(self): - while True: - try: - event = self.socket.recv(flags=zmq.DONTWAIT) - except zmq.Again: - break - if not event: - continue + def _handle_events(self): + zmq_events = {} + try: + zmq_events = dict(self.poller.poll(timeout=200)) + except zmq.ZMQError: + return + + if self.pub in zmq_events: + event = self.pub.recv(flags=zmq.DONTWAIT) op = event[0] topic = event[1:].decode(errors="ignore") - if op != 1: - continue - - if re.match(r"^[0-9A-Za-z]{4}_.+$", topic): + if op == 1 and re.match(r"^[0-9A-Za-z]{4}_.+$", topic): t = topic.split("_", 1)[1] if t in self.messages: message = json.dumps( self.messages[t], separators=(",", ":") ).encode("utf-8") - self.socket.send_multipart([ + self.pub.send_multipart([ topic.encode("ascii"), message ]) @@ -88,10 +125,29 @@ def _handle_subscription(self): subparts, separators=(",", ":") ).encode("utf-8") - self.socket.send_multipart([ + self.pub.send_multipart([ topic.encode("ascii"), message ]) + if self.router in zmq_events: + req = self.router.recv_multipart(copy=False) + routing_id, sep, payload = (req + [None])[:3] # noqa + payload = json.loads(payload.bytes) + command = payload["command"] + if command == "disconnect": + # We are testing how the client handles server disconnection + self.router.close() + else: + answer = { + "executed": True, + "command": payload["command"] + } + self.router.send_multipart([ + routing_id, + b"", + json.dumps(answer, separators=(",", ":")).encode() + ]) + def _send_periodic_messages(self): for timestamp in self.timestamps: timestamp["unix_time"] = time.time() @@ -103,21 +159,23 @@ def _send_periodic_messages(self): payload, separators=(",", ":") ).encode("utf-8") - self.socket.send_multipart([ + self.pub.send_multipart([ topic.encode("ascii"), payload ]) - def publish(self): + def loop(self): while not self.event.is_set(): - self._handle_subscription() + self._handle_events() self._send_periodic_messages() time.sleep(0.1) def __exit__(self, exc_type, exc_value, traceback): self.event.set() self.t.join() - self.socket.close() + self.pub.close() + self.router.close() + self.auth.stop() self.context.term() @@ -126,16 +184,28 @@ class TestDISCOSClient(unittest.TestCase): def test_no_topics(self): DISCOSClient( address="127.0.0.1", - port=DEFAULT_PORT, + sub_port=DEFAULT_SUB_PORT, telescope="SRT" ) + def test_unknown_telescope(self): + with self.assertRaises(ValueError) as ex: + DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT, + telescope="Unknown" + ) + self.assertEqual( + "Unknown telescope: 'Unknown'", + ex.exception.args[0] + ) + def test_unknown_topic(self): with self.assertRaises(ValueError) as ex: DISCOSClient( "foo", address="127.0.0.1", - port=DEFAULT_PORT + sub_port=DEFAULT_SUB_PORT ) self.assertTrue( "Topic 'foo' is not known" in ex.exception.args[0] @@ -144,28 +214,37 @@ def test_unknown_topic(self): DISCOSClient( "foo", "bar", address="127.0.0.1", - port=DEFAULT_PORT, + sub_port=DEFAULT_SUB_PORT, ) self.assertTrue( "Topics 'foo' and 'bar' are not known" in ex.exception.args[0] ) def test_repr(self): - client = DISCOSClient(address="127.0.0.1", port=DEFAULT_PORT) + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT + ) self.assertTrue( repr(client).startswith("") ) def test_str(self): - client = DISCOSClient(address="127.0.0.1", port=DEFAULT_PORT) + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT + ) self.assertTrue( str(client).startswith("{") and str(client).endswith("}") ) def test_format(self): - client = DISCOSClient(address="127.0.0.1", port=DEFAULT_PORT) + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT + ) self.assertTrue( f"{client:}".startswith("{") and f"{client:}".endswith("}") @@ -177,10 +256,10 @@ def test_format(self): "Unknown format code 'u' for DISCOSClient" ) with self.assertRaises(ValueError) as ex: - _ = f"{client:.3f}" + _ = f"{client:.3e}" self.assertEqual( str(ex.exception), - "Unknown format code '.3f' for DISCOSClient" + "Unknown format code '.3e' for DISCOSClient" ) with self.assertRaises(ValueError) as ex: _ = f"{client:.3m}" @@ -189,10 +268,10 @@ def test_format(self): "Unknown format code '.3m' for DISCOSClient" ) with self.assertRaises(ValueError) as ex: - _ = f"{client:fm}" + _ = f"{client:em}" self.assertEqual( str(ex.exception), - "Format specifier cannot contain both 'f' and 'm'." + "Format specifier cannot contain both 'e' and 'm'." ) with self.assertRaises(ValueError) as ex: _ = f"{client:0i}" @@ -212,11 +291,14 @@ def test_format(self): str(ex.exception), "Unknown format code '3c' for DISCOSClient" ) - self.assertNotIn("\": ", f"{client:c}") + self.assertNotIn("\": ", f"{client:t}") def test_bind(self): with TestPublisher("SRT"): - client = DISCOSClient(address="127.0.0.1", port=DEFAULT_PORT) + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT + ) s = set() called = set() s.add(id(client.antenna.timestamp.unix_time)) @@ -240,7 +322,10 @@ def callback(value): def test_wait(self): with TestPublisher(): - client = DISCOSClient(address="127.0.0.1", port=DEFAULT_PORT) + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT + ) unix_time = client.antenna.timestamp.unix_time.copy() antenna = client.antenna.copy() self.assertNotEqual( @@ -252,6 +337,130 @@ def test_wait(self): client.antenna.wait(timeout=5) ) + @patch("discos_client.utils.load_certificate") + def test_command(self, mock_load_cert): + mock_load_cert.return_value = (dummy_public, dummy_secret) + with TestPublisher(router=True): + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT, + req_port=DEFAULT_REQ_PORT, + telescope="SRT", + identity="identity" + ) + self.assertTrue(hasattr(client, "command")) + answer = client.command("dummy") + self.assertTrue(answer.executed) + + @patch("discos_client.utils.load_certificate") + def test_command_with_args(self, mock_load_cert): + mock_load_cert.return_value = (dummy_public, dummy_secret) + with TestPublisher(router=True): + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT, + req_port=DEFAULT_REQ_PORT, + telescope="SRT", + identity="identity" + ) + self.assertTrue(hasattr(client, "command")) + answer = client.command("dummy", 1, 2, 3) + self.assertTrue(answer.executed) + + @patch("discos_client.utils.load_certificate") + def test_command_unreachable(self, mock_load_cert): + mock_load_cert.return_value = (dummy_public, dummy_secret) + with TestPublisher(): + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT, + req_port=DEFAULT_REQ_PORT, + telescope="SRT", + identity="identity" + ) + self.assertTrue(hasattr(client, "command")) + self.assertFalse(client.command("dummy").executed) + + def test_command_not_present(self): + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT + ) + self.assertFalse(hasattr(client, "command")) + + @patch("discos_client.utils.load_certificate") + def test_command_unknown_keys(self, mock_load_cert): + mock_load_cert.side_effect = OSError + with self.assertRaises(ValueError) as ex: + DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT, + req_port=DEFAULT_REQ_PORT, + telescope="SRT", + identity="dummy" + ) + self.assertIn("Unknown or invalid identity", str(ex.exception)) + + @patch("discos_client.utils.load_certificate") + def test_command_disconnect_after_send(self, mock_load_cert): + mock_load_cert.return_value = (dummy_public, dummy_secret) + with TestPublisher(router=True): + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT, + req_port=DEFAULT_REQ_PORT, + telescope="SRT", + identity="identity" + ) + self.assertTrue(hasattr(client, "command")) + answer = client.command("disconnect") + self.assertFalse(answer.executed) + + @patch("discos_client.utils.load_certificate") + def test_command_with_server_public_key_file(self, mock_load_cert): + mock_load_cert.return_value = (dummy_public, dummy_secret) + with TestPublisher(router=True): + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT, + req_port=DEFAULT_REQ_PORT, + identity="identity", + server_public_key_file="/tmp/server.key", + ) + self.assertTrue(hasattr(client, "command")) + answer = client.command("dummy") + self.assertTrue(answer.executed) + + @patch("discos_client.utils.load_certificate") + def test_command_without_telescope_or_server_public_key_file( + self, + mock_load_cert + ): + mock_load_cert.return_value = (dummy_public, dummy_secret) + with self.assertRaises(ValueError) as ex: + DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT, + req_port=DEFAULT_REQ_PORT, + identity="identity", + ) + self.assertIn( + "Either 'telescope' or 'server_public_key_file' must be provided", + str(ex.exception) + ) + + +class TestTelescopeClients(unittest.TestCase): + + def test_srt_client(self): + _ = SRTClient() + + def test_medicina_client(self): + _ = MedicinaClient() + + def test_noto_client(self): + _ = NotoClient() + if __name__ == '__main__': unittest.main() diff --git a/tests/test_keys/dummy.key b/tests/test_keys/dummy.key new file mode 100644 index 0000000..a2598fa --- /dev/null +++ b/tests/test_keys/dummy.key @@ -0,0 +1,7 @@ +# **** Generated on 2026-01-05 22:54:36.363259 by pyzmq **** +# ZeroMQ CURVE **Secret** Certificate +# DO NOT PROVIDE THIS FILE TO OTHER USERS nor change its permissions. + +metadata +curve + public-key = "f!G:wZysatP7c3Pbicu42Ng]ttQ}HWeqeQ(TJ(rq" diff --git a/tests/test_keys/dummy.key_secret b/tests/test_keys/dummy.key_secret new file mode 100644 index 0000000..c0e0d4e --- /dev/null +++ b/tests/test_keys/dummy.key_secret @@ -0,0 +1,8 @@ +# **** Generated on 2026-01-05 22:54:36.363259 by pyzmq **** +# ZeroMQ CURVE **Secret** Certificate +# DO NOT PROVIDE THIS FILE TO OTHER USERS nor change its permissions. + +metadata +curve + public-key = "f!G:wZysatP7c3Pbicu42Ng]ttQ}HWeqeQ(TJ(rq" + secret-key = "?bBw]p8$%gJPUKi3Kf#CSkuq63u6YrUY