diff --git a/discos_client/client.py b/discos_client/client.py index 6e618ce..11e734f 100644 --- a/discos_client/client.py +++ b/discos_client/client.py @@ -130,8 +130,9 @@ def __initialize__(self) -> None: ) self.__update_namespace__(topic, payload) except zmq.Again: # pragma: no cover - dummy = self._schema_merger.merge_schema(t, {}) - self.__update_namespace__(t, DISCOSNamespace(**dummy)) + self.__update_namespace__(t, DISCOSNamespace( + **self._schema_merger.merge_schema(t, {}) + )) self._socket.unsubscribe(f'{rand_id}_{t}') self._socket.setsockopt(zmq.RCVTIMEO, -1) for topic in self._topics: diff --git a/discos_client/namespace.py b/discos_client/namespace.py index c196631..5435fc2 100644 --- a/discos_client/namespace.py +++ b/discos_client/namespace.py @@ -576,7 +576,7 @@ def __value_repr__(cls, obj: Any) -> Any: return { k: cls.__value_repr__(v) for k, v in vars(obj).items() - if not k.startswith("_") and cls.__is__(v) + if not k.startswith("_") } if isinstance(obj, (tuple, list)): return [cls.__value_repr__(v) for v in obj] @@ -633,6 +633,5 @@ def __dir__(self) -> None: value = self._value if DISCOSNamespace.__is__(value): attrs.discard("get_value") - else: - attrs.update(dir(value)) + attrs.update(dir(value)) return sorted(attrs) diff --git a/discos_client/schemas/common/receivers.json b/discos_client/schemas/common/receivers.json index 448df12..4278301 100644 --- a/discos_client/schemas/common/receivers.json +++ b/discos_client/schemas/common/receivers.json @@ -8,124 +8,109 @@ "$defs": { "boss": { "type": "object", + "title": "Receivers Boss", + "description": "Status of the DISCOS ReceiversBoss component.", + "node": "receivers.boss", "properties": { - "boss": { - "type": "object", - "title": "Receivers Boss", - "description": "Status of the DISCOS ReceiversBoss component.", - "node": "receivers.boss", - "properties": { - "currentReceiver": { - "type": "string", - "title": "Current Receiver", - "description": "Currently selected receiver's name." - }, - "currentSetup": { - "type": "string", - "title": "Current setup", - "description": "Current DISCOS setup code" - }, - "status": { - "$ref": "../definitions/status.json" - }, - "timestamp": { - "$ref": "../definitions/timestamp.json" - } - }, - "required": [ - "currentReceiver", - "currentSetup", - "status", - "timestamp" - ], - "additionalProperties": false + "currentReceiver": { + "type": "string", + "title": "Current Receiver", + "description": "Currently selected receiver's name." + }, + "currentSetup": { + "type": "string", + "title": "Current setup", + "description": "Current DISCOS setup code" + }, + "status": { + "$ref": "../definitions/status.json" + }, + "timestamp": { + "$ref": "../definitions/timestamp.json" } }, - "required": ["boss"], + "required": [ + "currentReceiver", + "currentSetup", + "status", + "timestamp" + ], "additionalProperties": false }, "receiver": { "type": "object", - "patternProperties": { - "^(?!boss$).*": { - "type": "object", - "title": "Receiver status", - "description": "Status of a receiver component.", - "node": "receivers.", - "properties": { - "cryoTemperatureCoolHead": { - "type": "number", - "title": "Cryo temperature cool head", - "description": "Cryogenic temperature of receiver's cool head", - "unit": "K" - }, - "cryoTemperatureCoolHeadWindow": { - "type": "number", - "title": "Cryo temperature cool head window", - "description": "Cryogenic temperature of receiver's cool head, window sensor", - "unit": "K" - }, - "cryoTemperatureLNA": { - "type": "number", - "title": "Cryo temperature LNAs", - "description": "Cryogenic temperature of receiver's LNAs", - "unit": "K" - }, - "cryoTemperatureLNAWindow": { - "type": "number", - "title": "Cryo temperature LNAs window", - "description": "Cryogenic temperature of receiver's LNAs, window sensor", - "unit": "K" - }, - "environmentTemperature": { - "type": "number", - "title": "Environment temperature", - "description": "Environment temperature.", - "unit": "°C" - }, - "operativeMode": { - "type": "string", - "title": "Operative mode", - "description": "Name of the current receiver operative mode." - }, - "channels": { - "type": "array", - "title": "Channels", - "description": "List of receiver's channelss.", - "items": { - "$ref": "#/$defs/channel" - } - }, - "status": { - "$ref": "../definitions/status.json" - }, - "timestamp": { - "$ref": "../definitions/timestamp.json" - }, - "vacuum": { - "type": "number", - "title": "Vacuum", - "description": "Dewar vacuum.", - "unit": "mbar" - } - }, - "required": [ - "cryoTemperatureCoolHead", - "cryoTemperatureCoolHeadWindow", - "cryoTemperatureLNA", - "cryoTemperatureLNAWindow", - "environmentTemperature", - "operativeMode", - "channels", - "status", - "timestamp", - "vacuum" - ], - "additionalProperties": false + "title": "Receiver status", + "description": "Status of a receiver component.", + "node": "receivers.", + "properties": { + "cryoTemperatureCoolHead": { + "type": "number", + "title": "Cryo temperature cool head", + "description": "Cryogenic temperature of receiver's cool head", + "unit": "K" + }, + "cryoTemperatureCoolHeadWindow": { + "type": "number", + "title": "Cryo temperature cool head window", + "description": "Cryogenic temperature of receiver's cool head, window sensor", + "unit": "K" + }, + "cryoTemperatureLNA": { + "type": "number", + "title": "Cryo temperature LNAs", + "description": "Cryogenic temperature of receiver's LNAs", + "unit": "K" + }, + "cryoTemperatureLNAWindow": { + "type": "number", + "title": "Cryo temperature LNAs window", + "description": "Cryogenic temperature of receiver's LNAs, window sensor", + "unit": "K" + }, + "environmentTemperature": { + "type": "number", + "title": "Environment temperature", + "description": "Environment temperature.", + "unit": "°C" + }, + "operativeMode": { + "type": "string", + "title": "Operative mode", + "description": "Name of the current receiver operative mode." + }, + "channels": { + "type": "array", + "title": "Channels", + "description": "List of receiver's channelss.", + "items": { + "$ref": "#/$defs/channel" + } + }, + "status": { + "$ref": "../definitions/status.json" + }, + "timestamp": { + "$ref": "../definitions/timestamp.json" + }, + "vacuum": { + "type": "number", + "title": "Vacuum", + "description": "Dewar vacuum.", + "unit": "mbar" } }, - "minProperties": 1, - "maxProperties": 1, + "required": [ + "cryoTemperatureCoolHead", + "cryoTemperatureCoolHeadWindow", + "cryoTemperatureLNA", + "cryoTemperatureLNAWindow", + "environmentTemperature", + "operativeMode", + "channels", + "status", + "timestamp", + "vacuum" + ], "additionalProperties": false }, "channel": { @@ -177,7 +162,22 @@ } }, "anyOf": [ - { "$ref": "#/$defs/boss" }, - { "$ref": "#/$defs/receiver" } + { + "type": "object", + "properties": { + "boss": { "$ref": "#/$defs/boss" } + }, + "required": ["boss"], + "additionalProperties": false + }, + { + "type": "object", + "patternProperties": { + "^(?!boss$).*": { "$ref": "#/$defs/receiver" } + }, + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": false + } ] } diff --git a/discos_client/schemas/srt/minor_servo.json b/discos_client/schemas/srt/minor_servo.json new file mode 100644 index 0000000..de10541 --- /dev/null +++ b/discos_client/schemas/srt/minor_servo.json @@ -0,0 +1,370 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "srt/minor_servo.json", + "title": "SRT Minor Servo", + "type": "object", + "description": "Status of the SRT DISCOS Minor Servos components.", + "node": "minor_servo", + "$defs": { + "translation_axis_info": { + "type": "object", + "title": "Translation Axis info", + "description": "Minor servo translation axis information.", + "properties": { + "currentPosition": { + "type": "number", + "title": "Current position", + "description": "Current axis virtual position.", + "unit": "mm" + }, + "commandedPosition": { + "type": "number", + "title": "Commanded position", + "description": "Axis commanded virtual position.", + "unit": "mm" + }, + "userOffset": { + "type": "number", + "title": "User Offset", + "description": "Axis user offset.", + "unit": "mm" + }, + "systemOffset": { + "type": "number", + "title": "System Offset", + "description": "Axis system offset.", + "unit": "mm" + }, + "trackingError": { + "type": "number", + "title": "Tracking Error", + "description": "Axis tracking error.", + "unit": "mm" + } + }, + "required": [ + "currentPosition", + "commandedPosition", + "userOffset", + "systemOffset" + ] + }, + "rotation_axis_info": { + "type": "object", + "title": "Rotation Axis info", + "description": "Minor servo rotation axis information.", + "properties": { + "currentPosition": { + "type": "number", + "title": "Current position", + "description": "Current axis virtual position.", + "unit": "degrees" + }, + "commandedPosition": { + "type": "number", + "title": "Commanded position", + "description": "Axis commanded virtual position.", + "unit": "degrees" + }, + "userOffset": { + "type": "number", + "title": "User Offset", + "description": "Axis user offset.", + "unit": "degrees" + }, + "systemOffset": { + "type": "number", + "title": "System Offset", + "description": "Axis system offset.", + "unit": "degrees" + }, + "trackingError": { + "type": "number", + "title": "Tracking Error", + "description": "Axis tracking error.", + "unit": "degrees" + } + }, + "required": [ + "currentPosition", + "commandedPosition", + "userOffset", + "systemOffset" + ] + }, + "error_code": { + "type": "string", + "title": "Error code", + "description": "Current error code, if present.", + "enum": [ + "NO ERROR", + "SOCKET NOT CONNECTED", + "SYSTEM IN MAINTENANCE MODE", + "EMERGENCY STOP", + "GREGORIAN COVER IN WRONG POSITION", + "CONFIGURATION ERROR", + "REMOTE COMMAND ERROR", + "MINOR SERVO IS BLOCKED", + "DRIVE CABINET ERROR" + ] + }, + "boss": { + "type": "object", + "title": "Minor Servo Boss", + "description": "Status of the DISCOS MinorServoBoss component.", + "node": "minor_servo.boss", + "properties": { + "PLCFirmwareVersion": { + "type": "string", + "title": "PLC Firmware Version", + "description": "Firmware version of the minor servo PLC controller." + }, + "currentSetup": { + "type": "string", + "title": "Current setup", + "description": "Current focal setup." + }, + "emergency": { + "type": "boolean", + "title": "Emergency", + "description": "Minor servo system in emergency status." + }, + "errorCode": { + "$ref": "#/$defs/error_code" + }, + "gregorianCoverPosition": { + "type": "string", + "title": "Gregorian cover position", + "description": "Current position of the gregorian cover.", + "enum": [ + "UNKNOWN", + "CLOSED", + "OPEN" + ] + }, + "maintenanceMode": { + "type": "boolean", + "title": "Maintenance mode", + "description": "Minor servo system in maintenance mode." + }, + "motionInfo": { + "type": "string", + "title": "Motion info", + "description": "Information regarding the motion status of the minor servo system.", + "enum": [ + "NOT CONFIGURED", + "STARTING", + "CONFIGURED", + "TRACKING", + "PARKING", + "PARKED", + "ERROR" + ] + }, + "scanning": { + "type": "boolean", + "title": "Scanning", + "description": "Minor servo system is performing a scan." + }, + "socketConnected": { + "type": "boolean", + "title": "Socket connected", + "description": "Minor servo system socket is connected." + }, + "status": { + "$ref": "../definitions/status.json" + }, + "timestamp": { + "$ref": "../definitions/timestamp.json" + }, + "tracking": { + "type": "boolean", + "title": "Tracking", + "description": "Minor servo system is tracking the given coordinates." + }, + "trackingEnabled": { + "type": "boolean", + "title": "Tracking enabled", + "description": "Minor servo system will track the given coordinates." + } + }, + "required": [ + "PLCFirmwareVersion", + "currentSetup", + "emergency", + "errorCode", + "gregorianCoverPosition", + "maintenanceMode", + "motionInfo", + "scanning", + "socketConnected", + "status", + "timestamp", + "tracking", + "trackingEnabled" + ] + }, + "minor_servo": { + "type": "object", + "title": "Minor Servo Status", + "description": "Status of a minor servo component.", + "node": "minor_servo.", + "properties": { + "axes": { + "type": "object", + "title": "Axes", + "description": "Minor servo axes information.", + "patternProperties": { + "^T[A-Z]+$": { + "$ref": "#/$defs/translation_axis_info" + }, + "^R[A-Z]+$": { + "$ref": "#/$defs/rotation_axis_info" + } + } + }, + "blocked": { + "type": "boolean", + "title": "Blocked", + "description": "Minor servo is blocked" + }, + "currentSetup": { + "type": "string", + "title": "Current Setup", + "description": "Minor servo current setup." + }, + "driveCabinetStatus": { + "type": "string", + "title": "Drive Cabinet Status", + "description": "Status of the minor servo drive cabinet", + "enum": [ + "OK", + "WARNING", + "FAILURE" + ] + }, + "enabled": { + "type": "boolean", + "title": "Enabled", + "description": "Minor servo is enabled." + }, + "errorCode": { + "$ref": "#/$defs/error_code" + }, + "inUse": { + "type": "boolean", + "title": "In Use", + "description": "Minor servo is used in the current focal configuration." + }, + "operativeMode": { + "type": "string", + "title": "Operative Mode", + "description": "Minor servo current operative mode.", + "enum": [ + "UNKNOWN", + "SETUP", + "STOW", + "STOP", + "PRESET", + "PROGRAM TRACK" + ] + }, + "timestamp": { + "$ref": "../definitions/timestamp.json" + } + }, + "required": [ + "axes", + "blocked", + "currentSetup", + "driveCabinetStatus", + "enabled", + "errorCode", + "inUse", + "operativeMode", + "timestamp" + ] + }, + "tracking_minor_servo": { + "type": "object", + "title": "Tracking Minor Servo Status", + "description": "Status of a tracking minor servo component.", + "node": "minor_servo.", + "allOf": [ + { "$ref": "#/$defs/minor_servo" }, + { + "properties": { + "remainingTrajectoryPoints": { + "type": "number", + "title": "Remaining Trajectory Points", + "description": "Remaining number of points for the current trajectory." + }, + "totalTrajectoryPoints": { + "type": "number", + "title": "Total Trajectory Points", + "description": "Total number of points of the current trajectory." + }, + "tracking": { + "type": "boolean", + "title": "Tracking", + "description": "Minor servo is tracking." + }, + "trajectoryID": { + "type": "number", + "title": "Trajectory ID", + "description": "ID of the current tracked trajectory." + } + }, + "required": [ + "remainingTrajectoryPoints", + "totalTrajectoryPoints", + "tracking", + "trajectoryID" + ] + } + ] + } + }, + "anyOf": [ + { + "type": "object", + "properties": { + "boss": { "$ref": "#/$defs/boss" } + }, + "required": ["boss"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "GFR": { "$ref": "#/$defs/minor_servo" } + }, + "required": ["GFR"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "M3R": { "$ref": "#/$defs/minor_servo" } + }, + "required": ["M3R"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "SRP": { "$ref": "#/$defs/tracking_minor_servo" } + }, + "required": ["SRP"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "PFP": { "$ref": "#/$defs/tracking_minor_servo" } + }, + "required": ["PFP"], + "additionalProperties": false + } + ] +} diff --git a/discos_client/utils.py b/discos_client/utils.py index bd63af9..ca3ed33 100644 --- a/discos_client/utils.py +++ b/discos_client/utils.py @@ -3,6 +3,7 @@ import json import operator from pathlib import Path +from copy import deepcopy from typing import Any, Callable, TYPE_CHECKING from importlib.resources import files @@ -116,38 +117,40 @@ def __unwrap_enum(value: Any, is_fn, get_value_fn) -> Any: class SchemaMerger: def __init__(self, telescope: str | None): self.base_dir = files("discos_client") / "schemas" - self.schemas, self.definitions, self.node_to_id = \ + self.schemas, definitions, self.node_to_id = \ self.__load_schemas(telescope) + + for def_id, definition in definitions.items(): + definition = self.__absolutize_refs(definition, def_id) + definition = self.__expand_refs(definition, definitions) + definition = self.__merge_all_of(definition) + definitions[def_id] = definition + for schema_id, schema in self.schemas.items(): - self.schemas[schema_id] = self.__expand_all_refs(schema) + schema = self.__absolutize_refs(schema, schema_id) + schema = self.__expand_refs(schema, definitions) + schema = self.__merge_all_of(schema) + schema.pop("$defs", None) + self.schemas[schema_id] = schema - def __normalize_ref__(self, ref: str, current_file: Path) -> str: - if ref.startswith("#"): - return f"{current_file.as_posix()}{ref}" - base_dir = self.base_dir - current_file = base_dir / current_file - current_dir = current_file.parent - ref_path, _, fragment = ref.partition("#") - ref_path = Path(ref_path) - if ".." in ref_path.as_posix(): - current_ref = current_dir / ref_path - base_dir = base_dir.resolve() - current_ref = current_ref.resolve() - result = current_ref.relative_to(base_dir) - else: - result = ref_path - return f"{result}#{fragment}" if fragment else result.as_posix() - - def __absolutize_refs(self, obj: dict | list, filename: str): - if isinstance(obj, dict): - if "$ref" in obj: - ref = obj["$ref"] - obj["$ref"] = self.__normalize_ref__(ref, Path(filename)) - for v in obj.values(): - self.__absolutize_refs(v, filename) - elif isinstance(obj, list): - for item in obj: - self.__absolutize_refs(item, filename) + def merge_schema( + self, + name: str, + message: dict[str, Any] + ) -> dict[str, Any]: + if name not in self.node_to_id: + raise ValueError(f"Schema '{name}' was not loaded.") + name = self.node_to_id[name] + schema = self.schemas[name] + enriched = self.__enrich_properties(schema, message) + return { + **{ + k: v + for k, v in enriched.items() + if k in ("title", "type", "description") + }, + **enriched + } def __load_schemas( self, @@ -186,118 +189,207 @@ def __load_schemas( definitions[f"{schema_id}#/$defs/{k}"] = v return schemas, definitions, node_to_id - def merge_schema( + def __absolutize_refs( self, - name: str, - message: dict[str, Any] + schema: dict[str, Any], + current_file: str ) -> dict[str, Any]: - if name not in self.node_to_id: - raise ValueError(f"Schema '{name}' was not loaded.") - name = self.node_to_id[name] - schema = self.schemas[name] - expanded = self.__expand_all_refs(schema) - enriched = self.__expand_schema_keywords(expanded, message) - enriched = self.__enrich_properties( - enriched, - message, - ) - return { - **{ - k: v - for k, v in enriched.items() - if k in ("title", "type", "description") - }, - **enriched - } + def recurse(obj: Any): + if isinstance(obj, dict): + if "$ref" in obj: + obj["$ref"] = self.__normalize_ref( + obj["$ref"], + Path(current_file) + ) + for v in obj.values(): + recurse(v) + elif isinstance(obj, list): + for item in obj: + recurse(item) + recurse(schema) + return schema + + def __normalize_ref(self, ref: str, current_file: Path) -> str: + if ref.startswith("#"): + return f"{current_file.as_posix()}{ref}" + base_dir = self.base_dir + current_file = base_dir / current_file + current_dir = current_file.parent + ref_path, _, fragment = ref.partition("#") + ref_path = Path(ref_path) + if ".." in ref_path.as_posix(): + current_ref = current_dir / ref_path + base_dir = base_dir.resolve() + current_ref = current_ref.resolve() + result = current_ref.relative_to(base_dir) + else: + result = ref_path + result = result.as_posix() + return f"{result}#{fragment}" if fragment else result - def __expand_all_refs(self, obj: Any) -> Any: - if isinstance(obj, dict): - if "$ref" in obj: - ref = obj["$ref"] - resolved = self.definitions.get(ref) - if not resolved: - raise ValueError(f"Unresolved $ref: {ref}") - return self.__expand_all_refs( - { + def __expand_refs( + self, + schema: dict[str, Any], + definitions: dict[str, Any] + ) -> dict[str, Any]: + def recurse(obj: Any): + if isinstance(obj, dict): + if "$ref" in obj: + ref = obj["$ref"] + resolved = definitions.get(ref) + if not resolved: + raise ValueError(f"Unresolved $ref: {ref}") + merged = { **resolved, **{k: v for k, v in obj.items() if k != "$ref"} } - ) - return { - k: self.__expand_all_refs(v) - for k, v in obj.items() - } - if isinstance(obj, list): - return [self.__expand_all_refs(item) for item in obj] - return obj - - @staticmethod - def __match_candidate__(data, candidate): + return recurse(merged) + return {k: recurse(v) for k, v in obj.items()} + if isinstance(obj, list): + return [recurse(item) for item in obj] + return obj + return recurse(schema) + + def __merge_all_of(self, schema: dict[str, Any]) -> dict[str, Any]: + def recurse(obj: Any): + if isinstance(obj, dict): + if "allOf" in obj: + merged = self._merge_subschemas(obj["allOf"]) + merged = self._merge_with_parent(obj, merged) + return recurse(merged) + return {k: recurse(v) for k, v in obj.items()} + if isinstance(obj, list): + return [recurse(item) for item in obj] + return obj + return recurse(schema) + + def _merge_subschemas( + self, + subschemas: list[dict[str, Any]] + ) -> dict[str, Any]: + merged: dict[str, Any] = {} + required_fields: set[str] = set() + for subschema in subschemas: + subschema = self.__merge_all_of(subschema) + merged.setdefault("properties", {}).update( + subschema.get("properties", {}) + ) + merged.setdefault("patternProperties", {}).update( + subschema.get("patternProperties", {}) + ) + required_fields.update(subschema.get("required", [])) + for k, v in subschema.items(): + if k not in ("properties", "patternProperties", + "required", "allOf"): + merged[k] = v + if required_fields: + merged["required"] = list(required_fields) + + return merged + + def _merge_with_parent( + self, + obj: dict[str, Any], + merged: dict[str, Any] + ) -> dict[str, Any]: + required_fields = set(merged.get("required", [])) + for k, v in obj.items(): + if k == "required": + required_fields.update(v) + merged["required"] = list(required_fields) + elif k == "properties": + merged.setdefault("properties", {}).update(v) + elif k == "patternProperties": + merged.setdefault("patternProperties", {}).update(v) + elif k != "allOf": + merged[k] = v + return merged + + def __score_candidate( + self, + message: dict[str, Any], + candidate: dict[str, Any] + ) -> int | None: props = set(candidate.get("properties", {}).keys()) patterns = candidate.get("patternProperties", {}) min_p = candidate.get("minProperties", 0) max_p = candidate.get("maxProperties", float("inf")) add_ok = candidate.get("additionalProperties", True) - keys = set(data.keys()) - + keys = set(message.keys()) if not min_p <= len(keys) <= max_p: - return False - if not add_ok and props: - if any(k not in props and - not any(re.fullmatch(p, k) for p in patterns) - for k in keys): - return False - if props and not props.issuperset(keys) and not patterns: - return False - return True + return None + if not add_ok: + for k in keys: + if k not in props \ + and not any(re.fullmatch(p, k) for p in patterns): + return None + required = set(candidate.get("required", [])) + if not required.issubset(keys): + return None + common_keys = len(keys & props) + pattern_matches = sum( + 1 for k in keys for p in patterns if re.fullmatch(p, k) + ) + return common_keys + pattern_matches def __expand_schema_keywords( self, obj: dict[str, Any], - root_schema: dict[str, Any] + message: dict[str, Any] ) -> dict[str, Any]: - if "allOf" in obj: - result = {} - for item in obj["allOf"]: - result.update(item) - return {**result, **{k: v for k, v in obj.items() if k != "allOf"}} if "anyOf" in obj: - data = root_schema - merged = {} + best_score = -1 + best_candidate = None for candidate in obj["anyOf"]: - if self.__match_candidate__(data, candidate): - merged.update(candidate) - if merged: + score = self.__score_candidate(message, candidate) + if score is not None and score > best_score: + best_score = score + best_candidate = candidate + if best_candidate: return { - **merged, **{k: v for k, v in obj.items() if k != "anyOf"} + **best_candidate, + **{k: v for k, v in obj.items() if k != "anyOf"} } - if "oneOf" in obj: - data = root_schema - for candidate in obj["oneOf"]: - if self.__match_candidate__(data, candidate): - return candidate - raise ValueError("No matching schema in oneOf") + return {} return obj + def __replace_patterns_with_properties( + self, + schema: dict[str, Any], + message: dict[str, Any] + ) -> dict[str, Any]: + schema_copy = deepcopy(schema) + pattern_props = schema_copy.pop("patternProperties", None) + if not pattern_props: + return schema_copy + schema_copy.setdefault("properties", {}) + for msg_key in message.keys(): + for pattern, pattern_schema in pattern_props.items(): + if re.fullmatch(pattern, msg_key): + schema_copy["properties"][msg_key] = pattern_schema + return schema_copy + def __enrich_properties( self, schema: dict[str, Any], values: dict[str, Any], ) -> dict[str, Any]: + schema = self.__expand_schema_keywords(schema, values) + schema = self.__replace_patterns_with_properties(schema, values) properties = schema.get("properties", {}) required = set(schema.get("required", [])) result = {} for key, prop_schema in properties.items(): if key in required or key in values: + prop_schema = self.__expand_schema_keywords( + prop_schema, schema + ) + prop_schema = self.__replace_patterns_with_properties( + prop_schema, values.get(key, {}) + ) result[key] = self.__enrich_named_property( - key, - self.__expand_schema_keywords(prop_schema, schema), - values + key, prop_schema, values ) - result.update(self.__enrich_pattern_properties( - schema, - values - )) return result def __enrich_named_property( @@ -306,8 +398,9 @@ def __enrich_named_property( schema: dict[str, Any], values: dict[str, Any] ) -> dict[str, Any]: + schema = self.__expand_schema_keywords(schema, values) value = values.get(key, None) - if schema.get("type") == "object" and "properties" in schema: + if schema.get("type") == "object": nested_values = value if isinstance(value, dict) else {} nested = self.__enrich_properties(schema, nested_values) return { @@ -340,23 +433,5 @@ def __enrich_named_property( enriched["value"] = value return enriched - def __enrich_pattern_properties( - self, - schema: dict[str, Any], - values: dict[str, Any] - ) -> dict[str, Any]: - result = {} - pattern_properties = schema.get("patternProperties", {}) - defined_properties = schema.get("properties", {}) - for pattern, pattern_schema in pattern_properties.items(): - regex = re.compile(pattern) - for key, _ in values.items(): - if key in defined_properties or not regex.match(key): - continue - result[key] = self.__enrich_named_property( - key, pattern_schema, values - ) - return result - def get_topics(self) -> list[str]: return list(self.node_to_id.keys()) diff --git a/docs/patches.py b/docs/patches.py index 9d89637..857b289 100644 --- a/docs/patches.py +++ b/docs/patches.py @@ -59,7 +59,7 @@ def _complexstructures(self, schema): if items: key = k if k == 'allOf': - key = 'properties' + key = 'all properties of' rows.extend(self._prepend(self._cell(key), items)) del schema[k] diff --git a/docs/schemas/minor_servo.rst b/docs/schemas/minor_servo.rst new file mode 100644 index 0000000..aa0a4bf --- /dev/null +++ b/docs/schemas/minor_servo.rst @@ -0,0 +1,24 @@ +Minor Servos +============ + +The SRT Minor Servo schema differs from the Medicina and Noto relative schemas. +Below you can find its definition. The Medicina and Noto definition will be +added in the future. + +.. jsonschema:: ../../discos_client/schemas/srt/minor_servo.json#/$defs/boss + :hide_key: /**/$id + +.. jsonschema:: ../../discos_client/schemas/srt/minor_servo.json#/$defs/minor_servo + :hide_key: /**/$id + +.. jsonschema:: ../../discos_client/schemas/srt/minor_servo.json#/$defs/tracking_minor_servo + :hide_key: /**/$id + +.. jsonschema:: ../../discos_client/schemas/srt/minor_servo.json#/$defs/error_code + :hide_key: /**/$id + +.. jsonschema:: ../../discos_client/schemas/srt/minor_servo.json#/$defs/translation_axis_info + :hide_key: /**/$id + +.. jsonschema:: ../../discos_client/schemas/srt/minor_servo.json#/$defs/rotation_axis_info + :hide_key: /**/$id diff --git a/docs/schemas/receivers.rst b/docs/schemas/receivers.rst index ea8b856..3b5a2cb 100644 --- a/docs/schemas/receivers.rst +++ b/docs/schemas/receivers.rst @@ -1,10 +1,10 @@ Receivers --------- -.. jsonschema:: ../../discos_client/schemas/common/receivers.json#/$defs/boss/properties/boss +.. jsonschema:: ../../discos_client/schemas/common/receivers.json#/$defs/boss :hide_key: /**/$id -.. jsonschema:: ../../discos_client/schemas/common/receivers.json#/$defs/receiver/patternProperties/^(?!boss$).* +.. jsonschema:: ../../discos_client/schemas/common/receivers.json#/$defs/receiver :hide_key: /**/$id .. jsonschema:: ../../discos_client/schemas/common/receivers.json#/$defs/channel diff --git a/docs/schemas/schemas.rst b/docs/schemas/schemas.rst index 4133f10..3d5ef06 100644 --- a/docs/schemas/schemas.rst +++ b/docs/schemas/schemas.rst @@ -19,6 +19,7 @@ More details regarding each schema can be found in the following sections. definitions scheduler antenna - mount receivers weather + mount + minor_servo diff --git a/tests/messages/minor_servo.GFR.json b/tests/messages/minor_servo.GFR.json new file mode 100644 index 0000000..0402126 --- /dev/null +++ b/tests/messages/minor_servo.GFR.json @@ -0,0 +1 @@ +{"axes":{"RZ":{"commandedPosition":0.0,"currentPosition":0.0,"systemOffset":0.0,"userOffset":0.0}},"blocked":false,"currentSetup":"","driveCabinetStatus":"OK","enabled":true,"errorCode":"NO ERROR","inUse":false,"operativeMode":"PROGRAM TRACK","timestamp":{"iso8601":"2025-08-04T08:41:53.476Z","mjd":60891.36242449097,"omg_time":139735897134762362,"unix_time":1754296913.476236}} diff --git a/tests/messages/minor_servo.SRP.json b/tests/messages/minor_servo.SRP.json new file mode 100644 index 0000000..46fc657 --- /dev/null +++ b/tests/messages/minor_servo.SRP.json @@ -0,0 +1 @@ +{"axes":{"RX":{"commandedPosition":0.0,"currentPosition":0.0,"systemOffset":0.0,"trackingError":0.0,"userOffset":0.0},"RY":{"commandedPosition":0.0,"currentPosition":0.0,"systemOffset":0.0,"trackingError":0.0,"userOffset":0.0},"RZ":{"commandedPosition":0.0,"currentPosition":0.0,"systemOffset":0.0,"trackingError":0.0,"userOffset":0.0},"TX":{"commandedPosition":0.0,"currentPosition":0.0,"systemOffset":0.0,"trackingError":0.0,"userOffset":0.0},"TY":{"commandedPosition":0.0,"currentPosition":0.0,"systemOffset":0.0,"trackingError":0.0,"userOffset":0.0},"TZ":{"commandedPosition":0.0,"currentPosition":0.0,"systemOffset":0.0,"trackingError":0.0,"userOffset":0.0}},"blocked":false,"currentSetup":"","driveCabinetStatus":"OK","enabled":true,"errorCode":"NO ERROR","inUse":false,"operativeMode":"PROGRAM TRACK","remainingTrajectoryPoints":0,"timestamp":{"iso8601":"2025-08-05T09:11:32.245Z","mjd":60892.383012095,"omg_time":139736778922458330,"unix_time":1754385092.245833},"totalTrajectoryPoints":0,"tracking":false,"trajectoryID":0} diff --git a/tests/messages/minor_servo.boss.json b/tests/messages/minor_servo.boss.json new file mode 100644 index 0000000..2afc013 --- /dev/null +++ b/tests/messages/minor_servo.boss.json @@ -0,0 +1 @@ +{"PLCFirmwareVersion":"1","currentSetup":"Unknown","emergency":false,"errorCode":"NO ERROR","gregorianCoverPosition":"CLOSED","maintenanceMode":false,"motionInfo":"NOT CONFIGURED","scanning":false,"socketConnected":true,"status":"WARNING","timestamp":{"iso8601":"2025-08-04T08:41:07.275Z","mjd":60891.36188975675,"omg_time":139735896672755448,"unix_time":1754296867.275545},"tracking":false,"trackingEnabled":true} diff --git a/tests/test_client.py b/tests/test_client.py index 0b85a3e..ad3d2a9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,7 @@ import json import unittest import time +import re from pathlib import Path from threading import Thread, Event import zmq @@ -38,8 +39,8 @@ def _handle_subscription(self, poller): if event[0] != 1: return topic = event[1:].decode() - if "_" in topic: - t = topic.partition("_")[-1] + if re.match(r"^\d{3}_.+$", topic): + *_, t = topic.partition("_") if t in self.messages: message = json.dumps( self.messages[t],