diff --git a/discos_client/namespace.py b/discos_client/namespace.py index 9d4118f..f24e6d6 100644 --- a/discos_client/namespace.py +++ b/discos_client/namespace.py @@ -194,7 +194,8 @@ def __get_value__(self) -> Any: def __bind__( self, callback: Callable[[DISCOSNamespace], None], - predicate: Callable[[DISCOSNamespace], bool] = None + predicate: Callable[[DISCOSNamespace], bool] = None, + unwrap: bool = False ) -> None: """ Bind a callback to the DISCOSNamespace object, @@ -203,13 +204,13 @@ def __bind__( :param callback: A function that receives the updated object when obj changes. :param predicate: Optional predicate that the value must satisfy + :param unwrap: If True, evaluates the predicate and calls the callback + passing the internal primitive value instead of the + namespace. """ with self._observers_lock: - self._observers.setdefault(callback, set()).add( - predicate - if predicate is not None - else lambda _: True - ) + pred = predicate if predicate is not None else lambda _: True + self._observers.setdefault(callback, set()).add((pred, unwrap)) def __unbind__( self, @@ -232,20 +233,29 @@ def __unbind__( if callback not in self._observers: return if predicate is not None: - self._observers[callback].discard(predicate) + to_remove = [ + p_tuple + for p_tuple in self._observers[callback] + if p_tuple[0] == predicate + ] + for p_tuple in to_remove: + self._observers[callback].discard(p_tuple) if predicate is None or not self._observers[callback]: del self._observers[callback] def __wait__( self, predicate: Callable[[DISCOSNamespace], bool] = None, - timeout: float | None = None - ) -> DISCOSNamespace: + timeout: float | None = None, + unwrap: bool = False + ) -> Any: """ Block until the DISCOSNamespace triggers a change notification. :param predicate: Optional predicate that the value must satisfy. :param timeout: Optional timeout in seconds. + :param unwrap: If True, the predicate operates on the internal value, + and the internal value itself is returned. :return: The updated object, or the same object if timeout has expired. """ event = threading.Event() @@ -253,12 +263,14 @@ def __wait__( def callback(_): event.set() - self.bind(callback, predicate) + self.bind(callback, predicate, unwrap=unwrap) try: event.wait(timeout) finally: self.unbind(callback, predicate) with self._lock: + if unwrap and self.__has_value__(self): + return self._value return self def __copy__(self) -> DISCOSNamespace: @@ -854,9 +866,20 @@ def __notify__(self) -> None: observers = list(self._observers.items()) with self._lock: - for cb, predicates in observers: - if any(predicate(self) for predicate in predicates): - cb(self) + for cb, conditions in observers: + should_call = False + value_to_pass = self + + for predicate, unwrap in conditions: + value_to_test = self._value if unwrap \ + and self.__has_value__(self) else self + + if predicate(value_to_test): + should_call = True + value_to_pass = value_to_test + break + if should_call: + cb(value_to_pass) def __getattr__(self, name: str): """ diff --git a/discos_client/schemas/common/backends.json b/discos_client/schemas/common/backends.json index ed7259d..2fa9bf9 100644 --- a/discos_client/schemas/common/backends.json +++ b/discos_client/schemas/common/backends.json @@ -152,6 +152,38 @@ } }, "anyOf": [ + { + "type": "object", + "properties": { + "availableBackends": { + "type": "array", + "title": "Available Backends", + "description": "List of available backends.", + "items": { + "type": "string" + } + }, + "currentBackend": { + "type": "string", + "title": "Current Backend", + "description": "Currently selected backend's name." + }, + "currentSetup": { + "type": "string", + "title": "Current Setup", + "description": "Currently selected backend's setup code." + }, + "timestamp": { + "$ref": "../definitions/timestamp.json" + } + }, + "required": [ + "availableBackends", + "currentBackend", + "currentSetup", + "timestamp" + ] + }, { "type": "object", "patternProperties": { diff --git a/discos_client/schemas/common/receivers.json b/discos_client/schemas/common/receivers.json index 21a5c40..3fee20f 100644 --- a/discos_client/schemas/common/receivers.json +++ b/discos_client/schemas/common/receivers.json @@ -134,6 +134,14 @@ { "type": "object", "properties": { + "availableReceivers": { + "type": "array", + "title": "Available Receivers", + "description": "List of available receivers.", + "items": { + "type": "string" + } + }, "currentReceiver": { "type": "string", "title": "Current Receiver", @@ -141,8 +149,8 @@ }, "currentSetup": { "type": "string", - "title": "Current setup", - "description": "Current DISCOS setup code" + "title": "Current Setup", + "description": "Currently selected receiver's setup code." }, "status": { "$ref": "../definitions/status.json" @@ -152,6 +160,7 @@ } }, "required": [ + "availableReceivers", "currentReceiver", "currentSetup", "status", diff --git a/discos_client/schemas/common/scheduler.json b/discos_client/schemas/common/scheduler.json index 48bbcc9..cd27e99 100644 --- a/discos_client/schemas/common/scheduler.json +++ b/discos_client/schemas/common/scheduler.json @@ -21,10 +21,20 @@ "title": "Current Recorder", "description": "Name of the currently used recorder." }, + "maxScanID": { + "type": "number", + "title": "Max Scan ID", + "description": "Identification number of the final scan of the current schedule." + }, + "maxSubScanID": { + "type": "number", + "title": "Max SubScan ID", + "description": "Identification number of the final subscan of the current scan." + }, "projectCode": { "type": "string", "title": "Project Code", - "description": "Identification number of the current project user." + "description": "Identification code of the current project user." }, "restFrequency": { "type": "array", @@ -66,6 +76,8 @@ "currentBackend", "currentDevice", "currentRecorder", + "maxScanID", + "maxSubScanID", "projectCode", "restFrequency", "scanID", diff --git a/docs/conf.py b/docs/conf.py index 1f40876..c6e1717 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -80,6 +80,8 @@ html_static_path = ['_static'] +latex_engine = 'lualatex' + def setup(app): app.connect("viewcode-find-source", on_viewcode_find_source) @@ -94,8 +96,9 @@ def on_viewcode_find_source(app, modname): 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__") + for method in ["bind", "copy", "get_value", "unbind", "wait"]: + if f"DISCOSNamespace.__{method}__" in analyzer.tags: + analyzer.tags[f"DISCOSNamespace.{method}"] = analyzer.tags.get(f"DISCOSNamespace.__{method}__") return analyzer.code, analyzer.tags diff --git a/tests/messages/common/backends.json b/tests/messages/common/backends.json index e189ccf..49eb482 100644 --- a/tests/messages/common/backends.json +++ b/tests/messages/common/backends.json @@ -1 +1 @@ -{"TotalPower":{"backendTime":{"iso8601":"2025-12-05T15:38:58.000Z","mjd":61014.65206018509,"omg_time":139842419380000000,"unix_time":1764949138.0},"busy":false,"channels":[{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":0,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":1,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":2,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":3,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":4,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":5,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":6,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":7,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":8,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":9,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":10,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":11,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":12,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":13,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0}],"commandLineError":false,"dataLineError":false,"integration":40,"sampling":false,"suspended":false,"timeSync":true,"timestamp":{"iso8601":"2025-12-05T15:38:58.627Z","mjd":61014.65206744196,"omg_time":139842419386276140,"unix_time":1764949138.627614}}} +{"TotalPower":{"backendTime":{"iso8601":"2026-05-04T08:22:29.000Z","mjd":61164.34894675948,"omg_time":139971757490000000,"unix_time":1777882949.0},"busy":false,"channels":[{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":0,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-18.86252081925788},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":1,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":2,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":3,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-87.87325181869207},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":4,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":5,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":6,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":7,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-31.30752112150976},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":8,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-1.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":9,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":41.756043814715845},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":10,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":193.59580191228858},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":11,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":238.08411375989303},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":12,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":46.91594690101812},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":13,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":-25.911116503337915}],"commandLineError":false,"dataLineError":false,"integration":40,"sampling":false,"suspended":false,"timeSync":true,"timestamp":{"iso8601":"2026-05-04T08:22:30.421Z","mjd":61164.34896320617,"omg_time":139971757504212560,"unix_time":1777882950.421256}},"availableBackends":["Sardara","TotalPower"],"currentBackend":"TotalPower","currentSetup":"KKG","timestamp":{"iso8601":"2026-05-04T08:22:30.072Z","mjd":61164.34895916656,"omg_time":139971757500724360,"unix_time":1777882950.072436}} diff --git a/tests/messages/common/receivers.json b/tests/messages/common/receivers.json index 4cf85dc..0f3d7e2 100644 --- a/tests/messages/common/receivers.json +++ b/tests/messages/common/receivers.json @@ -1 +1 @@ -{"currentReceiver": "SRTKBandMFReceiver", "currentSetup": "KKG", "status": "OK", "timestamp": {"iso8601": "2025-07-19T19:36:06.069Z", "mjd": 60875.81673690956, "omg_time": 139722465660699220, "unix_time": 1752953766.069922}, "SRTKBandMFReceiver": {"channels": [{"bandWidth": 1936.0, "id": 0, "localOscillator": 21964.0, "polarization": "LHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 1, "localOscillator": 21964.0, "polarization": "RHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 2, "localOscillator": 21964.0, "polarization": "LHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 3, "localOscillator": 21964.0, "polarization": "RHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 4, "localOscillator": 21964.0, "polarization": "LHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 5, "localOscillator": 21964.0, "polarization": "RHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 6, "localOscillator": 21964.0, "polarization": "LHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 7, "localOscillator": 21964.0, "polarization": "RHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 8, "localOscillator": 21964.0, "polarization": "LHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 9, "localOscillator": 21964.0, "polarization": "RHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 10, "localOscillator": 21964.0, "polarization": "LHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 11, "localOscillator": 21964.0, "polarization": "RHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 12, "localOscillator": 21964.0, "polarization": "LHCP", "startFrequency": 100.0}, {"bandWidth": 1936.0, "id": 13, "localOscillator": 21964.0, "polarization": "RHCP", "startFrequency": 100.0}], "cryoTemperatureCoolHead": 519.607622398508, "cryoTemperatureCoolHeadWindow": 519.607622398508, "cryoTemperatureLNA": 519.607622398508, "cryoTemperatureLNAWindow": 519.607622398508, "environmentTemperature": 112.79, "operativeMode": "SINGLEDISH", "status": "OK", "timestamp": {"iso8601": "2025-07-19T19:40:14.063Z", "mjd": 60875.81960721081, "omg_time": 139722468140638350, "unix_time": 1752954014.063835}, "vacuum": 1e-12}} \ No newline at end of file +{"SRT5GHzReceiver":{"channels":[{"bandWidth":1400.0,"id":0,"localOscillator":4100.0,"polarization":"LHCP","startFrequency":100.0},{"bandWidth":1400.0,"id":1,"localOscillator":4100.0,"polarization":"RHCP","startFrequency":100.0}],"cryoTemperatureCoolHead":519.607622398508,"cryoTemperatureCoolHeadWindow":519.607622398508,"cryoTemperatureLNA":519.607622398508,"cryoTemperatureLNAWindow":519.607622398508,"environmentTemperature":112.79,"operativeMode":"NORMAL","status":"OK","timestamp":{"iso8601":"2026-05-04T08:21:10.263Z","mjd":61164.34803545149,"omg_time":139971756702637730,"unix_time":1777882870.263773},"vacuum":1e-12},"SRT7GHzReceiver":{"channels":[{"bandWidth":2000.0,"id":0,"localOscillator":5600.0,"polarization":"LHCP","startFrequency":100.0},{"bandWidth":2000.0,"id":1,"localOscillator":5600.0,"polarization":"RHCP","startFrequency":100.0}],"cryoTemperatureCoolHead":519.607622398508,"cryoTemperatureCoolHeadWindow":519.607622398508,"cryoTemperatureLNA":519.607622398508,"cryoTemperatureLNAWindow":519.607622398508,"environmentTemperature":112.79,"operativeMode":"","status":"OK","timestamp":{"iso8601":"2026-05-04T08:21:10.476Z","mjd":61164.3480379167,"omg_time":139971756704768790,"unix_time":1777882870.476879},"vacuum":1e-12},"SRTKBandMFReceiver":{"channels":[{"bandWidth":1936.0,"id":0,"localOscillator":21964.0,"polarization":"LHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":1,"localOscillator":21964.0,"polarization":"RHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":2,"localOscillator":21964.0,"polarization":"LHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":3,"localOscillator":21964.0,"polarization":"RHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":4,"localOscillator":21964.0,"polarization":"LHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":5,"localOscillator":21964.0,"polarization":"RHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":6,"localOscillator":21964.0,"polarization":"LHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":7,"localOscillator":21964.0,"polarization":"RHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":8,"localOscillator":21964.0,"polarization":"LHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":9,"localOscillator":21964.0,"polarization":"RHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":10,"localOscillator":21964.0,"polarization":"LHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":11,"localOscillator":21964.0,"polarization":"RHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":12,"localOscillator":21964.0,"polarization":"LHCP","startFrequency":100.0},{"bandWidth":1936.0,"id":13,"localOscillator":21964.0,"polarization":"RHCP","startFrequency":100.0}],"cryoTemperatureCoolHead":519.607622398508,"cryoTemperatureCoolHeadWindow":519.607622398508,"cryoTemperatureLNA":519.607622398508,"cryoTemperatureLNAWindow":519.607622398508,"environmentTemperature":112.79,"operativeMode":"SINGLEDISH","status":"OK","timestamp":{"iso8601":"2026-05-04T08:21:10.384Z","mjd":61164.348036851734,"omg_time":139971756703847980,"unix_time":1777882870.384798},"vacuum":1e-12},"availableReceivers":["KQWBandReceiver","SRT5GHzReceiver","SRT7GHzReceiver","SRTKBandMFReceiver","SRTLPBandReceiver"],"currentReceiver":"SRTKBandMFReceiver","currentSetup":"KKG","status":"OK","timestamp":{"iso8601":"2026-05-04T08:21:10.389Z","mjd":61164.34803690994,"omg_time":139971756703890910,"unix_time":1777882870.389091}} diff --git a/tests/messages/common/scheduler.json b/tests/messages/common/scheduler.json index 9f8caea..2e52544 100644 --- a/tests/messages/common/scheduler.json +++ b/tests/messages/common/scheduler.json @@ -1 +1 @@ -{"currentBackend":"TotalPower","currentDevice":0,"currentRecorder":"FitsZilla","projectCode":"Maintenance","restFrequency":[0.0],"scanID":0,"scheduleName":"","status":"OK","subScanID":0,"timestamp":{"iso8601":"2025-11-21T11:45:01.497Z","mjd":61000.489600659814,"omg_time":139830183014975870,"unix_time":1763725501.497587},"tracking":true} +{"currentBackend":"TotalPower","currentDevice":0,"currentRecorder":"FitsZilla","maxScanID":0,"maxSubScanID":0,"projectCode":"Maintenance","restFrequency":[0.0],"scanID":0,"scheduleName":"","status":"OK","subScanID":0,"timestamp":{"iso8601":"2025-11-21T11:45:01.497Z","mjd":61000.489600659814,"omg_time":139830183014975870,"unix_time":1763725501.497587},"tracking":true} diff --git a/tests/test_client.py b/tests/test_client.py index 98e2333..4df029b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -13,6 +13,7 @@ from discos_client.client import DISCOSClient, \ SRTClient, MedicinaClient, NotoClient, \ DEFAULT_SUB_PORT, DEFAULT_REQ_PORT +from discos_client.namespace import DISCOSNamespace if sys.platform == "win32": @@ -320,6 +321,12 @@ def callback(value): client.antenna.unbind(int) # Never bound callback client.antenna.unbind(None) # Unbind all callbacks + def custom_predicate(_): + return True + + client.antenna.bind(callback, predicate=custom_predicate) + client.antenna.unbind(callback, predicate=custom_predicate) + def test_wait(self): with TestPublisher(): client = DISCOSClient( @@ -337,6 +344,43 @@ def test_wait(self): client.antenna.wait(timeout=5) ) + def test_unwrap(self): + with TestPublisher("SRT"): + client = DISCOSClient( + address="127.0.0.1", + sub_port=DEFAULT_SUB_PORT + ) + + received_values = [] + + def callback(value): + received_values.append(value) + + client.antenna.timestamp.unix_time.bind( + callback, + unwrap=True + ) + + start = time.time() + while not received_values and (time.time() - start) < 10: + time.sleep(0.1) + + self.assertTrue(len(received_values) > 0) + + first_val = received_values[0] + self.assertNotIsInstance(first_val, DISCOSNamespace) + self.assertIsInstance(first_val, float) + + client.antenna.timestamp.unix_time.unbind(callback) + + new_val = client.antenna.timestamp.unix_time.wait( + timeout=10, + unwrap=True + ) + + self.assertNotIsInstance(new_val, DISCOSNamespace) + self.assertIsInstance(new_val, float) + @patch("discos_client.utils.load_certificate") def test_command(self, mock_load_cert): mock_load_cert.return_value = (dummy_public, dummy_secret)