From 80df3a8346f6a4c12c6ac59d6f8793668f25d7d2 Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Wed, 7 Jan 2026 13:34:14 +0100 Subject: [PATCH 01/11] First implementation of sending commands feature --- discos_client/__init__.py | 14 +- discos_client/client.py | 264 +++++++++++++++++++-------- discos_client/servers/srt/server.key | 9 + discos_client/utils.py | 26 ++- pyproject.toml | 4 +- scripts/discos-keygen | 69 +++++++ tests/test_client.py | 205 +++++++++++++++++---- tests/test_keys/dummy.key | 7 + tests/test_keys/dummy.key_secret | 8 + 9 files changed, 493 insertions(+), 113 deletions(-) create mode 100644 discos_client/servers/srt/server.key create mode 100644 scripts/discos-keygen create mode 100644 tests/test_keys/dummy.key create mode 100644 tests/test_keys/dummy.key_secret diff --git a/discos_client/__init__.py b/discos_client/__init__.py index 0bd20d4..97407e0 100644 --- a/discos_client/__init__.py +++ b/discos_client/__init__.py @@ -1,27 +1,31 @@ from functools import partial -from .client import DISCOSClient, DEFAULT_PORT +from .client import DISCOSClient, DEFAULT_SUB_PORT, DEFAULT_REQ_PORT SRTClient = partial( DISCOSClient, address="192.168.200.203", - port=DEFAULT_PORT, + sub_port=DEFAULT_SUB_PORT, + req_port=DEFAULT_REQ_PORT, telescope="SRT" ) MedicinaClient = partial( DISCOSClient, address="192.168.1.100", - port=DEFAULT_PORT, + sub_port=DEFAULT_SUB_PORT, + req_port=DEFAULT_REQ_PORT, telescope="Medicina" ) NotoClient = partial( DISCOSClient, address="192.167.187.17", - port=DEFAULT_PORT, + sub_port=DEFAULT_SUB_PORT, + req_port=DEFAULT_REQ_PORT, telescope="Noto" ) del partial -del DEFAULT_PORT +del DEFAULT_SUB_PORT +del DEFAULT_REQ_PORT __all__ = [ "DISCOSClient", diff --git a/discos_client/client.py b/discos_client/client.py index bdf3662..02639c8 100644 --- a/discos_client/client.py +++ b/discos_client/client.py @@ -3,18 +3,22 @@ import weakref from threading import Thread, Lock, Event from collections import defaultdict +from typing import Any 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 from .initializer import NSInitializer __all__ = [ - "DEFAULT_PORT", + "DEFAULT_SUB_PORT", + "DEFAULT_REQ_PORT", "DISCOSClient" ] -DEFAULT_PORT = 16000 +DEFAULT_SUB_PORT = 16000 +DEFAULT_REQ_PORT = 16010 class DISCOSClient: @@ -27,7 +31,8 @@ def __init__( self, *topics: str, address: str, - port: int, + sub_port: int, + req_port: int | None = None, telescope: str | None = None ) -> None: """ @@ -35,49 +40,50 @@ def __init__( :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. :raises ValueError: If one or more given topics are not known. """ + if telescope not in ("Medicina", "Noto", "SRT", None): + raise ValueError(f"Unknown telescope: '{telescope}'") 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 + self._topics = self.__validate_topics__(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}") + + events = {} + events["stop"] = self._stop + + self._sub = self._context.socket(zmq.SUB) + self._sub.setsockopt(zmq.LINGER, 0) + self._sub.setsockopt(zmq.RCVTIMEO, 10) + self._sub.setsockopt(zmq.RECONNECT_IVL, 1000) + self._sub.setsockopt(zmq.CONNECT_TIMEOUT, 500) + self._sub.connect(f"tcp://{address}:{sub_port}") + + sockets = {} + sockets["sub"] = self._sub + + if req_port and telescope: + self.__init_req_socket__( + address, req_port, telescope, events, sockets + ) self._locks = defaultdict(Lock) for topic in self._topics: self.__dict__[topic] = initializer.initialize(topic) - self._receiver = Thread( - target=self.__receive__, + self._updater = Thread( + target=self.__update__, args=( - self._socket, - self._locks, self._client_id, + sockets, + self._locks, self.__dict__, - self._event + events, ), daemon=True ) @@ -85,67 +91,181 @@ def __init__( self._finalizer = weakref.finalize( self, self.__cleanup__, - self._event, - self._socket, - self._context, - self._receiver + self._stop, + self._updater, + sockets, + self._context ) - self._receiver.start() + self._updater.start() for topic in self._topics: - self._socket.subscribe(f"{self._client_id}{topic}") + self._sub.subscribe(f"{self._client_id}{topic}") + + def __init_req_socket__( + self, + address: str, + req_port: int, + telescope: str, + events: dict[str, Event], + sockets: dict[str, zmq.Socket] + ) -> None: + try: + client_public, client_secret, server_public = get_auth_keys( + telescope + ) + except OSError: + # A curve key is missing, this + # telemetry and will not be able to send commands + return + self._req = self._context.socket(zmq.REQ) + self._req.setsockopt(zmq.LINGER, 0) + self._req.setsockopt(zmq.IMMEDIATE, 1) + self._req.setsockopt(zmq.SNDTIMEO, 0) + 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 = client_public + self._req.curve_secretkey = client_secret + self._req.curve_serverkey = server_public + self._mon = self._req.get_monitor_socket() + self._online = Event() + events["online"] = self._online + self._req.connect(f"tcp://{address}:{req_port}") + sockets["req"] = self._req + sockets["mon"] = self._mon + self.command = self.__command__ + + @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, + updater: 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: + updater.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, - locks: dict[str, Lock], + def __update__( client_id: str, - d: dict[str, DISCOSNamespace], - event: Event + sockets: dict[str, zmq.Socket], + locks: dict[str, Lock], + namespaces: dict[str, DISCOSNamespace], + events: dict[str, Event] ) -> None: """ - Loops continuously waiting for new ZMQ messages. + Loops continuously waiting for new ZMQ messages and events. - :param socket: The ZMQ socket object. - :param locks: The locks dictionary, used for thread synchronization. :param client_id: The random string identifying the client. - :param d: The client __dict__ object. - :param event: The Event object that will break the receiver loop. + :param sockets: The dictionary containing the ZMQ sockets. + :param locks: The locks dictionary, used for thread synchronization. + :param namespaces: The client __dict__ object, containing the + DISCOSNamespaces. + :param events: The dictionary containing the Event objects for + synchronization. """ - while not event.is_set(): + sub = sockets.get("sub") + mon = sockets.get("mon") + stop = events.get("stop") + online = events.get("online") + + poller = zmq.Poller() + poller.register(sub, zmq.POLLIN) + if mon is not None: + poller.register(mon, zmq.POLLIN) + while not stop.is_set(): + zmq_events = {} try: - t, p = socket.recv_multipart() # noqa - t = t.decode("ascii") - if t.startswith(client_id): - socket.unsubscribe(t) - t = t[len(client_id):] - socket.subscribe(t) - p = json.loads(p) - with locks[t]: - d[t] <<= p - except zmq.Again: - # No data received, cycle again - pass + zmq_events = dict(poller.poll(timeout=200)) + except zmq.ZMQError: # pragma: no cover + break + + if sub in zmq_events: + try: + t, p = sub.recv_multipart(flags=zmq.DONTWAIT) # noqa + t = t.decode("ascii") + if t.startswith(client_id): + sub.unsubscribe(t) + t = t[len(client_id):] + sub.subscribe(t) + p = json.loads(p) + with locks[t]: + namespaces[t] <<= p + except zmq.Again: # pragma: no cover + # We should never get here since there will always be + # some data to recover from the socket + pass + + if mon is not None and mon in zmq_events: + while True: + try: + event = recv_monitor_message(mon, flags=zmq.DONTWAIT) + except zmq.Again: + break + + event = event["event"] + if event == zmq.EVENT_CONNECTED: + online.set() + elif event in \ + (zmq.EVENT_DISCONNECTED, zmq.EVENT_CLOSED): + online.clear() + + def __command__(self, cmd: str, *args) -> dict[str, Any]: + if self._online.is_set(): + message = {"command": cmd} + if args: + message["args"] = args + payload = json.dumps(message, separators=(",", ":")) + self._req.send_string(payload) + answer = json.loads(self._req.recv_string()) + else: + answer = { + "executed": False, + "error": { + "type": 2101, # ClientErrors + "code": 14, # DISCOSUnreachableError + "description": "DISCOS is unreachable" + } + } + return answer def __repr__(self) -> str: """ 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..69168cd 100644 --- a/discos_client/utils.py +++ b/discos_client/utils.py @@ -3,6 +3,10 @@ import secrets import string 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 +14,8 @@ "rand_id", "delegated_operations", "delegated_comparisons", - "public_dict" + "public_dict", + "get_auth_keys" ] META_KEYS = ("type", "title", "description", "format", "unit", "enum") @@ -145,3 +150,22 @@ 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_auth_keys(telescope: str) -> tuple[bytes]: + """Retrieves the CURVE authentication keys, both for the client and + the desired server. + + :param telescope: The telescope for which the server public key will be + retrieved. + :return: The client's public and secret keys, followed by the server's + public key, as a tuple. + """ + config_base = Path(user_config_dir("discos")) + curve_directory = config_base / "rpc" / "client" + client_pair = curve_directory / "identity.key_secret" + server_pair = files("discos_client") / "servers" \ + / telescope.lower() / "server.key" + client_public, client_secret = load_certificate(client_pair) + server_public, _ = load_certificate(server_pair) + return client_public, client_secret, server_public diff --git a/pyproject.toml b/pyproject.toml index 86ad494..0755857 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,13 +16,15 @@ classifiers = [ ] dependencies = [ "pyzmq", + "platformdirs" ] [tool.setuptools] packages = ["discos_client"] +script-files = ["scripts/discos-keygen"] [tool.setuptools.package-data] -"discos_client" = ["schemas/**"] +"discos_client" = ["schemas/**", "servers/**"] [project.optional-dependencies] test = ["coverage", "prospector", "jsonschema", "referencing"] diff --git a/scripts/discos-keygen b/scripts/discos-keygen new file mode 100644 index 0000000..db9d4ea --- /dev/null +++ b/scripts/discos-keygen @@ -0,0 +1,69 @@ +#!/usr/bin/env python +import os +import sys +from pathlib import Path +from argparse import ArgumentParser +from platformdirs import user_config_dir +from zmq.auth import create_certificates + +base_config = Path(user_config_dir("discos")) +target_dir = base_config / "rpc" / "client" +key_filename = "identity" +full_path_public = target_dir / f"{key_filename}.key" +full_path_secret = target_dir / f"{key_filename}.key_secret" + +def create_discos_keys(overwrite): + + if full_path_secret.exists() and not overwrite: + print("Kept previously created key pair. Use --overwrite to replace it.\n") + return + + try: + target_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + print(f"Error creating the configuration directory: {e}") + sys.exit(1) + + create_certificates(str(target_dir), key_filename) + + if os.name == 'posix': + full_path_secret.chmod(0o600) + (target_dir / f"{key_filename}.key").chmod(0o644) + print(f"Key pair created in: '{target_dir}'.") + +def print_discos_keys(): + if not full_path_public.exists(): + print("No key was generated yet.") + return + + with open(full_path_public, "r") as f: + print(f.read()) + print(f"\nPath of the public key file: {full_path_public}") + print(f"Remember to never share the '{key_filename}.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 '{key_filename}.key' file to the " \ + "DISCOS team, asking for authorization. Your request will be taken " \ + "into consideration and you will hear back from the team." + ) + +if __name__ == "__main__": + parser = ArgumentParser( + "DISCOS CURVE key pairs generator." + ) + 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." + ) + args = parser.parse_args() + + if not args.show_only: + create_discos_keys(args.overwrite) + + print_discos_keys() diff --git a/tests/test_client.py b/tests/test_client.py index fd119ac..d49112e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,26 +2,57 @@ import unittest import time import re +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, \ + DEFAULT_SUB_PORT, DEFAULT_REQ_PORT + +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 +77,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 +117,24 @@ 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) + 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 +146,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 +171,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 +201,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("}") @@ -216,7 +282,10 @@ def test_format(self): 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 +309,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 +324,71 @@ 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" + ) + self.assertTrue(hasattr(client, "command")) + self.assertTrue(hasattr(client, "_online")) + while not client._online.is_set(): + time.sleep(0.01) + 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" + ) + self.assertTrue(hasattr(client, "command")) + self.assertTrue(hasattr(client, "_online")) + while not client._online.is_set(): + time.sleep(0.01) + 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" + ) + 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_keys_not_present(self, mock_load_cert): + mock_load_cert.side_effect = OSError + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT, + req_port=DEFAULT_REQ_PORT, + telescope="SRT" + ) + self.assertFalse(hasattr(client, "command")) + 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 Date: Wed, 7 Jan 2026 14:01:32 +0100 Subject: [PATCH 02/11] Issue #19, fixed documentation issue --- docs/conf.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0d415b3..0d38709 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -64,31 +64,6 @@ 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) - - sjs_wide_format = importlib.import_module("sphinx-jsonschema.wide_format") sjs_wide_format.WideFormat._simpletype = _simpletype sjs_wide_format.WideFormat._complexstructures = _complexstructures From 983837940053f19c580adcdf8705c11ad73bf21c Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Wed, 7 Jan 2026 14:06:38 +0100 Subject: [PATCH 03/11] Issue #19, fixed another documentation issue --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0755857..a0e8ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,4 +28,4 @@ script-files = ["scripts/discos-keygen"] [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"] From d4581c5f67d8276ce92e2b6e981b52862c319920 Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Wed, 7 Jan 2026 15:19:42 +0100 Subject: [PATCH 04/11] Issue #19, fixed for windows --- .coveragerc | 1 + scripts/discos-keygen => discos_client/cli.py | 6 ++---- pyproject.toml | 8 +++++--- 3 files changed, 8 insertions(+), 7 deletions(-) rename scripts/discos-keygen => discos_client/cli.py (96%) diff --git a/.coveragerc b/.coveragerc index 473d62c..5a0bb03 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,7 @@ [run] concurrency = thread source = discos_client +omit = discos_client/cli.py [paths] discos_client = diff --git a/scripts/discos-keygen b/discos_client/cli.py similarity index 96% rename from scripts/discos-keygen rename to discos_client/cli.py index db9d4ea..6dea55f 100644 --- a/scripts/discos-keygen +++ b/discos_client/cli.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python import os import sys from pathlib import Path @@ -47,7 +46,7 @@ def print_discos_keys(): "into consideration and you will hear back from the team." ) -if __name__ == "__main__": +def main(): parser = ArgumentParser( "DISCOS CURVE key pairs generator." ) @@ -65,5 +64,4 @@ def print_discos_keys(): if not args.show_only: create_discos_keys(args.overwrite) - - print_discos_keys() + print_discos_keys() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a0e8ba9..6123871 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,11 +21,13 @@ dependencies = [ [tool.setuptools] packages = ["discos_client"] -script-files = ["scripts/discos-keygen"] + +[project.scripts] +discos-keygen = "discos_client.cli:main" [tool.setuptools.package-data] -"discos_client" = ["schemas/**", "servers/**"] +discos_client = ["schemas/**", "servers/**"] [project.optional-dependencies] -test = ["coverage", "prospector", "jsonschema", "referencing"] +test = ["coverage", "prospector", "jsonschema", "referencing", "tornado"] docs = ["sphinx<9,>=6", "sphinx-rtd-theme", "sphinx-autodoc-typehints", "sphinx-jsonschema"] From 0701192ade7442406f3792827ca005816f559aab Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Wed, 7 Jan 2026 16:02:04 +0100 Subject: [PATCH 05/11] Issue #19, attempt to fix windows tests --- discos_client/cli.py | 32 +++++++++++++++++++------------- pyproject.toml | 2 +- tests/test_client.py | 6 ++++++ 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/discos_client/cli.py b/discos_client/cli.py index 6dea55f..f2274a1 100644 --- a/discos_client/cli.py +++ b/discos_client/cli.py @@ -7,14 +7,16 @@ base_config = Path(user_config_dir("discos")) target_dir = base_config / "rpc" / "client" -key_filename = "identity" -full_path_public = target_dir / f"{key_filename}.key" -full_path_secret = target_dir / f"{key_filename}.key_secret" +KEY_FILENAME = "identity" +full_path_public = target_dir / f"{KEY_FILENAME}.key" +full_path_secret = target_dir / f"{KEY_FILENAME}.key_secret" + def create_discos_keys(overwrite): if full_path_secret.exists() and not overwrite: - print("Kept previously created key pair. Use --overwrite to replace it.\n") + print("Kept previously created key pair. " + "Use --overwrite to replace it.\n") return try: @@ -23,29 +25,32 @@ def create_discos_keys(overwrite): print(f"Error creating the configuration directory: {e}") sys.exit(1) - create_certificates(str(target_dir), key_filename) + create_certificates(str(target_dir), KEY_FILENAME) if os.name == 'posix': full_path_secret.chmod(0o600) - (target_dir / f"{key_filename}.key").chmod(0o644) + (target_dir / f"{KEY_FILENAME}.key").chmod(0o644) print(f"Key pair created in: '{target_dir}'.") + def print_discos_keys(): if not full_path_public.exists(): print("No key was generated yet.") return - with open(full_path_public, "r") as f: + with open(full_path_public, "r", encoding="utf-8") as f: print(f.read()) print(f"\nPath of the public key file: {full_path_public}") - print(f"Remember to never share the '{key_filename}.key_secret' file with anyone.") + print(f"Remember to never share the '{KEY_FILENAME}.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 '{key_filename}.key' file to the " \ - "DISCOS team, asking for authorization. Your request will be taken " \ + "In order to be authorized to send command to any of the telescopes, " + f"remember to send a copy of the '{KEY_FILENAME}.key' file to the " + "DISCOS team, asking for authorization. Your request will be taken " "into consideration and you will hear back from the team." ) + def main(): parser = ArgumentParser( "DISCOS CURVE key pairs generator." @@ -58,10 +63,11 @@ def main(): parser.add_argument( "--show-only", action="store_true", - help="Only prints the public key and its path without generating a new pair." + help="Only prints the public key and its path without \ + generating a new pair." ) args = parser.parse_args() if not args.show_only: create_discos_keys(args.overwrite) - print_discos_keys() \ No newline at end of file + print_discos_keys() diff --git a/pyproject.toml b/pyproject.toml index 6123871..040e53a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,5 +29,5 @@ discos-keygen = "discos_client.cli:main" discos_client = ["schemas/**", "servers/**"] [project.optional-dependencies] -test = ["coverage", "prospector", "jsonschema", "referencing", "tornado"] +test = ["coverage", "prospector", "jsonschema", "referencing"] docs = ["sphinx<9,>=6", "sphinx-rtd-theme", "sphinx-autodoc-typehints", "sphinx-jsonschema"] diff --git a/tests/test_client.py b/tests/test_client.py index d49112e..71d4cca 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,6 +2,8 @@ import unittest import time import re +import asyncio +import sys from unittest.mock import patch from pathlib import Path from threading import Thread, Event @@ -11,6 +13,10 @@ from discos_client.client import DISCOSClient, \ DEFAULT_SUB_PORT, DEFAULT_REQ_PORT + +if sys.platform == "win32": + 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" From 70b33216a3196454c9d4867ade06c6618465fea5 Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Wed, 7 Jan 2026 23:47:47 +0100 Subject: [PATCH 06/11] Issue #19, added tests for discos-keygen --- .coveragerc | 1 - .gitignore | 6 +- discos_client/{cli.py => scripts.py} | 57 +++++++++------- pyproject.toml | 2 +- tests/test_scripts.py | 98 ++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 28 deletions(-) rename discos_client/{cli.py => scripts.py} (52%) create mode 100644 tests/test_scripts.py diff --git a/.coveragerc b/.coveragerc index 5a0bb03..473d62c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,6 @@ [run] concurrency = thread source = discos_client -omit = discos_client/cli.py [paths] discos_client = 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/discos_client/cli.py b/discos_client/scripts.py similarity index 52% rename from discos_client/cli.py rename to discos_client/scripts.py index f2274a1..a7819cd 100644 --- a/discos_client/cli.py +++ b/discos_client/scripts.py @@ -1,57 +1,64 @@ import os -import sys from pathlib import Path from argparse import ArgumentParser from platformdirs import user_config_dir from zmq.auth import create_certificates -base_config = Path(user_config_dir("discos")) -target_dir = base_config / "rpc" / "client" -KEY_FILENAME = "identity" -full_path_public = target_dir / f"{KEY_FILENAME}.key" -full_path_secret = target_dir / f"{KEY_FILENAME}.key_secret" + +def get_config_paths(): + base_config = Path(user_config_dir("discos")) + config_dir = base_config / "rpc" / "client" + public = config_dir / "identity.key" + secret = config_dir / "identity.key_secret" + return config_dir, public, secret def create_discos_keys(overwrite): + config_dir, public, secret = get_config_paths() - if full_path_secret.exists() and not overwrite: + if secret.exists() and not overwrite: print("Kept previously created key pair. " "Use --overwrite to replace it.\n") - return + return 0 try: - target_dir.mkdir(parents=True, exist_ok=True) + config_dir.mkdir(parents=True, exist_ok=True) except OSError as e: print(f"Error creating the configuration directory: {e}") - sys.exit(1) + return 1 - create_certificates(str(target_dir), KEY_FILENAME) + create_certificates(str(config_dir), "identity") if os.name == 'posix': - full_path_secret.chmod(0o600) - (target_dir / f"{KEY_FILENAME}.key").chmod(0o644) - print(f"Key pair created in: '{target_dir}'.") + public.chmod(0o644) + secret.chmod(0o600) + print(f"Key pair created in: '{config_dir}'.") + return 0 def print_discos_keys(): - if not full_path_public.exists(): + _, public, _ = get_config_paths() + + if not public.exists(): print("No key was generated yet.") - return + return 0 - with open(full_path_public, "r", encoding="utf-8") as f: + with open(public, "r", encoding="utf-8") as f: print(f.read()) - print(f"\nPath of the public key file: {full_path_public}") - print(f"Remember to never share the '{KEY_FILENAME}.key_secret' file with " + + print(f"\nPath of the public key file: {public}") + print("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 '{KEY_FILENAME}.key' file to the " + "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 main(): +def keygen(): parser = ArgumentParser( "DISCOS CURVE key pairs generator." ) @@ -69,5 +76,9 @@ def main(): args = parser.parse_args() if not args.show_only: - create_discos_keys(args.overwrite) - print_discos_keys() + return_code = create_discos_keys(args.overwrite) + + if return_code != 0: + return return_code + + return print_discos_keys() diff --git a/pyproject.toml b/pyproject.toml index 040e53a..356a567 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ packages = ["discos_client"] [project.scripts] -discos-keygen = "discos_client.cli:main" +discos-keygen = "discos_client.scripts:keygen" [tool.setuptools.package-data] discos_client = ["schemas/**", "servers/**"] diff --git a/tests/test_scripts.py b/tests/test_scripts.py new file mode 100644 index 0000000..cf22c3d --- /dev/null +++ b/tests/test_scripts.py @@ -0,0 +1,98 @@ +import unittest +import sys +import shutil +import tempfile +from io import StringIO +from pathlib import Path +from unittest.mock import patch, MagicMock +from platformdirs import user_config_dir +from discos_client import scripts + + +class TestKeygen(unittest.TestCase): + + def setUp(self): + self.test_dir = tempfile.mkdtemp() + self.test_path = Path(self.test_dir) + self.mock_target_dir = self.test_path / "rpc" / "client" + self.mock_public = self.mock_target_dir / "identity.key" + self.mock_secret = self.mock_target_dir / "identity.key_secret" + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def test_correct_paths(self): + config_dir, public, secret = scripts.get_config_paths() + expected_config_dir = \ + Path(user_config_dir("discos")) / "rpc" / "client" + expected_public = expected_config_dir / "identity.key" + expected_secret = expected_config_dir / "identity.key_secret" + self.assertEqual(config_dir, expected_config_dir) + self.assertEqual(public, expected_public) + self.assertEqual(secret, expected_secret) + + @patch("discos_client.scripts.get_config_paths") + @patch("sys.stdout", new_callable=StringIO) + @patch.object(sys, "argv", ["discos-keygen"]) + def test_keygen(self, mock_stdout, mock_paths): + mock_paths.return_value = ( + self.mock_target_dir, + self.mock_public, + self.mock_secret + ) + rc = scripts.keygen() + self.assertEqual(rc, 0) + self.assertTrue(self.mock_public.exists()) + self.assertTrue(self.mock_secret.exists()) + output = mock_stdout.getvalue() + self.assertIn("Key pair created in", output) + + @patch("discos_client.scripts.get_config_paths") + @patch("sys.stdout", new_callable=StringIO) + @patch.object(sys, "argv", ["discos-keygen"]) + def test_keygen_no_overwrite(self, mock_stdout, mock_paths): + mock_paths.return_value = ( + self.mock_target_dir, + self.mock_public, + self.mock_secret + ) + self.assertEqual(scripts.keygen(), 0) + self.assertTrue(self.mock_public.exists()) + self.assertTrue(self.mock_secret.exists()) + output = mock_stdout.getvalue() + self.assertIn("Key pair created in", output) + self.assertEqual(scripts.keygen(), 0) + output = mock_stdout.getvalue() + self.assertIn("Kept previously created key pair", output) + + @patch("discos_client.scripts.get_config_paths") + @patch("sys.stdout", new_callable=StringIO) + def test_print_keys(self, mock_stdout, mock_paths): + mock_paths.return_value = ( + self.mock_target_dir, + self.mock_public, + self.mock_secret + ) + scripts.print_discos_keys() + output = mock_stdout.getvalue() + self.assertIn("No key was generated yet.", output) + + @patch("discos_client.scripts.get_config_paths") + @patch("sys.stdout", new_callable=StringIO) + @patch.object(sys, "argv", ["discos-keygen"]) + def test_mkdir_error(self, mock_stdout, mock_paths): + mock_target_dir = MagicMock() + mock_target_dir.mkdir.side_effect = OSError("Test error") + mock_paths.return_value = ( + mock_target_dir, + self.mock_public, + self.mock_secret + ) + rc = scripts.keygen() + self.assertEqual(rc, 1) + output = mock_stdout.getvalue() + self.assertIn("Error creating the configuration directory", output) + + +if __name__ == "__main__": + unittest.main() From d4eec7af364936424c107d5218118c8258baae0a Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Wed, 25 Mar 2026 10:33:07 +0100 Subject: [PATCH 07/11] Fix #19, added ability to send commands --- .readthedocs.yaml | 2 +- discos_client/__init__.py | 28 +- discos_client/client.py | 367 +++++++++++------- discos_client/initializer.py | 19 +- discos_client/namespace.py | 159 +++++--- discos_client/schemas/common/antenna.json | 201 +++++++--- .../schemas/common/command_answer.json | 66 ++++ discos_client/schemas/common/derotators.json | 6 + .../schemas/definitions/active_surface.json | 2 +- .../schemas/medicina/active_surface.json | 16 +- .../schemas/noto/active_surface.json | 16 +- discos_client/scripts.py | 127 ++++-- discos_client/utils.py | 28 +- .../inner_classes.rst => api/api.rst} | 8 +- docs/conf.py | 37 +- docs/developer/developer.rst | 1 - docs/index.rst | 67 ++-- docs/install.rst | 31 +- docs/schemas/schemas.rst | 17 +- docs/user/client.rst | 93 +++++ docs/user/commands.rst | 75 ++++ docs/{user.rst => user/telemetry.rst} | 181 +++------ docs/user/user.rst | 12 + tests/messages/common/antenna.json | 2 +- tests/messages/common/derotators.json | 2 +- tests/test_client.py | 100 +++-- tests/test_namespace.py | 18 +- tests/test_scripts.py | 196 +++++++++- 28 files changed, 1320 insertions(+), 557 deletions(-) create mode 100644 discos_client/schemas/common/command_answer.json rename docs/{developer/inner_classes.rst => api/api.rst} (92%) create mode 100644 docs/user/client.rst create mode 100644 docs/user/commands.rst rename docs/{user.rst => user/telemetry.rst} (67%) create mode 100644 docs/user/user.rst 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 97407e0..c0842ac 100644 --- a/discos_client/__init__.py +++ b/discos_client/__init__.py @@ -1,31 +1,5 @@ -from functools import partial -from .client import DISCOSClient, DEFAULT_SUB_PORT, DEFAULT_REQ_PORT +from .client import DISCOSClient, SRTClient, MedicinaClient, NotoClient -SRTClient = partial( - DISCOSClient, - address="192.168.200.203", - sub_port=DEFAULT_SUB_PORT, - req_port=DEFAULT_REQ_PORT, - telescope="SRT" -) -MedicinaClient = partial( - DISCOSClient, - address="192.168.1.100", - sub_port=DEFAULT_SUB_PORT, - req_port=DEFAULT_REQ_PORT, - telescope="Medicina" -) -NotoClient = partial( - DISCOSClient, - address="192.167.187.17", - sub_port=DEFAULT_SUB_PORT, - req_port=DEFAULT_REQ_PORT, - telescope="Noto" -) - -del partial -del DEFAULT_SUB_PORT -del DEFAULT_REQ_PORT __all__ = [ "DISCOSClient", diff --git a/discos_client/client.py b/discos_client/client.py index 02639c8..7fad1ea 100644 --- a/discos_client/client.py +++ b/discos_client/client.py @@ -7,15 +7,11 @@ import zmq from zmq.utils.monitor import recv_monitor_message from .namespace import DISCOSNamespace -from .utils import rand_id, get_auth_keys +from .utils import rand_id, get_auth_keys, timestamp from .initializer import NSInitializer -__all__ = [ - "DEFAULT_SUB_PORT", - "DEFAULT_REQ_PORT", - "DISCOSClient" -] +__all__ = ["DISCOSClient", "SRTClient", "MedicinaClient", "NotoClient"] DEFAULT_SUB_PORT = 16000 DEFAULT_REQ_PORT = 16010 @@ -23,67 +19,64 @@ 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, - sub_port: int, - req_port: int | None = None, - telescope: str | None = None + sub_port: int = DEFAULT_SUB_PORT, + req_port: int = DEFAULT_REQ_PORT, + telescope: str | None = None, + identity: str | None = None ) -> None: """ - Initializes the class instance. - :param topics: topic names to subscribe to. :param address: IP address 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. :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. """ if telescope not in ("Medicina", "Noto", "SRT", None): raise ValueError(f"Unknown telescope: '{telescope}'") - initializer = NSInitializer(telescope) - self._topics = self.__validate_topics__(initializer, topics) + self._initializer = NSInitializer(telescope) + self._topics = self.__validate_topics__(self._initializer, topics) self._client_id = rand_id() self._stop = Event() self._context = zmq.Context() - events = {} - events["stop"] = self._stop + self._events = {} + self._events["stop"] = self._stop self._sub = self._context.socket(zmq.SUB) self._sub.setsockopt(zmq.LINGER, 0) - self._sub.setsockopt(zmq.RCVTIMEO, 10) self._sub.setsockopt(zmq.RECONNECT_IVL, 1000) self._sub.setsockopt(zmq.CONNECT_TIMEOUT, 500) self._sub.connect(f"tcp://{address}:{sub_port}") - sockets = {} - sockets["sub"] = self._sub - - if req_port and telescope: - self.__init_req_socket__( - address, req_port, telescope, events, sockets - ) + self._sockets = {} + self._sockets["sub"] = self._sub self._locks = defaultdict(Lock) - for topic in self._topics: - self.__dict__[topic] = initializer.initialize(topic) - - self._updater = Thread( - target=self.__update__, + self._receiver = Thread( + target=self.__receive__, args=( - self._client_id, - sockets, + self._sub, self._locks, + self._client_id, self.__dict__, - events, + self._stop ), daemon=True ) @@ -92,49 +85,86 @@ def __init__( self, self.__cleanup__, self._stop, - self._updater, - sockets, + self._receiver, + self._sockets, self._context ) - self._updater.start() + if telescope and identity: + try: + public, secret, server = get_auth_keys(telescope, identity) + except OSError as ex: + raise ValueError( + f"Unknown or invalid identity '{identity}'." + ) 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._sub.subscribe(f"{self._client_id}{topic}") - def __init_req_socket__( - self, - address: str, - req_port: int, - telescope: str, - events: dict[str, Event], - sockets: dict[str, zmq.Socket] - ) -> None: - try: - client_public, client_secret, server_public = get_auth_keys( - telescope - ) - except OSError: - # A curve key is missing, this - # telemetry and will not be able to send commands - return + 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.SNDTIMEO, 0) 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 = client_public - self._req.curve_secretkey = client_secret - self._req.curve_serverkey = server_public + 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() - events["online"] = self._online - self._req.connect(f"tcp://{address}:{req_port}") - sockets["req"] = self._req - sockets["mon"] = self._mon - self.command = self.__command__ + self._events["online"] = self._online + self._req.connect(endpoint) + self._sockets["req"] = self._req + self._sockets["mon"] = self._mon @staticmethod def __validate_topics__( @@ -161,7 +191,7 @@ def __validate_topics__( @staticmethod def __cleanup__( stop: Event, - updater: Thread, + receiver: Thread, sockets: dict[str, zmq.Socket], context: zmq.Context ) -> None: @@ -175,7 +205,7 @@ def __cleanup__( """ stop.set() try: - updater.join() + receiver.join() except RuntimeError: # pragma: no cover pass for _, socket in sockets.items(): @@ -184,88 +214,73 @@ def __cleanup__( context.term() @staticmethod - def __update__( - client_id: str, - sockets: dict[str, zmq.Socket], + def __receive__( + sub: zmq.Socket, locks: dict[str, Lock], + client_id: str, namespaces: dict[str, DISCOSNamespace], - events: dict[str, Event] + stop: Event ) -> None: """ - Loops continuously waiting for new ZMQ messages and events. + Loops continuously waiting for new ZMQ messages. - :param client_id: The random string identifying the client. - :param sockets: The dictionary containing the ZMQ sockets. + :param socket: The ZMQ socket object. :param locks: The locks dictionary, used for thread synchronization. - :param namespaces: The client __dict__ object, containing the - DISCOSNamespaces. - :param events: The dictionary containing the Event objects for - synchronization. + :param client_id: The random string identifying the client. + :param d: The client __dict__ object. + :param event: The Event object that will break the receiver loop. """ - sub = sockets.get("sub") - mon = sockets.get("mon") - stop = events.get("stop") - online = events.get("online") - - poller = zmq.Poller() - poller.register(sub, zmq.POLLIN) - if mon is not None: - poller.register(mon, zmq.POLLIN) while not stop.is_set(): - zmq_events = {} - try: - zmq_events = dict(poller.poll(timeout=200)) - except zmq.ZMQError: # pragma: no cover - break + if (sub.poll(10) & zmq.POLLIN) != 0: + t, p = sub.recv_multipart() # noqa + t = t.decode("ascii") + if t.startswith(client_id): + sub.unsubscribe(t) + t = t[len(client_id):] + sub.subscribe(t) + p = json.loads(p) + with locks[t]: + namespaces[t] <<= p + + def __req_connected__(self, strict: bool = False) -> bool: + """ + Checks if the REQ socket is connected. - if sub in zmq_events: - try: - t, p = sub.recv_multipart(flags=zmq.DONTWAIT) # noqa - t = t.decode("ascii") - if t.startswith(client_id): - sub.unsubscribe(t) - t = t[len(client_id):] - sub.subscribe(t) - p = json.loads(p) - with locks[t]: - namespaces[t] <<= p - except zmq.Again: # pragma: no cover - # We should never get here since there will always be - # some data to recover from the socket - pass - - if mon is not None and mon in zmq_events: - while True: - try: - event = recv_monitor_message(mon, flags=zmq.DONTWAIT) - except zmq.Again: - break - - event = event["event"] - if event == zmq.EVENT_CONNECTED: - online.set() - elif event in \ - (zmq.EVENT_DISCONNECTED, zmq.EVENT_CLOSED): - online.clear() - - def __command__(self, cmd: str, *args) -> dict[str, Any]: - if self._online.is_set(): - message = {"command": cmd} - if args: - message["args"] = args - payload = json.dumps(message, separators=(",", ":")) - self._req.send_string(payload) - answer = json.loads(self._req.recv_string()) - else: - answer = { - "executed": False, - "error": { - "type": 2101, # ClientErrors - "code": 14, # DISCOSUnreachableError - "description": "DISCOS is unreachable" - } - } - return answer + :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: """ @@ -289,27 +304,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 @@ -319,14 +334,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] @@ -371,3 +386,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 index a7819cd..7414f0e 100644 --- a/discos_client/scripts.py +++ b/discos_client/scripts.py @@ -1,23 +1,85 @@ import os +import shutil from pathlib import Path from argparse import ArgumentParser from platformdirs import user_config_dir -from zmq.auth import create_certificates +from zmq.auth import create_certificates, load_certificate -def get_config_paths(): +def get_config_paths(identity: str): base_config = Path(user_config_dir("discos")) config_dir = base_config / "rpc" / "client" - public = config_dir / "identity.key" - secret = config_dir / "identity.key_secret" + public = config_dir / f"{identity}.key" + secret = config_dir / f"{identity}.key_secret" return config_dir, public, secret -def create_discos_keys(overwrite): - config_dir, public, secret = get_config_paths() +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("Kept previously created key pair. " + print(f"Kept previously created key pair '{identity}'. " "Use --overwrite to replace it.\n") return 0 @@ -27,31 +89,29 @@ def create_discos_keys(overwrite): print(f"Error creating the configuration directory: {e}") return 1 - create_certificates(str(config_dir), "identity") + create_certificates(str(config_dir), identity) + set_permissions(public, secret) - if os.name == 'posix': - public.chmod(0o644) - secret.chmod(0o600) - print(f"Key pair created in: '{config_dir}'.") + print(f"Key pair '{identity}' created in: '{config_dir}'.") return 0 -def print_discos_keys(): - _, public, _ = get_config_paths() +def print_discos_keys(identity): + _, public, _ = get_config_paths(identity) if not public.exists(): - print("No key was generated yet.") + 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("Remember to never share the 'identity.key_secret' file with " + 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, " - "remember to send a copy of the 'identity.key' file to the " + 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." ) @@ -60,7 +120,13 @@ def print_discos_keys(): def keygen(): parser = ArgumentParser( - "DISCOS CURVE key pairs generator." + 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", @@ -73,12 +139,27 @@ def keygen(): 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 not args.show_only: - return_code = create_discos_keys(args.overwrite) + 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 + if return_code != 0: + return return_code - return print_discos_keys() + return print_discos_keys(args.identity) diff --git a/discos_client/utils.py b/discos_client/utils.py index 69168cd..412a64c 100644 --- a/discos_client/utils.py +++ b/discos_client/utils.py @@ -2,6 +2,8 @@ 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 @@ -15,7 +17,8 @@ "delegated_operations", "delegated_comparisons", "public_dict", - "get_auth_keys" + "get_auth_keys", + "timestamp" ] META_KEYS = ("type", "title", "description", "format", "unit", "enum") @@ -118,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) @@ -152,20 +158,36 @@ def __unwrap(value: Any, is_fn, get_value_fn) -> Any: return list(value) if isinstance(value, (list, tuple)) else value -def get_auth_keys(telescope: str) -> tuple[bytes]: +def get_auth_keys( + telescope: str, + identity: str +) -> tuple[bytes]: """Retrieves the CURVE authentication keys, both for the client and the desired server. :param telescope: The telescope for which the server public key will be retrieved. + :param identity: The name of the key pair file to be used. :return: The client's public and secret keys, followed by the server's public key, as a tuple. """ config_base = Path(user_config_dir("discos")) curve_directory = config_base / "rpc" / "client" - client_pair = curve_directory / "identity.key_secret" + client_pair = curve_directory / f"{identity}.key_secret" server_pair = files("discos_client") / "servers" \ / telescope.lower() / "server.key" client_public, client_secret = load_certificate(client_pair) server_public, _ = load_certificate(server_pair) return client_public, client_secret, server_public + + +def timestamp() -> dict[str, Any]: + 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 0d38709..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,6 +81,24 @@ html_static_path = ['_static'] +def setup(app): + 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") sjs_wide_format.WideFormat._simpletype = _simpletype sjs_wide_format.WideFormat._complexstructures = _complexstructures 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..845a0b3 --- /dev/null +++ b/docs/user/client.rst @@ -0,0 +1,93 @@ +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. diff --git a/docs/user/commands.rst b/docs/user/commands.rst new file mode 100644 index 0000000..a239975 --- /dev/null +++ b/docs/user/commands.rst @@ -0,0 +1,75 @@ +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. 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/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 71d4cca..90b9a51 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -11,6 +11,7 @@ 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 @@ -131,15 +132,20 @@ def _handle_events(self): req = self.router.recv_multipart(copy=False) routing_id, sep, payload = (req + [None])[:3] # noqa payload = json.loads(payload.bytes) - answer = { - "executed": True, - "command": payload["command"] - } - self.router.send_multipart([ - routing_id, - b"", - json.dumps(answer, separators=(",", ":")).encode() - ]) + 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: @@ -249,10 +255,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}" @@ -261,10 +267,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}" @@ -284,7 +290,7 @@ 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"): @@ -338,14 +344,12 @@ def test_command(self, mock_load_cert): address="127.0.0.1", sub_port=DEFAULT_SUB_PORT, req_port=DEFAULT_REQ_PORT, - telescope="SRT" + telescope="SRT", + identity="identity" ) self.assertTrue(hasattr(client, "command")) - self.assertTrue(hasattr(client, "_online")) - while not client._online.is_set(): - time.sleep(0.01) answer = client.command("dummy") - self.assertTrue(answer["executed"]) + self.assertTrue(answer.executed) @patch("discos_client.utils.load_certificate") def test_command_with_args(self, mock_load_cert): @@ -355,14 +359,12 @@ def test_command_with_args(self, mock_load_cert): address="127.0.0.1", sub_port=DEFAULT_SUB_PORT, req_port=DEFAULT_REQ_PORT, - telescope="SRT" + telescope="SRT", + identity="identity" ) self.assertTrue(hasattr(client, "command")) - self.assertTrue(hasattr(client, "_online")) - while not client._online.is_set(): - time.sleep(0.01) answer = client.command("dummy", 1, 2, 3) - self.assertTrue(answer["executed"]) + self.assertTrue(answer.executed) @patch("discos_client.utils.load_certificate") def test_command_unreachable(self, mock_load_cert): @@ -372,10 +374,11 @@ def test_command_unreachable(self, mock_load_cert): address="127.0.0.1", sub_port=DEFAULT_SUB_PORT, req_port=DEFAULT_REQ_PORT, - telescope="SRT" + telescope="SRT", + identity="identity" ) self.assertTrue(hasattr(client, "command")) - self.assertFalse(client.command("dummy")["executed"]) + self.assertFalse(client.command("dummy").executed) def test_command_not_present(self): client = DISCOSClient( @@ -385,15 +388,44 @@ def test_command_not_present(self): self.assertFalse(hasattr(client, "command")) @patch("discos_client.utils.load_certificate") - def test_command_keys_not_present(self, mock_load_cert): + def test_command_unknown_keys(self, mock_load_cert): mock_load_cert.side_effect = OSError - client = DISCOSClient( - address="127.0.0.1", - sub_port=DEFAULT_SUB_PORT, - req_port=DEFAULT_REQ_PORT, - telescope="SRT" - ) - self.assertFalse(hasattr(client, "command")) + 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) + + +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__': diff --git a/tests/test_namespace.py b/tests/test_namespace.py index 9d9f453..c5f0415 100644 --- a/tests/test_namespace.py +++ b/tests/test_namespace.py @@ -182,7 +182,7 @@ def test_format(self): "Unknown format code '.3f' for DISCOSNamespace" ) self.assertEqual( - f"{ns:c}", + f"{ns:t}", json.dumps({"a": a}, separators=(",", ":")) ) with self.assertRaises(ValueError) as ex: @@ -192,17 +192,17 @@ def test_format(self): "Unknown format code '3c' for DISCOSNamespace" ) with self.assertRaises(ValueError) as ex: - _ = f"{ns:fm}" + _ = f"{ns:em}" self.assertEqual( str(ex.exception), - "Format specifier cannot contain both 'f' and 'm'." + "Format specifier cannot contain both 'e' and 'm'." ) self.assertEqual( f"{ns:i}", json.dumps({"a": a}, indent=2) ) self.assertEqual( - f"{ns:f}", + f"{ns:e}", json.dumps(b) ) b_ = deepcopy(b) @@ -217,7 +217,7 @@ def test_format(self): json.dumps({"a": a}, indent=indent) ) self.assertEqual( - f"{ns:f{indent}i}", + f"{ns:e{indent}i}", json.dumps(b, indent=indent) ) with self.assertRaises(ValueError) as ex: @@ -242,6 +242,14 @@ def test_format(self): self.assertEqual(f"{ns}", json.dumps(d)) _ = f"{ns:f}" + ns = DISCOSNamespace(node_name="a", **{"b": 1.234}) + self.assertEqual(f"{ns:w}", '{"a": {"b": 1.234}}') + + ns = DISCOSNamespace(**{"b": 1.234}) + with self.assertRaises(ValueError) as ex: + _ = f"{ns:w}" + self.assertEqual(str(ex.exception), "Cannot wrap node without a key!") + def test_op(self): a = 2 ns = DISCOSNamespace(value=a) diff --git a/tests/test_scripts.py b/tests/test_scripts.py index cf22c3d..4233ae5 100644 --- a/tests/test_scripts.py +++ b/tests/test_scripts.py @@ -6,6 +6,7 @@ from pathlib import Path from unittest.mock import patch, MagicMock from platformdirs import user_config_dir +from zmq.auth import create_certificates, load_certificate from discos_client import scripts @@ -22,7 +23,7 @@ def tearDown(self): shutil.rmtree(self.test_dir) def test_correct_paths(self): - config_dir, public, secret = scripts.get_config_paths() + config_dir, public, secret = scripts.get_config_paths("identity") expected_config_dir = \ Path(user_config_dir("discos")) / "rpc" / "client" expected_public = expected_config_dir / "identity.key" @@ -33,7 +34,7 @@ def test_correct_paths(self): @patch("discos_client.scripts.get_config_paths") @patch("sys.stdout", new_callable=StringIO) - @patch.object(sys, "argv", ["discos-keygen"]) + @patch.object(sys, "argv", ["discos-keygen", "identity"]) def test_keygen(self, mock_stdout, mock_paths): mock_paths.return_value = ( self.mock_target_dir, @@ -45,11 +46,11 @@ def test_keygen(self, mock_stdout, mock_paths): self.assertTrue(self.mock_public.exists()) self.assertTrue(self.mock_secret.exists()) output = mock_stdout.getvalue() - self.assertIn("Key pair created in", output) + self.assertIn("Key pair 'identity' created in", output) @patch("discos_client.scripts.get_config_paths") @patch("sys.stdout", new_callable=StringIO) - @patch.object(sys, "argv", ["discos-keygen"]) + @patch.object(sys, "argv", ["discos-keygen", "identity"]) def test_keygen_no_overwrite(self, mock_stdout, mock_paths): mock_paths.return_value = ( self.mock_target_dir, @@ -60,7 +61,7 @@ def test_keygen_no_overwrite(self, mock_stdout, mock_paths): self.assertTrue(self.mock_public.exists()) self.assertTrue(self.mock_secret.exists()) output = mock_stdout.getvalue() - self.assertIn("Key pair created in", output) + self.assertIn("Key pair 'identity' created in", output) self.assertEqual(scripts.keygen(), 0) output = mock_stdout.getvalue() self.assertIn("Kept previously created key pair", output) @@ -73,25 +74,202 @@ def test_print_keys(self, mock_stdout, mock_paths): self.mock_public, self.mock_secret ) - scripts.print_discos_keys() + scripts.print_discos_keys("identity") output = mock_stdout.getvalue() - self.assertIn("No key was generated yet.", output) + self.assertIn("No key named 'identity' was found.", output) @patch("discos_client.scripts.get_config_paths") @patch("sys.stdout", new_callable=StringIO) - @patch.object(sys, "argv", ["discos-keygen"]) def test_mkdir_error(self, mock_stdout, mock_paths): mock_target_dir = MagicMock() mock_target_dir.mkdir.side_effect = OSError("Test error") + mock_paths.return_value = ( mock_target_dir, self.mock_public, self.mock_secret ) + + with patch.object(sys, "argv", ["discos-keygen", "identity"]): + rc = scripts.keygen() + self.assertEqual(rc, 1) + output = mock_stdout.getvalue() + self.assertIn("Error creating the configuration directory", output) + + mock_stdout.truncate(0) + mock_stdout.seek(0) + + source_dir = Path(tempfile.mkdtemp()) + create_certificates(str(source_dir), "identity") + source_secret = source_dir / "identity.key_secret" + + with patch.object( + sys, + "argv", + ["discos-keygen", "--import-pair", str(source_secret), "identity"] + ): + rc = scripts.keygen() + self.assertEqual(rc, 1) + output = mock_stdout.getvalue() + self.assertIn("Error creating the configuration directory", output) + + shutil.rmtree(source_dir) + + @patch("discos_client.scripts.get_config_paths") + @patch("sys.stdout", new_callable=StringIO) + @patch.object(sys, "argv", ["discos-keygen", "--show-only", "identity"]) + def test_keygen_show_only(self, mock_stdout, mock_paths): + self.mock_target_dir.mkdir(parents=True, exist_ok=True) + create_certificates(str(self.mock_target_dir), "identity") + + mock_paths.return_value = ( + self.mock_target_dir, + self.mock_public, + self.mock_secret + ) + rc = scripts.keygen() + + self.assertEqual(rc, 0) + output = mock_stdout.getvalue() + self.assertIn("public-key =", output) + self.assertIn( + f"Path of the public key file: {self.mock_public}", output + ) + + @patch("discos_client.scripts.get_config_paths") + @patch("sys.stdout", new_callable=StringIO) + def test_keygen_import_success(self, mock_stdout, mock_paths): + source_dir = Path(tempfile.mkdtemp()) + create_certificates(str(source_dir), "identity") + source_secret = source_dir / "identity.key_secret" + + src_pub, src_sec = load_certificate(str(source_secret)) + + mock_paths.return_value = ( + self.mock_target_dir, + self.mock_public, + self.mock_secret + ) + + with patch.object( + sys, + "argv", + ["discos-keygen", "--import-pair", str(source_secret), "identity"] + ): + rc = scripts.keygen() + + self.assertEqual(rc, 0) + self.assertTrue(self.mock_public.exists()) + self.assertTrue(self.mock_secret.exists()) + + dst_pub, dst_sec = load_certificate(str(self.mock_secret)) + self.assertEqual(src_sec, dst_sec) + self.assertEqual(src_pub, dst_pub) + + output = mock_stdout.getvalue() + self.assertIn("Keys 'identity' imported successfully to", output) + + shutil.rmtree(source_dir) + + @patch("discos_client.scripts.get_config_paths") + @patch("sys.stdout", new_callable=StringIO) + def test_keygen_import_file_not_found(self, mock_stdout, mock_paths): + non_existent = self.test_path / "non_existent.key_secret" + + mock_paths.return_value = ( + self.mock_target_dir, + self.mock_public, + self.mock_secret + ) + + with patch.object( + sys, + "argv", + ["discos-keygen", "--import-pair", str(non_existent), "identity"] + ): + rc = scripts.keygen() + + self.assertEqual(rc, 1) + output = mock_stdout.getvalue() + self.assertIn("Error: Secret key file not found", output) + + @patch("discos_client.scripts.get_config_paths") + @patch("sys.stdout", new_callable=StringIO) + def test_keygen_import_invalid_file(self, mock_stdout, mock_paths): + source_dir = Path(tempfile.mkdtemp()) + invalid_file = source_dir / "invalid.key" + with open(invalid_file, "w", encoding="utf-8") as f: + f.write("This is not a ZMQ key file") + + mock_paths.return_value = ( + self.mock_target_dir, + self.mock_public, + self.mock_secret + ) + + with patch.object( + sys, + "argv", + ["discos-keygen", "--import-pair", str(invalid_file), "identity"] + ): + rc = scripts.keygen() + self.assertEqual(rc, 1) output = mock_stdout.getvalue() - self.assertIn("Error creating the configuration directory", output) + self.assertIn("Error reading secret key file", output) + + shutil.rmtree(source_dir) + + @patch("discos_client.scripts.get_config_paths") + @patch("sys.stdout", new_callable=StringIO) + def test_keygen_import_no_overwrite(self, mock_stdout, mock_paths): + self.mock_target_dir.mkdir(parents=True, exist_ok=True) + create_certificates(str(self.mock_target_dir), "identity") + + orig_pub, orig_sec = load_certificate(str(self.mock_secret)) + + source_dir = Path(tempfile.mkdtemp()) + create_certificates(str(source_dir), "identity") + source_secret = source_dir / "identity.key_secret" + + mock_paths.return_value = ( + self.mock_target_dir, + self.mock_public, + self.mock_secret + ) + + with patch.object( + sys, + "argv", + ["discos-keygen", "--import-pair", str(source_secret), "identity"] + ): + rc = scripts.keygen() + + self.assertEqual(rc, 0) + output = mock_stdout.getvalue() + self.assertIn("Kept previously created key pair", output) + + current_pub, current_sec = load_certificate(str(self.mock_secret)) + self.assertEqual(current_pub, orig_pub) + self.assertEqual(current_sec, orig_sec) + + shutil.rmtree(source_dir) + + @patch("sys.stdout", new_callable=StringIO) + def test_set_permissions_oserror(self, mock_stdout): + mock_pub = MagicMock() + mock_sec = MagicMock() + + error = OSError("Permission denied") + mock_pub.chmod.side_effect = error + + with patch("os.name", "posix"): + scripts.set_permissions(mock_pub, mock_sec) + + output = mock_stdout.getvalue() + self.assertIn("Warning: Could not set permissions", output) + self.assertIn("Permission denied", output) if __name__ == "__main__": From 6e51b9849bfa3e240fe1ef5c39e5a96d17d9293c Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Wed, 25 Mar 2026 11:34:37 +0100 Subject: [PATCH 08/11] Added ability to use RPC with plain DISCOSClients Also, suppressed a linter error --- discos_client/client.py | 19 ++++++-- discos_client/utils.py | 105 ++++++++++++++++++++++++++++++++++------ tests/test_client.py | 54 +++++++++++++++++++++ 3 files changed, 159 insertions(+), 19 deletions(-) diff --git a/discos_client/client.py b/discos_client/client.py index 7fad1ea..3cf6db9 100644 --- a/discos_client/client.py +++ b/discos_client/client.py @@ -4,6 +4,7 @@ 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 @@ -31,7 +32,8 @@ def __init__( sub_port: int = DEFAULT_SUB_PORT, req_port: int = DEFAULT_REQ_PORT, telescope: str | None = None, - identity: str | None = None + identity: str | None = None, + server_public_key_file: str | Path | None = None, ) -> None: """ :param topics: topic names to subscribe to. @@ -42,6 +44,10 @@ def __init__( :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 @@ -90,12 +96,17 @@ def __init__( self._context ) - if telescope and identity: + if identity is not None: try: - public, secret, server = get_auth_keys(telescope, identity) + 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}'." + f"Unknown or invalid identity '{identity}', " + "or invalid server public key." ) from ex self._client_public = public self._client_secret = secret diff --git a/discos_client/utils.py b/discos_client/utils.py index 412a64c..5cf2d77 100644 --- a/discos_client/utils.py +++ b/discos_client/utils.py @@ -158,30 +158,105 @@ def __unwrap(value: Any, is_fn, get_value_fn) -> Any: return list(value) if isinstance(value, (list, tuple)) else value -def get_auth_keys( - telescope: str, - identity: str -) -> tuple[bytes]: - """Retrieves the CURVE authentication keys, both for the client and - the desired server. - - :param telescope: The telescope for which the server public key will be - retrieved. - :param identity: The name of the key pair file to be used. - :return: The client's public and secret keys, followed by the server's - public key, as a tuple. +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" - server_pair = files("discos_client") / "servers" \ - / telescope.lower() / "server.key" client_public, client_secret = load_certificate(client_pair) - server_public, _ = load_certificate(server_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. + + The server public key can be obtained either from a predefined + telescope configuration or from an explicit key file. + + Exactly one of the following must be provided: + - ``telescope``: loads the key from the package resources + (``discos_client/servers//server.key``) + - ``server_public_key_file``: loads the key from a user-specified path + + :param telescope: The telescope name used to locate a bundled server key. + :param server_public_key_file: Path to a server public key file. + :return: The server public key. + :raises ValueError: If both or neither arguments are provided. + :raises OSError: If the key file cannot be found or loaded. + """ + if telescope is not None and server_public_key_file is not None: + raise ValueError( + "Use either 'telescope' or 'server_public_key_file', not both." + ) + + 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. + + This function loads: + - the client key pair associated with the given ``identity`` + - the server public key, either from a telescope profile or an + explicit file + + The server key selection follows these rules: + - if ``server_public_key_file`` is provided, it is used + - otherwise, ``telescope`` must be provided to load a bundled key + + :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. + :return: A tuple containing (client_public, client_secret, server_public). + :raises ValueError: If neither or both server key sources are provided. + :raises OSError: If any 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") diff --git a/tests/test_client.py b/tests/test_client.py index 90b9a51..cc02827 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -16,6 +16,7 @@ if sys.platform == "win32": + # pylint: disable=deprecated-class asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) keys_path = Path(__file__).resolve().parent / "test_keys" @@ -415,6 +416,59 @@ def test_command_disconnect_after_send(self, mock_load_cert): 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_with_telescope_and_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, + telescope="SRT", + identity="identity", + server_public_key_file="/tmp/server.key", + ) + self.assertIn( + "Use either 'telescope' or 'server_public_key_file', not both.", + str(ex.exception) + ) + + @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): From 0a5a063533a4d428627e18c86a27dff31fb9efee Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Wed, 25 Mar 2026 11:44:11 +0100 Subject: [PATCH 09/11] Upgraded GitHub Actions versions --- .github/workflows/ci_tests.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 From 7615ab309840496c5a4685193c8ae85f2d9c79d1 Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Wed, 25 Mar 2026 12:23:30 +0100 Subject: [PATCH 10/11] Slight variation for server_public_key_file --- discos_client/utils.py | 41 +++++++++++++++++------------------------ tests/test_client.py | 20 -------------------- 2 files changed, 17 insertions(+), 44 deletions(-) diff --git a/discos_client/utils.py b/discos_client/utils.py index 5cf2d77..6826b0e 100644 --- a/discos_client/utils.py +++ b/discos_client/utils.py @@ -182,25 +182,19 @@ def get_server_public_key( ) -> bytes: """Retrieve the CURVE public key of a DISCOS RPC server. - The server public key can be obtained either from a predefined - telescope configuration or from an explicit key file. - - Exactly one of the following must be provided: - - ``telescope``: loads the key from the package resources - (``discos_client/servers//server.key``) - - ``server_public_key_file``: loads the key from a user-specified path + 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 both or neither arguments are provided. - :raises OSError: If the key file cannot be found or loaded. + :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 telescope is not None and server_public_key_file is not None: - raise ValueError( - "Use either 'telescope' or 'server_public_key_file', not both." - ) - if server_public_key_file is not None: server_public, _ = load_certificate(Path(server_public_key_file)) return server_public @@ -224,21 +218,20 @@ def get_auth_keys( ) -> tuple[bytes, bytes, bytes]: """Retrieve CURVE authentication keys for both client and server. - This function loads: - - the client key pair associated with the given ``identity`` - - the server public key, either from a telescope profile or an - explicit file - - The server key selection follows these rules: - - if ``server_public_key_file`` is provided, it is used - - otherwise, ``telescope`` must be provided to load a bundled key + 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 or both server key sources are provided. - :raises OSError: If any key file cannot be found or loaded. + :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) diff --git a/tests/test_client.py b/tests/test_client.py index cc02827..77440cd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -431,26 +431,6 @@ def test_command_with_server_public_key_file(self, mock_load_cert): answer = client.command("dummy") self.assertTrue(answer.executed) - @patch("discos_client.utils.load_certificate") - def test_command_with_telescope_and_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, - telescope="SRT", - identity="identity", - server_public_key_file="/tmp/server.key", - ) - self.assertIn( - "Use either 'telescope' or 'server_public_key_file', not both.", - str(ex.exception) - ) - @patch("discos_client.utils.load_certificate") def test_command_without_telescope_or_server_public_key_file( self, From f0f1a3086d4f5375a129e3a9024ad2f288d918ad Mon Sep 17 00:00:00 2001 From: Giuseppe Carboni Date: Wed, 25 Mar 2026 12:35:04 +0100 Subject: [PATCH 11/11] Updated docs --- docs/user/client.rst | 30 ++++++++++++++++++++++++++++++ docs/user/commands.rst | 14 ++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/docs/user/client.rst b/docs/user/client.rst index 845a0b3..74c7712 100644 --- a/docs/user/client.rst +++ b/docs/user/client.rst @@ -91,3 +91,33 @@ When using the generic client, you **must** specify two key parameters: 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 index a239975..16fdf29 100644 --- a/docs/user/commands.rst +++ b/docs/user/commands.rst @@ -73,3 +73,17 @@ To be authorized to send commands to any of the telescopes: #. 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.